Данные игрока сохраняются

- PlayerData: файл на игрока в world/players/<аккаунт>.json. Позиция, рыскание и
  тангаж, 30 слотов инвентаря, 7 мест брони по два слоя, здоровье, режим игры,
  выученные рецепты и открытые узлы тех-дерева.
- Запись: при выходе игрока, раз в 30 секунд и при остановке сервера. Чтение:
  при входе, так что игра продолжается с того места, где игрок остановился.
- В Json добавлен писатель: quote, number, numbers, texts.
- Файл пишется через временный файл и подменяется, так что оборванная запись
  не портит прошлую.

Тесты: сервер 92 (было 88), добавлен PlayerDataTest.
This commit is contained in:
sasheg dev
2026-10-22 08:57:13 +03:00
parent d2a0a7a93b
commit fd596d4ff7
8 changed files with 633 additions and 12 deletions
+1
View File
@@ -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'
)
@@ -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;
}
@@ -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<Integer> recipes = new ArrayList<Integer>();
for (String id : state.recipes) {
recipes.add(Integer.valueOf(id));
}
List<Integer> tree = new ArrayList<Integer>();
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<ClientSession>(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,
@@ -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();
@@ -128,6 +128,36 @@ public final class Knowledge {
return ids;
}
/** The recipes the player has learned, by the id of the item. */
public List<Integer> learnedRecipes() {
List<Integer> out = new ArrayList<Integer>(this.learned);
return out;
}
/** The nodes the player has opened, by the id of the item. */
public List<Integer> openedNodes() {
List<Integer> out = new ArrayList<Integer>(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<Integer> recipes, List<Integer> 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;
@@ -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<String, String> 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<Object> rows = (List<Object>) value;
for (int i = 0; i < into.length && i < rows.size(); i++) {
Object row = rows.get(i);
if (!(row instanceof List)) {
continue;
}
List<Object> layers = (List<Object>) 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<String, Object> map = (java.util.Map<String, Object>) 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<String, Object> entry
: ((java.util.Map<String, Object>) 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<Object> list = (List<Object>) 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<String> texts(Object root, String key) {
List<String> out = new ArrayList<String>();
Object got = Json.get(root, key);
if (got instanceof List) {
for (Object value : (List<Object>) 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<String> recipes = new ArrayList<String>();
public final List<String> tree = new ArrayList<String>();
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;
}
}
}
}
@@ -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<String> 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();
}
}
@@ -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<String> failures = new java.util.ArrayList<String>();
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);
}
}
}