diff --git a/TODO.md b/TODO.md index 57270c23..590a168c 100644 --- a/TODO.md +++ b/TODO.md @@ -102,6 +102,22 @@ Per-item detail lives in the dated work log below. **Since 2026-07 versions are --- +### ★ Space rocks come in sizes and flavors: seeded mineral families, water ice included (#687, 2026-08-03, branch feat/mini-asteroid-variety) +Every mineable space rock was the same clone: a fixed r=2 sphere with a titanium core and an +iron/copper/stone shell. Rocks now roll a seeded FAMILY — stony (~40 %), metallic (~25 %), +icy (~20 %, made of hand-mineable water ice around a rocky heart), carbonaceous (~10 %) and rare +crystalline (~5 %), mirroring the landable families from #515 — and a SIZE: common r=1 pebbles +(7 blocks), the classic r=2 (33) and rare r=3 boulders (123) whose cores grow with the rock. The +shoot-down loot matches the family and pays out more for boulders. Rock 0 of every field stays +pinned to the classic metallic r=2 (a guaranteed titanium core — mirrors the start-planet ring +pin), so progression and the mining tests keep their anchor. Rolls come from a deterministic +xorshift seeded by instance id hash + world seed + spawn ordinal — integer state only, no +`Random`, no `string.GetHashCode` (process-randomized), no trig floats (Win/Linux libm) — so the +same world always grows the same rocks, restart-safe (locked by `SpaceAsteroids_RollSeededFamiliesAndSizes`). +With #685's hardness parity the feel scales by material for free: ice pops in one bare-hand hit, +titanium still wants the tier-2 drill. Hull tracking, laser carving and the client structure +renderer were already size-agnostic — no client change. + ### ★ EVA asteroid mining plays by the rules: drill tiers, hardness hits, no lost ore (#685, 2026-08-03, branch fix/eva-asteroid-mining-hardness) During a spacewalk any asteroid block popped with a SINGLE bare-hand click — no drill, no hardness, titanium included — while the same block on a planet takes a tier-2 drill and several timed hits. And diff --git a/src/BlocksBeyondTheStars.GameServer/GameServerSpaceCombat.cs b/src/BlocksBeyondTheStars.GameServer/GameServerSpaceCombat.cs index 6a514e78..967441ac 100644 --- a/src/BlocksBeyondTheStars.GameServer/GameServerSpaceCombat.cs +++ b/src/BlocksBeyondTheStars.GameServer/GameServerSpaceCombat.cs @@ -530,8 +530,10 @@ private SpaceInstance CreateSpaceInstance(string instanceId) float ang = i * 2.39996f; float rad = 18f + i * 8f; // 18 / 26 / 34 // item 20 S3: each asteroid is a voxel ore body (entity + structure) you can shoot AND EVA-mine. + // #687: the ordinal seeds the family/size roll (0 = the pinned classic metallic rock). SpawnAsteroid(instance, new Vector3f(rad * (float)System.Math.Cos(ang), (i - 1) * 9f, rad * (float)System.Math.Sin(ang)), + ordinal: i, broadcast: false); } @@ -1204,7 +1206,9 @@ private void RespawnAsteroids(SpaceInstance instance, double dt) float rang = r * 2.39996f; float rrad = 22f + (r % 3) * 6f; // 22 / 28 / 34 var pos = new Vector3f(rrad * (float)System.Math.Cos(rang), ((r % 5) - 2) * 8f, rrad * (float)System.Math.Sin(rang)); - SpawnAsteroid(instance, pos, broadcast: true); // item 20 S3: voxel ore body (sends its mesh + state) + // item 20 S3: voxel ore body (sends its mesh + state). #687: respawn ordinals continue past the + // initial batch (3 + rotor) so replenished rocks roll fresh families/sizes deterministically. + SpawnAsteroid(instance, pos, ordinal: 3 + r, broadcast: true); } private const double SpottedCalloutCooldown = 15.0; // s between "hostile spotted you" warnings per instance diff --git a/src/BlocksBeyondTheStars.GameServer/GameServerSpaceStructure.cs b/src/BlocksBeyondTheStars.GameServer/GameServerSpaceStructure.cs index 1d9d3086..4af06812 100644 --- a/src/BlocksBeyondTheStars.GameServer/GameServerSpaceStructure.cs +++ b/src/BlocksBeyondTheStars.GameServer/GameServerSpaceStructure.cs @@ -7,6 +7,7 @@ using BlocksBeyondTheStars.Shared.Definitions; using BlocksBeyondTheStars.Shared.Geometry; using BlocksBeyondTheStars.Shared.Primitives; +using BlocksBeyondTheStars.WorldGeneration; namespace BlocksBeyondTheStars.GameServer; @@ -777,6 +778,21 @@ public ushort StructureBlockForTest(string playerId, int x, int y, int z) ? s.Get(new Vector3i(x, y, z)).Value : BlockId.AirValue; + /// Test/inspection: the block id at a cell of ANY structure (by id) across the space instances — + /// for asserting asteroid family compositions (#687). Air if there is no such structure/cell. + public ushort StructureCellForTest(string structureId, int x, int y, int z) + { + foreach (var inst in _spaceInstances.Values) + { + if (inst.Structures.TryGetValue(structureId, out var s)) + { + return s.Get(new Vector3i(x, y, z)).Value; + } + } + + return BlockId.AirValue; + } + /// Test/inspection: the number of solid cells in a structure (by id) across any space instance — for /// asserting asteroid carving/mining (item 20 S3). 0 if no such structure. public int StructureBlockCountForTest(string structureId) @@ -857,22 +873,79 @@ private void SendShipDesign(PlayerSession session, SpaceStructure s, string? kin }); } - // ---------------- item 20 S3: voxel ore asteroids ---------------- + // ---------------- item 20 S3 + #687: voxel ore asteroids ---------------- - private const int AsteroidVoxelRadius = 2; // a ~5-block rough sphere of ore + private const int AsteroidVoxelRadius = 2; // the classic rock: a ~5-block rough sphere of ore - /// Builds a small voxel ore body (a rough sphere of iron/copper/titanium ore + stone) centred on the - /// origin, for an in-space asteroid (item 20 S3). The structure rides at . - private SpaceStructure MakeAsteroidStructure(string id, Vector3f worldPos) + /// #687: the mineable space rocks roll a seeded FAMILY (which minerals they carry). Weights make + /// stony rocks common and crystal ones rare; icy rocks are water-ice deposits. Mirrors the landable + /// asteroid families (#515). + private static readonly (string Family, int Weight)[] AsteroidFamilies = { - var iron = _content.GetBlock("iron_ore")?.NumericId ?? BlockId.Air; - var copper = _content.GetBlock("copper_ore")?.NumericId ?? iron; - var titanium = _content.GetBlock("titanium_ore")?.NumericId ?? iron; - var stone = _content.GetBlock("stone")?.NumericId ?? iron; + ("stony", 40), + ("metallic", 25), + ("icy", 20), + ("carbonaceous", 10), + ("crystalline", 5), + }; + + /// #687: the size roll (voxel sphere radius) — pebbles are common, big boulders rare. + private static readonly (int Radius, int Weight)[] AsteroidSizes = + { + (1, 45), + (2, 40), + (3, 15), + }; + + /// Small deterministic xorshift (#687): the asteroid roll must be identical across restarts and + /// platforms, so no Random, no string.GetHashCode (randomized per process) and no + /// trig-derived floats (libm differs between Windows and Linux). + private static uint NextAsteroidRand(ref uint state) + { + state ^= state << 13; + state ^= state >> 17; + state ^= state << 5; + return state; + } + + private static T PickWeighted((T Value, int Weight)[] table, ref uint state) + { + int total = table.Sum(e => e.Weight); + int roll = (int)(NextAsteroidRand(ref state) % (uint)total); + foreach (var (value, weight) in table) + { + roll -= weight; + if (roll < 0) + { + return value; + } + } + + return table[^1].Value; + } + + /// Builds a small voxel ore body — a rough sphere of the family's minerals around its core — for + /// an in-space asteroid (item 20 S3; families + sizes #687). The structure rides at + /// . + private SpaceStructure MakeAsteroidStructure(string id, Vector3f worldPos, string family, int r) + { + var stone = _content.GetBlock("stone")?.NumericId ?? BlockId.Air; + BlockId B(string key) => _content.GetBlock(key)?.NumericId ?? stone; + + // Per family: the core mineral + the shell mix, indexed by the same deterministic vein pattern for + // every family ((x+y+z) parity). The "metallic" row is the classic pre-#687 rock, byte-identical. + var (core, vein, even, odd) = family switch + { + "metallic" => (B("titanium_ore"), B("copper_ore"), stone, B("iron_ore")), + "icy" => (stone, stone, B("ice"), B("ice")), // a rocky heart under hand-mineable water ice + "carbonaceous" => (B("carbon"), stone, B("carbon"), B("carbon")), + "crystalline" => (B("crystal"), B("crystal"), stone, stone), + _ => (B("iron_ore"), B("iron_ore"), stone, stone), // stony: iron veins in plain rock + }; var s = new SpaceStructure { Id = id, Kind = "asteroid", OwnerId = string.Empty, Position = worldPos }; - int r = AsteroidVoxelRadius; int rSq = r * r; + int coreSq = r <= 1 ? 0 : r == 2 ? 1 : 2; // the core grows a little with the rock for (int x = -r; x <= r; x++) for (int y = -r; y <= r; y++) for (int z = -r; z <= r; z++) @@ -883,12 +956,11 @@ private SpaceStructure MakeAsteroidStructure(string id, Vector3f worldPos) continue; // carve to a rough sphere } - // A titanium core, an iron/copper shell, a little stone — a deterministic veined mix. BlockId block; - if (dSq <= 1) { block = titanium; } - else if (((x + y + z) & 3) == 0) { block = copper; } - else if (((x + y + z) & 1) == 0) { block = stone; } - else { block = iron; } + if (dSq <= coreSq) { block = core; } + else if (((x + y + z) & 3) == 0) { block = vein; } + else if (((x + y + z) & 1) == 0) { block = even; } + else { block = odd; } s.Set(new Vector3i(x, y, z), block); } @@ -897,11 +969,43 @@ private SpaceStructure MakeAsteroidStructure(string id, Vector3f worldPos) return s; } + /// #687: what a shot-down asteroid bursts into, by family — bigger rocks pay out more. (EVA + /// mining ignores this and yields per-block drops instead, #685.) + private static List AsteroidLoot(string family, int radius) + { + bool big = radius >= 3; + return family switch + { + "icy" => new() { new ItemAmount("ice", big ? 10 : 6) }, + "carbonaceous" => new() { new ItemAmount("carbon", big ? 8 : 5) }, + "crystalline" => new() { new ItemAmount("crystal", big ? 5 : 3) }, + "stony" => new() { new ItemAmount("iron_ore", big ? 7 : 4) }, + _ => new() + { + new ItemAmount("iron_ore", big ? 8 : 5), + new ItemAmount("titanium_ore", big ? 4 : 2), + }, + }; + } + /// Spawns one asteroid: a combat entity (for ship targeting/firing + respawn accounting) paired with /// a voxel ore structure of the same id (for rendering + EVA mining) — item 20 S3. The entity's hull tracks - /// its block count so laser fire carves the rock down as it depletes. - private void SpawnAsteroid(SpaceInstance instance, Vector3f pos, bool broadcast) + /// its block count so laser fire carves the rock down as it depletes. #687: the family/size roll is seeded + /// from the instance id + spawn , so the same world always grows the same rocks; + /// ordinal 0 is pinned to the classic metallic r=2 rock so every field guarantees one titanium core + /// (mirrors the start-planet ring pin). + private void SpawnAsteroid(SpaceInstance instance, Vector3f pos, int ordinal, bool broadcast) { + string family = "metallic"; + int radius = AsteroidVoxelRadius; + if (ordinal > 0) + { + long h = WorldGenerator.StableHash(instance.Id); + uint state = (uint)(h ^ (h >> 32) ^ (_config.Seed * 397L) ^ (ordinal * 668265263L)) | 1u; + family = PickWeighted(AsteroidFamilies, ref state); + radius = PickWeighted(AsteroidSizes, ref state); + } + var entity = new CombatEntity { Id = NextEntityId(), @@ -909,10 +1013,13 @@ private void SpawnAsteroid(SpaceInstance instance, Vector3f pos, bool broadcast) Hostile = false, AsteroidTier = 0, // voxel asteroids don't split — they carve + deplete Position = pos, - Loot = { new ItemAmount("iron_ore", 5), new ItemAmount("titanium_ore", 2) }, }; + foreach (var drop in AsteroidLoot(family, radius)) + { + entity.Loot.Add(drop); + } - var s = MakeAsteroidStructure(entity.Id, pos); + var s = MakeAsteroidStructure(entity.Id, pos, family, radius); entity.HullMax = entity.Hull = System.Math.Max(8, s.Cells.Count); // hull == blocks → carve maps to damage instance.Entities.Add(entity); instance.Structures[s.Id] = s; diff --git a/tests/BlocksBeyondTheStars.Tests/SpaceCombatTests.cs b/tests/BlocksBeyondTheStars.Tests/SpaceCombatTests.cs index 1440b42a..6047eff6 100644 --- a/tests/BlocksBeyondTheStars.Tests/SpaceCombatTests.cs +++ b/tests/BlocksBeyondTheStars.Tests/SpaceCombatTests.cs @@ -270,6 +270,51 @@ public void EvaAsteroidMine_RefusedWhenTheDropsWouldNotFit() } } + [Fact] + public void SpaceAsteroids_RollSeededFamiliesAndSizes() + { + // #687: the rocks of a field are no longer identical clones. Rock 0 is pinned to the classic + // metallic r=2 sphere (a guaranteed titanium core — the anchor for progression + the mining tests), + // the rest roll seeded families/sizes — and the same world always grows the same rocks again. + ushort titanium = _content.GetBlock("titanium_ore")!.NumericId.Value; + + List<(int Count, ushort Core)> Snapshot(string name, long seed) + { + using var repo = new SqliteWorldRepository(new SaveGamePaths(_root, name)); + var st = new LoopbackServerTransport(new LoopbackLink()); + var config = new ServerConfig { WorldName = name, Seed = seed, AutoSaveIntervalMinutes = 9999, PlaceStarterShip = false }; + config.Rules.FreeSpaceFlight = true; + var server = new SvGameServer(config, _content, st, repo); + server.Start(); + server.AddLocalPlayer("Pilot"); + server.EnterSpace("Pilot"); + return server.SpaceEntitiesFor("Pilot") + .Where(e => e.Kind == CombatEntityKind.Asteroid) + .Select(e => (server.StructureBlockCountForTest(e.Id), server.StructureCellForTest(e.Id, 0, 0, 0))) + .ToList(); + } + + var first = Snapshot("astroll", 1); + Assert.Equal(3, first.Count); + Assert.Equal(33, first[0].Count); // the pinned classic rock: r=2 sphere … + Assert.Equal(titanium, first[0].Core); // … with its titanium core + + // Every rock is a legal sphere size (r = 1 / 2 / 3 → 7 / 33 / 123 cells). + Assert.All(first, rock => Assert.Contains(rock.Count, new[] { 7, 33, 123 })); + + // The same world rolls the same rocks again (deterministic — restart-safe). + Assert.Equal(first, Snapshot("astroll", 1)); + + // And across a handful of seeds the rolled rocks are NOT all clones of the classic one. + var rolled = new List<(int Count, ushort Core)>(); + for (long seed = 2; seed <= 5; seed++) + { + rolled.AddRange(Snapshot("astroll" + seed, seed).Skip(1)); + } + + Assert.Contains(rolled, rock => rock.Count != 33 || rock.Core != titanium); + } + [Fact] public void PlayerStation_Deploys_BuildsOut_AndCommissions() {