diff --git a/TODO.md b/TODO.md index fb870e45..56071af7 100644 --- a/TODO.md +++ b/TODO.md @@ -7,7 +7,7 @@ plans live under [docs/](docs/) (committed); the long-range direction is the str keep it current when controls/features change. Last consolidated 2026-06-04. **Build:** `scripts/build-client.ps1` (Windows) or `scripts/build-client.sh` (Linux) — publishes shared libs + bundled server + Unity player. -**Test:** `./scripts/run-tests.sh` — currently **1332 server + 154 client passing** (2026-08-01). Locale parity (en/de) is enforced by a test. +**Test:** `./scripts/run-tests.sh` — currently **1413 server + 154 client passing** (2026-08-03). Locale parity (en/de) is enforced by a test. CI runs two tiers: PRs skip the tests marked `[Trait("Category", "Slow")]`; pushes to `main` and the release workflow run the full suite. CI builds/runs tests in Release, and a per-test duration guardrail (`scripts/check-test-durations.py`, PRs only) fails the gate when a non-Slow test exceeds 120 s. **Conventions:** English docs/comments; in-game text bilingual DE+EN; commit to `main` with the @@ -6990,6 +6990,36 @@ is **pre-approved** (keys in `tools/ai-assets/.env`, run via `uv`). --- +## ✅ Done (2026-08-03): beaches along seas and larger lakes (#679) + +Coastlines ended abruptly — the biome surface (grass/mud/snow) ran straight into the water; sandy +shores only happened where a sand biome region touched the waterline. Now a water shoreline grows real +beaches (server + client preview agree — pure seed functions, no protocol/save changes; existing worlds +gain sandy shores retroactively since worldgen is seed-derived, player builds untouched): + +- **Shore detection** — a column is beach when it sits in a narrow band above the waterline + (jittered 1..3 blocks) AND real water lies within a probe ring (8 dirs × radii 4/8/12, early-out) — + so inland lowland at coastal altitude never sand-coats. The submerged apron (seabed ≤3 under the sea + line) needs no probe. A large-scale coast-character mask (~55–60 % beach) alternates sand with bare + rocky shore, and the beach edge wanders instead of following a contour. +- **Large lakes too** — `RiverField` labels each pooled reach's fill-and-spill lake (connected coarse + cells sharing one filled level) and pre-rings lakes with ≥64 visible water columns with dry shore + markers (`TryGetLakeShore`); small pools, 1-wide rivers and puddle ponds get no beach. Large PONDS + (bowl ≥3 deep nearby) get a mask-edge rim via the shared pond mask. +- **Material data-driven** — new optional `beachBlock` per planet (`data/planets.json`), default sand; + beaches only on WATER shorelines (lava seas keep their volcanic coasts; dry/airless worlds none). + Surface AND sub-surface turn to the beach block, so the varied topsoil depth yields a real sand layer. +- **Override order** — biome → beach → snow/ice → volcano basalt: cold coasts get snow-dusted shores, + volcano flanks still win. Painted in `Generate`'s surface chain; the shared `IsBeachColumn` query keeps + Generate, tree stamping and tests agreeing. +- **Palms on the beach** — tree stamping consults the beach helper (it can't see Generate's painted + block): beach columns grow only palms/dead snags (themes with neither leave the beach bare), so + jungle-theme coasts get palm-fringed shores. Giant fungi skip beaches; beach flora comes from the + sand host pool (sparse tufts, ×0.35 density). +- **Tests** — 5 new (`BeachGenerationTests`): coast grows beaches + Generate paints them, sandy apron, + no beaches on dry/lava/airless worlds, cross-instance determinism, synthetic-basin lake-shore ring + with size threshold. 1413 server tests green. + ## ✅ Done (2026-08-03): Sandbox mining unblocked — creative kit materials go to the cargo hold (#677) In Sandbox (and Creative with the kit ticked) a fresh player could not mine most blocks: the forced diff --git a/data/planets.json b/data/planets.json index 2d98a1bb..4a1bc551 100644 --- a/data/planets.json +++ b/data/planets.json @@ -330,7 +330,7 @@ { "key": "salt_flats", "floraTheme": "desert", "exotic": true, "baseTemperature": 40, "nameKey": "planet.salt_flats.name", "atmosphereHeight": 200, "cloudColor": 15263962, "cloudDensity": 0.2, "baseHeight": 60, "amplitude": 4, "terrainScale": 70.0, "terrainStyle": "flats", - "surfaceBlock": "salt", "subSurfaceBlock": "sand", "deepBlock": "stone", "surfaceDepth": 4, + "surfaceBlock": "salt", "subSurfaceBlock": "sand", "deepBlock": "stone", "surfaceDepth": 4, "beachBlock": "salt", "caveThreshold": 0.82, "dataCacheRarity": 0.0012, "weather": "clear", "stormChance": 0.1, "dayLengthSeconds": 660, "worldRadius": 900, "floraDensity": 0.02, "creatureAbundance": "few", "atmosphere": "toxic", "oxygenExtractability": 0.5, "spawnWeight": 6, "waterAbundance": 0.0, "biomes": [ diff --git a/src/BlocksBeyondTheStars.Shared/Content/GameContent.cs b/src/BlocksBeyondTheStars.Shared/Content/GameContent.cs index 947db83b..20a7c047 100644 --- a/src/BlocksBeyondTheStars.Shared/Content/GameContent.cs +++ b/src/BlocksBeyondTheStars.Shared/Content/GameContent.cs @@ -488,6 +488,7 @@ void RequireBlock(string ctx, string? blockKey) RequireBlock($"Planet '{planet.Key}' surface", planet.SurfaceBlock); RequireBlock($"Planet '{planet.Key}' sub-surface", planet.SubSurfaceBlock); RequireBlock($"Planet '{planet.Key}' deep", planet.DeepBlock); + RequireBlock($"Planet '{planet.Key}' beach", planet.BeachBlock); foreach (var biome in planet.Biomes) { RequireBlock($"Planet '{planet.Key}' biome surface", biome.SurfaceBlock); diff --git a/src/BlocksBeyondTheStars.Shared/Definitions/PlanetType.cs b/src/BlocksBeyondTheStars.Shared/Definitions/PlanetType.cs index ab4496aa..8ad6fc2a 100644 --- a/src/BlocksBeyondTheStars.Shared/Definitions/PlanetType.cs +++ b/src/BlocksBeyondTheStars.Shared/Definitions/PlanetType.cs @@ -125,6 +125,10 @@ public sealed class PlanetType /// worlds with an atmosphere get water. null = auto (atmosphere worlds get a moderate amount). public double? WaterAbundance { get; set; } + /// Beach surface block stamped along sea coasts and large-lake shores (#679). Empty = sand. + /// Beaches only form where the shore's fluid is water — lava seas keep their volcanic coasts. + public string BeachBlock { get; set; } = string.Empty; + /// 0..1 — how much surface lava this world has (lava seas in basins on volcanic/airless worlds). /// null = auto (volcanic worlds get a moderate amount). Watery worlds get no lava SEA — their /// molten side comes from volcanoes (summit crater pools + vents, #477) and the deep lava table diff --git a/src/BlocksBeyondTheStars.WorldGeneration/RiverField.cs b/src/BlocksBeyondTheStars.WorldGeneration/RiverField.cs index 4cfc8fcd..55d75818 100644 --- a/src/BlocksBeyondTheStars.WorldGeneration/RiverField.cs +++ b/src/BlocksBeyondTheStars.WorldGeneration/RiverField.cs @@ -44,11 +44,15 @@ public RiverColumn(int surface, int bed, int waterfallDrop, byte flowAxis) } private readonly Dictionary<(int X, int Z), RiverColumn> _cols; + private readonly Dictionary<(int X, int Z), int> _lakeShore; private readonly int _circumference; public int ColumnCount => _cols.Count; public int WaterfallColumnCount { get; } + /// Dry columns ringing a LARGE lake's pooled water (inspection / tests). + public int LakeShoreColumnCount => _lakeShore.Count; + /// The fluid this field fills its channels with — water on watery worlds, lava on volcanic ones. /// Generate reads it so one routing path serves both (L2). Air on an empty field. public BlockId FillFluid { get; } @@ -56,18 +60,27 @@ public RiverColumn(int surface, int bed, int waterfallDrop, byte flowAxis) /// All stamped columns (inspection / tests). public IReadOnlyCollection Columns => _cols.Values; - private RiverField(Dictionary<(int, int), RiverColumn> cols, int circumference, int waterfalls, BlockId fillFluid) + private RiverField(Dictionary<(int, int), RiverColumn> cols, Dictionary<(int, int), int> lakeShore, + int circumference, int waterfalls, BlockId fillFluid) { - _cols = cols; _circumference = circumference; WaterfallColumnCount = waterfalls; FillFluid = fillFluid; + _cols = cols; _lakeShore = lakeShore; _circumference = circumference; + WaterfallColumnCount = waterfalls; FillFluid = fillFluid; } /// An empty field (dry / no-river worlds) — every lookup misses. - public static RiverField Empty(int circumference) => new(new Dictionary<(int, int), RiverColumn>(), circumference, 0, default); + public static RiverField Empty(int circumference) + => new(new Dictionary<(int, int), RiverColumn>(), new Dictionary<(int, int), int>(), circumference, 0, default); /// O(1) lookup: is (worldX, worldZ) a river column, and with what surface/bed/waterfall? Wraps X. public bool TryGet(int worldX, int worldZ, out RiverColumn col) => _cols.TryGetValue((WorldConstants.WrapX(worldX, _circumference), WorldConstants.WrapZ(worldZ, _circumference)), out col); + /// O(1) lookup: is (worldX, worldZ) a dry column on the shore ring of a LARGE lake — a pooled + /// reach whose lake gathered at least the build's minimum of visible water columns? Returns the lake's + /// flat water level so the caller can band-test a beach against it (#679). Wraps X/Z like TryGet. + public bool TryGetLakeShore(int worldX, int worldZ, out int waterLevel) + => _lakeShore.TryGetValue((WorldConstants.WrapX(worldX, _circumference), WorldConstants.WrapZ(worldZ, _circumference)), out waterLevel); + public static RiverField Build( RiverNetwork net, System.Func height, @@ -78,9 +91,14 @@ public static RiverField Build( int fullWidthAccum = 8, int waterfallMinDrop = 4, int maxLakeDepth = 6, - int estuaryWiden = 3) + int estuaryWiden = 3, + int lakeShoreWidth = 3, + int minLakeShoreColumns = 64) { var cols = new Dictionary<(int, int), RiverColumn>(); + // Pooled (flat-lake) columns and the coarse cell that set their level — the lake-shore pass below + // rings these with dry shore markers (#679). Keyed like `cols` so the two lookups agree. + var pooledCols = new Dictionary<(int X, int Z), int>(); int period = net.LatitudePeriod; int cell = net.CellSize; int gridW = net.GridW, gridH = net.GridH; @@ -175,9 +193,10 @@ void Stamp(int wx, int wz, int surface, int bed, int waterfallDrop, byte axis) int cellIdx = CellOf(wx, wz); int poolDepth = net.FilledLevel[cellIdx] - net.Height[cellIdx]; + bool pooled = poolDepth > 0 && poolDepth <= maxLakeDepth; int surface, bed; - if (poolDepth > 0 && poolDepth <= maxLakeDepth) + if (pooled) { surface = net.FilledLevel[cellIdx]; // flat pool surface bed = net.Height[cellIdx] - 1; @@ -203,10 +222,139 @@ void Stamp(int wx, int wz, int surface, int bed, int waterfallDrop, byte axis) int sx = axis == 0 ? wx : wx + o; int sz = axis == 0 ? wz + o : wz; Stamp(sx, sz, surface, bed, o == 0 ? waterfallDrop : 0, axis); + if (pooled) + { + pooledCols[(WorldConstants.WrapX(sx, circumference), WorldConstants.WrapZ(sz, circumference))] = cellIdx; + } + } + } + } + + var lakeShore = BuildLakeShores(net, height, circumference, cols, pooledCols, lakeShoreWidth, minLakeShoreColumns); + return new RiverField(cols, lakeShore, circumference, waterfalls, fillFluid); + } + + /// + /// Lake shores (#679): labels each pooled reach's lake — connected coarse cells sharing one filled + /// level (one basin fills to one spill level, so equality + adjacency IS the basin) — and, for lakes + /// whose visible pooled water gathered at least columns, rings + /// the water with dry shore markers wherever the terrain sits just above the pool. + /// turns those into beach columns; small pools and plain flowing reaches + /// get none. Only the lake's EDGE columns pay the terrain lookups, so the pass costs ~perimeter. + /// + private static Dictionary<(int, int), int> BuildLakeShores( + RiverNetwork net, + System.Func height, + int circumference, + Dictionary<(int, int), RiverColumn> cols, + Dictionary<(int X, int Z), int> pooledCols, + int lakeShoreWidth, + int minLakeShoreColumns) + { + var lakeShore = new Dictionary<(int, int), int>(); + if (lakeShoreWidth <= 0 || pooledCols.Count == 0) + { + return lakeShore; + } + + int gridW = net.GridW, gridH = net.GridH; + var ndx = new[] { 1, -1, 0, 0 }; + var ndz = new[] { 0, 0, 1, -1 }; + + // Flood-label the lake component containing `start` (memoized), returning its root cell. + var root = new Dictionary(); + int RootOf(int start) + { + if (root.TryGetValue(start, out int known)) + { + return known; + } + + int level = net.FilledLevel[start]; + var comp = new List(); + var queue = new Queue(); + var seen = new HashSet { start }; + queue.Enqueue(start); + while (queue.Count > 0) + { + int c = queue.Dequeue(); + comp.Add(c); + int gx = c % gridW, gz = c / gridW; + for (int n = 0; n < 4; n++) + { + int nx = (gx + ndx[n] + gridW) % gridW; + int nz = (gz + ndz[n] + gridH) % gridH; + int nc = nz * gridW + nx; + if (!seen.Contains(nc) && net.FilledLevel[nc] > net.Height[nc] && net.FilledLevel[nc] == level) + { + seen.Add(nc); + queue.Enqueue(nc); + } } } + + foreach (int c in comp) + { + root[c] = start; + } + + return start; + } + + // Visible size per lake = how many pooled water columns the strokes actually stamped for it — + // the basin's cell count would overstate lakes the channels barely touch. + var visibleColumns = new Dictionary(); + foreach (var kv in pooledCols) + { + int r = RootOf(kv.Value); + visibleColumns[r] = visibleColumns.TryGetValue(r, out int n) ? n + 1 : 1; + } + + foreach (var kv in pooledCols) + { + if (visibleColumns[RootOf(kv.Value)] < minLakeShoreColumns) + { + continue; // small pool — no beach ring + } + + var (px, pz) = kv.Key; + bool edge = !cols.ContainsKey((WorldConstants.WrapX(px + 1, circumference), pz)) + || !cols.ContainsKey((WorldConstants.WrapX(px - 1, circumference), pz)) + || !cols.ContainsKey((px, WorldConstants.WrapZ(pz + 1, circumference))) + || !cols.ContainsKey((px, WorldConstants.WrapZ(pz - 1, circumference))); + if (!edge) + { + continue; // interior water — only the lake's rim rings shore markers + } + + int lakeLevel = net.FilledLevel[kv.Value]; + for (int dx = -lakeShoreWidth; dx <= lakeShoreWidth; dx++) + for (int dz = -lakeShoreWidth; dz <= lakeShoreWidth; dz++) + { + if (dx == 0 && dz == 0) + { + continue; + } + + var target = (WorldConstants.WrapX(px + dx, circumference), WorldConstants.WrapZ(pz + dz, circumference)); + if (cols.ContainsKey(target)) + { + continue; // water column, not shore + } + + if (lakeShore.TryGetValue(target, out int prev) && prev <= lakeLevel) + { + continue; // already marked against an equal/lower pool — keep the lower waterline + } + + int terrain = height(px + dx, pz + dz); + if (terrain >= lakeLevel && terrain <= lakeLevel + 3) + { + lakeShore[target] = lakeLevel; + } + } } - return new RiverField(cols, circumference, waterfalls, fillFluid); + return lakeShore; } } diff --git a/src/BlocksBeyondTheStars.WorldGeneration/WorldGenerator.cs b/src/BlocksBeyondTheStars.WorldGeneration/WorldGenerator.cs index d3bbd513..91626452 100644 --- a/src/BlocksBeyondTheStars.WorldGeneration/WorldGenerator.cs +++ b/src/BlocksBeyondTheStars.WorldGeneration/WorldGenerator.cs @@ -1317,8 +1317,15 @@ private int SurfaceSlope(PlanetType planet, int worldX, int worldZ) /// level. Deterministic — pure noise. The caller fills the carved bowl with water up to the original /// surface, so a pond reads as a swimmable pool flush with the surrounding terrain (B7). private int PondDepthAt(PlanetType planet, long seed, int worldX, int worldZ, double threshold) + => PondDepthFromMask(planet, seed, worldX, worldZ, threshold, PondMaskAt(planet, seed, worldX, worldZ)); + + /// The raw pond placement mask at a column — split out so Generate can compute it once per + /// column and share it between the pond carve and the beach rim test (#679). + private double PondMaskAt(PlanetType planet, long seed, int worldX, int worldZ) + => FbmT(seed + 0x7A11, worldX, worldZ, planet.TerrainScale * 4.0, octaves: 3); + + private int PondDepthFromMask(PlanetType planet, long seed, int worldX, int worldZ, double threshold, double mask) { - double mask = FbmT(seed + 0x7A11, worldX, worldZ, planet.TerrainScale * 4.0, octaves: 3); double strength = (mask - threshold) / PondBand; if (strength <= 0.0) { @@ -1341,6 +1348,148 @@ private int PondDepthAt(PlanetType planet, long seed, int worldX, int worldZ, do return (int)System.Math.Round(System.Math.Min(1.0, strength) * PondMaxDepth); } + // --- Beaches (#679): sand along the waterline of the sea and of LARGE lakes/ponds --- + private const int BeachApronDepth = 3; // submerged shore: seabed this close under the sea line reads sandy + private const int BeachMaxRise = 3; // tallest dry beach strip above a waterline (per-column jitter 1..3) + private const int BeachLargePondDepth = 3; // a pond earns a beach rim only where its bowl gets this deep nearby + private static readonly int[] BeachProbeRadii = { 4, 8, 12 }; + private static readonly int[] BeachDirX = { 1, -1, 0, 0, 1, 1, -1, -1 }; + private static readonly int[] BeachDirZ = { 0, 0, 1, -1, 1, -1, 1, -1 }; + + /// Coast-character mask (#679): long stretches of coast alternate between beach and bare + /// (rocky/cliff) shore, so sand doesn't ring every waterline uniformly (~55–60 % of coast is beach). + private bool CoastMaskAt(PlanetType planet, long seed, int worldX, int worldZ) + => FbmT(seed + 0xBEAC50, worldX, worldZ, planet.TerrainScale * 3.0, octaves: 2) > 0.46; + + /// How high above its waterline this column's dry beach strip may reach (1..3) — jittered by + /// a small noise so the sand edge wanders instead of following a contour line. + private int BeachRiseAt(long seed, int worldX, int worldZ) + => 1 + (int)(System.Math.Clamp(FbmT(seed + 0xBEAC51, worldX, worldZ, 13.0, octaves: 1), 0.0, 0.999) * BeachMaxRise); + + /// True when actual sea water lies within the probe ring of this column — the guard that keeps + /// inland lowland at coastal ALTITUDE from sand-coating (#679). Early-outs on the first hit, and a real + /// shore answers on the innermost ring, so the full 24 samples are only paid by the (rare) rejects. + private bool SeaWithinBeachProbe(PlanetType planet, int worldX, int worldZ, int seaLevel) + { + for (int r = 0; r < BeachProbeRadii.Length; r++) + for (int d = 0; d < 8; d++) + { + int radius = BeachProbeRadii[r]; + if (SurfaceHeight(planet, worldX + BeachDirX[d] * radius, worldZ + BeachDirZ[d] * radius) < seaLevel) + { + return true; + } + } + + return false; + } + + /// + /// Dry-beach test (#679) for a column KNOWN to hold no water itself (no sea/pond/river/crater — the + /// caller guarantees it). Three shorelines qualify, checked in rising cost order behind cheap band + /// gates: the sea coast (band above the sea line + real-water probe), a large lake's shore ring + /// (pre-marked by ), and a large pond's rim (mask edge + depth probe). All of + /// it is masked by and a jittered rise so the sand edge varies. Pure function + /// of (seed, x, z) — Generate, tree stamping, tests and the client can never disagree. + /// + private bool DryBeachAt(PlanetType planet, WorldCalibration calib, long seed, RiverField riverField, + BlockId waterId, int worldX, int worldZ, int surfaceY, double? pondMask = null) + { + if (waterId.IsAir) + { + return false; + } + + bool seaIsWater = calib.SeaLevel != int.MinValue && calib.SeaFluid == waterId; + bool riversAreWater = riverField.FillFluid == waterId; + if (!seaIsWater && !riversAreWater) + { + return false; // no water shoreline anywhere on this world (dry, airless or lava-sea) + } + + // Cheap candidacy gates first — the mask FBM and the probes only run on waterline-band columns. + bool? coast = null; + bool Coast() => coast ??= CoastMaskAt(planet, seed, worldX, worldZ); + + if (seaIsWater && surfaceY >= calib.SeaLevel && surfaceY - calib.SeaLevel <= BeachMaxRise + && Coast() + && surfaceY - calib.SeaLevel <= BeachRiseAt(seed, worldX, worldZ) + && SeaWithinBeachProbe(planet, worldX, worldZ, calib.SeaLevel)) + { + return true; + } + + if (riversAreWater && riverField.TryGetLakeShore(worldX, worldZ, out int lakeLevel) + && surfaceY >= lakeLevel && surfaceY - lakeLevel <= BeachMaxRise + && Coast() + && surfaceY - lakeLevel <= BeachRiseAt(seed, worldX, worldZ)) + { + return true; + } + + // Large-pond rim: just OUTSIDE the pond mask's waterline (depth 0 there), confirmed against a + // nearby bowl that actually reaches lake depth — depth tracks the mask's excess, so only the big + // ponds qualify and puddles get no rim. Ponds share the sea's water gate (they never form otherwise). + if (!seaIsWater) + { + return false; + } + + double pondAbundance = planet.WaterAbundance + ?? (string.Equals(planet.Atmosphere, "none", System.StringComparison.OrdinalIgnoreCase) ? 0.0 : 0.55); + if (!(pondAbundance > 0.15)) + { + return false; + } + + double pondThreshold = 0.70 - pondAbundance * 0.12; + double mask = pondMask ?? PondMaskAt(planet, seed, worldX, worldZ); + if (mask <= pondThreshold - PondBand || mask > pondThreshold || !Coast()) + { + return false; + } + + for (int r = 0; r < BeachProbeRadii.Length; r++) + for (int d = 0; d < 8; d++) + { + int radius = BeachProbeRadii[r]; + if (PondDepthAt(planet, seed, worldX + BeachDirX[d] * radius, worldZ + BeachDirZ[d] * radius, + pondThreshold) >= BeachLargePondDepth) + { + return true; + } + } + + return false; + } + + /// True when this dry surface column is a beach (#679): the shoreline band of the sea or of a + /// large lake/pond, on a beach-masked stretch of coast. Water columns (sea/pond/river/crater) are never + /// "beach" — the submerged sandy apron is Generate's detail, not part of this query. Deterministic; + /// shared by Generate, tree stamping and tests so they can never disagree about the painted ground. + public bool IsBeachColumn(PlanetType planet, int worldX, int worldZ) + { + int surfaceY = SurfaceHeight(planet, worldX, worldZ); + var calib = CalibFor(planet); + if (calib.SeaLevel != int.MinValue && surfaceY < calib.SeaLevel) + { + return false; // submerged under the sea + } + + if (SurfacePondDepth(planet, worldX, worldZ) > 0 || SurfaceRiverDepth(planet, worldX, worldZ) > 0 + || TryGetVolcanoCrater(planet, worldX, worldZ, out _)) + { + return false; // a water column is never the beach + } + + var waterId = _content.GetBlock("water")?.NumericId ?? BlockId.Air; + return DryBeachAt(planet, calib, PlanetSeed(planet), RiverFieldFor(planet), waterId, worldX, worldZ, surfaceY); + } + + /// This planet's beach surface block (#679): , sand by default. + private BlockId BeachBlockFor(PlanetType planet) + => ResolveBlock(string.IsNullOrWhiteSpace(planet.BeachBlock) ? "sand" : planet.BeachBlock); + // --- Routed rivers (Phase 1): per-world memoized network + block-resolution placement field --- // A river is no longer a height-blind noise band. RiverNetwork traces every river downhill (steepest // descent + fill-and-spill lakes) to a guaranteed sink (the sea or a self-formed lake); RiverField then @@ -1752,6 +1901,12 @@ public ChunkData Generate(PlanetType planet, ChunkCoord coord) // O(1) lookup per column below. Replaces the old height-blind noise band + flat-ground gate. var riverField = RiverFieldFor(planet); + // Beaches (#679): along a WATER shoreline (the sea, a large lake or a large pond) the ground turns + // to the planet's beach block — lava seas keep their volcanic coasts, dry/airless worlds none. + var beachId = BeachBlockFor(planet); + bool beachPossible = !beachId.IsAir && !seaWaterId.IsAir + && ((fluidId == seaWaterId && fluidLevel != int.MinValue) || riverField.FillFluid == seaWaterId); + var origin = WorldConstants.ChunkOrigin(coord); for (int lx = 0; lx < WorldConstants.ChunkSize; lx++) @@ -1768,9 +1923,11 @@ public ChunkData Generate(PlanetType planet, ChunkCoord coord) int waterTop = fluidLevel; var columnFluid = fluidId; bool pondHere = false; + double? pondMask = null; // computed at most once per column; shared with the beach rim test (#679) if (ponds && surfaceY > fluidLevel) { - int pondDepth = PondDepthAt(planet, seed, worldX, worldZ, pondThreshold); + pondMask = PondMaskAt(planet, seed, worldX, worldZ); + int pondDepth = PondDepthFromMask(planet, seed, worldX, worldZ, pondThreshold, pondMask.Value); if (pondDepth > 0) { seabedY = surfaceY - pondDepth; @@ -1800,8 +1957,10 @@ public ChunkData Generate(PlanetType planet, ChunkCoord coord) // thin sheet on a flowing reach (no floating wall), the pooled level inside a capped lake, and at // a flagged step a vertical waterfall column poured into the lower reach. Skipped where a pond, // a volcano crater or the global sea already claims the column. The river bed is carved to BedY. + bool riverHere = false; if (!pondHere && !craterHere && surfaceY > fluidLevel && riverField.TryGet(worldX, worldZ, out var river)) { + riverHere = true; seabedY = river.BedY; waterTop = river.WaterfallDrop > 0 ? river.WaterSurfaceY + river.WaterfallDrop : river.WaterSurfaceY; columnFluid = riverField.FillFluid; // water on watery worlds, lava on lava/ashen worlds (L2) @@ -1822,6 +1981,31 @@ public ChunkData Generate(PlanetType planet, ChunkCoord coord) var surfaceId = biome.Surface; var subSurfaceId = biome.Sub; + // Beaches (#679): near a water shoreline the ground turns to the beach block — surface AND + // sub-surface, so the varied topsoil depth yields a real sand layer, and the shallow seabed + // apron continues the beach under water. The coast mask alternates beach and bare shore; + // the snow pass below still dusts cold coasts, and volcano basalt still wins near a cone. + bool beachHere = false; + if (beachPossible) + { + if (surfaceY < fluidLevel && fluidId == seaWaterId) + { + beachHere = fluidLevel - surfaceY <= BeachApronDepth + && CoastMaskAt(planet, seed, worldX, worldZ); + } + else if (!pondHere && !craterHere && !riverHere) + { + beachHere = DryBeachAt(planet, calib, seed, riverField, seaWaterId, + worldX, worldZ, surfaceY, pondMask); + } + + if (beachHere) + { + surfaceId = beachId; + subSurfaceId = beachId; + } + } + // Altitude climate (#476): above the snow line the ground gets a snow cover, further up solid // ice. Dithered (±1.5 °C noise) so the line wanders naturally instead of cutting a contour. if (snowPossible && surfaceY > waterTop) @@ -1961,7 +2145,10 @@ public ChunkData Generate(PlanetType planet, ChunkCoord coord) // aquatic flora instead (kelp + lily pads); land plants don't grow underwater. if (flora && seabedY + 1 > waterTop) { - var floraId = FloraForSurface(planet, biome, seed, worldX, worldZ); + // On a beach the painted ground is the beach block, not the biome surface — grow that + // host's flora (sparse sand tufts), never grass plants standing in sand (#679). + var floraId = FloraForSurface(planet, biome, seed, worldX, worldZ, + beachHere ? surfaceId : (BlockId?)null); int fy = seabedY + 1; int fly = fy - origin.Y; // Local density is modulated by a vegetation-richness mask (lush forest floors / meadows vs @@ -1970,6 +2157,10 @@ public ChunkData Generate(PlanetType planet, ChunkCoord coord) // The cold factor (#476) thins growth toward the snow line and stops it at the ice. double localFloraDensity = LocalFloraDensity(planet, biome, floraDensity, seed, worldX, worldZ) * ColdFloraFactor(calib, surfaceY); + if (beachHere) + { + localFloraDensity *= 0.35; // beaches read best mostly bare + } if (!floraId.IsAir && fly >= 0 && fly < WorldConstants.ChunkSize && Noise.Value01(seed + 9001, WorldConstants.WrapX(worldX, _circumference), 7, Wz(worldZ)) < localFloraDensity) { @@ -2245,6 +2436,12 @@ void SetCell(int wx, int wy, int wz, BlockId block, bool overwrite) continue; // not in water } + if (DryBeachAt(planet, calib, seed, RiverFieldFor(planet), + _content.GetBlock("water")?.NumericId ?? BlockId.Air, wx, wz, sy)) + { + continue; // #679: the painted ground here is beach sand — no giant fungi on the beach + } + // Per-mushroom size (loosely-coupled stem height + cap): a shared bell factor with independent // jitter on each, so a fungal grove reads as a mix of small and towering capped fungi. double sizeF = SizeFactor(seed + 0x53410, wx, wz, 0.30); // overall size, ±30% (bell) @@ -2323,6 +2520,8 @@ void SetCell(int wx, int wy, int wz, BlockId block, bool overwrite) } var calib = CalibFor(planet); + var waterId = _content.GetBlock("water")?.NumericId ?? BlockId.Air; + var riverField = RiverFieldFor(planet); // cached — needed for the beach ground check (#679) for (int wx = origin.X - maxCrown; wx < origin.X + cs + maxCrown; wx++) for (int wz = origin.Z - maxCrown; wz < origin.Z + cs + maxCrown; wz++) { @@ -2353,14 +2552,6 @@ void SetCell(int wx, int wy, int wz, BlockId block, bool overwrite) continue; // this theme grows no trees here (e.g. fungal → giant mushrooms instead) } - var surf = biome.Surface; - bool earthy = surf == grassId || surf == dirtId || surf == mudId; - bool sandyOk = surf == sandId && (kind == TreeKind.Palm || kind == TreeKind.Dead); // palms/dead snags on sand - if (!earthy && !sandyOk) - { - continue; - } - if (sy + 1 <= fluidLevel) { continue; // not in the sea @@ -2371,6 +2562,36 @@ void SetCell(int wx, int wy, int wz, BlockId block, bool overwrite) continue; // B35: an upland pond/lake or a river here — a tree would stand in the water } + // Beaches (#679): on a beach column the painted ground is the beach block, NOT the biome + // surface (StampTrees can't see Generate's override, so it must ask the shared helper). + // Only palms / dead snags belong in the sand — themes that grow either get palm-fringed + // shores, themes with neither leave the beach bare. + if (DryBeachAt(planet, calib, seed, riverField, waterId, wx, wz, sy)) + { + if (System.Array.IndexOf(biome.Theme.Trees, TreeKind.Palm) >= 0) + { + kind = TreeKind.Palm; + } + else if (System.Array.IndexOf(biome.Theme.Trees, TreeKind.Dead) >= 0) + { + kind = TreeKind.Dead; + } + else + { + continue; + } + } + else + { + var surf = biome.Surface; + bool earthy = surf == grassId || surf == dirtId || surf == mudId; + bool sandyOk = surf == sandId && (kind == TreeKind.Palm || kind == TreeKind.Dead); // palms/dead snags on sand + if (!earthy && !sandyOk) + { + continue; + } + } + // Per-tree size (loosely-coupled height + crown): a shared bell factor sets the overall scale, // with a smaller independent jitter on each so trunk height and crown width still vary apart. double sizeF = SizeFactor(seed + 0x71EE5, wx, wz, 0.30); // overall tree size, ±30% (bell) @@ -2926,10 +3147,12 @@ private void ResolveFlora(PlanetType planet) /// meadow there — instead of a salt-and-pepper mix; and it is THEME-WEIGHTED so the biome's preferred /// climate species fill most of the patches while off-theme ones still turn up for variety. /// - private BlockId FloraForSurface(PlanetType planet, BiomeResolved biome, long seed, int worldX, int worldZ) + private BlockId FloraForSurface(PlanetType planet, BiomeResolved biome, long seed, int worldX, int worldZ, + BlockId? surfaceOverride = null) { ResolveFlora(planet); - if (!_floraBySurface.TryGetValue(biome.Surface.Value, out var pool) || pool.Length == 0) + var host = surfaceOverride ?? biome.Surface; // a beach column hosts the beach block's flora (#679) + if (!_floraBySurface.TryGetValue(host.Value, out var pool) || pool.Length == 0) { return BlockId.Air; } diff --git a/tests/BlocksBeyondTheStars.Tests/BeachGenerationTests.cs b/tests/BlocksBeyondTheStars.Tests/BeachGenerationTests.cs new file mode 100644 index 00000000..4ba850f7 --- /dev/null +++ b/tests/BlocksBeyondTheStars.Tests/BeachGenerationTests.cs @@ -0,0 +1,251 @@ +// Blocks Beyond the Stars — Copyright (c) 2026 Justus Dütscher & Marcel Dütscher (JuMaVe Games) +// SPDX-License-Identifier: AGPL-3.0-or-later +// This file is part of Blocks Beyond the Stars. See LICENSE for the full AGPL-3.0 text. +using System; +using System.Collections.Generic; +using BlocksBeyondTheStars.Shared.Content; +using BlocksBeyondTheStars.Shared.Definitions; +using BlocksBeyondTheStars.Shared.Geometry; +using BlocksBeyondTheStars.Shared.Primitives; +using BlocksBeyondTheStars.Shared.World; +using BlocksBeyondTheStars.WorldGeneration; +using Xunit; +using Xunit.Abstractions; + +namespace BlocksBeyondTheStars.Tests; + +/// +/// Beaches (#679): sand along the waterline of the sea and of large lakes. Verifies the shared +/// query, that Generate actually paints the beach block on +/// those columns (and on the shallow submerged apron), that dry/lava worlds get none, determinism, and +/// the lake-shore ring on a synthetic basin. +/// +public class BeachGenerationTests +{ + private readonly ITestOutputHelper _out; + public BeachGenerationTests(ITestOutputHelper output) => _out = output; + + private static GameContent Content() => ContentLoader.LoadFromDirectory(TestPaths.DataDir()); + + private static int FloorDiv(int a, int b) => (int)Math.Floor((double)a / b); + + /// The generated block at an absolute world position (generates the containing chunk). + private static BlockId BlockAt(WorldGenerator gen, PlanetType planet, int wx, int wy, int wz) + { + int cs = WorldConstants.ChunkSize; + var coord = new ChunkCoord(FloorDiv(wx, cs), FloorDiv(wy, cs), FloorDiv(wz, cs)); + var chunk = gen.Generate(planet, coord); + var origin = WorldConstants.ChunkOrigin(coord); + return chunk.Get(wx - origin.X, wy - origin.Y, wz - origin.Z); + } + + /// Sparse whole-world scan for dry columns inside the sea's beach band (sea..sea+3). + private static List<(int X, int Z, int SurfaceY)> SeaBandColumns( + WorldGenerator gen, PlanetType planet, int sea, int step) + { + int circ = WorldConstants.Circumference; + int period = WorldConstants.LatitudePeriodFor(circ); + var band = new List<(int, int, int)>(); + for (int x = 0; x < circ; x += step) + for (int z = -period / 2; z < period / 2; z += step) + { + int sy = gen.SurfaceHeight(planet, x, z); + if (sy >= sea && sy - sea <= 3) + { + band.Add((x, z, sy)); + } + } + + return band; + } + + [Fact] + public void SeaCoast_GrowsBeaches_AndGeneratePaintsTheBeachBlock() + { + var content = Content(); + var planet = content.GetPlanet("jungle")!; + var gen = new WorldGenerator(7, content); + int sea = gen.SeaLevel(planet); + Assert.True(sea != int.MinValue, "jungle must have a sea (percentile level, #473)"); + + var band = SeaBandColumns(gen, planet, sea, step: 13); + Assert.True(band.Count > 0, "no columns in the coastal band at all — terrain sampling broken?"); + + var beaches = new List<(int X, int Z, int SurfaceY)>(); + foreach (var (x, z, sy) in band) + { + if (gen.IsBeachColumn(planet, x, z)) + { + beaches.Add((x, z, sy)); + } + } + + _out.WriteLine($"jungle/7: bandColumns={band.Count}, beachColumns={beaches.Count}, sea={sea}"); + Assert.True(beaches.Count > 0, "a watery world's coast produced no beach columns"); + Assert.True(beaches.Count < band.Count, + "EVERY coastal-band column is beach — the coast-character mask isn't gating anything"); + + // Generate must paint the beach block (sand on jungle) on the beach columns it claims. + var sand = content.GetBlock("sand")!.NumericId; + int verified = 0; + foreach (var (x, z, sy) in beaches) + { + if (verified >= 6) + { + break; + } + + Assert.Equal(sand, BlockAt(gen, planet, x, sy, z)); + verified++; + } + + Assert.True(verified > 0); + } + + [Fact] + public void SeaApron_ShallowSeabedNearTheShore_ReadsSandy() + { + var content = Content(); + var planet = content.GetPlanet("jungle")!; + var gen = new WorldGenerator(7, content); + int sea = gen.SeaLevel(planet); + var sand = content.GetBlock("sand")!.NumericId; + + int circ = WorldConstants.Circumference; + int period = WorldConstants.LatitudePeriodFor(circ); + int shallow = 0, sandy = 0; + for (int x = 0; x < circ && sandy == 0; x += 13) + for (int z = -period / 2; z < period / 2; z += 13) + { + int sy = gen.SurfaceHeight(planet, x, z); + int depth = sea - sy; + if (depth < 1 || depth > 3) + { + continue; // not the shallow apron band + } + + shallow++; + if (BlockAt(gen, planet, x, sy, z) == sand) + { + sandy++; + break; + } + + if (shallow >= 60) + { + break; // the coast mask covers ~55-60 % — 60 shallow samples MUST hit a beach stretch + } + } + + _out.WriteLine($"jungle/7: shallowSampled={shallow}, sandySeabed={sandy}"); + Assert.True(sandy > 0, "no sandy seabed apron found in the shallow band near the coast"); + } + + [Fact] + public void DryAndLavaWorlds_GetNoBeaches() + { + var content = Content(); + var gen = new WorldGenerator(7, content); + int circ = WorldConstants.Circumference; + int period = WorldConstants.LatitudePeriodFor(circ); + + foreach (var key in new[] { "desert", "lava", "asteroid" }) + { + var planet = content.GetPlanet(key)!; + for (int x = 0; x < circ; x += 97) + for (int z = -period / 2; z < period / 2; z += 97) + { + Assert.False(gen.IsBeachColumn(planet, x, z), + $"{key} ({x},{z}): a world without a water shoreline claims a beach column"); + } + } + } + + [Fact] + public void BeachClassification_IsDeterministic_AcrossGeneratorInstances() + { + var content = Content(); + var planet = content.GetPlanet("jungle")!; + var genA = new WorldGenerator(7, content); + var genB = new WorldGenerator(7, content); + int sea = genA.SeaLevel(planet); + + int checked_ = 0; + foreach (var (x, z, _) in SeaBandColumns(genA, planet, sea, step: 31)) + { + Assert.Equal(genA.IsBeachColumn(planet, x, z), genB.IsBeachColumn(planet, x, z)); + if (++checked_ >= 300) + { + break; + } + } + + Assert.True(checked_ > 0, "no coastal columns compared"); + } + + // A synthetic closed basin above sea level: the priority-flood fills it (fill-and-spill lake), the + // strokes pool through it, and the field must ring the pooled water with dry shore markers — but only + // when the lake's visible water meets the size threshold. + [Fact] + public void Synthetic_LargeLake_GetsShoreRing_SmallThresholdRespected() + { + const int w = 160, period = 80, seaLevel = 5, cell = 4; + int H(int x, int z) + { + int wx = ((x % w) + w) % w; + if (wx < 3) + { + return 0; // sea sink at the west edge + } + + int zc = WorldConstants.WrapZ(z, w); + int baseH = wx + Math.Abs(zc) / 4; // west-draining ramp + V-valley funnel onto z=0 + int dx = wx - 60; + if (dx * dx + zc * zc <= 100) + { + return 45; // flat-bottom bowl around (60,0): a closed depression the flood fills to ~its west rim + } + + return baseH; + } + + var net = RiverNetwork.Build(seed: 77, circumference: w, latitudePeriod: period, + seaLevel: seaLevel, height: H, cellSize: cell); + var field = RiverField.Build(net, H, circumference: w, minLakeShoreColumns: 8); + var field2 = RiverField.Build(net, H, circumference: w, minLakeShoreColumns: 8); + var fieldHuge = RiverField.Build(net, H, circumference: w, minLakeShoreColumns: 100000); + + Assert.True(field.LakeShoreColumnCount > 0, "the filled basin produced no lake-shore ring"); + Assert.Equal(field.LakeShoreColumnCount, field2.LakeShoreColumnCount); // determinism + Assert.Equal(0, fieldHuge.LakeShoreColumnCount); // size threshold respected + + // Every shore marker is DRY (not a water column), sits just above its lake's waterline, and has + // pooled/river water nearby (within the ring width of 3). + int shores = 0; + for (int x = 0; x < w; x++) + for (int z = -period / 2; z < period / 2; z++) + { + if (!field.TryGetLakeShore(x, z, out int level)) + { + continue; + } + + shores++; + Assert.False(field.TryGet(x, z, out _), $"shore ({x},{z}) is also a water column"); + int terrain = H(x, z); + Assert.InRange(terrain, level, level + 3); + + bool waterNearby = false; + for (int dx = -3; dx <= 3 && !waterNearby; dx++) + for (int dz = -3; dz <= 3 && !waterNearby; dz++) + { + waterNearby = field.TryGet(x + dx, z + dz, out _); + } + + Assert.True(waterNearby, $"shore ({x},{z}) has no water within the ring width"); + } + + Assert.Equal(field.LakeShoreColumnCount, shores); + _out.WriteLine($"synthetic lake: shoreColumns={shores}"); + } +}