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