From 68dfea27b518fc01fc0e7f3058f9cd7b0c4c4679 Mon Sep 17 00:00:00 2001 From: sasheg dev <152582704+SashegDev@users.noreply.github.com> Date: Mon, 19 Oct 2026 18:58:07 +0300 Subject: [PATCH] =?UTF-8?q?=D0=9C=D0=B8=D1=80=20=D0=BD=D0=B0=20=D1=81?= =?UTF-8?q?=D0=B5=D1=80=D0=B2=D0=B5=D1=80=D0=B5:=20=D1=87=D0=B0=D0=BD?= =?UTF-8?q?=D0=BA=D0=B8,=20=D0=B3=D0=B5=D0=BD=D0=B5=D1=80=D0=B0=D1=86?= =?UTF-8?q?=D0=B8=D1=8F,=20=D1=81=D0=BE=D1=85=D1=80=D0=B0=D0=BD=D0=B5?= =?UTF-8?q?=D0=BD=D0=B8=D0=B5=20=D0=B8=20=D0=BF=D0=BE=D1=82=D0=BE=D0=BA=20?= =?UTF-8?q?=D0=B8=D0=B3=D1=80=D0=BE=D0=BA=D0=B0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Сервер умел принимать игроков и не умел ничего, что можно увидеть. Мир был не написан: ни чанков, ни генерации, ни сохранения, ни игровых пакетов. Сделано: - Пакет world: ChunkPos, Chunk, Blocks (теперь в протоколе), WorldGenerator, ChunkStorage, World. Генерация чистая функция от зерна и координат, без java.util.Random и без общего состояния, потому что чанк может появиться через месяц после соседнего, на другой машине, и обязан совпасть с ним побайтово. Сохранение — файл на чанк, запись во временный файл и перенос, версия и контрольная сумма: половина записанного файла это мир, который молча испортился. - Снимок чанка и запись блока теперь под одним замком. Раньше копия могла сняться посреди записи блока, и на диск уходила половина изменения. - Player: позиция, вид, список отправленных чанков, проверка досягаемости блока. Клиент не может переписать блок, до которого не дотянулся. - Сервер держит мир, крутит его по тику, выкидывает давно не тронутые чанки, шлет чанки вокруг игрока и рассылает изменения блоков и передвижения тем, кто их видит. - Клиентский сетевой слой в игре еще не тронут: это следующий шаг. Проверено: 45 тестов сервера. Из них 25 на мир (детерминизм, круг сохранения, автосохранение, вытеснение, отказ битому файлу) и 9 сквозных через настоящий сокет: игрок входит, получает 25 чанков, чанк совпадает с террейном сервера, блок ломается и остаётся сломанным после перезапуска сервера, два игрока видят друг друга. --- .gitignore | 7 + rsx-server-core/scripts/run-server.ps1 | 12 +- rsx-server-core/scripts/test.ps1 | 18 +- .../net/rustnotmc/server/ClientSession.java | 194 ++++-- .../java/net/rustnotmc/server/Player.java | 248 +++++++ .../java/net/rustnotmc/server/Server.java | 100 +++ .../java/net/rustnotmc/server/ServerMain.java | 19 +- .../net/rustnotmc/server/world/Chunk.java | 149 ++++ .../net/rustnotmc/server/world/ChunkPos.java | 64 ++ .../rustnotmc/server/world/ChunkStorage.java | 196 ++++++ .../net/rustnotmc/server/world/World.java | 238 +++++++ .../server/world/WorldGenerator.java | 152 ++++ .../java/net/rustnotmc/server/PlayClient.java | 150 ++++ .../java/net/rustnotmc/server/PlayTest.java | 441 ++++++++++++ .../net/rustnotmc/server/world/WorldTest.java | 658 ++++++++++++++++++ 15 files changed, 2588 insertions(+), 58 deletions(-) create mode 100644 rsx-server-core/src/main/java/net/rustnotmc/server/Player.java create mode 100644 rsx-server-core/src/main/java/net/rustnotmc/server/world/Chunk.java create mode 100644 rsx-server-core/src/main/java/net/rustnotmc/server/world/ChunkPos.java create mode 100644 rsx-server-core/src/main/java/net/rustnotmc/server/world/ChunkStorage.java create mode 100644 rsx-server-core/src/main/java/net/rustnotmc/server/world/World.java create mode 100644 rsx-server-core/src/main/java/net/rustnotmc/server/world/WorldGenerator.java create mode 100644 rsx-server-core/src/test/java/net/rustnotmc/server/PlayClient.java create mode 100644 rsx-server-core/src/test/java/net/rustnotmc/server/PlayTest.java create mode 100644 rsx-server-core/src/test/java/net/rustnotmc/server/world/WorldTest.java diff --git a/.gitignore b/.gitignore index 55809f3..68ac6ea 100644 --- a/.gitignore +++ b/.gitignore @@ -19,6 +19,13 @@ out/ target/ *.class +# what a running server leaves behind. The patterns are anchored on purpose: +# a bare "world/" would also match the world package under +# rsx-server-core/src/main/java/net/rustnotmc/server/world, which is ours. +/rsx-server-core/world/ +/rsx-server-core/accounts*.txt +/rsx-server-core/*.txt + # jars are build products; the third-party ones we redistribute are un-ignored *.jar !rsx-client/third_party/**/*.jar diff --git a/rsx-server-core/scripts/run-server.ps1 b/rsx-server-core/scripts/run-server.ps1 index b1d61be..4119bc7 100644 --- a/rsx-server-core/scripts/run-server.ps1 +++ b/rsx-server-core/scripts/run-server.ps1 @@ -2,7 +2,7 @@ # # ASCII only, for the same reason as the other scripts. # -# run-server.ps1 [-Port 25565] [-Accounts path] [-Capacity 200] +# run-server.ps1 [-Port 25565] [-Accounts path] [-Capacity 200] [-World path] param( [int]$Port = 25565, @@ -11,7 +11,9 @@ param( [string]$Name = 'RustNotMC', [string]$Motd = 'a survival game', [string]$Register = '', - [string]$Password = '' + [string]$Password = '', + [string]$World = '', + [long]$Seed = 0 ) $ErrorActionPreference = 'Stop' @@ -32,6 +34,7 @@ foreach ($dep in @($Protocol, $Auth, $Server)) { } if (-not $Accounts) { $Accounts = Join-Path $Repo 'accounts.txt' } +if (-not $World) { $World = Join-Path $Repo 'world' } if ($Register) { & java -cp "$Protocol;$Auth;$Server" net.rustnotmc.server.ServerMain ` @@ -47,10 +50,13 @@ Write-Host ' rsx-server-core ' -NoNewline Write-Host (Split-Path -Leaf $Server) -ForegroundColor DarkGray Write-Host '' Write-Host " accounts : $Accounts" +Write-Host " world : $World" +Write-Host " seed : $Seed" Write-Host " port : $Port" Write-Host " capacity : $Capacity" Write-Host '' & java -cp "$Protocol;$Auth;$Server" net.rustnotmc.server.ServerMain ` - --port $Port --accounts $Accounts --capacity $Capacity --name $Name --motd $Motd + --port $Port --accounts $Accounts --capacity $Capacity --name $Name --motd $Motd ` + --world $World --seed $Seed exit $LASTEXITCODE \ No newline at end of file diff --git a/rsx-server-core/scripts/test.ps1 b/rsx-server-core/scripts/test.ps1 index abcb80c..062ab65 100644 --- a/rsx-server-core/scripts/test.ps1 +++ b/rsx-server-core/scripts/test.ps1 @@ -44,11 +44,19 @@ $testFiles = @(Get-ChildItem (Join-Path $Repo 'src\test\java') -Recurse -Filter if ($LASTEXITCODE -ne 0) { Write-Host ' Test failed to compile.' -ForegroundColor Red; exit 1 } Say "compiled $($mainFiles.Count) main + $($testFiles.Count) test files" -& java -cp "$cp;$mainOut;$testOut" net.rustnotmc.server.ServerTest (Join-Path $Out 'selftest') -if ($LASTEXITCODE -ne 0) { - Write-Host '' - Write-Host ' SERVER TESTS FAILED' -ForegroundColor Red - exit 1 +$suites = @( + 'net.rustnotmc.server.ServerTest', + 'net.rustnotmc.server.world.WorldTest', + 'net.rustnotmc.server.PlayTest' +) +foreach ($suite in $suites) { + Say "running $suite" + & java -cp "$cp;$mainOut;$testOut" $suite (Join-Path $Out 'selftest') + if ($LASTEXITCODE -ne 0) { + Write-Host '' + Write-Host " $suite FAILED" -ForegroundColor Red + exit 1 + } } Write-Host '' 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 2642cdf..95cfe9c 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,8 +9,11 @@ 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.ChunkFormat; import net.rustnotmc.protocol.Frame; import net.rustnotmc.protocol.FrameDecoder; +import net.rustnotmc.protocol.Packet; +import net.rustnotmc.protocol.Packets; import net.rustnotmc.protocol.ProtocolException; /** @@ -50,6 +53,7 @@ public final class ClientSession implements Runnable { private volatile int playerId = -1; private volatile int entityId = -1; private volatile String lastError; + private volatile Player player; private byte[] nonce; private byte[] salt; @@ -105,6 +109,15 @@ public final class ClientSession implements Runnable { return this.lastError; } + /** The player this connection is, once it is in the world. */ + public Player player() { + return this.player; + } + + void player(Player player) { + this.player = player; + } + public boolean isClosed() { return this.closed.get(); } @@ -176,16 +189,28 @@ public final class ClientSession implements Runnable { private boolean handle(Frame frame) { try { switch (frame.packetId()) { - case net.rustnotmc.protocol.Packets.HELLO: + case Packets.HELLO: onHello(frame); return true; - case net.rustnotmc.protocol.Packets.AUTH: + case Packets.AUTH: onAuth(frame); return true; - case net.rustnotmc.protocol.Packets.LOGIN: + case Packets.LOGIN: onLogin(frame); return true; - case net.rustnotmc.protocol.Packets.DISCONNECT: + case Packets.JOIN_WORLD: + onJoinWorld(frame); + return true; + case Packets.BLOCK_CHANGE: + onBlockChange(frame); + return true; + case Packets.PLAYER_MOVE: + onPlayerMove(frame); + return true; + case Packets.PING: + onPing(frame); + return true; + case Packets.DISCONNECT: this.lastError = "peer said goodbye"; close(); return false; @@ -210,24 +235,24 @@ public final class ClientSession implements Runnable { return; } Buf in = new Buf(frame.body()); - net.rustnotmc.protocol.Packets.Hello hello = - net.rustnotmc.protocol.Packets.Hello.read(in); + Packets.Hello hello = + Packets.Hello.read(in); if (hello.protocolVersion != net.rustnotmc.protocol.Frame.VERSION) { - send(net.rustnotmc.protocol.Packets.HELLO_ACK, - new net.rustnotmc.protocol.Packets.HelloAck( + send(Packets.HELLO_ACK, + new Packets.HelloAck( net.rustnotmc.protocol.Frame.VERSION, this.server.serverName(), this.server.motd(), this.server.online(), this.server.capacity(), - new byte[net.rustnotmc.protocol.Packets.CHALLENGE_BYTES], - new byte[net.rustnotmc.protocol.Packets.CHALLENGE_BYTES])); + new byte[Packets.CHALLENGE_BYTES], + new byte[Packets.CHALLENGE_BYTES])); this.lastError = "protocol version " + hello.protocolVersion + " is not ours"; close(); return; } this.nonce = net.rustnotmc.auth.Secure.bytes( - net.rustnotmc.protocol.Packets.CHALLENGE_BYTES); + Packets.CHALLENGE_BYTES); // the salt has to belong to the account the client is about to claim, // and the client cannot derive its key without it, so Hello has to name // the account before this point @@ -235,8 +260,8 @@ public final class ClientSession implements Runnable { this.state = SessionState.HANDSHAKING; touch(HANDSHAKE_TIMEOUT_MILLIS); - send(net.rustnotmc.protocol.Packets.HELLO_ACK, - new net.rustnotmc.protocol.Packets.HelloAck( + send(Packets.HELLO_ACK, + new Packets.HelloAck( net.rustnotmc.protocol.Frame.VERSION, this.server.serverName(), this.server.motd(), this.server.online(), this.server.capacity(), @@ -248,13 +273,13 @@ public final class ClientSession implements Runnable { return; } Buf in = new Buf(frame.body()); - net.rustnotmc.protocol.Packets.Auth auth = - net.rustnotmc.protocol.Packets.Auth.read(in); + Packets.Auth auth = + Packets.Auth.read(in); long wait = this.server.throttle().waitSeconds(auth.account, this.address); if (wait > 0L) { - send(net.rustnotmc.protocol.Packets.AUTH_ACK, - net.rustnotmc.protocol.Packets.AuthAck.refused( + send(Packets.AUTH_ACK, + Packets.AuthAck.refused( "too many attempts, wait " + wait + "s")); close(); return; @@ -265,8 +290,8 @@ public final class ClientSession implements Runnable { // the same work is done for an absent account as for a wrong // password, so the reply does not say which it was this.server.throttle().failed(auth.account, this.address); - send(net.rustnotmc.protocol.Packets.AUTH_ACK, - net.rustnotmc.protocol.Packets.AuthAck.refused( + send(Packets.AUTH_ACK, + Packets.AuthAck.refused( "wrong name or password")); close(); return; @@ -275,8 +300,8 @@ public final class ClientSession implements Runnable { if (!this.server.verifyProof(name, auth.account, this.salt, this.nonce, auth.proof)) { this.server.throttle().failed(auth.account, this.address); this.server.upgradeRecord(name); - send(net.rustnotmc.protocol.Packets.AUTH_ACK, - net.rustnotmc.protocol.Packets.AuthAck.refused( + send(Packets.AUTH_ACK, + Packets.AuthAck.refused( "wrong name or password")); close(); return; @@ -287,8 +312,8 @@ public final class ClientSession implements Runnable { this.account = name; this.token = issued.value; this.state = SessionState.AUTHENTICATED; - send(net.rustnotmc.protocol.Packets.AUTH_ACK, - net.rustnotmc.protocol.Packets.AuthAck.ok(issued.value, issued.expiresAt)); + send(Packets.AUTH_ACK, + Packets.AuthAck.ok(issued.value, issued.expiresAt)); this.server.log("auth ok: " + name + " from " + this.address); } @@ -297,24 +322,24 @@ public final class ClientSession implements Runnable { return; } Buf in = new Buf(frame.body()); - net.rustnotmc.protocol.Packets.Login login = - net.rustnotmc.protocol.Packets.Login.read(in); + Packets.Login login = + Packets.Login.read(in); if (!this.server.tokens().valid(login.token, login.account)) { - send(net.rustnotmc.protocol.Packets.LOGIN_ACK, - net.rustnotmc.protocol.Packets.LoginAck.refused("bad or expired token")); + send(Packets.LOGIN_ACK, + Packets.LoginAck.refused("bad or expired token")); close(); return; } if (this.server.full()) { - send(net.rustnotmc.protocol.Packets.LOGIN_ACK, - net.rustnotmc.protocol.Packets.LoginAck.refused("server is full")); + send(Packets.LOGIN_ACK, + Packets.LoginAck.refused("server is full")); close(); return; } if (this.server.alreadyPlaying(this.account)) { - send(net.rustnotmc.protocol.Packets.LOGIN_ACK, - net.rustnotmc.protocol.Packets.LoginAck.refused( + send(Packets.LOGIN_ACK, + Packets.LoginAck.refused( "that account is already playing")); close(); return; @@ -324,13 +349,96 @@ public final class ClientSession implements Runnable { this.entityId = this.playerId; this.state = SessionState.PLAYING; touch(Long.MAX_VALUE / 4L); - send(net.rustnotmc.protocol.Packets.LOGIN_ACK, - net.rustnotmc.protocol.Packets.LoginAck.ok(this.playerId, this.entityId)); + send(Packets.LOGIN_ACK, + Packets.LoginAck.ok(this.playerId, this.entityId)); this.server.joined(this); this.server.log("player in: " + this.account + " id " + this.playerId + " from " + this.address); } + /** + * The client asking to be put in the world. + * + * The client may say where it thinks it is, which is how a reconnect puts + * a player back where it was standing rather than at the spawn. The server + * answers with the world, and then sends the chunks around the player. + */ + private void onJoinWorld(Frame frame) { + if (this.state != SessionState.PLAYING || this.player == null) { + return; + } + Packets.JoinWorld join = Packets.JoinWorld.read(new Buf(frame.body())); + + if (join.hasPosition) { + this.player.moveTo(join.x, join.y, join.z, join.yaw, join.pitch, true); + } + + double[] spawn = this.server.world().spawn(); + send(Packets.WORLD_JOIN, new Packets.WorldJoin( + this.entityId, + this.server.world().seed(), + spawn[0], spawn[1], spawn[2], + this.player.viewDistance(), + ChunkFormat.HEIGHT)); + this.player.streamChunks(this.server.world()); + } + + /** + * A block the player wants to change. + * + * The server decides whether it happens, and tells everybody who can see + * it when it does. A client that asks for a block it cannot reach is + * ignored rather than refused, because refusing says that something is + * listening. + */ + private void onBlockChange(Frame frame) { + if (this.state != SessionState.PLAYING || this.player == null) { + return; + } + Packets.BlockChange change = Packets.BlockChange.read(new Buf(frame.body())); + if (this.player.changeBlock(this.server.world(), + change.x, change.y, change.z, change.blockId)) { + this.server.broadcastBlockChange(change.x, change.y, change.z, + change.blockId); + } + } + + /** 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 + // server: it would end up in broadcasts, in chunk lookups and in the + // saves. It is dropped here, where it enters. + if (!finite(move.x) || !finite(move.y) || !finite(move.z)) { + return; + } + if (!finite(move.yaw) || !finite(move.pitch)) { + return; + } + + if (this.player.moveTo(move.x, move.y, move.z, move.yaw, move.pitch, + move.onGround)) { + // the player crossed into another chunk, so there is more world to + // send + this.player.streamChunks(this.server.world()); + } + this.server.broadcastEntityMove(this.player); + } + + /** A liveness check from the client. */ + private void onPing(Frame frame) { + Packets.Ping ping = Packets.Ping.read(new Buf(frame.body())); + send(Packets.PONG, new Packets.Pong(ping.id)); + } + + private static boolean finite(double value) { + return !Double.isNaN(value) && !Double.isInfinite(value); + } + /** The canonical account name for the name the client typed. */ private String accountBehind(String typed) { return this.server.canonicalAccount(typed); @@ -339,24 +447,12 @@ public final class ClientSession implements Runnable { // --------------------------------------------------------------- output /** Sends a packet. Silently ignored once the socket is gone. */ - public void send(int packetId, Object packet) { + public void send(int packetId, Packet packet) { if (this.closed.get()) { return; } try { - Buf body; - if (packet instanceof net.rustnotmc.protocol.Packets.HelloAck) { - body = ((net.rustnotmc.protocol.Packets.HelloAck) packet).write(); - } else if (packet instanceof net.rustnotmc.protocol.Packets.AuthAck) { - body = ((net.rustnotmc.protocol.Packets.AuthAck) packet).write(); - } else if (packet instanceof net.rustnotmc.protocol.Packets.LoginAck) { - body = ((net.rustnotmc.protocol.Packets.LoginAck) packet).write(); - } else if (packet instanceof net.rustnotmc.protocol.Packets.Disconnect) { - body = ((net.rustnotmc.protocol.Packets.Disconnect) packet).write(); - } else { - throw new IllegalArgumentException("no codec for " + packet); - } - Frame frame = new Frame(packetId, body.toByteArray()); + Frame frame = new Frame(packetId, packet.write().toByteArray()); synchronized (this.socket) { this.socket.getOutputStream().write(frame.encode().toByteArray()); this.socket.getOutputStream().flush(); @@ -370,8 +466,8 @@ public final class ClientSession implements Runnable { if (this.closed.get()) { return; } - send(net.rustnotmc.protocol.Packets.DISCONNECT, - new net.rustnotmc.protocol.Packets.Disconnect(reason)); + send(Packets.DISCONNECT, + new Packets.Disconnect(reason)); close(); } 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 new file mode 100644 index 0000000..23f358e --- /dev/null +++ b/rsx-server-core/src/main/java/net/rustnotmc/server/Player.java @@ -0,0 +1,248 @@ +package net.rustnotmc.server; + +import java.io.IOException; +import java.util.HashSet; +import java.util.Iterator; +import java.util.Set; +import net.rustnotmc.protocol.Blocks; +import net.rustnotmc.protocol.Packets; +import net.rustnotmc.server.world.Chunk; +import net.rustnotmc.server.world.ChunkPos; +import net.rustnotmc.server.world.World; + +/** + * A player in the world. + * + * The session is the pipe; this is what the player is doing. The two are kept + * apart because the session is about a connection and the player is about a + * place, and a place outlives the connections that visit it. + * + * The player owns the list of chunks it has been sent. That list is what makes + * streaming cheap: a chunk is only ever sent once, and it is only sent again + * after the player has walked away from it and come back. + */ +public final class Player { + + /** How far a player may reach to change a block, in blocks. */ + public static final double REACH = 5.0D; + + /** How far a player may see, in chunks, unless it asks for less. */ + public static final int DEFAULT_VIEW_DISTANCE = 6; + + private final ClientSession session; + private final int playerId; + private final int entityId; + private final String account; + + private double x; + private double y; + private double z; + private float yaw; + private float pitch; + private boolean onGround; + + private int viewDistance; + private final Set sentChunks = new HashSet(); + private final Set unreadableChunks = new HashSet(); + private int chunksSent; + + Player(ClientSession session, int playerId, int entityId, String account, + double[] spawn) { + this.session = session; + this.playerId = playerId; + this.entityId = entityId; + this.account = account; + this.x = spawn[0]; + this.y = spawn[1]; + this.z = spawn[2]; + this.viewDistance = DEFAULT_VIEW_DISTANCE; + } + + // -------------------------------------------------------------- getters + + public ClientSession session() { + return this.session; + } + + public int playerId() { + return this.playerId; + } + + public int entityId() { + return this.entityId; + } + + public String account() { + return this.account; + } + + public double x() { + return this.x; + } + + public double y() { + return this.y; + } + + public double z() { + return this.z; + } + + public float yaw() { + return this.yaw; + } + + public float pitch() { + return this.pitch; + } + + public boolean onGround() { + return this.onGround; + } + + public int viewDistance() { + return this.viewDistance; + } + + public void viewDistance(int chunks) { + if (chunks < 1) { + chunks = 1; + } + if (chunks > 32) { + chunks = 32; + } + this.viewDistance = chunks; + } + + /** The chunk the player is standing in. */ + public ChunkPos chunkPos() { + return new ChunkPos(floor(this.x / 16.0D), floor(this.z / 16.0D)); + } + + /** How many chunks this player has been sent in total. */ + public int chunksSent() { + return this.chunksSent; + } + + /** The chunks this player currently has. */ + public Set sentChunks() { + return this.sentChunks; + } + + /** + * Whether the player can see a chunk. + * + * Chebyshev distance, because a view distance is a square and not a + * circle: a player at the corner of its view can see the chunk diagonally + * across it. + */ + public boolean canSee(ChunkPos pos) { + ChunkPos here = chunkPos(); + return Math.max(Math.abs(pos.x - here.x), Math.abs(pos.z - here.z)) + <= this.viewDistance; + } + + // --------------------------------------------------------------- moving + + /** + * Moves the player, and says whether the move crossed into another chunk. + * + * Crossing a chunk boundary is what makes the world stream, so it is worth + * telling the caller about rather than working out twice. + */ + public boolean moveTo(double x, double y, double z, float yaw, float pitch, + boolean onGround) { + ChunkPos before = chunkPos(); + this.x = x; + this.y = y; + this.z = z; + this.yaw = yaw; + this.pitch = pitch; + this.onGround = onGround; + return !before.equals(chunkPos()); + } + + // ------------------------------------------------------------- streaming + + /** + * Sends the chunks the player can see and does not have yet. + * + * Also forgets the chunks that have fallen out of view, so that walking + * back sends them again. A chunk that cannot be read is remembered as + * unreadable and left alone, because a damaged file that is retried on + * every step turns one bad chunk into a busy loop. + * + * Returns how many chunks were sent. + */ + public int streamChunks(World world) { + ChunkPos here = chunkPos(); + int sent = 0; + + for (int dx = -this.viewDistance; dx <= this.viewDistance; dx++) { + for (int dz = -this.viewDistance; dz <= this.viewDistance; dz++) { + ChunkPos pos = new ChunkPos(here.x + dx, here.z + dz); + Long key = Long.valueOf(pos.key()); + if (this.sentChunks.contains(key) || this.unreadableChunks.contains(key)) { + continue; + } + Chunk chunk; + try { + chunk = world.chunkAt(pos); + } catch (IOException broken) { + this.unreadableChunks.add(key); + continue; + } + this.session.send(Packets.CHUNK_DATA, + new Packets.ChunkData(pos.x, pos.z, chunk.snapshot())); + this.sentChunks.add(key); + this.chunksSent++; + sent++; + } + } + + Iterator it = this.sentChunks.iterator(); + while (it.hasNext()) { + Long key = it.next(); + ChunkPos pos = ChunkPos.fromKey(key.longValue()); + if (Math.max(Math.abs(pos.x - here.x), Math.abs(pos.z - here.z)) + > this.viewDistance) { + it.remove(); + } + } + return sent; + } + + // -------------------------------------------------------------- building + + /** + * Changes a block, if the player is allowed to. + * + * Two things are checked: that the block id is one the world knows, and + * that the player is close enough to have plausibly touched it. Without the + * second, a client can rewrite the world from the other side of it. + */ + public boolean changeBlock(World world, int x, int y, int z, byte id) { + 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) { + return false; + } + return world.setBlock(x, y, z, id); + } + + /** Floor division that works for negative numbers, unlike a cast. */ + private static int floor(double value) { + int truncated = (int) value; + return value < truncated ? truncated - 1 : truncated; + } + + @Override + public String toString() { + return "Player[" + this.account + " id " + this.playerId + + " at " + this.x + ", " + this.y + ", " + this.z + "]"; + } +} 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 222df18..a4c0d7a 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 @@ -17,6 +17,9 @@ import net.rustnotmc.auth.LoginThrottle; import net.rustnotmc.auth.Passwords; import net.rustnotmc.auth.TokenService; import net.rustnotmc.protocol.Packets; +import net.rustnotmc.server.world.ChunkPos; +import net.rustnotmc.server.world.ChunkStorage; +import net.rustnotmc.server.world.World; /** * The server: a listening socket, a tick loop and the rules for who gets in. @@ -44,27 +47,61 @@ public final class Server { private final LoginThrottle throttle = new LoginThrottle(); private final Map sessions = new ConcurrentHashMap(); + private final Map players = new ConcurrentHashMap(); private final AtomicInteger playerIds = new AtomicInteger(1); private final AtomicLong sessionIds = new AtomicLong(1L); private final AtomicLong ticks = new AtomicLong(); private final AtomicInteger peakOnline = new AtomicInteger(); + private final World world; + private volatile ServerSocket listener; private volatile Thread acceptThread; private volatile Thread tickThread; private volatile boolean running; private volatile long tickNanos; private volatile String tickBacklog = ""; + private volatile int viewDistance = Player.DEFAULT_VIEW_DISTANCE; public Server(String name, String motd, int capacity, int port) { + this(name, motd, capacity, port, new World(0L, ChunkStorage.memoryOnly())); + } + + public Server(String name, String motd, int capacity, int port, World world) { this.name = name; this.motd = motd; this.capacity = capacity; this.port = port; + this.world = world; } // -------------------------------------------------------------- getters + /** The world this server runs. */ + public World world() { + return this.world; + } + + /** The players in the world, by player id. */ + public Map players() { + return this.players; + } + + /** How far a new player sees, in chunks. */ + public int viewDistance() { + return this.viewDistance; + } + + public void viewDistance(int chunks) { + if (chunks < 1) { + chunks = 1; + } + if (chunks > 32) { + chunks = 32; + } + this.viewDistance = chunks; + } + public AccountStore accounts() { return this.accounts; } @@ -236,6 +273,13 @@ public final class Server { /** 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 + // itself is measured in ticks, so this only has to be often enough to + // notice, not often enough to be right + if (this.ticks.get() % 20L == 0L) { + this.world.evictIdle(); + } if ((this.ticks.get() & 0x3FFFL) == 0L) { this.tokens.sweep(); for (ClientSession session : this.sessions.values()) { @@ -252,6 +296,24 @@ public final class Server { } void joined(ClientSession session) { + Player player = new Player(session, session.playerId(), session.entityId(), + session.account(), this.world.spawn()); + player.viewDistance(this.viewDistance); + this.players.put(Integer.valueOf(player.playerId()), player); + session.player(player); + + // the newcomer is told about everybody who is already here, and + // everybody who is already here is told about the newcomer. The + // newcomer does not need to be told about itself: it already knows + // its own entity id from the world join. + for (Player other : this.players.values()) { + if (other == player) { + continue; + } + player.session().send(Packets.ENTITY_SPAWN, spawnPacket(other)); + other.session().send(Packets.ENTITY_SPAWN, spawnPacket(player)); + } + int n = online(); if (n > this.peakOnline.get()) { this.peakOnline.set(n); @@ -260,11 +322,49 @@ public final class Server { void closed(ClientSession session) { this.sessions.remove(Long.valueOf(session.id())); + Player player = session.player(); + if (player != null) { + this.players.remove(Integer.valueOf(player.playerId())); + for (Player other : this.players.values()) { + other.session().send(Packets.ENTITY_REMOVE, + new Packets.EntityRemove(player.entityId())); + } + } if (session.lastError() != null) { log("closed " + session.address() + ": " + session.lastError()); } } + private static Packets.EntitySpawn spawnPacket(Player player) { + return new Packets.EntitySpawn(player.entityId(), player.playerId(), + player.account(), player.x(), player.y(), player.z(), + player.yaw(), player.pitch()); + } + + /** Tells everybody who can see it that a block changed. */ + void broadcastBlockChange(int x, int y, int z, byte id) { + ChunkPos pos = ChunkPos.ofBlock(x, z); + Packets.BlockChange change = new Packets.BlockChange(x, y, z, id); + for (Player player : this.players.values()) { + if (player.canSee(pos)) { + player.session().send(Packets.BLOCK_CHANGE, change); + } + } + } + + /** Tells everybody who can see it that a player moved. */ + void broadcastEntityMove(Player moved) { + ChunkPos pos = moved.chunkPos(); + Packets.EntityMove move = new Packets.EntityMove(moved.entityId(), + moved.x(), moved.y(), moved.z(), moved.yaw(), moved.pitch()); + for (Player player : this.players.values()) { + if (player == moved || !player.canSee(pos)) { + continue; + } + player.session().send(Packets.ENTITY_MOVE, move); + } + } + public List sessions() { return new ArrayList(this.sessions.values()); } 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 b9bcd3b..2855bb6 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 @@ -2,6 +2,8 @@ package net.rustnotmc.server; import java.io.IOException; import java.nio.file.Path; +import net.rustnotmc.server.world.ChunkStorage; +import net.rustnotmc.server.world.World; /** * Command line entry point. @@ -11,6 +13,7 @@ import java.nio.file.Path; * only has to be obviously correct rather than clever. * * run-server.ps1 [--port N] [--accounts path] [--name text] [--motd text] + * [--world path] [--seed N] * run-server.ps1 --register Player [--password text] * * Registration is an operator action and not something a client can do over the @@ -28,6 +31,8 @@ public final class ServerMain { String name = "RustNotMC"; String motd = "a survival game"; Path accounts = Path.of("accounts.txt"); + Path worldDir = Path.of("world"); + long seed = 0L; String register = null; String password = null; @@ -40,6 +45,10 @@ public final class ServerMain { capacity = Integer.parseInt(value); } else if ("--accounts".equals(flag)) { accounts = Path.of(value); + } else if ("--world".equals(flag)) { + worldDir = Path.of(value); + } else if ("--seed".equals(flag)) { + seed = Long.parseLong(value); } else if ("--name".equals(flag)) { name = value; } else if ("--motd".equals(flag)) { @@ -51,7 +60,8 @@ public final class ServerMain { } } - Server server = new Server(name, motd, capacity, port); + World world = new World(seed, new ChunkStorage(worldDir)); + Server server = new Server(name, motd, capacity, port, world); server.loadAccounts(accounts); final Path accountFile = accounts.toAbsolutePath(); @@ -74,6 +84,7 @@ public final class ServerMain { server.start(); server.log("accounts file: " + accountFile); + server.log("world folder: " + worldDir.toAbsolutePath() + ", seed " + seed); Runtime.getRuntime().addShutdownHook(new Thread(new Runnable() { public void run() { @@ -83,6 +94,12 @@ public final class ServerMain { } catch (IOException e) { server.log("could not save accounts: " + e); } + server.log("saving the world"); + try { + server.world().close(); + } catch (RuntimeException e) { + server.log("could not save the world: " + e); + } server.stop(); } }, "rsx-shutdown")); diff --git a/rsx-server-core/src/main/java/net/rustnotmc/server/world/Chunk.java b/rsx-server-core/src/main/java/net/rustnotmc/server/world/Chunk.java new file mode 100644 index 0000000..9d3fb00 --- /dev/null +++ b/rsx-server-core/src/main/java/net/rustnotmc/server/world/Chunk.java @@ -0,0 +1,149 @@ +package net.rustnotmc.server.world; + +import java.util.Arrays; +import net.rustnotmc.protocol.Blocks; +import net.rustnotmc.protocol.ChunkFormat; + +/** + * A 16 by 16 column of blocks, 256 high. + * + * Blocks are indexed as {@code (y << 8) | (z << 4) | x}, so the sixteen blocks + * of one layer are next to each other in memory. That is the order a generator + * fills a chunk in and the order it gets saved in, and it is why generation + * walks in large strides instead of one byte at a time. + * + * The shape is not decided here. The numbers come from {@link ChunkFormat}, + * which is the wire format, so that the chunk the server saves and the chunk + * the client receives cannot drift apart. + * + * The class is not synchronised, but the two places where it matters are: a + * block is written by the thread that owns the player who broke it, and a chunk + * is copied by the thread that is sending it to somebody. Those two are made to + * take turns, because a copy taken while a block is being written is a copy of + * half a change, and that half change is what ends up on the disk. + */ +public final class Chunk { + + /** Blocks along one edge of a chunk. */ + public static final int WIDTH = ChunkFormat.WIDTH; + + /** Blocks from the bottom of a chunk to the top. */ + public static final int HEIGHT = ChunkFormat.HEIGHT; + + /** Blocks in one layer of a chunk. */ + public static final int AREA = ChunkFormat.AREA; + + /** Blocks in a whole chunk. */ + public static final int VOLUME = ChunkFormat.VOLUME; + + private final ChunkPos pos; + private final byte[] blocks; + + private volatile boolean generated; + private volatile boolean dirty; + private volatile long lastUsed; + + public Chunk(ChunkPos pos) { + this.pos = pos; + this.blocks = new byte[VOLUME]; + this.lastUsed = System.currentTimeMillis(); + } + + public ChunkPos pos() { + return this.pos; + } + + public static int index(int x, int y, int z) { + return ChunkFormat.index(x, y, z); + } + + public byte block(int x, int y, int z) { + return this.blocks[index(x, y, z)]; + } + + /** Writes a block and marks the chunk for saving. */ + public synchronized boolean setBlock(int x, int y, int z, byte id) { + if (y < 0 || y >= HEIGHT) { + return false; + } + int at = index(x, y, z); + if (this.blocks[at] == id) { + return false; + } + this.blocks[at] = id; + this.dirty = true; + return true; + } + + public boolean isGenerated() { + return this.generated; + } + + void markGenerated() { + this.generated = true; + this.dirty = true; + } + + public boolean isDirty() { + return this.dirty; + } + + void clearDirty() { + this.dirty = false; + } + + public long lastUsed() { + return this.lastUsed; + } + + void touch(long now) { + this.lastUsed = now; + } + + /** The height of the first air block above the surface, 0 if none. */ + public int surfaceHeight(int x, int z) { + for (int y = HEIGHT - 1; y >= 0; y--) { + byte id = this.blocks[index(x, y, z)]; + if (id != Blocks.AIR && id != Blocks.WATER && id != Blocks.LEAVES) { + return y + 1; + } + } + return 0; + } + + /** + * A copy, safe to hand to another thread while this chunk keeps changing. + * + * Takes the same lock as a block write, so the copy is of a whole state and + * not of one that was halfway through being changed. + */ + public synchronized byte[] snapshot() { + return Arrays.copyOf(this.blocks, this.blocks.length); + } + + /** Replaces the contents wholesale, used by loading. */ + synchronized void restore(byte[] data) { + if (data.length != VOLUME) { + throw new IllegalArgumentException("chunk data is " + data.length + + " bytes, expected " + VOLUME); + } + System.arraycopy(data, 0, this.blocks, 0, VOLUME); + this.generated = true; + this.dirty = false; + } + + /** + * The block array itself, for a generator to fill. + * + * Only the generator may use this, and only before the chunk is handed to + * anybody: the array it returns is the live one, not a copy. + */ + byte[] raw() { + return this.blocks; + } + + @Override + public String toString() { + return "Chunk" + this.pos + (this.generated ? "" : " (not generated)"); + } +} diff --git a/rsx-server-core/src/main/java/net/rustnotmc/server/world/ChunkPos.java b/rsx-server-core/src/main/java/net/rustnotmc/server/world/ChunkPos.java new file mode 100644 index 0000000..74924da --- /dev/null +++ b/rsx-server-core/src/main/java/net/rustnotmc/server/world/ChunkPos.java @@ -0,0 +1,64 @@ +package net.rustnotmc.server.world; + +/** + * Where a chunk sits, on an infinite grid. + * + * Immutable, with a real hashCode. It is used as a map key across a whole + * server, and the obvious wrong implementation of the pair makes every chunk + * land in one bucket, which turns a hash map into a list exactly when there are + * enough chunks for it to matter. + */ +public final class ChunkPos { + + public final int x; + public final int z; + + public ChunkPos(int x, int z) { + this.x = x; + this.z = z; + } + + /** The chunk containing a block column. */ + public static ChunkPos ofBlock(int blockX, int blockZ) { + return new ChunkPos(blockX >> 4, blockZ >> 4); + } + + /** Packs the pair into one long, for on-disk names. */ + public long key() { + return ((long) this.x << 32) | (this.z & 0xFFFFFFFFL); + } + + public static ChunkPos fromKey(long key) { + return new ChunkPos((int) (key >> 32), (int) key); + } + + /** How many chunks from this one to that one, Chebyshev distance. */ + public int distanceTo(ChunkPos other) { + return Math.max(Math.abs(this.x - other.x), Math.abs(this.z - other.z)); + } + + @Override + public boolean equals(Object other) { + if (this == other) { + return true; + } + if (!(other instanceof ChunkPos)) { + return false; + } + ChunkPos that = (ChunkPos) other; + return this.x == that.x && this.z == that.z; + } + + @Override + public int hashCode() { + // spread the pair so that neighbouring chunks, which differ by one in + // both axes, do not land on neighbouring buckets + int h = this.x * 0x1f1f1f1f ^ this.z * 0x8da6b343; + return h ^ (h >>> 16); + } + + @Override + public String toString() { + return "(" + this.x + ", " + this.z + ")"; + } +} \ No newline at end of file diff --git a/rsx-server-core/src/main/java/net/rustnotmc/server/world/ChunkStorage.java b/rsx-server-core/src/main/java/net/rustnotmc/server/world/ChunkStorage.java new file mode 100644 index 0000000..5f5edfe --- /dev/null +++ b/rsx-server-core/src/main/java/net/rustnotmc/server/world/ChunkStorage.java @@ -0,0 +1,196 @@ +package net.rustnotmc.server.world; + +import java.io.BufferedInputStream; +import java.io.BufferedOutputStream; +import java.io.DataInputStream; +import java.io.DataOutputStream; +import java.io.IOException; +import java.nio.file.AtomicMoveNotSupportedException; +import java.nio.file.Files; +import java.nio.file.Path; +import java.nio.file.StandardCopyOption; + +/** + * Chunks on disk. + * + * One file per chunk, written to a temporary name and moved into place. A chunk + * that is half written when the power goes is a chunk the server will refuse to + * load, and the player who was standing on it loses everything in it. + * + * The format carries a version and a checksum, because the failure this protects + * against is silent. A truncated file that loads anyway corrupts the terrain + * around it in ways that only show up much later. + */ +public final class ChunkStorage { + + /** Bumped whenever the layout changes; an unknown version is refused. */ + public static final int FORMAT_VERSION = 1; + + private static final int CHUNK_MAGIC = 0x52535843; // "RSXC" + + /** magic, version, x, z, checksum, length */ + private static final int HEADER_BYTES = 4 * 6; + + /** The whole file: header, block data, and the trailing checksum. */ + static final int FILE_BYTES = HEADER_BYTES + Chunk.VOLUME + 4; + + private final Path directory; + + public ChunkStorage(Path directory) { + this.directory = directory; + } + + /** + * A storage that keeps nothing. + * + * For a server that has no business touching the disk, and for tests that + * would rather not leave files behind. A chunk written to it is forgotten, + * which is exactly what a server with no world folder does. + */ + public static ChunkStorage memoryOnly() { + return new ChunkStorage(null); + } + + /** True when chunks written here survive the server going away. */ + public boolean persistent() { + return this.directory != null; + } + + public Path directory() { + return this.directory; + } + + /** True when this chunk has been saved before. */ + public boolean exists(ChunkPos pos) throws IOException { + if (this.directory == null) { + return false; + } + return Files.exists(fileFor(pos)); + } + + /** Reads a chunk, or returns null when it was never saved. */ + public Chunk load(ChunkPos pos) throws IOException { + if (this.directory == null) { + return null; + } + Path file = fileFor(pos); + if (!Files.exists(file)) { + return null; + } + DataInputStream in = new DataInputStream( + new BufferedInputStream(Files.newInputStream(file), 8192)); + try { + if (in.readInt() != CHUNK_MAGIC) { + throw new IOException("not a chunk file: " + file); + } + int version = in.readInt(); + if (version != FORMAT_VERSION) { + throw new IOException("chunk " + pos + " is version " + version + + ", this build writes " + FORMAT_VERSION); + } + int x = in.readInt(); + int z = in.readInt(); + int storedChecksum = in.readInt(); + int length = in.readInt(); + + if (x != pos.x || z != pos.z) { + throw new IOException("chunk " + file.getFileName() + + " holds " + x + "," + z); + } + if (length != Chunk.VOLUME) { + throw new IOException("chunk " + pos + " is " + length + + " bytes, expected " + Chunk.VOLUME); + } + byte[] data = new byte[Chunk.VOLUME]; + in.readFully(data); + + int trailing = in.readInt(); + if (trailing != storedChecksum) { + throw new IOException("chunk " + pos + " is damaged: it ends with " + + Integer.toHexString(trailing) + " but holds " + + Integer.toHexString(storedChecksum)); + } + + int actual = checksum(data); + if (actual != storedChecksum) { + throw new IOException("chunk " + pos + " is damaged: checksum " + + Integer.toHexString(storedChecksum) + " but the data hashes to " + + Integer.toHexString(actual)); + } + + Chunk chunk = new Chunk(pos); + chunk.restore(data); + return chunk; + } finally { + in.close(); + } + } + + /** Writes a chunk. Safe to interrupt: the old file survives until the move. */ + public void save(Chunk chunk) throws IOException { + if (this.directory == null) { + // nowhere to put it, but the chunk is not dirty any more either, so + // that the world does not keep trying + chunk.clearDirty(); + return; + } + Files.createDirectories(this.directory); + byte[] data = chunk.snapshot(); + int sum = checksum(data); + + Path file = fileFor(chunk.pos()); + Path temporary = Files.createTempFile(this.directory, "chunk", ".tmp"); + DataOutputStream out = new DataOutputStream( + new BufferedOutputStream(Files.newOutputStream(temporary), 8192)); + try { + out.writeInt(CHUNK_MAGIC); + out.writeInt(FORMAT_VERSION); + out.writeInt(chunk.pos().x); + out.writeInt(chunk.pos().z); + out.writeInt(sum); + out.writeInt(data.length); + out.write(data); + out.writeInt(sum); + } finally { + out.close(); + } + try { + Files.move(temporary, file, + StandardCopyOption.REPLACE_EXISTING, StandardCopyOption.ATOMIC_MOVE); + } catch (AtomicMoveNotSupportedException e) { + Files.move(temporary, file, StandardCopyOption.REPLACE_EXISTING); + } + chunk.clearDirty(); + } + + public boolean delete(ChunkPos pos) throws IOException { + if (this.directory == null) { + return false; + } + return Files.deleteIfExists(fileFor(pos)); + } + + private Path fileFor(ChunkPos pos) { + return this.directory.resolve("c." + pos.x + "." + pos.z + ".rsx"); + } + + /** + * A cheap integrity sum. + * + * Not a cryptographic hash, and not meant to be: this catches a truncated + * or interleaved write, which is the actual failure, and does it in a pass + * over bytes that are already in memory. + */ + static int checksum(byte[] data) { + int sum = 0; + for (int i = 0; i < data.length; i++) { + sum = (sum << 5) - sum + data[i]; + } + return sum; + } + + /** Bytes of a chunk file that are not block data. */ + static int headerBytes() { + return HEADER_BYTES; + } +} \ No newline at end of file diff --git a/rsx-server-core/src/main/java/net/rustnotmc/server/world/World.java b/rsx-server-core/src/main/java/net/rustnotmc/server/world/World.java new file mode 100644 index 0000000..451c33e --- /dev/null +++ b/rsx-server-core/src/main/java/net/rustnotmc/server/world/World.java @@ -0,0 +1,238 @@ +package net.rustnotmc.server.world; + +import java.io.IOException; +import java.util.ArrayList; +import java.util.Collection; +import java.util.List; +import java.util.Map; +import java.util.concurrent.ConcurrentHashMap; +import java.util.concurrent.atomic.AtomicLong; + +/** + * The world: chunks that exist, chunks that do not yet, and the tick that keeps + * them saved. + * + * Generation is lazy. A world is infinite, so asking for a chunk is the only way + * it comes into existence, and it is generated once and then remembered. + * + * Nothing here decides when a player should see something. This class answers + * "what is at these coordinates", and the streaming side decides who is told. + * Mixing the two is how a world ends up generating terrain for a player who left + * the server ten minutes ago. + */ +public final class World { + + /** Chunks written to disk this many ticks after they were last changed. */ + public static final int AUTOSAVE_INTERVAL_TICKS = 200; + + /** Never keep an untouched chunk in memory longer than this, in ticks. */ + public static final long EVICT_AFTER_TICKS = 2400L; + + private final Map loaded = new ConcurrentHashMap(); + private final WorldGenerator generator; + private final ChunkStorage storage; + + private final AtomicLong generated = new AtomicLong(); + private final AtomicLong served = new AtomicLong(); + private final AtomicLong evicted = new AtomicLong(); + private final AtomicLong saved = new AtomicLong(); + + private volatile long tick; + private volatile long lastAutosave; + + public World(long seed, ChunkStorage storage) { + this.generator = new WorldGenerator(seed); + this.storage = storage; + } + + public WorldGenerator generator() { + return this.generator; + } + + public ChunkStorage storage() { + return this.storage; + } + + public long seed() { + return this.generator.seed(); + } + + /** How many ticks this world has seen. */ + public long ticks() { + return this.tick; + } + + public long generatedCount() { + return this.generated.get(); + } + + public long servedCount() { + return this.served.get(); + } + + public long evictedCount() { + return this.evicted.get(); + } + + public long savedCount() { + return this.saved.get(); + } + + public int loadedChunks() { + return this.loaded.size(); + } + + /** + * The chunk at a position, generated or read from disk if needed. + * + * Disk is tried first so that a chunk a player has built on comes back with + * their buildings instead of being generated underneath them. + */ + public Chunk chunkAt(ChunkPos pos) throws IOException { + Chunk existing = this.loaded.get(pos); + if (existing != null) { + existing.touch(this.tick); + this.served.incrementAndGet(); + return existing; + } + + Chunk fromDisk = this.storage.load(pos); + if (fromDisk != null) { + fromDisk.touch(this.tick); + this.loaded.put(pos, fromDisk); + this.served.incrementAndGet(); + return fromDisk; + } + + Chunk fresh = new Chunk(pos); + this.generator.generate(fresh); + fresh.touch(this.tick); + this.loaded.put(pos, fresh); + this.generated.incrementAndGet(); + this.served.incrementAndGet(); + return fresh; + } + + /** The chunk only if it is already in memory. Never generates, never loads. */ + public Chunk loadedChunk(ChunkPos pos) { + return this.loaded.get(pos); + } + + public byte blockAt(int x, int y, int z) throws IOException { + return chunkAt(ChunkPos.ofBlock(x, z)).block(x & 15, y, z & 15); + } + + /** + * Writes a block. + * + * Returns false when the chunk is not loaded, on purpose. Silently + * generating a chunk because a player placed a block would generate whole + * terrain from a stray click, so callers decide whether to load first. + */ + public boolean setBlock(int x, int y, int z, byte id) { + Chunk chunk = this.loaded.get(ChunkPos.ofBlock(x, z)); + if (chunk == null || y < 0 || y >= Chunk.HEIGHT) { + return false; + } + boolean changed = chunk.setBlock(x & 15, y, z & 15, id); + if (changed) { + chunk.touch(this.tick); + } + return changed; + } + + /** Every chunk currently in memory. */ + public Collection loaded() { + return this.loaded.values(); + } + + /** Every chunk position in memory, for sending a player their surroundings. */ + public List loadedPositions() { + return new ArrayList(this.loaded.keySet()); + } + + /** + * Where a player starts. + * + * The top of the ground at the world origin, because the origin is the one + * point every world has and the spawn has to be somewhere the server can + * name before a client has said where it wants to be. + */ + public double[] spawn() { + int height = this.generator.heightAt(0, 0); + return new double[]{0.5D, height + 1.0D, 0.5D}; + } + + /** True when this world is written to disk. */ + public boolean persistent() { + return this.storage.persistent(); + } + + /** One tick. The place block updates and entity movement will live. */ + public void tick() { + this.tick++; + if (this.tick - this.lastAutosave >= AUTOSAVE_INTERVAL_TICKS) { + autosave(); + this.lastAutosave = this.tick; + } + } + + /** Writes every changed chunk. Returns how many were written. */ + public int autosave() { + int written = 0; + for (Chunk chunk : this.loaded.values()) { + if (!chunk.isDirty()) { + continue; + } + try { + this.storage.save(chunk); + this.saved.incrementAndGet(); + written++; + } catch (IOException e) { + // one unwritable chunk must not stop the others, and must not be + // dropped silently either + System.err.println("[world] could not save " + chunk.pos() + ": " + e); + } + } + return written; + } + + /** + * Drops chunks nobody has touched for a long time, after saving them. + * + * Without this the server's memory grows with the area ever visited, which + * for a survival world is the entire map. + */ + public int evictIdle() { + long limit = this.tick - EVICT_AFTER_TICKS; + List drop = new ArrayList(); + for (Map.Entry entry : this.loaded.entrySet()) { + Chunk chunk = entry.getValue(); + if (chunk.lastUsed() >= limit) { + continue; + } + if (chunk.isDirty()) { + try { + this.storage.save(chunk); + this.saved.incrementAndGet(); + } catch (IOException e) { + System.err.println("[world] could not save " + chunk.pos() + ": " + e); + continue; + } + } + drop.add(entry.getKey()); + } + for (int i = 0; i < drop.size(); i++) { + if (this.loaded.remove(drop.get(i)) != null) { + this.evicted.incrementAndGet(); + } + } + return drop.size(); + } + + /** Saves everything and forgets what is in memory. */ + public void close() { + autosave(); + this.loaded.clear(); + } +} \ No newline at end of file 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 new file mode 100644 index 0000000..520789e --- /dev/null +++ b/rsx-server-core/src/main/java/net/rustnotmc/server/world/WorldGenerator.java @@ -0,0 +1,152 @@ +package net.rustnotmc.server.world; + +import net.rustnotmc.protocol.Blocks; +import net.rustnotmc.protocol.ChunkFormat; + +/** + * Turns a seed and a position into terrain. + * + * The only property that matters here is determinism, and it is stricter than it + * sounds. Generation may run from a worker thread, months apart, on chunks that + * were never resident at the same time. If the same seed and position can + * produce two different chunks, then a player who walks away and back sees the + * ground change, and nothing in a save file can fix it. + * + * So no shared state, no {@link java.util.Random} carried between calls, and no + * dependence on the order chunks are requested in. Every value is a pure + * function of the seed and the coordinates. + */ +public final class WorldGenerator { + + private final long seed; + + public WorldGenerator(long seed) { + this.seed = seed; + } + + public long seed() { + return this.seed; + } + + /** + * A hash of one coordinate, and nothing else. + * + * This is the whole trick behind a generator that survives being spread + * across threads and months: any value in the world can be computed from a + * coordinate without having visited any other coordinate. + */ + private long hash(int x, int y, int z) { + long h = this.seed; + h = h * 0x9E3779B97F4A7C15L + (x * 0x27D4EB2F165667C5L); + h ^= h >>> 29; + h = h * 0x165667B19E3779F9L + (y * 0x85EBCA77C2B2AE63L); + h ^= h >>> 32; + h = h * 0x27D4EB2F165667C5L + (z * 0xC2B2AE3D27D4EB4FL); + h ^= h >>> 31; + return h; + } + + /** Value noise in the range -1..1, smooth between lattice points. */ + public double noise(double x, double z) { + int x0 = (int) Math.floor(x); + int z0 = (int) Math.floor(z); + double fx = x - x0; + double fz = z - z0; + // smoothstep, so the terrain has no grid and no creases + double u = fx * fx * (3.0D - 2.0D * fx); + double v = fz * fz * (3.0D - 2.0D * fz); + + double n00 = lattice(x0, z0); + double n10 = lattice(x0 + 1, z0); + double n01 = lattice(x0, z0 + 1); + double n11 = lattice(x0 + 1, z0 + 1); + + double top = n00 + u * (n10 - n00); + double bottom = n01 + u * (n11 - n01); + return top + v * (bottom - top); + } + + private double lattice(int x, int z) { + long h = hash(x, 0, z); + // take the top bits so the distribution is even across the range + return ((h >>> 11) / (double) (1L << 53)) * 2.0D - 1.0D; + } + + /** Several octaves of noise, which is what makes it look like ground. */ + public double fBm(double x, double z, int octaves) { + double sum = 0.0D; + double amplitude = 1.0D; + double frequency = 1.0D; + double norm = 0.0D; + for (int i = 0; i < octaves; i++) { + sum += noise(x * frequency, z * frequency) * amplitude; + norm += amplitude; + amplitude *= 0.5D; + frequency *= 2.0D; + } + return norm == 0.0D ? 0.0D : sum / norm; + } + + /** The height of solid ground at a block column. */ + public int heightAt(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); + if (h < 2) { + h = 2; + } + if (h > Chunk.HEIGHT - 2) { + h = Chunk.HEIGHT - 2; + } + return h; + } + + /** Fills one chunk. Pure: same seed and position, same bytes out. */ + public void generate(Chunk chunk) { + ChunkPos pos = chunk.pos(); + int baseX = pos.x * ChunkFormat.WIDTH; + int baseZ = pos.z * ChunkFormat.WIDTH; + // the generator owns the chunk until it is published, and the lock + // keeps a second thread from writing to it at the same time + synchronized (chunk) { + byte[] blocks = chunk.raw(); + + for (int z = 0; z < ChunkFormat.WIDTH; z++) { + for (int x = 0; x < ChunkFormat.WIDTH; x++) { + int worldX = baseX + x; + int worldZ = baseZ + z; + int height = heightAt(worldX, worldZ); + + // a column: dirt under the surface, grass on top, stone below + for (int y = 0; y <= height; y++) { + byte id; + if (y == 0) { + id = Blocks.STONE; + } else if (y == height) { + id = Blocks.GRASS; + } else if (y > height - 4) { + id = Blocks.DIRT; + } else { + id = Blocks.STONE; + } + blocks[ChunkFormat.index(x, y, z)] = id; + } + for (int y = height + 1; y < ChunkFormat.HEIGHT; y++) { + blocks[ChunkFormat.index(x, y, z)] = Blocks.AIR; + } + } + } + chunk.markGenerated(); + } + } + + /** + * Whether a block is within reach of the given column. + * + * A generator that produces something tall, such as a tree, has to be able + * to say so, or a neighbour generation would cut through it. + */ + public boolean reachesIntoNeighbour(int blockX, int blockZ) { + return false; + } +} \ No newline at end of file diff --git a/rsx-server-core/src/test/java/net/rustnotmc/server/PlayClient.java b/rsx-server-core/src/test/java/net/rustnotmc/server/PlayClient.java new file mode 100644 index 0000000..0152b42 --- /dev/null +++ b/rsx-server-core/src/test/java/net/rustnotmc/server/PlayClient.java @@ -0,0 +1,150 @@ +package net.rustnotmc.server; + +import java.io.IOException; +import java.io.InputStream; +import java.io.OutputStream; +import java.net.Socket; +import java.net.SocketTimeoutException; +import java.util.ArrayList; +import java.util.List; +import net.rustnotmc.protocol.Frame; +import net.rustnotmc.protocol.FrameDecoder; +import net.rustnotmc.protocol.Packet; +import net.rustnotmc.protocol.Packets; + +/** + * A client that speaks the whole protocol, for the tests. + * + * The point of going through a socket is that the parts most likely to be + * wrong are the parts nobody calls directly: the framing, the order the two + * sides assume the other has already done something in, and the fact that a + * chunk is sixty five kilobytes of data that has to survive a socket. + */ +final class PlayClient { + + private final Socket socket; + private final InputStream in; + private final OutputStream out; + private final FrameDecoder decoder = new FrameDecoder(); + private final List inbox = new ArrayList(); + + PlayClient(String host, int port, int timeoutMillis) throws IOException { + this.socket = new Socket(host, port); + this.socket.setSoTimeout(timeoutMillis); + this.socket.setTcpNoDelay(true); + this.in = this.socket.getInputStream(); + this.out = this.socket.getOutputStream(); + } + + void send(int packetId, Packet packet) throws IOException { + Frame frame = new Frame(packetId, packet.write().toByteArray()); + this.out.write(frame.encode().toByteArray()); + this.out.flush(); + } + + /** + * Waits for the next frame with this id. + * + * Anything else that arrives first is kept for later, so a client that + * asks for a chunk does not lose the world join that came before it. + * Returns null when the wait runs out. + */ + Frame await(int packetId, long millis) throws IOException { + long end = System.currentTimeMillis() + millis; + while (true) { + for (int i = 0; i < this.inbox.size(); i++) { + Frame frame = this.inbox.get(i); + if (frame.packetId() == packetId) { + this.inbox.remove(i); + return frame; + } + } + if (System.currentTimeMillis() > end) { + return null; + } + byte[] chunk = new byte[16384]; + int read; + try { + read = this.in.read(chunk); + } catch (SocketTimeoutException nothingYet) { + continue; + } + if (read < 0) { + return null; + } + this.inbox.addAll(this.decoder.offer(chunk, 0, read)); + } + } + + /** Every frame with this id that has already arrived. */ + List pending(int packetId) { + List found = new ArrayList(); + for (int i = this.inbox.size() - 1; i >= 0; i--) { + if (this.inbox.get(i).packetId() == packetId) { + found.add(0, this.inbox.remove(i)); + } + } + return found; + } + + /** Waits for this many frames with this id, or gives up. */ + List awaitMany(int packetId, int count, long millis) throws IOException { + List found = new ArrayList(); + long end = System.currentTimeMillis() + millis; + while (found.size() < count) { + Frame frame = await(packetId, Math.max(1L, end - System.currentTimeMillis())); + if (frame == null) { + return found; + } + found.add(frame); + } + return found; + } + + /** The handshake, from the client's point of view. */ + Packets.HelloAck hello(String account, String clientName) throws IOException { + send(Packets.HELLO, new Packets.Hello( + net.rustnotmc.protocol.Frame.VERSION, account, clientName, 1)); + Frame frame = await(Packets.HELLO_ACK, 5000L); + return frame == null ? null : Packets.HelloAck.read(new net.rustnotmc.protocol.Buf(frame.body())); + } + + Packets.AuthAck auth(String account, char[] password, byte[] salt, byte[] nonce) + throws IOException { + send(Packets.AUTH, new Packets.Auth(account, + Packets.Auth.proofFor(account, password, salt, nonce))); + Frame frame = await(Packets.AUTH_ACK, 5000L); + return frame == null ? null : Packets.AuthAck.read(new net.rustnotmc.protocol.Buf(frame.body())); + } + + Packets.LoginAck login(String account, String token) throws IOException { + send(Packets.LOGIN, new Packets.Login(account, token)); + Frame frame = await(Packets.LOGIN_ACK, 5000L); + return frame == null ? null : Packets.LoginAck.read(new net.rustnotmc.protocol.Buf(frame.body())); + } + + /** Logs in from the start and returns the client, ready to play. */ + PlayClient signIn(String account, String password) throws IOException { + Packets.HelloAck hello = hello(account, "test"); + if (hello == null) { + return null; + } + Packets.AuthAck auth = auth(account, password.toCharArray(), hello.salt, hello.nonce); + if (auth == null || !auth.accepted) { + return null; + } + Packets.LoginAck login = login(account, auth.token); + if (login == null || !login.accepted) { + return null; + } + return this; + } + + void close() { + try { + this.socket.close(); + } catch (IOException ignored) { + // the peer is gone; there is nothing left to do about it + } + } +} diff --git a/rsx-server-core/src/test/java/net/rustnotmc/server/PlayTest.java b/rsx-server-core/src/test/java/net/rustnotmc/server/PlayTest.java new file mode 100644 index 0000000..2b0e1c3 --- /dev/null +++ b/rsx-server-core/src/test/java/net/rustnotmc/server/PlayTest.java @@ -0,0 +1,441 @@ +package net.rustnotmc.server; + +import java.io.IOException; +import java.nio.file.Files; +import java.nio.file.Path; +import java.util.List; +import net.rustnotmc.protocol.Blocks; +import net.rustnotmc.protocol.Buf; +import net.rustnotmc.protocol.ChunkFormat; +import net.rustnotmc.protocol.Frame; +import net.rustnotmc.protocol.Packets; +import net.rustnotmc.server.world.ChunkStorage; +import net.rustnotmc.server.world.World; + +/** + * Tests the play phase, over a real socket. + * + * The world is the first thing a player ever sees, and the first thing that + * has to survive the wire: a chunk is sixty five kilobytes of terrain that + * has to arrive in the right order, in the right shape, and be the same + * terrain the server has. + */ +public final class PlayTest { + + 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("a player joins the world", new Throwing() { + public void go() throws Exception { testJoinWorld(work); } + }); + run("the world arrives with the chunks around it", new Throwing() { + public void go() throws Exception { testChunksArrive(work); } + }); + run("the chunk that arrives is the terrain", new Throwing() { + public void go() throws Exception { testChunkIsTerrain(work); } + }); + run("breaking a block changes the world", new Throwing() { + public void go() throws Exception { testBreakBlock(work); } + }); + run("a block out of reach is refused", new Throwing() { + public void go() throws Exception { testReach(work); } + }); + run("walking on brings the next view", new Throwing() { + public void go() throws Exception { testWalkingStreams(work); } + }); + run("two players see each other", new Throwing() { + public void go() throws Exception { testPlayersSeeEachOther(work); } + }); + run("a ping is answered", new Throwing() { + public void go() throws Exception { testPing(work); } + }); + run("the world survives a restart", new Throwing() { + public void go() throws Exception { testRestart(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); + } + } + + private static void yes(String what, boolean condition) { + if (!condition) { + failed++; + failures.add(what); + } + } + + private static void eq(Object what, Object expected, Object actual) { + if (expected == null ? actual != null : !expected.equals(actual)) { + failed++; + failures.add(what + ": expected " + expected + " but got " + actual); + } + } + + // ------------------------------------------------------------- the tests + + private static Server boot(Path work, String account) throws IOException { + Server server = new Server("RustNotMC play", "hello", 20, 0, + new World(1234L, ChunkStorage.memoryOnly())); + // a small view, so the tests do not spend their time generating a + // hundred chunks to prove that a dozen arrive + server.viewDistance(2); + Path accounts = work.resolve("play-accounts-" + account + ".txt"); + Files.deleteIfExists(accounts); + server.loadAccounts(accounts); + server.accounts().create(account, "password1".toCharArray(), 1L); + server.start(); + return server; + } + + private static void testJoinWorld(Path work) throws Exception { + Server server = boot(work, "Joiner"); + try { + PlayClient client = new PlayClient("127.0.0.1", server.port(), 5000); + yes("signed in", client.signIn("Joiner", "password1") != null); + + client.send(Packets.JOIN_WORLD, Packets.JoinWorld.fresh()); + Frame frame = client.await(Packets.WORLD_JOIN, 5000L); + yes("the world came", frame != null); + if (frame == null) { + return; + } + Packets.WorldJoin world = Packets.WorldJoin.read(new Buf(frame.body())); + eq("the seed came across", Long.valueOf(server.world().seed()), + Long.valueOf(world.seed)); + eq("the height came across", Integer.valueOf(ChunkFormat.HEIGHT), + Integer.valueOf(world.worldHeight)); + eq("the view distance came across", Integer.valueOf(2), + Integer.valueOf(world.viewDistance)); + yes("the spawn is above the ground", world.spawnY > 1.0D); + yes("the player has an entity id", world.entityId > 0); + client.close(); + } finally { + server.stop(); + } + } + + private static void testChunksArrive(Path work) throws Exception { + Server server = boot(work, "Chunks"); + try { + PlayClient client = new PlayClient("127.0.0.1", server.port(), 5000); + client.signIn("Chunks", "password1"); + client.send(Packets.JOIN_WORLD, Packets.JoinWorld.fresh()); + client.await(Packets.WORLD_JOIN, 5000L); + + int expected = 25; // a view of two is five by five + List chunks = client.awaitMany(Packets.CHUNK_DATA, expected, 15000L); + eq("every chunk in view arrived", Integer.valueOf(expected), + Integer.valueOf(chunks.size())); + client.close(); + } finally { + server.stop(); + } + } + + private static void testChunkIsTerrain(Path work) throws Exception { + Server server = boot(work, "Terrain"); + try { + PlayClient client = new PlayClient("127.0.0.1", server.port(), 5000); + client.signIn("Terrain", "password1"); + client.send(Packets.JOIN_WORLD, Packets.JoinWorld.fresh()); + client.await(Packets.WORLD_JOIN, 5000L); + + List chunks = client.awaitMany(Packets.CHUNK_DATA, 25, 15000L); + Packets.ChunkData home = null; + for (int i = 0; i < chunks.size(); i++) { + Packets.ChunkData chunk = Packets.ChunkData.read(new Buf(chunks.get(i).body())); + if (chunk.chunkX == 0 && chunk.chunkZ == 0) { + home = chunk; + } + } + yes("the chunk under the player arrived", home != null); + if (home == null) { + return; + } + + int height = server.world().generator().heightAt(0, 0); + eq("the top of the ground is grass", Byte.valueOf(Blocks.GRASS), + Byte.valueOf(home.blocks[ChunkFormat.index(0, height, 0)])); + eq("the sky above it is air", Byte.valueOf(Blocks.AIR), + Byte.valueOf(home.blocks[ChunkFormat.index(0, height + 1, 0)])); + eq("the ground below it is dirt", Byte.valueOf(Blocks.DIRT), + Byte.valueOf(home.blocks[ChunkFormat.index(0, height - 1, 0)])); + eq("the floor is stone", Byte.valueOf(Blocks.STONE), + Byte.valueOf(home.blocks[ChunkFormat.index(0, 0, 0)])); + + // and it is the same terrain the server has + eq("the server agrees", Byte.valueOf(Blocks.GRASS), + Byte.valueOf(server.world().blockAt(0, height, 0))); + client.close(); + } finally { + server.stop(); + } + } + + private static void testBreakBlock(Path work) throws Exception { + Server server = boot(work, "Breaker"); + try { + PlayClient client = new PlayClient("127.0.0.1", server.port(), 5000); + client.signIn("Breaker", "password1"); + client.send(Packets.JOIN_WORLD, Packets.JoinWorld.fresh()); + client.await(Packets.WORLD_JOIN, 5000L); + client.awaitMany(Packets.CHUNK_DATA, 25, 15000L); + + double[] spawn = server.world().spawn(); + int under = (int) spawn[1] - 1; + byte before = server.world().blockAt(0, under, 0); + yes("there is something to break", before != Blocks.AIR); + + client.send(Packets.BLOCK_CHANGE, new Packets.BlockChange(0, under, 0, Blocks.AIR)); + waitFor(() -> blockAt(server.world(), 0, under, 0) == Blocks.AIR, 3000L); + eq("the block is gone", Byte.valueOf(Blocks.AIR), + Byte.valueOf(server.world().blockAt(0, under, 0))); + client.close(); + } finally { + server.stop(); + } + } + + private static void testReach(Path work) throws Exception { + Server server = boot(work, "Reacher"); + try { + PlayClient client = new PlayClient("127.0.0.1", server.port(), 5000); + client.signIn("Reacher", "password1"); + client.send(Packets.JOIN_WORLD, Packets.JoinWorld.fresh()); + client.await(Packets.WORLD_JOIN, 5000L); + client.awaitMany(Packets.CHUNK_DATA, 25, 15000L); + + Player player = null; + for (Player p : server.players().values()) { + player = p; + } + yes("the server knows the player", player != null); + if (player == null) { + return; + } + + double[] spawn = server.world().spawn(); + int under = (int) spawn[1] - 1; + + yes("a block under the player can be changed", + player.changeBlock(server.world(), 0, under, 0, Blocks.AIR)); + yes("a block far away cannot", + !player.changeBlock(server.world(), 100, 70, 100, Blocks.STONE)); + yes("a block just out of reach cannot", + !player.changeBlock(server.world(), 0, under, 40, Blocks.STONE)); + yes("a block that does not exist cannot", + !player.changeBlock(server.world(), 0, under, 0, (byte) 99)); + + // and over the wire, a far away change changes nothing + client.send(Packets.BLOCK_CHANGE, new Packets.BlockChange(0, under, 0, Blocks.STONE)); + Thread.sleep(250L); + eq("the near block changed", Byte.valueOf(Blocks.STONE), + Byte.valueOf(server.world().blockAt(0, under, 0))); + client.close(); + } finally { + server.stop(); + } + } + + private static void testWalkingStreams(Path work) throws Exception { + Server server = boot(work, "Walker"); + try { + PlayClient client = new PlayClient("127.0.0.1", server.port(), 5000); + client.signIn("Walker", "password1"); + client.send(Packets.JOIN_WORLD, Packets.JoinWorld.fresh()); + client.await(Packets.WORLD_JOIN, 5000L); + client.awaitMany(Packets.CHUNK_DATA, 25, 15000L); + + double[] spawn = server.world().spawn(); + // far enough that the new view has nothing in common with the old + client.send(Packets.PLAYER_MOVE, new Packets.PlayerMove( + 100.0D, spawn[1], 0.0D, 0.0F, 0.0F, true)); + + List more = client.awaitMany(Packets.CHUNK_DATA, 25, 15000L); + eq("walking brought a new view", Integer.valueOf(25), + Integer.valueOf(more.size())); + + Player player = null; + for (Player p : server.players().values()) { + player = p; + } + yes("the server moved the player", player != null); + if (player != null) { + eq("to where the client said", Double.valueOf(100.0D), + Double.valueOf(player.x())); + eq("into the right chunk", Integer.valueOf(6), + Integer.valueOf(player.chunkPos().x)); + } + client.close(); + } finally { + server.stop(); + } + } + + private static void testPlayersSeeEachOther(Path work) throws Exception { + Server server = boot(work, "First"); + try { + server.accounts().create("Second", "password1".toCharArray(), 1L); + PlayClient first = new PlayClient("127.0.0.1", server.port(), 5000); + yes("the first player signed in", first.signIn("First", "password1") != null); + + PlayClient second = new PlayClient("127.0.0.1", server.port(), 5000); + yes("the second player signed in", second.signIn("Second", "password1") != null); + + Frame seen = first.await(Packets.ENTITY_SPAWN, 5000L); + yes("the first player was told about the second", seen != null); + if (seen != null) { + Packets.EntitySpawn spawn = Packets.EntitySpawn.read(new Buf(seen.body())); + eq("with the right name", "Second", spawn.name); + yes("with an entity id", spawn.entityId > 0); + } + eq("the server knows both", Integer.valueOf(2), + Integer.valueOf(server.players().size())); + + second.close(); + waitFor(() -> server.players().size() == 1, 3000L); + eq("and one of them left", Integer.valueOf(1), + Integer.valueOf(server.players().size())); + first.close(); + } finally { + server.stop(); + } + } + + private static void testPing(Path work) throws Exception { + Server server = boot(work, "Pinger"); + try { + PlayClient client = new PlayClient("127.0.0.1", server.port(), 5000); + client.signIn("Pinger", "password1"); + client.send(Packets.PING, new Packets.Ping(4242L)); + Frame pong = client.await(Packets.PONG, 3000L); + yes("the ping was answered", pong != null); + if (pong != null) { + eq("with the same number", Long.valueOf(4242L), + Long.valueOf(Packets.Pong.read(new Buf(pong.body())).id)); + } + client.close(); + } finally { + server.stop(); + } + } + + /** + * The world outlives the server. + * + * A player breaks a block, the server goes away, and a new server with the + * same folder and the same seed has to find the broken block still broken + * and the rest of the terrain untouched. + */ + private static void testRestart(Path work) throws Exception { + Path dir = work.resolve("play-world"); + deleteTree(dir); + + int[] under = new int[1]; + Server server = new Server("RustNotMC play", "hello", 20, 0, + new World(1234L, new ChunkStorage(dir))); + server.viewDistance(2); + Path accounts = work.resolve("play-accounts-restart.txt"); + Files.deleteIfExists(accounts); + server.loadAccounts(accounts); + server.accounts().create("Keeper", "password1".toCharArray(), 1L); + server.start(); + try { + PlayClient client = new PlayClient("127.0.0.1", server.port(), 5000); + yes("signed in", client.signIn("Keeper", "password1") != null); + client.send(Packets.JOIN_WORLD, Packets.JoinWorld.fresh()); + client.await(Packets.WORLD_JOIN, 5000L); + client.awaitMany(Packets.CHUNK_DATA, 25, 15000L); + + double[] spawn = server.world().spawn(); + under[0] = (int) spawn[1] - 1; + client.send(Packets.BLOCK_CHANGE, + new Packets.BlockChange(0, under[0], 0, Blocks.AIR)); + waitFor(() -> blockAt(server.world(), 0, under[0], 0) == Blocks.AIR, 3000L); + eq("the block is gone", Byte.valueOf(Blocks.AIR), + Byte.valueOf(server.world().blockAt(0, under[0], 0))); + client.close(); + } finally { + server.stop(); + server.world().close(); + } + + Server again = new Server("RustNotMC play", "hello", 20, 0, + new World(1234L, new ChunkStorage(dir))); + try { + eq("the broken block is still broken", Byte.valueOf(Blocks.AIR), + Byte.valueOf(again.world().blockAt(0, under[0], 0))); + eq("and nothing was generated again", Long.valueOf(0L), + Long.valueOf(again.world().generatedCount())); + } finally { + again.stop(); + } + } + + /** A block, or a byte that is not a block if it cannot be read. */ + private static byte blockAt(World world, int x, int y, int z) { + try { + return world.blockAt(x, y, z); + } catch (IOException unreadable) { + return (byte) -1; + } + } + private static void waitFor(java.util.function.BooleanSupplier condition, long millis) + throws InterruptedException { + long end = System.currentTimeMillis() + millis; + while (System.currentTimeMillis() < end) { + if (condition.getAsBoolean()) { + return; + } + Thread.sleep(25L); + } + } + + /** An empty directory, so a test never reads what another one wrote. */ + private static void deleteTree(Path dir) throws IOException { + if (!Files.exists(dir)) { + return; + } + java.util.List all = new java.util.ArrayList(); + try (java.util.stream.Stream walk = Files.walk(dir)) { + walk.forEach(all::add); + } + for (int i = all.size() - 1; i >= 0; i--) { + Files.deleteIfExists(all.get(i)); + } + } +} diff --git a/rsx-server-core/src/test/java/net/rustnotmc/server/world/WorldTest.java b/rsx-server-core/src/test/java/net/rustnotmc/server/world/WorldTest.java new file mode 100644 index 0000000..5d92755 --- /dev/null +++ b/rsx-server-core/src/test/java/net/rustnotmc/server/world/WorldTest.java @@ -0,0 +1,658 @@ +package net.rustnotmc.server.world; + +import java.io.IOException; +import java.nio.file.Files; +import java.nio.file.Path; +import java.util.Arrays; +import net.rustnotmc.protocol.Blocks; + +/** + * Tests the world: generation, storage and the tick that keeps them honest. + * + * The property that matters most is determinism. A chunk may be generated + * months after the chunk next to it, on a different machine, in a different + * process, and the two have to agree exactly, because a save file can only + * record what changed and not the terrain underneath it. + * + * The second property is that damage is noticed. A chunk file that is half + * written must be refused, not loaded, because a chunk loaded with the wrong + * bytes corrupts the world around it in ways that only show up much later. + */ +public final class WorldTest { + + 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("blocks map to the chunk that holds them", new Throwing() { + public void go() throws Exception { testChunkPositions(); } + }); + run("chunk positions survive a round trip through a long", new Throwing() { + public void go() throws Exception { testPositionKeys(); } + }); + run("chunk distance is chebyshev", new Throwing() { + public void go() throws Exception { testDistance(); } + }); + run("block ids are valid and named", new Throwing() { + public void go() throws Exception { testBlockIds(); } + }); + run("the same seed makes the same chunk", new Throwing() { + public void go() throws Exception { testDeterminism(); } + }); + run("a different seed makes a different world", new Throwing() { + public void go() throws Exception { testSeedMatters(); } + }); + run("generation does not depend on order", new Throwing() { + public void go() throws Exception { testOrderIndependence(); } + }); + run("generated terrain has a floor and air above it", new Throwing() { + public void go() throws Exception { testTerrainShape(); } + }); + run("a chunk knows where its surface is", new Throwing() { + public void go() throws Exception { testSurfaceHeight(); } + }); + run("blocks can be written and read", new Throwing() { + public void go() throws Exception { testBlockWrite(); } + }); + run("a snapshot does not change under the reader", new Throwing() { + public void go() throws Exception { testSnapshotIsolation(); } + }); + run("a chunk survives a save and a load", new Throwing() { + public void go() throws Exception { testSaveLoadRoundTrip(); } + }); + run("a saved file has the expected shape", new Throwing() { + public void go() throws Exception { testFileShape(); } + }); + run("a damaged chunk file is refused", new Throwing() { + public void go() throws Exception { testDamagedFile(); } + }); + run("a truncated chunk file is refused", new Throwing() { + public void go() throws Exception { testTruncatedFile(); } + }); + run("a chunk file from another version is refused", new Throwing() { + public void go() throws Exception { testWrongVersion(); } + }); + run("a chunk file in the wrong place is refused", new Throwing() { + public void go() throws Exception { testWrongPlace(); } + }); + run("the checksum catches a single flipped bit", new Throwing() { + public void go() throws Exception { testChecksum(); } + }); + run("a saved chunk comes back instead of being regenerated", new Throwing() { + public void go() throws Exception { testDiskBeatsGeneration(); } + }); + run("a chunk that was never saved is generated", new Throwing() { + public void go() throws Exception { testGenerationOnDemand(); } + }); + run("the world autosaves changed chunks", new Throwing() { + public void go() throws Exception { testAutosave(); } + }); + run("the world drops chunks nobody is using", new Throwing() { + public void go() throws Exception { testEviction(); } + }); + run("writing to a chunk that is not loaded is refused", new Throwing() { + public void go() throws Exception { testUnloadedWriteRefused(); } + }); + run("the world counts what it did", new Throwing() { + public void go() throws Exception { testCounters(); } + }); + run("the world ticks", new Throwing() { + public void go() throws Exception { testTick(); } + }); + + System.out.println(); + for (int i = 0; i < failures.size(); i++) { + System.out.println(" FAIL " + failures.get(i)); + } + System.out.println(); + System.out.println("checks: " + checks + ", failed: " + failed); + if (failed != 0) { + System.out.println("FAILED"); + System.exit(1); + } + System.out.println("OK"); + } + + private interface Throwing { + void go() throws Exception; + } + + private static void run(String name, Throwing body) { + checks++; + int before = failed; + try { + body.go(); + } catch (Throwable t) { + failed++; + failures.add(name + ": threw " + t); + t.printStackTrace(); + return; + } + if (failed == before) { + System.out.println(" ok " + name); + } + } + + private static void yes(String what, boolean condition) { + if (!condition) { + failed++; + failures.add(what); + } + } + + private static void eq(Object what, Object expected, Object actual) { + if (expected == null ? actual != null : !expected.equals(actual)) { + failed++; + failures.add(what + ": expected " + expected + " but got " + actual); + } + } + + // ------------------------------------------------------------ positions + + private static void testChunkPositions() { + eq("block 0 is in chunk 0", Integer.valueOf(0), + Integer.valueOf(ChunkPos.ofBlock(0, 0).x)); + eq("block 15 is in chunk 0", Integer.valueOf(0), + Integer.valueOf(ChunkPos.ofBlock(15, 99).x)); + eq("block 16 is in chunk 1", Integer.valueOf(1), + Integer.valueOf(ChunkPos.ofBlock(16, 0).x)); + eq("block -1 is in chunk -1", Integer.valueOf(-1), + Integer.valueOf(ChunkPos.ofBlock(-1, -1).x)); + eq("block -16 is in chunk -1", Integer.valueOf(-1), + Integer.valueOf(ChunkPos.ofBlock(-16, 0).x)); + eq("block -17 is in chunk -2", Integer.valueOf(-2), + Integer.valueOf(ChunkPos.ofBlock(-17, 0).x)); + eq("the other axis too", Integer.valueOf(-2), + Integer.valueOf(ChunkPos.ofBlock(0, -17).z)); + + // the local coordinates a negative block maps to + eq("negative blocks land in the right column", Integer.valueOf(15), + Integer.valueOf(-1 & 15)); + eq("and the right row", Integer.valueOf(15), Integer.valueOf(-1 & 15)); + } + + private static void testPositionKeys() { + ChunkPos[] samples = { + new ChunkPos(0, 0), + new ChunkPos(1, -1), + new ChunkPos(-1, 1), + new ChunkPos(-2147483648, 2147483647), + new ChunkPos(12345, -67890), + }; + for (int i = 0; i < samples.length; i++) { + ChunkPos back = ChunkPos.fromKey(samples[i].key()); + eq("x survived " + samples[i], Integer.valueOf(samples[i].x), + Integer.valueOf(back.x)); + eq("z survived " + samples[i], Integer.valueOf(samples[i].z), + Integer.valueOf(back.z)); + yes("and it is the same position", samples[i].equals(back)); + yes("and it hashes the same", samples[i].hashCode() == back.hashCode()); + } + } + + private static void testDistance() { + ChunkPos origin = new ChunkPos(0, 0); + eq("same place", Integer.valueOf(0), Integer.valueOf(origin.distanceTo(origin))); + eq("one over", Integer.valueOf(1), Integer.valueOf(origin.distanceTo(new ChunkPos(1, 0)))); + eq("diagonal is the larger side", Integer.valueOf(3), + Integer.valueOf(origin.distanceTo(new ChunkPos(3, 2)))); + eq("distance is symmetric", Integer.valueOf(4), + Integer.valueOf(new ChunkPos(4, 0).distanceTo(origin))); + eq("negative counts too", Integer.valueOf(4), + Integer.valueOf(new ChunkPos(-4, 0).distanceTo(origin))); + + // the hash must not put neighbouring chunks in the same bucket + java.util.Set seen = new java.util.HashSet(); + int collisions = 0; + for (int x = 0; x < 64; x++) { + for (int z = 0; z < 64; z++) { + if (!seen.add(Integer.valueOf(new ChunkPos(x, z).hashCode()))) { + collisions++; + } + } + } + yes("neighbouring chunks spread out (" + collisions + " collisions)", collisions < 64); + } + + // --------------------------------------------------------------- blocks + + private static void testBlockIds() { + eq("air is zero", Byte.valueOf(Blocks.AIR), Byte.valueOf((byte) 0)); + eq("stone is named", "stone", Blocks.name(Blocks.STONE)); + eq("grass is named", "grass", Blocks.name(Blocks.GRASS)); + eq("an unknown id says so", "unknown(42)", Blocks.name((byte) 42)); + yes("air is passable", Blocks.isPassable(Blocks.AIR)); + yes("water is passable", Blocks.isPassable(Blocks.WATER)); + yes("stone is not passable", !Blocks.isPassable(Blocks.STONE)); + yes("air is transparent", Blocks.isTransparent(Blocks.AIR)); + yes("stone is not transparent", !Blocks.isTransparent(Blocks.STONE)); + yes("a known id is valid", Blocks.isValid(Blocks.LEAVES)); + yes("an unknown id is not valid", !Blocks.isValid((byte) 99)); + } + + private static void testBlockWrite() { + Chunk chunk = new Chunk(new ChunkPos(0, 0)); + eq("a new chunk is all air", Byte.valueOf(Blocks.AIR), + Byte.valueOf(chunk.block(0, 0, 0))); + yes("writing reports a change", chunk.setBlock(1, 2, 3, Blocks.WOOD)); + eq("the block reads back", Byte.valueOf(Blocks.WOOD), + Byte.valueOf(chunk.block(1, 2, 3))); + yes("writing the same value is not a change", + !chunk.setBlock(1, 2, 3, Blocks.WOOD)); + yes("the chunk is dirty", chunk.isDirty()); + + yes("below the world is refused", !chunk.setBlock(0, -1, 0, Blocks.STONE)); + yes("above the world is refused", !chunk.setBlock(0, Chunk.HEIGHT, 0, Blocks.STONE)); + yes("the world is 256 high", chunk.setBlock(0, Chunk.HEIGHT - 1, 0, Blocks.STONE)); + + // the index must not run one column into the next + int last = Chunk.index(15, Chunk.HEIGHT - 1, 15); + eq("the last block is the last slot", Integer.valueOf(Chunk.VOLUME - 1), + Integer.valueOf(last)); + yes("and it is inside the array", last < Chunk.VOLUME); + } + + private static void testSnapshotIsolation() { + Chunk chunk = new Chunk(new ChunkPos(0, 0)); + chunk.setBlock(0, 0, 0, Blocks.STONE); + byte[] copy = chunk.snapshot(); + eq("the copy has the block", Byte.valueOf(Blocks.STONE), + Byte.valueOf(copy[Chunk.index(0, 0, 0)])); + chunk.setBlock(0, 0, 0, Blocks.AIR); + eq("the copy did not follow the change", Byte.valueOf(Blocks.STONE), + Byte.valueOf(copy[Chunk.index(0, 0, 0)])); + eq("the chunk did change", Byte.valueOf(Blocks.AIR), + Byte.valueOf(chunk.block(0, 0, 0))); + } + + // ----------------------------------------------------------- generation + + private static void testDeterminism() { + ChunkPos pos = new ChunkPos(3, -7); + byte[] first = generateInto(new WorldGenerator(1234L), pos); + byte[] second = generateInto(new WorldGenerator(1234L), pos); + yes("the same seed and position give the same bytes", + Arrays.equals(first, second)); + + // and the same generator, asked twice, gives the same bytes + WorldGenerator generator = new WorldGenerator(1234L); + byte[] again = generateInto(generator, pos); + yes("asking the same generator twice gives the same bytes", + Arrays.equals(first, again)); + } + + private static void testSeedMatters() { + ChunkPos pos = new ChunkPos(3, -7); + byte[] one = generateInto(new WorldGenerator(1L), pos); + byte[] two = generateInto(new WorldGenerator(2L), pos); + yes("two seeds give two different worlds", !Arrays.equals(one, two)); + + // a seed must not be a small perturbation of itself + byte[] three = generateInto(new WorldGenerator(1L + 1L), pos); + yes("neighbouring seeds give different worlds", !Arrays.equals(one, three)); + } + + private static void testOrderIndependence() { + ChunkPos first = new ChunkPos(0, 0); + ChunkPos second = new ChunkPos(1, 0); + ChunkPos third = new ChunkPos(-2, 5); + + WorldGenerator generator = new WorldGenerator(555L); + byte[] a1 = generateInto(generator, first); + byte[] b1 = generateInto(generator, second); + byte[] c1 = generateInto(generator, third); + + WorldGenerator other = new WorldGenerator(555L); + byte[] c2 = generateInto(other, third); + byte[] a2 = generateInto(other, first); + byte[] b2 = generateInto(other, second); + + yes("the first chunk does not depend on what came before", + Arrays.equals(a1, a2)); + yes("nor the second", Arrays.equals(b1, b2)); + yes("nor the third", Arrays.equals(c1, c2)); + } + + private static byte[] generateInto(WorldGenerator generator, ChunkPos pos) { + Chunk chunk = new Chunk(pos); + generator.generate(chunk); + return chunk.snapshot(); + } + + private static void testTerrainShape() { + WorldGenerator generator = new WorldGenerator(31337L); + Chunk chunk = new Chunk(new ChunkPos(0, 0)); + generator.generate(chunk); + + yes("the chunk says it is generated", chunk.isGenerated()); + for (int z = 0; z < Chunk.WIDTH; z++) { + for (int x = 0; x < Chunk.WIDTH; x++) { + int worldX = x; + int worldZ = z; + int height = generator.heightAt(worldX, worldZ); + eq("the floor is stone at " + x + "," + z, Byte.valueOf(Blocks.STONE), + Byte.valueOf(chunk.block(x, 0, z))); + eq("the top is grass at " + x + "," + z, Byte.valueOf(Blocks.GRASS), + Byte.valueOf(chunk.block(x, height, z))); + eq("the sky is air at " + x + "," + z, Byte.valueOf(Blocks.AIR), + Byte.valueOf(chunk.block(x, height + 1, z))); + eq("the air goes on at " + x + "," + z, Byte.valueOf(Blocks.AIR), + Byte.valueOf(chunk.block(x, Chunk.HEIGHT - 1, z))); + } + } + } + + private static void testSurfaceHeight() { + WorldGenerator generator = new WorldGenerator(808L); + Chunk chunk = new Chunk(new ChunkPos(2, 2)); + generator.generate(chunk); + for (int z = 0; z < Chunk.WIDTH; z++) { + for (int x = 0; x < Chunk.WIDTH; x++) { + int worldX = 2 * Chunk.WIDTH + x; + int worldZ = 2 * Chunk.WIDTH + z; + eq("the surface is where the height says at " + x + "," + z, + Integer.valueOf(generator.heightAt(worldX, worldZ) + 1), + Integer.valueOf(chunk.surfaceHeight(x, z))); + } + } + } + + // -------------------------------------------------------------- storage + + private static void testSaveLoadRoundTrip() throws IOException { + Path dir = freshDir("world-roundtrip"); + ChunkStorage storage = new ChunkStorage(dir); + ChunkPos pos = new ChunkPos(-3, 7); + Chunk chunk = new Chunk(pos); + chunk.setBlock(1, 2, 3, Blocks.WOOD); + chunk.setBlock(4, 5, 6, Blocks.SAND); + chunk.setBlock(15, 255, 15, Blocks.LEAVES); + + storage.save(chunk); + yes("the file is there", storage.exists(pos)); + + Chunk back = storage.load(pos); + yes("it came back", back != null); + eq("the position came back", pos, back.pos()); + eq("a block survived", Byte.valueOf(Blocks.WOOD), Byte.valueOf(back.block(1, 2, 3))); + eq("another survived", Byte.valueOf(Blocks.SAND), Byte.valueOf(back.block(4, 5, 6))); + eq("the corner survived", Byte.valueOf(Blocks.LEAVES), + Byte.valueOf(back.block(15, 255, 15))); + eq("and the empty space is still empty", Byte.valueOf(Blocks.AIR), + Byte.valueOf(back.block(0, 0, 0))); + yes("it is generated", back.isGenerated()); + yes("and it is clean", !back.isDirty()); + yes("the saved chunk is clean too", !chunk.isDirty()); + } + + private static void testFileShape() throws IOException { + Path dir = freshDir("world-shape"); + ChunkStorage storage = new ChunkStorage(dir); + ChunkPos pos = new ChunkPos(0, 0); + Chunk chunk = new Chunk(pos); + storage.save(chunk); + Path file = dir.resolve("c.0.0.rsx"); + eq("the file has a fixed size", Integer.valueOf(ChunkStorage.FILE_BYTES), + Integer.valueOf((int) Files.size(file))); + eq("the header is six words", Integer.valueOf(24), + Integer.valueOf(ChunkStorage.headerBytes())); + } + + private static void testDamagedFile() throws IOException { + Path dir = freshDir("world-damaged"); + ChunkStorage storage = new ChunkStorage(dir); + ChunkPos pos = new ChunkPos(1, 1); + Chunk chunk = new Chunk(pos); + chunk.setBlock(0, 0, 0, Blocks.STONE); + storage.save(chunk); + + Path file = dir.resolve("c.1.1.rsx"); + byte[] bytes = Files.readAllBytes(file); + int dataStart = ChunkStorage.headerBytes(); + bytes[dataStart + 100] = (byte) (bytes[dataStart + 100] ^ 0x40); + Files.write(file, bytes); + + try { + storage.load(pos); + failed++; + failures.add("a damaged chunk file was accepted"); + } catch (IOException expected) { + // correct + } + } + + private static void testTruncatedFile() throws IOException { + Path dir = freshDir("world-truncated"); + ChunkStorage storage = new ChunkStorage(dir); + ChunkPos pos = new ChunkPos(2, 2); + Chunk chunk = new Chunk(pos); + storage.save(chunk); + + Path file = dir.resolve("c.2.2.rsx"); + byte[] bytes = Files.readAllBytes(file); + Files.write(file, Arrays.copyOf(bytes, bytes.length / 2)); + + try { + storage.load(pos); + failed++; + failures.add("a truncated chunk file was accepted"); + } catch (IOException expected) { + // correct + } + } + + private static void testWrongVersion() throws IOException { + Path dir = freshDir("world-version"); + ChunkStorage storage = new ChunkStorage(dir); + ChunkPos pos = new ChunkPos(3, 3); + Chunk chunk = new Chunk(pos); + storage.save(chunk); + + Path file = dir.resolve("c.3.3.rsx"); + byte[] bytes = Files.readAllBytes(file); + bytes[4] = 99; // the version word + bytes[5] = 0; + bytes[6] = 0; + bytes[7] = 0; + Files.write(file, bytes); + + try { + storage.load(pos); + failed++; + failures.add("a chunk file from another version was accepted"); + } catch (IOException expected) { + // correct + } + } + + private static void testWrongPlace() throws IOException { + Path dir = freshDir("world-place"); + ChunkStorage storage = new ChunkStorage(dir); + ChunkPos pos = new ChunkPos(4, 4); + Chunk chunk = new Chunk(pos); + storage.save(chunk); + + Path file = dir.resolve("c.4.4.rsx"); + byte[] bytes = Files.readAllBytes(file); + bytes[8] = 9; // the x word + bytes[9] = 0; + bytes[10] = 0; + bytes[11] = 0; + Files.write(file, bytes); + + try { + storage.load(pos); + failed++; + failures.add("a chunk file in the wrong place was accepted"); + } catch (IOException expected) { + // correct + } + } + + private static void testChecksum() { + byte[] data = new byte[Chunk.VOLUME]; + int before = ChunkStorage.checksum(data); + data[1000] = 1; + int after = ChunkStorage.checksum(data); + yes("one bit changes the sum", before != after); + data[1000] = 0; + eq("and changing it back restores the sum", Integer.valueOf(before), + Integer.valueOf(ChunkStorage.checksum(data))); + } + + // ---------------------------------------------------------------- world + + private static void testDiskBeatsGeneration() throws IOException { + Path dir = freshDir("world-reopen"); + World world = new World(4242L, new ChunkStorage(dir)); + ChunkPos pos = new ChunkPos(2, -1); + Chunk chunk = world.chunkAt(pos); + chunk.setBlock(8, 70, 8, Blocks.WOOD); + world.close(); + + World reopened = new World(4242L, new ChunkStorage(dir)); + Chunk back = reopened.chunkAt(pos); + eq("the building came back", Byte.valueOf(Blocks.WOOD), + Byte.valueOf(back.block(8, 70, 8))); + eq("nothing was regenerated", Long.valueOf(0L), + Long.valueOf(reopened.generatedCount())); + eq("it came from disk", Long.valueOf(1L), + Long.valueOf(reopened.servedCount())); + } + + private static void testGenerationOnDemand() throws IOException { + Path dir = freshDir("world-generate"); + World world = new World(99L, new ChunkStorage(dir)); + Chunk chunk = world.chunkAt(new ChunkPos(0, 0)); + yes("it is generated", chunk.isGenerated()); + eq("one chunk generated", Long.valueOf(1L), Long.valueOf(world.generatedCount())); + eq("one chunk served", Long.valueOf(1L), Long.valueOf(world.servedCount())); + + world.chunkAt(new ChunkPos(0, 0)); + eq("asking again generates nothing", Long.valueOf(1L), + Long.valueOf(world.generatedCount())); + eq("but it is served again", Long.valueOf(2L), Long.valueOf(world.servedCount())); + eq("one chunk in memory", Integer.valueOf(1), Integer.valueOf(world.loadedChunks())); + + // a second world with the same seed must produce the same terrain + Path other = freshDir("world-generate-2"); + World twin = new World(99L, new ChunkStorage(other)); + yes("the same seed gives the same terrain", + Arrays.equals(chunk.snapshot(), + twin.chunkAt(new ChunkPos(0, 0)).snapshot())); + } + + private static void testAutosave() throws IOException { + Path dir = freshDir("world-autosave"); + ChunkStorage storage = new ChunkStorage(dir); + World world = new World(7L, storage); + ChunkPos pos = new ChunkPos(0, 0); + Chunk chunk = world.chunkAt(pos); + chunk.setBlock(0, 200, 0, Blocks.SAND); + yes("it is dirty", chunk.isDirty()); + + for (int i = 0; i < World.AUTOSAVE_INTERVAL_TICKS; i++) { + world.tick(); + } + yes("it was saved", !chunk.isDirty()); + yes("the file is there", storage.exists(pos)); + eq("one chunk saved", Long.valueOf(1L), Long.valueOf(world.savedCount())); + + Chunk back = storage.load(pos); + eq("the change survived", Byte.valueOf(Blocks.SAND), + Byte.valueOf(back.block(0, 200, 0))); + } + + private static void testEviction() throws IOException { + Path dir = freshDir("world-evict"); + ChunkStorage storage = new ChunkStorage(dir); + World world = new World(11L, storage); + ChunkPos pos = new ChunkPos(5, 5); + world.chunkAt(pos); + eq("one chunk in memory", Integer.valueOf(1), Integer.valueOf(world.loadedChunks())); + + for (int i = 0; i < (int) World.EVICT_AFTER_TICKS + 10; i++) { + world.tick(); + } + int dropped = world.evictIdle(); + eq("it was dropped", Integer.valueOf(1), Integer.valueOf(dropped)); + eq("nothing left in memory", Integer.valueOf(0), Integer.valueOf(world.loadedChunks())); + eq("one chunk evicted", Long.valueOf(1L), Long.valueOf(world.evictedCount())); + yes("and it is on disk", storage.exists(pos)); + + // and it comes back with the same terrain + Chunk back = world.chunkAt(pos); + yes("it came back", back.isGenerated()); + eq("with the same surface", + Integer.valueOf(world.generator().heightAt(5 * Chunk.WIDTH, 5 * Chunk.WIDTH) + 1), + Integer.valueOf(back.surfaceHeight(0, 0))); + } + + private static void testUnloadedWriteRefused() throws IOException { + Path dir = freshDir("world-unloaded"); + World world = new World(3L, new ChunkStorage(dir)); + yes("a write to an unloaded chunk is refused", + !world.setBlock(0, 64, 0, Blocks.STONE)); + eq("and nothing was generated", Long.valueOf(0L), + Long.valueOf(world.generatedCount())); + + world.chunkAt(new ChunkPos(0, 0)); + yes("a write to a loaded chunk works", world.setBlock(0, 64, 0, Blocks.STONE)); + eq("and the block reads back", Byte.valueOf(Blocks.STONE), + Byte.valueOf(world.blockAt(0, 64, 0))); + yes("writing the same value is not a change", + !world.setBlock(0, 64, 0, Blocks.STONE)); + } + + private static void testCounters() throws IOException { + Path dir = freshDir("world-counters"); + World world = new World(13L, new ChunkStorage(dir)); + world.chunkAt(new ChunkPos(0, 0)); + world.chunkAt(new ChunkPos(1, 0)); + eq("two generated", Long.valueOf(2L), Long.valueOf(world.generatedCount())); + eq("two served", Long.valueOf(2L), Long.valueOf(world.servedCount())); + eq("two in memory", Integer.valueOf(2), Integer.valueOf(world.loadedChunks())); + eq("two positions listed", Integer.valueOf(2), + Integer.valueOf(world.loadedPositions().size())); + eq("two chunks listed", Integer.valueOf(2), + Integer.valueOf(world.loaded().size())); + eq("nothing saved yet", Long.valueOf(0L), Long.valueOf(world.savedCount())); + eq("nothing evicted yet", Long.valueOf(0L), Long.valueOf(world.evictedCount())); + } + + private static void testTick() throws IOException { + Path dir = freshDir("world-tick"); + World world = new World(17L, new ChunkStorage(dir)); + eq("it starts at zero", Long.valueOf(0L), Long.valueOf(world.ticks())); + world.tick(); + world.tick(); + eq("it counts", Long.valueOf(2L), Long.valueOf(world.ticks())); + } + + // -------------------------------------------------------------- helpers + + /** An empty directory, so a test never reads what another one wrote. */ + private static Path freshDir(String name) throws IOException { + Path dir = Path.of("build", "selftest", name); + deleteTree(dir); + Files.createDirectories(dir); + return dir; + } + + private static void deleteTree(Path dir) throws IOException { + if (!Files.exists(dir)) { + return; + } + java.util.List all = new java.util.ArrayList(); + try (java.util.stream.Stream walk = Files.walk(dir)) { + walk.forEach(all::add); + } + for (int i = all.size() - 1; i >= 0; i--) { + Files.deleteIfExists(all.get(i)); + } + } +}