From fa904d876c23418faf318636c6f9ee995d8900a6 Mon Sep 17 00:00:00 2001 From: marceld23 Date: Mon, 3 Aug 2026 21:31:30 +0200 Subject: [PATCH] fix(worldgen): shuffle which biomes a multi-biome world uses, not just how many (#696) ResolveBiomes rolled only the biome COUNT (2..pool) and then always took the first N pool entries, so the tail entries of a type's biome pool in data/planets.json were missing from every world that rolled a smaller count, and the first entry appeared on every world of the type. A per-world Fisher-Yates shuffle (seeded from PlanetSeed, so server and client preview agree) now also picks WHICH entries make the cut. ResolveBiomes/BiomeResolved became internal (+InternalsVisibleTo for the test project); two new tests cover membership variety across seeds and determinism. Only newly generated chunks are affected. closes #696 Co-Authored-By: Claude Fable 5 --- TODO.md | 11 +++++- ...locksBeyondTheStars.WorldGeneration.csproj | 4 ++ .../WorldGenerator.cs | 27 ++++++++++--- .../WorldGenerationTests.cs | 39 +++++++++++++++++++ 4 files changed, 75 insertions(+), 6 deletions(-) diff --git a/TODO.md b/TODO.md index 177693e4..d070fd8f 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 **1419 server + 154 client passing** (2026-08-03). Locale parity (en/de) is enforced by a test. +**Test:** `./scripts/run-tests.sh` — currently **1421 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 @@ -102,6 +102,15 @@ Per-item detail lives in the dated work log below. **Since 2026-07 versions are --- +### ★ Multi-biome worlds shuffle WHICH biomes they get, not just how many (#696, 2026-08-03, branch fix/biome-subset-shuffle) +`ResolveBiomes` randomised only the biome COUNT (2..pool) and then always took the first N entries of +the type's pool in `data/planets.json` order — so e.g. a 2-biome `varied` world was always sand+grass, +and the tail entries (`mud`, `stone`) never appeared without all earlier ones. A per-world Fisher–Yates +shuffle (seeded from `PlanetSeed`, deterministic — server and client preview agree) now also picks +WHICH entries make the cut. `ResolveBiomes` became `internal` (+`InternalsVisibleTo` for the test +project); 2 new tests cover membership variety across seeds and determinism. Only newly generated +chunks are affected; existing persisted terrain is untouched. + ### ★ Enemy health bars + real aiming: crosshair shots, AutoAim world rule, ship-weapon enforcement (#692, #693, #694, 2026-08-03, branch feat/health-bars-and-aiming — LOCAL, no PR) Combat never missed and never showed enemy health. **Health bars (#692):** every damageable entity (machines/drones/bandits, creatures incl. titans, space drones/UFOs/cruisers/bandit ships) draws a diff --git a/src/BlocksBeyondTheStars.WorldGeneration/BlocksBeyondTheStars.WorldGeneration.csproj b/src/BlocksBeyondTheStars.WorldGeneration/BlocksBeyondTheStars.WorldGeneration.csproj index 981392a0..ea23f452 100644 --- a/src/BlocksBeyondTheStars.WorldGeneration/BlocksBeyondTheStars.WorldGeneration.csproj +++ b/src/BlocksBeyondTheStars.WorldGeneration/BlocksBeyondTheStars.WorldGeneration.csproj @@ -4,6 +4,10 @@ + + + + netstandard2.1 enable diff --git a/src/BlocksBeyondTheStars.WorldGeneration/WorldGenerator.cs b/src/BlocksBeyondTheStars.WorldGeneration/WorldGenerator.cs index 91626452..0b2b1424 100644 --- a/src/BlocksBeyondTheStars.WorldGeneration/WorldGenerator.cs +++ b/src/BlocksBeyondTheStars.WorldGeneration/WorldGenerator.cs @@ -2885,7 +2885,7 @@ private BlockId SelectOre(PlanetType planet, WorldCalibration calib, long seed, /// A biome resolved for this world: its surface/sub-surface blocks plus the per-biome flora /// theme + density multipliers used when seeding plants and trees (so one region reads lush + tropical /// and another sparse + arid within the same world). - private readonly struct BiomeResolved + internal readonly struct BiomeResolved { public BiomeResolved(BlockId surface, BlockId sub, double floraMul, double treeMul, FloraThemes.Theme theme) { @@ -2905,10 +2905,11 @@ public BiomeResolved(BlockId surface, BlockId sub, double floraMul, double treeM /// /// Resolves the surface/sub-surface blocks (+ per-biome flora theme & density) the planet actually - /// uses. A multi-biome planet lists a *pool* of biomes; how many of them this world uses is randomised - /// per world from the seed (2..pool), so each multi-biome world differs. Single-biome → one entry. + /// uses. A multi-biome planet lists a *pool* of biomes; how many of them this world uses (2..pool) + /// AND which ones make the cut are randomised per world from the seed, so each multi-biome world + /// differs. Single-biome → one entry. /// - private List ResolveBiomes(PlanetType planet) + internal List ResolveBiomes(PlanetType planet) { var planetTheme = FloraThemes.Resolve(planet.FloraTheme); var list = new List(); @@ -2921,15 +2922,31 @@ private List ResolveBiomes(PlanetType planet) int pool = planet.Biomes.Count; int count = pool; + var order = new int[pool]; + for (int i = 0; i < pool; i++) + { + order[i] = i; + } + if (pool > 1) { long s = PlanetSeed(planet) ^ 0x0B10C0; count = 2 + (int)((ulong)(s < 0 ? -s : s) % (ulong)(pool - 1)); // 2..pool, seed-derived + + // WHICH biomes make the cut is shuffled per world too (#696): previously the first N pool + // entries always won, so the tail entries were missing from every world that rolled a + // smaller count. Fisher–Yates seeded from the world so server and client preview agree. + var rng = new DeterministicRandom((PlanetSeed(planet) ^ 0x0B10C7) * 2654435761L); + for (int i = pool - 1; i > 0; i--) + { + int j = rng.Range(0, i); + (order[i], order[j]) = (order[j], order[i]); + } } for (int i = 0; i < count; i++) { - var b = planet.Biomes[i]; + var b = planet.Biomes[order[i]]; var theme = string.IsNullOrWhiteSpace(b.FloraTheme) ? planetTheme : FloraThemes.Resolve(b.FloraTheme); list.Add(new BiomeResolved(ResolveBlock(b.SurfaceBlock), ResolveBlock(b.SubSurfaceBlock), b.FloraDensityMul, b.TreeDensityMul, theme)); diff --git a/tests/BlocksBeyondTheStars.Tests/WorldGenerationTests.cs b/tests/BlocksBeyondTheStars.Tests/WorldGenerationTests.cs index af759fbf..4390ee9a 100644 --- a/tests/BlocksBeyondTheStars.Tests/WorldGenerationTests.cs +++ b/tests/BlocksBeyondTheStars.Tests/WorldGenerationTests.cs @@ -371,6 +371,45 @@ public void MultiBiomeWorld_HasSeveralSurfaceBlocks() Assert.True(surfaces.Count >= 2, $"Expected a multi-biome world to show several surface blocks (got {surfaces.Count})."); } + [Fact] + public void MultiBiomeWorld_ShufflesWhichBiomesMakeTheCut() + { + // #696: the per-world biome subset must vary in MEMBERSHIP, not only in size — before the fix + // the first N pool entries always won, so a later entry could never appear without all earlier + // ones and the first entry was present on every world of the type. + var content = Content(); + var planet = content.GetPlanet("varied")!; // pool: sand, grass, mud, stone + int pool = planet.Biomes.Count; + Assert.True(pool >= 3, "test needs a planet type with a multi-biome pool"); + var firstPoolSurface = content.GetBlock(planet.Biomes[0].SurfaceBlock)!.NumericId; + + bool sawWorldWithoutFirstEntry = false; + var seenSurfaces = new HashSet(); + for (int seed = 1; seed <= 80; seed++) + { + var biomes = new WorldGenerator(seed, content).ResolveBiomes(planet); + Assert.InRange(biomes.Count, 2, pool); + var surfaces = biomes.Select(b => b.Surface).ToHashSet(); + Assert.Equal(biomes.Count, surfaces.Count); // no biome picked twice + seenSurfaces.UnionWith(surfaces); + sawWorldWithoutFirstEntry |= !surfaces.Contains(firstPoolSurface); + } + + Assert.True(sawWorldWithoutFirstEntry, + "across many seeds some world should skip the first pool entry — the subset is shuffled, not a prefix"); + Assert.Equal(pool, seenSurfaces.Count); // every pool entry appears on some world + } + + [Fact] + public void ResolveBiomes_IsDeterministic_ForSameSeed() + { + var content = Content(); + var planet = content.GetPlanet("varied")!; + var a = new WorldGenerator(4242, content).ResolveBiomes(planet); + var b = new WorldGenerator(4242, content).ResolveBiomes(planet); + Assert.Equal(a.Select(x => x.Surface), b.Select(x => x.Surface)); + } + [Fact] public void GeneratedOres_AreAmongPlanetDefinition() {