From fd596d4ff78ed7c7088df28b7f24bf949fe119bc Mon Sep 17 00:00:00 2001 From: sasheg dev <152582704+SashegDev@users.noreply.github.com> Date: Thu, 22 Oct 2026 08:57:13 +0300 Subject: [PATCH] =?UTF-8?q?=D0=94=D0=B0=D0=BD=D0=BD=D1=8B=D0=B5=20=D0=B8?= =?UTF-8?q?=D0=B3=D1=80=D0=BE=D0=BA=D0=B0=20=D1=81=D0=BE=D1=85=D1=80=D0=B0?= =?UTF-8?q?=D0=BD=D1=8F=D1=8E=D1=82=D1=81=D1=8F?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - PlayerData: файл на игрока в world/players/<аккаунт>.json. Позиция, рыскание и тангаж, 30 слотов инвентаря, 7 мест брони по два слоя, здоровье, режим игры, выученные рецепты и открытые узлы тех-дерева. - Запись: при выходе игрока, раз в 30 секунд и при остановке сервера. Чтение: при входе, так что игра продолжается с того места, где игрок остановился. - В Json добавлен писатель: quote, number, numbers, texts. - Файл пишется через временный файл и подменяется, так что оборванная запись не портит прошлую. Тесты: сервер 92 (было 88), добавлен PlayerDataTest. --- rsx-server-core/scripts/test.ps1 | 1 + .../java/net/rustnotmc/server/Player.java | 23 +- .../java/net/rustnotmc/server/Server.java | 98 ++++++- .../java/net/rustnotmc/server/ServerMain.java | 3 + .../net/rustnotmc/server/craft/Knowledge.java | 30 ++ .../rustnotmc/server/player/PlayerData.java | 260 ++++++++++++++++++ .../java/net/rustnotmc/server/util/Json.java | 64 +++++ .../server/player/PlayerDataTest.java | 166 +++++++++++ 8 files changed, 633 insertions(+), 12 deletions(-) create mode 100644 rsx-server-core/src/main/java/net/rustnotmc/server/player/PlayerData.java create mode 100644 rsx-server-core/src/test/java/net/rustnotmc/server/player/PlayerDataTest.java diff --git a/rsx-server-core/scripts/test.ps1 b/rsx-server-core/scripts/test.ps1 index a545cd7..feab2bf 100644 --- a/rsx-server-core/scripts/test.ps1 +++ b/rsx-server-core/scripts/test.ps1 @@ -52,6 +52,7 @@ $suites = @( 'net.rustnotmc.server.item.InventoryTest', 'net.rustnotmc.server.craft.CraftTest', 'net.rustnotmc.server.hitbox.HitboxTest', + 'net.rustnotmc.server.player.PlayerDataTest', 'net.rustnotmc.server.combat.DamageTest', 'net.rustnotmc.server.PlayTest' ) 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 9253724..d2def3a 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 @@ -83,6 +83,24 @@ public final class Player { return this.health; } + public float pitch() { + return this.pitch; + } + + /** + * Puts the player where they left off. + * + * The server reads this back from the file of the player, so that a game + * continues where it stopped instead of starting over in the spawn. + */ + public void placeAt(double x, double y, double z, float yaw, float pitch) { + this.x = x; + this.y = y; + this.z = z; + this.yaw = yaw; + this.pitch = pitch; + } + /** * Hurts the player. * @@ -214,11 +232,6 @@ public final class Player { public float yaw() { return this.yaw; } - - public float pitch() { - return this.pitch; - } - public boolean onGround() { return this.onGround; } 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 fbc074e..9eebf80 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 @@ -59,6 +59,8 @@ public final class Server { private volatile TokenAuth tokenAuth; private volatile boolean localAccounts = true; private volatile AdminRegistry admins = new AdminRegistry(null); + private volatile net.rustnotmc.server.player.PlayerData playerData = + new net.rustnotmc.server.player.PlayerData(null); private volatile HitboxRegistry hitboxes = new HitboxRegistry(null); private volatile ServerSocket listener; @@ -176,18 +178,89 @@ public final class Server { } /** - * Tells everybody whether they are an administrator. + * Writes down what a player leaves behind. * - * The client needs to know: the things that only an administrator may do - * are not offered to anybody else, and a right that is taken away while - * the player is looking has to disappear at once. + * The position, what they carry, what they know and how much life they + * have left. A file that cannot be written is a reason to say so, not a + * reason to stop the server. */ - public void sendAdminStates() { - for (Player player : this.players.values()) { - player.session().sendAdminState(player.isAdmin()); + public void savePlayer(Player player) { + try { + net.rustnotmc.server.player.PlayerData.PlayerState state = + new net.rustnotmc.server.player.PlayerData.PlayerState(); + state.x = player.x(); + state.y = player.y(); + state.z = player.z(); + state.yaw = player.yaw(); + state.pitch = player.pitch(); + state.health = player.health(); + state.gameMode = player.gameMode(); + for (int i = 0; i < state.inventory.length; i++) { + state.inventory[i][0] = player.inventory().get(i); + } + for (int i = 0; i < state.armor.length; i++) { + state.armor[i][0] = player.inventory().armor(i, 0); + state.armor[i][1] = player.inventory().armor(i, 1); + } + for (Integer id : player.knowledge().learnedRecipes()) { + state.recipes.add(String.valueOf(id)); + } + for (Integer id : player.knowledge().openedNodes()) { + state.tree.add(String.valueOf(id)); + } + this.playerData.save(player.account(), state); + } catch (IOException e) { + log("could not write the data of " + player.account() + ": " + + e.getMessage()); } } + /** + * Reads back what a player left behind. + * + * A player who never played starts in the spawn with a rock in the hand. + */ + private void loadPlayer(Player player) { + try { + net.rustnotmc.server.player.PlayerData.PlayerState state = + this.playerData.load(player.account()); + if (state == null) { + return; + } + player.placeAt(state.x, state.y, state.z, state.yaw, state.pitch); + player.heal(state.health - player.health()); + for (int i = 0; i < state.inventory.length; i++) { + player.inventory().set(i, state.inventory[i][0]); + } + for (int i = 0; i < state.armor.length; i++) { + player.inventory().setArmor(i, 0, state.armor[i][0]); + player.inventory().setArmor(i, 1, state.armor[i][1]); + } + List recipes = new ArrayList(); + for (String id : state.recipes) { + recipes.add(Integer.valueOf(id)); + } + List tree = new ArrayList(); + for (String id : state.tree) { + tree.add(Integer.valueOf(id)); + } + player.knowledge().restore(recipes, tree); + } catch (IOException e) { + log("could not read the data of " + player.account() + ": " + + e.getMessage()); + } + } + + /** Where the data of the players is kept. */ + public net.rustnotmc.server.player.PlayerData playerData() { + return this.playerData; + } + + public void playerData(net.rustnotmc.server.player.PlayerData playerData) { + this.playerData = playerData; + } + + /** * Whether the server's own accounts may be used. * @@ -333,6 +406,10 @@ public final class Server { for (ClientSession session : new ArrayList(this.sessions.values())) { session.close(); } + // everybody who is still here gets their things written down + for (Player player : this.players.values()) { + savePlayer(player); + } log("stopped after " + this.ticks.get() + " ticks"); } @@ -436,6 +513,11 @@ public final class Server { } } } + if (this.ticks.get() % 600L == 0L) { + for (Player player : this.players.values()) { + savePlayer(player); + } + } if ((this.ticks.get() & 0x3FFFL) == 0L) { this.tokens.sweep(); for (ClientSession session : this.sessions.values()) { @@ -456,6 +538,7 @@ public final class Server { session.account(), this.world.spawn()); player.viewDistance(this.viewDistance); player.gameMode(this.gameMode); + loadPlayer(player); this.players.put(Integer.valueOf(player.playerId()), player); session.player(player); @@ -518,6 +601,7 @@ public final class Server { this.sessions.remove(Long.valueOf(session.id())); Player player = session.player(); if (player != null) { + savePlayer(player); this.players.remove(Integer.valueOf(player.playerId())); for (Player other : this.players.values()) { other.session().send(Packets.ENTITY_REMOVE, diff --git a/rsx-server-core/src/main/java/net/rustnotmc/server/ServerMain.java b/rsx-server-core/src/main/java/net/rustnotmc/server/ServerMain.java index 8145451..e475389 100644 --- a/rsx-server-core/src/main/java/net/rustnotmc/server/ServerMain.java +++ b/rsx-server-core/src/main/java/net/rustnotmc/server/ServerMain.java @@ -129,6 +129,9 @@ public final class ServerMain { // The administrators are the server's own list. Path adminFile = adminsFile != null ? adminsFile : worldDir.resolve("admins.txt"); server.admins(new net.rustnotmc.server.admin.AdminRegistry(adminFile)); + // where the players leave their things + server.playerData(new net.rustnotmc.server.player.PlayerData( + worldDir.resolve("players"))); server.loadAccounts(accounts); final Path accountFile = accounts.toAbsolutePath(); 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 index 5c8dcc4..eeb02c0 100644 --- 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 @@ -128,6 +128,36 @@ public final class Knowledge { return ids; } + /** The recipes the player has learned, by the id of the item. */ + public List learnedRecipes() { + List out = new ArrayList(this.learned); + return out; + } + + /** The nodes the player has opened, by the id of the item. */ + public List openedNodes() { + List out = new ArrayList(this.unlocked); + return out; + } + + /** Forgets everything, so that a player can start over. */ + public void clear() { + this.learned.clear(); + this.unlocked.clear(); + } + + /** Puts back what a player knew before. */ + public void restore(List recipes, List tree) { + this.learned.clear(); + this.unlocked.clear(); + for (Integer id : recipes) { + this.learned.add(id); + } + for (Integer id : tree) { + this.unlocked.add(id); + } + } + /** 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/player/PlayerData.java b/rsx-server-core/src/main/java/net/rustnotmc/server/player/PlayerData.java new file mode 100644 index 0000000..652c326 --- /dev/null +++ b/rsx-server-core/src/main/java/net/rustnotmc/server/player/PlayerData.java @@ -0,0 +1,260 @@ +package net.rustnotmc.server.player; + +import java.io.IOException; +import java.nio.charset.StandardCharsets; +import java.nio.file.Files; +import java.nio.file.Path; +import java.util.ArrayList; +import java.util.List; +import net.rustnotmc.protocol.ItemRegistry; +import net.rustnotmc.protocol.ItemStack; +import net.rustnotmc.protocol.ItemType; +import net.rustnotmc.protocol.Packets; +import net.rustnotmc.server.item.Inventory; +import net.rustnotmc.server.util.Json; + +/** + * What a player leaves behind when they go. + * + * The position, what they carry, what they know and how much life they have + * left. It is written when the player goes and every half minute while they + * play, and it is read when they come back. + * + * One file per player, in the world folder, so that a person can look at it + * and see what the server thinks of them. + */ +public final class PlayerData { + + /** The folder the files are in. */ + private final Path folder; + + public PlayerData(Path folder) { + this.folder = folder; + } + + /** The file of one player. */ + public Path fileOf(String account) { + if (this.folder == null) { + return null; + } + return this.folder.resolve(safe(account) + ".json"); + } + + /** + * Writes what the player has. + * + * A file that cannot be written is not a reason to stop the server: the + * caller logs it and carries on. + */ + public boolean save(String account, PlayerState state) throws IOException { + if (this.folder == null) { + return false; + } + Files.createDirectories(this.folder); + StringBuilder out = new StringBuilder(); + out.append("{\n"); + out.append(" \"account\": ").append(Json.quote(account)).append(",\n"); + out.append(" \"version\": 1,\n"); + out.append(" \"position\": ").append(Json.numbers( + state.x, state.y, state.z, state.yaw, state.pitch)).append(",\n"); + out.append(" \"health\": ").append(Json.number(state.health)).append(",\n"); + out.append(" \"gameMode\": ").append(state.gameMode).append(",\n"); + out.append(" \"inventory\": ").append(slots(state.inventory)).append(",\n"); + out.append(" \"armor\": ").append(slots(state.armor)).append(",\n"); + out.append(" \"recipes\": ").append(Json.texts(state.recipes)).append(",\n"); + out.append(" \"tree\": ").append(Json.texts(state.tree)).append("\n"); + out.append("}\n"); + Path file = fileOf(account); + Path temp = file.resolveSibling(file.getFileName() + ".tmp"); + Files.write(temp, out.toString().getBytes(StandardCharsets.UTF_8)); + Files.move(temp, file, java.nio.file.StandardCopyOption.REPLACE_EXISTING); + return true; + } + + /** + * Reads what the player left behind. + * + * Returns null when there is nothing to read: a player who never played + * gets the world spawn and a rock in the hand. + */ + public PlayerState load(String account) throws IOException { + if (this.folder == null) { + return null; + } + Path file = fileOf(account); + if (file == null || !Files.isRegularFile(file)) { + return null; + } + Object root = Json.readFile(file); + PlayerState state = new PlayerState(); + double[] position = numbers(root, "position"); + if (position.length >= 5) { + state.x = position[0]; + state.y = position[1]; + state.z = position[2]; + state.yaw = (float) position[3]; + state.pitch = (float) position[4]; + } + state.health = (float) Json.number(root, "health", 100.0D); + state.gameMode = (int) Json.number(root, "gameMode", 0.0D); + readSlots(Json.get(root, "inventory"), state.inventory); + readSlots(Json.get(root, "armor"), state.armor); + state.recipes.addAll(texts(root, "recipes")); + state.tree.addAll(texts(root, "tree")); + return state; + } + + private static String slots(ItemStack[][] stacks) { + StringBuilder out = new StringBuilder("["); + for (int i = 0; i < stacks.length; i++) { + if (i > 0) { + out.append(','); + } + out.append('['); + for (int layer = 0; layer < stacks[i].length; layer++) { + if (layer > 0) { + out.append(','); + } + out.append(slot(stacks[i][layer])); + } + out.append(']'); + } + return out.append(']').toString(); + } + + private static String slot(ItemStack stack) { + if (stack == null || stack.isEmpty()) { + return "0"; + } + StringBuilder out = new StringBuilder("{"); + out.append("\"item\":").append(stack.item); + out.append(",\"count\":").append(stack.count); + if (stack.condition >= 0) { + out.append(",\"condition\":").append(stack.condition); + } + if (!stack.data().isEmpty()) { + out.append(",\"data\":{"); + boolean first = true; + for (java.util.Map.Entry entry : stack.data().entrySet()) { + if (!first) { + out.append(','); + } + first = false; + out.append(Json.quote(entry.getKey())).append(':') + .append(Json.quote(entry.getValue())); + } + out.append('}'); + } + return out.append('}').toString(); + } + + @SuppressWarnings("unchecked") + private static void readSlots(Object value, ItemStack[][] into) { + if (!(value instanceof List)) { + return; + } + List rows = (List) value; + for (int i = 0; i < into.length && i < rows.size(); i++) { + Object row = rows.get(i); + if (!(row instanceof List)) { + continue; + } + List layers = (List) row; + for (int layer = 0; layer < into[i].length && layer < layers.size(); layer++) { + into[i][layer] = readSlot(layers.get(layer)); + } + } + } + + @SuppressWarnings("unchecked") + private static ItemStack readSlot(Object value) { + if (!(value instanceof java.util.Map)) { + return ItemStack.EMPTY; + } + java.util.Map map = (java.util.Map) value; + int item = (int) Json.number(map, "item", 0.0D); + if (item == ItemRegistry.NOTHING) { + return ItemStack.EMPTY; + } + int count = (int) Json.number(map, "count", 1.0D); + int condition = (int) Json.number(map, "condition", -1.0D); + ItemStack stack = new ItemStack(item, count, condition); + Object data = Json.get(map, "data"); + if (data instanceof java.util.Map) { + for (java.util.Map.Entry entry + : ((java.util.Map) data).entrySet()) { + stack.set(entry.getKey(), String.valueOf(entry.getValue())); + } + } + return stack; + } + + private static double[] numbers(Object root, String key) { + Object got = Json.get(root, key); + if (!(got instanceof List)) { + return new double[0]; + } + List list = (List) got; + double[] out = new double[list.size()]; + for (int i = 0; i < out.length; i++) { + Object value = list.get(i); + out[i] = value instanceof Number ? ((Number) value).doubleValue() : 0.0D; + } + return out; + } + + @SuppressWarnings("unchecked") + private static List texts(Object root, String key) { + List out = new ArrayList(); + Object got = Json.get(root, key); + if (got instanceof List) { + for (Object value : (List) got) { + out.add(String.valueOf(value)); + } + } + return out; + } + + /** A name that is safe to use as a file name. */ + public static String safe(String account) { + if (account == null) { + return "unknown"; + } + StringBuilder out = new StringBuilder(); + for (int i = 0; i < account.length(); i++) { + char c = account.charAt(i); + boolean plain = (c >= 'a' && c <= 'z') || (c >= 'A' && c <= 'Z') + || (c >= '0' && c <= '9') || c == '-' || c == '_'; + out.append(plain ? c : '_'); + } + String name = out.toString(); + return name.isEmpty() ? "unknown" : name; + } + + /** Everything about a player that is written down. */ + public static final class PlayerState { + + public double x; + public double y; + public double z; + public float yaw; + public float pitch; + public float health = 100.0F; + public int gameMode; + public final ItemStack[][] inventory = new ItemStack[Inventory.SLOTS][1]; + public final ItemStack[][] armor = + new ItemStack[ItemType.ARMOR_SLOTS][2]; + public final List recipes = new ArrayList(); + public final List tree = new ArrayList(); + + public PlayerState() { + for (int i = 0; i < this.inventory.length; i++) { + this.inventory[i][0] = ItemStack.EMPTY; + } + for (int i = 0; i < this.armor.length; i++) { + this.armor[i][0] = ItemStack.EMPTY; + this.armor[i][1] = ItemStack.EMPTY; + } + } + } +} diff --git a/rsx-server-core/src/main/java/net/rustnotmc/server/util/Json.java b/rsx-server-core/src/main/java/net/rustnotmc/server/util/Json.java index e53ea75..5a5fc56 100644 --- a/rsx-server-core/src/main/java/net/rustnotmc/server/util/Json.java +++ b/rsx-server-core/src/main/java/net/rustnotmc/server/util/Json.java @@ -275,4 +275,68 @@ public final class Json { } return fallback; } + + // ------------------------------------------------------------- writing + + /** A string in JSON, with the quotes and the backslashes put right. */ + public static String quote(String value) { + if (value == null) { + return "\"\""; + } + StringBuilder out = new StringBuilder(value.length() + 2); + out.append('"'); + for (int i = 0; i < value.length(); i++) { + char c = value.charAt(i); + if (c == '"' || c == '\\') { + out.append('\\'); + } + if (c == '\n') { + out.append("\\n"); + continue; + } + if (c == '\r') { + out.append("\\r"); + continue; + } + if (c == '\t') { + out.append("\\t"); + continue; + } + out.append(c); + } + out.append('"'); + return out.toString(); + } + + /** A number in JSON, without a decimal tail when it is whole. */ + public static String number(double value) { + if (value == Math.rint(value) && !Double.isInfinite(value)) { + return Long.toString((long) value); + } + return Double.toString(value); + } + + /** A list of numbers in JSON. */ + public static String numbers(double... values) { + StringBuilder out = new StringBuilder("["); + for (int i = 0; i < values.length; i++) { + if (i > 0) { + out.append(','); + } + out.append(number(values[i])); + } + return out.append(']').toString(); + } + + /** A list of strings in JSON. */ + public static String texts(java.util.List values) { + StringBuilder out = new StringBuilder("["); + for (int i = 0; i < values.size(); i++) { + if (i > 0) { + out.append(','); + } + out.append(quote(values.get(i))); + } + return out.append(']').toString(); + } } diff --git a/rsx-server-core/src/test/java/net/rustnotmc/server/player/PlayerDataTest.java b/rsx-server-core/src/test/java/net/rustnotmc/server/player/PlayerDataTest.java new file mode 100644 index 0000000..ee6b32e --- /dev/null +++ b/rsx-server-core/src/test/java/net/rustnotmc/server/player/PlayerDataTest.java @@ -0,0 +1,166 @@ +package net.rustnotmc.server.player; + +import java.io.IOException; +import java.nio.file.Files; +import java.nio.file.Path; +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 leaves behind. + * + * The data of a player is the one thing that has to survive a restart: the + * position, the bag, the clothes and what the player knows. + */ +public final class PlayerDataTest { + + 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) throws Exception { + Path work = Path.of(args.length > 0 ? args[0] : "build/selftest"); + Files.createDirectories(work); + + run("what a player leaves is written down", new Throwing() { + public void go() throws Exception { testRoundTrip(work); } + }); + run("a player who never played has nothing to read", new Throwing() { + public void go() throws Exception { testMissing(work); } + }); + run("the file is a name that is safe", new Throwing() { + public void go() throws Exception { testSafeName(work); } + }); + run("the clothes keep their layers", new Throwing() { + public void go() throws Exception { testArmor(work); } + }); + + 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 testRoundTrip(Path work) throws Exception { + Path folder = work.resolve("players-test"); + PlayerData data = new PlayerData(folder); + + PlayerData.PlayerState state = new PlayerData.PlayerState(); + state.x = 120.5D; + state.y = 64.0D; + state.z = -33.25D; + state.yaw = 1.5F; + state.pitch = -0.25F; + state.health = 73.5F; + state.gameMode = 1; + state.inventory[0][0] = ItemStack.of(ItemRegistry.ROCK, 1); + state.inventory[3][0] = new ItemStack(ItemRegistry.SCRAP, 17, -1); + state.recipes.add("16"); + state.tree.add("18"); + + yes("it is written", data.save("Player", state)); + yes("the file is there", Files.isRegularFile(data.fileOf("Player"))); + + PlayerData.PlayerState back = data.load("Player"); + yes("it is read back", back != null); + if (back == null) { + return; + } + near("the position", 120.5D, back.x); + near("the height", 64.0D, back.y); + near("the other side", -33.25D, back.z); + near("the life", 73.5F, back.health); + eq("the mode", Integer.valueOf(1), Integer.valueOf(back.gameMode)); + eq("the rock is in the first slot", Integer.valueOf(ItemRegistry.ROCK), + Integer.valueOf(back.inventory[0][0].item)); + eq("and the scrap in the fourth", Integer.valueOf(17), + Integer.valueOf(back.inventory[3][0].count)); + eq("the recipes are remembered", Integer.valueOf(1), + Integer.valueOf(back.recipes.size())); + eq("and the tree too", Integer.valueOf(1), Integer.valueOf(back.tree.size())); + } + + private static void testMissing(Path work) throws Exception { + PlayerData data = new PlayerData(work.resolve("players-missing")); + yes("there is nothing to read", data.load("Nobody") == null); + } + + private static void testSafeName(Path work) throws Exception { + PlayerData data = new PlayerData(work.resolve("players-safe")); + String name = data.fileOf("bad/name:with*chars").getFileName().toString(); + yes("the name is safe", !name.contains("/") && !name.contains(":") + && !name.contains("*")); + } + + private static void testArmor(Path work) throws Exception { + Path folder = work.resolve("players-armor"); + PlayerData data = new PlayerData(folder); + PlayerData.PlayerState state = new PlayerData.PlayerState(); + // the chest has two layers: the clothes under and the armour over + state.armor[ItemType.ARMOR_CHEST - 1][0] = ItemStack.of(ItemRegistry.T_SHIRT, 1); + state.armor[ItemType.ARMOR_CHEST - 1][1] = ItemStack.of(ItemRegistry.ROADSIGN_JACKET, 1); + data.save("Dressed", state); + + PlayerData.PlayerState back = data.load("Dressed"); + yes("it is read back", back != null); + if (back == null) { + return; + } + eq("the clothes are under", Integer.valueOf(ItemRegistry.T_SHIRT), + Integer.valueOf(back.armor[ItemType.ARMOR_CHEST - 1][0].item)); + eq("the armour is over", Integer.valueOf(ItemRegistry.ROADSIGN_JACKET), + Integer.valueOf(back.armor[ItemType.ARMOR_CHEST - 1][1].item)); + } + + private static void near(Object what, double expected, double actual) { + if (Math.abs(expected - actual) > 0.001D) { + failed++; + failures.add(what + ": expected " + expected + " but got " + actual); + } + } +}