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
16 changes: 16 additions & 0 deletions TODO.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
6 changes: 5 additions & 1 deletion src/BlocksBeyondTheStars.GameServer/GameServerSpaceCombat.cs
Original file line number Diff line number Diff line change
Expand Up @@ -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);
}

Expand Down Expand Up @@ -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
Expand Down
145 changes: 126 additions & 19 deletions src/BlocksBeyondTheStars.GameServer/GameServerSpaceStructure.cs
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@
using BlocksBeyondTheStars.Shared.Definitions;
using BlocksBeyondTheStars.Shared.Geometry;
using BlocksBeyondTheStars.Shared.Primitives;
using BlocksBeyondTheStars.WorldGeneration;

namespace BlocksBeyondTheStars.GameServer;

Expand Down Expand Up @@ -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;

/// <summary>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.</summary>
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;
}

/// <summary>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.</summary>
public int StructureBlockCountForTest(string structureId)
Expand Down Expand Up @@ -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

/// <summary>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 <paramref name="worldPos"/>.</summary>
private SpaceStructure MakeAsteroidStructure(string id, Vector3f worldPos)
/// <summary>#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).</summary>
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),
};

/// <summary>#687: the size roll (voxel sphere radius) — pebbles are common, big boulders rare.</summary>
private static readonly (int Radius, int Weight)[] AsteroidSizes =
{
(1, 45),
(2, 40),
(3, 15),
};

/// <summary>Small deterministic xorshift (#687): the asteroid roll must be identical across restarts and
/// platforms, so no <c>Random</c>, no <c>string.GetHashCode</c> (randomized per process) and no
/// trig-derived floats (libm differs between Windows and Linux).</summary>
private static uint NextAsteroidRand(ref uint state)
{
state ^= state << 13;
state ^= state >> 17;
state ^= state << 5;
return state;
}

private static T PickWeighted<T>((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;
}

/// <summary>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
/// <paramref name="worldPos"/>.</summary>
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++)
Expand All @@ -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);
}
Expand All @@ -897,22 +969,57 @@ private SpaceStructure MakeAsteroidStructure(string id, Vector3f worldPos)
return s;
}

/// <summary>#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.)</summary>
private static List<ItemAmount> 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),
},
};
}

/// <summary>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.</summary>
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 <paramref name="ordinal"/>, 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).</summary>
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(),
Kind = CombatEntityKind.Asteroid,
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;
Expand Down
45 changes: 45 additions & 0 deletions tests/BlocksBeyondTheStars.Tests/SpaceCombatTests.cs
Original file line number Diff line number Diff line change
Expand Up @@ -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()
{
Expand Down