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}");
+ }
+}