From be6c9fe6de465b4f5d75f257165ff6dce6c11782 Mon Sep 17 00:00:00 2001 From: kuroppoi <68156848+kuroppoi@users.noreply.github.com> Date: Fri, 26 Sep 2025 21:15:54 +0200 Subject: [PATCH 01/12] Add experimental exoskeleton functionality Known quirks: - v3 clients only support up to unique 20 hidden accessories (9 are currently in use, v2 maximum unknown) - Hiding exoskeletons does not work properly on v3. Fixed by relogging. --- .../gameserver/command/ExoCommand.java | 80 +++++++++++++++++++ .../gameserver/item/InventoryType.java | 12 +++ .../java/brainwine/gameserver/item/Item.java | 27 +++++++ .../gameserver/item/ItemRegistry.java | 15 ++++ .../gameserver/player/AppearanceSlot.java | 3 + .../gameserver/player/Inventory.java | 48 +++++++++-- 6 files changed, 177 insertions(+), 8 deletions(-) create mode 100644 gameserver/src/main/java/brainwine/gameserver/command/ExoCommand.java create mode 100644 gameserver/src/main/java/brainwine/gameserver/item/InventoryType.java diff --git a/gameserver/src/main/java/brainwine/gameserver/command/ExoCommand.java b/gameserver/src/main/java/brainwine/gameserver/command/ExoCommand.java new file mode 100644 index 00000000..0c1c3792 --- /dev/null +++ b/gameserver/src/main/java/brainwine/gameserver/command/ExoCommand.java @@ -0,0 +1,80 @@ +package brainwine.gameserver.command; + +import java.util.ArrayList; +import java.util.HashMap; +import java.util.List; +import java.util.Map; + +import brainwine.gameserver.dialog.Dialog; +import brainwine.gameserver.dialog.DialogSection; +import brainwine.gameserver.dialog.input.DialogSelectInput; +import brainwine.gameserver.item.Item; +import brainwine.gameserver.player.AppearanceSlot; +import brainwine.gameserver.player.Player; + +@CommandInfo(name = "exo", description = "Configure exosuit visibility settings.") +public class ExoCommand extends Command { + + @Override + public void execute(CommandExecutor executor, String[] args) { + Player player = (Player)executor; + + // TODO: text index would be far more appropriate for this + Map headgearKeys = new HashMap<>(); + Map torsoKeys = new HashMap<>(); + Map legsKeys = new HashMap<>(); + Dialog dialog = new Dialog() + .addSection(new DialogSection().setTitle("Exo Visibility")) + .addSection(createSlotSection(player, headgearKeys, AppearanceSlot.FACIAL_GEAR, "Headset", "headset")) + .addSection(createSlotSection(player, torsoKeys, AppearanceSlot.TOPS_OVERLAY, "Torso", "torso")) + .addSection(createSlotSection(player, legsKeys, AppearanceSlot.LEGS_OVERLAY, "Legs", "legs")); + + player.showDialog(dialog, data -> { + // Handle cancellation + if(data.length == 1 && data[0].equals("cancel")) { + return; + } + + // Check data length + if(data.length != 3) { + return; + } + + // Update player appearance + // TODO: Hiding exoskeletons is not implemented properly on v3 clients. + // Players will need to relog in order for changes to apply properly. + Map appearance = new HashMap<>(); + appearance.put(AppearanceSlot.FACIAL_GEAR.getId(), headgearKeys.getOrDefault(String.valueOf(data[0]), 0)); + appearance.put(AppearanceSlot.TOPS_OVERLAY.getId(), torsoKeys.getOrDefault(String.valueOf(data[1]), 0)); + appearance.put(AppearanceSlot.LEGS_OVERLAY.getId(), legsKeys.getOrDefault(String.valueOf(data[2]), 0)); + player.updateAppearance(appearance); + }); + } + + @Override + public String getUsage(CommandExecutor executor) { + return "/exo"; + } + + @Override + public boolean canExecute(CommandExecutor executor) { + return executor instanceof Player; + } + + private static DialogSection createSlotSection(Player player, Map keyMap, AppearanceSlot slot, String text, String key) { + List options = new ArrayList<>(); + + for(Item item : player.getInventory().getAccessories()) { + if(item.getAppearanceSlot() == slot) { + String option = item.getTitle().split(" ", 2)[0]; + options.add(option); + keyMap.put(option, item.getCode()); + } + } + + // TODO options are sorted inversely by item code but should ideally be sorted by some kind of arbitrary tier + options.sort((a, b) -> Integer.compare(keyMap.get(b), keyMap.get(a))); + options.add("Hidden"); + return new DialogSection().setText(text).setInput(new DialogSelectInput().setOptions(options).setKey(key)); + } +} 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 a60c5d85..af9233e0 100644 --- a/gameserver/src/main/java/brainwine/gameserver/item/Item.java +++ b/gameserver/src/main/java/brainwine/gameserver/item/Item.java @@ -13,6 +13,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; @@ -63,6 +64,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); @@ -339,6 +346,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(); } diff --git a/gameserver/src/main/java/brainwine/gameserver/item/ItemRegistry.java b/gameserver/src/main/java/brainwine/gameserver/item/ItemRegistry.java index db35ed79..9ee01630 100644 --- a/gameserver/src/main/java/brainwine/gameserver/item/ItemRegistry.java +++ b/gameserver/src/main/java/brainwine/gameserver/item/ItemRegistry.java @@ -18,6 +18,7 @@ public class ItemRegistry { private static final Map items = new HashMap<>(); private static final Map itemsByCode = new HashMap<>(); private static final Map> itemsByCategory = new HashMap<>(); + private static final List hiddenItems = new ArrayList<>(); // TODO maybe just move the registry stuff here public static void clear() { @@ -48,6 +49,16 @@ public static boolean registerItem(Item item) { } categorizedItems.add(item); + + if(item.isHidden()) { + // TODO v3 has a hard limit of 20 hidden items (see Inventory#maxLocationSlots in the game client) + if(hiddenItems.size() == 20) { + logger.warn(SERVER_MARKER, "Upper hidden item limit has been reached. Certain hidden accessories might not work properly!"); + } + + hiddenItems.add(item.getId()); + } + items.put(id, item); itemsByCode.put(code, item); return true; @@ -68,4 +79,8 @@ public static Collection getItems() { public static List getItemsByCategory(String category) { return Collections.unmodifiableList(itemsByCategory.getOrDefault(category, Collections.emptyList())); } + + public static int getHiddenItemIndex(Item item) { + return hiddenItems.indexOf(item.getId()); + } } diff --git a/gameserver/src/main/java/brainwine/gameserver/player/AppearanceSlot.java b/gameserver/src/main/java/brainwine/gameserver/player/AppearanceSlot.java index 5c15f5fe..42556dd5 100644 --- a/gameserver/src/main/java/brainwine/gameserver/player/AppearanceSlot.java +++ b/gameserver/src/main/java/brainwine/gameserver/player/AppearanceSlot.java @@ -1,5 +1,7 @@ package brainwine.gameserver.player; +import com.fasterxml.jackson.annotation.JsonCreator; + public enum AppearanceSlot { SKIN_COLOR("c*", "skin-color", true), @@ -33,6 +35,7 @@ private AppearanceSlot(String id, String category, boolean changeable) { this.changeable = changeable; } + @JsonCreator public static AppearanceSlot fromId(String id) { for(AppearanceSlot value : values()) { if(value.getId().equals(id)) { diff --git a/gameserver/src/main/java/brainwine/gameserver/player/Inventory.java b/gameserver/src/main/java/brainwine/gameserver/player/Inventory.java index c2a96c9c..0f77b08c 100644 --- a/gameserver/src/main/java/brainwine/gameserver/player/Inventory.java +++ b/gameserver/src/main/java/brainwine/gameserver/player/Inventory.java @@ -9,17 +9,19 @@ import java.util.Map.Entry; import java.util.Set; import java.util.stream.Collectors; -import java.util.stream.Stream; import com.fasterxml.jackson.annotation.JsonIncludeProperties; import com.fasterxml.jackson.annotation.JsonProperty; import com.fasterxml.jackson.annotation.JsonValue; +import brainwine.gameserver.item.InventoryType; import brainwine.gameserver.item.Item; +import brainwine.gameserver.item.ItemRegistry; import brainwine.gameserver.item.ItemUseType; import brainwine.gameserver.server.messages.EntityChangeMessage; import brainwine.gameserver.server.messages.InventoryMessage; import brainwine.gameserver.server.messages.WardrobeMessage; +import brainwine.gameserver.util.MapHelper; @JsonIncludeProperties({"items", "hotbar", "accessories"}) public class Inventory { @@ -116,6 +118,8 @@ public void removeItem(Item item, int quantity, boolean sendMessage) { } private void setItem(Item item, int quantity, boolean sendMessage) { + AppearanceSlot slot = item.getAppearanceSlot(); + if(quantity <= 0) { items.remove(item); hotbar.removeItem(item); @@ -124,7 +128,18 @@ private void setItem(Item item, int quantity, boolean sendMessage) { accessories.removeItem(item); player.sendMessageToPeers(new EntityChangeMessage(player.getId(), player.getStatusConfig())); } + + // Unequip appearance item + // TODO: potential nullptr if appearance value is null + if(slot != null && player.getAppearance().getOrDefault(slot.getId(), 0).equals(item.getCode())) { + player.updateAppearance(MapHelper.map(slot.getId(), 0)); + } } else { + // Equip appearance item (unless player already has it) + if(slot != null && !hasItem(item)) { + player.updateAppearance(MapHelper.map(slot.getId(), item.getCode())); + } + items.put(item, quantity); } @@ -171,18 +186,32 @@ public ItemContainer getHotbar() { return hotbar; } - public ItemContainer getAccessories() { - return accessories; + public List getAccessories() { + return getAccessories(true); + } + + public List getAccessories(boolean includeHidden) { + List items = new ArrayList<>(); + + for(Item item : accessories.getItems()) { + if(item.isAccessory()) { + items.add(item); + } + } + + if(includeHidden) { + this.items.keySet().stream().filter(item -> item.getInventoryType() == InventoryType.HIDDEN).forEach(items::add); + } + + return items; } - // TODO hidden accessories public int getSkillBonus(Skill skill) { - return Stream.of(accessories.getItems()).map(item -> item.getSkillBonus(skill)).max(Integer::compareTo).orElse(0); + return getAccessories().stream().map(item -> item.getSkillBonus(skill)).max(Integer::compareTo).orElse(0); } - // TODO hidden accessories public double getRegenBonus() { - return Stream.of(accessories.getItems()).map(Item::getRegenBonus).min(Double::compareTo).orElse(1.0); + return getAccessories().stream().map(Item::getRegenBonus).min(Double::compareTo).orElse(1.0); } public Set getWardrobe() { @@ -201,7 +230,10 @@ public Map getJsonValue() { private void addItemLocation(Item item, List itemData) { int slot = -1; - if((slot = hotbar.getSlot(item)) != -1) { + if(item.isHidden()) { + itemData.add("z"); + itemData.add(ItemRegistry.getHiddenItemIndex(item)); + } else if((slot = hotbar.getSlot(item)) != -1) { itemData.add(ContainerType.HOTBAR.getId()); itemData.add(slot); } else if((slot = accessories.getSlot(item)) != -1) { From 697c894766a64696dd212f47166633f98ed0de40 Mon Sep 17 00:00:00 2001 From: kuroppoi <68156848+kuroppoi@users.noreply.github.com> Date: Fri, 26 Sep 2025 21:37:59 +0200 Subject: [PATCH 02/12] Add exo overlay colors v2 only --- .../java/brainwine/gameserver/player/Player.java | 14 +++++++++++--- 1 file changed, 11 insertions(+), 3 deletions(-) diff --git a/gameserver/src/main/java/brainwine/gameserver/player/Player.java b/gameserver/src/main/java/brainwine/gameserver/player/Player.java index a4cd54e1..761c1425 100644 --- a/gameserver/src/main/java/brainwine/gameserver/player/Player.java +++ b/gameserver/src/main/java/brainwine/gameserver/player/Player.java @@ -442,8 +442,7 @@ public void setProperties(Map properties, boolean sendMessage) { public Map getStatusConfig() { Map config = super.getStatusConfig(); config.put("id", documentId); - config.putAll(appearance); - config.put("u", inventory.findJetpack().getCode()); + config.putAll(getDetails()); return config; } @@ -1722,6 +1721,15 @@ public boolean isOnline() { return connection != null && connection.isOpen(); } + private Map getDetails() { + Map details = new HashMap<>(); + details.putAll(appearance); + details.put("u", inventory.findJetpack().getCode()); + details.put("to*", "ffff55"); // Top overlay color + details.put("fg*", "ffff55"); // Facial gear overlay color + return details; + } + /** * @return A {@link Map} containing all the data necessary for use in {@link ConfigurationMessage}. */ @@ -1743,7 +1751,7 @@ public Map getClientConfig() { config.put("items_crafted", statistics.getTotalItemsCrafted()); config.put("play_time", (int)(statistics.getPlayTime())); config.put("deaths", statistics.getDeaths()); - config.put("appearance", appearance); + config.put("appearance", getDetails()); config.put("settings", settings); config.put("api_token", apiToken); return config; From 1e3e78e2f3b49abba02190b851625b883d0b64bd Mon Sep 17 00:00:00 2001 From: kuroppoi <68156848+kuroppoi@users.noreply.github.com> Date: Fri, 26 Sep 2025 22:32:38 +0200 Subject: [PATCH 03/12] Add crown store inventory limit support --- .../gameserver/shop/ItemProduct.java | 27 +++++++++++++++++++ .../brainwine/gameserver/shop/Product.java | 4 +++ .../gameserver/shop/ShopManager.java | 6 +++++ 3 files changed, 37 insertions(+) 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 84f7954a..11dc7067 100644 --- a/gameserver/src/main/java/brainwine/gameserver/shop/ShopManager.java +++ b/gameserver/src/main/java/brainwine/gameserver/shop/ShopManager.java @@ -110,6 +110,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); return true; From d605e847529f5e2f7df186e1f617a5ff92034cfa Mon Sep 17 00:00:00 2001 From: kuroppoi <68156848+kuroppoi@users.noreply.github.com> Date: Fri, 26 Sep 2025 22:34:15 +0200 Subject: [PATCH 04/12] Add exosuits to crown store configuration --- gameserver/src/main/resources/shop.json | 132 ++++++++++++++++++++++++ 1 file changed, 132 insertions(+) diff --git a/gameserver/src/main/resources/shop.json b/gameserver/src/main/resources/shop.json index de163030..a77ed55e 100644 --- a/gameserver/src/main/resources/shop.json +++ b/gameserver/src/main/resources/shop.json @@ -21,6 +21,21 @@ "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" + ] + }, "worlds": { "name": "Private Worlds", "icon": "icon-world", @@ -148,6 +163,123 @@ "mechanical/dish-micro": 4 } }, + "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 + } + }, "private-world-small": { "type": "zone", "name": "Private Mini World", From 59dca784d90d9664a5992c4cdb12ec8564884aee Mon Sep 17 00:00:00 2001 From: kuroppoi <68156848+kuroppoi@users.noreply.github.com> Date: Sat, 27 Sep 2025 02:25:13 +0200 Subject: [PATCH 05/12] Add exo skin to crown store --- gameserver/src/main/resources/shop.json | 13 ++++++++++++- 1 file changed, 12 insertions(+), 1 deletion(-) diff --git a/gameserver/src/main/resources/shop.json b/gameserver/src/main/resources/shop.json index a77ed55e..e803cbd7 100644 --- a/gameserver/src/main/resources/shop.json +++ b/gameserver/src/main/resources/shop.json @@ -33,7 +33,8 @@ "diamond-exo-legs", "onyx-exo-headset", "onyx-exo-torso", - "onyx-exo-legs" + "onyx-exo-legs", + "exo-skin" ] }, "worlds": { @@ -280,6 +281,16 @@ "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", From 5f85572ff6ee4209029b26573b5c266e65fcaf53 Mon Sep 17 00:00:00 2001 From: kuroppoi <68156848+kuroppoi@users.noreply.github.com> Date: Sat, 11 Oct 2025 19:05:56 +0200 Subject: [PATCH 06/12] Don't send empty entity position messages --- .../src/main/java/brainwine/gameserver/player/Player.java | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/gameserver/src/main/java/brainwine/gameserver/player/Player.java b/gameserver/src/main/java/brainwine/gameserver/player/Player.java index 761c1425..bd3359a7 100644 --- a/gameserver/src/main/java/brainwine/gameserver/player/Player.java +++ b/gameserver/src/main/java/brainwine/gameserver/player/Player.java @@ -257,8 +257,11 @@ public void tick(float deltaTime) { // Update tracked entities if(now - lastTrackedEntityUpdate >= TRACKED_ENTITY_UPDATE_INTERVAL) { updateTrackedEntities(); - sendMessage(new EntityPositionMessage(trackedEntities)); lastTrackedEntityUpdate = now; + + if(!trackedEntities.isEmpty()) { + sendMessage(new EntityPositionMessage(trackedEntities)); + } } } From 5d1f57146565531c8aacf905e77c6beee4c1b65e Mon Sep 17 00:00:00 2001 From: kuroppoi <68156848+kuroppoi@users.noreply.github.com> Date: Wed, 5 Nov 2025 14:37:00 +0100 Subject: [PATCH 07/12] Oops, should only send this once per player... --- gameserver/src/main/java/brainwine/gameserver/zone/Zone.java | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/gameserver/src/main/java/brainwine/gameserver/zone/Zone.java b/gameserver/src/main/java/brainwine/gameserver/zone/Zone.java index f72540be..0a13c8c7 100644 --- a/gameserver/src/main/java/brainwine/gameserver/zone/Zone.java +++ b/gameserver/src/main/java/brainwine/gameserver/zone/Zone.java @@ -182,7 +182,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; From 73e0a83088cfcf0abc53104b014b665047b4bbe3 Mon Sep 17 00:00:00 2001 From: kuroppoi <68156848+kuroppoi@users.noreply.github.com> Date: Sun, 23 Nov 2025 22:19:19 +0100 Subject: [PATCH 08/12] Add simple SSL support How to use: 1. Set `enable_ssl` to `true` in `api.json` 2. Create a certificate keystore 3. Properly configure keystore settings in `api.json` 4. Make sure your client is set to use HTTPS NOTE: a valid SSL certificate is most likely required. --- api/src/main/java/brainwine/api/Api.java | 17 ++++++++- .../java/brainwine/api/GatewayService.java | 13 ++++++- .../java/brainwine/api/PortalService.java | 11 +++++- .../java/brainwine/api/config/ApiConfig.java | 17 ++++++--- .../java/brainwine/api/config/SslConfig.java | 37 +++++++++++++++++++ .../java/brainwine/api/util/JettyUtils.java | 23 ++++++++++++ 6 files changed, 108 insertions(+), 10 deletions(-) create mode 100644 api/src/main/java/brainwine/api/config/SslConfig.java create mode 100644 api/src/main/java/brainwine/api/util/JettyUtils.java diff --git a/api/src/main/java/brainwine/api/Api.java b/api/src/main/java/brainwine/api/Api.java index fd74bdcd..0105dcef 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 org.apache.logging.log4j.LogManager; @@ -10,6 +12,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; @@ -17,6 +20,7 @@ public class Api { private static final Logger logger = LogManager.getLogger(); private final ApiConfig config; + private final List news; private final DataFetcher dataFetcher; private final GatewayService gatewayService; private final PortalService portalService; @@ -32,6 +36,9 @@ 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); LoomUtil.useLoomThreadPool = false; gatewayService = new GatewayService(this, config.getGatewayPort()); portalService = new PortalService(this, config.getPortalPort()); @@ -54,7 +61,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); @@ -64,13 +73,17 @@ private ApiConfig loadConfig() { } public List getNews() { - return config.getNews(); + return news; } 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 c2927ea8..970610db 100644 --- a/api/src/main/java/brainwine/api/GatewayService.java +++ b/api/src/main/java/brainwine/api/GatewayService.java @@ -10,9 +10,11 @@ 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; @@ -29,8 +31,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) .post("/players", this::handlePlayerRegistration) diff --git a/api/src/main/java/brainwine/api/PortalService.java b/api/src/main/java/brainwine/api/PortalService.java index feb015fa..9b6d1fb6 100644 --- a/api/src/main/java/brainwine/api/PortalService.java +++ b/api/src/main/java/brainwine/api/PortalService.java @@ -16,8 +16,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; @@ -38,7 +40,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 ff6278c8..5f0412bd 100644 --- a/api/src/main/java/brainwine/api/config/ApiConfig.java +++ b/api/src/main/java/brainwine/api/config/ApiConfig.java @@ -2,29 +2,31 @@ import java.beans.ConstructorProperties; import java.util.Arrays; -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)); + public static final ApiConfig DEFAULT_CONFIG = new ApiConfig("127.0.0.1", 5002, 5001, 5003, SslConfig.DEFAULT_CONFIG, Arrays.asList(NewsEntry.DEFAULT_NEWS)); private final String gameServerIp; private final int gameServerPort; private final int gatewayPort; private final int portalPort; + private final SslConfig sslConfig; private final List news; - @ConstructorProperties({"game_server_ip", "game_server_port", "gateway_port", "portal_port", "news"}) - public ApiConfig(String gameServerIp, int gameServerPort, int gatewayPort, int portalPort, List news) { + @ConstructorProperties({"game_server_ip", "game_server_port", "gateway_port", "portal_port", "ssl", "news"}) + public ApiConfig(String gameServerIp, int gameServerPort, int gatewayPort, int portalPort, SslConfig sslConfig, List news) { this.gameServerIp = gameServerIp; this.gameServerPort = gameServerPort; this.gatewayPort = gatewayPort; this.portalPort = portalPort; + this.sslConfig = sslConfig == null ? SslConfig.DEFAULT_CONFIG : sslConfig; + System.out.println("ssl config: " + sslConfig); this.news = news; - Collections.reverse(this.news); } public String getGameServerIp() { @@ -43,6 +45,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; + } +} From 00833542361744b103017a1fd4e1b98a2f4f6a4c Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Mert=20Bora=20=C4=B0nevi?= Date: Thu, 16 Oct 2025 23:42:52 +0200 Subject: [PATCH 09/12] guard against followees and followers who changed their name If a player who is visible on a follower/followee list changes their name before the viewing player could click their name, it would not be able to find the player by name, and the server would crash. This fix mitigates this by failing early on unknown name. --- .../brainwine/gameserver/server/requests/DialogRequest.java | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/gameserver/src/main/java/brainwine/gameserver/server/requests/DialogRequest.java b/gameserver/src/main/java/brainwine/gameserver/server/requests/DialogRequest.java index 49dfc1c3..e8a27c79 100644 --- a/gameserver/src/main/java/brainwine/gameserver/server/requests/DialogRequest.java +++ b/gameserver/src/main/java/brainwine/gameserver/server/requests/DialogRequest.java @@ -61,6 +61,11 @@ private void showPlayerDialog(Player player) { // Create player info dialog Player subject = GameServer.getInstance().getPlayerManager().getPlayer((String)input[0]); + if(subject == null) { + player.showDialog(DialogHelper.messageDialog("Player not found!")); + return; + } + Dialog dialog = new Dialog().setTitle(subject.getName()); // Online status section From ea7c5cbfde958bac4c70cc582cf2480b057fcd9c Mon Sep 17 00:00:00 2001 From: kuroppoi <68156848+kuroppoi@users.noreply.github.com> Date: Fri, 19 Dec 2025 02:13:00 +0100 Subject: [PATCH 10/12] Show followees in v2 zone searcher --- .../java/brainwine/gameserver/server/models/ZoneSearchData.java | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) 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); From a0a1d0b1f1846f2022a2679fea4e6f911a811c00 Mon Sep 17 00:00:00 2001 From: kuroppoi <68156848+kuroppoi@users.noreply.github.com> Date: Thu, 8 Jan 2026 23:51:18 +0100 Subject: [PATCH 11/12] Fix sunlight data not being sent correctly --- gameserver/src/main/java/brainwine/gameserver/zone/Zone.java | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/gameserver/src/main/java/brainwine/gameserver/zone/Zone.java b/gameserver/src/main/java/brainwine/gameserver/zone/Zone.java index 0a13c8c7..c5230743 100644 --- a/gameserver/src/main/java/brainwine/gameserver/zone/Zone.java +++ b/gameserver/src/main/java/brainwine/gameserver/zone/Zone.java @@ -1638,7 +1638,7 @@ public void setSunlight(int x, int 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); } From 9698056a3c6a1efd10721eff2dcd83d3165baa97 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Mert=20Bora=20=C4=B0nevi?= Date: Mon, 2 Feb 2026 18:47:40 +0100 Subject: [PATCH 12/12] Merge pull request #78 from boraini/fix/target-teleporter Make target teleporters only check for player-owned protector fields. --- .../item/interactions/TargetTeleportInteraction.java | 6 +++--- .../src/main/java/brainwine/gameserver/zone/Zone.java | 10 +++++++++- 2 files changed, 12 insertions(+), 4 deletions(-) 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 a8fb199a..a0d59d11 100644 --- a/gameserver/src/main/java/brainwine/gameserver/item/interactions/TargetTeleportInteraction.java +++ b/gameserver/src/main/java/brainwine/gameserver/item/interactions/TargetTeleportInteraction.java @@ -75,12 +75,12 @@ public void interact(Zone zone, Entity entity, int x, int y, Layer layer, Item i player.notify("That area hasn't been explored yet."); return; } - + // Check area protection - if(!player.isGodMode() && targetZone.isBlockProtected(targetX, targetY, player)) { + if(!player.isGodMode() && targetZone.isBlockProtectedByField(targetX, targetY, player)) { Player owner = metaBlock.getOwner(); int setting = metaBlock.getIntProperty("pt"); - boolean ownerCanEdit = !targetZone.isBlockProtected(targetX, targetY, owner); + boolean ownerCanEdit = !targetZone.isBlockProtectedByField(targetX, targetY, owner); // Check protection entry setting if(owner == null || !ownerCanEdit || setting == 0 || (setting == 1 && !owner.isFollowing(player))) { diff --git a/gameserver/src/main/java/brainwine/gameserver/zone/Zone.java b/gameserver/src/main/java/brainwine/gameserver/zone/Zone.java index c5230743..cbca1b13 100644 --- a/gameserver/src/main/java/brainwine/gameserver/zone/Zone.java +++ b/gameserver/src/main/java/brainwine/gameserver/zone/Zone.java @@ -685,6 +685,14 @@ public boolean isBlockProtected(int x, int y, Player player, Collection fieldBlocks) { // Check field blocks for(MetaBlock fieldBlock : fieldBlocks) { Item item = fieldBlock.getItem(); @@ -705,7 +713,7 @@ public boolean isBlockProtected(int x, int y, Player player, Collection