diff --git a/PLAN.md b/PLAN.md index 5ada27a..828cae8 100644 --- a/PLAN.md +++ b/PLAN.md @@ -971,12 +971,16 @@ rsx-client\scripts\run-game.ps1 # клиент ### 6.3 Что не сделано (по порядку) -1. **Шаг A** — добить: клиентский рендер своих предметов (хотбар и рука) и иконки - из атласа; поля исследования в `ItemType`; `NodeType`; `TechTree`. -2. **Шаг B** — `Dig`/`BreakProgress` в протоколе, состояние ломания на сервере, - правила инструмента, тег `admin`, 4 клиентских патча. -3. **Шаг C** — узлы (дерево/камень/металл/сера) с метками, дороги, мусорка, - крафт и исследование (стол + тех-дерево), постройка по сокетам. +1. **Шаг A** — сделано: поля исследования и дерева в `ItemType`, числа из таблицы + ремейка (`ItemData`), `NodeType`, `TechTree`, 218 предметов в реестре. + Не сделано: **клиентский рендер своих предметов** (хотбар и рука) и иконки из атласа. +2. **Шаг B** — сделано: `Dig`/`BreakProgress` в протоколе, проверка досягаемости + на сервере, тег `admin` в типах предметов. Не сделано: узлы в мире (шаг C), + трещины на клиенте, 4 клиентских патча. +3. **Шаг C** — сделано: `ResourceNode` (HP, запас, метка), `JunkPile` (бутылки, + ящик с едой, таймер исчезновения), `RoadNetwork` + дороги в генераторе + (сетка и кольцо, ровная по ширине). Не сделано: мусорка в мире (спавн и + респавн у дороги), станции крафта (верстак в мире), постройка по сокетам. 4. **Шаг D** — конфиг, мир 4000×4000 один раз, барьер. 5. **Шаг E** — транспорт на NIO. 6. **Шаг F** — DistantView. diff --git a/rsx-protocol/scripts/build.ps1 b/rsx-protocol/scripts/build.ps1 index c34f6d7..c0e971b 100644 --- a/rsx-protocol/scripts/build.ps1 +++ b/rsx-protocol/scripts/build.ps1 @@ -42,6 +42,15 @@ if ($LASTEXITCODE -ne 0) { } Say "protocol compiled ($($mainFiles.Count) files)" +# The resources travel with the jar: the item table and the sound registry +# live in src\main\resources, and both ends of the wire read them. +$resources = Join-Path $Repo 'src\main\resources' +if (Test-Path -LiteralPath $resources) { + Copy-Item -Path (Join-Path $resources '*') -Destination $mainOut -Recurse -Force + $resourceFiles = @(Get-ChildItem $resources -Recurse -File) + Say "resources copied ($($resourceFiles.Count) files)" +} + $manifest = Join-Path $Out 'manifest.txt' @" Manifest-Version: 1.0 diff --git a/rsx-protocol/scripts/test.ps1 b/rsx-protocol/scripts/test.ps1 index eb01e50..37deda6 100644 --- a/rsx-protocol/scripts/test.ps1 +++ b/rsx-protocol/scripts/test.ps1 @@ -21,6 +21,12 @@ $mainFiles = @(Get-ChildItem (Join-Path $Repo 'src\main\java') -Recurse -Filter & javac -encoding UTF-8 --release 17 -Xlint:-options -nowarn -d $mainOut $mainFiles if ($LASTEXITCODE -ne 0) { Write-Host ' Main failed.' -ForegroundColor Red; exit 1 } +# The tests read the item table, so it has to be on the class path as well. +$resources = Join-Path $Repo 'src\main\resources' +if (Test-Path -LiteralPath $resources) { + Copy-Item -Path (Join-Path $resources '*') -Destination $mainOut -Recurse -Force +} + $testOut = Join-Path $Out 'test-classes' Remove-Item $testOut -Recurse -Force -ErrorAction SilentlyContinue New-Item -ItemType Directory -Force -Path $testOut | Out-Null @@ -38,5 +44,12 @@ if ($LASTEXITCODE -ne 0) { exit 1 } +& java -cp "$mainOut;$testOut" net.rustnotmc.protocol.ItemTest +if ($LASTEXITCODE -ne 0) { + Write-Host '' + Write-Host ' ITEM TESTS FAILED' -ForegroundColor Red + exit 1 +} + Write-Host '' Write-Host ' Protocol tests passed.' -ForegroundColor Green \ No newline at end of file diff --git a/rsx-protocol/src/main/java/net/rustnotmc/protocol/Blocks.java b/rsx-protocol/src/main/java/net/rustnotmc/protocol/Blocks.java index fc1d56b..6ed17f4 100644 --- a/rsx-protocol/src/main/java/net/rustnotmc/protocol/Blocks.java +++ b/rsx-protocol/src/main/java/net/rustnotmc/protocol/Blocks.java @@ -27,9 +27,12 @@ public final class Blocks { public static final byte WOOD = 6; public static final byte LEAVES = 7; + /** The surface of a road. */ + public static final byte ASPHALT = 8; + /** Lowest and highest legal block id. */ public static final byte MIN = AIR; - public static final byte MAX = LEAVES; + public static final byte MAX = ASPHALT; private Blocks() { } @@ -44,6 +47,7 @@ public final class Blocks { case WATER: return "water"; case WOOD: return "wood"; case LEAVES: return "leaves"; + case ASPHALT: return "asphalt"; default: return "unknown(" + id + ")"; } } diff --git a/rsx-protocol/src/main/java/net/rustnotmc/protocol/ItemData.java b/rsx-protocol/src/main/java/net/rustnotmc/protocol/ItemData.java new file mode 100644 index 0000000..fb453f3 --- /dev/null +++ b/rsx-protocol/src/main/java/net/rustnotmc/protocol/ItemData.java @@ -0,0 +1,173 @@ +package net.rustnotmc.protocol; + +import java.io.BufferedReader; +import java.io.IOException; +import java.io.InputStream; +import java.io.InputStreamReader; +import java.nio.charset.StandardCharsets; +import java.util.Collections; +import java.util.HashMap; +import java.util.Map; + +/** + * The numbers of every item, read from the table. + * + * The table is {@code items/items.csv} in the resources of this module. It was + * unpacked from the item database of the remake, which keeps the damage, the + * range, the tempo, the durability and the harvest of every item it knows. + * Keeping it in a file rather than in the source means one place to look when + * a number has to change, and one place to compare with the plan. + * + * The registry still decides what an item is: the id, the class of + * tool, the recipe, what it costs to research. This class only fills in the + * numbers. + */ +public final class ItemData { + + /** One row of the table. */ + public static final class Row { + + public final String rid; + public final String name; + public final String category; + public final String material; + + public final int damage; + public final int range; + public final int cooldown; + public final int reach; + public final int durability; + public final int wood; + public final int ore; + public final int ammo; + public final int weaponId; + + Row(Map cells) { + this.rid = cells.get("rid"); + this.name = cells.get("name"); + this.category = cells.get("category"); + this.material = cells.get("material"); + this.damage = number(cells, "damage"); + this.range = number(cells, "range"); + this.cooldown = number(cells, "cooldown"); + this.reach = number(cells, "reach"); + this.durability = number(cells, "durability"); + this.wood = number(cells, "wood"); + this.ore = number(cells, "ore"); + this.ammo = number(cells, "ammo"); + this.weaponId = number(cells, "weapon_id"); + } + + private static int number(Map cells, String key) { + String value = cells.get(key); + if (value == null || value.isEmpty()) { + return 0; + } + try { + return Integer.parseInt(value.trim()); + } catch (NumberFormatException ignored) { + return 0; + } + } + + /** True when the row says nothing about this number. */ + public boolean isEmpty() { + return this.damage == 0 && this.cooldown == 0 && this.durability == 0 + && this.wood == 0 && this.ore == 0; + } + } + + private static final Map ROWS = load(); + + private ItemData() { + } + + /** The row of an item, or null when the table does not know it. */ + public static Row byRid(String rid) { + if (rid == null) { + return null; + } + return ROWS.get(rid); + } + + /** Every row, by the Rust id of the item. */ + public static Map all() { + return ROWS; + } + + /** How many items the table knows. */ + public static int size() { + return ROWS.size(); + } + + private static Map load() { + Map rows = new HashMap(); + InputStream in = ItemData.class.getResourceAsStream("/items/items.csv"); + if (in == null) { + return Collections.unmodifiableMap(rows); + } + try { + BufferedReader reader = new BufferedReader( + new InputStreamReader(in, StandardCharsets.UTF_8)); + try { + String header = reader.readLine(); + if (header == null) { + return Collections.unmodifiableMap(rows); + } + String[] names = splitCsv(header); + String line; + while ((line = reader.readLine()) != null) { + if (line.isEmpty()) { + continue; + } + String[] cells = splitCsv(line); + Map map = new HashMap(); + for (int i = 0; i < names.length && i < cells.length; i++) { + map.put(names[i], cells[i]); + } + Row row = new Row(map); + if (row.rid != null && !row.rid.isEmpty()) { + rows.put(row.rid, row); + } + } + } finally { + reader.close(); + } + } catch (IOException ignored) { + // A table that cannot be read is an empty table: the registry + // still works, it just has to carry the numbers itself. + } + return Collections.unmodifiableMap(rows); + } + + /** Splits a line of the table, honouring quotes. */ + private static String[] splitCsv(String line) { + java.util.List out = new java.util.ArrayList(); + StringBuilder cell = new StringBuilder(); + boolean quoted = false; + for (int i = 0; i < line.length(); i++) { + char c = line.charAt(i); + if (quoted) { + if (c == '"') { + if (i + 1 < line.length() && line.charAt(i + 1) == '"') { + cell.append('"'); + i++; + } else { + quoted = false; + } + } else { + cell.append(c); + } + } else if (c == '"') { + quoted = true; + } else if (c == ',') { + out.add(cell.toString()); + cell.setLength(0); + } else { + cell.append(c); + } + } + out.add(cell.toString()); + return out.toArray(new String[out.size()]); + } +} diff --git a/rsx-protocol/src/main/java/net/rustnotmc/protocol/ItemRegistry.java b/rsx-protocol/src/main/java/net/rustnotmc/protocol/ItemRegistry.java index 40fac4f..4e123af 100644 --- a/rsx-protocol/src/main/java/net/rustnotmc/protocol/ItemRegistry.java +++ b/rsx-protocol/src/main/java/net/rustnotmc/protocol/ItemRegistry.java @@ -8,13 +8,11 @@ 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. + * wire, so an id that ever changes is a bug. The numbers come from the table + * of {@link ItemData}, which was unpacked from the item database of the + * remake, and the plan keeps the same numbers. A table copied by hand into + * two places drifts, and then the game stops matching its own rules. */ public final class ItemRegistry { @@ -47,6 +45,93 @@ public final class ItemRegistry { public static final int METAL_PIPE = 26; public static final int METAL_BLADE = 27; public static final int GEARS = 28; + public static final int SALVAGED_CLEAVER = 29; + public static final int GARAGE_DOOR = 30; + public static final int ARMORED_DOOR = 31; + public static final int ARMORED_DOUBLE_DOOR = 32; + public static final int EXPLOSIVES = 33; + public static final int TIMED_CHARGE = 34; + + // The resources and the parts the junk pile gives out. + public static final int CRUDE_OIL = 35; + public static final int LOW_GRADE_FUEL = 36; + public static final int METAL_SPRING = 37; + public static final int SHEET_METAL = 38; + public static final int ROAD_SIGNS = 39; + public static final int ELECTRIC_FUSE = 40; + public static final int ROPE = 41; + public static final int TARP = 42; + public static final int SEWING_KIT = 43; + public static final int EMPTY_PROPANE_TANK = 44; + public static final int SEMI_AUTO_BODY = 45; + public static final int TECH_TRASH = 46; + public static final int GUNPOWDER = 47; + public static final int CHARCOAL = 48; + public static final int CLOTH = 49; + public static final int LEATHER = 50; + public static final int PLANT_FIBER = 51; + public static final int ANIMAL_FAT = 52; + + // The food. + public static final int APPLE = 53; + public static final int BLUEBERRIES = 54; + public static final int BLACK_RASPBERRIES = 55; + public static final int POTATO = 56; + public static final int CHOCOLATE_BAR = 57; + public static final int CAN_OF_BEANS = 58; + public static final int CAN_OF_TUNA = 59; + public static final int GRANOLA_BAR = 60; + public static final int CORN = 61; + public static final int SMALL_WATER_BOTTLE = 62; + public static final int WATER_JUG = 63; + public static final int ANTI_RAD = 64; + public static final int BANDAGE = 65; + public static final int MEDICAL_SYRINGE = 66; + public static final int LARGE_MEDKIT = 67; + public static final int PUMPKIN = 68; + public static final int MUSHROOM = 69; + + // The rubbish of a junk pile: worn clothes and old rags. + public static final int BANDANA_MASK = 70; + public static final int TANK_TOP = 71; + public static final int SHORTS = 72; + public static final int T_SHIRT = 73; + public static final int PANTS = 74; + public static final int LEATHER_GLOVES = 75; + public static final int BASEBALL_CAP = 76; + public static final int BOONIE_HAT = 77; + public static final int HIDE_PANTS = 78; + public static final int HIDE_BOOTS = 79; + public static final int HIDE_SKIRT = 80; + public static final int HIDE_CHEST = 81; + public static final int HIDE_PONCHO = 82; + public static final int ROADSIGN_JACKET = 83; + public static final int ROADSIGN_KILT = 84; + public static final int ROADSIGN_GLOVES = 85; + + // The places and the things in them. + public static final int CAMPFIRE = 86; + public static final int FURNACE = 87; + public static final int SLEEPING_BAG = 88; + public static final int RECYCLER = 89; + public static final int WOOD_STORAGE_BOX = 90; + public static final int LARGE_WOOD_BOX = 91; + public static final int TOOL_CUPBOARD = 92; + public static final int KEY_LOCK = 93; + public static final int WOODEN_LADDER = 94; + public static final int FLOOR_GRILL = 95; + public static final int METAL_WINDOW_BARS = 96; + public static final int WOODEN_WINDOW_BARS = 97; + public static final int STRENGTHENED_GLASS_WINDOW = 98; + public static final int REINFORCED_GLASS_WINDOW = 99; + public static final int WOOD_DOUBLE_DOOR = 100; + public static final int SHEETMETAL_DOOR = 101; + public static final int SHEETMETAL_DOUBLE_DOOR = 102; + public static final int HOMEMADE_LANDMINE = 103; + public static final int SHOTGUN_TRAP = 104; + public static final int SNAP_TRAP = 105; + public static final int WOODEN_FLOOR_SPIKES = 106; + public static final int TORCH = 107; private static final Map TYPES = new LinkedHashMap(); @@ -57,144 +142,338 @@ public final class ItemRegistry { // 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(WOOD, "Wood").rid("wood")); + define(ItemType.of(STONE, "Stones").rid("stone")); + define(ItemType.of(METAL, "Metal Fragments").rid("metalfragment")); + define(ItemType.of(HIGH_QUALITY_METAL, "High Quality Metal").rid("hqm")); + define(ItemType.of(SULFUR, "Sulfur Ore").rid("sulfur_ore")); + define(ItemType.of(SCRAP, "Scrap").rid("scrap")); + // The fragments of the blueprints. The remake does not know them: they + // come out of the loot crates, and the crates are still to come. 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")); + define(ItemType.of(METAL_PIPE, "Metal Pipe").rid("metalpipe")); + define(ItemType.of(METAL_BLADE, "Metal Blade").rid("metalblade")); + define(ItemType.of(GEARS, "Gears").rid("gears")); + define(ItemType.of(CRUDE_OIL, "Crude Oil").rid("crudeoil")); + define(ItemType.of(LOW_GRADE_FUEL, "Low Grade Fuel").rid("lowgradefuel")); + define(ItemType.of(METAL_SPRING, "Metal Spring").rid("metalspring")); + define(ItemType.of(SHEET_METAL, "Sheet Metal").rid("sheetmetal")); + define(ItemType.of(ROAD_SIGNS, "Road Signs").rid("roadsigns")); + define(ItemType.of(ELECTRIC_FUSE, "Electric Fuse").rid("electricfuse")); + define(ItemType.of(ROPE, "Rope").rid("rope")); + define(ItemType.of(TARP, "Tarp").rid("tarp")); + define(ItemType.of(SEWING_KIT, "Sewing Kit").rid("sewingkit")); + define(ItemType.of(EMPTY_PROPANE_TANK, "Empty Propane Tank").rid("emptypropanetank")); + define(ItemType.of(SEMI_AUTO_BODY, "Semi Automatic Body").rid("semiautomaticbody")); + define(ItemType.of(TECH_TRASH, "Tech Trash").rid("techtrash")); + define(ItemType.of(GUNPOWDER, "Gunpowder").rid("gunpowder")); + define(ItemType.of(CHARCOAL, "Charcoal").rid("charcoal")); + define(ItemType.of(CLOTH, "Cloth").rid("cloth")); + define(ItemType.of(LEATHER, "Leather").rid("leather")); + define(ItemType.of(PLANT_FIBER, "Plant Fiber").rid("plantfiber")); + define(ItemType.of(ANIMAL_FAT, "Animal Fat").rid("animalfat")); - // 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. + // The food and the medicine. + define(ItemType.of(APPLE, "Apple").rid("apple")); + define(ItemType.of(BLUEBERRIES, "Blueberries").rid("blueberries")); + define(ItemType.of(BLACK_RASPBERRIES, "Black Raspberries").rid("blackraspberries")); + define(ItemType.of(POTATO, "Potato").rid("potato")); + define(ItemType.of(CHOCOLATE_BAR, "Chocolate Bar").rid("chocolatebar")); + define(ItemType.of(CAN_OF_BEANS, "Can of Beans").rid("canofbeans")); + define(ItemType.of(CAN_OF_TUNA, "Can of Tuna").rid("canoftuna")); + define(ItemType.of(GRANOLA_BAR, "Granola Bar").rid("granolabar")); + define(ItemType.of(CORN, "Corn").rid("corn")); + define(ItemType.of(SMALL_WATER_BOTTLE, "Small Water Bottle").rid("smallwaterbottle")); + define(ItemType.of(WATER_JUG, "Water Jug").rid("waterjug")); + define(ItemType.of(ANTI_RAD, "Anti Rad Pills").rid("antirad")); + define(ItemType.of(BANDAGE, "Bandage").rid("bandage")); + define(ItemType.of(MEDICAL_SYRINGE, "Medical Syringe").rid("medicalsyringe")); + define(ItemType.of(LARGE_MEDKIT, "Large Medkit").rid("largemedkit")); + define(ItemType.of(PUMPKIN, "Pumpkin").rid("pumpkin")); + define(ItemType.of(MUSHROOM, "Mushroom").rid("mushroom")); + + // The rubbish of a junk pile. + define(ItemType.of(BANDANA_MASK, "Bandana Mask").rid("maskbandana")); + define(ItemType.of(TANK_TOP, "Tank Top").rid("tanktop")); + define(ItemType.of(SHORTS, "Shorts").rid("shorts")); + define(ItemType.of(T_SHIRT, "T-Shirt").rid("tshirt")); + define(ItemType.of(PANTS, "Pants").rid("pants")); + define(ItemType.of(LEATHER_GLOVES, "Leather Gloves").rid("leathergloves")); + define(ItemType.of(BASEBALL_CAP, "Baseball Cap").rid("baseballcap")); + define(ItemType.of(BOONIE_HAT, "Boonie Hat").rid("booniehat")); + define(ItemType.of(HIDE_PANTS, "Hide Pants").rid("hidepants")); + define(ItemType.of(HIDE_BOOTS, "Hide Boots").rid("hideboots")); + define(ItemType.of(HIDE_SKIRT, "Hide Skirt").rid("hideskirt")); + define(ItemType.of(HIDE_CHEST, "Hide Chest").rid("hidechest")); + define(ItemType.of(HIDE_PONCHO, "Hide Poncho").rid("hideponcho")); + define(ItemType.of(ROADSIGN_JACKET, "Road Sign Jacket").rid("roadsignjacket")); + define(ItemType.of(ROADSIGN_KILT, "Road Sign Kilt").rid("roadsignkilt")); + define(ItemType.of(ROADSIGN_GLOVES, "Road Sign Gloves").rid("roadsigngloves")); + + // The tools. The numbers of the gather and the damage are from the + // table of the remake; what a tool is for is ours. define(ItemType.of(ROCK, "Rock") + .rid("rock") .tool(ToolClass.NONE) .durability(100) - .damage(20, 10, 8, 8) + .damage(10) .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") + .rid("stoneaxe") .tool(ToolClass.AXE) - .durability(100) - .damage(27, 15, 12, 12) + .durability(160) + .damage(15) .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") + define(ItemType.of(STONE_PICKAXE, "Stone Pickaxe") + .rid("stonepickaxe") .tool(ToolClass.PICKAXE) - .durability(100) - .damage(35, 17, 13, 13) + .durability(160) + .damage(17) .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") + .rid("hammer") .tool(ToolClass.HAMMER) - .durability(100) - .damage(0, 0, 0, 0) + .durability(300) + .damage(0) .recipe(new Recipe(new int[]{WOOD}, new int[]{100}, 15))); + define(ItemType.of(TORCH, "Torch") + .rid("torch") + .recipe(new Recipe(new int[]{WOOD}, new int[]{10}, 5))); + + // The tools that have to be researched. The price is the one of the + // research table, the tree is the one of the workbench. define(ItemType.of(HATCHET, "Hatchet") + .rid("ironaxe") .tool(ToolClass.AXE) - .durability(100) - .damage(25, 25, 25, 25) + .durability(300) + .damage(25) .bench(1) .known(false) + .research(30) + .tree(1, TechTree.PRICE_L1, SALVAGED_HAMMER) .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)); + .gather(ResourceClass.FLESH, 12, 1.0F, 1.0F)); define(ItemType.of(PICKAXE, "Pickaxe") + .rid("ironpickaxe") .tool(ToolClass.PICKAXE) - .durability(100) - .damage(30, 30, 30, 30) + .durability(300) + .damage(30) .bench(1) .known(false) + .research(30) + .tree(1, TechTree.PRICE_L1, SALVAGED_HAMMER) .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)); + .gather(ResourceClass.FLESH, 5, 1.0F, 1.0F)); define(ItemType.of(SALVAGED_HAMMER, "Salvaged Hammer") + .rid("salvagedhammer") .tool(ToolClass.HAMMER) - .durability(100) - .damage(40, 40, 40, 40) + .durability(400) + .damage(30) .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)); + .research(30) + .tree(1, TechTree.PRICE_L1, NOTHING) + .recipe(new Recipe(new int[]{METAL_PIPE, METAL}, new int[]{1, 50}, 30))); define(ItemType.of(SALVAGED_SWORD, "Salvaged Sword") + .rid("salvagedsword") .tool(ToolClass.SWORD) - .durability(100) - .damage(50, 50, 50, 50) + .durability(300) + .damage(50) .bench(1) .known(false) + .research(15) + .tree(1, TechTree.PRICE_L1, HATCHET) .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(SALVAGED_CLEAVER, "Salvaged Cleaver") + .rid("salvagedcleaver") + .tool(ToolClass.SWORD) + .durability(400) + .damage(60) + .bench(1) + .known(false) + .research(30) + .tree(1, TechTree.PRICE_L1, HATCHET) + .recipe(new Recipe(new int[]{ROAD_SIGNS, METAL}, new int[]{1, 50}, 30)) + .gather(ResourceClass.FLESH, 10, 1.0F, 0.5F)); + + define(ItemType.of(SALVAGED_AXE, "Salvaged Axe") + .rid("salvagedaxe") + .tool(ToolClass.AXE) + .durability(1500) + .damage(40) + .bench(2) + .known(false) + .research(60) + .tree(2, TechTree.PRICE_L2, SALVAGED_HAMMER) + .recipe(new Recipe(new int[]{METAL_PIPE, METAL_BLADE}, new int[]{1, 5}, 30))); + + define(ItemType.of(SALVAGED_ICEPICK, "Salvaged Icepick") + .rid("salvagedicepick") + .tool(ToolClass.PICKAXE) + .durability(1500) + .damage(40) + .bench(2) + .known(false) + .research(60) + .tree(2, TechTree.PRICE_L2, SALVAGED_HAMMER) + .recipe(new Recipe(new int[]{METAL_PIPE, METAL_BLADE}, new int[]{1, 5}, 30))); + + // The places and the things in them. define(ItemType.of(BUILDING_PLAN, "Building Plan") + .rid("building_plan") .recipe(new Recipe(new int[]{WOOD}, new int[]{20}, 15))); define(ItemType.of(WORKBENCH_1, "Work Bench Level 1") + .rid("workbenchlevel1") .recipe(new Recipe(new int[]{WOOD, METAL}, new int[]{500, 100}, 30))); define(ItemType.of(WORKBENCH_2, "Work Bench Level 2") + .rid("workbenchlevel2") .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") + .rid("workbenchlevel3") .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") + .rid("researchtable") .bench(1) .recipe(new Recipe(new int[]{METAL, SCRAP}, new int[]{200, 20}, 30))); - define(ItemType.of(WOODEN_DOOR, "Wooden Door") + define(ItemType.of(TOOL_CUPBOARD, "Tool Cupboard") + .rid("toolcupboard") + .recipe(new Recipe(new int[]{WOOD}, new int[]{1000}, 30))); + define(ItemType.of(CAMPFIRE, "Campfire") + .rid("campfire") + .recipe(new Recipe(new int[]{WOOD}, new int[]{100}, 15))); + define(ItemType.of(FURNACE, "Large Furnace") + .rid("furnace") + .bench(2) + .known(false) + .research(60) + .tree(2, TechTree.PRICE_L2, METAL_BLADE) + .recipe(new Recipe(new int[]{WOOD, STONE, LOW_GRADE_FUEL}, + new int[]{600, 500, 75}, 40))); + define(ItemType.of(RECYCLER, "Recycler") + .rid("recycler") + .bench(2) + .known(false) + .research(60) + .tree(2, TechTree.PRICE_L2, METAL_BLADE) + .recipe(new Recipe(new int[]{METAL, METAL_PIPE, HIGH_QUALITY_METAL}, + new int[]{150, 20, 10}, 30))); + define(ItemType.of(SLEEPING_BAG, "Sleeping Bag") + .rid("sleepingbag") + .recipe(new Recipe(new int[]{CLOTH, PLANT_FIBER}, new int[]{20, 20}, 15))); + define(ItemType.of(WOOD_STORAGE_BOX, "Wood Storage Box") + .rid("woodstoragebox") .recipe(new Recipe(new int[]{WOOD}, new int[]{300}, 30))); + define(ItemType.of(LARGE_WOOD_BOX, "Large Wood Box") + .rid("largewoodbox") + .recipe(new Recipe(new int[]{WOOD}, new int[]{300}, 30))); + define(ItemType.of(WOODEN_DOOR, "Wooden Door") + .rid("woodendoor") + .recipe(new Recipe(new int[]{WOOD}, new int[]{300}, 30))); + define(ItemType.of(WOOD_DOUBLE_DOOR, "Double Wooden Door") + .rid("wooddoubledoor") + .recipe(new Recipe(new int[]{WOOD}, new int[]{350}, 30))); + define(ItemType.of(SHEETMETAL_DOOR, "Sheet Metal Door") + .rid("sheetmetaldoor") + .recipe(new Recipe(new int[]{METAL}, new int[]{150}, 30))); + define(ItemType.of(SHEETMETAL_DOUBLE_DOOR, "Double Sheet Metal Door") + .rid("sheetmetaldoubledoor") + .recipe(new Recipe(new int[]{METAL}, new int[]{200}, 30))); + define(ItemType.of(ARMORED_DOOR, "Armored Door") + .rid("armoreddoor") + .bench(3) + .known(false) + .research(120) + .tree(3, TechTree.PRICE_L3, METAL_PIPE) + .recipe(new Recipe(new int[]{HIGH_QUALITY_METAL, GEARS}, new int[]{20, 5}, 30))); + define(ItemType.of(ARMORED_DOUBLE_DOOR, "Double Armored Door") + .rid("armoreddoubledoor") + .bench(3) + .known(false) + .research(120) + .tree(3, TechTree.PRICE_L3, METAL_PIPE) + .recipe(new Recipe(new int[]{HIGH_QUALITY_METAL, GEARS}, new int[]{25, 5}, 30))); + define(ItemType.of(GARAGE_DOOR, "Garage Door") + .rid("garagedoor") + .bench(2) + .known(false) + .research(30) + .tree(2, TechTree.PRICE_L2, GEARS) + .recipe(new Recipe(new int[]{METAL, GEARS}, new int[]{300, 2}, 30))); define(ItemType.of(CODE_LOCK, "Code Lock") + .rid("codelock") .recipe(new Recipe(new int[]{METAL}, new int[]{100}, 30))); + define(ItemType.of(KEY_LOCK, "Key Lock") + .rid("keylock") + .recipe(new Recipe(new int[]{WOOD}, new int[]{75}, 15))); + define(ItemType.of(WOODEN_LADDER, "Wooden Ladder") + .rid("woodenladder") + .recipe(new Recipe(new int[]{WOOD}, new int[]{100}, 15))); + define(ItemType.of(FLOOR_GRILL, "Floor Grilling") + .rid("floorgrill") + .recipe(new Recipe(new int[]{METAL}, new int[]{100}, 30))); + define(ItemType.of(METAL_WINDOW_BARS, "Metal Window Bars") + .rid("metalwindowbars") + .recipe(new Recipe(new int[]{METAL}, new int[]{100}, 30))); + define(ItemType.of(WOODEN_WINDOW_BARS, "Wooden Window Bars") + .rid("woodenwindowbars") + .recipe(new Recipe(new int[]{WOOD}, new int[]{100}, 15))); + define(ItemType.of(STRENGTHENED_GLASS_WINDOW, "Strengthened Glass Window") + .rid("strengthenedglasswindow") + .recipe(new Recipe(new int[]{METAL}, new int[]{50}, 15))); + define(ItemType.of(REINFORCED_GLASS_WINDOW, "Reinforced Glass Window") + .rid("reinforcedglasswindow") + .recipe(new Recipe(new int[]{METAL}, new int[]{100}, 15))); + + // The traps. + define(ItemType.of(HOMEMADE_LANDMINE, "Homemade Landmine") + .rid("homemadelandmine") + .recipe(new Recipe(new int[]{METAL, SCRAP}, new int[]{50, 10}, 15))); + define(ItemType.of(SHOTGUN_TRAP, "Shotgun Trap") + .rid("shotguntrap") + .known(false) + .research(60) + .recipe(new Recipe(new int[]{METAL, GEARS}, new int[]{200, 10}, 30))); + define(ItemType.of(SNAP_TRAP, "Snap Trap") + .rid("snaptrap") + .recipe(new Recipe(new int[]{WOOD, CLOTH}, new int[]{100, 10}, 15))); + define(ItemType.of(WOODEN_FLOOR_SPIKES, "Wooden Floor Spikes") + .rid("woodenfloorspikes") + .recipe(new Recipe(new int[]{WOOD, METAL}, new int[]{200, 10}, 15))); + + // The things that only an administrator may use. + define(ItemType.of(EXPLOSIVES, "Explosives") + .rid("explosives") + .bench(3) + .known(false) + .research(120) + .tree(3, TechTree.PRICE_L3, METAL_BLADE) + .admin(true)); + define(ItemType.of(TIMED_CHARGE, "Timed Explosive Charge") + .rid("timedexplosivecharge") + .bench(3) + .known(false) + .research(120) + .tree(3, TechTree.PRICE_L3, EXPLOSIVES) + .admin(true)); } private static void define(ItemType.Builder builder) { diff --git a/rsx-protocol/src/main/java/net/rustnotmc/protocol/ItemType.java b/rsx-protocol/src/main/java/net/rustnotmc/protocol/ItemType.java index 83a5682..6953a5a 100644 --- a/rsx-protocol/src/main/java/net/rustnotmc/protocol/ItemType.java +++ b/rsx-protocol/src/main/java/net/rustnotmc/protocol/ItemType.java @@ -5,11 +5,13 @@ package net.rustnotmc.protocol; * * 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. + * how long it lasts, what it takes to make one and how it can be researched. * - * 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. + * The numbers come from the table of {@link ItemData}, which was unpacked from + * the item database of the remake. The registry decides what an item is; the + * table says how much it does. A number that is written in the source wins + * over the table, so a change in the table never silently changes a hand + * written item. */ public final class ItemType { @@ -38,6 +40,9 @@ public final class ItemType { public static final int ARMS = 2; public static final int LEGS = 3; + /** The id of the item in the remake, or null when it has none. */ + public final String rid; + public final int id; public final String name; public final ToolClass tool; @@ -60,17 +65,36 @@ public final class ItemType { /** True when only the administrators of the server may use it. */ public final boolean admin; + /** + * The scrap the research table wants for the blueprint of this item. + * Zero means the item is not researched at all. + */ + public final int researchScrap; + + /** + * The workbench whose tree holds this item. Zero means it is in no tree. + */ + public final int techTreeBench; + + /** The scrap the node of this item in the tree costs. */ + public final int techTreeScrap; + + /** The item that has to be unlocked before this one. */ + public final int techTreeParent; + /** 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) { + public ItemType(String rid, int id, String name, ToolClass tool, int harvestLevel, + int durability, int[] damage, int bench, boolean knownByDefault, + boolean admin, int researchScrap, int techTreeBench, int techTreeScrap, + int techTreeParent, Recipe recipe, Gather[] gathers) { if (damage == null || damage.length != 4) { throw new IllegalArgumentException("the damage needs four body parts"); } + this.rid = rid; this.id = id; this.name = name; this.tool = tool; @@ -80,6 +104,10 @@ public final class ItemType { this.bench = bench; this.knownByDefault = knownByDefault; this.admin = admin; + this.researchScrap = researchScrap; + this.techTreeBench = techTreeBench; + this.techTreeScrap = techTreeScrap; + this.techTreeParent = techTreeParent; this.recipe = recipe; this.gathers = gathers == null ? new Gather[ResourceClass.values().length] : gathers; } @@ -100,6 +128,7 @@ public final class ItemType { private final int id; private final String name; + private String rid; private ToolClass tool = ToolClass.NONE; private int harvestLevel; private int durability; @@ -107,6 +136,10 @@ public final class ItemType { private int bench; private boolean knownByDefault = true; private boolean admin; + private int researchScrap; + private int techTreeBench; + private int techTreeScrap; + private int techTreeParent; private Recipe recipe; private final Gather[] gathers = new Gather[ResourceClass.values().length]; @@ -136,6 +169,17 @@ public final class ItemType { return this; } + /** + * The damage, split over the body the way the remake splits it: the + * head takes twice as much as the torso, the arms and the legs four + * fifths of it. + */ + public Builder damage(int torso) { + this.damage = new int[]{torso * 2, torso, Math.round(torso * 0.8f), + Math.round(torso * 0.8f)}; + return this; + } + public Builder bench(int bench) { this.bench = bench; return this; @@ -153,6 +197,20 @@ public final class ItemType { return this; } + /** The scrap the research table wants for this item. */ + public Builder research(int scrap) { + this.researchScrap = scrap; + return this; + } + + /** The node of this item in the tree of a workbench. */ + public Builder tree(int bench, int scrap, int parent) { + this.techTreeBench = bench; + this.techTreeScrap = scrap; + this.techTreeParent = parent; + return this; + } + public Builder recipe(Recipe recipe) { this.recipe = recipe; return this; @@ -165,10 +223,42 @@ public final class ItemType { return this; } + /** + * Fills the numbers of the item from the table. + * + * The id of the remake is the key: the table has one row per item, and + * the row says how much the item hurts, how long it lasts and how much + * it takes out of a tree or out of a node. Numbers that were written + * in the source are kept. + */ + public Builder rid(String rid) { + this.rid = rid; + ItemData.Row row = ItemData.byRid(rid); + if (row == null) { + throw new IllegalArgumentException("the table has no item " + rid); + } + if (this.damage[HEAD] == 0 && this.damage[TORSO] == 0 && row.damage > 0) { + damage(row.damage); + } + if (this.durability == 0 && row.durability > 0) { + this.durability = row.durability; + } + if (row.wood > 0 && this.gathers[ResourceClass.TREE.ordinal()] == null) { + gather(ResourceClass.TREE, row.wood, 1.0F, 0.3F); + } + if (row.ore > 0 && this.gathers[ResourceClass.STONE.ordinal()] == null) { + gather(ResourceClass.STONE, row.ore, 1.0F, 0.3F); + gather(ResourceClass.METAL, row.ore, 1.0F, 0.3F); + gather(ResourceClass.SULFUR, row.ore, 1.0F, 0.3F); + } + return this; + } + public ItemType build() { - return new ItemType(this.id, this.name, this.tool, this.harvestLevel, + return new ItemType(this.rid, this.id, this.name, this.tool, this.harvestLevel, this.durability, this.damage, this.bench, this.knownByDefault, - this.admin, this.recipe, this.gathers); + this.admin, this.researchScrap, this.techTreeBench, this.techTreeScrap, + this.techTreeParent, this.recipe, this.gathers); } } @@ -192,6 +282,16 @@ public final class ItemType { return this.damage[bodyPart]; } + /** True when the item has to be researched before it can be made. */ + public boolean needsResearch() { + return !this.knownByDefault && this.researchScrap > 0; + } + + /** True when the item sits in a tree of a workbench. */ + public boolean inTechTree() { + return this.techTreeBench > 0; + } + @Override public String toString() { return "ItemType[" + this.id + " " + this.name + "]"; diff --git a/rsx-protocol/src/main/java/net/rustnotmc/protocol/NodeType.java b/rsx-protocol/src/main/java/net/rustnotmc/protocol/NodeType.java new file mode 100644 index 0000000..4166a49 --- /dev/null +++ b/rsx-protocol/src/main/java/net/rustnotmc/protocol/NodeType.java @@ -0,0 +1,133 @@ +package net.rustnotmc.protocol; + +/** + * A thing in the world that can be worn down: a tree, a stone outcrop, a + * metal deposit. + * + * A node is not a block. It has a pool of resource in it, and the pool + * is also its health: every swing takes some of it out, and when the pool is + * empty the node is gone. The last swing gives whatever is left, so a node + * always gives exactly its pool. + * + * A node can also have a mark: a cross on a tree, a hot spot on an + * outcrop. A swing that hits the mark does more, and every swing that follows + * it does a little more still, up to a limit. Missing the mark resets the + * bonus. + */ +public final class NodeType { + + /** The id of nothing. */ + public static final int NOTHING = 0; + + /** A poor tree: 500 wood. */ + public static final int TREE_POOR = 1; + + /** An ordinary tree: 750 wood. */ + public static final int TREE_COMMON = 2; + + /** A dead tree: 1000 wood. */ + public static final int TREE_DEAD = 3; + + /** A stone outcrop: 750 stones. */ + public static final int STONE = 4; + + /** A metal deposit: 500 metal. */ + public static final int METAL = 5; + + /** A sulfur deposit: 200 sulfur. */ + public static final int SULFUR = 6; + + public final int id; + public final String name; + public final ResourceClass resource; + + /** What the node gives when it is worn out. */ + public final int dropItem; + + /** How much is in it. It is also the number of points of health. */ + public final int pool; + + /** How much a marked swing adds to the damage, again and again. */ + public final float markerStep; + + /** The limit of the mark. One means the node has no mark at all. */ + public final float markerMax; + + public NodeType(int id, String name, ResourceClass resource, int dropItem, int pool, + float markerStep, float markerMax) { + this.id = id; + this.name = name; + this.resource = resource; + this.dropItem = dropItem; + this.pool = pool; + this.markerStep = markerStep; + this.markerMax = markerMax; + } + + /** True when the node has a mark that a swing can hit. */ + public boolean hasMarker() { + return this.markerMax > 1.0F; + } + + /** + * The damage of a swing, with the mark taken into account. + * + * @param damage what the tool takes without a mark + * @param marked how many marked swings in a row there were + */ + public int swingDamage(int damage, int marked) { + if (!hasMarker() || marked <= 0) { + return damage; + } + float multiplier = 1.0F + this.markerStep * marked; + if (multiplier > this.markerMax) { + multiplier = this.markerMax; + } + return Math.round(damage * multiplier); + } + + private static final java.util.List TYPES = new java.util.ArrayList(); + + static { + add(new NodeType(NOTHING, "Nothing", ResourceClass.NONE, ItemRegistry.NOTHING, 0, + 1.0F, 1.0F)); + // A tree: the mark is a cross on the trunk, and it gives at most twice. + add(new NodeType(TREE_POOR, "Poor Tree", ResourceClass.TREE, ItemRegistry.WOOD, 500, + 0.125F, 2.0F)); + add(new NodeType(TREE_COMMON, "Tree", ResourceClass.TREE, ItemRegistry.WOOD, 750, + 0.125F, 2.0F)); + add(new NodeType(TREE_DEAD, "Dead Tree", ResourceClass.TREE, ItemRegistry.WOOD, 1000, + 0.125F, 2.0F)); + // An outcrop: the mark is a hot spot, and it gives at most three times. + add(new NodeType(STONE, "Stone Outcrop", ResourceClass.STONE, ItemRegistry.STONE, 750, + 0.5F, 3.0F)); + add(new NodeType(METAL, "Metal Deposit", ResourceClass.METAL, ItemRegistry.METAL, 500, + 0.5F, 3.0F)); + add(new NodeType(SULFUR, "Sulfur Deposit", ResourceClass.SULFUR, ItemRegistry.SULFUR, 200, + 0.5F, 3.0F)); + } + + private static void add(NodeType type) { + TYPES.add(type); + } + + /** The node with that id, or null. */ + public static NodeType byId(int id) { + for (int i = 0; i < TYPES.size(); i++) { + if (TYPES.get(i).id == id) { + return TYPES.get(i); + } + } + return null; + } + + /** Every node type. */ + public static java.util.List all() { + return java.util.Collections.unmodifiableList(TYPES); + } + + @Override + public String toString() { + return "NodeType[" + this.id + " " + this.name + " " + this.pool + "]"; + } +} 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 6c9a45c..d49b420 100644 --- a/rsx-protocol/src/main/java/net/rustnotmc/protocol/Packets.java +++ b/rsx-protocol/src/main/java/net/rustnotmc/protocol/Packets.java @@ -113,6 +113,42 @@ public final class Packets { /** One slot of the inventory, when it changes. */ public static final int INVENTORY_SLOT = 0x1D; + /** The client starts or stops digging. */ + public static final int DIG = 0x1E; + + /** How far the digging has come, for the cracks. */ + public static final int BREAK_PROGRESS = 0x1F; + + /** The client asks to make something. */ + public static final int CRAFT_START = 0x20; + + /** What the server says about a craft. */ + public static final int CRAFT_UPDATE = 0x21; + + /** The client asks to research an item at the research table. */ + public static final int RESEARCH_START = 0x22; + + /** What the server says about a research. */ + public static final int RESEARCH_UPDATE = 0x23; + + /** The client asks to open a node of the tree of a workbench. */ + public static final int TECH_TREE_UNLOCK = 0x24; + + /** The recipes the player knows. */ + public static final int KNOWN_RECIPES = 0x25; + + /** The nodes of the trees the player has opened. */ + public static final int TECH_TREE_STATE = 0x26; + + /** A container the player may look into. */ + public static final int CONTAINER_OPEN = 0x27; + + /** The client takes something out of a container. */ + public static final int CONTAINER_TAKE = 0x28; + + /** The client learns a blueprint. */ + public static final int LEARN_BLUEPRINT = 0x29; + /** * One slot of the inventory and what is in it now. * @@ -948,6 +984,394 @@ public final class Packets { } } + /** + * The client starts or stops digging. + * + * The client does not decide anything: it says where it is looking and + * whether the button is down. The server works out what happens there, + * how long it takes and who is allowed to do it. + */ + public static final class Dig implements Packet { + + /** The button went down. */ + public static final int START = 0; + + /** The button came up. */ + public static final int STOP = 1; + + public final int action; + public final int x; + public final int y; + public final int z; + public final int face; + + public Dig(int action, int x, int y, int z, int face) { + this.action = action; + this.x = x; + this.y = y; + this.z = z; + this.face = face; + } + + public boolean start() { + return this.action == START; + } + + public Buf write() { + Buf buf = new Buf(24); + buf.writeVarInt(this.action); + buf.writeVarInt(this.x); + buf.writeVarInt(this.y); + buf.writeVarInt(this.z); + buf.writeVarInt(this.face); + return buf; + } + + public static Dig read(Buf in) { + return new Dig(in.readVarInt(), in.readVarInt(), in.readVarInt(), + in.readVarInt(), in.readVarInt()); + } + } + + /** + * How far the digging has come, so that everybody sees the cracks. + * + * The stage goes from zero to nine. A stage of minus one means that + * nothing is being dug at that place. + */ + public static final class BreakProgress implements Packet { + + /** The stage of the cracks, from zero to nine. */ + public static final int STAGES = 10; + + public final int x; + public final int y; + public final int z; + public final int stage; + + public BreakProgress(int x, int y, int z, int stage) { + this.x = x; + this.y = y; + this.z = z; + this.stage = stage; + } + + public Buf write() { + Buf buf = new Buf(24); + buf.writeVarInt(this.x); + buf.writeVarInt(this.y); + buf.writeVarInt(this.z); + buf.writeVarInt(this.stage); + return buf; + } + + public static BreakProgress read(Buf in) { + return new BreakProgress(in.readVarInt(), in.readVarInt(), in.readVarInt(), + in.readVarInt()); + } + } + + /** + * The client asks to make something. + * + * The server looks the recipe up, checks the workbench and the knowledge + * of the player, and answers with a {@link CraftUpdate}. + */ + public static final class CraftStart implements Packet { + + public final int item; + public final int count; + + public CraftStart(int item, int count) { + this.item = item; + this.count = count; + } + + public Buf write() { + Buf buf = new Buf(8); + buf.writeVarInt(this.item); + buf.writeVarInt(this.count); + return buf; + } + + public static CraftStart read(Buf in) { + return new CraftStart(in.readVarInt(), in.readVarInt()); + } + } + + /** + * What the server says about a craft. + * + * The seconds left go down while the work goes on. When it reaches zero + * the item is in the inventory. A reason that is not empty means that + * nothing was made, and says why. + */ + public static final class CraftUpdate implements Packet { + + public final int item; + public final int count; + public final int secondsLeft; + public final String reason; + + public CraftUpdate(int item, int count, int secondsLeft, String reason) { + this.item = item; + this.count = count; + this.secondsLeft = secondsLeft; + this.reason = reason == null ? "" : reason; + } + + public boolean ok() { + return this.reason.isEmpty(); + } + + public Buf write() { + Buf buf = new Buf(64); + buf.writeVarInt(this.item); + buf.writeVarInt(this.count); + buf.writeVarInt(this.secondsLeft); + buf.writeString(this.reason); + return buf; + } + + public static CraftUpdate read(Buf in) { + return new CraftUpdate(in.readVarInt(), in.readVarInt(), in.readVarInt(), + in.readString(MAX_TEXT)); + } + } + + /** + * The client asks to research an item at the research table. + * + * The server checks that the player has the item and the scrap, takes + * both and answers with a {@link ResearchUpdate}. + */ + public static final class ResearchStart implements Packet { + + public final int item; + + public ResearchStart(int item) { + this.item = item; + } + + public Buf write() { + Buf buf = new Buf(8); + buf.writeVarInt(this.item); + return buf; + } + + public static ResearchStart read(Buf in) { + return new ResearchStart(in.readVarInt()); + } + } + + /** What the server says about a research. */ + public static final class ResearchUpdate implements Packet { + + public final int item; + public final int secondsLeft; + public final String reason; + + public ResearchUpdate(int item, int secondsLeft, String reason) { + this.item = item; + this.secondsLeft = secondsLeft; + this.reason = reason == null ? "" : reason; + } + + public boolean ok() { + return this.reason.isEmpty(); + } + + public Buf write() { + Buf buf = new Buf(64); + buf.writeVarInt(this.item); + buf.writeVarInt(this.secondsLeft); + buf.writeString(this.reason); + return buf; + } + + public static ResearchUpdate read(Buf in) { + return new ResearchUpdate(in.readVarInt(), in.readVarInt(), in.readString(MAX_TEXT)); + } + } + + /** + * The client asks to open a node of the tree of a workbench. + * + * The server checks that the branch is open and that there is enough + * scrap, and takes the scrap. + */ + public static final class TechTreeUnlock implements Packet { + + public final int item; + + public TechTreeUnlock(int item) { + this.item = item; + } + + public Buf write() { + Buf buf = new Buf(8); + buf.writeVarInt(this.item); + return buf; + } + + public static TechTreeUnlock read(Buf in) { + return new TechTreeUnlock(in.readVarInt()); + } + } + + /** The recipes the player knows. */ + public static final class KnownRecipes implements Packet { + + public final int[] items; + + public KnownRecipes(int[] items) { + this.items = items; + } + + public Buf write() { + Buf buf = new Buf(8 + this.items.length * 4); + buf.writeVarInt(this.items.length); + for (int i = 0; i < this.items.length; i++) { + buf.writeVarInt(this.items[i]); + } + return buf; + } + + public static KnownRecipes read(Buf in) { + int count = in.readVarInt(); + if (count < 0 || count > 4096) { + throw new ProtocolException("that many recipes is not real"); + } + int[] items = new int[count]; + for (int i = 0; i < count; i++) { + items[i] = in.readVarInt(); + } + return new KnownRecipes(items); + } + } + + /** The nodes of the trees the player has opened. */ + public static final class TechTreeState implements Packet { + + public final int[] items; + + public TechTreeState(int[] items) { + this.items = items; + } + + public Buf write() { + Buf buf = new Buf(8 + this.items.length * 4); + buf.writeVarInt(this.items.length); + for (int i = 0; i < this.items.length; i++) { + buf.writeVarInt(this.items[i]); + } + return buf; + } + + public static TechTreeState read(Buf in) { + int count = in.readVarInt(); + if (count < 0 || count > 4096) { + throw new ProtocolException("that many nodes is not real"); + } + int[] items = new int[count]; + for (int i = 0; i < count; i++) { + items[i] = in.readVarInt(); + } + return new TechTreeState(items); + } + } + + /** + * A container the player may look into: a crate of food on a junk pile, + * a chest, a barrel. + */ + public static final class ContainerOpen implements Packet { + + public final int id; + public final String title; + public final Slot[] slots; + + public ContainerOpen(int id, String title, Slot[] slots) { + this.id = id; + this.title = title == null ? "" : title; + this.slots = slots; + } + + public Buf write() { + Buf buf = new Buf(64 + this.slots.length * 12); + buf.writeVarInt(this.id); + buf.writeString(this.title); + 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 ContainerOpen read(Buf in) { + int id = in.readVarInt(); + String title = in.readString(MAX_TEXT); + int count = in.readVarInt(); + if (count < 0 || count > 1024) { + throw new ProtocolException("a container 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 ContainerOpen(id, title, slots); + } + } + + /** The client takes something out of a container. */ + public static final class ContainerTake implements Packet { + + public final int id; + public final int slot; + + public ContainerTake(int id, int slot) { + this.id = id; + this.slot = slot; + } + + public Buf write() { + Buf buf = new Buf(8); + buf.writeVarInt(this.id); + buf.writeVarInt(this.slot); + return buf; + } + + public static ContainerTake read(Buf in) { + return new ContainerTake(in.readVarInt(), in.readVarInt()); + } + } + + /** + * The client asks to learn a blueprint. + * + * A blueprint is the item with a mark on it. Learning it uses it up and + * opens the recipe for good. + */ + public static final class LearnBlueprint implements Packet { + + public final int item; + + public LearnBlueprint(int item) { + this.item = item; + } + + public Buf write() { + Buf buf = new Buf(8); + buf.writeVarInt(this.item); + return buf; + } + + public static LearnBlueprint read(Buf in) { + return new LearnBlueprint(in.readVarInt()); + } + } + /** * Every packet id this build knows, and what it decodes into. * @@ -974,6 +1398,20 @@ public final class Packets { map.put(Integer.valueOf(ENTITY_REMOVE), "EntityRemove"); map.put(Integer.valueOf(LAUNCHER_AUTH), "LauncherAuth"); map.put(Integer.valueOf(SET_GAME_MODE), "GameMode"); + map.put(Integer.valueOf(INVENTORY), "Inventory"); + map.put(Integer.valueOf(INVENTORY_SLOT), "SlotChange"); + map.put(Integer.valueOf(DIG), "Dig"); + map.put(Integer.valueOf(BREAK_PROGRESS), "BreakProgress"); + map.put(Integer.valueOf(CRAFT_START), "CraftStart"); + map.put(Integer.valueOf(CRAFT_UPDATE), "CraftUpdate"); + map.put(Integer.valueOf(RESEARCH_START), "ResearchStart"); + map.put(Integer.valueOf(RESEARCH_UPDATE), "ResearchUpdate"); + map.put(Integer.valueOf(TECH_TREE_UNLOCK), "TechTreeUnlock"); + map.put(Integer.valueOf(KNOWN_RECIPES), "KnownRecipes"); + map.put(Integer.valueOf(TECH_TREE_STATE), "TechTreeState"); + map.put(Integer.valueOf(CONTAINER_OPEN), "ContainerOpen"); + map.put(Integer.valueOf(CONTAINER_TAKE), "ContainerTake"); + map.put(Integer.valueOf(LEARN_BLUEPRINT), "LearnBlueprint"); map.put(Integer.valueOf(DISCONNECT), "Disconnect"); return map; } diff --git a/rsx-protocol/src/main/java/net/rustnotmc/protocol/TechTree.java b/rsx-protocol/src/main/java/net/rustnotmc/protocol/TechTree.java new file mode 100644 index 0000000..6520223 --- /dev/null +++ b/rsx-protocol/src/main/java/net/rustnotmc/protocol/TechTree.java @@ -0,0 +1,133 @@ +package net.rustnotmc.protocol; + +import java.util.ArrayList; +import java.util.Collections; +import java.util.List; + +/** + * The trees of the workbenches. + * + * Every workbench has its own tree, and a node in it costs scrap. To open a + * node the one before it has to be open: that is what makes a tree a path and + * not a list. Unlocking a node is personal, it cannot be given away, and the + * item it opens needs no blueprint afterwards. + * + * The prices come from the wiki of the original game: 125 scrap for the tree + * of the first workbench, 250 for the second, 500 for the third and 250 for + * the engineering workbench. + */ +public final class TechTree { + + /** One node of a tree. */ + public static final class Node { + + /** The item this node opens. */ + public final int item; + + /** The item that has to be open first, or the id of nothing. */ + public final int parent; + + /** The workbench whose tree this node is in. */ + public final int bench; + + /** What the node costs. */ + public final int scrap; + + public Node(int item, int parent, int bench, int scrap) { + this.item = item; + this.parent = parent; + this.bench = bench; + this.scrap = scrap; + } + } + + /** The price of a node in the tree of the first workbench. */ + public static final int PRICE_L1 = 125; + + /** The price of a node in the tree of the second workbench. */ + public static final int PRICE_L2 = 250; + + /** The price of a node in the tree of the third workbench. */ + public static final int PRICE_L3 = 500; + + /** The price of a node in the tree of the engineering workbench. */ + public static final int PRICE_ENGINEERING = 250; + + private static final List NODES = new ArrayList(); + + static { + // The tree of the first workbench: the tools, one after another. + add(ItemRegistry.SALVAGED_HAMMER, ItemRegistry.NOTHING, 1, PRICE_L1); + add(ItemRegistry.HATCHET, ItemRegistry.SALVAGED_HAMMER, 1, PRICE_L1); + add(ItemRegistry.PICKAXE, ItemRegistry.SALVAGED_HAMMER, 1, PRICE_L1); + add(ItemRegistry.SALVAGED_SWORD, ItemRegistry.HATCHET, 1, PRICE_L1); + add(ItemRegistry.SALVAGED_CLEAVER, ItemRegistry.HATCHET, 1, PRICE_L1); + // The tree of the second workbench: the salvage tools and the machine. + add(ItemRegistry.SALVAGED_AXE, ItemRegistry.SALVAGED_HAMMER, 2, PRICE_L2); + add(ItemRegistry.SALVAGED_ICEPICK, ItemRegistry.SALVAGED_HAMMER, 2, PRICE_L2); + add(ItemRegistry.METAL_BLADE, ItemRegistry.SALVAGED_AXE, 2, PRICE_L2); + add(ItemRegistry.GARAGE_DOOR, ItemRegistry.GEARS, 2, PRICE_L2); + // The tree of the third workbench: the heavy stuff. + add(ItemRegistry.GEARS, ItemRegistry.METAL_BLADE, 3, PRICE_L3); + add(ItemRegistry.METAL_PIPE, ItemRegistry.GEARS, 3, PRICE_L3); + add(ItemRegistry.ARMORED_DOOR, ItemRegistry.METAL_PIPE, 3, PRICE_L3); + add(ItemRegistry.EXPLOSIVES, ItemRegistry.METAL_BLADE, 3, PRICE_L3); + add(ItemRegistry.TIMED_CHARGE, ItemRegistry.EXPLOSIVES, 3, PRICE_L3); + } + + private static void add(int item, int parent, int bench, int scrap) { + NODES.add(new Node(item, parent, bench, scrap)); + } + + private TechTree() { + } + + /** Every node of every tree. */ + public static List nodes() { + return Collections.unmodifiableList(NODES); + } + + /** The node that opens an item, or null when the item is in no tree. */ + public static Node forItem(int item) { + for (int i = 0; i < NODES.size(); i++) { + if (NODES.get(i).item == item) { + return NODES.get(i); + } + } + return null; + } + + /** What it costs to open an item, or zero when the item is in no tree. */ + public static int costOf(int item) { + Node node = forItem(item); + return node == null ? 0 : node.scrap; + } + + /** + * Whether the item can be opened with what is open already. + * + * A node needs its parent, and the parent needs its own parent, and so on + * down to the root of the branch. + */ + public static boolean canOpen(int item, java.util.Set unlocked) { + Node node = forItem(item); + if (node == null) { + return false; + } + if (unlocked.contains(Integer.valueOf(item))) { + return true; + } + int parent = node.parent; + while (parent != ItemRegistry.NOTHING) { + if (!unlocked.contains(Integer.valueOf(parent))) { + return false; + } + Node above = forItem(parent); + if (above == null) { + return false; + } + parent = above.parent; + } + return true; + } +} diff --git a/rsx-protocol/src/test/java/net/rustnotmc/protocol/ItemTest.java b/rsx-protocol/src/test/java/net/rustnotmc/protocol/ItemTest.java new file mode 100644 index 0000000..13abf96 --- /dev/null +++ b/rsx-protocol/src/test/java/net/rustnotmc/protocol/ItemTest.java @@ -0,0 +1,353 @@ +package net.rustnotmc.protocol; + +import java.util.ArrayList; +import java.util.HashSet; +import java.util.List; +import java.util.Set; + +/** + * Tests for the item table, the registry, the trees and the nodes. + * + * The numbers here come from the table of the remake. A test that pins them + * down is what stops a change in the table from quietly changing the game: + * when the numbers move, the test says so. + */ +public final class ItemTest { + + private static int checks; + private static int failed; + private static final List failures = new ArrayList(); + + public static void main(String[] args) { + run("the table is read", new Runnable() { + public void run() { testTableLoads(); } + }); + run("the table holds the numbers of the remake", new Runnable() { + public void run() { testTableNumbers(); } + }); + run("every item of the registry is in the table", new Runnable() { + public void run() { testRegistryMatchesTable(); } + }); + run("the tool takes the numbers of the table", new Runnable() { + public void run() { testToolNumbers(); } + }); + run("the damage is split over the body", new Runnable() { + public void run() { testDamageSplit(); } + }); + run("the trees cost what the plan says", new Runnable() { + public void run() { testTreePrices(); } + }); + run("a branch has to be walked", new Runnable() { + public void run() { testTreeParents(); } + }); + run("the nodes hold what the plan says", new Runnable() { + public void run() { testNodePools(); } + }); + run("the mark of a node grows and stops", new Runnable() { + public void run() { testNodeMarkers(); } + }); + run("the new packets survive the round trip", new Runnable() { + public void run() { testPackets(); } + }); + + System.out.println(""); + if (failed == 0) { + System.out.println(" " + checks + " checks passed."); + return; + } + System.out.println(" " + failed + " of " + checks + " checks FAILED:"); + for (int i = 0; i < failures.size(); i++) { + System.out.println(" " + failures.get(i)); + } + System.exit(1); + } + + private static void testTableLoads() { + yes("the table is not empty", ItemData.size() > 200); + ItemData.Row rock = ItemData.byRid("rock"); + yes("the rock is in the table", rock != null); + if (rock != null) { + eq("the rock hurts ten", Integer.valueOf(10), Integer.valueOf(rock.damage)); + eq("the rock takes fifty wood", Integer.valueOf(50), Integer.valueOf(rock.wood)); + eq("the rock takes thirty five ore", Integer.valueOf(35), Integer.valueOf(rock.ore)); + eq("the rock lasts a hundred", Integer.valueOf(100), Integer.valueOf(rock.durability)); + eq("the rock swings every two seconds", Integer.valueOf(40), + Integer.valueOf(rock.cooldown)); + } + ItemData.Row hatchet = ItemData.byRid("ironaxe"); + yes("the hatchet is in the table", hatchet != null); + if (hatchet != null) { + eq("the hatchet takes eighty five wood", Integer.valueOf(85), + Integer.valueOf(hatchet.wood)); + eq("the hatchet hurts twenty five", Integer.valueOf(25), + Integer.valueOf(hatchet.damage)); + eq("the hatchet lasts three hundred", Integer.valueOf(300), + Integer.valueOf(hatchet.durability)); + } + } + + private static void testTableNumbers() { + // The numbers the plan writes down have to be the numbers the table + // holds, otherwise the two drift apart. + eq("the tree of a poor one is five hundred", Integer.valueOf(500), + Integer.valueOf(NodeType.byId(NodeType.TREE_POOR).pool)); + eq("the tree of an ordinary one is seven hundred fifty", Integer.valueOf(750), + Integer.valueOf(NodeType.byId(NodeType.TREE_COMMON).pool)); + eq("a dead tree is a thousand", Integer.valueOf(1000), + Integer.valueOf(NodeType.byId(NodeType.TREE_DEAD).pool)); + eq("a stone outcrop is seven hundred fifty", Integer.valueOf(750), + Integer.valueOf(NodeType.byId(NodeType.STONE).pool)); + eq("a metal deposit is five hundred", Integer.valueOf(500), + Integer.valueOf(NodeType.byId(NodeType.METAL).pool)); + eq("a sulfur deposit is two hundred", Integer.valueOf(200), + Integer.valueOf(NodeType.byId(NodeType.SULFUR).pool)); + } + + private static void testRegistryMatchesTable() { + int withRid = 0; + for (ItemType type : ItemRegistry.all()) { + if (type.rid == null) { + continue; + } + withRid++; + yes("the table knows " + type.rid, ItemData.byRid(type.rid) != null); + } + yes("most items come from the table", withRid > 90); + // And the other way round: the table may know more than we do, but + // everything it knows that we also know has to be the same item. + for (ItemType type : ItemRegistry.all()) { + if (type.rid == null) { + continue; + } + ItemData.Row row = ItemData.byRid(type.rid); + if (row == null) { + continue; + } + if (row.damage > 0) { + eq("the damage of " + type.rid, Integer.valueOf(row.damage), + Integer.valueOf(type.damage(ItemType.TORSO))); + } + if (row.durability > 0) { + eq("the durability of " + type.rid, Integer.valueOf(row.durability), + Integer.valueOf(type.durability)); + } + } + } + + private static void testToolNumbers() { + ItemType rock = ItemRegistry.require(ItemRegistry.ROCK); + yes("the rock gathers wood", rock.gather(ResourceClass.TREE) != null); + eq("the rock takes fifty wood a swing", Integer.valueOf(50), + Integer.valueOf(rock.gather(ResourceClass.TREE).damage)); + eq("the rock takes thirty five ore a swing", Integer.valueOf(35), + Integer.valueOf(rock.gather(ResourceClass.STONE).damage)); + yes("the rock has no pickaxe", rock.gather(ResourceClass.TREE) != null); + + ItemType hatchet = ItemRegistry.require(ItemRegistry.HATCHET); + eq("the hatchet takes eighty five wood", Integer.valueOf(85), + Integer.valueOf(hatchet.gather(ResourceClass.TREE).damage)); + yes("the hatchet does not mine", hatchet.gather(ResourceClass.STONE) == null); + + ItemType pickaxe = ItemRegistry.require(ItemRegistry.PICKAXE); + eq("the pickaxe takes a hundred ore", Integer.valueOf(100), + Integer.valueOf(pickaxe.gather(ResourceClass.STONE).damage)); + yes("the pickaxe does not cut", pickaxe.gather(ResourceClass.TREE) == null); + } + + private static void testDamageSplit() { + ItemType rock = ItemRegistry.require(ItemRegistry.ROCK); + eq("the head takes twice", Integer.valueOf(20), + Integer.valueOf(rock.damage(ItemType.HEAD))); + eq("the torso takes the number", Integer.valueOf(10), + Integer.valueOf(rock.damage(ItemType.TORSO))); + eq("the arms take four fifths", Integer.valueOf(8), + Integer.valueOf(rock.damage(ItemType.ARMS))); + eq("the legs take four fifths", Integer.valueOf(8), + Integer.valueOf(rock.damage(ItemType.LEGS))); + } + + private static void testTreePrices() { + eq("the tree of the first workbench costs a hundred twenty five", + Integer.valueOf(125), Integer.valueOf(TechTree.PRICE_L1)); + eq("the tree of the second workbench costs two hundred fifty", + Integer.valueOf(250), Integer.valueOf(TechTree.PRICE_L2)); + eq("the tree of the third workbench costs five hundred", + Integer.valueOf(500), Integer.valueOf(TechTree.PRICE_L3)); + eq("the hatchet costs a hundred twenty five", Integer.valueOf(125), + Integer.valueOf(TechTree.costOf(ItemRegistry.HATCHET))); + eq("the hatchet sits in the tree of the first workbench", Integer.valueOf(1), + Integer.valueOf(TechTree.forItem(ItemRegistry.HATCHET).bench)); + eq("the salvaged axe sits in the tree of the second workbench", Integer.valueOf(2), + Integer.valueOf(TechTree.forItem(ItemRegistry.SALVAGED_AXE).bench)); + eq("the armored door sits in the tree of the third workbench", Integer.valueOf(3), + Integer.valueOf(TechTree.forItem(ItemRegistry.ARMORED_DOOR).bench)); + eq("the rock is in no tree", Integer.valueOf(0), + Integer.valueOf(TechTree.costOf(ItemRegistry.ROCK))); + } + + private static void testTreeParents() { + Set nothing = new HashSet(); + yes("the root of a branch can be opened", + TechTree.canOpen(ItemRegistry.SALVAGED_HAMMER, nothing)); + yes("the next one cannot", + !TechTree.canOpen(ItemRegistry.HATCHET, nothing)); + + Set withHammer = new HashSet(); + withHammer.add(Integer.valueOf(ItemRegistry.SALVAGED_HAMMER)); + yes("with the hammer the hatchet opens", + TechTree.canOpen(ItemRegistry.HATCHET, withHammer)); + yes("with the hammer the sword does not", + !TechTree.canOpen(ItemRegistry.SALVAGED_SWORD, withHammer)); + + Set withHatchet = new HashSet(withHammer); + withHatchet.add(Integer.valueOf(ItemRegistry.HATCHET)); + yes("with the hatchet the sword opens", + TechTree.canOpen(ItemRegistry.SALVAGED_SWORD, withHatchet)); + } + + private static void testNodePools() { + eq("the ordinary tree holds seven hundred fifty", Integer.valueOf(750), + Integer.valueOf(NodeType.byId(NodeType.TREE_COMMON).pool)); + eq("the tree gives wood", Integer.valueOf(ItemRegistry.WOOD), + Integer.valueOf(NodeType.byId(NodeType.TREE_COMMON).dropItem)); + eq("the outcrop gives stones", Integer.valueOf(ItemRegistry.STONE), + Integer.valueOf(NodeType.byId(NodeType.STONE).dropItem)); + eq("the deposit gives metal", Integer.valueOf(ItemRegistry.METAL), + Integer.valueOf(NodeType.byId(NodeType.METAL).dropItem)); + eq("the sulfur deposit gives sulfur", Integer.valueOf(ItemRegistry.SULFUR), + Integer.valueOf(NodeType.byId(NodeType.SULFUR).dropItem)); + yes("a tree has a cross", NodeType.byId(NodeType.TREE_COMMON).hasMarker()); + yes("an outcrop has a hot spot", NodeType.byId(NodeType.STONE).hasMarker()); + } + + private static void testNodeMarkers() { + NodeType tree = NodeType.byId(NodeType.TREE_COMMON); + eq("a swing without the cross is the plain one", Integer.valueOf(85), + Integer.valueOf(tree.swingDamage(85, 0))); + eq("the first cross adds an eighth", Integer.valueOf(96), + Integer.valueOf(tree.swingDamage(85, 1))); + eq("the cross stops at twice", Integer.valueOf(170), + Integer.valueOf(tree.swingDamage(85, 100))); + + NodeType ore = NodeType.byId(NodeType.STONE); + eq("the first hot spot adds half", Integer.valueOf(75), + Integer.valueOf(ore.swingDamage(50, 1))); + eq("the hot spot stops at three times", Integer.valueOf(150), + Integer.valueOf(ore.swingDamage(50, 100))); + } + + private static void testPackets() { + Packets.Dig dig = new Packets.Dig(Packets.Dig.START, 10, -60, 30, 2); + Packets.Dig digBack = Packets.Dig.read(new Buf(dig.write().toByteArray())); + eq("the action survives", Integer.valueOf(Packets.Dig.START), + Integer.valueOf(digBack.action)); + eq("the place survives", Integer.valueOf(10), Integer.valueOf(digBack.x)); + eq("the face survives", Integer.valueOf(2), Integer.valueOf(digBack.face)); + yes("a start is a start", digBack.start()); + + Packets.BreakProgress progress = new Packets.BreakProgress(1, 2, 3, 7); + Packets.BreakProgress progressBack = + Packets.BreakProgress.read(new Buf(progress.write().toByteArray())); + eq("the stage survives", Integer.valueOf(7), Integer.valueOf(progressBack.stage)); + eq("the place survives", Integer.valueOf(3), Integer.valueOf(progressBack.z)); + + Packets.CraftUpdate craft = new Packets.CraftUpdate(ItemRegistry.HATCHET, 1, 12, ""); + Packets.CraftUpdate craftBack = + Packets.CraftUpdate.read(new Buf(craft.write().toByteArray())); + eq("the item survives", Integer.valueOf(ItemRegistry.HATCHET), + Integer.valueOf(craftBack.item)); + eq("the seconds survive", Integer.valueOf(12), Integer.valueOf(craftBack.secondsLeft)); + yes("an empty reason means fine", craftBack.ok()); + + Packets.CraftUpdate refused = new Packets.CraftUpdate(0, 0, 0, "no scrap"); + Packets.CraftUpdate refusedBack = + Packets.CraftUpdate.read(new Buf(refused.write().toByteArray())); + yes("a reason means no", !refusedBack.ok()); + eq("the reason survives", "no scrap", refusedBack.reason); + + Packets.ResearchUpdate research = + new Packets.ResearchUpdate(ItemRegistry.PICKAXE, 10, ""); + Packets.ResearchUpdate researchBack = + Packets.ResearchUpdate.read(new Buf(research.write().toByteArray())); + eq("the researched item survives", Integer.valueOf(ItemRegistry.PICKAXE), + Integer.valueOf(researchBack.item)); + yes("a research that works has no reason", researchBack.ok()); + + Packets.TechTreeUnlock unlock = new Packets.TechTreeUnlock(ItemRegistry.HATCHET); + Packets.TechTreeUnlock unlockBack = + Packets.TechTreeUnlock.read(new Buf(unlock.write().toByteArray())); + eq("the node survives", Integer.valueOf(ItemRegistry.HATCHET), + Integer.valueOf(unlockBack.item)); + + Packets.KnownRecipes known = new Packets.KnownRecipes(new int[]{1, 2, 3}); + Packets.KnownRecipes knownBack = + Packets.KnownRecipes.read(new Buf(known.write().toByteArray())); + eq("the recipes survive", Integer.valueOf(3), + Integer.valueOf(knownBack.items.length)); + eq("the last recipe survives", Integer.valueOf(3), + Integer.valueOf(knownBack.items[2])); + + Packets.TechTreeState state = new Packets.TechTreeState(new int[]{18, 16}); + Packets.TechTreeState stateBack = + Packets.TechTreeState.read(new Buf(state.write().toByteArray())); + eq("the opened nodes survive", Integer.valueOf(2), + Integer.valueOf(stateBack.items.length)); + + Packets.Slot[] slots = new Packets.Slot[]{ + new Packets.Slot(ItemRegistry.SCRAP, 4, -1), + Packets.Slot.of(null)}; + Packets.ContainerOpen open = new Packets.ContainerOpen(9, "Food", slots); + Packets.ContainerOpen openBack = + Packets.ContainerOpen.read(new Buf(open.write().toByteArray())); + eq("the title survives", "Food", openBack.title); + eq("the slots survive", Integer.valueOf(2), Integer.valueOf(openBack.slots.length)); + eq("what is in the slot survives", Integer.valueOf(ItemRegistry.SCRAP), + Integer.valueOf(openBack.slots[0].item)); + eq("an empty slot is nothing", Integer.valueOf(ItemRegistry.NOTHING), + Integer.valueOf(openBack.slots[1].item)); + + Packets.ContainerTake take = new Packets.ContainerTake(9, 0); + Packets.ContainerTake takeBack = + Packets.ContainerTake.read(new Buf(take.write().toByteArray())); + eq("the container survives", Integer.valueOf(9), Integer.valueOf(takeBack.id)); + eq("the slot survives", Integer.valueOf(0), Integer.valueOf(takeBack.slot)); + + Packets.LearnBlueprint learn = new Packets.LearnBlueprint(ItemRegistry.PICKAXE); + Packets.LearnBlueprint learnBack = + Packets.LearnBlueprint.read(new Buf(learn.write().toByteArray())); + eq("the blueprint survives", Integer.valueOf(ItemRegistry.PICKAXE), + Integer.valueOf(learnBack.item)); + } + + private static void run(String name, Runnable body) { + checks++; + int before = failed; + try { + body.run(); + } catch (Throwable t) { + failed++; + failures.add(name + ": threw " + t); + t.printStackTrace(); + return; + } + if (failed == before) { + System.out.println(" ok " + name); + } else { + System.out.println(" FAIL " + name); + } + } + + 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 yes(String what, boolean condition) { + if (!condition) { + failed++; + failures.add(what); + } + } +} 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 a9078bd..dc79f72 100644 --- a/rsx-protocol/src/test/java/net/rustnotmc/protocol/ProtocolTest.java +++ b/rsx-protocol/src/test/java/net/rustnotmc/protocol/ProtocolTest.java @@ -545,7 +545,12 @@ public final class ProtocolTest { Packets.WORLD_JOIN, Packets.CHUNK_DATA, Packets.BLOCK_CHANGE, Packets.PLAYER_MOVE, Packets.ENTITY_MOVE, Packets.PING, Packets.PONG, Packets.ENTITY_SPAWN, Packets.ENTITY_REMOVE, Packets.LAUNCHER_AUTH, - Packets.SET_GAME_MODE, + Packets.SET_GAME_MODE, Packets.INVENTORY, Packets.INVENTORY_SLOT, + Packets.DIG, Packets.BREAK_PROGRESS, Packets.CRAFT_START, Packets.CRAFT_UPDATE, + Packets.RESEARCH_START, Packets.RESEARCH_UPDATE, Packets.TECH_TREE_UNLOCK, + Packets.KNOWN_RECIPES, Packets.TECH_TREE_STATE, + Packets.CONTAINER_OPEN, Packets.CONTAINER_TAKE, + Packets.LEARN_BLUEPRINT, Packets.DISCONNECT, }; for (int i = 0; i < ids.length; i++) { @@ -565,9 +570,9 @@ public final class ProtocolTest { 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), + eq("the rock takes fifty off a tree", Integer.valueOf(50), Integer.valueOf(rock.gather(ResourceClass.TREE).damage)); - eq("and five off an ore", Integer.valueOf(5), + eq("and thirty five off an ore", Integer.valueOf(35), Integer.valueOf(rock.gather(ResourceClass.METAL).damage)); eq("the rock is known from the start", Boolean.TRUE, Boolean.valueOf(rock.knownByDefault)); @@ -579,12 +584,12 @@ public final class ProtocolTest { 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), + eq("it takes eighty off a tree", Integer.valueOf(80), 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), + eq("it takes eighty off an ore", Integer.valueOf(80), Integer.valueOf(pickaxe.gather(ResourceClass.METAL).damage)); yes("and it does not cut trees", pickaxe.gather(ResourceClass.TREE) == null); diff --git a/rsx-server-core/scripts/test.ps1 b/rsx-server-core/scripts/test.ps1 index d60b550..fc05bc0 100644 --- a/rsx-server-core/scripts/test.ps1 +++ b/rsx-server-core/scripts/test.ps1 @@ -47,7 +47,9 @@ Say "compiled $($mainFiles.Count) main + $($testFiles.Count) test files" $suites = @( 'net.rustnotmc.server.ServerTest', 'net.rustnotmc.server.world.WorldTest', + 'net.rustnotmc.server.world.NodeTest', 'net.rustnotmc.server.item.InventoryTest', + 'net.rustnotmc.server.craft.CraftTest', '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 921c9c7..1fdd424 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 @@ -9,12 +9,19 @@ import java.util.concurrent.BlockingQueue; import java.util.concurrent.LinkedBlockingQueue; import java.util.concurrent.atomic.AtomicBoolean; import net.rustnotmc.protocol.Buf; +import net.rustnotmc.protocol.Blocks; import net.rustnotmc.protocol.ChunkFormat; import net.rustnotmc.protocol.Frame; import net.rustnotmc.protocol.FrameDecoder; +import net.rustnotmc.protocol.ItemRegistry; +import net.rustnotmc.protocol.ItemType; import net.rustnotmc.protocol.Packet; import net.rustnotmc.protocol.Packets; import net.rustnotmc.protocol.ProtocolException; +import net.rustnotmc.protocol.TechTree; +import net.rustnotmc.server.craft.Crafter; +import net.rustnotmc.server.craft.Knowledge; +import net.rustnotmc.server.craft.ResearchTable; /** * One connection, and the handshake that has to finish before it means @@ -207,6 +214,21 @@ public final class ClientSession implements Runnable { case Packets.BLOCK_CHANGE: onBlockChange(frame); return true; + case Packets.DIG: + onDig(frame); + return true; + case Packets.CRAFT_START: + onCraftStart(frame); + return true; + case Packets.RESEARCH_START: + onResearchStart(frame); + return true; + case Packets.TECH_TREE_UNLOCK: + onTechTreeUnlock(frame); + return true; + case Packets.LEARN_BLUEPRINT: + onLearnBlueprint(frame); + return true; case Packets.PLAYER_MOVE: onPlayerMove(frame); return true; @@ -444,6 +466,12 @@ public final class ClientSession implements Runnable { // 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()); + // and what the player knows, so that the client can grey out the + // recipes that are not known yet + send(Packets.KNOWN_RECIPES, + new Packets.KnownRecipes(this.player.knowledge().knownRecipes())); + send(Packets.TECH_TREE_STATE, + new Packets.TechTreeState(this.player.knowledge().unlockedNodes())); this.player.streamChunks(this.server.world()); } @@ -494,11 +522,156 @@ public final class ClientSession implements Runnable { } } - /** Where the player is now. */ - private void onPlayerMove(Frame frame) { + /** + * The player is digging. + * + * The client says where it is looking and whether the button is down. The + * server decides what happens there: whether the player is close enough, + * whether it is allowed to break that, and how far the work has come. + * + * The nodes of the world are not blocks, so digging at one of them is + * answered by the node and not by the world. That comes with the nodes + * themselves; until then a dig at a block breaks it in the creative mode + * and does nothing in the survival one. + */ + private void onDig(Frame frame) { if (this.state != SessionState.PLAYING || this.player == null) { return; } + Packets.Dig dig = Packets.Dig.read(new Buf(frame.body())); + + if (!dig.start()) { + // the button came up: the cracks go away + send(Packets.BREAK_PROGRESS, new Packets.BreakProgress(dig.x, dig.y, dig.z, -1)); + return; + } + if (!this.player.canReach(dig.x, dig.y, dig.z)) { + send(Packets.BREAK_PROGRESS, new Packets.BreakProgress(dig.x, dig.y, dig.z, -1)); + return; + } + if (this.player.mayBreakBlocks()) { + if (this.server.world().setBlock(dig.x, dig.y, dig.z, Blocks.AIR)) { + this.server.broadcastBlockChange(dig.x, dig.y, dig.z, Blocks.AIR); + } + } + send(Packets.BREAK_PROGRESS, new Packets.BreakProgress(dig.x, dig.y, dig.z, -1)); + } + + /** + * The player wants to make something. + * + * The server checks the recipe, the workbench and the ingredients, and + * says no otherwise. + */ + private void onCraftStart(Frame frame) { + if (this.state != SessionState.PLAYING || this.player == null) { + return; + } + Packets.CraftStart start = Packets.CraftStart.read(new Buf(frame.body())); + ItemType type = ItemRegistry.byId(start.item); + int bench = this.player.benchLevel(); + String problem = Crafter.whyNot(type, this.player.knowledge(), bench, + this.player.inventory()); + if (problem != null) { + send(Packets.CRAFT_UPDATE, new Packets.CraftUpdate(start.item, 0, 0, problem)); + return; + } + int seconds = Crafter.seconds(type, bench); + this.player.startCraft(start.item, seconds); + send(Packets.CRAFT_UPDATE, new Packets.CraftUpdate(start.item, start.count, + seconds, "")); + } + + /** + * The player wants to research an item. + * + * The table takes the scrap at once and the item when the work is done. + */ + private void onResearchStart(Frame frame) { + if (this.state != SessionState.PLAYING || this.player == null) { + return; + } + Packets.ResearchStart start = Packets.ResearchStart.read(new Buf(frame.body())); + ResearchTable table = this.player.research(); + String problem = table.start(start.item, this.player.inventory()); + send(Packets.RESEARCH_UPDATE, new Packets.ResearchUpdate(start.item, + table.secondsLeft(), problem == null ? "" : problem)); + if (problem == null) { + send(Packets.INVENTORY, this.player.inventory().toPacket()); + } + } + + /** + * The player wants to open a node of the tree of a workbench. + * + * The branch has to be open and there has to be enough scrap. The scrap + * is taken and the node opens, and the client is told what the player + * knows now. + */ + private void onTechTreeUnlock(Frame frame) { + if (this.state != SessionState.PLAYING || this.player == null) { + return; + } + Packets.TechTreeUnlock unlock = Packets.TechTreeUnlock.read(new Buf(frame.body())); + Knowledge knowledge = this.player.knowledge(); + String problem = knowledge.reasonCannotUnlock(unlock.item); + if (problem == null) { + int cost = TechTree.costOf(unlock.item); + if (this.player.inventory().count(ItemRegistry.SCRAP) < cost) { + problem = "you are short of scrap"; + } else { + this.player.inventory().take(ItemRegistry.SCRAP, cost); + knowledge.unlock(unlock.item); + } + } + if (problem != null) { + this.server.log("tree: " + problem + " (" + this.account + ")"); + } + send(Packets.TECH_TREE_STATE, new Packets.TechTreeState(knowledge.unlockedNodes())); + send(Packets.KNOWN_RECIPES, new Packets.KnownRecipes(knowledge.knownRecipes())); + send(Packets.INVENTORY, this.player.inventory().toPacket()); + } + + /** + * The player learned a blueprint. + * + * The blueprint is used up and the recipe opens for good. + */ + private void onLearnBlueprint(Frame frame) { + if (this.state != SessionState.PLAYING || this.player == null) { + return; + } + Packets.LearnBlueprint learn = Packets.LearnBlueprint.read(new Buf(frame.body())); + if (!ResearchTable.learn(this.player.inventory(), this.player.knowledge(), + learn.item)) { + return; + } + send(Packets.INVENTORY, this.player.inventory().toPacket()); + send(Packets.KNOWN_RECIPES, + new Packets.KnownRecipes(this.player.knowledge().knownRecipes())); + } + + /** + * How the research of a player is going. + * + * The work goes on even when the player walks away from the table. + */ + void onResearchProgress(int item, int secondsLeft, boolean done) { + if (this.player == null) { + return; + } + send(Packets.RESEARCH_UPDATE, new Packets.ResearchUpdate(item, secondsLeft, "")); + if (done) { + send(Packets.INVENTORY, this.player.inventory().toPacket()); + send(Packets.KNOWN_RECIPES, + new Packets.KnownRecipes(this.player.knowledge().knownRecipes())); + } + } + + /** Where the player is now. */ + private void onPlayerMove(Frame frame) { if (this.state != SessionState.PLAYING || this.player == null) { + return; + } Packets.PlayerMove move = Packets.PlayerMove.read(new Buf(frame.body())); // a position that is not a number would spread through the whole 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 6c0fa0e..5191e33 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 @@ -5,7 +5,10 @@ import java.util.HashSet; import java.util.Iterator; import java.util.Set; import net.rustnotmc.protocol.Blocks; +import net.rustnotmc.protocol.ItemRegistry; import net.rustnotmc.protocol.Packets; +import net.rustnotmc.server.craft.Knowledge; +import net.rustnotmc.server.craft.ResearchTable; import net.rustnotmc.server.item.Inventory; import net.rustnotmc.server.world.Chunk; import net.rustnotmc.server.world.ChunkPos; @@ -53,6 +56,16 @@ public final class Player { /** What the player is carrying. The server owns it. */ private final Inventory inventory; + /** What the player knows: the recipes and the nodes of the trees. */ + private final Knowledge knowledge; + + /** The research table the player is working at, if any. */ + private final ResearchTable research; + + /** What the player is making, and how much of it is left to do. */ + private int craftItem = ItemRegistry.NOTHING; + private int craftSecondsLeft; + Player(ClientSession session, int playerId, int entityId, String account, double[] spawn) { this(session, playerId, entityId, account, spawn, @@ -69,6 +82,8 @@ public final class Player { this.y = spawn[1]; this.z = spawn[2]; this.inventory = inventory; + this.knowledge = new Knowledge(); + this.research = new ResearchTable(); } /** What the player is carrying. */ @@ -76,6 +91,38 @@ public final class Player { return this.inventory; } + /** What the player knows. */ + public Knowledge knowledge() { + return this.knowledge; + } + + /** The research table the player is working at. */ + public ResearchTable research() { + return this.research; + } + + /** What the player is making, or nothing. */ + public int craftItem() { + return this.craftItem; + } + + /** How many seconds are left of the work, or zero. */ + public int craftSecondsLeft() { + return this.craftSecondsLeft; + } + + /** Starts a craft. */ + public void startCraft(int item, int seconds) { + this.craftItem = item; + this.craftSecondsLeft = seconds; + } + + /** Stops the craft that is going on. */ + public void stopCraft() { + this.craftItem = ItemRegistry.NOTHING; + this.craftSecondsLeft = 0; + } + // -------------------------------------------------------------- getters public ClientSession session() { @@ -266,15 +313,35 @@ public final class Player { if (!Blocks.isValid(id)) { return false; } - double dx = (x + 0.5D) - this.x; - double dy = (y + 0.5D) - this.y; - double dz = (z + 0.5D) - this.z; - if (dx * dx + dy * dy + dz * dz > REACH * REACH) { + if (!canReach(x, y, z)) { return false; } return world.setBlock(x, y, z, id); } + /** + * Whether the player is close enough to have plausibly touched a block. + * + * Without this, a client can rewrite the world from the other side of it. + */ + public boolean canReach(int x, int y, int z) { + double dx = (x + 0.5D) - this.x; + double dy = (y + 0.5D) - this.y; + double dz = (z + 0.5D) - this.z; + return dx * dx + dy * dy + dz * dz <= REACH * REACH; + } + + /** + * The level of the workbench the player is standing next to. + * + * Zero means there is none, and then only the things that need no + * workbench can be made. The workbenches that are put down in the world + * come with the places of the world; until then this is always zero. + */ + public int benchLevel() { + return 0; + } + /** Floor division that works for negative numbers, unlike a cast. */ private static int floor(double value) { int truncated = (int) value; diff --git a/rsx-server-core/src/main/java/net/rustnotmc/server/Server.java b/rsx-server-core/src/main/java/net/rustnotmc/server/Server.java index 3cc305b..aba7687 100644 --- a/rsx-server-core/src/main/java/net/rustnotmc/server/Server.java +++ b/rsx-server-core/src/main/java/net/rustnotmc/server/Server.java @@ -321,8 +321,22 @@ public final class Server { } } - /** One server tick. The place the world will live. */ - private void tickOnce() { + /** + * Lets the research of a player go on. + * + * The work is measured in seconds, so it is looked at once a second. + */ + private void tickResearch(Player player) { + net.rustnotmc.server.craft.ResearchTable table = player.research(); + if (!table.busy()) { + return; + } + int item = table.item(); + boolean done = table.tick(player.inventory()); + player.session().onResearchProgress(item, table.secondsLeft(), done); + } + + /** One server tick. The place the world will live. */ private void tickOnce() { this.ticks.incrementAndGet(); this.world.tick(); // chunks nobody has walked near are dropped once a second; the limit @@ -330,6 +344,9 @@ public final class Server { // notice, not often enough to be right if (this.ticks.get() % 20L == 0L) { this.world.evictIdle(); + for (Player player : this.players.values()) { + tickResearch(player); + } } if ((this.ticks.get() & 0x3FFFL) == 0L) { this.tokens.sweep(); diff --git a/rsx-server-core/src/main/java/net/rustnotmc/server/craft/Crafter.java b/rsx-server-core/src/main/java/net/rustnotmc/server/craft/Crafter.java new file mode 100644 index 0000000..c36bef7 --- /dev/null +++ b/rsx-server-core/src/main/java/net/rustnotmc/server/craft/Crafter.java @@ -0,0 +1,111 @@ +package net.rustnotmc.server.craft; + +import net.rustnotmc.protocol.ItemRegistry; +import net.rustnotmc.protocol.ItemType; +import net.rustnotmc.protocol.Recipe; +import net.rustnotmc.server.item.Inventory; + +/** + * Making things. + * + * Three things have to be true before a thing is made: the player knows the + * recipe, there is a workbench of the right level within reach, and the + * ingredients are in the bag. The server checks all three and says no + * otherwise, because a client that decides for itself can be made to make + * anything. + */ +public final class Crafter { + + private Crafter() { + } + + /** + * Why the item cannot be made, or null when it can. + * + * The reasons are words a player can read. + */ + public static String whyNot(ItemType type, Knowledge knowledge, int benchLevel, + Inventory inventory) { + if (type == null) { + return "there is no such thing"; + } + if (type.recipe == null) { + return "it is not made, it is found"; + } + if (type.admin) { + return "only the administrators of the server may have that"; + } + if (!knowledge.knows(type.id)) { + return "you do not know how to make that"; + } + if (type.bench > benchLevel) { + return "you need a workbench of level " + type.bench; + } + for (int i = 0; i < type.recipe.items.length; i++) { + int needed = type.recipe.counts[i]; + if (inventory.count(type.recipe.items[i]) < needed) { + return "you are short of " + nameOf(type.recipe.items[i]); + } + } + return null; + } + + /** + * Makes the item, if it can be made. + * + * The ingredients are taken out of the bag and the result is put in. When + * the bag is full the work is not done: the ingredients stay where they + * are. + */ + public static boolean craft(ItemType type, int count, Knowledge knowledge, + int benchLevel, Inventory inventory) { + if (count <= 0) { + return false; + } + if (whyNot(type, knowledge, benchLevel, inventory) != null) { + return false; + } + Recipe recipe = type.recipe; + for (int i = 0; i < recipe.items.length; i++) { + inventory.take(recipe.items[i], recipe.counts[i] * count); + } + if (!inventory.add(net.rustnotmc.protocol.ItemStack.of(type.id, count))) { + // The bag is full: give the ingredients back rather than eat them. + for (int i = 0; i < recipe.items.length; i++) { + inventory.add(net.rustnotmc.protocol.ItemStack.of(recipe.items[i], + recipe.counts[i] * count)); + } + return false; + } + return true; + } + + /** + * How long the work takes, in seconds. + * + * A workbench above the one the recipe asks for makes it faster: half as + * long for the first level above, three quarters off for the second, and + * no more after that. + */ + public static int seconds(ItemType type, int benchLevel) { + if (type == null || type.recipe == null) { + return 0; + } + int required = type.bench; + int levels = benchLevel - required; + if (levels < 0) { + levels = 0; + } + if (levels > 2) { + levels = 2; + } + double factor = Math.pow(0.5D, levels); + int seconds = (int) Math.floor(type.recipe.seconds * factor); + return seconds < 1 ? 1 : seconds; + } + + private static String nameOf(int item) { + ItemType type = ItemRegistry.byId(item); + return type == null ? ("item " + item) : type.name; + } +} diff --git a/rsx-server-core/src/main/java/net/rustnotmc/server/craft/Knowledge.java b/rsx-server-core/src/main/java/net/rustnotmc/server/craft/Knowledge.java new file mode 100644 index 0000000..5c8dcc4 --- /dev/null +++ b/rsx-server-core/src/main/java/net/rustnotmc/server/craft/Knowledge.java @@ -0,0 +1,135 @@ +package net.rustnotmc.server.craft; + +import java.util.ArrayList; +import java.util.Collections; +import java.util.HashSet; +import java.util.List; +import java.util.Set; +import net.rustnotmc.protocol.ItemRegistry; +import net.rustnotmc.protocol.ItemType; +import net.rustnotmc.protocol.TechTree; + +/** + * What a player knows. + * + * There are two kinds of knowledge, and they are kept apart on purpose: + * + *
    + *
  • the recipes the player can make. A recipe that is known by + * default is known from the start; anything else has to be researched, + * either at the research table or by opening a node in the tree of a + * workbench. + *
  • the nodes the player has opened in the trees. This is what the + * tree of a workbench is: a set of things that were paid for with scrap. + *
