Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
32 changes: 31 additions & 1 deletion TODO.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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
Expand Down
2 changes: 1 addition & 1 deletion data/planets.json
Original file line number Diff line number Diff line change
Expand Up @@ -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": [
Expand Down
1 change: 1 addition & 0 deletions src/BlocksBeyondTheStars.Shared/Content/GameContent.cs
Original file line number Diff line number Diff line change
Expand Up @@ -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);
Expand Down
4 changes: 4 additions & 0 deletions src/BlocksBeyondTheStars.Shared/Definitions/PlanetType.cs
Original file line number Diff line number Diff line change
Expand Up @@ -125,6 +125,10 @@ public sealed class PlanetType
/// worlds with an atmosphere get water. <c>null</c> = auto (atmosphere worlds get a moderate amount).</summary>
public double? WaterAbundance { get; set; }

/// <summary>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.</summary>
public string BeachBlock { get; set; } = string.Empty;

/// <summary>0..1 — how much surface lava this world has (lava seas in basins on volcanic/airless worlds).
/// <c>null</c> = 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
Expand Down
160 changes: 154 additions & 6 deletions src/BlocksBeyondTheStars.WorldGeneration/RiverField.cs
Original file line number Diff line number Diff line change
Expand Up @@ -44,30 +44,43 @@ 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; }

/// <summary>Dry columns ringing a LARGE lake's pooled water (inspection / tests).</summary>
public int LakeShoreColumnCount => _lakeShore.Count;

/// <summary>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.</summary>
public BlockId FillFluid { get; }

/// <summary>All stamped columns (inspection / tests).</summary>
public IReadOnlyCollection<RiverColumn> 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;
}

/// <summary>An empty field (dry / no-river worlds) — every lookup misses.</summary>
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);

/// <summary>O(1) lookup: is (worldX, worldZ) a river column, and with what surface/bed/waterfall? Wraps X.</summary>
public bool TryGet(int worldX, int worldZ, out RiverColumn col)
=> _cols.TryGetValue((WorldConstants.WrapX(worldX, _circumference), WorldConstants.WrapZ(worldZ, _circumference)), out col);

/// <summary>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.</summary>
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<int, int, int> height,
Expand All @@ -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;
Expand Down Expand Up @@ -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;
Expand All @@ -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);
}

/// <summary>
/// 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 <paramref name="minLakeShoreColumns"/> columns, rings
/// the water with dry shore markers wherever the terrain sits just above the pool.
/// <see cref="WorldGenerator"/> 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.
/// </summary>
private static Dictionary<(int, int), int> BuildLakeShores(
RiverNetwork net,
System.Func<int, int, int> 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, int>();
int RootOf(int start)
{
if (root.TryGetValue(start, out int known))
{
return known;
}

int level = net.FilledLevel[start];
var comp = new List<int>();
var queue = new Queue<int>();
var seen = new HashSet<int> { 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<int, int>();
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;
}
}
Loading