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()
{