diff --git a/api/src/main/java/brainwine/api/Api.java b/api/src/main/java/brainwine/api/Api.java index 2cf8602d..15e3b650 100644 --- a/api/src/main/java/brainwine/api/Api.java +++ b/api/src/main/java/brainwine/api/Api.java @@ -3,6 +3,8 @@ import static brainwine.shared.LogMarkers.SERVER_MARKER; import java.io.File; +import java.util.ArrayList; +import java.util.Collections; import java.util.List; import brainwine.api.config.BetaEntry; @@ -11,6 +13,7 @@ import brainwine.api.config.ApiConfig; import brainwine.api.config.NewsEntry; +import brainwine.api.config.SslConfig; import brainwine.shared.JsonHelper; import io.javalin.core.LoomUtil; @@ -18,6 +21,8 @@ public class Api { private static final Logger logger = LogManager.getLogger(); private final ApiConfig config; + private final List news; + private final BetaEntry beta; private final DataFetcher dataFetcher; private final GatewayService gatewayService; private final PortalService portalService; @@ -33,6 +38,10 @@ public Api(DataFetcher dataFetcher) { logger.info(SERVER_MARKER, "Using data fetcher {}", dataFetcher.getClass().getName()); logger.info(SERVER_MARKER, "Loading configuration ..."); config = loadConfig(); + logger.info(SERVER_MARKER, "Is SSL enabled? {}", config.getSslConfig().isSslEnabled() ? "Yes" : "No"); + news = new ArrayList<>(config.getNews()); // Explicit copy + Collections.reverse(news); + beta = config.getBeta(); LoomUtil.useLoomThreadPool = false; gatewayService = new GatewayService(this, config.getGatewayPort()); portalService = new PortalService(this, config.getPortalPort()); @@ -55,7 +64,9 @@ private ApiConfig loadConfig() { return ApiConfig.DEFAULT_CONFIG; } - return JsonHelper.readValue(file, ApiConfig.class); + ApiConfig config = JsonHelper.readValue(file, ApiConfig.class); + JsonHelper.writeValue(file, config); + return config; } catch (Exception e) { logger.fatal(SERVER_MARKER, "Failed to load configuration", e); System.exit(-1); @@ -69,17 +80,21 @@ public void broadcast(String type, Object data) { } public List getNews() { - return config.getNews(); + return news; } public BetaEntry getBeta() { - return config.getBeta(); + return beta; } public String getGameServerHost() { return config.getGameServerIp() + ":" + config.getGameServerPort(); } + public SslConfig getSslConfig() { + return config.getSslConfig(); + } + public DataFetcher getDataFetcher() { return dataFetcher; } diff --git a/api/src/main/java/brainwine/api/GatewayService.java b/api/src/main/java/brainwine/api/GatewayService.java index 6b12166b..bf8595d9 100644 --- a/api/src/main/java/brainwine/api/GatewayService.java +++ b/api/src/main/java/brainwine/api/GatewayService.java @@ -16,10 +16,12 @@ import org.apache.logging.log4j.LogManager; import org.apache.logging.log4j.Logger; +import brainwine.api.config.SslConfig; import brainwine.api.models.PlayersRequest; import brainwine.api.models.ServerConnectInfo; import brainwine.api.models.SessionsRequest; +import brainwine.api.util.JettyUtils; import brainwine.shared.JsonHelper; import io.javalin.Javalin; import io.javalin.http.Context; @@ -37,8 +39,15 @@ public class GatewayService { public GatewayService(Api api, int port) { this.api = api; this.dataFetcher = api.getDataFetcher(); - logger.info(SERVER_MARKER, "Starting GatewayService @ port {} ...", port); - gateway = Javalin.create(config -> config.jsonMapper(new JavalinJackson(JsonHelper.MAPPER))) + logger.info(SERVER_MARKER, "Starting GatewayService @ port {} ...", port); + SslConfig ssl = api.getSslConfig(); + gateway = Javalin.create(config -> { + config.jsonMapper(new JavalinJackson(JsonHelper.MAPPER)); + + if(ssl.isSslEnabled()) { + config.server(() -> JettyUtils.createJettyServerWithSsl(port, ssl.getKeyStorePath(), ssl.getKeyStorePassword())); + } + }) .exception(Exception.class, this::handleException) .get("/clients", this::handleNewsRequest) .get("/players", this::handlePlayerSearch) @@ -194,7 +203,7 @@ private void handlePlayerSearch(Context ctx) { int toIndex = page * playerSearchPageSize; ctx.json(players.subList(fromIndex < 0 ? 0 : fromIndex > players.size() ? players.size() : fromIndex, toIndex > players.size() ? players.size() : toIndex)); } - + /** * Handler function for registering a new account. */ diff --git a/api/src/main/java/brainwine/api/PortalService.java b/api/src/main/java/brainwine/api/PortalService.java index f11d7232..f35599b7 100644 --- a/api/src/main/java/brainwine/api/PortalService.java +++ b/api/src/main/java/brainwine/api/PortalService.java @@ -22,8 +22,10 @@ import org.apache.logging.log4j.LogManager; import org.apache.logging.log4j.Logger; +import brainwine.api.config.SslConfig; import brainwine.api.models.ZoneInfo; import brainwine.api.util.ImageUtils; +import brainwine.api.util.JettyUtils; import brainwine.shared.JsonHelper; import io.javalin.Javalin; import io.javalin.http.ContentType; @@ -45,7 +47,14 @@ public class PortalService { public PortalService(Api api, int port) { this.dataFetcher = api.getDataFetcher(); logger.info(SERVER_MARKER, "Starting PortalService @ port {} ...", port); - portal = Javalin.create(config -> config.jsonMapper(new JavalinJackson(JsonHelper.MAPPER))) + SslConfig ssl = api.getSslConfig(); + portal = Javalin.create(config -> { + config.jsonMapper(new JavalinJackson(JsonHelper.MAPPER)); + + if(ssl.isSslEnabled()) { + config.server(() -> JettyUtils.createJettyServerWithSsl(port, ssl.getKeyStorePath(), ssl.getKeyStorePassword())); + } + }) .exception(Exception.class, this::handleException) .get("/v1/map/{zone}", this::handleMapRequest) .get("/v1/worlds", this::handleZoneSearch) diff --git a/api/src/main/java/brainwine/api/config/ApiConfig.java b/api/src/main/java/brainwine/api/config/ApiConfig.java index c1949f49..a1c88986 100644 --- a/api/src/main/java/brainwine/api/config/ApiConfig.java +++ b/api/src/main/java/brainwine/api/config/ApiConfig.java @@ -5,25 +5,28 @@ import java.util.Collections; import java.util.List; +import com.fasterxml.jackson.annotation.JsonGetter; import com.fasterxml.jackson.annotation.JsonIgnoreProperties; @JsonIgnoreProperties(ignoreUnknown = true) public class ApiConfig { - public static final ApiConfig DEFAULT_CONFIG = new ApiConfig("127.0.0.1", 5002, 5001, 5003, Arrays.asList(NewsEntry.DEFAULT_NEWS), new BetaEntry()); + public static final ApiConfig DEFAULT_CONFIG = new ApiConfig("127.0.0.1", 5002, 5001, 5003, SslConfig.DEFAULT_CONFIG, Arrays.asList(NewsEntry.DEFAULT_NEWS), new BetaEntry()); private final String gameServerIp; private final int gameServerPort; private final int gatewayPort; private final int portalPort; + private final SslConfig sslConfig; private final List news; private final BetaEntry beta; - - @ConstructorProperties({"game_server_ip", "game_server_port", "gateway_port", "portal_port", "news", "beta"}) - public ApiConfig(String gameServerIp, int gameServerPort, int gatewayPort, int portalPort, List news, BetaEntry beta) { + + @ConstructorProperties({"game_server_ip", "game_server_port", "gateway_port", "portal_port", "ssl", "news", "beta"}) + public ApiConfig(String gameServerIp, int gameServerPort, int gatewayPort, int portalPort, SslConfig sslConfig, List news, BetaEntry beta) { this.gameServerIp = gameServerIp; this.gameServerPort = gameServerPort; this.gatewayPort = gatewayPort; this.portalPort = portalPort; + this.sslConfig = sslConfig == null ? SslConfig.DEFAULT_CONFIG : sslConfig; this.news = news; this.beta = beta; Collections.reverse(this.news); @@ -45,6 +48,11 @@ public int getPortalPort() { return portalPort; } + @JsonGetter("ssl") + public SslConfig getSslConfig() { + return sslConfig; + } + public List getNews() { return news; } diff --git a/api/src/main/java/brainwine/api/config/SslConfig.java b/api/src/main/java/brainwine/api/config/SslConfig.java new file mode 100644 index 00000000..0f13a065 --- /dev/null +++ b/api/src/main/java/brainwine/api/config/SslConfig.java @@ -0,0 +1,37 @@ +package brainwine.api.config; + +import java.beans.ConstructorProperties; + +import com.fasterxml.jackson.annotation.JsonGetter; +import com.fasterxml.jackson.annotation.JsonIgnoreProperties; + +@JsonIgnoreProperties(ignoreUnknown = true) +public class SslConfig { + + public static final SslConfig DEFAULT_CONFIG = new SslConfig(false, "./keystore", "password"); + private final boolean enableSsl; + private final String keyStorePath; + private final String keyStorePassword; + + @ConstructorProperties({"enable_ssl", "keystore_path", "keystore_password"}) + public SslConfig(boolean enableSsl, String keyStorePath, String keyStorePassword) { + this.enableSsl = enableSsl; + this.keyStorePath = keyStorePath; + this.keyStorePassword = keyStorePassword; + } + + @JsonGetter("enable_ssl") + public boolean isSslEnabled() { + return enableSsl; + } + + @JsonGetter("keystore_path") + public String getKeyStorePath() { + return keyStorePath; + } + + @JsonGetter("keystore_password") + public String getKeyStorePassword() { + return keyStorePassword; + } +} diff --git a/api/src/main/java/brainwine/api/util/JettyUtils.java b/api/src/main/java/brainwine/api/util/JettyUtils.java new file mode 100644 index 00000000..ef52b68d --- /dev/null +++ b/api/src/main/java/brainwine/api/util/JettyUtils.java @@ -0,0 +1,23 @@ +package brainwine.api.util; + +import org.eclipse.jetty.server.Server; +import org.eclipse.jetty.server.ServerConnector; +import org.eclipse.jetty.util.ssl.SslContextFactory; + +public class JettyUtils { + + public static Server createJettyServerWithSsl(int port, String keyStorePath, String keyStorePassword) { + Server server = new Server(); + ServerConnector connector = new ServerConnector(server, createSslContextFactory(keyStorePath, keyStorePassword)); + connector.setPort(port); + server.addConnector(connector); + return server; + } + + public static SslContextFactory.Server createSslContextFactory(String keyStorePath, String keyStorePassword) { + SslContextFactory.Server sslContextFactory = new SslContextFactory.Server(); + sslContextFactory.setKeyStorePath(keyStorePath); + sslContextFactory.setKeyStorePassword(keyStorePassword); + return sslContextFactory; + } +} diff --git a/gameserver/src/main/java/brainwine/gameserver/item/InventoryType.java b/gameserver/src/main/java/brainwine/gameserver/item/InventoryType.java new file mode 100644 index 00000000..5f98279a --- /dev/null +++ b/gameserver/src/main/java/brainwine/gameserver/item/InventoryType.java @@ -0,0 +1,12 @@ +package brainwine.gameserver.item; + +import com.fasterxml.jackson.annotation.JsonEnumDefaultValue; + +public enum InventoryType { + + ACCESSORY, + HIDDEN, + + @JsonEnumDefaultValue + NONE, +} diff --git a/gameserver/src/main/java/brainwine/gameserver/item/Item.java b/gameserver/src/main/java/brainwine/gameserver/item/Item.java index 57ad66f8..dd3c95a7 100644 --- a/gameserver/src/main/java/brainwine/gameserver/item/Item.java +++ b/gameserver/src/main/java/brainwine/gameserver/item/Item.java @@ -11,7 +11,6 @@ import brainwine.gameserver.GameServer; import brainwine.gameserver.command.CommandAccessLevel; import brainwine.gameserver.item.usetypeconfig.ItemUseTypeConfig; -import brainwine.gameserver.player.AppearanceSlot; import brainwine.gameserver.player.NotificationType; import com.fasterxml.jackson.annotation.JsonAlias; import com.fasterxml.jackson.annotation.JsonCreator; @@ -21,6 +20,7 @@ import com.fasterxml.jackson.annotation.JsonValue; import brainwine.gameserver.dialog.DialogType; +import brainwine.gameserver.player.AppearanceSlot; import brainwine.gameserver.player.Skill; import brainwine.gameserver.util.Pair; import brainwine.gameserver.util.Vector2i; @@ -81,6 +81,12 @@ public class Item { @JsonProperty("group") private ItemGroup group = ItemGroup.NONE; + @JsonProperty("inventory type") + private InventoryType inventoryType = InventoryType.NONE; + + @JsonProperty("appearance") + private AppearanceSlot appearanceSlot; + @JsonProperty("size") private Vector2i size = new Vector2i(1, 1); @@ -288,9 +294,6 @@ private Item(@JsonProperty(value = "id", required = true) String id, this.code = code; } - @JsonProperty("appearance") - public AppearanceSlot appearanceSlot; - @JsonSetter("use") public void setUseConfigs(Map uses) { useConfigs = new HashMap<>(); @@ -437,6 +440,26 @@ public ItemGroup getGroup() { return group; } + public boolean isAccessory() { + return inventoryType == InventoryType.ACCESSORY; + } + + public boolean isHidden() { + return inventoryType == InventoryType.HIDDEN; + } + + public InventoryType getInventoryType() { + return inventoryType; + } + + public boolean hasAppearanceSlot() { + return appearanceSlot != null; + } + + public AppearanceSlot getAppearanceSlot() { + return appearanceSlot; + } + public int getBlockWidth() { return size.getX(); } @@ -744,10 +767,6 @@ public boolean requiresWorkshop() { public List getCraftingHelpers() { return craftingHelpers; } - - public AppearanceSlot getAppearanceSlot() { - return appearanceSlot; - } public boolean hasUse(ItemUseType... types) { for(ItemUseType type : types) { diff --git a/gameserver/src/main/java/brainwine/gameserver/item/interactions/TargetTeleportInteraction.java b/gameserver/src/main/java/brainwine/gameserver/item/interactions/TargetTeleportInteraction.java index 65df027c..67a3e639 100644 --- a/gameserver/src/main/java/brainwine/gameserver/item/interactions/TargetTeleportInteraction.java +++ b/gameserver/src/main/java/brainwine/gameserver/item/interactions/TargetTeleportInteraction.java @@ -81,7 +81,7 @@ public void interact(Zone zone, Entity entity, int x, int y, Layer layer, Item i List protectors = targetZone.getMetaBlocks( m -> m.hasOwner() && m.getItem().getId().startsWith("mechanical/dish")); - + // Check area protection if(!player.isGodMode() && targetZone.isBlockProtectedByField(targetX, targetY, player, false, protectors)) { Player owner = metaBlock.getOwner(); diff --git a/gameserver/src/main/java/brainwine/gameserver/player/Player.java b/gameserver/src/main/java/brainwine/gameserver/player/Player.java index a05bad63..0e6f24ae 100644 --- a/gameserver/src/main/java/brainwine/gameserver/player/Player.java +++ b/gameserver/src/main/java/brainwine/gameserver/player/Player.java @@ -296,12 +296,15 @@ public void tick(float deltaTime) { // Process timers timers.removeIf(Timer::process); - + // Update tracked entities if(now - lastTrackedEntityUpdate >= TRACKED_ENTITY_UPDATE_INTERVAL) { updateTrackedEntities(); - sendMessage(new EntityPositionMessage(trackedEntities)); lastTrackedEntityUpdate = now; + + if(!trackedEntities.isEmpty()) { + sendMessage(new EntityPositionMessage(trackedEntities)); + } } DailyQuests.tryIssueDailyQuest(this); @@ -1773,6 +1776,9 @@ public Map getCustomizedAppearance() { } } + appearance.put("to*", "ffff55"); // Top overlay color + appearance.put("fg*", "ffff55"); // Facial gear overlay color + return appearance; } diff --git a/gameserver/src/main/java/brainwine/gameserver/server/models/ZoneSearchData.java b/gameserver/src/main/java/brainwine/gameserver/server/models/ZoneSearchData.java index 19bfba43..db916f0b 100644 --- a/gameserver/src/main/java/brainwine/gameserver/server/models/ZoneSearchData.java +++ b/gameserver/src/main/java/brainwine/gameserver/server/models/ZoneSearchData.java @@ -27,7 +27,7 @@ public ZoneSearchData(Zone zone, Player player) { this.id = zone.getDocumentId(); this.name = zone.getName(); this.playerCount = zone.getPlayerCount(); - this.followeeCount = 0; // TODO + this.followeeCount = (int)zone.getPlayers().stream().filter(player::isFollowing).count(); this.followees = new String[0]; // TODO this.activeDuration = 0; // TODO this.explorationProgress = (int)(zone.getExplorationProgress() * 100); diff --git a/gameserver/src/main/java/brainwine/gameserver/shop/ItemProduct.java b/gameserver/src/main/java/brainwine/gameserver/shop/ItemProduct.java index adadc637..83286ebe 100644 --- a/gameserver/src/main/java/brainwine/gameserver/shop/ItemProduct.java +++ b/gameserver/src/main/java/brainwine/gameserver/shop/ItemProduct.java @@ -1,6 +1,9 @@ package brainwine.gameserver.shop; +import java.util.ArrayList; import java.util.Collections; +import java.util.HashMap; +import java.util.List; import java.util.Map; import com.fasterxml.jackson.annotation.JsonCreator; @@ -16,6 +19,7 @@ public class ItemProduct extends Product { private final Map items; + private Map inventoryLimits = new HashMap<>(); @JsonCreator public ItemProduct( @@ -52,7 +56,30 @@ public void purchase(Player player) { } } + @Override + public boolean validate(Player player) { + List errors = new ArrayList<>(); + + // Check if owned item limits have been reached already + inventoryLimits.forEach((item, quantity) -> { + if(player.getInventory().hasItem(item, quantity)) { + errors.add(String.format("You already own %sx %s and cannot purchase this item.", quantity, item.getTitle())); + } + }); + + if(!errors.isEmpty()) { + player.notify(errors.get(0)); + return false; + } + + return true; + } + public Map getItems() { return Collections.unmodifiableMap(items); } + + public Map getInventoryLimits() { + return Collections.unmodifiableMap(inventoryLimits); + } } diff --git a/gameserver/src/main/java/brainwine/gameserver/shop/Product.java b/gameserver/src/main/java/brainwine/gameserver/shop/Product.java index 97529639..e997ea17 100644 --- a/gameserver/src/main/java/brainwine/gameserver/shop/Product.java +++ b/gameserver/src/main/java/brainwine/gameserver/shop/Product.java @@ -28,6 +28,10 @@ public Product(String name, int cost) { public abstract void purchase(Player player); + protected boolean validate(Player player) { + return true; // Override + } + public String getName() { return name; } diff --git a/gameserver/src/main/java/brainwine/gameserver/shop/ShopManager.java b/gameserver/src/main/java/brainwine/gameserver/shop/ShopManager.java index 3d6cebda..a4d2e6d8 100644 --- a/gameserver/src/main/java/brainwine/gameserver/shop/ShopManager.java +++ b/gameserver/src/main/java/brainwine/gameserver/shop/ShopManager.java @@ -114,6 +114,12 @@ public static boolean purchaseProduct(Player player, Product product) { return false; } + // Run product-specific validation + if(!product.validate(player)) { + player.sendMessage(new StatMessage(PlayerStat.CROWNS, player.getCrowns())); + return false; + } + player.setCrowns(player.getCrowns() - product.getCost()); product.purchase(player); player.getStatistics().trackCrownsSpent(product.getCost()); diff --git a/gameserver/src/main/java/brainwine/gameserver/zone/Zone.java b/gameserver/src/main/java/brainwine/gameserver/zone/Zone.java index 16c8852b..c24f2b7c 100644 --- a/gameserver/src/main/java/brainwine/gameserver/zone/Zone.java +++ b/gameserver/src/main/java/brainwine/gameserver/zone/Zone.java @@ -250,7 +250,7 @@ public void tick(float deltaTime) { if(!getPlayers().isEmpty()) { if(now >= lastStatusUpdate + 4000) { for(Player player : getPlayers()) { - sendMessage(new ZoneStatusMessage(getStatusConfig(player))); + player.sendMessage(new ZoneStatusMessage(getStatusConfig(player))); } lastStatusUpdate = now; @@ -1908,14 +1908,14 @@ public void setSunlight(int x, int sunlight) { this.sunlight[x] = sunlight; } - + public int[] getSunlight(int x, int length) { int[] sunlight = new int[length]; - - if(x >= 0 && x + length < width) { + + if(x >= 0 && x + length <= width) { System.arraycopy(this.sunlight, x, sunlight, 0, length); } - + return sunlight; } diff --git a/gameserver/src/main/resources/shop.json b/gameserver/src/main/resources/shop.json index 3e13ebc8..b1482a85 100644 --- a/gameserver/src/main/resources/shop.json +++ b/gameserver/src/main/resources/shop.json @@ -24,6 +24,22 @@ "micro-protector-pack" ] }, + "weapons": { + "name": "Armaments", + "icon": "icon-gun", + "products": [ + "brass-exo-headset", + "brass-exo-torso", + "brass-exo-legs", + "diamond-exo-headset", + "diamond-exo-torso", + "diamond-exo-legs", + "onyx-exo-headset", + "onyx-exo-torso", + "onyx-exo-legs", + "exo-skin" + ] + }, "worlds": { "name": "Private Worlds", "icon": "icon-world", @@ -174,6 +190,133 @@ "mechanical/dish-micro": 1 } }, + "brass-exo-headset": { + "type": "item", + "name": "Brass Headset", + "cost": 125, + "description": "Give your perception and engineering skills a +1 bump and improve low-light visibility with this fancy brass headset!", + "image": "inventory/prosthetics/brass-headset", + "inventory_limits": { + "prosthetics/brass-headset": 1 + }, + "items": { + "prosthetics/brass-headset": 1 + } + }, + "brass-exo-torso": { + "type": "item", + "name": "Brass Exotorso", + "cost": 150, + "description": "Boost your staying power and look great with a shiny brass exotorso! Offers +1 to stamina and mining, plus faster health regeneration.", + "image": "inventory/prosthetics/brass-torso", + "inventory_limits": { + "prosthetics/brass-torso": 1 + }, + "items": { + "prosthetics/brass-torso": 1 + } + }, + "brass-exo-legs": { + "type": "item", + "name": "Brass Exolegs", + "cost": 150, + "description": "Kick your agility and survival skills up +1 with these shiny brass exolegs. You'll also love the powerful new stomp attack and foot damage reduction.", + "image": "inventory/prosthetics/brass-legs", + "inventory_limits": { + "prosthetics/brass-legs": 1 + }, + "items": { + "prosthetics/brass-legs": 1 + } + }, + "diamond-exo-headset": { + "type": "item", + "name": "Diamond Headset", + "cost": 225, + "description": "Blast your perception skill +2 into overdrive and engineering by +1. Your zoom view and low-light vision will give you the ultimate edge!", + "image": "inventory/prosthetics/diamond-headset", + "inventory_limits": { + "prosthetics/diamond-headset": 1 + }, + "items": { + "prosthetics/diamond-headset": 1 + } + }, + "diamond-exo-torso": { + "type": "item", + "name": "Diamond Exotorso", + "cost": 250, + "description": "Double down on your stamina power with this glowing mantle of protection! Offers +2 stamina, +1 mining, and fast health regeneration.", + "image": "inventory/prosthetics/diamond-torso", + "inventory_limits": { + "prosthetics/diamond-torso": 1 + }, + "items": { + "prosthetics/diamond-torso": 1 + } + }, + "diamond-exo-legs": { + "type": "item", + "name": "Diamond Exolegs", + "cost": 250, + "description": "Max your speed and damage reductions with these brilliant leg attachments. Get +2 agility, +1 survival, and enhanced stomp attacks and foot protection.", + "image": "inventory/prosthetics/diamond-legs", + "inventory_limits": { + "prosthetics/diamond-legs": 1 + }, + "items": { + "prosthetics/diamond-legs": 1 + } + }, + "onyx-exo-headset": { + "type": "item", + "name": "Onyx Headset", + "cost": 325, + "description": "Integrate your mind with the most powerful headgear around! Your +2 bonus to perception and engineering will leave even Google Glass feeling inadequate.", + "image": "inventory/prosthetics/onyx-headset", + "inventory_limits": { + "prosthetics/onyx-headset": 1 + }, + "items": { + "prosthetics/onyx-headset": 1 + } + }, + "onyx-exo-torso": { + "type": "item", + "name": "Onyx Exotorso", + "cost": 350, + "description": "Own the ultimate body suit and launch your stamina and mining skills with a +2 bonus. And with a triple-speed health regen rate, you'll be unstoppable on the battlefield!", + "image": "inventory/prosthetics/onyx-torso", + "inventory_limits": { + "prosthetics/onyx-torso": 1 + }, + "items": { + "prosthetics/onyx-torso": 1 + } + }, + "onyx-exo-legs": { + "type": "item", + "name": "Onyx Exolegs", + "cost": 350, + "description": "Kick danger in the face with the finest exoleg available! Boost your agility and survival skills by +2 and deal powerful blows with your deadly feet.", + "image": "inventory/prosthetics/onyx-legs", + "inventory_limits": { + "prosthetics/onyx-legs": 1 + }, + "items": { + "prosthetics/onyx-legs": 1 + } + }, + "exo-skin": { + "type": "item", + "name": "Exo Skin", + "cost": 750, + "description": "Avoid acid and lava damage with this powerful exo barrier. Toxic swims and hellish rains will never feel more delightful! Note: this item is not tradeable.", + "image": "inventory/accessories/exo-skin", + "items": { + "accessories/exo-skin": 1 + } + }, "private-world-small": { "type": "zone", "name": "Private Mini World",