From d0019127c68628e0434d591c4e8fd8c8f26247b9 Mon Sep 17 00:00:00 2001 From: Paldiu Date: Fri, 7 Aug 2026 17:15:17 -0500 Subject: [PATCH 01/32] Contract Publication --- .../totalfreedommod/util/Lazy.java | 52 +++++++++++++++++++ .../totalfreedommod/world/base/Carver.java | 39 ++++++++++++++ .../totalfreedommod/world/base/Designer.java | 30 +++++++++++ .../totalfreedommod/world/base/Generator.java | 34 ++++++++++++ .../totalfreedommod/world/base/Populator.java | 26 ++++++++++ 5 files changed, 181 insertions(+) create mode 100644 src/main/java/me/totalfreedom/totalfreedommod/util/Lazy.java create mode 100644 src/main/java/me/totalfreedom/totalfreedommod/world/base/Carver.java create mode 100644 src/main/java/me/totalfreedom/totalfreedommod/world/base/Designer.java create mode 100644 src/main/java/me/totalfreedom/totalfreedommod/world/base/Generator.java create mode 100644 src/main/java/me/totalfreedom/totalfreedommod/world/base/Populator.java diff --git a/src/main/java/me/totalfreedom/totalfreedommod/util/Lazy.java b/src/main/java/me/totalfreedom/totalfreedommod/util/Lazy.java new file mode 100644 index 000000000..120f6e6c4 --- /dev/null +++ b/src/main/java/me/totalfreedom/totalfreedommod/util/Lazy.java @@ -0,0 +1,52 @@ +package me.totalfreedom.totalfreedommod.util; + +import java.util.function.Supplier; + +/** + * A value that gets worked out the first time you ask for it, then cached. + *

+ * Wrap the expensive part in a supplier and hand it over; nothing runs until the first + * {@link #get()}. Every call after that hands back the same value, and the supplier is never run + * again, including when it returned null. + *

+ * Safe to share between threads. Do not call {@link #get()} from inside the supplier though, since + * it will deadlock on the lock the first call is already holding. + * + * @param the type being worked out + */ +public class Lazy implements Supplier +{ + private final Supplier delegate; + private volatile boolean initialized = false; + private T value; + + public Lazy(Supplier delegate) + { + this.delegate = delegate; + } + + /** + * The value, working it out on the first call. + *

+ * The volatile on {@code initialized} is load bearing. Setting it after {@code value} is what + * makes {@code value} visible to other threads, which is what lets the first check run without + * taking the lock. Dropping volatile off the flag, or moving it onto {@code value} instead, + * breaks that. + */ + @Override + public T get() + { + if (!initialized) + synchronized(this) + { + if (!initialized) + { + value = delegate.get(); + initialized = true; + } + + } + + return value; + } +} diff --git a/src/main/java/me/totalfreedom/totalfreedommod/world/base/Carver.java b/src/main/java/me/totalfreedom/totalfreedommod/world/base/Carver.java new file mode 100644 index 000000000..65db8dde1 --- /dev/null +++ b/src/main/java/me/totalfreedom/totalfreedommod/world/base/Carver.java @@ -0,0 +1,39 @@ +package me.totalfreedom.totalfreedommod.world.base; + +/** + * Carves caves and ravines out of the terrain. Subtractive only; terrain shaping belongs to + * {@link Generator}. + *

+ * Runs under generateCaves, and only ever answers yes or no about one block at a time. + * {@code ProfileChunkGenerator} runs the loop and decides what actually happens to a block this + * flags: leaving bedrock alone, and filling with water instead of air below the depth the profile + * sets. + */ +public interface Carver +{ + /** + * True if the block should be removed. Only called for y within {@link #minY()} and + * {@link #maxY()}. + *

+ * The answer has to depend only on the position. Do not use the context's random here; it is + * seeded per chunk, so one block could get two different answers depending on which chunk was + * being generated at the time, and you end up with seams along every chunk border. + *

+ * Reading the context's terrain height is fine, and tightening the threshold as you get near it + * blends cave mouths into the hillside. Do not read the column top, though; that is worked out + * by calling this method, so you will deadlock. + */ + boolean isCarved(ChunkContext context, int worldX, int y, int worldZ); + + /** + * Lowest y this carver touches, inclusive. Read once per chunk and checked outside the block + * loop, so a deep carver costs nothing in the columns above it. Must be constant. + *

+ * Return {@link Integer#MAX_VALUE} here and {@link Integer#MIN_VALUE} from {@link #maxY()} for + * a no-op carver. + */ + int minY(); + + /** Highest y this carver touches, inclusive. See {@link #minY()}. */ + int maxY(); +} diff --git a/src/main/java/me/totalfreedom/totalfreedommod/world/base/Designer.java b/src/main/java/me/totalfreedom/totalfreedommod/world/base/Designer.java new file mode 100644 index 000000000..4ddee4926 --- /dev/null +++ b/src/main/java/me/totalfreedom/totalfreedommod/world/base/Designer.java @@ -0,0 +1,30 @@ +package me.totalfreedom.totalfreedommod.world.base; + +import org.bukkit.generator.ChunkGenerator; + +/** + * Decides what the base shape is made of. Surface blocks and bedrock. + *

+ * Runs under generateSurface then generateBedrock, after {@link Generator} and before + * {@link Carver}. + *

+ * Driven by the surface rules in the world's profile. First rule that matches wins. + */ +public interface Designer +{ + /** + * Swaps the generator's filler for real blocks. Runs under generateSurface. + *

+ * Substitution only; never change which positions are solid. Walk down from the context's + * column top, not its terrain height, or cave mouths get floating grass. Leave water alone. + */ + void surface(ChunkContext context, ChunkGenerator.ChunkData data); + + /** + * Writes the bedrock floor, and a roof if the world wants one. Runs under generateBedrock. + *

+ * The {@link Carver} runs after this and will happily eat bedrock, so keep its + * {@link Carver#minY()} above this layer. + */ + void bedrock(ChunkContext context, ChunkGenerator.ChunkData data); +} diff --git a/src/main/java/me/totalfreedom/totalfreedommod/world/base/Generator.java b/src/main/java/me/totalfreedom/totalfreedommod/world/base/Generator.java new file mode 100644 index 000000000..be8df86f0 --- /dev/null +++ b/src/main/java/me/totalfreedom/totalfreedommod/world/base/Generator.java @@ -0,0 +1,34 @@ +package me.totalfreedom.totalfreedommod.world.base; + +import org.bukkit.generator.ChunkGenerator; + +/** + * Primary world designer; generates the base shape of a chunk out of stone, air, and water, and + * handles all terrain shaping including rivers. Everything after this either changes what blocks + * are made of or takes blocks away. + *

+ * Reads its settings from a .json file in {@link org.bukkit.plugin.Plugin#getDataFolder}/worlds, + * named after the world. One implementation per mode (flat, heightmap, density), picked when the + * profile compiles. + */ +public interface Generator +{ + /** + * Writes the chunk. Runs under generateNoise. + *

+ * ChunkData takes local x/z (0-15) and absolute y. Use setRegion for runs of the same block up + * a column, and sample noise on a grid and interpolate between the samples; a chunk is 98,304 + * blocks, so sampling every one of them is not an option. + *

+ * Off the main thread. Only the context's WorldInfo is safe to touch, never World or entities. + */ + void generateBase(ChunkContext context, ChunkGenerator.ChunkData data); + + /** + * Terrain height at a world position, before carving. Pure, no chunk access. + *

+ * Backs getBaseHeight, the spawn finder, and the context's column heights. Must agree with what + * {@link #generateBase} writes or spawn lands in mid-air. + */ + int surfaceHeight(ChunkContext context, int worldX, int worldZ); +} diff --git a/src/main/java/me/totalfreedom/totalfreedommod/world/base/Populator.java b/src/main/java/me/totalfreedom/totalfreedommod/world/base/Populator.java new file mode 100644 index 000000000..1a40b5427 --- /dev/null +++ b/src/main/java/me/totalfreedom/totalfreedommod/world/base/Populator.java @@ -0,0 +1,26 @@ +package me.totalfreedom.totalfreedommod.world.base; + +import org.bukkit.generator.LimitedRegion; + +/** + * Places features into a finished chunk. Trees, flowers, ores, structures. + *

+ * Last in the chain, and the only stage that runs as a BlockPopulator, so it gets a + * {@link LimitedRegion} instead of chunk data. The region covers the chunk plus a margin, so a tree + * on a border can put its canopy in the next chunk. + *

+ * Driven by the feature list in the world's profile. + */ +public interface Populator +{ + /** + * Places this chunk's features. + *

+ * Use the context's random for every roll so placement is reproducible. Bounds check writes + * with isInRegion, and only originate features from inside the target chunk; originating from + * the margin doubles them along borders, since neighbours populate independently. + *

+ * Off the main thread. No chunk loading, no entity spawning through World. + */ + void populate(ChunkContext context, LimitedRegion data); +} From 7d1b2c736879771710289112261a233e0a20bac8 Mon Sep 17 00:00:00 2001 From: Paldiu Date: Fri, 7 Aug 2026 17:15:41 -0500 Subject: [PATCH 02/32] Update Flatlands.java --- .../me/totalfreedom/totalfreedommod/world/Flatlands.java | 5 ----- 1 file changed, 5 deletions(-) diff --git a/src/main/java/me/totalfreedom/totalfreedommod/world/Flatlands.java b/src/main/java/me/totalfreedom/totalfreedommod/world/Flatlands.java index 2c3e9b097..d2535e47d 100644 --- a/src/main/java/me/totalfreedom/totalfreedommod/world/Flatlands.java +++ b/src/main/java/me/totalfreedom/totalfreedommod/world/Flatlands.java @@ -3,11 +3,6 @@ import java.io.File; import org.bukkit.*; -import org.bukkit.block.Block; -import org.bukkit.block.BlockFace; - -import net.kyori.adventure.text.Component; -import net.kyori.adventure.text.format.NamedTextColor; import me.totalfreedom.totalfreedommod.TotalFreedomMod; import me.totalfreedom.totalfreedommod.config.ConfigEntry; From c9444aaf570ac2c99aeb85cacea7904f9d6bf737 Mon Sep 17 00:00:00 2001 From: Paldiu Date: Fri, 7 Aug 2026 17:23:42 -0500 Subject: [PATCH 03/32] Some more basics --- .../world/GenerationProfile.java | 22 +++++++++++++++++++ .../world/noise/NoiseType.java | 8 +++++++ .../world/profile/GenerationMode.java | 14 ++++++++++++ .../world/profile/StageSet.java | 18 +++++++++++++++ .../world/profile/json/Defaulted.java | 14 ++++++++++++ .../world/stage/feature/Feature.java | 17 ++++++++++++++ 6 files changed, 93 insertions(+) create mode 100644 src/main/java/me/totalfreedom/totalfreedommod/world/GenerationProfile.java create mode 100644 src/main/java/me/totalfreedom/totalfreedommod/world/noise/NoiseType.java create mode 100644 src/main/java/me/totalfreedom/totalfreedommod/world/profile/GenerationMode.java create mode 100644 src/main/java/me/totalfreedom/totalfreedommod/world/profile/StageSet.java create mode 100644 src/main/java/me/totalfreedom/totalfreedommod/world/profile/json/Defaulted.java create mode 100644 src/main/java/me/totalfreedom/totalfreedommod/world/stage/feature/Feature.java diff --git a/src/main/java/me/totalfreedom/totalfreedommod/world/GenerationProfile.java b/src/main/java/me/totalfreedom/totalfreedommod/world/GenerationProfile.java new file mode 100644 index 000000000..231c6f65d --- /dev/null +++ b/src/main/java/me/totalfreedom/totalfreedommod/world/GenerationProfile.java @@ -0,0 +1,22 @@ +package me.totalfreedom.totalfreedommod.world; + +import me.totalfreedom.totalfreedommod.world.profile.Bounds; +import me.totalfreedom.totalfreedommod.world.profile.GenerationMode; +import me.totalfreedom.totalfreedommod.world.profile.Palette; +import me.totalfreedom.totalfreedommod.world.profile.StageSet; +import me.totalfreedom.totalfreedommod.world.profile.WorldSettings; + +/** + * One world's compiled profile. Built by the compiler once the seed is known, immutable after that. + *

+ * Holds only what more than one stage needs. Per-stage tuning belongs to the stage: terrain noise + * and spline in the generator, surface rules in the designer, feature specs in the populator. + */ +public record GenerationProfile(String name, + GenerationMode mode, + Bounds bounds, + Palette palette, + StageSet stages, + WorldSettings world) +{ +} diff --git a/src/main/java/me/totalfreedom/totalfreedommod/world/noise/NoiseType.java b/src/main/java/me/totalfreedom/totalfreedommod/world/noise/NoiseType.java new file mode 100644 index 000000000..dcc6519f3 --- /dev/null +++ b/src/main/java/me/totalfreedom/totalfreedommod/world/noise/NoiseType.java @@ -0,0 +1,8 @@ +package me.totalfreedom.totalfreedommod.world.noise; + +/** Which of Bukkit's generators backs a field. Both live in org.bukkit.util.noise, so no new deps. */ +public enum NoiseType +{ + PERLIN, + SIMPLEX +} diff --git a/src/main/java/me/totalfreedom/totalfreedommod/world/profile/GenerationMode.java b/src/main/java/me/totalfreedom/totalfreedommod/world/profile/GenerationMode.java new file mode 100644 index 000000000..27f3f25e9 --- /dev/null +++ b/src/main/java/me/totalfreedom/totalfreedommod/world/profile/GenerationMode.java @@ -0,0 +1,14 @@ +package me.totalfreedom.totalfreedommod.world.profile; + +/** + * Which generator a profile uses. Read once when the profile compiles, to pick the stages. + *

+ * FLAT samples no noise at all. HEIGHTMAP is 2D, has no overhangs, and covers most survival worlds. + * DENSITY is 3D, gets you overhangs, and takes roughly fifty times the samples. + */ +public enum GenerationMode +{ + FLAT, + HEIGHTMAP, + DENSITY +} diff --git a/src/main/java/me/totalfreedom/totalfreedommod/world/profile/StageSet.java b/src/main/java/me/totalfreedom/totalfreedommod/world/profile/StageSet.java new file mode 100644 index 000000000..538003fe3 --- /dev/null +++ b/src/main/java/me/totalfreedom/totalfreedommod/world/profile/StageSet.java @@ -0,0 +1,18 @@ +package me.totalfreedom.totalfreedommod.world.profile; + +import me.totalfreedom.totalfreedommod.world.base.Carver; +import me.totalfreedom.totalfreedommod.world.base.Designer; +import me.totalfreedom.totalfreedommod.world.base.Generator; +import me.totalfreedom.totalfreedommod.world.base.Populator; + +/** + * The four stages a profile runs, picked from the mode at compile time. + *

+ * Also how the chunk context reaches the generator and carver for its column heights. + */ +public record StageSet(Generator generator, + Designer designer, + Carver carver, + Populator populator) +{ +} diff --git a/src/main/java/me/totalfreedom/totalfreedommod/world/profile/json/Defaulted.java b/src/main/java/me/totalfreedom/totalfreedommod/world/profile/json/Defaulted.java new file mode 100644 index 000000000..d50cf201c --- /dev/null +++ b/src/main/java/me/totalfreedom/totalfreedommod/world/profile/json/Defaulted.java @@ -0,0 +1,14 @@ +package me.totalfreedom.totalfreedommod.world.profile.json; + +/** + * A profile record that can fill in its own missing fields. + *

+ * Return a copy with every null replaced, including whole sections that were absent. Gson leaves + * absent fields null and never runs compact constructors, so defaults cannot live in the record. + * + * @param the implementing record's own type + */ +public interface Defaulted +{ + T withDefaults(); +} diff --git a/src/main/java/me/totalfreedom/totalfreedommod/world/stage/feature/Feature.java b/src/main/java/me/totalfreedom/totalfreedommod/world/stage/feature/Feature.java new file mode 100644 index 000000000..3e4564629 --- /dev/null +++ b/src/main/java/me/totalfreedom/totalfreedommod/world/stage/feature/Feature.java @@ -0,0 +1,17 @@ +package me.totalfreedom.totalfreedommod.world.stage.feature; + +import org.bukkit.generator.LimitedRegion; + +import me.totalfreedom.totalfreedommod.world.base.ChunkContext; + +/** + * One kind of thing that can be placed into a finished chunk. + *

+ * Shared and run concurrently, so keep implementations immutable and take every roll from the + * context's random. The origin is always inside the target chunk, but the overhang may not be, so + * bounds check writes with isInRegion. + */ +public interface Feature +{ + void place(ChunkContext context, LimitedRegion region, FeatureSpec spec, int x, int y, int z); +} From 3863bda719246becf75502a1d89797040f439cdf Mon Sep 17 00:00:00 2001 From: Paldiu Date: Sun, 9 Aug 2026 00:59:40 -0500 Subject: [PATCH 04/32] committing actually implemented files --- .../world/GenerationProfile.java | 17 +- .../totalfreedommod/world/base/Carver.java | 4 +- .../world/base/ChunkContext.java | 165 ++++++++++++++++++ .../totalfreedommod/world/base/Designer.java | 5 +- .../totalfreedommod/world/profile/Anchor.java | 14 ++ .../world/profile/BedrockMode.java | 14 ++ .../world/profile/BiomeFilter.java | 53 ++++++ .../totalfreedommod/world/profile/Bounds.java | 22 +++ .../totalfreedommod/world/profile/Depth.java | 54 ++++++ .../world/profile/FeatureDetail.java | 75 ++++++++ .../world/profile/FeatureSpec.java | 30 ++++ .../world/profile/GenerationMode.java | 14 -- .../world/profile/Materials.java | 16 ++ .../world/profile/Palette.java | 63 +++++++ .../world/profile/ProfileError.java | 19 ++ .../world/profile/ProfileException.java | 29 +++ .../world/profile/StageSet.java | 18 -- .../world/profile/WorldSettings.java | 32 ++++ .../world/profile/json/Defaulted.java | 14 -- .../world/stage/feature/Feature.java | 25 ++- .../world/stage/feature/OreFeature.java | 112 ++++++++++++ .../resources/worlds/flatlands-template.json | 31 ++++ .../resources/worlds/overworld-template.json | 133 ++++++++++++++ 23 files changed, 900 insertions(+), 59 deletions(-) create mode 100644 src/main/java/me/totalfreedom/totalfreedommod/world/base/ChunkContext.java create mode 100644 src/main/java/me/totalfreedom/totalfreedommod/world/profile/Anchor.java create mode 100644 src/main/java/me/totalfreedom/totalfreedommod/world/profile/BedrockMode.java create mode 100644 src/main/java/me/totalfreedom/totalfreedommod/world/profile/BiomeFilter.java create mode 100644 src/main/java/me/totalfreedom/totalfreedommod/world/profile/Bounds.java create mode 100644 src/main/java/me/totalfreedom/totalfreedommod/world/profile/Depth.java create mode 100644 src/main/java/me/totalfreedom/totalfreedommod/world/profile/FeatureDetail.java create mode 100644 src/main/java/me/totalfreedom/totalfreedommod/world/profile/FeatureSpec.java delete mode 100644 src/main/java/me/totalfreedom/totalfreedommod/world/profile/GenerationMode.java create mode 100644 src/main/java/me/totalfreedom/totalfreedommod/world/profile/Materials.java create mode 100644 src/main/java/me/totalfreedom/totalfreedommod/world/profile/Palette.java create mode 100644 src/main/java/me/totalfreedom/totalfreedommod/world/profile/ProfileError.java create mode 100644 src/main/java/me/totalfreedom/totalfreedommod/world/profile/ProfileException.java delete mode 100644 src/main/java/me/totalfreedom/totalfreedommod/world/profile/StageSet.java create mode 100644 src/main/java/me/totalfreedom/totalfreedommod/world/profile/WorldSettings.java delete mode 100644 src/main/java/me/totalfreedom/totalfreedommod/world/profile/json/Defaulted.java create mode 100644 src/main/java/me/totalfreedom/totalfreedommod/world/stage/feature/OreFeature.java create mode 100644 src/main/resources/worlds/flatlands-template.json create mode 100644 src/main/resources/worlds/overworld-template.json diff --git a/src/main/java/me/totalfreedom/totalfreedommod/world/GenerationProfile.java b/src/main/java/me/totalfreedom/totalfreedommod/world/GenerationProfile.java index 231c6f65d..a1ae1badf 100644 --- a/src/main/java/me/totalfreedom/totalfreedommod/world/GenerationProfile.java +++ b/src/main/java/me/totalfreedom/totalfreedommod/world/GenerationProfile.java @@ -1,22 +1,25 @@ package me.totalfreedom.totalfreedommod.world; +import java.util.List; + import me.totalfreedom.totalfreedommod.world.profile.Bounds; -import me.totalfreedom.totalfreedommod.world.profile.GenerationMode; +import me.totalfreedom.totalfreedommod.world.profile.FeatureSpec; import me.totalfreedom.totalfreedommod.world.profile.Palette; -import me.totalfreedom.totalfreedommod.world.profile.StageSet; +import me.totalfreedom.totalfreedommod.world.profile.Shape; import me.totalfreedom.totalfreedommod.world.profile.WorldSettings; /** - * One world's compiled profile. Built by the compiler once the seed is known, immutable after that. + * One world's profile. Pure data, and every field is already checked, so anything reading this can + * take it at face value. *

- * Holds only what more than one stage needs. Per-stage tuning belongs to the stage: terrain noise - * and spline in the generator, surface rules in the designer, feature specs in the populator. + * Holds no stage objects. The chunk generator pattern matches {@link Shape} once to pick its + * stages, which keeps this package free of any dependency on the generation code. */ public record GenerationProfile(String name, - GenerationMode mode, Bounds bounds, + Shape shape, Palette palette, - StageSet stages, + List features, WorldSettings world) { } diff --git a/src/main/java/me/totalfreedom/totalfreedommod/world/base/Carver.java b/src/main/java/me/totalfreedom/totalfreedommod/world/base/Carver.java index 65db8dde1..db0f38134 100644 --- a/src/main/java/me/totalfreedom/totalfreedommod/world/base/Carver.java +++ b/src/main/java/me/totalfreedom/totalfreedommod/world/base/Carver.java @@ -29,8 +29,8 @@ public interface Carver * Lowest y this carver touches, inclusive. Read once per chunk and checked outside the block * loop, so a deep carver costs nothing in the columns above it. Must be constant. *

- * Return {@link Integer#MAX_VALUE} here and {@link Integer#MIN_VALUE} from {@link #maxY()} for - * a no-op carver. + * A world with no caves has no carver at all, so there is never a reason to report a range that + * cannot match. */ int minY(); diff --git a/src/main/java/me/totalfreedom/totalfreedommod/world/base/ChunkContext.java b/src/main/java/me/totalfreedom/totalfreedommod/world/base/ChunkContext.java new file mode 100644 index 000000000..239068166 --- /dev/null +++ b/src/main/java/me/totalfreedom/totalfreedommod/world/base/ChunkContext.java @@ -0,0 +1,165 @@ +package me.totalfreedom.totalfreedommod.world.base; + +import java.util.Optional; +import java.util.Random; +import java.util.stream.IntStream; + +import org.bukkit.generator.WorldInfo; + +import me.totalfreedom.totalfreedommod.util.Lazy; +import me.totalfreedom.totalfreedommod.world.GenerationProfile; + +/** + * Per-chunk state for one generation stage. The stages themselves are shared and run concurrently, + * so everything that varies per chunk lives here instead. + *

+ * Build a fresh one in each callback, use it on that thread, throw it away. The stages are separate + * Bukkit calls and the populator phase may not even run on the generating thread, so nothing can be + * handed from one stage to the next. + *

+ * Both column heights are worked out on first use and cached: terrain height comes from + * {@link Generator#surfaceHeight}, column top from walking that down while + * {@link Carver#isCarved} keeps saying true. A full pass over the columns is 256 samples. + */ +public final class ChunkContext +{ + private final GenerationProfile profile; + private final Stages stages; + private final WorldInfo info; + private final int chunkX; + private final int chunkZ; + private final Random random; + private final Lazy terrainHeights; + private final Lazy columnTops; + + private ChunkContext(final GenerationProfile profile, + final Stages stages, + final WorldInfo info, + final Random random, + final int chunkX, + final int chunkZ) + { + this.profile = profile; + this.stages = stages; + this.info = info; + this.chunkX = chunkX; + this.chunkZ = chunkZ; + this.random = random; + this.terrainHeights = new Lazy<>(this::computeTerrainHeights); + this.columnTops = new Lazy<>(this::computeColumnTops); + } + + /** The random comes from Bukkit already seeded per chunk, so decoration is reproducible. */ + public static ChunkContext of(final GenerationProfile profile, + final Stages stages, + final WorldInfo info, + final Random random, + final int chunkX, + final int chunkZ) + { + return new ChunkContext(profile, stages, info, random, chunkX, chunkZ); + } + + /** Terrain height for a local column, before carving. */ + public int terrainHeight(final int localX, final int localZ) + { + return this.terrainHeights.get()[index(localX, localZ)]; + } + + /** + * Highest solid block for a local column, after carving. Differs from the terrain height + * wherever a cave broke the surface, so this is the one the {@link Designer} wants. + */ + public int columnTop(final int localX, final int localZ) + { + return this.columnTops.get()[index(localX, localZ)]; + } + + /** Local x (0-15) to absolute. */ + public int worldX(final int localX) + { + return (this.chunkX << 4) + localX; + } + + /** Local z (0-15) to absolute. */ + public int worldZ(final int localZ) + { + return (this.chunkZ << 4) + localZ; + } + + public GenerationProfile getProfile() + { + return this.profile; + } + + public Stages getStages() + { + return this.stages; + } + + public WorldInfo getInfo() + { + return this.info; + } + + public Random getRandom() + { + return this.random; + } + + public int getChunkX() + { + return this.chunkX; + } + + public int getChunkZ() + { + return this.chunkZ; + } + + /** One full pass over the chunk's 256 columns, sampling {@link Generator#surfaceHeight} once each. */ + private int[] computeTerrainHeights() + { + final int[] heights = new int[256]; + final Generator generator = this.stages.generator(); + + IntStream.range(0, 256) + .forEach(i -> heights[i] = generator.surfaceHeight(this, this.worldX(i & 0xF), this.worldZ(i >> 4))); + + return heights; + } + + /** One full pass over the chunk's 256 columns, walking each down through the carver. */ + private int[] computeColumnTops() + { + final int[] tops = new int[256]; + final Optional carver = this.stages.carver(); + + IntStream.range(0, 256) + .forEach(i -> tops[i] = this.computeColumnTop(i & 0xF, i >> 4, carver)); + + return tops; + } + + private int computeColumnTop(final int localX, final int localZ, final Optional carver) + { + int y = this.terrainHeight(localX, localZ); + + if (carver.isEmpty()) + return y; + + final Carver actualCarver = carver.get(); + final int worldX = this.worldX(localX); + final int worldZ = this.worldZ(localZ); + + while (y >= actualCarver.minY() && y <= actualCarver.maxY() && actualCarver.isCarved(this, worldX, y, worldZ)) + y--; + + return y; + } + + private static int index(final int localX, final int localZ) + { + return localZ << 4 | localX; + } +} diff --git a/src/main/java/me/totalfreedom/totalfreedommod/world/base/Designer.java b/src/main/java/me/totalfreedom/totalfreedommod/world/base/Designer.java index 4ddee4926..aa490b3f4 100644 --- a/src/main/java/me/totalfreedom/totalfreedommod/world/base/Designer.java +++ b/src/main/java/me/totalfreedom/totalfreedommod/world/base/Designer.java @@ -21,7 +21,10 @@ public interface Designer void surface(ChunkContext context, ChunkGenerator.ChunkData data); /** - * Writes the bedrock floor, and a roof if the world wants one. Runs under generateBedrock. + * Writes the bedrock layer. Runs under generateBedrock. + *

+ * How much to write comes from the profile's bedrock mode: a floor, a floor and a roof, or + * nothing at all for a world made of floating islands. *

* The {@link Carver} runs after this and will happily eat bedrock, so keep its * {@link Carver#minY()} above this layer. diff --git a/src/main/java/me/totalfreedom/totalfreedommod/world/profile/Anchor.java b/src/main/java/me/totalfreedom/totalfreedommod/world/profile/Anchor.java new file mode 100644 index 000000000..55a9d7188 --- /dev/null +++ b/src/main/java/me/totalfreedom/totalfreedommod/world/profile/Anchor.java @@ -0,0 +1,14 @@ +package me.totalfreedom.totalfreedommod.world.profile; + +/** + * Where a feature wants to be placed, which decides how the populator picks its y. + *

+ * SURFACE sits on the column top, so trees and grass land on the ground. RANGE picks a y somewhere + * inside the feature's own minY and maxY, which is what ores want. Either way minY and maxY still + * filter, so a surface feature can say it never appears above a certain height. + */ +public enum Anchor +{ + SURFACE, + RANGE +} diff --git a/src/main/java/me/totalfreedom/totalfreedommod/world/profile/BedrockMode.java b/src/main/java/me/totalfreedom/totalfreedommod/world/profile/BedrockMode.java new file mode 100644 index 000000000..ef651a9a6 --- /dev/null +++ b/src/main/java/me/totalfreedom/totalfreedommod/world/profile/BedrockMode.java @@ -0,0 +1,14 @@ +package me.totalfreedom.totalfreedommod.world.profile; + +/** + * What the designer writes for a world's bedrock layer. + *

+ * FLOOR is the usual one. FLOOR_AND_ROOF caps the world off as well, which is what the nether + * wants. NONE writes nothing at all, for worlds made of floating islands. + */ +public enum BedrockMode +{ + FLOOR, + FLOOR_AND_ROOF, + NONE +} diff --git a/src/main/java/me/totalfreedom/totalfreedommod/world/profile/BiomeFilter.java b/src/main/java/me/totalfreedom/totalfreedommod/world/profile/BiomeFilter.java new file mode 100644 index 000000000..d3078ae9c --- /dev/null +++ b/src/main/java/me/totalfreedom/totalfreedommod/world/profile/BiomeFilter.java @@ -0,0 +1,53 @@ +package me.totalfreedom.totalfreedommod.world.profile; + +import java.util.Set; + +import org.bukkit.block.Biome; + +/** + * Which biomes a surface rule or feature applies to. + *

+ * Replaces a null biome meaning "any", so nothing downstream has to remember which way round that + * was. {@link Any} says so out loud. + */ +public sealed interface BiomeFilter +{ + record Any() implements BiomeFilter + { + @Override + public boolean matches(final Biome biome) + { + return true; + } + } + + /** @throws IllegalArgumentException if the set is empty, which would match nothing at all */ + record OneOf(Set biomes) implements BiomeFilter + { + public OneOf + { + if (biomes.isEmpty()) + throw new IllegalArgumentException("biomes must not be empty"); + + biomes = Set.copyOf(biomes); + } + + @Override + public boolean matches(final Biome biome) + { + return this.biomes.contains(biome); + } + } + + boolean matches(Biome biome); + + static BiomeFilter any() + { + return new Any(); + } + + static BiomeFilter of(final Set biomes) + { + return new OneOf(biomes); + } +} diff --git a/src/main/java/me/totalfreedom/totalfreedommod/world/profile/Bounds.java b/src/main/java/me/totalfreedom/totalfreedommod/world/profile/Bounds.java new file mode 100644 index 000000000..6655ffebf --- /dev/null +++ b/src/main/java/me/totalfreedom/totalfreedommod/world/profile/Bounds.java @@ -0,0 +1,22 @@ +package me.totalfreedom.totalfreedommod.world.profile; + +import java.util.Optional; + +/** + * A world's vertical limits and water line. Clamped against WorldInfo when the profile parses. + *

+ * An empty seaLevel means the world has no sea at all, which is what the end and flat worlds want. + * There is no "sea level 0 means off" rule to remember. + */ +public record Bounds(int minY, int maxY, Optional seaLevel) +{ + /** @throws IllegalArgumentException if minY is not below maxY, or a sea level falls outside them */ + public Bounds + { + if (minY >= maxY) + throw new IllegalArgumentException("minY (" + minY + ") must be below maxY (" + maxY + ")"); + + if (seaLevel.isPresent() && (seaLevel.get() < minY || seaLevel.get() > maxY)) + throw new IllegalArgumentException("seaLevel (" + seaLevel.get() + ") must fall within " + minY + " to " + maxY); + } +} diff --git a/src/main/java/me/totalfreedom/totalfreedommod/world/profile/Depth.java b/src/main/java/me/totalfreedom/totalfreedommod/world/profile/Depth.java new file mode 100644 index 000000000..5c6775dec --- /dev/null +++ b/src/main/java/me/totalfreedom/totalfreedommod/world/profile/Depth.java @@ -0,0 +1,54 @@ +package me.totalfreedom.totalfreedommod.world.profile; + +/** + * How far below the top of a column a surface rule applies. Depth 0 is the surface block itself. + *

+ * Two shapes only, so there is no magic number standing in for "all the way down". A rule that + * wants everything below a point says {@link Rest}, not {@link Range} with a made up upper bound. + */ +public sealed interface Depth +{ + /** Inclusive both ends. Use {@link #range} so from cannot end up above to. */ + record Range(int from, int to) implements Depth + { + @Override + public boolean contains(final int depth) + { + return depth >= this.from && depth <= this.to; + } + } + + /** From here to the bottom of the column. */ + record Rest(int from) implements Depth + { + @Override + public boolean contains(final int depth) + { + return depth >= this.from; + } + } + + boolean contains(int depth); + + /** @throws IllegalArgumentException if from is above to, or either is negative */ + static Depth range(final int from, final int to) + { + if (from > to) + throw new IllegalArgumentException("from (" + from + ") must not be above to (" + to + ")"); + + if (from < 0 || to < 0) + throw new IllegalArgumentException("from and to must not be negative"); + + return new Range(from, to); + } + + static Depth at(final int depth) + { + return range(depth, depth); + } + + static Depth rest(final int from) + { + return new Rest(from); + } +} diff --git a/src/main/java/me/totalfreedom/totalfreedommod/world/profile/FeatureDetail.java b/src/main/java/me/totalfreedom/totalfreedommod/world/profile/FeatureDetail.java new file mode 100644 index 000000000..b3cd48791 --- /dev/null +++ b/src/main/java/me/totalfreedom/totalfreedommod/world/profile/FeatureDetail.java @@ -0,0 +1,75 @@ +package me.totalfreedom.totalfreedommod.world.profile; + +import org.bukkit.TreeType; +import org.bukkit.block.data.BlockData; + +/** + * What a feature actually places. One variant per kind, each carrying only the settings that kind + * uses. + *

+ * This is why there is no shared size field meaning vein length here and radius there. A boulder + * has a radius, a patch has a spread, and a tree has neither. + *

+ * Being sealed also means the populator's switch over these is checked for exhaustiveness, so + * adding a variant will not compile until something knows how to place it. + */ +public sealed interface FeatureDetail +{ + /** A vein buried in the filler block. size is how many blocks the vein is. */ + record Ore(BlockData block, int size) implements FeatureDetail + { + @Override + public Anchor anchor() + { + return Anchor.RANGE; + } + } + + /** A scatter across the surface. spread is how far from the origin it reaches. */ + record Patch(BlockData block, int spread) implements FeatureDetail + { + @Override + public Anchor anchor() + { + return Anchor.SURFACE; + } + } + + /** A hollowed bowl filled with fluid. */ + record Lake(BlockData fluid, int radius) implements FeatureDetail + { + @Override + public Anchor anchor() + { + return Anchor.RANGE; + } + } + + /** A rough blob resting on the ground. */ + record Boulder(BlockData block, int radius) implements FeatureDetail + { + @Override + public Anchor anchor() + { + return Anchor.SURFACE; + } + } + + /** + * A tree, named exactly. A sapling would not be enough, since spruce alone covers REDWOOD, + * TALL_REDWOOD, and MEGA_REDWOOD. + *

+ * For a vanilla-style mix, write one entry per variant and let rarity do the weighting: nine + * REDWOOD to one MEGA_REDWOOD. + */ + record Tree(TreeType type) implements FeatureDetail + { + @Override + public Anchor anchor() + { + return Anchor.SURFACE; + } + } + + Anchor anchor(); +} diff --git a/src/main/java/me/totalfreedom/totalfreedommod/world/profile/FeatureSpec.java b/src/main/java/me/totalfreedom/totalfreedommod/world/profile/FeatureSpec.java new file mode 100644 index 000000000..c87128c0e --- /dev/null +++ b/src/main/java/me/totalfreedom/totalfreedommod/world/profile/FeatureSpec.java @@ -0,0 +1,30 @@ +package me.totalfreedom.totalfreedommod.world.profile; + +import org.bukkit.block.Biome; + +/** + * One feature entry. rarity is attempts per chunk, and minY and maxY bound where it may appear. + *

+ * What gets placed, and every setting particular to it, lives in the detail. + */ +public record FeatureSpec(int rarity, + int minY, + int maxY, + BiomeFilter biomes, + FeatureDetail detail) +{ + /** @throws IllegalArgumentException if rarity is below one, or minY is above maxY */ + public FeatureSpec + { + if (rarity < 1) + throw new IllegalArgumentException("rarity (" + rarity + ") must be at least one"); + + if (minY > maxY) + throw new IllegalArgumentException("minY (" + minY + ") must not be above maxY (" + maxY + ")"); + } + + public boolean appliesTo(final Biome biome) + { + return this.biomes.matches(biome); + } +} diff --git a/src/main/java/me/totalfreedom/totalfreedommod/world/profile/GenerationMode.java b/src/main/java/me/totalfreedom/totalfreedommod/world/profile/GenerationMode.java deleted file mode 100644 index 27f3f25e9..000000000 --- a/src/main/java/me/totalfreedom/totalfreedommod/world/profile/GenerationMode.java +++ /dev/null @@ -1,14 +0,0 @@ -package me.totalfreedom.totalfreedommod.world.profile; - -/** - * Which generator a profile uses. Read once when the profile compiles, to pick the stages. - *

- * FLAT samples no noise at all. HEIGHTMAP is 2D, has no overhangs, and covers most survival worlds. - * DENSITY is 3D, gets you overhangs, and takes roughly fifty times the samples. - */ -public enum GenerationMode -{ - FLAT, - HEIGHTMAP, - DENSITY -} diff --git a/src/main/java/me/totalfreedom/totalfreedommod/world/profile/Materials.java b/src/main/java/me/totalfreedom/totalfreedommod/world/profile/Materials.java new file mode 100644 index 000000000..037dbc7fe --- /dev/null +++ b/src/main/java/me/totalfreedom/totalfreedommod/world/profile/Materials.java @@ -0,0 +1,16 @@ +package me.totalfreedom.totalfreedommod.world.profile; + +import org.bukkit.block.data.BlockData; + +/** + * The three blocks every profile needs: what the generator fills with, what the sea is made of, and + * what the floor is, plus how much of a bedrock layer to write. + *

+ * Already looked up, so never call createBlockData in a stage. + */ +public record Materials(BlockData defaultBlock, + BlockData fluidBlock, + BlockData bedrockBlock, + BedrockMode bedrock) +{ +} diff --git a/src/main/java/me/totalfreedom/totalfreedommod/world/profile/Palette.java b/src/main/java/me/totalfreedom/totalfreedommod/world/profile/Palette.java new file mode 100644 index 000000000..b43164637 --- /dev/null +++ b/src/main/java/me/totalfreedom/totalfreedommod/world/profile/Palette.java @@ -0,0 +1,63 @@ +package me.totalfreedom.totalfreedommod.world.profile; + +import java.util.List; + +import org.bukkit.block.Biome; + +import me.totalfreedom.totalfreedommod.world.noise.NoiseField; + +/** + * What the world is made of, plus the climate that decides which materials go where. + *

+ * Climate and biomes are shared: the biome provider, the designer's biome-specific rules, and the + * populator's biome filters all read them. Nothing can ask Bukkit for a biome mid-generation. + */ +public record Palette(Materials materials, + List surface, + Climate climate, + Biome fallback, + List biomes) +{ + /** @throws IllegalArgumentException if the rule list is empty, which would leave bare filler */ + public Palette + { + if (surface.isEmpty()) + throw new IllegalArgumentException("surface rules must not be empty"); + + surface = List.copyOf(surface); + biomes = List.copyOf(biomes); + } + + /** The two noise fields a position is scored against to land it in a band. */ + public record Climate(NoiseField temperature, NoiseField humidity, double scale) + { + } + + /** + * One biome table entry. First band containing both values wins, and anything no band covers + * gets the palette's fallback. + * + * @throws IllegalArgumentException if either range is inverted or falls outside -1 to 1 + */ + public record BiomeBand(Biome biome, + double minTemperature, + double maxTemperature, + double minHumidity, + double maxHumidity) + { + public BiomeBand + { + if (minTemperature > maxTemperature || minTemperature < -1 || maxTemperature > 1) + throw new IllegalArgumentException("temperature range must fall within -1 to 1"); + + if (minHumidity > maxHumidity || minHumidity < -1 || maxHumidity > 1) + throw new IllegalArgumentException("humidity range must fall within -1 to 1"); + } + + public boolean matches(final double temperature, final double humidity) + { + return temperature >= this.minTemperature && temperature <= this.maxTemperature + && humidity >= this.minHumidity && humidity <= this.maxHumidity; + } + } +} diff --git a/src/main/java/me/totalfreedom/totalfreedommod/world/profile/ProfileError.java b/src/main/java/me/totalfreedom/totalfreedommod/world/profile/ProfileError.java new file mode 100644 index 000000000..ff4cf1980 --- /dev/null +++ b/src/main/java/me/totalfreedom/totalfreedommod/world/profile/ProfileError.java @@ -0,0 +1,19 @@ +package me.totalfreedom.totalfreedommod.world.profile; + +/** + * One thing wrong with a profile file. + *

+ * path is the dotted key it came from, so an admin can go straight to it rather than hunting. + * + * @param path for example {@code shape.caves.threshold} + * @param message what was wrong, in words, including what was found + */ +public record ProfileError(String path, String message) +{ + /** Renders as {@code shape.caves.threshold: expected a number, got "0.5x"}. */ + @Override + public String toString() + { + return this.path + ": " + this.message; + } +} diff --git a/src/main/java/me/totalfreedom/totalfreedommod/world/profile/ProfileException.java b/src/main/java/me/totalfreedom/totalfreedommod/world/profile/ProfileException.java new file mode 100644 index 000000000..566ccfd15 --- /dev/null +++ b/src/main/java/me/totalfreedom/totalfreedommod/world/profile/ProfileException.java @@ -0,0 +1,29 @@ +package me.totalfreedom.totalfreedommod.world.profile; + +import java.util.List; + +/** + * Thrown when a profile file cannot be turned into a world. + *

+ * Carries every problem found, not just the first one, so an admin fixes the file in one pass + * instead of rerunning after each typo. + *

+ * Checked on purpose. Parsing a profile is the one place a bad world can enter the system, and the + * caller has to decide what to do about it rather than letting it slip past. + */ +public final class ProfileException extends Exception +{ + private final List errors; + + public ProfileException(final String worldName, final List errors) + { + super("Profile for world '" + worldName + "' has " + errors.size() + " problem(s)"); + + this.errors = List.copyOf(errors); + } + + public List getErrors() + { + return this.errors; + } +} diff --git a/src/main/java/me/totalfreedom/totalfreedommod/world/profile/StageSet.java b/src/main/java/me/totalfreedom/totalfreedommod/world/profile/StageSet.java deleted file mode 100644 index 538003fe3..000000000 --- a/src/main/java/me/totalfreedom/totalfreedommod/world/profile/StageSet.java +++ /dev/null @@ -1,18 +0,0 @@ -package me.totalfreedom.totalfreedommod.world.profile; - -import me.totalfreedom.totalfreedommod.world.base.Carver; -import me.totalfreedom.totalfreedommod.world.base.Designer; -import me.totalfreedom.totalfreedommod.world.base.Generator; -import me.totalfreedom.totalfreedommod.world.base.Populator; - -/** - * The four stages a profile runs, picked from the mode at compile time. - *

- * Also how the chunk context reaches the generator and carver for its column heights. - */ -public record StageSet(Generator generator, - Designer designer, - Carver carver, - Populator populator) -{ -} diff --git a/src/main/java/me/totalfreedom/totalfreedommod/world/profile/WorldSettings.java b/src/main/java/me/totalfreedom/totalfreedommod/world/profile/WorldSettings.java new file mode 100644 index 000000000..c954f81de --- /dev/null +++ b/src/main/java/me/totalfreedom/totalfreedommod/world/profile/WorldSettings.java @@ -0,0 +1,32 @@ +package me.totalfreedom.totalfreedommod.world.profile; + +import java.util.Optional; + +import org.bukkit.World; + +/** + * Settings applied to the WorldCreator at creation. Never read during chunk generation. + *

+ * keepSpawnLoaded makes createWorld generate the entire spawn square on the main thread. Leave it + * off unless a world needs it. + */ +public record WorldSettings(World.Environment environment, + boolean generateStructures, + boolean keepSpawnLoaded, + Optional seed, + VanillaFlags vanilla) +{ + /** + * Which vanilla generation steps run alongside ours. All default off. The chunk generator hands + * these to Bukkit through its shouldGenerate methods. + *

+ * Turning on decorations gets you vanilla trees, flowers, and ore without writing any features. + */ + public record VanillaFlags(boolean surface, + boolean caves, + boolean decorations, + boolean mobs, + boolean structures) + { + } +} diff --git a/src/main/java/me/totalfreedom/totalfreedommod/world/profile/json/Defaulted.java b/src/main/java/me/totalfreedom/totalfreedommod/world/profile/json/Defaulted.java deleted file mode 100644 index d50cf201c..000000000 --- a/src/main/java/me/totalfreedom/totalfreedommod/world/profile/json/Defaulted.java +++ /dev/null @@ -1,14 +0,0 @@ -package me.totalfreedom.totalfreedommod.world.profile.json; - -/** - * A profile record that can fill in its own missing fields. - *

- * Return a copy with every null replaced, including whole sections that were absent. Gson leaves - * absent fields null and never runs compact constructors, so defaults cannot live in the record. - * - * @param the implementing record's own type - */ -public interface Defaulted -{ - T withDefaults(); -} diff --git a/src/main/java/me/totalfreedom/totalfreedommod/world/stage/feature/Feature.java b/src/main/java/me/totalfreedom/totalfreedommod/world/stage/feature/Feature.java index 3e4564629..2044bdf69 100644 --- a/src/main/java/me/totalfreedom/totalfreedommod/world/stage/feature/Feature.java +++ b/src/main/java/me/totalfreedom/totalfreedommod/world/stage/feature/Feature.java @@ -3,15 +3,34 @@ import org.bukkit.generator.LimitedRegion; import me.totalfreedom.totalfreedommod.world.base.ChunkContext; +import me.totalfreedom.totalfreedommod.world.profile.FeatureDetail; /** - * One kind of thing that can be placed into a finished chunk. + * Places one kind of thing into a finished chunk. + *

+ * Typed to its own detail, so a tree gets a Tree and never has to check what it was handed. The + * populator's switch over the sealed detail is what makes that safe. *

* Shared and run concurrently, so keep implementations immutable and take every roll from the * context's random. The origin is always inside the target chunk, but the overhang may not be, so * bounds check writes with isInRegion. + * + * @param the detail variant this feature places */ -public interface Feature +public interface Feature { - void place(ChunkContext context, LimitedRegion region, FeatureSpec spec, int x, int y, int z); + void place(ChunkContext context, LimitedRegion region, D detail, int x, int y, int z); + + /** + * Interpolates between two points. Written the precise way, {@code from*(1-t) + to*t}, so that + * a progress of exactly 1 returns exactly {@code to}. The shorter {@code from + t*(to-from)} + * rounds twice and can miss the far endpoint by an ulp. + *

+ * Nothing today loops far enough to reach 1, but this is shared, and the next feature to use it + * should not have to loop a particular way to stay correct. + */ + default double lerp(final double progress, final double from, final double to) + { + return from * (1.0D - progress) + to * progress; + } } diff --git a/src/main/java/me/totalfreedom/totalfreedommod/world/stage/feature/OreFeature.java b/src/main/java/me/totalfreedom/totalfreedommod/world/stage/feature/OreFeature.java new file mode 100644 index 000000000..b620bdc20 --- /dev/null +++ b/src/main/java/me/totalfreedom/totalfreedommod/world/stage/feature/OreFeature.java @@ -0,0 +1,112 @@ +package me.totalfreedom.totalfreedommod.world.stage.feature; + +import java.util.Random; + +import org.bukkit.Material; +import org.bukkit.generator.LimitedRegion; + +import me.totalfreedom.totalfreedommod.world.base.ChunkContext; +import me.totalfreedom.totalfreedommod.world.profile.FeatureDetail; + +/** + * A vein of ore buried in the filler block. Detail size is how many blocks the vein is worth. + *

+ * Laid along a short line with a swollen middle rather than as a ball, which is what gives a vein + * its stretched, slightly lumpy shape. The line is randomly angled on the horizontal, so veins do + * not all run the same way. + *

+ * Replaces the profile's own filler block and nothing else. That is why an ore entry works in the + * nether without being told about netherrack: the generator only ever writes the filler, so + * matching against it is the same as asking "is this untouched stone". + */ +public final class OreFeature implements Feature +{ + @Override + public void place(final ChunkContext context, + final LimitedRegion region, + final FeatureDetail.Ore detail, + final int x, + final int y, + final int z) + { + final Random random = context.getRandom(); + final Material filler = context.getProfile().palette().materials().defaultBlock().getMaterial(); + final int size = detail.size(); + + // Both ends of the line, angled anywhere on the horizontal and drifting a little vertically. + final double angle = random.nextDouble() * Math.PI; + final double reach = size / 8.0D; + + final double fromX = x + Math.sin(angle) * reach; + final double toX = x - Math.sin(angle) * reach; + final double fromZ = z + Math.cos(angle) * reach; + final double toZ = z - Math.cos(angle) * reach; + final double fromY = y + random.nextInt(3) - 2; + final double toY = y + random.nextInt(3) - 2; + + for (int step = 0; step < size; step++) + { + final double progress = step / (double) size; + + final double centreX = lerp(progress, fromX, toX); + final double centreY = lerp(progress, fromY, toY); + final double centreZ = lerp(progress, fromZ, toZ); + + // Swells to its widest halfway along and tapers back at both ends, so the vein has + // pointed tips instead of blunt ones. The +1 keeps the thinnest step at least a block. + final double swell = random.nextDouble() * size / 16.0D; + final double radius = ((Math.sin(Math.PI * progress) + 1.0D) * swell + 1.0D) / 2.0D; + + blob(region, detail, filler, centreX, centreY, centreZ, radius); + } + } + + /** Fills one sphere of the vein, skipping anything outside the region or not made of filler. */ + private static void blob(final LimitedRegion region, + final FeatureDetail.Ore detail, + final Material filler, + final double centreX, + final double centreY, + final double centreZ, + final double radius) + { + final int minX = (int) Math.floor(centreX - radius); + final int maxX = (int) Math.floor(centreX + radius); + final int minY = (int) Math.floor(centreY - radius); + final int maxY = (int) Math.floor(centreY + radius); + final int minZ = (int) Math.floor(centreZ - radius); + final int maxZ = (int) Math.floor(centreZ + radius); + + for (int blockX = minX; blockX <= maxX; blockX++) + { + final double offsetX = (blockX + 0.5D - centreX) / radius; + + if (offsetX * offsetX >= 1.0D) + continue; + + for (int blockY = minY; blockY <= maxY; blockY++) + { + final double offsetY = (blockY + 0.5D - centreY) / radius; + + if (offsetX * offsetX + offsetY * offsetY >= 1.0D) + continue; + + for (int blockZ = minZ; blockZ <= maxZ; blockZ++) + { + final double offsetZ = (blockZ + 0.5D - centreZ) / radius; + + if (offsetX * offsetX + offsetY * offsetY + offsetZ * offsetZ >= 1.0D) + continue; + + if (!region.isInRegion(blockX, blockY, blockZ)) + continue; + + if (region.getType(blockX, blockY, blockZ) != filler) + continue; + + region.setBlockData(blockX, blockY, blockZ, detail.block()); + } + } + } + } +} diff --git a/src/main/resources/worlds/flatlands-template.json b/src/main/resources/worlds/flatlands-template.json new file mode 100644 index 000000000..d9af5c466 --- /dev/null +++ b/src/main/resources/worlds/flatlands-template.json @@ -0,0 +1,31 @@ +{ + "shape": { + "mode": "flat", + "bounds": { "minY": 0, "maxY": 320 }, + "layers": "1|bedrock|59|stone|3|dirt|1|grass_block" + }, + + "palette": { + "materials": { + "defaultBlock": "stone", + "fluidBlock": "water", + "bedrockBlock": "bedrock", + "bedrock": "FLOOR" + } + }, + + "features": [], + + "world": { + "environment": "normal", + "generateStructures": false, + "keepSpawnLoaded": false, + "vanilla": { + "surface": false, + "caves": false, + "decorations": false, + "mobs": false, + "structures": false + } + } +} diff --git a/src/main/resources/worlds/overworld-template.json b/src/main/resources/worlds/overworld-template.json new file mode 100644 index 000000000..713cd548e --- /dev/null +++ b/src/main/resources/worlds/overworld-template.json @@ -0,0 +1,133 @@ +{ + "shape": { + "mode": "heightmap", + "bounds": { "minY": -64, "maxY": 320, "seaLevel": 63 }, + "terrain": { + "noise": { + "type": "simplex", + "octaves": 4, + "frequency": 0.00195, + "persistence": 0.5, + "lacunarity": 2.0, + "ridged": false + }, + "spline": { + "points": [ + [-1.00, 30], + [-0.60, 45], + [-0.30, 58], + [-0.10, 63], + [ 0.05, 68], + [ 0.30, 85], + [ 0.60, 120], + [ 1.00, 180] + ] + }, + "river": { + "noise": { + "type": "simplex", + "octaves": 2, + "frequency": 0.0016, + "persistence": 0.5, + "lacunarity": 2.0, + "ridged": false + }, + "threshold": 0.03, + "depth": 5, + "bedBlock": "gravel" + }, + "warp": 0.015 + }, + "caves": { + "noise": { + "type": "simplex", + "octaves": 3, + "frequency": 0.0078, + "persistence": 0.5, + "lacunarity": 2.0, + "ridged": true + }, + "threshold": 0.48, + "minY": -59, + "maxY": 128, + "floodLevel": -54 + } + }, + + "palette": { + "materials": { + "defaultBlock": "stone", + "fluidBlock": "water", + "bedrockBlock": "bedrock", + "bedrock": "FLOOR" + }, + "surface": [ + { "biome": "desert", "depthFrom": 0, "depthTo": 6, "block": "sand" }, + { "biome": "snowy_plains", "depthFrom": 0, "depthTo": 0, "block": "snow_block" }, + { "depthFrom": 0, "depthTo": 0, "block": "grass_block" }, + { "depthFrom": 1, "depthTo": 3, "block": "dirt" }, + { "depthFrom": 4, "block": "stone" } + ], + "climate": { + "temperature": { + "type": "simplex", + "octaves": 2, + "frequency": 0.00098, + "persistence": 0.5, + "lacunarity": 2.0, + "ridged": false + }, + "humidity": { + "type": "simplex", + "octaves": 2, + "frequency": 0.00098, + "persistence": 0.5, + "lacunarity": 2.0, + "ridged": false + }, + "scale": 1.0 + }, + "fallback": "plains", + "biomes": [ + { "biome": "snowy_plains", "minTemperature": -1.00, "maxTemperature": -0.45, "minHumidity": -1.00, "maxHumidity": 1.00 }, + { "biome": "taiga", "minTemperature": -0.45, "maxTemperature": -0.15, "minHumidity": -1.00, "maxHumidity": 1.00 }, + { "biome": "plains", "minTemperature": -0.15, "maxTemperature": 0.55, "minHumidity": -1.00, "maxHumidity": 0.10 }, + { "biome": "forest", "minTemperature": -0.15, "maxTemperature": 0.55, "minHumidity": 0.10, "maxHumidity": 1.00 }, + { "biome": "desert", "minTemperature": 0.55, "maxTemperature": 1.00, "minHumidity": -1.00, "maxHumidity": -0.10 }, + { "biome": "savanna", "minTemperature": 0.55, "maxTemperature": 1.00, "minHumidity": -0.10, "maxHumidity": 0.30 }, + { "biome": "jungle", "minTemperature": 0.55, "maxTemperature": 1.00, "minHumidity": 0.30, "maxHumidity": 1.00 } + ] + }, + + "features": [ + { "type": "ore", "rarity": 20, "minY": 0, "maxY": 192, "block": "coal_ore", "size": 17 }, + { "type": "ore", "rarity": 16, "minY": -16, "maxY": 112, "block": "copper_ore", "size": 10 }, + { "type": "ore", "rarity": 10, "minY": -24, "maxY": 56, "block": "iron_ore", "size": 9 }, + { "type": "ore", "rarity": 8, "minY": -64, "maxY": 15, "block": "redstone_ore", "size": 8 }, + { "type": "ore", "rarity": 4, "minY": -64, "maxY": 32, "block": "gold_ore", "size": 9 }, + { "type": "ore", "rarity": 2, "minY": -64, "maxY": 30, "block": "lapis_ore", "size": 7 }, + { "type": "ore", "rarity": 1, "minY": -64, "maxY": 16, "block": "diamond_ore", "size": 8 }, + { "type": "tree", "rarity": 10, "minY": 60, "maxY": 200, "block": "oak_sapling", "size": 1, "biomes": ["forest"] }, + { "type": "tree", "rarity": 8, "minY": 60, "maxY": 200, "block": "jungle_sapling", "size": 1, "biomes": ["jungle"] }, + { "type": "tree", "rarity": 6, "minY": 60, "maxY": 200, "block": "spruce_sapling", "size": 1, "biomes": ["taiga", "snowy_plains"] }, + { "type": "tree", "rarity": 1, "minY": 60, "maxY": 200, "block": "oak_sapling", "size": 1, "biomes": ["plains", "savanna"] }, + { "type": "patch", "rarity": 7, "minY": 60, "maxY": 200, "block": "short_grass", "size": 32, "biomes": ["plains", "savanna"] }, + { "type": "patch", "rarity": 2, "minY": 60, "maxY": 200, "block": "short_grass", "size": 32, "biomes": ["forest", "jungle"] }, + { "type": "patch", "rarity": 2, "minY": 60, "maxY": 200, "block": "dead_bush", "size": 6, "biomes": ["desert"] }, + { "type": "boulder", "rarity": 1, "minY": 60, "maxY": 220, "block": "mossy_cobblestone", "size": 3, "biomes": ["taiga"] }, + { "type": "lake", "rarity": 1, "minY": 40, "maxY": 90, "block": "water", "size": 6 } + ], + + "world": { + "environment": "normal", + "generateStructures": false, + "keepSpawnLoaded": false, + "vanilla": { + "surface": false, + "caves": false, + "decorations": false, + "mobs": true, + "structures": false + } + } +} From 03048655ac1a254a68612b2913d7beeebcb6b8a4 Mon Sep 17 00:00:00 2001 From: Paldiu Date: Sun, 9 Aug 2026 01:00:09 -0500 Subject: [PATCH 05/32] skeletonized --- .../totalfreedommod/world/GeneratedWorld.java | 32 ++++ .../world/GenerationService.java | 75 +++++++++ .../world/adapter/ProfileBiomeProvider.java | 38 +++++ .../world/adapter/ProfileBlockPopulator.java | 34 ++++ .../world/adapter/ProfileChunkGenerator.java | 149 ++++++++++++++++++ .../world/adapter/SpawnFinder.java | 27 ++++ .../totalfreedommod/world/base/Stages.java | 17 ++ .../world/noise/NoiseField.java | 48 ++++++ .../world/noise/NoiseProfile.java | 23 +++ .../world/profile/LayerStack.java | 58 +++++++ .../world/profile/ProfileLoader.java | 67 ++++++++ .../world/profile/ProfileParser.java | 29 ++++ .../totalfreedommod/world/profile/Shape.java | 65 ++++++++ .../totalfreedommod/world/profile/Spline.java | 27 ++++ .../world/profile/SurfaceRule.java | 17 ++ .../world/stage/DensityGenerator.java | 42 +++++ .../world/stage/FeaturePopulator.java | 32 ++++ .../world/stage/FlatGenerator.java | 35 ++++ .../world/stage/HeightmapGenerator.java | 54 +++++++ .../world/stage/LayerDesigner.java | 36 +++++ .../world/stage/NoiseCarver.java | 45 ++++++ .../world/stage/RuleDesigner.java | 38 +++++ .../world/stage/feature/BoulderFeature.java | 21 +++ .../world/stage/feature/LakeFeature.java | 26 +++ .../world/stage/feature/PatchFeature.java | 21 +++ .../world/stage/feature/TreeFeature.java | 36 +++++ 26 files changed, 1092 insertions(+) create mode 100644 src/main/java/me/totalfreedom/totalfreedommod/world/GeneratedWorld.java create mode 100644 src/main/java/me/totalfreedom/totalfreedommod/world/GenerationService.java create mode 100644 src/main/java/me/totalfreedom/totalfreedommod/world/adapter/ProfileBiomeProvider.java create mode 100644 src/main/java/me/totalfreedom/totalfreedommod/world/adapter/ProfileBlockPopulator.java create mode 100644 src/main/java/me/totalfreedom/totalfreedommod/world/adapter/ProfileChunkGenerator.java create mode 100644 src/main/java/me/totalfreedom/totalfreedommod/world/adapter/SpawnFinder.java create mode 100644 src/main/java/me/totalfreedom/totalfreedommod/world/base/Stages.java create mode 100644 src/main/java/me/totalfreedom/totalfreedommod/world/noise/NoiseField.java create mode 100644 src/main/java/me/totalfreedom/totalfreedommod/world/noise/NoiseProfile.java create mode 100644 src/main/java/me/totalfreedom/totalfreedommod/world/profile/LayerStack.java create mode 100644 src/main/java/me/totalfreedom/totalfreedommod/world/profile/ProfileLoader.java create mode 100644 src/main/java/me/totalfreedom/totalfreedommod/world/profile/ProfileParser.java create mode 100644 src/main/java/me/totalfreedom/totalfreedommod/world/profile/Shape.java create mode 100644 src/main/java/me/totalfreedom/totalfreedommod/world/profile/Spline.java create mode 100644 src/main/java/me/totalfreedom/totalfreedommod/world/profile/SurfaceRule.java create mode 100644 src/main/java/me/totalfreedom/totalfreedommod/world/stage/DensityGenerator.java create mode 100644 src/main/java/me/totalfreedom/totalfreedommod/world/stage/FeaturePopulator.java create mode 100644 src/main/java/me/totalfreedom/totalfreedommod/world/stage/FlatGenerator.java create mode 100644 src/main/java/me/totalfreedom/totalfreedommod/world/stage/HeightmapGenerator.java create mode 100644 src/main/java/me/totalfreedom/totalfreedommod/world/stage/LayerDesigner.java create mode 100644 src/main/java/me/totalfreedom/totalfreedommod/world/stage/NoiseCarver.java create mode 100644 src/main/java/me/totalfreedom/totalfreedommod/world/stage/RuleDesigner.java create mode 100644 src/main/java/me/totalfreedom/totalfreedommod/world/stage/feature/BoulderFeature.java create mode 100644 src/main/java/me/totalfreedom/totalfreedommod/world/stage/feature/LakeFeature.java create mode 100644 src/main/java/me/totalfreedom/totalfreedommod/world/stage/feature/PatchFeature.java create mode 100644 src/main/java/me/totalfreedom/totalfreedommod/world/stage/feature/TreeFeature.java diff --git a/src/main/java/me/totalfreedom/totalfreedommod/world/GeneratedWorld.java b/src/main/java/me/totalfreedom/totalfreedommod/world/GeneratedWorld.java new file mode 100644 index 000000000..9316f5fc8 --- /dev/null +++ b/src/main/java/me/totalfreedom/totalfreedommod/world/GeneratedWorld.java @@ -0,0 +1,32 @@ +package me.totalfreedom.totalfreedommod.world; + +import org.bukkit.World; + +import me.totalfreedom.totalfreedommod.TotalFreedomMod; + +/** + * A custom world built from a profile. Applies the profile's world settings to the WorldCreator and + * takes its spawn point from the spawn finder. + */ +public class GeneratedWorld extends CustomWorld +{ + private final GenerationProfile profile; + + public GeneratedWorld(final TotalFreedomMod plugin, final GenerationProfile profile, final String displayName) + { + super(plugin, profile.name(), displayName); + + this.profile = profile; + } + + @Override + protected World generateWorld() + { + + } + + public GenerationProfile getProfile() + { + return this.profile; + } +} diff --git a/src/main/java/me/totalfreedom/totalfreedommod/world/GenerationService.java b/src/main/java/me/totalfreedom/totalfreedommod/world/GenerationService.java new file mode 100644 index 000000000..d65b73e23 --- /dev/null +++ b/src/main/java/me/totalfreedom/totalfreedommod/world/GenerationService.java @@ -0,0 +1,75 @@ +package me.totalfreedom.totalfreedommod.world; + +import java.util.HashMap; +import java.util.Map; +import java.util.Optional; +import java.util.Set; + +import org.bukkit.generator.ChunkGenerator; + +import me.totalfreedom.totalfreedommod.FreedomService; +import me.totalfreedom.totalfreedommod.TotalFreedomMod; +import me.totalfreedom.totalfreedommod.world.profile.ProfileLoader; +import me.totalfreedom.totalfreedommod.world.profile.ProfileParser; + +/** + * Holds the profile registry. Files are read and parsed at startup, and only worlds that parsed + * cleanly end up in here. + *

+ * A profile that fails is logged with every problem found and then skipped, so one bad file costs + * you that world and nothing else. The rest of the plugin does not care that world generation had a + * bad day. + *

+ * Sits behind the plugin's getDefaultWorldGenerator hook, which is what lets a profile drive a + * world created through bukkit.yml or a world manager instead of only ones we create ourselves. + */ +public final class GenerationService extends FreedomService +{ + private final ProfileLoader loader; + private final ProfileParser parser; + private final Map profiles; + + public GenerationService(final TotalFreedomMod plugin) + { + super(plugin); + + this.loader = new ProfileLoader(plugin); + this.parser = new ProfileParser(); + this.profiles = new HashMap<>(); + } + + @Override + protected void onStart() + { + + } + + @Override + protected void onStop() + { + + } + + public Optional profile(final String worldName) + { + + } + + /** Empty if no profile covers the world, or if its file failed to parse. */ + public Optional generatorFor(final String worldName) + { + + } + + /** Only worlds whose profiles parsed. A file that failed does not appear here. */ + public Set available() + { + + } + + /** Re-reads every profile file. Already-loaded worlds keep the profile they were built with. */ + public void reload() + { + + } +} diff --git a/src/main/java/me/totalfreedom/totalfreedommod/world/adapter/ProfileBiomeProvider.java b/src/main/java/me/totalfreedom/totalfreedommod/world/adapter/ProfileBiomeProvider.java new file mode 100644 index 000000000..997ffe077 --- /dev/null +++ b/src/main/java/me/totalfreedom/totalfreedommod/world/adapter/ProfileBiomeProvider.java @@ -0,0 +1,38 @@ +package me.totalfreedom.totalfreedommod.world.adapter; + +import java.util.List; + +import org.bukkit.block.Biome; +import org.bukkit.generator.BiomeProvider; +import org.bukkit.generator.WorldInfo; + +import me.totalfreedom.totalfreedommod.world.GenerationProfile; + +/** + * Samples the profile's temperature and humidity noise and drops the result into a biome band. + *

+ * Sets grass and water colour and what mobs spawn, and gives the designer's rules and the + * populator's filters something to match against. + */ +public final class ProfileBiomeProvider extends BiomeProvider +{ + private final GenerationProfile profile; + + public ProfileBiomeProvider(final GenerationProfile profile) + { + this.profile = profile; + } + + @Override + public Biome getBiome(final WorldInfo worldInfo, final int x, final int y, final int z) + { + + } + + /** Must list every biome getBiome can return, or the server rejects the provider. */ + @Override + public List getBiomes(final WorldInfo worldInfo) + { + + } +} diff --git a/src/main/java/me/totalfreedom/totalfreedommod/world/adapter/ProfileBlockPopulator.java b/src/main/java/me/totalfreedom/totalfreedommod/world/adapter/ProfileBlockPopulator.java new file mode 100644 index 000000000..e40c42c96 --- /dev/null +++ b/src/main/java/me/totalfreedom/totalfreedommod/world/adapter/ProfileBlockPopulator.java @@ -0,0 +1,34 @@ +package me.totalfreedom.totalfreedommod.world.adapter; + +import java.util.Random; + +import org.bukkit.generator.BlockPopulator; +import org.bukkit.generator.LimitedRegion; +import org.bukkit.generator.WorldInfo; + +import me.totalfreedom.totalfreedommod.world.GenerationProfile; + +/** + * Runs the profile's populator as a Bukkit block populator. + *

+ * Builds its own ChunkContext, since this may not run on the thread that generated the chunk. + */ +public final class ProfileBlockPopulator extends BlockPopulator +{ + private final GenerationProfile profile; + + public ProfileBlockPopulator(final GenerationProfile profile) + { + this.profile = profile; + } + + @Override + public void populate(final WorldInfo worldInfo, + final Random random, + final int chunkX, + final int chunkZ, + final LimitedRegion limitedRegion) + { + + } +} diff --git a/src/main/java/me/totalfreedom/totalfreedommod/world/adapter/ProfileChunkGenerator.java b/src/main/java/me/totalfreedom/totalfreedommod/world/adapter/ProfileChunkGenerator.java new file mode 100644 index 000000000..2deb32660 --- /dev/null +++ b/src/main/java/me/totalfreedom/totalfreedommod/world/adapter/ProfileChunkGenerator.java @@ -0,0 +1,149 @@ +package me.totalfreedom.totalfreedommod.world.adapter; + +import java.util.List; +import java.util.Random; + +import org.bukkit.HeightMap; +import org.bukkit.Location; +import org.bukkit.World; +import org.bukkit.generator.BiomeProvider; +import org.bukkit.generator.BlockPopulator; +import org.bukkit.generator.ChunkGenerator; +import org.bukkit.generator.WorldInfo; + +import me.totalfreedom.totalfreedommod.world.GenerationProfile; +import me.totalfreedom.totalfreedommod.world.base.Stages; +import me.totalfreedom.totalfreedommod.world.profile.Shape; + +/** + * Bridges Bukkit's callbacks to the profile's stages. One stage per callback: noise to the + * generator, surface and bedrock to the designer, caves to the carver, populators to the populator. + *

+ * The only class that knows what order Bukkit runs things in, and the only one that turns a + * {@link Shape} into actual stage objects. That pattern match happens once here, in + * {@link #wire(GenerationProfile)}, which is why no per-chunk code ever asks what mode a world is. + *

+ * Builds a fresh ChunkContext in each callback, since nothing carries over between them. + *

+ * Owns the cave loop: reads the carver's y range once before looping, leaves bedrock alone, and + * fills cleared blocks with water instead of air below the depth the profile sets. + *

+ * The shouldGenerate methods come straight from the profile's vanilla flags. + */ +public final class ProfileChunkGenerator extends ChunkGenerator +{ + private final GenerationProfile profile; + private final Stages stages; + + public ProfileChunkGenerator(final GenerationProfile profile) + { + this.profile = profile; + this.stages = wire(profile); + } + + /** Picks the stages for a profile's shape. The one place that switch is written. */ + private static Stages wire(final GenerationProfile profile) + { + + } + + @Override + public void generateNoise(final WorldInfo worldInfo, + final Random random, + final int chunkX, + final int chunkZ, + final ChunkData chunkData) + { + + } + + @Override + public void generateSurface(final WorldInfo worldInfo, + final Random random, + final int chunkX, + final int chunkZ, + final ChunkData chunkData) + { + + } + + @Override + public void generateBedrock(final WorldInfo worldInfo, + final Random random, + final int chunkX, + final int chunkZ, + final ChunkData chunkData) + { + + } + + /** Walks the carver's y range and clears whatever it flags. No carver means no work. */ + @Override + public void generateCaves(final WorldInfo worldInfo, + final Random random, + final int chunkX, + final int chunkZ, + final ChunkData chunkData) + { + + } + + @Override + public int getBaseHeight(final WorldInfo worldInfo, + final Random random, + final int x, + final int z, + final HeightMap heightMap) + { + + } + + @Override + public Location getFixedSpawnLocation(final World world, final Random random) + { + + } + + @Override + public BiomeProvider getDefaultBiomeProvider(final WorldInfo worldInfo) + { + + } + + /** Returns the block populator wrapping the profile's populator. */ + @Override + public List getDefaultPopulators(final World world) + { + + } + + @Override + public boolean shouldGenerateSurface() + { + + } + + @Override + public boolean shouldGenerateCaves() + { + + } + + @Override + public boolean shouldGenerateDecorations() + { + + } + + @Override + public boolean shouldGenerateMobs() + { + + } + + @Override + public boolean shouldGenerateStructures() + { + + } +} diff --git a/src/main/java/me/totalfreedom/totalfreedommod/world/adapter/SpawnFinder.java b/src/main/java/me/totalfreedom/totalfreedommod/world/adapter/SpawnFinder.java new file mode 100644 index 000000000..6aff046fc --- /dev/null +++ b/src/main/java/me/totalfreedom/totalfreedommod/world/adapter/SpawnFinder.java @@ -0,0 +1,27 @@ +package me.totalfreedom.totalfreedommod.world.adapter; + +import org.bukkit.Location; +import org.bukkit.World; + +import me.totalfreedom.totalfreedommod.world.GenerationProfile; + +/** + * Picks a spawn point by asking the generator's height function. + *

+ * Loads no chunks, since that function is pure maths. + */ +public final class SpawnFinder +{ + private final GenerationProfile profile; + + public SpawnFinder(final GenerationProfile profile) + { + this.profile = profile; + } + + /** Searches out from origin for the first column that is not underwater or void. */ + public Location findSpawn(final World world) + { + + } +} diff --git a/src/main/java/me/totalfreedom/totalfreedommod/world/base/Stages.java b/src/main/java/me/totalfreedom/totalfreedommod/world/base/Stages.java new file mode 100644 index 000000000..23bdbc051 --- /dev/null +++ b/src/main/java/me/totalfreedom/totalfreedommod/world/base/Stages.java @@ -0,0 +1,17 @@ +package me.totalfreedom.totalfreedommod.world.base; + +import java.util.Optional; + +/** + * The four stages wired up for one world. + *

+ * Built once by the chunk generator, which pattern matches the profile's shape to decide what goes + * in here. An empty carver means the world has no caves, so nothing has to invent a y range that + * never matches. + */ +public record Stages(Generator generator, + Designer designer, + Optional carver, + Populator populator) +{ +} \ No newline at end of file diff --git a/src/main/java/me/totalfreedom/totalfreedommod/world/noise/NoiseField.java b/src/main/java/me/totalfreedom/totalfreedommod/world/noise/NoiseField.java new file mode 100644 index 000000000..4f80862d0 --- /dev/null +++ b/src/main/java/me/totalfreedom/totalfreedommod/world/noise/NoiseField.java @@ -0,0 +1,48 @@ +package me.totalfreedom.totalfreedommod.world.noise; + +import org.bukkit.util.noise.OctaveGenerator; + +/** + * A built noise field. Immutable, sampled concurrently from every worldgen thread. + *

+ * Build once at compile and hand it to a stage as a final field; that is what guarantees other + * threads see it fully constructed. Never reconfigure it afterward, setScale mutates and may only + * be touched inside {@link #of}. + */ +public final class NoiseField +{ + private final NoiseProfile profile; + private final OctaveGenerator generator; + + private NoiseField(final NoiseProfile profile, final OctaveGenerator generator) + { + this.profile = profile; + this.generator = generator; + } + + /** + * @param role stable name like "terrain" or "caves", mixed into the seed so two fields in one + * profile cannot end up on the same stream + */ + public static NoiseField of(final NoiseProfile profile, final long seed, final String role) + { + + } + + /** 2D sample, in the range -1 to 1. */ + public double sample(final int x, final int z) + { + + } + + /** 3D sample, in the range -1 to 1. */ + public double sample(final int x, final int y, final int z) + { + + } + + public NoiseProfile getProfile() + { + return this.profile; + } +} diff --git a/src/main/java/me/totalfreedom/totalfreedommod/world/noise/NoiseProfile.java b/src/main/java/me/totalfreedom/totalfreedommod/world/noise/NoiseProfile.java new file mode 100644 index 000000000..212c58cd7 --- /dev/null +++ b/src/main/java/me/totalfreedom/totalfreedommod/world/noise/NoiseProfile.java @@ -0,0 +1,23 @@ +package me.totalfreedom.totalfreedommod.world.noise; + +/** + * One noise field's settings. Feed it to {@link NoiseField#of} to get a usable field. + *

+ * Amplitude is set by the spline for terrain and by the threshold for caves, so it is not a knob + * here. The seed comes from the field's role name mixed with the world seed when the profile + * parses. + * + * @throws IllegalArgumentException if octaves is below one, or frequency is not positive + */ +public record NoiseProfile(NoiseType type, + int octaves, + double frequency, + double persistence, + double lacunarity, + boolean ridged) +{ + public NoiseProfile + { + + } +} diff --git a/src/main/java/me/totalfreedom/totalfreedommod/world/profile/LayerStack.java b/src/main/java/me/totalfreedom/totalfreedommod/world/profile/LayerStack.java new file mode 100644 index 000000000..ac6cbb0f1 --- /dev/null +++ b/src/main/java/me/totalfreedom/totalfreedommod/world/profile/LayerStack.java @@ -0,0 +1,58 @@ +package me.totalfreedom.totalfreedommod.world.profile; + +import org.bukkit.block.data.BlockData; + +/** + * Block layers for a flat world, bottom to top, starting at the world's minY. + *

+ * Takes the same syntax as flatlands.generate_params in the config. + *

+ * A class rather than a record because the layers are arrays. A record would hand out its backing + * arrays through the generated accessors, and anything holding the profile could then rewrite a + * compiled world's layers in place. + */ +public final class LayerStack +{ + private final BlockData[] blocks; + private final int[] heights; + + private LayerStack(final BlockData[] blocks, final int[] heights) + { + this.blocks = blocks; + this.heights = heights; + } + + /** + * @param spec e.g. {@code "16|stone|32|dirt|1|grass_block"}; the legacy comma form also works + * @throws IllegalArgumentException if the spec is malformed, names an unknown block, or gives a + * height below one + */ + public static LayerStack parse(final String spec) + { + + } + + /** How many layers there are, bottom to top. */ + public int size() + { + + } + + /** @throws IndexOutOfBoundsException if the layer does not exist */ + public BlockData blockAt(final int layer) + { + + } + + /** @throws IndexOutOfBoundsException if the layer does not exist */ + public int heightAt(final int layer) + { + + } + + /** Total height of every layer combined. */ + public int totalHeight() + { + + } +} diff --git a/src/main/java/me/totalfreedom/totalfreedommod/world/profile/ProfileLoader.java b/src/main/java/me/totalfreedom/totalfreedommod/world/profile/ProfileLoader.java new file mode 100644 index 000000000..e1e3e6f68 --- /dev/null +++ b/src/main/java/me/totalfreedom/totalfreedommod/world/profile/ProfileLoader.java @@ -0,0 +1,67 @@ +package me.totalfreedom.totalfreedommod.world.profile; + +import java.io.File; +import java.util.Optional; +import java.util.Set; + +import com.google.gson.JsonObject; + +import me.totalfreedom.totalfreedommod.TotalFreedomMod; + +/** + * Reads the .json files in the data folder's worlds directory. A file's name is the world's name, + * and every file in there is a world we manage. + *

+ * {@link #copyTemplate} is the only way a template would ever reach the disk, + * and once it does it stops being a template and becomes that world's profile. + *

+ * Only reads and parses JSON, so it is safe off the main thread. Turning that JSON into a profile + * is {@link ProfileParser}, which is not. + */ +public final class ProfileLoader +{ + private static final String WORLDS_DIRECTORY = "worlds"; + + private final TotalFreedomMod plugin; + private final File directory; + + public ProfileLoader(final TotalFreedomMod plugin) + { + this.plugin = plugin; + this.directory = new File(plugin.getDataFolder(), WORLDS_DIRECTORY); + } + + /** Every world with a profile file on disk. */ + public Set available() + { + + } + + /** + * One world's raw JSON, off disk. Empty if it has no file, which is not an error. + * + * @throws ProfileException if the file exists but is not readable JSON + */ + public Optional read(final String worldName) throws ProfileException + { + + } + + /** Names of the templates bundled in the jar. Never worlds. */ + public Set templates() + { + + } + + /** + * Writes a bundled template out as a new world's profile. This is what creates a managed world, + * so refuse if a file for that world already exists rather than overwriting someone's edits. + * + * @param templateName one of {@link #templates()} + * @param worldName the world to create, which becomes the file name + */ + public boolean copyTemplate(final String templateName, final String worldName) + { + + } +} diff --git a/src/main/java/me/totalfreedom/totalfreedommod/world/profile/ProfileParser.java b/src/main/java/me/totalfreedom/totalfreedommod/world/profile/ProfileParser.java new file mode 100644 index 000000000..2deb32109 --- /dev/null +++ b/src/main/java/me/totalfreedom/totalfreedommod/world/profile/ProfileParser.java @@ -0,0 +1,29 @@ +package me.totalfreedom.totalfreedommod.world.profile; + +import com.google.gson.JsonObject; + +import me.totalfreedom.totalfreedommod.world.GenerationProfile; + +/** + * Turns a profile file into a checked {@link GenerationProfile}, or explains why it cannot. + *

+ * A missing required key is an error; a missing optional one is {@code Optional.empty()}. + * Nothing is quietly defaulted into something that generates the wrong terrain. + *

+ * Collect every problem before giving up, so one run of the server tells an admin everything wrong + * with the file. Stopping at the first error means fixing typos one server restart at a time. + *

+ * Main thread only, since block and biome names are looked up here. Reading the file is not, so do + * that first and hand the parsed JSON in. + */ +public final class ProfileParser +{ + /** + * @param worldName the file's name, which becomes the world's name + * @throws ProfileException carrying every problem found, never just the first + */ + public GenerationProfile parse(final String worldName, final JsonObject root) throws ProfileException + { + + } +} diff --git a/src/main/java/me/totalfreedom/totalfreedommod/world/profile/Shape.java b/src/main/java/me/totalfreedom/totalfreedommod/world/profile/Shape.java new file mode 100644 index 000000000..ada5503e7 --- /dev/null +++ b/src/main/java/me/totalfreedom/totalfreedommod/world/profile/Shape.java @@ -0,0 +1,65 @@ +package me.totalfreedom.totalfreedommod.world.profile; + +import java.util.Optional; + +import org.bukkit.block.data.BlockData; + +import me.totalfreedom.totalfreedommod.world.noise.NoiseProfile; + +/** + * How a world's terrain gets formed. One variant per generator, and each carries exactly the + * settings that generator reads. + *

+ * This is what stops a profile saying flat and then setting terrain noise, or saying heightmap with + * no terrain at all. Those states cannot be typed, so nothing has to check for them. + *

+ * Pattern match it once when wiring up the chunk generator to pick the stages. Nothing per chunk + * and nothing per block should ever look at it again. + */ +public sealed interface Shape +{ + /** Fixed layers, no noise. */ + record Flat(LayerStack layers) implements Shape + { + } + + /** 2D height through a spline. No overhangs. */ + record Heightmap(Terrain terrain, + Optional river, + Optional caves) implements Shape + { + } + + /** 3D density. Overhangs and floating islands, at roughly fifty times the samples. */ + record Density(NoiseProfile noise, + double warp, + Optional caves) implements Shape + { + } + + /** warp offsets the sample coordinates by a second noise. */ + record Terrain(NoiseProfile noise, Spline spline, double warp) + { + } + + /** Pulls height toward sea level where the noise is near zero. */ + record River(NoiseProfile noise, double threshold, int depth, BlockData bedBlock) + { + } + + /** + * floodLevel is the y below which a carved out block fills with water instead of air. + *

+ * Keep minY above the bedrock layer, since carving runs after bedrock is written. + * + * @throws IllegalArgumentException if minY is above maxY + */ + record Caves(NoiseProfile noise, double threshold, int minY, int maxY, int floodLevel) + { + public Caves + { + if (minY > maxY) + throw new IllegalArgumentException("minY (" + minY + ") must not be above maxY (" + maxY + ")"); + } + } +} diff --git a/src/main/java/me/totalfreedom/totalfreedommod/world/profile/Spline.java b/src/main/java/me/totalfreedom/totalfreedommod/world/profile/Spline.java new file mode 100644 index 000000000..a6541f2e6 --- /dev/null +++ b/src/main/java/me/totalfreedom/totalfreedommod/world/profile/Spline.java @@ -0,0 +1,27 @@ +package me.totalfreedom.totalfreedommod.world.profile; + +import java.util.List; + +/** + * Maps raw noise to a terrain height through control points. Binary search plus a lerp per column. + *

+ * Plateaus, cliffs, and flat plains all come out of one array of points. + */ +public record Spline(double[] inputs, double[] outputs) +{ + /** + * @param points {@code [noise, height]} pairs + * @throws IllegalArgumentException if fewer than two points, or the noise values are not + * strictly ascending, which would make the search ambiguous + */ + public static Spline of(final List points) + { + + } + + /** Clamps to the first and last point outside their range. */ + public double apply(final double noise) + { + + } +} diff --git a/src/main/java/me/totalfreedom/totalfreedommod/world/profile/SurfaceRule.java b/src/main/java/me/totalfreedom/totalfreedommod/world/profile/SurfaceRule.java new file mode 100644 index 000000000..83693f222 --- /dev/null +++ b/src/main/java/me/totalfreedom/totalfreedommod/world/profile/SurfaceRule.java @@ -0,0 +1,17 @@ +package me.totalfreedom.totalfreedommod.world.profile; + +import org.bukkit.block.Biome; +import org.bukkit.block.data.BlockData; + +/** + * One surface rule: which biomes it covers, how far down it reaches, and what to put there. + *

+ * The designer walks its rules in order and takes the first match, so put the specific ones first. + */ +public record SurfaceRule(BiomeFilter biomes, Depth depth, BlockData block) +{ + public boolean matches(final Biome biome, final int depth) + { + + } +} diff --git a/src/main/java/me/totalfreedom/totalfreedommod/world/stage/DensityGenerator.java b/src/main/java/me/totalfreedom/totalfreedommod/world/stage/DensityGenerator.java new file mode 100644 index 000000000..e3e63d8cc --- /dev/null +++ b/src/main/java/me/totalfreedom/totalfreedommod/world/stage/DensityGenerator.java @@ -0,0 +1,42 @@ +package me.totalfreedom.totalfreedommod.world.stage; + +import org.bukkit.generator.ChunkGenerator; + +import me.totalfreedom.totalfreedommod.world.base.ChunkContext; +import me.totalfreedom.totalfreedommod.world.base.Generator; +import me.totalfreedom.totalfreedommod.world.noise.NoiseField; +import me.totalfreedom.totalfreedommod.world.profile.Bounds; +import me.totalfreedom.totalfreedommod.world.profile.Materials; + +/** + * 3D mode. Samples on a grid in all three directions and interpolates between the samples, which + * gets you overhangs and floating islands. + *

+ * Roughly fifty times the samples of heightmap mode, so only use it if a world actually needs + * those shapes. + */ +public final class DensityGenerator implements Generator +{ + private final NoiseField density; + private final Bounds bounds; + private final Materials materials; + + public DensityGenerator(final NoiseField density, final Bounds bounds, final Materials materials) + { + this.density = density; + this.bounds = bounds; + this.materials = materials; + } + + @Override + public void generateBase(final ChunkContext context, final ChunkGenerator.ChunkData data) + { + + } + + @Override + public int surfaceHeight(final ChunkContext context, final int worldX, final int worldZ) + { + + } +} diff --git a/src/main/java/me/totalfreedom/totalfreedommod/world/stage/FeaturePopulator.java b/src/main/java/me/totalfreedom/totalfreedommod/world/stage/FeaturePopulator.java new file mode 100644 index 000000000..54ef1aa46 --- /dev/null +++ b/src/main/java/me/totalfreedom/totalfreedommod/world/stage/FeaturePopulator.java @@ -0,0 +1,32 @@ +package me.totalfreedom.totalfreedommod.world.stage; + +import java.util.List; + +import org.bukkit.generator.LimitedRegion; + +import me.totalfreedom.totalfreedommod.world.base.ChunkContext; +import me.totalfreedom.totalfreedommod.world.base.Populator; +import me.totalfreedom.totalfreedommod.world.profile.FeatureSpec; +import me.totalfreedom.totalfreedommod.world.stage.feature.FeatureRegistry; + +/** + * Rolls each feature in the profile against the chunk and hands off the hits. This only decides + * what gets placed and where; the features do the placing. + */ +public final class FeaturePopulator implements Populator +{ + private final List specs; + private final FeatureRegistry registry; + + public FeaturePopulator(final List specs, final FeatureRegistry registry) + { + this.specs = specs; + this.registry = registry; + } + + @Override + public void populate(final ChunkContext context, final LimitedRegion data) + { + + } +} diff --git a/src/main/java/me/totalfreedom/totalfreedommod/world/stage/FlatGenerator.java b/src/main/java/me/totalfreedom/totalfreedommod/world/stage/FlatGenerator.java new file mode 100644 index 000000000..595e514d2 --- /dev/null +++ b/src/main/java/me/totalfreedom/totalfreedommod/world/stage/FlatGenerator.java @@ -0,0 +1,35 @@ +package me.totalfreedom.totalfreedommod.world.stage; + +import org.bukkit.generator.ChunkGenerator; + +import me.totalfreedom.totalfreedommod.world.base.ChunkContext; +import me.totalfreedom.totalfreedommod.world.base.Generator; +import me.totalfreedom.totalfreedommod.world.profile.Bounds; +import me.totalfreedom.totalfreedommod.world.profile.LayerStack; + +/** + * Flat mode. Samples no noise at all and writes each layer as a single setRegion call. + */ +public final class FlatGenerator implements Generator +{ + private final LayerStack layers; + private final Bounds bounds; + + public FlatGenerator(final LayerStack layers, final Bounds bounds) + { + this.layers = layers; + this.bounds = bounds; + } + + @Override + public void generateBase(final ChunkContext context, final ChunkGenerator.ChunkData data) + { + + } + + @Override + public int surfaceHeight(final ChunkContext context, final int worldX, final int worldZ) + { + + } +} diff --git a/src/main/java/me/totalfreedom/totalfreedommod/world/stage/HeightmapGenerator.java b/src/main/java/me/totalfreedom/totalfreedommod/world/stage/HeightmapGenerator.java new file mode 100644 index 000000000..55415a79a --- /dev/null +++ b/src/main/java/me/totalfreedom/totalfreedommod/world/stage/HeightmapGenerator.java @@ -0,0 +1,54 @@ +package me.totalfreedom.totalfreedommod.world.stage; + +import org.bukkit.generator.ChunkGenerator; + +import me.totalfreedom.totalfreedommod.world.base.ChunkContext; +import me.totalfreedom.totalfreedommod.world.base.Generator; +import me.totalfreedom.totalfreedommod.world.noise.NoiseField; +import me.totalfreedom.totalfreedommod.world.profile.Bounds; +import me.totalfreedom.totalfreedommod.world.profile.Materials; +import me.totalfreedom.totalfreedommod.world.profile.Spline; + +/** + * The default mode. 2D noise through a spline, with rivers pulling height toward sea level. No + * overhangs, and it covers most of what a custom survival world wants. + *

+ * Sample on a grid and interpolate between the samples. Sampling every block is 98,304 positions + * per chunk, times however many octaves the noise has. + */ +public final class HeightmapGenerator implements Generator +{ + private final NoiseField terrain; + private final NoiseField river; + private final Spline spline; + private final Bounds bounds; + private final Materials materials; + private final double warp; + + public HeightmapGenerator(final NoiseField terrain, + final NoiseField river, + final Spline spline, + final Bounds bounds, + final Materials materials, + final double warp) + { + this.terrain = terrain; + this.river = river; + this.spline = spline; + this.bounds = bounds; + this.materials = materials; + this.warp = warp; + } + + @Override + public void generateBase(final ChunkContext context, final ChunkGenerator.ChunkData data) + { + + } + + @Override + public int surfaceHeight(final ChunkContext context, final int worldX, final int worldZ) + { + + } +} diff --git a/src/main/java/me/totalfreedom/totalfreedommod/world/stage/LayerDesigner.java b/src/main/java/me/totalfreedom/totalfreedommod/world/stage/LayerDesigner.java new file mode 100644 index 000000000..ec11dffcf --- /dev/null +++ b/src/main/java/me/totalfreedom/totalfreedommod/world/stage/LayerDesigner.java @@ -0,0 +1,36 @@ +package me.totalfreedom.totalfreedommod.world.stage; + +import org.bukkit.generator.ChunkGenerator; + +import me.totalfreedom.totalfreedommod.world.base.ChunkContext; +import me.totalfreedom.totalfreedommod.world.base.Designer; +import me.totalfreedom.totalfreedommod.world.profile.LayerStack; +import me.totalfreedom.totalfreedommod.world.profile.Materials; + +/** + * Flat mode's designer. The layer stack already named every block, so surface is a no-op and only + * bedrock gets written here. + */ +public final class LayerDesigner implements Designer +{ + private final LayerStack layers; + private final Materials materials; + + public LayerDesigner(final LayerStack layers, final Materials materials) + { + this.layers = layers; + this.materials = materials; + } + + @Override + public void surface(final ChunkContext context, final ChunkGenerator.ChunkData data) + { + + } + + @Override + public void bedrock(final ChunkContext context, final ChunkGenerator.ChunkData data) + { + + } +} diff --git a/src/main/java/me/totalfreedom/totalfreedommod/world/stage/NoiseCarver.java b/src/main/java/me/totalfreedom/totalfreedommod/world/stage/NoiseCarver.java new file mode 100644 index 000000000..182eb60d0 --- /dev/null +++ b/src/main/java/me/totalfreedom/totalfreedommod/world/stage/NoiseCarver.java @@ -0,0 +1,45 @@ +package me.totalfreedom.totalfreedommod.world.stage; + +import me.totalfreedom.totalfreedommod.world.base.Carver; +import me.totalfreedom.totalfreedommod.world.base.ChunkContext; +import me.totalfreedom.totalfreedommod.world.noise.NoiseField; + +/** + * Cuts caves and ravines wherever the noise goes past the threshold. + *

+ * Tighten the threshold as you get near the context's terrain height and cave mouths blend into the + * hillside instead of cutting a flat wall into it. + */ +public final class NoiseCarver implements Carver +{ + private final NoiseField noise; + private final double threshold; + private final int minY; + private final int maxY; + + public NoiseCarver(final NoiseField noise, final double threshold, final int minY, final int maxY) + { + this.noise = noise; + this.threshold = threshold; + this.minY = minY; + this.maxY = maxY; + } + + @Override + public boolean isCarved(final ChunkContext context, final int worldX, final int y, final int worldZ) + { + + } + + @Override + public int minY() + { + return this.minY; + } + + @Override + public int maxY() + { + return this.maxY; + } +} diff --git a/src/main/java/me/totalfreedom/totalfreedommod/world/stage/RuleDesigner.java b/src/main/java/me/totalfreedom/totalfreedommod/world/stage/RuleDesigner.java new file mode 100644 index 000000000..79df600c2 --- /dev/null +++ b/src/main/java/me/totalfreedom/totalfreedommod/world/stage/RuleDesigner.java @@ -0,0 +1,38 @@ +package me.totalfreedom.totalfreedommod.world.stage; + +import java.util.List; + +import org.bukkit.generator.ChunkGenerator; + +import me.totalfreedom.totalfreedommod.world.base.ChunkContext; +import me.totalfreedom.totalfreedommod.world.base.Designer; +import me.totalfreedom.totalfreedommod.world.profile.Materials; +import me.totalfreedom.totalfreedommod.world.profile.SurfaceRule; + +/** + * Applies the profile's surface rules. Walks each column down from the context's column top with a + * depth counter that resets on air gaps, so cave floors get their own treatment. + */ +public final class RuleDesigner implements Designer +{ + private final List rules; + private final Materials materials; + + public RuleDesigner(final List rules, final Materials materials) + { + this.rules = rules; + this.materials = materials; + } + + @Override + public void surface(final ChunkContext context, final ChunkGenerator.ChunkData data) + { + + } + + @Override + public void bedrock(final ChunkContext context, final ChunkGenerator.ChunkData data) + { + + } +} diff --git a/src/main/java/me/totalfreedom/totalfreedommod/world/stage/feature/BoulderFeature.java b/src/main/java/me/totalfreedom/totalfreedommod/world/stage/feature/BoulderFeature.java new file mode 100644 index 000000000..c2cfac7e1 --- /dev/null +++ b/src/main/java/me/totalfreedom/totalfreedommod/world/stage/feature/BoulderFeature.java @@ -0,0 +1,21 @@ +package me.totalfreedom.totalfreedommod.world.stage.feature; + +import org.bukkit.generator.LimitedRegion; + +import me.totalfreedom.totalfreedommod.world.base.ChunkContext; +import me.totalfreedom.totalfreedommod.world.profile.FeatureSpec; + +/** A rough blob sitting on the surface. Spec size is the radius. */ +public final class BoulderFeature implements Feature +{ + @Override + public void place(final ChunkContext context, + final LimitedRegion region, + final FeatureSpec spec, + final int x, + final int y, + final int z) + { + + } +} diff --git a/src/main/java/me/totalfreedom/totalfreedommod/world/stage/feature/LakeFeature.java b/src/main/java/me/totalfreedom/totalfreedommod/world/stage/feature/LakeFeature.java new file mode 100644 index 000000000..c14d7d33c --- /dev/null +++ b/src/main/java/me/totalfreedom/totalfreedommod/world/stage/feature/LakeFeature.java @@ -0,0 +1,26 @@ +package me.totalfreedom.totalfreedommod.world.stage.feature; + +import org.bukkit.generator.LimitedRegion; + +import me.totalfreedom.totalfreedommod.world.base.ChunkContext; +import me.totalfreedom.totalfreedommod.world.profile.FeatureSpec; + +/** + * A dip filled with fluid. Spec size is the radius. + *

+ * The only feature that removes blocks as well as placing them, so it needs to clear the bowl + * before it fills it. + */ +public final class LakeFeature implements Feature +{ + @Override + public void place(final ChunkContext context, + final LimitedRegion region, + final FeatureSpec spec, + final int x, + final int y, + final int z) + { + + } +} diff --git a/src/main/java/me/totalfreedom/totalfreedommod/world/stage/feature/PatchFeature.java b/src/main/java/me/totalfreedom/totalfreedommod/world/stage/feature/PatchFeature.java new file mode 100644 index 000000000..d549ca370 --- /dev/null +++ b/src/main/java/me/totalfreedom/totalfreedommod/world/stage/feature/PatchFeature.java @@ -0,0 +1,21 @@ +package me.totalfreedom.totalfreedommod.world.stage.feature; + +import org.bukkit.generator.LimitedRegion; + +import me.totalfreedom.totalfreedommod.world.base.ChunkContext; +import me.totalfreedom.totalfreedommod.world.profile.FeatureSpec; + +/** A scatter of blocks on the surface; flowers, grass, that sort of thing. Spec size is spread. */ +public final class PatchFeature implements Feature +{ + @Override + public void place(final ChunkContext context, + final LimitedRegion region, + final FeatureSpec spec, + final int x, + final int y, + final int z) + { + + } +} diff --git a/src/main/java/me/totalfreedom/totalfreedommod/world/stage/feature/TreeFeature.java b/src/main/java/me/totalfreedom/totalfreedommod/world/stage/feature/TreeFeature.java new file mode 100644 index 000000000..1daf45f4c --- /dev/null +++ b/src/main/java/me/totalfreedom/totalfreedommod/world/stage/feature/TreeFeature.java @@ -0,0 +1,36 @@ +package me.totalfreedom.totalfreedommod.world.stage.feature; + +import java.security.InvalidParameterException; +import java.util.HashMap; +import java.util.Map; + +import org.bukkit.Location; +import org.bukkit.Material; +import org.bukkit.Tag; +import org.bukkit.TreeType; +import org.bukkit.block.BlockType; +import org.bukkit.generator.LimitedRegion; + +import me.totalfreedom.totalfreedommod.world.base.ChunkContext; +import me.totalfreedom.totalfreedommod.world.profile.FeatureDetail; +import me.totalfreedom.totalfreedommod.world.profile.FeatureSpec; + +/** + * Grows a tree. The spec's block names the sapling, and the sapling picks the species, so + * oak_sapling grows an oak and spruce_sapling grows a spruce. + *

+ * Hands off to LimitedRegion#generateTree, which knows every vanilla tree shape and handles the + * canopy crossing a chunk border. + */ +public final class TreeFeature implements Feature +{ + @Override + public void place(final ChunkContext context, + final LimitedRegion region, + final FeatureDetail.Tree detail, + final int x, + final int y, + final int z) + { + } +} From c4ddef221510d250aef6455e72aa359f0a7adc00 Mon Sep 17 00:00:00 2001 From: Paldiu Date: Sun, 9 Aug 2026 10:28:22 -0500 Subject: [PATCH 06/32] adjustments --- .../totalfreedommod/util/FUtil.java | 8 +++ .../world/profile/LayerStack.java | 51 ++++++++++++++++-- .../world/profile/SurfaceRule.java | 2 +- .../world/stage/FeaturePopulator.java | 53 +++++++++++++++++++ .../world/stage/feature/BoulderFeature.java | 6 +-- .../world/stage/feature/Feature.java | 13 ----- .../world/stage/feature/FeatureRegistry.java | 39 ++++++++++++++ .../world/stage/feature/LakeFeature.java | 7 ++- .../world/stage/feature/OreFeature.java | 7 +-- .../world/stage/feature/PatchFeature.java | 6 +-- 10 files changed, 162 insertions(+), 30 deletions(-) create mode 100644 src/main/java/me/totalfreedom/totalfreedommod/world/stage/feature/FeatureRegistry.java diff --git a/src/main/java/me/totalfreedom/totalfreedommod/util/FUtil.java b/src/main/java/me/totalfreedom/totalfreedommod/util/FUtil.java index 1b38f6769..1b0928458 100644 --- a/src/main/java/me/totalfreedom/totalfreedommod/util/FUtil.java +++ b/src/main/java/me/totalfreedom/totalfreedommod/util/FUtil.java @@ -400,6 +400,14 @@ public static T getField(Object from, String name) return null; } + /** + * Interpolates between two points. + */ + public static final double lerp(final double progress, final double from, final double to) + { + return from * (1.0D - progress) + to * progress; + } + public static NamedTextColor randomChatColor() { return CHAT_COLOR_POOL.get(RANDOM.nextInt(CHAT_COLOR_POOL.size())); diff --git a/src/main/java/me/totalfreedom/totalfreedommod/world/profile/LayerStack.java b/src/main/java/me/totalfreedom/totalfreedommod/world/profile/LayerStack.java index ac6cbb0f1..152b482e1 100644 --- a/src/main/java/me/totalfreedom/totalfreedommod/world/profile/LayerStack.java +++ b/src/main/java/me/totalfreedom/totalfreedommod/world/profile/LayerStack.java @@ -1,5 +1,10 @@ package me.totalfreedom.totalfreedommod.world.profile; +import java.util.Arrays; +import java.util.Locale; +import java.util.stream.IntStream; + +import org.bukkit.Material; import org.bukkit.block.data.BlockData; /** @@ -23,36 +28,76 @@ private LayerStack(final BlockData[] blocks, final int[] heights) } /** + * @apiNote Uses bitwise operations instead of standard mathematical operators for efficiency. * @param spec e.g. {@code "16|stone|32|dirt|1|grass_block"}; the legacy comma form also works * @throws IllegalArgumentException if the spec is malformed, names an unknown block, or gives a * height below one */ public static LayerStack parse(final String spec) { + if (spec == null || spec.trim().isEmpty()) + throw new IllegalArgumentException("Spec cannot be empty"); + + String[] split = spec.split("[,|]"); + if ((split.length & 1) != 0) + throw new IllegalArgumentException("Invalid spec format. Expected pairs of height and material."); + + final int pairCount = split.length >> 1; // divides by 2 + final BlockData[] blocks = new BlockData[pairCount]; + final int[] heights = new int[pairCount]; + + IntStream.range(0, pairCount) + .forEach(i -> + { + final int heightIdx = i << 1; // i * 2 + final int materialIdx = heightIdx | 1; // (i * 2) + 1 + + heights[i] = Integer.parseInt(split[heightIdx].trim()); + + final Material mat = Material.valueOf(split[materialIdx].trim().toUpperCase(Locale.ROOT)); + blocks[i] = mat.createBlockData(); + }); + + final LayerStack stack = new LayerStack(blocks, heights); + if (stack.totalHeight() > 320) + { + throw new IllegalArgumentException(String.format( + "Total layer height (%d) exceeds Minecraft's maximum world height limit (384 blocks, Y=-64 to Y=320)", + stack.totalHeight() + )); + } + + return stack; } /** How many layers there are, bottom to top. */ public int size() { - + return heights.length; } /** @throws IndexOutOfBoundsException if the layer does not exist */ public BlockData blockAt(final int layer) { + if (layer < 0 || layer >= blocks.length) + throw new IndexOutOfBoundsException(); + return blocks[layer]; } /** @throws IndexOutOfBoundsException if the layer does not exist */ public int heightAt(final int layer) { + if (layer < 0 || layer >= heights.length) + throw new IndexOutOfBoundsException(); + return heights[layer]; } /** Total height of every layer combined. */ - public int totalHeight() + public final int totalHeight() { - + return Arrays.stream(heights).sum(); } } diff --git a/src/main/java/me/totalfreedom/totalfreedommod/world/profile/SurfaceRule.java b/src/main/java/me/totalfreedom/totalfreedommod/world/profile/SurfaceRule.java index 83693f222..6ba52abad 100644 --- a/src/main/java/me/totalfreedom/totalfreedommod/world/profile/SurfaceRule.java +++ b/src/main/java/me/totalfreedom/totalfreedommod/world/profile/SurfaceRule.java @@ -12,6 +12,6 @@ public record SurfaceRule(BiomeFilter biomes, Depth depth, BlockData block) { public boolean matches(final Biome biome, final int depth) { - + } } diff --git a/src/main/java/me/totalfreedom/totalfreedommod/world/stage/FeaturePopulator.java b/src/main/java/me/totalfreedom/totalfreedommod/world/stage/FeaturePopulator.java index 54ef1aa46..409ed7095 100644 --- a/src/main/java/me/totalfreedom/totalfreedommod/world/stage/FeaturePopulator.java +++ b/src/main/java/me/totalfreedom/totalfreedommod/world/stage/FeaturePopulator.java @@ -1,12 +1,16 @@ package me.totalfreedom.totalfreedommod.world.stage; import java.util.List; +import java.util.Random; +import org.bukkit.block.Biome; import org.bukkit.generator.LimitedRegion; import me.totalfreedom.totalfreedommod.world.base.ChunkContext; import me.totalfreedom.totalfreedommod.world.base.Populator; +import me.totalfreedom.totalfreedommod.world.profile.Anchor; import me.totalfreedom.totalfreedommod.world.profile.FeatureSpec; +import me.totalfreedom.totalfreedommod.world.profile.Palette; import me.totalfreedom.totalfreedommod.world.stage.feature.FeatureRegistry; /** @@ -27,6 +31,55 @@ public FeaturePopulator(final List specs, final FeatureRegistry reg @Override public void populate(final ChunkContext context, final LimitedRegion data) { + final Random random = context.getRandom(); + this.specs.forEach(spec -> this.roll(context, data, random, spec)); + } + + private void roll(final ChunkContext context, + final LimitedRegion data, + final Random random, + final FeatureSpec spec) + { + for (int attempt = 0; attempt < spec.rarity(); attempt++) + { + final int localX = random.nextInt(16); + final int localZ = random.nextInt(16); + final int worldX = context.worldX(localX); + final int worldZ = context.worldZ(localZ); + + if (!spec.appliesTo(this.biomeAt(context, worldX, worldZ))) + continue; + + final int y = spec.detail().anchor() == Anchor.SURFACE + ? context.columnTop(localX, localZ) + 1 + : spec.minY() + random.nextInt(spec.maxY() - spec.minY() + 1); + + if (y < spec.minY() || y > spec.maxY()) + continue; + + this.registry.place(context, data, spec.detail(), worldX, y, worldZ); + } + } + + /** + * Same temperature/humidity lookup {@link me.totalfreedom.totalfreedommod.world.adapter.ProfileBiomeProvider} + * uses. Duplicated rather than shared, since that class does not expose it as a static helper. + */ + private Biome biomeAt(final ChunkContext context, final int worldX, final int worldZ) + { + final Palette palette = context.getProfile().palette(); + final Palette.Climate climate = palette.climate(); + final int sampleX = (int) (worldX * climate.scale()); + final int sampleZ = (int) (worldZ * climate.scale()); + final double temperature = climate.temperature().sample(sampleX, sampleZ); + final double humidity = climate.humidity().sample(sampleX, sampleZ); + + return palette.biomes() + .stream() + .filter(band -> band.matches(temperature, humidity)) + .findFirst() + .map(Palette.BiomeBand::biome) + .orElse(palette.fallback()); } } diff --git a/src/main/java/me/totalfreedom/totalfreedommod/world/stage/feature/BoulderFeature.java b/src/main/java/me/totalfreedom/totalfreedommod/world/stage/feature/BoulderFeature.java index c2cfac7e1..7d5cb5ace 100644 --- a/src/main/java/me/totalfreedom/totalfreedommod/world/stage/feature/BoulderFeature.java +++ b/src/main/java/me/totalfreedom/totalfreedommod/world/stage/feature/BoulderFeature.java @@ -3,15 +3,15 @@ import org.bukkit.generator.LimitedRegion; import me.totalfreedom.totalfreedommod.world.base.ChunkContext; -import me.totalfreedom.totalfreedommod.world.profile.FeatureSpec; +import me.totalfreedom.totalfreedommod.world.profile.FeatureDetail; /** A rough blob sitting on the surface. Spec size is the radius. */ -public final class BoulderFeature implements Feature +public final class BoulderFeature implements Feature { @Override public void place(final ChunkContext context, final LimitedRegion region, - final FeatureSpec spec, + final FeatureDetail.Boulder detail, final int x, final int y, final int z) diff --git a/src/main/java/me/totalfreedom/totalfreedommod/world/stage/feature/Feature.java b/src/main/java/me/totalfreedom/totalfreedommod/world/stage/feature/Feature.java index 2044bdf69..d234decf9 100644 --- a/src/main/java/me/totalfreedom/totalfreedommod/world/stage/feature/Feature.java +++ b/src/main/java/me/totalfreedom/totalfreedommod/world/stage/feature/Feature.java @@ -20,17 +20,4 @@ public interface Feature { void place(ChunkContext context, LimitedRegion region, D detail, int x, int y, int z); - - /** - * Interpolates between two points. Written the precise way, {@code from*(1-t) + to*t}, so that - * a progress of exactly 1 returns exactly {@code to}. The shorter {@code from + t*(to-from)} - * rounds twice and can miss the far endpoint by an ulp. - *

- * Nothing today loops far enough to reach 1, but this is shared, and the next feature to use it - * should not have to loop a particular way to stay correct. - */ - default double lerp(final double progress, final double from, final double to) - { - return from * (1.0D - progress) + to * progress; - } } diff --git a/src/main/java/me/totalfreedom/totalfreedommod/world/stage/feature/FeatureRegistry.java b/src/main/java/me/totalfreedom/totalfreedommod/world/stage/feature/FeatureRegistry.java new file mode 100644 index 000000000..77fe317bd --- /dev/null +++ b/src/main/java/me/totalfreedom/totalfreedommod/world/stage/feature/FeatureRegistry.java @@ -0,0 +1,39 @@ +package me.totalfreedom.totalfreedommod.world.stage.feature; + +import org.bukkit.generator.LimitedRegion; + +import me.totalfreedom.totalfreedommod.world.base.ChunkContext; +import me.totalfreedom.totalfreedommod.world.profile.FeatureDetail; + +/** + * Dispatches a feature detail to the {@link Feature} that knows how to place it. + *

+ * One instance per chunk generator, shared across every chunk. The switch over the sealed + * {@link FeatureDetail} is exhaustive, so a new variant will not compile until this knows how to + * place it too. + */ +public final class FeatureRegistry +{ + private final OreFeature ore = new OreFeature(); + private final PatchFeature patch = new PatchFeature(); + private final LakeFeature lake = new LakeFeature(); + private final BoulderFeature boulder = new BoulderFeature(); + private final TreeFeature tree = new TreeFeature(); + + public void place(final ChunkContext context, + final LimitedRegion region, + final FeatureDetail detail, + final int x, + final int y, + final int z) + { + switch (detail) + { + case FeatureDetail.Ore d -> this.ore.place(context, region, d, x, y, z); + case FeatureDetail.Patch d -> this.patch.place(context, region, d, x, y, z); + case FeatureDetail.Lake d -> this.lake.place(context, region, d, x, y, z); + case FeatureDetail.Boulder d -> this.boulder.place(context, region, d, x, y, z); + case FeatureDetail.Tree d -> this.tree.place(context, region, d, x, y, z); + } + } +} diff --git a/src/main/java/me/totalfreedom/totalfreedommod/world/stage/feature/LakeFeature.java b/src/main/java/me/totalfreedom/totalfreedommod/world/stage/feature/LakeFeature.java index c14d7d33c..acead2d23 100644 --- a/src/main/java/me/totalfreedom/totalfreedommod/world/stage/feature/LakeFeature.java +++ b/src/main/java/me/totalfreedom/totalfreedommod/world/stage/feature/LakeFeature.java @@ -3,20 +3,19 @@ import org.bukkit.generator.LimitedRegion; import me.totalfreedom.totalfreedommod.world.base.ChunkContext; -import me.totalfreedom.totalfreedommod.world.profile.FeatureSpec; - +import me.totalfreedom.totalfreedommod.world.profile.FeatureDetail; /** * A dip filled with fluid. Spec size is the radius. *

* The only feature that removes blocks as well as placing them, so it needs to clear the bowl * before it fills it. */ -public final class LakeFeature implements Feature +public final class LakeFeature implements Feature { @Override public void place(final ChunkContext context, final LimitedRegion region, - final FeatureSpec spec, + final FeatureDetail.Lake detail, final int x, final int y, final int z) diff --git a/src/main/java/me/totalfreedom/totalfreedommod/world/stage/feature/OreFeature.java b/src/main/java/me/totalfreedom/totalfreedommod/world/stage/feature/OreFeature.java index b620bdc20..967ac8d7e 100644 --- a/src/main/java/me/totalfreedom/totalfreedommod/world/stage/feature/OreFeature.java +++ b/src/main/java/me/totalfreedom/totalfreedommod/world/stage/feature/OreFeature.java @@ -5,6 +5,7 @@ import org.bukkit.Material; import org.bukkit.generator.LimitedRegion; +import me.totalfreedom.totalfreedommod.util.FUtil; import me.totalfreedom.totalfreedommod.world.base.ChunkContext; import me.totalfreedom.totalfreedommod.world.profile.FeatureDetail; @@ -48,9 +49,9 @@ public void place(final ChunkContext context, { final double progress = step / (double) size; - final double centreX = lerp(progress, fromX, toX); - final double centreY = lerp(progress, fromY, toY); - final double centreZ = lerp(progress, fromZ, toZ); + final double centreX = FUtil.lerp(progress, fromX, toX); + final double centreY = FUtil.lerp(progress, fromY, toY); + final double centreZ = FUtil.lerp(progress, fromZ, toZ); // Swells to its widest halfway along and tapers back at both ends, so the vein has // pointed tips instead of blunt ones. The +1 keeps the thinnest step at least a block. diff --git a/src/main/java/me/totalfreedom/totalfreedommod/world/stage/feature/PatchFeature.java b/src/main/java/me/totalfreedom/totalfreedommod/world/stage/feature/PatchFeature.java index d549ca370..9e93fa6f1 100644 --- a/src/main/java/me/totalfreedom/totalfreedommod/world/stage/feature/PatchFeature.java +++ b/src/main/java/me/totalfreedom/totalfreedommod/world/stage/feature/PatchFeature.java @@ -3,15 +3,15 @@ import org.bukkit.generator.LimitedRegion; import me.totalfreedom.totalfreedommod.world.base.ChunkContext; -import me.totalfreedom.totalfreedommod.world.profile.FeatureSpec; +import me.totalfreedom.totalfreedommod.world.profile.FeatureDetail; /** A scatter of blocks on the surface; flowers, grass, that sort of thing. Spec size is spread. */ -public final class PatchFeature implements Feature +public final class PatchFeature implements Feature { @Override public void place(final ChunkContext context, final LimitedRegion region, - final FeatureSpec spec, + final FeatureDetail.Patch detail, final int x, final int y, final int z) From f3f8b6f75237bc3c6cfce65a7df41d9c5bc7bc2d Mon Sep 17 00:00:00 2001 From: Paldiu Date: Sun, 9 Aug 2026 11:35:55 -0500 Subject: [PATCH 07/32] I think i got this now lowkey its all coming together --- .../totalfreedommod/PluginProvider.java | 3 +- .../world/adapter/SpawnFinder.java | 86 ++++++++++++++++++- .../world/base/ChunkContext.java | 2 +- .../totalfreedommod/world/base/Generator.java | 8 +- .../world/noise/NoiseField.java | 53 +++++++++++- .../totalfreedommod/world/profile/Spline.java | 71 ++++++++++++++- .../world/stage/DensityGenerator.java | 2 +- .../world/stage/FlatGenerator.java | 2 +- .../world/stage/HeightmapGenerator.java | 2 +- 9 files changed, 212 insertions(+), 17 deletions(-) diff --git a/src/main/java/me/totalfreedom/totalfreedommod/PluginProvider.java b/src/main/java/me/totalfreedom/totalfreedommod/PluginProvider.java index 210684830..56996aa98 100644 --- a/src/main/java/me/totalfreedom/totalfreedommod/PluginProvider.java +++ b/src/main/java/me/totalfreedom/totalfreedommod/PluginProvider.java @@ -37,8 +37,7 @@ public static void unbind() * caching the variable as in `var plugin = PluginProvider.get()` like in FCommand is fine, since the bind never changes during lifetime * and binding is the first thing that happens in the plugins lifecycle in onLoad() and everything else is registered and initialized in the onEnable() method. * - * This however should be avoided moving forward; I will replace plugin and server variable with overload getters that just return this get method instead. - * There are many locations in the code where this occurs, FCommand is the only one thats feasibly fixable in this scope. + * This however should be avoided moving forward. * * @return the live plugin instance. * @throws IllegalStateException if the plugin is not currently bound. diff --git a/src/main/java/me/totalfreedom/totalfreedommod/world/adapter/SpawnFinder.java b/src/main/java/me/totalfreedom/totalfreedommod/world/adapter/SpawnFinder.java index 6aff046fc..8a6217f44 100644 --- a/src/main/java/me/totalfreedom/totalfreedommod/world/adapter/SpawnFinder.java +++ b/src/main/java/me/totalfreedom/totalfreedommod/world/adapter/SpawnFinder.java @@ -1,27 +1,107 @@ package me.totalfreedom.totalfreedommod.world.adapter; +import java.util.Optional; + import org.bukkit.Location; import org.bukkit.World; import me.totalfreedom.totalfreedommod.world.GenerationProfile; +import me.totalfreedom.totalfreedommod.world.base.Generator; /** * Picks a spawn point by asking the generator's height function. *

- * Loads no chunks, since that function is pure maths. + * Loads no chunks, since that function is pure maths, which is what lets it check hundreds of + * candidates in the time a single chunk load would take. The cleanroom generator it replaces loaded + * chunk (0, 0) on the main thread during world creation just to find one column. */ public final class SpawnFinder { + /** How far out to search before giving up, in blocks. */ + private static final int RANGE = 512; + + /** Distance between candidates. Fine enough to find a coast, coarse enough to stay cheap. */ + private static final int STEP = 16; + private final GenerationProfile profile; + private final Generator generator; - public SpawnFinder(final GenerationProfile profile) + public SpawnFinder(final GenerationProfile profile, final Generator generator) { this.profile = profile; + this.generator = generator; } - /** Searches out from origin for the first column that is not underwater or void. */ + /** + * Spirals out from the origin for the first column that is above sea level and inside the + * world's bounds. + *

+ * Falls back to the origin at sea level if nothing qualifies, which happens for a world that is + * entirely ocean or entirely void. + */ public Location findSpawn(final World world) { + final int floor = this.profile.bounds().seaLevel().orElse(this.profile.bounds().minY()); + + for (int ring = 0; ring <= RANGE / STEP; ring++) + { + final Optional found = searchRing(world, ring, floor); + + if (found.isPresent()) + return found.get(); + } + + return new Location(world, 0.5D, floor + 1, 0.5D); + } + + /** + * Walks the edge of one square ring at this radius. + *

+ * Squares rather than circles because the point is to spread outward evenly, and a square ring + * is a single loop with no trigonometry. Ring zero is the origin itself. + */ + private Optional searchRing(final World world, final int ring, final int floor) + { + final int extent = ring * STEP; + + if (ring == 0) + return candidate(world, 0, 0, floor); + + for (int offset = -extent; offset <= extent; offset += STEP) + { + final Optional north = candidate(world, offset, -extent, floor); + + if (north.isPresent()) + return north; + + final Optional south = candidate(world, offset, extent, floor); + + if (south.isPresent()) + return south; + + final Optional west = candidate(world, -extent, offset, floor); + + if (west.isPresent()) + return west; + + final Optional east = candidate(world, extent, offset, floor); + + if (east.isPresent()) + return east; + } + + return Optional.empty(); + } + + /** A column qualifies if its ground sits above the water line and below the world's ceiling. */ + private Optional candidate(final World world, final int x, final int z, final int floor) + { + final int height = this.generator.surfaceHeight(x, z); + + if (height <= floor || height >= this.profile.bounds().maxY()) + return Optional.empty(); + // Centred in the block and one above the ground, so the player is not standing inside it. + return Optional.of(new Location(world, x + 0.5D, height + 1, z + 0.5D)); } } diff --git a/src/main/java/me/totalfreedom/totalfreedommod/world/base/ChunkContext.java b/src/main/java/me/totalfreedom/totalfreedommod/world/base/ChunkContext.java index 239068166..ff93dbafe 100644 --- a/src/main/java/me/totalfreedom/totalfreedommod/world/base/ChunkContext.java +++ b/src/main/java/me/totalfreedom/totalfreedommod/world/base/ChunkContext.java @@ -124,7 +124,7 @@ private int[] computeTerrainHeights() final Generator generator = this.stages.generator(); IntStream.range(0, 256) - .forEach(i -> heights[i] = generator.surfaceHeight(this, this.worldX(i & 0xF), this.worldZ(i >> 4))); + .forEach(i -> heights[i] = generator.surfaceHeight(this.worldX(i & 0xF), this.worldZ(i >> 4))); return heights; } diff --git a/src/main/java/me/totalfreedom/totalfreedommod/world/base/Generator.java b/src/main/java/me/totalfreedom/totalfreedommod/world/base/Generator.java index be8df86f0..c023e60c9 100644 --- a/src/main/java/me/totalfreedom/totalfreedommod/world/base/Generator.java +++ b/src/main/java/me/totalfreedom/totalfreedommod/world/base/Generator.java @@ -25,10 +25,12 @@ public interface Generator void generateBase(ChunkContext context, ChunkGenerator.ChunkData data); /** - * Terrain height at a world position, before carving. Pure, no chunk access. + * Terrain height at a world position, before carving. Pure, no chunk access, and no context: the + * spawn finder calls this at world creation, before any chunk exists to build one from. *

- * Backs getBaseHeight, the spawn finder, and the context's column heights. Must agree with what + * Backs getBaseHeight, the spawn finder, and the context's own column heights, which is why an + * implementation must not read those back through a context. Must agree with what * {@link #generateBase} writes or spawn lands in mid-air. */ - int surfaceHeight(ChunkContext context, int worldX, int worldZ); + int surfaceHeight(int worldX, int worldZ); } diff --git a/src/main/java/me/totalfreedom/totalfreedommod/world/noise/NoiseField.java b/src/main/java/me/totalfreedom/totalfreedommod/world/noise/NoiseField.java index 4f80862d0..b9532ed3c 100644 --- a/src/main/java/me/totalfreedom/totalfreedommod/world/noise/NoiseField.java +++ b/src/main/java/me/totalfreedom/totalfreedommod/world/noise/NoiseField.java @@ -1,6 +1,10 @@ package me.totalfreedom.totalfreedommod.world.noise; +import java.util.Random; + import org.bukkit.util.noise.OctaveGenerator; +import org.bukkit.util.noise.PerlinOctaveGenerator; +import org.bukkit.util.noise.SimplexOctaveGenerator; /** * A built noise field. Immutable, sampled concurrently from every worldgen thread. @@ -13,11 +17,13 @@ public final class NoiseField { private final NoiseProfile profile; private final OctaveGenerator generator; + private final double normaliser; - private NoiseField(final NoiseProfile profile, final OctaveGenerator generator) + private NoiseField(final NoiseProfile profile, final OctaveGenerator generator, final double normaliser) { this.profile = profile; this.generator = generator; + this.normaliser = normaliser; } /** @@ -26,23 +32,68 @@ private NoiseField(final NoiseProfile profile, final OctaveGenerator generator) */ public static NoiseField of(final NoiseProfile profile, final long seed, final String role) { + final Random source = new Random(seed ^ role.hashCode()); + + final OctaveGenerator generator = switch (profile.type()) + { + case PERLIN -> new PerlinOctaveGenerator(source, profile.octaves()); + case SIMPLEX -> new SimplexOctaveGenerator(source, profile.octaves()); + }; + generator.setScale(profile.frequency()); + + return new NoiseField(profile, generator, normaliserFor(profile)); } /** 2D sample, in the range -1 to 1. */ public double sample(final int x, final int z) { + final double raw = this.generator.noise(x, 0.0D, z, profile.lacunarity(), profile.persistence(), false); + return shape(raw / this.normaliser); } /** 3D sample, in the range -1 to 1. */ public double sample(final int x, final int y, final int z) { + final double raw = this.generator.noise(x, y, z, profile.lacunarity(), profile.persistence(), false); + return shape(raw / this.normaliser); } public NoiseProfile getProfile() { return this.profile; } + + /** + * Bukkit sums its octaves without scaling them back down, so a four octave field at persistence + * 0.5 returns roughly plus or minus 1.875 rather than 1. Dividing by the summed amplitudes is + * what puts it back in range. + */ + private static double normaliserFor(final NoiseProfile profile) + { + double total = 0.0D; + double amplitude = 1.0D; + + for (int octave = 0; octave < profile.octaves(); octave++) + { + total += amplitude; + amplitude *= profile.persistence(); + } + + return total; + } + + /** + * Ridged noise folds the field about zero and inverts it, turning the smooth peaks into sharp + * ones. Good for mountain ridges and for cave tunnels. + */ + private double shape(final double normalised) + { + if (!this.profile.ridged()) + return normalised; + + return 1.0D - Math.abs(normalised) * 2.0D; + } } diff --git a/src/main/java/me/totalfreedom/totalfreedommod/world/profile/Spline.java b/src/main/java/me/totalfreedom/totalfreedommod/world/profile/Spline.java index a6541f2e6..543dfab3e 100644 --- a/src/main/java/me/totalfreedom/totalfreedommod/world/profile/Spline.java +++ b/src/main/java/me/totalfreedom/totalfreedommod/world/profile/Spline.java @@ -1,27 +1,90 @@ package me.totalfreedom.totalfreedommod.world.profile; +import java.util.Arrays; import java.util.List; +import me.totalfreedom.totalfreedommod.util.FUtil; + /** * Maps raw noise to a terrain height through control points. Binary search plus a lerp per column. *

* Plateaus, cliffs, and flat plains all come out of one array of points. */ -public record Spline(double[] inputs, double[] outputs) +public final class Spline { + private final double[] inputs; + private final double[] outputs; + + private Spline(final double[] inputs, final double[] outputs) + { + this.inputs = inputs; + this.outputs = outputs; + } + /** * @param points {@code [noise, height]} pairs - * @throws IllegalArgumentException if fewer than two points, or the noise values are not - * strictly ascending, which would make the search ambiguous + * @throws IllegalArgumentException if fewer than two points, a point is not a pair, or the + * noise values are not strictly ascending, which would make + * the search ambiguous */ public static Spline of(final List points) { + if (points == null || points.size() < 2) + throw new IllegalArgumentException("A spline needs at least two points"); + + final double[] inputs = new double[points.size()]; + final double[] outputs = new double[points.size()]; + for (int i = 0; i < points.size(); i++) + { + final double[] point = points.get(i); + + if (point == null || point.length != 2) + throw new IllegalArgumentException("Point " + i + " must be a [noise, height] pair"); + + if (i > 0 && point[0] <= inputs[i - 1]) + throw new IllegalArgumentException("Point " + i + " noise (" + point[0] + + ") must be above the previous point's (" + inputs[i - 1] + ")"); + + inputs[i] = point[0]; + outputs[i] = point[1]; + } + + return new Spline(inputs, outputs); } - /** Clamps to the first and last point outside their range. */ + /** + * The height this noise maps to. Clamps to the first and last point outside their range, so + * noise beyond the outermost control points flattens off rather than running away. + */ public double apply(final double noise) { + final int last = this.inputs.length - 1; + + if (noise <= this.inputs[0]) + return this.outputs[0]; + + if (noise >= this.inputs[last]) + return this.outputs[last]; + + final int found = Arrays.binarySearch(this.inputs, noise); + + if (found >= 0) + return this.outputs[found]; + // A miss returns -(insertion point) - 1, and the insertion point is the first control point + // above the noise, so the segment we want is the one ending there. + final int upper = -(found + 1); + final int lower = upper - 1; + + final double progress = (noise - this.inputs[lower]) / (this.inputs[upper] - this.inputs[lower]); + + return FUtil.lerp(progress, this.outputs[lower], this.outputs[upper]); + } + + /** How many control points there are. */ + public int size() + { + return this.inputs.length; } } diff --git a/src/main/java/me/totalfreedom/totalfreedommod/world/stage/DensityGenerator.java b/src/main/java/me/totalfreedom/totalfreedommod/world/stage/DensityGenerator.java index e3e63d8cc..42a0206a5 100644 --- a/src/main/java/me/totalfreedom/totalfreedommod/world/stage/DensityGenerator.java +++ b/src/main/java/me/totalfreedom/totalfreedommod/world/stage/DensityGenerator.java @@ -35,7 +35,7 @@ public void generateBase(final ChunkContext context, final ChunkGenerator.ChunkD } @Override - public int surfaceHeight(final ChunkContext context, final int worldX, final int worldZ) + public int surfaceHeight(final int worldX, final int worldZ) { } diff --git a/src/main/java/me/totalfreedom/totalfreedommod/world/stage/FlatGenerator.java b/src/main/java/me/totalfreedom/totalfreedommod/world/stage/FlatGenerator.java index 595e514d2..862203c58 100644 --- a/src/main/java/me/totalfreedom/totalfreedommod/world/stage/FlatGenerator.java +++ b/src/main/java/me/totalfreedom/totalfreedommod/world/stage/FlatGenerator.java @@ -28,7 +28,7 @@ public void generateBase(final ChunkContext context, final ChunkGenerator.ChunkD } @Override - public int surfaceHeight(final ChunkContext context, final int worldX, final int worldZ) + public int surfaceHeight(final int worldX, final int worldZ) { } diff --git a/src/main/java/me/totalfreedom/totalfreedommod/world/stage/HeightmapGenerator.java b/src/main/java/me/totalfreedom/totalfreedommod/world/stage/HeightmapGenerator.java index 55415a79a..32ce2b311 100644 --- a/src/main/java/me/totalfreedom/totalfreedommod/world/stage/HeightmapGenerator.java +++ b/src/main/java/me/totalfreedom/totalfreedommod/world/stage/HeightmapGenerator.java @@ -47,7 +47,7 @@ public void generateBase(final ChunkContext context, final ChunkGenerator.ChunkD } @Override - public int surfaceHeight(final ChunkContext context, final int worldX, final int worldZ) + public int surfaceHeight(final int worldX, final int worldZ) { } From b01bc96633737d9593054883a2807c3c829cad83 Mon Sep 17 00:00:00 2001 From: Paldiu Date: Sun, 9 Aug 2026 11:41:31 -0500 Subject: [PATCH 08/32] Incorrectly stated that reading columnTop deadlocks Instead of deadlocking, what actually happens is that since synchronized() is reentrant, then calling get() from the same monitor would just reenter the lock, which would effectively recursively call until a StackOverflowError, not a deadlock while other monitors await the release. Updated comments to appropriately describe that instead of incorrectly classifying it as a classic deadlock. --- .../java/me/totalfreedom/totalfreedommod/util/Lazy.java | 7 +++++-- .../me/totalfreedom/totalfreedommod/world/base/Carver.java | 2 +- 2 files changed, 6 insertions(+), 3 deletions(-) diff --git a/src/main/java/me/totalfreedom/totalfreedommod/util/Lazy.java b/src/main/java/me/totalfreedom/totalfreedommod/util/Lazy.java index 120f6e6c4..300a8de9d 100644 --- a/src/main/java/me/totalfreedom/totalfreedommod/util/Lazy.java +++ b/src/main/java/me/totalfreedom/totalfreedommod/util/Lazy.java @@ -9,8 +9,11 @@ * {@link #get()}. Every call after that hands back the same value, and the supplier is never run * again, including when it returned null. *

- * Safe to share between threads. Do not call {@link #get()} from inside the supplier though, since - * it will deadlock on the lock the first call is already holding. + * Safe to share between threads. Do not call {@link #get()} from inside the supplier though. + * {@code synchronized} is reentrant on the thread already holding it, so this will not deadlock; + * instead the supplier calls itself, {@code initialized} is still false each time, and it recurses + * until the stack overflows, all while holding the monitor and blocking every other thread's call + * to {@link #get()} for as long as that takes. * * @param the type being worked out */ diff --git a/src/main/java/me/totalfreedom/totalfreedommod/world/base/Carver.java b/src/main/java/me/totalfreedom/totalfreedommod/world/base/Carver.java index db0f38134..6ca8ab5eb 100644 --- a/src/main/java/me/totalfreedom/totalfreedommod/world/base/Carver.java +++ b/src/main/java/me/totalfreedom/totalfreedommod/world/base/Carver.java @@ -21,7 +21,7 @@ public interface Carver *

* Reading the context's terrain height is fine, and tightening the threshold as you get near it * blends cave mouths into the hillside. Do not read the column top, though; that is worked out - * by calling this method, so you will deadlock. + * by calling this method, so you will recurse into yourself until the stack overflows. */ boolean isCarved(ChunkContext context, int worldX, int y, int worldZ); From 287c230f76c54cc6d5f373c64943d26ee1cb2086 Mon Sep 17 00:00:00 2001 From: Paldiu Date: Sun, 9 Aug 2026 12:53:35 -0500 Subject: [PATCH 09/32] Finish features --- .../world/stage/feature/BoulderFeature.java | 67 ++++++++++++++++++- .../world/stage/feature/LakeFeature.java | 48 ++++++++++++- .../world/stage/feature/PatchFeature.java | 49 +++++++++++++- 3 files changed, 159 insertions(+), 5 deletions(-) diff --git a/src/main/java/me/totalfreedom/totalfreedommod/world/stage/feature/BoulderFeature.java b/src/main/java/me/totalfreedom/totalfreedommod/world/stage/feature/BoulderFeature.java index 7d5cb5ace..5a5dde54d 100644 --- a/src/main/java/me/totalfreedom/totalfreedommod/world/stage/feature/BoulderFeature.java +++ b/src/main/java/me/totalfreedom/totalfreedommod/world/stage/feature/BoulderFeature.java @@ -1,13 +1,24 @@ package me.totalfreedom.totalfreedommod.world.stage.feature; +import java.util.Random; + import org.bukkit.generator.LimitedRegion; import me.totalfreedom.totalfreedommod.world.base.ChunkContext; import me.totalfreedom.totalfreedommod.world.profile.FeatureDetail; -/** A rough blob sitting on the surface. Spec size is the radius. */ +/** + * A rough blob resting on the ground. Detail radius is how wide it is. + *

+ * Built from a few overlapping spheres rather than one, which is what keeps it from reading as a + * ball someone dropped. Each sphere is nudged off the last so the shape comes out lopsided. + *

+ * Sunk one block into the ground on purpose, so it looks embedded rather than balanced. + */ public final class BoulderFeature implements Feature { + private static final int LOBES = 3; + @Override public void place(final ChunkContext context, final LimitedRegion region, @@ -16,6 +27,60 @@ public void place(final ChunkContext context, final int y, final int z) { + final Random random = context.getRandom(); + final int radius = detail.radius(); + + int lobeX = x; + int lobeY = y - 1; + int lobeZ = z; + + for (int lobe = 0; lobe < LOBES; lobe++) + { + // Later lobes are smaller, so the boulder tapers instead of growing arms. + final int lobeRadius = Math.max(1, radius - lobe); + + sphere(region, detail, lobeX, lobeY, lobeZ, lobeRadius); + + lobeX += random.nextInt(radius + 1) - radius / 2; + lobeY += random.nextInt(2); + lobeZ += random.nextInt(radius + 1) - radius / 2; + } + } + + /** + * Fills a sphere, overwriting whatever is already there. + *

+ * Unlike an ore vein this does not check what it is replacing, because a boulder sits on the + * surface and is meant to bury the grass under it. + */ + private static void sphere(final LimitedRegion region, + final FeatureDetail.Boulder detail, + final int centreX, + final int centreY, + final int centreZ, + final int radius) + { + final int squared = radius * radius; + + for (int offsetX = -radius; offsetX <= radius; offsetX++) + { + for (int offsetY = -radius; offsetY <= radius; offsetY++) + { + for (int offsetZ = -radius; offsetZ <= radius; offsetZ++) + { + if (offsetX * offsetX + offsetY * offsetY + offsetZ * offsetZ > squared) + continue; + + final int blockX = centreX + offsetX; + final int blockY = centreY + offsetY; + final int blockZ = centreZ + offsetZ; + + if (!region.isInRegion(blockX, blockY, blockZ)) + continue; + region.setBlockData(blockX, blockY, blockZ, detail.block()); + } + } + } } } diff --git a/src/main/java/me/totalfreedom/totalfreedommod/world/stage/feature/LakeFeature.java b/src/main/java/me/totalfreedom/totalfreedommod/world/stage/feature/LakeFeature.java index acead2d23..6b2144c65 100644 --- a/src/main/java/me/totalfreedom/totalfreedommod/world/stage/feature/LakeFeature.java +++ b/src/main/java/me/totalfreedom/totalfreedommod/world/stage/feature/LakeFeature.java @@ -1,17 +1,28 @@ package me.totalfreedom.totalfreedommod.world.stage.feature; +import org.bukkit.Material; import org.bukkit.generator.LimitedRegion; import me.totalfreedom.totalfreedommod.world.base.ChunkContext; import me.totalfreedom.totalfreedommod.world.profile.FeatureDetail; + /** - * A dip filled with fluid. Spec size is the radius. + * A dip filled with fluid. Detail radius is how wide the bowl is. + *

+ * The only feature that removes blocks as well as placing them, so it clears the bowl before it + * fills it. Everything else here is additive and can be reasoned about one block at a time; this + * one destroys terrain the designer already finished, and it will happily eat a hillside if it + * lands in one. *

- * The only feature that removes blocks as well as placing them, so it needs to clear the bowl - * before it fills it. + * Shaped as a squashed sphere, wider than it is deep, because a round hole reads as a crater. Only + * the lower half is filled; the upper half is cleared to air, which is what gives the water a bank + * instead of a lid. */ public final class LakeFeature implements Feature { + /** How much flatter the bowl is than it is wide. */ + private static final double SQUASH = 2.0D; + @Override public void place(final ChunkContext context, final LimitedRegion region, @@ -20,6 +31,37 @@ public void place(final ChunkContext context, final int y, final int z) { + final int radius = detail.radius(); + final int depth = Math.max(1, (int) Math.round(radius / SQUASH)); + + for (int offsetX = -radius; offsetX <= radius; offsetX++) + { + for (int offsetZ = -radius; offsetZ <= radius; offsetZ++) + { + for (int offsetY = -depth; offsetY <= depth; offsetY++) + { + final double reachX = offsetX / (double) radius; + final double reachY = offsetY / (double) depth; + final double reachZ = offsetZ / (double) radius; + + if (reachX * reachX + reachY * reachY + reachZ * reachZ > 1.0D) + continue; + + final int blockX = x + offsetX; + final int blockY = y + offsetY; + final int blockZ = z + offsetZ; + + if (!region.isInRegion(blockX, blockY, blockZ)) + continue; + // Fluid in the bottom half, air above it. Filling the whole bowl would seal the + // lake over and leave a block of water floating at head height. + if (offsetY <= 0) + region.setBlockData(blockX, blockY, blockZ, detail.fluid()); + else + region.setType(blockX, blockY, blockZ, Material.AIR); + } + } + } } } diff --git a/src/main/java/me/totalfreedom/totalfreedommod/world/stage/feature/PatchFeature.java b/src/main/java/me/totalfreedom/totalfreedommod/world/stage/feature/PatchFeature.java index 9e93fa6f1..87a7b8f6e 100644 --- a/src/main/java/me/totalfreedom/totalfreedommod/world/stage/feature/PatchFeature.java +++ b/src/main/java/me/totalfreedom/totalfreedommod/world/stage/feature/PatchFeature.java @@ -1,13 +1,28 @@ package me.totalfreedom.totalfreedommod.world.stage.feature; +import java.util.Random; + +import org.bukkit.Material; import org.bukkit.generator.LimitedRegion; import me.totalfreedom.totalfreedommod.world.base.ChunkContext; import me.totalfreedom.totalfreedommod.world.profile.FeatureDetail; -/** A scatter of blocks on the surface; flowers, grass, that sort of thing. Spec size is spread. */ +/** + * A scatter of blocks on the surface; flowers, grass, that sort of thing. Detail spread is how many + * placements it attempts, not how many it lands. + *

+ * Attempts are scattered on a gaussian around the origin rather than uniformly, so a patch thins + * out at its edges instead of stopping at a square border. + *

+ * Each attempt finds its own ground rather than reusing the origin's height, otherwise a patch on a + * slope would hang in the air on one side and bury itself on the other. + */ public final class PatchFeature implements Feature { + /** How far above and below the origin an attempt will look for ground. */ + private static final int SEARCH = 4; + @Override public void place(final ChunkContext context, final LimitedRegion region, @@ -16,6 +31,38 @@ public void place(final ChunkContext context, final int y, final int z) { + final Random random = context.getRandom(); + final double deviation = Math.max(1.0D, detail.spread() / 4.0D); + + for (int attempt = 0; attempt < detail.spread(); attempt++) + { + final int spotX = x + (int) Math.round(random.nextGaussian() * deviation); + final int spotZ = z + (int) Math.round(random.nextGaussian() * deviation); + + placeOne(region, detail, spotX, y, spotZ); + } + } + + /** Drops a single block onto whatever ground is nearest this column, if any is in reach. */ + private static void placeOne(final LimitedRegion region, + final FeatureDetail.Patch detail, + final int x, + final int y, + final int z) + { + for (int spotY = y + SEARCH; spotY >= y - SEARCH; spotY--) + { + if (!region.isInRegion(x, spotY, z) || !region.isInRegion(x, spotY - 1, z)) + continue; + + if (region.getType(x, spotY, z) != Material.AIR) + continue; + + if (!region.getType(x, spotY - 1, z).isSolid()) + continue; + region.setBlockData(x, spotY, z, detail.block()); + return; + } } } From 255a806338852c8d87fc15249b83a00dacfbc25f Mon Sep 17 00:00:00 2001 From: Paldiu Date: Mon, 10 Aug 2026 13:10:21 -0500 Subject: [PATCH 10/32] Change how biomes and trees are calculated Previously you had to manually define in each json file for each world what biomes and trees to include, and since there are tens to hundreds of biomes and a non-trivial handful of treetypes, this was wildly inefficient and required the server owner to both know all the biomes, write them out (which will make the json file unnecessarily long) and also the same for treetype which imo is ugly and bad. This system now dynamically infers treetype with zero per-world setup, and if you want to override a specific biome's trees (or anything else about it) you can write your own biome json (e.g. swamp.json) and point a world at it with a ref, though heads up the actual override logic in FeaturePopulator isn't wired up yet, just the schema/parser side. Additionally, we can now create custom logical biomes where we handle everything ourselves (terrain, surface, features) except invariable stuff like fog/ambient sound/mob tables, which just get mapped to whatever vanilla biome you pick for that. --- .../world/GenerationService.java | 7 +- .../world/adapter/ProfileBiomeProvider.java | 12 +- .../world/adapter/ProfileChunkGenerator.java | 8 +- .../world/profile/BiomeDefinition.java | 28 + .../world/profile/BiomeTarget.java | 37 + .../world/profile/FeatureDetail.java | 17 + .../world/profile/Palette.java | 25 +- .../world/profile/ProfileLoader.java | 147 ++- .../world/profile/ProfileParser.java | 1030 ++++++++++++++++- .../totalfreedommod/world/profile/Shape.java | 69 +- .../world/stage/DensityGenerator.java | 28 +- .../world/stage/FeaturePopulator.java | 28 +- .../world/stage/HeightmapGenerator.java | 32 +- .../world/stage/RuleDesigner.java | 4 + .../world/stage/feature/FeatureRegistry.java | 2 + .../stage/feature/NaturalTreeFeature.java | 121 ++ .../world/stage/feature/TreeFeature.java | 14 +- .../resources/worlds/flatlands-template.json | 11 +- .../resources/worlds/overworld-template.json | 6 +- 19 files changed, 1570 insertions(+), 56 deletions(-) create mode 100644 src/main/java/me/totalfreedom/totalfreedommod/world/profile/BiomeDefinition.java create mode 100644 src/main/java/me/totalfreedom/totalfreedommod/world/profile/BiomeTarget.java create mode 100644 src/main/java/me/totalfreedom/totalfreedommod/world/stage/feature/NaturalTreeFeature.java diff --git a/src/main/java/me/totalfreedom/totalfreedommod/world/GenerationService.java b/src/main/java/me/totalfreedom/totalfreedommod/world/GenerationService.java index d65b73e23..cafd88441 100644 --- a/src/main/java/me/totalfreedom/totalfreedommod/world/GenerationService.java +++ b/src/main/java/me/totalfreedom/totalfreedommod/world/GenerationService.java @@ -67,7 +67,12 @@ public Set available() } - /** Re-reads every profile file. Already-loaded worlds keep the profile they were built with. */ + /** + * Re-reads every profile file. Already-loaded worlds keep the profile they were built with. + *

+ * TODO: call {@code this.loader.biomeLibrary()} once per reload and reuse the result for every + * {@code this.parser.parse(...)} call, not once per world. + */ public void reload() { diff --git a/src/main/java/me/totalfreedom/totalfreedommod/world/adapter/ProfileBiomeProvider.java b/src/main/java/me/totalfreedom/totalfreedommod/world/adapter/ProfileBiomeProvider.java index 997ffe077..2b564cdb7 100644 --- a/src/main/java/me/totalfreedom/totalfreedommod/world/adapter/ProfileBiomeProvider.java +++ b/src/main/java/me/totalfreedom/totalfreedommod/world/adapter/ProfileBiomeProvider.java @@ -13,6 +13,10 @@ *

* Sets grass and water colour and what mobs spawn, and gives the designer's rules and the * populator's filters something to match against. + *

+ * A band's target may be a plain vanilla biome or a TFM-only one; either way this only ever hands + * Bukkit {@link me.totalfreedom.totalfreedommod.world.profile.BiomeTarget#display()}'s result, since + * that is the one thing the client and the server's own biome-driven systems can understand. */ public final class ProfileBiomeProvider extends BiomeProvider { @@ -23,13 +27,19 @@ public ProfileBiomeProvider(final GenerationProfile profile) this.profile = profile; } + /** TODO: {@code return this.profile.palette().resolveBiome(x, z); } once resolveBand is implemented. */ @Override public Biome getBiome(final WorldInfo worldInfo, final int x, final int y, final int z) { } - /** Must list every biome getBiome can return, or the server rejects the provider. */ + /** + * Must list every biome getBiome can return, or the server rejects the provider. + *

+ * TODO: collect every band's {@code target().display()} plus {@code palette.fallback()}, + * deduplicated. + */ @Override public List getBiomes(final WorldInfo worldInfo) { diff --git a/src/main/java/me/totalfreedom/totalfreedommod/world/adapter/ProfileChunkGenerator.java b/src/main/java/me/totalfreedom/totalfreedommod/world/adapter/ProfileChunkGenerator.java index 2deb32660..6aaa7c9dd 100644 --- a/src/main/java/me/totalfreedom/totalfreedommod/world/adapter/ProfileChunkGenerator.java +++ b/src/main/java/me/totalfreedom/totalfreedommod/world/adapter/ProfileChunkGenerator.java @@ -41,7 +41,13 @@ public ProfileChunkGenerator(final GenerationProfile profile) this.stages = wire(profile); } - /** Picks the stages for a profile's shape. The one place that switch is written. */ + /** + * Picks the stages for a profile's shape. The one place that switch is written. + *

+ * TODO: build each region's {@code NoiseField} via {@code NoiseField.of(noise, seed, role)}, with + * a distinct role string per region plus one for the selector. Reusing a role collapses two + * fields onto the same random stream. + */ private static Stages wire(final GenerationProfile profile) { diff --git a/src/main/java/me/totalfreedom/totalfreedommod/world/profile/BiomeDefinition.java b/src/main/java/me/totalfreedom/totalfreedommod/world/profile/BiomeDefinition.java new file mode 100644 index 000000000..784a8dd74 --- /dev/null +++ b/src/main/java/me/totalfreedom/totalfreedommod/world/profile/BiomeDefinition.java @@ -0,0 +1,28 @@ +package me.totalfreedom.totalfreedommod.world.profile; + +import java.util.List; +import java.util.Optional; + +import org.bukkit.block.Biome; + +/** + * A biome TFM controls entirely, with no datapack. + *

+ * display is the real vanilla biome the client renders: fog, sky colour, ambient sound, mob tables. + * Everything else about a Logical biome is TFM's own to decide. + *

+ * surface and features, when present, wholly replace the world's own lists for any column that + * resolves to this definition; they do not merge with them. A reader of one definition's file can + * answer "what spawns here" from that file alone, and merging would also risk placing a world-level + * feature twice onto a column whose display happens to match its filter. + * Absent means the column falls through to the world's plainly-filtered surface and features, the + * same as a {@link BiomeTarget.Vanilla} band. + *

+ * No identity field. A definition's identity is the filename it was loaded from, the same idiom + * {@link ProfileLoader} already uses for a world's own profile. + */ +public record BiomeDefinition(Biome display, + Optional> surface, + Optional> features) +{ +} diff --git a/src/main/java/me/totalfreedom/totalfreedommod/world/profile/BiomeTarget.java b/src/main/java/me/totalfreedom/totalfreedommod/world/profile/BiomeTarget.java new file mode 100644 index 000000000..e8ed04ae4 --- /dev/null +++ b/src/main/java/me/totalfreedom/totalfreedommod/world/profile/BiomeTarget.java @@ -0,0 +1,37 @@ +package me.totalfreedom.totalfreedommod.world.profile; + +import org.bukkit.block.Biome; + +/** + * What a {@link Palette.BiomeBand} actually resolves to: a plain vanilla biome, or a TFM-only one. + *

+ * Every consumer that only needs to know what the client sees, mob spawning included, can call + * {@link #display()} without caring which case it is. Only the designer and the populator need to + * know the difference, since a {@link Logical} biome's own surface and features, if it has them, + * replace the world's rather than adding to them. + */ +public sealed interface BiomeTarget +{ + /** Today's plain case. Whatever this names is exactly what the client sees. */ + record Vanilla(Biome biome) implements BiomeTarget + { + @Override + public Biome display() + { + return this.biome; + } + } + + /** A biome TFM controls entirely. See {@link BiomeDefinition}. */ + record Logical(BiomeDefinition definition) implements BiomeTarget + { + @Override + public Biome display() + { + return this.definition.display(); + } + } + + /** The vanilla biome the client renders: fog, sky colour, ambient sound, mob tables. */ + Biome display(); +} diff --git a/src/main/java/me/totalfreedom/totalfreedommod/world/profile/FeatureDetail.java b/src/main/java/me/totalfreedom/totalfreedommod/world/profile/FeatureDetail.java index b3cd48791..d6bcf0ffb 100644 --- a/src/main/java/me/totalfreedom/totalfreedommod/world/profile/FeatureDetail.java +++ b/src/main/java/me/totalfreedom/totalfreedommod/world/profile/FeatureDetail.java @@ -71,5 +71,22 @@ public Anchor anchor() } } + /** + * A tree grown as its biome's vanilla mix would choose it, rather than one type named exactly. + * Resolved against the actual biome at the placement site, not the entry's own biome filter, + * since a wide filter can still span several distinct mixes. A biome with no natural tree cover + * at all (desert, ocean, badlands) is simply skipped, not defaulted to oak. + *

+ * Use {@link Tree} instead to force one species regardless of the biome it lands in. + */ + record NaturalTree() implements FeatureDetail + { + @Override + public Anchor anchor() + { + return Anchor.SURFACE; + } + } + Anchor anchor(); } diff --git a/src/main/java/me/totalfreedom/totalfreedommod/world/profile/Palette.java b/src/main/java/me/totalfreedom/totalfreedommod/world/profile/Palette.java index b43164637..d6afc5690 100644 --- a/src/main/java/me/totalfreedom/totalfreedommod/world/profile/Palette.java +++ b/src/main/java/me/totalfreedom/totalfreedommod/world/profile/Palette.java @@ -1,6 +1,7 @@ package me.totalfreedom.totalfreedommod.world.profile; import java.util.List; +import java.util.Optional; import org.bukkit.block.Biome; @@ -28,6 +29,25 @@ public record Palette(Materials materials, biomes = List.copyOf(biomes); } + /** + * Samples this palette's climate at a world position and returns the band it lands in, if any. + *

+ * The one place every consumer that needs a position's biome should call through, rather than + * each re-deriving its own temperature/humidity lookup. + */ + public Optional resolveBand(final int worldX, final int worldZ) + { + throw new UnsupportedOperationException("not yet implemented"); + } + + /** Convenience over {@link #resolveBand}: the matched band's display biome, or this palette's fallback. */ + public Biome resolveBiome(final int worldX, final int worldZ) + { + return this.resolveBand(worldX, worldZ) + .map(band -> band.target().display()) + .orElse(this.fallback); + } + /** The two noise fields a position is scored against to land it in a band. */ public record Climate(NoiseField temperature, NoiseField humidity, double scale) { @@ -36,10 +56,13 @@ public record Climate(NoiseField temperature, NoiseField humidity, double scale) /** * One biome table entry. First band containing both values wins, and anything no band covers * gets the palette's fallback. + *

+ * target is either a plain vanilla biome or a TFM-only one; see {@link BiomeTarget}. Whichever it + * is, {@link BiomeTarget#display()} is what the client and the biome provider see. * * @throws IllegalArgumentException if either range is inverted or falls outside -1 to 1 */ - public record BiomeBand(Biome biome, + public record BiomeBand(BiomeTarget target, double minTemperature, double maxTemperature, double minHumidity, diff --git a/src/main/java/me/totalfreedom/totalfreedommod/world/profile/ProfileLoader.java b/src/main/java/me/totalfreedom/totalfreedommod/world/profile/ProfileLoader.java index e1e3e6f68..a1354fc05 100644 --- a/src/main/java/me/totalfreedom/totalfreedommod/world/profile/ProfileLoader.java +++ b/src/main/java/me/totalfreedom/totalfreedommod/world/profile/ProfileLoader.java @@ -1,18 +1,36 @@ package me.totalfreedom.totalfreedommod.world.profile; import java.io.File; +import java.io.FileReader; +import java.io.IOException; +import java.io.InputStream; +import java.io.Reader; +import java.net.URISyntaxException; +import java.nio.file.FileSystem; +import java.nio.file.FileSystems; +import java.nio.file.Files; +import java.nio.file.Path; +import java.util.Arrays; +import java.util.HashMap; +import java.util.List; +import java.util.Map; import java.util.Optional; import java.util.Set; +import java.util.stream.Collectors; +import java.util.stream.Stream; import com.google.gson.JsonObject; +import com.google.gson.JsonParser; +import com.google.gson.JsonSyntaxException; import me.totalfreedom.totalfreedommod.TotalFreedomMod; +import me.totalfreedom.totalfreedommod.util.FLog; /** * Reads the .json files in the data folder's worlds directory. A file's name is the world's name, * and every file in there is a world we manage. *

- * {@link #copyTemplate} is the only way a template would ever reach the disk, + * {@link #copyTemplate} is the only way a template would ever reach the disk, * and once it does it stops being a template and becomes that world's profile. *

* Only reads and parses JSON, so it is safe off the main thread. Turning that JSON into a profile @@ -21,20 +39,24 @@ public final class ProfileLoader { private static final String WORLDS_DIRECTORY = "worlds"; + private static final String BIOMES_DIRECTORY = "biomes"; + private static final String JSON_EXTENSION = ".json"; private final TotalFreedomMod plugin; private final File directory; + private final File biomeDirectory; public ProfileLoader(final TotalFreedomMod plugin) { this.plugin = plugin; this.directory = new File(plugin.getDataFolder(), WORLDS_DIRECTORY); + this.biomeDirectory = new File(this.directory, BIOMES_DIRECTORY); } /** Every world with a profile file on disk. */ public Set available() { - + return namesOf(this.directory); } /** @@ -44,13 +66,45 @@ public Set available() */ public Optional read(final String worldName) throws ProfileException { + final File file = new File(this.directory, worldName + JSON_EXTENSION); + + if (!file.isFile()) + return Optional.empty(); + return Optional.of(readDisk(file, worldName)); + } + + /** + * Every biome definition a profile can reference by name, bundled defaults first, then this + * server's own {@code worlds/biomes} directory overlaid on top of any same-named bundled one. + * + * @throws ProfileException if a file on disk exists but is not readable JSON; a broken bundled + * file is a packaging bug, not something an admin can fix, so it is + * logged and skipped instead + */ + public Map biomeLibrary() throws ProfileException + { + final Map library = new HashMap<>(readBundled(WORLDS_DIRECTORY + "/" + BIOMES_DIRECTORY)); + final File[] files = this.biomeDirectory.listFiles((dir, name) -> name.endsWith(JSON_EXTENSION)); + + if (files == null) + return library; + + for (final File file : files) + { + if (!file.isFile()) + continue; + + library.put(stripExtension(file.getName()), readDisk(file, BIOMES_DIRECTORY + "/" + file.getName())); + } + + return library; } /** Names of the templates bundled in the jar. Never worlds. */ public Set templates() { - + return readBundled(WORLDS_DIRECTORY).keySet(); } /** @@ -62,6 +116,93 @@ public Set templates() */ public boolean copyTemplate(final String templateName, final String worldName) { + final File target = new File(this.directory, worldName + JSON_EXTENSION); + + if (target.exists()) + return false; + + final String resourcePath = WORLDS_DIRECTORY + "/" + templateName + JSON_EXTENSION; + + try (final InputStream in = this.plugin.getResource(resourcePath)) + { + if (in == null) + return false; + + this.directory.mkdirs(); + Files.copy(in, target.toPath()); + return true; + } + catch (final IOException ex) + { + FLog.warning("Failed to copy template '" + templateName + "' to world '" + worldName + "': " + ex.getMessage()); + return false; + } + } + + /** Direct .json children of a data-folder directory, extension stripped. Never recurses. */ + private static Set namesOf(final File directory) + { + final File[] files = directory.listFiles((dir, name) -> name.endsWith(JSON_EXTENSION)); + + if (files == null) + return Set.of(); + + return Arrays.stream(files) + .filter(File::isFile) + .map(file -> stripExtension(file.getName())) + .collect(Collectors.toUnmodifiableSet()); + } + + /** One disk file, parsed. path is where the resulting ProfileError points if it fails. */ + private static JsonObject readDisk(final File file, final String path) throws ProfileException + { + try (final Reader reader = new FileReader(file)) + { + return JsonParser.parseReader(reader).getAsJsonObject(); + } + catch (final IOException | JsonSyntaxException | IllegalStateException ex) + { + throw new ProfileException(path, List.of(new ProfileError(path, ex.getMessage()))); + } + } + /** + * Every direct .json child of one directory bundled in the plugin jar, keyed by filename with + * the extension stripped. Never recurses, so walking "worlds" never picks up "worlds/biomes". + * A file that fails to parse is logged and skipped rather than failing the whole walk, since a + * broken bundled resource is a packaging bug an admin cannot fix by editing anything on disk. + */ + private Map readBundled(final String jarPath) + { + final Map result = new HashMap<>(); + + try (final FileSystem zipFs = FileSystems.newFileSystem(Path.of(this.plugin.getClass().getProtectionDomain().getCodeSource().getLocation().toURI())); + final Stream walk = Files.walk(zipFs.getPath("/" + jarPath), 1)) + { + walk.filter(Files::isRegularFile) + .filter(path -> path.getFileName().toString().endsWith(JSON_EXTENSION)) + .forEach(path -> + { + try (final Reader reader = Files.newBufferedReader(path)) + { + result.put(stripExtension(path.getFileName().toString()), JsonParser.parseReader(reader).getAsJsonObject()); + } + catch (final IOException | JsonSyntaxException | IllegalStateException ex) + { + FLog.warning("Failed to read bundled resource " + path + ": " + ex.getMessage()); + } + }); + } + catch (final IOException | URISyntaxException ex) + { + FLog.warning("Failed to walk bundled " + jarPath + " resources: " + ex.getMessage()); + } + + return result; + } + + private static String stripExtension(final String fileName) + { + return fileName.substring(0, fileName.length() - JSON_EXTENSION.length()); } } diff --git a/src/main/java/me/totalfreedom/totalfreedommod/world/profile/ProfileParser.java b/src/main/java/me/totalfreedom/totalfreedommod/world/profile/ProfileParser.java index 2deb32109..bfc742d0e 100644 --- a/src/main/java/me/totalfreedom/totalfreedommod/world/profile/ProfileParser.java +++ b/src/main/java/me/totalfreedom/totalfreedommod/world/profile/ProfileParser.java @@ -1,13 +1,36 @@ package me.totalfreedom.totalfreedommod.world.profile; +import java.util.ArrayList; +import java.util.HashSet; +import java.util.LinkedHashSet; +import java.util.List; +import java.util.Locale; +import java.util.Map; +import java.util.Optional; +import java.util.Set; +import java.util.function.BiFunction; +import java.util.function.Function; +import java.util.stream.IntStream; + +import org.bukkit.Material; +import org.bukkit.TreeType; +import org.bukkit.World; +import org.bukkit.block.Biome; +import org.bukkit.block.data.BlockData; + +import com.google.gson.JsonArray; +import com.google.gson.JsonElement; import com.google.gson.JsonObject; import me.totalfreedom.totalfreedommod.world.GenerationProfile; +import me.totalfreedom.totalfreedommod.world.noise.NoiseField; +import me.totalfreedom.totalfreedommod.world.noise.NoiseProfile; +import me.totalfreedom.totalfreedommod.world.noise.NoiseType; /** * Turns a profile file into a checked {@link GenerationProfile}, or explains why it cannot. *

- * A missing required key is an error; a missing optional one is {@code Optional.empty()}. + * A missing required key is an error; a missing optional one is {@code Optional.empty()}. * Nothing is quietly defaulted into something that generates the wrong terrain. *

* Collect every problem before giving up, so one run of the server tells an admin everything wrong @@ -15,15 +38,1016 @@ *

* Main thread only, since block and biome names are looked up here. Reading the file is not, so do * that first and hand the parsed JSON in. + *

+ * A {@link FeatureSpec}'s own {@code "type"} key is the variant discriminator, so no + * {@link FeatureDetail} variant's fields may reuse that name. */ public final class ProfileParser { /** - * @param worldName the file's name, which becomes the world's name + * @param worldName the file's name, which becomes the world's name + * @param biomeLibrary every biome definition a {@code {"ref": "..."}} may name, from + * {@link ProfileLoader#biomeLibrary()} * @throws ProfileException carrying every problem found, never just the first */ - public GenerationProfile parse(final String worldName, final JsonObject root) throws ProfileException + public GenerationProfile parse(final String worldName, final JsonObject root, final Map biomeLibrary) throws ProfileException + { + final List errors = new ArrayList<>(); + final long seed = resolveSeed(worldName, root); + + final Optional shape = parseShapeSection(root, errors); + final Optional palette = parsePalette(root, errors, seed, biomeLibrary); + final List features = optionalArray(root, "features", "", errors) + .map(array -> parseFeatures(array, "features", errors)) + .orElse(List.of()); + final Optional world = parseWorldSettings(root, errors); + + if (!errors.isEmpty()) + throw new ProfileException(worldName, errors); + + return new GenerationProfile(worldName, shape.get().bounds(), shape.get().shape(), palette.get(), features, world.get()); + } + + /** shape.bounds and shape's own mode-specific fields, parsed together since they share one JSON object. */ + private record ParsedShape(Bounds bounds, Shape shape) + { + } + + /** + * A pinned seed if world.seed is a valid number, else one derived from the world's own name, so + * re-parsing an unseeded profile after a restart still produces the same terrain. Resolved before + * anything else, since palette.climate needs a seed to build its NoiseFields. Never records an + * error itself; a malformed world.seed is reported properly later, by parseWorldSettings. + */ + private static long resolveSeed(final String worldName, final JsonObject root) + { + final JsonElement worldNode = root.get("world"); + + if (worldNode != null && worldNode.isJsonObject()) + { + final JsonElement seedElement = worldNode.getAsJsonObject().get("seed"); + + if (seedElement != null && seedElement.isJsonPrimitive() && seedElement.getAsJsonPrimitive().isNumber()) + { + try + { + return seedElement.getAsLong(); + } + catch (final NumberFormatException ignored) + { + // Falls through; parseWorldSettings reports the real problem against world.seed. + } + } + } + + return worldName.hashCode(); + } + + private static Optional parseShapeSection(final JsonObject root, final List errors) + { + final Optional node = requireObject(root, "shape", "", errors); + if (node.isEmpty()) + return Optional.empty(); + + final String path = "shape"; + final Optional bounds = parseBounds(node.get(), path, errors); + final Optional mode = requireString(node.get(), "mode", path, errors); + + if (bounds.isEmpty() || mode.isEmpty()) + return Optional.empty(); + + final Optional shape = switch (mode.get().toLowerCase(Locale.ROOT)) + { + case "flat" -> parseFlatShape(node.get(), path, errors); + case "heightmap" -> parseHeightmapShape(node.get(), path, errors); + case "density" -> parseDensityShape(node.get(), path, errors); + default -> + { + errors.add(new ProfileError(childPath(path, "mode"), "unknown mode \"" + mode.get() + "\"")); + yield Optional.empty(); + } + }; + + return shape.map(s -> new ParsedShape(bounds.get(), s)); + } + + private static Optional parseBounds(final JsonObject shapeNode, final String parentPath, final List errors) + { + final Optional node = requireObject(shapeNode, "bounds", parentPath, errors); + if (node.isEmpty()) + return Optional.empty(); + + final String path = childPath(parentPath, "bounds"); + final Optional minY = requireInt(node.get(), "minY", path, errors); + final Optional maxY = requireInt(node.get(), "maxY", path, errors); + final Optional seaLevel = optionalInt(node.get(), "seaLevel", path, errors); + + if (minY.isEmpty() || maxY.isEmpty()) + return Optional.empty(); + + try + { + return Optional.of(new Bounds(minY.get(), maxY.get(), seaLevel)); + } + catch (final IllegalArgumentException ex) + { + errors.add(new ProfileError(path, ex.getMessage())); + return Optional.empty(); + } + } + + private static Optional parseFlatShape(final JsonObject shapeNode, final String path, final List errors) + { + final Optional spec = requireString(shapeNode, "layers", path, errors); + if (spec.isEmpty()) + return Optional.empty(); + + try + { + return Optional.of(new Shape.Flat(LayerStack.parse(spec.get()))); + } + catch (final IllegalArgumentException ex) + { + errors.add(new ProfileError(childPath(path, "layers"), ex.getMessage())); + return Optional.empty(); + } + } + + private static Optional parseHeightmapShape(final JsonObject shapeNode, final String path, final List errors) + { + final Optional terrainNode = requireObject(shapeNode, "terrain", path, errors); + final Optional terrain = terrainNode.flatMap(node -> parseShapeTerrain(node, childPath(path, "terrain"), errors)); + + final boolean hasRiver = hasKey(shapeNode, "river"); + final Optional river = hasRiver ? parseRiver(shapeNode, path, errors) : Optional.empty(); + + final boolean hasCaves = hasKey(shapeNode, "caves"); + final Optional caves = hasCaves ? parseCaves(shapeNode, path, errors) : Optional.empty(); + + final boolean hasRegions = hasKey(shapeNode, "regions"); + final Optional> regions = + parseRegions(shapeNode, path, errors, (node, p) -> parseShapeTerrain(node, p, errors)); + + if (terrain.isEmpty() || (hasRiver && river.isEmpty()) || (hasCaves && caves.isEmpty()) || (hasRegions && regions.isEmpty())) + return Optional.empty(); + + return Optional.of(new Shape.Heightmap(terrain.get(), river, caves, regions)); + } + + private static Optional parseDensityShape(final JsonObject shapeNode, final String path, final List errors) + { + final Optional terrainNode = requireObject(shapeNode, "terrain", path, errors); + final Optional terrain = terrainNode.flatMap(node -> parseDensityLayer(node, childPath(path, "terrain"), errors)); + + final boolean hasCaves = hasKey(shapeNode, "caves"); + final Optional caves = hasCaves ? parseCaves(shapeNode, path, errors) : Optional.empty(); + + final boolean hasRegions = hasKey(shapeNode, "regions"); + final Optional> regions = + parseRegions(shapeNode, path, errors, (node, p) -> parseDensityLayer(node, p, errors)); + + if (terrain.isEmpty() || (hasCaves && caves.isEmpty()) || (hasRegions && regions.isEmpty())) + return Optional.empty(); + + return Optional.of(new Shape.Density(terrain.get().noise(), terrain.get().warp(), caves, regions)); + } + + private static Optional parseShapeTerrain(final JsonObject node, final String path, final List errors) + { + final Optional noiseNode = requireObject(node, "noise", path, errors); + final Optional noise = noiseNode.flatMap(n -> parseNoiseProfile(n, childPath(path, "noise"), errors)); + final Optional splineNode = requireObject(node, "spline", path, errors); + final Optional spline = splineNode.flatMap(n -> parseSpline(n, childPath(path, "spline"), errors)); + final Optional warp = requireDouble(node, "warp", path, errors); + + if (noise.isEmpty() || spline.isEmpty() || warp.isEmpty()) + return Optional.empty(); + + return Optional.of(new Shape.Terrain(noise.get(), spline.get(), warp.get())); + } + + private static Optional parseDensityLayer(final JsonObject node, final String path, final List errors) + { + final Optional noiseNode = requireObject(node, "noise", path, errors); + final Optional noise = noiseNode.flatMap(n -> parseNoiseProfile(n, childPath(path, "noise"), errors)); + final Optional warp = requireDouble(node, "warp", path, errors); + + if (noise.isEmpty() || warp.isEmpty()) + return Optional.empty(); + + return Optional.of(new Shape.DensityLayer(noise.get(), warp.get())); + } + + private static Optional parseRiver(final JsonObject parent, final String parentPath, final List errors) + { + final Optional node = requireObject(parent, "river", parentPath, errors); + if (node.isEmpty()) + return Optional.empty(); + + final String path = childPath(parentPath, "river"); + final Optional noiseNode = requireObject(node.get(), "noise", path, errors); + final Optional noise = noiseNode.flatMap(n -> parseNoiseProfile(n, childPath(path, "noise"), errors)); + final Optional threshold = requireDouble(node.get(), "threshold", path, errors); + final Optional depth = requireInt(node.get(), "depth", path, errors); + final Optional bedBlock = requireBlock(node.get(), "bedBlock", path, errors); + + if (noise.isEmpty() || threshold.isEmpty() || depth.isEmpty() || bedBlock.isEmpty()) + return Optional.empty(); + + return Optional.of(new Shape.River(noise.get(), threshold.get(), depth.get(), bedBlock.get())); + } + + private static Optional parseCaves(final JsonObject parent, final String parentPath, final List errors) + { + final Optional node = requireObject(parent, "caves", parentPath, errors); + if (node.isEmpty()) + return Optional.empty(); + + final String path = childPath(parentPath, "caves"); + final Optional noiseNode = requireObject(node.get(), "noise", path, errors); + final Optional noise = noiseNode.flatMap(n -> parseNoiseProfile(n, childPath(path, "noise"), errors)); + final Optional threshold = requireDouble(node.get(), "threshold", path, errors); + final Optional minY = requireInt(node.get(), "minY", path, errors); + final Optional maxY = requireInt(node.get(), "maxY", path, errors); + final Optional floodLevel = requireInt(node.get(), "floodLevel", path, errors); + + if (noise.isEmpty() || threshold.isEmpty() || minY.isEmpty() || maxY.isEmpty() || floodLevel.isEmpty()) + return Optional.empty(); + + try + { + return Optional.of(new Shape.Caves(noise.get(), threshold.get(), minY.get(), maxY.get(), floodLevel.get())); + } + catch (final IllegalArgumentException ex) + { + errors.add(new ProfileError(path, ex.getMessage())); + return Optional.empty(); + } + } + + /** Shared by Heightmap and Density; T is whichever terrain shape that mode's regions carry. */ + private static Optional> parseRegions(final JsonObject parent, final String parentPath, + final List errors, + final BiFunction> terrainParser) + { + final Optional node = requireObject(parent, "regions", parentPath, errors); + if (node.isEmpty()) + return Optional.empty(); + + final String path = childPath(parentPath, "regions"); + final Optional selectorNode = requireObject(node.get(), "selector", path, errors); + final Optional selector = selectorNode.flatMap(n -> parseNoiseProfile(n, childPath(path, "selector"), errors)); + final Optional blendWidth = requireDouble(node.get(), "blendWidth", path, errors); + final Optional regionArray = requireArray(node.get(), "regions", path, errors); + + if (selector.isEmpty() || blendWidth.isEmpty() || regionArray.isEmpty()) + return Optional.empty(); + + final List> regions = new ArrayList<>(); + final Set seenNames = new HashSet<>(); + final boolean[] valid = { true }; + + IntStream.range(0, regionArray.get().size()).forEach(i -> + { + final String regionPath = childPath(path, "regions") + "[" + i + "]"; + final JsonElement element = regionArray.get().get(i); + + if (!element.isJsonObject()) + { + errors.add(new ProfileError(regionPath, "expected an object, got \"" + element + "\"")); + valid[0] = false; + return; + } + + final JsonObject regionNode = element.getAsJsonObject(); + final Optional name = requireString(regionNode, "name", regionPath, errors); + final Optional min = requireDouble(regionNode, "min", regionPath, errors); + final Optional max = requireDouble(regionNode, "max", regionPath, errors); + final Optional terrainNode = requireObject(regionNode, "terrain", regionPath, errors); + final Optional terrain = terrainNode.flatMap(n -> terrainParser.apply(n, childPath(regionPath, "terrain"))); + + if (name.isEmpty() || min.isEmpty() || max.isEmpty() || terrain.isEmpty()) + { + valid[0] = false; + return; + } + + if (!seenNames.add(name.get())) + { + errors.add(new ProfileError(childPath(regionPath, "name"), "duplicate region name \"" + name.get() + "\"")); + valid[0] = false; + return; + } + + try + { + regions.add(new Shape.Region<>(name.get(), min.get(), max.get(), terrain.get())); + } + catch (final IllegalArgumentException ex) + { + errors.add(new ProfileError(regionPath, ex.getMessage())); + valid[0] = false; + } + }); + + if (!valid[0]) + return Optional.empty(); + + return Optional.of(new Shape.Regions<>(selector.get(), blendWidth.get(), regions)); + } + + private static Optional parseNoiseProfile(final JsonObject node, final String path, final List errors) + { + final Optional type = requireEnum(node, "type", path, errors, "noise type", NoiseType::valueOf); + final Optional octaves = requireInt(node, "octaves", path, errors); + final Optional frequency = requireDouble(node, "frequency", path, errors); + final Optional persistence = requireDouble(node, "persistence", path, errors); + final Optional lacunarity = requireDouble(node, "lacunarity", path, errors); + final Optional ridged = requireBoolean(node, "ridged", path, errors); + + if (type.isEmpty() || octaves.isEmpty() || frequency.isEmpty() || persistence.isEmpty() || lacunarity.isEmpty() || ridged.isEmpty()) + return Optional.empty(); + + return Optional.of(new NoiseProfile(type.get(), octaves.get(), frequency.get(), persistence.get(), lacunarity.get(), ridged.get())); + } + + private static Optional parseSpline(final JsonObject node, final String path, final List errors) + { + final Optional pointsArray = requireArray(node, "points", path, errors); + if (pointsArray.isEmpty()) + return Optional.empty(); + + final List points = new ArrayList<>(); + final boolean[] valid = { true }; + final String pointsPath = childPath(path, "points"); + + IntStream.range(0, pointsArray.get().size()).forEach(i -> + { + final JsonElement element = pointsArray.get().get(i); + + try + { + final JsonArray pair = element.getAsJsonArray(); + + if (pair.size() != 2) + throw new IllegalStateException("expected a [noise, height] pair"); + + points.add(new double[] { pair.get(0).getAsDouble(), pair.get(1).getAsDouble() }); + } + catch (final RuntimeException ex) + { + errors.add(new ProfileError(pointsPath + "[" + i + "]", "expected a [noise, height] pair, got \"" + element + "\"")); + valid[0] = false; + } + }); + + if (!valid[0]) + return Optional.empty(); + + try + { + return Optional.of(Spline.of(points)); + } + catch (final IllegalArgumentException ex) + { + errors.add(new ProfileError(pointsPath, ex.getMessage())); + return Optional.empty(); + } + } + + private static Optional parsePalette(final JsonObject root, final List errors, + final long seed, final Map biomeLibrary) + { + final Optional node = requireObject(root, "palette", "", errors); + if (node.isEmpty()) + return Optional.empty(); + + final String path = "palette"; + final Optional materials = parseMaterials(node.get(), path, errors); + final Optional surfaceArray = requireArray(node.get(), "surface", path, errors); + final List surface = surfaceArray.map(array -> parseSurfaceRules(array, childPath(path, "surface"), errors)).orElse(List.of()); + final Optional climate = parseClimate(node.get(), path, errors, seed); + final Optional fallback = requireBiome(node.get(), "fallback", path, errors); + + final List biomes = optionalArray(node.get(), "biomes", path, errors) + .map(array -> parseBiomeBands(array, childPath(path, "biomes"), errors, biomeLibrary)) + .orElse(List.of()); + + if (materials.isEmpty() || surfaceArray.isEmpty() || climate.isEmpty() || fallback.isEmpty()) + return Optional.empty(); + + try + { + return Optional.of(new Palette(materials.get(), surface, climate.get(), fallback.get(), biomes)); + } + catch (final IllegalArgumentException ex) + { + errors.add(new ProfileError(path, ex.getMessage())); + return Optional.empty(); + } + } + + private static Optional parseMaterials(final JsonObject paletteNode, final String parentPath, final List errors) + { + final Optional node = requireObject(paletteNode, "materials", parentPath, errors); + if (node.isEmpty()) + return Optional.empty(); + + final String path = childPath(parentPath, "materials"); + final Optional defaultBlock = requireBlock(node.get(), "defaultBlock", path, errors); + final Optional fluidBlock = requireBlock(node.get(), "fluidBlock", path, errors); + final Optional bedrockBlock = requireBlock(node.get(), "bedrockBlock", path, errors); + final Optional bedrock = requireEnum(node.get(), "bedrock", path, errors, "bedrock mode", BedrockMode::valueOf); + + if (defaultBlock.isEmpty() || fluidBlock.isEmpty() || bedrockBlock.isEmpty() || bedrock.isEmpty()) + return Optional.empty(); + + return Optional.of(new Materials(defaultBlock.get(), fluidBlock.get(), bedrockBlock.get(), bedrock.get())); + } + + private static Optional parseClimate(final JsonObject paletteNode, final String parentPath, + final List errors, final long seed) + { + final Optional node = requireObject(paletteNode, "climate", parentPath, errors); + if (node.isEmpty()) + return Optional.empty(); + + final String path = childPath(parentPath, "climate"); + final Optional temperatureNode = requireObject(node.get(), "temperature", path, errors); + final Optional temperature = temperatureNode.flatMap(n -> parseNoiseProfile(n, childPath(path, "temperature"), errors)); + final Optional humidityNode = requireObject(node.get(), "humidity", path, errors); + final Optional humidity = humidityNode.flatMap(n -> parseNoiseProfile(n, childPath(path, "humidity"), errors)); + final Optional scale = requireDouble(node.get(), "scale", path, errors); + + if (temperature.isEmpty() || humidity.isEmpty() || scale.isEmpty()) + return Optional.empty(); + + final NoiseField temperatureField = NoiseField.of(temperature.get(), seed, "climate-temperature"); + final NoiseField humidityField = NoiseField.of(humidity.get(), seed, "climate-humidity"); + + return Optional.of(new Palette.Climate(temperatureField, humidityField, scale.get())); + } + + private static List parseBiomeBands(final JsonArray array, final String path, final List errors, + final Map biomeLibrary) + { + final List bands = new ArrayList<>(); + + IntStream.range(0, array.size()).forEach(i -> + parseBiomeBand(array.get(i), path + "[" + i + "]", errors, biomeLibrary).ifPresent(bands::add)); + + return bands; + } + + private static Optional parseBiomeBand(final JsonElement element, final String path, final List errors, + final Map biomeLibrary) + { + if (!element.isJsonObject()) + { + errors.add(new ProfileError(path, "expected an object, got \"" + element + "\"")); + return Optional.empty(); + } + + final JsonObject node = element.getAsJsonObject(); + final Optional target = parseBiomeTarget(node, path, errors, biomeLibrary); + final Optional minTemperature = requireDouble(node, "minTemperature", path, errors); + final Optional maxTemperature = requireDouble(node, "maxTemperature", path, errors); + final Optional minHumidity = requireDouble(node, "minHumidity", path, errors); + final Optional maxHumidity = requireDouble(node, "maxHumidity", path, errors); + + if (target.isEmpty() || minTemperature.isEmpty() || maxTemperature.isEmpty() || minHumidity.isEmpty() || maxHumidity.isEmpty()) + return Optional.empty(); + + try + { + return Optional.of(new Palette.BiomeBand( + target.get(), minTemperature.get(), maxTemperature.get(), minHumidity.get(), maxHumidity.get())); + } + catch (final IllegalArgumentException ex) + { + errors.add(new ProfileError(path, ex.getMessage())); + return Optional.empty(); + } + } + + /** "biome": a string names a vanilla biome; an object is a Logical one, inline or by {"ref": "..."}. */ + private static Optional parseBiomeTarget(final JsonObject bandNode, final String parentPath, + final List errors, final Map biomeLibrary) + { + final JsonElement element = bandNode.get("biome"); + final String path = childPath(parentPath, "biome"); + + if (element == null || element.isJsonNull()) + { + errors.add(new ProfileError(path, "missing required key")); + return Optional.empty(); + } + + if (element.isJsonPrimitive()) + return parseBiome(element.getAsString(), path, errors).map(BiomeTarget.Vanilla::new); + + if (!element.isJsonObject()) + { + errors.add(new ProfileError(path, "expected a string or an object, got \"" + element + "\"")); + return Optional.empty(); + } + + final JsonObject biomeNode = element.getAsJsonObject(); + final boolean hasRef = hasKey(biomeNode, "ref"); + final boolean hasDisplay = hasKey(biomeNode, "display"); + + if (hasRef == hasDisplay) + { + errors.add(new ProfileError(path, hasRef + ? "must not specify both \"ref\" and \"display\"" + : "must specify exactly one of \"ref\" or \"display\"")); + return Optional.empty(); + } + + if (!hasRef) + return parseBiomeDefinition(biomeNode, path, errors, biomeLibrary).map(BiomeTarget.Logical::new); + + final Optional ref = requireString(biomeNode, "ref", path, errors); + if (ref.isEmpty()) + return Optional.empty(); + + final JsonObject definitionNode = biomeLibrary.get(ref.get()); + if (definitionNode == null) + { + errors.add(new ProfileError(childPath(path, "ref"), + "unknown biome \"" + ref.get() + "\", not found in the bundled or world biome library")); + return Optional.empty(); + } + + return parseBiomeDefinition(definitionNode, path + ".ref(" + ref.get() + ")", errors, biomeLibrary).map(BiomeTarget.Logical::new); + } + + private static Optional parseBiomeDefinition(final JsonObject node, final String path, final List errors, + final Map biomeLibrary) + { + final Optional display = requireBiome(node, "display", path, errors); + if (display.isEmpty()) + return Optional.empty(); + + final Optional> surface = hasKey(node, "surface") + ? requireArray(node, "surface", path, errors).map(array -> parseSurfaceRules(array, childPath(path, "surface"), errors)) + : Optional.empty(); + + final Optional> features = hasKey(node, "features") + ? requireArray(node, "features", path, errors).map(array -> parseFeatures(array, childPath(path, "features"), errors)) + : Optional.empty(); + + return Optional.of(new BiomeDefinition(display.get(), surface, features)); + } + + private static List parseSurfaceRules(final JsonArray array, final String path, final List errors) + { + final List rules = new ArrayList<>(); + + IntStream.range(0, array.size()).forEach(i -> + parseSurfaceRule(array.get(i), path + "[" + i + "]", errors).ifPresent(rules::add)); + + return rules; + } + + private static Optional parseSurfaceRule(final JsonElement element, final String path, final List errors) + { + if (!element.isJsonObject()) + { + errors.add(new ProfileError(path, "expected an object, got \"" + element + "\"")); + return Optional.empty(); + } + + final JsonObject node = element.getAsJsonObject(); + final BiomeFilter biomes = parseSurfaceBiomeFilter(node, path, errors); + final Optional depth = parseDepth(node, path, errors); + final Optional block = requireBlock(node, "block", path, errors); + + if (depth.isEmpty() || block.isEmpty()) + return Optional.empty(); + + return Optional.of(new SurfaceRule(biomes, depth.get(), block.get())); + } + + private static Optional parseDepth(final JsonObject node, final String path, final List errors) + { + final Optional from = requireInt(node, "depthFrom", path, errors); + final Optional to = optionalInt(node, "depthTo", path, errors); + + if (from.isEmpty()) + return Optional.empty(); + + try + { + return Optional.of(to.isPresent() ? Depth.range(from.get(), to.get()) : Depth.rest(from.get())); + } + catch (final IllegalArgumentException ex) + { + errors.add(new ProfileError(path, ex.getMessage())); + return Optional.empty(); + } + } + + /** SurfaceRule's own shape: a single optional "biome" string. Absent means Any(). */ + private static BiomeFilter parseSurfaceBiomeFilter(final JsonObject node, final String path, final List errors) + { + final Optional name = optionalString(node, "biome", path, errors); + if (name.isEmpty()) + return BiomeFilter.any(); + + return parseBiome(name.get(), childPath(path, "biome"), errors).map(biome -> BiomeFilter.of(Set.of(biome))) + .orElseGet(BiomeFilter::any); + } + + /** A feature or biome band's own shape: an optional "biomes" array of strings. Absent means Any(). */ + private static BiomeFilter parseFeatureBiomeFilter(final JsonObject node, final String path, final List errors) + { + final Optional array = optionalArray(node, "biomes", path, errors); + if (array.isEmpty()) + return BiomeFilter.any(); + + final Set biomes = new LinkedHashSet<>(); + final String biomesPath = childPath(path, "biomes"); + + IntStream.range(0, array.get().size()).forEach(i -> + { + final JsonElement element = array.get().get(i); + + try + { + biomes.add(Biome.valueOf(element.getAsString().toUpperCase(Locale.ROOT))); + } + catch (final RuntimeException ex) + { + errors.add(new ProfileError(biomesPath + "[" + i + "]", "unknown biome \"" + element + "\"")); + } + }); + + if (biomes.isEmpty()) + { + errors.add(new ProfileError(biomesPath, "must not be empty")); + return BiomeFilter.any(); + } + + return BiomeFilter.of(biomes); + } + + private static List parseFeatures(final JsonArray array, final String path, final List errors) + { + final List specs = new ArrayList<>(); + + IntStream.range(0, array.size()).forEach(i -> + parseFeatureSpec(array.get(i), path + "[" + i + "]", errors).ifPresent(specs::add)); + + return specs; + } + + private static Optional parseFeatureSpec(final JsonElement element, final String path, final List errors) + { + if (!element.isJsonObject()) + { + errors.add(new ProfileError(path, "expected an object, got \"" + element + "\"")); + return Optional.empty(); + } + + final JsonObject node = element.getAsJsonObject(); + final Optional type = requireString(node, "type", path, errors); + final Optional rarity = requireInt(node, "rarity", path, errors); + final Optional minY = requireInt(node, "minY", path, errors); + final Optional maxY = requireInt(node, "maxY", path, errors); + final BiomeFilter biomes = parseFeatureBiomeFilter(node, path, errors); + + if (type.isEmpty() || rarity.isEmpty() || minY.isEmpty() || maxY.isEmpty()) + return Optional.empty(); + + final Optional detail = parseFeatureDetail(node, path, type.get().toLowerCase(Locale.ROOT), errors); + if (detail.isEmpty()) + return Optional.empty(); + + try + { + return Optional.of(new FeatureSpec(rarity.get(), minY.get(), maxY.get(), biomes, detail.get())); + } + catch (final IllegalArgumentException ex) + { + errors.add(new ProfileError(path, ex.getMessage())); + return Optional.empty(); + } + } + + private static Optional parseFeatureDetail(final JsonObject node, final String path, final String type, + final List errors) + { + return switch (type) + { + case "ore" -> + { + final Optional block = requireBlock(node, "block", path, errors); + final Optional size = requireInt(node, "size", path, errors); + yield (block.isEmpty() || size.isEmpty()) ? Optional.empty() : Optional.of(new FeatureDetail.Ore(block.get(), size.get())); + } + case "patch" -> + { + final Optional block = requireBlock(node, "block", path, errors); + final Optional spread = requireInt(node, "size", path, errors); + yield (block.isEmpty() || spread.isEmpty()) ? Optional.empty() : Optional.of(new FeatureDetail.Patch(block.get(), spread.get())); + } + case "lake" -> + { + final Optional fluid = requireBlock(node, "block", path, errors); + final Optional radius = requireInt(node, "size", path, errors); + yield (fluid.isEmpty() || radius.isEmpty()) ? Optional.empty() : Optional.of(new FeatureDetail.Lake(fluid.get(), radius.get())); + } + case "boulder" -> + { + final Optional block = requireBlock(node, "block", path, errors); + final Optional radius = requireInt(node, "size", path, errors); + yield (block.isEmpty() || radius.isEmpty()) ? Optional.empty() : Optional.of(new FeatureDetail.Boulder(block.get(), radius.get())); + } + case "tree" -> + { + if (hasKey(node, "treeType")) + { + final Optional treeType = requireEnum(node, "treeType", path, errors, "tree type", TreeType::valueOf); + yield treeType.map(FeatureDetail.Tree::new); + } + + yield Optional.of(new FeatureDetail.NaturalTree()); + } + default -> + { + errors.add(new ProfileError(childPath(path, "type"), "unknown feature type \"" + type + "\"")); + yield Optional.empty(); + } + }; + } + + private static Optional parseWorldSettings(final JsonObject root, final List errors) + { + final Optional node = requireObject(root, "world", "", errors); + if (node.isEmpty()) + return Optional.empty(); + + final String path = "world"; + final Optional environment = + requireEnum(node.get(), "environment", path, errors, "environment", World.Environment::valueOf); + final Optional generateStructures = requireBoolean(node.get(), "generateStructures", path, errors); + final Optional keepSpawnLoaded = requireBoolean(node.get(), "keepSpawnLoaded", path, errors); + final Optional seed = optionalLong(node.get(), "seed", path, errors); + final Optional vanilla = parseVanillaFlags(node.get(), path, errors); + + if (environment.isEmpty() || generateStructures.isEmpty() || keepSpawnLoaded.isEmpty() || vanilla.isEmpty()) + return Optional.empty(); + + return Optional.of(new WorldSettings(environment.get(), generateStructures.get(), keepSpawnLoaded.get(), seed, vanilla.get())); + } + + private static Optional parseVanillaFlags(final JsonObject worldNode, final String parentPath, + final List errors) + { + final Optional node = requireObject(worldNode, "vanilla", parentPath, errors); + if (node.isEmpty()) + return Optional.empty(); + + final String path = childPath(parentPath, "vanilla"); + final Optional surface = requireBoolean(node.get(), "surface", path, errors); + final Optional caves = requireBoolean(node.get(), "caves", path, errors); + final Optional decorations = requireBoolean(node.get(), "decorations", path, errors); + final Optional mobs = requireBoolean(node.get(), "mobs", path, errors); + final Optional structures = requireBoolean(node.get(), "structures", path, errors); + + if (surface.isEmpty() || caves.isEmpty() || decorations.isEmpty() || mobs.isEmpty() || structures.isEmpty()) + return Optional.empty(); + + return Optional.of(new WorldSettings.VanillaFlags(surface.get(), caves.get(), decorations.get(), mobs.get(), structures.get())); + } + + // Every requireX/optionalX below records a ProfileError itself before returning empty, so a + // caller can always tell "already reported" apart from "fine, wasn't there" without adding its + // own error. + + private static String childPath(final String parentPath, final String key) + { + return parentPath.isEmpty() ? key : parentPath + "." + key; + } + + private static boolean hasKey(final JsonObject obj, final String key) + { + final JsonElement element = obj.get(key); + return element != null && !element.isJsonNull(); + } + + private static Optional presentField(final JsonObject obj, final String key, final String parentPath, + final boolean required, final List errors) { + final JsonElement element = obj.get(key); + if (element == null || element.isJsonNull()) + { + if (required) + errors.add(new ProfileError(childPath(parentPath, key), "missing required key")); + + return Optional.empty(); + } + + return Optional.of(element); + } + + private static Optional asString(final JsonElement element, final String key, final String parentPath, final List errors) + { + try + { + return Optional.of(element.getAsString()); + } + catch (final RuntimeException ex) + { + errors.add(new ProfileError(childPath(parentPath, key), "expected a string, got \"" + element + "\"")); + return Optional.empty(); + } + } + + private static Optional asInt(final JsonElement element, final String key, final String parentPath, final List errors) + { + try + { + return Optional.of(element.getAsInt()); + } + catch (final RuntimeException ex) + { + errors.add(new ProfileError(childPath(parentPath, key), "expected a number, got \"" + element + "\"")); + return Optional.empty(); + } + } + + private static Optional asLong(final JsonElement element, final String key, final String parentPath, final List errors) + { + try + { + return Optional.of(element.getAsLong()); + } + catch (final RuntimeException ex) + { + errors.add(new ProfileError(childPath(parentPath, key), "expected a number, got \"" + element + "\"")); + return Optional.empty(); + } + } + + private static Optional asDouble(final JsonElement element, final String key, final String parentPath, final List errors) + { + try + { + return Optional.of(element.getAsDouble()); + } + catch (final RuntimeException ex) + { + errors.add(new ProfileError(childPath(parentPath, key), "expected a number, got \"" + element + "\"")); + return Optional.empty(); + } + } + + private static Optional asBoolean(final JsonElement element, final String key, final String parentPath, final List errors) + { + try + { + return Optional.of(element.getAsBoolean()); + } + catch (final RuntimeException ex) + { + errors.add(new ProfileError(childPath(parentPath, key), "expected true or false, got \"" + element + "\"")); + return Optional.empty(); + } + } + + private static Optional asObject(final JsonElement element, final String key, final String parentPath, final List errors) + { + try + { + return Optional.of(element.getAsJsonObject()); + } + catch (final RuntimeException ex) + { + errors.add(new ProfileError(childPath(parentPath, key), "expected an object, got \"" + element + "\"")); + return Optional.empty(); + } + } + + private static Optional asArray(final JsonElement element, final String key, final String parentPath, final List errors) + { + try + { + return Optional.of(element.getAsJsonArray()); + } + catch (final RuntimeException ex) + { + errors.add(new ProfileError(childPath(parentPath, key), "expected an array, got \"" + element + "\"")); + return Optional.empty(); + } + } + + private static Optional requireString(final JsonObject obj, final String key, final String path, final List errors) + { + return presentField(obj, key, path, true, errors).flatMap(e -> asString(e, key, path, errors)); + } + + private static Optional optionalString(final JsonObject obj, final String key, final String path, final List errors) + { + return presentField(obj, key, path, false, errors).flatMap(e -> asString(e, key, path, errors)); + } + + private static Optional requireInt(final JsonObject obj, final String key, final String path, final List errors) + { + return presentField(obj, key, path, true, errors).flatMap(e -> asInt(e, key, path, errors)); + } + + private static Optional optionalInt(final JsonObject obj, final String key, final String path, final List errors) + { + return presentField(obj, key, path, false, errors).flatMap(e -> asInt(e, key, path, errors)); + } + + private static Optional optionalLong(final JsonObject obj, final String key, final String path, final List errors) + { + return presentField(obj, key, path, false, errors).flatMap(e -> asLong(e, key, path, errors)); + } + + private static Optional requireDouble(final JsonObject obj, final String key, final String path, final List errors) + { + return presentField(obj, key, path, true, errors).flatMap(e -> asDouble(e, key, path, errors)); + } + + private static Optional requireBoolean(final JsonObject obj, final String key, final String path, final List errors) + { + return presentField(obj, key, path, true, errors).flatMap(e -> asBoolean(e, key, path, errors)); + } + + private static Optional requireObject(final JsonObject obj, final String key, final String path, final List errors) + { + return presentField(obj, key, path, true, errors).flatMap(e -> asObject(e, key, path, errors)); + } + + private static Optional requireArray(final JsonObject obj, final String key, final String path, final List errors) + { + return presentField(obj, key, path, true, errors).flatMap(e -> asArray(e, key, path, errors)); + } + + private static Optional optionalArray(final JsonObject obj, final String key, final String path, final List errors) + { + return presentField(obj, key, path, false, errors).flatMap(e -> asArray(e, key, path, errors)); + } + + private static > Optional requireEnum(final JsonObject obj, final String key, final String path, + final List errors, final String typeName, + final Function valueOf) + { + return requireString(obj, key, path, errors).flatMap(name -> parseEnum(name, childPath(path, key), errors, typeName, valueOf)); + } + + private static Optional parseEnum(final String name, final String path, final List errors, + final String typeName, final Function valueOf) + { + try + { + return Optional.of(valueOf.apply(name.toUpperCase(Locale.ROOT))); + } + catch (final IllegalArgumentException ex) + { + errors.add(new ProfileError(path, "unknown " + typeName + " \"" + name + "\"")); + return Optional.empty(); + } + } + + private static Optional requireBlock(final JsonObject obj, final String key, final String path, final List errors) + { + return requireString(obj, key, path, errors).flatMap(name -> parseBlock(name, childPath(path, key), errors)); + } + + private static Optional parseBlock(final String name, final String path, final List errors) + { + try + { + return Optional.of(Material.valueOf(name.toUpperCase(Locale.ROOT)).createBlockData()); + } + catch (final IllegalArgumentException ex) + { + errors.add(new ProfileError(path, "unknown block \"" + name + "\"")); + return Optional.empty(); + } + } + + private static Optional requireBiome(final JsonObject obj, final String key, final String path, final List errors) + { + return requireString(obj, key, path, errors).flatMap(name -> parseBiome(name, childPath(path, key), errors)); + } + + private static Optional parseBiome(final String name, final String path, final List errors) + { + try + { + return Optional.of(Biome.valueOf(name.toUpperCase(Locale.ROOT))); + } + catch (final IllegalArgumentException ex) + { + errors.add(new ProfileError(path, "unknown biome \"" + name + "\"")); + return Optional.empty(); + } } } diff --git a/src/main/java/me/totalfreedom/totalfreedommod/world/profile/Shape.java b/src/main/java/me/totalfreedom/totalfreedommod/world/profile/Shape.java index ada5503e7..0742b38f9 100644 --- a/src/main/java/me/totalfreedom/totalfreedommod/world/profile/Shape.java +++ b/src/main/java/me/totalfreedom/totalfreedommod/world/profile/Shape.java @@ -1,5 +1,6 @@ package me.totalfreedom.totalfreedommod.world.profile; +import java.util.List; import java.util.Optional; import org.bukkit.block.data.BlockData; @@ -23,17 +24,29 @@ record Flat(LayerStack layers) implements Shape { } - /** 2D height through a spline. No overhangs. */ + /** + * 2D height through a spline. No overhangs. + *

+ * regions, when present, lets different parts of the world use different terrain instead of one + * spline everywhere; see {@link Regions}. + */ record Heightmap(Terrain terrain, Optional river, - Optional caves) implements Shape + Optional caves, + Optional> regions) implements Shape { } - /** 3D density. Overhangs and floating islands, at roughly fifty times the samples. */ + /** + * 3D density. Overhangs and floating islands, at roughly fifty times the samples. + *

+ * regions, when present, lets different parts of the world use different density noise instead + * of one field everywhere; see {@link Regions}. + */ record Density(NoiseProfile noise, double warp, - Optional caves) implements Shape + Optional caves, + Optional> regions) implements Shape { } @@ -42,6 +55,11 @@ record Terrain(NoiseProfile noise, Spline spline, double warp) { } + /** A density mode region's own noise. warp offsets the sample coordinates, same as {@link Terrain}. */ + record DensityLayer(NoiseProfile noise, double warp) + { + } + /** Pulls height toward sea level where the noise is near zero. */ record River(NoiseProfile noise, double threshold, int depth, BlockData bedBlock) { @@ -62,4 +80,47 @@ record Caves(NoiseProfile noise, double threshold, int minY, int maxY, int flood throw new IllegalArgumentException("minY (" + minY + ") must not be above maxY (" + maxY + ")"); } } + + /** + * One named region: the slice of the selector noise it claims, and the terrain it uses there. + *

+ * First matching region in the enclosing list wins, same idiom as {@link Palette.BiomeBand} and + * {@link SurfaceRule}. name only has to be unique within that list; it exists so an admin + * authoring a profile can tell regions apart in an error message, not for anything to reference. + * + * @throws IllegalArgumentException if min is above max + */ + record Region(String name, double min, double max, T terrain) + { + public Region + { + if (min > max) + throw new IllegalArgumentException("min (" + min + ") must not be above max (" + max + ")"); + } + + public boolean matches(final double value) + { + return value >= this.min && value <= this.max; + } + } + + /** + * A coarse selector noise plus the ordered regions it can pick between, for worlds that want + * different terrain in different places rather than one spline or one density field everywhere. + *

+ * blendWidth is how far either side of a region boundary, in the selector's own -1 to 1 units, + * two neighbouring regions' terrain gets blended together, so borders read as a gradient rather + * than a hard seam. + *

+ * Wherever the selector's value matches no listed region, the enclosing {@link Heightmap} or + * {@link Density}'s own terrain field applies instead. Same fallback idiom as a palette's biome + * bands plus its fallback biome; there is no separate "implicit region" to reason about. + */ + record Regions(NoiseProfile selector, double blendWidth, List> regions) + { + public Regions + { + regions = List.copyOf(regions); + } + } } diff --git a/src/main/java/me/totalfreedom/totalfreedommod/world/stage/DensityGenerator.java b/src/main/java/me/totalfreedom/totalfreedommod/world/stage/DensityGenerator.java index 42a0206a5..2441ffd01 100644 --- a/src/main/java/me/totalfreedom/totalfreedommod/world/stage/DensityGenerator.java +++ b/src/main/java/me/totalfreedom/totalfreedommod/world/stage/DensityGenerator.java @@ -1,5 +1,8 @@ package me.totalfreedom.totalfreedommod.world.stage; +import java.util.List; +import java.util.Optional; + import org.bukkit.generator.ChunkGenerator; import me.totalfreedom.totalfreedommod.world.base.ChunkContext; @@ -14,18 +17,28 @@ *

* Roughly fifty times the samples of heightmap mode, so only use it if a world actually needs * those shapes. + *

+ * density is the fallback used wherever regions is empty or its selector matches no listed region. + * TODO: generateBase/surfaceHeight need to sample regions.selector() per column once regions is + * present, blend the matched BuiltRegion's own noise/warp in over blendWidth, and fall back to + * density otherwise. Same shape as {@link HeightmapGenerator}, just without a spline. */ public final class DensityGenerator implements Generator { private final NoiseField density; private final Bounds bounds; private final Materials materials; + private final Optional regions; - public DensityGenerator(final NoiseField density, final Bounds bounds, final Materials materials) + public DensityGenerator(final NoiseField density, + final Bounds bounds, + final Materials materials, + final Optional regions) { this.density = density; this.bounds = bounds; this.materials = materials; + this.regions = regions; } @Override @@ -39,4 +52,17 @@ public int surfaceHeight(final int worldX, final int worldZ) { } + + /** + * One profile region, already built: a sampled noise field, not the raw settings + * {@link me.totalfreedom.totalfreedommod.world.profile.Shape.DensityLayer} carries. + */ + record BuiltRegion(String name, double min, double max, NoiseField noise, double warp) + { + } + + /** See {@link HeightmapGenerator.RegionSet}; the same role-string requirement applies here too. */ + record RegionSet(NoiseField selector, double blendWidth, List regions) + { + } } diff --git a/src/main/java/me/totalfreedom/totalfreedommod/world/stage/FeaturePopulator.java b/src/main/java/me/totalfreedom/totalfreedommod/world/stage/FeaturePopulator.java index 409ed7095..f7b5ebb2c 100644 --- a/src/main/java/me/totalfreedom/totalfreedommod/world/stage/FeaturePopulator.java +++ b/src/main/java/me/totalfreedom/totalfreedommod/world/stage/FeaturePopulator.java @@ -3,19 +3,20 @@ import java.util.List; import java.util.Random; -import org.bukkit.block.Biome; import org.bukkit.generator.LimitedRegion; import me.totalfreedom.totalfreedommod.world.base.ChunkContext; import me.totalfreedom.totalfreedommod.world.base.Populator; import me.totalfreedom.totalfreedommod.world.profile.Anchor; import me.totalfreedom.totalfreedommod.world.profile.FeatureSpec; -import me.totalfreedom.totalfreedommod.world.profile.Palette; import me.totalfreedom.totalfreedommod.world.stage.feature.FeatureRegistry; /** * Rolls each feature in the profile against the chunk and hands off the hits. This only decides * what gets placed and where; the features do the placing. + *

+ * TODO: roll() must resolve each column's band via {@code palette().resolveBand()}. A column whose + * band has its own {@code features()} list should roll only that list, not this.specs. */ public final class FeaturePopulator implements Populator { @@ -48,7 +49,7 @@ private void roll(final ChunkContext context, final int worldX = context.worldX(localX); final int worldZ = context.worldZ(localZ); - if (!spec.appliesTo(this.biomeAt(context, worldX, worldZ))) + if (!spec.appliesTo(context.getProfile().palette().resolveBiome(worldX, worldZ))) continue; final int y = spec.detail().anchor() == Anchor.SURFACE @@ -61,25 +62,4 @@ private void roll(final ChunkContext context, this.registry.place(context, data, spec.detail(), worldX, y, worldZ); } } - - /** - * Same temperature/humidity lookup {@link me.totalfreedom.totalfreedommod.world.adapter.ProfileBiomeProvider} - * uses. Duplicated rather than shared, since that class does not expose it as a static helper. - */ - private Biome biomeAt(final ChunkContext context, final int worldX, final int worldZ) - { - final Palette palette = context.getProfile().palette(); - final Palette.Climate climate = palette.climate(); - final int sampleX = (int) (worldX * climate.scale()); - final int sampleZ = (int) (worldZ * climate.scale()); - final double temperature = climate.temperature().sample(sampleX, sampleZ); - final double humidity = climate.humidity().sample(sampleX, sampleZ); - - return palette.biomes() - .stream() - .filter(band -> band.matches(temperature, humidity)) - .findFirst() - .map(Palette.BiomeBand::biome) - .orElse(palette.fallback()); - } } diff --git a/src/main/java/me/totalfreedom/totalfreedommod/world/stage/HeightmapGenerator.java b/src/main/java/me/totalfreedom/totalfreedommod/world/stage/HeightmapGenerator.java index 32ce2b311..3a392a85c 100644 --- a/src/main/java/me/totalfreedom/totalfreedommod/world/stage/HeightmapGenerator.java +++ b/src/main/java/me/totalfreedom/totalfreedommod/world/stage/HeightmapGenerator.java @@ -1,5 +1,8 @@ package me.totalfreedom.totalfreedommod.world.stage; +import java.util.List; +import java.util.Optional; + import org.bukkit.generator.ChunkGenerator; import me.totalfreedom.totalfreedommod.world.base.ChunkContext; @@ -15,6 +18,11 @@ *

* Sample on a grid and interpolate between the samples. Sampling every block is 98,304 positions * per chunk, times however many octaves the noise has. + *

+ * terrain/spline/warp are the fallback used wherever regions is empty or its selector matches no + * listed region, exactly as they always have been. TODO: generateBase/surfaceHeight need to sample + * regions.selector() per column once regions is present, blend the matched BuiltRegion's own + * noise/spline/warp in over blendWidth, and fall back to the fields above otherwise. */ public final class HeightmapGenerator implements Generator { @@ -24,13 +32,15 @@ public final class HeightmapGenerator implements Generator private final Bounds bounds; private final Materials materials; private final double warp; + private final Optional regions; public HeightmapGenerator(final NoiseField terrain, final NoiseField river, final Spline spline, final Bounds bounds, final Materials materials, - final double warp) + final double warp, + final Optional regions) { this.terrain = terrain; this.river = river; @@ -38,6 +48,7 @@ public HeightmapGenerator(final NoiseField terrain, this.bounds = bounds; this.materials = materials; this.warp = warp; + this.regions = regions; } @Override @@ -51,4 +62,23 @@ public int surfaceHeight(final int worldX, final int worldZ) { } + + /** + * One profile region, already built: a sampled noise field and a ready spline, not the raw + * settings {@link me.totalfreedom.totalfreedommod.world.profile.Shape.Region} carries. + */ + record BuiltRegion(String name, double min, double max, NoiseField noise, Spline spline, double warp) + { + } + + /** + * The selector noise plus its built regions and blend width. {@code ProfileChunkGenerator.wire()} + * builds this from a profile's {@link me.totalfreedom.totalfreedommod.world.profile.Shape.Regions} + * by calling {@code NoiseField.of} once per region plus once for the selector itself, each with + * its own role string (e.g. {@code "terrain-region-"}, {@code "terrain-selector"}) so no two + * fields in one profile collapse onto the same random stream. + */ + record RegionSet(NoiseField selector, double blendWidth, List regions) + { + } } diff --git a/src/main/java/me/totalfreedom/totalfreedommod/world/stage/RuleDesigner.java b/src/main/java/me/totalfreedom/totalfreedommod/world/stage/RuleDesigner.java index 79df600c2..0ee39a33e 100644 --- a/src/main/java/me/totalfreedom/totalfreedommod/world/stage/RuleDesigner.java +++ b/src/main/java/me/totalfreedom/totalfreedommod/world/stage/RuleDesigner.java @@ -12,6 +12,10 @@ /** * Applies the profile's surface rules. Walks each column down from the context's column top with a * depth counter that resets on air gaps, so cave floors get their own treatment. + *

+ * TODO: surface() must resolve each column's band via {@code palette().resolveBand()}, not just its + * display biome, and use a {@code Logical} band's own {@code surface()} list when it has one. + * Everything else falls through to this.rules. */ public final class RuleDesigner implements Designer { diff --git a/src/main/java/me/totalfreedom/totalfreedommod/world/stage/feature/FeatureRegistry.java b/src/main/java/me/totalfreedom/totalfreedommod/world/stage/feature/FeatureRegistry.java index 77fe317bd..28f089631 100644 --- a/src/main/java/me/totalfreedom/totalfreedommod/world/stage/feature/FeatureRegistry.java +++ b/src/main/java/me/totalfreedom/totalfreedommod/world/stage/feature/FeatureRegistry.java @@ -19,6 +19,7 @@ public final class FeatureRegistry private final LakeFeature lake = new LakeFeature(); private final BoulderFeature boulder = new BoulderFeature(); private final TreeFeature tree = new TreeFeature(); + private final NaturalTreeFeature naturalTree = new NaturalTreeFeature(); public void place(final ChunkContext context, final LimitedRegion region, @@ -34,6 +35,7 @@ public void place(final ChunkContext context, case FeatureDetail.Lake d -> this.lake.place(context, region, d, x, y, z); case FeatureDetail.Boulder d -> this.boulder.place(context, region, d, x, y, z); case FeatureDetail.Tree d -> this.tree.place(context, region, d, x, y, z); + case FeatureDetail.NaturalTree d -> this.naturalTree.place(context, region, d, x, y, z); } } } diff --git a/src/main/java/me/totalfreedom/totalfreedommod/world/stage/feature/NaturalTreeFeature.java b/src/main/java/me/totalfreedom/totalfreedommod/world/stage/feature/NaturalTreeFeature.java new file mode 100644 index 000000000..518be5469 --- /dev/null +++ b/src/main/java/me/totalfreedom/totalfreedommod/world/stage/feature/NaturalTreeFeature.java @@ -0,0 +1,121 @@ +package me.totalfreedom.totalfreedommod.world.stage.feature; + +import java.util.HashMap; +import java.util.List; +import java.util.Map; +import java.util.Random; +import java.util.stream.Stream; + +import org.bukkit.Location; +import org.bukkit.TreeType; +import org.bukkit.block.Biome; +import org.bukkit.generator.LimitedRegion; + +import me.totalfreedom.totalfreedommod.world.base.ChunkContext; +import me.totalfreedom.totalfreedommod.world.profile.FeatureDetail; + +/** + * Grows whichever tree the placement site's actual biome would naturally produce, weighted the way + * vanilla mixes them; nine spruce to one tall spruce in a taiga, mostly oak with a scattering of + * birch in a forest, and so on. + *

+ * Reads the biome from the region rather than the feature spec's own biome filter, since a spec can + * span several biomes with different mixes. + *

+ * A biome missing from the table has no natural tree cover at all (desert, ocean, badlands, the + * nether, the end) and is silently skipped rather than defaulted to oak. + */ +public final class NaturalTreeFeature implements Feature +{ + private record Weighted(TreeType type, int weight) + { + } + + /** + * One mix per distinct species combination, not per biome. Plenty of biomes share the exact same + * mix (three kinds of savanna all want plain acacia), so the table says so once instead of + * repeating it. + */ + private static final Map> MIXES = buildMixes(); + + private static Map> buildMixes() + { + final Map> mixes = new HashMap<>(); + + mix(mixes, List.of(new Weighted(TreeType.TREE, 4), new Weighted(TreeType.BIRCH, 1)), + Biome.FOREST); + mix(mixes, List.of(new Weighted(TreeType.TREE, 1)), + Biome.PLAINS, Biome.SUNFLOWER_PLAINS, Biome.FLOWER_FOREST); + mix(mixes, List.of(new Weighted(TreeType.BIRCH, 1)), + Biome.BIRCH_FOREST); + mix(mixes, List.of(new Weighted(TreeType.TALL_BIRCH, 1)), + Biome.OLD_GROWTH_BIRCH_FOREST); + mix(mixes, List.of(new Weighted(TreeType.DARK_OAK, 4), new Weighted(TreeType.TREE, 1)), + Biome.DARK_FOREST); + mix(mixes, List.of(new Weighted(TreeType.TREE, 3), new Weighted(TreeType.REDWOOD, 1)), + Biome.WINDSWEPT_FOREST); + mix(mixes, List.of(new Weighted(TreeType.REDWOOD, 9), new Weighted(TreeType.TALL_REDWOOD, 1)), + Biome.TAIGA); + mix(mixes, List.of(new Weighted(TreeType.TALL_REDWOOD, 3), new Weighted(TreeType.MEGA_REDWOOD, 1)), + Biome.OLD_GROWTH_PINE_TAIGA); + mix(mixes, List.of(new Weighted(TreeType.MEGA_REDWOOD, 3), new Weighted(TreeType.TALL_REDWOOD, 1)), + Biome.OLD_GROWTH_SPRUCE_TAIGA); + mix(mixes, List.of(new Weighted(TreeType.REDWOOD, 1)), + Biome.SNOWY_TAIGA, Biome.SNOWY_PLAINS); + mix(mixes, List.of(new Weighted(TreeType.JUNGLE, 4), new Weighted(TreeType.SMALL_JUNGLE, 1)), + Biome.JUNGLE); + mix(mixes, List.of(new Weighted(TreeType.SMALL_JUNGLE, 3), new Weighted(TreeType.JUNGLE, 1)), + Biome.SPARSE_JUNGLE); + mix(mixes, List.of(new Weighted(TreeType.JUNGLE, 1)), + Biome.BAMBOO_JUNGLE); + mix(mixes, List.of(new Weighted(TreeType.ACACIA, 1)), + Biome.SAVANNA, Biome.SAVANNA_PLATEAU, Biome.WINDSWEPT_SAVANNA); + mix(mixes, List.of(new Weighted(TreeType.SWAMP, 1)), + Biome.SWAMP); + + return Map.copyOf(mixes); + } + + private static void mix(final Map> mixes, final List mix, final Biome... biomes) + { + Stream.of(biomes).forEach(biome -> mixes.put(biome, mix)); + } + + @Override + public void place(final ChunkContext context, + final LimitedRegion region, + final FeatureDetail.NaturalTree detail, + final int x, + final int y, + final int z) + { + final Biome biome = region.getBiome(x, y, z); + final List mix = MIXES.get(biome); + + if (mix == null) + return; + + final Random random = context.getRandom(); + final TreeType type = pick(mix, random); + + region.generateTree(new Location(null, x, y, z), random, type); + } + + /** Rolls one weighted pick out of a biome's mix. */ + private static TreeType pick(final List mix, final Random random) + { + final int total = mix.stream().mapToInt(Weighted::weight).sum(); + int roll = random.nextInt(total); + + for (final Weighted candidate : mix) + { + if (roll < candidate.weight()) + return candidate.type(); + + roll -= candidate.weight(); + } + + // Unreachable: weights sum to total, so the loop above always returns first. + return mix.get(0).type(); + } +} diff --git a/src/main/java/me/totalfreedom/totalfreedommod/world/stage/feature/TreeFeature.java b/src/main/java/me/totalfreedom/totalfreedommod/world/stage/feature/TreeFeature.java index 1daf45f4c..cbb37cea0 100644 --- a/src/main/java/me/totalfreedom/totalfreedommod/world/stage/feature/TreeFeature.java +++ b/src/main/java/me/totalfreedom/totalfreedommod/world/stage/feature/TreeFeature.java @@ -1,23 +1,14 @@ package me.totalfreedom.totalfreedommod.world.stage.feature; -import java.security.InvalidParameterException; -import java.util.HashMap; -import java.util.Map; - import org.bukkit.Location; -import org.bukkit.Material; -import org.bukkit.Tag; -import org.bukkit.TreeType; -import org.bukkit.block.BlockType; import org.bukkit.generator.LimitedRegion; import me.totalfreedom.totalfreedommod.world.base.ChunkContext; import me.totalfreedom.totalfreedommod.world.profile.FeatureDetail; -import me.totalfreedom.totalfreedommod.world.profile.FeatureSpec; /** - * Grows a tree. The spec's block names the sapling, and the sapling picks the species, so - * oak_sapling grows an oak and spruce_sapling grows a spruce. + * Grows one exact tree species, regardless of the biome it lands in. Use {@link NaturalTreeFeature} + * instead for a biome-appropriate mix. *

* Hands off to LimitedRegion#generateTree, which knows every vanilla tree shape and handles the * canopy crossing a chunk border. @@ -32,5 +23,6 @@ public void place(final ChunkContext context, final int y, final int z) { + region.generateTree(new Location(null, x, y, z), context.getRandom(), detail.type()); } } diff --git a/src/main/resources/worlds/flatlands-template.json b/src/main/resources/worlds/flatlands-template.json index d9af5c466..85fa5b36c 100644 --- a/src/main/resources/worlds/flatlands-template.json +++ b/src/main/resources/worlds/flatlands-template.json @@ -11,7 +11,16 @@ "fluidBlock": "water", "bedrockBlock": "bedrock", "bedrock": "FLOOR" - } + }, + "surface": [ + { "depthFrom": 0, "block": "grass_block" } + ], + "climate": { + "temperature": { "type": "simplex", "octaves": 1, "frequency": 0.001, "persistence": 0.5, "lacunarity": 2.0, "ridged": false }, + "humidity": { "type": "simplex", "octaves": 1, "frequency": 0.001, "persistence": 0.5, "lacunarity": 2.0, "ridged": false }, + "scale": 1.0 + }, + "fallback": "plains" }, "features": [], diff --git a/src/main/resources/worlds/overworld-template.json b/src/main/resources/worlds/overworld-template.json index 713cd548e..d8dd0ba88 100644 --- a/src/main/resources/worlds/overworld-template.json +++ b/src/main/resources/worlds/overworld-template.json @@ -107,10 +107,8 @@ { "type": "ore", "rarity": 4, "minY": -64, "maxY": 32, "block": "gold_ore", "size": 9 }, { "type": "ore", "rarity": 2, "minY": -64, "maxY": 30, "block": "lapis_ore", "size": 7 }, { "type": "ore", "rarity": 1, "minY": -64, "maxY": 16, "block": "diamond_ore", "size": 8 }, - { "type": "tree", "rarity": 10, "minY": 60, "maxY": 200, "block": "oak_sapling", "size": 1, "biomes": ["forest"] }, - { "type": "tree", "rarity": 8, "minY": 60, "maxY": 200, "block": "jungle_sapling", "size": 1, "biomes": ["jungle"] }, - { "type": "tree", "rarity": 6, "minY": 60, "maxY": 200, "block": "spruce_sapling", "size": 1, "biomes": ["taiga", "snowy_plains"] }, - { "type": "tree", "rarity": 1, "minY": 60, "maxY": 200, "block": "oak_sapling", "size": 1, "biomes": ["plains", "savanna"] }, + { "type": "tree", "rarity": 10, "minY": 60, "maxY": 200, "biomes": ["forest", "taiga", "jungle"] }, + { "type": "tree", "rarity": 3, "minY": 60, "maxY": 200, "biomes": ["plains", "savanna", "snowy_plains"] }, { "type": "patch", "rarity": 7, "minY": 60, "maxY": 200, "block": "short_grass", "size": 32, "biomes": ["plains", "savanna"] }, { "type": "patch", "rarity": 2, "minY": 60, "maxY": 200, "block": "short_grass", "size": 32, "biomes": ["forest", "jungle"] }, { "type": "patch", "rarity": 2, "minY": 60, "maxY": 200, "block": "dead_bush", "size": 6, "biomes": ["desert"] }, From b8d036c492c2abc3726adc4946bcec7c6c64785d Mon Sep 17 00:00:00 2001 From: Paldiu Date: Fri, 7 Aug 2026 17:15:17 -0500 Subject: [PATCH 11/32] Contract Publication --- .../totalfreedommod/util/Lazy.java | 52 +++++++++++++++++++ .../totalfreedommod/world/base/Carver.java | 39 ++++++++++++++ .../totalfreedommod/world/base/Designer.java | 30 +++++++++++ .../totalfreedommod/world/base/Generator.java | 34 ++++++++++++ .../totalfreedommod/world/base/Populator.java | 26 ++++++++++ 5 files changed, 181 insertions(+) create mode 100644 src/main/java/me/totalfreedom/totalfreedommod/util/Lazy.java create mode 100644 src/main/java/me/totalfreedom/totalfreedommod/world/base/Carver.java create mode 100644 src/main/java/me/totalfreedom/totalfreedommod/world/base/Designer.java create mode 100644 src/main/java/me/totalfreedom/totalfreedommod/world/base/Generator.java create mode 100644 src/main/java/me/totalfreedom/totalfreedommod/world/base/Populator.java diff --git a/src/main/java/me/totalfreedom/totalfreedommod/util/Lazy.java b/src/main/java/me/totalfreedom/totalfreedommod/util/Lazy.java new file mode 100644 index 000000000..120f6e6c4 --- /dev/null +++ b/src/main/java/me/totalfreedom/totalfreedommod/util/Lazy.java @@ -0,0 +1,52 @@ +package me.totalfreedom.totalfreedommod.util; + +import java.util.function.Supplier; + +/** + * A value that gets worked out the first time you ask for it, then cached. + *

+ * Wrap the expensive part in a supplier and hand it over; nothing runs until the first + * {@link #get()}. Every call after that hands back the same value, and the supplier is never run + * again, including when it returned null. + *

+ * Safe to share between threads. Do not call {@link #get()} from inside the supplier though, since + * it will deadlock on the lock the first call is already holding. + * + * @param the type being worked out + */ +public class Lazy implements Supplier +{ + private final Supplier delegate; + private volatile boolean initialized = false; + private T value; + + public Lazy(Supplier delegate) + { + this.delegate = delegate; + } + + /** + * The value, working it out on the first call. + *

+ * The volatile on {@code initialized} is load bearing. Setting it after {@code value} is what + * makes {@code value} visible to other threads, which is what lets the first check run without + * taking the lock. Dropping volatile off the flag, or moving it onto {@code value} instead, + * breaks that. + */ + @Override + public T get() + { + if (!initialized) + synchronized(this) + { + if (!initialized) + { + value = delegate.get(); + initialized = true; + } + + } + + return value; + } +} diff --git a/src/main/java/me/totalfreedom/totalfreedommod/world/base/Carver.java b/src/main/java/me/totalfreedom/totalfreedommod/world/base/Carver.java new file mode 100644 index 000000000..65db8dde1 --- /dev/null +++ b/src/main/java/me/totalfreedom/totalfreedommod/world/base/Carver.java @@ -0,0 +1,39 @@ +package me.totalfreedom.totalfreedommod.world.base; + +/** + * Carves caves and ravines out of the terrain. Subtractive only; terrain shaping belongs to + * {@link Generator}. + *

+ * Runs under generateCaves, and only ever answers yes or no about one block at a time. + * {@code ProfileChunkGenerator} runs the loop and decides what actually happens to a block this + * flags: leaving bedrock alone, and filling with water instead of air below the depth the profile + * sets. + */ +public interface Carver +{ + /** + * True if the block should be removed. Only called for y within {@link #minY()} and + * {@link #maxY()}. + *

+ * The answer has to depend only on the position. Do not use the context's random here; it is + * seeded per chunk, so one block could get two different answers depending on which chunk was + * being generated at the time, and you end up with seams along every chunk border. + *

+ * Reading the context's terrain height is fine, and tightening the threshold as you get near it + * blends cave mouths into the hillside. Do not read the column top, though; that is worked out + * by calling this method, so you will deadlock. + */ + boolean isCarved(ChunkContext context, int worldX, int y, int worldZ); + + /** + * Lowest y this carver touches, inclusive. Read once per chunk and checked outside the block + * loop, so a deep carver costs nothing in the columns above it. Must be constant. + *

+ * Return {@link Integer#MAX_VALUE} here and {@link Integer#MIN_VALUE} from {@link #maxY()} for + * a no-op carver. + */ + int minY(); + + /** Highest y this carver touches, inclusive. See {@link #minY()}. */ + int maxY(); +} diff --git a/src/main/java/me/totalfreedom/totalfreedommod/world/base/Designer.java b/src/main/java/me/totalfreedom/totalfreedommod/world/base/Designer.java new file mode 100644 index 000000000..4ddee4926 --- /dev/null +++ b/src/main/java/me/totalfreedom/totalfreedommod/world/base/Designer.java @@ -0,0 +1,30 @@ +package me.totalfreedom.totalfreedommod.world.base; + +import org.bukkit.generator.ChunkGenerator; + +/** + * Decides what the base shape is made of. Surface blocks and bedrock. + *

+ * Runs under generateSurface then generateBedrock, after {@link Generator} and before + * {@link Carver}. + *

+ * Driven by the surface rules in the world's profile. First rule that matches wins. + */ +public interface Designer +{ + /** + * Swaps the generator's filler for real blocks. Runs under generateSurface. + *

+ * Substitution only; never change which positions are solid. Walk down from the context's + * column top, not its terrain height, or cave mouths get floating grass. Leave water alone. + */ + void surface(ChunkContext context, ChunkGenerator.ChunkData data); + + /** + * Writes the bedrock floor, and a roof if the world wants one. Runs under generateBedrock. + *

+ * The {@link Carver} runs after this and will happily eat bedrock, so keep its + * {@link Carver#minY()} above this layer. + */ + void bedrock(ChunkContext context, ChunkGenerator.ChunkData data); +} diff --git a/src/main/java/me/totalfreedom/totalfreedommod/world/base/Generator.java b/src/main/java/me/totalfreedom/totalfreedommod/world/base/Generator.java new file mode 100644 index 000000000..be8df86f0 --- /dev/null +++ b/src/main/java/me/totalfreedom/totalfreedommod/world/base/Generator.java @@ -0,0 +1,34 @@ +package me.totalfreedom.totalfreedommod.world.base; + +import org.bukkit.generator.ChunkGenerator; + +/** + * Primary world designer; generates the base shape of a chunk out of stone, air, and water, and + * handles all terrain shaping including rivers. Everything after this either changes what blocks + * are made of or takes blocks away. + *

+ * Reads its settings from a .json file in {@link org.bukkit.plugin.Plugin#getDataFolder}/worlds, + * named after the world. One implementation per mode (flat, heightmap, density), picked when the + * profile compiles. + */ +public interface Generator +{ + /** + * Writes the chunk. Runs under generateNoise. + *

+ * ChunkData takes local x/z (0-15) and absolute y. Use setRegion for runs of the same block up + * a column, and sample noise on a grid and interpolate between the samples; a chunk is 98,304 + * blocks, so sampling every one of them is not an option. + *

+ * Off the main thread. Only the context's WorldInfo is safe to touch, never World or entities. + */ + void generateBase(ChunkContext context, ChunkGenerator.ChunkData data); + + /** + * Terrain height at a world position, before carving. Pure, no chunk access. + *

+ * Backs getBaseHeight, the spawn finder, and the context's column heights. Must agree with what + * {@link #generateBase} writes or spawn lands in mid-air. + */ + int surfaceHeight(ChunkContext context, int worldX, int worldZ); +} diff --git a/src/main/java/me/totalfreedom/totalfreedommod/world/base/Populator.java b/src/main/java/me/totalfreedom/totalfreedommod/world/base/Populator.java new file mode 100644 index 000000000..1a40b5427 --- /dev/null +++ b/src/main/java/me/totalfreedom/totalfreedommod/world/base/Populator.java @@ -0,0 +1,26 @@ +package me.totalfreedom.totalfreedommod.world.base; + +import org.bukkit.generator.LimitedRegion; + +/** + * Places features into a finished chunk. Trees, flowers, ores, structures. + *

+ * Last in the chain, and the only stage that runs as a BlockPopulator, so it gets a + * {@link LimitedRegion} instead of chunk data. The region covers the chunk plus a margin, so a tree + * on a border can put its canopy in the next chunk. + *

+ * Driven by the feature list in the world's profile. + */ +public interface Populator +{ + /** + * Places this chunk's features. + *

+ * Use the context's random for every roll so placement is reproducible. Bounds check writes + * with isInRegion, and only originate features from inside the target chunk; originating from + * the margin doubles them along borders, since neighbours populate independently. + *

+ * Off the main thread. No chunk loading, no entity spawning through World. + */ + void populate(ChunkContext context, LimitedRegion data); +} From 1ead2063aacad060b26b982c6f1eab7d16bca3a1 Mon Sep 17 00:00:00 2001 From: Paldiu Date: Fri, 7 Aug 2026 17:15:41 -0500 Subject: [PATCH 12/32] Update Flatlands.java --- .../me/totalfreedom/totalfreedommod/world/Flatlands.java | 5 ----- 1 file changed, 5 deletions(-) diff --git a/src/main/java/me/totalfreedom/totalfreedommod/world/Flatlands.java b/src/main/java/me/totalfreedom/totalfreedommod/world/Flatlands.java index 2c3e9b097..d2535e47d 100644 --- a/src/main/java/me/totalfreedom/totalfreedommod/world/Flatlands.java +++ b/src/main/java/me/totalfreedom/totalfreedommod/world/Flatlands.java @@ -3,11 +3,6 @@ import java.io.File; import org.bukkit.*; -import org.bukkit.block.Block; -import org.bukkit.block.BlockFace; - -import net.kyori.adventure.text.Component; -import net.kyori.adventure.text.format.NamedTextColor; import me.totalfreedom.totalfreedommod.TotalFreedomMod; import me.totalfreedom.totalfreedommod.config.ConfigEntry; From 3158ed0c2588c130e89a85dfd6b784f04f465158 Mon Sep 17 00:00:00 2001 From: Paldiu Date: Fri, 7 Aug 2026 17:23:42 -0500 Subject: [PATCH 13/32] Some more basics --- .../world/GenerationProfile.java | 22 +++++++++++++++++++ .../world/noise/NoiseType.java | 8 +++++++ .../world/profile/GenerationMode.java | 14 ++++++++++++ .../world/profile/StageSet.java | 18 +++++++++++++++ .../world/profile/json/Defaulted.java | 14 ++++++++++++ .../world/stage/feature/Feature.java | 17 ++++++++++++++ 6 files changed, 93 insertions(+) create mode 100644 src/main/java/me/totalfreedom/totalfreedommod/world/GenerationProfile.java create mode 100644 src/main/java/me/totalfreedom/totalfreedommod/world/noise/NoiseType.java create mode 100644 src/main/java/me/totalfreedom/totalfreedommod/world/profile/GenerationMode.java create mode 100644 src/main/java/me/totalfreedom/totalfreedommod/world/profile/StageSet.java create mode 100644 src/main/java/me/totalfreedom/totalfreedommod/world/profile/json/Defaulted.java create mode 100644 src/main/java/me/totalfreedom/totalfreedommod/world/stage/feature/Feature.java diff --git a/src/main/java/me/totalfreedom/totalfreedommod/world/GenerationProfile.java b/src/main/java/me/totalfreedom/totalfreedommod/world/GenerationProfile.java new file mode 100644 index 000000000..231c6f65d --- /dev/null +++ b/src/main/java/me/totalfreedom/totalfreedommod/world/GenerationProfile.java @@ -0,0 +1,22 @@ +package me.totalfreedom.totalfreedommod.world; + +import me.totalfreedom.totalfreedommod.world.profile.Bounds; +import me.totalfreedom.totalfreedommod.world.profile.GenerationMode; +import me.totalfreedom.totalfreedommod.world.profile.Palette; +import me.totalfreedom.totalfreedommod.world.profile.StageSet; +import me.totalfreedom.totalfreedommod.world.profile.WorldSettings; + +/** + * One world's compiled profile. Built by the compiler once the seed is known, immutable after that. + *

+ * Holds only what more than one stage needs. Per-stage tuning belongs to the stage: terrain noise + * and spline in the generator, surface rules in the designer, feature specs in the populator. + */ +public record GenerationProfile(String name, + GenerationMode mode, + Bounds bounds, + Palette palette, + StageSet stages, + WorldSettings world) +{ +} diff --git a/src/main/java/me/totalfreedom/totalfreedommod/world/noise/NoiseType.java b/src/main/java/me/totalfreedom/totalfreedommod/world/noise/NoiseType.java new file mode 100644 index 000000000..dcc6519f3 --- /dev/null +++ b/src/main/java/me/totalfreedom/totalfreedommod/world/noise/NoiseType.java @@ -0,0 +1,8 @@ +package me.totalfreedom.totalfreedommod.world.noise; + +/** Which of Bukkit's generators backs a field. Both live in org.bukkit.util.noise, so no new deps. */ +public enum NoiseType +{ + PERLIN, + SIMPLEX +} diff --git a/src/main/java/me/totalfreedom/totalfreedommod/world/profile/GenerationMode.java b/src/main/java/me/totalfreedom/totalfreedommod/world/profile/GenerationMode.java new file mode 100644 index 000000000..27f3f25e9 --- /dev/null +++ b/src/main/java/me/totalfreedom/totalfreedommod/world/profile/GenerationMode.java @@ -0,0 +1,14 @@ +package me.totalfreedom.totalfreedommod.world.profile; + +/** + * Which generator a profile uses. Read once when the profile compiles, to pick the stages. + *

+ * FLAT samples no noise at all. HEIGHTMAP is 2D, has no overhangs, and covers most survival worlds. + * DENSITY is 3D, gets you overhangs, and takes roughly fifty times the samples. + */ +public enum GenerationMode +{ + FLAT, + HEIGHTMAP, + DENSITY +} diff --git a/src/main/java/me/totalfreedom/totalfreedommod/world/profile/StageSet.java b/src/main/java/me/totalfreedom/totalfreedommod/world/profile/StageSet.java new file mode 100644 index 000000000..538003fe3 --- /dev/null +++ b/src/main/java/me/totalfreedom/totalfreedommod/world/profile/StageSet.java @@ -0,0 +1,18 @@ +package me.totalfreedom.totalfreedommod.world.profile; + +import me.totalfreedom.totalfreedommod.world.base.Carver; +import me.totalfreedom.totalfreedommod.world.base.Designer; +import me.totalfreedom.totalfreedommod.world.base.Generator; +import me.totalfreedom.totalfreedommod.world.base.Populator; + +/** + * The four stages a profile runs, picked from the mode at compile time. + *

+ * Also how the chunk context reaches the generator and carver for its column heights. + */ +public record StageSet(Generator generator, + Designer designer, + Carver carver, + Populator populator) +{ +} diff --git a/src/main/java/me/totalfreedom/totalfreedommod/world/profile/json/Defaulted.java b/src/main/java/me/totalfreedom/totalfreedommod/world/profile/json/Defaulted.java new file mode 100644 index 000000000..d50cf201c --- /dev/null +++ b/src/main/java/me/totalfreedom/totalfreedommod/world/profile/json/Defaulted.java @@ -0,0 +1,14 @@ +package me.totalfreedom.totalfreedommod.world.profile.json; + +/** + * A profile record that can fill in its own missing fields. + *

+ * Return a copy with every null replaced, including whole sections that were absent. Gson leaves + * absent fields null and never runs compact constructors, so defaults cannot live in the record. + * + * @param the implementing record's own type + */ +public interface Defaulted +{ + T withDefaults(); +} diff --git a/src/main/java/me/totalfreedom/totalfreedommod/world/stage/feature/Feature.java b/src/main/java/me/totalfreedom/totalfreedommod/world/stage/feature/Feature.java new file mode 100644 index 000000000..3e4564629 --- /dev/null +++ b/src/main/java/me/totalfreedom/totalfreedommod/world/stage/feature/Feature.java @@ -0,0 +1,17 @@ +package me.totalfreedom.totalfreedommod.world.stage.feature; + +import org.bukkit.generator.LimitedRegion; + +import me.totalfreedom.totalfreedommod.world.base.ChunkContext; + +/** + * One kind of thing that can be placed into a finished chunk. + *

+ * Shared and run concurrently, so keep implementations immutable and take every roll from the + * context's random. The origin is always inside the target chunk, but the overhang may not be, so + * bounds check writes with isInRegion. + */ +public interface Feature +{ + void place(ChunkContext context, LimitedRegion region, FeatureSpec spec, int x, int y, int z); +} From 1fc5f6753f318546bfaab340cf98930dc5589b81 Mon Sep 17 00:00:00 2001 From: Paldiu Date: Sun, 9 Aug 2026 00:59:40 -0500 Subject: [PATCH 14/32] committing actually implemented files --- .../world/GenerationProfile.java | 17 +- .../totalfreedommod/world/base/Carver.java | 4 +- .../world/base/ChunkContext.java | 165 ++++++++++++++++++ .../totalfreedommod/world/base/Designer.java | 5 +- .../totalfreedommod/world/profile/Anchor.java | 14 ++ .../world/profile/BedrockMode.java | 14 ++ .../world/profile/BiomeFilter.java | 53 ++++++ .../totalfreedommod/world/profile/Bounds.java | 22 +++ .../totalfreedommod/world/profile/Depth.java | 54 ++++++ .../world/profile/FeatureDetail.java | 75 ++++++++ .../world/profile/FeatureSpec.java | 30 ++++ .../world/profile/GenerationMode.java | 14 -- .../world/profile/Materials.java | 16 ++ .../world/profile/Palette.java | 63 +++++++ .../world/profile/ProfileError.java | 19 ++ .../world/profile/ProfileException.java | 29 +++ .../world/profile/StageSet.java | 18 -- .../world/profile/WorldSettings.java | 32 ++++ .../world/profile/json/Defaulted.java | 14 -- .../world/stage/feature/Feature.java | 25 ++- .../world/stage/feature/OreFeature.java | 112 ++++++++++++ .../resources/worlds/flatlands-template.json | 31 ++++ .../resources/worlds/overworld-template.json | 133 ++++++++++++++ 23 files changed, 900 insertions(+), 59 deletions(-) create mode 100644 src/main/java/me/totalfreedom/totalfreedommod/world/base/ChunkContext.java create mode 100644 src/main/java/me/totalfreedom/totalfreedommod/world/profile/Anchor.java create mode 100644 src/main/java/me/totalfreedom/totalfreedommod/world/profile/BedrockMode.java create mode 100644 src/main/java/me/totalfreedom/totalfreedommod/world/profile/BiomeFilter.java create mode 100644 src/main/java/me/totalfreedom/totalfreedommod/world/profile/Bounds.java create mode 100644 src/main/java/me/totalfreedom/totalfreedommod/world/profile/Depth.java create mode 100644 src/main/java/me/totalfreedom/totalfreedommod/world/profile/FeatureDetail.java create mode 100644 src/main/java/me/totalfreedom/totalfreedommod/world/profile/FeatureSpec.java delete mode 100644 src/main/java/me/totalfreedom/totalfreedommod/world/profile/GenerationMode.java create mode 100644 src/main/java/me/totalfreedom/totalfreedommod/world/profile/Materials.java create mode 100644 src/main/java/me/totalfreedom/totalfreedommod/world/profile/Palette.java create mode 100644 src/main/java/me/totalfreedom/totalfreedommod/world/profile/ProfileError.java create mode 100644 src/main/java/me/totalfreedom/totalfreedommod/world/profile/ProfileException.java delete mode 100644 src/main/java/me/totalfreedom/totalfreedommod/world/profile/StageSet.java create mode 100644 src/main/java/me/totalfreedom/totalfreedommod/world/profile/WorldSettings.java delete mode 100644 src/main/java/me/totalfreedom/totalfreedommod/world/profile/json/Defaulted.java create mode 100644 src/main/java/me/totalfreedom/totalfreedommod/world/stage/feature/OreFeature.java create mode 100644 src/main/resources/worlds/flatlands-template.json create mode 100644 src/main/resources/worlds/overworld-template.json diff --git a/src/main/java/me/totalfreedom/totalfreedommod/world/GenerationProfile.java b/src/main/java/me/totalfreedom/totalfreedommod/world/GenerationProfile.java index 231c6f65d..a1ae1badf 100644 --- a/src/main/java/me/totalfreedom/totalfreedommod/world/GenerationProfile.java +++ b/src/main/java/me/totalfreedom/totalfreedommod/world/GenerationProfile.java @@ -1,22 +1,25 @@ package me.totalfreedom.totalfreedommod.world; +import java.util.List; + import me.totalfreedom.totalfreedommod.world.profile.Bounds; -import me.totalfreedom.totalfreedommod.world.profile.GenerationMode; +import me.totalfreedom.totalfreedommod.world.profile.FeatureSpec; import me.totalfreedom.totalfreedommod.world.profile.Palette; -import me.totalfreedom.totalfreedommod.world.profile.StageSet; +import me.totalfreedom.totalfreedommod.world.profile.Shape; import me.totalfreedom.totalfreedommod.world.profile.WorldSettings; /** - * One world's compiled profile. Built by the compiler once the seed is known, immutable after that. + * One world's profile. Pure data, and every field is already checked, so anything reading this can + * take it at face value. *

- * Holds only what more than one stage needs. Per-stage tuning belongs to the stage: terrain noise - * and spline in the generator, surface rules in the designer, feature specs in the populator. + * Holds no stage objects. The chunk generator pattern matches {@link Shape} once to pick its + * stages, which keeps this package free of any dependency on the generation code. */ public record GenerationProfile(String name, - GenerationMode mode, Bounds bounds, + Shape shape, Palette palette, - StageSet stages, + List features, WorldSettings world) { } diff --git a/src/main/java/me/totalfreedom/totalfreedommod/world/base/Carver.java b/src/main/java/me/totalfreedom/totalfreedommod/world/base/Carver.java index 65db8dde1..db0f38134 100644 --- a/src/main/java/me/totalfreedom/totalfreedommod/world/base/Carver.java +++ b/src/main/java/me/totalfreedom/totalfreedommod/world/base/Carver.java @@ -29,8 +29,8 @@ public interface Carver * Lowest y this carver touches, inclusive. Read once per chunk and checked outside the block * loop, so a deep carver costs nothing in the columns above it. Must be constant. *

- * Return {@link Integer#MAX_VALUE} here and {@link Integer#MIN_VALUE} from {@link #maxY()} for - * a no-op carver. + * A world with no caves has no carver at all, so there is never a reason to report a range that + * cannot match. */ int minY(); diff --git a/src/main/java/me/totalfreedom/totalfreedommod/world/base/ChunkContext.java b/src/main/java/me/totalfreedom/totalfreedommod/world/base/ChunkContext.java new file mode 100644 index 000000000..239068166 --- /dev/null +++ b/src/main/java/me/totalfreedom/totalfreedommod/world/base/ChunkContext.java @@ -0,0 +1,165 @@ +package me.totalfreedom.totalfreedommod.world.base; + +import java.util.Optional; +import java.util.Random; +import java.util.stream.IntStream; + +import org.bukkit.generator.WorldInfo; + +import me.totalfreedom.totalfreedommod.util.Lazy; +import me.totalfreedom.totalfreedommod.world.GenerationProfile; + +/** + * Per-chunk state for one generation stage. The stages themselves are shared and run concurrently, + * so everything that varies per chunk lives here instead. + *

+ * Build a fresh one in each callback, use it on that thread, throw it away. The stages are separate + * Bukkit calls and the populator phase may not even run on the generating thread, so nothing can be + * handed from one stage to the next. + *

+ * Both column heights are worked out on first use and cached: terrain height comes from + * {@link Generator#surfaceHeight}, column top from walking that down while + * {@link Carver#isCarved} keeps saying true. A full pass over the columns is 256 samples. + */ +public final class ChunkContext +{ + private final GenerationProfile profile; + private final Stages stages; + private final WorldInfo info; + private final int chunkX; + private final int chunkZ; + private final Random random; + private final Lazy terrainHeights; + private final Lazy columnTops; + + private ChunkContext(final GenerationProfile profile, + final Stages stages, + final WorldInfo info, + final Random random, + final int chunkX, + final int chunkZ) + { + this.profile = profile; + this.stages = stages; + this.info = info; + this.chunkX = chunkX; + this.chunkZ = chunkZ; + this.random = random; + this.terrainHeights = new Lazy<>(this::computeTerrainHeights); + this.columnTops = new Lazy<>(this::computeColumnTops); + } + + /** The random comes from Bukkit already seeded per chunk, so decoration is reproducible. */ + public static ChunkContext of(final GenerationProfile profile, + final Stages stages, + final WorldInfo info, + final Random random, + final int chunkX, + final int chunkZ) + { + return new ChunkContext(profile, stages, info, random, chunkX, chunkZ); + } + + /** Terrain height for a local column, before carving. */ + public int terrainHeight(final int localX, final int localZ) + { + return this.terrainHeights.get()[index(localX, localZ)]; + } + + /** + * Highest solid block for a local column, after carving. Differs from the terrain height + * wherever a cave broke the surface, so this is the one the {@link Designer} wants. + */ + public int columnTop(final int localX, final int localZ) + { + return this.columnTops.get()[index(localX, localZ)]; + } + + /** Local x (0-15) to absolute. */ + public int worldX(final int localX) + { + return (this.chunkX << 4) + localX; + } + + /** Local z (0-15) to absolute. */ + public int worldZ(final int localZ) + { + return (this.chunkZ << 4) + localZ; + } + + public GenerationProfile getProfile() + { + return this.profile; + } + + public Stages getStages() + { + return this.stages; + } + + public WorldInfo getInfo() + { + return this.info; + } + + public Random getRandom() + { + return this.random; + } + + public int getChunkX() + { + return this.chunkX; + } + + public int getChunkZ() + { + return this.chunkZ; + } + + /** One full pass over the chunk's 256 columns, sampling {@link Generator#surfaceHeight} once each. */ + private int[] computeTerrainHeights() + { + final int[] heights = new int[256]; + final Generator generator = this.stages.generator(); + + IntStream.range(0, 256) + .forEach(i -> heights[i] = generator.surfaceHeight(this, this.worldX(i & 0xF), this.worldZ(i >> 4))); + + return heights; + } + + /** One full pass over the chunk's 256 columns, walking each down through the carver. */ + private int[] computeColumnTops() + { + final int[] tops = new int[256]; + final Optional carver = this.stages.carver(); + + IntStream.range(0, 256) + .forEach(i -> tops[i] = this.computeColumnTop(i & 0xF, i >> 4, carver)); + + return tops; + } + + private int computeColumnTop(final int localX, final int localZ, final Optional carver) + { + int y = this.terrainHeight(localX, localZ); + + if (carver.isEmpty()) + return y; + + final Carver actualCarver = carver.get(); + final int worldX = this.worldX(localX); + final int worldZ = this.worldZ(localZ); + + while (y >= actualCarver.minY() && y <= actualCarver.maxY() && actualCarver.isCarved(this, worldX, y, worldZ)) + y--; + + return y; + } + + private static int index(final int localX, final int localZ) + { + return localZ << 4 | localX; + } +} diff --git a/src/main/java/me/totalfreedom/totalfreedommod/world/base/Designer.java b/src/main/java/me/totalfreedom/totalfreedommod/world/base/Designer.java index 4ddee4926..aa490b3f4 100644 --- a/src/main/java/me/totalfreedom/totalfreedommod/world/base/Designer.java +++ b/src/main/java/me/totalfreedom/totalfreedommod/world/base/Designer.java @@ -21,7 +21,10 @@ public interface Designer void surface(ChunkContext context, ChunkGenerator.ChunkData data); /** - * Writes the bedrock floor, and a roof if the world wants one. Runs under generateBedrock. + * Writes the bedrock layer. Runs under generateBedrock. + *

+ * How much to write comes from the profile's bedrock mode: a floor, a floor and a roof, or + * nothing at all for a world made of floating islands. *

* The {@link Carver} runs after this and will happily eat bedrock, so keep its * {@link Carver#minY()} above this layer. diff --git a/src/main/java/me/totalfreedom/totalfreedommod/world/profile/Anchor.java b/src/main/java/me/totalfreedom/totalfreedommod/world/profile/Anchor.java new file mode 100644 index 000000000..55a9d7188 --- /dev/null +++ b/src/main/java/me/totalfreedom/totalfreedommod/world/profile/Anchor.java @@ -0,0 +1,14 @@ +package me.totalfreedom.totalfreedommod.world.profile; + +/** + * Where a feature wants to be placed, which decides how the populator picks its y. + *

+ * SURFACE sits on the column top, so trees and grass land on the ground. RANGE picks a y somewhere + * inside the feature's own minY and maxY, which is what ores want. Either way minY and maxY still + * filter, so a surface feature can say it never appears above a certain height. + */ +public enum Anchor +{ + SURFACE, + RANGE +} diff --git a/src/main/java/me/totalfreedom/totalfreedommod/world/profile/BedrockMode.java b/src/main/java/me/totalfreedom/totalfreedommod/world/profile/BedrockMode.java new file mode 100644 index 000000000..ef651a9a6 --- /dev/null +++ b/src/main/java/me/totalfreedom/totalfreedommod/world/profile/BedrockMode.java @@ -0,0 +1,14 @@ +package me.totalfreedom.totalfreedommod.world.profile; + +/** + * What the designer writes for a world's bedrock layer. + *

+ * FLOOR is the usual one. FLOOR_AND_ROOF caps the world off as well, which is what the nether + * wants. NONE writes nothing at all, for worlds made of floating islands. + */ +public enum BedrockMode +{ + FLOOR, + FLOOR_AND_ROOF, + NONE +} diff --git a/src/main/java/me/totalfreedom/totalfreedommod/world/profile/BiomeFilter.java b/src/main/java/me/totalfreedom/totalfreedommod/world/profile/BiomeFilter.java new file mode 100644 index 000000000..d3078ae9c --- /dev/null +++ b/src/main/java/me/totalfreedom/totalfreedommod/world/profile/BiomeFilter.java @@ -0,0 +1,53 @@ +package me.totalfreedom.totalfreedommod.world.profile; + +import java.util.Set; + +import org.bukkit.block.Biome; + +/** + * Which biomes a surface rule or feature applies to. + *

+ * Replaces a null biome meaning "any", so nothing downstream has to remember which way round that + * was. {@link Any} says so out loud. + */ +public sealed interface BiomeFilter +{ + record Any() implements BiomeFilter + { + @Override + public boolean matches(final Biome biome) + { + return true; + } + } + + /** @throws IllegalArgumentException if the set is empty, which would match nothing at all */ + record OneOf(Set biomes) implements BiomeFilter + { + public OneOf + { + if (biomes.isEmpty()) + throw new IllegalArgumentException("biomes must not be empty"); + + biomes = Set.copyOf(biomes); + } + + @Override + public boolean matches(final Biome biome) + { + return this.biomes.contains(biome); + } + } + + boolean matches(Biome biome); + + static BiomeFilter any() + { + return new Any(); + } + + static BiomeFilter of(final Set biomes) + { + return new OneOf(biomes); + } +} diff --git a/src/main/java/me/totalfreedom/totalfreedommod/world/profile/Bounds.java b/src/main/java/me/totalfreedom/totalfreedommod/world/profile/Bounds.java new file mode 100644 index 000000000..6655ffebf --- /dev/null +++ b/src/main/java/me/totalfreedom/totalfreedommod/world/profile/Bounds.java @@ -0,0 +1,22 @@ +package me.totalfreedom.totalfreedommod.world.profile; + +import java.util.Optional; + +/** + * A world's vertical limits and water line. Clamped against WorldInfo when the profile parses. + *

+ * An empty seaLevel means the world has no sea at all, which is what the end and flat worlds want. + * There is no "sea level 0 means off" rule to remember. + */ +public record Bounds(int minY, int maxY, Optional seaLevel) +{ + /** @throws IllegalArgumentException if minY is not below maxY, or a sea level falls outside them */ + public Bounds + { + if (minY >= maxY) + throw new IllegalArgumentException("minY (" + minY + ") must be below maxY (" + maxY + ")"); + + if (seaLevel.isPresent() && (seaLevel.get() < minY || seaLevel.get() > maxY)) + throw new IllegalArgumentException("seaLevel (" + seaLevel.get() + ") must fall within " + minY + " to " + maxY); + } +} diff --git a/src/main/java/me/totalfreedom/totalfreedommod/world/profile/Depth.java b/src/main/java/me/totalfreedom/totalfreedommod/world/profile/Depth.java new file mode 100644 index 000000000..5c6775dec --- /dev/null +++ b/src/main/java/me/totalfreedom/totalfreedommod/world/profile/Depth.java @@ -0,0 +1,54 @@ +package me.totalfreedom.totalfreedommod.world.profile; + +/** + * How far below the top of a column a surface rule applies. Depth 0 is the surface block itself. + *

+ * Two shapes only, so there is no magic number standing in for "all the way down". A rule that + * wants everything below a point says {@link Rest}, not {@link Range} with a made up upper bound. + */ +public sealed interface Depth +{ + /** Inclusive both ends. Use {@link #range} so from cannot end up above to. */ + record Range(int from, int to) implements Depth + { + @Override + public boolean contains(final int depth) + { + return depth >= this.from && depth <= this.to; + } + } + + /** From here to the bottom of the column. */ + record Rest(int from) implements Depth + { + @Override + public boolean contains(final int depth) + { + return depth >= this.from; + } + } + + boolean contains(int depth); + + /** @throws IllegalArgumentException if from is above to, or either is negative */ + static Depth range(final int from, final int to) + { + if (from > to) + throw new IllegalArgumentException("from (" + from + ") must not be above to (" + to + ")"); + + if (from < 0 || to < 0) + throw new IllegalArgumentException("from and to must not be negative"); + + return new Range(from, to); + } + + static Depth at(final int depth) + { + return range(depth, depth); + } + + static Depth rest(final int from) + { + return new Rest(from); + } +} diff --git a/src/main/java/me/totalfreedom/totalfreedommod/world/profile/FeatureDetail.java b/src/main/java/me/totalfreedom/totalfreedommod/world/profile/FeatureDetail.java new file mode 100644 index 000000000..b3cd48791 --- /dev/null +++ b/src/main/java/me/totalfreedom/totalfreedommod/world/profile/FeatureDetail.java @@ -0,0 +1,75 @@ +package me.totalfreedom.totalfreedommod.world.profile; + +import org.bukkit.TreeType; +import org.bukkit.block.data.BlockData; + +/** + * What a feature actually places. One variant per kind, each carrying only the settings that kind + * uses. + *

+ * This is why there is no shared size field meaning vein length here and radius there. A boulder + * has a radius, a patch has a spread, and a tree has neither. + *

+ * Being sealed also means the populator's switch over these is checked for exhaustiveness, so + * adding a variant will not compile until something knows how to place it. + */ +public sealed interface FeatureDetail +{ + /** A vein buried in the filler block. size is how many blocks the vein is. */ + record Ore(BlockData block, int size) implements FeatureDetail + { + @Override + public Anchor anchor() + { + return Anchor.RANGE; + } + } + + /** A scatter across the surface. spread is how far from the origin it reaches. */ + record Patch(BlockData block, int spread) implements FeatureDetail + { + @Override + public Anchor anchor() + { + return Anchor.SURFACE; + } + } + + /** A hollowed bowl filled with fluid. */ + record Lake(BlockData fluid, int radius) implements FeatureDetail + { + @Override + public Anchor anchor() + { + return Anchor.RANGE; + } + } + + /** A rough blob resting on the ground. */ + record Boulder(BlockData block, int radius) implements FeatureDetail + { + @Override + public Anchor anchor() + { + return Anchor.SURFACE; + } + } + + /** + * A tree, named exactly. A sapling would not be enough, since spruce alone covers REDWOOD, + * TALL_REDWOOD, and MEGA_REDWOOD. + *

+ * For a vanilla-style mix, write one entry per variant and let rarity do the weighting: nine + * REDWOOD to one MEGA_REDWOOD. + */ + record Tree(TreeType type) implements FeatureDetail + { + @Override + public Anchor anchor() + { + return Anchor.SURFACE; + } + } + + Anchor anchor(); +} diff --git a/src/main/java/me/totalfreedom/totalfreedommod/world/profile/FeatureSpec.java b/src/main/java/me/totalfreedom/totalfreedommod/world/profile/FeatureSpec.java new file mode 100644 index 000000000..c87128c0e --- /dev/null +++ b/src/main/java/me/totalfreedom/totalfreedommod/world/profile/FeatureSpec.java @@ -0,0 +1,30 @@ +package me.totalfreedom.totalfreedommod.world.profile; + +import org.bukkit.block.Biome; + +/** + * One feature entry. rarity is attempts per chunk, and minY and maxY bound where it may appear. + *

+ * What gets placed, and every setting particular to it, lives in the detail. + */ +public record FeatureSpec(int rarity, + int minY, + int maxY, + BiomeFilter biomes, + FeatureDetail detail) +{ + /** @throws IllegalArgumentException if rarity is below one, or minY is above maxY */ + public FeatureSpec + { + if (rarity < 1) + throw new IllegalArgumentException("rarity (" + rarity + ") must be at least one"); + + if (minY > maxY) + throw new IllegalArgumentException("minY (" + minY + ") must not be above maxY (" + maxY + ")"); + } + + public boolean appliesTo(final Biome biome) + { + return this.biomes.matches(biome); + } +} diff --git a/src/main/java/me/totalfreedom/totalfreedommod/world/profile/GenerationMode.java b/src/main/java/me/totalfreedom/totalfreedommod/world/profile/GenerationMode.java deleted file mode 100644 index 27f3f25e9..000000000 --- a/src/main/java/me/totalfreedom/totalfreedommod/world/profile/GenerationMode.java +++ /dev/null @@ -1,14 +0,0 @@ -package me.totalfreedom.totalfreedommod.world.profile; - -/** - * Which generator a profile uses. Read once when the profile compiles, to pick the stages. - *

- * FLAT samples no noise at all. HEIGHTMAP is 2D, has no overhangs, and covers most survival worlds. - * DENSITY is 3D, gets you overhangs, and takes roughly fifty times the samples. - */ -public enum GenerationMode -{ - FLAT, - HEIGHTMAP, - DENSITY -} diff --git a/src/main/java/me/totalfreedom/totalfreedommod/world/profile/Materials.java b/src/main/java/me/totalfreedom/totalfreedommod/world/profile/Materials.java new file mode 100644 index 000000000..037dbc7fe --- /dev/null +++ b/src/main/java/me/totalfreedom/totalfreedommod/world/profile/Materials.java @@ -0,0 +1,16 @@ +package me.totalfreedom.totalfreedommod.world.profile; + +import org.bukkit.block.data.BlockData; + +/** + * The three blocks every profile needs: what the generator fills with, what the sea is made of, and + * what the floor is, plus how much of a bedrock layer to write. + *

+ * Already looked up, so never call createBlockData in a stage. + */ +public record Materials(BlockData defaultBlock, + BlockData fluidBlock, + BlockData bedrockBlock, + BedrockMode bedrock) +{ +} diff --git a/src/main/java/me/totalfreedom/totalfreedommod/world/profile/Palette.java b/src/main/java/me/totalfreedom/totalfreedommod/world/profile/Palette.java new file mode 100644 index 000000000..b43164637 --- /dev/null +++ b/src/main/java/me/totalfreedom/totalfreedommod/world/profile/Palette.java @@ -0,0 +1,63 @@ +package me.totalfreedom.totalfreedommod.world.profile; + +import java.util.List; + +import org.bukkit.block.Biome; + +import me.totalfreedom.totalfreedommod.world.noise.NoiseField; + +/** + * What the world is made of, plus the climate that decides which materials go where. + *

+ * Climate and biomes are shared: the biome provider, the designer's biome-specific rules, and the + * populator's biome filters all read them. Nothing can ask Bukkit for a biome mid-generation. + */ +public record Palette(Materials materials, + List surface, + Climate climate, + Biome fallback, + List biomes) +{ + /** @throws IllegalArgumentException if the rule list is empty, which would leave bare filler */ + public Palette + { + if (surface.isEmpty()) + throw new IllegalArgumentException("surface rules must not be empty"); + + surface = List.copyOf(surface); + biomes = List.copyOf(biomes); + } + + /** The two noise fields a position is scored against to land it in a band. */ + public record Climate(NoiseField temperature, NoiseField humidity, double scale) + { + } + + /** + * One biome table entry. First band containing both values wins, and anything no band covers + * gets the palette's fallback. + * + * @throws IllegalArgumentException if either range is inverted or falls outside -1 to 1 + */ + public record BiomeBand(Biome biome, + double minTemperature, + double maxTemperature, + double minHumidity, + double maxHumidity) + { + public BiomeBand + { + if (minTemperature > maxTemperature || minTemperature < -1 || maxTemperature > 1) + throw new IllegalArgumentException("temperature range must fall within -1 to 1"); + + if (minHumidity > maxHumidity || minHumidity < -1 || maxHumidity > 1) + throw new IllegalArgumentException("humidity range must fall within -1 to 1"); + } + + public boolean matches(final double temperature, final double humidity) + { + return temperature >= this.minTemperature && temperature <= this.maxTemperature + && humidity >= this.minHumidity && humidity <= this.maxHumidity; + } + } +} diff --git a/src/main/java/me/totalfreedom/totalfreedommod/world/profile/ProfileError.java b/src/main/java/me/totalfreedom/totalfreedommod/world/profile/ProfileError.java new file mode 100644 index 000000000..ff4cf1980 --- /dev/null +++ b/src/main/java/me/totalfreedom/totalfreedommod/world/profile/ProfileError.java @@ -0,0 +1,19 @@ +package me.totalfreedom.totalfreedommod.world.profile; + +/** + * One thing wrong with a profile file. + *

+ * path is the dotted key it came from, so an admin can go straight to it rather than hunting. + * + * @param path for example {@code shape.caves.threshold} + * @param message what was wrong, in words, including what was found + */ +public record ProfileError(String path, String message) +{ + /** Renders as {@code shape.caves.threshold: expected a number, got "0.5x"}. */ + @Override + public String toString() + { + return this.path + ": " + this.message; + } +} diff --git a/src/main/java/me/totalfreedom/totalfreedommod/world/profile/ProfileException.java b/src/main/java/me/totalfreedom/totalfreedommod/world/profile/ProfileException.java new file mode 100644 index 000000000..566ccfd15 --- /dev/null +++ b/src/main/java/me/totalfreedom/totalfreedommod/world/profile/ProfileException.java @@ -0,0 +1,29 @@ +package me.totalfreedom.totalfreedommod.world.profile; + +import java.util.List; + +/** + * Thrown when a profile file cannot be turned into a world. + *

+ * Carries every problem found, not just the first one, so an admin fixes the file in one pass + * instead of rerunning after each typo. + *

+ * Checked on purpose. Parsing a profile is the one place a bad world can enter the system, and the + * caller has to decide what to do about it rather than letting it slip past. + */ +public final class ProfileException extends Exception +{ + private final List errors; + + public ProfileException(final String worldName, final List errors) + { + super("Profile for world '" + worldName + "' has " + errors.size() + " problem(s)"); + + this.errors = List.copyOf(errors); + } + + public List getErrors() + { + return this.errors; + } +} diff --git a/src/main/java/me/totalfreedom/totalfreedommod/world/profile/StageSet.java b/src/main/java/me/totalfreedom/totalfreedommod/world/profile/StageSet.java deleted file mode 100644 index 538003fe3..000000000 --- a/src/main/java/me/totalfreedom/totalfreedommod/world/profile/StageSet.java +++ /dev/null @@ -1,18 +0,0 @@ -package me.totalfreedom.totalfreedommod.world.profile; - -import me.totalfreedom.totalfreedommod.world.base.Carver; -import me.totalfreedom.totalfreedommod.world.base.Designer; -import me.totalfreedom.totalfreedommod.world.base.Generator; -import me.totalfreedom.totalfreedommod.world.base.Populator; - -/** - * The four stages a profile runs, picked from the mode at compile time. - *

- * Also how the chunk context reaches the generator and carver for its column heights. - */ -public record StageSet(Generator generator, - Designer designer, - Carver carver, - Populator populator) -{ -} diff --git a/src/main/java/me/totalfreedom/totalfreedommod/world/profile/WorldSettings.java b/src/main/java/me/totalfreedom/totalfreedommod/world/profile/WorldSettings.java new file mode 100644 index 000000000..c954f81de --- /dev/null +++ b/src/main/java/me/totalfreedom/totalfreedommod/world/profile/WorldSettings.java @@ -0,0 +1,32 @@ +package me.totalfreedom.totalfreedommod.world.profile; + +import java.util.Optional; + +import org.bukkit.World; + +/** + * Settings applied to the WorldCreator at creation. Never read during chunk generation. + *

+ * keepSpawnLoaded makes createWorld generate the entire spawn square on the main thread. Leave it + * off unless a world needs it. + */ +public record WorldSettings(World.Environment environment, + boolean generateStructures, + boolean keepSpawnLoaded, + Optional seed, + VanillaFlags vanilla) +{ + /** + * Which vanilla generation steps run alongside ours. All default off. The chunk generator hands + * these to Bukkit through its shouldGenerate methods. + *

+ * Turning on decorations gets you vanilla trees, flowers, and ore without writing any features. + */ + public record VanillaFlags(boolean surface, + boolean caves, + boolean decorations, + boolean mobs, + boolean structures) + { + } +} diff --git a/src/main/java/me/totalfreedom/totalfreedommod/world/profile/json/Defaulted.java b/src/main/java/me/totalfreedom/totalfreedommod/world/profile/json/Defaulted.java deleted file mode 100644 index d50cf201c..000000000 --- a/src/main/java/me/totalfreedom/totalfreedommod/world/profile/json/Defaulted.java +++ /dev/null @@ -1,14 +0,0 @@ -package me.totalfreedom.totalfreedommod.world.profile.json; - -/** - * A profile record that can fill in its own missing fields. - *

- * Return a copy with every null replaced, including whole sections that were absent. Gson leaves - * absent fields null and never runs compact constructors, so defaults cannot live in the record. - * - * @param the implementing record's own type - */ -public interface Defaulted -{ - T withDefaults(); -} diff --git a/src/main/java/me/totalfreedom/totalfreedommod/world/stage/feature/Feature.java b/src/main/java/me/totalfreedom/totalfreedommod/world/stage/feature/Feature.java index 3e4564629..2044bdf69 100644 --- a/src/main/java/me/totalfreedom/totalfreedommod/world/stage/feature/Feature.java +++ b/src/main/java/me/totalfreedom/totalfreedommod/world/stage/feature/Feature.java @@ -3,15 +3,34 @@ import org.bukkit.generator.LimitedRegion; import me.totalfreedom.totalfreedommod.world.base.ChunkContext; +import me.totalfreedom.totalfreedommod.world.profile.FeatureDetail; /** - * One kind of thing that can be placed into a finished chunk. + * Places one kind of thing into a finished chunk. + *

+ * Typed to its own detail, so a tree gets a Tree and never has to check what it was handed. The + * populator's switch over the sealed detail is what makes that safe. *

* Shared and run concurrently, so keep implementations immutable and take every roll from the * context's random. The origin is always inside the target chunk, but the overhang may not be, so * bounds check writes with isInRegion. + * + * @param the detail variant this feature places */ -public interface Feature +public interface Feature { - void place(ChunkContext context, LimitedRegion region, FeatureSpec spec, int x, int y, int z); + void place(ChunkContext context, LimitedRegion region, D detail, int x, int y, int z); + + /** + * Interpolates between two points. Written the precise way, {@code from*(1-t) + to*t}, so that + * a progress of exactly 1 returns exactly {@code to}. The shorter {@code from + t*(to-from)} + * rounds twice and can miss the far endpoint by an ulp. + *

+ * Nothing today loops far enough to reach 1, but this is shared, and the next feature to use it + * should not have to loop a particular way to stay correct. + */ + default double lerp(final double progress, final double from, final double to) + { + return from * (1.0D - progress) + to * progress; + } } diff --git a/src/main/java/me/totalfreedom/totalfreedommod/world/stage/feature/OreFeature.java b/src/main/java/me/totalfreedom/totalfreedommod/world/stage/feature/OreFeature.java new file mode 100644 index 000000000..b620bdc20 --- /dev/null +++ b/src/main/java/me/totalfreedom/totalfreedommod/world/stage/feature/OreFeature.java @@ -0,0 +1,112 @@ +package me.totalfreedom.totalfreedommod.world.stage.feature; + +import java.util.Random; + +import org.bukkit.Material; +import org.bukkit.generator.LimitedRegion; + +import me.totalfreedom.totalfreedommod.world.base.ChunkContext; +import me.totalfreedom.totalfreedommod.world.profile.FeatureDetail; + +/** + * A vein of ore buried in the filler block. Detail size is how many blocks the vein is worth. + *

+ * Laid along a short line with a swollen middle rather than as a ball, which is what gives a vein + * its stretched, slightly lumpy shape. The line is randomly angled on the horizontal, so veins do + * not all run the same way. + *

+ * Replaces the profile's own filler block and nothing else. That is why an ore entry works in the + * nether without being told about netherrack: the generator only ever writes the filler, so + * matching against it is the same as asking "is this untouched stone". + */ +public final class OreFeature implements Feature +{ + @Override + public void place(final ChunkContext context, + final LimitedRegion region, + final FeatureDetail.Ore detail, + final int x, + final int y, + final int z) + { + final Random random = context.getRandom(); + final Material filler = context.getProfile().palette().materials().defaultBlock().getMaterial(); + final int size = detail.size(); + + // Both ends of the line, angled anywhere on the horizontal and drifting a little vertically. + final double angle = random.nextDouble() * Math.PI; + final double reach = size / 8.0D; + + final double fromX = x + Math.sin(angle) * reach; + final double toX = x - Math.sin(angle) * reach; + final double fromZ = z + Math.cos(angle) * reach; + final double toZ = z - Math.cos(angle) * reach; + final double fromY = y + random.nextInt(3) - 2; + final double toY = y + random.nextInt(3) - 2; + + for (int step = 0; step < size; step++) + { + final double progress = step / (double) size; + + final double centreX = lerp(progress, fromX, toX); + final double centreY = lerp(progress, fromY, toY); + final double centreZ = lerp(progress, fromZ, toZ); + + // Swells to its widest halfway along and tapers back at both ends, so the vein has + // pointed tips instead of blunt ones. The +1 keeps the thinnest step at least a block. + final double swell = random.nextDouble() * size / 16.0D; + final double radius = ((Math.sin(Math.PI * progress) + 1.0D) * swell + 1.0D) / 2.0D; + + blob(region, detail, filler, centreX, centreY, centreZ, radius); + } + } + + /** Fills one sphere of the vein, skipping anything outside the region or not made of filler. */ + private static void blob(final LimitedRegion region, + final FeatureDetail.Ore detail, + final Material filler, + final double centreX, + final double centreY, + final double centreZ, + final double radius) + { + final int minX = (int) Math.floor(centreX - radius); + final int maxX = (int) Math.floor(centreX + radius); + final int minY = (int) Math.floor(centreY - radius); + final int maxY = (int) Math.floor(centreY + radius); + final int minZ = (int) Math.floor(centreZ - radius); + final int maxZ = (int) Math.floor(centreZ + radius); + + for (int blockX = minX; blockX <= maxX; blockX++) + { + final double offsetX = (blockX + 0.5D - centreX) / radius; + + if (offsetX * offsetX >= 1.0D) + continue; + + for (int blockY = minY; blockY <= maxY; blockY++) + { + final double offsetY = (blockY + 0.5D - centreY) / radius; + + if (offsetX * offsetX + offsetY * offsetY >= 1.0D) + continue; + + for (int blockZ = minZ; blockZ <= maxZ; blockZ++) + { + final double offsetZ = (blockZ + 0.5D - centreZ) / radius; + + if (offsetX * offsetX + offsetY * offsetY + offsetZ * offsetZ >= 1.0D) + continue; + + if (!region.isInRegion(blockX, blockY, blockZ)) + continue; + + if (region.getType(blockX, blockY, blockZ) != filler) + continue; + + region.setBlockData(blockX, blockY, blockZ, detail.block()); + } + } + } + } +} diff --git a/src/main/resources/worlds/flatlands-template.json b/src/main/resources/worlds/flatlands-template.json new file mode 100644 index 000000000..d9af5c466 --- /dev/null +++ b/src/main/resources/worlds/flatlands-template.json @@ -0,0 +1,31 @@ +{ + "shape": { + "mode": "flat", + "bounds": { "minY": 0, "maxY": 320 }, + "layers": "1|bedrock|59|stone|3|dirt|1|grass_block" + }, + + "palette": { + "materials": { + "defaultBlock": "stone", + "fluidBlock": "water", + "bedrockBlock": "bedrock", + "bedrock": "FLOOR" + } + }, + + "features": [], + + "world": { + "environment": "normal", + "generateStructures": false, + "keepSpawnLoaded": false, + "vanilla": { + "surface": false, + "caves": false, + "decorations": false, + "mobs": false, + "structures": false + } + } +} diff --git a/src/main/resources/worlds/overworld-template.json b/src/main/resources/worlds/overworld-template.json new file mode 100644 index 000000000..713cd548e --- /dev/null +++ b/src/main/resources/worlds/overworld-template.json @@ -0,0 +1,133 @@ +{ + "shape": { + "mode": "heightmap", + "bounds": { "minY": -64, "maxY": 320, "seaLevel": 63 }, + "terrain": { + "noise": { + "type": "simplex", + "octaves": 4, + "frequency": 0.00195, + "persistence": 0.5, + "lacunarity": 2.0, + "ridged": false + }, + "spline": { + "points": [ + [-1.00, 30], + [-0.60, 45], + [-0.30, 58], + [-0.10, 63], + [ 0.05, 68], + [ 0.30, 85], + [ 0.60, 120], + [ 1.00, 180] + ] + }, + "river": { + "noise": { + "type": "simplex", + "octaves": 2, + "frequency": 0.0016, + "persistence": 0.5, + "lacunarity": 2.0, + "ridged": false + }, + "threshold": 0.03, + "depth": 5, + "bedBlock": "gravel" + }, + "warp": 0.015 + }, + "caves": { + "noise": { + "type": "simplex", + "octaves": 3, + "frequency": 0.0078, + "persistence": 0.5, + "lacunarity": 2.0, + "ridged": true + }, + "threshold": 0.48, + "minY": -59, + "maxY": 128, + "floodLevel": -54 + } + }, + + "palette": { + "materials": { + "defaultBlock": "stone", + "fluidBlock": "water", + "bedrockBlock": "bedrock", + "bedrock": "FLOOR" + }, + "surface": [ + { "biome": "desert", "depthFrom": 0, "depthTo": 6, "block": "sand" }, + { "biome": "snowy_plains", "depthFrom": 0, "depthTo": 0, "block": "snow_block" }, + { "depthFrom": 0, "depthTo": 0, "block": "grass_block" }, + { "depthFrom": 1, "depthTo": 3, "block": "dirt" }, + { "depthFrom": 4, "block": "stone" } + ], + "climate": { + "temperature": { + "type": "simplex", + "octaves": 2, + "frequency": 0.00098, + "persistence": 0.5, + "lacunarity": 2.0, + "ridged": false + }, + "humidity": { + "type": "simplex", + "octaves": 2, + "frequency": 0.00098, + "persistence": 0.5, + "lacunarity": 2.0, + "ridged": false + }, + "scale": 1.0 + }, + "fallback": "plains", + "biomes": [ + { "biome": "snowy_plains", "minTemperature": -1.00, "maxTemperature": -0.45, "minHumidity": -1.00, "maxHumidity": 1.00 }, + { "biome": "taiga", "minTemperature": -0.45, "maxTemperature": -0.15, "minHumidity": -1.00, "maxHumidity": 1.00 }, + { "biome": "plains", "minTemperature": -0.15, "maxTemperature": 0.55, "minHumidity": -1.00, "maxHumidity": 0.10 }, + { "biome": "forest", "minTemperature": -0.15, "maxTemperature": 0.55, "minHumidity": 0.10, "maxHumidity": 1.00 }, + { "biome": "desert", "minTemperature": 0.55, "maxTemperature": 1.00, "minHumidity": -1.00, "maxHumidity": -0.10 }, + { "biome": "savanna", "minTemperature": 0.55, "maxTemperature": 1.00, "minHumidity": -0.10, "maxHumidity": 0.30 }, + { "biome": "jungle", "minTemperature": 0.55, "maxTemperature": 1.00, "minHumidity": 0.30, "maxHumidity": 1.00 } + ] + }, + + "features": [ + { "type": "ore", "rarity": 20, "minY": 0, "maxY": 192, "block": "coal_ore", "size": 17 }, + { "type": "ore", "rarity": 16, "minY": -16, "maxY": 112, "block": "copper_ore", "size": 10 }, + { "type": "ore", "rarity": 10, "minY": -24, "maxY": 56, "block": "iron_ore", "size": 9 }, + { "type": "ore", "rarity": 8, "minY": -64, "maxY": 15, "block": "redstone_ore", "size": 8 }, + { "type": "ore", "rarity": 4, "minY": -64, "maxY": 32, "block": "gold_ore", "size": 9 }, + { "type": "ore", "rarity": 2, "minY": -64, "maxY": 30, "block": "lapis_ore", "size": 7 }, + { "type": "ore", "rarity": 1, "minY": -64, "maxY": 16, "block": "diamond_ore", "size": 8 }, + { "type": "tree", "rarity": 10, "minY": 60, "maxY": 200, "block": "oak_sapling", "size": 1, "biomes": ["forest"] }, + { "type": "tree", "rarity": 8, "minY": 60, "maxY": 200, "block": "jungle_sapling", "size": 1, "biomes": ["jungle"] }, + { "type": "tree", "rarity": 6, "minY": 60, "maxY": 200, "block": "spruce_sapling", "size": 1, "biomes": ["taiga", "snowy_plains"] }, + { "type": "tree", "rarity": 1, "minY": 60, "maxY": 200, "block": "oak_sapling", "size": 1, "biomes": ["plains", "savanna"] }, + { "type": "patch", "rarity": 7, "minY": 60, "maxY": 200, "block": "short_grass", "size": 32, "biomes": ["plains", "savanna"] }, + { "type": "patch", "rarity": 2, "minY": 60, "maxY": 200, "block": "short_grass", "size": 32, "biomes": ["forest", "jungle"] }, + { "type": "patch", "rarity": 2, "minY": 60, "maxY": 200, "block": "dead_bush", "size": 6, "biomes": ["desert"] }, + { "type": "boulder", "rarity": 1, "minY": 60, "maxY": 220, "block": "mossy_cobblestone", "size": 3, "biomes": ["taiga"] }, + { "type": "lake", "rarity": 1, "minY": 40, "maxY": 90, "block": "water", "size": 6 } + ], + + "world": { + "environment": "normal", + "generateStructures": false, + "keepSpawnLoaded": false, + "vanilla": { + "surface": false, + "caves": false, + "decorations": false, + "mobs": true, + "structures": false + } + } +} From 1c5cc949f5c5fb3908de4f54d13a8d6b28683ad0 Mon Sep 17 00:00:00 2001 From: Paldiu Date: Sun, 9 Aug 2026 01:00:09 -0500 Subject: [PATCH 15/32] skeletonized --- .../totalfreedommod/world/GeneratedWorld.java | 32 ++++ .../world/GenerationService.java | 75 +++++++++ .../world/adapter/ProfileBiomeProvider.java | 38 +++++ .../world/adapter/ProfileBlockPopulator.java | 34 ++++ .../world/adapter/ProfileChunkGenerator.java | 149 ++++++++++++++++++ .../world/adapter/SpawnFinder.java | 27 ++++ .../totalfreedommod/world/base/Stages.java | 17 ++ .../world/noise/NoiseField.java | 48 ++++++ .../world/noise/NoiseProfile.java | 23 +++ .../world/profile/LayerStack.java | 58 +++++++ .../world/profile/ProfileLoader.java | 67 ++++++++ .../world/profile/ProfileParser.java | 29 ++++ .../totalfreedommod/world/profile/Shape.java | 65 ++++++++ .../totalfreedommod/world/profile/Spline.java | 27 ++++ .../world/profile/SurfaceRule.java | 17 ++ .../world/stage/DensityGenerator.java | 42 +++++ .../world/stage/FeaturePopulator.java | 32 ++++ .../world/stage/FlatGenerator.java | 35 ++++ .../world/stage/HeightmapGenerator.java | 54 +++++++ .../world/stage/LayerDesigner.java | 36 +++++ .../world/stage/NoiseCarver.java | 45 ++++++ .../world/stage/RuleDesigner.java | 38 +++++ .../world/stage/feature/BoulderFeature.java | 21 +++ .../world/stage/feature/LakeFeature.java | 26 +++ .../world/stage/feature/PatchFeature.java | 21 +++ .../world/stage/feature/TreeFeature.java | 36 +++++ 26 files changed, 1092 insertions(+) create mode 100644 src/main/java/me/totalfreedom/totalfreedommod/world/GeneratedWorld.java create mode 100644 src/main/java/me/totalfreedom/totalfreedommod/world/GenerationService.java create mode 100644 src/main/java/me/totalfreedom/totalfreedommod/world/adapter/ProfileBiomeProvider.java create mode 100644 src/main/java/me/totalfreedom/totalfreedommod/world/adapter/ProfileBlockPopulator.java create mode 100644 src/main/java/me/totalfreedom/totalfreedommod/world/adapter/ProfileChunkGenerator.java create mode 100644 src/main/java/me/totalfreedom/totalfreedommod/world/adapter/SpawnFinder.java create mode 100644 src/main/java/me/totalfreedom/totalfreedommod/world/base/Stages.java create mode 100644 src/main/java/me/totalfreedom/totalfreedommod/world/noise/NoiseField.java create mode 100644 src/main/java/me/totalfreedom/totalfreedommod/world/noise/NoiseProfile.java create mode 100644 src/main/java/me/totalfreedom/totalfreedommod/world/profile/LayerStack.java create mode 100644 src/main/java/me/totalfreedom/totalfreedommod/world/profile/ProfileLoader.java create mode 100644 src/main/java/me/totalfreedom/totalfreedommod/world/profile/ProfileParser.java create mode 100644 src/main/java/me/totalfreedom/totalfreedommod/world/profile/Shape.java create mode 100644 src/main/java/me/totalfreedom/totalfreedommod/world/profile/Spline.java create mode 100644 src/main/java/me/totalfreedom/totalfreedommod/world/profile/SurfaceRule.java create mode 100644 src/main/java/me/totalfreedom/totalfreedommod/world/stage/DensityGenerator.java create mode 100644 src/main/java/me/totalfreedom/totalfreedommod/world/stage/FeaturePopulator.java create mode 100644 src/main/java/me/totalfreedom/totalfreedommod/world/stage/FlatGenerator.java create mode 100644 src/main/java/me/totalfreedom/totalfreedommod/world/stage/HeightmapGenerator.java create mode 100644 src/main/java/me/totalfreedom/totalfreedommod/world/stage/LayerDesigner.java create mode 100644 src/main/java/me/totalfreedom/totalfreedommod/world/stage/NoiseCarver.java create mode 100644 src/main/java/me/totalfreedom/totalfreedommod/world/stage/RuleDesigner.java create mode 100644 src/main/java/me/totalfreedom/totalfreedommod/world/stage/feature/BoulderFeature.java create mode 100644 src/main/java/me/totalfreedom/totalfreedommod/world/stage/feature/LakeFeature.java create mode 100644 src/main/java/me/totalfreedom/totalfreedommod/world/stage/feature/PatchFeature.java create mode 100644 src/main/java/me/totalfreedom/totalfreedommod/world/stage/feature/TreeFeature.java diff --git a/src/main/java/me/totalfreedom/totalfreedommod/world/GeneratedWorld.java b/src/main/java/me/totalfreedom/totalfreedommod/world/GeneratedWorld.java new file mode 100644 index 000000000..9316f5fc8 --- /dev/null +++ b/src/main/java/me/totalfreedom/totalfreedommod/world/GeneratedWorld.java @@ -0,0 +1,32 @@ +package me.totalfreedom.totalfreedommod.world; + +import org.bukkit.World; + +import me.totalfreedom.totalfreedommod.TotalFreedomMod; + +/** + * A custom world built from a profile. Applies the profile's world settings to the WorldCreator and + * takes its spawn point from the spawn finder. + */ +public class GeneratedWorld extends CustomWorld +{ + private final GenerationProfile profile; + + public GeneratedWorld(final TotalFreedomMod plugin, final GenerationProfile profile, final String displayName) + { + super(plugin, profile.name(), displayName); + + this.profile = profile; + } + + @Override + protected World generateWorld() + { + + } + + public GenerationProfile getProfile() + { + return this.profile; + } +} diff --git a/src/main/java/me/totalfreedom/totalfreedommod/world/GenerationService.java b/src/main/java/me/totalfreedom/totalfreedommod/world/GenerationService.java new file mode 100644 index 000000000..d65b73e23 --- /dev/null +++ b/src/main/java/me/totalfreedom/totalfreedommod/world/GenerationService.java @@ -0,0 +1,75 @@ +package me.totalfreedom.totalfreedommod.world; + +import java.util.HashMap; +import java.util.Map; +import java.util.Optional; +import java.util.Set; + +import org.bukkit.generator.ChunkGenerator; + +import me.totalfreedom.totalfreedommod.FreedomService; +import me.totalfreedom.totalfreedommod.TotalFreedomMod; +import me.totalfreedom.totalfreedommod.world.profile.ProfileLoader; +import me.totalfreedom.totalfreedommod.world.profile.ProfileParser; + +/** + * Holds the profile registry. Files are read and parsed at startup, and only worlds that parsed + * cleanly end up in here. + *

+ * A profile that fails is logged with every problem found and then skipped, so one bad file costs + * you that world and nothing else. The rest of the plugin does not care that world generation had a + * bad day. + *

+ * Sits behind the plugin's getDefaultWorldGenerator hook, which is what lets a profile drive a + * world created through bukkit.yml or a world manager instead of only ones we create ourselves. + */ +public final class GenerationService extends FreedomService +{ + private final ProfileLoader loader; + private final ProfileParser parser; + private final Map profiles; + + public GenerationService(final TotalFreedomMod plugin) + { + super(plugin); + + this.loader = new ProfileLoader(plugin); + this.parser = new ProfileParser(); + this.profiles = new HashMap<>(); + } + + @Override + protected void onStart() + { + + } + + @Override + protected void onStop() + { + + } + + public Optional profile(final String worldName) + { + + } + + /** Empty if no profile covers the world, or if its file failed to parse. */ + public Optional generatorFor(final String worldName) + { + + } + + /** Only worlds whose profiles parsed. A file that failed does not appear here. */ + public Set available() + { + + } + + /** Re-reads every profile file. Already-loaded worlds keep the profile they were built with. */ + public void reload() + { + + } +} diff --git a/src/main/java/me/totalfreedom/totalfreedommod/world/adapter/ProfileBiomeProvider.java b/src/main/java/me/totalfreedom/totalfreedommod/world/adapter/ProfileBiomeProvider.java new file mode 100644 index 000000000..997ffe077 --- /dev/null +++ b/src/main/java/me/totalfreedom/totalfreedommod/world/adapter/ProfileBiomeProvider.java @@ -0,0 +1,38 @@ +package me.totalfreedom.totalfreedommod.world.adapter; + +import java.util.List; + +import org.bukkit.block.Biome; +import org.bukkit.generator.BiomeProvider; +import org.bukkit.generator.WorldInfo; + +import me.totalfreedom.totalfreedommod.world.GenerationProfile; + +/** + * Samples the profile's temperature and humidity noise and drops the result into a biome band. + *

+ * Sets grass and water colour and what mobs spawn, and gives the designer's rules and the + * populator's filters something to match against. + */ +public final class ProfileBiomeProvider extends BiomeProvider +{ + private final GenerationProfile profile; + + public ProfileBiomeProvider(final GenerationProfile profile) + { + this.profile = profile; + } + + @Override + public Biome getBiome(final WorldInfo worldInfo, final int x, final int y, final int z) + { + + } + + /** Must list every biome getBiome can return, or the server rejects the provider. */ + @Override + public List getBiomes(final WorldInfo worldInfo) + { + + } +} diff --git a/src/main/java/me/totalfreedom/totalfreedommod/world/adapter/ProfileBlockPopulator.java b/src/main/java/me/totalfreedom/totalfreedommod/world/adapter/ProfileBlockPopulator.java new file mode 100644 index 000000000..e40c42c96 --- /dev/null +++ b/src/main/java/me/totalfreedom/totalfreedommod/world/adapter/ProfileBlockPopulator.java @@ -0,0 +1,34 @@ +package me.totalfreedom.totalfreedommod.world.adapter; + +import java.util.Random; + +import org.bukkit.generator.BlockPopulator; +import org.bukkit.generator.LimitedRegion; +import org.bukkit.generator.WorldInfo; + +import me.totalfreedom.totalfreedommod.world.GenerationProfile; + +/** + * Runs the profile's populator as a Bukkit block populator. + *

+ * Builds its own ChunkContext, since this may not run on the thread that generated the chunk. + */ +public final class ProfileBlockPopulator extends BlockPopulator +{ + private final GenerationProfile profile; + + public ProfileBlockPopulator(final GenerationProfile profile) + { + this.profile = profile; + } + + @Override + public void populate(final WorldInfo worldInfo, + final Random random, + final int chunkX, + final int chunkZ, + final LimitedRegion limitedRegion) + { + + } +} diff --git a/src/main/java/me/totalfreedom/totalfreedommod/world/adapter/ProfileChunkGenerator.java b/src/main/java/me/totalfreedom/totalfreedommod/world/adapter/ProfileChunkGenerator.java new file mode 100644 index 000000000..2deb32660 --- /dev/null +++ b/src/main/java/me/totalfreedom/totalfreedommod/world/adapter/ProfileChunkGenerator.java @@ -0,0 +1,149 @@ +package me.totalfreedom.totalfreedommod.world.adapter; + +import java.util.List; +import java.util.Random; + +import org.bukkit.HeightMap; +import org.bukkit.Location; +import org.bukkit.World; +import org.bukkit.generator.BiomeProvider; +import org.bukkit.generator.BlockPopulator; +import org.bukkit.generator.ChunkGenerator; +import org.bukkit.generator.WorldInfo; + +import me.totalfreedom.totalfreedommod.world.GenerationProfile; +import me.totalfreedom.totalfreedommod.world.base.Stages; +import me.totalfreedom.totalfreedommod.world.profile.Shape; + +/** + * Bridges Bukkit's callbacks to the profile's stages. One stage per callback: noise to the + * generator, surface and bedrock to the designer, caves to the carver, populators to the populator. + *

+ * The only class that knows what order Bukkit runs things in, and the only one that turns a + * {@link Shape} into actual stage objects. That pattern match happens once here, in + * {@link #wire(GenerationProfile)}, which is why no per-chunk code ever asks what mode a world is. + *

+ * Builds a fresh ChunkContext in each callback, since nothing carries over between them. + *

+ * Owns the cave loop: reads the carver's y range once before looping, leaves bedrock alone, and + * fills cleared blocks with water instead of air below the depth the profile sets. + *

+ * The shouldGenerate methods come straight from the profile's vanilla flags. + */ +public final class ProfileChunkGenerator extends ChunkGenerator +{ + private final GenerationProfile profile; + private final Stages stages; + + public ProfileChunkGenerator(final GenerationProfile profile) + { + this.profile = profile; + this.stages = wire(profile); + } + + /** Picks the stages for a profile's shape. The one place that switch is written. */ + private static Stages wire(final GenerationProfile profile) + { + + } + + @Override + public void generateNoise(final WorldInfo worldInfo, + final Random random, + final int chunkX, + final int chunkZ, + final ChunkData chunkData) + { + + } + + @Override + public void generateSurface(final WorldInfo worldInfo, + final Random random, + final int chunkX, + final int chunkZ, + final ChunkData chunkData) + { + + } + + @Override + public void generateBedrock(final WorldInfo worldInfo, + final Random random, + final int chunkX, + final int chunkZ, + final ChunkData chunkData) + { + + } + + /** Walks the carver's y range and clears whatever it flags. No carver means no work. */ + @Override + public void generateCaves(final WorldInfo worldInfo, + final Random random, + final int chunkX, + final int chunkZ, + final ChunkData chunkData) + { + + } + + @Override + public int getBaseHeight(final WorldInfo worldInfo, + final Random random, + final int x, + final int z, + final HeightMap heightMap) + { + + } + + @Override + public Location getFixedSpawnLocation(final World world, final Random random) + { + + } + + @Override + public BiomeProvider getDefaultBiomeProvider(final WorldInfo worldInfo) + { + + } + + /** Returns the block populator wrapping the profile's populator. */ + @Override + public List getDefaultPopulators(final World world) + { + + } + + @Override + public boolean shouldGenerateSurface() + { + + } + + @Override + public boolean shouldGenerateCaves() + { + + } + + @Override + public boolean shouldGenerateDecorations() + { + + } + + @Override + public boolean shouldGenerateMobs() + { + + } + + @Override + public boolean shouldGenerateStructures() + { + + } +} diff --git a/src/main/java/me/totalfreedom/totalfreedommod/world/adapter/SpawnFinder.java b/src/main/java/me/totalfreedom/totalfreedommod/world/adapter/SpawnFinder.java new file mode 100644 index 000000000..6aff046fc --- /dev/null +++ b/src/main/java/me/totalfreedom/totalfreedommod/world/adapter/SpawnFinder.java @@ -0,0 +1,27 @@ +package me.totalfreedom.totalfreedommod.world.adapter; + +import org.bukkit.Location; +import org.bukkit.World; + +import me.totalfreedom.totalfreedommod.world.GenerationProfile; + +/** + * Picks a spawn point by asking the generator's height function. + *

+ * Loads no chunks, since that function is pure maths. + */ +public final class SpawnFinder +{ + private final GenerationProfile profile; + + public SpawnFinder(final GenerationProfile profile) + { + this.profile = profile; + } + + /** Searches out from origin for the first column that is not underwater or void. */ + public Location findSpawn(final World world) + { + + } +} diff --git a/src/main/java/me/totalfreedom/totalfreedommod/world/base/Stages.java b/src/main/java/me/totalfreedom/totalfreedommod/world/base/Stages.java new file mode 100644 index 000000000..23bdbc051 --- /dev/null +++ b/src/main/java/me/totalfreedom/totalfreedommod/world/base/Stages.java @@ -0,0 +1,17 @@ +package me.totalfreedom.totalfreedommod.world.base; + +import java.util.Optional; + +/** + * The four stages wired up for one world. + *

+ * Built once by the chunk generator, which pattern matches the profile's shape to decide what goes + * in here. An empty carver means the world has no caves, so nothing has to invent a y range that + * never matches. + */ +public record Stages(Generator generator, + Designer designer, + Optional carver, + Populator populator) +{ +} \ No newline at end of file diff --git a/src/main/java/me/totalfreedom/totalfreedommod/world/noise/NoiseField.java b/src/main/java/me/totalfreedom/totalfreedommod/world/noise/NoiseField.java new file mode 100644 index 000000000..4f80862d0 --- /dev/null +++ b/src/main/java/me/totalfreedom/totalfreedommod/world/noise/NoiseField.java @@ -0,0 +1,48 @@ +package me.totalfreedom.totalfreedommod.world.noise; + +import org.bukkit.util.noise.OctaveGenerator; + +/** + * A built noise field. Immutable, sampled concurrently from every worldgen thread. + *

+ * Build once at compile and hand it to a stage as a final field; that is what guarantees other + * threads see it fully constructed. Never reconfigure it afterward, setScale mutates and may only + * be touched inside {@link #of}. + */ +public final class NoiseField +{ + private final NoiseProfile profile; + private final OctaveGenerator generator; + + private NoiseField(final NoiseProfile profile, final OctaveGenerator generator) + { + this.profile = profile; + this.generator = generator; + } + + /** + * @param role stable name like "terrain" or "caves", mixed into the seed so two fields in one + * profile cannot end up on the same stream + */ + public static NoiseField of(final NoiseProfile profile, final long seed, final String role) + { + + } + + /** 2D sample, in the range -1 to 1. */ + public double sample(final int x, final int z) + { + + } + + /** 3D sample, in the range -1 to 1. */ + public double sample(final int x, final int y, final int z) + { + + } + + public NoiseProfile getProfile() + { + return this.profile; + } +} diff --git a/src/main/java/me/totalfreedom/totalfreedommod/world/noise/NoiseProfile.java b/src/main/java/me/totalfreedom/totalfreedommod/world/noise/NoiseProfile.java new file mode 100644 index 000000000..212c58cd7 --- /dev/null +++ b/src/main/java/me/totalfreedom/totalfreedommod/world/noise/NoiseProfile.java @@ -0,0 +1,23 @@ +package me.totalfreedom.totalfreedommod.world.noise; + +/** + * One noise field's settings. Feed it to {@link NoiseField#of} to get a usable field. + *

+ * Amplitude is set by the spline for terrain and by the threshold for caves, so it is not a knob + * here. The seed comes from the field's role name mixed with the world seed when the profile + * parses. + * + * @throws IllegalArgumentException if octaves is below one, or frequency is not positive + */ +public record NoiseProfile(NoiseType type, + int octaves, + double frequency, + double persistence, + double lacunarity, + boolean ridged) +{ + public NoiseProfile + { + + } +} diff --git a/src/main/java/me/totalfreedom/totalfreedommod/world/profile/LayerStack.java b/src/main/java/me/totalfreedom/totalfreedommod/world/profile/LayerStack.java new file mode 100644 index 000000000..ac6cbb0f1 --- /dev/null +++ b/src/main/java/me/totalfreedom/totalfreedommod/world/profile/LayerStack.java @@ -0,0 +1,58 @@ +package me.totalfreedom.totalfreedommod.world.profile; + +import org.bukkit.block.data.BlockData; + +/** + * Block layers for a flat world, bottom to top, starting at the world's minY. + *

+ * Takes the same syntax as flatlands.generate_params in the config. + *

+ * A class rather than a record because the layers are arrays. A record would hand out its backing + * arrays through the generated accessors, and anything holding the profile could then rewrite a + * compiled world's layers in place. + */ +public final class LayerStack +{ + private final BlockData[] blocks; + private final int[] heights; + + private LayerStack(final BlockData[] blocks, final int[] heights) + { + this.blocks = blocks; + this.heights = heights; + } + + /** + * @param spec e.g. {@code "16|stone|32|dirt|1|grass_block"}; the legacy comma form also works + * @throws IllegalArgumentException if the spec is malformed, names an unknown block, or gives a + * height below one + */ + public static LayerStack parse(final String spec) + { + + } + + /** How many layers there are, bottom to top. */ + public int size() + { + + } + + /** @throws IndexOutOfBoundsException if the layer does not exist */ + public BlockData blockAt(final int layer) + { + + } + + /** @throws IndexOutOfBoundsException if the layer does not exist */ + public int heightAt(final int layer) + { + + } + + /** Total height of every layer combined. */ + public int totalHeight() + { + + } +} diff --git a/src/main/java/me/totalfreedom/totalfreedommod/world/profile/ProfileLoader.java b/src/main/java/me/totalfreedom/totalfreedommod/world/profile/ProfileLoader.java new file mode 100644 index 000000000..e1e3e6f68 --- /dev/null +++ b/src/main/java/me/totalfreedom/totalfreedommod/world/profile/ProfileLoader.java @@ -0,0 +1,67 @@ +package me.totalfreedom.totalfreedommod.world.profile; + +import java.io.File; +import java.util.Optional; +import java.util.Set; + +import com.google.gson.JsonObject; + +import me.totalfreedom.totalfreedommod.TotalFreedomMod; + +/** + * Reads the .json files in the data folder's worlds directory. A file's name is the world's name, + * and every file in there is a world we manage. + *

+ * {@link #copyTemplate} is the only way a template would ever reach the disk, + * and once it does it stops being a template and becomes that world's profile. + *

+ * Only reads and parses JSON, so it is safe off the main thread. Turning that JSON into a profile + * is {@link ProfileParser}, which is not. + */ +public final class ProfileLoader +{ + private static final String WORLDS_DIRECTORY = "worlds"; + + private final TotalFreedomMod plugin; + private final File directory; + + public ProfileLoader(final TotalFreedomMod plugin) + { + this.plugin = plugin; + this.directory = new File(plugin.getDataFolder(), WORLDS_DIRECTORY); + } + + /** Every world with a profile file on disk. */ + public Set available() + { + + } + + /** + * One world's raw JSON, off disk. Empty if it has no file, which is not an error. + * + * @throws ProfileException if the file exists but is not readable JSON + */ + public Optional read(final String worldName) throws ProfileException + { + + } + + /** Names of the templates bundled in the jar. Never worlds. */ + public Set templates() + { + + } + + /** + * Writes a bundled template out as a new world's profile. This is what creates a managed world, + * so refuse if a file for that world already exists rather than overwriting someone's edits. + * + * @param templateName one of {@link #templates()} + * @param worldName the world to create, which becomes the file name + */ + public boolean copyTemplate(final String templateName, final String worldName) + { + + } +} diff --git a/src/main/java/me/totalfreedom/totalfreedommod/world/profile/ProfileParser.java b/src/main/java/me/totalfreedom/totalfreedommod/world/profile/ProfileParser.java new file mode 100644 index 000000000..2deb32109 --- /dev/null +++ b/src/main/java/me/totalfreedom/totalfreedommod/world/profile/ProfileParser.java @@ -0,0 +1,29 @@ +package me.totalfreedom.totalfreedommod.world.profile; + +import com.google.gson.JsonObject; + +import me.totalfreedom.totalfreedommod.world.GenerationProfile; + +/** + * Turns a profile file into a checked {@link GenerationProfile}, or explains why it cannot. + *

+ * A missing required key is an error; a missing optional one is {@code Optional.empty()}. + * Nothing is quietly defaulted into something that generates the wrong terrain. + *

+ * Collect every problem before giving up, so one run of the server tells an admin everything wrong + * with the file. Stopping at the first error means fixing typos one server restart at a time. + *

+ * Main thread only, since block and biome names are looked up here. Reading the file is not, so do + * that first and hand the parsed JSON in. + */ +public final class ProfileParser +{ + /** + * @param worldName the file's name, which becomes the world's name + * @throws ProfileException carrying every problem found, never just the first + */ + public GenerationProfile parse(final String worldName, final JsonObject root) throws ProfileException + { + + } +} diff --git a/src/main/java/me/totalfreedom/totalfreedommod/world/profile/Shape.java b/src/main/java/me/totalfreedom/totalfreedommod/world/profile/Shape.java new file mode 100644 index 000000000..ada5503e7 --- /dev/null +++ b/src/main/java/me/totalfreedom/totalfreedommod/world/profile/Shape.java @@ -0,0 +1,65 @@ +package me.totalfreedom.totalfreedommod.world.profile; + +import java.util.Optional; + +import org.bukkit.block.data.BlockData; + +import me.totalfreedom.totalfreedommod.world.noise.NoiseProfile; + +/** + * How a world's terrain gets formed. One variant per generator, and each carries exactly the + * settings that generator reads. + *

+ * This is what stops a profile saying flat and then setting terrain noise, or saying heightmap with + * no terrain at all. Those states cannot be typed, so nothing has to check for them. + *

+ * Pattern match it once when wiring up the chunk generator to pick the stages. Nothing per chunk + * and nothing per block should ever look at it again. + */ +public sealed interface Shape +{ + /** Fixed layers, no noise. */ + record Flat(LayerStack layers) implements Shape + { + } + + /** 2D height through a spline. No overhangs. */ + record Heightmap(Terrain terrain, + Optional river, + Optional caves) implements Shape + { + } + + /** 3D density. Overhangs and floating islands, at roughly fifty times the samples. */ + record Density(NoiseProfile noise, + double warp, + Optional caves) implements Shape + { + } + + /** warp offsets the sample coordinates by a second noise. */ + record Terrain(NoiseProfile noise, Spline spline, double warp) + { + } + + /** Pulls height toward sea level where the noise is near zero. */ + record River(NoiseProfile noise, double threshold, int depth, BlockData bedBlock) + { + } + + /** + * floodLevel is the y below which a carved out block fills with water instead of air. + *

+ * Keep minY above the bedrock layer, since carving runs after bedrock is written. + * + * @throws IllegalArgumentException if minY is above maxY + */ + record Caves(NoiseProfile noise, double threshold, int minY, int maxY, int floodLevel) + { + public Caves + { + if (minY > maxY) + throw new IllegalArgumentException("minY (" + minY + ") must not be above maxY (" + maxY + ")"); + } + } +} diff --git a/src/main/java/me/totalfreedom/totalfreedommod/world/profile/Spline.java b/src/main/java/me/totalfreedom/totalfreedommod/world/profile/Spline.java new file mode 100644 index 000000000..a6541f2e6 --- /dev/null +++ b/src/main/java/me/totalfreedom/totalfreedommod/world/profile/Spline.java @@ -0,0 +1,27 @@ +package me.totalfreedom.totalfreedommod.world.profile; + +import java.util.List; + +/** + * Maps raw noise to a terrain height through control points. Binary search plus a lerp per column. + *

+ * Plateaus, cliffs, and flat plains all come out of one array of points. + */ +public record Spline(double[] inputs, double[] outputs) +{ + /** + * @param points {@code [noise, height]} pairs + * @throws IllegalArgumentException if fewer than two points, or the noise values are not + * strictly ascending, which would make the search ambiguous + */ + public static Spline of(final List points) + { + + } + + /** Clamps to the first and last point outside their range. */ + public double apply(final double noise) + { + + } +} diff --git a/src/main/java/me/totalfreedom/totalfreedommod/world/profile/SurfaceRule.java b/src/main/java/me/totalfreedom/totalfreedommod/world/profile/SurfaceRule.java new file mode 100644 index 000000000..83693f222 --- /dev/null +++ b/src/main/java/me/totalfreedom/totalfreedommod/world/profile/SurfaceRule.java @@ -0,0 +1,17 @@ +package me.totalfreedom.totalfreedommod.world.profile; + +import org.bukkit.block.Biome; +import org.bukkit.block.data.BlockData; + +/** + * One surface rule: which biomes it covers, how far down it reaches, and what to put there. + *

+ * The designer walks its rules in order and takes the first match, so put the specific ones first. + */ +public record SurfaceRule(BiomeFilter biomes, Depth depth, BlockData block) +{ + public boolean matches(final Biome biome, final int depth) + { + + } +} diff --git a/src/main/java/me/totalfreedom/totalfreedommod/world/stage/DensityGenerator.java b/src/main/java/me/totalfreedom/totalfreedommod/world/stage/DensityGenerator.java new file mode 100644 index 000000000..e3e63d8cc --- /dev/null +++ b/src/main/java/me/totalfreedom/totalfreedommod/world/stage/DensityGenerator.java @@ -0,0 +1,42 @@ +package me.totalfreedom.totalfreedommod.world.stage; + +import org.bukkit.generator.ChunkGenerator; + +import me.totalfreedom.totalfreedommod.world.base.ChunkContext; +import me.totalfreedom.totalfreedommod.world.base.Generator; +import me.totalfreedom.totalfreedommod.world.noise.NoiseField; +import me.totalfreedom.totalfreedommod.world.profile.Bounds; +import me.totalfreedom.totalfreedommod.world.profile.Materials; + +/** + * 3D mode. Samples on a grid in all three directions and interpolates between the samples, which + * gets you overhangs and floating islands. + *

+ * Roughly fifty times the samples of heightmap mode, so only use it if a world actually needs + * those shapes. + */ +public final class DensityGenerator implements Generator +{ + private final NoiseField density; + private final Bounds bounds; + private final Materials materials; + + public DensityGenerator(final NoiseField density, final Bounds bounds, final Materials materials) + { + this.density = density; + this.bounds = bounds; + this.materials = materials; + } + + @Override + public void generateBase(final ChunkContext context, final ChunkGenerator.ChunkData data) + { + + } + + @Override + public int surfaceHeight(final ChunkContext context, final int worldX, final int worldZ) + { + + } +} diff --git a/src/main/java/me/totalfreedom/totalfreedommod/world/stage/FeaturePopulator.java b/src/main/java/me/totalfreedom/totalfreedommod/world/stage/FeaturePopulator.java new file mode 100644 index 000000000..54ef1aa46 --- /dev/null +++ b/src/main/java/me/totalfreedom/totalfreedommod/world/stage/FeaturePopulator.java @@ -0,0 +1,32 @@ +package me.totalfreedom.totalfreedommod.world.stage; + +import java.util.List; + +import org.bukkit.generator.LimitedRegion; + +import me.totalfreedom.totalfreedommod.world.base.ChunkContext; +import me.totalfreedom.totalfreedommod.world.base.Populator; +import me.totalfreedom.totalfreedommod.world.profile.FeatureSpec; +import me.totalfreedom.totalfreedommod.world.stage.feature.FeatureRegistry; + +/** + * Rolls each feature in the profile against the chunk and hands off the hits. This only decides + * what gets placed and where; the features do the placing. + */ +public final class FeaturePopulator implements Populator +{ + private final List specs; + private final FeatureRegistry registry; + + public FeaturePopulator(final List specs, final FeatureRegistry registry) + { + this.specs = specs; + this.registry = registry; + } + + @Override + public void populate(final ChunkContext context, final LimitedRegion data) + { + + } +} diff --git a/src/main/java/me/totalfreedom/totalfreedommod/world/stage/FlatGenerator.java b/src/main/java/me/totalfreedom/totalfreedommod/world/stage/FlatGenerator.java new file mode 100644 index 000000000..595e514d2 --- /dev/null +++ b/src/main/java/me/totalfreedom/totalfreedommod/world/stage/FlatGenerator.java @@ -0,0 +1,35 @@ +package me.totalfreedom.totalfreedommod.world.stage; + +import org.bukkit.generator.ChunkGenerator; + +import me.totalfreedom.totalfreedommod.world.base.ChunkContext; +import me.totalfreedom.totalfreedommod.world.base.Generator; +import me.totalfreedom.totalfreedommod.world.profile.Bounds; +import me.totalfreedom.totalfreedommod.world.profile.LayerStack; + +/** + * Flat mode. Samples no noise at all and writes each layer as a single setRegion call. + */ +public final class FlatGenerator implements Generator +{ + private final LayerStack layers; + private final Bounds bounds; + + public FlatGenerator(final LayerStack layers, final Bounds bounds) + { + this.layers = layers; + this.bounds = bounds; + } + + @Override + public void generateBase(final ChunkContext context, final ChunkGenerator.ChunkData data) + { + + } + + @Override + public int surfaceHeight(final ChunkContext context, final int worldX, final int worldZ) + { + + } +} diff --git a/src/main/java/me/totalfreedom/totalfreedommod/world/stage/HeightmapGenerator.java b/src/main/java/me/totalfreedom/totalfreedommod/world/stage/HeightmapGenerator.java new file mode 100644 index 000000000..55415a79a --- /dev/null +++ b/src/main/java/me/totalfreedom/totalfreedommod/world/stage/HeightmapGenerator.java @@ -0,0 +1,54 @@ +package me.totalfreedom.totalfreedommod.world.stage; + +import org.bukkit.generator.ChunkGenerator; + +import me.totalfreedom.totalfreedommod.world.base.ChunkContext; +import me.totalfreedom.totalfreedommod.world.base.Generator; +import me.totalfreedom.totalfreedommod.world.noise.NoiseField; +import me.totalfreedom.totalfreedommod.world.profile.Bounds; +import me.totalfreedom.totalfreedommod.world.profile.Materials; +import me.totalfreedom.totalfreedommod.world.profile.Spline; + +/** + * The default mode. 2D noise through a spline, with rivers pulling height toward sea level. No + * overhangs, and it covers most of what a custom survival world wants. + *

+ * Sample on a grid and interpolate between the samples. Sampling every block is 98,304 positions + * per chunk, times however many octaves the noise has. + */ +public final class HeightmapGenerator implements Generator +{ + private final NoiseField terrain; + private final NoiseField river; + private final Spline spline; + private final Bounds bounds; + private final Materials materials; + private final double warp; + + public HeightmapGenerator(final NoiseField terrain, + final NoiseField river, + final Spline spline, + final Bounds bounds, + final Materials materials, + final double warp) + { + this.terrain = terrain; + this.river = river; + this.spline = spline; + this.bounds = bounds; + this.materials = materials; + this.warp = warp; + } + + @Override + public void generateBase(final ChunkContext context, final ChunkGenerator.ChunkData data) + { + + } + + @Override + public int surfaceHeight(final ChunkContext context, final int worldX, final int worldZ) + { + + } +} diff --git a/src/main/java/me/totalfreedom/totalfreedommod/world/stage/LayerDesigner.java b/src/main/java/me/totalfreedom/totalfreedommod/world/stage/LayerDesigner.java new file mode 100644 index 000000000..ec11dffcf --- /dev/null +++ b/src/main/java/me/totalfreedom/totalfreedommod/world/stage/LayerDesigner.java @@ -0,0 +1,36 @@ +package me.totalfreedom.totalfreedommod.world.stage; + +import org.bukkit.generator.ChunkGenerator; + +import me.totalfreedom.totalfreedommod.world.base.ChunkContext; +import me.totalfreedom.totalfreedommod.world.base.Designer; +import me.totalfreedom.totalfreedommod.world.profile.LayerStack; +import me.totalfreedom.totalfreedommod.world.profile.Materials; + +/** + * Flat mode's designer. The layer stack already named every block, so surface is a no-op and only + * bedrock gets written here. + */ +public final class LayerDesigner implements Designer +{ + private final LayerStack layers; + private final Materials materials; + + public LayerDesigner(final LayerStack layers, final Materials materials) + { + this.layers = layers; + this.materials = materials; + } + + @Override + public void surface(final ChunkContext context, final ChunkGenerator.ChunkData data) + { + + } + + @Override + public void bedrock(final ChunkContext context, final ChunkGenerator.ChunkData data) + { + + } +} diff --git a/src/main/java/me/totalfreedom/totalfreedommod/world/stage/NoiseCarver.java b/src/main/java/me/totalfreedom/totalfreedommod/world/stage/NoiseCarver.java new file mode 100644 index 000000000..182eb60d0 --- /dev/null +++ b/src/main/java/me/totalfreedom/totalfreedommod/world/stage/NoiseCarver.java @@ -0,0 +1,45 @@ +package me.totalfreedom.totalfreedommod.world.stage; + +import me.totalfreedom.totalfreedommod.world.base.Carver; +import me.totalfreedom.totalfreedommod.world.base.ChunkContext; +import me.totalfreedom.totalfreedommod.world.noise.NoiseField; + +/** + * Cuts caves and ravines wherever the noise goes past the threshold. + *

+ * Tighten the threshold as you get near the context's terrain height and cave mouths blend into the + * hillside instead of cutting a flat wall into it. + */ +public final class NoiseCarver implements Carver +{ + private final NoiseField noise; + private final double threshold; + private final int minY; + private final int maxY; + + public NoiseCarver(final NoiseField noise, final double threshold, final int minY, final int maxY) + { + this.noise = noise; + this.threshold = threshold; + this.minY = minY; + this.maxY = maxY; + } + + @Override + public boolean isCarved(final ChunkContext context, final int worldX, final int y, final int worldZ) + { + + } + + @Override + public int minY() + { + return this.minY; + } + + @Override + public int maxY() + { + return this.maxY; + } +} diff --git a/src/main/java/me/totalfreedom/totalfreedommod/world/stage/RuleDesigner.java b/src/main/java/me/totalfreedom/totalfreedommod/world/stage/RuleDesigner.java new file mode 100644 index 000000000..79df600c2 --- /dev/null +++ b/src/main/java/me/totalfreedom/totalfreedommod/world/stage/RuleDesigner.java @@ -0,0 +1,38 @@ +package me.totalfreedom.totalfreedommod.world.stage; + +import java.util.List; + +import org.bukkit.generator.ChunkGenerator; + +import me.totalfreedom.totalfreedommod.world.base.ChunkContext; +import me.totalfreedom.totalfreedommod.world.base.Designer; +import me.totalfreedom.totalfreedommod.world.profile.Materials; +import me.totalfreedom.totalfreedommod.world.profile.SurfaceRule; + +/** + * Applies the profile's surface rules. Walks each column down from the context's column top with a + * depth counter that resets on air gaps, so cave floors get their own treatment. + */ +public final class RuleDesigner implements Designer +{ + private final List rules; + private final Materials materials; + + public RuleDesigner(final List rules, final Materials materials) + { + this.rules = rules; + this.materials = materials; + } + + @Override + public void surface(final ChunkContext context, final ChunkGenerator.ChunkData data) + { + + } + + @Override + public void bedrock(final ChunkContext context, final ChunkGenerator.ChunkData data) + { + + } +} diff --git a/src/main/java/me/totalfreedom/totalfreedommod/world/stage/feature/BoulderFeature.java b/src/main/java/me/totalfreedom/totalfreedommod/world/stage/feature/BoulderFeature.java new file mode 100644 index 000000000..c2cfac7e1 --- /dev/null +++ b/src/main/java/me/totalfreedom/totalfreedommod/world/stage/feature/BoulderFeature.java @@ -0,0 +1,21 @@ +package me.totalfreedom.totalfreedommod.world.stage.feature; + +import org.bukkit.generator.LimitedRegion; + +import me.totalfreedom.totalfreedommod.world.base.ChunkContext; +import me.totalfreedom.totalfreedommod.world.profile.FeatureSpec; + +/** A rough blob sitting on the surface. Spec size is the radius. */ +public final class BoulderFeature implements Feature +{ + @Override + public void place(final ChunkContext context, + final LimitedRegion region, + final FeatureSpec spec, + final int x, + final int y, + final int z) + { + + } +} diff --git a/src/main/java/me/totalfreedom/totalfreedommod/world/stage/feature/LakeFeature.java b/src/main/java/me/totalfreedom/totalfreedommod/world/stage/feature/LakeFeature.java new file mode 100644 index 000000000..c14d7d33c --- /dev/null +++ b/src/main/java/me/totalfreedom/totalfreedommod/world/stage/feature/LakeFeature.java @@ -0,0 +1,26 @@ +package me.totalfreedom.totalfreedommod.world.stage.feature; + +import org.bukkit.generator.LimitedRegion; + +import me.totalfreedom.totalfreedommod.world.base.ChunkContext; +import me.totalfreedom.totalfreedommod.world.profile.FeatureSpec; + +/** + * A dip filled with fluid. Spec size is the radius. + *

+ * The only feature that removes blocks as well as placing them, so it needs to clear the bowl + * before it fills it. + */ +public final class LakeFeature implements Feature +{ + @Override + public void place(final ChunkContext context, + final LimitedRegion region, + final FeatureSpec spec, + final int x, + final int y, + final int z) + { + + } +} diff --git a/src/main/java/me/totalfreedom/totalfreedommod/world/stage/feature/PatchFeature.java b/src/main/java/me/totalfreedom/totalfreedommod/world/stage/feature/PatchFeature.java new file mode 100644 index 000000000..d549ca370 --- /dev/null +++ b/src/main/java/me/totalfreedom/totalfreedommod/world/stage/feature/PatchFeature.java @@ -0,0 +1,21 @@ +package me.totalfreedom.totalfreedommod.world.stage.feature; + +import org.bukkit.generator.LimitedRegion; + +import me.totalfreedom.totalfreedommod.world.base.ChunkContext; +import me.totalfreedom.totalfreedommod.world.profile.FeatureSpec; + +/** A scatter of blocks on the surface; flowers, grass, that sort of thing. Spec size is spread. */ +public final class PatchFeature implements Feature +{ + @Override + public void place(final ChunkContext context, + final LimitedRegion region, + final FeatureSpec spec, + final int x, + final int y, + final int z) + { + + } +} diff --git a/src/main/java/me/totalfreedom/totalfreedommod/world/stage/feature/TreeFeature.java b/src/main/java/me/totalfreedom/totalfreedommod/world/stage/feature/TreeFeature.java new file mode 100644 index 000000000..1daf45f4c --- /dev/null +++ b/src/main/java/me/totalfreedom/totalfreedommod/world/stage/feature/TreeFeature.java @@ -0,0 +1,36 @@ +package me.totalfreedom.totalfreedommod.world.stage.feature; + +import java.security.InvalidParameterException; +import java.util.HashMap; +import java.util.Map; + +import org.bukkit.Location; +import org.bukkit.Material; +import org.bukkit.Tag; +import org.bukkit.TreeType; +import org.bukkit.block.BlockType; +import org.bukkit.generator.LimitedRegion; + +import me.totalfreedom.totalfreedommod.world.base.ChunkContext; +import me.totalfreedom.totalfreedommod.world.profile.FeatureDetail; +import me.totalfreedom.totalfreedommod.world.profile.FeatureSpec; + +/** + * Grows a tree. The spec's block names the sapling, and the sapling picks the species, so + * oak_sapling grows an oak and spruce_sapling grows a spruce. + *

+ * Hands off to LimitedRegion#generateTree, which knows every vanilla tree shape and handles the + * canopy crossing a chunk border. + */ +public final class TreeFeature implements Feature +{ + @Override + public void place(final ChunkContext context, + final LimitedRegion region, + final FeatureDetail.Tree detail, + final int x, + final int y, + final int z) + { + } +} From 23ac9c9c84065125797a2743565938edcba4f3c5 Mon Sep 17 00:00:00 2001 From: Paldiu Date: Sun, 9 Aug 2026 10:28:22 -0500 Subject: [PATCH 16/32] adjustments --- .../totalfreedommod/util/FUtil.java | 8 +++ .../world/profile/LayerStack.java | 51 ++++++++++++++++-- .../world/profile/SurfaceRule.java | 2 +- .../world/stage/FeaturePopulator.java | 53 +++++++++++++++++++ .../world/stage/feature/BoulderFeature.java | 6 +-- .../world/stage/feature/Feature.java | 13 ----- .../world/stage/feature/FeatureRegistry.java | 39 ++++++++++++++ .../world/stage/feature/LakeFeature.java | 7 ++- .../world/stage/feature/OreFeature.java | 7 +-- .../world/stage/feature/PatchFeature.java | 6 +-- 10 files changed, 162 insertions(+), 30 deletions(-) create mode 100644 src/main/java/me/totalfreedom/totalfreedommod/world/stage/feature/FeatureRegistry.java diff --git a/src/main/java/me/totalfreedom/totalfreedommod/util/FUtil.java b/src/main/java/me/totalfreedom/totalfreedommod/util/FUtil.java index 1b38f6769..1b0928458 100644 --- a/src/main/java/me/totalfreedom/totalfreedommod/util/FUtil.java +++ b/src/main/java/me/totalfreedom/totalfreedommod/util/FUtil.java @@ -400,6 +400,14 @@ public static T getField(Object from, String name) return null; } + /** + * Interpolates between two points. + */ + public static final double lerp(final double progress, final double from, final double to) + { + return from * (1.0D - progress) + to * progress; + } + public static NamedTextColor randomChatColor() { return CHAT_COLOR_POOL.get(RANDOM.nextInt(CHAT_COLOR_POOL.size())); diff --git a/src/main/java/me/totalfreedom/totalfreedommod/world/profile/LayerStack.java b/src/main/java/me/totalfreedom/totalfreedommod/world/profile/LayerStack.java index ac6cbb0f1..152b482e1 100644 --- a/src/main/java/me/totalfreedom/totalfreedommod/world/profile/LayerStack.java +++ b/src/main/java/me/totalfreedom/totalfreedommod/world/profile/LayerStack.java @@ -1,5 +1,10 @@ package me.totalfreedom.totalfreedommod.world.profile; +import java.util.Arrays; +import java.util.Locale; +import java.util.stream.IntStream; + +import org.bukkit.Material; import org.bukkit.block.data.BlockData; /** @@ -23,36 +28,76 @@ private LayerStack(final BlockData[] blocks, final int[] heights) } /** + * @apiNote Uses bitwise operations instead of standard mathematical operators for efficiency. * @param spec e.g. {@code "16|stone|32|dirt|1|grass_block"}; the legacy comma form also works * @throws IllegalArgumentException if the spec is malformed, names an unknown block, or gives a * height below one */ public static LayerStack parse(final String spec) { + if (spec == null || spec.trim().isEmpty()) + throw new IllegalArgumentException("Spec cannot be empty"); + + String[] split = spec.split("[,|]"); + if ((split.length & 1) != 0) + throw new IllegalArgumentException("Invalid spec format. Expected pairs of height and material."); + + final int pairCount = split.length >> 1; // divides by 2 + final BlockData[] blocks = new BlockData[pairCount]; + final int[] heights = new int[pairCount]; + + IntStream.range(0, pairCount) + .forEach(i -> + { + final int heightIdx = i << 1; // i * 2 + final int materialIdx = heightIdx | 1; // (i * 2) + 1 + + heights[i] = Integer.parseInt(split[heightIdx].trim()); + + final Material mat = Material.valueOf(split[materialIdx].trim().toUpperCase(Locale.ROOT)); + blocks[i] = mat.createBlockData(); + }); + + final LayerStack stack = new LayerStack(blocks, heights); + if (stack.totalHeight() > 320) + { + throw new IllegalArgumentException(String.format( + "Total layer height (%d) exceeds Minecraft's maximum world height limit (384 blocks, Y=-64 to Y=320)", + stack.totalHeight() + )); + } + + return stack; } /** How many layers there are, bottom to top. */ public int size() { - + return heights.length; } /** @throws IndexOutOfBoundsException if the layer does not exist */ public BlockData blockAt(final int layer) { + if (layer < 0 || layer >= blocks.length) + throw new IndexOutOfBoundsException(); + return blocks[layer]; } /** @throws IndexOutOfBoundsException if the layer does not exist */ public int heightAt(final int layer) { + if (layer < 0 || layer >= heights.length) + throw new IndexOutOfBoundsException(); + return heights[layer]; } /** Total height of every layer combined. */ - public int totalHeight() + public final int totalHeight() { - + return Arrays.stream(heights).sum(); } } diff --git a/src/main/java/me/totalfreedom/totalfreedommod/world/profile/SurfaceRule.java b/src/main/java/me/totalfreedom/totalfreedommod/world/profile/SurfaceRule.java index 83693f222..6ba52abad 100644 --- a/src/main/java/me/totalfreedom/totalfreedommod/world/profile/SurfaceRule.java +++ b/src/main/java/me/totalfreedom/totalfreedommod/world/profile/SurfaceRule.java @@ -12,6 +12,6 @@ public record SurfaceRule(BiomeFilter biomes, Depth depth, BlockData block) { public boolean matches(final Biome biome, final int depth) { - + } } diff --git a/src/main/java/me/totalfreedom/totalfreedommod/world/stage/FeaturePopulator.java b/src/main/java/me/totalfreedom/totalfreedommod/world/stage/FeaturePopulator.java index 54ef1aa46..409ed7095 100644 --- a/src/main/java/me/totalfreedom/totalfreedommod/world/stage/FeaturePopulator.java +++ b/src/main/java/me/totalfreedom/totalfreedommod/world/stage/FeaturePopulator.java @@ -1,12 +1,16 @@ package me.totalfreedom.totalfreedommod.world.stage; import java.util.List; +import java.util.Random; +import org.bukkit.block.Biome; import org.bukkit.generator.LimitedRegion; import me.totalfreedom.totalfreedommod.world.base.ChunkContext; import me.totalfreedom.totalfreedommod.world.base.Populator; +import me.totalfreedom.totalfreedommod.world.profile.Anchor; import me.totalfreedom.totalfreedommod.world.profile.FeatureSpec; +import me.totalfreedom.totalfreedommod.world.profile.Palette; import me.totalfreedom.totalfreedommod.world.stage.feature.FeatureRegistry; /** @@ -27,6 +31,55 @@ public FeaturePopulator(final List specs, final FeatureRegistry reg @Override public void populate(final ChunkContext context, final LimitedRegion data) { + final Random random = context.getRandom(); + this.specs.forEach(spec -> this.roll(context, data, random, spec)); + } + + private void roll(final ChunkContext context, + final LimitedRegion data, + final Random random, + final FeatureSpec spec) + { + for (int attempt = 0; attempt < spec.rarity(); attempt++) + { + final int localX = random.nextInt(16); + final int localZ = random.nextInt(16); + final int worldX = context.worldX(localX); + final int worldZ = context.worldZ(localZ); + + if (!spec.appliesTo(this.biomeAt(context, worldX, worldZ))) + continue; + + final int y = spec.detail().anchor() == Anchor.SURFACE + ? context.columnTop(localX, localZ) + 1 + : spec.minY() + random.nextInt(spec.maxY() - spec.minY() + 1); + + if (y < spec.minY() || y > spec.maxY()) + continue; + + this.registry.place(context, data, spec.detail(), worldX, y, worldZ); + } + } + + /** + * Same temperature/humidity lookup {@link me.totalfreedom.totalfreedommod.world.adapter.ProfileBiomeProvider} + * uses. Duplicated rather than shared, since that class does not expose it as a static helper. + */ + private Biome biomeAt(final ChunkContext context, final int worldX, final int worldZ) + { + final Palette palette = context.getProfile().palette(); + final Palette.Climate climate = palette.climate(); + final int sampleX = (int) (worldX * climate.scale()); + final int sampleZ = (int) (worldZ * climate.scale()); + final double temperature = climate.temperature().sample(sampleX, sampleZ); + final double humidity = climate.humidity().sample(sampleX, sampleZ); + + return palette.biomes() + .stream() + .filter(band -> band.matches(temperature, humidity)) + .findFirst() + .map(Palette.BiomeBand::biome) + .orElse(palette.fallback()); } } diff --git a/src/main/java/me/totalfreedom/totalfreedommod/world/stage/feature/BoulderFeature.java b/src/main/java/me/totalfreedom/totalfreedommod/world/stage/feature/BoulderFeature.java index c2cfac7e1..7d5cb5ace 100644 --- a/src/main/java/me/totalfreedom/totalfreedommod/world/stage/feature/BoulderFeature.java +++ b/src/main/java/me/totalfreedom/totalfreedommod/world/stage/feature/BoulderFeature.java @@ -3,15 +3,15 @@ import org.bukkit.generator.LimitedRegion; import me.totalfreedom.totalfreedommod.world.base.ChunkContext; -import me.totalfreedom.totalfreedommod.world.profile.FeatureSpec; +import me.totalfreedom.totalfreedommod.world.profile.FeatureDetail; /** A rough blob sitting on the surface. Spec size is the radius. */ -public final class BoulderFeature implements Feature +public final class BoulderFeature implements Feature { @Override public void place(final ChunkContext context, final LimitedRegion region, - final FeatureSpec spec, + final FeatureDetail.Boulder detail, final int x, final int y, final int z) diff --git a/src/main/java/me/totalfreedom/totalfreedommod/world/stage/feature/Feature.java b/src/main/java/me/totalfreedom/totalfreedommod/world/stage/feature/Feature.java index 2044bdf69..d234decf9 100644 --- a/src/main/java/me/totalfreedom/totalfreedommod/world/stage/feature/Feature.java +++ b/src/main/java/me/totalfreedom/totalfreedommod/world/stage/feature/Feature.java @@ -20,17 +20,4 @@ public interface Feature { void place(ChunkContext context, LimitedRegion region, D detail, int x, int y, int z); - - /** - * Interpolates between two points. Written the precise way, {@code from*(1-t) + to*t}, so that - * a progress of exactly 1 returns exactly {@code to}. The shorter {@code from + t*(to-from)} - * rounds twice and can miss the far endpoint by an ulp. - *

- * Nothing today loops far enough to reach 1, but this is shared, and the next feature to use it - * should not have to loop a particular way to stay correct. - */ - default double lerp(final double progress, final double from, final double to) - { - return from * (1.0D - progress) + to * progress; - } } diff --git a/src/main/java/me/totalfreedom/totalfreedommod/world/stage/feature/FeatureRegistry.java b/src/main/java/me/totalfreedom/totalfreedommod/world/stage/feature/FeatureRegistry.java new file mode 100644 index 000000000..77fe317bd --- /dev/null +++ b/src/main/java/me/totalfreedom/totalfreedommod/world/stage/feature/FeatureRegistry.java @@ -0,0 +1,39 @@ +package me.totalfreedom.totalfreedommod.world.stage.feature; + +import org.bukkit.generator.LimitedRegion; + +import me.totalfreedom.totalfreedommod.world.base.ChunkContext; +import me.totalfreedom.totalfreedommod.world.profile.FeatureDetail; + +/** + * Dispatches a feature detail to the {@link Feature} that knows how to place it. + *

+ * One instance per chunk generator, shared across every chunk. The switch over the sealed + * {@link FeatureDetail} is exhaustive, so a new variant will not compile until this knows how to + * place it too. + */ +public final class FeatureRegistry +{ + private final OreFeature ore = new OreFeature(); + private final PatchFeature patch = new PatchFeature(); + private final LakeFeature lake = new LakeFeature(); + private final BoulderFeature boulder = new BoulderFeature(); + private final TreeFeature tree = new TreeFeature(); + + public void place(final ChunkContext context, + final LimitedRegion region, + final FeatureDetail detail, + final int x, + final int y, + final int z) + { + switch (detail) + { + case FeatureDetail.Ore d -> this.ore.place(context, region, d, x, y, z); + case FeatureDetail.Patch d -> this.patch.place(context, region, d, x, y, z); + case FeatureDetail.Lake d -> this.lake.place(context, region, d, x, y, z); + case FeatureDetail.Boulder d -> this.boulder.place(context, region, d, x, y, z); + case FeatureDetail.Tree d -> this.tree.place(context, region, d, x, y, z); + } + } +} diff --git a/src/main/java/me/totalfreedom/totalfreedommod/world/stage/feature/LakeFeature.java b/src/main/java/me/totalfreedom/totalfreedommod/world/stage/feature/LakeFeature.java index c14d7d33c..acead2d23 100644 --- a/src/main/java/me/totalfreedom/totalfreedommod/world/stage/feature/LakeFeature.java +++ b/src/main/java/me/totalfreedom/totalfreedommod/world/stage/feature/LakeFeature.java @@ -3,20 +3,19 @@ import org.bukkit.generator.LimitedRegion; import me.totalfreedom.totalfreedommod.world.base.ChunkContext; -import me.totalfreedom.totalfreedommod.world.profile.FeatureSpec; - +import me.totalfreedom.totalfreedommod.world.profile.FeatureDetail; /** * A dip filled with fluid. Spec size is the radius. *

* The only feature that removes blocks as well as placing them, so it needs to clear the bowl * before it fills it. */ -public final class LakeFeature implements Feature +public final class LakeFeature implements Feature { @Override public void place(final ChunkContext context, final LimitedRegion region, - final FeatureSpec spec, + final FeatureDetail.Lake detail, final int x, final int y, final int z) diff --git a/src/main/java/me/totalfreedom/totalfreedommod/world/stage/feature/OreFeature.java b/src/main/java/me/totalfreedom/totalfreedommod/world/stage/feature/OreFeature.java index b620bdc20..967ac8d7e 100644 --- a/src/main/java/me/totalfreedom/totalfreedommod/world/stage/feature/OreFeature.java +++ b/src/main/java/me/totalfreedom/totalfreedommod/world/stage/feature/OreFeature.java @@ -5,6 +5,7 @@ import org.bukkit.Material; import org.bukkit.generator.LimitedRegion; +import me.totalfreedom.totalfreedommod.util.FUtil; import me.totalfreedom.totalfreedommod.world.base.ChunkContext; import me.totalfreedom.totalfreedommod.world.profile.FeatureDetail; @@ -48,9 +49,9 @@ public void place(final ChunkContext context, { final double progress = step / (double) size; - final double centreX = lerp(progress, fromX, toX); - final double centreY = lerp(progress, fromY, toY); - final double centreZ = lerp(progress, fromZ, toZ); + final double centreX = FUtil.lerp(progress, fromX, toX); + final double centreY = FUtil.lerp(progress, fromY, toY); + final double centreZ = FUtil.lerp(progress, fromZ, toZ); // Swells to its widest halfway along and tapers back at both ends, so the vein has // pointed tips instead of blunt ones. The +1 keeps the thinnest step at least a block. diff --git a/src/main/java/me/totalfreedom/totalfreedommod/world/stage/feature/PatchFeature.java b/src/main/java/me/totalfreedom/totalfreedommod/world/stage/feature/PatchFeature.java index d549ca370..9e93fa6f1 100644 --- a/src/main/java/me/totalfreedom/totalfreedommod/world/stage/feature/PatchFeature.java +++ b/src/main/java/me/totalfreedom/totalfreedommod/world/stage/feature/PatchFeature.java @@ -3,15 +3,15 @@ import org.bukkit.generator.LimitedRegion; import me.totalfreedom.totalfreedommod.world.base.ChunkContext; -import me.totalfreedom.totalfreedommod.world.profile.FeatureSpec; +import me.totalfreedom.totalfreedommod.world.profile.FeatureDetail; /** A scatter of blocks on the surface; flowers, grass, that sort of thing. Spec size is spread. */ -public final class PatchFeature implements Feature +public final class PatchFeature implements Feature { @Override public void place(final ChunkContext context, final LimitedRegion region, - final FeatureSpec spec, + final FeatureDetail.Patch detail, final int x, final int y, final int z) From cc89cdc4412529e9bb4bdbb922995972cfa66ed2 Mon Sep 17 00:00:00 2001 From: Paldiu Date: Sun, 9 Aug 2026 11:35:55 -0500 Subject: [PATCH 17/32] I think i got this now lowkey its all coming together --- .../totalfreedommod/PluginProvider.java | 3 +- .../world/adapter/SpawnFinder.java | 86 ++++++++++++++++++- .../world/base/ChunkContext.java | 2 +- .../totalfreedommod/world/base/Generator.java | 8 +- .../world/noise/NoiseField.java | 53 +++++++++++- .../totalfreedommod/world/profile/Spline.java | 71 ++++++++++++++- .../world/stage/DensityGenerator.java | 2 +- .../world/stage/FlatGenerator.java | 2 +- .../world/stage/HeightmapGenerator.java | 2 +- 9 files changed, 212 insertions(+), 17 deletions(-) diff --git a/src/main/java/me/totalfreedom/totalfreedommod/PluginProvider.java b/src/main/java/me/totalfreedom/totalfreedommod/PluginProvider.java index 210684830..56996aa98 100644 --- a/src/main/java/me/totalfreedom/totalfreedommod/PluginProvider.java +++ b/src/main/java/me/totalfreedom/totalfreedommod/PluginProvider.java @@ -37,8 +37,7 @@ public static void unbind() * caching the variable as in `var plugin = PluginProvider.get()` like in FCommand is fine, since the bind never changes during lifetime * and binding is the first thing that happens in the plugins lifecycle in onLoad() and everything else is registered and initialized in the onEnable() method. * - * This however should be avoided moving forward; I will replace plugin and server variable with overload getters that just return this get method instead. - * There are many locations in the code where this occurs, FCommand is the only one thats feasibly fixable in this scope. + * This however should be avoided moving forward. * * @return the live plugin instance. * @throws IllegalStateException if the plugin is not currently bound. diff --git a/src/main/java/me/totalfreedom/totalfreedommod/world/adapter/SpawnFinder.java b/src/main/java/me/totalfreedom/totalfreedommod/world/adapter/SpawnFinder.java index 6aff046fc..8a6217f44 100644 --- a/src/main/java/me/totalfreedom/totalfreedommod/world/adapter/SpawnFinder.java +++ b/src/main/java/me/totalfreedom/totalfreedommod/world/adapter/SpawnFinder.java @@ -1,27 +1,107 @@ package me.totalfreedom.totalfreedommod.world.adapter; +import java.util.Optional; + import org.bukkit.Location; import org.bukkit.World; import me.totalfreedom.totalfreedommod.world.GenerationProfile; +import me.totalfreedom.totalfreedommod.world.base.Generator; /** * Picks a spawn point by asking the generator's height function. *

- * Loads no chunks, since that function is pure maths. + * Loads no chunks, since that function is pure maths, which is what lets it check hundreds of + * candidates in the time a single chunk load would take. The cleanroom generator it replaces loaded + * chunk (0, 0) on the main thread during world creation just to find one column. */ public final class SpawnFinder { + /** How far out to search before giving up, in blocks. */ + private static final int RANGE = 512; + + /** Distance between candidates. Fine enough to find a coast, coarse enough to stay cheap. */ + private static final int STEP = 16; + private final GenerationProfile profile; + private final Generator generator; - public SpawnFinder(final GenerationProfile profile) + public SpawnFinder(final GenerationProfile profile, final Generator generator) { this.profile = profile; + this.generator = generator; } - /** Searches out from origin for the first column that is not underwater or void. */ + /** + * Spirals out from the origin for the first column that is above sea level and inside the + * world's bounds. + *

+ * Falls back to the origin at sea level if nothing qualifies, which happens for a world that is + * entirely ocean or entirely void. + */ public Location findSpawn(final World world) { + final int floor = this.profile.bounds().seaLevel().orElse(this.profile.bounds().minY()); + + for (int ring = 0; ring <= RANGE / STEP; ring++) + { + final Optional found = searchRing(world, ring, floor); + + if (found.isPresent()) + return found.get(); + } + + return new Location(world, 0.5D, floor + 1, 0.5D); + } + + /** + * Walks the edge of one square ring at this radius. + *

+ * Squares rather than circles because the point is to spread outward evenly, and a square ring + * is a single loop with no trigonometry. Ring zero is the origin itself. + */ + private Optional searchRing(final World world, final int ring, final int floor) + { + final int extent = ring * STEP; + + if (ring == 0) + return candidate(world, 0, 0, floor); + + for (int offset = -extent; offset <= extent; offset += STEP) + { + final Optional north = candidate(world, offset, -extent, floor); + + if (north.isPresent()) + return north; + + final Optional south = candidate(world, offset, extent, floor); + + if (south.isPresent()) + return south; + + final Optional west = candidate(world, -extent, offset, floor); + + if (west.isPresent()) + return west; + + final Optional east = candidate(world, extent, offset, floor); + + if (east.isPresent()) + return east; + } + + return Optional.empty(); + } + + /** A column qualifies if its ground sits above the water line and below the world's ceiling. */ + private Optional candidate(final World world, final int x, final int z, final int floor) + { + final int height = this.generator.surfaceHeight(x, z); + + if (height <= floor || height >= this.profile.bounds().maxY()) + return Optional.empty(); + // Centred in the block and one above the ground, so the player is not standing inside it. + return Optional.of(new Location(world, x + 0.5D, height + 1, z + 0.5D)); } } diff --git a/src/main/java/me/totalfreedom/totalfreedommod/world/base/ChunkContext.java b/src/main/java/me/totalfreedom/totalfreedommod/world/base/ChunkContext.java index 239068166..ff93dbafe 100644 --- a/src/main/java/me/totalfreedom/totalfreedommod/world/base/ChunkContext.java +++ b/src/main/java/me/totalfreedom/totalfreedommod/world/base/ChunkContext.java @@ -124,7 +124,7 @@ private int[] computeTerrainHeights() final Generator generator = this.stages.generator(); IntStream.range(0, 256) - .forEach(i -> heights[i] = generator.surfaceHeight(this, this.worldX(i & 0xF), this.worldZ(i >> 4))); + .forEach(i -> heights[i] = generator.surfaceHeight(this.worldX(i & 0xF), this.worldZ(i >> 4))); return heights; } diff --git a/src/main/java/me/totalfreedom/totalfreedommod/world/base/Generator.java b/src/main/java/me/totalfreedom/totalfreedommod/world/base/Generator.java index be8df86f0..c023e60c9 100644 --- a/src/main/java/me/totalfreedom/totalfreedommod/world/base/Generator.java +++ b/src/main/java/me/totalfreedom/totalfreedommod/world/base/Generator.java @@ -25,10 +25,12 @@ public interface Generator void generateBase(ChunkContext context, ChunkGenerator.ChunkData data); /** - * Terrain height at a world position, before carving. Pure, no chunk access. + * Terrain height at a world position, before carving. Pure, no chunk access, and no context: the + * spawn finder calls this at world creation, before any chunk exists to build one from. *

- * Backs getBaseHeight, the spawn finder, and the context's column heights. Must agree with what + * Backs getBaseHeight, the spawn finder, and the context's own column heights, which is why an + * implementation must not read those back through a context. Must agree with what * {@link #generateBase} writes or spawn lands in mid-air. */ - int surfaceHeight(ChunkContext context, int worldX, int worldZ); + int surfaceHeight(int worldX, int worldZ); } diff --git a/src/main/java/me/totalfreedom/totalfreedommod/world/noise/NoiseField.java b/src/main/java/me/totalfreedom/totalfreedommod/world/noise/NoiseField.java index 4f80862d0..b9532ed3c 100644 --- a/src/main/java/me/totalfreedom/totalfreedommod/world/noise/NoiseField.java +++ b/src/main/java/me/totalfreedom/totalfreedommod/world/noise/NoiseField.java @@ -1,6 +1,10 @@ package me.totalfreedom.totalfreedommod.world.noise; +import java.util.Random; + import org.bukkit.util.noise.OctaveGenerator; +import org.bukkit.util.noise.PerlinOctaveGenerator; +import org.bukkit.util.noise.SimplexOctaveGenerator; /** * A built noise field. Immutable, sampled concurrently from every worldgen thread. @@ -13,11 +17,13 @@ public final class NoiseField { private final NoiseProfile profile; private final OctaveGenerator generator; + private final double normaliser; - private NoiseField(final NoiseProfile profile, final OctaveGenerator generator) + private NoiseField(final NoiseProfile profile, final OctaveGenerator generator, final double normaliser) { this.profile = profile; this.generator = generator; + this.normaliser = normaliser; } /** @@ -26,23 +32,68 @@ private NoiseField(final NoiseProfile profile, final OctaveGenerator generator) */ public static NoiseField of(final NoiseProfile profile, final long seed, final String role) { + final Random source = new Random(seed ^ role.hashCode()); + + final OctaveGenerator generator = switch (profile.type()) + { + case PERLIN -> new PerlinOctaveGenerator(source, profile.octaves()); + case SIMPLEX -> new SimplexOctaveGenerator(source, profile.octaves()); + }; + generator.setScale(profile.frequency()); + + return new NoiseField(profile, generator, normaliserFor(profile)); } /** 2D sample, in the range -1 to 1. */ public double sample(final int x, final int z) { + final double raw = this.generator.noise(x, 0.0D, z, profile.lacunarity(), profile.persistence(), false); + return shape(raw / this.normaliser); } /** 3D sample, in the range -1 to 1. */ public double sample(final int x, final int y, final int z) { + final double raw = this.generator.noise(x, y, z, profile.lacunarity(), profile.persistence(), false); + return shape(raw / this.normaliser); } public NoiseProfile getProfile() { return this.profile; } + + /** + * Bukkit sums its octaves without scaling them back down, so a four octave field at persistence + * 0.5 returns roughly plus or minus 1.875 rather than 1. Dividing by the summed amplitudes is + * what puts it back in range. + */ + private static double normaliserFor(final NoiseProfile profile) + { + double total = 0.0D; + double amplitude = 1.0D; + + for (int octave = 0; octave < profile.octaves(); octave++) + { + total += amplitude; + amplitude *= profile.persistence(); + } + + return total; + } + + /** + * Ridged noise folds the field about zero and inverts it, turning the smooth peaks into sharp + * ones. Good for mountain ridges and for cave tunnels. + */ + private double shape(final double normalised) + { + if (!this.profile.ridged()) + return normalised; + + return 1.0D - Math.abs(normalised) * 2.0D; + } } diff --git a/src/main/java/me/totalfreedom/totalfreedommod/world/profile/Spline.java b/src/main/java/me/totalfreedom/totalfreedommod/world/profile/Spline.java index a6541f2e6..543dfab3e 100644 --- a/src/main/java/me/totalfreedom/totalfreedommod/world/profile/Spline.java +++ b/src/main/java/me/totalfreedom/totalfreedommod/world/profile/Spline.java @@ -1,27 +1,90 @@ package me.totalfreedom.totalfreedommod.world.profile; +import java.util.Arrays; import java.util.List; +import me.totalfreedom.totalfreedommod.util.FUtil; + /** * Maps raw noise to a terrain height through control points. Binary search plus a lerp per column. *

* Plateaus, cliffs, and flat plains all come out of one array of points. */ -public record Spline(double[] inputs, double[] outputs) +public final class Spline { + private final double[] inputs; + private final double[] outputs; + + private Spline(final double[] inputs, final double[] outputs) + { + this.inputs = inputs; + this.outputs = outputs; + } + /** * @param points {@code [noise, height]} pairs - * @throws IllegalArgumentException if fewer than two points, or the noise values are not - * strictly ascending, which would make the search ambiguous + * @throws IllegalArgumentException if fewer than two points, a point is not a pair, or the + * noise values are not strictly ascending, which would make + * the search ambiguous */ public static Spline of(final List points) { + if (points == null || points.size() < 2) + throw new IllegalArgumentException("A spline needs at least two points"); + + final double[] inputs = new double[points.size()]; + final double[] outputs = new double[points.size()]; + for (int i = 0; i < points.size(); i++) + { + final double[] point = points.get(i); + + if (point == null || point.length != 2) + throw new IllegalArgumentException("Point " + i + " must be a [noise, height] pair"); + + if (i > 0 && point[0] <= inputs[i - 1]) + throw new IllegalArgumentException("Point " + i + " noise (" + point[0] + + ") must be above the previous point's (" + inputs[i - 1] + ")"); + + inputs[i] = point[0]; + outputs[i] = point[1]; + } + + return new Spline(inputs, outputs); } - /** Clamps to the first and last point outside their range. */ + /** + * The height this noise maps to. Clamps to the first and last point outside their range, so + * noise beyond the outermost control points flattens off rather than running away. + */ public double apply(final double noise) { + final int last = this.inputs.length - 1; + + if (noise <= this.inputs[0]) + return this.outputs[0]; + + if (noise >= this.inputs[last]) + return this.outputs[last]; + + final int found = Arrays.binarySearch(this.inputs, noise); + + if (found >= 0) + return this.outputs[found]; + // A miss returns -(insertion point) - 1, and the insertion point is the first control point + // above the noise, so the segment we want is the one ending there. + final int upper = -(found + 1); + final int lower = upper - 1; + + final double progress = (noise - this.inputs[lower]) / (this.inputs[upper] - this.inputs[lower]); + + return FUtil.lerp(progress, this.outputs[lower], this.outputs[upper]); + } + + /** How many control points there are. */ + public int size() + { + return this.inputs.length; } } diff --git a/src/main/java/me/totalfreedom/totalfreedommod/world/stage/DensityGenerator.java b/src/main/java/me/totalfreedom/totalfreedommod/world/stage/DensityGenerator.java index e3e63d8cc..42a0206a5 100644 --- a/src/main/java/me/totalfreedom/totalfreedommod/world/stage/DensityGenerator.java +++ b/src/main/java/me/totalfreedom/totalfreedommod/world/stage/DensityGenerator.java @@ -35,7 +35,7 @@ public void generateBase(final ChunkContext context, final ChunkGenerator.ChunkD } @Override - public int surfaceHeight(final ChunkContext context, final int worldX, final int worldZ) + public int surfaceHeight(final int worldX, final int worldZ) { } diff --git a/src/main/java/me/totalfreedom/totalfreedommod/world/stage/FlatGenerator.java b/src/main/java/me/totalfreedom/totalfreedommod/world/stage/FlatGenerator.java index 595e514d2..862203c58 100644 --- a/src/main/java/me/totalfreedom/totalfreedommod/world/stage/FlatGenerator.java +++ b/src/main/java/me/totalfreedom/totalfreedommod/world/stage/FlatGenerator.java @@ -28,7 +28,7 @@ public void generateBase(final ChunkContext context, final ChunkGenerator.ChunkD } @Override - public int surfaceHeight(final ChunkContext context, final int worldX, final int worldZ) + public int surfaceHeight(final int worldX, final int worldZ) { } diff --git a/src/main/java/me/totalfreedom/totalfreedommod/world/stage/HeightmapGenerator.java b/src/main/java/me/totalfreedom/totalfreedommod/world/stage/HeightmapGenerator.java index 55415a79a..32ce2b311 100644 --- a/src/main/java/me/totalfreedom/totalfreedommod/world/stage/HeightmapGenerator.java +++ b/src/main/java/me/totalfreedom/totalfreedommod/world/stage/HeightmapGenerator.java @@ -47,7 +47,7 @@ public void generateBase(final ChunkContext context, final ChunkGenerator.ChunkD } @Override - public int surfaceHeight(final ChunkContext context, final int worldX, final int worldZ) + public int surfaceHeight(final int worldX, final int worldZ) { } From 741d8b09ee83895c7f5874ddf1e1b2134268b630 Mon Sep 17 00:00:00 2001 From: Paldiu Date: Sun, 9 Aug 2026 11:41:31 -0500 Subject: [PATCH 18/32] Incorrectly stated that reading columnTop deadlocks Instead of deadlocking, what actually happens is that since synchronized() is reentrant, then calling get() from the same monitor would just reenter the lock, which would effectively recursively call until a StackOverflowError, not a deadlock while other monitors await the release. Updated comments to appropriately describe that instead of incorrectly classifying it as a classic deadlock. --- .../java/me/totalfreedom/totalfreedommod/util/Lazy.java | 7 +++++-- .../me/totalfreedom/totalfreedommod/world/base/Carver.java | 2 +- 2 files changed, 6 insertions(+), 3 deletions(-) diff --git a/src/main/java/me/totalfreedom/totalfreedommod/util/Lazy.java b/src/main/java/me/totalfreedom/totalfreedommod/util/Lazy.java index 120f6e6c4..300a8de9d 100644 --- a/src/main/java/me/totalfreedom/totalfreedommod/util/Lazy.java +++ b/src/main/java/me/totalfreedom/totalfreedommod/util/Lazy.java @@ -9,8 +9,11 @@ * {@link #get()}. Every call after that hands back the same value, and the supplier is never run * again, including when it returned null. *

- * Safe to share between threads. Do not call {@link #get()} from inside the supplier though, since - * it will deadlock on the lock the first call is already holding. + * Safe to share between threads. Do not call {@link #get()} from inside the supplier though. + * {@code synchronized} is reentrant on the thread already holding it, so this will not deadlock; + * instead the supplier calls itself, {@code initialized} is still false each time, and it recurses + * until the stack overflows, all while holding the monitor and blocking every other thread's call + * to {@link #get()} for as long as that takes. * * @param the type being worked out */ diff --git a/src/main/java/me/totalfreedom/totalfreedommod/world/base/Carver.java b/src/main/java/me/totalfreedom/totalfreedommod/world/base/Carver.java index db0f38134..6ca8ab5eb 100644 --- a/src/main/java/me/totalfreedom/totalfreedommod/world/base/Carver.java +++ b/src/main/java/me/totalfreedom/totalfreedommod/world/base/Carver.java @@ -21,7 +21,7 @@ public interface Carver *

* Reading the context's terrain height is fine, and tightening the threshold as you get near it * blends cave mouths into the hillside. Do not read the column top, though; that is worked out - * by calling this method, so you will deadlock. + * by calling this method, so you will recurse into yourself until the stack overflows. */ boolean isCarved(ChunkContext context, int worldX, int y, int worldZ); From c547efbce8e0fb3fb34a509611f7c91788b910b0 Mon Sep 17 00:00:00 2001 From: Paldiu Date: Sun, 9 Aug 2026 12:53:35 -0500 Subject: [PATCH 19/32] Finish features --- .../world/stage/feature/BoulderFeature.java | 67 ++++++++++++++++++- .../world/stage/feature/LakeFeature.java | 48 ++++++++++++- .../world/stage/feature/PatchFeature.java | 49 +++++++++++++- 3 files changed, 159 insertions(+), 5 deletions(-) diff --git a/src/main/java/me/totalfreedom/totalfreedommod/world/stage/feature/BoulderFeature.java b/src/main/java/me/totalfreedom/totalfreedommod/world/stage/feature/BoulderFeature.java index 7d5cb5ace..5a5dde54d 100644 --- a/src/main/java/me/totalfreedom/totalfreedommod/world/stage/feature/BoulderFeature.java +++ b/src/main/java/me/totalfreedom/totalfreedommod/world/stage/feature/BoulderFeature.java @@ -1,13 +1,24 @@ package me.totalfreedom.totalfreedommod.world.stage.feature; +import java.util.Random; + import org.bukkit.generator.LimitedRegion; import me.totalfreedom.totalfreedommod.world.base.ChunkContext; import me.totalfreedom.totalfreedommod.world.profile.FeatureDetail; -/** A rough blob sitting on the surface. Spec size is the radius. */ +/** + * A rough blob resting on the ground. Detail radius is how wide it is. + *

+ * Built from a few overlapping spheres rather than one, which is what keeps it from reading as a + * ball someone dropped. Each sphere is nudged off the last so the shape comes out lopsided. + *

+ * Sunk one block into the ground on purpose, so it looks embedded rather than balanced. + */ public final class BoulderFeature implements Feature { + private static final int LOBES = 3; + @Override public void place(final ChunkContext context, final LimitedRegion region, @@ -16,6 +27,60 @@ public void place(final ChunkContext context, final int y, final int z) { + final Random random = context.getRandom(); + final int radius = detail.radius(); + + int lobeX = x; + int lobeY = y - 1; + int lobeZ = z; + + for (int lobe = 0; lobe < LOBES; lobe++) + { + // Later lobes are smaller, so the boulder tapers instead of growing arms. + final int lobeRadius = Math.max(1, radius - lobe); + + sphere(region, detail, lobeX, lobeY, lobeZ, lobeRadius); + + lobeX += random.nextInt(radius + 1) - radius / 2; + lobeY += random.nextInt(2); + lobeZ += random.nextInt(radius + 1) - radius / 2; + } + } + + /** + * Fills a sphere, overwriting whatever is already there. + *

+ * Unlike an ore vein this does not check what it is replacing, because a boulder sits on the + * surface and is meant to bury the grass under it. + */ + private static void sphere(final LimitedRegion region, + final FeatureDetail.Boulder detail, + final int centreX, + final int centreY, + final int centreZ, + final int radius) + { + final int squared = radius * radius; + + for (int offsetX = -radius; offsetX <= radius; offsetX++) + { + for (int offsetY = -radius; offsetY <= radius; offsetY++) + { + for (int offsetZ = -radius; offsetZ <= radius; offsetZ++) + { + if (offsetX * offsetX + offsetY * offsetY + offsetZ * offsetZ > squared) + continue; + + final int blockX = centreX + offsetX; + final int blockY = centreY + offsetY; + final int blockZ = centreZ + offsetZ; + + if (!region.isInRegion(blockX, blockY, blockZ)) + continue; + region.setBlockData(blockX, blockY, blockZ, detail.block()); + } + } + } } } diff --git a/src/main/java/me/totalfreedom/totalfreedommod/world/stage/feature/LakeFeature.java b/src/main/java/me/totalfreedom/totalfreedommod/world/stage/feature/LakeFeature.java index acead2d23..6b2144c65 100644 --- a/src/main/java/me/totalfreedom/totalfreedommod/world/stage/feature/LakeFeature.java +++ b/src/main/java/me/totalfreedom/totalfreedommod/world/stage/feature/LakeFeature.java @@ -1,17 +1,28 @@ package me.totalfreedom.totalfreedommod.world.stage.feature; +import org.bukkit.Material; import org.bukkit.generator.LimitedRegion; import me.totalfreedom.totalfreedommod.world.base.ChunkContext; import me.totalfreedom.totalfreedommod.world.profile.FeatureDetail; + /** - * A dip filled with fluid. Spec size is the radius. + * A dip filled with fluid. Detail radius is how wide the bowl is. + *

+ * The only feature that removes blocks as well as placing them, so it clears the bowl before it + * fills it. Everything else here is additive and can be reasoned about one block at a time; this + * one destroys terrain the designer already finished, and it will happily eat a hillside if it + * lands in one. *

- * The only feature that removes blocks as well as placing them, so it needs to clear the bowl - * before it fills it. + * Shaped as a squashed sphere, wider than it is deep, because a round hole reads as a crater. Only + * the lower half is filled; the upper half is cleared to air, which is what gives the water a bank + * instead of a lid. */ public final class LakeFeature implements Feature { + /** How much flatter the bowl is than it is wide. */ + private static final double SQUASH = 2.0D; + @Override public void place(final ChunkContext context, final LimitedRegion region, @@ -20,6 +31,37 @@ public void place(final ChunkContext context, final int y, final int z) { + final int radius = detail.radius(); + final int depth = Math.max(1, (int) Math.round(radius / SQUASH)); + + for (int offsetX = -radius; offsetX <= radius; offsetX++) + { + for (int offsetZ = -radius; offsetZ <= radius; offsetZ++) + { + for (int offsetY = -depth; offsetY <= depth; offsetY++) + { + final double reachX = offsetX / (double) radius; + final double reachY = offsetY / (double) depth; + final double reachZ = offsetZ / (double) radius; + + if (reachX * reachX + reachY * reachY + reachZ * reachZ > 1.0D) + continue; + + final int blockX = x + offsetX; + final int blockY = y + offsetY; + final int blockZ = z + offsetZ; + + if (!region.isInRegion(blockX, blockY, blockZ)) + continue; + // Fluid in the bottom half, air above it. Filling the whole bowl would seal the + // lake over and leave a block of water floating at head height. + if (offsetY <= 0) + region.setBlockData(blockX, blockY, blockZ, detail.fluid()); + else + region.setType(blockX, blockY, blockZ, Material.AIR); + } + } + } } } diff --git a/src/main/java/me/totalfreedom/totalfreedommod/world/stage/feature/PatchFeature.java b/src/main/java/me/totalfreedom/totalfreedommod/world/stage/feature/PatchFeature.java index 9e93fa6f1..87a7b8f6e 100644 --- a/src/main/java/me/totalfreedom/totalfreedommod/world/stage/feature/PatchFeature.java +++ b/src/main/java/me/totalfreedom/totalfreedommod/world/stage/feature/PatchFeature.java @@ -1,13 +1,28 @@ package me.totalfreedom.totalfreedommod.world.stage.feature; +import java.util.Random; + +import org.bukkit.Material; import org.bukkit.generator.LimitedRegion; import me.totalfreedom.totalfreedommod.world.base.ChunkContext; import me.totalfreedom.totalfreedommod.world.profile.FeatureDetail; -/** A scatter of blocks on the surface; flowers, grass, that sort of thing. Spec size is spread. */ +/** + * A scatter of blocks on the surface; flowers, grass, that sort of thing. Detail spread is how many + * placements it attempts, not how many it lands. + *

+ * Attempts are scattered on a gaussian around the origin rather than uniformly, so a patch thins + * out at its edges instead of stopping at a square border. + *

+ * Each attempt finds its own ground rather than reusing the origin's height, otherwise a patch on a + * slope would hang in the air on one side and bury itself on the other. + */ public final class PatchFeature implements Feature { + /** How far above and below the origin an attempt will look for ground. */ + private static final int SEARCH = 4; + @Override public void place(final ChunkContext context, final LimitedRegion region, @@ -16,6 +31,38 @@ public void place(final ChunkContext context, final int y, final int z) { + final Random random = context.getRandom(); + final double deviation = Math.max(1.0D, detail.spread() / 4.0D); + + for (int attempt = 0; attempt < detail.spread(); attempt++) + { + final int spotX = x + (int) Math.round(random.nextGaussian() * deviation); + final int spotZ = z + (int) Math.round(random.nextGaussian() * deviation); + + placeOne(region, detail, spotX, y, spotZ); + } + } + + /** Drops a single block onto whatever ground is nearest this column, if any is in reach. */ + private static void placeOne(final LimitedRegion region, + final FeatureDetail.Patch detail, + final int x, + final int y, + final int z) + { + for (int spotY = y + SEARCH; spotY >= y - SEARCH; spotY--) + { + if (!region.isInRegion(x, spotY, z) || !region.isInRegion(x, spotY - 1, z)) + continue; + + if (region.getType(x, spotY, z) != Material.AIR) + continue; + + if (!region.getType(x, spotY - 1, z).isSolid()) + continue; + region.setBlockData(x, spotY, z, detail.block()); + return; + } } } From 9fd0bafe02a6c66787e7ef00b6bc3219ee759356 Mon Sep 17 00:00:00 2001 From: Paldiu Date: Mon, 10 Aug 2026 13:10:21 -0500 Subject: [PATCH 20/32] Change how biomes and trees are calculated Previously you had to manually define in each json file for each world what biomes and trees to include, and since there are tens to hundreds of biomes and a non-trivial handful of treetypes, this was wildly inefficient and required the server owner to both know all the biomes, write them out (which will make the json file unnecessarily long) and also the same for treetype which imo is ugly and bad. This system now dynamically infers treetype with zero per-world setup, and if you want to override a specific biome's trees (or anything else about it) you can write your own biome json (e.g. swamp.json) and point a world at it with a ref, though heads up the actual override logic in FeaturePopulator isn't wired up yet, just the schema/parser side. Additionally, we can now create custom logical biomes where we handle everything ourselves (terrain, surface, features) except invariable stuff like fog/ambient sound/mob tables, which just get mapped to whatever vanilla biome you pick for that. --- .../world/GenerationService.java | 7 +- .../world/adapter/ProfileBiomeProvider.java | 12 +- .../world/adapter/ProfileChunkGenerator.java | 8 +- .../world/profile/BiomeDefinition.java | 28 + .../world/profile/BiomeTarget.java | 37 + .../world/profile/FeatureDetail.java | 17 + .../world/profile/Palette.java | 25 +- .../world/profile/ProfileLoader.java | 147 ++- .../world/profile/ProfileParser.java | 1030 ++++++++++++++++- .../totalfreedommod/world/profile/Shape.java | 69 +- .../world/stage/DensityGenerator.java | 28 +- .../world/stage/FeaturePopulator.java | 28 +- .../world/stage/HeightmapGenerator.java | 32 +- .../world/stage/RuleDesigner.java | 4 + .../world/stage/feature/FeatureRegistry.java | 2 + .../stage/feature/NaturalTreeFeature.java | 121 ++ .../world/stage/feature/TreeFeature.java | 14 +- .../resources/worlds/flatlands-template.json | 11 +- .../resources/worlds/overworld-template.json | 6 +- 19 files changed, 1570 insertions(+), 56 deletions(-) create mode 100644 src/main/java/me/totalfreedom/totalfreedommod/world/profile/BiomeDefinition.java create mode 100644 src/main/java/me/totalfreedom/totalfreedommod/world/profile/BiomeTarget.java create mode 100644 src/main/java/me/totalfreedom/totalfreedommod/world/stage/feature/NaturalTreeFeature.java diff --git a/src/main/java/me/totalfreedom/totalfreedommod/world/GenerationService.java b/src/main/java/me/totalfreedom/totalfreedommod/world/GenerationService.java index d65b73e23..cafd88441 100644 --- a/src/main/java/me/totalfreedom/totalfreedommod/world/GenerationService.java +++ b/src/main/java/me/totalfreedom/totalfreedommod/world/GenerationService.java @@ -67,7 +67,12 @@ public Set available() } - /** Re-reads every profile file. Already-loaded worlds keep the profile they were built with. */ + /** + * Re-reads every profile file. Already-loaded worlds keep the profile they were built with. + *

+ * TODO: call {@code this.loader.biomeLibrary()} once per reload and reuse the result for every + * {@code this.parser.parse(...)} call, not once per world. + */ public void reload() { diff --git a/src/main/java/me/totalfreedom/totalfreedommod/world/adapter/ProfileBiomeProvider.java b/src/main/java/me/totalfreedom/totalfreedommod/world/adapter/ProfileBiomeProvider.java index 997ffe077..2b564cdb7 100644 --- a/src/main/java/me/totalfreedom/totalfreedommod/world/adapter/ProfileBiomeProvider.java +++ b/src/main/java/me/totalfreedom/totalfreedommod/world/adapter/ProfileBiomeProvider.java @@ -13,6 +13,10 @@ *

* Sets grass and water colour and what mobs spawn, and gives the designer's rules and the * populator's filters something to match against. + *

+ * A band's target may be a plain vanilla biome or a TFM-only one; either way this only ever hands + * Bukkit {@link me.totalfreedom.totalfreedommod.world.profile.BiomeTarget#display()}'s result, since + * that is the one thing the client and the server's own biome-driven systems can understand. */ public final class ProfileBiomeProvider extends BiomeProvider { @@ -23,13 +27,19 @@ public ProfileBiomeProvider(final GenerationProfile profile) this.profile = profile; } + /** TODO: {@code return this.profile.palette().resolveBiome(x, z); } once resolveBand is implemented. */ @Override public Biome getBiome(final WorldInfo worldInfo, final int x, final int y, final int z) { } - /** Must list every biome getBiome can return, or the server rejects the provider. */ + /** + * Must list every biome getBiome can return, or the server rejects the provider. + *

+ * TODO: collect every band's {@code target().display()} plus {@code palette.fallback()}, + * deduplicated. + */ @Override public List getBiomes(final WorldInfo worldInfo) { diff --git a/src/main/java/me/totalfreedom/totalfreedommod/world/adapter/ProfileChunkGenerator.java b/src/main/java/me/totalfreedom/totalfreedommod/world/adapter/ProfileChunkGenerator.java index 2deb32660..6aaa7c9dd 100644 --- a/src/main/java/me/totalfreedom/totalfreedommod/world/adapter/ProfileChunkGenerator.java +++ b/src/main/java/me/totalfreedom/totalfreedommod/world/adapter/ProfileChunkGenerator.java @@ -41,7 +41,13 @@ public ProfileChunkGenerator(final GenerationProfile profile) this.stages = wire(profile); } - /** Picks the stages for a profile's shape. The one place that switch is written. */ + /** + * Picks the stages for a profile's shape. The one place that switch is written. + *

+ * TODO: build each region's {@code NoiseField} via {@code NoiseField.of(noise, seed, role)}, with + * a distinct role string per region plus one for the selector. Reusing a role collapses two + * fields onto the same random stream. + */ private static Stages wire(final GenerationProfile profile) { diff --git a/src/main/java/me/totalfreedom/totalfreedommod/world/profile/BiomeDefinition.java b/src/main/java/me/totalfreedom/totalfreedommod/world/profile/BiomeDefinition.java new file mode 100644 index 000000000..784a8dd74 --- /dev/null +++ b/src/main/java/me/totalfreedom/totalfreedommod/world/profile/BiomeDefinition.java @@ -0,0 +1,28 @@ +package me.totalfreedom.totalfreedommod.world.profile; + +import java.util.List; +import java.util.Optional; + +import org.bukkit.block.Biome; + +/** + * A biome TFM controls entirely, with no datapack. + *

+ * display is the real vanilla biome the client renders: fog, sky colour, ambient sound, mob tables. + * Everything else about a Logical biome is TFM's own to decide. + *

+ * surface and features, when present, wholly replace the world's own lists for any column that + * resolves to this definition; they do not merge with them. A reader of one definition's file can + * answer "what spawns here" from that file alone, and merging would also risk placing a world-level + * feature twice onto a column whose display happens to match its filter. + * Absent means the column falls through to the world's plainly-filtered surface and features, the + * same as a {@link BiomeTarget.Vanilla} band. + *

+ * No identity field. A definition's identity is the filename it was loaded from, the same idiom + * {@link ProfileLoader} already uses for a world's own profile. + */ +public record BiomeDefinition(Biome display, + Optional> surface, + Optional> features) +{ +} diff --git a/src/main/java/me/totalfreedom/totalfreedommod/world/profile/BiomeTarget.java b/src/main/java/me/totalfreedom/totalfreedommod/world/profile/BiomeTarget.java new file mode 100644 index 000000000..e8ed04ae4 --- /dev/null +++ b/src/main/java/me/totalfreedom/totalfreedommod/world/profile/BiomeTarget.java @@ -0,0 +1,37 @@ +package me.totalfreedom.totalfreedommod.world.profile; + +import org.bukkit.block.Biome; + +/** + * What a {@link Palette.BiomeBand} actually resolves to: a plain vanilla biome, or a TFM-only one. + *

+ * Every consumer that only needs to know what the client sees, mob spawning included, can call + * {@link #display()} without caring which case it is. Only the designer and the populator need to + * know the difference, since a {@link Logical} biome's own surface and features, if it has them, + * replace the world's rather than adding to them. + */ +public sealed interface BiomeTarget +{ + /** Today's plain case. Whatever this names is exactly what the client sees. */ + record Vanilla(Biome biome) implements BiomeTarget + { + @Override + public Biome display() + { + return this.biome; + } + } + + /** A biome TFM controls entirely. See {@link BiomeDefinition}. */ + record Logical(BiomeDefinition definition) implements BiomeTarget + { + @Override + public Biome display() + { + return this.definition.display(); + } + } + + /** The vanilla biome the client renders: fog, sky colour, ambient sound, mob tables. */ + Biome display(); +} diff --git a/src/main/java/me/totalfreedom/totalfreedommod/world/profile/FeatureDetail.java b/src/main/java/me/totalfreedom/totalfreedommod/world/profile/FeatureDetail.java index b3cd48791..d6bcf0ffb 100644 --- a/src/main/java/me/totalfreedom/totalfreedommod/world/profile/FeatureDetail.java +++ b/src/main/java/me/totalfreedom/totalfreedommod/world/profile/FeatureDetail.java @@ -71,5 +71,22 @@ public Anchor anchor() } } + /** + * A tree grown as its biome's vanilla mix would choose it, rather than one type named exactly. + * Resolved against the actual biome at the placement site, not the entry's own biome filter, + * since a wide filter can still span several distinct mixes. A biome with no natural tree cover + * at all (desert, ocean, badlands) is simply skipped, not defaulted to oak. + *

+ * Use {@link Tree} instead to force one species regardless of the biome it lands in. + */ + record NaturalTree() implements FeatureDetail + { + @Override + public Anchor anchor() + { + return Anchor.SURFACE; + } + } + Anchor anchor(); } diff --git a/src/main/java/me/totalfreedom/totalfreedommod/world/profile/Palette.java b/src/main/java/me/totalfreedom/totalfreedommod/world/profile/Palette.java index b43164637..d6afc5690 100644 --- a/src/main/java/me/totalfreedom/totalfreedommod/world/profile/Palette.java +++ b/src/main/java/me/totalfreedom/totalfreedommod/world/profile/Palette.java @@ -1,6 +1,7 @@ package me.totalfreedom.totalfreedommod.world.profile; import java.util.List; +import java.util.Optional; import org.bukkit.block.Biome; @@ -28,6 +29,25 @@ public record Palette(Materials materials, biomes = List.copyOf(biomes); } + /** + * Samples this palette's climate at a world position and returns the band it lands in, if any. + *

+ * The one place every consumer that needs a position's biome should call through, rather than + * each re-deriving its own temperature/humidity lookup. + */ + public Optional resolveBand(final int worldX, final int worldZ) + { + throw new UnsupportedOperationException("not yet implemented"); + } + + /** Convenience over {@link #resolveBand}: the matched band's display biome, or this palette's fallback. */ + public Biome resolveBiome(final int worldX, final int worldZ) + { + return this.resolveBand(worldX, worldZ) + .map(band -> band.target().display()) + .orElse(this.fallback); + } + /** The two noise fields a position is scored against to land it in a band. */ public record Climate(NoiseField temperature, NoiseField humidity, double scale) { @@ -36,10 +56,13 @@ public record Climate(NoiseField temperature, NoiseField humidity, double scale) /** * One biome table entry. First band containing both values wins, and anything no band covers * gets the palette's fallback. + *

+ * target is either a plain vanilla biome or a TFM-only one; see {@link BiomeTarget}. Whichever it + * is, {@link BiomeTarget#display()} is what the client and the biome provider see. * * @throws IllegalArgumentException if either range is inverted or falls outside -1 to 1 */ - public record BiomeBand(Biome biome, + public record BiomeBand(BiomeTarget target, double minTemperature, double maxTemperature, double minHumidity, diff --git a/src/main/java/me/totalfreedom/totalfreedommod/world/profile/ProfileLoader.java b/src/main/java/me/totalfreedom/totalfreedommod/world/profile/ProfileLoader.java index e1e3e6f68..a1354fc05 100644 --- a/src/main/java/me/totalfreedom/totalfreedommod/world/profile/ProfileLoader.java +++ b/src/main/java/me/totalfreedom/totalfreedommod/world/profile/ProfileLoader.java @@ -1,18 +1,36 @@ package me.totalfreedom.totalfreedommod.world.profile; import java.io.File; +import java.io.FileReader; +import java.io.IOException; +import java.io.InputStream; +import java.io.Reader; +import java.net.URISyntaxException; +import java.nio.file.FileSystem; +import java.nio.file.FileSystems; +import java.nio.file.Files; +import java.nio.file.Path; +import java.util.Arrays; +import java.util.HashMap; +import java.util.List; +import java.util.Map; import java.util.Optional; import java.util.Set; +import java.util.stream.Collectors; +import java.util.stream.Stream; import com.google.gson.JsonObject; +import com.google.gson.JsonParser; +import com.google.gson.JsonSyntaxException; import me.totalfreedom.totalfreedommod.TotalFreedomMod; +import me.totalfreedom.totalfreedommod.util.FLog; /** * Reads the .json files in the data folder's worlds directory. A file's name is the world's name, * and every file in there is a world we manage. *

- * {@link #copyTemplate} is the only way a template would ever reach the disk, + * {@link #copyTemplate} is the only way a template would ever reach the disk, * and once it does it stops being a template and becomes that world's profile. *

* Only reads and parses JSON, so it is safe off the main thread. Turning that JSON into a profile @@ -21,20 +39,24 @@ public final class ProfileLoader { private static final String WORLDS_DIRECTORY = "worlds"; + private static final String BIOMES_DIRECTORY = "biomes"; + private static final String JSON_EXTENSION = ".json"; private final TotalFreedomMod plugin; private final File directory; + private final File biomeDirectory; public ProfileLoader(final TotalFreedomMod plugin) { this.plugin = plugin; this.directory = new File(plugin.getDataFolder(), WORLDS_DIRECTORY); + this.biomeDirectory = new File(this.directory, BIOMES_DIRECTORY); } /** Every world with a profile file on disk. */ public Set available() { - + return namesOf(this.directory); } /** @@ -44,13 +66,45 @@ public Set available() */ public Optional read(final String worldName) throws ProfileException { + final File file = new File(this.directory, worldName + JSON_EXTENSION); + + if (!file.isFile()) + return Optional.empty(); + return Optional.of(readDisk(file, worldName)); + } + + /** + * Every biome definition a profile can reference by name, bundled defaults first, then this + * server's own {@code worlds/biomes} directory overlaid on top of any same-named bundled one. + * + * @throws ProfileException if a file on disk exists but is not readable JSON; a broken bundled + * file is a packaging bug, not something an admin can fix, so it is + * logged and skipped instead + */ + public Map biomeLibrary() throws ProfileException + { + final Map library = new HashMap<>(readBundled(WORLDS_DIRECTORY + "/" + BIOMES_DIRECTORY)); + final File[] files = this.biomeDirectory.listFiles((dir, name) -> name.endsWith(JSON_EXTENSION)); + + if (files == null) + return library; + + for (final File file : files) + { + if (!file.isFile()) + continue; + + library.put(stripExtension(file.getName()), readDisk(file, BIOMES_DIRECTORY + "/" + file.getName())); + } + + return library; } /** Names of the templates bundled in the jar. Never worlds. */ public Set templates() { - + return readBundled(WORLDS_DIRECTORY).keySet(); } /** @@ -62,6 +116,93 @@ public Set templates() */ public boolean copyTemplate(final String templateName, final String worldName) { + final File target = new File(this.directory, worldName + JSON_EXTENSION); + + if (target.exists()) + return false; + + final String resourcePath = WORLDS_DIRECTORY + "/" + templateName + JSON_EXTENSION; + + try (final InputStream in = this.plugin.getResource(resourcePath)) + { + if (in == null) + return false; + + this.directory.mkdirs(); + Files.copy(in, target.toPath()); + return true; + } + catch (final IOException ex) + { + FLog.warning("Failed to copy template '" + templateName + "' to world '" + worldName + "': " + ex.getMessage()); + return false; + } + } + + /** Direct .json children of a data-folder directory, extension stripped. Never recurses. */ + private static Set namesOf(final File directory) + { + final File[] files = directory.listFiles((dir, name) -> name.endsWith(JSON_EXTENSION)); + + if (files == null) + return Set.of(); + + return Arrays.stream(files) + .filter(File::isFile) + .map(file -> stripExtension(file.getName())) + .collect(Collectors.toUnmodifiableSet()); + } + + /** One disk file, parsed. path is where the resulting ProfileError points if it fails. */ + private static JsonObject readDisk(final File file, final String path) throws ProfileException + { + try (final Reader reader = new FileReader(file)) + { + return JsonParser.parseReader(reader).getAsJsonObject(); + } + catch (final IOException | JsonSyntaxException | IllegalStateException ex) + { + throw new ProfileException(path, List.of(new ProfileError(path, ex.getMessage()))); + } + } + /** + * Every direct .json child of one directory bundled in the plugin jar, keyed by filename with + * the extension stripped. Never recurses, so walking "worlds" never picks up "worlds/biomes". + * A file that fails to parse is logged and skipped rather than failing the whole walk, since a + * broken bundled resource is a packaging bug an admin cannot fix by editing anything on disk. + */ + private Map readBundled(final String jarPath) + { + final Map result = new HashMap<>(); + + try (final FileSystem zipFs = FileSystems.newFileSystem(Path.of(this.plugin.getClass().getProtectionDomain().getCodeSource().getLocation().toURI())); + final Stream walk = Files.walk(zipFs.getPath("/" + jarPath), 1)) + { + walk.filter(Files::isRegularFile) + .filter(path -> path.getFileName().toString().endsWith(JSON_EXTENSION)) + .forEach(path -> + { + try (final Reader reader = Files.newBufferedReader(path)) + { + result.put(stripExtension(path.getFileName().toString()), JsonParser.parseReader(reader).getAsJsonObject()); + } + catch (final IOException | JsonSyntaxException | IllegalStateException ex) + { + FLog.warning("Failed to read bundled resource " + path + ": " + ex.getMessage()); + } + }); + } + catch (final IOException | URISyntaxException ex) + { + FLog.warning("Failed to walk bundled " + jarPath + " resources: " + ex.getMessage()); + } + + return result; + } + + private static String stripExtension(final String fileName) + { + return fileName.substring(0, fileName.length() - JSON_EXTENSION.length()); } } diff --git a/src/main/java/me/totalfreedom/totalfreedommod/world/profile/ProfileParser.java b/src/main/java/me/totalfreedom/totalfreedommod/world/profile/ProfileParser.java index 2deb32109..bfc742d0e 100644 --- a/src/main/java/me/totalfreedom/totalfreedommod/world/profile/ProfileParser.java +++ b/src/main/java/me/totalfreedom/totalfreedommod/world/profile/ProfileParser.java @@ -1,13 +1,36 @@ package me.totalfreedom.totalfreedommod.world.profile; +import java.util.ArrayList; +import java.util.HashSet; +import java.util.LinkedHashSet; +import java.util.List; +import java.util.Locale; +import java.util.Map; +import java.util.Optional; +import java.util.Set; +import java.util.function.BiFunction; +import java.util.function.Function; +import java.util.stream.IntStream; + +import org.bukkit.Material; +import org.bukkit.TreeType; +import org.bukkit.World; +import org.bukkit.block.Biome; +import org.bukkit.block.data.BlockData; + +import com.google.gson.JsonArray; +import com.google.gson.JsonElement; import com.google.gson.JsonObject; import me.totalfreedom.totalfreedommod.world.GenerationProfile; +import me.totalfreedom.totalfreedommod.world.noise.NoiseField; +import me.totalfreedom.totalfreedommod.world.noise.NoiseProfile; +import me.totalfreedom.totalfreedommod.world.noise.NoiseType; /** * Turns a profile file into a checked {@link GenerationProfile}, or explains why it cannot. *

- * A missing required key is an error; a missing optional one is {@code Optional.empty()}. + * A missing required key is an error; a missing optional one is {@code Optional.empty()}. * Nothing is quietly defaulted into something that generates the wrong terrain. *

* Collect every problem before giving up, so one run of the server tells an admin everything wrong @@ -15,15 +38,1016 @@ *

* Main thread only, since block and biome names are looked up here. Reading the file is not, so do * that first and hand the parsed JSON in. + *

+ * A {@link FeatureSpec}'s own {@code "type"} key is the variant discriminator, so no + * {@link FeatureDetail} variant's fields may reuse that name. */ public final class ProfileParser { /** - * @param worldName the file's name, which becomes the world's name + * @param worldName the file's name, which becomes the world's name + * @param biomeLibrary every biome definition a {@code {"ref": "..."}} may name, from + * {@link ProfileLoader#biomeLibrary()} * @throws ProfileException carrying every problem found, never just the first */ - public GenerationProfile parse(final String worldName, final JsonObject root) throws ProfileException + public GenerationProfile parse(final String worldName, final JsonObject root, final Map biomeLibrary) throws ProfileException + { + final List errors = new ArrayList<>(); + final long seed = resolveSeed(worldName, root); + + final Optional shape = parseShapeSection(root, errors); + final Optional palette = parsePalette(root, errors, seed, biomeLibrary); + final List features = optionalArray(root, "features", "", errors) + .map(array -> parseFeatures(array, "features", errors)) + .orElse(List.of()); + final Optional world = parseWorldSettings(root, errors); + + if (!errors.isEmpty()) + throw new ProfileException(worldName, errors); + + return new GenerationProfile(worldName, shape.get().bounds(), shape.get().shape(), palette.get(), features, world.get()); + } + + /** shape.bounds and shape's own mode-specific fields, parsed together since they share one JSON object. */ + private record ParsedShape(Bounds bounds, Shape shape) + { + } + + /** + * A pinned seed if world.seed is a valid number, else one derived from the world's own name, so + * re-parsing an unseeded profile after a restart still produces the same terrain. Resolved before + * anything else, since palette.climate needs a seed to build its NoiseFields. Never records an + * error itself; a malformed world.seed is reported properly later, by parseWorldSettings. + */ + private static long resolveSeed(final String worldName, final JsonObject root) + { + final JsonElement worldNode = root.get("world"); + + if (worldNode != null && worldNode.isJsonObject()) + { + final JsonElement seedElement = worldNode.getAsJsonObject().get("seed"); + + if (seedElement != null && seedElement.isJsonPrimitive() && seedElement.getAsJsonPrimitive().isNumber()) + { + try + { + return seedElement.getAsLong(); + } + catch (final NumberFormatException ignored) + { + // Falls through; parseWorldSettings reports the real problem against world.seed. + } + } + } + + return worldName.hashCode(); + } + + private static Optional parseShapeSection(final JsonObject root, final List errors) + { + final Optional node = requireObject(root, "shape", "", errors); + if (node.isEmpty()) + return Optional.empty(); + + final String path = "shape"; + final Optional bounds = parseBounds(node.get(), path, errors); + final Optional mode = requireString(node.get(), "mode", path, errors); + + if (bounds.isEmpty() || mode.isEmpty()) + return Optional.empty(); + + final Optional shape = switch (mode.get().toLowerCase(Locale.ROOT)) + { + case "flat" -> parseFlatShape(node.get(), path, errors); + case "heightmap" -> parseHeightmapShape(node.get(), path, errors); + case "density" -> parseDensityShape(node.get(), path, errors); + default -> + { + errors.add(new ProfileError(childPath(path, "mode"), "unknown mode \"" + mode.get() + "\"")); + yield Optional.empty(); + } + }; + + return shape.map(s -> new ParsedShape(bounds.get(), s)); + } + + private static Optional parseBounds(final JsonObject shapeNode, final String parentPath, final List errors) + { + final Optional node = requireObject(shapeNode, "bounds", parentPath, errors); + if (node.isEmpty()) + return Optional.empty(); + + final String path = childPath(parentPath, "bounds"); + final Optional minY = requireInt(node.get(), "minY", path, errors); + final Optional maxY = requireInt(node.get(), "maxY", path, errors); + final Optional seaLevel = optionalInt(node.get(), "seaLevel", path, errors); + + if (minY.isEmpty() || maxY.isEmpty()) + return Optional.empty(); + + try + { + return Optional.of(new Bounds(minY.get(), maxY.get(), seaLevel)); + } + catch (final IllegalArgumentException ex) + { + errors.add(new ProfileError(path, ex.getMessage())); + return Optional.empty(); + } + } + + private static Optional parseFlatShape(final JsonObject shapeNode, final String path, final List errors) + { + final Optional spec = requireString(shapeNode, "layers", path, errors); + if (spec.isEmpty()) + return Optional.empty(); + + try + { + return Optional.of(new Shape.Flat(LayerStack.parse(spec.get()))); + } + catch (final IllegalArgumentException ex) + { + errors.add(new ProfileError(childPath(path, "layers"), ex.getMessage())); + return Optional.empty(); + } + } + + private static Optional parseHeightmapShape(final JsonObject shapeNode, final String path, final List errors) + { + final Optional terrainNode = requireObject(shapeNode, "terrain", path, errors); + final Optional terrain = terrainNode.flatMap(node -> parseShapeTerrain(node, childPath(path, "terrain"), errors)); + + final boolean hasRiver = hasKey(shapeNode, "river"); + final Optional river = hasRiver ? parseRiver(shapeNode, path, errors) : Optional.empty(); + + final boolean hasCaves = hasKey(shapeNode, "caves"); + final Optional caves = hasCaves ? parseCaves(shapeNode, path, errors) : Optional.empty(); + + final boolean hasRegions = hasKey(shapeNode, "regions"); + final Optional> regions = + parseRegions(shapeNode, path, errors, (node, p) -> parseShapeTerrain(node, p, errors)); + + if (terrain.isEmpty() || (hasRiver && river.isEmpty()) || (hasCaves && caves.isEmpty()) || (hasRegions && regions.isEmpty())) + return Optional.empty(); + + return Optional.of(new Shape.Heightmap(terrain.get(), river, caves, regions)); + } + + private static Optional parseDensityShape(final JsonObject shapeNode, final String path, final List errors) + { + final Optional terrainNode = requireObject(shapeNode, "terrain", path, errors); + final Optional terrain = terrainNode.flatMap(node -> parseDensityLayer(node, childPath(path, "terrain"), errors)); + + final boolean hasCaves = hasKey(shapeNode, "caves"); + final Optional caves = hasCaves ? parseCaves(shapeNode, path, errors) : Optional.empty(); + + final boolean hasRegions = hasKey(shapeNode, "regions"); + final Optional> regions = + parseRegions(shapeNode, path, errors, (node, p) -> parseDensityLayer(node, p, errors)); + + if (terrain.isEmpty() || (hasCaves && caves.isEmpty()) || (hasRegions && regions.isEmpty())) + return Optional.empty(); + + return Optional.of(new Shape.Density(terrain.get().noise(), terrain.get().warp(), caves, regions)); + } + + private static Optional parseShapeTerrain(final JsonObject node, final String path, final List errors) + { + final Optional noiseNode = requireObject(node, "noise", path, errors); + final Optional noise = noiseNode.flatMap(n -> parseNoiseProfile(n, childPath(path, "noise"), errors)); + final Optional splineNode = requireObject(node, "spline", path, errors); + final Optional spline = splineNode.flatMap(n -> parseSpline(n, childPath(path, "spline"), errors)); + final Optional warp = requireDouble(node, "warp", path, errors); + + if (noise.isEmpty() || spline.isEmpty() || warp.isEmpty()) + return Optional.empty(); + + return Optional.of(new Shape.Terrain(noise.get(), spline.get(), warp.get())); + } + + private static Optional parseDensityLayer(final JsonObject node, final String path, final List errors) + { + final Optional noiseNode = requireObject(node, "noise", path, errors); + final Optional noise = noiseNode.flatMap(n -> parseNoiseProfile(n, childPath(path, "noise"), errors)); + final Optional warp = requireDouble(node, "warp", path, errors); + + if (noise.isEmpty() || warp.isEmpty()) + return Optional.empty(); + + return Optional.of(new Shape.DensityLayer(noise.get(), warp.get())); + } + + private static Optional parseRiver(final JsonObject parent, final String parentPath, final List errors) + { + final Optional node = requireObject(parent, "river", parentPath, errors); + if (node.isEmpty()) + return Optional.empty(); + + final String path = childPath(parentPath, "river"); + final Optional noiseNode = requireObject(node.get(), "noise", path, errors); + final Optional noise = noiseNode.flatMap(n -> parseNoiseProfile(n, childPath(path, "noise"), errors)); + final Optional threshold = requireDouble(node.get(), "threshold", path, errors); + final Optional depth = requireInt(node.get(), "depth", path, errors); + final Optional bedBlock = requireBlock(node.get(), "bedBlock", path, errors); + + if (noise.isEmpty() || threshold.isEmpty() || depth.isEmpty() || bedBlock.isEmpty()) + return Optional.empty(); + + return Optional.of(new Shape.River(noise.get(), threshold.get(), depth.get(), bedBlock.get())); + } + + private static Optional parseCaves(final JsonObject parent, final String parentPath, final List errors) + { + final Optional node = requireObject(parent, "caves", parentPath, errors); + if (node.isEmpty()) + return Optional.empty(); + + final String path = childPath(parentPath, "caves"); + final Optional noiseNode = requireObject(node.get(), "noise", path, errors); + final Optional noise = noiseNode.flatMap(n -> parseNoiseProfile(n, childPath(path, "noise"), errors)); + final Optional threshold = requireDouble(node.get(), "threshold", path, errors); + final Optional minY = requireInt(node.get(), "minY", path, errors); + final Optional maxY = requireInt(node.get(), "maxY", path, errors); + final Optional floodLevel = requireInt(node.get(), "floodLevel", path, errors); + + if (noise.isEmpty() || threshold.isEmpty() || minY.isEmpty() || maxY.isEmpty() || floodLevel.isEmpty()) + return Optional.empty(); + + try + { + return Optional.of(new Shape.Caves(noise.get(), threshold.get(), minY.get(), maxY.get(), floodLevel.get())); + } + catch (final IllegalArgumentException ex) + { + errors.add(new ProfileError(path, ex.getMessage())); + return Optional.empty(); + } + } + + /** Shared by Heightmap and Density; T is whichever terrain shape that mode's regions carry. */ + private static Optional> parseRegions(final JsonObject parent, final String parentPath, + final List errors, + final BiFunction> terrainParser) + { + final Optional node = requireObject(parent, "regions", parentPath, errors); + if (node.isEmpty()) + return Optional.empty(); + + final String path = childPath(parentPath, "regions"); + final Optional selectorNode = requireObject(node.get(), "selector", path, errors); + final Optional selector = selectorNode.flatMap(n -> parseNoiseProfile(n, childPath(path, "selector"), errors)); + final Optional blendWidth = requireDouble(node.get(), "blendWidth", path, errors); + final Optional regionArray = requireArray(node.get(), "regions", path, errors); + + if (selector.isEmpty() || blendWidth.isEmpty() || regionArray.isEmpty()) + return Optional.empty(); + + final List> regions = new ArrayList<>(); + final Set seenNames = new HashSet<>(); + final boolean[] valid = { true }; + + IntStream.range(0, regionArray.get().size()).forEach(i -> + { + final String regionPath = childPath(path, "regions") + "[" + i + "]"; + final JsonElement element = regionArray.get().get(i); + + if (!element.isJsonObject()) + { + errors.add(new ProfileError(regionPath, "expected an object, got \"" + element + "\"")); + valid[0] = false; + return; + } + + final JsonObject regionNode = element.getAsJsonObject(); + final Optional name = requireString(regionNode, "name", regionPath, errors); + final Optional min = requireDouble(regionNode, "min", regionPath, errors); + final Optional max = requireDouble(regionNode, "max", regionPath, errors); + final Optional terrainNode = requireObject(regionNode, "terrain", regionPath, errors); + final Optional terrain = terrainNode.flatMap(n -> terrainParser.apply(n, childPath(regionPath, "terrain"))); + + if (name.isEmpty() || min.isEmpty() || max.isEmpty() || terrain.isEmpty()) + { + valid[0] = false; + return; + } + + if (!seenNames.add(name.get())) + { + errors.add(new ProfileError(childPath(regionPath, "name"), "duplicate region name \"" + name.get() + "\"")); + valid[0] = false; + return; + } + + try + { + regions.add(new Shape.Region<>(name.get(), min.get(), max.get(), terrain.get())); + } + catch (final IllegalArgumentException ex) + { + errors.add(new ProfileError(regionPath, ex.getMessage())); + valid[0] = false; + } + }); + + if (!valid[0]) + return Optional.empty(); + + return Optional.of(new Shape.Regions<>(selector.get(), blendWidth.get(), regions)); + } + + private static Optional parseNoiseProfile(final JsonObject node, final String path, final List errors) + { + final Optional type = requireEnum(node, "type", path, errors, "noise type", NoiseType::valueOf); + final Optional octaves = requireInt(node, "octaves", path, errors); + final Optional frequency = requireDouble(node, "frequency", path, errors); + final Optional persistence = requireDouble(node, "persistence", path, errors); + final Optional lacunarity = requireDouble(node, "lacunarity", path, errors); + final Optional ridged = requireBoolean(node, "ridged", path, errors); + + if (type.isEmpty() || octaves.isEmpty() || frequency.isEmpty() || persistence.isEmpty() || lacunarity.isEmpty() || ridged.isEmpty()) + return Optional.empty(); + + return Optional.of(new NoiseProfile(type.get(), octaves.get(), frequency.get(), persistence.get(), lacunarity.get(), ridged.get())); + } + + private static Optional parseSpline(final JsonObject node, final String path, final List errors) + { + final Optional pointsArray = requireArray(node, "points", path, errors); + if (pointsArray.isEmpty()) + return Optional.empty(); + + final List points = new ArrayList<>(); + final boolean[] valid = { true }; + final String pointsPath = childPath(path, "points"); + + IntStream.range(0, pointsArray.get().size()).forEach(i -> + { + final JsonElement element = pointsArray.get().get(i); + + try + { + final JsonArray pair = element.getAsJsonArray(); + + if (pair.size() != 2) + throw new IllegalStateException("expected a [noise, height] pair"); + + points.add(new double[] { pair.get(0).getAsDouble(), pair.get(1).getAsDouble() }); + } + catch (final RuntimeException ex) + { + errors.add(new ProfileError(pointsPath + "[" + i + "]", "expected a [noise, height] pair, got \"" + element + "\"")); + valid[0] = false; + } + }); + + if (!valid[0]) + return Optional.empty(); + + try + { + return Optional.of(Spline.of(points)); + } + catch (final IllegalArgumentException ex) + { + errors.add(new ProfileError(pointsPath, ex.getMessage())); + return Optional.empty(); + } + } + + private static Optional parsePalette(final JsonObject root, final List errors, + final long seed, final Map biomeLibrary) + { + final Optional node = requireObject(root, "palette", "", errors); + if (node.isEmpty()) + return Optional.empty(); + + final String path = "palette"; + final Optional materials = parseMaterials(node.get(), path, errors); + final Optional surfaceArray = requireArray(node.get(), "surface", path, errors); + final List surface = surfaceArray.map(array -> parseSurfaceRules(array, childPath(path, "surface"), errors)).orElse(List.of()); + final Optional climate = parseClimate(node.get(), path, errors, seed); + final Optional fallback = requireBiome(node.get(), "fallback", path, errors); + + final List biomes = optionalArray(node.get(), "biomes", path, errors) + .map(array -> parseBiomeBands(array, childPath(path, "biomes"), errors, biomeLibrary)) + .orElse(List.of()); + + if (materials.isEmpty() || surfaceArray.isEmpty() || climate.isEmpty() || fallback.isEmpty()) + return Optional.empty(); + + try + { + return Optional.of(new Palette(materials.get(), surface, climate.get(), fallback.get(), biomes)); + } + catch (final IllegalArgumentException ex) + { + errors.add(new ProfileError(path, ex.getMessage())); + return Optional.empty(); + } + } + + private static Optional parseMaterials(final JsonObject paletteNode, final String parentPath, final List errors) + { + final Optional node = requireObject(paletteNode, "materials", parentPath, errors); + if (node.isEmpty()) + return Optional.empty(); + + final String path = childPath(parentPath, "materials"); + final Optional defaultBlock = requireBlock(node.get(), "defaultBlock", path, errors); + final Optional fluidBlock = requireBlock(node.get(), "fluidBlock", path, errors); + final Optional bedrockBlock = requireBlock(node.get(), "bedrockBlock", path, errors); + final Optional bedrock = requireEnum(node.get(), "bedrock", path, errors, "bedrock mode", BedrockMode::valueOf); + + if (defaultBlock.isEmpty() || fluidBlock.isEmpty() || bedrockBlock.isEmpty() || bedrock.isEmpty()) + return Optional.empty(); + + return Optional.of(new Materials(defaultBlock.get(), fluidBlock.get(), bedrockBlock.get(), bedrock.get())); + } + + private static Optional parseClimate(final JsonObject paletteNode, final String parentPath, + final List errors, final long seed) + { + final Optional node = requireObject(paletteNode, "climate", parentPath, errors); + if (node.isEmpty()) + return Optional.empty(); + + final String path = childPath(parentPath, "climate"); + final Optional temperatureNode = requireObject(node.get(), "temperature", path, errors); + final Optional temperature = temperatureNode.flatMap(n -> parseNoiseProfile(n, childPath(path, "temperature"), errors)); + final Optional humidityNode = requireObject(node.get(), "humidity", path, errors); + final Optional humidity = humidityNode.flatMap(n -> parseNoiseProfile(n, childPath(path, "humidity"), errors)); + final Optional scale = requireDouble(node.get(), "scale", path, errors); + + if (temperature.isEmpty() || humidity.isEmpty() || scale.isEmpty()) + return Optional.empty(); + + final NoiseField temperatureField = NoiseField.of(temperature.get(), seed, "climate-temperature"); + final NoiseField humidityField = NoiseField.of(humidity.get(), seed, "climate-humidity"); + + return Optional.of(new Palette.Climate(temperatureField, humidityField, scale.get())); + } + + private static List parseBiomeBands(final JsonArray array, final String path, final List errors, + final Map biomeLibrary) + { + final List bands = new ArrayList<>(); + + IntStream.range(0, array.size()).forEach(i -> + parseBiomeBand(array.get(i), path + "[" + i + "]", errors, biomeLibrary).ifPresent(bands::add)); + + return bands; + } + + private static Optional parseBiomeBand(final JsonElement element, final String path, final List errors, + final Map biomeLibrary) + { + if (!element.isJsonObject()) + { + errors.add(new ProfileError(path, "expected an object, got \"" + element + "\"")); + return Optional.empty(); + } + + final JsonObject node = element.getAsJsonObject(); + final Optional target = parseBiomeTarget(node, path, errors, biomeLibrary); + final Optional minTemperature = requireDouble(node, "minTemperature", path, errors); + final Optional maxTemperature = requireDouble(node, "maxTemperature", path, errors); + final Optional minHumidity = requireDouble(node, "minHumidity", path, errors); + final Optional maxHumidity = requireDouble(node, "maxHumidity", path, errors); + + if (target.isEmpty() || minTemperature.isEmpty() || maxTemperature.isEmpty() || minHumidity.isEmpty() || maxHumidity.isEmpty()) + return Optional.empty(); + + try + { + return Optional.of(new Palette.BiomeBand( + target.get(), minTemperature.get(), maxTemperature.get(), minHumidity.get(), maxHumidity.get())); + } + catch (final IllegalArgumentException ex) + { + errors.add(new ProfileError(path, ex.getMessage())); + return Optional.empty(); + } + } + + /** "biome": a string names a vanilla biome; an object is a Logical one, inline or by {"ref": "..."}. */ + private static Optional parseBiomeTarget(final JsonObject bandNode, final String parentPath, + final List errors, final Map biomeLibrary) + { + final JsonElement element = bandNode.get("biome"); + final String path = childPath(parentPath, "biome"); + + if (element == null || element.isJsonNull()) + { + errors.add(new ProfileError(path, "missing required key")); + return Optional.empty(); + } + + if (element.isJsonPrimitive()) + return parseBiome(element.getAsString(), path, errors).map(BiomeTarget.Vanilla::new); + + if (!element.isJsonObject()) + { + errors.add(new ProfileError(path, "expected a string or an object, got \"" + element + "\"")); + return Optional.empty(); + } + + final JsonObject biomeNode = element.getAsJsonObject(); + final boolean hasRef = hasKey(biomeNode, "ref"); + final boolean hasDisplay = hasKey(biomeNode, "display"); + + if (hasRef == hasDisplay) + { + errors.add(new ProfileError(path, hasRef + ? "must not specify both \"ref\" and \"display\"" + : "must specify exactly one of \"ref\" or \"display\"")); + return Optional.empty(); + } + + if (!hasRef) + return parseBiomeDefinition(biomeNode, path, errors, biomeLibrary).map(BiomeTarget.Logical::new); + + final Optional ref = requireString(biomeNode, "ref", path, errors); + if (ref.isEmpty()) + return Optional.empty(); + + final JsonObject definitionNode = biomeLibrary.get(ref.get()); + if (definitionNode == null) + { + errors.add(new ProfileError(childPath(path, "ref"), + "unknown biome \"" + ref.get() + "\", not found in the bundled or world biome library")); + return Optional.empty(); + } + + return parseBiomeDefinition(definitionNode, path + ".ref(" + ref.get() + ")", errors, biomeLibrary).map(BiomeTarget.Logical::new); + } + + private static Optional parseBiomeDefinition(final JsonObject node, final String path, final List errors, + final Map biomeLibrary) + { + final Optional display = requireBiome(node, "display", path, errors); + if (display.isEmpty()) + return Optional.empty(); + + final Optional> surface = hasKey(node, "surface") + ? requireArray(node, "surface", path, errors).map(array -> parseSurfaceRules(array, childPath(path, "surface"), errors)) + : Optional.empty(); + + final Optional> features = hasKey(node, "features") + ? requireArray(node, "features", path, errors).map(array -> parseFeatures(array, childPath(path, "features"), errors)) + : Optional.empty(); + + return Optional.of(new BiomeDefinition(display.get(), surface, features)); + } + + private static List parseSurfaceRules(final JsonArray array, final String path, final List errors) + { + final List rules = new ArrayList<>(); + + IntStream.range(0, array.size()).forEach(i -> + parseSurfaceRule(array.get(i), path + "[" + i + "]", errors).ifPresent(rules::add)); + + return rules; + } + + private static Optional parseSurfaceRule(final JsonElement element, final String path, final List errors) + { + if (!element.isJsonObject()) + { + errors.add(new ProfileError(path, "expected an object, got \"" + element + "\"")); + return Optional.empty(); + } + + final JsonObject node = element.getAsJsonObject(); + final BiomeFilter biomes = parseSurfaceBiomeFilter(node, path, errors); + final Optional depth = parseDepth(node, path, errors); + final Optional block = requireBlock(node, "block", path, errors); + + if (depth.isEmpty() || block.isEmpty()) + return Optional.empty(); + + return Optional.of(new SurfaceRule(biomes, depth.get(), block.get())); + } + + private static Optional parseDepth(final JsonObject node, final String path, final List errors) + { + final Optional from = requireInt(node, "depthFrom", path, errors); + final Optional to = optionalInt(node, "depthTo", path, errors); + + if (from.isEmpty()) + return Optional.empty(); + + try + { + return Optional.of(to.isPresent() ? Depth.range(from.get(), to.get()) : Depth.rest(from.get())); + } + catch (final IllegalArgumentException ex) + { + errors.add(new ProfileError(path, ex.getMessage())); + return Optional.empty(); + } + } + + /** SurfaceRule's own shape: a single optional "biome" string. Absent means Any(). */ + private static BiomeFilter parseSurfaceBiomeFilter(final JsonObject node, final String path, final List errors) + { + final Optional name = optionalString(node, "biome", path, errors); + if (name.isEmpty()) + return BiomeFilter.any(); + + return parseBiome(name.get(), childPath(path, "biome"), errors).map(biome -> BiomeFilter.of(Set.of(biome))) + .orElseGet(BiomeFilter::any); + } + + /** A feature or biome band's own shape: an optional "biomes" array of strings. Absent means Any(). */ + private static BiomeFilter parseFeatureBiomeFilter(final JsonObject node, final String path, final List errors) + { + final Optional array = optionalArray(node, "biomes", path, errors); + if (array.isEmpty()) + return BiomeFilter.any(); + + final Set biomes = new LinkedHashSet<>(); + final String biomesPath = childPath(path, "biomes"); + + IntStream.range(0, array.get().size()).forEach(i -> + { + final JsonElement element = array.get().get(i); + + try + { + biomes.add(Biome.valueOf(element.getAsString().toUpperCase(Locale.ROOT))); + } + catch (final RuntimeException ex) + { + errors.add(new ProfileError(biomesPath + "[" + i + "]", "unknown biome \"" + element + "\"")); + } + }); + + if (biomes.isEmpty()) + { + errors.add(new ProfileError(biomesPath, "must not be empty")); + return BiomeFilter.any(); + } + + return BiomeFilter.of(biomes); + } + + private static List parseFeatures(final JsonArray array, final String path, final List errors) + { + final List specs = new ArrayList<>(); + + IntStream.range(0, array.size()).forEach(i -> + parseFeatureSpec(array.get(i), path + "[" + i + "]", errors).ifPresent(specs::add)); + + return specs; + } + + private static Optional parseFeatureSpec(final JsonElement element, final String path, final List errors) + { + if (!element.isJsonObject()) + { + errors.add(new ProfileError(path, "expected an object, got \"" + element + "\"")); + return Optional.empty(); + } + + final JsonObject node = element.getAsJsonObject(); + final Optional type = requireString(node, "type", path, errors); + final Optional rarity = requireInt(node, "rarity", path, errors); + final Optional minY = requireInt(node, "minY", path, errors); + final Optional maxY = requireInt(node, "maxY", path, errors); + final BiomeFilter biomes = parseFeatureBiomeFilter(node, path, errors); + + if (type.isEmpty() || rarity.isEmpty() || minY.isEmpty() || maxY.isEmpty()) + return Optional.empty(); + + final Optional detail = parseFeatureDetail(node, path, type.get().toLowerCase(Locale.ROOT), errors); + if (detail.isEmpty()) + return Optional.empty(); + + try + { + return Optional.of(new FeatureSpec(rarity.get(), minY.get(), maxY.get(), biomes, detail.get())); + } + catch (final IllegalArgumentException ex) + { + errors.add(new ProfileError(path, ex.getMessage())); + return Optional.empty(); + } + } + + private static Optional parseFeatureDetail(final JsonObject node, final String path, final String type, + final List errors) + { + return switch (type) + { + case "ore" -> + { + final Optional block = requireBlock(node, "block", path, errors); + final Optional size = requireInt(node, "size", path, errors); + yield (block.isEmpty() || size.isEmpty()) ? Optional.empty() : Optional.of(new FeatureDetail.Ore(block.get(), size.get())); + } + case "patch" -> + { + final Optional block = requireBlock(node, "block", path, errors); + final Optional spread = requireInt(node, "size", path, errors); + yield (block.isEmpty() || spread.isEmpty()) ? Optional.empty() : Optional.of(new FeatureDetail.Patch(block.get(), spread.get())); + } + case "lake" -> + { + final Optional fluid = requireBlock(node, "block", path, errors); + final Optional radius = requireInt(node, "size", path, errors); + yield (fluid.isEmpty() || radius.isEmpty()) ? Optional.empty() : Optional.of(new FeatureDetail.Lake(fluid.get(), radius.get())); + } + case "boulder" -> + { + final Optional block = requireBlock(node, "block", path, errors); + final Optional radius = requireInt(node, "size", path, errors); + yield (block.isEmpty() || radius.isEmpty()) ? Optional.empty() : Optional.of(new FeatureDetail.Boulder(block.get(), radius.get())); + } + case "tree" -> + { + if (hasKey(node, "treeType")) + { + final Optional treeType = requireEnum(node, "treeType", path, errors, "tree type", TreeType::valueOf); + yield treeType.map(FeatureDetail.Tree::new); + } + + yield Optional.of(new FeatureDetail.NaturalTree()); + } + default -> + { + errors.add(new ProfileError(childPath(path, "type"), "unknown feature type \"" + type + "\"")); + yield Optional.empty(); + } + }; + } + + private static Optional parseWorldSettings(final JsonObject root, final List errors) + { + final Optional node = requireObject(root, "world", "", errors); + if (node.isEmpty()) + return Optional.empty(); + + final String path = "world"; + final Optional environment = + requireEnum(node.get(), "environment", path, errors, "environment", World.Environment::valueOf); + final Optional generateStructures = requireBoolean(node.get(), "generateStructures", path, errors); + final Optional keepSpawnLoaded = requireBoolean(node.get(), "keepSpawnLoaded", path, errors); + final Optional seed = optionalLong(node.get(), "seed", path, errors); + final Optional vanilla = parseVanillaFlags(node.get(), path, errors); + + if (environment.isEmpty() || generateStructures.isEmpty() || keepSpawnLoaded.isEmpty() || vanilla.isEmpty()) + return Optional.empty(); + + return Optional.of(new WorldSettings(environment.get(), generateStructures.get(), keepSpawnLoaded.get(), seed, vanilla.get())); + } + + private static Optional parseVanillaFlags(final JsonObject worldNode, final String parentPath, + final List errors) + { + final Optional node = requireObject(worldNode, "vanilla", parentPath, errors); + if (node.isEmpty()) + return Optional.empty(); + + final String path = childPath(parentPath, "vanilla"); + final Optional surface = requireBoolean(node.get(), "surface", path, errors); + final Optional caves = requireBoolean(node.get(), "caves", path, errors); + final Optional decorations = requireBoolean(node.get(), "decorations", path, errors); + final Optional mobs = requireBoolean(node.get(), "mobs", path, errors); + final Optional structures = requireBoolean(node.get(), "structures", path, errors); + + if (surface.isEmpty() || caves.isEmpty() || decorations.isEmpty() || mobs.isEmpty() || structures.isEmpty()) + return Optional.empty(); + + return Optional.of(new WorldSettings.VanillaFlags(surface.get(), caves.get(), decorations.get(), mobs.get(), structures.get())); + } + + // Every requireX/optionalX below records a ProfileError itself before returning empty, so a + // caller can always tell "already reported" apart from "fine, wasn't there" without adding its + // own error. + + private static String childPath(final String parentPath, final String key) + { + return parentPath.isEmpty() ? key : parentPath + "." + key; + } + + private static boolean hasKey(final JsonObject obj, final String key) + { + final JsonElement element = obj.get(key); + return element != null && !element.isJsonNull(); + } + + private static Optional presentField(final JsonObject obj, final String key, final String parentPath, + final boolean required, final List errors) { + final JsonElement element = obj.get(key); + if (element == null || element.isJsonNull()) + { + if (required) + errors.add(new ProfileError(childPath(parentPath, key), "missing required key")); + + return Optional.empty(); + } + + return Optional.of(element); + } + + private static Optional asString(final JsonElement element, final String key, final String parentPath, final List errors) + { + try + { + return Optional.of(element.getAsString()); + } + catch (final RuntimeException ex) + { + errors.add(new ProfileError(childPath(parentPath, key), "expected a string, got \"" + element + "\"")); + return Optional.empty(); + } + } + + private static Optional asInt(final JsonElement element, final String key, final String parentPath, final List errors) + { + try + { + return Optional.of(element.getAsInt()); + } + catch (final RuntimeException ex) + { + errors.add(new ProfileError(childPath(parentPath, key), "expected a number, got \"" + element + "\"")); + return Optional.empty(); + } + } + + private static Optional asLong(final JsonElement element, final String key, final String parentPath, final List errors) + { + try + { + return Optional.of(element.getAsLong()); + } + catch (final RuntimeException ex) + { + errors.add(new ProfileError(childPath(parentPath, key), "expected a number, got \"" + element + "\"")); + return Optional.empty(); + } + } + + private static Optional asDouble(final JsonElement element, final String key, final String parentPath, final List errors) + { + try + { + return Optional.of(element.getAsDouble()); + } + catch (final RuntimeException ex) + { + errors.add(new ProfileError(childPath(parentPath, key), "expected a number, got \"" + element + "\"")); + return Optional.empty(); + } + } + + private static Optional asBoolean(final JsonElement element, final String key, final String parentPath, final List errors) + { + try + { + return Optional.of(element.getAsBoolean()); + } + catch (final RuntimeException ex) + { + errors.add(new ProfileError(childPath(parentPath, key), "expected true or false, got \"" + element + "\"")); + return Optional.empty(); + } + } + + private static Optional asObject(final JsonElement element, final String key, final String parentPath, final List errors) + { + try + { + return Optional.of(element.getAsJsonObject()); + } + catch (final RuntimeException ex) + { + errors.add(new ProfileError(childPath(parentPath, key), "expected an object, got \"" + element + "\"")); + return Optional.empty(); + } + } + + private static Optional asArray(final JsonElement element, final String key, final String parentPath, final List errors) + { + try + { + return Optional.of(element.getAsJsonArray()); + } + catch (final RuntimeException ex) + { + errors.add(new ProfileError(childPath(parentPath, key), "expected an array, got \"" + element + "\"")); + return Optional.empty(); + } + } + + private static Optional requireString(final JsonObject obj, final String key, final String path, final List errors) + { + return presentField(obj, key, path, true, errors).flatMap(e -> asString(e, key, path, errors)); + } + + private static Optional optionalString(final JsonObject obj, final String key, final String path, final List errors) + { + return presentField(obj, key, path, false, errors).flatMap(e -> asString(e, key, path, errors)); + } + + private static Optional requireInt(final JsonObject obj, final String key, final String path, final List errors) + { + return presentField(obj, key, path, true, errors).flatMap(e -> asInt(e, key, path, errors)); + } + + private static Optional optionalInt(final JsonObject obj, final String key, final String path, final List errors) + { + return presentField(obj, key, path, false, errors).flatMap(e -> asInt(e, key, path, errors)); + } + + private static Optional optionalLong(final JsonObject obj, final String key, final String path, final List errors) + { + return presentField(obj, key, path, false, errors).flatMap(e -> asLong(e, key, path, errors)); + } + + private static Optional requireDouble(final JsonObject obj, final String key, final String path, final List errors) + { + return presentField(obj, key, path, true, errors).flatMap(e -> asDouble(e, key, path, errors)); + } + + private static Optional requireBoolean(final JsonObject obj, final String key, final String path, final List errors) + { + return presentField(obj, key, path, true, errors).flatMap(e -> asBoolean(e, key, path, errors)); + } + + private static Optional requireObject(final JsonObject obj, final String key, final String path, final List errors) + { + return presentField(obj, key, path, true, errors).flatMap(e -> asObject(e, key, path, errors)); + } + + private static Optional requireArray(final JsonObject obj, final String key, final String path, final List errors) + { + return presentField(obj, key, path, true, errors).flatMap(e -> asArray(e, key, path, errors)); + } + + private static Optional optionalArray(final JsonObject obj, final String key, final String path, final List errors) + { + return presentField(obj, key, path, false, errors).flatMap(e -> asArray(e, key, path, errors)); + } + + private static > Optional requireEnum(final JsonObject obj, final String key, final String path, + final List errors, final String typeName, + final Function valueOf) + { + return requireString(obj, key, path, errors).flatMap(name -> parseEnum(name, childPath(path, key), errors, typeName, valueOf)); + } + + private static Optional parseEnum(final String name, final String path, final List errors, + final String typeName, final Function valueOf) + { + try + { + return Optional.of(valueOf.apply(name.toUpperCase(Locale.ROOT))); + } + catch (final IllegalArgumentException ex) + { + errors.add(new ProfileError(path, "unknown " + typeName + " \"" + name + "\"")); + return Optional.empty(); + } + } + + private static Optional requireBlock(final JsonObject obj, final String key, final String path, final List errors) + { + return requireString(obj, key, path, errors).flatMap(name -> parseBlock(name, childPath(path, key), errors)); + } + + private static Optional parseBlock(final String name, final String path, final List errors) + { + try + { + return Optional.of(Material.valueOf(name.toUpperCase(Locale.ROOT)).createBlockData()); + } + catch (final IllegalArgumentException ex) + { + errors.add(new ProfileError(path, "unknown block \"" + name + "\"")); + return Optional.empty(); + } + } + + private static Optional requireBiome(final JsonObject obj, final String key, final String path, final List errors) + { + return requireString(obj, key, path, errors).flatMap(name -> parseBiome(name, childPath(path, key), errors)); + } + + private static Optional parseBiome(final String name, final String path, final List errors) + { + try + { + return Optional.of(Biome.valueOf(name.toUpperCase(Locale.ROOT))); + } + catch (final IllegalArgumentException ex) + { + errors.add(new ProfileError(path, "unknown biome \"" + name + "\"")); + return Optional.empty(); + } } } diff --git a/src/main/java/me/totalfreedom/totalfreedommod/world/profile/Shape.java b/src/main/java/me/totalfreedom/totalfreedommod/world/profile/Shape.java index ada5503e7..0742b38f9 100644 --- a/src/main/java/me/totalfreedom/totalfreedommod/world/profile/Shape.java +++ b/src/main/java/me/totalfreedom/totalfreedommod/world/profile/Shape.java @@ -1,5 +1,6 @@ package me.totalfreedom.totalfreedommod.world.profile; +import java.util.List; import java.util.Optional; import org.bukkit.block.data.BlockData; @@ -23,17 +24,29 @@ record Flat(LayerStack layers) implements Shape { } - /** 2D height through a spline. No overhangs. */ + /** + * 2D height through a spline. No overhangs. + *

+ * regions, when present, lets different parts of the world use different terrain instead of one + * spline everywhere; see {@link Regions}. + */ record Heightmap(Terrain terrain, Optional river, - Optional caves) implements Shape + Optional caves, + Optional> regions) implements Shape { } - /** 3D density. Overhangs and floating islands, at roughly fifty times the samples. */ + /** + * 3D density. Overhangs and floating islands, at roughly fifty times the samples. + *

+ * regions, when present, lets different parts of the world use different density noise instead + * of one field everywhere; see {@link Regions}. + */ record Density(NoiseProfile noise, double warp, - Optional caves) implements Shape + Optional caves, + Optional> regions) implements Shape { } @@ -42,6 +55,11 @@ record Terrain(NoiseProfile noise, Spline spline, double warp) { } + /** A density mode region's own noise. warp offsets the sample coordinates, same as {@link Terrain}. */ + record DensityLayer(NoiseProfile noise, double warp) + { + } + /** Pulls height toward sea level where the noise is near zero. */ record River(NoiseProfile noise, double threshold, int depth, BlockData bedBlock) { @@ -62,4 +80,47 @@ record Caves(NoiseProfile noise, double threshold, int minY, int maxY, int flood throw new IllegalArgumentException("minY (" + minY + ") must not be above maxY (" + maxY + ")"); } } + + /** + * One named region: the slice of the selector noise it claims, and the terrain it uses there. + *

+ * First matching region in the enclosing list wins, same idiom as {@link Palette.BiomeBand} and + * {@link SurfaceRule}. name only has to be unique within that list; it exists so an admin + * authoring a profile can tell regions apart in an error message, not for anything to reference. + * + * @throws IllegalArgumentException if min is above max + */ + record Region(String name, double min, double max, T terrain) + { + public Region + { + if (min > max) + throw new IllegalArgumentException("min (" + min + ") must not be above max (" + max + ")"); + } + + public boolean matches(final double value) + { + return value >= this.min && value <= this.max; + } + } + + /** + * A coarse selector noise plus the ordered regions it can pick between, for worlds that want + * different terrain in different places rather than one spline or one density field everywhere. + *

+ * blendWidth is how far either side of a region boundary, in the selector's own -1 to 1 units, + * two neighbouring regions' terrain gets blended together, so borders read as a gradient rather + * than a hard seam. + *

+ * Wherever the selector's value matches no listed region, the enclosing {@link Heightmap} or + * {@link Density}'s own terrain field applies instead. Same fallback idiom as a palette's biome + * bands plus its fallback biome; there is no separate "implicit region" to reason about. + */ + record Regions(NoiseProfile selector, double blendWidth, List> regions) + { + public Regions + { + regions = List.copyOf(regions); + } + } } diff --git a/src/main/java/me/totalfreedom/totalfreedommod/world/stage/DensityGenerator.java b/src/main/java/me/totalfreedom/totalfreedommod/world/stage/DensityGenerator.java index 42a0206a5..2441ffd01 100644 --- a/src/main/java/me/totalfreedom/totalfreedommod/world/stage/DensityGenerator.java +++ b/src/main/java/me/totalfreedom/totalfreedommod/world/stage/DensityGenerator.java @@ -1,5 +1,8 @@ package me.totalfreedom.totalfreedommod.world.stage; +import java.util.List; +import java.util.Optional; + import org.bukkit.generator.ChunkGenerator; import me.totalfreedom.totalfreedommod.world.base.ChunkContext; @@ -14,18 +17,28 @@ *

* Roughly fifty times the samples of heightmap mode, so only use it if a world actually needs * those shapes. + *

+ * density is the fallback used wherever regions is empty or its selector matches no listed region. + * TODO: generateBase/surfaceHeight need to sample regions.selector() per column once regions is + * present, blend the matched BuiltRegion's own noise/warp in over blendWidth, and fall back to + * density otherwise. Same shape as {@link HeightmapGenerator}, just without a spline. */ public final class DensityGenerator implements Generator { private final NoiseField density; private final Bounds bounds; private final Materials materials; + private final Optional regions; - public DensityGenerator(final NoiseField density, final Bounds bounds, final Materials materials) + public DensityGenerator(final NoiseField density, + final Bounds bounds, + final Materials materials, + final Optional regions) { this.density = density; this.bounds = bounds; this.materials = materials; + this.regions = regions; } @Override @@ -39,4 +52,17 @@ public int surfaceHeight(final int worldX, final int worldZ) { } + + /** + * One profile region, already built: a sampled noise field, not the raw settings + * {@link me.totalfreedom.totalfreedommod.world.profile.Shape.DensityLayer} carries. + */ + record BuiltRegion(String name, double min, double max, NoiseField noise, double warp) + { + } + + /** See {@link HeightmapGenerator.RegionSet}; the same role-string requirement applies here too. */ + record RegionSet(NoiseField selector, double blendWidth, List regions) + { + } } diff --git a/src/main/java/me/totalfreedom/totalfreedommod/world/stage/FeaturePopulator.java b/src/main/java/me/totalfreedom/totalfreedommod/world/stage/FeaturePopulator.java index 409ed7095..f7b5ebb2c 100644 --- a/src/main/java/me/totalfreedom/totalfreedommod/world/stage/FeaturePopulator.java +++ b/src/main/java/me/totalfreedom/totalfreedommod/world/stage/FeaturePopulator.java @@ -3,19 +3,20 @@ import java.util.List; import java.util.Random; -import org.bukkit.block.Biome; import org.bukkit.generator.LimitedRegion; import me.totalfreedom.totalfreedommod.world.base.ChunkContext; import me.totalfreedom.totalfreedommod.world.base.Populator; import me.totalfreedom.totalfreedommod.world.profile.Anchor; import me.totalfreedom.totalfreedommod.world.profile.FeatureSpec; -import me.totalfreedom.totalfreedommod.world.profile.Palette; import me.totalfreedom.totalfreedommod.world.stage.feature.FeatureRegistry; /** * Rolls each feature in the profile against the chunk and hands off the hits. This only decides * what gets placed and where; the features do the placing. + *

+ * TODO: roll() must resolve each column's band via {@code palette().resolveBand()}. A column whose + * band has its own {@code features()} list should roll only that list, not this.specs. */ public final class FeaturePopulator implements Populator { @@ -48,7 +49,7 @@ private void roll(final ChunkContext context, final int worldX = context.worldX(localX); final int worldZ = context.worldZ(localZ); - if (!spec.appliesTo(this.biomeAt(context, worldX, worldZ))) + if (!spec.appliesTo(context.getProfile().palette().resolveBiome(worldX, worldZ))) continue; final int y = spec.detail().anchor() == Anchor.SURFACE @@ -61,25 +62,4 @@ private void roll(final ChunkContext context, this.registry.place(context, data, spec.detail(), worldX, y, worldZ); } } - - /** - * Same temperature/humidity lookup {@link me.totalfreedom.totalfreedommod.world.adapter.ProfileBiomeProvider} - * uses. Duplicated rather than shared, since that class does not expose it as a static helper. - */ - private Biome biomeAt(final ChunkContext context, final int worldX, final int worldZ) - { - final Palette palette = context.getProfile().palette(); - final Palette.Climate climate = palette.climate(); - final int sampleX = (int) (worldX * climate.scale()); - final int sampleZ = (int) (worldZ * climate.scale()); - final double temperature = climate.temperature().sample(sampleX, sampleZ); - final double humidity = climate.humidity().sample(sampleX, sampleZ); - - return palette.biomes() - .stream() - .filter(band -> band.matches(temperature, humidity)) - .findFirst() - .map(Palette.BiomeBand::biome) - .orElse(palette.fallback()); - } } diff --git a/src/main/java/me/totalfreedom/totalfreedommod/world/stage/HeightmapGenerator.java b/src/main/java/me/totalfreedom/totalfreedommod/world/stage/HeightmapGenerator.java index 32ce2b311..3a392a85c 100644 --- a/src/main/java/me/totalfreedom/totalfreedommod/world/stage/HeightmapGenerator.java +++ b/src/main/java/me/totalfreedom/totalfreedommod/world/stage/HeightmapGenerator.java @@ -1,5 +1,8 @@ package me.totalfreedom.totalfreedommod.world.stage; +import java.util.List; +import java.util.Optional; + import org.bukkit.generator.ChunkGenerator; import me.totalfreedom.totalfreedommod.world.base.ChunkContext; @@ -15,6 +18,11 @@ *

* Sample on a grid and interpolate between the samples. Sampling every block is 98,304 positions * per chunk, times however many octaves the noise has. + *

+ * terrain/spline/warp are the fallback used wherever regions is empty or its selector matches no + * listed region, exactly as they always have been. TODO: generateBase/surfaceHeight need to sample + * regions.selector() per column once regions is present, blend the matched BuiltRegion's own + * noise/spline/warp in over blendWidth, and fall back to the fields above otherwise. */ public final class HeightmapGenerator implements Generator { @@ -24,13 +32,15 @@ public final class HeightmapGenerator implements Generator private final Bounds bounds; private final Materials materials; private final double warp; + private final Optional regions; public HeightmapGenerator(final NoiseField terrain, final NoiseField river, final Spline spline, final Bounds bounds, final Materials materials, - final double warp) + final double warp, + final Optional regions) { this.terrain = terrain; this.river = river; @@ -38,6 +48,7 @@ public HeightmapGenerator(final NoiseField terrain, this.bounds = bounds; this.materials = materials; this.warp = warp; + this.regions = regions; } @Override @@ -51,4 +62,23 @@ public int surfaceHeight(final int worldX, final int worldZ) { } + + /** + * One profile region, already built: a sampled noise field and a ready spline, not the raw + * settings {@link me.totalfreedom.totalfreedommod.world.profile.Shape.Region} carries. + */ + record BuiltRegion(String name, double min, double max, NoiseField noise, Spline spline, double warp) + { + } + + /** + * The selector noise plus its built regions and blend width. {@code ProfileChunkGenerator.wire()} + * builds this from a profile's {@link me.totalfreedom.totalfreedommod.world.profile.Shape.Regions} + * by calling {@code NoiseField.of} once per region plus once for the selector itself, each with + * its own role string (e.g. {@code "terrain-region-"}, {@code "terrain-selector"}) so no two + * fields in one profile collapse onto the same random stream. + */ + record RegionSet(NoiseField selector, double blendWidth, List regions) + { + } } diff --git a/src/main/java/me/totalfreedom/totalfreedommod/world/stage/RuleDesigner.java b/src/main/java/me/totalfreedom/totalfreedommod/world/stage/RuleDesigner.java index 79df600c2..0ee39a33e 100644 --- a/src/main/java/me/totalfreedom/totalfreedommod/world/stage/RuleDesigner.java +++ b/src/main/java/me/totalfreedom/totalfreedommod/world/stage/RuleDesigner.java @@ -12,6 +12,10 @@ /** * Applies the profile's surface rules. Walks each column down from the context's column top with a * depth counter that resets on air gaps, so cave floors get their own treatment. + *

+ * TODO: surface() must resolve each column's band via {@code palette().resolveBand()}, not just its + * display biome, and use a {@code Logical} band's own {@code surface()} list when it has one. + * Everything else falls through to this.rules. */ public final class RuleDesigner implements Designer { diff --git a/src/main/java/me/totalfreedom/totalfreedommod/world/stage/feature/FeatureRegistry.java b/src/main/java/me/totalfreedom/totalfreedommod/world/stage/feature/FeatureRegistry.java index 77fe317bd..28f089631 100644 --- a/src/main/java/me/totalfreedom/totalfreedommod/world/stage/feature/FeatureRegistry.java +++ b/src/main/java/me/totalfreedom/totalfreedommod/world/stage/feature/FeatureRegistry.java @@ -19,6 +19,7 @@ public final class FeatureRegistry private final LakeFeature lake = new LakeFeature(); private final BoulderFeature boulder = new BoulderFeature(); private final TreeFeature tree = new TreeFeature(); + private final NaturalTreeFeature naturalTree = new NaturalTreeFeature(); public void place(final ChunkContext context, final LimitedRegion region, @@ -34,6 +35,7 @@ public void place(final ChunkContext context, case FeatureDetail.Lake d -> this.lake.place(context, region, d, x, y, z); case FeatureDetail.Boulder d -> this.boulder.place(context, region, d, x, y, z); case FeatureDetail.Tree d -> this.tree.place(context, region, d, x, y, z); + case FeatureDetail.NaturalTree d -> this.naturalTree.place(context, region, d, x, y, z); } } } diff --git a/src/main/java/me/totalfreedom/totalfreedommod/world/stage/feature/NaturalTreeFeature.java b/src/main/java/me/totalfreedom/totalfreedommod/world/stage/feature/NaturalTreeFeature.java new file mode 100644 index 000000000..518be5469 --- /dev/null +++ b/src/main/java/me/totalfreedom/totalfreedommod/world/stage/feature/NaturalTreeFeature.java @@ -0,0 +1,121 @@ +package me.totalfreedom.totalfreedommod.world.stage.feature; + +import java.util.HashMap; +import java.util.List; +import java.util.Map; +import java.util.Random; +import java.util.stream.Stream; + +import org.bukkit.Location; +import org.bukkit.TreeType; +import org.bukkit.block.Biome; +import org.bukkit.generator.LimitedRegion; + +import me.totalfreedom.totalfreedommod.world.base.ChunkContext; +import me.totalfreedom.totalfreedommod.world.profile.FeatureDetail; + +/** + * Grows whichever tree the placement site's actual biome would naturally produce, weighted the way + * vanilla mixes them; nine spruce to one tall spruce in a taiga, mostly oak with a scattering of + * birch in a forest, and so on. + *

+ * Reads the biome from the region rather than the feature spec's own biome filter, since a spec can + * span several biomes with different mixes. + *

+ * A biome missing from the table has no natural tree cover at all (desert, ocean, badlands, the + * nether, the end) and is silently skipped rather than defaulted to oak. + */ +public final class NaturalTreeFeature implements Feature +{ + private record Weighted(TreeType type, int weight) + { + } + + /** + * One mix per distinct species combination, not per biome. Plenty of biomes share the exact same + * mix (three kinds of savanna all want plain acacia), so the table says so once instead of + * repeating it. + */ + private static final Map> MIXES = buildMixes(); + + private static Map> buildMixes() + { + final Map> mixes = new HashMap<>(); + + mix(mixes, List.of(new Weighted(TreeType.TREE, 4), new Weighted(TreeType.BIRCH, 1)), + Biome.FOREST); + mix(mixes, List.of(new Weighted(TreeType.TREE, 1)), + Biome.PLAINS, Biome.SUNFLOWER_PLAINS, Biome.FLOWER_FOREST); + mix(mixes, List.of(new Weighted(TreeType.BIRCH, 1)), + Biome.BIRCH_FOREST); + mix(mixes, List.of(new Weighted(TreeType.TALL_BIRCH, 1)), + Biome.OLD_GROWTH_BIRCH_FOREST); + mix(mixes, List.of(new Weighted(TreeType.DARK_OAK, 4), new Weighted(TreeType.TREE, 1)), + Biome.DARK_FOREST); + mix(mixes, List.of(new Weighted(TreeType.TREE, 3), new Weighted(TreeType.REDWOOD, 1)), + Biome.WINDSWEPT_FOREST); + mix(mixes, List.of(new Weighted(TreeType.REDWOOD, 9), new Weighted(TreeType.TALL_REDWOOD, 1)), + Biome.TAIGA); + mix(mixes, List.of(new Weighted(TreeType.TALL_REDWOOD, 3), new Weighted(TreeType.MEGA_REDWOOD, 1)), + Biome.OLD_GROWTH_PINE_TAIGA); + mix(mixes, List.of(new Weighted(TreeType.MEGA_REDWOOD, 3), new Weighted(TreeType.TALL_REDWOOD, 1)), + Biome.OLD_GROWTH_SPRUCE_TAIGA); + mix(mixes, List.of(new Weighted(TreeType.REDWOOD, 1)), + Biome.SNOWY_TAIGA, Biome.SNOWY_PLAINS); + mix(mixes, List.of(new Weighted(TreeType.JUNGLE, 4), new Weighted(TreeType.SMALL_JUNGLE, 1)), + Biome.JUNGLE); + mix(mixes, List.of(new Weighted(TreeType.SMALL_JUNGLE, 3), new Weighted(TreeType.JUNGLE, 1)), + Biome.SPARSE_JUNGLE); + mix(mixes, List.of(new Weighted(TreeType.JUNGLE, 1)), + Biome.BAMBOO_JUNGLE); + mix(mixes, List.of(new Weighted(TreeType.ACACIA, 1)), + Biome.SAVANNA, Biome.SAVANNA_PLATEAU, Biome.WINDSWEPT_SAVANNA); + mix(mixes, List.of(new Weighted(TreeType.SWAMP, 1)), + Biome.SWAMP); + + return Map.copyOf(mixes); + } + + private static void mix(final Map> mixes, final List mix, final Biome... biomes) + { + Stream.of(biomes).forEach(biome -> mixes.put(biome, mix)); + } + + @Override + public void place(final ChunkContext context, + final LimitedRegion region, + final FeatureDetail.NaturalTree detail, + final int x, + final int y, + final int z) + { + final Biome biome = region.getBiome(x, y, z); + final List mix = MIXES.get(biome); + + if (mix == null) + return; + + final Random random = context.getRandom(); + final TreeType type = pick(mix, random); + + region.generateTree(new Location(null, x, y, z), random, type); + } + + /** Rolls one weighted pick out of a biome's mix. */ + private static TreeType pick(final List mix, final Random random) + { + final int total = mix.stream().mapToInt(Weighted::weight).sum(); + int roll = random.nextInt(total); + + for (final Weighted candidate : mix) + { + if (roll < candidate.weight()) + return candidate.type(); + + roll -= candidate.weight(); + } + + // Unreachable: weights sum to total, so the loop above always returns first. + return mix.get(0).type(); + } +} diff --git a/src/main/java/me/totalfreedom/totalfreedommod/world/stage/feature/TreeFeature.java b/src/main/java/me/totalfreedom/totalfreedommod/world/stage/feature/TreeFeature.java index 1daf45f4c..cbb37cea0 100644 --- a/src/main/java/me/totalfreedom/totalfreedommod/world/stage/feature/TreeFeature.java +++ b/src/main/java/me/totalfreedom/totalfreedommod/world/stage/feature/TreeFeature.java @@ -1,23 +1,14 @@ package me.totalfreedom.totalfreedommod.world.stage.feature; -import java.security.InvalidParameterException; -import java.util.HashMap; -import java.util.Map; - import org.bukkit.Location; -import org.bukkit.Material; -import org.bukkit.Tag; -import org.bukkit.TreeType; -import org.bukkit.block.BlockType; import org.bukkit.generator.LimitedRegion; import me.totalfreedom.totalfreedommod.world.base.ChunkContext; import me.totalfreedom.totalfreedommod.world.profile.FeatureDetail; -import me.totalfreedom.totalfreedommod.world.profile.FeatureSpec; /** - * Grows a tree. The spec's block names the sapling, and the sapling picks the species, so - * oak_sapling grows an oak and spruce_sapling grows a spruce. + * Grows one exact tree species, regardless of the biome it lands in. Use {@link NaturalTreeFeature} + * instead for a biome-appropriate mix. *

* Hands off to LimitedRegion#generateTree, which knows every vanilla tree shape and handles the * canopy crossing a chunk border. @@ -32,5 +23,6 @@ public void place(final ChunkContext context, final int y, final int z) { + region.generateTree(new Location(null, x, y, z), context.getRandom(), detail.type()); } } diff --git a/src/main/resources/worlds/flatlands-template.json b/src/main/resources/worlds/flatlands-template.json index d9af5c466..85fa5b36c 100644 --- a/src/main/resources/worlds/flatlands-template.json +++ b/src/main/resources/worlds/flatlands-template.json @@ -11,7 +11,16 @@ "fluidBlock": "water", "bedrockBlock": "bedrock", "bedrock": "FLOOR" - } + }, + "surface": [ + { "depthFrom": 0, "block": "grass_block" } + ], + "climate": { + "temperature": { "type": "simplex", "octaves": 1, "frequency": 0.001, "persistence": 0.5, "lacunarity": 2.0, "ridged": false }, + "humidity": { "type": "simplex", "octaves": 1, "frequency": 0.001, "persistence": 0.5, "lacunarity": 2.0, "ridged": false }, + "scale": 1.0 + }, + "fallback": "plains" }, "features": [], diff --git a/src/main/resources/worlds/overworld-template.json b/src/main/resources/worlds/overworld-template.json index 713cd548e..d8dd0ba88 100644 --- a/src/main/resources/worlds/overworld-template.json +++ b/src/main/resources/worlds/overworld-template.json @@ -107,10 +107,8 @@ { "type": "ore", "rarity": 4, "minY": -64, "maxY": 32, "block": "gold_ore", "size": 9 }, { "type": "ore", "rarity": 2, "minY": -64, "maxY": 30, "block": "lapis_ore", "size": 7 }, { "type": "ore", "rarity": 1, "minY": -64, "maxY": 16, "block": "diamond_ore", "size": 8 }, - { "type": "tree", "rarity": 10, "minY": 60, "maxY": 200, "block": "oak_sapling", "size": 1, "biomes": ["forest"] }, - { "type": "tree", "rarity": 8, "minY": 60, "maxY": 200, "block": "jungle_sapling", "size": 1, "biomes": ["jungle"] }, - { "type": "tree", "rarity": 6, "minY": 60, "maxY": 200, "block": "spruce_sapling", "size": 1, "biomes": ["taiga", "snowy_plains"] }, - { "type": "tree", "rarity": 1, "minY": 60, "maxY": 200, "block": "oak_sapling", "size": 1, "biomes": ["plains", "savanna"] }, + { "type": "tree", "rarity": 10, "minY": 60, "maxY": 200, "biomes": ["forest", "taiga", "jungle"] }, + { "type": "tree", "rarity": 3, "minY": 60, "maxY": 200, "biomes": ["plains", "savanna", "snowy_plains"] }, { "type": "patch", "rarity": 7, "minY": 60, "maxY": 200, "block": "short_grass", "size": 32, "biomes": ["plains", "savanna"] }, { "type": "patch", "rarity": 2, "minY": 60, "maxY": 200, "block": "short_grass", "size": 32, "biomes": ["forest", "jungle"] }, { "type": "patch", "rarity": 2, "minY": 60, "maxY": 200, "block": "dead_bush", "size": 6, "biomes": ["desert"] }, From bcc041c0293f22821cb66482278b6f61838d4cfb Mon Sep 17 00:00:00 2001 From: Paldiu Date: Mon, 10 Aug 2026 14:45:26 -0500 Subject: [PATCH 21/32] Minor logic changes ; comment revisions --- .../totalfreedommod/util/Lazy.java | 14 +-- .../world/GenerationProfile.java | 8 +- .../world/adapter/SpawnFinder.java | 7 +- .../world/base/ChunkContext.java | 5 + .../totalfreedommod/world/base/Generator.java | 5 +- .../world/noise/NoiseProfile.java | 17 ++- .../totalfreedommod/world/profile/Bounds.java | 2 +- .../world/profile/FeatureDetail.java | 40 +++++++ .../world/profile/LayerStack.java | 39 ++++--- .../world/profile/ProfileLoader.java | 34 +++++- .../world/profile/ProfileParser.java | 100 ++++++++++++++---- .../totalfreedommod/world/profile/Shape.java | 5 + .../stage/feature/NaturalTreeFeature.java | 9 +- .../world/stage/feature/OreFeature.java | 9 +- 14 files changed, 229 insertions(+), 65 deletions(-) diff --git a/src/main/java/me/totalfreedom/totalfreedommod/util/Lazy.java b/src/main/java/me/totalfreedom/totalfreedommod/util/Lazy.java index 300a8de9d..4f715a60b 100644 --- a/src/main/java/me/totalfreedom/totalfreedommod/util/Lazy.java +++ b/src/main/java/me/totalfreedom/totalfreedommod/util/Lazy.java @@ -3,17 +3,11 @@ import java.util.function.Supplier; /** - * A value that gets worked out the first time you ask for it, then cached. + * Lazily computes a value once and safely publishes it to concurrent callers. {@code null} is a + * valid cached result. *

- * Wrap the expensive part in a supplier and hand it over; nothing runs until the first - * {@link #get()}. Every call after that hands back the same value, and the supplier is never run - * again, including when it returned null. - *

- * Safe to share between threads. Do not call {@link #get()} from inside the supplier though. - * {@code synchronized} is reentrant on the thread already holding it, so this will not deadlock; - * instead the supplier calls itself, {@code initialized} is still false each time, and it recurses - * until the stack overflows, all while holding the monitor and blocking every other thread's call - * to {@link #get()} for as long as that takes. + * The supplier must not call {@link #get()} on this same instance; that recurses instead of + * deadlocking, since the lock is reentrant. * * @param the type being worked out */ diff --git a/src/main/java/me/totalfreedom/totalfreedommod/world/GenerationProfile.java b/src/main/java/me/totalfreedom/totalfreedommod/world/GenerationProfile.java index a1ae1badf..ee5149dff 100644 --- a/src/main/java/me/totalfreedom/totalfreedommod/world/GenerationProfile.java +++ b/src/main/java/me/totalfreedom/totalfreedommod/world/GenerationProfile.java @@ -5,12 +5,16 @@ import me.totalfreedom.totalfreedommod.world.profile.Bounds; import me.totalfreedom.totalfreedommod.world.profile.FeatureSpec; import me.totalfreedom.totalfreedommod.world.profile.Palette; +import me.totalfreedom.totalfreedommod.world.profile.ProfileParser; import me.totalfreedom.totalfreedommod.world.profile.Shape; import me.totalfreedom.totalfreedommod.world.profile.WorldSettings; /** - * One world's profile. Pure data, and every field is already checked, so anything reading this can - * take it at face value. + * One world's profile. Pure data, built only by {@link ProfileParser}, which turns away the obvious + * nonsense: negative radii, an octave count that would never finish, that kind of thing. So the + * fields you'd actually crash on are safe to trust. What's not covered yet is the merely + * questionable, like a {@link Palette.Climate#scale} of zero; nothing stops that yet but this should + * probably be something that we look into making much more strict. *

* Holds no stage objects. The chunk generator pattern matches {@link Shape} once to pick its * stages, which keeps this package free of any dependency on the generation code. diff --git a/src/main/java/me/totalfreedom/totalfreedommod/world/adapter/SpawnFinder.java b/src/main/java/me/totalfreedom/totalfreedommod/world/adapter/SpawnFinder.java index 8a6217f44..f05e7567d 100644 --- a/src/main/java/me/totalfreedom/totalfreedommod/world/adapter/SpawnFinder.java +++ b/src/main/java/me/totalfreedom/totalfreedommod/world/adapter/SpawnFinder.java @@ -14,6 +14,11 @@ * Loads no chunks, since that function is pure maths, which is what lets it check hundreds of * candidates in the time a single chunk load would take. The cleanroom generator it replaces loaded * chunk (0, 0) on the main thread during world creation just to find one column. + *

+ * The height it reads is the designer's terrain, before caves or any other carving runs, since + * carving only happens per chunk and this deliberately loads none. So if a carver would've hollowed + * out the column it picked, that never gets factored in, meaning a cave or overhang could end up + * right under the chosen spawn. */ public final class SpawnFinder { @@ -93,7 +98,7 @@ private Optional searchRing(final World world, final int ring, final i return Optional.empty(); } - /** A column qualifies if its ground sits above the water line and below the world's ceiling. */ + /** A column qualifies if its pre-carving ground sits above the water line and below the world's ceiling. */ private Optional candidate(final World world, final int x, final int z, final int floor) { final int height = this.generator.surfaceHeight(x, z); diff --git a/src/main/java/me/totalfreedom/totalfreedommod/world/base/ChunkContext.java b/src/main/java/me/totalfreedom/totalfreedommod/world/base/ChunkContext.java index ff93dbafe..0aaaf22a4 100644 --- a/src/main/java/me/totalfreedom/totalfreedommod/world/base/ChunkContext.java +++ b/src/main/java/me/totalfreedom/totalfreedommod/world/base/ChunkContext.java @@ -69,6 +69,11 @@ public int terrainHeight(final int localX, final int localZ) /** * Highest solid block for a local column, after carving. Differs from the terrain height * wherever a cave broke the surface, so this is the one the {@link Designer} wants. + *

+ * Starts at {@link #terrainHeight} and walks down while the carver keeps saying that y is + * carved. That only works if terrain height really is the highest solid block in the column, + * which holds for heightmap generation but is just an approximation for density, where an + * overhang sitting above the nominal surface would slip past this entirely. */ public int columnTop(final int localX, final int localZ) { diff --git a/src/main/java/me/totalfreedom/totalfreedommod/world/base/Generator.java b/src/main/java/me/totalfreedom/totalfreedommod/world/base/Generator.java index c023e60c9..6d502f44d 100644 --- a/src/main/java/me/totalfreedom/totalfreedommod/world/base/Generator.java +++ b/src/main/java/me/totalfreedom/totalfreedommod/world/base/Generator.java @@ -17,8 +17,9 @@ public interface Generator * Writes the chunk. Runs under generateNoise. *

* ChunkData takes local x/z (0-15) and absolute y. Use setRegion for runs of the same block up - * a column, and sample noise on a grid and interpolate between the samples; a chunk is 98,304 - * blocks, so sampling every one of them is not an option. + * a column, and sample noise on a grid and interpolate between the samples; a chunk can hold + * hundreds of thousands of block positions depending on the world's own bounds, so sampling + * every one of them is not an option. *

* Off the main thread. Only the context's WorldInfo is safe to touch, never World or entities. */ diff --git a/src/main/java/me/totalfreedom/totalfreedommod/world/noise/NoiseProfile.java b/src/main/java/me/totalfreedom/totalfreedommod/world/noise/NoiseProfile.java index 212c58cd7..65bb76387 100644 --- a/src/main/java/me/totalfreedom/totalfreedommod/world/noise/NoiseProfile.java +++ b/src/main/java/me/totalfreedom/totalfreedommod/world/noise/NoiseProfile.java @@ -7,7 +7,9 @@ * here. The seed comes from the field's role name mixed with the world seed when the profile * parses. * - * @throws IllegalArgumentException if octaves is below one, or frequency is not positive + * @throws IllegalArgumentException if octaves is below one or above {@value #MAX_OCTAVES}, if + * frequency is not positive and finite, or if persistence or + * lacunarity is not finite */ public record NoiseProfile(NoiseType type, int octaves, @@ -16,8 +18,21 @@ public record NoiseProfile(NoiseType type, double lacunarity, boolean ridged) { + /** Above this, an OctaveGenerator's own per-octave cost stops being worth what it buys. */ + private static final int MAX_OCTAVES = 16; + public NoiseProfile { + if (octaves < 1 || octaves > MAX_OCTAVES) + throw new IllegalArgumentException("octaves (" + octaves + ") must fall within 1 to " + MAX_OCTAVES); + + if (!(frequency > 0.0D) || Double.isInfinite(frequency)) + throw new IllegalArgumentException("frequency (" + frequency + ") must be positive and finite"); + + if (!Double.isFinite(persistence)) + throw new IllegalArgumentException("persistence (" + persistence + ") must be finite"); + if (!Double.isFinite(lacunarity)) + throw new IllegalArgumentException("lacunarity (" + lacunarity + ") must be finite"); } } diff --git a/src/main/java/me/totalfreedom/totalfreedommod/world/profile/Bounds.java b/src/main/java/me/totalfreedom/totalfreedommod/world/profile/Bounds.java index 6655ffebf..2f344dc26 100644 --- a/src/main/java/me/totalfreedom/totalfreedommod/world/profile/Bounds.java +++ b/src/main/java/me/totalfreedom/totalfreedommod/world/profile/Bounds.java @@ -3,7 +3,7 @@ import java.util.Optional; /** - * A world's vertical limits and water line. Clamped against WorldInfo when the profile parses. + * A world's configured vertical limits and water line. *

* An empty seaLevel means the world has no sea at all, which is what the end and flat worlds want. * There is no "sea level 0 means off" rule to remember. diff --git a/src/main/java/me/totalfreedom/totalfreedommod/world/profile/FeatureDetail.java b/src/main/java/me/totalfreedom/totalfreedommod/world/profile/FeatureDetail.java index d6bcf0ffb..54e3f4702 100644 --- a/src/main/java/me/totalfreedom/totalfreedommod/world/profile/FeatureDetail.java +++ b/src/main/java/me/totalfreedom/totalfreedommod/world/profile/FeatureDetail.java @@ -18,6 +18,16 @@ public sealed interface FeatureDetail /** A vein buried in the filler block. size is how many blocks the vein is. */ record Ore(BlockData block, int size) implements FeatureDetail { + /** How large a single vein is allowed to get, so one entry cannot become a per-chunk sink. */ + private static final int MAX_SIZE = 64; + + /** @throws IllegalArgumentException if size is below one or above {@value #MAX_SIZE} */ + public Ore + { + if (size < 1 || size > MAX_SIZE) + throw new IllegalArgumentException("size (" + size + ") must fall within 1 to " + MAX_SIZE); + } + @Override public Anchor anchor() { @@ -28,6 +38,16 @@ public Anchor anchor() /** A scatter across the surface. spread is how far from the origin it reaches. */ record Patch(BlockData block, int spread) implements FeatureDetail { + /** How many placement attempts a single entry may make. */ + private static final int MAX_SPREAD = 256; + + /** @throws IllegalArgumentException if spread is below one or above {@value #MAX_SPREAD} */ + public Patch + { + if (spread < 1 || spread > MAX_SPREAD) + throw new IllegalArgumentException("spread (" + spread + ") must fall within 1 to " + MAX_SPREAD); + } + @Override public Anchor anchor() { @@ -38,6 +58,16 @@ public Anchor anchor() /** A hollowed bowl filled with fluid. */ record Lake(BlockData fluid, int radius) implements FeatureDetail { + /** How wide a bowl is allowed to get; the fill loop is cubic in this. */ + private static final int MAX_RADIUS = 32; + + /** @throws IllegalArgumentException if radius is below one or above {@value #MAX_RADIUS} */ + public Lake + { + if (radius < 1 || radius > MAX_RADIUS) + throw new IllegalArgumentException("radius (" + radius + ") must fall within 1 to " + MAX_RADIUS); + } + @Override public Anchor anchor() { @@ -48,6 +78,16 @@ public Anchor anchor() /** A rough blob resting on the ground. */ record Boulder(BlockData block, int radius) implements FeatureDetail { + /** How wide a lobe is allowed to get; the fill loop is cubic in this. */ + private static final int MAX_RADIUS = 16; + + /** @throws IllegalArgumentException if radius is below one or above {@value #MAX_RADIUS} */ + public Boulder + { + if (radius < 1 || radius > MAX_RADIUS) + throw new IllegalArgumentException("radius (" + radius + ") must fall within 1 to " + MAX_RADIUS); + } + @Override public Anchor anchor() { diff --git a/src/main/java/me/totalfreedom/totalfreedommod/world/profile/LayerStack.java b/src/main/java/me/totalfreedom/totalfreedommod/world/profile/LayerStack.java index 152b482e1..73323f862 100644 --- a/src/main/java/me/totalfreedom/totalfreedommod/world/profile/LayerStack.java +++ b/src/main/java/me/totalfreedom/totalfreedommod/world/profile/LayerStack.java @@ -31,44 +31,41 @@ private LayerStack(final BlockData[] blocks, final int[] heights) * @apiNote Uses bitwise operations instead of standard mathematical operators for efficiency. * @param spec e.g. {@code "16|stone|32|dirt|1|grass_block"}; the legacy comma form also works * @throws IllegalArgumentException if the spec is malformed, names an unknown block, or gives a - * height below one + * height below one. Does not know the world's own vertical + * limits, so a stack fitting inside those is the parser's job, + * checked against {@link Bounds} once minY/maxY are known. */ public static LayerStack parse(final String spec) { - if (spec == null || spec.trim().isEmpty()) + if (spec == null || spec.trim().isEmpty()) throw new IllegalArgumentException("Spec cannot be empty"); - + String[] split = spec.split("[,|]"); if ((split.length & 1) != 0) throw new IllegalArgumentException("Invalid spec format. Expected pairs of height and material."); - + final int pairCount = split.length >> 1; // divides by 2 final BlockData[] blocks = new BlockData[pairCount]; final int[] heights = new int[pairCount]; - + IntStream.range(0, pairCount) - .forEach(i -> + .forEach(i -> { final int heightIdx = i << 1; // i * 2 final int materialIdx = heightIdx | 1; // (i * 2) + 1 - - heights[i] = Integer.parseInt(split[heightIdx].trim()); - + + final int height = Integer.parseInt(split[heightIdx].trim()); + + if (height < 1) + throw new IllegalArgumentException("layer height (" + height + ") must be at least one"); + + heights[i] = height; + final Material mat = Material.valueOf(split[materialIdx].trim().toUpperCase(Locale.ROOT)); blocks[i] = mat.createBlockData(); }); - - final LayerStack stack = new LayerStack(blocks, heights); - - if (stack.totalHeight() > 320) - { - throw new IllegalArgumentException(String.format( - "Total layer height (%d) exceeds Minecraft's maximum world height limit (384 blocks, Y=-64 to Y=320)", - stack.totalHeight() - )); - } - - return stack; + + return new LayerStack(blocks, heights); } /** How many layers there are, bottom to top. */ diff --git a/src/main/java/me/totalfreedom/totalfreedommod/world/profile/ProfileLoader.java b/src/main/java/me/totalfreedom/totalfreedommod/world/profile/ProfileLoader.java index a1354fc05..109ad9c68 100644 --- a/src/main/java/me/totalfreedom/totalfreedommod/world/profile/ProfileLoader.java +++ b/src/main/java/me/totalfreedom/totalfreedommod/world/profile/ProfileLoader.java @@ -16,9 +16,11 @@ import java.util.Map; import java.util.Optional; import java.util.Set; +import java.util.regex.Pattern; import java.util.stream.Collectors; import java.util.stream.Stream; +import com.google.gson.JsonElement; import com.google.gson.JsonObject; import com.google.gson.JsonParser; import com.google.gson.JsonSyntaxException; @@ -35,6 +37,10 @@ *

* Only reads and parses JSON, so it is safe off the main thread. Turning that JSON into a profile * is {@link ProfileParser}, which is not. + *

+ * Every world name passed in is checked against {@link #VALID_WORLD_NAME} before it touches a + * {@link File}, so nothing here trusts a caller to have already constrained it. A world name with a + * path separator or ".." would otherwise be able to read or write outside the worlds directory. */ public final class ProfileLoader { @@ -42,6 +48,9 @@ public final class ProfileLoader private static final String BIOMES_DIRECTORY = "biomes"; private static final String JSON_EXTENSION = ".json"; + /** No path separators or "..", so a world name can never resolve outside {@link #directory}. */ + private static final Pattern VALID_WORLD_NAME = Pattern.compile("[A-Za-z0-9_-]+"); + private final TotalFreedomMod plugin; private final File directory; private final File biomeDirectory; @@ -62,10 +71,15 @@ public Set available() /** * One world's raw JSON, off disk. Empty if it has no file, which is not an error. * - * @throws ProfileException if the file exists but is not readable JSON + * @throws ProfileException if the file exists but is not readable JSON + * @throws IllegalArgumentException if worldName is not a valid world name; callers taking a + * world name from an admin or a command must validate it + * before it reaches here, since this is the boundary that + * decides which file on disk gets touched */ public Optional read(final String worldName) throws ProfileException { + requireValidWorldName(worldName); final File file = new File(this.directory, worldName + JSON_EXTENSION); if (!file.isFile()) @@ -113,9 +127,11 @@ public Set templates() * * @param templateName one of {@link #templates()} * @param worldName the world to create, which becomes the file name + * @throws IllegalArgumentException if worldName is not a valid world name */ public boolean copyTemplate(final String templateName, final String worldName) { + requireValidWorldName(worldName); final File target = new File(this.directory, worldName + JSON_EXTENSION); if (target.exists()) @@ -139,6 +155,13 @@ public boolean copyTemplate(final String templateName, final String worldName) } } + /** @throws IllegalArgumentException if worldName contains anything but letters, digits, underscores, or hyphens */ + private static void requireValidWorldName(final String worldName) + { + if (worldName == null || !VALID_WORLD_NAME.matcher(worldName).matches()) + throw new IllegalArgumentException("invalid world name \"" + worldName + "\""); + } + /** Direct .json children of a data-folder directory, extension stripped. Never recurses. */ private static Set namesOf(final File directory) { @@ -158,9 +181,14 @@ private static JsonObject readDisk(final File file, final String path) throws Pr { try (final Reader reader = new FileReader(file)) { - return JsonParser.parseReader(reader).getAsJsonObject(); + final JsonElement root = JsonParser.parseReader(reader); + + if (!root.isJsonObject()) + throw new ProfileException(path, List.of(new ProfileError(path, "expected an object"))); + + return root.getAsJsonObject(); } - catch (final IOException | JsonSyntaxException | IllegalStateException ex) + catch (final IOException | JsonSyntaxException ex) { throw new ProfileException(path, List.of(new ProfileError(path, ex.getMessage()))); } diff --git a/src/main/java/me/totalfreedom/totalfreedommod/world/profile/ProfileParser.java b/src/main/java/me/totalfreedom/totalfreedommod/world/profile/ProfileParser.java index bfc742d0e..c24f7aa49 100644 --- a/src/main/java/me/totalfreedom/totalfreedommod/world/profile/ProfileParser.java +++ b/src/main/java/me/totalfreedom/totalfreedommod/world/profile/ProfileParser.java @@ -10,6 +10,7 @@ import java.util.Set; import java.util.function.BiFunction; import java.util.function.Function; +import java.util.function.Supplier; import java.util.stream.IntStream; import org.bukkit.Material; @@ -36,8 +37,11 @@ * Collect every problem before giving up, so one run of the server tells an admin everything wrong * with the file. Stopping at the first error means fixing typos one server restart at a time. *

- * Main thread only, since block and biome names are looked up here. Reading the file is not, so do - * that first and hand the parsed JSON in. + * Treated as main thread only, though honestly that's more a project convention than a real API + * requirement; {@link Material#valueOf} and {@link Biome#valueOf} are just enum lookups and don't + * actually need the main thread. Keeping every Bukkit-facing lookup on one thread just means nobody + * has to double check that assumption later as this file grows. Reading the file itself is not + * bound the same way, so do that first and hand in the parsed JSON. *

* A {@link FeatureSpec}'s own {@code "type"} key is the variant discriminator, so no * {@link FeatureDetail} variant's fields may reuse that name. @@ -118,7 +122,7 @@ private static Optional parseShapeSection(final JsonObject root, fi final Optional shape = switch (mode.get().toLowerCase(Locale.ROOT)) { - case "flat" -> parseFlatShape(node.get(), path, errors); + case "flat" -> parseFlatShape(node.get(), path, errors, bounds.get()); case "heightmap" -> parseHeightmapShape(node.get(), path, errors); case "density" -> parseDensityShape(node.get(), path, errors); default -> @@ -156,7 +160,8 @@ private static Optional parseBounds(final JsonObject shapeNode, final St } } - private static Optional parseFlatShape(final JsonObject shapeNode, final String path, final List errors) + private static Optional parseFlatShape(final JsonObject shapeNode, final String path, final List errors, + final Bounds bounds) { final Optional spec = requireString(shapeNode, "layers", path, errors); if (spec.isEmpty()) @@ -164,7 +169,17 @@ private static Optional parseFlatShape(final JsonObject shapeNode, final try { - return Optional.of(new Shape.Flat(LayerStack.parse(spec.get()))); + final LayerStack layers = LayerStack.parse(spec.get()); + final int available = bounds.maxY() - bounds.minY(); + + if (layers.totalHeight() > available) + { + errors.add(new ProfileError(childPath(path, "layers"), "total layer height (" + layers.totalHeight() + + ") exceeds the world's own bounds (" + available + " blocks, Y=" + bounds.minY() + " to Y=" + bounds.maxY() + ")")); + return Optional.empty(); + } + + return Optional.of(new Shape.Flat(layers)); } catch (final IllegalArgumentException ex) { @@ -185,8 +200,9 @@ private static Optional parseHeightmapShape(final JsonObject shapeNode, f final Optional caves = hasCaves ? parseCaves(shapeNode, path, errors) : Optional.empty(); final boolean hasRegions = hasKey(shapeNode, "regions"); - final Optional> regions = - parseRegions(shapeNode, path, errors, (node, p) -> parseShapeTerrain(node, p, errors)); + final Optional> regions = hasRegions + ? parseRegions(shapeNode, path, errors, (node, p) -> parseShapeTerrain(node, p, errors)) + : Optional.empty(); if (terrain.isEmpty() || (hasRiver && river.isEmpty()) || (hasCaves && caves.isEmpty()) || (hasRegions && regions.isEmpty())) return Optional.empty(); @@ -203,8 +219,9 @@ private static Optional parseDensityShape(final JsonObject shapeNode, fin final Optional caves = hasCaves ? parseCaves(shapeNode, path, errors) : Optional.empty(); final boolean hasRegions = hasKey(shapeNode, "regions"); - final Optional> regions = - parseRegions(shapeNode, path, errors, (node, p) -> parseDensityLayer(node, p, errors)); + final Optional> regions = hasRegions + ? parseRegions(shapeNode, path, errors, (node, p) -> parseDensityLayer(node, p, errors)) + : Optional.empty(); if (terrain.isEmpty() || (hasCaves && caves.isEmpty()) || (hasRegions && regions.isEmpty())) return Optional.empty(); @@ -353,7 +370,15 @@ private static Optional> parseRegions(final JsonObject pare if (!valid[0]) return Optional.empty(); - return Optional.of(new Shape.Regions<>(selector.get(), blendWidth.get(), regions)); + try + { + return Optional.of(new Shape.Regions<>(selector.get(), blendWidth.get(), regions)); + } + catch (final IllegalArgumentException ex) + { + errors.add(new ProfileError(path, ex.getMessage())); + return Optional.empty(); + } } private static Optional parseNoiseProfile(final JsonObject node, final String path, final List errors) @@ -368,7 +393,15 @@ private static Optional parseNoiseProfile(final JsonObject node, f if (type.isEmpty() || octaves.isEmpty() || frequency.isEmpty() || persistence.isEmpty() || lacunarity.isEmpty() || ridged.isEmpty()) return Optional.empty(); - return Optional.of(new NoiseProfile(type.get(), octaves.get(), frequency.get(), persistence.get(), lacunarity.get(), ridged.get())); + try + { + return Optional.of(new NoiseProfile(type.get(), octaves.get(), frequency.get(), persistence.get(), lacunarity.get(), ridged.get())); + } + catch (final IllegalArgumentException ex) + { + errors.add(new ProfileError(path, ex.getMessage())); + return Optional.empty(); + } } private static Optional parseSpline(final JsonObject node, final String path, final List errors) @@ -482,10 +515,18 @@ private static Optional parseClimate(final JsonObject paletteNo if (temperature.isEmpty() || humidity.isEmpty() || scale.isEmpty()) return Optional.empty(); - final NoiseField temperatureField = NoiseField.of(temperature.get(), seed, "climate-temperature"); - final NoiseField humidityField = NoiseField.of(humidity.get(), seed, "climate-humidity"); + try + { + final NoiseField temperatureField = NoiseField.of(temperature.get(), seed, "climate-temperature"); + final NoiseField humidityField = NoiseField.of(humidity.get(), seed, "climate-humidity"); - return Optional.of(new Palette.Climate(temperatureField, humidityField, scale.get())); + return Optional.of(new Palette.Climate(temperatureField, humidityField, scale.get())); + } + catch (final IllegalArgumentException ex) + { + errors.add(new ProfileError(path, ex.getMessage())); + return Optional.empty(); + } } private static List parseBiomeBands(final JsonArray array, final String path, final List errors, @@ -744,25 +785,33 @@ private static Optional parseFeatureDetail(final JsonObject node, { final Optional block = requireBlock(node, "block", path, errors); final Optional size = requireInt(node, "size", path, errors); - yield (block.isEmpty() || size.isEmpty()) ? Optional.empty() : Optional.of(new FeatureDetail.Ore(block.get(), size.get())); + yield (block.isEmpty() || size.isEmpty()) + ? Optional.empty() + : buildDetail(path, errors, () -> new FeatureDetail.Ore(block.get(), size.get())); } case "patch" -> { final Optional block = requireBlock(node, "block", path, errors); final Optional spread = requireInt(node, "size", path, errors); - yield (block.isEmpty() || spread.isEmpty()) ? Optional.empty() : Optional.of(new FeatureDetail.Patch(block.get(), spread.get())); + yield (block.isEmpty() || spread.isEmpty()) + ? Optional.empty() + : buildDetail(path, errors, () -> new FeatureDetail.Patch(block.get(), spread.get())); } case "lake" -> { final Optional fluid = requireBlock(node, "block", path, errors); final Optional radius = requireInt(node, "size", path, errors); - yield (fluid.isEmpty() || radius.isEmpty()) ? Optional.empty() : Optional.of(new FeatureDetail.Lake(fluid.get(), radius.get())); + yield (fluid.isEmpty() || radius.isEmpty()) + ? Optional.empty() + : buildDetail(path, errors, () -> new FeatureDetail.Lake(fluid.get(), radius.get())); } case "boulder" -> { final Optional block = requireBlock(node, "block", path, errors); final Optional radius = requireInt(node, "size", path, errors); - yield (block.isEmpty() || radius.isEmpty()) ? Optional.empty() : Optional.of(new FeatureDetail.Boulder(block.get(), radius.get())); + yield (block.isEmpty() || radius.isEmpty()) + ? Optional.empty() + : buildDetail(path, errors, () -> new FeatureDetail.Boulder(block.get(), radius.get())); } case "tree" -> { @@ -782,6 +831,21 @@ private static Optional parseFeatureDetail(final JsonObject node, }; } + /** Runs a feature detail's constructor, turning the IllegalArgumentException its validation may throw into a ProfileError. */ + private static Optional buildDetail(final String path, final List errors, + final Supplier constructor) + { + try + { + return Optional.of(constructor.get()); + } + catch (final IllegalArgumentException ex) + { + errors.add(new ProfileError(path, ex.getMessage())); + return Optional.empty(); + } + } + private static Optional parseWorldSettings(final JsonObject root, final List errors) { final Optional node = requireObject(root, "world", "", errors); diff --git a/src/main/java/me/totalfreedom/totalfreedommod/world/profile/Shape.java b/src/main/java/me/totalfreedom/totalfreedommod/world/profile/Shape.java index 0742b38f9..3a772441e 100644 --- a/src/main/java/me/totalfreedom/totalfreedommod/world/profile/Shape.java +++ b/src/main/java/me/totalfreedom/totalfreedommod/world/profile/Shape.java @@ -115,11 +115,16 @@ public boolean matches(final double value) * Wherever the selector's value matches no listed region, the enclosing {@link Heightmap} or * {@link Density}'s own terrain field applies instead. Same fallback idiom as a palette's biome * bands plus its fallback biome; there is no separate "implicit region" to reason about. + * + * @throws IllegalArgumentException if blendWidth is negative */ record Regions(NoiseProfile selector, double blendWidth, List> regions) { public Regions { + if (blendWidth < 0.0D) + throw new IllegalArgumentException("blendWidth (" + blendWidth + ") must not be negative"); + regions = List.copyOf(regions); } } diff --git a/src/main/java/me/totalfreedom/totalfreedommod/world/stage/feature/NaturalTreeFeature.java b/src/main/java/me/totalfreedom/totalfreedommod/world/stage/feature/NaturalTreeFeature.java index 518be5469..dc1f5f75c 100644 --- a/src/main/java/me/totalfreedom/totalfreedommod/world/stage/feature/NaturalTreeFeature.java +++ b/src/main/java/me/totalfreedom/totalfreedommod/world/stage/feature/NaturalTreeFeature.java @@ -15,9 +15,12 @@ import me.totalfreedom.totalfreedommod.world.profile.FeatureDetail; /** - * Grows whichever tree the placement site's actual biome would naturally produce, weighted the way - * vanilla mixes them; nine spruce to one tall spruce in a taiga, mostly oak with a scattering of - * birch in a forest, and so on. + * Grows whichever tree the placement site's actual biome would naturally produce, weighted to + * approximate the vanilla mix for that biome; nine spruce to one tall spruce in a taiga, mostly oak + * with a scattering of birch in a forest, and so on. "Approximate" is doing real work in that + * sentence though: the table below is hand-tuned against Bukkit's {@link TreeType} set, not pulled + * from Mojang's actual configured feature system, and nobody's checked it species-for-species + * against a specific game version. *

* Reads the biome from the region rather than the feature spec's own biome filter, since a spec can * span several biomes with different mixes. diff --git a/src/main/java/me/totalfreedom/totalfreedommod/world/stage/feature/OreFeature.java b/src/main/java/me/totalfreedom/totalfreedommod/world/stage/feature/OreFeature.java index 967ac8d7e..6a42f9a1c 100644 --- a/src/main/java/me/totalfreedom/totalfreedommod/world/stage/feature/OreFeature.java +++ b/src/main/java/me/totalfreedom/totalfreedommod/world/stage/feature/OreFeature.java @@ -16,9 +16,12 @@ * its stretched, slightly lumpy shape. The line is randomly angled on the horizontal, so veins do * not all run the same way. *

- * Replaces the profile's own filler block and nothing else. That is why an ore entry works in the - * nether without being told about netherrack: the generator only ever writes the filler, so - * matching against it is the same as asking "is this untouched stone". + * Only replaces blocks matching {@code palette.materials().defaultBlock()}, nothing else. That is + * why an ore entry works in the nether without being told about netherrack: the generator writes + * the filler there instead of stone. Don't read too much into "filler" though; it's not the same as + * "untouched terrain". Whatever a surface rule, a river, or an earlier feature already swapped out no + * longer matches, so ore skips it same as anything else that isn't filler. Ends up leaning away from + * ground generation has already touched, which is usually what you'd want anyway. */ public final class OreFeature implements Feature { From 0d32ecfd6f0cc1c5a37c942928e748a7761dd873 Mon Sep 17 00:00:00 2001 From: Paldiu Date: Tue, 11 Aug 2026 02:58:45 -0500 Subject: [PATCH 22/32] start flatlands impl --- .../totalfreedommod/world/CustomWorld.java | 89 ++++++++++++------- .../totalfreedommod/world/GeneratedWorld.java | 28 ++++++ .../world/GenerationService.java | 79 ++++++++++++++-- .../world/adapter/ProfileChunkGenerator.java | 7 ++ .../world/adapter/SpawnFinder.java | 2 +- .../world/profile/SurfaceRule.java | 2 +- .../world/stage/FlatGenerator.java | 12 ++- .../world/stage/LayerDesigner.java | 13 ++- 8 files changed, 183 insertions(+), 49 deletions(-) diff --git a/src/main/java/me/totalfreedom/totalfreedommod/world/CustomWorld.java b/src/main/java/me/totalfreedom/totalfreedommod/world/CustomWorld.java index adaaff3cc..5283c6799 100644 --- a/src/main/java/me/totalfreedom/totalfreedommod/world/CustomWorld.java +++ b/src/main/java/me/totalfreedom/totalfreedommod/world/CustomWorld.java @@ -5,6 +5,9 @@ import org.bukkit.World; import org.bukkit.block.Block; import org.bukkit.block.BlockFace; +import org.bukkit.block.Sign; +import org.bukkit.block.sign.Side; +import org.bukkit.block.sign.SignSide; import org.bukkit.entity.Player; import net.kyori.adventure.text.Component; @@ -14,15 +17,16 @@ import me.totalfreedom.totalfreedommod.framework.PluginComponent; import me.totalfreedom.totalfreedommod.util.FLog; -import lombok.Getter; - +/** + * Base for a world TFM creates and manages itself. Caches the {@link World} once generated and + * rebuilds it if Bukkit ever drops it from {@link Bukkit#getWorlds()}. + *

+ * A welcome sign is planted at whatever the world's own spawn location turns out to be, so a + * subclass is free to pick that location however it likes. + */ public abstract class CustomWorld extends PluginComponent { - - @Getter private final String name; - - @Getter private final String displayName; // private World world; @@ -39,48 +43,65 @@ public CustomWorld(TotalFreedomMod plugin, String name) this(plugin, name, name); } + public final String getName() + { + return this.name; + } + + public final String getDisplayName() + { + return this.displayName; + } + public final World getWorld() { - if (world == null || !Bukkit.getWorlds().contains(world)) + if (world != null && Bukkit.getWorlds().contains(world)) { - world = generateWorld(); + return world; + } - final Block welcomeSignBlock = world.getBlockAt(0, 50, 0); - welcomeSignBlock.setType(Material.OAK_SIGN); - // Use BlockData API instead of deprecated MaterialData - org.bukkit.block.data.type.Sign signData = (org.bukkit.block.data.type.Sign) Material.OAK_SIGN.createBlockData(); - signData.setRotation(BlockFace.NORTH); - welcomeSignBlock.setBlockData(signData); + world = generateWorld(); - org.bukkit.block.Sign welcomeSign = (org.bukkit.block.Sign) welcomeSignBlock.getState(); + if (world == null) + { + FLog.warning("Could not load world: " + name); + return null; + } - Component[] lines = { - Component.text(this.displayName, NamedTextColor.GREEN), - Component.text("---", NamedTextColor.DARK_GRAY), - Component.text("Spawn Point", NamedTextColor.YELLOW), - Component.text("---", NamedTextColor.DARK_GRAY) - }; + placeWelcomeSign(world); + plugin.gr.enforceGameRuleDefaultsForWorld(world); - org.bukkit.block.sign.SignSide front = welcomeSign.getSide(org.bukkit.block.sign.Side.FRONT); - org.bukkit.block.sign.SignSide back = welcomeSign.getSide(org.bukkit.block.sign.Side.BACK); + return world; + } - for (int i = 0; i < lines.length; i++) - { - front.line(i, lines[i]); - back.line(i, lines[i]); - } + private void placeWelcomeSign(final World world) + { + final Block welcomeSignBlock = world.getSpawnLocation().getBlock(); + welcomeSignBlock.setType(Material.OAK_SIGN); - welcomeSign.update(); + final org.bukkit.block.data.type.Sign signData = (org.bukkit.block.data.type.Sign) Material.OAK_SIGN.createBlockData(); + signData.setRotation(BlockFace.NORTH); + welcomeSignBlock.setBlockData(signData); - plugin.gr.enforceGameRuleDefaultsForWorld(world); - } + final Sign welcomeSign = (Sign) welcomeSignBlock.getState(); - if (world == null) + final Component[] lines = { + Component.text(this.displayName, NamedTextColor.GREEN), + Component.text("---", NamedTextColor.DARK_GRAY), + Component.text("Spawn Point", NamedTextColor.YELLOW), + Component.text("---", NamedTextColor.DARK_GRAY) + }; + + final SignSide front = welcomeSign.getSide(Side.FRONT); + final SignSide back = welcomeSign.getSide(Side.BACK); + + for (int i = 0; i < lines.length; i++) { - FLog.warning("Could not load world: " + name); + front.line(i, lines[i]); + back.line(i, lines[i]); } - return world; + welcomeSign.update(); } public void sendToWorld(Player player) diff --git a/src/main/java/me/totalfreedom/totalfreedommod/world/GeneratedWorld.java b/src/main/java/me/totalfreedom/totalfreedommod/world/GeneratedWorld.java index 9316f5fc8..d34ce689d 100644 --- a/src/main/java/me/totalfreedom/totalfreedommod/world/GeneratedWorld.java +++ b/src/main/java/me/totalfreedom/totalfreedommod/world/GeneratedWorld.java @@ -1,12 +1,23 @@ package me.totalfreedom.totalfreedommod.world; +import org.bukkit.Bukkit; +import org.bukkit.Location; +import org.bukkit.NamespacedKey; import org.bukkit.World; +import org.bukkit.WorldCreator; import me.totalfreedom.totalfreedommod.TotalFreedomMod; +import me.totalfreedom.totalfreedommod.world.adapter.ProfileChunkGenerator; +import me.totalfreedom.totalfreedommod.world.adapter.SpawnFinder; +import me.totalfreedom.totalfreedommod.world.profile.WorldSettings; /** * A custom world built from a profile. Applies the profile's world settings to the WorldCreator and * takes its spawn point from the spawn finder. + *

+ * Keyed under the {@code minecraft} namespace so the level/folder name Paper derives from the key + * matches {@link GenerationProfile#name()} exactly, keeping it a plain lookup for + * {@link WorldManager#gotoWorld} and {@link GenerationService#profile}. */ public class GeneratedWorld extends CustomWorld { @@ -22,7 +33,24 @@ public GeneratedWorld(final TotalFreedomMod plugin, final GenerationProfile prof @Override protected World generateWorld() { + final WorldSettings settings = this.profile.world(); + final ProfileChunkGenerator generator = new ProfileChunkGenerator(this.profile); + final WorldCreator worldCreator = WorldCreator.ofKey(NamespacedKey.minecraft(getName())); + worldCreator.environment(settings.environment()); + worldCreator.generateStructures(settings.generateStructures()); + worldCreator.generator(generator); + settings.seed().ifPresent(seed -> worldCreator.seed(seed.longValue())); + + final World world = Bukkit.getServer().createWorld(worldCreator); + + if (world == null) + return null; + + final Location spawn = new SpawnFinder(this.profile, generator.generator()).findSpawn(world); + world.setSpawnLocation(spawn.getBlockX(), spawn.getBlockY(), spawn.getBlockZ()); + + return world; } public GenerationProfile getProfile() diff --git a/src/main/java/me/totalfreedom/totalfreedommod/world/GenerationService.java b/src/main/java/me/totalfreedom/totalfreedommod/world/GenerationService.java index cafd88441..15714b87a 100644 --- a/src/main/java/me/totalfreedom/totalfreedommod/world/GenerationService.java +++ b/src/main/java/me/totalfreedom/totalfreedommod/world/GenerationService.java @@ -1,14 +1,29 @@ package me.totalfreedom.totalfreedommod.world; +import java.io.File; +import java.io.FileNotFoundException; +import java.io.IOException; +import java.io.Reader; +import java.util.Arrays; import java.util.HashMap; import java.util.Map; import java.util.Optional; import java.util.Set; +import java.util.stream.Stream; +import org.apache.commons.lang3.exception.ExceptionUtils; +import org.bukkit.Bukkit; import org.bukkit.generator.ChunkGenerator; +import com.google.gson.JsonObject; +import com.google.gson.JsonParser; +import com.google.gson.stream.JsonReader; + import me.totalfreedom.totalfreedommod.FreedomService; import me.totalfreedom.totalfreedommod.TotalFreedomMod; +import me.totalfreedom.totalfreedommod.util.FLog; +import me.totalfreedom.totalfreedommod.world.adapter.ProfileChunkGenerator; +import me.totalfreedom.totalfreedommod.world.profile.ProfileException; import me.totalfreedom.totalfreedommod.world.profile.ProfileLoader; import me.totalfreedom.totalfreedommod.world.profile.ProfileParser; @@ -25,6 +40,8 @@ */ public final class GenerationService extends FreedomService { + private static final String JSON_ENDING = ".json"; + private final ProfileLoader loader; private final ProfileParser parser; private final Map profiles; @@ -41,6 +58,22 @@ public GenerationService(final TotalFreedomMod plugin) @Override protected void onStart() { + final Map biomeLibrary; + + try + { + biomeLibrary = this.loader.biomeLibrary(); + } + catch (final ProfileException ex) + { + FLog.severe("Failed to load biome library: " + ExceptionUtils.getRootCauseMessage(ex)); + Bukkit.getPluginManager().disablePlugin(plugin); // we don't want to load TFM because no worlds can be loaded. + return; + } + + this.loader + .available() + .forEach(name -> this.loadProfile(name, biomeLibrary)); } @@ -52,29 +85,57 @@ protected void onStop() public Optional profile(final String worldName) { - + return Optional.ofNullable(profiles.get(worldName)); } /** Empty if no profile covers the world, or if its file failed to parse. */ public Optional generatorFor(final String worldName) { - + return profile(worldName).map(p -> new ProfileChunkGenerator(p)); } /** Only worlds whose profiles parsed. A file that failed does not appear here. */ public Set available() { - + return profiles.keySet(); } - /** - * Re-reads every profile file. Already-loaded worlds keep the profile they were built with. - *

- * TODO: call {@code this.loader.biomeLibrary()} once per reload and reuse the result for every - * {@code this.parser.parse(...)} call, not once per world. - */ public void reload() { + final Map biomeLibrary; + + try + { + biomeLibrary = this.loader.biomeLibrary(); + } + catch (final ProfileException ex) + { + FLog.warning("Failed to reload biome library: " + ExceptionUtils.getRootCauseMessage(ex)); + // we don't want to disable the plugin here because this executes assuming worlds have already loaded. + return; + } + + this.loader + .available() + .stream() + .filter(name -> !this.profiles.containsKey(name)) + .forEach(name -> this.loadProfile(name, biomeLibrary)); + } + private void loadProfile(final String worldName, final Map biomeLibrary) + { + try + { + final Optional jsonRoot = this.loader.read(worldName); + + if (jsonRoot.isEmpty()) + return; + + this.profiles.put(worldName, parser.parse(worldName, jsonRoot.get(), biomeLibrary)); + } + catch (final ProfileException ex) + { + FLog.warning(String.format("Failed to parse json object for %s: \n%s", worldName, ExceptionUtils.getRootCauseMessage(ex))); + } } } diff --git a/src/main/java/me/totalfreedom/totalfreedommod/world/adapter/ProfileChunkGenerator.java b/src/main/java/me/totalfreedom/totalfreedommod/world/adapter/ProfileChunkGenerator.java index 6aaa7c9dd..9212692d8 100644 --- a/src/main/java/me/totalfreedom/totalfreedommod/world/adapter/ProfileChunkGenerator.java +++ b/src/main/java/me/totalfreedom/totalfreedommod/world/adapter/ProfileChunkGenerator.java @@ -12,6 +12,7 @@ import org.bukkit.generator.WorldInfo; import me.totalfreedom.totalfreedommod.world.GenerationProfile; +import me.totalfreedom.totalfreedommod.world.base.Generator; import me.totalfreedom.totalfreedommod.world.base.Stages; import me.totalfreedom.totalfreedommod.world.profile.Shape; @@ -41,6 +42,12 @@ public ProfileChunkGenerator(final GenerationProfile profile) this.stages = wire(profile); } + /** The wired terrain generator, for callers that need heights or spawn candidates outside a chunk callback. */ + public Generator generator() + { + return this.stages.generator(); + } + /** * Picks the stages for a profile's shape. The one place that switch is written. *

diff --git a/src/main/java/me/totalfreedom/totalfreedommod/world/adapter/SpawnFinder.java b/src/main/java/me/totalfreedom/totalfreedommod/world/adapter/SpawnFinder.java index f05e7567d..9b222a17e 100644 --- a/src/main/java/me/totalfreedom/totalfreedommod/world/adapter/SpawnFinder.java +++ b/src/main/java/me/totalfreedom/totalfreedommod/world/adapter/SpawnFinder.java @@ -18,7 +18,7 @@ * The height it reads is the designer's terrain, before caves or any other carving runs, since * carving only happens per chunk and this deliberately loads none. So if a carver would've hollowed * out the column it picked, that never gets factored in, meaning a cave or overhang could end up - * right under the chosen spawn. + * right under/over the chosen spawn. */ public final class SpawnFinder { diff --git a/src/main/java/me/totalfreedom/totalfreedommod/world/profile/SurfaceRule.java b/src/main/java/me/totalfreedom/totalfreedommod/world/profile/SurfaceRule.java index 6ba52abad..3a9521153 100644 --- a/src/main/java/me/totalfreedom/totalfreedommod/world/profile/SurfaceRule.java +++ b/src/main/java/me/totalfreedom/totalfreedommod/world/profile/SurfaceRule.java @@ -12,6 +12,6 @@ public record SurfaceRule(BiomeFilter biomes, Depth depth, BlockData block) { public boolean matches(final Biome biome, final int depth) { - + return depth().contains(depth) && biomes.matches(biome); } } diff --git a/src/main/java/me/totalfreedom/totalfreedommod/world/stage/FlatGenerator.java b/src/main/java/me/totalfreedom/totalfreedommod/world/stage/FlatGenerator.java index 862203c58..7955cd5f6 100644 --- a/src/main/java/me/totalfreedom/totalfreedommod/world/stage/FlatGenerator.java +++ b/src/main/java/me/totalfreedom/totalfreedommod/world/stage/FlatGenerator.java @@ -1,5 +1,7 @@ package me.totalfreedom.totalfreedommod.world.stage; +import java.util.stream.IntStream; + import org.bukkit.generator.ChunkGenerator; import me.totalfreedom.totalfreedommod.world.base.ChunkContext; @@ -24,12 +26,20 @@ public FlatGenerator(final LayerStack layers, final Bounds bounds) @Override public void generateBase(final ChunkContext context, final ChunkGenerator.ChunkData data) { + final int[] y = { this.bounds.minY() }; // effectively final + + IntStream.range(0, this.layers.size()).forEach(layer -> + { + final int height = this.layers.heightAt(layer); + data.setRegion(0, y[0], 0, 16, y[0] + height, 16, this.layers.blockAt(layer)); + y[0] += height; + }); } @Override public int surfaceHeight(final int worldX, final int worldZ) { - + return this.bounds.minY() + this.layers.totalHeight(); } } diff --git a/src/main/java/me/totalfreedom/totalfreedommod/world/stage/LayerDesigner.java b/src/main/java/me/totalfreedom/totalfreedommod/world/stage/LayerDesigner.java index ec11dffcf..08c304060 100644 --- a/src/main/java/me/totalfreedom/totalfreedommod/world/stage/LayerDesigner.java +++ b/src/main/java/me/totalfreedom/totalfreedommod/world/stage/LayerDesigner.java @@ -4,6 +4,7 @@ import me.totalfreedom.totalfreedommod.world.base.ChunkContext; import me.totalfreedom.totalfreedommod.world.base.Designer; +import me.totalfreedom.totalfreedommod.world.profile.BedrockMode; import me.totalfreedom.totalfreedommod.world.profile.LayerStack; import me.totalfreedom.totalfreedommod.world.profile.Materials; @@ -13,24 +14,30 @@ */ public final class LayerDesigner implements Designer { - private final LayerStack layers; private final Materials materials; public LayerDesigner(final LayerStack layers, final Materials materials) { - this.layers = layers; + // layers is unneeded this.materials = materials; } @Override public void surface(final ChunkContext context, final ChunkGenerator.ChunkData data) { - + // no-op } @Override public void bedrock(final ChunkContext context, final ChunkGenerator.ChunkData data) { + int meny = context.getProfile().bounds().minY(); + int man = context.getProfile().bounds().maxY(); + + data.setRegion(0, meny, 0, 16, meny + 1, 16, materials.bedrockBlock()); + if (this.materials.bedrock() == BedrockMode.FLOOR_AND_ROOF) + data.setRegion(0, man, 0, 16, man - 1, 16, materials.bedrockBlock()); } + } From 1af39a691b5f4dc873e69cb2d1f0bd276320474e Mon Sep 17 00:00:00 2001 From: Paldiu Date: Thu, 13 Aug 2026 03:17:55 -0500 Subject: [PATCH 23/32] Implement DensityGenerator & remaining Bukkit adapter classes --- .../world/adapter/ProfileBiomeProvider.java | 21 +- .../world/adapter/ProfileChunkGenerator.java | 241 +++++++++++++--- .../world/profile/Palette.java | 8 +- .../world/stage/DensityGenerator.java | 268 +++++++++++++++++- .../world/stage/HeightmapGenerator.java | 13 +- 5 files changed, 491 insertions(+), 60 deletions(-) diff --git a/src/main/java/me/totalfreedom/totalfreedommod/world/adapter/ProfileBiomeProvider.java b/src/main/java/me/totalfreedom/totalfreedommod/world/adapter/ProfileBiomeProvider.java index 2b564cdb7..fa83e0046 100644 --- a/src/main/java/me/totalfreedom/totalfreedommod/world/adapter/ProfileBiomeProvider.java +++ b/src/main/java/me/totalfreedom/totalfreedommod/world/adapter/ProfileBiomeProvider.java @@ -1,12 +1,14 @@ package me.totalfreedom.totalfreedommod.world.adapter; import java.util.List; +import java.util.stream.Stream; import org.bukkit.block.Biome; import org.bukkit.generator.BiomeProvider; import org.bukkit.generator.WorldInfo; import me.totalfreedom.totalfreedommod.world.GenerationProfile; +import me.totalfreedom.totalfreedommod.world.profile.Palette; /** * Samples the profile's temperature and humidity noise and drops the result into a biome band. @@ -27,22 +29,23 @@ public ProfileBiomeProvider(final GenerationProfile profile) this.profile = profile; } - /** TODO: {@code return this.profile.palette().resolveBiome(x, z); } once resolveBand is implemented. */ @Override public Biome getBiome(final WorldInfo worldInfo, final int x, final int y, final int z) { - + return this.profile.palette().resolveBiome(x, z); } - /** - * Must list every biome getBiome can return, or the server rejects the provider. - *

- * TODO: collect every band's {@code target().display()} plus {@code palette.fallback()}, - * deduplicated. - */ + /** Must list every biome getBiome can return, or the server rejects the provider. */ @Override public List getBiomes(final WorldInfo worldInfo) { - + final Palette palette = this.profile.palette(); + final Stream bandBiomes = palette.biomes() + .stream() + .map(band -> band.target().display()); + + return Stream.concat(bandBiomes, Stream.of(palette.fallback())) + .distinct() + .toList(); } } diff --git a/src/main/java/me/totalfreedom/totalfreedommod/world/adapter/ProfileChunkGenerator.java b/src/main/java/me/totalfreedom/totalfreedommod/world/adapter/ProfileChunkGenerator.java index 9212692d8..78c9e202d 100644 --- a/src/main/java/me/totalfreedom/totalfreedommod/world/adapter/ProfileChunkGenerator.java +++ b/src/main/java/me/totalfreedom/totalfreedommod/world/adapter/ProfileChunkGenerator.java @@ -1,32 +1,31 @@ package me.totalfreedom.totalfreedommod.world.adapter; import java.util.List; +import java.util.Optional; import java.util.Random; +import java.util.stream.IntStream; -import org.bukkit.HeightMap; -import org.bukkit.Location; -import org.bukkit.World; -import org.bukkit.generator.BiomeProvider; -import org.bukkit.generator.BlockPopulator; -import org.bukkit.generator.ChunkGenerator; -import org.bukkit.generator.WorldInfo; +import org.bukkit.*; +import org.bukkit.block.data.BlockData; +import org.bukkit.generator.*; import me.totalfreedom.totalfreedommod.world.GenerationProfile; -import me.totalfreedom.totalfreedommod.world.base.Generator; -import me.totalfreedom.totalfreedommod.world.base.Stages; +import me.totalfreedom.totalfreedommod.world.base.*; +import me.totalfreedom.totalfreedommod.world.noise.NoiseField; +import me.totalfreedom.totalfreedommod.world.profile.Materials; import me.totalfreedom.totalfreedommod.world.profile.Shape; +import me.totalfreedom.totalfreedommod.world.stage.*; +import me.totalfreedom.totalfreedommod.world.stage.feature.FeatureRegistry; /** - * Bridges Bukkit's callbacks to the profile's stages. One stage per callback: noise to the - * generator, surface and bedrock to the designer, caves to the carver, populators to the populator. + * Bridges Bukkit's callbacks to the profile's stages. *

* The only class that knows what order Bukkit runs things in, and the only one that turns a - * {@link Shape} into actual stage objects. That pattern match happens once here, in - * {@link #wire(GenerationProfile)}, which is why no per-chunk code ever asks what mode a world is. + * {@link Shape} into actual stage objects. *

* Builds a fresh ChunkContext in each callback, since nothing carries over between them. *

- * Owns the cave loop: reads the carver's y range once before looping, leaves bedrock alone, and + * Reads the carver's y range once before looping, leaves bedrock alone, and * fills cleared blocks with water instead of air below the depth the profile sets. *

* The shouldGenerate methods come straight from the profile's vanilla flags. @@ -35,11 +34,13 @@ public final class ProfileChunkGenerator extends ChunkGenerator { private final GenerationProfile profile; private final Stages stages; + private final Optional caveFloodLevel; public ProfileChunkGenerator(final GenerationProfile profile) { this.profile = profile; this.stages = wire(profile); + this.caveFloodLevel = caveFloodLevel(profile.shape()); } /** The wired terrain generator, for callers that need heights or spawn candidates outside a chunk callback. */ @@ -48,18 +49,6 @@ public Generator generator() return this.stages.generator(); } - /** - * Picks the stages for a profile's shape. The one place that switch is written. - *

- * TODO: build each region's {@code NoiseField} via {@code NoiseField.of(noise, seed, role)}, with - * a distinct role string per region plus one for the selector. Reusing a role collapses two - * fields onto the same random stream. - */ - private static Stages wire(final GenerationProfile profile) - { - - } - @Override public void generateNoise(final WorldInfo worldInfo, final Random random, @@ -67,7 +56,14 @@ public void generateNoise(final WorldInfo worldInfo, final int chunkZ, final ChunkData chunkData) { - + final ChunkContext context = ChunkContext.of(this.profile, + this.stages, + worldInfo, + random, + chunkX, + chunkZ); + + this.stages.generator().generateBase(context, chunkData); } @Override @@ -77,7 +73,14 @@ public void generateSurface(final WorldInfo worldInfo, final int chunkZ, final ChunkData chunkData) { - + final ChunkContext context = ChunkContext.of(this.profile, + this.stages, + worldInfo, + random, + chunkX, + chunkZ); + + this.stages.designer().surface(context, chunkData); } @Override @@ -87,7 +90,14 @@ public void generateBedrock(final WorldInfo worldInfo, final int chunkZ, final ChunkData chunkData) { - + final ChunkContext context = ChunkContext.of(this.profile, + this.stages, + worldInfo, + random, + chunkX, + chunkZ); + + this.stages.designer().bedrock(context, chunkData); } /** Walks the carver's y range and clears whatever it flags. No carver means no work. */ @@ -98,7 +108,31 @@ public void generateCaves(final WorldInfo worldInfo, final int chunkZ, final ChunkData chunkData) { - + final Optional carver = this.stages.carver(); + + if (carver.isEmpty()) + return; + + final ChunkContext context = ChunkContext.of(this.profile, + this.stages, + worldInfo, + random, + chunkX, + chunkZ); + + final BlockData air = Material.CAVE_AIR.createBlockData(); + final BlockData fluid = this.profile.palette().materials().fluidBlock(); + final int floodLevel = this.caveFloodLevel.orElse(Integer.MIN_VALUE); + + IntStream.range(0, 256) + .forEach(index -> this.carveColumn(context, + chunkData, + carver.get(), + index & 0xF, + index >> 4, + air, + fluid, + floodLevel)); } @Override @@ -108,55 +142,186 @@ public int getBaseHeight(final WorldInfo worldInfo, final int z, final HeightMap heightMap) { - + return this.stages.generator().surfaceHeight(x, z); } @Override public Location getFixedSpawnLocation(final World world, final Random random) { - + return new SpawnFinder(this.profile, this.stages.generator()).findSpawn(world); } @Override public BiomeProvider getDefaultBiomeProvider(final WorldInfo worldInfo) { - + return new ProfileBiomeProvider(this.profile); } /** Returns the block populator wrapping the profile's populator. */ @Override public List getDefaultPopulators(final World world) { - + return List.of(new ProfileBlockPopulator(this.profile)); } @Override public boolean shouldGenerateSurface() { - + return this.profile.world().vanilla().surface(); } @Override public boolean shouldGenerateCaves() { - + return this.profile.world().vanilla().caves(); } @Override public boolean shouldGenerateDecorations() { - + return this.profile.world().vanilla().decorations(); } @Override public boolean shouldGenerateMobs() { - + return this.profile.world().vanilla().mobs(); } @Override public boolean shouldGenerateStructures() { + return this.profile.world().vanilla().structures(); + } + + /** One column of the cave loop: clear whatever the carver flags, water below the flood level. */ + private void carveColumn(final ChunkContext context, + final ChunkData data, + final Carver carver, + final int localX, + final int localZ, + final BlockData air, + final BlockData fluid, + final int floodLevel) + { + final int worldX = context.worldX(localX); + final int worldZ = context.worldZ(localZ); + + IntStream.rangeClosed(carver.minY(), carver.maxY()) + .filter(y -> carver.isCarved(context, worldX, y, worldZ)) + .forEach(y -> data.setBlock(localX, y, localZ, y < floodLevel ? fluid : air)); + } + + /** Picks the stages for a profile's shape. The one place that switch is written. */ + private static Stages wire(final GenerationProfile profile) + { + final long seed = profile.world().seed().orElse((long) profile.name().hashCode()); + final Materials materials = profile.palette().materials(); + final Populator populator = new FeaturePopulator(profile.features(), new FeatureRegistry()); + + return switch (profile.shape()) + { + case Shape.Flat flat -> new Stages(new FlatGenerator(flat.layers(), profile.bounds()), + new LayerDesigner(flat.layers(), materials), + Optional.empty(), + populator); + + case Shape.Heightmap heightmap -> new Stages(new HeightmapGenerator(NoiseField.of(heightmap.terrain().noise(), + seed, + "terrain"), + heightmap.river().map(river -> NoiseField.of(river.noise(), + seed, + "river")), + heightmap.terrain().spline(), + profile.bounds(), + materials, + heightmap.terrain().warp(), + wireHeightmapRegions(heightmap.regions(), seed)), + new RuleDesigner(profile.palette().surface(), materials), + wireCaves(heightmap.caves(), seed), + populator); + + case Shape.Density density -> new Stages(new DensityGenerator(NoiseField.of(density.noise(), + seed, + "density"), + density.warp(), + profile.bounds(), + materials, + wireDensityRegions(density.regions(), seed)), + new RuleDesigner(profile.palette().surface(), materials), + wireCaves(density.caves(), seed), + populator); + }; + } + + private static Optional wireCaves(final Optional caves, final long seed) + { + return caves.map(c -> new NoiseCarver(NoiseField.of(c.noise(), + seed, + "caves"), + c.threshold(), + c.minY(), + c.maxY())); + } + + /** + * The Optional a profile's own {@link Shape.Caves#floodLevel} lives in, whatever mode the shape is. + * Flat is intentionally unused because + */ + private static Optional caveFloodLevel(final Shape shape) + { + return switch (shape) + { + case Shape.Heightmap heightmap -> heightmap.caves().map(Shape.Caves::floodLevel); + case Shape.Density density -> density.caves().map(Shape.Caves::floodLevel); + case Shape.Flat flat -> Optional.empty(); + }; + } + + private static Optional wireHeightmapRegions(final Optional> regions, + final long seed) + { + return regions.map(built -> new HeightmapGenerator.RegionSet(NoiseField.of(built.selector(), + seed, + "terrain-selector"), + built.blendWidth(), + built.regions() + .stream() + .map(region -> wireHeightmapRegion(region, seed)) + .toList())); + } + private static HeightmapGenerator.BuiltRegion wireHeightmapRegion(final Shape.Region region, final long seed) + { + return new HeightmapGenerator.BuiltRegion(region.name(), + region.min(), + region.max(), + NoiseField.of(region.terrain().noise(), + seed, + "terrain-region-" + region.name()), + region.terrain().spline(), + region.terrain().warp()); + } + + private static Optional wireDensityRegions(final Optional> regions, + final long seed) + { + return regions.map(built -> new DensityGenerator.RegionSet(NoiseField.of(built.selector(), seed, "density-selector"), + built.blendWidth(), + built.regions() + .stream() + .map(region -> wireDensityRegion(region, seed)) + .toList())); + } + + private static DensityGenerator.BuiltRegion wireDensityRegion(final Shape.Region region, final long seed) + { + return new DensityGenerator.BuiltRegion(region.name(), + region.min(), + region.max(), + NoiseField.of(region.terrain().noise(), + seed, + "density-region-" + region.name()), + region.terrain().warp()); } } diff --git a/src/main/java/me/totalfreedom/totalfreedommod/world/profile/Palette.java b/src/main/java/me/totalfreedom/totalfreedommod/world/profile/Palette.java index d6afc5690..9abcd47e0 100644 --- a/src/main/java/me/totalfreedom/totalfreedommod/world/profile/Palette.java +++ b/src/main/java/me/totalfreedom/totalfreedommod/world/profile/Palette.java @@ -37,7 +37,13 @@ public record Palette(Materials materials, */ public Optional resolveBand(final int worldX, final int worldZ) { - throw new UnsupportedOperationException("not yet implemented"); + final double temperature = this.climate.temperature().sample(worldX, worldZ) * this.climate.scale(); + final double humidity = this.climate.humidity().sample(worldX, worldZ) * this.climate.scale(); + + return this.biomes + .stream() + .filter(band -> band.matches(temperature, humidity)) + .findFirst(); } /** Convenience over {@link #resolveBand}: the matched band's display biome, or this palette's fallback. */ diff --git a/src/main/java/me/totalfreedom/totalfreedommod/world/stage/DensityGenerator.java b/src/main/java/me/totalfreedom/totalfreedommod/world/stage/DensityGenerator.java index 2441ffd01..cc43299cf 100644 --- a/src/main/java/me/totalfreedom/totalfreedommod/world/stage/DensityGenerator.java +++ b/src/main/java/me/totalfreedom/totalfreedommod/world/stage/DensityGenerator.java @@ -2,9 +2,12 @@ import java.util.List; import java.util.Optional; +import java.util.stream.IntStream; +import java.util.stream.Stream; import org.bukkit.generator.ChunkGenerator; +import me.totalfreedom.totalfreedommod.util.FUtil; import me.totalfreedom.totalfreedommod.world.base.ChunkContext; import me.totalfreedom.totalfreedommod.world.base.Generator; import me.totalfreedom.totalfreedommod.world.noise.NoiseField; @@ -12,30 +15,58 @@ import me.totalfreedom.totalfreedommod.world.profile.Materials; /** - * 3D mode. Samples on a grid in all three directions and interpolates between the samples, which - * gets you overhangs and floating islands. + * 3D mode. Samples on a grid in all three directions and interpolates between the samples. + * Turns out, this was a lot of math. *

* Roughly fifty times the samples of heightmap mode, so only use it if a world actually needs * those shapes. *

- * density is the fallback used wherever regions is empty or its selector matches no listed region. - * TODO: generateBase/surfaceHeight need to sample regions.selector() per column once regions is - * present, blend the matched BuiltRegion's own noise/warp in over blendWidth, and fall back to - * density otherwise. Same shape as {@link HeightmapGenerator}, just without a spline. + * Raw noise has no notion of "up" on its own, so a block is solid where {@code noise - falloff(y) > + * 0}. falloff climbs from -1 well below the world's centre height to +1 well above it, over + * {@link #TRANSITION_HEIGHT} blocks either side, which is what keeps the ground roughly where the + * profile expects instead of scattering solid blocks across the world's full height. That falloff is + * a property of the world's own bounds, not of any one region, so it is applied once after blending + * rather than per region. + *

+ * {@link #regions}, when present, lets different parts of the world sample a different {@link NoiseField} + * instead of one density field everywhere. Every column samples {@code regions.selector()} once, + * two-dimensionally, so a region's border does not wobble with height, and blends every region within + * {@code blendWidth} of that value: full weight throughout the region's own range, fading linearly to + * zero over blendWidth beyond either edge. Whatever weight no region claims falls to this generator's + * own density field, so every position resolves to a normalisable blend even where no listed region + * reaches. + *

+ * {@link #warp} offsets a field's sample coordinates by a second sample of that same field, taken + * {@link #WARP_PROBE_OFFSET} blocks away on each axis so the offset doesn't just echo the position + * it's displacing. Scaled by the field's own wavelength (1 / frequency), so a coarse field warps by + * more blocks than a fine one for the same warp value instead of the two reading differently for the + * same number. Applied per contribution, each with its own warp, before blending or falloff see any + * of it. */ public final class DensityGenerator implements Generator { + private static final int HORIZONTAL_STEP = 4; + private static final int HORIZONTAL_NODES = 16 / HORIZONTAL_STEP + 1; + private static final int VERTICAL_STEP = 8; + private static final double TRANSITION_HEIGHT = 32.0D; + + /** How far away, on each axis, warp's offset probes sample the same field they're displacing. */ + private static final int WARP_PROBE_OFFSET = 1013; + private final NoiseField density; + private final double warp; private final Bounds bounds; private final Materials materials; private final Optional regions; public DensityGenerator(final NoiseField density, + final double warp, final Bounds bounds, final Materials materials, final Optional regions) { this.density = density; + this.warp = warp; this.bounds = bounds; this.materials = materials; this.regions = regions; @@ -44,25 +75,246 @@ public DensityGenerator(final NoiseField density, @Override public void generateBase(final ChunkContext context, final ChunkGenerator.ChunkData data) { + final int originX = context.worldX(0); + final int originZ = context.worldZ(0); + final int[] nodeY = this.verticalNodes(); + final double[][][] grid = this.sampleGrid(originX, originZ, nodeY); + final Optional seaLevel = this.bounds.seaLevel(); + IntStream.range(0, 256).forEach(index -> this.writeColumn(data, grid, nodeY, index & 0xF, index >> 4, seaLevel)); } @Override public int surfaceHeight(final int worldX, final int worldZ) { + final List contributions = this.contributions(worldX, worldZ); + return IntStream.iterate(this.bounds.maxY(), y -> y >= this.bounds.minY(), y -> y - 1) + .filter(y -> this.isSolid(blend(contributions, worldX, y, worldZ), y)) + .findFirst() + .orElse(this.bounds.minY()); } /** * One profile region, already built: a sampled noise field, not the raw settings * {@link me.totalfreedom.totalfreedommod.world.profile.Shape.DensityLayer} carries. */ - record BuiltRegion(String name, double min, double max, NoiseField noise, double warp) + public record BuiltRegion(String name, double min, double max, NoiseField noise, double warp) { } /** See {@link HeightmapGenerator.RegionSet}; the same role-string requirement applies here too. */ - record RegionSet(NoiseField selector, double blendWidth, List regions) + public record RegionSet(NoiseField selector, double blendWidth, List regions) + { + } + + /** One noise field's share of a blended sample, and the warp to apply before sampling it. Purely internal; never leaves this class. */ + private record Contribution(NoiseField noise, double warp, double weight) + { + } + + /** The y each vertical node sits at: every {@link #VERTICAL_STEP} blocks from minY, plus maxY itself. */ + private int[] verticalNodes() + { + final int minY = this.bounds.minY(); + final int maxY = this.bounds.maxY(); + final IntStream steps = IntStream.iterate(minY, + y -> y < maxY, + y -> y + VERTICAL_STEP); + + return IntStream.concat(steps, IntStream.of(maxY)) + .toArray(); + } + + /** One blended noise sample per grid node. {@code grid[x][y][z]}, all three indexed by node, not by block. */ + private double[][][] sampleGrid(final int originX, final int originZ, final int[] nodeY) + { + final double[][][] grid = new double[HORIZONTAL_NODES][nodeY.length][HORIZONTAL_NODES]; + + IntStream.range(0, HORIZONTAL_NODES).forEach(x -> + IntStream.range(0, HORIZONTAL_NODES).forEach(z -> + this.fillColumn(grid, + nodeY, + x, + z, + originX + x * HORIZONTAL_STEP, + originZ + z * HORIZONTAL_STEP))); + + return grid; + } + + /** Resolves this horizontal node's region blend once, then reuses it for every vertical node above it. */ + private void fillColumn(final double[][][] grid, final int[] nodeY, final int nodeX, final int nodeZ, final int worldX, final int worldZ) + { + final List contributions = this.contributions(worldX, worldZ); + + IntStream.range(0, nodeY.length).forEach(y -> grid[nodeX][y][nodeZ] = blend(contributions, worldX, nodeY[y], worldZ)); + } + + /** + * This position's density sources and how much each one counts. Just this generator's own field + * when there are no regions; otherwise every region within blendWidth of the selector's value, + * plus this generator's own field for whatever weight none of them claim. + */ + private List contributions(final int worldX, final int worldZ) + { + if (this.regions.isEmpty()) + return List.of(new Contribution(this.density, this.warp, 1.0D)); + + final RegionSet regionSet = this.regions.get(); + final double selector = regionSet.selector().sample(worldX, worldZ); + final double blendWidth = regionSet.blendWidth(); + + final List matched = regionSet.regions() + .stream() + .map(region -> new Contribution(region.noise(), region.warp(), edgeWeight(region, selector, blendWidth))) + .filter(contribution -> contribution.weight() > 0.0D) + .toList(); + + final double covered = matched.stream().mapToDouble(Contribution::weight).sum(); + final double fallbackWeight = Math.max(0.0D, 1.0D - covered); + + if (fallbackWeight <= 0.0D) + return matched; + + return Stream.concat(matched.stream(), Stream.of(new Contribution(this.density, this.warp, fallbackWeight))).toList(); + } + + /** + * 1 throughout the region's own range, fading linearly to 0 over blendWidth beyond either edge, + * 0 past that. The one place a region's border actually gets decided. + */ + private static double edgeWeight(final BuiltRegion region, final double selector, final double blendWidth) + { + final double min = region.min(); + final double max = region.max(); + + if (selector < min - blendWidth || selector > max + blendWidth) + return 0.0D; + + if (selector < min) + return (selector - (min - blendWidth)) / blendWidth; + + if (selector > max) + return ((max + blendWidth) - selector) / blendWidth; + + return 1.0D; + } + + /** Every contribution's noise, warped then sampled at the same position and combined by weighted average. */ + private static double blend(final List contributions, final int worldX, final int y, final int worldZ) + { + final double totalWeight = contributions.stream().mapToDouble(Contribution::weight).sum(); + final double weightedSum = contributions.stream() + .mapToDouble(contribution -> contribution.weight() + * warpedSample(contribution.noise(), contribution.warp(), worldX, y, worldZ)) + .sum(); + + return weightedSum / totalWeight; + } + + /** + * Displaces (worldX, worldZ) before the real sample, using two more samples of the same field to + * decide by how much. That's what turns smooth noise contours into the twisted, drippy shapes + * density mode is for; without it every region reads as plain rounded blobs. The probes sample + * {@link #WARP_PROBE_OFFSET} blocks away on each axis so they don't just echo the position they're + * displacing, and the result is scaled by the field's own wavelength (1 / frequency) so warp reads + * the same whether the field is coarse or fine. + */ + private static double warpedSample(final NoiseField noise, final double warp, final int worldX, final int y, final int worldZ) + { + if (warp == 0.0D) + return noise.sample(worldX, y, worldZ); + + final double wavelength = 1.0D / noise.getProfile().frequency(); + final double offsetX = noise.sample(worldX + WARP_PROBE_OFFSET, y, worldZ) * warp * wavelength; + final double offsetZ = noise.sample(worldX, y, worldZ + WARP_PROBE_OFFSET) * warp * wavelength; + + return noise.sample(worldX + (int) Math.round(offsetX), y, worldZ + (int) Math.round(offsetZ)); + } + + private void writeColumn(final ChunkGenerator.ChunkData data, + final double[][][] grid, + final int[] nodeY, + final int localX, + final int localZ, + final Optional seaLevel) + { + IntStream.rangeClosed(this.bounds.minY(), this.bounds.maxY()) + .forEach(y -> this.writeBlock(data, + grid, + nodeY, + localX, + localZ, + y, + seaLevel)); + } + + private void writeBlock(final ChunkGenerator.ChunkData data, + final double[][][] grid, + final int[] nodeY, + final int localX, + final int localZ, + final int y, + final Optional seaLevel) + { + final double noise = this.interpolate(grid, nodeY, localX, y, localZ); + + if (this.isSolid(noise, y)) + data.setBlock(localX, y, localZ, this.materials.defaultBlock()); + + else if (seaLevel.isPresent() && y <= seaLevel.get()) + data.setBlock(localX, y, localZ, this.materials.fluidBlock()); + } + + /** Trilinear interpolation of the sampled grid at an arbitrary block position. */ + private double interpolate(final double[][][] grid, + final int[] nodeY, + final int localX, + final int y, + final int localZ) + { + final int x = Math.min(localX / HORIZONTAL_STEP, HORIZONTAL_NODES - 2); + final int z = Math.min(localZ / HORIZONTAL_STEP, HORIZONTAL_NODES - 2); + final int j = Math.min((y - this.bounds.minY()) / VERTICAL_STEP, nodeY.length - 2); + + final double tx = (localX - x * HORIZONTAL_STEP) / (double) HORIZONTAL_STEP; + final double tz = (localZ - z * HORIZONTAL_STEP) / (double) HORIZONTAL_STEP; + final double ty = (y - nodeY[j]) / (double) (nodeY[j + 1] - nodeY[j]); + + final double lower = bilerp(grid, x, z, j, tx, tz); + final double upper = bilerp(grid, x, z, j + 1, tx, tz); + + return FUtil.lerp(ty, lower, upper); + } + + private static double bilerp(final double[][][] grid, + final int x, + final int z, + final int node, + final double tx, + final double tz) { + final double near = FUtil.lerp(tx, grid[x][node][z], grid[x + 1][node][z]); + final double far = FUtil.lerp(tx, grid[x][node][z + 1], grid[x + 1][node][z + 1]); + + return FUtil.lerp(tz, near, far); + } + + /** True below the profile's ground, false above it; the noise decides which side of that split a position lands on. */ + private boolean isSolid(final double noise, final int y) + { + return noise - this.falloff(y) > 0.0D; + } + + private double falloff(final int y) + { + final double centre = this.bounds.seaLevel() + .map(Integer::doubleValue) + .orElse((this.bounds.minY() + this.bounds.maxY()) / 2.0D); + + final double slope = (y - centre) / TRANSITION_HEIGHT; + + return Math.max(-1.0D, Math.min(1.0D, slope)); } } diff --git a/src/main/java/me/totalfreedom/totalfreedommod/world/stage/HeightmapGenerator.java b/src/main/java/me/totalfreedom/totalfreedommod/world/stage/HeightmapGenerator.java index 3a392a85c..5eed49976 100644 --- a/src/main/java/me/totalfreedom/totalfreedommod/world/stage/HeightmapGenerator.java +++ b/src/main/java/me/totalfreedom/totalfreedommod/world/stage/HeightmapGenerator.java @@ -27,7 +27,7 @@ public final class HeightmapGenerator implements Generator { private final NoiseField terrain; - private final NoiseField river; + private final Optional river; private final Spline spline; private final Bounds bounds; private final Materials materials; @@ -35,7 +35,7 @@ public final class HeightmapGenerator implements Generator private final Optional regions; public HeightmapGenerator(final NoiseField terrain, - final NoiseField river, + final Optional river, final Spline spline, final Bounds bounds, final Materials materials, @@ -67,7 +67,12 @@ public int surfaceHeight(final int worldX, final int worldZ) * One profile region, already built: a sampled noise field and a ready spline, not the raw * settings {@link me.totalfreedom.totalfreedommod.world.profile.Shape.Region} carries. */ - record BuiltRegion(String name, double min, double max, NoiseField noise, Spline spline, double warp) + public record BuiltRegion(String name, + double min, + double max, + NoiseField noise, + Spline spline, + double warp) { } @@ -78,7 +83,7 @@ record BuiltRegion(String name, double min, double max, NoiseField noise, Spline * its own role string (e.g. {@code "terrain-region-"}, {@code "terrain-selector"}) so no two * fields in one profile collapse onto the same random stream. */ - record RegionSet(NoiseField selector, double blendWidth, List regions) + public record RegionSet(NoiseField selector, double blendWidth, List regions) { } } From e5cf78438061d24e2940d1efca9a96e3f432a615 Mon Sep 17 00:00:00 2001 From: Paldiu Date: Thu, 13 Aug 2026 13:42:08 -0500 Subject: [PATCH 24/32] minor logic changes --- .../totalfreedommod/TotalFreedomMod.java | 14 ++ .../cmd/Command_admininfo.java | 6 - .../world/GenerationService.java | 16 ++- .../world/adapter/ProfileBlockPopulator.java | 8 +- .../world/adapter/ProfileChunkGenerator.java | 15 +- .../world/noise/NoiseProfile.java | 15 +- .../world/profile/ProfileParser.java | 134 +++++++++++++++--- .../totalfreedommod/world/profile/Shape.java | 11 +- .../world/stage/FlatGenerator.java | 2 +- .../world/stage/LayerDesigner.java | 11 +- .../world/stage/NoiseCarver.java | 13 +- .../world/stage/feature/BoulderFeature.java | 5 +- .../world/stage/feature/OreFeature.java | 4 +- 13 files changed, 196 insertions(+), 58 deletions(-) diff --git a/src/main/java/me/totalfreedom/totalfreedommod/TotalFreedomMod.java b/src/main/java/me/totalfreedom/totalfreedommod/TotalFreedomMod.java index f7895ae04..35a458118 100644 --- a/src/main/java/me/totalfreedom/totalfreedommod/TotalFreedomMod.java +++ b/src/main/java/me/totalfreedom/totalfreedommod/TotalFreedomMod.java @@ -2,6 +2,7 @@ import java.io.File; import java.io.InputStream; +import java.util.Optional; import java.util.Properties; import org.bukkit.generator.ChunkGenerator; @@ -45,6 +46,7 @@ import me.totalfreedom.totalfreedommod.util.FUtil; import me.totalfreedom.totalfreedommod.util.MethodTimer; import me.totalfreedom.totalfreedommod.world.CleanroomChunkGenerator; +import me.totalfreedom.totalfreedommod.world.GenerationService; import me.totalfreedom.totalfreedommod.world.WorldManager; public class TotalFreedomMod extends JavaPlugin @@ -64,6 +66,7 @@ public class TotalFreedomMod extends JavaPlugin public FreedomDatabase dm; // FreedomDatabase - Manages SQL database connections public SavedFlags sf; // SavedFlags - Stores saved flag states public WorldManager wm; // WorldManager - Manages world operations + public GenerationService gs; // GenerationService - Loads and serves world-generation profiles public AdminList al; // AdminList - Manages admin list and permissions public RankManager rm; // RankManager - Handles player ranks and display public TitleManager tm; // TitleManager - Flat, non-inheriting capability grants shown alongside ranks @@ -178,6 +181,8 @@ public void onEnable() dm = services.registerService(FreedomDatabase.class); sf = services.registerService(SavedFlags.class); + // Before WorldManager: profiles must be loaded before anything tries to spin up a world from one. + gs = services.registerService(GenerationService.class); wm = services.registerService(WorldManager.class); al = services.registerService(AdminList.class); @@ -298,6 +303,15 @@ public ChunkGenerator getDefaultWorldGenerator(String worldName, String id) } return new CleanroomChunkGenerator(params); } + + if (gs != null) + { + final Optional generator = gs.generatorFor(worldName); + + if (generator.isPresent()) + return generator.get(); + } + return super.getDefaultWorldGenerator(worldName, id); } diff --git a/src/main/java/me/totalfreedom/totalfreedommod/cmd/Command_admininfo.java b/src/main/java/me/totalfreedom/totalfreedommod/cmd/Command_admininfo.java index 75d41cd14..0a2fe5492 100644 --- a/src/main/java/me/totalfreedom/totalfreedommod/cmd/Command_admininfo.java +++ b/src/main/java/me/totalfreedom/totalfreedommod/cmd/Command_admininfo.java @@ -7,7 +7,6 @@ import net.kyori.adventure.text.Component; import net.kyori.adventure.text.JoinConfiguration; -<<<<<<< HEAD import me.totalfreedom.totalfreedommod.cmd.internal.annotation.*; import me.totalfreedom.totalfreedommod.config.ConfigEntry; import me.totalfreedom.totalfreedommod.util.FUtil; @@ -15,11 +14,6 @@ @Command(name = "admininfo", description = "Information on how to apply for admin.", usage = "/admininfo", aliases = {"si", "ai", "staffinfo"}) @Permission(source = SourceType.BOTH, permission = "tfm.player.admininfo") public class Command_admininfo extends FCommand -======= -@Command(name = "admininfo", description = "Information on how to apply for admin.", usage = "/admininfo", aliases={"si", "ai", "staffinfo"}) -@Permission(level = Rank.OP, source = SourceType.BOTH, permission = "tfm.player.admininfo") -public class Command_admininfo extends FCommand ->>>>>>> prod { @Callback public void info(CommandSender sender) diff --git a/src/main/java/me/totalfreedom/totalfreedommod/world/GenerationService.java b/src/main/java/me/totalfreedom/totalfreedommod/world/GenerationService.java index 15714b87a..4f3b3c2ac 100644 --- a/src/main/java/me/totalfreedom/totalfreedommod/world/GenerationService.java +++ b/src/main/java/me/totalfreedom/totalfreedommod/world/GenerationService.java @@ -100,6 +100,11 @@ public Set available() return profiles.keySet(); } + /** + * Re-parses every available profile and drops any no longer on disk. A profile that fails to + * re-parse keeps its last good copy, since {@link #loadProfile} only overwrites an entry once the + * new one parses cleanly. + */ public void reload() { final Map biomeLibrary; @@ -110,16 +115,15 @@ public void reload() } catch (final ProfileException ex) { - FLog.warning("Failed to reload biome library: " + ExceptionUtils.getRootCauseMessage(ex)); + FLog.warning("Failed to reload biome library: " + ExceptionUtils.getRootCauseMessage(ex)); // we don't want to disable the plugin here because this executes assuming worlds have already loaded. return; } - this.loader - .available() - .stream() - .filter(name -> !this.profiles.containsKey(name)) - .forEach(name -> this.loadProfile(name, biomeLibrary)); + final Set available = this.loader.available(); + + this.profiles.keySet().retainAll(available); + available.forEach(name -> this.loadProfile(name, biomeLibrary)); } private void loadProfile(final String worldName, final Map biomeLibrary) diff --git a/src/main/java/me/totalfreedom/totalfreedommod/world/adapter/ProfileBlockPopulator.java b/src/main/java/me/totalfreedom/totalfreedommod/world/adapter/ProfileBlockPopulator.java index e40c42c96..a3b66d0ce 100644 --- a/src/main/java/me/totalfreedom/totalfreedommod/world/adapter/ProfileBlockPopulator.java +++ b/src/main/java/me/totalfreedom/totalfreedommod/world/adapter/ProfileBlockPopulator.java @@ -7,6 +7,8 @@ import org.bukkit.generator.WorldInfo; import me.totalfreedom.totalfreedommod.world.GenerationProfile; +import me.totalfreedom.totalfreedommod.world.base.ChunkContext; +import me.totalfreedom.totalfreedommod.world.base.Stages; /** * Runs the profile's populator as a Bukkit block populator. @@ -16,10 +18,12 @@ public final class ProfileBlockPopulator extends BlockPopulator { private final GenerationProfile profile; + private final Stages stages; - public ProfileBlockPopulator(final GenerationProfile profile) + public ProfileBlockPopulator(final GenerationProfile profile, final Stages stages) { this.profile = profile; + this.stages = stages; } @Override @@ -29,6 +33,8 @@ public void populate(final WorldInfo worldInfo, final int chunkZ, final LimitedRegion limitedRegion) { + final ChunkContext context = ChunkContext.of(this.profile, this.stages, worldInfo, random, chunkX, chunkZ); + this.stages.populator().populate(context, limitedRegion); } } diff --git a/src/main/java/me/totalfreedom/totalfreedommod/world/adapter/ProfileChunkGenerator.java b/src/main/java/me/totalfreedom/totalfreedommod/world/adapter/ProfileChunkGenerator.java index 78c9e202d..53a109ccb 100644 --- a/src/main/java/me/totalfreedom/totalfreedommod/world/adapter/ProfileChunkGenerator.java +++ b/src/main/java/me/totalfreedom/totalfreedommod/world/adapter/ProfileChunkGenerator.java @@ -161,7 +161,7 @@ public BiomeProvider getDefaultBiomeProvider(final WorldInfo worldInfo) @Override public List getDefaultPopulators(final World world) { - return List.of(new ProfileBlockPopulator(this.profile)); + return List.of(new ProfileBlockPopulator(this.profile, this.stages)); } @Override @@ -226,12 +226,15 @@ private static Stages wire(final GenerationProfile profile) Optional.empty(), populator); - case Shape.Heightmap heightmap -> new Stages(new HeightmapGenerator(NoiseField.of(heightmap.terrain().noise(), - seed, + case Shape.Heightmap heightmap -> new Stages(new HeightmapGenerator(NoiseField.of(heightmap.terrain().noise(), + seed, "terrain"), - heightmap.river().map(river -> NoiseField.of(river.noise(), - seed, - "river")), + heightmap.river().map(river -> new HeightmapGenerator.River(NoiseField.of(river.noise(), + seed, + "river"), + river.threshold(), + river.depth(), + river.bedBlock())), heightmap.terrain().spline(), profile.bounds(), materials, diff --git a/src/main/java/me/totalfreedom/totalfreedommod/world/noise/NoiseProfile.java b/src/main/java/me/totalfreedom/totalfreedommod/world/noise/NoiseProfile.java index 65bb76387..ac456a3b1 100644 --- a/src/main/java/me/totalfreedom/totalfreedommod/world/noise/NoiseProfile.java +++ b/src/main/java/me/totalfreedom/totalfreedommod/world/noise/NoiseProfile.java @@ -6,10 +6,15 @@ * Amplitude is set by the spline for terrain and by the threshold for caves, so it is not a knob * here. The seed comes from the field's role name mixed with the world seed when the profile * parses. + *

+ * persistence and lacunarity must be positive: {@link NoiseField}'s normaliser sums each octave's + * amplitude, itself the previous octave's times persistence, and a zero or negative persistence can + * walk that sum through zero (an even octave count with persistence exactly -1 zeroes it outright), + * turning every sample into infinity or NaN. * * @throws IllegalArgumentException if octaves is below one or above {@value #MAX_OCTAVES}, if * frequency is not positive and finite, or if persistence or - * lacunarity is not finite + * lacunarity is not positive and finite */ public record NoiseProfile(NoiseType type, int octaves, @@ -29,10 +34,10 @@ public record NoiseProfile(NoiseType type, if (!(frequency > 0.0D) || Double.isInfinite(frequency)) throw new IllegalArgumentException("frequency (" + frequency + ") must be positive and finite"); - if (!Double.isFinite(persistence)) - throw new IllegalArgumentException("persistence (" + persistence + ") must be finite"); + if (!(persistence > 0.0D) || Double.isInfinite(persistence)) + throw new IllegalArgumentException("persistence (" + persistence + ") must be positive and finite"); - if (!Double.isFinite(lacunarity)) - throw new IllegalArgumentException("lacunarity (" + lacunarity + ") must be finite"); + if (!(lacunarity > 0.0D) || Double.isInfinite(lacunarity)) + throw new IllegalArgumentException("lacunarity (" + lacunarity + ") must be positive and finite"); } } diff --git a/src/main/java/me/totalfreedom/totalfreedommod/world/profile/ProfileParser.java b/src/main/java/me/totalfreedom/totalfreedommod/world/profile/ProfileParser.java index c24f7aa49..adf1474fd 100644 --- a/src/main/java/me/totalfreedom/totalfreedommod/world/profile/ProfileParser.java +++ b/src/main/java/me/totalfreedom/totalfreedommod/world/profile/ProfileParser.java @@ -23,6 +23,10 @@ import com.google.gson.JsonElement; import com.google.gson.JsonObject; +import io.papermc.paper.registry.RegistryAccess; +import io.papermc.paper.registry.RegistryKey; +import net.kyori.adventure.key.Key; + import me.totalfreedom.totalfreedommod.world.GenerationProfile; import me.totalfreedom.totalfreedommod.world.noise.NoiseField; import me.totalfreedom.totalfreedommod.world.noise.NoiseProfile; @@ -38,10 +42,10 @@ * with the file. Stopping at the first error means fixing typos one server restart at a time. *

* Treated as main thread only, though honestly that's more a project convention than a real API - * requirement; {@link Material#valueOf} and {@link Biome#valueOf} are just enum lookups and don't - * actually need the main thread. Keeping every Bukkit-facing lookup on one thread just means nobody - * has to double check that assumption later as this file grows. Reading the file itself is not - * bound the same way, so do that first and hand in the parsed JSON. + * requirement; {@link Material#valueOf} and a {@link RegistryKey#BIOME} lookup are just lookups and + * don't actually need the main thread. Keeping every Bukkit-facing lookup on one thread just means + * nobody has to double check that assumption later as this file grows. Reading the file itself is + * not bound the same way, so do that first and hand in the parsed JSON. *

* A {@link FeatureSpec}'s own {@code "type"} key is the variant discriminator, so no * {@link FeatureDetail} variant's fields may reuse that name. @@ -54,7 +58,9 @@ public final class ProfileParser * {@link ProfileLoader#biomeLibrary()} * @throws ProfileException carrying every problem found, never just the first */ - public GenerationProfile parse(final String worldName, final JsonObject root, final Map biomeLibrary) throws ProfileException + public GenerationProfile parse(final String worldName, + final JsonObject root, + final Map biomeLibrary) throws ProfileException { final List errors = new ArrayList<>(); final long seed = resolveSeed(worldName, root); @@ -123,8 +129,8 @@ private static Optional parseShapeSection(final JsonObject root, fi final Optional shape = switch (mode.get().toLowerCase(Locale.ROOT)) { case "flat" -> parseFlatShape(node.get(), path, errors, bounds.get()); - case "heightmap" -> parseHeightmapShape(node.get(), path, errors); - case "density" -> parseDensityShape(node.get(), path, errors); + case "heightmap" -> parseHeightmapShape(node.get(), path, errors, bounds.get()); + case "density" -> parseDensityShape(node.get(), path, errors, bounds.get()); default -> { errors.add(new ProfileError(childPath(path, "mode"), "unknown mode \"" + mode.get() + "\"")); @@ -135,7 +141,9 @@ private static Optional parseShapeSection(final JsonObject root, fi return shape.map(s -> new ParsedShape(bounds.get(), s)); } - private static Optional parseBounds(final JsonObject shapeNode, final String parentPath, final List errors) + private static Optional parseBounds(final JsonObject shapeNode, + final String parentPath, + final List errors) { final Optional node = requireObject(shapeNode, "bounds", parentPath, errors); if (node.isEmpty()) @@ -160,8 +168,10 @@ private static Optional parseBounds(final JsonObject shapeNode, final St } } - private static Optional parseFlatShape(final JsonObject shapeNode, final String path, final List errors, - final Bounds bounds) + private static Optional parseFlatShape(final JsonObject shapeNode, + final String path, + final List errors, + final Bounds bounds) { final Optional spec = requireString(shapeNode, "layers", path, errors); if (spec.isEmpty()) @@ -188,7 +198,10 @@ private static Optional parseFlatShape(final JsonObject shapeNode, final } } - private static Optional parseHeightmapShape(final JsonObject shapeNode, final String path, final List errors) + private static Optional parseHeightmapShape(final JsonObject shapeNode, + final String path, + final List errors, + final Bounds bounds) { final Optional terrainNode = requireObject(shapeNode, "terrain", path, errors); final Optional terrain = terrainNode.flatMap(node -> parseShapeTerrain(node, childPath(path, "terrain"), errors)); @@ -197,7 +210,7 @@ private static Optional parseHeightmapShape(final JsonObject shapeNode, f final Optional river = hasRiver ? parseRiver(shapeNode, path, errors) : Optional.empty(); final boolean hasCaves = hasKey(shapeNode, "caves"); - final Optional caves = hasCaves ? parseCaves(shapeNode, path, errors) : Optional.empty(); + final Optional caves = hasCaves ? parseCaves(shapeNode, path, errors, bounds) : Optional.empty(); final boolean hasRegions = hasKey(shapeNode, "regions"); final Optional> regions = hasRegions @@ -210,13 +223,16 @@ private static Optional parseHeightmapShape(final JsonObject shapeNode, f return Optional.of(new Shape.Heightmap(terrain.get(), river, caves, regions)); } - private static Optional parseDensityShape(final JsonObject shapeNode, final String path, final List errors) + private static Optional parseDensityShape(final JsonObject shapeNode, + final String path, + final List errors, + final Bounds bounds) { final Optional terrainNode = requireObject(shapeNode, "terrain", path, errors); final Optional terrain = terrainNode.flatMap(node -> parseDensityLayer(node, childPath(path, "terrain"), errors)); final boolean hasCaves = hasKey(shapeNode, "caves"); - final Optional caves = hasCaves ? parseCaves(shapeNode, path, errors) : Optional.empty(); + final Optional caves = hasCaves ? parseCaves(shapeNode, path, errors, bounds) : Optional.empty(); final boolean hasRegions = hasKey(shapeNode, "regions"); final Optional> regions = hasRegions @@ -274,7 +290,7 @@ private static Optional parseRiver(final JsonObject parent, final S return Optional.of(new Shape.River(noise.get(), threshold.get(), depth.get(), bedBlock.get())); } - private static Optional parseCaves(final JsonObject parent, final String parentPath, final List errors) + private static Optional parseCaves(final JsonObject parent, final String parentPath, final List errors, final Bounds bounds) { final Optional node = requireObject(parent, "caves", parentPath, errors); if (node.isEmpty()) @@ -288,12 +304,40 @@ private static Optional parseCaves(final JsonObject parent, final S final Optional maxY = requireInt(node.get(), "maxY", path, errors); final Optional floodLevel = requireInt(node.get(), "floodLevel", path, errors); - if (noise.isEmpty() || threshold.isEmpty() || minY.isEmpty() || maxY.isEmpty() || floodLevel.isEmpty()) + if (noise.isEmpty() || threshold.isEmpty() + || minY.isEmpty() + || maxY.isEmpty() + || floodLevel.isEmpty()) return Optional.empty(); + if (minY.get() < bounds.minY() || maxY.get() > bounds.maxY()) + { + errors.add(new ProfileError(path, + String.format("minY/maxY (%d to %d) must fall within the world's own bounds (Y=%d to Y=%d)", + minY.get(), + maxY.get(), + bounds.minY(), + bounds.maxY()))); + return Optional.empty(); + } + + if (floodLevel.get() < bounds.minY() || floodLevel.get() > bounds.maxY()) + { + errors.add(new ProfileError(childPath(path, "floodLevel"), + String.format("floodLevel (%d) must fall within the world's own bounds (Y=%d to Y=%d)", + floodLevel.get(), + bounds.minY(), + bounds.maxY()))); + return Optional.empty(); + } + try { - return Optional.of(new Shape.Caves(noise.get(), threshold.get(), minY.get(), maxY.get(), floodLevel.get())); + return Optional.of(new Shape.Caves(noise.get(), + threshold.get(), + minY.get(), + maxY.get(), + floodLevel.get())); } catch (final IllegalArgumentException ex) { @@ -370,6 +414,9 @@ private static Optional> parseRegions(final JsonObject pare if (!valid[0]) return Optional.empty(); + if (hasOverlappingCores(regions, path, errors)) + return Optional.empty(); + try { return Optional.of(new Shape.Regions<>(selector.get(), blendWidth.get(), regions)); @@ -381,6 +428,39 @@ private static Optional> parseRegions(final JsonObject pare } } + /** + * Regions are meant to overlap only within blendWidth of a shared border, which is what turns + * that border into a gradient; see {@link Shape.Region}. An outright overlap of two regions' own + * min/max would instead average them at full strength across their shared territory, which reads + * as a configuration mistake rather than an intended blend, so every overlapping pair is reported. + */ + private static boolean hasOverlappingCores(final List> regions, final String path, final List errors) + { + final boolean[] overlapping = { false }; + + IntStream.range(0, regions.size()).forEach(i -> + IntStream.range(i + 1, regions.size()).forEach(j -> + { + final Shape.Region a = regions.get(i); + final Shape.Region b = regions.get(j); + + if (a.min() <= b.max() && b.min() <= a.max()) + { + errors.add(new ProfileError(path, + String.format("regions \"%s\" and \"%s\" overlap (%d to %d vs %d to %d); narrow their ranges so they meet at most within blendWidth", + a.name(), + b.name(), + a.min(), + a.max(), + b.min(), + b.max()))); + overlapping[0] = true; + } + })); + + return overlapping[0]; + } + private static Optional parseNoiseProfile(final JsonObject node, final String path, final List errors) { final Optional type = requireEnum(node, "type", path, errors, "noise type", NoiseType::valueOf); @@ -697,7 +777,7 @@ private static BiomeFilter parseSurfaceBiomeFilter(final JsonObject node, final return BiomeFilter.any(); return parseBiome(name.get(), childPath(path, "biome"), errors).map(biome -> BiomeFilter.of(Set.of(biome))) - .orElseGet(BiomeFilter::any); + .orElseGet(BiomeFilter::any); } /** A feature or biome band's own shape: an optional "biomes" array of strings. Absent means Any(). */ @@ -716,7 +796,14 @@ private static BiomeFilter parseFeatureBiomeFilter(final JsonObject node, final try { - biomes.add(Biome.valueOf(element.getAsString().toUpperCase(Locale.ROOT))); + final Biome biome = RegistryAccess.registryAccess() + .getRegistry(RegistryKey.BIOME) + .get(Key.key(element.getAsString().toLowerCase(Locale.ROOT))); + + if (biome == null) + throw new IllegalArgumentException(); + + biomes.add(biome); } catch (final RuntimeException ex) { @@ -1106,7 +1193,14 @@ private static Optional parseBiome(final String name, final String path, { try { - return Optional.of(Biome.valueOf(name.toUpperCase(Locale.ROOT))); + final Biome biome = RegistryAccess.registryAccess() + .getRegistry(RegistryKey.BIOME) + .get(Key.key(name.toLowerCase(Locale.ROOT))); + + if (biome == null) + throw new IllegalArgumentException(); + + return Optional.of(biome); } catch (final IllegalArgumentException ex) { diff --git a/src/main/java/me/totalfreedom/totalfreedommod/world/profile/Shape.java b/src/main/java/me/totalfreedom/totalfreedommod/world/profile/Shape.java index 3a772441e..00c712d57 100644 --- a/src/main/java/me/totalfreedom/totalfreedommod/world/profile/Shape.java +++ b/src/main/java/me/totalfreedom/totalfreedommod/world/profile/Shape.java @@ -84,9 +84,14 @@ record Caves(NoiseProfile noise, double threshold, int minY, int maxY, int flood /** * One named region: the slice of the selector noise it claims, and the terrain it uses there. *

- * First matching region in the enclosing list wins, same idiom as {@link Palette.BiomeBand} and - * {@link SurfaceRule}. name only has to be unique within that list; it exists so an admin - * authoring a profile can tell regions apart in an error message, not for anything to reference. + * Unlike {@link Palette.BiomeBand} or {@link SurfaceRule}, this is not first-match-wins: every + * region whose range the selector's value falls within, or comes within the enclosing + * {@link Regions#blendWidth} of, contributes, weighted by how close. That is what turns a shared + * border into a gradient instead of a seam, so two regions are expected to overlap only within + * that blend margin; {@link ProfileParser} rejects a profile whose regions overlap outright, since + * two full-strength regions simply averaging there would be a surprise, not a border. name only + * has to be unique within the enclosing list; it exists so an admin authoring a profile can tell + * regions apart in an error message, not for anything to reference. * * @throws IllegalArgumentException if min is above max */ diff --git a/src/main/java/me/totalfreedom/totalfreedommod/world/stage/FlatGenerator.java b/src/main/java/me/totalfreedom/totalfreedommod/world/stage/FlatGenerator.java index 7955cd5f6..349b547f0 100644 --- a/src/main/java/me/totalfreedom/totalfreedommod/world/stage/FlatGenerator.java +++ b/src/main/java/me/totalfreedom/totalfreedommod/world/stage/FlatGenerator.java @@ -40,6 +40,6 @@ public void generateBase(final ChunkContext context, final ChunkGenerator.ChunkD @Override public int surfaceHeight(final int worldX, final int worldZ) { - return this.bounds.minY() + this.layers.totalHeight(); + return this.bounds.minY() + this.layers.totalHeight() - 1; } } diff --git a/src/main/java/me/totalfreedom/totalfreedommod/world/stage/LayerDesigner.java b/src/main/java/me/totalfreedom/totalfreedommod/world/stage/LayerDesigner.java index 08c304060..7adb7ff30 100644 --- a/src/main/java/me/totalfreedom/totalfreedommod/world/stage/LayerDesigner.java +++ b/src/main/java/me/totalfreedom/totalfreedommod/world/stage/LayerDesigner.java @@ -31,13 +31,16 @@ public void surface(final ChunkContext context, final ChunkGenerator.ChunkData d @Override public void bedrock(final ChunkContext context, final ChunkGenerator.ChunkData data) { - int meny = context.getProfile().bounds().minY(); - int man = context.getProfile().bounds().maxY(); + if (this.materials.bedrock() == BedrockMode.NONE) + return; - data.setRegion(0, meny, 0, 16, meny + 1, 16, materials.bedrockBlock()); + final int minY = context.getProfile().bounds().minY(); + final int maxY = context.getProfile().bounds().maxY(); + + data.setRegion(0, minY, 0, 16, minY + 1, 16, this.materials.bedrockBlock()); if (this.materials.bedrock() == BedrockMode.FLOOR_AND_ROOF) - data.setRegion(0, man, 0, 16, man - 1, 16, materials.bedrockBlock()); + data.setRegion(0, maxY, 0, 16, maxY + 1, 16, this.materials.bedrockBlock()); } } diff --git a/src/main/java/me/totalfreedom/totalfreedommod/world/stage/NoiseCarver.java b/src/main/java/me/totalfreedom/totalfreedommod/world/stage/NoiseCarver.java index 182eb60d0..0ed0ea7ea 100644 --- a/src/main/java/me/totalfreedom/totalfreedommod/world/stage/NoiseCarver.java +++ b/src/main/java/me/totalfreedom/totalfreedommod/world/stage/NoiseCarver.java @@ -1,5 +1,6 @@ package me.totalfreedom.totalfreedommod.world.stage; +import me.totalfreedom.totalfreedommod.util.FUtil; import me.totalfreedom.totalfreedommod.world.base.Carver; import me.totalfreedom.totalfreedommod.world.base.ChunkContext; import me.totalfreedom.totalfreedommod.world.noise.NoiseField; @@ -8,10 +9,15 @@ * Cuts caves and ravines wherever the noise goes past the threshold. *

* Tighten the threshold as you get near the context's terrain height and cave mouths blend into the - * hillside instead of cutting a flat wall into it. + * hillside instead of cutting a flat wall into it. The threshold ramps linearly from the configured + * value, {@link #SURFACE_BLEND_RANGE} blocks below terrain height, up to 1.0 (never carved) right at + * and above it, so a tunnel needs an increasingly strong noise peak to punch through as it nears the + * surface instead of stopping dead in a flat plane the moment it crosses the terrain height. */ public final class NoiseCarver implements Carver { + private static final double SURFACE_BLEND_RANGE = 8.0D; + private final NoiseField noise; private final double threshold; private final int minY; @@ -28,7 +34,12 @@ public NoiseCarver(final NoiseField noise, final double threshold, final int min @Override public boolean isCarved(final ChunkContext context, final int worldX, final int y, final int worldZ) { + final double sample = this.noise.sample(worldX, y, worldZ); + final int terrainHeight = context.terrainHeight(worldX & 0xF, worldZ & 0xF); + final double proximity = Math.max(0.0D, Math.min(1.0D, (terrainHeight - y) / SURFACE_BLEND_RANGE)); + final double effectiveThreshold = FUtil.lerp(proximity, 1.0D, this.threshold); + return sample > effectiveThreshold; } @Override diff --git a/src/main/java/me/totalfreedom/totalfreedommod/world/stage/feature/BoulderFeature.java b/src/main/java/me/totalfreedom/totalfreedommod/world/stage/feature/BoulderFeature.java index 5a5dde54d..408c24b48 100644 --- a/src/main/java/me/totalfreedom/totalfreedommod/world/stage/feature/BoulderFeature.java +++ b/src/main/java/me/totalfreedom/totalfreedommod/world/stage/feature/BoulderFeature.java @@ -36,14 +36,13 @@ public void place(final ChunkContext context, for (int lobe = 0; lobe < LOBES; lobe++) { - // Later lobes are smaller, so the boulder tapers instead of growing arms. final int lobeRadius = Math.max(1, radius - lobe); sphere(region, detail, lobeX, lobeY, lobeZ, lobeRadius); - lobeX += random.nextInt(radius + 1) - radius / 2; + lobeX += (int) Math.round((random.nextDouble() - 0.5D) * radius); lobeY += random.nextInt(2); - lobeZ += random.nextInt(radius + 1) - radius / 2; + lobeZ += (int) Math.round((random.nextDouble() - 0.5D) * radius); } } diff --git a/src/main/java/me/totalfreedom/totalfreedommod/world/stage/feature/OreFeature.java b/src/main/java/me/totalfreedom/totalfreedommod/world/stage/feature/OreFeature.java index 6a42f9a1c..45d45df79 100644 --- a/src/main/java/me/totalfreedom/totalfreedommod/world/stage/feature/OreFeature.java +++ b/src/main/java/me/totalfreedom/totalfreedommod/world/stage/feature/OreFeature.java @@ -45,8 +45,8 @@ public void place(final ChunkContext context, final double toX = x - Math.sin(angle) * reach; final double fromZ = z + Math.cos(angle) * reach; final double toZ = z - Math.cos(angle) * reach; - final double fromY = y + random.nextInt(3) - 2; - final double toY = y + random.nextInt(3) - 2; + final double fromY = y + random.nextInt(3) - 1; + final double toY = y + random.nextInt(3) - 1; for (int step = 0; step < size; step++) { From 23b80632ccdf5f0762bf0d99e4e06736765a5295 Mon Sep 17 00:00:00 2001 From: Paldiu Date: Fri, 14 Aug 2026 23:53:18 -0500 Subject: [PATCH 25/32] finishing up --- .../world/stage/HeightmapGenerator.java | 226 +++++++++++++++++- .../world/stage/RuleDesigner.java | 106 +++++++- 2 files changed, 314 insertions(+), 18 deletions(-) diff --git a/src/main/java/me/totalfreedom/totalfreedommod/world/stage/HeightmapGenerator.java b/src/main/java/me/totalfreedom/totalfreedommod/world/stage/HeightmapGenerator.java index 5eed49976..49c94fccc 100644 --- a/src/main/java/me/totalfreedom/totalfreedommod/world/stage/HeightmapGenerator.java +++ b/src/main/java/me/totalfreedom/totalfreedommod/world/stage/HeightmapGenerator.java @@ -2,9 +2,13 @@ import java.util.List; import java.util.Optional; +import java.util.stream.IntStream; +import java.util.stream.Stream; +import org.bukkit.block.data.BlockData; import org.bukkit.generator.ChunkGenerator; +import me.totalfreedom.totalfreedommod.util.FUtil; import me.totalfreedom.totalfreedommod.world.base.ChunkContext; import me.totalfreedom.totalfreedommod.world.base.Generator; import me.totalfreedom.totalfreedommod.world.noise.NoiseField; @@ -16,18 +20,29 @@ * The default mode. 2D noise through a spline, with rivers pulling height toward sea level. No * overhangs, and it covers most of what a custom survival world wants. *

- * Sample on a grid and interpolate between the samples. Sampling every block is 98,304 positions - * per chunk, times however many octaves the noise has. + * {@link #heightAt} is sampled exactly, once per column, and is the one place height is decided: + * both {@link #generateBase} and {@link #surfaceHeight} call it directly rather than each keeping + * their own approximation of the other. Interpolating a coarser grid would be cheaper, but + * {@link Generator#surfaceHeight} must agree exactly with what {@link #generateBase} writes, since + * {@link ChunkContext#terrainHeight}, cave-mouth blending, and spawn selection all trust it, and a + * heightmap column has no overhangs to make an approximation unavoidable the way density mode's does. *

* terrain/spline/warp are the fallback used wherever regions is empty or its selector matches no - * listed region, exactly as they always have been. TODO: generateBase/surfaceHeight need to sample - * regions.selector() per column once regions is present, blend the matched BuiltRegion's own - * noise/spline/warp in over blendWidth, and fall back to the fields above otherwise. + * listed region, exactly as they always have been. Every listed region blends in over + * {@code regions.blendWidth()} the same way {@link DensityGenerator} blends its own regions, just + * with a spline standing in for a falloff. + *

+ * A river's threshold sets how wide, in normalised noise either side of zero, its channel reads; + * depth is how many blocks of bedBlock line the channel floor at full strength, tapering to none at + * the channel's edge. */ public final class HeightmapGenerator implements Generator { + /** How far away, on each axis, warp's offset probes sample the same field they're displacing. */ + private static final int WARP_PROBE_OFFSET = 1013; + private final NoiseField terrain; - private final Optional river; + private final Optional river; private final Spline spline; private final Bounds bounds; private final Materials materials; @@ -35,7 +50,7 @@ public final class HeightmapGenerator implements Generator private final Optional regions; public HeightmapGenerator(final NoiseField terrain, - final Optional river, + final Optional river, final Spline spline, final Bounds bounds, final Materials materials, @@ -54,24 +69,32 @@ public HeightmapGenerator(final NoiseField terrain, @Override public void generateBase(final ChunkContext context, final ChunkGenerator.ChunkData data) { + final Optional seaLevel = this.bounds.seaLevel(); + + IntStream.range(0, 256).forEach(index -> + { + final int localX = index & 0xF; + final int localZ = index >> 4; + this.writeColumn(data, localX, localZ, context.worldX(localX), context.worldZ(localZ), seaLevel); + }); } @Override public int surfaceHeight(final int worldX, final int worldZ) { - + return this.clamp((int) Math.round(this.heightAt(worldX, worldZ))); } /** * One profile region, already built: a sampled noise field and a ready spline, not the raw * settings {@link me.totalfreedom.totalfreedommod.world.profile.Shape.Region} carries. */ - public record BuiltRegion(String name, - double min, - double max, - NoiseField noise, - Spline spline, + public record BuiltRegion(String name, + double min, + double max, + NoiseField noise, + Spline spline, double warp) { } @@ -86,4 +109,181 @@ public record BuiltRegion(String name, public record RegionSet(NoiseField selector, double blendWidth, List regions) { } + + /** + * A river's own noise plus how it behaves. threshold sets the channel's half-width in normalised + * noise; depth is how many blocks of bedBlock line the channel floor at full strength. + */ + public record River(NoiseField noise, double threshold, int depth, BlockData bedBlock) + { + } + + /** One noise field's share of a blended height, and the spline and warp to apply before blending. */ + private record Contribution(NoiseField noise, Spline spline, double warp, double weight) + { + } + + /** The blended, river-adjusted height at one world position; the one place height is actually decided. */ + private double heightAt(final int worldX, final int worldZ) + { + final List contributions = this.contributions(worldX, worldZ); + final double totalWeight = contributions.stream() + .mapToDouble(Contribution::weight) + .sum(); + + final double weightedSum = contributions.stream() + .mapToDouble(contribution -> contribution.weight() * contribution.spline() + .apply(warpedSample(contribution.noise(), + contribution.warp(), + worldX, + worldZ))) + .sum(); + + final double height = weightedSum / totalWeight; + + return this.river.map(river -> this.pullTowardRiver(river, height, worldX, worldZ)) + .orElse(height); + } + + /** + * This position's height sources and how much each one counts. Just this generator's own + * terrain/spline/warp when there are no regions; otherwise every region within blendWidth of the + * selector's value, plus this generator's own fields for whatever weight none of them claim. + */ + private List contributions(final int worldX, final int worldZ) + { + if (this.regions.isEmpty()) + return List.of(new Contribution(this.terrain, this.spline, this.warp, 1.0D)); + + final RegionSet regionSet = this.regions.get(); + final double selector = regionSet.selector().sample(worldX, worldZ); + final double blendWidth = regionSet.blendWidth(); + + final List matched = regionSet.regions() + .stream() + .map(region -> new Contribution(region.noise(), + region.spline(), + region.warp(), + edgeWeight(region, selector, blendWidth))) + .filter(contribution -> contribution.weight() > 0.0D) + .toList(); + + final double covered = matched.stream().mapToDouble(Contribution::weight).sum(); + final double fallbackWeight = Math.max(0.0D, 1.0D - covered); + + if (fallbackWeight <= 0.0D) + return matched; + + return Stream.concat(matched.stream(), Stream.of(new Contribution(this.terrain, + this.spline, + this.warp, + fallbackWeight))) + .toList(); + } + + /** + * 1 throughout the region's own range, fading linearly to 0 over blendWidth beyond either edge, + * 0 past that. The one place a region's border actually gets decided. + */ + private static double edgeWeight(final BuiltRegion region, final double selector, final double blendWidth) + { + final double min = region.min(); + final double max = region.max(); + + if (selector < min - blendWidth || selector > max + blendWidth) + return 0.0D; + + if (selector < min) + return (selector - (min - blendWidth)) / blendWidth; + + if (selector > max) + return ((max + blendWidth) - selector) / blendWidth; + + return 1.0D; + } + + /** + * Displaces (worldX, worldZ) before the real sample, using two more samples of the same field to + * decide by how much, scaled by the field's own wavelength so warp reads the same regardless of + * how coarse or fine the field is. See {@link DensityGenerator#warpedSample} for the 3D version. + */ + private static double warpedSample(final NoiseField noise, final double warp, final int worldX, final int worldZ) + { + if (warp == 0.0D) + return noise.sample(worldX, worldZ); + + final double wavelength = 1.0D / noise.getProfile().frequency(); + final double offsetX = noise.sample(worldX + WARP_PROBE_OFFSET, worldZ) * warp * wavelength; + final double offsetZ = noise.sample(worldX, worldZ + WARP_PROBE_OFFSET) * warp * wavelength; + + return noise.sample(worldX + (int) Math.round(offsetX), worldZ + (int) Math.round(offsetZ)); + } + + /** + * Pulls height toward sea level as the river's own noise sample nears zero, full strength at zero + * and fading out over {@code river.threshold()}. Falls back to the bounds' vertical midpoint where + * the profile has no sea, so a river still carves a channel rather than doing nothing. + */ + private double pullTowardRiver(final River river, final double height, final int worldX, final int worldZ) + { + final double strength = riverStrength(river, worldX, worldZ); + final double riverLevel = this.bounds.seaLevel() + .map(Integer::doubleValue) + .orElse((this.bounds.minY() + this.bounds.maxY()) / 2.0D); + + return FUtil.lerp(strength, height, riverLevel); + } + + /** 1 at the channel's centre (noise sample of zero), fading linearly to 0 at threshold either side. */ + private static double riverStrength(final River river, final int worldX, final int worldZ) + { + final double sample = river.noise().sample(worldX, worldZ); + + return Math.max(0.0D, 1.0D - Math.abs(sample) / river.threshold()); + } + + /** How many blocks of bedBlock this column's river channel gets, tapering to 0 at the channel's edge. */ + private int riverBedThickness(final int worldX, final int worldZ) + { + return this.river.map(river -> (int) Math.round(riverStrength(river, worldX, worldZ) * river.depth())) + .orElse(0); + } + + private void writeColumn(final ChunkGenerator.ChunkData data, + final int localX, + final int localZ, + final int worldX, + final int worldZ, + final Optional seaLevel) + { + final int minY = this.bounds.minY(); + final int height = this.clamp((int) Math.round(this.heightAt(worldX, worldZ))); + final int bedThickness = this.riverBedThickness(worldX, worldZ); + + if (bedThickness > 0) + { + final int bedTop = Math.max(minY, height - bedThickness + 1); + + data.setRegion(localX, minY, localZ, localX + 1, bedTop, localZ + 1, this.materials.defaultBlock()); + data.setRegion(localX, bedTop, localZ, localX + 1, height + 1, localZ + 1, this.river.get().bedBlock()); + } + else + { + data.setRegion(localX, minY, localZ, localX + 1, height + 1, localZ + 1, this.materials.defaultBlock()); + } + + if (seaLevel.isPresent() && seaLevel.get() > height) + data.setRegion(localX, + height + 1, + localZ, + localX + 1, + seaLevel.get() + 1, + localZ + 1, + this.materials.fluidBlock()); + } + + private int clamp(final int height) + { + return Math.max(this.bounds.minY(), Math.min(this.bounds.maxY(), height)); + } } diff --git a/src/main/java/me/totalfreedom/totalfreedommod/world/stage/RuleDesigner.java b/src/main/java/me/totalfreedom/totalfreedommod/world/stage/RuleDesigner.java index 0ee39a33e..2f761e293 100644 --- a/src/main/java/me/totalfreedom/totalfreedommod/world/stage/RuleDesigner.java +++ b/src/main/java/me/totalfreedom/totalfreedommod/world/stage/RuleDesigner.java @@ -1,21 +1,38 @@ package me.totalfreedom.totalfreedommod.world.stage; import java.util.List; +import java.util.Optional; +import java.util.stream.IntStream; +import org.bukkit.Material; +import org.bukkit.block.Biome; import org.bukkit.generator.ChunkGenerator; +import me.totalfreedom.totalfreedommod.world.base.Carver; import me.totalfreedom.totalfreedommod.world.base.ChunkContext; import me.totalfreedom.totalfreedommod.world.base.Designer; +import me.totalfreedom.totalfreedommod.world.base.Generator; +import me.totalfreedom.totalfreedommod.world.profile.BedrockMode; +import me.totalfreedom.totalfreedommod.world.profile.BiomeTarget; import me.totalfreedom.totalfreedommod.world.profile.Materials; +import me.totalfreedom.totalfreedommod.world.profile.Palette; import me.totalfreedom.totalfreedommod.world.profile.SurfaceRule; /** * Applies the profile's surface rules. Walks each column down from the context's column top with a - * depth counter that resets on air gaps, so cave floors get their own treatment. + * depth counter that resets on every gap, so cave floors and, for density terrain, natural overhangs + * each get their own treatment rather than being buried under a depth count that assumed solid rock. *

- * TODO: surface() must resolve each column's band via {@code palette().resolveBand()}, not just its - * display biome, and use a {@code Logical} band's own {@code surface()} list when it has one. - * Everything else falls through to this.rules. + * A column's biome comes from {@code palette().resolveBand()}, not just its display biome, since a + * {@link BiomeTarget.Logical} band's own {@code surface()} list, when it has one, wholly replaces + * this.rules for that column rather than adding to it. Everything else falls through to this.rules. + *

+ * A gap is either of two things, checked together at every y: the block {@link Generator#generateBase} + * already wrote there is not solid (covers density terrain's own overhangs and cavities, and any + * heightmap/density approximation slipping a block either side of where {@link ChunkContext#columnTop} + * expected), or the carver says the position will end up carved once {@code generateCaves} runs, which + * hasn't happened yet at this point in the callback order so isCarved is asked rather than the not-yet- + * carved chunk data. */ public final class RuleDesigner implements Designer { @@ -31,12 +48,91 @@ public RuleDesigner(final List rules, final Materials materials) @Override public void surface(final ChunkContext context, final ChunkGenerator.ChunkData data) { - + IntStream.range(0, 256) + .forEach(index -> this.surfaceColumn(context, data, index & 0xF, index >> 4)); } @Override public void bedrock(final ChunkContext context, final ChunkGenerator.ChunkData data) { + if (this.materials.bedrock() == BedrockMode.NONE) + return; + + final int minY = context.getProfile().bounds().minY(); + final int maxY = context.getProfile().bounds().maxY(); + + data.setRegion(0, minY, 0, 16, minY + 1, 16, this.materials.bedrockBlock()); + + if (this.materials.bedrock() == BedrockMode.FLOOR_AND_ROOF) + data.setRegion(0, maxY, 0, 16, maxY + 1, 16, this.materials.bedrockBlock()); + } + + private void surfaceColumn(final ChunkContext context, final ChunkGenerator.ChunkData data, final int localX, final int localZ) + { + final int worldX = context.worldX(localX); + final int worldZ = context.worldZ(localZ); + + final Palette palette = context.getProfile().palette(); + final Optional band = palette.resolveBand(worldX, worldZ); + final Biome biome = band.map(built -> built.target().display()) + .orElse(palette.fallback()); + + final List rules = this.rulesFor(band); + final Optional carver = context.getStages().carver(); + final int minY = context.getProfile().bounds().minY(); + final int[] depth = { 0 }; + + IntStream.iterate(context.columnTop(localX, localZ), y -> y >= minY, y -> y - 1) + .forEach(y -> + { + if (!isSolid(data, this.materials, localX, y, localZ) || isCarved(context, carver, worldX, y, worldZ)) + { + depth[0] = 0; + return; + } + + final int currentDepth = depth[0]; + + rules.stream() + .filter(rule -> rule.matches(biome, currentDepth)) + .findFirst() + .ifPresent(rule -> data.setBlock(localX, y, localZ, rule.block())); + + depth[0]++; + }); + } + + /** A Logical band's own surface list when it has one, otherwise this designer's world-level rules. */ + private List rulesFor(final Optional band) + { + return band.map(Palette.BiomeBand::target) + .filter(BiomeTarget.Logical.class::isInstance) + .map(BiomeTarget.Logical.class::cast) + .flatMap(logical -> logical.definition().surface()) + .orElse(this.rules); + } + + /** + * Whatever {@link Generator#generateBase} actually wrote there, not what a terrain-height + * approximation assumed. Air (of any of Bukkit's three flavours) and the profile's own fluid both + * count as not solid, the latter so a lake or aquifer bed resets depth and gets dressed too instead + * of staying buried under whatever depth count the column had before the water started. + */ + private static boolean isSolid(final ChunkGenerator.ChunkData data, final Materials materials, final int localX, final int y, final int localZ) + { + final Material type = data.getType(localX, y, localZ); + + if (type == Material.AIR || type == Material.CAVE_AIR || type == Material.VOID_AIR) + return false; + return type != materials.fluidBlock().getMaterial(); + } + + private static boolean isCarved(final ChunkContext context, final Optional carver, final int worldX, final int y, final int worldZ) + { + return carver.isPresent() + && y >= carver.get().minY() + && y <= carver.get().maxY() + && carver.get().isCarved(context, worldX, y, worldZ); } } From e3e31bbddb76c184e9ba51ca619334e66af40e1f Mon Sep 17 00:00:00 2001 From: Paldiu Date: Fri, 14 Aug 2026 23:54:00 -0500 Subject: [PATCH 26/32] Update GenerationService.java --- .../totalfreedommod/world/GenerationService.java | 11 +---------- 1 file changed, 1 insertion(+), 10 deletions(-) diff --git a/src/main/java/me/totalfreedom/totalfreedommod/world/GenerationService.java b/src/main/java/me/totalfreedom/totalfreedommod/world/GenerationService.java index 4f3b3c2ac..d21058b22 100644 --- a/src/main/java/me/totalfreedom/totalfreedommod/world/GenerationService.java +++ b/src/main/java/me/totalfreedom/totalfreedommod/world/GenerationService.java @@ -1,23 +1,16 @@ package me.totalfreedom.totalfreedommod.world; -import java.io.File; -import java.io.FileNotFoundException; -import java.io.IOException; -import java.io.Reader; -import java.util.Arrays; +\ import java.util.HashMap; import java.util.Map; import java.util.Optional; import java.util.Set; -import java.util.stream.Stream; import org.apache.commons.lang3.exception.ExceptionUtils; import org.bukkit.Bukkit; import org.bukkit.generator.ChunkGenerator; import com.google.gson.JsonObject; -import com.google.gson.JsonParser; -import com.google.gson.stream.JsonReader; import me.totalfreedom.totalfreedommod.FreedomService; import me.totalfreedom.totalfreedommod.TotalFreedomMod; @@ -40,8 +33,6 @@ */ public final class GenerationService extends FreedomService { - private static final String JSON_ENDING = ".json"; - private final ProfileLoader loader; private final ProfileParser parser; private final Map profiles; From 8cedbef7df1bf8d2764b61f0b51e98ddbc13ed75 Mon Sep 17 00:00:00 2001 From: Paldiu Date: Fri, 14 Aug 2026 23:54:13 -0500 Subject: [PATCH 27/32] Update GenerationService.java --- .../me/totalfreedom/totalfreedommod/world/GenerationService.java | 1 - 1 file changed, 1 deletion(-) diff --git a/src/main/java/me/totalfreedom/totalfreedommod/world/GenerationService.java b/src/main/java/me/totalfreedom/totalfreedommod/world/GenerationService.java index d21058b22..2ceade827 100644 --- a/src/main/java/me/totalfreedom/totalfreedommod/world/GenerationService.java +++ b/src/main/java/me/totalfreedom/totalfreedommod/world/GenerationService.java @@ -1,6 +1,5 @@ package me.totalfreedom.totalfreedommod.world; -\ import java.util.HashMap; import java.util.Map; import java.util.Optional; From 7c8c4d1eda75b5a61fb0e7620ba6b9e61cd39eb8 Mon Sep 17 00:00:00 2001 From: Paldiu Date: Sat, 15 Aug 2026 01:22:57 -0500 Subject: [PATCH 28/32] final pass-through for world generation --- .../totalfreedommod/world/AdminWorld.java | 10 ++++ .../world/CleanroomChunkGenerator.java | 17 ++++++- .../totalfreedommod/world/Flatlands.java | 3 ++ .../totalfreedommod/world/GeneratedWorld.java | 20 +++----- .../world/GenerationService.java | 9 ++++ .../totalfreedommod/world/WorldManager.java | 7 +++ .../totalfreedommod/world/WorldTime.java | 4 +- .../world/adapter/ProfileChunkGenerator.java | 49 +++++++++++++----- .../world/adapter/SpawnFinder.java | 15 ++++-- .../totalfreedommod/world/profile/Bounds.java | 9 +++- .../world/profile/ProfileParser.java | 11 ++-- .../world/stage/DensityGenerator.java | 29 +++++++---- .../world/stage/FeaturePopulator.java | 51 +++++++++++++++++-- .../world/stage/HeightmapGenerator.java | 3 +- .../world/stage/LayerDesigner.java | 2 +- .../world/stage/RuleDesigner.java | 2 +- .../world/stage/feature/LakeFeature.java | 8 +++ .../resources/worlds/overworld-template.json | 26 +++++----- 18 files changed, 204 insertions(+), 71 deletions(-) diff --git a/src/main/java/me/totalfreedom/totalfreedommod/world/AdminWorld.java b/src/main/java/me/totalfreedom/totalfreedommod/world/AdminWorld.java index d5e5f68cb..e71449421 100644 --- a/src/main/java/me/totalfreedom/totalfreedommod/world/AdminWorld.java +++ b/src/main/java/me/totalfreedom/totalfreedommod/world/AdminWorld.java @@ -64,6 +64,9 @@ protected World generateWorld() final World world = Bukkit.getServer().createWorld(worldCreator); + if (world == null) + return null; + world.setSpawnFlags(false, false); world.setSpawnLocation(0, 50, 0); @@ -180,6 +183,13 @@ public void wipeAccessCache() accessCache.clear(); } + /** Drops a departed player's cooldown and access-cache entries, so those maps don't grow forever. */ + public void forgetPlayer(final Player player) + { + teleportCooldown.remove(player); + accessCache.remove(player); + } + public boolean canAccessWorld(final Player player) { long currentTimeMillis = System.currentTimeMillis(); diff --git a/src/main/java/me/totalfreedom/totalfreedommod/world/CleanroomChunkGenerator.java b/src/main/java/me/totalfreedom/totalfreedommod/world/CleanroomChunkGenerator.java index ae90be088..c5531f8e9 100644 --- a/src/main/java/me/totalfreedom/totalfreedommod/world/CleanroomChunkGenerator.java +++ b/src/main/java/me/totalfreedom/totalfreedommod/world/CleanroomChunkGenerator.java @@ -229,6 +229,21 @@ public Location getFixedSpawnLocation(World world, Random random) return new Location(world, 0, 64, 0); } - return new Location(world, 0, highestBlock, 0); + // One above the highest solid block, so the player stands on it instead of inside it. + return new Location(world, 0, highestBlock + 1, 0); + } + + /** A cleanroom world is exactly what generateChunkData wrote; no vanilla-biome decorations on top. */ + @Override + public boolean shouldGenerateDecorations() + { + return false; + } + + /** No vanilla-biome mob spawning in a flat/void world that never asked for any. */ + @Override + public boolean shouldGenerateMobs() + { + return false; } } diff --git a/src/main/java/me/totalfreedom/totalfreedommod/world/Flatlands.java b/src/main/java/me/totalfreedom/totalfreedommod/world/Flatlands.java index d2535e47d..e3317c337 100644 --- a/src/main/java/me/totalfreedom/totalfreedommod/world/Flatlands.java +++ b/src/main/java/me/totalfreedom/totalfreedommod/world/Flatlands.java @@ -38,6 +38,9 @@ protected World generateWorld() final World world = Bukkit.getServer().createWorld(worldCreator); + if (world == null) + return null; + world.setSpawnFlags(false, false); world.setSpawnLocation(0, 50, 0); diff --git a/src/main/java/me/totalfreedom/totalfreedommod/world/GeneratedWorld.java b/src/main/java/me/totalfreedom/totalfreedommod/world/GeneratedWorld.java index d34ce689d..7a8a1b5a7 100644 --- a/src/main/java/me/totalfreedom/totalfreedommod/world/GeneratedWorld.java +++ b/src/main/java/me/totalfreedom/totalfreedommod/world/GeneratedWorld.java @@ -1,23 +1,25 @@ package me.totalfreedom.totalfreedommod.world; import org.bukkit.Bukkit; -import org.bukkit.Location; import org.bukkit.NamespacedKey; import org.bukkit.World; import org.bukkit.WorldCreator; import me.totalfreedom.totalfreedommod.TotalFreedomMod; import me.totalfreedom.totalfreedommod.world.adapter.ProfileChunkGenerator; -import me.totalfreedom.totalfreedommod.world.adapter.SpawnFinder; import me.totalfreedom.totalfreedommod.world.profile.WorldSettings; /** - * A custom world built from a profile. Applies the profile's world settings to the WorldCreator and - * takes its spawn point from the spawn finder. + * A custom world built from a profile. Applies the profile's world settings to the WorldCreator. *

* Keyed under the {@code minecraft} namespace so the level/folder name Paper derives from the key * matches {@link GenerationProfile#name()} exactly, keeping it a plain lookup for * {@link WorldManager#gotoWorld} and {@link GenerationService#profile}. + *

+ * Sets no spawn location itself. {@link ProfileChunkGenerator#getFixedSpawnLocation} already runs + * the same deterministic search, and Bukkit applies whatever it returns to the world during + * {@link Bukkit#createWorld}, so searching again here would only repeat that same scan for the same + * answer. */ public class GeneratedWorld extends CustomWorld { @@ -42,15 +44,7 @@ protected World generateWorld() worldCreator.generator(generator); settings.seed().ifPresent(seed -> worldCreator.seed(seed.longValue())); - final World world = Bukkit.getServer().createWorld(worldCreator); - - if (world == null) - return null; - - final Location spawn = new SpawnFinder(this.profile, generator.generator()).findSpawn(world); - world.setSpawnLocation(spawn.getBlockX(), spawn.getBlockY(), spawn.getBlockZ()); - - return world; + return Bukkit.getServer().createWorld(worldCreator); } public GenerationProfile getProfile() diff --git a/src/main/java/me/totalfreedom/totalfreedommod/world/GenerationService.java b/src/main/java/me/totalfreedom/totalfreedommod/world/GenerationService.java index 2ceade827..03333e180 100644 --- a/src/main/java/me/totalfreedom/totalfreedommod/world/GenerationService.java +++ b/src/main/java/me/totalfreedom/totalfreedommod/world/GenerationService.java @@ -32,6 +32,9 @@ */ public final class GenerationService extends FreedomService { + /** World names TFM already manages itself, outside the profile system; see {@link Flatlands} and {@link AdminWorld}. */ + private static final Set RESERVED_WORLD_NAMES = Set.of("flatlands", "adminworld"); + private final ProfileLoader loader; private final ProfileParser parser; private final Map profiles; @@ -118,6 +121,12 @@ public void reload() private void loadProfile(final String worldName, final Map biomeLibrary) { + if (RESERVED_WORLD_NAMES.contains(worldName)) + { + FLog.warning("Skipping profile \"" + worldName + "\": that name is reserved for TFM's own " + worldName + " world and can never be generated from a profile."); + return; + } + try { final Optional jsonRoot = this.loader.read(worldName); diff --git a/src/main/java/me/totalfreedom/totalfreedommod/world/WorldManager.java b/src/main/java/me/totalfreedom/totalfreedommod/world/WorldManager.java index cd4287143..d3ebe2200 100644 --- a/src/main/java/me/totalfreedom/totalfreedommod/world/WorldManager.java +++ b/src/main/java/me/totalfreedom/totalfreedommod/world/WorldManager.java @@ -6,6 +6,7 @@ import org.bukkit.event.EventHandler; import org.bukkit.event.EventPriority; import org.bukkit.event.player.PlayerMoveEvent; +import org.bukkit.event.player.PlayerQuitEvent; import org.bukkit.event.player.PlayerTeleportEvent; import org.bukkit.event.weather.ThunderChangeEvent; import org.bukkit.event.weather.WeatherChangeEvent; @@ -109,6 +110,12 @@ public void onThunderChange(ThunderChangeEvent event) } } + @EventHandler(priority = EventPriority.MONITOR) + public void onPlayerQuit(PlayerQuitEvent event) + { + adminworld.forgetPlayer(event.getPlayer()); + } + @EventHandler(priority = EventPriority.HIGH) public void onWeatherChange(WeatherChangeEvent event) { diff --git a/src/main/java/me/totalfreedom/totalfreedommod/world/WorldTime.java b/src/main/java/me/totalfreedom/totalfreedommod/world/WorldTime.java index 71a3842a9..80c64c4ec 100644 --- a/src/main/java/me/totalfreedom/totalfreedommod/world/WorldTime.java +++ b/src/main/java/me/totalfreedom/totalfreedommod/world/WorldTime.java @@ -47,9 +47,7 @@ public List getAliases() public void setWorldToTime(World world) { - long time = world.getTime(); - time -= time % 24000; - world.setTime(time + 24000 + getTimeTicks()); + world.setTime(getTimeTicks()); } public static WorldTime getByAlias(String needle) diff --git a/src/main/java/me/totalfreedom/totalfreedommod/world/adapter/ProfileChunkGenerator.java b/src/main/java/me/totalfreedom/totalfreedommod/world/adapter/ProfileChunkGenerator.java index 53a109ccb..ea8566281 100644 --- a/src/main/java/me/totalfreedom/totalfreedommod/world/adapter/ProfileChunkGenerator.java +++ b/src/main/java/me/totalfreedom/totalfreedommod/world/adapter/ProfileChunkGenerator.java @@ -12,6 +12,7 @@ import me.totalfreedom.totalfreedommod.world.GenerationProfile; import me.totalfreedom.totalfreedommod.world.base.*; import me.totalfreedom.totalfreedommod.world.noise.NoiseField; +import me.totalfreedom.totalfreedommod.world.profile.BedrockMode; import me.totalfreedom.totalfreedommod.world.profile.Materials; import me.totalfreedom.totalfreedommod.world.profile.Shape; import me.totalfreedom.totalfreedommod.world.stage.*; @@ -113,28 +114,48 @@ public void generateCaves(final WorldInfo worldInfo, if (carver.isEmpty()) return; - final ChunkContext context = ChunkContext.of(this.profile, - this.stages, - worldInfo, - random, - chunkX, + final ChunkContext context = ChunkContext.of(this.profile, + this.stages, + worldInfo, + random, + chunkX, chunkZ); final BlockData air = Material.CAVE_AIR.createBlockData(); final BlockData fluid = this.profile.palette().materials().fluidBlock(); final int floodLevel = this.caveFloodLevel.orElse(Integer.MIN_VALUE); + final int carveMinY = Math.max(carver.get().minY(), this.bedrockFloorGuard()); + final int carveMaxY = Math.min(carver.get().maxY(), this.bedrockRoofGuard()); IntStream.range(0, 256) - .forEach(index -> this.carveColumn(context, - chunkData, - carver.get(), - index & 0xF, - index >> 4, - air, - fluid, + .forEach(index -> this.carveColumn(context, + chunkData, + carver.get(), + carveMinY, + carveMaxY, + index & 0xF, + index >> 4, + air, + fluid, floodLevel)); } + /** One past the bedrock floor {@link RuleDesigner}/{@link LayerDesigner} write at bounds.minY(), or no guard at all if there is none. */ + private int bedrockFloorGuard() + { + return this.profile.palette().materials().bedrock() == BedrockMode.NONE + ? Integer.MIN_VALUE + : this.profile.bounds().minY() + 1; + } + + /** One below the bedrock roof a FLOOR_AND_ROOF profile writes at bounds.maxY() - 1, or no guard for any other mode. */ + private int bedrockRoofGuard() + { + return this.profile.palette().materials().bedrock() == BedrockMode.FLOOR_AND_ROOF + ? this.profile.bounds().maxY() - 2 + : Integer.MAX_VALUE; + } + @Override public int getBaseHeight(final WorldInfo worldInfo, final Random random, @@ -198,6 +219,8 @@ public boolean shouldGenerateStructures() private void carveColumn(final ChunkContext context, final ChunkData data, final Carver carver, + final int carveMinY, + final int carveMaxY, final int localX, final int localZ, final BlockData air, @@ -207,7 +230,7 @@ private void carveColumn(final ChunkContext context, final int worldX = context.worldX(localX); final int worldZ = context.worldZ(localZ); - IntStream.rangeClosed(carver.minY(), carver.maxY()) + IntStream.rangeClosed(carveMinY, carveMaxY) .filter(y -> carver.isCarved(context, worldX, y, worldZ)) .forEach(y -> data.setBlock(localX, y, localZ, y < floodLevel ? fluid : air)); } diff --git a/src/main/java/me/totalfreedom/totalfreedommod/world/adapter/SpawnFinder.java b/src/main/java/me/totalfreedom/totalfreedommod/world/adapter/SpawnFinder.java index 9b222a17e..8485aa7c9 100644 --- a/src/main/java/me/totalfreedom/totalfreedommod/world/adapter/SpawnFinder.java +++ b/src/main/java/me/totalfreedom/totalfreedommod/world/adapter/SpawnFinder.java @@ -62,8 +62,8 @@ public Location findSpawn(final World world) /** * Walks the edge of one square ring at this radius. *

- * Squares rather than circles because the point is to spread outward evenly, and a square ring - * is a single loop with no trigonometry. Ring zero is the origin itself. + * Squares rather than circles because a square ring is a single loop with no trigonometry. + * Ring zero is the origin itself. */ private Optional searchRing(final World world, final int ring, final int floor) { @@ -98,12 +98,19 @@ private Optional searchRing(final World world, final int ring, final i return Optional.empty(); } - /** A column qualifies if its pre-carving ground sits above the water line and below the world's ceiling. */ + /** + * A column qualifies if its pre-carving ground sits above the water line and leaves a standing + * player clear of the world's ceiling, with a two-block margin below it. + *

+ * A {@code FLOOR_AND_ROOF} profile caps its world with bedrock at {@code bounds.maxY() - 1}, + * so without that margin a column whose ground crested right under the roof + * would spawn a player with their head inside it. + */ private Optional candidate(final World world, final int x, final int z, final int floor) { final int height = this.generator.surfaceHeight(x, z); - if (height <= floor || height >= this.profile.bounds().maxY()) + if (height <= floor || height >= this.profile.bounds().maxY() - 3) return Optional.empty(); // Centred in the block and one above the ground, so the player is not standing inside it. diff --git a/src/main/java/me/totalfreedom/totalfreedommod/world/profile/Bounds.java b/src/main/java/me/totalfreedom/totalfreedommod/world/profile/Bounds.java index 2f344dc26..12fe6d9b8 100644 --- a/src/main/java/me/totalfreedom/totalfreedommod/world/profile/Bounds.java +++ b/src/main/java/me/totalfreedom/totalfreedommod/world/profile/Bounds.java @@ -5,6 +5,11 @@ /** * A world's configured vertical limits and water line. *

+ * minY is the world's lowest usable row, same as {@link org.bukkit.World#getMinHeight()}. maxY is one + * past its highest usable row, same as {@link org.bukkit.World#getMaxHeight()}, so the world's own top + * row is {@code maxY - 1} and every stage that writes or reads the ceiling has to remember the + * subtraction rather than treating maxY itself as a block position. + *

* An empty seaLevel means the world has no sea at all, which is what the end and flat worlds want. * There is no "sea level 0 means off" rule to remember. */ @@ -16,7 +21,7 @@ public record Bounds(int minY, int maxY, Optional seaLevel) if (minY >= maxY) throw new IllegalArgumentException("minY (" + minY + ") must be below maxY (" + maxY + ")"); - if (seaLevel.isPresent() && (seaLevel.get() < minY || seaLevel.get() > maxY)) - throw new IllegalArgumentException("seaLevel (" + seaLevel.get() + ") must fall within " + minY + " to " + maxY); + if (seaLevel.isPresent() && (seaLevel.get() < minY || seaLevel.get() >= maxY)) + throw new IllegalArgumentException("seaLevel (" + seaLevel.get() + ") must fall within " + minY + " to " + (maxY - 1)); } } diff --git a/src/main/java/me/totalfreedom/totalfreedommod/world/profile/ProfileParser.java b/src/main/java/me/totalfreedom/totalfreedommod/world/profile/ProfileParser.java index adf1474fd..02d1c5f72 100644 --- a/src/main/java/me/totalfreedom/totalfreedommod/world/profile/ProfileParser.java +++ b/src/main/java/me/totalfreedom/totalfreedommod/world/profile/ProfileParser.java @@ -84,10 +84,11 @@ private record ParsedShape(Bounds bounds, Shape shape) } /** - * A pinned seed if world.seed is a valid number, else one derived from the world's own name, so - * re-parsing an unseeded profile after a restart still produces the same terrain. Resolved before - * anything else, since palette.climate needs a seed to build its NoiseFields. Never records an - * error itself; a malformed world.seed is reported properly later, by parseWorldSettings. + * A pinned seed if world.seed is a valid number (or a quoted number; Gson happily coerces either), + * else one derived from the world's own name, so re-parsing an unseeded profile after a restart + * still produces the same terrain. Resolved before anything else, since palette.climate needs a + * seed to build its NoiseFields. Never records an error itself; a malformed world.seed is reported + * properly later, by parseWorldSettings. */ private static long resolveSeed(final String worldName, final JsonObject root) { @@ -97,7 +98,7 @@ private static long resolveSeed(final String worldName, final JsonObject root) { final JsonElement seedElement = worldNode.getAsJsonObject().get("seed"); - if (seedElement != null && seedElement.isJsonPrimitive() && seedElement.getAsJsonPrimitive().isNumber()) + if (seedElement != null && seedElement.isJsonPrimitive() && !seedElement.getAsJsonPrimitive().isBoolean()) { try { diff --git a/src/main/java/me/totalfreedom/totalfreedommod/world/stage/DensityGenerator.java b/src/main/java/me/totalfreedom/totalfreedommod/world/stage/DensityGenerator.java index cc43299cf..33528d9a0 100644 --- a/src/main/java/me/totalfreedom/totalfreedommod/world/stage/DensityGenerator.java +++ b/src/main/java/me/totalfreedom/totalfreedommod/world/stage/DensityGenerator.java @@ -22,11 +22,13 @@ * those shapes. *

* Raw noise has no notion of "up" on its own, so a block is solid where {@code noise - falloff(y) > - * 0}. falloff climbs from -1 well below the world's centre height to +1 well above it, over - * {@link #TRANSITION_HEIGHT} blocks either side, which is what keeps the ground roughly where the - * profile expects instead of scattering solid blocks across the world's full height. That falloff is - * a property of the world's own bounds, not of any one region, so it is applied once after blending - * rather than per region. + * 0}. falloff climbs from -1 at the world's own floor to +1 at its own ceiling, centred on sea level + * (or the bounds' vertical midpoint where there is none), which is what keeps the ground roughly + * where the profile expects instead of scattering solid blocks across the world's full height. Scaled + * to the bounds themselves rather than a fixed number of blocks, so a shallow nether and a tall + * overworld each get a transition that actually reaches their own floor and ceiling instead of + * saturating partway through. That falloff is a property of the world's own bounds, not of any one + * region, so it is applied once after blending rather than per region. *

* {@link #regions}, when present, lets different parts of the world sample a different {@link NoiseField} * instead of one density field everywhere. Every column samples {@code regions.selector()} once, @@ -48,7 +50,6 @@ public final class DensityGenerator implements Generator private static final int HORIZONTAL_STEP = 4; private static final int HORIZONTAL_NODES = 16 / HORIZONTAL_STEP + 1; private static final int VERTICAL_STEP = 8; - private static final double TRANSITION_HEIGHT = 32.0D; /** How far away, on each axis, warp's offset probes sample the same field they're displacing. */ private static final int WARP_PROBE_OFFSET = 1013; @@ -89,7 +90,7 @@ public int surfaceHeight(final int worldX, final int worldZ) { final List contributions = this.contributions(worldX, worldZ); - return IntStream.iterate(this.bounds.maxY(), y -> y >= this.bounds.minY(), y -> y - 1) + return IntStream.iterate(this.bounds.maxY() - 1, y -> y >= this.bounds.minY(), y -> y - 1) .filter(y -> this.isSolid(blend(contributions, worldX, y, worldZ), y)) .findFirst() .orElse(this.bounds.minY()); @@ -240,7 +241,7 @@ private void writeColumn(final ChunkGenerator.ChunkData data, final int localZ, final Optional seaLevel) { - IntStream.rangeClosed(this.bounds.minY(), this.bounds.maxY()) + IntStream.range(this.bounds.minY(), this.bounds.maxY()) .forEach(y -> this.writeBlock(data, grid, nodeY, @@ -307,13 +308,23 @@ private boolean isSolid(final double noise, final int y) return noise - this.falloff(y) > 0.0D; } + /** + * The transition spans from centre down to {@link Bounds#minY()} on the low side and from centre + * up to {@link Bounds#maxY()} on the high side, each scaled independently, so falloff only + * actually saturates to -1/+1 at the world's own floor and ceiling rather than a fixed number of + * blocks away from centre regardless of how tall the world is. A world whose centre sits far off + * to one side (a shallow nether with sea level near its floor, say) gets a short transition on + * that side and a long one on the other, instead of the short side clipping solid ground off + * early and the long side leaving most of its height permanently void. + */ private double falloff(final int y) { final double centre = this.bounds.seaLevel() .map(Integer::doubleValue) .orElse((this.bounds.minY() + this.bounds.maxY()) / 2.0D); - final double slope = (y - centre) / TRANSITION_HEIGHT; + final double span = Math.max(1.0D, y < centre ? centre - this.bounds.minY() : this.bounds.maxY() - centre); + final double slope = (y - centre) / span; return Math.max(-1.0D, Math.min(1.0D, slope)); } diff --git a/src/main/java/me/totalfreedom/totalfreedommod/world/stage/FeaturePopulator.java b/src/main/java/me/totalfreedom/totalfreedommod/world/stage/FeaturePopulator.java index f7b5ebb2c..1eb9ef501 100644 --- a/src/main/java/me/totalfreedom/totalfreedommod/world/stage/FeaturePopulator.java +++ b/src/main/java/me/totalfreedom/totalfreedommod/world/stage/FeaturePopulator.java @@ -1,22 +1,29 @@ package me.totalfreedom.totalfreedommod.world.stage; import java.util.List; +import java.util.Optional; import java.util.Random; +import org.bukkit.block.Biome; import org.bukkit.generator.LimitedRegion; import me.totalfreedom.totalfreedommod.world.base.ChunkContext; import me.totalfreedom.totalfreedommod.world.base.Populator; import me.totalfreedom.totalfreedommod.world.profile.Anchor; +import me.totalfreedom.totalfreedommod.world.profile.BiomeDefinition; +import me.totalfreedom.totalfreedommod.world.profile.BiomeTarget; import me.totalfreedom.totalfreedommod.world.profile.FeatureSpec; +import me.totalfreedom.totalfreedommod.world.profile.Palette; import me.totalfreedom.totalfreedommod.world.stage.feature.FeatureRegistry; /** * Rolls each feature in the profile against the chunk and hands off the hits. This only decides * what gets placed and where; the features do the placing. *

- * TODO: roll() must resolve each column's band via {@code palette().resolveBand()}. A column whose - * band has its own {@code features()} list should roll only that list, not this.specs. + * A {@link BiomeTarget.Logical} band with its own {@code features()} list wholly replaces this.specs + * for any column that resolves to it, the same "replace, don't merge" contract {@link RuleDesigner} + * applies to surface rules. So this rolls this.specs once, then rolls every such band's own list once + * more, and each roll's attempts only land on columns whose resolved band actually owns that list. */ public final class FeaturePopulator implements Populator { @@ -33,23 +40,47 @@ public FeaturePopulator(final List specs, final FeatureRegistry reg public void populate(final ChunkContext context, final LimitedRegion data) { final Random random = context.getRandom(); + final Palette palette = context.getProfile().palette(); - this.specs.forEach(spec -> this.roll(context, data, random, spec)); + this.specs.forEach(spec -> this.roll(context, data, random, spec, Optional.empty())); + + palette.biomes() + .stream() + .map(Palette.BiomeBand::target) + .filter(BiomeTarget.Logical.class::isInstance) + .map(BiomeTarget.Logical.class::cast) + .map(BiomeTarget.Logical::definition) + .distinct() + .forEach(definition -> definition.features() + .ifPresent(overrides -> overrides.forEach(spec -> this.roll(context, + data, + random, + spec, + Optional.of(definition))))); } private void roll(final ChunkContext context, final LimitedRegion data, final Random random, - final FeatureSpec spec) + final FeatureSpec spec, + final Optional owner) { + final Palette palette = context.getProfile().palette(); + for (int attempt = 0; attempt < spec.rarity(); attempt++) { final int localX = random.nextInt(16); final int localZ = random.nextInt(16); final int worldX = context.worldX(localX); final int worldZ = context.worldZ(localZ); + final Optional band = palette.resolveBand(worldX, worldZ); + + if (!owner.equals(overrideOwner(band))) + continue; - if (!spec.appliesTo(context.getProfile().palette().resolveBiome(worldX, worldZ))) + final Biome biome = band.map(built -> built.target().display()).orElse(palette.fallback()); + + if (!spec.appliesTo(biome)) continue; final int y = spec.detail().anchor() == Anchor.SURFACE @@ -62,4 +93,14 @@ private void roll(final ChunkContext context, this.registry.place(context, data, spec.detail(), worldX, y, worldZ); } } + + /** The Logical band owning this column's feature list, if its band is one and it has its own list. */ + private static Optional overrideOwner(final Optional band) + { + return band.map(Palette.BiomeBand::target) + .filter(BiomeTarget.Logical.class::isInstance) + .map(BiomeTarget.Logical.class::cast) + .map(BiomeTarget.Logical::definition) + .filter(definition -> definition.features().isPresent()); + } } diff --git a/src/main/java/me/totalfreedom/totalfreedommod/world/stage/HeightmapGenerator.java b/src/main/java/me/totalfreedom/totalfreedommod/world/stage/HeightmapGenerator.java index 49c94fccc..893643d7d 100644 --- a/src/main/java/me/totalfreedom/totalfreedommod/world/stage/HeightmapGenerator.java +++ b/src/main/java/me/totalfreedom/totalfreedommod/world/stage/HeightmapGenerator.java @@ -282,8 +282,9 @@ private void writeColumn(final ChunkGenerator.ChunkData data, this.materials.fluidBlock()); } + /** {@link Bounds#maxY()} is one past the world's own top row, so the tallest column clamps to maxY - 1. */ private int clamp(final int height) { - return Math.max(this.bounds.minY(), Math.min(this.bounds.maxY(), height)); + return Math.max(this.bounds.minY(), Math.min(this.bounds.maxY() - 1, height)); } } diff --git a/src/main/java/me/totalfreedom/totalfreedommod/world/stage/LayerDesigner.java b/src/main/java/me/totalfreedom/totalfreedommod/world/stage/LayerDesigner.java index 7adb7ff30..d99fb3dc4 100644 --- a/src/main/java/me/totalfreedom/totalfreedommod/world/stage/LayerDesigner.java +++ b/src/main/java/me/totalfreedom/totalfreedommod/world/stage/LayerDesigner.java @@ -40,7 +40,7 @@ public void bedrock(final ChunkContext context, final ChunkGenerator.ChunkData d data.setRegion(0, minY, 0, 16, minY + 1, 16, this.materials.bedrockBlock()); if (this.materials.bedrock() == BedrockMode.FLOOR_AND_ROOF) - data.setRegion(0, maxY, 0, 16, maxY + 1, 16, this.materials.bedrockBlock()); + data.setRegion(0, maxY - 1, 0, 16, maxY, 16, this.materials.bedrockBlock()); } } diff --git a/src/main/java/me/totalfreedom/totalfreedommod/world/stage/RuleDesigner.java b/src/main/java/me/totalfreedom/totalfreedommod/world/stage/RuleDesigner.java index 2f761e293..ffb94e234 100644 --- a/src/main/java/me/totalfreedom/totalfreedommod/world/stage/RuleDesigner.java +++ b/src/main/java/me/totalfreedom/totalfreedommod/world/stage/RuleDesigner.java @@ -64,7 +64,7 @@ public void bedrock(final ChunkContext context, final ChunkGenerator.ChunkData d data.setRegion(0, minY, 0, 16, minY + 1, 16, this.materials.bedrockBlock()); if (this.materials.bedrock() == BedrockMode.FLOOR_AND_ROOF) - data.setRegion(0, maxY, 0, 16, maxY + 1, 16, this.materials.bedrockBlock()); + data.setRegion(0, maxY - 1, 0, 16, maxY, 16, this.materials.bedrockBlock()); } private void surfaceColumn(final ChunkContext context, final ChunkGenerator.ChunkData data, final int localX, final int localZ) diff --git a/src/main/java/me/totalfreedom/totalfreedommod/world/stage/feature/LakeFeature.java b/src/main/java/me/totalfreedom/totalfreedommod/world/stage/feature/LakeFeature.java index 6b2144c65..d3daedf81 100644 --- a/src/main/java/me/totalfreedom/totalfreedommod/world/stage/feature/LakeFeature.java +++ b/src/main/java/me/totalfreedom/totalfreedommod/world/stage/feature/LakeFeature.java @@ -17,6 +17,10 @@ * Shaped as a squashed sphere, wider than it is deep, because a round hole reads as a crater. Only * the lower half is filled; the upper half is cleared to air, which is what gives the water a bank * instead of a lid. + *

+ * Skips the world's own bedrock block wherever it finds it, floor or roof, rather than clearing + * straight through it. A lake spec placed near either edge of the world's bounds still carves its + * bowl; it just cannot open a hole into the void the way an unguarded clear would. */ public final class LakeFeature implements Feature { @@ -33,6 +37,7 @@ public void place(final ChunkContext context, { final int radius = detail.radius(); final int depth = Math.max(1, (int) Math.round(radius / SQUASH)); + final Material bedrock = context.getProfile().palette().materials().bedrockBlock().getMaterial(); for (int offsetX = -radius; offsetX <= radius; offsetX++) { @@ -54,6 +59,9 @@ public void place(final ChunkContext context, if (!region.isInRegion(blockX, blockY, blockZ)) continue; + if (region.getType(blockX, blockY, blockZ) == bedrock) + continue; + // Fluid in the bottom half, air above it. Filling the whole bowl would seal the // lake over and leave a block of water floating at head height. if (offsetY <= 0) diff --git a/src/main/resources/worlds/overworld-template.json b/src/main/resources/worlds/overworld-template.json index d8dd0ba88..9343ccbf2 100644 --- a/src/main/resources/worlds/overworld-template.json +++ b/src/main/resources/worlds/overworld-template.json @@ -23,21 +23,21 @@ [ 1.00, 180] ] }, - "river": { - "noise": { - "type": "simplex", - "octaves": 2, - "frequency": 0.0016, - "persistence": 0.5, - "lacunarity": 2.0, - "ridged": false - }, - "threshold": 0.03, - "depth": 5, - "bedBlock": "gravel" - }, "warp": 0.015 }, + "river": { + "noise": { + "type": "simplex", + "octaves": 2, + "frequency": 0.0016, + "persistence": 0.5, + "lacunarity": 2.0, + "ridged": false + }, + "threshold": 0.03, + "depth": 5, + "bedBlock": "gravel" + }, "caves": { "noise": { "type": "simplex", From 0cd822dbc402c48b82a7a1b7e08f3d36a25c9947 Mon Sep 17 00:00:00 2001 From: Paldiu Date: Fri, 21 Aug 2026 13:57:30 -0500 Subject: [PATCH 29/32] Kirking Upon It --- .../totalfreedommod/TotalFreedomMod.java | 16 -- .../totalfreedommod/admin/AdminList.java | 27 +- .../blocking/BlockBlocker.java | 2 +- .../blocking/EventBlocker.java | 18 +- .../blocking/spawner/SpawnerValidator.java | 2 +- .../cmd/Command_adminworld.java | 120 -------- .../cmd/Command_flatlands.java | 23 -- .../totalfreedommod/cmd/Command_jumppads.java | 2 +- .../cmd/Command_manageworld.java | 218 +++++++++++++++ .../totalfreedommod/cmd/Command_ro.java | 4 +- .../cmd/Command_wipeflatlands.java | 25 -- .../totalfreedommod/cmd/Command_worldtp.java | 25 ++ .../totalfreedommod/config/ConfigEntry.java | 12 - .../ssh/AttributedConsoleSender.java | 7 +- .../totalfreedommod/title/TitleManager.java | 6 +- .../totalfreedommod/world/AdminWorld.java | 260 ------------------ .../world/CleanroomChunkGenerator.java | 249 ----------------- .../totalfreedommod/world/Flatlands.java | 76 ----- .../totalfreedommod/world/GeneratedWorld.java | 54 ---- .../world/profile/LayerStack.java | 2 +- .../world/profile/ProfileParser.java | 45 ++- src/main/resources/config.yml | 27 -- src/main/resources/ranks.json | 2 +- .../resources/worlds/adminworld-template.json | 45 +++ .../resources/worlds/flatlands-template.json | 8 + 25 files changed, 371 insertions(+), 904 deletions(-) delete mode 100644 src/main/java/me/totalfreedom/totalfreedommod/cmd/Command_adminworld.java delete mode 100644 src/main/java/me/totalfreedom/totalfreedommod/cmd/Command_flatlands.java create mode 100644 src/main/java/me/totalfreedom/totalfreedommod/cmd/Command_manageworld.java delete mode 100644 src/main/java/me/totalfreedom/totalfreedommod/cmd/Command_wipeflatlands.java create mode 100644 src/main/java/me/totalfreedom/totalfreedommod/cmd/Command_worldtp.java delete mode 100644 src/main/java/me/totalfreedom/totalfreedommod/world/AdminWorld.java delete mode 100644 src/main/java/me/totalfreedom/totalfreedommod/world/CleanroomChunkGenerator.java delete mode 100644 src/main/java/me/totalfreedom/totalfreedommod/world/Flatlands.java delete mode 100644 src/main/java/me/totalfreedom/totalfreedommod/world/GeneratedWorld.java create mode 100644 src/main/resources/worlds/adminworld-template.json diff --git a/src/main/java/me/totalfreedom/totalfreedommod/TotalFreedomMod.java b/src/main/java/me/totalfreedom/totalfreedommod/TotalFreedomMod.java index e771938f1..9e8c863ec 100644 --- a/src/main/java/me/totalfreedom/totalfreedommod/TotalFreedomMod.java +++ b/src/main/java/me/totalfreedom/totalfreedommod/TotalFreedomMod.java @@ -46,7 +46,6 @@ import me.totalfreedom.totalfreedommod.util.FLog; import me.totalfreedom.totalfreedommod.util.FUtil; import me.totalfreedom.totalfreedommod.util.MethodTimer; -import me.totalfreedom.totalfreedommod.world.CleanroomChunkGenerator; import me.totalfreedom.totalfreedommod.world.GenerationService; import me.totalfreedom.totalfreedommod.world.WorldManager; @@ -292,21 +291,6 @@ public void onEnable() @Override public ChunkGenerator getDefaultWorldGenerator(String worldName, String id) { - if ("flatlands".equals(worldName)) - { - String params; - if (config != null) - { - params = ConfigEntry.FLATLANDS_GENERATE_PARAMS.getString(); - } - else - { - saveDefaultConfig(); - params = getConfig().getString("flatlands.generate_params", "16|stone|32|dirt|1|grass_block"); - } - return new CleanroomChunkGenerator(params); - } - if (gs != null) { final Optional generator = gs.generatorFor(worldName); diff --git a/src/main/java/me/totalfreedom/totalfreedommod/admin/AdminList.java b/src/main/java/me/totalfreedom/totalfreedommod/admin/AdminList.java index 2b99c7a49..e69c311eb 100644 --- a/src/main/java/me/totalfreedom/totalfreedommod/admin/AdminList.java +++ b/src/main/java/me/totalfreedom/totalfreedommod/admin/AdminList.java @@ -7,7 +7,6 @@ import java.lang.reflect.Type; import java.nio.charset.StandardCharsets; import java.sql.SQLException; -import java.time.Duration; import java.util.ArrayList; import java.util.Date; import java.util.List; @@ -287,27 +286,19 @@ public Admin getAdmin(CommandSender sender) Admin admin = getEntryByName(player.getName()); // Admin by name - if (admin != null) + if (admin != null && (Bukkit.getOnlineMode() || admin.getIps().contains(ip))) { - // Check if we're in online mode, - // Or the players IP is in the admin entry - if (Bukkit.getOnlineMode() || admin.getIps().contains(ip)) + if (!admin.getIps().contains(ip)) { - if (!admin.getIps().contains(ip)) - { - // Add the new IP if we have to - admin.addIp(ip); - ipTable.put(ip, admin); - saveAdminAsync(admin); - } - return admin; + // Add the new IP if we have to + admin.addIp(ip); + ipTable.put(ip, admin); + saveAdminAsync(admin); } - // Impostor: the name is ours but the IP is not. Fall through to - // the IP lookup, which will not match this entry. + return admin; } - // Admin by ip admin = getEntryByIp(ip); if (admin != null) { @@ -500,9 +491,9 @@ public void updateTables() .filter(this::isAdmin) .forEach(onlineAdminPlayers::add); - if (plugin.wm != null && plugin.wm.adminworld != null) + if (plugin.wm != null) { - plugin.wm.adminworld.wipeAccessCache(); + plugin.wm.invalidateAccessCaches(); } } diff --git a/src/main/java/me/totalfreedom/totalfreedommod/blocking/BlockBlocker.java b/src/main/java/me/totalfreedom/totalfreedommod/blocking/BlockBlocker.java index 66b3e637e..911c17a1f 100644 --- a/src/main/java/me/totalfreedom/totalfreedommod/blocking/BlockBlocker.java +++ b/src/main/java/me/totalfreedom/totalfreedommod/blocking/BlockBlocker.java @@ -127,7 +127,7 @@ public void onBlockPlace(BlockPlaceEvent event) } case SPAWNER: { - if (ConfigEntry.DISABLE_SPAWNER_PLACE.getBoolean()) + if (plugin.gs.blocking(event.getBlock().getWorld().getName()).spawnerPlace()) { player.sendMessage(Component.text("Spawners are currently disabled.", NamedTextColor.GRAY)); diff --git a/src/main/java/me/totalfreedom/totalfreedommod/blocking/EventBlocker.java b/src/main/java/me/totalfreedom/totalfreedommod/blocking/EventBlocker.java index d10bacef1..cbf5fd3dc 100644 --- a/src/main/java/me/totalfreedom/totalfreedommod/blocking/EventBlocker.java +++ b/src/main/java/me/totalfreedom/totalfreedommod/blocking/EventBlocker.java @@ -1,5 +1,7 @@ package me.totalfreedom.totalfreedommod.blocking; +import java.util.Optional; + import io.papermc.paper.event.block.BlockPreDispenseEvent; import org.bukkit.GameMode; import org.bukkit.Material; @@ -229,7 +231,7 @@ public void onLeavesDecay(LeavesDecayEvent event) @EventHandler(priority = EventPriority.HIGH) public void onSpawnerSpawn(SpawnerSpawnEvent event) { - if (ConfigEntry.DISABLE_SPAWNERS.getBoolean()) + if (plugin.gs.blocking(event.getEntity().getWorld().getName()).spawners()) { event.setCancelled(true); } @@ -238,7 +240,7 @@ public void onSpawnerSpawn(SpawnerSpawnEvent event) @EventHandler(priority = EventPriority.HIGH) public void onTrialSpawnerSpawn(TrialSpawnerSpawnEvent event) { - if (ConfigEntry.DISABLE_SPAWNERS.getBoolean()) + if (plugin.gs.blocking(event.getEntity().getWorld().getName()).spawners()) { event.setCancelled(true); } @@ -247,7 +249,7 @@ public void onTrialSpawnerSpawn(TrialSpawnerSpawnEvent event) @EventHandler(priority = EventPriority.HIGH) public void onPortalCreate(PortalCreateEvent event) { - if (ConfigEntry.DISABLE_PORTAL_CREATE.getBoolean()) + if (plugin.gs.blocking(event.getWorld().getName()).portalCreate()) { event.setCancelled(true); } @@ -256,7 +258,7 @@ public void onPortalCreate(PortalCreateEvent event) @EventHandler(priority = EventPriority.HIGH) public void onPistonExtend(BlockPistonExtendEvent event) { - if (ConfigEntry.DISABLE_PISTONS.getBoolean() || redstoneBlocked()) + if (plugin.gs.blocking(event.getBlock().getWorld().getName()).pistons() || redstoneBlocked()) { event.setCancelled(true); } @@ -265,7 +267,7 @@ public void onPistonExtend(BlockPistonExtendEvent event) @EventHandler(priority = EventPriority.HIGH) public void onPistonRetract(BlockPistonRetractEvent event) { - if (ConfigEntry.DISABLE_PISTONS.getBoolean() || redstoneBlocked()) + if (plugin.gs.blocking(event.getBlock().getWorld().getName()).pistons() || redstoneBlocked()) { event.setCancelled(true); } @@ -274,7 +276,8 @@ public void onPistonRetract(BlockPistonRetractEvent event) @EventHandler(priority = EventPriority.NORMAL) public void onEntitySpawn(EntitySpawnEvent event) { - if (!ConfigEntry.DISABLE_ENTITY_SPAM.getBoolean()) + final Optional max = plugin.gs.blocking(event.getLocation().getWorld().getName()).entitySpamMax(); + if (max.isEmpty()) { return; } @@ -291,8 +294,7 @@ public void onEntitySpawn(EntitySpawnEvent event) return; } - final int max = ConfigEntry.DISABLE_ENTITY_SPAM_MAX.getInteger(); - if (max > 0 && event.getLocation().getWorld().getEntities().size() > max) + if (max.get() > 0 && event.getLocation().getWorld().getEntities().size() > max.get()) { event.setCancelled(true); } diff --git a/src/main/java/me/totalfreedom/totalfreedommod/blocking/spawner/SpawnerValidator.java b/src/main/java/me/totalfreedom/totalfreedommod/blocking/spawner/SpawnerValidator.java index e95c6a998..cf69f5dc1 100644 --- a/src/main/java/me/totalfreedom/totalfreedommod/blocking/spawner/SpawnerValidator.java +++ b/src/main/java/me/totalfreedom/totalfreedommod/blocking/spawner/SpawnerValidator.java @@ -148,7 +148,7 @@ private void blockWindChargeFromSpawner(SpawnerSpawnEvent event) private void blockHangingFromSpawner(EntitySpawnEvent event, String reason, String label) { - if (Boolean.TRUE.equals(ConfigEntry.DISABLE_SPAWNERS.getBoolean())) + if (plugin.gs.blocking(event.getEntity().getWorld().getName()).spawners()) { return; } diff --git a/src/main/java/me/totalfreedom/totalfreedommod/cmd/Command_adminworld.java b/src/main/java/me/totalfreedom/totalfreedommod/cmd/Command_adminworld.java deleted file mode 100644 index 876facc3a..000000000 --- a/src/main/java/me/totalfreedom/totalfreedommod/cmd/Command_adminworld.java +++ /dev/null @@ -1,120 +0,0 @@ -package me.totalfreedom.totalfreedommod.cmd; - -import org.bukkit.World; -import org.bukkit.command.CommandSender; -import org.bukkit.entity.Player; - -import net.kyori.adventure.text.minimessage.tag.resolver.Placeholder; - -import me.totalfreedom.totalfreedommod.cmd.internal.annotation.*; -import me.totalfreedom.totalfreedommod.world.WorldTime; -import me.totalfreedom.totalfreedommod.world.WorldWeather; - -@Command(name = "adminworld", description = "Go to, or manage the AdminWorld.", usage = "/adminworld [guest ]", aliases = {"aw"}) -@Permission(permission = "tfm.world.adminworld") // default permission is op, and default source is BOTH -public class Command_adminworld extends FCommand -{ - - @Callback - @Permission(source = SourceType.ONLY_IN_GAME, permission = "tfm.world.adminworld") - public void moveToWorld(Player player) - { - World adminWorld = null; - try - { - adminWorld = plugin().wm.adminworld.getWorld(); - } - catch (Exception ignored) {} - - if (adminWorld == null || player.getWorld().getUID().equals(adminWorld.getUID())) - { - msg(player, "Going to the main world."); - player.teleport(server().getWorlds().get(0).getSpawnLocation().clone()); - } - else if (plugin().wm.adminworld.canAccessWorld(player)) - { - msg(player, "Going to the AdminWorld."); - plugin().wm.adminworld.sendToWorld(player); - } - else - { - msg(player, "You don't have permission to access the AdminWorld."); - } - } - - @Callback - @Subcommand("guest add") - @Permission(source = SourceType.ONLY_IN_GAME, permission = "tfm.world.adminworld.manage") - public void addGuest(Player sender, Player target) - { - if (plugin().wm.adminworld.addGuest(target, sender)) - { - adminAction(sender, "AdminWorld guest added: ", - Placeholder.unparsed("target", target.getName())); - return; - } - msg(sender, "Could not add player to guest list."); - } - - @Callback - @Subcommand("guest list") - @Permission(permission = "tfm.world.adminworld") - public void listGuests(CommandSender sender) - { - if (plugin().wm.adminworld.hasGuests()) - { - // TODO: Probably not use this weird guestListToString - msg(sender, "AdminWorld guest list: ", - Placeholder.parsed("list", plugin().wm.adminworld.guestListToString())); - return; - } - - msg(sender, "There are no AdminWorld guests."); - } - - @Callback - @Subcommand("guest remove") - @Permission(permission = "tfm.world.adminworld.manage") - public void removeGuest(CommandSender sender, Player target) - { - if (plugin().wm.adminworld.removeGuest(target)) - { - adminAction(sender, "AdminWorld guest removed: ", - Placeholder.unparsed("target", target.getName())); - } - else - { - msg(sender, "Can't find guest entry for .", - Placeholder.unparsed("target", target.getName())); - } - } - - @Callback - @Subcommand("guest purge") - @Permission(permission = "tfm.world.adminworld.manage") - public void purgeGuests(CommandSender sender) - { - plugin().wm.adminworld.purgeGuestList(); - adminAction(sender, "AdminWorld guest list purged."); - } - - @Callback - @Subcommand("time") - @Permission(permission = "tfm.world.adminworld.manage") - public void setTime(CommandSender sender, WorldTime time) - { - plugin().wm.adminworld.setTimeOfDay(time); - msg(sender, "AdminWorld time set to

- * Keyed under the {@code minecraft} namespace so the level/folder name Paper derives from the key - * matches {@link GenerationProfile#name()} exactly, keeping it a plain lookup for - * {@link WorldManager#gotoWorld} and {@link GenerationService#profile}. - *

- * Sets no spawn location itself. {@link ProfileChunkGenerator#getFixedSpawnLocation} already runs - * the same deterministic search, and Bukkit applies whatever it returns to the world during - * {@link Bukkit#createWorld}, so searching again here would only repeat that same scan for the same - * answer. - */ -public class GeneratedWorld extends CustomWorld -{ - private final GenerationProfile profile; - - public GeneratedWorld(final TotalFreedomMod plugin, final GenerationProfile profile, final String displayName) - { - super(plugin, profile.name(), displayName); - - this.profile = profile; - } - - @Override - protected World generateWorld() - { - final WorldSettings settings = this.profile.world(); - final ProfileChunkGenerator generator = new ProfileChunkGenerator(this.profile); - - final WorldCreator worldCreator = WorldCreator.ofKey(NamespacedKey.minecraft(getName())); - worldCreator.environment(settings.environment()); - worldCreator.generateStructures(settings.generateStructures()); - worldCreator.generator(generator); - settings.seed().ifPresent(seed -> worldCreator.seed(seed.longValue())); - - return Bukkit.getServer().createWorld(worldCreator); - } - - public GenerationProfile getProfile() - { - return this.profile; - } -} diff --git a/src/main/java/me/totalfreedom/totalfreedommod/world/profile/LayerStack.java b/src/main/java/me/totalfreedom/totalfreedommod/world/profile/LayerStack.java index 73323f862..34296945b 100644 --- a/src/main/java/me/totalfreedom/totalfreedommod/world/profile/LayerStack.java +++ b/src/main/java/me/totalfreedom/totalfreedommod/world/profile/LayerStack.java @@ -10,7 +10,7 @@ /** * Block layers for a flat world, bottom to top, starting at the world's minY. *

- * Takes the same syntax as flatlands.generate_params in the config. + * Takes the same syntax the old CleanroomGenerator flat-layer strings used. *

* A class rather than a record because the layers are arrays. A record would hand out its backing * arrays through the generated accessors, and anything holding the profile could then rewrite a diff --git a/src/main/java/me/totalfreedom/totalfreedommod/world/profile/ProfileParser.java b/src/main/java/me/totalfreedom/totalfreedommod/world/profile/ProfileParser.java index 02d1c5f72..77c7a8ebb 100644 --- a/src/main/java/me/totalfreedom/totalfreedommod/world/profile/ProfileParser.java +++ b/src/main/java/me/totalfreedom/totalfreedommod/world/profile/ProfileParser.java @@ -946,12 +946,48 @@ private static Optional parseWorldSettings(final JsonObject root, final Optional generateStructures = requireBoolean(node.get(), "generateStructures", path, errors); final Optional keepSpawnLoaded = requireBoolean(node.get(), "keepSpawnLoaded", path, errors); final Optional seed = optionalLong(node.get(), "seed", path, errors); + final boolean hasAccess = hasKey(node.get(), "access"); + final Optional access = hasAccess ? parseAccess(node.get(), path, errors) : Optional.empty(); + final boolean roExempt = optionalBoolean(node.get(), "roExempt", path, errors).orElse(false); + final boolean weatherDisabled = optionalBoolean(node.get(), "weatherDisabled", path, errors).orElse(false); + final boolean hasBlocking = hasKey(node.get(), "blocking"); + final Optional blocking = hasBlocking ? parseBlocking(node.get(), path, errors) : Optional.of(WorldSettings.Blocking.NONE); final Optional vanilla = parseVanillaFlags(node.get(), path, errors); - if (environment.isEmpty() || generateStructures.isEmpty() || keepSpawnLoaded.isEmpty() || vanilla.isEmpty()) + if (environment.isEmpty() || generateStructures.isEmpty() || keepSpawnLoaded.isEmpty() + || (hasAccess && access.isEmpty()) || (hasBlocking && blocking.isEmpty()) || vanilla.isEmpty()) return Optional.empty(); - return Optional.of(new WorldSettings(environment.get(), generateStructures.get(), keepSpawnLoaded.get(), seed, vanilla.get())); + return Optional.of(new WorldSettings(environment.get(), generateStructures.get(), keepSpawnLoaded.get(), seed, access, + roExempt, weatherDisabled, blocking.get(), vanilla.get())); + } + + private static Optional parseAccess(final JsonObject worldNode, final String parentPath, final List errors) + { + final Optional node = requireObject(worldNode, "access", parentPath, errors); + if (node.isEmpty()) + return Optional.empty(); + + final String path = childPath(parentPath, "access"); + final Optional permission = requireString(node.get(), "permission", path, errors); + + return permission.map(WorldSettings.Access::new); + } + + private static Optional parseBlocking(final JsonObject worldNode, final String parentPath, final List errors) + { + final Optional node = requireObject(worldNode, "blocking", parentPath, errors); + if (node.isEmpty()) + return Optional.empty(); + + final String path = childPath(parentPath, "blocking"); + final boolean spawners = optionalBoolean(node.get(), "spawners", path, errors).orElse(false); + final boolean spawnerPlace = optionalBoolean(node.get(), "spawnerPlace", path, errors).orElse(false); + final boolean portalCreate = optionalBoolean(node.get(), "portalCreate", path, errors).orElse(false); + final boolean pistons = optionalBoolean(node.get(), "pistons", path, errors).orElse(false); + final Optional entitySpamMax = optionalInt(node.get(), "entitySpamMax", path, errors); + + return Optional.of(new WorldSettings.Blocking(spawners, spawnerPlace, portalCreate, pistons, entitySpamMax)); } private static Optional parseVanillaFlags(final JsonObject worldNode, final String parentPath, @@ -1121,6 +1157,11 @@ private static Optional optionalLong(final JsonObject obj, final String ke return presentField(obj, key, path, false, errors).flatMap(e -> asLong(e, key, path, errors)); } + private static Optional optionalBoolean(final JsonObject obj, final String key, final String path, final List errors) + { + return presentField(obj, key, path, false, errors).flatMap(e -> asBoolean(e, key, path, errors)); + } + private static Optional requireDouble(final JsonObject obj, final String key, final String path, final List errors) { return presentField(obj, key, path, true, errors).flatMap(e -> asDouble(e, key, path, errors)); diff --git a/src/main/resources/config.yml b/src/main/resources/config.yml index cb3d2eb25..2ad43d3d5 100644 --- a/src/main/resources/config.yml +++ b/src/main/resources/config.yml @@ -460,17 +460,6 @@ autoeject: freecam_trigger_count: 10 explosive_radius: 4.0 -# Disable certain events -disable: - night: true - weather: true - spawners: false - spawner_place: false - portal_create: false - pistons: false - entity_spam: false - entity_spam_max: 750 - # Enable misc. features landmines_enabled: false mp44_enabled: false @@ -490,22 +479,6 @@ moblimiter: - "minecraft:giant" - "minecraft:bat" -# Flatlands -flatlands: - generate: true - - # Flatlands generation parameters - Uses CleanroomGenerator v1.2.1 syntax - # Format: [prefix]height|block|height|block|... - # Prefixes: . = no bedrock, ^ = start at y=-64 (1.18+ deep worlds) - # Examples: - # "64|stone" - 1 bedrock + 64 stone - # ".64|stone" - 64 stone, no bedrock - # "^64|stone" - starts at y=-64 - # "." - void world - # Block names support modern format like "minecraft:grass_block[snowy=true]" - # Legacy comma-separated format is still supported for backward compatibility - generate_params: 16|stone|32|dirt|1|grass_block - # Admin-Only Mode admin_only_mode: false diff --git a/src/main/resources/ranks.json b/src/main/resources/ranks.json index 49026dc6a..0ebb547e3 100644 --- a/src/main/resources/ranks.json +++ b/src/main/resources/ranks.json @@ -32,7 +32,7 @@ "tfm.player.spawn", "tfm.player.list", "tfm.player.joinmessages", - "tfm.world.flatlands", + "tfm.world.tp", "tfm.server.info" ] }, diff --git a/src/main/resources/worlds/adminworld-template.json b/src/main/resources/worlds/adminworld-template.json new file mode 100644 index 000000000..86a18f99e --- /dev/null +++ b/src/main/resources/worlds/adminworld-template.json @@ -0,0 +1,45 @@ +{ + "shape": { + "mode": "flat", + "bounds": { "minY": 0, "maxY": 320 }, + "layers": "1|bedrock|59|stone|3|dirt|1|grass_block" + }, + + "palette": { + "materials": { + "defaultBlock": "stone", + "fluidBlock": "water", + "bedrockBlock": "bedrock", + "bedrock": "FLOOR" + }, + "surface": [ + { "depthFrom": 0, "block": "grass_block" } + ], + "climate": { + "temperature": { "type": "simplex", "octaves": 1, "frequency": 0.001, "persistence": 0.5, "lacunarity": 2.0, "ridged": false }, + "humidity": { "type": "simplex", "octaves": 1, "frequency": 0.001, "persistence": 0.5, "lacunarity": 2.0, "ridged": false }, + "scale": 1.0 + }, + "fallback": "plains" + }, + + "features": [], + + "world": { + "environment": "normal", + "generateStructures": false, + "keepSpawnLoaded": false, + "access": { + "permission": "tfm.world.adminworld" + }, + "roExempt": true, + "weatherDisabled": true, + "vanilla": { + "surface": false, + "caves": false, + "decorations": false, + "mobs": false, + "structures": false + } + } +} diff --git a/src/main/resources/worlds/flatlands-template.json b/src/main/resources/worlds/flatlands-template.json index 85fa5b36c..633a5ff61 100644 --- a/src/main/resources/worlds/flatlands-template.json +++ b/src/main/resources/worlds/flatlands-template.json @@ -29,6 +29,14 @@ "environment": "normal", "generateStructures": false, "keepSpawnLoaded": false, + "weatherDisabled": true, + "blocking": { + "spawners": false, + "spawnerPlace": false, + "portalCreate": false, + "pistons": false, + "entitySpamMax": 750 + }, "vanilla": { "surface": false, "caves": false, From a628912651866b0d8fc242869f32292abf9b9ef4 Mon Sep 17 00:00:00 2001 From: Paldiu Date: Fri, 21 Aug 2026 14:33:45 -0500 Subject: [PATCH 30/32] GET ON!!! --- .../blocking/EventBlocker.java | 15 + .../totalfreedommod/world/CustomWorld.java | 373 +++++++++++++++++- .../world/GenerationService.java | 87 +++- .../totalfreedommod/world/WorldManager.java | 126 ++++-- .../world/profile/ProfileParser.java | 8 +- .../world/profile/WorldSettings.java | 41 +- .../resources/worlds/adminworld-template.json | 4 +- .../resources/worlds/flatlands-template.json | 2 +- .../resources/worlds/overworld-template.json | 1 - 9 files changed, 605 insertions(+), 52 deletions(-) diff --git a/src/main/java/me/totalfreedom/totalfreedommod/blocking/EventBlocker.java b/src/main/java/me/totalfreedom/totalfreedommod/blocking/EventBlocker.java index cbf5fd3dc..ef8924eaa 100644 --- a/src/main/java/me/totalfreedom/totalfreedommod/blocking/EventBlocker.java +++ b/src/main/java/me/totalfreedom/totalfreedommod/blocking/EventBlocker.java @@ -246,6 +246,21 @@ public void onTrialSpawnerSpawn(TrialSpawnerSpawnEvent event) } } + /** Reads {@code blocking.monsters} live on every natural spawn attempt rather than pushing it onto the world once at creation, so editing a profile takes effect on the very next spawn with nothing to reload. */ + @EventHandler(priority = EventPriority.HIGH) + public void onCreatureSpawn(CreatureSpawnEvent event) + { + if (event.getSpawnReason() != CreatureSpawnEvent.SpawnReason.NATURAL) + { + return; + } + + if (event.getEntity() instanceof Monster && plugin.gs.blocking(event.getEntity().getWorld().getName()).monsters()) + { + event.setCancelled(true); + } + } + @EventHandler(priority = EventPriority.HIGH) public void onPortalCreate(PortalCreateEvent event) { diff --git a/src/main/java/me/totalfreedom/totalfreedommod/world/CustomWorld.java b/src/main/java/me/totalfreedom/totalfreedommod/world/CustomWorld.java index 5283c6799..1f824080f 100644 --- a/src/main/java/me/totalfreedom/totalfreedommod/world/CustomWorld.java +++ b/src/main/java/me/totalfreedom/totalfreedommod/world/CustomWorld.java @@ -1,35 +1,69 @@ package me.totalfreedom.totalfreedommod.world; +import java.io.File; +import java.io.IOException; +import java.nio.file.Files; +import java.util.ArrayList; +import java.util.HashMap; +import java.util.Iterator; +import java.util.List; +import java.util.Map; +import java.util.Map.Entry; +import java.util.Optional; + import org.bukkit.Bukkit; import org.bukkit.Material; +import org.bukkit.NamespacedKey; import org.bukkit.World; +import org.bukkit.WorldCreator; import org.bukkit.block.Block; import org.bukkit.block.BlockFace; import org.bukkit.block.Sign; import org.bukkit.block.sign.Side; import org.bukkit.block.sign.SignSide; +import org.bukkit.command.CommandSender; import org.bukkit.entity.Player; +import org.bukkit.event.player.PlayerMoveEvent; import net.kyori.adventure.text.Component; import net.kyori.adventure.text.format.NamedTextColor; +import org.apache.commons.io.FileUtils; + import me.totalfreedom.totalfreedommod.TotalFreedomMod; import me.totalfreedom.totalfreedommod.framework.PluginComponent; import me.totalfreedom.totalfreedommod.util.FLog; +import me.totalfreedom.totalfreedommod.world.adapter.ProfileChunkGenerator; +import me.totalfreedom.totalfreedommod.world.profile.WorldSettings; /** - * Base for a world TFM creates and manages itself. Caches the {@link World} once generated and - * rebuilds it if Bukkit ever drops it from {@link Bukkit#getWorlds()}. + * A world TFM creates and manages itself, generated from {@code worlds/.json} as a + * profile through {@link GenerationService}. Caches the {@link World} once generated and rebuilds it + * if Bukkit ever drops it from {@link Bukkit#getWorlds()}. + *

+ * A world whose profile declares {@link WorldSettings.Access} gets a guest list, permission-gated + * entry, and its own weather/time controls on top. + * A world without one is open to everyone and none of that state does anything. *

- * A welcome sign is planted at whatever the world's own spawn location turns out to be, so a - * subclass is free to pick that location however it likes. + * A welcome sign is planted at whatever the world's own spawn location turns out to be. */ -public abstract class CustomWorld extends PluginComponent +public final class CustomWorld extends PluginComponent { + private static final long ACCESS_CACHE_CLEAR_FREQUENCY = 30L * 1000L; // 30 seconds, milliseconds + private static final long TP_COOLDOWN_TIME = 500L; // 0.5 seconds, milliseconds + private static final String PROFILE_MARKER_FILENAME = "tfm-profile.json"; + private final String name; private final String displayName; // private World world; + // + private final Map teleportCooldown = new HashMap<>(); + private final Map accessCache = new HashMap<>(); + private final Map guestList = new HashMap<>(); // Guest, Supervisor + private Long accessCacheLastCleared = null; + private WorldWeather weather = WorldWeather.OFF; + private WorldTime time = WorldTime.INHERIT; public CustomWorld(TotalFreedomMod plugin, String name, String displayName) { @@ -43,17 +77,17 @@ public CustomWorld(TotalFreedomMod plugin, String name) this(plugin, name, name); } - public final String getName() + public String getName() { return this.name; } - public final String getDisplayName() + public String getDisplayName() { return this.displayName; } - public final World getWorld() + public World getWorld() { if (world != null && Bukkit.getWorlds().contains(world)) { @@ -74,6 +108,135 @@ public final World getWorld() return world; } + /** + * Wipes this world's folder first if flagged, archives it if its profile changed since it was + * last generated, then builds it from its own generation profile. + */ + private World generateWorld() + { + wipeIfFlagged(); + + final Optional profile = plugin.gs.profile(this.name); + + if (profile.isEmpty()) + { + FLog.severe("No generation profile for world \"" + name + "\"; it will not be created."); + return null; + } + + final Optional fingerprint = plugin.gs.generationFingerprint(this.name); + fingerprint.ifPresent(this::archiveIfProfileChanged); + + final WorldSettings settings = profile.get().world(); + final WorldCreator worldCreator = WorldCreator.ofKey(NamespacedKey.minecraft(this.name)); + worldCreator.environment(settings.environment()); + worldCreator.generateStructures(settings.generateStructures()); + worldCreator.generator(new ProfileChunkGenerator(profile.get())); + settings.seed().ifPresent(seed -> worldCreator.seed(seed.longValue())); + + final World createdWorld = Bukkit.getServer().createWorld(worldCreator); + + if (createdWorld == null) + return null; + + fingerprint.ifPresent(this::writeProfileMarker); + + return createdWorld; + } + + /** + * A world's generation settings (shape/palette/features/environment/generateStructures/seed/ + * vanilla) are treated as constant once generated; a behavioral flag like weatherDisabled or + * blocking.monsters is not, since those are read live wherever they matter and can change + * freely. If this world already has data on disk carrying a marker from a different generation + * fingerprint than {@code currentFingerprint}, moves that data aside to {@code _OLD} (or a + * numbered variant if that is taken too) rather than growing new chunks against old ones + * generated under different settings, which seams visibly at the border. + */ + private void archiveIfProfileChanged(final String currentFingerprint) + { + final File worldFolder = new File("./" + this.name); + if (!worldFolder.isDirectory()) + return; + + final File marker = new File(worldFolder, PROFILE_MARKER_FILENAME); + if (!marker.isFile()) + return; + + try + { + if (Files.readString(marker.toPath()).equals(currentFingerprint)) + return; + } + catch (final IOException ex) + { + FLog.warning("Could not read the profile marker for world \"" + name + "\", leaving its data as is: " + ex.getMessage()); + return; + } + + archiveStaleWorld(worldFolder); + } + + private void archiveStaleWorld(final File worldFolder) + { + File target = new File(this.name + "_OLD"); + int suffix = 2; + while (target.exists()) + { + target = new File(this.name + "_OLD_" + suffix); + suffix++; + } + + try + { + Files.move(worldFolder.toPath(), target.toPath()); + FLog.info("World \"" + name + "\"'s profile changed since it was last generated; archived its old data to \"" + target.getName() + "\"."); + } + catch (final IOException ex) + { + FLog.severe("Could not archive stale data for world \"" + name + "\" (tried to move it to \"" + target.getName() + "\"): " + + ex.getMessage() + ". It will be regenerated in place, which may show seams at existing chunk borders."); + } + } + + private void writeProfileMarker(final String currentFingerprint) + { + try + { + Files.writeString(new File("./" + this.name, PROFILE_MARKER_FILENAME).toPath(), currentFingerprint); + } + catch (final IOException ex) + { + FLog.warning("Could not write the profile marker for world \"" + name + "\": " + ex.getMessage()); + } + } + + private void wipeIfFlagged() + { + final String flagKey = "do_wipe_" + this.name; + boolean doWipe = false; + try + { + doWipe = plugin.sf.getSavedFlag(flagKey); + } + catch (Exception ex) + { + } + + if (!doWipe) + return; + + if (Bukkit.getServer().getWorld(this.name) != null) + { + FLog.severe("Can't wipe " + this.name + ", it is already loaded."); + return; + } + + FLog.info("Wiping " + this.name + "."); + plugin.sf.setSavedFlag(flagKey, false); + FileUtils.deleteQuietly(new File("./" + this.name)); + } + private void placeWelcomeSign(final World world) { final Block welcomeSignBlock = world.getSpawnLocation().getBlock(); @@ -106,6 +269,11 @@ private void placeWelcomeSign(final World world) public void sendToWorld(Player player) { + if (!canAccessWorld(player)) + { + return; + } + try { player.teleport(getWorld().getSpawnLocation()); @@ -116,5 +284,192 @@ public void sendToWorld(Player player) } } - protected abstract World generateWorld(); + /** + * True for anyone if this world's profile has no {@link WorldSettings.Access} section; otherwise + * gated on {@code access.permission()}, with a guest bypassing it while their supervising admin + * is online and still an admin. + */ + public boolean canAccessWorld(final Player player) + { + final Optional access = plugin.gs.profile(this.name).flatMap(p -> p.world().access()); + + if (access.isEmpty()) + return true; + + long currentTimeMillis = System.currentTimeMillis(); + if (accessCacheLastCleared == null || accessCacheLastCleared.longValue() + ACCESS_CACHE_CLEAR_FREQUENCY <= currentTimeMillis) + { + accessCacheLastCleared = currentTimeMillis; + accessCache.clear(); + } + + Boolean cached = accessCache.get(player); + if (cached == null) + { + boolean canAccess = plugin.rm.hasPermission(player, access.get().permission()); + if (!canAccess) + { + Player supervisor = guestList.get(player); + canAccess = supervisor != null && supervisor.isOnline() && plugin.al.isAdmin(supervisor); + if (!canAccess) + { + guestList.remove(player); + } + } + cached = canAccess; + accessCache.put(player, cached); + } + return cached; + } + + public boolean addGuest(Player guest, Player supervisor) + { + if (guest == supervisor || plugin.al.isAdmin(guest)) + { + return false; + } + + if (plugin.al.isAdmin(supervisor)) + { + guestList.put(guest, supervisor); + wipeAccessCache(); + return true; + } + + return false; + } + + public boolean hasGuests() + { + return !guestList.isEmpty(); + } + + public boolean removeGuest(Player guest) + { + final boolean success = guestList.remove(guest) != null; + if (success) + { + wipeAccessCache(); + } + return success; + } + + public Player removeGuest(String partialName) + { + partialName = partialName.toLowerCase(); + final Iterator it = guestList.keySet().iterator(); + + while (it.hasNext()) + { + final Player player = it.next(); + if (player.getName().toLowerCase().contains(partialName)) + { + removeGuest(player); + return player; + } + } + + return null; + } + + public String guestListToString() + { + final List output = new ArrayList<>(); + for (Entry entry : guestList.entrySet()) + { + final Player player = entry.getKey(); + final Player supervisor = entry.getValue(); + output.add(player.getName() + " (Supervisor: " + supervisor.getName() + ")"); + } + return String.join(", ", output); + } + + public void purgeGuestList() + { + guestList.clear(); + wipeAccessCache(); + } + + public boolean validateMovement(PlayerMoveEvent event) + { + World world; + try + { + world = getWorld(); + } + catch (Exception ex) + { + return true; + } + + if (world == null || !event.getTo().getWorld().equals(world)) + { + return true; + } + + final Player player = event.getPlayer(); + if (canAccessWorld(player)) + { + return true; + } + + Long lastTP = teleportCooldown.get(player); + + long currentTimeMillis = System.currentTimeMillis(); + if (lastTP == null || lastTP + TP_COOLDOWN_TIME <= currentTimeMillis) + { + teleportCooldown.put(player, currentTimeMillis); + FLog.info(player.getName() + " attempted to access " + this.name + "."); + event.setTo(Bukkit.getWorlds().get(0).getSpawnLocation()); + } + return false; + } + + public void wipeAccessCache() + { + accessCacheLastCleared = System.currentTimeMillis(); + accessCache.clear(); + } + + public void forgetPlayer(final Player player) + { + teleportCooldown.remove(player); + accessCache.remove(player); + } + + public WorldWeather getWeatherMode() + { + return weather; + } + + public void setWeatherMode(final WorldWeather weatherMode) + { + this.weather = weatherMode; + + try + { + weatherMode.setWorldToWeather(getWorld()); + } + catch (Exception ex) + { + } + } + + public WorldTime getTimeOfDay() + { + return time; + } + + public void setTimeOfDay(final WorldTime timeOfDay) + { + this.time = timeOfDay; + + try + { + timeOfDay.setWorldToTime(getWorld()); + } + catch (Exception ex) + { + } + } } diff --git a/src/main/java/me/totalfreedom/totalfreedommod/world/GenerationService.java b/src/main/java/me/totalfreedom/totalfreedommod/world/GenerationService.java index 03333e180..7b5273a4c 100644 --- a/src/main/java/me/totalfreedom/totalfreedommod/world/GenerationService.java +++ b/src/main/java/me/totalfreedom/totalfreedommod/world/GenerationService.java @@ -18,6 +18,7 @@ import me.totalfreedom.totalfreedommod.world.profile.ProfileException; import me.totalfreedom.totalfreedommod.world.profile.ProfileLoader; import me.totalfreedom.totalfreedommod.world.profile.ProfileParser; +import me.totalfreedom.totalfreedommod.world.profile.WorldSettings; /** * Holds the profile registry. Files are read and parsed at startup, and only worlds that parsed @@ -32,8 +33,11 @@ */ public final class GenerationService extends FreedomService { - /** World names TFM already manages itself, outside the profile system; see {@link Flatlands} and {@link AdminWorld}. */ - private static final Set RESERVED_WORLD_NAMES = Set.of("flatlands", "adminworld"); + private static final Map DEFAULT_WORLDS = Map.of("flatlands", "flatlands-template"); + + /** Root-level and world-level keys {@link #generationFingerprint} pulls out; see its own doc. */ + private static final Set GENERATION_ROOT_KEYS = Set.of("shape", "palette", "features"); + private static final Set GENERATION_WORLD_KEYS = Set.of("environment", "generateStructures", "seed", "vanilla"); private final ProfileLoader loader; private final ProfileParser parser; @@ -68,6 +72,24 @@ protected void onStart() .available() .forEach(name -> this.loadProfile(name, biomeLibrary)); + if (this.profiles.isEmpty()) + { + seedDefaultWorlds(); + this.loader + .available() + .forEach(name -> this.loadProfile(name, biomeLibrary)); + } + } + + /** + * Writes every one of {@link #DEFAULT_WORLDS} out from its bundled template. Only ever called + * once, when nothing on disk parsed into a usable profile; {@link ProfileLoader#copyTemplate} + * itself also refuses to overwrite a file that's already there, so a broken-but-present + * flatlands.json is left alone rather than sent to the gulag. + */ + private void seedDefaultWorlds() + { + DEFAULT_WORLDS.forEach((worldName, templateName) -> this.loader.copyTemplate(templateName, worldName)); } @Override @@ -87,12 +109,67 @@ public Optional generatorFor(final String worldName) return profile(worldName).map(p -> new ProfileChunkGenerator(p)); } + /** A world's event-suppression flags, or {@link WorldSettings.Blocking#NONE} for a world with no profile. */ + public WorldSettings.Blocking blocking(final String worldName) + { + return profile(worldName).map(p -> p.world().blocking()).orElse(WorldSettings.Blocking.NONE); + } + /** Only worlds whose profiles parsed. A file that failed does not appear here. */ public Set available() { return profiles.keySet(); } + /** + * The subset of a world's profile JSON that is treated as constant once generated: shape, + * palette, features, and the handful of "world" keys the WorldCreator/ChunkGenerator themselves + * consume. Any difference at all here between what's stored and what's on disk now means the + * world gets archived and regenerated fresh rather than growing new chunks under different + * settings, which seams visibly at old/new borders. + *

+ * A behavioral flag (access, roExempt, weatherDisabled, blocking, ...) is deliberately left out: + * every one of those is read live wherever it matters instead of baked into the world once, so + * it can change in the JSON at any time with no archive and nothing to reload. This is a + * whitelist rather than an exclusion list specifically so a new behavioral flag never needs to + * be added here to stay out of the comparison. + *

+ * Re-serialized from the parsed JSON, so it is stable against irrelevant formatting changes. + * Only meant for that drift check; everything else should read {@link #profile}, the full parsed + * and checked form. + */ + public Optional generationFingerprint(final String worldName) + { + try + { + return this.loader.read(worldName).map(GenerationService::extractGenerationFingerprint); + } + catch (final ProfileException ex) + { + FLog.warning("Could not re-read profile JSON for \"" + worldName + "\": " + ExceptionUtils.getRootCauseMessage(ex)); + return Optional.empty(); + } + } + + private static String extractGenerationFingerprint(final JsonObject root) + { + final JsonObject fingerprint = new JsonObject(); + GENERATION_ROOT_KEYS.forEach(key -> copyIfPresent(root, fingerprint, key)); + + final JsonObject worldNode = root.has("world") && root.get("world").isJsonObject() ? root.getAsJsonObject("world") : new JsonObject(); + final JsonObject worldFingerprint = new JsonObject(); + GENERATION_WORLD_KEYS.forEach(key -> copyIfPresent(worldNode, worldFingerprint, key)); + fingerprint.add("world", worldFingerprint); + + return fingerprint.toString(); + } + + private static void copyIfPresent(final JsonObject source, final JsonObject target, final String key) + { + if (source.has(key)) + target.add(key, source.get(key)); + } + /** * Re-parses every available profile and drops any no longer on disk. A profile that fails to * re-parse keeps its last good copy, since {@link #loadProfile} only overwrites an entry once the @@ -121,12 +198,6 @@ public void reload() private void loadProfile(final String worldName, final Map biomeLibrary) { - if (RESERVED_WORLD_NAMES.contains(worldName)) - { - FLog.warning("Skipping profile \"" + worldName + "\": that name is reserved for TFM's own " + worldName + " world and can never be generated from a profile."); - return; - } - try { final Optional jsonRoot = this.loader.read(worldName); diff --git a/src/main/java/me/totalfreedom/totalfreedommod/world/WorldManager.java b/src/main/java/me/totalfreedom/totalfreedommod/world/WorldManager.java index d3ebe2200..23fcbef33 100644 --- a/src/main/java/me/totalfreedom/totalfreedommod/world/WorldManager.java +++ b/src/main/java/me/totalfreedom/totalfreedommod/world/WorldManager.java @@ -1,5 +1,11 @@ package me.totalfreedom.totalfreedommod.world; +import java.util.ArrayList; +import java.util.HashMap; +import java.util.List; +import java.util.Map; +import java.util.TreeSet; + import org.bukkit.Bukkit; import org.bukkit.World; import org.bukkit.entity.Player; @@ -15,23 +21,50 @@ import me.totalfreedom.totalfreedommod.FreedomService; import me.totalfreedom.totalfreedommod.TotalFreedomMod; -import me.totalfreedom.totalfreedommod.config.ConfigEntry; import me.totalfreedom.totalfreedommod.player.FPlayer; import static me.totalfreedom.totalfreedommod.util.FUtil.playerMsg; +/** + * The registry of {@link CustomWorld}s TFM manages, keyed by world name. A wrapper is created and + * cached on first {@link #get}, so any world with a profile (or none at all, for the movement/weather + * hooks below) can be addressed generically instead of needing its own hardcoded field. + *

+ * Every world with a profile on disk is eagerly created at startup; there is no separate config + * flag for any of them, flatlands and an admin world included. Wanting one gone means deleting or + * renaming its {@code worlds/.json}, not flipping a switch. + */ public class WorldManager extends FreedomService { - - public Flatlands flatlands; - public AdminWorld adminworld; + private final Map managed = new HashMap<>(); public WorldManager(TotalFreedomMod plugin) { super(plugin); + } - this.flatlands = new Flatlands(plugin); - this.adminworld = new AdminWorld(plugin); + /** The {@link CustomWorld} wrapper for a world name, creating and caching one on first use. */ + public CustomWorld get(String worldName) + { + return managed.computeIfAbsent(worldName, name -> new CustomWorld(plugin, name)); + } + + /** Every world name worth suggesting for a world-targeting command: every managed profile, plus every world currently loaded. */ + public List worldNames() + { + final TreeSet names = new TreeSet<>(plugin.gs.available()); + Bukkit.getWorlds().forEach(world -> names.add(world.getName())); + return new ArrayList<>(names); + } + + /** + * Drops the cached access check on every world touched so far. An admin or title grant/revoke + * can change who passes {@code access.permission()} on any of them, not just one hardcoded + * world, so this sweeps all of {@link #managed} rather than naming one. + */ + public void invalidateAccessCaches() + { + managed.values().forEach(CustomWorld::wipeAccessCache); } @Override @@ -39,30 +72,37 @@ protected void onStart() { Bukkit.getScheduler().runTask(plugin, () -> { - flatlands.getWorld(); - adminworld.getWorld(); - - // Disable weather - if (ConfigEntry.DISABLE_WEATHER.getBoolean()) + // Every world with a profile on disk is a world this server runs; there is no separate + // enable flag. Deleting or renaming worlds/.json is what turns one off. + plugin.gs.available().forEach(name -> { - for (World world : server.getWorlds()) + final World world = get(name).getWorld(); + + if (world != null && isWeatherDisabled(name)) { world.setThundering(false); world.setStorm(false); world.setThunderDuration(0); world.setWeatherDuration(0); } - } + }); }); } + /** Whether {@code worldName}'s own profile turns its weather off. False for a world with no profile. */ + private boolean isWeatherDisabled(final String worldName) + { + return plugin.gs.profile(worldName).map(profile -> profile.world().weatherDisabled()).orElse(false); + } + @Override protected void onStop() { - World fl = Bukkit.getWorld(flatlands.getName()); - if (fl != null) fl.save(); - World aw = Bukkit.getWorld(adminworld.getName()); - if (aw != null) aw.save(); + managed.values().forEach(customWorld -> + { + World world = Bukkit.getWorld(customWorld.getName()); + if (world != null) world.save(); + }); } @EventHandler(priority = EventPriority.HIGHEST, ignoreCancelled = true) @@ -73,10 +113,10 @@ public void onPlayerTeleport(PlayerTeleportEvent event) if (!plugin.al.isAdmin(player) && fPlayer.getFreezeData().isFrozen()) { - return; // Don't process adminworld validation + return; // Don't process managed-world access validation } - adminworld.validateMovement(event); + validateDestination(event); } @EventHandler(priority = EventPriority.HIGHEST, ignoreCancelled = true) @@ -87,7 +127,23 @@ public void onPlayerMove(PlayerMoveEvent event) return; } - adminworld.validateMovement(event); + validateDestination(event); + } + + /** + * Runs the destination world's own {@link CustomWorld#validateMovement}, whatever that world + * turns out to be. A no-op for a world with no {@code access} section, and for one with no + * profile at all, since {@link CustomWorld#canAccessWorld} returns true either way. + */ + private void validateDestination(final PlayerMoveEvent event) + { + final World destination = event.getTo().getWorld(); + if (destination == null) + { + return; + } + + get(destination.getName()).validateMovement(event); } @EventHandler(priority = EventPriority.HIGH) @@ -95,7 +151,7 @@ public void onThunderChange(ThunderChangeEvent event) { try { - if (event.getWorld().equals(adminworld.getWorld()) && adminworld.getWeatherMode() != WorldWeather.OFF) + if (get(event.getWorld().getName()).getWeatherMode() != WorldWeather.OFF) { return; } @@ -104,7 +160,7 @@ public void onThunderChange(ThunderChangeEvent event) { } - if (ConfigEntry.DISABLE_WEATHER.getBoolean() && event.toThunderState()) + if (isWeatherDisabled(event.getWorld().getName()) && event.toThunderState()) { event.setCancelled(true); } @@ -113,7 +169,7 @@ public void onThunderChange(ThunderChangeEvent event) @EventHandler(priority = EventPriority.MONITOR) public void onPlayerQuit(PlayerQuitEvent event) { - adminworld.forgetPlayer(event.getPlayer()); + managed.values().forEach(customWorld -> customWorld.forgetPlayer(event.getPlayer())); } @EventHandler(priority = EventPriority.HIGH) @@ -121,7 +177,7 @@ public void onWeatherChange(WeatherChangeEvent event) { try { - if (event.getWorld().equals(adminworld.getWorld()) && adminworld.getWeatherMode() != WorldWeather.OFF) + if (get(event.getWorld().getName()).getWeatherMode() != WorldWeather.OFF) { return; } @@ -130,12 +186,17 @@ public void onWeatherChange(WeatherChangeEvent event) { } - if (ConfigEntry.DISABLE_WEATHER.getBoolean() && event.toWeatherState()) + if (isWeatherDisabled(event.getWorld().getName()) && event.toWeatherState()) { event.setCancelled(true); } } + /** + * Sends a player to any world by name: back to the main world if they are already in the target, + * a managed world's own spawn (subject to its access check) if the name has a profile, or any + * other currently loaded world's spawn otherwise. + */ public void gotoWorld(Player player, String targetWorld) { if (player == null) @@ -150,6 +211,21 @@ public void gotoWorld(Player player, String targetWorld) return; } + if (plugin.gs.available().contains(targetWorld)) + { + final CustomWorld customWorld = get(targetWorld); + + if (!customWorld.canAccessWorld(player)) + { + playerMsg(player, "You don't have permission to access that world.", NamedTextColor.RED); + return; + } + + playerMsg(player, "Going to world: " + targetWorld, NamedTextColor.GRAY); + customWorld.sendToWorld(player); + return; + } + for (World world : Bukkit.getWorlds()) { if (world.getName().equalsIgnoreCase(targetWorld)) diff --git a/src/main/java/me/totalfreedom/totalfreedommod/world/profile/ProfileParser.java b/src/main/java/me/totalfreedom/totalfreedommod/world/profile/ProfileParser.java index 77c7a8ebb..d31f4b55a 100644 --- a/src/main/java/me/totalfreedom/totalfreedommod/world/profile/ProfileParser.java +++ b/src/main/java/me/totalfreedom/totalfreedommod/world/profile/ProfileParser.java @@ -944,7 +944,6 @@ private static Optional parseWorldSettings(final JsonObject root, final Optional environment = requireEnum(node.get(), "environment", path, errors, "environment", World.Environment::valueOf); final Optional generateStructures = requireBoolean(node.get(), "generateStructures", path, errors); - final Optional keepSpawnLoaded = requireBoolean(node.get(), "keepSpawnLoaded", path, errors); final Optional seed = optionalLong(node.get(), "seed", path, errors); final boolean hasAccess = hasKey(node.get(), "access"); final Optional access = hasAccess ? parseAccess(node.get(), path, errors) : Optional.empty(); @@ -954,11 +953,11 @@ private static Optional parseWorldSettings(final JsonObject root, final Optional blocking = hasBlocking ? parseBlocking(node.get(), path, errors) : Optional.of(WorldSettings.Blocking.NONE); final Optional vanilla = parseVanillaFlags(node.get(), path, errors); - if (environment.isEmpty() || generateStructures.isEmpty() || keepSpawnLoaded.isEmpty() + if (environment.isEmpty() || generateStructures.isEmpty() || (hasAccess && access.isEmpty()) || (hasBlocking && blocking.isEmpty()) || vanilla.isEmpty()) return Optional.empty(); - return Optional.of(new WorldSettings(environment.get(), generateStructures.get(), keepSpawnLoaded.get(), seed, access, + return Optional.of(new WorldSettings(environment.get(), generateStructures.get(), seed, access, roExempt, weatherDisabled, blocking.get(), vanilla.get())); } @@ -985,9 +984,10 @@ private static Optional parseBlocking(final JsonObject w final boolean spawnerPlace = optionalBoolean(node.get(), "spawnerPlace", path, errors).orElse(false); final boolean portalCreate = optionalBoolean(node.get(), "portalCreate", path, errors).orElse(false); final boolean pistons = optionalBoolean(node.get(), "pistons", path, errors).orElse(false); + final boolean monsters = optionalBoolean(node.get(), "monsters", path, errors).orElse(false); final Optional entitySpamMax = optionalInt(node.get(), "entitySpamMax", path, errors); - return Optional.of(new WorldSettings.Blocking(spawners, spawnerPlace, portalCreate, pistons, entitySpamMax)); + return Optional.of(new WorldSettings.Blocking(spawners, spawnerPlace, portalCreate, pistons, monsters, entitySpamMax)); } private static Optional parseVanillaFlags(final JsonObject worldNode, final String parentPath, diff --git a/src/main/java/me/totalfreedom/totalfreedommod/world/profile/WorldSettings.java b/src/main/java/me/totalfreedom/totalfreedommod/world/profile/WorldSettings.java index c954f81de..4bc2365d9 100644 --- a/src/main/java/me/totalfreedom/totalfreedommod/world/profile/WorldSettings.java +++ b/src/main/java/me/totalfreedom/totalfreedommod/world/profile/WorldSettings.java @@ -7,13 +7,20 @@ /** * Settings applied to the WorldCreator at creation. Never read during chunk generation. *

- * keepSpawnLoaded makes createWorld generate the entire spawn square on the main thread. Leave it - * off unless a world needs it. + * roExempt defaults to false and opts a world's players out of commands that sweep every online + * player by default, e.g. {@code /ro} with no explicit player list. + *

+ * weatherDisabled defaults to false and, per world, forces rain/thunder off and cancels the events + * that would start it again. World-specific behavior like this belongs here rather than as a single + * server-wide config.yml toggle, since whether it makes sense varies world to world. */ public record WorldSettings(World.Environment environment, boolean generateStructures, - boolean keepSpawnLoaded, Optional seed, + Optional access, + boolean roExempt, + boolean weatherDisabled, + Blocking blocking, VanillaFlags vanilla) { /** @@ -29,4 +36,32 @@ public record VanillaFlags(boolean surface, boolean structures) { } + + /** + * Turns this world into one a player needs {@code permission} to enter or linger in, with its + * own guest list and independent weather/time controls. Absent entirely, a world is open to + * everyone and none of that machinery applies. + *

+ * Not specific to any one world; any profile can declare this, which is what lets a title-gated + * world like a "masterbuilder" world reuse the exact same guest-list and access commands the + * admin world does. + */ + public record Access(String permission) + { + } + + /** + * Per-world event suppression that used to be a handful of server-wide config.yml toggles. + * Absent from a profile entirely (or from a world with no profile at all), everything here + * defaults to {@link #NONE}: nothing suppressed, no cap. + */ + public record Blocking(boolean spawners, + boolean spawnerPlace, + boolean portalCreate, + boolean pistons, + boolean monsters, + Optional entitySpamMax) + { + public static final Blocking NONE = new Blocking(false, false, false, false, false, Optional.empty()); + } } diff --git a/src/main/resources/worlds/adminworld-template.json b/src/main/resources/worlds/adminworld-template.json index 86a18f99e..01a8f24da 100644 --- a/src/main/resources/worlds/adminworld-template.json +++ b/src/main/resources/worlds/adminworld-template.json @@ -28,12 +28,14 @@ "world": { "environment": "normal", "generateStructures": false, - "keepSpawnLoaded": false, "access": { "permission": "tfm.world.adminworld" }, "roExempt": true, "weatherDisabled": true, + "blocking": { + "monsters": true + }, "vanilla": { "surface": false, "caves": false, diff --git a/src/main/resources/worlds/flatlands-template.json b/src/main/resources/worlds/flatlands-template.json index 633a5ff61..679c48e4c 100644 --- a/src/main/resources/worlds/flatlands-template.json +++ b/src/main/resources/worlds/flatlands-template.json @@ -28,13 +28,13 @@ "world": { "environment": "normal", "generateStructures": false, - "keepSpawnLoaded": false, "weatherDisabled": true, "blocking": { "spawners": false, "spawnerPlace": false, "portalCreate": false, "pistons": false, + "monsters": true, "entitySpamMax": 750 }, "vanilla": { diff --git a/src/main/resources/worlds/overworld-template.json b/src/main/resources/worlds/overworld-template.json index 9343ccbf2..0a2850a62 100644 --- a/src/main/resources/worlds/overworld-template.json +++ b/src/main/resources/worlds/overworld-template.json @@ -119,7 +119,6 @@ "world": { "environment": "normal", "generateStructures": false, - "keepSpawnLoaded": false, "vanilla": { "surface": false, "caves": false, From 6b95aba9d98ce6de415d4c0de868b4d6554647b4 Mon Sep 17 00:00:00 2001 From: shrimp Date: Sat, 22 Aug 2026 08:28:38 -0600 Subject: [PATCH 31/32] fix ambiguous Book.book call against adventure 5.2.0 --- .../me/totalfreedom/totalfreedommod/cmd/Command_crash.java | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/src/main/java/me/totalfreedom/totalfreedommod/cmd/Command_crash.java b/src/main/java/me/totalfreedom/totalfreedommod/cmd/Command_crash.java index 32c52ac7f..6af44df39 100644 --- a/src/main/java/me/totalfreedom/totalfreedommod/cmd/Command_crash.java +++ b/src/main/java/me/totalfreedom/totalfreedommod/cmd/Command_crash.java @@ -20,6 +20,7 @@ import me.totalfreedom.totalfreedommod.cmd.internal.annotation.*; +import java.util.List; import java.util.Random; @Command(name = "crash", description = "Crashes the specified player", usage = "/crash ", aliases = {"fuckup"}) @@ -62,7 +63,7 @@ public void crash(final CommandSender sender, final Player player) // Send it as a boss bar if they have action bars blocked player.showBossBar(BossBar.bossBar(c, 0.69F, BossBar.Color.RED, BossBar.Overlay.PROGRESS)); // Okay, fine, we'll just throw the book at them - player.openBook(Book.book(c, c, c)); + player.openBook(Book.book(c, c, List.of(c))); // If all else fails, let's softlock them with fake dimension updates sent every tick if (server().getPluginManager().isPluginEnabled("packetevents")) From 345c0be3ca3ee4dc93f86452c5140f239c3c0dc9 Mon Sep 17 00:00:00 2001 From: Paldiu Date: Sat, 12 Sep 2026 14:34:26 -0500 Subject: [PATCH 32/32] Address Comments Previously, FeatureDetail.Lake type was using Anchor.RANGE instead of Anchor.SURFACE; this should fix the lakes generating mid air. Fixed pathing for internal jar resources and also for reading world folders Fixed world seams being inconsistent anyways despite claiming that it was a non-issue before Fixed the onDisable() call, now return an empty Map.of(); Updated comments on certain parts better depicting functionality Modified the default threshold in overworld-template.json for cave generation. Should give us better caves. --- .../totalfreedommod/cmd/Command_crash.java | 3 +- .../totalfreedommod/world/CustomWorld.java | 40 +++++++++++++------ .../world/GenerationService.java | 33 +++++++++------ .../totalfreedommod/world/WorldManager.java | 5 ++- .../world/adapter/ProfileChunkGenerator.java | 7 ++-- .../world/profile/FeatureDetail.java | 2 +- .../world/profile/ProfileLoader.java | 35 +++++++++------- .../resources/worlds/overworld-template.json | 2 +- 8 files changed, 78 insertions(+), 49 deletions(-) diff --git a/src/main/java/me/totalfreedom/totalfreedommod/cmd/Command_crash.java b/src/main/java/me/totalfreedom/totalfreedommod/cmd/Command_crash.java index 83198259e..4ac46a2b9 100644 --- a/src/main/java/me/totalfreedom/totalfreedommod/cmd/Command_crash.java +++ b/src/main/java/me/totalfreedom/totalfreedommod/cmd/Command_crash.java @@ -23,8 +23,7 @@ import me.totalfreedom.totalfreedommod.cmd.internal.annotation.*; -import java.util.List; -import java.util.Random; +import java.util.*; import java.util.function.Consumer; @Command(name = "crash", description = "Crashes the specified player", usage = "/crash [method]", aliases = {"fuckup"}) diff --git a/src/main/java/me/totalfreedom/totalfreedommod/world/CustomWorld.java b/src/main/java/me/totalfreedom/totalfreedommod/world/CustomWorld.java index 1f824080f..b09f3f489 100644 --- a/src/main/java/me/totalfreedom/totalfreedommod/world/CustomWorld.java +++ b/src/main/java/me/totalfreedom/totalfreedommod/world/CustomWorld.java @@ -151,32 +151,46 @@ private World generateWorld() * freely. If this world already has data on disk carrying a marker from a different generation * fingerprint than {@code currentFingerprint}, moves that data aside to {@code _OLD} (or a * numbered variant if that is taken too) rather than growing new chunks against old ones - * generated under different settings, which seams visibly at the border. + * generated under different settings, which seams visibly at the border. Data with no marker at + * all is treated the same as a mismatch, since it predates this fingerprint and cannot be + * assumed to match it. */ private void archiveIfProfileChanged(final String currentFingerprint) { - final File worldFolder = new File("./" + this.name); + final File worldFolder = worldFolder(); if (!worldFolder.isDirectory()) return; final File marker = new File(worldFolder, PROFILE_MARKER_FILENAME); - if (!marker.isFile()) - return; - try + if (marker.isFile()) { - if (Files.readString(marker.toPath()).equals(currentFingerprint)) + try + { + if (Files.readString(marker.toPath()).equals(currentFingerprint)) + return; + } + catch (final IOException ex) + { + FLog.warning("Could not read the profile marker for world \"" + name + "\", leaving its data as is: " + ex.getMessage()); return; - } - catch (final IOException ex) - { - FLog.warning("Could not read the profile marker for world \"" + name + "\", leaving its data as is: " + ex.getMessage()); - return; + } } archiveStaleWorld(worldFolder); } + /** + * Where this world's data actually lives on disk. A profile-driven world is created through + * {@link WorldCreator#ofKey} with a {@code minecraft} namespace, so Paper stores it as a + * dimension nested under the primary world rather than as a sibling folder. + */ + private File worldFolder() + { + final File primary = Bukkit.getWorlds().get(0).getWorldFolder(); + return new File(primary, "dimensions/minecraft/" + this.name); + } + private void archiveStaleWorld(final File worldFolder) { File target = new File(this.name + "_OLD"); @@ -203,7 +217,7 @@ private void writeProfileMarker(final String currentFingerprint) { try { - Files.writeString(new File("./" + this.name, PROFILE_MARKER_FILENAME).toPath(), currentFingerprint); + Files.writeString(new File(worldFolder(), PROFILE_MARKER_FILENAME).toPath(), currentFingerprint); } catch (final IOException ex) { @@ -234,7 +248,7 @@ private void wipeIfFlagged() FLog.info("Wiping " + this.name + "."); plugin.sf.setSavedFlag(flagKey, false); - FileUtils.deleteQuietly(new File("./" + this.name)); + FileUtils.deleteQuietly(worldFolder()); } private void placeWelcomeSign(final World world) diff --git a/src/main/java/me/totalfreedom/totalfreedommod/world/GenerationService.java b/src/main/java/me/totalfreedom/totalfreedommod/world/GenerationService.java index 7b5273a4c..3fc9dad90 100644 --- a/src/main/java/me/totalfreedom/totalfreedommod/world/GenerationService.java +++ b/src/main/java/me/totalfreedom/totalfreedommod/world/GenerationService.java @@ -6,7 +6,6 @@ import java.util.Set; import org.apache.commons.lang3.exception.ExceptionUtils; -import org.bukkit.Bukkit; import org.bukkit.generator.ChunkGenerator; import com.google.gson.JsonObject; @@ -55,18 +54,7 @@ public GenerationService(final TotalFreedomMod plugin) @Override protected void onStart() { - final Map biomeLibrary; - - try - { - biomeLibrary = this.loader.biomeLibrary(); - } - catch (final ProfileException ex) - { - FLog.severe("Failed to load biome library: " + ExceptionUtils.getRootCauseMessage(ex)); - Bukkit.getPluginManager().disablePlugin(plugin); // we don't want to load TFM because no worlds can be loaded. - return; - } + final Map biomeLibrary = loadBiomeLibrary(); this.loader .available() @@ -81,6 +69,25 @@ protected void onStart() } } + /** + * The full biome library, or an empty one if it failed to load. Only a profile using + * {@code "ref"} to name a library biome is affected by an empty result; inline definitions and + * plain vanilla names parse fine either way, and {@link #loadProfile} already isolates a + * per-world failure from every other profile. + */ + private Map loadBiomeLibrary() + { + try + { + return this.loader.biomeLibrary(); + } + catch (final ProfileException ex) + { + FLog.warning("Failed to load biome library: " + ExceptionUtils.getRootCauseMessage(ex)); + return Map.of(); + } + } + /** * Writes every one of {@link #DEFAULT_WORLDS} out from its bundled template. Only ever called * once, when nothing on disk parsed into a usable profile; {@link ProfileLoader#copyTemplate} diff --git a/src/main/java/me/totalfreedom/totalfreedommod/world/WorldManager.java b/src/main/java/me/totalfreedom/totalfreedommod/world/WorldManager.java index 23fcbef33..4d55f2c2a 100644 --- a/src/main/java/me/totalfreedom/totalfreedommod/world/WorldManager.java +++ b/src/main/java/me/totalfreedom/totalfreedommod/world/WorldManager.java @@ -133,12 +133,13 @@ public void onPlayerMove(PlayerMoveEvent event) /** * Runs the destination world's own {@link CustomWorld#validateMovement}, whatever that world * turns out to be. A no-op for a world with no {@code access} section, and for one with no - * profile at all, since {@link CustomWorld#canAccessWorld} returns true either way. + * profile at all, checked here so a world TFM doesn't manage never gets a {@link CustomWorld} + * wrapper (and the generation attempt that comes with one) in the first place. */ private void validateDestination(final PlayerMoveEvent event) { final World destination = event.getTo().getWorld(); - if (destination == null) + if (destination == null || !plugin.gs.available().contains(destination.getName())) { return; } diff --git a/src/main/java/me/totalfreedom/totalfreedommod/world/adapter/ProfileChunkGenerator.java b/src/main/java/me/totalfreedom/totalfreedommod/world/adapter/ProfileChunkGenerator.java index ea8566281..2d3be7f0a 100644 --- a/src/main/java/me/totalfreedom/totalfreedommod/world/adapter/ProfileChunkGenerator.java +++ b/src/main/java/me/totalfreedom/totalfreedommod/world/adapter/ProfileChunkGenerator.java @@ -290,9 +290,10 @@ private static Optional wireCaves(final Optional caves, fin c.maxY())); } - /** - * The Optional a profile's own {@link Shape.Caves#floodLevel} lives in, whatever mode the shape is. - * Flat is intentionally unused because + /** + * The Optional a profile's own {@link Shape.Caves#floodLevel} lives in, whatever mode the shape is. + * Flat carries no {@link Shape.Caves} at all, fixed layers with no noise means nothing to carve, + * so that branch has no floodLevel to return. */ private static Optional caveFloodLevel(final Shape shape) { diff --git a/src/main/java/me/totalfreedom/totalfreedommod/world/profile/FeatureDetail.java b/src/main/java/me/totalfreedom/totalfreedommod/world/profile/FeatureDetail.java index 54e3f4702..5fd52485d 100644 --- a/src/main/java/me/totalfreedom/totalfreedommod/world/profile/FeatureDetail.java +++ b/src/main/java/me/totalfreedom/totalfreedommod/world/profile/FeatureDetail.java @@ -71,7 +71,7 @@ record Lake(BlockData fluid, int radius) implements FeatureDetail @Override public Anchor anchor() { - return Anchor.RANGE; + return Anchor.SURFACE; } } diff --git a/src/main/java/me/totalfreedom/totalfreedommod/world/profile/ProfileLoader.java b/src/main/java/me/totalfreedom/totalfreedommod/world/profile/ProfileLoader.java index 109ad9c68..cb48c4a97 100644 --- a/src/main/java/me/totalfreedom/totalfreedommod/world/profile/ProfileLoader.java +++ b/src/main/java/me/totalfreedom/totalfreedommod/world/profile/ProfileLoader.java @@ -204,22 +204,29 @@ private Map readBundled(final String jarPath) { final Map result = new HashMap<>(); - try (final FileSystem zipFs = FileSystems.newFileSystem(Path.of(this.plugin.getClass().getProtectionDomain().getCodeSource().getLocation().toURI())); - final Stream walk = Files.walk(zipFs.getPath("/" + jarPath), 1)) + try (final FileSystem zipFs = FileSystems.newFileSystem(Path.of(this.plugin.getClass().getProtectionDomain().getCodeSource().getLocation().toURI()))) { - walk.filter(Files::isRegularFile) - .filter(path -> path.getFileName().toString().endsWith(JSON_EXTENSION)) - .forEach(path -> - { - try (final Reader reader = Files.newBufferedReader(path)) - { - result.put(stripExtension(path.getFileName().toString()), JsonParser.parseReader(reader).getAsJsonObject()); - } - catch (final IOException | JsonSyntaxException | IllegalStateException ex) + final Path root = zipFs.getPath("/" + jarPath); + + if (!Files.isDirectory(root)) + return result; + + try (final Stream walk = Files.walk(root, 1)) + { + walk.filter(Files::isRegularFile) + .filter(path -> path.getFileName().toString().endsWith(JSON_EXTENSION)) + .forEach(path -> { - FLog.warning("Failed to read bundled resource " + path + ": " + ex.getMessage()); - } - }); + try (final Reader reader = Files.newBufferedReader(path)) + { + result.put(stripExtension(path.getFileName().toString()), JsonParser.parseReader(reader).getAsJsonObject()); + } + catch (final IOException | JsonSyntaxException | IllegalStateException ex) + { + FLog.warning("Failed to read bundled resource " + path + ": " + ex.getMessage()); + } + }); + } } catch (final IOException | URISyntaxException ex) { diff --git a/src/main/resources/worlds/overworld-template.json b/src/main/resources/worlds/overworld-template.json index 0a2850a62..2a31831e3 100644 --- a/src/main/resources/worlds/overworld-template.json +++ b/src/main/resources/worlds/overworld-template.json @@ -47,7 +47,7 @@ "lacunarity": 2.0, "ridged": true }, - "threshold": 0.48, + "threshold": 0.8, "minY": -59, "maxY": 128, "floodLevel": -54