Мир: остров, дороги-дамбы, биомы; LOD ступенями; консоль сервера; баны; постобработка

This commit is contained in:
sasheg dev
2026-10-22 21:38:23 +03:00
parent d98cd83fe5
commit 07ed4ce02d
19 changed files with 2257 additions and 122 deletions
@@ -66,9 +66,9 @@ public final class ClientSession implements Runnable {
/** The identity of the player, as the launcher gave it. */
private volatile String uuid = "";
/** The regions of the far view that were sent already. */
private final java.util.Set<Integer> sentLodRegions =
new java.util.LinkedHashSet<Integer>();
/** The regions of the far view that were sent already, with their step. */
private final java.util.Set<Long> sentLodRegions =
new java.util.LinkedHashSet<Long>();
/** When the session runs out. */
private volatile long sessionExpiresAt;
@@ -386,6 +386,13 @@ public final class ClientSession implements Runnable {
if (canonical == null) {
canonical = claims.nick;
}
if (this.server.bans().isBanned(canonical)) {
this.server.log("token of " + canonical + " refused: the account is banned");
send(Packets.AUTH_ACK, Packets.AuthAck.refused(
"the session was refused: the account is banned"));
close();
return;
}
this.server.throttle().succeeded(given.account);
this.account = canonical;
this.uuid = claims.uuid == null ? "" : claims.uuid;
@@ -503,8 +510,12 @@ public final class ClientSession implements Runnable {
/**
* Sends the far view around the player.
*
* The far view is a grid of heights and colours, one region at a time. A
* region is only sent once: the client keeps what it got.
* The far view is a grid of heights and colours, one region at a time, and
* the regions near the player are finer than the ones far away: a region
* comes with every fourth block up to two regions away, then every
* eighth, sixteenth, thirty-second and, at the horizon, every
* sixty-fourth. The whole thing reaches further than any player can see, a
* ring of regions at a time, and the client keeps the last of each.
*/
void sendLod() {
if (this.player == null) {
@@ -514,23 +525,41 @@ public final class ClientSession implements Runnable {
int chunkZ = (int) Math.floor(this.player.z() / 16.0D);
int regionX = net.rustnotmc.server.world.LodGenerator.regionChunk(chunkX);
int regionZ = net.rustnotmc.server.world.LodGenerator.regionChunk(chunkZ);
for (int dx = -2; dx <= 2; dx++) {
for (int dz = -2; dz <= 2; dz++) {
for (int dx = -LOD_RINGS; dx <= LOD_RINGS; dx++) {
for (int dz = -LOD_RINGS; dz <= LOD_RINGS; dz++) {
int rx = regionX + dx * net.rustnotmc.server.world.LodGenerator.REGION_CHUNKS;
int rz = regionZ + dz * net.rustnotmc.server.world.LodGenerator.REGION_CHUNKS;
Integer key = Integer.valueOf(rx * 100003 + rz);
int ring = Math.max(Math.abs(dx), Math.abs(dz));
int step = lodStep(ring);
Long key = Long.valueOf(((rx & 0xFFFFL) << 48) | ((rz & 0xFFFFL) << 32)
| (step & 0xFFFFL));
if (this.sentLodRegions.contains(key)) {
continue;
}
this.sentLodRegions.add(key);
if (this.sentLodRegions.size() > 128) {
if (this.sentLodRegions.size() > 1024) {
this.sentLodRegions.clear();
}
send(Packets.LOD_REGION, this.server.lod().region(rx, rz));
send(Packets.LOD_REGION, this.server.lod().region(rx, rz, step));
}
}
}
/** How many regions there are from the player out to the horizon. */
private static final int LOD_RINGS = 6;
/** How far apart the samples of a ring are. */
static int lodStep(int ring) {
int step = net.rustnotmc.server.world.LodGenerator.STEP;
for (int r = ring; r > 1; r--) {
if (step >= 64) {
break;
}
step *= 2;
}
return step;
}
private void onAuth(Frame frame) {
if (this.state != SessionState.HANDSHAKING) {
return;
@@ -578,6 +607,13 @@ public final class ClientSession implements Runnable {
}
this.server.throttle().succeeded(auth.account);
if (this.server.bans().isBanned(name)) {
this.server.log("auth of " + name + " refused: the account is banned");
send(Packets.AUTH_ACK, Packets.AuthAck.refused(
"the session was refused: the account is banned"));
close();
return;
}
net.rustnotmc.auth.TokenService.Token issued = this.server.tokens().issue(name);
this.account = name;
this.token = issued.value;
@@ -0,0 +1,192 @@
package net.rustnotmc.server;
import java.io.BufferedReader;
import java.io.IOException;
import java.io.InputStreamReader;
/**
* The console of the server.
*
* A server is not just a socket that answers; it is a machine somebody is
* sitting at, and the person sitting at it needs to say things: who may do
* what, who may not come in, and when to stop. Typing a line is the plainest
* way to say it.
*
* The lines are the same ones the rest of the server uses, so a console
* command and a change to a file agree with each other: the rights live in
* files, the console only writes to the files, and what the file says is what
* the world obeys.
*/
public final class ConsoleCommands implements Runnable {
private final Server server;
public ConsoleCommands(Server server) {
this.server = server;
}
@Override
public void run() {
BufferedReader reader = new BufferedReader(
new InputStreamReader(System.in));
while (this.server.running()) {
String line;
try {
line = reader.readLine();
} catch (IOException closed) {
// the console is gone; there is nobody left to talk to
return;
}
if (line == null) {
return;
}
try {
command(line.trim());
} catch (RuntimeException e) {
System.out.println("[rsx] console: " + e);
}
}
}
/** Carries out one line the console was given. */
void command(String line) {
if (line.isEmpty() || line.startsWith("#")) {
return;
}
String[] words = line.split("\\s+", 2);
String command = words[0];
String rest = words.length > 1 ? words[1].trim() : "";
// a slash is what a player types; the console takes either
if (command.startsWith("/")) {
command = command.substring(1);
}
switch (command.toLowerCase()) {
case "help":
System.out.println("[rsx] console commands:");
System.out.println(" list who is here");
System.out.println(" say <text> say something to everybody");
System.out.println(" op <name> make somebody an administrator");
System.out.println(" deop <name> take the right back");
System.out.println(" kick <name> send somebody home");
System.out.println(" ban <name> keep an account out for good");
System.out.println(" unban <name> let an account come back");
System.out.println(" save save accounts and players");
System.out.println(" stop save everything and stop");
break;
case "list":
list();
break;
case "say":
if (rest.isEmpty()) {
System.out.println("[rsx] say what?");
break;
}
this.server.broadcastChat("Server", rest);
System.out.println("[rsx] said to everybody: " + rest);
break;
case "op":
if (rest.isEmpty()) {
System.out.println("[rsx] op who?");
break;
}
this.server.admins().give(rest);
System.out.println("[rsx] " + rest + " is an administrator now");
break;
case "deop":
if (rest.isEmpty()) {
System.out.println("[rsx] deop who?");
break;
}
this.server.admins().take(rest);
System.out.println("[rsx] " + rest + " is not an administrator anymore");
break;
case "kick": {
if (rest.isEmpty()) {
System.out.println("[rsx] kick who?");
break;
}
Player player = findOnline(rest);
if (player == null) {
System.out.println("[rsx] " + rest + " is not here");
break;
}
player.session().close();
System.out.println("[rsx] " + rest + " was sent home");
break;
}
case "ban":
if (rest.isEmpty()) {
System.out.println("[rsx] ban who?");
break;
}
this.server.bans().ban(rest);
// an account that is here now does not stay
for (Player player : this.server.players().values()) {
if (player.account().equalsIgnoreCase(rest)) {
player.session().close();
}
}
System.out.println("[rsx] " + rest + " is banned");
break;
case "unban":
if (rest.isEmpty()) {
System.out.println("[rsx] unban who?");
break;
}
this.server.bans().unban(rest);
System.out.println("[rsx] " + rest + " may come back");
break;
case "save":
saveEverything();
System.out.println("[rsx] saved");
break;
case "stop":
System.out.println("[rsx] stopping");
saveEverything();
this.server.stop();
System.exit(0);
break;
default:
System.out.println("[rsx] unknown console command: " + command);
}
}
/** Says who is in the world. */
private void list() {
java.util.Map<Integer, Player> players = this.server.players();
if (players.isEmpty()) {
System.out.println("[rsx] nobody is in the world");
return;
}
for (Player player : players.values()) {
System.out.println("[rsx] " + player.account()
+ " at " + ((int) player.x()) + "," + ((int) player.y())
+ "," + ((int) player.z()));
}
System.out.println("[rsx] " + players.size() + " online, "
+ "sessions " + this.server.sessions().size());
}
/** The player of an account, online right now, or null. */
private Player findOnline(String account) {
for (Player player : this.server.players().values()) {
if (player.account().equalsIgnoreCase(account)) {
return player;
}
}
return null;
}
/** Writes down what would be a shame to lose. */
private void saveEverything() {
for (Player player : this.server.players().values()) {
this.server.savePlayer(player);
}
try {
this.server.world().close();
} catch (RuntimeException e) {
System.out.println("[rsx] the world would not all be written: " + e);
}
}
}
@@ -59,6 +59,8 @@ public final class Server implements net.rustnotmc.server.status.StatusSession.I
private volatile TokenAuth tokenAuth;
private volatile boolean localAccounts = true;
private volatile AdminRegistry admins = new AdminRegistry(null);
private volatile net.rustnotmc.server.admin.BanRegistry bans =
new net.rustnotmc.server.admin.BanRegistry(null);
private volatile net.rustnotmc.server.player.PlayerData playerData =
new net.rustnotmc.server.player.PlayerData(null);
private volatile net.rustnotmc.server.world.LodGenerator lod;
@@ -196,6 +198,21 @@ public final class Server implements net.rustnotmc.server.status.StatusSession.I
this.admins = admins == null ? new AdminRegistry(null) : admins;
}
/**
* Who may not come in at all.
*
* The names live in a file beside the world, and every entrance is
* checked against the file before the player is believed.
*/
public net.rustnotmc.server.admin.BanRegistry bans() {
return this.bans;
}
public void bans(net.rustnotmc.server.admin.BanRegistry bans) {
this.bans = bans == null ? new net.rustnotmc.server.admin.BanRegistry(null)
: bans;
}
/**
* Writes down what a player leaves behind.
*
@@ -135,6 +135,8 @@ public final class ServerMain {
// The administrators are the server's own list.
Path adminFile = adminsFile != null ? adminsFile : worldDir.resolve("admins.txt");
server.admins(new net.rustnotmc.server.admin.AdminRegistry(adminFile));
// who may not come in at all, in a file beside the world
server.bans(new net.rustnotmc.server.admin.BanRegistry(worldDir.resolve("bans.txt")));
// the far view of the world, kept beside it
server.lod(new net.rustnotmc.server.world.LodGenerator(world.generator(),
worldDir.resolve("lod")));
@@ -213,6 +215,12 @@ public final class ServerMain {
}
}, "rsx-shutdown"));
// the console: whoever is sitting at the machine talks to the server
// here, and the queue of words is answered as it comes
Thread console = new Thread(new ConsoleCommands(server), "rsx-console");
console.setDaemon(true);
console.start();
long reportedAt = 0L;
while (server.running()) {
Thread.sleep(5_000L);
@@ -81,6 +81,50 @@ public final class AdminRegistry {
}
}
/** Names one of the administrators, in the file and at once. */
public void give(String account) {
if (account == null || account.isEmpty()) {
return;
}
reread();
String name = account.toLowerCase(Locale.ROOT);
if (!this.admins.add(name)) {
return;
}
save();
this.lastRead = -1L; // the file was just written; do not read it back
}
/** Takes a right away, in the file and at once. */
public void take(String account) {
if (account == null || account.isEmpty()) {
return;
}
reread();
String name = account.toLowerCase(Locale.ROOT);
if (!this.admins.remove(name)) {
return;
}
save();
this.lastRead = -1L;
}
/** Writes the administrators one per line, newest order forgotten. */
private void save() {
if (this.file == null) {
return;
}
try {
java.util.List<String> lines = new java.util.ArrayList<String>(
new java.util.TreeSet<String>(this.admins));
lines.add(0, "# one name of an administrator per line");
Files.createDirectories(this.file.toAbsolutePath().getParent());
Files.write(this.file, lines, StandardCharsets.UTF_8);
} catch (IOException ignored) {
// the list still holds for this run; the log says what happened
}
}
/** Reads the file. */
private void read() {
this.admins.clear();
@@ -0,0 +1,140 @@
package net.rustnotmc.server.admin;
import java.io.IOException;
import java.nio.charset.StandardCharsets;
import java.nio.file.Files;
import java.nio.file.Path;
import java.util.Collections;
import java.util.HashSet;
import java.util.Locale;
import java.util.Set;
/**
* Who may not come in at all.
*
* A ban is a promise the server keeps without anybody looking: the names live
* in a file beside the world, and every entrance is checked against it. A
* crashed or a stopped server forgets nothing, because the file is the thing
* that knows.
*
* The same file the registry of the administrators grows up from: one name per
* line, read again whenever anything else writes to it, so that a console
* command is law at once and not at the next restart.
*/
public final class BanRegistry {
/** The file the names are written in. */
private final Path file;
private final Set<String> names = new HashSet<String>();
private long lastRead;
public BanRegistry(Path file) {
this.file = file;
read();
}
/** The file the names are written in. */
public Path file() {
return this.file;
}
/** Whether the account may not come in. */
public boolean isBanned(String account) {
if (account == null || account.isEmpty()) {
return false;
}
reread();
return this.names.contains(account.toLowerCase(Locale.ROOT));
}
/** The names, as the file spells them. */
public Set<String> bans() {
reread();
return Collections.unmodifiableSet(this.names);
}
/** Bans an account, in the file and at once. */
public void ban(String account) {
if (account == null || account.isEmpty()) {
return;
}
String name = account.toLowerCase(Locale.ROOT);
if (!this.names.add(name)) {
return;
}
save();
// the file was just written; the clock must not read it again as new
this.lastRead = -1L;
}
/** Takes a ban away, in the file and at once. */
public void unban(String account) {
if (account == null || account.isEmpty()) {
return;
}
String name = account.toLowerCase(Locale.ROOT);
if (!this.names.remove(name)) {
return;
}
save();
this.lastRead = -1L;
}
/** Reads the file again when it changed. */
private void reread() {
if (this.file == null) {
return;
}
try {
long changed = Files.getLastModifiedTime(this.file).toMillis();
if (changed != this.lastRead) {
read();
}
} catch (IOException ignored) {
// the list stays as it was until the disk answers again
}
}
/** Reads the file. A missing one is an empty list. */
private void read() {
this.names.clear();
this.lastRead = 0L;
if (this.file == null) {
return;
}
try {
if (!Files.isRegularFile(this.file)) {
return;
}
this.lastRead = Files.getLastModifiedTime(this.file).toMillis();
for (String line : Files.readAllLines(this.file, StandardCharsets.UTF_8)) {
String name = line.trim();
if (name.isEmpty() || name.startsWith("#")) {
continue;
}
this.names.add(name.toLowerCase(Locale.ROOT));
}
} catch (IOException ignored) {
// A list that cannot be read lets players in rather than locking
// the door behind a hiccup of the disk.
}
}
/** Writes the names one per line. */
private void save() {
if (this.file == null) {
return;
}
try {
java.util.List<String> lines = new java.util.ArrayList<String>(
new java.util.TreeSet<String>(this.names));
lines.add(0, "# one banned account per line");
Files.createDirectories(this.file.toAbsolutePath().getParent());
Files.write(this.file, lines, StandardCharsets.UTF_8);
} catch (IOException ignored) {
// the ban holds for this run; the log says what happened
}
}
}
@@ -51,28 +51,42 @@ public final class LodGenerator {
* The far view of one region.
*
* The region is read from the disk when it is there, and worked out when
* it is not.
* it is not. The step decides how far apart the samples are: near the
* player the ground is known finely, far away the same region can come
* with every second, fourth or eighth sample, and the client keeps the
* last one it was given.
*/
public Packets.LodRegion region(int chunkX, int chunkZ) {
public Packets.LodRegion region(int chunkX, int chunkZ, int step) {
int startChunk = regionChunk(chunkX);
int startZ = regionChunk(chunkZ);
int originX = startChunk * 16;
int originZ = startZ * 16;
int count = samples(step);
byte[] heights = new byte[SAMPLES * SAMPLES];
byte[] colors = new byte[SAMPLES * SAMPLES * 3];
if (!read(startChunk, startZ, heights, colors)) {
write(startChunk, startZ, heights, colors);
byte[] heights = new byte[count * count];
byte[] colors = new byte[count * count * 3];
if (!read(startChunk, startZ, step, heights, colors)) {
write(startChunk, startZ, step, heights, colors);
}
return new Packets.LodRegion(originX, originZ, STEP, SAMPLES, heights, colors);
return new Packets.LodRegion(originX, originZ, step, count, heights, colors);
}
/** Reads a region from the disk. */
private boolean read(int chunkX, int chunkZ, byte[] heights, byte[] colors) {
/** The far view of one region at the finest step. */
public Packets.LodRegion region(int chunkX, int chunkZ) {
return region(chunkX, chunkZ, STEP);
}
/** How many samples there are on the side of a region, at a step. */
public static int samples(int step) {
return REGION_BLOCKS / Math.max(1, step);
}
/** Reads a region from the disk, at a step. */
private boolean read(int chunkX, int chunkZ, int step, byte[] heights, byte[] colors) {
if (this.cache == null) {
return false;
}
Path file = this.cache.resolve(chunkX + "_" + chunkZ + ".bin");
Path file = fileOf(chunkX, chunkZ, step);
if (!Files.isRegularFile(file)) {
return false;
}
@@ -90,14 +104,20 @@ public final class LodGenerator {
}
}
/** Works a region out and writes it down. */
private void write(int chunkX, int chunkZ, byte[] heights, byte[] colors) {
for (int z = 0; z < SAMPLES; z++) {
for (int x = 0; x < SAMPLES; x++) {
int worldX = chunkX * 16 + x * STEP;
int worldZ = chunkZ * 16 + z * STEP;
/** The file a region of a step lives in. */
private Path fileOf(int chunkX, int chunkZ, int step) {
return this.cache.resolve(chunkX + "_" + chunkZ + "_s" + step + ".bin");
}
/** Works a region out and writes it down, at a step. */
private void write(int chunkX, int chunkZ, int step, byte[] heights, byte[] colors) {
int count = samples(step);
for (int z = 0; z < count; z++) {
for (int x = 0; x < count; x++) {
int worldX = chunkX * 16 + x * step;
int worldZ = chunkZ * 16 + z * step;
int height = this.generator.heightAt(worldX, worldZ);
int at = z * SAMPLES + x;
int at = z * count + x;
heights[at] = (byte) height;
int colour = colourOf(worldX, worldZ, height);
colors[at * 3] = (byte) ((colour >> 16) & 0xFF);
@@ -113,7 +133,7 @@ public final class LodGenerator {
byte[] data = new byte[heights.length + colors.length];
System.arraycopy(heights, 0, data, 0, heights.length);
System.arraycopy(colors, 0, data, heights.length, colors.length);
Path file = this.cache.resolve(chunkX + "_" + chunkZ + ".bin");
Path file = fileOf(chunkX, chunkZ, step);
Path temp = file.resolveSibling(file.getFileName() + ".tmp");
Files.write(temp, data);
Files.move(temp, file, java.nio.file.StandardCopyOption.REPLACE_EXISTING);
@@ -128,7 +148,7 @@ public final class LodGenerator {
if (height < WorldGenerator.SEA_LEVEL) {
return 0x2E5C8A;
}
byte top = topBlock(worldX, worldZ, height);
byte top = this.generator.surfaceAt(worldX, worldZ);
switch (top) {
case Blocks.SAND:
return 0xC8B47C;
@@ -136,24 +156,12 @@ public final class LodGenerator {
return 0x6E6E6E;
case Blocks.ASPHALT:
return 0x3A3A3A;
case Blocks.SNOW:
return 0xE9EDEF;
case Blocks.WATER:
return 0x2E5C8A;
default:
return 0x4E7A34;
}
}
/** The block on the top of a column. */
private byte topBlock(int worldX, int worldZ, int height) {
if (this.generator.isRoad(worldX, worldZ)) {
return Blocks.ASPHALT;
}
if (height < WorldGenerator.SEA_LEVEL) {
return Blocks.WATER;
}
if (height <= WorldGenerator.SEA_LEVEL + 1) {
return Blocks.SAND;
}
return Blocks.GRASS;
}
}
@@ -97,6 +97,26 @@ public final class RoadNetwork {
return onStraightRoad(x, z) || onRing(x, z) != null;
}
/**
* True when the column is on a road or within {@code extra} blocks of one.
*
* Things that need a clear view of the sky or the road, like the trees,
* keep their distance with this: a road with a crown hanging over it
* would be a road at the bottom of a green hole.
*/
public boolean nearRoad(int x, int z, int extra) {
int reach = HALF_WIDTH + extra;
boolean straights = Math.abs(x - centerLine(x)) <= reach
|| Math.abs(z - centerLine(z)) <= reach;
if (straights) {
return true;
}
double dx = x - center();
double dz = z - center();
double distance = Math.sqrt(dx * dx + dz * dz);
return Math.abs(distance - this.ringRadius) <= reach;
}
/**
* The points along the roads where a junk pile may stand.
*
@@ -17,8 +17,20 @@ import net.rustnotmc.protocol.ChunkFormat;
* 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.
* in the middle of it and the sea around it. The land inside the island is a
* broad plain with ridged mountains rising in the middle of it, and it does not
* turn into lakes on its own: the inland ground sits at or above the sea, and
* the open water stays outside the shore where it belongs.
*
* The roads of the road network run over it. A road is level across its width
* and follows the land it runs over along its length, carried by a smooth rank
* of the ground under its middle: where the ground falls to the sea the road
* rides on to it as a causeway, so a road is never under water.
*
* The land wears three kinds of coat: grass in the temperate places, sand in
* the dry ones and on the beaches, and snow on the cold tops of the mountains.
* Trees grow on the temperate ground, evenly at random, and never close enough
* to a road to hang over it.
*/
public final class WorldGenerator {
@@ -34,6 +46,18 @@ public final class WorldGenerator {
/** How deep the sea is at its deepest. */
public static final int SEA_DEPTH = 12;
/** Above this height the ground turns to bare rock. */
public static final int ROCK_TOP = 86;
/** Above this height the rock keeps a coat of snow. */
public static final int SNOW_TOP = 96;
/** How many blocks either side of a road's middle the rank looks at. */
private static final int ROAD_RANK = 8;
/** How many blocks of canopy a tree may reach. */
private static final int TREE_REACH = 2;
private final long seed;
private final int size;
private final int islandRadius;
@@ -84,6 +108,9 @@ public final class WorldGenerator {
* 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.
*
* The middle coordinate is also the salt: two fields that must not be the
* same field are drawn from two different salts.
*/
private long hash(int x, int y, int z) {
long h = this.seed;
@@ -98,6 +125,10 @@ public final class WorldGenerator {
/** Value noise in the range -1..1, smooth between lattice points. */
public double noise(double x, double z) {
return this.noise(x, z, 0);
}
private double noise(double x, double z, int salt) {
int x0 = (int) Math.floor(x);
int z0 = (int) Math.floor(z);
double fx = x - x0;
@@ -106,30 +137,34 @@ public final class WorldGenerator {
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 n00 = lattice(x0, z0, salt);
double n10 = lattice(x0 + 1, z0, salt);
double n01 = lattice(x0, z0 + 1, salt);
double n11 = lattice(x0 + 1, z0 + 1, salt);
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);
private double lattice(int x, int z, int salt) {
long h = hash(x, salt, 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) {
return this.fBm(x, z, octaves, 0);
}
private double fBm(double x, double z, int octaves, int salt) {
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;
sum += noise(x * frequency, z * frequency, salt) * amplitude;
norm += amplitude;
amplitude *= 0.5D;
frequency *= 2.0D;
@@ -137,6 +172,51 @@ public final class WorldGenerator {
return norm == 0.0D ? 0.0D : sum / norm;
}
/**
* Ridge noise: the crest of a folded landscape.
*
* Where plain fBm builds hills that wander, rounded noise makes ranges:
* long lines where the field drifts over zero, steep where it turns. The
* salt keeps the mountain field from being the land field.
*/
private double ridged(double x, double z, int salt) {
double n = this.noise(x, z, salt);
n = 1.0D - Math.abs(n);
return n * n;
}
private double ridgedFbm(double x, double z, int salt) {
double coarse = (1.0D - Math.abs(this.noise(x, z, salt)));
coarse = coarse * coarse;
double fine = (1.0D - Math.abs(this.noise(x * 3.0D, z * 3.0D, salt)));
fine = fine * fine;
return coarse * 0.75D + fine * 0.25D;
}
/**
* How warm a column is, from -1 (the coldest) to 1 (the hottest), salted
* so it is not the same field as the land it lies on.
*/
private double temperature(int blockX, int blockZ) {
return this.fBm(blockX / 300.0D, blockZ / 300.0D, 2, 1111);
}
/** How damp a column is, from -1 (the driest) to 1 (the wettest). */
private double humidity(int blockX, int blockZ) {
return this.fBm(blockX / 240.0D, blockZ / 240.0D, 2, 2222);
}
/** True when the column is in the dry belt that wears sand. */
public boolean isDesert(int blockX, int blockZ) {
return this.temperature(blockX, blockZ) > 0.4D
&& this.humidity(blockX, blockZ) < 0.0D;
}
/** True when the column is in the cold belt that wears snow. */
public boolean isSnowCountry(int blockX, int blockZ) {
return this.temperature(blockX, blockZ) < -0.45D;
}
/**
* How much of the land there is at a column.
*
@@ -155,13 +235,36 @@ public final class WorldGenerator {
return falloff * falloff * (3.0D - 2.0D * falloff);
}
/** The height of solid ground at a block column. */
public int heightAt(int blockX, int blockZ) {
/**
* The height of the ground with no road on it.
*
* The blend is the classic one: the land field slides from a plain under
* the open sky down to the sea floor at the shore, and the sea asks for
* nothing more. On top of that come the mountains, masked by the falloff
* twice so that ranges stay off the beach.
*/
private int rawGround(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;
double land = 62.0D + base * 26.0D + detail * 6.0D;
double land = SEA_LEVEL + base * 26.0D + detail * 6.0D;
double falloff = islandFalloff(blockX, blockZ);
double height = (SEA_LEVEL - SEA_DEPTH) + falloff * (land - (SEA_LEVEL - SEA_DEPTH));
double height = (SEA_LEVEL - SEA_DEPTH)
+ falloff * (land - (SEA_LEVEL - SEA_DEPTH));
if (falloff > 0.18D) {
double ridge = ridgedFbm(blockX / 420.0D, blockZ / 420.0D, 3333);
double mountain = Math.pow(Math.max(0.0D, (ridge - 0.42D) / 0.58D),
1.8D) * 54.0D * falloff * falloff;
height += mountain;
}
// the inland ground does not fall under the sea: where the plain would
// grow a lake, it is a damp flat instead, and the open water keeps to
// the shore and the sea beyond it
if (falloff > 0.25D && height < SEA_LEVEL) {
height = SEA_LEVEL + (falloff - 0.25D) * 4.0D;
}
int h = (int) Math.round(height);
if (h < 2) {
h = 2;
@@ -172,11 +275,232 @@ public final class WorldGenerator {
return h;
}
/** The height of solid ground at a block column. */
public int heightAt(int blockX, int blockZ) {
if (this.isRoad(blockX, blockZ)) {
int rank = this.roadRank(blockX, blockZ);
if (rank < SEA_LEVEL + 1) {
rank = SEA_LEVEL + 1;
}
return this.clamp(rank);
}
return this.clamp(this.rawGround(blockX, blockZ));
}
private int clamp(int h) {
if (h < 2) {
h = 2;
}
if (h > Chunk.HEIGHT - 2) {
h = Chunk.HEIGHT - 2;
}
return h;
}
/**
* The height a road rides at, taken from the land under its middle.
*
* A road is level across its width because every column of it reads the
* same rank, and it follows the land along its length because the rank is
* the average of the ground a few blocks up and down the road, which
* smooths the ground without losing it. Where that rank has sunk under the
* sea the road still rides on it, a causeway, and never drowns.
*/
private int roadRank(int blockX, int blockZ) {
int cx = roads.centerLine(blockX);
int cz = roads.centerLine(blockZ);
boolean acrossX = Math.abs(blockZ - cz) <= RoadNetwork.HALF_WIDTH;
boolean acrossZ = Math.abs(blockX - cx) <= RoadNetwork.HALF_WIDTH;
int level = SEA_LEVEL;
if (acrossX) {
level = rankOfStraight(blockX, blockZ, cz, true);
}
if (acrossZ) {
int other = rankOfStraight(blockX, blockZ, cx, false);
if (other > level) {
level = other;
}
}
if (roads.onRing(blockX, blockZ) != null) {
int ring = rankOfRing(blockX, blockZ);
if (ring > level) {
level = ring;
}
}
return level;
}
/**
* The rank of one straight road. For a road that runs along
* {@code x} the cross line is the {@code z} the road sits on, and the
* other way round.
*/
private int rankOfStraight(int blockX, int blockZ, int crossLine, boolean alongX) {
long sum = 0L;
int count = 0;
for (int k = -ROAD_RANK; k <= ROAD_RANK; k++) {
int sx = alongX ? blockX + k : crossLine;
int sz = alongX ? crossLine : blockZ + k;
sum += rawGround(sx, sz);
count++;
}
int rank = (int) (sum / count);
if (rank < SEA_LEVEL + 1) {
rank = SEA_LEVEL + 1;
}
return rank;
}
/**
* The rank of the ring road, smoothed along the ring itself.
*/
private int rankOfRing(int blockX, int blockZ) {
double dx = blockX - roads.center();
double dz = blockZ - roads.center();
double angle = Math.atan2(dz, dx);
double tx = -Math.sin(angle);
double tz = Math.cos(angle);
long sum = 0L;
int count = 0;
for (int k = -ROAD_RANK; k <= ROAD_RANK; k++) {
int sx = blockX + (int) Math.round(tx * k);
int sz = blockZ + (int) Math.round(tz * k);
sum += rawGround(sx, sz);
count++;
}
int rank = (int) (sum / count);
if (rank < SEA_LEVEL + 1) {
rank = SEA_LEVEL + 1;
}
return rank;
}
/** True when the column is under the sea. */
public boolean underWater(int blockX, int blockZ) {
return heightAt(blockX, blockZ) < SEA_LEVEL;
}
/**
* The block on the top of the ground at a column, before anything grows
* over it.
*
* Roads wear asphalt, ground under the sea and near it wears sand, the dry
* belt wears sand, the tops of the mountains wear rock, the coldest tops
* wear snow, and the rest of the island is grass.
*/
public byte surfaceAt(int blockX, int blockZ) {
if (isRoad(blockX, blockZ)) {
return Blocks.ASPHALT;
}
int height = heightAt(blockX, blockZ);
if (height < SEA_LEVEL) {
return Blocks.SAND;
}
if (height <= SEA_LEVEL + 1) {
return Blocks.SAND;
}
if (height >= SNOW_TOP) {
return Blocks.SNOW;
}
if (height >= ROCK_TOP) {
return Blocks.STONE;
}
if (this.isDesert(blockX, blockZ)) {
return Blocks.SAND;
}
if (this.isSnowCountry(blockX, blockZ)) {
return Blocks.SNOW;
}
return Blocks.GRASS;
}
/**
* Whether a tree stands at a column.
*
* A tree is picked by the hash of its own square, and a square is only a
* candidate where the ground is grass and no road runs near enough for a
* canopy to hang over it. Nothing spans the island edge: no tree grows in
* sand or on rock or in snow, and none stands in the water.
*/
public boolean treeAt(int blockX, int blockZ) {
if (this.isRoad(blockX, blockZ)) {
return false;
}
if (this.roads.nearRoad(blockX, blockZ, TREE_REACH + 1)) {
return false;
}
int height = this.heightAt(blockX, blockZ);
if (height <= SEA_LEVEL + 1) {
return false;
}
byte surface = this.surfaceAtRaw(blockX, blockZ, height);
if (surface != Blocks.GRASS) {
return false;
}
double humidity = this.humidity(blockX, blockZ);
double temperature = this.temperature(blockX, blockZ);
double density = pickDensity(humidity, temperature);
double pick = treePick(blockX, blockZ);
return pick < density;
}
private double treePick(int blockX, int blockZ) {
long h = hash(blockX, 7, blockZ);
double pick = (h >>> 11) / (double) (1L << 53);
if (pick > 1.0D) {
pick = 1.0D;
}
return pick;
}
private double pickDensity(double humidity, double temperature) {
// the woods grow where the land is wet, the plains carry a tree here
// and there, and the snow and the desert nearly none. In the wettest
// woods about every sixth block carries a tree.
if (temperature < -0.45D) {
return 1.0D / 220.0D;
}
if (temperature > 0.4D && humidity < 0.0D) {
return 0.0D;
}
if (humidity > 0.25D) {
return 1.0D / 6.0D;
}
if (humidity > 0.0D) {
return 1.0D / 26.0D;
}
return 1.0D / 150.0D;
}
/** How tall the trunk of a tree is, from the ground up. */
public int trunkHeight(int blockX, int blockZ) {
return 4 + ((int) (hash(blockX, 8, blockZ) & 3));
}
/** The coat of the ground under the road's own rules, for tests. */
public byte surfaceAtRaw(int blockX, int blockZ, int height) {
if (height < SEA_LEVEL) {
return Blocks.SAND;
}
if (height <= SEA_LEVEL + 1) {
return Blocks.SAND;
}
if (height >= SNOW_TOP) {
return Blocks.SNOW;
}
if (height >= ROCK_TOP) {
return Blocks.STONE;
}
if (this.isDesert(blockX, blockZ)) {
return Blocks.SAND;
}
if (this.isSnowCountry(blockX, blockZ)) {
return Blocks.SNOW;
}
return Blocks.GRASS;
}
/** Fills one chunk. Pure: same seed and position, same bytes out. */
public void generate(Chunk chunk) {
ChunkPos pos = chunk.pos();
@@ -187,44 +511,70 @@ public final class WorldGenerator {
synchronized (chunk) {
byte[] blocks = chunk.raw();
for (int z = 0; z < ChunkFormat.WIDTH; z++) {
for (int x = 0; x < ChunkFormat.WIDTH; x++) {
// the trees reach two blocks of canopy, so a chunk is planned not
// only over its own squares but over the rank of squares around it
int pad = TREE_REACH;
int span = ChunkFormat.WIDTH + pad * 2;
int[] spanHeights = new int[span * span];
byte[] spanSurface = new byte[span * span];
boolean[] spanTree = new boolean[span * span];
for (int z = -pad; z < ChunkFormat.WIDTH + pad; z++) {
for (int x = -pad; x < ChunkFormat.WIDTH + pad; x++) {
int at = (z + pad) * span + (x + pad);
int worldX = baseX + x;
int worldZ = baseZ + z;
int height = heightAt(worldX, worldZ);
boolean road = isRoad(worldX, worldZ);
boolean beach = height <= SEA_LEVEL + 1;
spanHeights[at] = height;
spanSurface[at] = surfaceAt(worldX, worldZ);
spanTree[at] = treeAt(worldX, worldZ);
}
}
// a column: dirt under the surface, grass on top, stone below
for (int z = 0; z < ChunkFormat.WIDTH; z++) {
for (int x = 0; x < ChunkFormat.WIDTH; x++) {
int at = (z + pad) * span + (x + pad);
int worldX = baseX + x;
int worldZ = baseZ + z;
int height = spanHeights[at];
byte surface = spanSurface[at];
// a column: dirt under the surface, its coat on top, stone
// below, and roads are asphalt over packed ground
for (int y = 0; y <= height; y++) {
byte id;
if (y == 0) {
id = Blocks.STONE;
} else if (y == height) {
if (road) {
id = Blocks.ASPHALT;
} else if (beach) {
id = Blocks.SAND;
} else {
id = Blocks.GRASS;
}
} else if (y > height - 4) {
id = Blocks.DIRT;
} else {
id = surface;
} else if (surface == Blocks.SAND) {
id = y > height - 3 ? Blocks.SAND
: (y > height - 4 ? Blocks.DIRT : Blocks.STONE);
} else if (surface == Blocks.STONE || surface == Blocks.SNOW) {
id = Blocks.STONE;
} else {
id = y > height - 4 ? Blocks.DIRT : Blocks.STONE;
}
blocks[ChunkFormat.index(x, y, z)] = id;
}
// 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;
if (height < SEA_LEVEL) {
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;
// the trees, if any stand over this column
for (int dz = -pad; dz <= pad; dz++) {
for (int dx = -pad; dx <= pad; dx++) {
int source = (z + pad + dz) * span + (x + pad + dx);
if (!spanTree[source]) {
continue;
}
growTree(blocks, x, z, spanHeights[source],
trunkHeight(baseX + x + dx, baseZ + z + dz),
dx, dz);
}
}
}
@@ -233,15 +583,58 @@ public final class WorldGenerator {
}
}
/**
* Lays the wood of one tree into a column of a chunk.
*
* The trunk stands on the tree's own square, and the leaves spread around
* it as a rounded crown: two wide rings under the top and two narrow ones
* above it. Only air is covered, so a trunk is never buried and two crowns
* that meet interleave.
*/
private void growTree(byte[] blocks, int x, int z, int groundHeight,
int trunk, int dx, int dz) {
int top = groundHeight + trunk;
int limit = Chunk.HEIGHT - 1;
if (dx == 0 && dz == 0) {
for (int y = groundHeight + 1; y <= top && y < limit; y++) {
blocks[ChunkFormat.index(x, y, z)] = Blocks.WOOD;
}
return;
}
for (int layer = 0; layer < 4; layer++) {
int y = top - 2 + layer;
if (y < 0 || y >= limit) {
continue;
}
int reach = layer < 2 ? TREE_REACH : 1;
if (Math.abs(dx) > reach || Math.abs(dz) > reach) {
continue;
}
if (reach == TREE_REACH && Math.abs(dx) == TREE_REACH
&& Math.abs(dz) == TREE_REACH) {
continue; // a rounded crown, no corners
}
int index = ChunkFormat.index(x, y, z);
if (blocks[index] == Blocks.AIR) {
blocks[index] = Blocks.LEAVES;
}
}
}
/**
* Whether a column is on one of the roads.
*
* The roads are the grid of the road network and the ring around the
* island.
* island, and they only run over the island: past the shore the network
* has no road and the sea keeps it so.
*/
public boolean isRoad(int blockX, int blockZ) {
RoadNetwork roads = new RoadNetwork(this.size);
return roads.onRoad(blockX, blockZ);
if (islandFalloff(blockX, blockZ) <= 0.0D) {
return false;
}
return this.roads.onRoad(blockX, blockZ);
}
/**
@@ -33,6 +33,9 @@ public final class LodTest {
run("the regions are numbered from the origin", new Throwing() {
public void go() throws Exception { testNumbering(); }
});
run("a region far away comes coarser", new Throwing() {
public void go() throws Exception { testSteps(work); }
});
System.out.println();
for (int i = 0; i < failures.size(); i++) {
@@ -115,15 +118,40 @@ public final class LodTest {
Path cache = work.resolve("lod-test");
WorldGenerator generator = new WorldGenerator(1234L);
LodGenerator lod = new LodGenerator(generator, cache);
Packets.LodRegion first = lod.region(0, 0);
yes("the region is written down", Files.isRegularFile(cache.resolve("0_0.bin")));
Packets.LodRegion first = lod.region(0, 0, LodGenerator.STEP);
yes("the region is written down",
Files.isRegularFile(cache.resolve("0_0_s" + LodGenerator.STEP + ".bin")));
// the second time it is read back, and it is the same
Packets.LodRegion second = lod.region(0, 0);
Packets.LodRegion second = lod.region(0, 0, LodGenerator.STEP);
yes("the same heights", java.util.Arrays.equals(first.heights, second.heights));
yes("the same colours", java.util.Arrays.equals(first.colors, second.colors));
}
private static void testSteps(Path work) throws Exception {
Path cache = work.resolve("lod-steps");
WorldGenerator generator = new WorldGenerator(1234L);
LodGenerator lod = new LodGenerator(generator, cache);
Packets.LodRegion mid = lod.region(0, 0, 16);
eq("the step of the request is the step of the answer", Integer.valueOf(16),
Integer.valueOf(mid.step));
eq("the samples on a side", Integer.valueOf(16), Integer.valueOf(mid.size));
eq("the heights", Integer.valueOf(256), Integer.valueOf(mid.heights.length));
eq("the colours", Integer.valueOf(768), Integer.valueOf(mid.colors.length));
// the corner sample is the real ground at the corner
int got = mid.heights[0] & 0xFF;
eq("the corner height", Integer.valueOf(generator.heightAt(0, 0)),
Integer.valueOf(got));
Packets.LodRegion coarse = lod.region(3, 7, 64);
eq("and the coarsest step", Integer.valueOf(64), Integer.valueOf(coarse.step));
eq("is the coarsest grid whatever the corner", Integer.valueOf(4),
Integer.valueOf(coarse.size));
yes("it is on disk too",
Files.isRegularFile(cache.resolve("0_0_s64.bin")));
}
private static void testNumbering() {
eq("the first region", Integer.valueOf(0),
Integer.valueOf(LodGenerator.regionChunk(0)));
@@ -55,6 +55,9 @@ public final class WorldTest {
run("a chunk knows where its surface is", new Throwing() {
public void go() throws Exception { testSurfaceHeight(); }
});
run("the island keeps its water at the shore and the roads dry", new Throwing() {
public void go() throws Exception { testIslandAndRoads(); }
});
run("blocks can be written and read", new Throwing() {
public void go() throws Exception { testBlockWrite(); }
});
@@ -336,16 +339,14 @@ 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)));
byte expectedTop = generator.isRoad(worldX, worldZ)
? Blocks.ASPHALT
: (generator.underWater(worldX, worldZ)
? Blocks.SAND
: (height <= WorldGenerator.SEA_LEVEL + 1
? Blocks.SAND : Blocks.GRASS));
byte expectedTop = generator.surfaceAt(worldX, worldZ);
eq("the top is the ground at " + x + "," + z, Byte.valueOf(expectedTop),
Byte.valueOf(chunk.block(x, height, z)));
// under the sea the water stands on the ground, above the sea
// there is air
// there is air, except where a tree grows over the column
if (anyTreeOver(generator, worldX, worldZ)) {
continue;
}
byte expectedSky = height < WorldGenerator.SEA_LEVEL
? Blocks.WATER : Blocks.AIR;
eq("the sky is air or water at " + x + "," + z, Byte.valueOf(expectedSky),
@@ -356,6 +357,18 @@ public final class WorldTest {
}
}
/** True when a tree stands in the canopy reach of the column. */
private static boolean anyTreeOver(WorldGenerator generator, int worldX, int worldZ) {
for (int dz = -2; dz <= 2; dz++) {
for (int dx = -2; dx <= 2; dx++) {
if (generator.treeAt(worldX + dx, worldZ + dz)) {
return true;
}
}
}
return false;
}
private static void testSurfaceHeight() {
WorldGenerator generator = new WorldGenerator(808L);
Chunk chunk = new Chunk(new ChunkPos(2, 2));
@@ -364,6 +377,14 @@ public final class WorldTest {
for (int x = 0; x < Chunk.WIDTH; x++) {
int worldX = 2 * Chunk.WIDTH + x;
int worldZ = 2 * Chunk.WIDTH + z;
if (anyTreeOver(generator, worldX, worldZ)) {
// a crown over the column lifts its surface past the
// ground, unless the crown is buried in a hillside
yes("a tree does not sink the surface at " + worldX + "," + worldZ,
chunk.surfaceHeight(x, z) >= generator
.heightAt(worldX, worldZ) + 1);
continue;
}
eq("the surface is where the height says at " + x + "," + z,
Integer.valueOf(generator.heightAt(worldX, worldZ) + 1),
Integer.valueOf(chunk.surfaceHeight(x, z)));
@@ -371,6 +392,66 @@ public final class WorldTest {
}
}
private static void testIslandAndRoads() {
WorldGenerator generator = new WorldGenerator(909L);
double tallest = 0.0D;
boolean mountain = false;
for (int v = -1100; v <= 1100; v += 37) {
for (int u = -1100; u <= 1100; u += 53) {
double falloff = generator.islandFalloff(u, v);
int height = generator.heightAt(u, v);
if (falloff > 0.26D) {
yes("no lake inland at " + u + "," + v,
height >= WorldGenerator.SEA_LEVEL);
}
if (generator.treeAt(u, v) && falloff < 0.26D) {
failed++;
failures.add("a tree grew in the water at " + u + "," + v);
}
if (height > tallest) {
tallest = height;
}
if (height >= 92) {
mountain = true;
}
}
}
yes("the island is dry where its plains are", tallest > 70.0D);
yes("the island carries mountains", mountain);
// roads ride over the land they cross, level across their width
RoadNetwork roads = new RoadNetwork(4000);
for (int v = -1200; v <= 1200; v += 111) {
for (int u = -1200; u <= 1200; u += 111) {
if (!generator.isRoad(u, v)) {
continue;
}
if (roads.onRoad(u, v) && generator.islandFalloff(u, v) < 0.001D) {
failed++;
failures.add("a road runs in the open sea at " + u + "," + v);
}
int height = generator.heightAt(u, v);
yes("the road holds itself above the sea at " + u + "," + v,
height >= WorldGenerator.SEA_LEVEL);
// the same road a few blocks across: level with this column
int dx = u > 0 ? 3 : -3;
int dz = v > 0 ? 3 : -3;
boolean sameX = roads.centerLine(u) == roads.centerLine(u + dx);
boolean sameZ = roads.centerLine(v) == roads.centerLine(v + dz);
if (sameX && generator.isRoad(u + dx, v) && roads.onRoad(u + dx, v)) {
eq("the road is level across it at " + u + "," + v,
Integer.valueOf(height),
Integer.valueOf(generator.heightAt(u + dx, v)));
}
if (sameZ && generator.isRoad(u, v + dz) && roads.onRoad(u, v + dz)) {
eq("and level across it the other way at " + u + "," + v,
Integer.valueOf(height),
Integer.valueOf(generator.heightAt(u, v + dz)));
}
}
}
}
// -------------------------------------------------------------- storage
private static void testSaveLoadRoundTrip() throws IOException {