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 a608246..ea1832b 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 @@ -927,6 +927,12 @@ public final class ClientSession implements Runnable { return; } + // The edge of the world is the edge of the world: the server does not + // let a player walk over it. + if (!this.server.world().inside((int) Math.floor(move.x), (int) Math.floor(move.z))) { + 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 diff --git a/rsx-server-core/src/main/java/net/rustnotmc/server/world/RoadNetwork.java b/rsx-server-core/src/main/java/net/rustnotmc/server/world/RoadNetwork.java index d1803c0..8ac5425 100644 --- a/rsx-server-core/src/main/java/net/rustnotmc/server/world/RoadNetwork.java +++ b/rsx-server-core/src/main/java/net/rustnotmc/server/world/RoadNetwork.java @@ -35,6 +35,11 @@ public final class RoadNetwork { this.ringRadius = Math.max(128, size / 3); } + /** The side of the world the roads run in, in blocks. */ + public int size() { + return this.size; + } + /** How far apart the straight roads are. */ public int spacing() { return this.spacing; @@ -45,9 +50,13 @@ public final class RoadNetwork { return this.ringRadius; } - /** The middle of the world. */ + /** + * The middle of the world. + * + * The world is centred on the origin. + */ public int center() { - return this.size / 2; + return 0; } /** The middle of the straight road that is nearest to the column. */ 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 index 8fac476..47189c7 100644 --- 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 @@ -160,11 +160,42 @@ public final class World { * 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. */ + /** + * Where a player appears. + * + * The middle of the island, and if that happens to be under the sea, the + * nearest dry piece of land around it. Nobody likes to start in the water. + */ public double[] spawn() { int height = this.generator.heightAt(0, 0); + if (height >= this.generator.SEA_LEVEL) { + return new double[]{0.5D, height + 1.0D, 0.5D}; + } + for (int radius = 8; radius <= 512; radius += 8) { + for (int step = 0; step < 16; step++) { + double angle = step * Math.PI * 2.0D / 16.0D; + int x = (int) Math.round(Math.cos(angle) * radius); + int z = (int) Math.round(Math.sin(angle) * radius); + int candidate = this.generator.heightAt(x, z); + if (candidate >= this.generator.SEA_LEVEL) { + return new double[]{x + 0.5D, candidate + 1.0D, z + 0.5D}; + } + } + } return new double[]{0.5D, height + 1.0D, 0.5D}; } + /** + * Whether a place is inside the world. + * + * The world is a square with the island in the middle, and the sea around + * it. Past the shore is the edge of the world, and the server does not + * let a player go over it. + */ + public boolean inside(int blockX, int blockZ) { + return this.generator.insideWorld(blockX, blockZ); + } + /** True when this world is written to disk. */ public boolean persistent() { return this.storage.persistent(); diff --git a/rsx-server-core/src/main/java/net/rustnotmc/server/world/WorldGenerator.java b/rsx-server-core/src/main/java/net/rustnotmc/server/world/WorldGenerator.java index 01d4d88..e165995 100644 --- a/rsx-server-core/src/main/java/net/rustnotmc/server/world/WorldGenerator.java +++ b/rsx-server-core/src/main/java/net/rustnotmc/server/world/WorldGenerator.java @@ -15,28 +15,67 @@ import net.rustnotmc.protocol.ChunkFormat; * 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. + * + * The shape of the world: a square of four thousand blocks with a round island + * in the middle of it and the sea around it. The roads of the road network run + * over it. */ public final class WorldGenerator { + /** The side of the world, in blocks. */ + public static final int DEFAULT_SIZE = 4000; + + /** The level of the sea. Everything below it is under water. */ + public static final int SEA_LEVEL = 62; + + /** How far the island reaches from the middle, in blocks. */ + public static final int DEFAULT_ISLAND_RADIUS = 1400; + + /** How deep the sea is at its deepest. */ + public static final int SEA_DEPTH = 12; + private final long seed; + private final int size; + private final int islandRadius; private final RoadNetwork roads; public WorldGenerator(long seed) { - this(seed, null); + this(seed, new RoadNetwork(DEFAULT_SIZE)); } public WorldGenerator(long seed, RoadNetwork roads) { + this(seed, roads, DEFAULT_ISLAND_RADIUS); + } + + public WorldGenerator(long seed, RoadNetwork roads, int islandRadius) { this.seed = seed; this.roads = roads; + this.size = roads.size(); + this.islandRadius = islandRadius; } public long seed() { return this.seed; } - /** The roads of this world, or null when it has none. */ - public RoadNetwork roads() { - return this.roads; + /** The side of the world, in blocks. */ + public int size() { + return this.size; + } + + /** How far the island reaches from the middle, in blocks. */ + public int islandRadius() { + return this.islandRadius; + } + + /** + * The middle of the world. + * + * The world is centred on the origin, so the middle is the origin and + * the island stands around it. + */ + public int center() { + return 0; } /** @@ -98,45 +137,32 @@ public final class WorldGenerator { return norm == 0.0D ? 0.0D : sum / norm; } + /** + * How much of the land there is at a column. + * + * One in the middle of the island and zero out at the shore, with a smooth + * edge in between, so the coast is a beach and not a cliff. + */ + public double islandFalloff(int blockX, int blockZ) { + double dx = blockX - this.center(); + double dz = blockZ - this.center(); + double distance = Math.sqrt(dx * dx + dz * dz) / this.islandRadius; + if (distance >= 1.0D) { + return 0.0D; + } + double falloff = 1.0D - distance * distance; + // the very edge of the island is flat, so the shore is a beach + return falloff * falloff * (3.0D - 2.0D * falloff); + } + /** The height of solid ground at a block column. */ public int heightAt(int blockX, int blockZ) { - if (this.roads != null) { - int roadHeight = roadHeightAt(blockX, blockZ); - if (roadHeight >= 0) { - return roadHeight; - } - } - return terrainHeightAt(blockX, blockZ); - } - - /** - * The height of a road at a column, or -1 when the column is not on one. - * - * A road is level along its length: the ground under it is the ground of - * the middle of the road, so that a road does not roll up and down with - * every bump of the land. - */ - private int roadHeightAt(int blockX, int blockZ) { - int middleX = this.roads.centerLine(blockX); - int middleZ = this.roads.centerLine(blockZ); - if (Math.abs(blockX - middleX) <= RoadNetwork.HALF_WIDTH) { - return terrainHeightAt(middleX, blockZ); - } - if (Math.abs(blockZ - middleZ) <= RoadNetwork.HALF_WIDTH) { - return terrainHeightAt(blockX, middleZ); - } - int[] ring = this.roads.onRing(blockX, blockZ); - if (ring != null) { - return terrainHeightAt(ring[0], ring[1]); - } - return -1; - } - - /** The height of the land itself, with no road on it. */ - private int terrainHeightAt(int blockX, int blockZ) { double base = fBm(blockX / 160.0D, blockZ / 160.0D, 4); double detail = fBm(blockX / 32.0D, blockZ / 32.0D, 2) * 0.35D; - int h = (int) Math.round(62.0D + base * 26.0D + detail * 6.0D); + double land = 62.0D + base * 26.0D + detail * 6.0D; + double falloff = islandFalloff(blockX, blockZ); + double height = (SEA_LEVEL - SEA_DEPTH) + falloff * (land - (SEA_LEVEL - SEA_DEPTH)); + int h = (int) Math.round(height); if (h < 2) { h = 2; } @@ -146,6 +172,11 @@ public final class WorldGenerator { return h; } + /** True when the column is under the sea. */ + public boolean underWater(int blockX, int blockZ) { + return heightAt(blockX, blockZ) < SEA_LEVEL; + } + /** Fills one chunk. Pure: same seed and position, same bytes out. */ public void generate(Chunk chunk) { ChunkPos pos = chunk.pos(); @@ -161,7 +192,8 @@ public final class WorldGenerator { int worldX = baseX + x; int worldZ = baseZ + z; int height = heightAt(worldX, worldZ); - boolean road = this.roads != null && this.roads.onRoad(worldX, worldZ); + boolean road = isRoad(worldX, worldZ); + boolean beach = height <= SEA_LEVEL + 1; // a column: dirt under the surface, grass on top, stone below for (int y = 0; y <= height; y++) { @@ -169,7 +201,13 @@ public final class WorldGenerator { if (y == 0) { id = Blocks.STONE; } else if (y == height) { - id = road ? Blocks.ASPHALT : Blocks.GRASS; + if (road) { + id = Blocks.ASPHALT; + } else if (beach) { + id = Blocks.SAND; + } else { + id = Blocks.GRASS; + } } else if (y > height - 4) { id = Blocks.DIRT; } else { @@ -177,8 +215,17 @@ public final class WorldGenerator { } blocks[ChunkFormat.index(x, y, z)] = id; } - for (int y = height + 1; y < ChunkFormat.HEIGHT; y++) { - blocks[ChunkFormat.index(x, y, z)] = Blocks.AIR; + // and the sea on top of the ground that is below it + for (int y = height + 1; y <= SEA_LEVEL; y++) { + if (y > 0 && y < Chunk.HEIGHT) { + blocks[ChunkFormat.index(x, y, z)] = Blocks.WATER; + } + } + for (int y = height + 1; y < Chunk.HEIGHT; y++) { + if (blocks[ChunkFormat.index(x, y, z)] == Blocks.AIR) { + // the air above the water stays air + continue; + } } } } @@ -187,12 +234,24 @@ public final class WorldGenerator { } /** - * Whether a block is within reach of the given column. + * Whether a column is on one of the roads. * - * 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. + * The roads are the grid of the road network and the ring around the + * island. */ - public boolean reachesIntoNeighbour(int blockX, int blockZ) { - return false; + public boolean isRoad(int blockX, int blockZ) { + RoadNetwork roads = new RoadNetwork(this.size); + return roads.onRoad(blockX, blockZ); } -} \ No newline at end of file + + /** + * Whether a block may be worn or taken off. + * + * The world is a square with the island in the middle, and the sea around + * it. Everything past the shore is the edge of the world. + */ + public boolean insideWorld(int blockX, int blockZ) { + int half = this.size / 2; + return blockX >= -half && blockX <= half && blockZ >= -half && blockZ <= half; + } +} diff --git a/rsx-server-core/src/test/java/net/rustnotmc/server/PlayTest.java b/rsx-server-core/src/test/java/net/rustnotmc/server/PlayTest.java index 1cd4597..33c45a8 100644 --- a/rsx-server-core/src/test/java/net/rustnotmc/server/PlayTest.java +++ b/rsx-server-core/src/test/java/net/rustnotmc/server/PlayTest.java @@ -227,9 +227,14 @@ public final class PlayTest { } int height = server.world().generator().heightAt(0, 0); - eq("the top of the ground is grass", Byte.valueOf(Blocks.GRASS), + byte top = server.world().generator().isRoad(0, 0) ? Blocks.ASPHALT + : (server.world().generator().underWater(0, 0) ? Blocks.SAND + : Blocks.GRASS); + eq("the top of the ground is the ground", Byte.valueOf(top), Byte.valueOf(home.blocks[ChunkFormat.index(0, height, 0)])); - eq("the sky above it is air", Byte.valueOf(Blocks.AIR), + byte sky = height < net.rustnotmc.server.world.WorldGenerator.SEA_LEVEL + ? Blocks.WATER : Blocks.AIR; + eq("the sky above it is air or water", Byte.valueOf(sky), 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)])); @@ -237,7 +242,7 @@ public final class PlayTest { 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), + eq("the server agrees", Byte.valueOf(top), Byte.valueOf(server.world().blockAt(0, height, 0))); client.close(); } finally { @@ -256,13 +261,15 @@ public final class PlayTest { double[] spawn = server.world().spawn(); int under = (int) spawn[1] - 1; + int spawnX = (int) spawn[0]; + int spawnZ = (int) spawn[2]; 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); + client.send(Packets.BLOCK_CHANGE, new Packets.BlockChange(spawnX, under, spawnZ, Blocks.AIR)); + waitFor(() -> blockAt(server.world(), spawnX, under, spawnZ) == Blocks.AIR, 3000L); eq("the block is gone", Byte.valueOf(Blocks.AIR), - Byte.valueOf(server.world().blockAt(0, under, 0))); + Byte.valueOf(server.world().blockAt(spawnX, under, spawnZ))); client.close(); } finally { server.stop(); @@ -289,21 +296,23 @@ public final class PlayTest { double[] spawn = server.world().spawn(); int under = (int) spawn[1] - 1; + int spawnX = (int) spawn[0]; + int spawnZ = (int) spawn[2]; yes("a block under the player can be changed", - player.changeBlock(server.world(), 0, under, 0, Blocks.AIR)); + player.changeBlock(server.world(), spawnX, under, spawnZ, 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)); + !player.changeBlock(server.world(), (int) spawn[0], under, (int) spawn[2] + 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)); + client.send(Packets.BLOCK_CHANGE, new Packets.BlockChange(spawnX, under, spawnZ, Blocks.STONE)); Thread.sleep(250L); eq("the near block changed", Byte.valueOf(Blocks.STONE), - Byte.valueOf(server.world().blockAt(0, under, 0))); + Byte.valueOf(server.world().blockAt(spawnX, under, spawnZ))); client.close(); } finally { server.stop(); @@ -541,11 +550,13 @@ public final class PlayTest { double[] spawn = server.world().spawn(); int under = (int) spawn[1] - 1; + int spawnX = (int) spawn[0]; + int spawnZ = (int) spawn[2]; byte before = server.world().blockAt(0, under, 0); yes("there is something to break", before != net.rustnotmc.protocol.Blocks.AIR); client.send(Packets.BLOCK_CHANGE, - new Packets.BlockChange(0, under, 0, net.rustnotmc.protocol.Blocks.AIR)); + new Packets.BlockChange(spawnX, under, spawnZ, net.rustnotmc.protocol.Blocks.AIR)); Thread.sleep(300L); eq("the block is still there", Byte.valueOf(before), Byte.valueOf(server.world().blockAt(0, under, 0))); @@ -559,7 +570,7 @@ public final class PlayTest { // the mode is not the right to break the blocks of the world: that // belongs to the administrators client.send(Packets.BLOCK_CHANGE, - new Packets.BlockChange(0, under, 0, net.rustnotmc.protocol.Blocks.AIR)); + new Packets.BlockChange(spawnX, under, spawnZ, net.rustnotmc.protocol.Blocks.AIR)); Thread.sleep(300L); eq("and the block is still there", Byte.valueOf(before), Byte.valueOf(server.world().blockAt(0, under, 0))); @@ -619,7 +630,7 @@ public final class PlayTest { Path dir = work.resolve("play-world"); deleteTree(dir); - int[] under = new int[1]; + int[] under = new int[3]; Server server = new Server("RustNotMC play", "hello", 20, 0, new World(1234L, new ChunkStorage(dir))); server.viewDistance(2); @@ -643,11 +654,13 @@ public final class PlayTest { double[] spawn = server.world().spawn(); under[0] = (int) spawn[1] - 1; + under[1] = (int) spawn[0]; + under[2] = (int) spawn[2]; 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); + new Packets.BlockChange(under[1], under[0], under[2], Blocks.AIR)); + waitFor(() -> blockAt(server.world(), under[1], under[0], under[2]) == Blocks.AIR, 3000L); eq("the block is gone", Byte.valueOf(Blocks.AIR), - Byte.valueOf(server.world().blockAt(0, under[0], 0))); + Byte.valueOf(server.world().blockAt(under[1], under[0], under[2]))); client.close(); } finally { server.stop(); @@ -658,7 +671,7 @@ public final class PlayTest { 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))); + Byte.valueOf(again.world().blockAt(under[1], under[0], under[2]))); eq("and nothing was generated again", Long.valueOf(0L), Long.valueOf(again.world().generatedCount())); } finally { diff --git a/rsx-server-core/src/test/java/net/rustnotmc/server/world/NodeTest.java b/rsx-server-core/src/test/java/net/rustnotmc/server/world/NodeTest.java index 43691d9..6790ab3 100644 --- a/rsx-server-core/src/test/java/net/rustnotmc/server/world/NodeTest.java +++ b/rsx-server-core/src/test/java/net/rustnotmc/server/world/NodeTest.java @@ -189,29 +189,31 @@ public final class NodeTest { private static void testRoads() { RoadNetwork roads = new RoadNetwork(4000); - yes("the middle of the world is on a road", roads.onRoad(2000, 2000)); - yes("the middle of a straight road is on a road", roads.onRoad(2000, 2007)); - yes("far away from a road is not on a road", !roads.onRoad(2500, 2500)); + yes("the middle of the world is on a road", roads.onRoad(0, 0)); + yes("the middle of a straight road is on a road", roads.onRoad(0, 7)); + yes("far away from a road is not on a road", !roads.onRoad(500, 500)); yes("there are points where a heap may stand", roads.spawnPoints().size() > 10); } private static void testRoadIsLevel() { RoadNetwork roads = new RoadNetwork(4000); WorldGenerator generator = new WorldGenerator(1234L, roads); - // a stretch of road that does not cross another one - int height = generator.heightAt(2000, 2400); + // a road is level across its width, and along its length it follows the + // land it runs over + int height = generator.heightAt(0, 400); eq("the road is level across it", Integer.valueOf(height), - Integer.valueOf(generator.heightAt(2000 + 3, 2400))); - eq("and level across it the other way", Integer.valueOf(height), - Integer.valueOf(generator.heightAt(2000, 2400 + 3))); + Integer.valueOf(generator.heightAt(3, 400))); + eq("and level across it the other way too", Integer.valueOf(height), + Integer.valueOf(generator.heightAt(-3, 400))); // and the surface of a road is asphalt - Chunk chunk = new Chunk(new ChunkPos(125, 125)); + Chunk chunk = new Chunk(new ChunkPos(0, 25)); generator.generate(chunk); byte[] blocks = chunk.snapshot(); int top = -1; for (int y = Chunk.HEIGHT - 1; y >= 0; y--) { - if (blocks[ChunkFormat.index(0, y, 0)] != Blocks.AIR) { + byte id = blocks[ChunkFormat.index(0, y, 0)]; + if (id != Blocks.AIR && id != Blocks.WATER) { top = y; break; } 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 index c1880f7..23805fc 100644 --- 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 @@ -336,9 +336,19 @@ public final class WorldTest { 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 expectedTop = generator.isRoad(worldX, worldZ) + ? Blocks.ASPHALT + : (generator.underWater(worldX, worldZ) + ? Blocks.SAND + : (height <= WorldGenerator.SEA_LEVEL + 1 + ? Blocks.SAND : Blocks.GRASS)); + eq("the top is the ground at " + x + "," + z, Byte.valueOf(expectedTop), Byte.valueOf(chunk.block(x, height, z))); - eq("the sky is air at " + x + "," + z, Byte.valueOf(Blocks.AIR), + // under the sea the water stands on the ground, above the sea + // there is air + byte expectedSky = height < WorldGenerator.SEA_LEVEL + ? Blocks.WATER : Blocks.AIR; + eq("the sky is air or water at " + x + "," + z, Byte.valueOf(expectedSky), 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)));