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
11 changes: 10 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 **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
Expand Down Expand Up @@ -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
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,10 @@
<ProjectReference Include="..\BlocksBeyondTheStars.Shared\BlocksBeyondTheStars.Shared.csproj" />
</ItemGroup>

<ItemGroup>
<InternalsVisibleTo Include="BlocksBeyondTheStars.Tests" />
</ItemGroup>

<PropertyGroup>
<TargetFramework>netstandard2.1</TargetFramework>
<Nullable>enable</Nullable>
Expand Down
27 changes: 22 additions & 5 deletions src/BlocksBeyondTheStars.WorldGeneration/WorldGenerator.cs
Original file line number Diff line number Diff line change
Expand Up @@ -2885,7 +2885,7 @@ private BlockId SelectOre(PlanetType planet, WorldCalibration calib, long seed,
/// <summary>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).</summary>
private readonly struct BiomeResolved
internal readonly struct BiomeResolved
{
public BiomeResolved(BlockId surface, BlockId sub, double floraMul, double treeMul, FloraThemes.Theme theme)
{
Expand All @@ -2905,10 +2905,11 @@ public BiomeResolved(BlockId surface, BlockId sub, double floraMul, double treeM

/// <summary>
/// Resolves the surface/sub-surface blocks (+ per-biome flora theme &amp; 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.
/// </summary>
private List<BiomeResolved> ResolveBiomes(PlanetType planet)
internal List<BiomeResolved> ResolveBiomes(PlanetType planet)
{
var planetTheme = FloraThemes.Resolve(planet.FloraTheme);
var list = new List<BiomeResolved>();
Expand All @@ -2921,15 +2922,31 @@ private List<BiomeResolved> 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));
Expand Down
39 changes: 39 additions & 0 deletions tests/BlocksBeyondTheStars.Tests/WorldGenerationTests.cs
Original file line number Diff line number Diff line change
Expand Up @@ -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<BlockId>();
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()
{
Expand Down