From 8488a34c801fd5507deb24839ea991538d989347 Mon Sep 17 00:00:00 2001 From: sasheg dev <152582704+SashegDev@users.noreply.github.com> Date: Wed, 21 Oct 2026 17:43:30 +0300 Subject: [PATCH] =?UTF-8?q?=D0=A8=D0=B0=D0=B3=20A:=20=D0=BF=D1=80=D0=B5?= =?UTF-8?q?=D0=B4=D0=BC=D0=B5=D1=82=D1=8B=20=D0=B8=20=D0=B8=D0=BD=D0=B2?= =?UTF-8?q?=D0=B5=D0=BD=D1=82=D0=B0=D1=80=D1=8C?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Фундамент предметов по данным плана (срез 11.2025) и серверный инвентарь. В протоколе (общий словарь для клиента и сервера): - ToolClass, ResourceClass - что чем ломается и что где лежит. - ItemType - определение предмета: класс инструмента, урон по четырем частям тела, прочность, Gather Rates по каждому классу ресурса (сколько снимает удар, разброс, сколько уходит прочности инструмента), bench - требуемый уровень верстака, knownByDefault - открыт ли рецепт с начала игры, admin - только ли админы сервера. - ItemRegistry - таблица предметов. Числа из плана, каждое в одном месте, чтобы игра и план не разъехались. - Recipe - ингредиенты, количество, базовое время крафта. - ItemStack - тип, количество, остаток прочности и свои данные на стеке (как теги у плагина в Paper): стек - это то, что путешествует. Пакеты: Inventory (весь инвентарь при входе) и SlotChange (один слот). На сервере: Inventory - 30 слотов, хотбар 6, сервер владеет инвентарем и сообщает клиенту, а не спрашивает. Стартовый набор - камень в первом слоте и в руке. Тесты: предметы закреплены числами из плана (камень бьёт в голову на 20, снимает 10 дерева и 5 руды; кирка не рубит дерево; меч не ломает вообще), инвентарь переживает провод, сумка наполняется и опустошается, полная сумма говорит об этом. Замечание: прогрессия крафта в Rust упирается в лутбоксы - скрап падает только из них, а чертёж требует найденного предмета. Это отдельный вопрос (п. 5.2 плана), он не блокирует предметы и инвентарь. --- .../net/rustnotmc/protocol/ItemRegistry.java | 225 ++++++++++++++++++ .../net/rustnotmc/protocol/ItemStack.java | 111 +++++++++ .../java/net/rustnotmc/protocol/ItemType.java | 199 ++++++++++++++++ .../java/net/rustnotmc/protocol/Packets.java | 120 ++++++++++ .../java/net/rustnotmc/protocol/Recipe.java | 61 +++++ .../net/rustnotmc/protocol/ResourceClass.java | 29 +++ .../net/rustnotmc/protocol/ToolClass.java | 30 +++ .../net/rustnotmc/protocol/ProtocolTest.java | 98 ++++++++ rsx-server-core/scripts/test.ps1 | 1 + .../net/rustnotmc/server/ClientSession.java | 13 + .../java/net/rustnotmc/server/Player.java | 17 +- .../net/rustnotmc/server/item/Inventory.java | 173 ++++++++++++++ .../java/net/rustnotmc/server/PlayClient.java | 7 + .../java/net/rustnotmc/server/PlayTest.java | 49 ++++ .../rustnotmc/server/item/InventoryTest.java | 192 +++++++++++++++ 15 files changed, 1324 insertions(+), 1 deletion(-) create mode 100644 rsx-protocol/src/main/java/net/rustnotmc/protocol/ItemRegistry.java create mode 100644 rsx-protocol/src/main/java/net/rustnotmc/protocol/ItemStack.java create mode 100644 rsx-protocol/src/main/java/net/rustnotmc/protocol/ItemType.java create mode 100644 rsx-protocol/src/main/java/net/rustnotmc/protocol/Recipe.java create mode 100644 rsx-protocol/src/main/java/net/rustnotmc/protocol/ResourceClass.java create mode 100644 rsx-protocol/src/main/java/net/rustnotmc/protocol/ToolClass.java create mode 100644 rsx-server-core/src/main/java/net/rustnotmc/server/item/Inventory.java create mode 100644 rsx-server-core/src/test/java/net/rustnotmc/server/item/InventoryTest.java diff --git a/rsx-protocol/src/main/java/net/rustnotmc/protocol/ItemRegistry.java b/rsx-protocol/src/main/java/net/rustnotmc/protocol/ItemRegistry.java new file mode 100644 index 0000000..40fac4f --- /dev/null +++ b/rsx-protocol/src/main/java/net/rustnotmc/protocol/ItemRegistry.java @@ -0,0 +1,225 @@ +package net.rustnotmc.protocol; + +import java.util.Collection; +import java.util.Collections; +import java.util.LinkedHashMap; +import java.util.Map; + +/** + * Every item the game knows. + * + * The numbers are the ones from the original game at the state of November + * 2025, and they are written here once so that the game, the plan and the + * wiki cannot drift apart. A table copied by hand into two places drifts, and + * then the game stops matching its own rules. + * + * The ids are ours and they are stable: they go into save files and into the + * wire, so an id that ever changes is a bug. + */ +public final class ItemRegistry { + + public static final int NOTHING = 0; + public static final int ROCK = 1; + public static final int WOOD = 2; + public static final int STONE = 3; + public static final int METAL = 4; + public static final int HIGH_QUALITY_METAL = 5; + public static final int SULFUR = 6; + public static final int SCRAP = 7; + public static final int STONE_HATCHET = 8; + public static final int STONE_PICKAXE = 9; + public static final int HAMMER = 10; + public static final int BUILDING_PLAN = 11; + public static final int WORKBENCH_1 = 12; + public static final int WORKBENCH_2 = 13; + public static final int WORKBENCH_3 = 14; + public static final int RESEARCH_TABLE = 15; + public static final int HATCHET = 16; + public static final int PICKAXE = 17; + public static final int SALVAGED_HAMMER = 18; + public static final int SALVAGED_SWORD = 19; + public static final int SALVAGED_AXE = 20; + public static final int SALVAGED_ICEPICK = 21; + public static final int WOODEN_DOOR = 22; + public static final int CODE_LOCK = 23; + public static final int BASIC_FRAGMENT = 24; + public static final int ADVANCED_FRAGMENT = 25; + public static final int METAL_PIPE = 26; + public static final int METAL_BLADE = 27; + public static final int GEARS = 28; + + private static final Map TYPES = new LinkedHashMap(); + + private ItemRegistry() { + } + + static { + // The resources. They are not made, they are gathered, so they have no + // recipe at all. + define(ItemType.of(NOTHING, "Nothing").tool(ToolClass.NONE)); + define(ItemType.of(WOOD, "Wood")); + define(ItemType.of(STONE, "Stones")); + define(ItemType.of(METAL, "Metal Fragments")); + define(ItemType.of(HIGH_QUALITY_METAL, "High Quality Metal")); + define(ItemType.of(SULFUR, "Sulfur Ore")); + define(ItemType.of(SCRAP, "Scrap")); + define(ItemType.of(BASIC_FRAGMENT, "Basic Blueprint Fragment")); + define(ItemType.of(ADVANCED_FRAGMENT, "Advanced Blueprint Fragment")); + define(ItemType.of(METAL_PIPE, "Metal Pipe")); + define(ItemType.of(METAL_BLADE, "Metal Blade")); + define(ItemType.of(GEARS, "Gears")); + + // The tools. Gather Damage, Bonus and Condition Loss are from the + // official Gather Rates table; the damage is to the head, the torso, + // the arms and the legs. + define(ItemType.of(ROCK, "Rock") + .tool(ToolClass.NONE) + .durability(100) + .damage(20, 10, 8, 8) + .bench(0) + .known(true) + .recipe(new Recipe(new int[]{STONE}, new int[]{10}, 5)) + .gather(ResourceClass.TREE, 10, 1.0F, 0.3F) + .gather(ResourceClass.STONE, 5, 1.0F, 0.3F) + .gather(ResourceClass.METAL, 5, 1.0F, 0.3F) + .gather(ResourceClass.SULFUR, 5, 1.0F, 0.3F) + .gather(ResourceClass.FLESH, 10, 1.0F, 0.0F)); + + define(ItemType.of(STONE_HATCHET, "Stone Hatchet") + .tool(ToolClass.AXE) + .durability(100) + .damage(27, 15, 12, 12) + .recipe(new Recipe(new int[]{WOOD, STONE}, new int[]{200, 100}, 15)) + .gather(ResourceClass.TREE, 20, 2.0F, 0.3F) + .gather(ResourceClass.FLESH, 10, 1.0F, 0.3F)); + + define(ItemType.of(STONE_PICKAXE, "Stone Pick Axe") + .tool(ToolClass.PICKAXE) + .durability(100) + .damage(35, 17, 13, 13) + .recipe(new Recipe(new int[]{WOOD, STONE}, new int[]{200, 100}, 15)) + .gather(ResourceClass.STONE, 16, 3.2F, 0.3F) + .gather(ResourceClass.METAL, 16, 3.2F, 0.3F) + .gather(ResourceClass.SULFUR, 16, 3.2F, 0.3F) + .gather(ResourceClass.FLESH, 10, 1.0F, 1.0F)); + + define(ItemType.of(HAMMER, "Hammer") + .tool(ToolClass.HAMMER) + .durability(100) + .damage(0, 0, 0, 0) + .recipe(new Recipe(new int[]{WOOD}, new int[]{100}, 15))); + + define(ItemType.of(HATCHET, "Hatchet") + .tool(ToolClass.AXE) + .durability(100) + .damage(25, 25, 25, 25) + .bench(1) + .known(false) + .recipe(new Recipe(new int[]{WOOD, METAL}, new int[]{100, 75}, 30)) + .gather(ResourceClass.TREE, 30, 3.0F, 0.3F) + .gather(ResourceClass.FLESH, 12, 1.2F, 1.0F)); + + define(ItemType.of(PICKAXE, "Pickaxe") + .tool(ToolClass.PICKAXE) + .durability(100) + .damage(30, 30, 30, 30) + .bench(1) + .known(false) + .recipe(new Recipe(new int[]{WOOD, METAL}, new int[]{100, 125}, 30)) + .gather(ResourceClass.STONE, 30, 6.0F, 1.0F) + .gather(ResourceClass.METAL, 30, 6.0F, 1.0F) + .gather(ResourceClass.SULFUR, 30, 6.0F, 1.0F) + .gather(ResourceClass.FLESH, 5, 0.5F, 1.0F)); + + define(ItemType.of(SALVAGED_HAMMER, "Salvaged Hammer") + .tool(ToolClass.HAMMER) + .durability(100) + .damage(40, 40, 40, 40) + .bench(1) + .known(false) + .recipe(new Recipe(new int[]{METAL_PIPE, METAL}, new int[]{1, 50}, 30)) + .gather(ResourceClass.TREE, 20, 2.0F, 0.5F) + .gather(ResourceClass.STONE, 20, 4.0F, 0.5F) + .gather(ResourceClass.METAL, 20, 4.0F, 0.5F) + .gather(ResourceClass.FLESH, 10, 1.0F, 0.5F)); + + define(ItemType.of(SALVAGED_AXE, "Salvaged Axe") + .tool(ToolClass.AXE) + .durability(100) + .damage(40, 40, 40, 40) + .bench(2) + .known(false) + .recipe(new Recipe(new int[]{METAL_PIPE, METAL_BLADE}, new int[]{1, 5}, 30)) + .gather(ResourceClass.TREE, 50, 5.0F, 0.3F) + .gather(ResourceClass.FLESH, 15, 1.5F, 1.0F)); + + define(ItemType.of(SALVAGED_ICEPICK, "Salvaged Icepick") + .tool(ToolClass.PICKAXE) + .durability(100) + .damage(40, 40, 40, 40) + .bench(2) + .known(false) + .recipe(new Recipe(new int[]{METAL_PIPE, METAL_BLADE}, new int[]{1, 5}, 30)) + .gather(ResourceClass.STONE, 50, 10.0F, 0.3F) + .gather(ResourceClass.METAL, 50, 10.0F, 0.3F) + .gather(ResourceClass.SULFUR, 50, 10.0F, 0.3F) + .gather(ResourceClass.FLESH, 5, 0.5F, 0.5F)); + + define(ItemType.of(SALVAGED_SWORD, "Salvaged Sword") + .tool(ToolClass.SWORD) + .durability(100) + .damage(50, 50, 50, 50) + .bench(1) + .known(false) + .recipe(new Recipe(new int[]{METAL_BLADE, METAL}, new int[]{1, 15}, 30)) + .gather(ResourceClass.FLESH, 10, 1.0F, 0.5F)); + + // The deployables and the furniture. They are not tools, so they + // gather nothing, and they are the things the players build with. + define(ItemType.of(BUILDING_PLAN, "Building Plan") + .recipe(new Recipe(new int[]{WOOD}, new int[]{20}, 15))); + define(ItemType.of(WORKBENCH_1, "Work Bench Level 1") + .recipe(new Recipe(new int[]{WOOD, METAL}, new int[]{500, 100}, 30))); + define(ItemType.of(WORKBENCH_2, "Work Bench Level 2") + .bench(1) + .recipe(new Recipe(new int[]{METAL, HIGH_QUALITY_METAL, BASIC_FRAGMENT}, + new int[]{500, 20, 5}, 45))); + define(ItemType.of(WORKBENCH_3, "Work Bench Level 3") + .bench(2) + .recipe(new Recipe(new int[]{METAL, HIGH_QUALITY_METAL, ADVANCED_FRAGMENT}, + new int[]{1000, 100, 5}, 60))); + define(ItemType.of(RESEARCH_TABLE, "Research Table") + .bench(1) + .recipe(new Recipe(new int[]{METAL, SCRAP}, new int[]{200, 20}, 30))); + define(ItemType.of(WOODEN_DOOR, "Wooden Door") + .recipe(new Recipe(new int[]{WOOD}, new int[]{300}, 30))); + define(ItemType.of(CODE_LOCK, "Code Lock") + .recipe(new Recipe(new int[]{METAL}, new int[]{100}, 30))); + } + + private static void define(ItemType.Builder builder) { + ItemType type = builder.build(); + if (TYPES.put(Integer.valueOf(type.id), type) != null) { + throw new IllegalStateException("item " + type.id + " is defined twice"); + } + } + + /** The item with that id, or null. */ + public static ItemType byId(int id) { + return TYPES.get(Integer.valueOf(id)); + } + + /** The item with that id. Throws when there is none. */ + public static ItemType require(int id) { + ItemType type = byId(id); + if (type == null) { + throw new IllegalArgumentException("no item with id " + id); + } + return type; + } + + /** Every item, in the order they were defined. */ + public static Collection all() { + return Collections.unmodifiableCollection(TYPES.values()); + } +} diff --git a/rsx-protocol/src/main/java/net/rustnotmc/protocol/ItemStack.java b/rsx-protocol/src/main/java/net/rustnotmc/protocol/ItemStack.java new file mode 100644 index 0000000..8b425f2 --- /dev/null +++ b/rsx-protocol/src/main/java/net/rustnotmc/protocol/ItemStack.java @@ -0,0 +1,111 @@ +package net.rustnotmc.protocol; + +import java.util.Collections; +import java.util.LinkedHashMap; +import java.util.Map; + +/** + * A stack of one kind of item. + * + * The stack is the thing that travels: the inventory holds stacks, a drop is a + * stack, a blueprint is a stack. The kind of item is an id and never changes; + * what changes is how many there are, how much life is left in the tool, and + * the data that belongs to this particular stack. + * + * The data is the same idea as the tags a plugin puts on an item in Paper: a + * small set of named values that travel with the item, so that a torch can + * carry its charge and a blueprint can carry what it unlocks. + */ +public final class ItemStack { + + /** The stack of nothing. */ + public static final ItemStack EMPTY = new ItemStack(ItemRegistry.NOTHING, 0, -1); + + /** The kind of item. */ + public final int item; + + /** How many there are. */ + public int count; + + /** How much life is left in the tool, or -1 when it does not wear out. */ + public int condition; + + private final Map data = new LinkedHashMap(); + + public ItemStack(int item, int count, int condition) { + this.item = item; + this.count = count; + this.condition = condition; + } + + /** A stack of an item, at full life. */ + public static ItemStack of(int item, int count) { + ItemType type = ItemRegistry.byId(item); + int condition = type == null || type.durability <= 0 ? -1 : type.durability; + return new ItemStack(item, count, condition); + } + + /** True when there is nothing here. */ + public boolean isEmpty() { + return this.item == ItemRegistry.NOTHING || this.count <= 0; + } + + /** The kind of item, or null when the stack is empty. */ + public ItemType type() { + if (isEmpty()) { + return null; + } + return ItemRegistry.byId(this.item); + } + + /** A value that travels with this stack. */ + public String get(String key) { + return this.data.get(key); + } + + /** A value that travels with this stack. */ + public ItemStack set(String key, String value) { + this.data.put(key, value); + return this; + } + + /** Every value that travels with this stack. */ + public Map data() { + return Collections.unmodifiableMap(this.data); + } + + /** A copy, so that handing a stack out does not hand out the original. */ + public ItemStack copy() { + ItemStack out = new ItemStack(this.item, this.count, this.condition); + out.data.putAll(this.data); + return out; + } + + @Override + public boolean equals(Object other) { + if (this == other) { + return true; + } + if (!(other instanceof ItemStack)) { + return false; + } + ItemStack that = (ItemStack) other; + return this.item == that.item && this.count == that.count + && this.condition == that.condition && this.data.equals(that.data); + } + + @Override + public int hashCode() { + int hash = this.item; + hash = hash * 31 + this.count; + hash = hash * 31 + this.condition; + hash = hash * 31 + this.data.hashCode(); + return hash; + } + + @Override + public String toString() { + return "ItemStack[" + this.item + " x" + this.count + + (this.condition >= 0 ? " cond " + this.condition : "") + "]"; + } +} diff --git a/rsx-protocol/src/main/java/net/rustnotmc/protocol/ItemType.java b/rsx-protocol/src/main/java/net/rustnotmc/protocol/ItemType.java new file mode 100644 index 0000000..83a5682 --- /dev/null +++ b/rsx-protocol/src/main/java/net/rustnotmc/protocol/ItemType.java @@ -0,0 +1,199 @@ +package net.rustnotmc.protocol; + +/** + * One kind of item. + * + * Everything about an item that the game needs to know and that never changes + * is here: what class of tool it is, how much it hurts, how fast it gathers, + * how long it lasts, and what it takes to make one. + * + * The numbers are the ones from the original game, at the state of November + * 2025, and they live in one place on purpose: a table that is copied by hand + * into two places drifts, and then the game stops matching its own rules. + */ +public final class ItemType { + + /** How much a swing takes out of a node, and what it costs the tool. */ + public static final class Gather { + + /** How much of the resource one swing takes. */ + public final int damage; + + /** The spread of that amount, as a fraction. */ + public final float bonus; + + /** How much of the tool's condition one swing costs. */ + public final float conditionLoss; + + public Gather(int damage, float bonus, float conditionLoss) { + this.damage = damage; + this.bonus = bonus; + this.conditionLoss = conditionLoss; + } + } + + /** The body parts a hit can land on, in the order the numbers are given. */ + public static final int HEAD = 0; + public static final int TORSO = 1; + public static final int ARMS = 2; + public static final int LEGS = 3; + + public final int id; + public final String name; + public final ToolClass tool; + + /** The level a tool has to be to gather the thing. */ + public final int harvestLevel; + + /** How many swings the tool lasts. Zero means it never breaks. */ + public final int durability; + + /** The damage to the head, the torso, the arms and the legs. */ + public final int[] damage; + + /** The level of workbench the recipe needs. Zero means no workbench. */ + public final int bench; + + /** True when the recipe is known from the start and needs no research. */ + public final boolean knownByDefault; + + /** True when only the administrators of the server may use it. */ + public final boolean admin; + + /** How to make one, or null when the item is not made but gathered. */ + public final Recipe recipe; + + private final Gather[] gathers; + + public ItemType(int id, String name, ToolClass tool, int harvestLevel, int durability, + int[] damage, int bench, boolean knownByDefault, boolean admin, + Recipe recipe, Gather[] gathers) { + if (damage == null || damage.length != 4) { + throw new IllegalArgumentException("the damage needs four body parts"); + } + this.id = id; + this.name = name; + this.tool = tool; + this.harvestLevel = harvestLevel; + this.durability = durability; + this.damage = damage; + this.bench = bench; + this.knownByDefault = knownByDefault; + this.admin = admin; + this.recipe = recipe; + this.gathers = gathers == null ? new Gather[ResourceClass.values().length] : gathers; + } + + /** A starting point for defining an item. */ + public static Builder of(int id, String name) { + return new Builder(id, name); + } + + /** + * Builds an item a piece at a time. + * + * The registry is a table of two hundred things eventually, and a table + * that reads like a table is the only kind that can be checked against the + * numbers in the plan. + */ + public static final class Builder { + + private final int id; + private final String name; + private ToolClass tool = ToolClass.NONE; + private int harvestLevel; + private int durability; + private int[] damage = new int[]{0, 0, 0, 0}; + private int bench; + private boolean knownByDefault = true; + private boolean admin; + private Recipe recipe; + private final Gather[] gathers = new Gather[ResourceClass.values().length]; + + private Builder(int id, String name) { + this.id = id; + this.name = name; + } + + public Builder tool(ToolClass tool) { + this.tool = tool; + return this; + } + + public Builder level(int level) { + this.harvestLevel = level; + return this; + } + + public Builder durability(int durability) { + this.durability = durability; + return this; + } + + /** The damage to the head, the torso, the arms and the legs. */ + public Builder damage(int head, int torso, int arms, int legs) { + this.damage = new int[]{head, torso, arms, legs}; + return this; + } + + public Builder bench(int bench) { + this.bench = bench; + return this; + } + + /** Whether the recipe is known from the start. */ + public Builder known(boolean known) { + this.knownByDefault = known; + return this; + } + + /** Whether only the administrators of the server may use it. */ + public Builder admin(boolean admin) { + this.admin = admin; + return this; + } + + public Builder recipe(Recipe recipe) { + this.recipe = recipe; + return this; + } + + /** What a swing takes out of a node, and what it costs the tool. */ + public Builder gather(ResourceClass resource, int damage, float bonus, + float conditionLoss) { + this.gathers[resource.ordinal()] = new Gather(damage, bonus, conditionLoss); + return this; + } + + public ItemType build() { + return new ItemType(this.id, this.name, this.tool, this.harvestLevel, + this.durability, this.damage, this.bench, this.knownByDefault, + this.admin, this.recipe, this.gathers); + } + } + + /** How this tool gathers something, or null when it cannot. */ + public Gather gather(ResourceClass resource) { + if (resource == null) { + return null; + } + int index = resource.ordinal(); + if (index < 0 || index >= this.gathers.length) { + return null; + } + return this.gathers[index]; + } + + /** The damage to a body part. */ + public int damage(int bodyPart) { + if (bodyPart < 0 || bodyPart >= this.damage.length) { + return 0; + } + return this.damage[bodyPart]; + } + + @Override + public String toString() { + return "ItemType[" + this.id + " " + this.name + "]"; + } +} diff --git a/rsx-protocol/src/main/java/net/rustnotmc/protocol/Packets.java b/rsx-protocol/src/main/java/net/rustnotmc/protocol/Packets.java index 6d51a31..6c9a45c 100644 --- a/rsx-protocol/src/main/java/net/rustnotmc/protocol/Packets.java +++ b/rsx-protocol/src/main/java/net/rustnotmc/protocol/Packets.java @@ -102,6 +102,52 @@ public final class Packets { */ public static final int LAUNCHER_AUTH = 0x1A; + /** + * The whole inventory, sent when the player arrives. + * + * The server is the only one who decides what is in the slots, so the + * client is told rather than asked. + */ + public static final int INVENTORY = 0x1C; + + /** One slot of the inventory, when it changes. */ + public static final int INVENTORY_SLOT = 0x1D; + + /** + * One slot of the inventory and what is in it now. + * + * The slot number is here because the client has to know which one to + * redraw, and the contents because it has no way of guessing them. + */ + public static final class SlotChange implements Packet { + + public final int slot; + public final int item; + public final int count; + public final int condition; + + public SlotChange(int slot, int item, int count, int condition) { + this.slot = slot; + this.item = item; + this.count = count; + this.condition = condition; + } + + public Buf write() { + Buf buf = new Buf(24); + buf.writeVarInt(this.slot); + buf.writeVarInt(this.item); + buf.writeVarInt(this.count); + buf.writeVarInt(this.condition); + return buf; + } + + public static SlotChange read(Buf in) { + return new SlotChange(in.readVarInt(), in.readVarInt(), in.readVarInt(), + in.readVarInt()); + } + } + /** * The player's game mode, changed while the player is in the world. * @@ -796,6 +842,80 @@ public final class Packets { } } + /** + * One slot of an inventory. + * + * The empty slot is a stack of nothing, so there is no special case: an + * empty slot is one with the id of nothing and a count of zero. + */ + public static final class Slot { + + public final int item; + public final int count; + public final int condition; + + public Slot(int item, int count, int condition) { + this.item = item; + this.count = count; + this.condition = condition; + } + + public static Slot of(ItemStack stack) { + if (stack == null || stack.isEmpty()) { + return new Slot(ItemRegistry.NOTHING, 0, -1); + } + return new Slot(stack.item, stack.count, stack.condition); + } + + public void write(Buf buf) { + buf.writeVarInt(this.item); + buf.writeVarInt(this.count); + buf.writeVarInt(this.condition); + } + + public static Slot read(Buf in) { + return new Slot(in.readVarInt(), in.readVarInt(), in.readVarInt()); + } + } + + /** The whole inventory of a player. */ + public static final class Inventory implements Packet { + + public final Slot[] slots; + + /** The slot the player is holding. */ + public final int held; + + public Inventory(Slot[] slots, int held) { + this.slots = slots; + this.held = held; + } + + public Buf write() { + Buf buf = new Buf(16 + this.slots.length * 12); + buf.writeVarInt(this.held); + buf.writeVarInt(this.slots.length); + for (int i = 0; i < this.slots.length; i++) { + Slot slot = this.slots[i] == null ? Slot.of(null) : this.slots[i]; + slot.write(buf); + } + return buf; + } + + public static Inventory read(Buf in) { + int held = in.readVarInt(); + int count = in.readVarInt(); + if (count < 0 || count > 1024) { + throw new ProtocolException("an inventory of " + count + " slots is not real"); + } + Slot[] slots = new Slot[count]; + for (int i = 0; i < count; i++) { + slots[i] = Slot.read(in); + } + return new Inventory(slots, held); + } + } + /** * The launcher's session, as the game server sees it. * diff --git a/rsx-protocol/src/main/java/net/rustnotmc/protocol/Recipe.java b/rsx-protocol/src/main/java/net/rustnotmc/protocol/Recipe.java new file mode 100644 index 0000000..eed41d1 --- /dev/null +++ b/rsx-protocol/src/main/java/net/rustnotmc/protocol/Recipe.java @@ -0,0 +1,61 @@ +package net.rustnotmc.protocol; + +/** + * A recipe, and what it takes to unlock it. + * + * Two things gate a recipe and they are not the same thing: + * + *
    + *
  • the bench is the level of workbench you have to be standing + * next to. Zero means you can craft it with your hands. + *
  • the blueprint is the knowledge itself. A recipe that is known + * from the start needs no research and no scrap; anything else has to be + * researched at a table, which needs scrap and the item itself. + *
+ * + * The knowledge is the player's, not the item's: two players can know + * different recipes at the same moment. + */ +public final class Recipe { + + /** The item ids that go in, in any order. */ + public final int[] items; + + /** How many of each, in the same order. */ + public final int[] counts; + + /** The base craft time in seconds, before the workbench shortens it. */ + public final int seconds; + + public Recipe(int[] items, int[] counts, int seconds) { + if (items.length != counts.length) { + throw new IllegalArgumentException("the recipe has " + + items.length + " items but " + counts.length + " counts"); + } + this.items = items; + this.counts = counts; + this.seconds = seconds; + } + + /** How many of an item the recipe needs, or zero. */ + public int needs(int item) { + for (int i = 0; i < this.items.length; i++) { + if (this.items[i] == item) { + return this.counts[i]; + } + } + return 0; + } + + @Override + public String toString() { + StringBuilder out = new StringBuilder("Recipe["); + for (int i = 0; i < this.items.length; i++) { + if (i > 0) { + out.append(" + "); + } + out.append(this.counts[i]).append("x").append(this.items[i]); + } + return out.append(", ").append(this.seconds).append("s]").toString(); + } +} diff --git a/rsx-protocol/src/main/java/net/rustnotmc/protocol/ResourceClass.java b/rsx-protocol/src/main/java/net/rustnotmc/protocol/ResourceClass.java new file mode 100644 index 0000000..48713c6 --- /dev/null +++ b/rsx-protocol/src/main/java/net/rustnotmc/protocol/ResourceClass.java @@ -0,0 +1,29 @@ +package net.rustnotmc.protocol; + +/** + * What a node in the world holds. + * + * A node is not a block in the world but a thing that can be worn down: an + * oak, a stone outcrop, a metal deposit, a carcass. What is inside it and how + * much there is decides what a swing of the tool takes away. + */ +public enum ResourceClass { + + /** Not a resource. A tool gathers nothing of this kind. */ + NONE, + + /** Wood. */ + TREE, + + /** A stone outcrop: stones. */ + STONE, + + /** A metal deposit: metal ore. */ + METAL, + + /** A sulfur deposit: sulfur ore. */ + SULFUR, + + /** A carcass: meat and hide. */ + FLESH +} diff --git a/rsx-protocol/src/main/java/net/rustnotmc/protocol/ToolClass.java b/rsx-protocol/src/main/java/net/rustnotmc/protocol/ToolClass.java new file mode 100644 index 0000000..b881c29 --- /dev/null +++ b/rsx-protocol/src/main/java/net/rustnotmc/protocol/ToolClass.java @@ -0,0 +1,30 @@ +package net.rustnotmc.protocol; + +/** + * What a tool is for. + * + * The class of the tool decides what it can gather and how fast. It is the + * short answer to "why does this pickaxe not cut a tree": because a pickaxe is + * for stone and ore, and a tree wants an axe. + * + * A tool can serve more than one class in Rust, where the salvaged axe also + * works as a hammer, but that is a detail for later. One class is enough to + * start, and the rules live with the tools. + */ +public enum ToolClass { + + /** Bare hands. They dig, badly and slowly. */ + NONE, + + /** For stone and ore. */ + PICKAXE, + + /** For wood. */ + AXE, + + /** A weapon. It gathers nothing, on purpose. */ + SWORD, + + /** For building: it upgrades a structure and repairs it. */ + HAMMER +} diff --git a/rsx-protocol/src/test/java/net/rustnotmc/protocol/ProtocolTest.java b/rsx-protocol/src/test/java/net/rustnotmc/protocol/ProtocolTest.java index 8f14dae..a9078bd 100644 --- a/rsx-protocol/src/test/java/net/rustnotmc/protocol/ProtocolTest.java +++ b/rsx-protocol/src/test/java/net/rustnotmc/protocol/ProtocolTest.java @@ -82,6 +82,12 @@ public final class ProtocolTest { run("every packet has a name", new Runnable() { public void run() { testPacketNames(); } }); + run("the items match the original game", new Runnable() { + public void run() { testItems(); } + }); + run("an inventory survives the wire", new Runnable() { + public void run() { testInventory(); } + }); run("a lying length cannot stall the heap", new Runnable() { public void run() { testLyingLength(); } }); @@ -550,6 +556,98 @@ public final class ProtocolTest { Integer.valueOf(names.size())); } + private static void testItems() { + // The numbers the game is built on. If these move, the game stops + // matching its own rules, so they are pinned here. + ItemType rock = ItemRegistry.require(ItemRegistry.ROCK); + eq("the rock is called a rock", "Rock", rock.name); + eq("the rock hits the head for twenty", Integer.valueOf(20), + Integer.valueOf(rock.damage(ItemType.HEAD))); + eq("and the torso for ten", Integer.valueOf(10), + Integer.valueOf(rock.damage(ItemType.TORSO))); + eq("the rock takes ten off a tree", Integer.valueOf(10), + Integer.valueOf(rock.gather(ResourceClass.TREE).damage)); + eq("and five off an ore", Integer.valueOf(5), + Integer.valueOf(rock.gather(ResourceClass.METAL).damage)); + eq("the rock is known from the start", Boolean.TRUE, + Boolean.valueOf(rock.knownByDefault)); + eq("the rock needs no workbench", Integer.valueOf(0), + Integer.valueOf(rock.bench)); + yes("the rock has a recipe", rock.recipe != null); + eq("the rock is made of ten stones", Integer.valueOf(10), + Integer.valueOf(rock.recipe.needs(ItemRegistry.STONE))); + + ItemType hatchet = ItemRegistry.require(ItemRegistry.STONE_HATCHET); + eq("the stone hatchet is an axe", ToolClass.AXE, hatchet.tool); + eq("it takes twenty off a tree", Integer.valueOf(20), + Integer.valueOf(hatchet.gather(ResourceClass.TREE).damage)); + + ItemType pickaxe = ItemRegistry.require(ItemRegistry.STONE_PICKAXE); + eq("the stone pickaxe is a pickaxe", ToolClass.PICKAXE, pickaxe.tool); + eq("it takes sixteen off an ore", Integer.valueOf(16), + Integer.valueOf(pickaxe.gather(ResourceClass.METAL).damage)); + yes("and it does not cut trees", pickaxe.gather(ResourceClass.TREE) == null); + + ItemType sword = ItemRegistry.require(ItemRegistry.SALVAGED_SWORD); + eq("the sword is a sword", ToolClass.SWORD, sword.tool); + yes("the sword gathers nothing but flesh", sword.gather(ResourceClass.TREE) == null + && sword.gather(ResourceClass.METAL) == null + && sword.gather(ResourceClass.FLESH) != null); + + ItemType workbench2 = ItemRegistry.require(ItemRegistry.WORKBENCH_2); + eq("the second workbench needs the first", Integer.valueOf(1), + Integer.valueOf(workbench2.bench)); + ItemType workbench3 = ItemRegistry.require(ItemRegistry.WORKBENCH_3); + eq("the third needs the second", Integer.valueOf(2), + Integer.valueOf(workbench3.bench)); + + // the resources are gathered, not made + ItemType wood = ItemRegistry.require(ItemRegistry.WOOD); + yes("wood has no recipe", wood.recipe == null); + yes("every id is different", ItemRegistry.all().size() > 20); + } + + private static void testInventory() { + Packets.Slot[] slots = new Packets.Slot[6]; + slots[0] = new Packets.Slot(ItemRegistry.ROCK, 1, 100); + slots[1] = new Packets.Slot(ItemRegistry.WOOD, 12, -1); + slots[2] = new Packets.Slot(ItemRegistry.NOTHING, 0, -1); + Packets.Inventory inventory = new Packets.Inventory(slots, 0); + + Packets.Inventory back = + Packets.Inventory.read(new Buf(inventory.write().toByteArray())); + eq("the slots survive", Integer.valueOf(6), + Integer.valueOf(back.slots.length)); + eq("the rock is in the first slot", Integer.valueOf(ItemRegistry.ROCK), + Integer.valueOf(back.slots[0].item)); + eq("with one of it", Integer.valueOf(1), + Integer.valueOf(back.slots[0].count)); + eq("and its life", Integer.valueOf(100), + Integer.valueOf(back.slots[0].condition)); + eq("wood is in the second", Integer.valueOf(12), + Integer.valueOf(back.slots[1].count)); + eq("an empty slot stays empty", Integer.valueOf(ItemRegistry.NOTHING), + Integer.valueOf(back.slots[2].item)); + eq("the hand survives", Integer.valueOf(0), Integer.valueOf(back.held)); + + Packets.SlotChange change = new Packets.SlotChange(3, ItemRegistry.SCRAP, 7, -1); + Packets.SlotChange changeBack = + Packets.SlotChange.read(new Buf(change.write().toByteArray())); + eq("the slot number survives", Integer.valueOf(3), + Integer.valueOf(changeBack.slot)); + eq("the item survives", Integer.valueOf(ItemRegistry.SCRAP), + Integer.valueOf(changeBack.item)); + eq("the count survives", Integer.valueOf(7), + Integer.valueOf(changeBack.count)); + + // a stack carries the data that belongs to it + ItemStack stack = ItemStack.of(ItemRegistry.ROCK, 1); + stack.set("charged", "yes"); + eq("the data travels", "yes", stack.copy().get("charged")); + eq("the copy is separate", Boolean.FALSE, + Boolean.valueOf(stack == stack.copy())); + } + private static void testLyingLength() { // announces a large but legal body, sends nothing, and keeps quiet. // The decoder must hold it without growing and without pretending the diff --git a/rsx-server-core/scripts/test.ps1 b/rsx-server-core/scripts/test.ps1 index 062ab65..d60b550 100644 --- a/rsx-server-core/scripts/test.ps1 +++ b/rsx-server-core/scripts/test.ps1 @@ -47,6 +47,7 @@ Say "compiled $($mainFiles.Count) main + $($testFiles.Count) test files" $suites = @( 'net.rustnotmc.server.ServerTest', 'net.rustnotmc.server.world.WorldTest', + 'net.rustnotmc.server.item.InventoryTest', 'net.rustnotmc.server.PlayTest' ) foreach ($suite in $suites) { diff --git a/rsx-server-core/src/main/java/net/rustnotmc/server/ClientSession.java b/rsx-server-core/src/main/java/net/rustnotmc/server/ClientSession.java index 3e622ad..921c9c7 100644 --- a/rsx-server-core/src/main/java/net/rustnotmc/server/ClientSession.java +++ b/rsx-server-core/src/main/java/net/rustnotmc/server/ClientSession.java @@ -441,9 +441,22 @@ public final class ClientSession implements Runnable { this.player.viewDistance(), ChunkFormat.HEIGHT, this.player.gameMode())); + // what the player is carrying comes with the world: the server owns + // the bag, so the client is told what is in it + send(Packets.INVENTORY, this.player.inventory().toPacket()); this.player.streamChunks(this.server.world()); } + /** Tells the client that one slot of the bag changed. */ + void sendSlot(int slot) { + if (this.player == null) { + return; + } + net.rustnotmc.protocol.ItemStack stack = this.player.inventory().get(slot); + send(Packets.INVENTORY_SLOT, new Packets.SlotChange(slot, stack.item, + stack.count, stack.condition)); + } + /** * The player's game mode changed while they were in the world. * diff --git a/rsx-server-core/src/main/java/net/rustnotmc/server/Player.java b/rsx-server-core/src/main/java/net/rustnotmc/server/Player.java index 086dde0..6c0fa0e 100644 --- a/rsx-server-core/src/main/java/net/rustnotmc/server/Player.java +++ b/rsx-server-core/src/main/java/net/rustnotmc/server/Player.java @@ -6,6 +6,7 @@ import java.util.Iterator; import java.util.Set; import net.rustnotmc.protocol.Blocks; import net.rustnotmc.protocol.Packets; +import net.rustnotmc.server.item.Inventory; import net.rustnotmc.server.world.Chunk; import net.rustnotmc.server.world.ChunkPos; import net.rustnotmc.server.world.World; @@ -49,8 +50,17 @@ public final class Player { /** Survival or creative. The server decides, and the client is told. */ private volatile int gameMode = Packets.GAMEMODE_SURVIVAL; + /** What the player is carrying. The server owns it. */ + private final Inventory inventory; + Player(ClientSession session, int playerId, int entityId, String account, double[] spawn) { + this(session, playerId, entityId, account, spawn, + Inventory.startingKit()); + } + + Player(ClientSession session, int playerId, int entityId, String account, + double[] spawn, Inventory inventory) { this.session = session; this.playerId = playerId; this.entityId = entityId; @@ -58,7 +68,12 @@ public final class Player { this.x = spawn[0]; this.y = spawn[1]; this.z = spawn[2]; - this.viewDistance = DEFAULT_VIEW_DISTANCE; + this.inventory = inventory; + } + + /** What the player is carrying. */ + public Inventory inventory() { + return this.inventory; } // -------------------------------------------------------------- getters diff --git a/rsx-server-core/src/main/java/net/rustnotmc/server/item/Inventory.java b/rsx-server-core/src/main/java/net/rustnotmc/server/item/Inventory.java new file mode 100644 index 0000000..ac823b8 --- /dev/null +++ b/rsx-server-core/src/main/java/net/rustnotmc/server/item/Inventory.java @@ -0,0 +1,173 @@ +package net.rustnotmc.server.item; + +import java.util.ArrayList; +import java.util.List; +import net.rustnotmc.protocol.ItemRegistry; +import net.rustnotmc.protocol.ItemStack; +import net.rustnotmc.protocol.Packets; + +/** + * What a player is carrying. + * + * The server is the only one who decides what is in the slots. The client is + * told, and when it is told it draws what it was given. A client that is free + * to decide what it holds is a client that can be made to hold anything. + * + * The layout is the one from the original game: the first slots are the hotbar + * and the rest is the bag behind it. + */ +public final class Inventory { + + /** How many slots the hotbar has. */ + public static final int HOTBAR = 6; + + /** How many slots there are in all. */ + public static final int SLOTS = 30; + + private final ItemStack[] slots = new ItemStack[SLOTS]; + private int held; + + public Inventory() { + for (int i = 0; i < SLOTS; i++) { + this.slots[i] = ItemStack.EMPTY; + } + } + + /** + * The starting kit. + * + * A rock and a torch, the way the original game hands them out. The rock + * is the first thing the player has and the only thing they can use at the + * start. + */ + public static Inventory startingKit() { + Inventory inventory = new Inventory(); + inventory.set(0, ItemStack.of(ItemRegistry.ROCK, 1)); + inventory.held = 0; + return inventory; + } + + /** The stack in a slot, never null. */ + public ItemStack get(int slot) { + if (slot < 0 || slot >= SLOTS) { + return ItemStack.EMPTY; + } + ItemStack stack = this.slots[slot]; + return stack == null ? ItemStack.EMPTY : stack; + } + + /** Puts a stack in a slot. */ + public void set(int slot, ItemStack stack) { + if (slot < 0 || slot >= SLOTS) { + return; + } + this.slots[slot] = stack == null ? ItemStack.EMPTY : stack; + } + + /** The slot the player is holding. */ + public int held() { + return this.held; + } + + /** Holds a different slot. */ + public void held(int slot) { + if (slot >= 0 && slot < HOTBAR) { + this.held = slot; + } + } + + /** The stack in the hand. */ + public ItemStack inHand() { + return get(this.held); + } + + /** + * Adds a stack to the bag, and to the hotbar first. + * + * Returns true when it fit. A bag that is full simply does not take it, + * which is the same thing every game does. + */ + public boolean add(ItemStack stack) { + if (stack == null || stack.isEmpty()) { + return true; + } + for (int i = 0; i < SLOTS; i++) { + ItemStack there = get(i); + if (there.item == stack.item && there.count < 64) { + int room = 64 - there.count; + int moved = Math.min(room, stack.count); + there.count += moved; + stack.count -= moved; + if (stack.count <= 0) { + return true; + } + } + } + for (int i = 0; i < SLOTS; i++) { + if (get(i).isEmpty()) { + set(i, stack); + return true; + } + } + return false; + } + + /** How many of an item are in the bag. */ + public int count(int item) { + int total = 0; + for (int i = 0; i < SLOTS; i++) { + ItemStack stack = get(i); + if (stack.item == item) { + total += stack.count; + } + } + return total; + } + + /** + * Takes an item out of the bag. + * + * Returns how many were actually there, which is not always what was + * asked for. + */ + public int take(int item, int amount) { + if (amount <= 0) { + return 0; + } + int taken = 0; + for (int i = 0; i < SLOTS && taken < amount; i++) { + ItemStack stack = get(i); + if (stack.item != item || stack.count <= 0) { + continue; + } + int moved = Math.min(stack.count, amount - taken); + stack.count -= moved; + taken += moved; + if (stack.count <= 0) { + set(i, ItemStack.EMPTY); + } + } + return taken; + } + + /** The bag as the client is told about it. */ + public Packets.Inventory toPacket() { + Packets.Slot[] out = new Packets.Slot[SLOTS]; + for (int i = 0; i < SLOTS; i++) { + out[i] = Packets.Slot.of(get(i)); + } + return new Packets.Inventory(out, this.held); + } + + /** The slots that are not empty, for a look at what a player carries. */ + public List contents() { + List out = new ArrayList(); + for (int i = 0; i < SLOTS; i++) { + ItemStack stack = get(i); + if (!stack.isEmpty()) { + out.add(stack); + } + } + return out; + } +} diff --git a/rsx-server-core/src/test/java/net/rustnotmc/server/PlayClient.java b/rsx-server-core/src/test/java/net/rustnotmc/server/PlayClient.java index 7be0281..4bde27a 100644 --- a/rsx-server-core/src/test/java/net/rustnotmc/server/PlayClient.java +++ b/rsx-server-core/src/test/java/net/rustnotmc/server/PlayClient.java @@ -154,6 +154,13 @@ final class PlayClient { : Packets.GameMode.read(new net.rustnotmc.protocol.Buf(frame.body())); } + /** Waits for the server to say what is in the bag. */ + Packets.Inventory awaitInventory(long millis) throws IOException { + Frame frame = await(Packets.INVENTORY, millis); + return frame == null ? null + : Packets.Inventory.read(new net.rustnotmc.protocol.Buf(frame.body())); + } + /** The launcher's session, offered instead of a password. */ void launcherAuth(String account, String uuid, String token) throws IOException { send(Packets.LAUNCHER_AUTH, new Packets.LauncherAuth(account, uuid, token)); diff --git a/rsx-server-core/src/test/java/net/rustnotmc/server/PlayTest.java b/rsx-server-core/src/test/java/net/rustnotmc/server/PlayTest.java index bfbcc7a..3549936 100644 --- a/rsx-server-core/src/test/java/net/rustnotmc/server/PlayTest.java +++ b/rsx-server-core/src/test/java/net/rustnotmc/server/PlayTest.java @@ -57,6 +57,9 @@ public final class PlayTest { run("a survival player cannot break blocks", new Throwing() { public void go() throws Exception { testSurvivalCannotBreak(work); } }); + run("the player starts with a rock in his hand", new Throwing() { + public void go() throws Exception { testStartingKit(work); } + }); run("the world survives a restart", new Throwing() { public void go() throws Exception { testRestart(work); } }); @@ -432,6 +435,52 @@ public final class PlayTest { } } + /** + * The player starts with a rock, and the client is told about it. + * + * The server owns the bag, so the client is not asked what is in it. + */ + private static void testStartingKit(Path work) throws Exception { + Server server = boot(work, "Kit"); + try { + PlayClient client = new PlayClient("127.0.0.1", server.port(), 5000); + yes("signed in", client.signIn("Kit", "password1") != null); + client.send(Packets.JOIN_WORLD, Packets.JoinWorld.fresh()); + client.await(Packets.WORLD_JOIN, 5000L); + + Packets.Inventory inventory = client.awaitInventory(5000L); + yes("the client was told what is in the bag", inventory != null); + if (inventory == null) { + return; + } + eq("the bag has slots", Integer.valueOf(30), + Integer.valueOf(inventory.slots.length)); + eq("the first slot holds the rock", Integer.valueOf(net.rustnotmc.protocol.ItemRegistry.ROCK), + Integer.valueOf(inventory.slots[0].item)); + eq("one of it", Integer.valueOf(1), + Integer.valueOf(inventory.slots[0].count)); + eq("and it is in his hand", Integer.valueOf(0), + Integer.valueOf(inventory.held)); + + Player player = null; + for (Player p : server.players().values()) { + player = p; + } + yes("the server knows the player", player != null); + if (player != null) { + eq("the server agrees about the rock", + Integer.valueOf(net.rustnotmc.protocol.ItemRegistry.ROCK), + Integer.valueOf(player.inventory().get(0).item)); + eq("and about what is in the hand", + Integer.valueOf(net.rustnotmc.protocol.ItemRegistry.ROCK), + Integer.valueOf(player.inventory().inHand().item)); + } + client.close(); + } finally { + server.stop(); + } + } + private static void testRestart(Path work) throws Exception { Path dir = work.resolve("play-world"); deleteTree(dir); diff --git a/rsx-server-core/src/test/java/net/rustnotmc/server/item/InventoryTest.java b/rsx-server-core/src/test/java/net/rustnotmc/server/item/InventoryTest.java new file mode 100644 index 0000000..b52395c --- /dev/null +++ b/rsx-server-core/src/test/java/net/rustnotmc/server/item/InventoryTest.java @@ -0,0 +1,192 @@ +package net.rustnotmc.server.item; + +import java.util.List; +import net.rustnotmc.protocol.ItemRegistry; +import net.rustnotmc.protocol.ItemStack; +import net.rustnotmc.protocol.ItemType; +import net.rustnotmc.protocol.Packets; +import net.rustnotmc.protocol.ResourceClass; + +/** + * Tests the bag. + * + * The bag belongs to the server and nobody else, so these are about the server + * being right about what a player has and what a player can do with it. + */ +public final class InventoryTest { + + private static int checks; + private static int failed; + private static final java.util.List failures = new java.util.ArrayList(); + + public static void main(String[] args) { + run("the starting kit is a rock in the hand", new Throwing() { + public void go() { testStartingKit(); } + }); + run("a bag holds what is put in it", new Throwing() { + public void go() { testAdd(); } + }); + run("a bag gives things back", new Throwing() { + public void go() { testTake(); } + }); + run("a full bag says so", new Throwing() { + public void go() { testFull(); } + }); + run("the client is told what is in the bag", new Throwing() { + public void go() { testPacket(); } + }); + run("a tool knows how much life it has left", new Throwing() { + public void go() { testCondition(); } + }); + + System.out.println(); + for (int i = 0; i < failures.size(); i++) { + System.out.println(" FAIL " + failures.get(i)); + } + System.out.println(); + System.out.println("checks: " + checks + ", failed: " + failed); + if (failed != 0) { + System.out.println("FAILED"); + System.exit(1); + } + System.out.println("OK"); + } + + private interface Throwing { + void go() throws Exception; + } + + private static void run(String name, Throwing body) { + checks++; + int before = failed; + try { + body.go(); + } catch (Throwable t) { + failed++; + failures.add(name + ": threw " + t); + t.printStackTrace(); + return; + } + if (failed == before) { + System.out.println(" ok " + name); + } + } + + private static void yes(String what, boolean condition) { + if (!condition) { + failed++; + failures.add(what); + } + } + + private static void eq(Object what, Object expected, Object actual) { + if (expected == null ? actual != null : !expected.equals(actual)) { + failed++; + failures.add(what + ": expected " + expected + " but got " + actual); + } + } + + private static void testStartingKit() { + Inventory inventory = Inventory.startingKit(); + eq("the first slot holds the rock", Integer.valueOf(ItemRegistry.ROCK), + Integer.valueOf(inventory.get(0).item)); + eq("one of it", Integer.valueOf(1), Integer.valueOf(inventory.get(0).count)); + eq("and it is in the hand", Integer.valueOf(0), + Integer.valueOf(inventory.held())); + eq("the hand holds the rock", Integer.valueOf(ItemRegistry.ROCK), + Integer.valueOf(inventory.inHand().item)); + eq("nothing else", Integer.valueOf(1), + Integer.valueOf(inventory.contents().size())); + } + + private static void testAdd() { + Inventory inventory = new Inventory(); + yes("the rock goes in", inventory.add(ItemStack.of(ItemRegistry.ROCK, 1))); + eq("and it is there", Integer.valueOf(1), + Integer.valueOf(inventory.count(ItemRegistry.ROCK))); + + // the same kind of thing piles up + yes("more wood goes in", inventory.add(ItemStack.of(ItemRegistry.WOOD, 10))); + yes("and more", inventory.add(ItemStack.of(ItemRegistry.WOOD, 5))); + eq("wood piles up", Integer.valueOf(15), + Integer.valueOf(inventory.count(ItemRegistry.WOOD))); + } + + private static void testTake() { + Inventory inventory = Inventory.startingKit(); + inventory.add(ItemStack.of(ItemRegistry.WOOD, 10)); + + eq("four wood comes out", Integer.valueOf(4), + Integer.valueOf(inventory.take(ItemRegistry.WOOD, 4))); + eq("six are left", Integer.valueOf(6), + Integer.valueOf(inventory.count(ItemRegistry.WOOD))); + + eq("more than there is gives what there is", Integer.valueOf(6), + Integer.valueOf(inventory.take(ItemRegistry.WOOD, 100))); + eq("and nothing is left", Integer.valueOf(0), + Integer.valueOf(inventory.count(ItemRegistry.WOOD))); + eq("taking nothing takes nothing", Integer.valueOf(0), + Integer.valueOf(inventory.take(ItemRegistry.WOOD, 0))); + } + + private static void testFull() { + Inventory inventory = new Inventory(); + // a slot holds a stack of up to sixty four, so the bag is full when + // every slot is + for (int i = 0; i < Inventory.SLOTS; i++) { + yes("slot " + i + " takes a stack", + inventory.add(ItemStack.of(ItemRegistry.WOOD, 64))); + } + yes("a full bag says so", !inventory.add(ItemStack.of(ItemRegistry.WOOD, 1))); + eq("and it did not take the last one", Integer.valueOf(Inventory.SLOTS * 64), + Integer.valueOf(inventory.count(ItemRegistry.WOOD))); + } + + private static void testPacket() { + Inventory inventory = Inventory.startingKit(); + inventory.add(ItemStack.of(ItemRegistry.SCRAP, 3)); + inventory.held(1); + + Packets.Inventory packet = inventory.toPacket(); + eq("every slot is there", Integer.valueOf(Inventory.SLOTS), + Integer.valueOf(packet.slots.length)); + eq("the rock is in the first slot", Integer.valueOf(ItemRegistry.ROCK), + Integer.valueOf(packet.slots[0].item)); + eq("the scrap is in the second", Integer.valueOf(ItemRegistry.SCRAP), + Integer.valueOf(packet.slots[1].item)); + eq("three of it", Integer.valueOf(3), + Integer.valueOf(packet.slots[1].count)); + eq("and the hand is the second slot", Integer.valueOf(1), + Integer.valueOf(packet.held)); + eq("the empty slots stay empty", Integer.valueOf(ItemRegistry.NOTHING), + Integer.valueOf(packet.slots[5].item)); + } + + private static void testCondition() { + // a tool has a life, a pile of wood does not + ItemStack rock = ItemStack.of(ItemRegistry.ROCK, 1); + eq("the rock starts whole", Integer.valueOf(100), Integer.valueOf(rock.condition)); + + ItemStack wood = ItemStack.of(ItemRegistry.WOOD, 1); + eq("wood does not wear out", Integer.valueOf(-1), + Integer.valueOf(wood.condition)); + + // a swing costs the tool something + ItemType rockType = ItemRegistry.require(ItemRegistry.ROCK); + eq("a swing on a tree costs the rock", 0.3F, + rockType.gather(ResourceClass.TREE).conditionLoss, 0.0001F); + eq("a swing on an ore costs the same", 0.3F, + rockType.gather(ResourceClass.METAL).conditionLoss, 0.0001F); + } + + private static void eq(String what, Object expected, Object actual, float tolerance) { + if (expected instanceof Float && actual instanceof Float) { + float difference = Math.abs(((Float) expected).floatValue() + - ((Float) actual).floatValue()); + if (difference <= tolerance) { + return; + } + } + eq(what, expected, actual); + } +}