diff --git a/CHANGELOG.md b/CHANGELOG.md
index 1416a92c..420d81a6 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -13,6 +13,25 @@ the richer, screenshot-laden versions live there. `(#123)` references the pull r
## [Unreleased]
+### ✨ Real asteroid belts (#683)
+
+- **Asteroids now orbit in belts** (new worlds): all of a system's landable asteroids share 1–2
+ orbit annuli — the outer belt just beyond the outermost planet, big systems sometimes a second
+ inner belt — instead of scattering randomly across planet orbits. Existing worlds keep their
+ layout untouched.
+- **Belts are worth flying to**: every asteroid body carries a cluster of ship-laser-mineable rocks
+ at its position, and launching *from* an asteroid surrounds you with a dense 9-rock field instead
+ of the usual three.
+- **The flight chart shows the belt as a belt**: one translucent band with its own label
+ ("Asteroid belt" / "Asteroidengürtel") instead of a smear of stacked orbit rings.
+
+### ✨ Planetary rings in more colours (#684)
+
+- Ring systems now roll a seeded **material family** — icy white stays the norm, but dusty tan,
+ rocky grey and a rare pale violet appear too — so two ringed planets under the same star no longer
+ wear the same colour. Applies everywhere a ring is drawn (orbit view, surface sky, horizon band),
+ including in existing worlds (same seed → same tilt and band pattern, just richer tints).
+
### ✨ Star systems & planets got real names (#678)
- **Several system-name registries** instead of one pattern: coined proper names ("Tharion"),
diff --git a/TODO.md b/TODO.md
index 590a168c..477acdf7 100644
--- a/TODO.md
+++ b/TODO.md
@@ -118,6 +118,32 @@ With #685's hardness parity the feel scales by material for free: ice pops in on
titanium still wants the tier-2 drill. Hull tracking, laser carving and the client structure
renderer were already size-agnostic — no client change.
+### ★ Asteroid belts: shared orbit annuli + mineable rock density; seeded ring-colour families (#683, #684, 2026-08-03, branch feat/asteroid-belts)
+Asteroids used to scatter uniformly over the whole system disc — regularly inside a planet's orbit
+lane — and the ship-laser rocks were the same trio next to EVERY launch body, so a "belt" had no
+mining pull. New worlds (flag `WorldDescription.AsteroidBelts`, default true on creation like
+`SystemVariance`; `--belts off` escape hatch) now put all landable asteroids of a system on 1–2
+shared orbit annuli: the outer belt one orbit step beyond the outermost planet, big systems (5+
+planets, 4+ asteroids) sometimes a second inner belt in a ≥620-unit orbit gap. Members sit on evenly
+spaced angular slots (Hash01 salt series 8xx, never the body rng — old saves regenerate
+byte-identically with the flag off) so the flight-view clear-gap guarantee holds by construction.
+Mining follows the geometry: every other asteroid body in the system carries a 4-rock mineable
+cluster at its flight-view position (server + client agree via the shared
+`SystemBodyLayout.FlightViewScale`; capped at 24), and launching FROM an asteroid spawns a dense
+9-rock local field (per-instance respawn target; only rocks within 60 units of the launch point
+count, or belt clusters would starve the replenish loop). The flight chart groups belt members into
+ONE translucent band with a localized label (`ui.map.belt`, DE "Asteroidengürtel") instead of N
+stacked orbit rings; legacy scattered worlds keep per-body rings. Star-centring zoom budget in
+`SystemChartLayoutTests` raised 2.0→2.1 (measured ~2.01× with the outer belt). Also #684: planetary
+ring COLOUR was near-constant ice-grey (±0.06 drift; the hue lerp target is the star, same for the
+whole system) — `PlanetRings.TintFor` now rolls a seeded material family per ring (ice white 55 %,
+dusty tan 20 %, rocky grey 15 %, pale violet 10 %), consistent across flight view, surface sky and
+horizon band, retroactive-safe (same RingSeed → same geometry). Gotcha found on the way: a
+never-launched ship's `CurrentLocationId` is the default planet TYPE ("varied"), not a body id — the
+space-instance anchor now falls back to `_meta.ActiveLocationId` for the asteroid logic, while the
+hostile-shading lookup deliberately keeps the legacy raw-id behaviour (fixing it would silently
+raise fresh-start difficulty in pirate-space starts).
+
### ★ 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/client/Assets/BlocksBeyondTheStars/Scripts/PlanetRings.cs b/client/Assets/BlocksBeyondTheStars/Scripts/PlanetRings.cs
index e1f145b1..fe3d76ae 100644
--- a/client/Assets/BlocksBeyondTheStars/Scripts/PlanetRings.cs
+++ b/client/Assets/BlocksBeyondTheStars/Scripts/PlanetRings.cs
@@ -40,12 +40,21 @@ public static float AzimuthDegrees(int ringSeed)
return (float)rng.NextDouble() * 360f;
}
- /// The ring's base colour: mostly pale ice-grey (real rings are water ice) pulled a
- /// little toward the given body/star hue, with a small seeded warm/cool drift per planet.
+ /// The ring's base colour (#684): a seeded MATERIAL FAMILY per ring system — mostly
+ /// pale water-ice white (real rings are water ice, so it stays the norm), sometimes dusty tan
+ /// or bare rocky grey, rarely a pale exotic violet — pulled a little toward the given body/star
+ /// hue, with a small seeded warm/cool drift on top. Before the families every ring in a system
+ /// wore virtually the same ice-grey (the drift alone is ±0.06); now two ringed planets under
+ /// the same star read as different ring systems. Same RingSeed → same family in every view
+ /// (flight orbit, surface sky, horizon band).
public static Color TintFor(int ringSeed, Color bodyHue)
{
var rng = new System.Random(ringSeed * 17 + 3);
- var pale = new Color(0.93f, 0.90f, 0.85f);
+ double family = rng.NextDouble();
+ var pale = family < 0.55 ? new Color(0.93f, 0.90f, 0.85f) // water ice — the common look
+ : family < 0.75 ? new Color(0.86f, 0.73f, 0.55f) // dusty tan
+ : family < 0.90 ? new Color(0.70f, 0.71f, 0.74f) // bare rocky grey
+ : new Color(0.82f, 0.73f, 0.93f); // rare pale violet
var c = Color.Lerp(pale, bodyHue, 0.28f);
float drift = ((float)rng.NextDouble() - 0.5f) * 0.12f;
return new Color(Mathf.Clamp01(c.r + drift), Mathf.Clamp01(c.g), Mathf.Clamp01(c.b - drift));
diff --git a/client/Assets/BlocksBeyondTheStars/Scripts/SpaceMap.cs b/client/Assets/BlocksBeyondTheStars/Scripts/SpaceMap.cs
index 102b256d..8fd8d8e0 100644
--- a/client/Assets/BlocksBeyondTheStars/Scripts/SpaceMap.cs
+++ b/client/Assets/BlocksBeyondTheStars/Scripts/SpaceMap.cs
@@ -38,6 +38,8 @@ public sealed class SpaceMap : MonoBehaviour
private const float OrbitAlpha = 0.34f; // faint enough to stay behind the markers it connects
private const float MaxBodyDisc = 46f; // body discs are clamped to this diameter, however big the body
private const float MarkerPad = MaxBodyDisc * 0.5f + 8f; // chart room a rim body's disc + backing needs
+ private const float BeltGroupGap = 26f; // flight units: asteroid orbit radii this close = one belt (#683)
+ private const float BeltBandPad = 8f; // chart units the belt band extends past its outermost member
private static readonly Color WaypointCol = new Color(1f, 0.85f, 0.3f);
private static readonly Color DiscCol = new Color(0.01f, 0.03f, 0.07f, 0.78f); // WorldMap's backing disc
@@ -217,16 +219,62 @@ private void Build()
Centered(_chart, Vector2.zero, new Vector2(24f, 24f), UiKit.DiscSprite, new Color(1f, 0.94f, 0.74f));
}
+ // Asteroid belts (#683): belt members share (nearly) one orbit radius, so per-member rings
+ // would stack into a smeared blob. Group the star-orbiting asteroid bodies by projected
+ // orbit radius; three or more within a belt-tight spread read as ONE belt — drawn as a
+ // single translucent band with one localized label — and their own rings are suppressed.
+ // Legacy scattered systems rarely group and simply keep their per-body rings.
+ var beltMembers = new HashSet();
+ if (starCentred && landables != null)
+ {
+ var fields = new List<(string Id, float R)>();
+ foreach (var b in landables)
+ {
+ var fnb = BodyFor(b.Id);
+ if (fnb != null && fnb.Kind == "AsteroidField" && string.IsNullOrEmpty(fnb.ParentId))
+ {
+ fields.Add((b.Id, new Vector2(b.Pos.x - _centre.x, b.Pos.z - _centre.z).magnitude));
+ }
+ }
+
+ fields.Sort((a, c) => a.R.CompareTo(c.R));
+ int start = 0;
+ for (int k = 1; k <= fields.Count; k++)
+ {
+ if (k < fields.Count && fields[k].R - fields[k - 1].R <= BeltGroupGap)
+ {
+ continue; // same annulus — keep extending the group
+ }
+
+ if (k - start >= 3)
+ {
+ float rMin = fields[start].R * _scale, rMax = fields[k - 1].R * _scale;
+ float outer = rMax + BeltBandPad;
+ var bandCol = new Color(0.78f, 0.72f, 0.6f, 0.12f);
+ UiOrbitRing.Create(_chart, Vector2.zero, new Vector2(outer * 2f, outer * 2f),
+ bandCol, rMax - rMin + BeltBandPad * 2f);
+ Label(_chart, new Vector2(0f, outer + 12f), L("ui.map.belt"));
+ for (int m = start; m < k; m++)
+ {
+ beltMembers.Add(fields[m].Id);
+ }
+ }
+
+ start = k;
+ }
+ }
+
// Orbit paths: one ring per body that circles the star — planets and the landable asteroid
// bodies. Moons are deliberately left out: they are re-laddered onto clearance slots just
// outside their parent's drawn radius, so their rings would collapse into the planet's disc
// and turn the chart into noise. Each radius comes from the body's own projected position, so
// the ring is guaranteed to pass through its marker whatever the layout passes did to it.
+ // Belt members are covered by their belt's band above instead of a ring each.
if (starCentred && landables != null)
{
foreach (var b in landables)
{
- if (!OrbitsStar(BodyFor(b.Id)))
+ if (beltMembers.Contains(b.Id) || !OrbitsStar(BodyFor(b.Id)))
{
continue;
}
diff --git a/client/Assets/BlocksBeyondTheStars/Scripts/SpaceView.cs b/client/Assets/BlocksBeyondTheStars/Scripts/SpaceView.cs
index 6ebdd16c..a5eb9dd6 100644
--- a/client/Assets/BlocksBeyondTheStars/Scripts/SpaceView.cs
+++ b/client/Assets/BlocksBeyondTheStars/Scripts/SpaceView.cs
@@ -3785,8 +3785,10 @@ public bool TryResolveSpaceWaypoint(out Vector3 target, out float arriveSq)
private float _landTargetSq; // squared distance to the land target (for station-vs-body priority)
private float _nearStationSq; // squared distance to the near station
private float _bounds = Bounds; // flight clamp, enlarged to span the resident system
- private const float SystemViewScale = 0.16f; // system units → flight-view units (kept compact so
- // neighbouring planets are a short cruise apart, not minutes)
+ private const float SystemViewScale = SystemBodyLayout.FlightViewScale; // system units → flight-view units
+ // (kept compact so neighbouring planets are a short cruise
+ // apart, not minutes; shared — the server parks belt rock
+ // clusters at the same transform, #683)
private const float KeepOutMargin = 10f; // how far outside a body's surface the ship is held
private const float LandBand = 40f; // land prompt shows within (body radius + margin + band)
diff --git a/data/locales/de.json b/data/locales/de.json
index 9736a444..c39b48df 100644
--- a/data/locales/de.json
+++ b/data/locales/de.json
@@ -662,6 +662,7 @@
"ui.map.kind_station": "Raumstation",
"ui.map.kind_planet": "Planet",
"ui.map.kind_moon": "Mond",
+ "ui.map.belt": "Asteroidengürtel",
"ui.map.kind_asteroidfield": "Asteroidenfeld",
"ui.map.kind_wreck": "Wrack",
"ui.map.status_notgenerated": "Unerforscht",
diff --git a/data/locales/en.json b/data/locales/en.json
index 3c42c54d..83a49846 100644
--- a/data/locales/en.json
+++ b/data/locales/en.json
@@ -661,6 +661,7 @@
"ui.map.kind_station": "Space Station",
"ui.map.kind_planet": "Planet",
"ui.map.kind_moon": "Moon",
+ "ui.map.belt": "Asteroid belt",
"ui.map.kind_asteroidfield": "Asteroid field",
"ui.map.kind_wreck": "Wreck",
"ui.map.status_notgenerated": "Uncharted",
diff --git a/docs/developer/WORLD_GENERATION.md b/docs/developer/WORLD_GENERATION.md
index 5924fea5..f25c75de 100644
--- a/docs/developer/WORLD_GENERATION.md
+++ b/docs/developer/WORLD_GENERATION.md
@@ -68,6 +68,20 @@ The data shapes are in [`Galaxy.cs`](../../src/BlocksBeyondTheStars.Shared/World
4000–16000 instead of 5000–12000). Bias 0 is bit-identical to the classic hash — that invariant
protects every existing save's terrain. The client receives the bias via `NetBody.SizeBias` and
must pass it to every `CircumferenceFor` call (orbit spheres, sky bodies, pad-map bakes).
+- **Asteroid belts (#683, worlds created with `WorldDescription.AsteroidBelts`):** the system's
+ landable asteroids share 1–2 **orbit annuli** instead of scattering across the whole disc (which
+ regularly parked them inside a planet's orbit lane). The outer belt always sits one orbit step
+ beyond the outermost planet; big systems (5+ planets, 4+ asteroids) may roll a second, inner belt
+ into a ≥620-unit gap between two planet orbits. Members occupy evenly spaced angular slots with a
+ small wobble (radial jitter ±60), so the flight view's clear-gap guarantee holds by construction.
+ Geometry uses the `Hash01` **8xx salt series** and never the body rng. Like `SystemVariance`, the
+ flag **defaults to false** (old saves keep the legacy `DiscPoint` scatter byte-identically) and to
+ **true** on `ServerConfig`'s creation-time description; `--belts off` is the escape hatch
+ (mirrors `--variance`). At runtime every asteroid body also carries a **mineable rock cluster** at its flight-view
+ position (server: `AddBeltRockClusters`, shared transform `SystemBodyLayout.FlightViewScale`),
+ and launching *from* an asteroid spawns a dense 9-rock local field instead of the classic trio.
+ The flight chart draws grouped members as one translucent belt band (`ui.map.belt`) instead of
+ stacked per-body orbit rings.
A `CelestialBody` stores only Id, Name, `Kind` (Planet/Moon/AsteroidField/SpaceStation/Wreck), a
**`PlanetType` key**, and orbit data. The body's *content* is generated only when a player enters it.
diff --git a/docs/user/USER_MANUAL.md b/docs/user/USER_MANUAL.md
index 30377438..377bd615 100644
--- a/docs/user/USER_MANUAL.md
+++ b/docs/user/USER_MANUAL.md
@@ -311,6 +311,14 @@ separate unlock; admins can still disable it through server world rules.
- Fly within local space instances; asteroids + NPC drones can damage hull/shield. Whether you can lose your
ship is set by the world's **"Keep ship on death"** rule (see *Repairing your own ship* above).
+### Asteroid belts
+- In worlds created with belts (the default for new worlds), a system's landable asteroids orbit
+ together in **1–2 shared belts** — the flight chart (M) draws them as one translucent band with an
+ "Asteroid belt" label instead of separate orbit rings.
+- Belts are the place to mine with the ship laser: **every asteroid body has a cluster of mineable
+ rocks floating around it**, and launching from an asteroid you landed on puts you inside a dense
+ local rock field (nine rocks instead of the usual three near planets).
+
### Peaceful NPC trader ships
- Space and busy systems feel alive with **civilian trader traffic**: merchant ships **warp in** at the system
edge, **cruise** to a station to **dock**, or head into the inner system and **land on a planet/moon** if a
diff --git a/src/BlocksBeyondTheStars.GameServer/GameServerSpaceCombat.cs b/src/BlocksBeyondTheStars.GameServer/GameServerSpaceCombat.cs
index 967441ac..47619ab4 100644
--- a/src/BlocksBeyondTheStars.GameServer/GameServerSpaceCombat.cs
+++ b/src/BlocksBeyondTheStars.GameServer/GameServerSpaceCombat.cs
@@ -171,6 +171,11 @@ public sealed class SpaceInstance
/// Spreads successive respawned asteroids so they don't stack on one spot.
public int AsteroidSpawnRotor { get; set; }
+ /// Rock count the LAUNCH-POINT field replenishes toward (#683 S1): the classic 3, or the
+ /// dense-field target when this instance is anchored at an asteroid body (the ship launched inside
+ /// the belt). Belt rock clusters parked at the OTHER asteroid bodies don't count toward it.
+ public int AsteroidFieldTarget { get; set; } = 3;
+
/// Voxel structures floating in this instance (item 20). S1: each present player's own ship,
/// keyed by player id, seeded from its ship-editor design. Later stages add stations + voxel asteroids.
public Dictionary Structures { get; } = new();
@@ -520,23 +525,34 @@ private SpaceInstance CreateSpaceInstance(string instanceId)
{
var instance = new SpaceInstance { Id = instanceId, Kind = "orbit" };
+ string anchorId = instanceId.StartsWith("space:") ? instanceId.Substring("space:".Length) : instanceId;
+ // A never-launched ship still carries its creation placeholder (the default planet TYPE, not a
+ // body id) as its location, so resolve the true start body through the save's active location
+ // when the instance key doesn't name a real body.
+ var anchor = _galaxy?.FindBody(anchorId) ?? _galaxy?.FindBody(_meta.ActiveLocationId);
+
// Asteroids are always present as scenery + mining targets; breaking them is gated at fire
- // time. They start large and split into smaller chunks when destroyed (§8.1).
- int asteroids = 3;
+ // time. Launching from an asteroid body means the ship starts INSIDE the belt, so the local
+ // field is dense (#683 S1); anywhere else it stays the classic sparse trio.
+ int asteroids = anchor?.Kind == CelestialKind.AsteroidField ? DenseAsteroidFieldTarget : AsteroidFieldTarget;
+ instance.AsteroidFieldTarget = asteroids;
for (int i = 0; i < asteroids; i++)
{
// B10: scatter them around the body (a golden-angle ring at varied radius/height) instead of a
// tight line — but inside weapon range (asteroid_breaker reaches ~40) so they stay shootable.
+ // The dense field stacks extra layers in height rather than radius for the same reason.
float ang = i * 2.39996f;
- float rad = 18f + i * 8f; // 18 / 26 / 34
+ float rad = 18f + (i % 3) * 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)),
+ new Vector3f(rad * (float)System.Math.Cos(ang), ((i % 3) - 1) * 9f + (i / 3) * 14f, rad * (float)System.Math.Sin(ang)),
ordinal: i,
broadcast: false);
}
+ AddBeltRockClusters(instance, anchor); // #683 S2: mineable rocks AT the system's asteroid bodies
+
AddStationContacts(instance);
AddPersistedStations(instance); // item 20 S4: re-create player-built stations floating in this instance
@@ -546,9 +562,8 @@ private SpaceInstance CreateSpaceInstance(string instanceId)
if (combatEnabled && !_storyState.GuardianDefeated)
{
// The finale system runs its own scripted ELITE gauntlet (P6 Stage 1) instead of the ambient
- // hostiles — strip the "space:" prefix to recover the anchor body id for the system check.
- string bodyId = instanceId.StartsWith("space:") ? instanceId.Substring("space:".Length) : instanceId;
- if (IsGuardianSystemLocation(bodyId))
+ // hostiles — the anchor body id (the "space:" prefix already stripped above) keys the check.
+ if (IsGuardianSystemLocation(anchorId))
{
SpawnGuardianGauntlet(instance);
}
@@ -559,7 +574,11 @@ private SpaceInstance CreateSpaceInstance(string instanceId)
// hammered the ship the instant it launched (continuous damage → destroyed → respawn at base).
// #547: the system archetype shades the ambient hostility — Desolate space is truly empty
// (no drones, no UFO), a Pirate Haven runs one extra drone when NPC enemies are on at all.
- var archetype = SystemArchetypeOf(_galaxy.FindBody(bodyId)?.SystemId);
+ // Deliberately keyed on the RAW anchor id (not the resolved start-body fallback above):
+ // a never-launched ship carries its type placeholder here, which never resolves — so the
+ // first launch has always been shaded Standard, and changing that would silently raise
+ // the fresh-start difficulty in pirate-space starts.
+ var archetype = SystemArchetypeOf(_galaxy?.FindBody(anchorId)?.SystemId);
int drones = ActivityCount(Rules.SpaceNpcEnemies);
if (archetype == SystemArchetype.Desolate)
{
@@ -607,6 +626,59 @@ private SpaceInstance CreateSpaceInstance(string instanceId)
return instance;
}
+ private const int DenseAsteroidFieldTarget = 9; // launch-field rocks when anchored at an asteroid (#683 S1)
+ private const int BeltClusterRocks = 4; // mineable rocks parked at each other asteroid body (#683 S2)
+ private const int BeltClusterCap = 24; // belt rocks per instance, total (broadcast/entity budget)
+ private const float BeltClusterMinRadius = 18f; // just outside an asteroid body's keep-out shell
+
+ /// #683 S2: parks a small mineable rock cluster at the flight-view position of every OTHER
+ /// landable asteroid body in the resident system, so flying INTO the belt means flying through rocks
+ /// worth mining — not just past the sized, landable bodies. Positions replicate the client's layout
+ /// transform (star-map delta to the anchor × ); the
+ /// client's overlap-relax pass can nudge a BODY slightly off that spot in a legacy scattered layout,
+ /// but the cluster still reads as "the rocks around that asteroid". Deterministic per body id, so
+ /// re-entering the instance rebuilds the same field.
+ private void AddBeltRockClusters(SpaceInstance instance, CelestialBody? anchor)
+ {
+ if (anchor is null || _galaxy?.Systems.FirstOrDefault(s => s.Id == anchor.SystemId) is not { } system)
+ {
+ return;
+ }
+
+ int spawned = 0;
+ foreach (var b in system.Bodies)
+ {
+ if (b.Kind != CelestialKind.AsteroidField || b.Id == anchor.Id || spawned >= BeltClusterCap)
+ {
+ continue;
+ }
+
+ float cx = (b.SystemX - anchor.SystemX) * SystemBodyLayout.FlightViewScale;
+ float cz = (b.SystemZ - anchor.SystemZ) * SystemBodyLayout.FlightViewScale;
+ int h = 17;
+ foreach (char c in b.Id)
+ {
+ h = h * 31 + c;
+ }
+
+ for (int r = 0; r < BeltClusterRocks && spawned < BeltClusterCap; r++, spawned++)
+ {
+ float ang = ((h & 0xff) / 255f) * 6.2831853f + r * 2.39996f; // per-body phase + golden spread
+ float rad = BeltClusterMinRadius + ((h >> (r * 3 + 8)) & 15); // 18..33
+ SpawnAsteroid(instance,
+ new Vector3f(
+ cx + rad * (float)System.Math.Cos(ang),
+ ((r % 3) - 1) * 10f,
+ cz + rad * (float)System.Math.Sin(ang)),
+ // #687 family/size roll: belt rocks use their own ordinal series (well past any
+ // launch-field/respawn ordinal, never the pinned 0) — deterministic per entry
+ // because bodies iterate in stable galaxy order.
+ ordinal: 100 + spawned,
+ broadcast: false);
+ }
+ }
+ }
+
/// P6 Stage 1 — the Guardian system's elite gauntlet: the hardest space wave in the game, ringed
/// around the dormant core. A heavy cruiser flanked by elite UFOs and a swarm of reinforced drones, all
/// well beyond engage range so the approach stays opt-in. Reuses the normal ship-combat resolution +
@@ -1181,13 +1253,18 @@ private void TickSpace(double dt)
private const int AsteroidFieldTarget = 3; // large-equivalent asteroids the field tends toward
private const double AsteroidRespawnInterval = 120.0; // seconds between replenishing spawns (B9: slower respawn)
+ private const float LaunchFieldRange = 60f; // rocks this close to the launch point ARE the local field
/// Slowly refills a mined-out asteroid field back toward its target so it isn't barren for the
- /// rest of the session (a fresh field is still generated on each space entry).
+ /// rest of the session (a fresh field is still generated on each space entry). Only the LAUNCH-POINT
+ /// field counts toward the target — the belt rock clusters parked at the system's other asteroid
+ /// bodies (#683 S2) sit far outside and must not satisfy it, or the
+ /// local field would never replenish in a belt-rich system.
private void RespawnAsteroids(SpaceInstance instance, double dt)
{
- int count = instance.Entities.Count(e => e.Kind == CombatEntityKind.Asteroid);
- if (count >= AsteroidFieldTarget)
+ int count = instance.Entities.Count(e => e.Kind == CombatEntityKind.Asteroid
+ && e.Position.X * e.Position.X + e.Position.Z * e.Position.Z <= LaunchFieldRange * LaunchFieldRange);
+ if (count >= instance.AsteroidFieldTarget)
{
instance.AsteroidRespawnTimer = 0;
return;
@@ -1207,8 +1284,9 @@ private void RespawnAsteroids(SpaceInstance instance, double dt)
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));
// 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);
+ // initial batch (field target + rotor — the launch field may be the dense 9, #683 S1) so
+ // replenished rocks roll fresh families/sizes deterministically.
+ SpawnAsteroid(instance, pos, ordinal: instance.AsteroidFieldTarget + r, broadcast: true);
}
private const double SpottedCalloutCooldown = 15.0; // s between "hostile spotted you" warnings per instance
diff --git a/src/BlocksBeyondTheStars.Shared/Configuration/ServerConfig.cs b/src/BlocksBeyondTheStars.Shared/Configuration/ServerConfig.cs
index 40a6b20d..d74a64f1 100644
--- a/src/BlocksBeyondTheStars.Shared/Configuration/ServerConfig.cs
+++ b/src/BlocksBeyondTheStars.Shared/Configuration/ServerConfig.cs
@@ -86,11 +86,13 @@ public sealed class ServerConfig
/// Authoritative world rules (mode, PvP, hazards, death penalty, cheats, ...).
public GameRules Rules { get; set; } = new();
- /// Universe description used when first creating the world. System variance (#546) is on
- /// here — every NEWLY created world gets archetype-varied star systems — while the
- /// property itself
- /// defaults to false, so a loaded save whose metadata predates the feature stays byte-identical.
- public BlocksBeyondTheStars.Shared.World.WorldDescription World { get; set; } = new() { SystemVariance = true };
+ /// Universe description used when first creating the world. System variance (#546) and
+ /// asteroid belts (#683) are on here — every NEWLY created world gets archetype-varied star systems
+ /// whose asteroids share real belt annuli — while the
+ /// and
+ /// properties
+ /// default to false, so a loaded save whose metadata predates the features stays byte-identical.
+ public BlocksBeyondTheStars.Shared.World.WorldDescription World { get; set; } = new() { SystemVariance = true, AsteroidBelts = true };
/// Optional AI mission backend level (Off keeps the game fully AI-free).
public AiLevel AiLevel { get; set; } = AiLevel.Off;
@@ -582,6 +584,13 @@ public IReadOnlyList ApplyCommandLine(string[]? args)
else if (string.Equals(value, "on", StringComparison.OrdinalIgnoreCase)) { World.SystemVariance = true; applied.Add("variance"); }
else if (string.Equals(value, "off", StringComparison.OrdinalIgnoreCase)) { World.SystemVariance = false; applied.Add("variance"); }
break;
+ case "belts":
+ // Asteroid-belt layout (#683). On for every new world by default; "off" restores the
+ // classic scattered-asteroid disc (same escape-hatch role as "variance").
+ if (bool.TryParse(value, out var ab)) { World.AsteroidBelts = ab; applied.Add("belts"); }
+ else if (string.Equals(value, "on", StringComparison.OrdinalIgnoreCase)) { World.AsteroidBelts = true; applied.Add("belts"); }
+ else if (string.Equals(value, "off", StringComparison.OrdinalIgnoreCase)) { World.AsteroidBelts = false; applied.Add("belts"); }
+ break;
case "danger":
// Global hostility multiplier (#547) — scales space-ambush odds + bandit-camp presence.
if (Enum.TryParse(value, ignoreCase: true, out var dg)) { World.Danger = dg; applied.Add("danger"); }
diff --git a/src/BlocksBeyondTheStars.Shared/World/SystemBodyLayout.cs b/src/BlocksBeyondTheStars.Shared/World/SystemBodyLayout.cs
index 8fd7ad23..e2c97be4 100644
--- a/src/BlocksBeyondTheStars.Shared/World/SystemBodyLayout.cs
+++ b/src/BlocksBeyondTheStars.Shared/World/SystemBodyLayout.cs
@@ -16,6 +16,12 @@ namespace BlocksBeyondTheStars.Shared.World;
///
public static class SystemBodyLayout
{
+ /// System (star-map) units → flight-view units. The client renders the resident system at
+ /// this scale; the server uses the SAME constant when it parks mineable rock clusters at the
+ /// flight-view positions of the system's asteroid bodies (#683) — one number, or the two sides
+ /// disagree about where "at that asteroid" is.
+ public const float FlightViewScale = 0.16f;
+
/// Smallest clear space between two bodies' surfaces, in flight-view units. Deliberately
/// above the ship's keep-out margin (10) so there is always a gap the ship can fly through.
public const float MinBodyGap = 14f;
diff --git a/src/BlocksBeyondTheStars.Shared/World/WorldDescription.cs b/src/BlocksBeyondTheStars.Shared/World/WorldDescription.cs
index 62d93c98..a7d5ce00 100644
--- a/src/BlocksBeyondTheStars.Shared/World/WorldDescription.cs
+++ b/src/BlocksBeyondTheStars.Shared/World/WorldDescription.cs
@@ -120,6 +120,14 @@ public sealed class WorldDescription
/// default description carries true; a loaded save keeps whatever its metadata stored).
public bool SystemVariance { get; set; }
+ /// Asteroid-belt layout (#683): when true, a system's landable asteroids share 1–2 orbit
+ /// annuli (real BELTS — big systems may roll a second, inner one) instead of scattering across the
+ /// whole disc. MUST default to false for the same reason as : the galaxy
+ /// is re-derived from the seed on every start, so flipping this on an existing save would move the
+ /// asteroid bodies players have visited or set waypoints to. New worlds switch it on at creation
+ /// (ServerConfig's default description carries true; a loaded save keeps whatever it stored).
+ public bool AsteroidBelts { get; set; }
+
// --- World options (creation-time; baked into the save's metadata — they shape worldgen) ---
/// Flora/tree density factor applied on top of each world's seeded variation.
diff --git a/src/BlocksBeyondTheStars.WorldGeneration/UniverseGenerator.cs b/src/BlocksBeyondTheStars.WorldGeneration/UniverseGenerator.cs
index e9eb0f34..ecbf4a49 100644
--- a/src/BlocksBeyondTheStars.WorldGeneration/UniverseGenerator.cs
+++ b/src/BlocksBeyondTheStars.WorldGeneration/UniverseGenerator.cs
@@ -333,9 +333,17 @@ public Galaxy Generate()
SystemArchetype.PirateHaven => rng.Range(3, 5), // cover for ambushes
_ => 2 + (rng.NextDouble() < 0.5 ? 1 : 0), // Standard/Hub/Twin: 2 or 3, the legacy draw
};
+ // Belt layout (#683, worlds created with AsteroidBelts): the system's asteroids share 1–2
+ // orbit annuli — a real belt — instead of scattering across the whole disc (which regularly
+ // parked them inside a planet's orbit lane). Geometry comes from its own Hash01 salts
+ // (the 8xx series belongs to belts), NEVER from rng, and the flag defaults off — so every
+ // pre-belt save keeps the legacy DiscPoint scatter byte-identically.
+ var beltRadii = _desc.AsteroidBelts ? BeltRadii(i, system, planets, asteroidCount) : null;
for (int a = 0; a < asteroidCount; a++)
{
- var (ax, az) = DiscPoint(i, planets, 310 + a);
+ var (ax, az) = beltRadii is { Count: > 0 }
+ ? BeltPoint(i, a, asteroidCount, beltRadii)
+ : DiscPoint(i, planets, 310 + a);
system.Bodies.Add(new CelestialBody
{
Id = $"{system.Id}-a{a}",
@@ -486,6 +494,73 @@ public static void EnsureStartPlanetRings(CelestialBody start)
start.RingSeed = 1 + (h & 0x7fffffff) % 999_999;
}
+ // Belt geometry (#683). Radial jitter is kept well under half an orbit step so a belt member can
+ // never wander into a neighbouring lane; the angular slots guarantee the flight view's pair gap
+ // by construction (the separation pass would otherwise shove a member radially OFF the belt).
+ private const float BeltRadialJitter = 120f; // full jitter span (±60) around the belt radius
+ private const float MinInnerBeltGap = 620f; // an inner belt needs this much room between two planet orbits
+
+ /// The system's 1–2 belt annulus radii (#683). The outer belt always exists, one orbit
+ /// step beyond the outermost planet (the main-belt/Kuiper-belt reading). Big systems (5+ planets,
+ /// enough asteroids for two rings) may roll a second, INNER belt — but only into a gap between two
+ /// adjacent planet orbits wide enough that a belt rock can never crowd a planet even when their
+ /// angles line up.
+ private List BeltRadii(int systemIndex, StarSystem system, int planets, int asteroidCount)
+ {
+ var orbitRadii = new List();
+ foreach (var b in system.Bodies)
+ {
+ if (b.Kind == CelestialKind.Planet)
+ {
+ orbitRadii.Add(System.MathF.Sqrt(b.SystemX * b.SystemX + b.SystemZ * b.SystemZ));
+ }
+ }
+
+ orbitRadii.Sort();
+ float outermost = orbitRadii.Count > 0 ? orbitRadii[orbitRadii.Count - 1] : BaseOrbit;
+ var radii = new List
+ {
+ outermost + OrbitStep + (Hash01(systemIndex, 800, 1) - 0.5f) * BeltRadialJitter,
+ };
+
+ if (planets >= 5 && asteroidCount >= 4 && Hash01(systemIndex, 800, 2) < 0.5f)
+ {
+ float bestGap = 0f, bestMid = 0f;
+ for (int k = 1; k < orbitRadii.Count; k++)
+ {
+ float gap = orbitRadii[k] - orbitRadii[k - 1];
+ if (gap > bestGap)
+ {
+ bestGap = gap;
+ bestMid = (orbitRadii[k] + orbitRadii[k - 1]) * 0.5f;
+ }
+ }
+
+ if (bestGap >= MinInnerBeltGap)
+ {
+ radii.Add(bestMid);
+ }
+ }
+
+ return radii;
+ }
+
+ /// A seeded point on one of the system's belt annuli (#683). Members go round-robin over
+ /// the belts; within one belt they occupy evenly spaced angular slots with a small seeded wobble
+ /// (≤¼ slot), so even the densest belt keeps at least half a slot of arc between neighbours —
+ /// comfortably above the flight view's required clear gap at any belt radius.
+ private (float X, float Z) BeltPoint(int systemIndex, int asteroidIndex, int asteroidCount, List radii)
+ {
+ int belt = asteroidIndex % radii.Count;
+ int slot = asteroidIndex / radii.Count;
+ int slots = (asteroidCount - belt + radii.Count - 1) / radii.Count; // members on THIS belt
+ float slotArc = Tau / System.Math.Max(1, slots);
+ float angle = Hash01(systemIndex, 810 + belt, 1) * Tau
+ + (slot + (Hash01(systemIndex, 820 + asteroidIndex, 1) - 0.5f) * 0.5f) * slotArc;
+ float radius = radii[belt] + (Hash01(systemIndex, 820 + asteroidIndex, 2) - 0.5f) * BeltRadialJitter;
+ return (radius * System.MathF.Cos(angle), radius * System.MathF.Sin(angle));
+ }
+
/// A seeded point on the system disc (out to roughly the outermost planet's orbit).
private (float X, float Z) DiscPoint(int systemIndex, int planets, int salt)
{
diff --git a/tests/BlocksBeyondTheStars.Client.Tests/SystemBodyLayoutTests.cs b/tests/BlocksBeyondTheStars.Client.Tests/SystemBodyLayoutTests.cs
index a46b947c..672184d4 100644
--- a/tests/BlocksBeyondTheStars.Client.Tests/SystemBodyLayoutTests.cs
+++ b/tests/BlocksBeyondTheStars.Client.Tests/SystemBodyLayoutTests.cs
@@ -66,11 +66,13 @@ public void EveryBodyKeepsItsClearGap_AcrossRealGeneratedSystems()
// Replay classic worlds AND archetype-varied ones (#546): the lone giant's 8-moon ladder and the
// size-biased bodies must keep the same clear-gap guarantee as the uniform layout.
- // Runs 1..40 use the classic description, 41..80 re-run the same 40 seeds with variance on.
- for (long run = 1; run <= 80; run++)
+ // Runs 1..40 use the classic description, 41..80 re-run the same 40 seeds with variance on, and
+ // 81..120 add asteroid belts (#683) on top — belt members share an orbit annulus, so they are
+ // exactly the pairs the angular-slot spacing must keep apart.
+ for (long run = 1; run <= 120; run++)
{
- long seed = run <= 40 ? run : run - 40;
- var desc = new WorldDescription { SystemVariance = run > 40 };
+ long seed = ((run - 1) % 40) + 1;
+ var desc = new WorldDescription { SystemVariance = run > 40, AsteroidBelts = run > 80 };
var galaxy = new UniverseGenerator(seed, desc, content).Generate();
foreach (var system in galaxy.Systems)
{
diff --git a/tests/BlocksBeyondTheStars.Client.Tests/SystemChartLayoutTests.cs b/tests/BlocksBeyondTheStars.Client.Tests/SystemChartLayoutTests.cs
index 7aba456d..887a0729 100644
--- a/tests/BlocksBeyondTheStars.Client.Tests/SystemChartLayoutTests.cs
+++ b/tests/BlocksBeyondTheStars.Client.Tests/SystemChartLayoutTests.cs
@@ -87,10 +87,12 @@ private static float Fit(List bodies, float centreX, float centreZ)
private static IEnumerable<(StarSystem System, CelestialBody Home)> RealSystems(int runs = 30)
{
var content = ContentLoader.LoadFromDirectory(ClientTestPaths.DataDir());
- for (long run = 1; run <= runs * 2; run++)
+ for (long run = 1; run <= runs * 3; run++)
{
- long seed = run <= runs ? run : run - runs;
- var desc = new WorldDescription { SystemVariance = run > runs }; // classic AND archetype-varied
+ long seed = ((run - 1) % runs) + 1;
+ // Classic, archetype-varied, and belt-layout (#683) sweeps: belt members share an orbit
+ // annulus, so they are the bodies most likely to stress the fit/ring invariants together.
+ var desc = new WorldDescription { SystemVariance = run > runs, AsteroidBelts = run > runs * 2 };
foreach (var system in new UniverseGenerator(seed, desc, content).Generate().Systems)
{
var home = system.Bodies.FirstOrDefault(b => b.Kind == CelestialKind.Planet);
@@ -169,9 +171,10 @@ public void StarCentring_CostsAtMostABoundedZoomOut()
// Centring on the star is what makes the orbit paths possible, and it is not free: where the
// launch body sits INSIDE a far-flung asteroid's orbit, framing on the launch body is tighter
// than framing on the star, so the chart zooms out. Measured worst case across real systems is
- // ~1.72×, and the 12-unit minimum disc size keeps every marker legible through it. This bounds
- // the cost so a future layout change can't quietly make the chart useless.
- const float Budget = 2.0f;
+ // ~1.72× classic and ~2.01× with asteroid belts (#683 — the outer belt orbits one step beyond
+ // the outermost planet by design), and the 12-unit minimum disc size keeps every marker legible
+ // through it. This bounds the cost so a future layout change can't quietly make the chart useless.
+ const float Budget = 2.1f;
float worst = 1f;
string worstId = string.Empty;
diff --git a/tests/BlocksBeyondTheStars.Tests/SpaceCombatTests.cs b/tests/BlocksBeyondTheStars.Tests/SpaceCombatTests.cs
index 6047eff6..5525c3c0 100644
--- a/tests/BlocksBeyondTheStars.Tests/SpaceCombatTests.cs
+++ b/tests/BlocksBeyondTheStars.Tests/SpaceCombatTests.cs
@@ -9,6 +9,7 @@
using BlocksBeyondTheStars.Shared.Configuration;
using BlocksBeyondTheStars.Shared.Content;
using BlocksBeyondTheStars.Shared.Geometry;
+using BlocksBeyondTheStars.Shared.World;
using Xunit;
using SvGameServer = BlocksBeyondTheStars.GameServer.GameServer;
@@ -66,11 +67,76 @@ public void EnterSpace_SpawnsAsteroids_ButNoDronesWhenCombatOff()
Assert.True(server.InSpace("Pilot"));
var entities = server.SpaceEntitiesFor("Pilot");
- Assert.Equal(3, entities.Count(e => e.Kind == CombatEntityKind.Asteroid));
+ // The classic trio hugs the launch point; any further rocks belong to the belt clusters
+ // parked at the system's asteroid bodies (#683 S2), well outside the local field.
+ Assert.Equal(3, entities.Count(e => e.Kind == CombatEntityKind.Asteroid
+ && (e.Position.X * e.Position.X) + (e.Position.Z * e.Position.Z) <= 60f * 60f));
Assert.DoesNotContain(entities, e => e.Hostile);
}
}
+ [Fact]
+ public void EnterSpace_ParksMineableClustersAtTheSystemsAsteroidBodies()
+ {
+ // #683 S2: flying INTO the belt means flying through mineable rocks — every landable asteroid
+ // body in the resident system carries its own cluster at its flight-view position.
+ var server = NewServer("beltrocks", r =>
+ {
+ r.FreeSpaceFlight = true;
+ r.SpaceCombat = SpaceCombatMode.Off;
+ }, out var repo);
+ using (repo)
+ {
+ server.AddLocalPlayer("Pilot");
+ var system = server.Galaxy.Systems.First(s => s.Bodies.Any(b => b.Id == server.ActiveLocationId));
+ var anchor = system.Bodies.First(b => b.Id == server.ActiveLocationId);
+ var fields = system.Bodies.Where(b => b.Kind == CelestialKind.AsteroidField).ToList();
+ Assert.NotEmpty(fields); // the home system always rolls asteroid bodies (never Desolate)
+
+ server.EnterSpace("Pilot");
+ var rocks = server.SpaceEntitiesFor("Pilot")
+ .Where(e => e.Kind == CombatEntityKind.Asteroid).ToList();
+
+ foreach (var f in fields)
+ {
+ float cx = (f.SystemX - anchor.SystemX) * SystemBodyLayout.FlightViewScale;
+ float cz = (f.SystemZ - anchor.SystemZ) * SystemBodyLayout.FlightViewScale;
+ int near = rocks.Count(r =>
+ {
+ float dx = r.Position.X - cx, dz = r.Position.Z - cz;
+ return (dx * dx) + (dz * dz) <= 40f * 40f;
+ });
+ Assert.True(near >= 4, $"{f.Id}: only {near} mineable rocks parked at the belt body");
+ }
+ }
+ }
+
+ [Fact]
+ public void EnterSpace_AtAnAsteroidBody_SpawnsADenseLocalField()
+ {
+ // #683 S1: launching FROM an asteroid means the ship starts inside the belt — the local field
+ // is dense (9 rocks) instead of the classic trio.
+ var server = NewServer("densefield", r =>
+ {
+ r.FreeSpaceFlight = true;
+ r.SpaceCombat = SpaceCombatMode.Off;
+ }, out var repo);
+ using (repo)
+ {
+ server.AddLocalPlayer("Pilot");
+ server.Ship.Modules.Add("jump_generator");
+ var rock = server.Galaxy.AllBodies().First(b =>
+ b.Kind == CelestialKind.AsteroidField && !string.IsNullOrEmpty(b.PlanetType));
+ server.Travel("Pilot", rock.Id);
+ Assert.Equal(rock.Id, server.ActiveLocationId);
+
+ server.EnterSpace("Pilot");
+ int local = server.SpaceEntitiesFor("Pilot").Count(e => e.Kind == CombatEntityKind.Asteroid
+ && (e.Position.X * e.Position.X) + (e.Position.Z * e.Position.Z) <= 60f * 60f);
+ Assert.True(local >= 9, $"launching inside the belt should surround the ship with rocks, got {local}");
+ }
+ }
+
[Fact]
public void EnterSpace_SpawnsDrones_WhenCombatAndNpcEnabled()
{
@@ -289,7 +355,10 @@ public void SpaceAsteroids_RollSeededFamiliesAndSizes()
server.AddLocalPlayer("Pilot");
server.EnterSpace("Pilot");
return server.SpaceEntitiesFor("Pilot")
- .Where(e => e.Kind == CombatEntityKind.Asteroid)
+ .Where(e => e.Kind == CombatEntityKind.Asteroid
+ // Launch field only — the belt rock clusters (#683 S2) park at the system's other
+ // asteroid bodies, far outside the local field this test pins.
+ && (e.Position.X * e.Position.X) + (e.Position.Z * e.Position.Z) <= 60f * 60f)
.Select(e => (server.StructureBlockCountForTest(e.Id), server.StructureCellForTest(e.Id, 0, 0, 0)))
.ToList();
}
diff --git a/tests/BlocksBeyondTheStars.Tests/UniverseTests.cs b/tests/BlocksBeyondTheStars.Tests/UniverseTests.cs
index cfcab8d0..dd58dcc7 100644
--- a/tests/BlocksBeyondTheStars.Tests/UniverseTests.cs
+++ b/tests/BlocksBeyondTheStars.Tests/UniverseTests.cs
@@ -346,6 +346,93 @@ public void Rings_StartPlanetGuarantee_RingsOnlyWhatItShould()
Assert.Equal(0, moon.RingSeed);
}
+ // --- Asteroid belts (#683) ---
+
+ private static WorldDescription BeltDesc(int systems = 120) => new()
+ {
+ StarSystemCount = systems,
+ SystemVariance = true,
+ AsteroidBelts = true,
+ PlanetsPerSystemMin = 2,
+ PlanetsPerSystemMax = 6,
+ SpaceStations = Frequency.Rare,
+ };
+
+ [Fact]
+ public void Belts_AsteroidsShareOneOrTwoAnnuli_ClearOfPlanetOrbits()
+ {
+ var galaxy = new UniverseGenerator(42, BeltDesc(), _content).Generate();
+ int beltSystems = 0, twoBeltSystems = 0;
+
+ foreach (var sys in galaxy.Systems)
+ {
+ var radii = sys.Bodies.Where(b => b.Kind == CelestialKind.AsteroidField)
+ .Select(b => MathF.Sqrt(b.SystemX * b.SystemX + b.SystemZ * b.SystemZ))
+ .OrderBy(r => r).ToList();
+ if (radii.Count == 0)
+ {
+ continue;
+ }
+
+ beltSystems++;
+
+ // Cluster the orbit radii: one belt's members span at most the full radial jitter (120),
+ // so a wider gap can only be the space between two DIFFERENT belts.
+ var belts = new List> { new() { radii[0] } };
+ for (int k = 1; k < radii.Count; k++)
+ {
+ if (radii[k] - radii[k - 1] > 130f)
+ {
+ belts.Add(new List());
+ }
+
+ belts[^1].Add(radii[k]);
+ }
+
+ Assert.InRange(belts.Count, 1, 2);
+ Assert.All(belts, belt => Assert.True(belt[^1] - belt[0] <= 130f,
+ $"{sys.Id}: belt spans {belt[^1] - belt[0]} — wider than the jitter allows"));
+ if (belts.Count == 2)
+ {
+ twoBeltSystems++;
+ }
+
+ // The whole point (#683): no asteroid ever sits in a planet's orbit lane again.
+ var planetOrbits = sys.Bodies.Where(b => b.Kind == CelestialKind.Planet)
+ .Select(b => MathF.Sqrt(b.SystemX * b.SystemX + b.SystemZ * b.SystemZ)).ToList();
+ foreach (var r in radii)
+ {
+ foreach (var p in planetOrbits)
+ {
+ Assert.True(System.Math.Abs(r - p) >= 200f,
+ $"{sys.Id}: asteroid at orbit {r} crowds a planet orbit at {p}");
+ }
+ }
+ }
+
+ Assert.True(beltSystems >= 80, $"only {beltSystems} systems carry asteroids — sweep too thin");
+ Assert.True(twoBeltSystems >= 1, "no big system ever rolled a second belt across 120 systems");
+ }
+
+ [Fact]
+ public void Belts_LeavePlanetsAndMoonsExactlyWhereTheLegacyLayoutPutThem()
+ {
+ // The flag may only move ASTEROID bodies (and, transitively, the free-floating stations/wrecks
+ // that separate away from them). Planets and moons are placed before the asteroid pass and must
+ // stay byte-identical — they anchor already-visited worlds' sky and travel targets.
+ var flagOff = BeltDesc();
+ flagOff.AsteroidBelts = false;
+ var off = new UniverseGenerator(42, flagOff, _content).Generate();
+ var on = new UniverseGenerator(42, BeltDesc(), _content).Generate();
+
+ Assert.Equal(BodyKey(off), BodyKey(on)); // ids/kinds/types never depend on the flag
+
+ static IEnumerable<(string, float, float)> Anchored(Galaxy g) => g.AllBodies()
+ .Where(b => b.Kind is CelestialKind.Planet or CelestialKind.Moon)
+ .Select(b => (b.Id, b.SystemX, b.SystemZ));
+ Assert.Equal(Anchored(off), Anchored(on));
+ }
+
// --- System archetype variance (#546/#549) ---
private static WorldDescription VarianceDesc(int systems = 150) => new()