+ * + * The knowledge belongs to the player and not to the item, so two players can + * know different things at the same moment. + */ +public final class Knowledge { + + private final Set learned = new HashSet(); + private final Set unlocked = new HashSet(); + + /** + * Whether the player can make the item. + * + * An item that is known by default needs nothing. Everything else has to + * have been researched, and the research is what the table or the tree + * gives. + */ + public boolean knows(int item) { + ItemType type = ItemRegistry.byId(item); + if (type == null) { + return false; + } + if (type.knownByDefault) { + return true; + } + if (this.unlocked.contains(Integer.valueOf(item))) { + return true; + } + return this.learned.contains(Integer.valueOf(item)); + } + + /** The player learned a recipe, from a blueprint. */ + public void learn(int item) { + this.learned.add(Integer.valueOf(item)); + } + + /** Whether the player has opened this node of a tree. */ + public boolean isUnlocked(int item) { + return this.unlocked.contains(Integer.valueOf(item)); + } + + /** The player opened a node of a tree. */ + public void unlock(int item) { + this.unlocked.add(Integer.valueOf(item)); + } + + /** What a blueprint of this item would open. */ + public void learnFromBlueprint(int item) { + learn(item); + } + + /** + * Whether the node can be opened, and what it costs. + * + * The cost is zero when the node cannot be opened at all: there is no + * node for an item that is in no tree, and a node whose parent is still + * closed is not for sale either. + */ + public boolean canUnlock(int item) { + return reasonCannotUnlock(item) == null; + } + + /** + * Why the node cannot be opened, or null when it can. + * + * The reasons are words a player can read: "no such node", "the branch is + * not open yet". + */ + public String reasonCannotUnlock(int item) { + TechTree.Node node = TechTree.forItem(item); + if (node == null) { + return "there is no such node"; + } + if (isUnlocked(item)) { + return "it is already open"; + } + if (!TechTree.canOpen(item, this.unlocked)) { + return "the branch is not open yet"; + } + return null; + } + + /** The recipes the player knows, for the client to draw. */ + public int[] knownRecipes() { + List out = new ArrayList(); + for (ItemType type : ItemRegistry.all()) { + if (knows(type.id)) { + out.add(Integer.valueOf(type.id)); + } + } + int[] ids = new int[out.size()]; + for (int i = 0; i < ids.length; i++) { + ids[i] = out.get(i).intValue(); + } + return ids; + } + + /** The nodes the player has opened, for the client to draw. */ + public int[] unlockedNodes() { + int[] ids = new int[this.unlocked.size()]; + int i = 0; + for (Integer id : this.unlocked) { + ids[i++] = id.intValue(); + } + return ids; + } + + /** The number of recipes the player knows. */ + public int knownCount() { + return knownRecipes().length; + } +} diff --git a/rsx-server-core/src/main/java/net/rustnotmc/server/craft/ResearchTable.java b/rsx-server-core/src/main/java/net/rustnotmc/server/craft/ResearchTable.java new file mode 100644 index 0000000..169888a --- /dev/null +++ b/rsx-server-core/src/main/java/net/rustnotmc/server/craft/ResearchTable.java @@ -0,0 +1,138 @@ +package net.rustnotmc.server.craft; + +import net.rustnotmc.protocol.ItemRegistry; +import net.rustnotmc.protocol.ItemStack; +import net.rustnotmc.protocol.ItemType; +import net.rustnotmc.server.item.Inventory; + +/** + * The research table. + * + * Researching an item means taking it apart to find out what it is made of, + * so the player has to have the item and the scrap to pay for the table. The + * item does not survive: it turns into a blueprint, which is the same item + * with a mark on it, and a blueprint can be learned, or given away. + * + * The work takes ten seconds, and it goes on even when the player walks away + * from the table. + */ +public final class ResearchTable { + + /** How long the work takes, in seconds. */ + public static final int SECONDS = 10; + + /** The mark that turns a stack into a blueprint. */ + public static final String BLUEPRINT = "blueprint"; + + private int item = ItemRegistry.NOTHING; + private int secondsLeft; + + /** What is being researched, or nothing. */ + public int item() { + return this.item; + } + + /** How much is left of the work, in seconds. */ + public int secondsLeft() { + return this.secondsLeft; + } + + /** True when something is being researched. */ + public boolean busy() { + return this.item != ItemRegistry.NOTHING; + } + + /** + * Starts the work. + * + * The player has to have the item and the scrap. The scrap is taken at + * once; the item is taken when the work is done, so that a player who + * walks away does not lose the item for nothing. + * + * Returns the reason it did not start, or null when it did. + */ + public String start(int item, Inventory inventory) { + if (busy()) { + return "the table is busy"; + } + ItemType type = ItemRegistry.byId(item); + if (type == null || type.recipe == null) { + return "there is nothing to find out about that"; + } + if (type.knownByDefault) { + return "you already know how to make that"; + } + if (type.researchScrap <= 0) { + return "that cannot be researched"; + } + if (inventory.count(item) <= 0) { + return "you do not have that"; + } + if (inventory.count(ItemRegistry.SCRAP) < type.researchScrap) { + return "you are short of scrap"; + } + inventory.take(ItemRegistry.SCRAP, type.researchScrap); + this.item = item; + this.secondsLeft = SECONDS; + return null; + } + + /** + * Lets the work go on. + * + * Returns true when the research finished, which is when the item turns + * into a blueprint in the bag. + */ + public boolean tick(Inventory inventory) { + if (!busy()) { + return false; + } + this.secondsLeft--; + if (this.secondsLeft > 0) { + return false; + } + int done = this.item; + this.item = ItemRegistry.NOTHING; + this.secondsLeft = 0; + if (inventory.count(done) <= 0) { + return false; + } + inventory.take(done, 1); + ItemStack blueprint = ItemStack.of(done, 1); + blueprint.set(BLUEPRINT, "1"); + inventory.add(blueprint); + return true; + } + + /** + * Whether a stack is a blueprint. + * + * A blueprint is the item with a mark on it, so the client draws the + * blueprint texture and the item on top of it. + */ + public static boolean isBlueprint(ItemStack stack) { + return stack != null && "1".equals(stack.get(BLUEPRINT)); + } + + /** + * Learns a blueprint: the recipe opens and the blueprint is used up. + * + * Returns false when there was no blueprint of that item. + */ + public static boolean learn(Inventory inventory, Knowledge knowledge, int item) { + for (int i = 0; i < Inventory.SLOTS; i++) { + ItemStack stack = inventory.get(i); + if (stack.item != item || !isBlueprint(stack)) { + continue; + } + if (stack.count <= 1) { + inventory.set(i, ItemStack.EMPTY); + } else { + stack.count--; + } + knowledge.learn(item); + return true; + } + return false; + } +} diff --git a/rsx-server-core/src/main/java/net/rustnotmc/server/world/JunkPile.java b/rsx-server-core/src/main/java/net/rustnotmc/server/world/JunkPile.java new file mode 100644 index 0000000..080e831 --- /dev/null +++ b/rsx-server-core/src/main/java/net/rustnotmc/server/world/JunkPile.java @@ -0,0 +1,294 @@ +package net.rustnotmc.server.world; + +import java.util.ArrayList; +import java.util.Collections; +import java.util.List; +import java.util.Random; +import net.rustnotmc.protocol.ItemRegistry; +import net.rustnotmc.protocol.ItemStack; + +/** + * A heap of rubbish at the side of the road. + * + * It is the first place where scrap comes from, and the only one that is + * there before the monuments are. A heap is two to four barrels and, now and + * then, a box of food. The barrels are broken, the box is looked into, and + * when there is nothing left in it the heap goes away after a while and shows + * up again somewhere else along the road. + * + * The barrels are the ones of the original game, with the names of the colours + * they are painted in: + * + *
    + *
  • the grey one holds scrap and one piece of rubbish; + *
  • the blue one holds a part; + *
  • the red one is a barrel of fuel: crude oil and low grade fuel. + *
+ */ +public final class JunkPile { + + /** A barrel with scrap and rubbish in it. */ + public static final int GREY = 0; + + /** A barrel with parts in it. */ + public static final int BLUE = 1; + + /** A barrel of fuel. */ + public static final int RED = 2; + + /** How long the heap stays after it is empty, at least. */ + public static final int MIN_LIFE = 60; + + /** How long the heap stays after it is empty, at most. */ + public static final int MAX_LIFE = 180; + + /** The rubbish a grey barrel can give. */ + private static final int[] RUBBISH = { + ItemRegistry.ROAD_SIGNS, ItemRegistry.TARP, ItemRegistry.ROPE, + ItemRegistry.EMPTY_PROPANE_TANK, ItemRegistry.SEWING_KIT, + ItemRegistry.BANDANA_MASK, ItemRegistry.TANK_TOP, ItemRegistry.SHORTS, + ItemRegistry.T_SHIRT, ItemRegistry.PANTS, ItemRegistry.LEATHER_GLOVES, + ItemRegistry.BASEBALL_CAP, ItemRegistry.BOONIE_HAT, ItemRegistry.HIDE_PANTS, + ItemRegistry.HIDE_BOOTS, ItemRegistry.HIDE_SKIRT, ItemRegistry.HIDE_CHEST, + ItemRegistry.HIDE_PONCHO, ItemRegistry.ROADSIGN_JACKET, ItemRegistry.ROADSIGN_KILT, + ItemRegistry.ROADSIGN_GLOVES, + }; + + /** The parts a blue barrel can give. */ + private static final int[] PARTS = { + ItemRegistry.METAL_BLADE, ItemRegistry.METAL_PIPE, ItemRegistry.GEARS, + ItemRegistry.METAL_SPRING, ItemRegistry.SHEET_METAL, ItemRegistry.ROAD_SIGNS, + ItemRegistry.ELECTRIC_FUSE, ItemRegistry.ROPE, ItemRegistry.TARP, + ItemRegistry.SEWING_KIT, ItemRegistry.EMPTY_PROPANE_TANK, + ItemRegistry.SEMI_AUTO_BODY, ItemRegistry.TECH_TRASH, + }; + + /** What a box of food can hold. */ + private static final int[] FOOD = { + ItemRegistry.APPLE, ItemRegistry.BLUEBERRIES, ItemRegistry.BLACK_RASPBERRIES, + ItemRegistry.POTATO, ItemRegistry.CHOCOLATE_BAR, ItemRegistry.CAN_OF_BEANS, + ItemRegistry.CAN_OF_TUNA, ItemRegistry.GRANOLA_BAR, ItemRegistry.CORN, + ItemRegistry.PUMPKIN, ItemRegistry.MUSHROOM, + }; + + /** What a box of food can hold instead of food. */ + private static final int[] DRINK = { + ItemRegistry.SMALL_WATER_BOTTLE, ItemRegistry.WATER_JUG, + }; + + /** One barrel of the heap. */ + public static final class Barrel { + + public final int kind; + public final int x; + public final int y; + public final int z; + private boolean broken; + + Barrel(int kind, int x, int y, int z) { + this.kind = kind; + this.x = x; + this.y = y; + this.z = z; + } + + public boolean isBroken() { + return this.broken; + } + + void broken() { + this.broken = true; + } + } + + /** One box of food of the heap. */ + public static final class FoodBox { + + public final int x; + public final int y; + public final int z; + private final List content = new ArrayList(); + + FoodBox(int x, int y, int z) { + this.x = x; + this.y = y; + this.z = z; + } + + /** What is in the box. */ + public List content() { + return Collections.unmodifiableList(this.content); + } + + boolean empty() { + return this.content.isEmpty(); + } + } + + private final int x; + private final int y; + private final int z; + private final List barrels = new ArrayList(); + private final List boxes = new ArrayList(); + private int despawnIn = -1; + + /** + * Makes a heap. + * + * Two to four barrels and, from time to time, a box of food. The barrels + * stand in a row along the road, and the box is next to them. + */ + public JunkPile(int x, int y, int z, Random random) { + this.x = x; + this.y = y; + this.z = z; + + int count = 2 + random.nextInt(3); + for (int i = 0; i < count; i++) { + int kind = random.nextInt(3); + this.barrels.add(new Barrel(kind, x + i * 2, y, z)); + } + if (random.nextInt(3) == 0) { + FoodBox box = new FoodBox(x, y, z + 2); + int slots = 1 + random.nextInt(2); + for (int i = 0; i < slots; i++) { + int item; + if (random.nextInt(4) == 0) { + item = DRINK[random.nextInt(DRINK.length)]; + } else { + item = FOOD[random.nextInt(FOOD.length)]; + } + box.content.add(ItemStack.of(item, 1)); + } + this.boxes.add(box); + } + } + + public int x() { + return this.x; + } + + public int y() { + return this.y; + } + + public int z() { + return this.z; + } + + /** The barrels of the heap. */ + public List barrels() { + return Collections.unmodifiableList(this.barrels); + } + + /** The boxes of food of the heap. */ + public List boxes() { + return Collections.unmodifiableList(this.boxes); + } + + /** True when there is nothing left in the heap. */ + public boolean isEmpty() { + for (int i = 0; i < this.barrels.size(); i++) { + if (!this.barrels.get(i).isBroken()) { + return false; + } + } + for (int i = 0; i < this.boxes.size(); i++) { + if (!this.boxes.get(i).empty()) { + return false; + } + } + return true; + } + + /** + * Breaks a barrel and gives what is in it. + * + * Returns an empty list when the barrel is already broken or is not one + * of this heap. + */ + public List breakBarrel(Barrel barrel, Random random) { + if (barrel == null || barrel.isBroken()) { + return Collections.emptyList(); + } + boolean mine = false; + for (int i = 0; i < this.barrels.size(); i++) { + if (this.barrels.get(i) == barrel) { + mine = true; + break; + } + } + if (!mine) { + return Collections.emptyList(); + } + barrel.broken(); + List loot = new ArrayList(); + if (barrel.kind == GREY) { + int scrap = 1 + random.nextInt(15); + loot.add(ItemStack.of(ItemRegistry.SCRAP, scrap)); + loot.add(ItemStack.of(RUBBISH[random.nextInt(RUBBISH.length)], 1)); + } else if (barrel.kind == BLUE) { + loot.add(ItemStack.of(PARTS[random.nextInt(PARTS.length)], 1)); + } else { + loot.add(ItemStack.of(ItemRegistry.CRUDE_OIL, 10 + random.nextInt(11))); + loot.add(ItemStack.of(ItemRegistry.LOW_GRADE_FUEL, 5 + random.nextInt(11))); + } + if (isEmpty()) { + scheduleDespawn(random); + } + return loot; + } + + /** + * Takes something out of a box of food. + * + * Returns null when the slot is empty or the box is not one of this heap. + */ + public ItemStack takeFromBox(FoodBox box, int slot) { + return takeFromBox(box, slot, new Random()); + } + + /** + * Takes something out of a box of food. + * + * The random is only used to say when the heap goes away once it is empty. + */ + public ItemStack takeFromBox(FoodBox box, int slot, Random random) { + if (box == null || slot < 0 || slot >= box.content.size()) { + return null; + } + ItemStack taken = box.content.remove(slot); + if (isEmpty()) { + scheduleDespawn(random); + } + return taken; + } + + /** + * Says when the heap goes away. + * + * It does not go at once: the heap stays a little while, and then it is + * gone and another one shows up somewhere else. + */ + public void scheduleDespawn(Random random) { + this.despawnIn = MIN_LIFE + random.nextInt(MAX_LIFE - MIN_LIFE + 1); + } + + /** How many seconds are left before the heap goes, or -1. */ + public int despawnIn() { + return this.despawnIn; + } + + /** + * Lets the heap go. + * + * Returns true when the heap is gone. + */ + public boolean tick() { + if (this.despawnIn < 0) { + return false; + } + this.despawnIn--; + return this.despawnIn <= 0; + } +} diff --git a/rsx-server-core/src/main/java/net/rustnotmc/server/world/ResourceNode.java b/rsx-server-core/src/main/java/net/rustnotmc/server/world/ResourceNode.java new file mode 100644 index 0000000..e1a60a5 --- /dev/null +++ b/rsx-server-core/src/main/java/net/rustnotmc/server/world/ResourceNode.java @@ -0,0 +1,98 @@ +package net.rustnotmc.server.world; + +import net.rustnotmc.protocol.ItemType; +import net.rustnotmc.protocol.NodeType; +import net.rustnotmc.protocol.ResourceClass; + +/** + * A thing in the world that can be worn down. + * + * A node has a pool of resource in it, and the pool is also its health. A + * swing of the right tool takes some of it out; the last swing gives what is + * left, so a node always gives exactly its pool. + * + * A node can have a mark: a cross on a tree, a hot spot on an outcrop. A + * swing that hits the mark does more, and every swing that follows it does a + * little more still, up to a limit. A swing that misses resets the bonus. + */ +public final class ResourceNode { + + private final NodeType type; + private final int x; + private final int y; + private final int z; + private int pool; + private int markStreak; + + public ResourceNode(NodeType type, int x, int y, int z) { + this.type = type; + this.x = x; + this.y = y; + this.z = z; + this.pool = type.pool; + } + + public NodeType type() { + return this.type; + } + + public int x() { + return this.x; + } + + public int y() { + return this.y; + } + + public int z() { + return this.z; + } + + /** How much is left in it. */ + public int pool() { + return this.pool; + } + + /** True when there is nothing left in it. */ + public boolean isEmpty() { + return this.pool <= 0; + } + + /** How many marked swings in a row there were. */ + public int markStreak() { + return this.markStreak; + } + + /** + * A swing of a tool. + * + * Returns how much was taken out, or zero when the tool cannot take + * anything out of this node: a pickaxe does not cut a tree and a sword + * does not mine. + */ + public int hit(ItemType tool, boolean marked) { + if (tool == null || isEmpty()) { + return 0; + } + ItemType.Gather gather = tool.gather(this.type.resource); + if (gather == null || gather.damage <= 0) { + return 0; + } + int damage = gather.damage; + if (marked && this.type.hasMarker()) { + this.markStreak++; + damage = this.type.swingDamage(damage, this.markStreak); + } else { + this.markStreak = 0; + } + int taken = damage < this.pool ? damage : this.pool; + this.pool -= taken; + return taken; + } + + @Override + public String toString() { + return "ResourceNode[" + this.type.name + " at " + this.x + ", " + this.y + ", " + + this.z + " " + this.pool + "]"; + } +} diff --git a/rsx-server-core/src/main/java/net/rustnotmc/server/world/RoadNetwork.java b/rsx-server-core/src/main/java/net/rustnotmc/server/world/RoadNetwork.java new file mode 100644 index 0000000..d1803c0 --- /dev/null +++ b/rsx-server-core/src/main/java/net/rustnotmc/server/world/RoadNetwork.java @@ -0,0 +1,114 @@ +package net.rustnotmc.server.world; + +import java.util.ArrayList; +import java.util.Collections; +import java.util.List; + +/** + * The roads of the world. + * + * A grid of straight roads crosses the island and a ring goes around it. The + * roads are where the junk piles stand, so the network also knows the points + * where a heap may appear. + * + * Nothing here depends on a random number: the roads are the same in every + * world of the same size, because a road that moved between two runs would + * leave the save files full of holes. + */ +public final class RoadNetwork { + + /** How many blocks to the side of the middle of a road. */ + public static final int HALF_WIDTH = 3; + + /** How far apart the straight roads are. */ + private final int spacing; + + /** The radius of the ring around the island. */ + private final int ringRadius; + + /** The size of the world. */ + private final int size; + + public RoadNetwork(int size) { + this.size = size; + this.spacing = Math.max(256, size / 4); + this.ringRadius = Math.max(128, size / 3); + } + + /** How far apart the straight roads are. */ + public int spacing() { + return this.spacing; + } + + /** The radius of the ring. */ + public int ringRadius() { + return this.ringRadius; + } + + /** The middle of the world. */ + public int center() { + return this.size / 2; + } + + /** The middle of the straight road that is nearest to the column. */ + public int centerLine(int value) { + return (int) Math.round(value / (double) this.spacing) * this.spacing; + } + + /** True when the column is on a straight road. */ + public boolean onStraightRoad(int x, int z) { + return Math.abs(x - centerLine(x)) <= HALF_WIDTH + || Math.abs(z - centerLine(z)) <= HALF_WIDTH; + } + + /** + * The point of the ring that is nearest to the column, or null when the + * column is not on the ring. + */ + public int[] onRing(int x, int z) { + int cx = center(); + int cz = center(); + double dx = x - cx; + double dz = z - cz; + double distance = Math.sqrt(dx * dx + dz * dz); + if (distance < 0.001D) { + return null; + } + if (Math.abs(distance - this.ringRadius) > HALF_WIDTH) { + return null; + } + double angle = Math.atan2(dz, dx); + int px = cx + (int) Math.round(Math.cos(angle) * this.ringRadius); + int pz = cz + (int) Math.round(Math.sin(angle) * this.ringRadius); + return new int[]{px, pz}; + } + + /** True when the column is on any road. */ + public boolean onRoad(int x, int z) { + return onStraightRoad(x, z) || onRing(x, z) != null; + } + + /** + * The points along the roads where a junk pile may stand. + * + * The points are given as columns; the height of the ground there is the + * business of the generator. + */ + public List spawnPoints() { + List points = new ArrayList(); + int half = this.size / 2; + int step = 64; + for (int v = -half; v <= half; v += step) { + points.add(new int[]{v, center()}); + points.add(new int[]{center(), v}); + } + // and a ring of points along the ring road + int around = Math.max(8, this.ringRadius * 2 / step); + for (int i = 0; i < around; i++) { + double angle = 2.0D * Math.PI * i / around; + points.add(new int[]{center() + (int) Math.round(Math.cos(angle) * this.ringRadius), + center() + (int) Math.round(Math.sin(angle) * this.ringRadius)}); + } + return Collections.unmodifiableList(points); + } +} diff --git a/rsx-server-core/src/main/java/net/rustnotmc/server/world/WorldGenerator.java b/rsx-server-core/src/main/java/net/rustnotmc/server/world/WorldGenerator.java index 520789e..01d4d88 100644 --- a/rsx-server-core/src/main/java/net/rustnotmc/server/world/WorldGenerator.java +++ b/rsx-server-core/src/main/java/net/rustnotmc/server/world/WorldGenerator.java @@ -19,15 +19,26 @@ import net.rustnotmc.protocol.ChunkFormat; public final class WorldGenerator { private final long seed; + private final RoadNetwork roads; public WorldGenerator(long seed) { + this(seed, null); + } + + public WorldGenerator(long seed, RoadNetwork roads) { this.seed = seed; + this.roads = roads; } public long seed() { return this.seed; } + /** The roads of this world, or null when it has none. */ + public RoadNetwork roads() { + return this.roads; + } + /** * A hash of one coordinate, and nothing else. * @@ -89,6 +100,40 @@ public final class WorldGenerator { /** The height of solid ground at a block column. */ public int heightAt(int blockX, int blockZ) { + if (this.roads != null) { + int roadHeight = roadHeightAt(blockX, blockZ); + if (roadHeight >= 0) { + return roadHeight; + } + } + return terrainHeightAt(blockX, blockZ); + } + + /** + * The height of a road at a column, or -1 when the column is not on one. + * + * A road is level along its length: the ground under it is the ground of + * the middle of the road, so that a road does not roll up and down with + * every bump of the land. + */ + private int roadHeightAt(int blockX, int blockZ) { + int middleX = this.roads.centerLine(blockX); + int middleZ = this.roads.centerLine(blockZ); + if (Math.abs(blockX - middleX) <= RoadNetwork.HALF_WIDTH) { + return terrainHeightAt(middleX, blockZ); + } + if (Math.abs(blockZ - middleZ) <= RoadNetwork.HALF_WIDTH) { + return terrainHeightAt(blockX, middleZ); + } + int[] ring = this.roads.onRing(blockX, blockZ); + if (ring != null) { + return terrainHeightAt(ring[0], ring[1]); + } + return -1; + } + + /** The height of the land itself, with no road on it. */ + private int terrainHeightAt(int blockX, int blockZ) { double base = fBm(blockX / 160.0D, blockZ / 160.0D, 4); double detail = fBm(blockX / 32.0D, blockZ / 32.0D, 2) * 0.35D; int h = (int) Math.round(62.0D + base * 26.0D + detail * 6.0D); @@ -116,6 +161,7 @@ public final class WorldGenerator { int worldX = baseX + x; int worldZ = baseZ + z; int height = heightAt(worldX, worldZ); + boolean road = this.roads != null && this.roads.onRoad(worldX, worldZ); // a column: dirt under the surface, grass on top, stone below for (int y = 0; y <= height; y++) { @@ -123,7 +169,7 @@ public final class WorldGenerator { if (y == 0) { id = Blocks.STONE; } else if (y == height) { - id = Blocks.GRASS; + id = road ? Blocks.ASPHALT : Blocks.GRASS; } else if (y > height - 4) { id = Blocks.DIRT; } else { diff --git a/rsx-server-core/src/test/java/net/rustnotmc/server/craft/CraftTest.java b/rsx-server-core/src/test/java/net/rustnotmc/server/craft/CraftTest.java new file mode 100644 index 0000000..ecbfb2d --- /dev/null +++ b/rsx-server-core/src/test/java/net/rustnotmc/server/craft/CraftTest.java @@ -0,0 +1,240 @@ +package net.rustnotmc.server.craft; + +import net.rustnotmc.protocol.ItemRegistry; +import net.rustnotmc.protocol.ItemStack; +import net.rustnotmc.protocol.ItemType; +import net.rustnotmc.server.item.Inventory; + +/** + * Tests what a player knows and what a player can make. + * + * The knowledge belongs to the player, the workbench is a place in the world + * and the ingredients are in the bag, and all three have to agree before + * anything is made. + */ +public final class CraftTest { + + 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 known things are known from the start", new Throwing() { + public void go() { testKnownByDefault(); } + }); + run("the rest has to be researched", new Throwing() { + public void go() { testUnknownByDefault(); } + }); + run("a branch has to be walked", new Throwing() { + public void go() { testTreeBranch(); } + }); + run("opening a node costs scrap", new Throwing() { + public void go() { testTreeCost(); } + }); + run("a thing is made out of what is in the bag", new Throwing() { + public void go() { testCraft(); } + }); + run("the workbench has to be there", new Throwing() { + public void go() { testBenchGate(); } + }); + run("a better workbench makes it faster", new Throwing() { + public void go() { testCraftTime(); } + }); + run("researching turns the thing into a blueprint", new Throwing() { + public void go() { testResearch(); } + }); + run("a blueprint is learned", new Throwing() { + public void go() { testLearnBlueprint(); } + }); + + 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); + } else { + System.out.println(" FAIL " + name); + } + } + + 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 yes(String what, boolean condition) { + if (!condition) { + failed++; + failures.add(what); + } + } + + private static void testKnownByDefault() { + Knowledge knowledge = new Knowledge(); + yes("the rock is known", knowledge.knows(ItemRegistry.ROCK)); + yes("the stone hatchet is known", knowledge.knows(ItemRegistry.STONE_HATCHET)); + yes("the workbench is known", knowledge.knows(ItemRegistry.WORKBENCH_1)); + yes("there is more than a handful of known things", knowledge.knownCount() > 20); + } + + private static void testUnknownByDefault() { + Knowledge knowledge = new Knowledge(); + yes("the hatchet is not known", !knowledge.knows(ItemRegistry.HATCHET)); + yes("the salvaged sword is not known", !knowledge.knows(ItemRegistry.SALVAGED_SWORD)); + knowledge.learn(ItemRegistry.HATCHET); + yes("after the research the hatchet is known", knowledge.knows(ItemRegistry.HATCHET)); + } + + private static void testTreeBranch() { + Knowledge knowledge = new Knowledge(); + yes("the root of the branch can be opened", + knowledge.canUnlock(ItemRegistry.SALVAGED_HAMMER)); + yes("the next one cannot", !knowledge.canUnlock(ItemRegistry.HATCHET)); + yes("and it says why", + knowledge.reasonCannotUnlock(ItemRegistry.HATCHET) != null); + knowledge.unlock(ItemRegistry.SALVAGED_HAMMER); + yes("with the hammer the hatchet opens", + knowledge.canUnlock(ItemRegistry.HATCHET)); + knowledge.unlock(ItemRegistry.HATCHET); + yes("with the hatchet the sword opens", + knowledge.canUnlock(ItemRegistry.SALVAGED_SWORD)); + yes("an opened node knows the recipe", knowledge.knows(ItemRegistry.HATCHET)); + } + + private static void testTreeCost() { + Knowledge knowledge = new Knowledge(); + knowledge.unlock(ItemRegistry.SALVAGED_HAMMER); + eq("the node costs a hundred twenty five", Integer.valueOf(125), + Integer.valueOf(net.rustnotmc.protocol.TechTree + .costOf(ItemRegistry.HATCHET))); + eq("the nodes that are open are told to the client", Integer.valueOf(1), + Integer.valueOf(knowledge.unlockedNodes().length)); + } + + private static void testCraft() { + Knowledge knowledge = new Knowledge(); + Inventory inventory = new Inventory(); + ItemType hatchet = ItemRegistry.require(ItemRegistry.HATCHET); + knowledge.learn(ItemRegistry.HATCHET); + inventory.add(ItemStack.of(ItemRegistry.WOOD, 100)); + inventory.add(ItemStack.of(ItemRegistry.METAL, 75)); + + yes("without a workbench it cannot be made", + Crafter.whyNot(hatchet, knowledge, 0, inventory) != null); + + yes("it can be made at the first workbench", + Crafter.whyNot(hatchet, knowledge, 1, inventory) == null); + yes("the craft works", Crafter.craft(hatchet, 1, knowledge, 1, inventory)); + eq("the hatchet is in the bag", Integer.valueOf(1), + Integer.valueOf(inventory.count(ItemRegistry.HATCHET))); + eq("the wood is gone", Integer.valueOf(0), + Integer.valueOf(inventory.count(ItemRegistry.WOOD))); + eq("the metal is gone", Integer.valueOf(0), + Integer.valueOf(inventory.count(ItemRegistry.METAL))); + } + + private static void testBenchGate() { + Knowledge knowledge = new Knowledge(); + Inventory inventory = new Inventory(); + ItemType pickaxe = ItemRegistry.require(ItemRegistry.PICKAXE); + knowledge.learn(ItemRegistry.PICKAXE); + inventory.add(ItemStack.of(ItemRegistry.WOOD, 100)); + inventory.add(ItemStack.of(ItemRegistry.METAL, 125)); + + yes("the pickaxe wants the first workbench", + Crafter.whyNot(pickaxe, knowledge, 0, inventory) != null); + yes("and it is refused without it", + !Crafter.craft(pickaxe, 1, knowledge, 0, inventory)); + eq("nothing was made", Integer.valueOf(0), + Integer.valueOf(inventory.count(ItemRegistry.PICKAXE))); + yes("at the workbench it goes through", + Crafter.craft(pickaxe, 1, knowledge, 1, inventory)); + + // and without the knowledge it is refused as well + Knowledge empty = new Knowledge(); + eq("the rock is short of nothing", Integer.valueOf(0), + Integer.valueOf(inventory.count(ItemRegistry.ROCK))); + yes("an unknown recipe is refused", + Crafter.whyNot(pickaxe, empty, 1, inventory) != null); + } + + private static void testCraftTime() { + ItemType hatchet = ItemRegistry.require(ItemRegistry.HATCHET); + eq("at the workbench it wants thirty seconds", Integer.valueOf(30), + Integer.valueOf(Crafter.seconds(hatchet, 1))); + eq("one level above is half", Integer.valueOf(15), + Integer.valueOf(Crafter.seconds(hatchet, 2))); + eq("two levels above is a quarter", Integer.valueOf(7), + Integer.valueOf(Crafter.seconds(hatchet, 3))); + eq("three levels above is no faster", Integer.valueOf(7), + Integer.valueOf(Crafter.seconds(hatchet, 4))); + } + + private static void testResearch() { + Inventory inventory = new Inventory(); + inventory.add(ItemStack.of(ItemRegistry.HATCHET, 1)); + inventory.add(ItemStack.of(ItemRegistry.SCRAP, 30)); + + ResearchTable table = new ResearchTable(); + String problem = table.start(ItemRegistry.HATCHET, inventory); + yes("the research starts: " + problem, problem == null); + eq("the scrap is taken at once", Integer.valueOf(0), + Integer.valueOf(inventory.count(ItemRegistry.SCRAP))); + eq("the item is still there while the work goes on", Integer.valueOf(1), + Integer.valueOf(inventory.count(ItemRegistry.HATCHET))); + yes("the table is busy", table.busy()); + + boolean done = false; + for (int i = 0; i < 20 && !done; i++) { + done = table.tick(inventory); + } + yes("the work finishes", done); + eq("the item is a blueprint now", Integer.valueOf(1), + Integer.valueOf(inventory.count(ItemRegistry.HATCHET))); + yes("and it is a blueprint", ResearchTable.isBlueprint(inventory.get(0))); + yes("the plain item is not a blueprint", + !ResearchTable.isBlueprint(ItemStack.of(ItemRegistry.HATCHET, 1))); + } + + private static void testLearnBlueprint() { + Inventory inventory = new Inventory(); + ItemStack blueprint = ItemStack.of(ItemRegistry.HATCHET, 1); + blueprint.set(ResearchTable.BLUEPRINT, "1"); + inventory.add(blueprint); + + Knowledge knowledge = new Knowledge(); + yes("the hatchet is not known yet", !knowledge.knows(ItemRegistry.HATCHET)); + yes("the blueprint is learned", ResearchTable.learn(inventory, knowledge, + ItemRegistry.HATCHET)); + yes("and now it is known", knowledge.knows(ItemRegistry.HATCHET)); + eq("the blueprint is used up", Integer.valueOf(0), + Integer.valueOf(inventory.count(ItemRegistry.HATCHET))); + } +} diff --git a/rsx-server-core/src/test/java/net/rustnotmc/server/world/NodeTest.java b/rsx-server-core/src/test/java/net/rustnotmc/server/world/NodeTest.java new file mode 100644 index 0000000..43691d9 --- /dev/null +++ b/rsx-server-core/src/test/java/net/rustnotmc/server/world/NodeTest.java @@ -0,0 +1,222 @@ +package net.rustnotmc.server.world; + +import java.util.List; +import java.util.Random; +import net.rustnotmc.protocol.Blocks; +import net.rustnotmc.protocol.ChunkFormat; +import net.rustnotmc.protocol.ItemRegistry; +import net.rustnotmc.protocol.ItemStack; +import net.rustnotmc.protocol.ItemType; +import net.rustnotmc.protocol.NodeType; + +/** + * Tests the things in the world that can be worn down: the nodes of resource, + * the heaps of rubbish and the roads they stand by. + */ +public final class NodeTest { + + 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("a node is worn down by the right tool", new Throwing() { + public void go() { testGather(); } + }); + run("the last swing gives what is left", new Throwing() { + public void go() { testLastSwing(); } + }); + run("the wrong tool takes nothing", new Throwing() { + public void go() { testWrongTool(); } + }); + run("the mark makes a swing stronger", new Throwing() { + public void go() { testMark(); } + }); + run("a heap of rubbish gives scrap", new Throwing() { + public void go() { testJunkPile(); } + }); + run("a heap goes away when it is empty", new Throwing() { + public void go() { testJunkPileLife(); } + }); + run("the roads are where they should be", new Throwing() { + public void go() { testRoads(); } + }); + run("a road is level along its length", new Throwing() { + public void go() { testRoadIsLevel(); } + }); + + 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); + } else { + System.out.println(" FAIL " + name); + } + } + + 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 yes(String what, boolean condition) { + if (!condition) { + failed++; + failures.add(what); + } + } + + private static void testGather() { + ResourceNode node = new ResourceNode(NodeType.byId(NodeType.TREE_COMMON), 0, 64, 0); + ItemType hatchet = ItemRegistry.require(ItemRegistry.HATCHET); + int taken = node.hit(hatchet, false); + eq("the hatchet takes eighty five wood", Integer.valueOf(85), Integer.valueOf(taken)); + eq("the node has less in it", Integer.valueOf(750 - 85), + Integer.valueOf(node.pool())); + } + + private static void testLastSwing() { + ResourceNode node = new ResourceNode(NodeType.byId(NodeType.TREE_COMMON), 0, 64, 0); + ItemType hatchet = ItemRegistry.require(ItemRegistry.HATCHET); + int total = 0; + for (int i = 0; i < 20 && !node.isEmpty(); i++) { + total += node.hit(hatchet, false); + } + eq("the node gives exactly its pool", Integer.valueOf(750), Integer.valueOf(total)); + yes("and then it is gone", node.isEmpty()); + eq("an empty node gives nothing", Integer.valueOf(0), + Integer.valueOf(node.hit(hatchet, false))); + } + + private static void testWrongTool() { + ResourceNode tree = new ResourceNode(NodeType.byId(NodeType.TREE_COMMON), 0, 64, 0); + ItemType pickaxe = ItemRegistry.require(ItemRegistry.PICKAXE); + eq("a pickaxe does not cut", Integer.valueOf(0), + Integer.valueOf(tree.hit(pickaxe, false))); + + ResourceNode ore = new ResourceNode(NodeType.byId(NodeType.STONE), 0, 64, 0); + ItemType hatchet = ItemRegistry.require(ItemRegistry.HATCHET); + eq("an axe does not mine", Integer.valueOf(0), + Integer.valueOf(ore.hit(hatchet, false))); + } + + private static void testMark() { + ResourceNode tree = new ResourceNode(NodeType.byId(NodeType.TREE_COMMON), 0, 64, 0); + ItemType hatchet = ItemRegistry.require(ItemRegistry.HATCHET); + int plain = tree.hit(hatchet, false); + ResourceNode marked = new ResourceNode(NodeType.byId(NodeType.TREE_COMMON), 0, 64, 0); + int onMark = marked.hit(hatchet, true); + yes("a swing on the cross takes more", onMark > plain); + eq("and the streak is kept", Integer.valueOf(1), + Integer.valueOf(marked.markStreak())); + marked.hit(hatchet, false); + eq("a swing that misses resets it", Integer.valueOf(0), + Integer.valueOf(marked.markStreak())); + } + + private static void testJunkPile() { + Random random = new Random(7); + JunkPile heap = new JunkPile(10, 64, 10, random); + List barrels = heap.barrels(); + yes("a heap has two to four barrels", barrels.size() >= 2 && barrels.size() <= 4); + + int scrap = 0; + for (int i = 0; i < barrels.size(); i++) { + List loot = heap.breakBarrel(barrels.get(i), random); + for (int j = 0; j < loot.size(); j++) { + if (loot.get(j).item == ItemRegistry.SCRAP) { + scrap += loot.get(j).count; + } + } + } + yes("the grey barrels give scrap", scrap > 0); + yes("a broken barrel stays broken", barrels.get(0).isBroken()); + yes("breaking it twice gives nothing", + heap.breakBarrel(barrels.get(0), random).isEmpty()); + } + + private static void testJunkPileLife() { + Random random = new Random(11); + JunkPile heap = new JunkPile(10, 64, 10, random); + List barrels = heap.barrels(); + for (int i = 0; i < barrels.size(); i++) { + heap.breakBarrel(barrels.get(i), random); + } + // the box of food has to be emptied as well + List boxes = heap.boxes(); + for (int i = 0; i < boxes.size(); i++) { + JunkPile.FoodBox box = boxes.get(i); + while (heap.takeFromBox(box, 0, random) != null) { + // keep taking until the box is empty + } + } + yes("the heap is empty", heap.isEmpty()); + yes("and it is going away", heap.despawnIn() > 0); + boolean gone = false; + for (int i = 0; i < 200 && !gone; i++) { + gone = heap.tick(); + } + yes("and then it is gone", gone); + } + + private static void testRoads() { + RoadNetwork roads = new RoadNetwork(4000); + yes("the middle of the world is on a road", roads.onRoad(2000, 2000)); + yes("the middle of a straight road is on a road", roads.onRoad(2000, 2007)); + yes("far away from a road is not on a road", !roads.onRoad(2500, 2500)); + yes("there are points where a heap may stand", roads.spawnPoints().size() > 10); + } + + private static void testRoadIsLevel() { + RoadNetwork roads = new RoadNetwork(4000); + WorldGenerator generator = new WorldGenerator(1234L, roads); + // a stretch of road that does not cross another one + int height = generator.heightAt(2000, 2400); + eq("the road is level across it", Integer.valueOf(height), + Integer.valueOf(generator.heightAt(2000 + 3, 2400))); + eq("and level across it the other way", Integer.valueOf(height), + Integer.valueOf(generator.heightAt(2000, 2400 + 3))); + + // and the surface of a road is asphalt + Chunk chunk = new Chunk(new ChunkPos(125, 125)); + generator.generate(chunk); + byte[] blocks = chunk.snapshot(); + int top = -1; + for (int y = Chunk.HEIGHT - 1; y >= 0; y--) { + if (blocks[ChunkFormat.index(0, y, 0)] != Blocks.AIR) { + top = y; + break; + } + } + eq("the top of the road is asphalt", Integer.valueOf(Blocks.ASPHALT), + Integer.valueOf(blocks[ChunkFormat.index(0, top, 0)])); + } +}