From 92d6153c2e60120233afe4e02e94214be4d4de87 Mon Sep 17 00:00:00 2001
From: Meta Vens
Date: Sun, 18 Oct 2026 16:28:42 +0200
Subject: [PATCH 001/444] Trojan swarms: a belt on a copy of its host's rail
whose rocks ride the rail's period
Rulings trojans-first, trojans-ride-host-period-with-stations. AsteroidBeltData
key 11 RidesRail; AsteroidBelt.Extent is the one belt extent rule (replaces
Zone's inline farthest-rock loop and the Math.Max at the well node); a swarm's
Periods are its rail's period. The generator draws swarms from a stream of its
own (TrojanStream), so no other draw in the zone moves. Tuning defaults are the
spec's proposals; the code default TrojanProbability 0 draws none. Existing
annulus tests skip swarm rails. Builds.
Co-Authored-By: Claude Sonnet 5.5
---
Assets/Resources/Settings.asset | 4 +-
Assets/Scripts/ServerShared/Settings.cs | 8 ++++
Assets/Scripts/ServerShared/Zone.cs | 16 ++++---
Assets/Scripts/ServerShared/ZoneData.cs | 6 +++
Assets/Scripts/ServerShared/ZoneGenerator.cs | 44 ++++++++++++++++++-
.../ChildrenWithinReachTests.cs | 10 ++---
.../StationPlacementTests.cs | 14 +++---
7 files changed, 83 insertions(+), 19 deletions(-)
diff --git a/Assets/Resources/Settings.asset b/Assets/Resources/Settings.asset
index f4c21ce13..78735dab3 100644
--- a/Assets/Resources/Settings.asset
+++ b/Assets/Resources/Settings.asset
@@ -1,3 +1,3 @@
version https://git-lfs.github.com/spec/v1
-oid sha256:95d1ba64331895cc66862010f94a8cd4598ec9dd211a83b14b6091ac948afd56
-size 12327
+oid sha256:7a0a0d2e7b8367e7c402975e5683434068f99b8c13c848764bbac39b43886411
+size 12477
diff --git a/Assets/Scripts/ServerShared/Settings.cs b/Assets/Scripts/ServerShared/Settings.cs
index 2f329e316..980828e53 100644
--- a/Assets/Scripts/ServerShared/Settings.cs
+++ b/Assets/Scripts/ServerShared/Settings.cs
@@ -126,6 +126,14 @@ public class ZoneGenerationSettings
public ExponentialCurve AsteroidCount;
public ExponentialLerp AsteroidRotationSpeed;
+ // Trojan swarms (ruling trojans-ride-host-period-with-stations): the chance a planet or gas giant with a body for a parent
+ // carries one, each swarm's length along the orbit (turns) and radial half-width (a fraction of the host's distance), and
+ // the rocks round each of L4 and L5 by host distance. 0 here: a settings object built in code draws none.
+ public float TrojanProbability = 0f;
+ public float TrojanArc = .07f;
+ public float TrojanHalfWidth = .06f;
+ public ExponentialCurve TrojanCount = new ExponentialCurve { Exponent = 1f, Multiplier = .04f, Constant = 8f };
+
public float ResourceDensityMinimum = .1f;
public float ResourceDensityMaximum = 1.5f;
diff --git a/Assets/Scripts/ServerShared/Zone.cs b/Assets/Scripts/ServerShared/Zone.cs
index 4db63be46..f68d3a1a9 100644
--- a/Assets/Scripts/ServerShared/Zone.cs
+++ b/Assets/Scripts/ServerShared/Zone.cs
@@ -212,13 +212,11 @@ public class Zone
foreach (var body in PlanetInstances)
if (body.Value.WellStrength > 0)
wellMass.TryAdd(Planets[body.Key].Orbit.Key, body.Value.BodyData.Mass);
- // A belt's rail carries its asteroids, which spread beyond the rail's own distance: its extent is the outermost.
var beltExtent = new Dictionary();
foreach (var belt in AsteroidBelts)
{
- var extent = 0f;
- foreach (var asteroid in belt.Value.Data.Asteroids) extent = Math.Max(extent, asteroid.Distance);
- beltExtent[Planets[belt.Key].Orbit.Key] = extent;
+ var rail = Planets[belt.Key].Orbit.Key;
+ beltExtent[rail] = belt.Value.Extent(Orbits[rail].Data.Distance);
}
var wellNodes = new List();
foreach (var orbit in Orbits)
@@ -226,7 +224,7 @@ public class Zone
var data = orbit.Value.Data;
wellNodes.Add(new WellNode(orbit.Key, data.Parent.Key, data.Distance, data.Phase,
wellMass.TryGetValue(orbit.Key, out var mass) ? mass : 0f,
- beltExtent.TryGetValue(orbit.Key, out var reach) ? Math.Max(reach, data.Distance) : data.Distance,
+ beltExtent.TryGetValue(orbit.Key, out var reach) ? reach : data.Distance,
data.FixedPosition + OrbitData.Evaluate(frac(data.Phase)) * data.Distance)); // a root's stationary place: Offset at an infinite period
}
Wells = WellTree.Build(wellNodes, settings, pack.Radius);
@@ -239,7 +237,9 @@ public class Zone
var beltOrbit = Planets[belt.Key].Orbit.Key;
var beltParent = Orbits[beltOrbit].Data.Parent.Key;
for (var i = 0; i < belt.Value.Periods.Length; i++)
- belt.Value.Periods[i] = PeriodAround(beltParent, belt.Value.Data.Asteroids[i].Distance, beltOrbit);
+ belt.Value.Periods[i] = belt.Value.Data.RidesRail
+ ? Orbits[beltOrbit].Period // a swarm's rocks ride their rail (trojans-ride-host-period-with-stations)
+ : PeriodAround(beltParent, belt.Value.Data.Asteroids[i].Distance, beltOrbit);
}
PopulatedRadius = ComputePopulatedRadius();
@@ -882,6 +882,10 @@ public class AsteroidBelt
Radius = data.Asteroids.Max(a => a.Distance);
}
+ // What the belt's rail adds to its parent's L_F (WellTree): an annulus reaches its outermost rock, whose period reads the
+ // field there; a swarm riding its rail reaches only the rail, whose period every rock shares.
+ public float Extent(float railDistance) => Data.RidesRail ? railDistance : Math.Max(Radius, railDistance);
+
// Cut 1 (docs/mining-cut.md): the formula of Zone.cs:269-271 verbatim, `time` in double as `_time` was used.
// The only place a chunk pose is computed; no per-tick copy survives it. Cut 2: size is no longer read from
// per-belt storage -- Zone owns wear now, so the caller (Zone.ChunkPose/EvaluateBelt) computes it through
diff --git a/Assets/Scripts/ServerShared/ZoneData.cs b/Assets/Scripts/ServerShared/ZoneData.cs
index dd098862f..646347af6 100644
--- a/Assets/Scripts/ServerShared/ZoneData.cs
+++ b/Assets/Scripts/ServerShared/ZoneData.cs
@@ -105,6 +105,12 @@ public class AsteroidBeltData : BodyData
// it on first load through FieldKinds.Ensure, the same function generation uses.
[JsonProperty("kind"), Key(10)]
public CultRecordRef Kind;
+
+ // A Trojan swarm (ruling trojans-ride-host-period-with-stations): every rock rides the belt's rail at the rail's period, so
+ // the swarm keeps its shape. False on a belt from before this key (MessagePack leaves a missing trailing key at its default),
+ // which keeps each rock on its own distance's period: an annulus.
+ [JsonProperty("ridesRail"), Key(11)]
+ public bool RidesRail;
}
// Mining Cut 3 (docs/mining-cut-refresh.md, Q11): a kind of debris field, an authored catalog record. A chunk's
diff --git a/Assets/Scripts/ServerShared/ZoneGenerator.cs b/Assets/Scripts/ServerShared/ZoneGenerator.cs
index c0c8cf16f..4d4c2613e 100644
--- a/Assets/Scripts/ServerShared/ZoneGenerator.cs
+++ b/Assets/Scripts/ServerShared/ZoneGenerator.cs
@@ -49,7 +49,8 @@ public static class ZoneGenerator
var pack = new ZonePack();
var cache = itemManager.ItemData;
- var random = new Random(galaxyZone.Name.StableHash() ^ (uint) pcg3d(galaxyZone.Position).x);
+ var seed = galaxyZone.Name.StableHash() ^ (uint) pcg3d(galaxyZone.Position).x;
+ var random = new Random(seed);
var density = saturate(galaxy.Background.CloudDensity(galaxyZone.Position)/2);
pack.Radius = zoneSettings.ZoneRadius.Evaluate(density);
@@ -214,6 +215,29 @@ public static class ZoneGenerator
return cache.Upsert(planetData);
}).ToList();
+ // Trojan swarms (rulings trojans-first, trojans-ride-host-period-with-stations): a belt on a copy of its host's rail, its
+ // rocks round both L4 and L5. Drawn from a stream of its own, so no other draw in the zone moves; not a GeneratorPlanet,
+ // so it takes no slot and no station candidacy sees it.
+ var swarmRandom = new Random(seed ^ TrojanStream);
+ foreach (var host in planets.Where(p => p.Parent != null && !p.Parent.Empty && !p.Empty && !p.Belt && p.Distance > 0
+ && p.Mass > zoneSettings.PlanetMass && p.Mass <= zoneSettings.SunMass))
+ {
+ if (swarmRandom.NextFloat() >= zoneSettings.TrojanProbability) continue;
+ var hostOrbit = orbitMap[host];
+ var rail = new OrbitData { Parent = hostOrbit.Parent, Distance = hostOrbit.Distance, Phase = hostOrbit.Phase };
+ pack.Orbits.Add(cache.Upsert(rail));
+ var swarm = new AsteroidBeltData
+ {
+ Orbit = cache.RefOf(rail), RidesRail = true,
+ Asteroids = TrojanRocks(zoneSettings, ref swarmRandom, rail.Distance, rail.Phase)
+ };
+ BodyData swarmBody = swarm;
+ var swarmKey = cache.Upsert(swarmBody).Key;
+ swarm.Name = swarmKey.Value.Substring(0, 8);
+ FieldKinds.Ensure(swarmKey, swarm, cache);
+ pack.Planets.Add(cache.Upsert(swarmBody));
+ }
+
// The generator's own well tree, for clearance only (the Zone builds its own): what a rail must stay out of is the body's
// support, or its Roche margin if more.
var wellNodes = planets.Select(p => new WellNode(cache.RefOf(orbitMap[p]).Key, p.Parent == null ? default : cache.RefOf(orbitMap[p.Parent]).Key,
@@ -577,6 +601,24 @@ public static class ZoneGenerator
// limits out; a massive child of a body also far enough that its own pull peak beats CuspMargin times its parent's pull there;
// siblings sit SiblingSpacing of a reach apart. Every other number is a GravityField constant.
public const float RocheMargin = 1.5f;
+
+ private const uint TrojanStream = 0x9E3779B9u;
+
+ // A swarm's rocks: alternately round L4 and L5 of the rail at `distance` and `phase`, each within half an arc of its point
+ // along the orbit and a half-width of the rail across it, thickest at the point.
+ public static Asteroid[] TrojanRocks(ZoneGenerationSettings settings, ref Random random, float distance, float phase)
+ {
+ var rocks = new Asteroid[2 * (int) settings.TrojanCount.Evaluate(distance)];
+ for (var i = 0; i < rocks.Length; i++)
+ rocks[i] = new Asteroid
+ {
+ Distance = distance * (1 + random.NextFloat() * (random.NextFloat() - .5f) * 2 * settings.TrojanHalfWidth),
+ Phase = frac(phase + (i % 2 == 0 ? 1f : -1f) / 6f + random.NextFloat() * (random.NextFloat() - .5f) * settings.TrojanArc),
+ Size = random.NextFloat(),
+ RotationSpeed = settings.AsteroidRotationSpeed.Evaluate(random.NextFloat())
+ };
+ return rocks;
+ }
public const float CuspMargin = 1.5f;
public const float SiblingSpacing = .5f;
diff --git a/tests/Aetheria.Shared.Tests/ChildrenWithinReachTests.cs b/tests/Aetheria.Shared.Tests/ChildrenWithinReachTests.cs
index 6f6dcb2b4..22646a129 100644
--- a/tests/Aetheria.Shared.Tests/ChildrenWithinReachTests.cs
+++ b/tests/Aetheria.Shared.Tests/ChildrenWithinReachTests.cs
@@ -79,11 +79,11 @@ public sealed class ChildrenWithinReachTests : IClassFixture BodyMass(Zone zone) =>
zone.Planets.Values.Where(b => !(b is AsteroidBeltData)).GroupBy(b => b.Orbit.Key).ToDictionary(g => g.Key, g => g.First().Mass);
- // The generated orbits: every body's and its ancestors', which leaves out stations, turrets and Lagrange copies.
+ // The generated orbits: every body's and its ancestors', which leaves out stations, turrets, Lagrange copies and swarm rails (a copy of the host's rail, like a Lagrange copy).
private static HashSet Placed(Zone zone)
{
var placed = new HashSet();
- foreach (var body in zone.Planets.Values)
+ foreach (var body in zone.Planets.Values.Where(b => !(b is AsteroidBeltData { RidesRail: true })))
for (var key = body.Orbit.Key; placed.Add(key) && zone.Orbits[key].Data.Parent.IsSet();) key = zone.Orbits[key].Data.Parent.Key;
return placed;
}
@@ -332,7 +332,7 @@ public sealed class ChildrenWithinReachTests : IClassFixture !b.Value.Data.RidesRail))
{
belts++;
var orbit = zone.Planets[beltKey].Orbit.Key;
@@ -374,7 +374,7 @@ public sealed class ChildrenWithinReachTests : IClassFixture zone.Planets[b.Key].Orbit.Key, b => b.Value.Data.Asteroids);
+ var beltAt = zone.AsteroidBelts.Where(b => !b.Value.Data.RidesRail).ToDictionary(b => zone.Planets[b.Key].Orbit.Key, b => b.Value.Data.Asteroids);
foreach (var (orbit, rocks) in beltAt)
{
var parent = zone.Orbits[orbit].Data.Parent.Key;
@@ -424,7 +424,7 @@ public sealed class ChildrenWithinReachTests : IClassFixture !b.Value.Data.RidesRail))
{
var rocks = belt.Data.Asteroids;
if (rocks.Length < 2) continue;
diff --git a/tests/Aetheria.Shared.Tests/StationPlacementTests.cs b/tests/Aetheria.Shared.Tests/StationPlacementTests.cs
index 437e7c1b7..0157f6a70 100644
--- a/tests/Aetheria.Shared.Tests/StationPlacementTests.cs
+++ b/tests/Aetheria.Shared.Tests/StationPlacementTests.cs
@@ -81,6 +81,10 @@ public sealed class StationPlacementTests : IDisposable
private static float Turns(float turns) => turns - (float) Math.Floor(turns);
+ // A Trojan swarm's rail is a copy of its host's, not a station's source.
+ private static bool SwarmRail(Zone zone, CultRecordKey orbit) =>
+ zone.Planets.Values.Any(b => b is AsteroidBeltData { RidesRail: true } && b.Orbit.Key.Equals(orbit));
+
[Fact]
public void AStationSitsAtItsPlanetsL4OrL5()
{
@@ -96,7 +100,7 @@ public sealed class StationPlacementTests : IDisposable
foreach (var station in zone.Entities.OfType().Where(e => TypeOf(built.Items, e) == HullType.Station))
{
var orbit = zone.Orbits[station.OrbitData];
- var sources = zone.Orbits.Where(o => !ridden.Contains(o.Key) && SameRing(o.Value, orbit)).ToList();
+ var sources = zone.Orbits.Where(o => !ridden.Contains(o.Key) && !SwarmRail(zone, o.Key) && SameRing(o.Value, orbit)).ToList();
if (sources.Count == 0)
{
// The tutorial entrance's one exception: a station on its own orbit round a root node, beyond the node's ring
@@ -156,7 +160,7 @@ public sealed class StationPlacementTests : IDisposable
foreach (var station in Orbitals(built).Where(e => TypeOf(built.Items, e) == HullType.Station))
{
var orbit = zone.Orbits[station.OrbitData];
- var sources = zone.Orbits.Where(o => !ridden.Contains(o.Key) && SameRing(o.Value, orbit)).ToList();
+ var sources = zone.Orbits.Where(o => !ridden.Contains(o.Key) && !SwarmRail(zone, o.Key) && SameRing(o.Value, orbit)).ToList();
if (sources.Count == 0) continue; // the tutorial entrance's own orbit round a root
stations++;
Assert.Contains(bodyOrbits, key => key.Equals(sources[0].Key));
@@ -173,12 +177,12 @@ public sealed class StationPlacementTests : IDisposable
var placed = new HashSet();
foreach (var body in zone.Planets.Values)
for (var key = body.Orbit.Key; placed.Add(key) && zone.Orbits[key].Data.Parent.IsSet();) key = zone.Orbits[key].Data.Parent.Key;
- var beltExtent = zone.AsteroidBelts.ToDictionary(b => zone.Planets[b.Key].Orbit.Key, b => b.Value.Data.Asteroids.Select(a => a.Distance).DefaultIfEmpty(0f).Max());
+ var beltExtent = zone.AsteroidBelts.ToDictionary(b => zone.Planets[b.Key].Orbit.Key, b => b.Value.Extent(zone.Orbits[zone.Planets[b.Key].Orbit.Key].Data.Distance));
var nodes = placed.Select(k =>
{
var d = zone.Orbits[k].Data;
return new WellNode(k, d.Parent.Key, d.Distance, d.Phase, mass.TryGetValue(k, out var m) ? m : 0f,
- beltExtent.TryGetValue(k, out var e) ? Math.Max(e, d.Distance) : d.Distance, d.FixedPosition + OrbitData.Evaluate(frac(d.Phase)) * d.Distance);
+ beltExtent.TryGetValue(k, out var e) ? e : d.Distance, d.FixedPosition + OrbitData.Evaluate(frac(d.Phase)) * d.Distance);
}).ToList();
var wells = WellTree.Build(nodes, zone.Settings, zone.Pack.Radius);
return Math.Max(wells.Support(orbit), 1.5f * zone.Settings.RocheLimit.Evaluate(mass[orbit]));
@@ -353,7 +357,7 @@ public sealed class StationPlacementTests : IDisposable
foreach (var station in Orbitals(built).Where(e => TypeOf(built.Items, e) == HullType.Station))
{
var orbit = zone.Orbits[station.OrbitData];
- var strays = zone.Orbits.Count(o => !ridden.Contains(o.Key) && SameRing(o.Value, orbit));
+ var strays = zone.Orbits.Count(o => !ridden.Contains(o.Key) && !SwarmRail(zone, o.Key) && SameRing(o.Value, orbit));
Assert.True(strays == 1, $"seed {built.Seed}: a station's ring holds {strays} orbits no entity rides, not just its source's");
stations++;
}
From 9cdd885af06e02a2f72729acf8d3df9abcf82c8c Mon Sep 17 00:00:00 2001
From: Meta Vens
Date: Sun, 18 Oct 2026 16:28:44 +0200
Subject: [PATCH 002/444] Trojan swarms: tests for the period rule, stations,
the unmoved zone and the save format
ZoneFingerprint pins a swarmless zone to digests computed at 67517f40 over seeds
1-6. Builds.
Co-Authored-By: Claude Sonnet 5.5
---
.../Aetheria.Shared.Tests/TrojanSwarmTests.cs | 150 ++++++++++++++++++
.../Aetheria.Shared.Tests/ZoneFingerprint.cs | 37 +++++
2 files changed, 187 insertions(+)
create mode 100644 tests/Aetheria.Shared.Tests/TrojanSwarmTests.cs
create mode 100644 tests/Aetheria.Shared.Tests/ZoneFingerprint.cs
diff --git a/tests/Aetheria.Shared.Tests/TrojanSwarmTests.cs b/tests/Aetheria.Shared.Tests/TrojanSwarmTests.cs
new file mode 100644
index 000000000..6691d6380
--- /dev/null
+++ b/tests/Aetheria.Shared.Tests/TrojanSwarmTests.cs
@@ -0,0 +1,150 @@
+using System;
+using System.Collections.Generic;
+using System.Linq;
+using CultMath;
+using GameCult.Caching;
+using GameCult.Caching.MessagePack;
+using MessagePack;
+using Xunit;
+using Xunit.Abstractions;
+using static CultMath.math;
+using float2 = CultMath.float2;
+
+// Trojan swarms (rulings trojans-first, trojans-ride-host-period-with-stations; map B60, B61): a belt on a copy of its host's
+// rail whose rocks round L4 and L5 ride the rail's period, so the swarm keeps its shape, adds nothing to the well tree, and
+// leaves every other draw in the zone where it was.
+public class TrojanSwarmTests
+{
+ private const uint First = 1, Last = 6;
+ private readonly ITestOutputHelper _out;
+ public TrojanSwarmTests(ITestOutputHelper output) => _out = output;
+
+ private static bool IsSwarm(BodyData body) => body is AsteroidBeltData { RidesRail: true };
+
+ private static IEnumerable<(Orbit Host, Orbit Rail, AsteroidBelt Swarm)> Swarms(Zone zone)
+ {
+ foreach (var (key, belt) in zone.AsteroidBelts.Where(b => b.Value.Data.RidesRail))
+ {
+ var rail = zone.Orbits[zone.Planets[key].Orbit.Key];
+ var host = zone.Planets.Values.Where(b => !(b is AsteroidBeltData)).Select(b => zone.Orbits[b.Orbit.Key])
+ .Single(o => o.Data.Parent.Key.Equals(rail.Data.Parent.Key) && o.Data.Distance == rail.Data.Distance && o.Data.Phase == rail.Data.Phase);
+ yield return (host, rail, belt);
+ }
+ }
+
+ private static HashSet SwarmRails(Zone zone) =>
+ zone.AsteroidBelts.Where(x => x.Value.Data.RidesRail).Select(x => zone.Planets[x.Key].Orbit.Key).ToHashSet();
+
+ private static float Turn(float2 p) => frac((float) (Math.Atan2(p.y, p.x) / (2 * Math.PI)));
+ private static float Wrap(float t) => t - (float) Math.Round(t);
+
+ [Fact]
+ public void ASwarmKeepsItsShapeOverAnHour()
+ {
+ using var zones = new ChildrenWithinReachTests.Zones(First, Last, null);
+ var s = zones.Settings;
+ int swarms = 0, l4 = 0, l5 = 0;
+ var drift = 0f;
+ foreach (var (seed, zone) in zones.All)
+ foreach (var (host, rail, swarm) in Swarms(zone))
+ {
+ swarms++;
+ var d = host.Data.Distance;
+ Assert.Equal(2 * (int) s.TrojanCount.Evaluate(d), swarm.Data.Asteroids.Length);
+ int sideA = 0, sideB = 0;
+ for (var i = 0; i < swarm.Data.Asteroids.Length; i++)
+ {
+ var rock = swarm.Data.Asteroids[i];
+ Assert.True(Math.Abs(rock.Distance / d - 1) <= s.TrojanHalfWidth * 1.0001f, $"seed {seed}: a rock sits {rock.Distance} from a rail at {d}");
+ float Offset(double t) => Wrap(Turn(swarm.Pose(i, t, float2.zero, 1).xy) - Turn(OrbitData.Evaluate((float) frac(t / host.Period + host.Data.Phase))));
+ var at0 = Offset(0);
+ var side = at0 > 0 ? 1f / 6 : -1f / 6;
+ if (side > 0) { l4++; sideA++; } else { l5++; sideB++; }
+ Assert.True(Math.Abs(at0 - side) <= s.TrojanArc / 2 + 1e-4f, $"seed {seed}: a rock sits {at0} turns from its host");
+ drift = Math.Max(drift, Math.Abs(Wrap(Offset(3600) - at0)));
+ }
+ Assert.Equal(sideA, sideB);
+ }
+ _out.WriteLine($"swarms {swarms}, rocks at L4 {l4}, L5 {l5}, largest drift against the host over an hour {drift} turns");
+ Assert.True(swarms >= 10, $"only {swarms} swarms in seeds {First}-{Last}");
+ Assert.True(l4 > 0 && l5 > 0);
+ Assert.True(drift < 1e-3f, $"a swarm rock drifts {drift} turns against its host in an hour");
+ }
+
+ [Fact]
+ public void StationsStillLandOnSwarmHosts()
+ {
+ using var zones = new ChildrenWithinReachTests.Zones(First, Last, null);
+ var onSwarmHost = 0;
+ foreach (var (seed, zone) in zones.All)
+ {
+ var hosts = Swarms(zone).Select(x => x.Host).ToHashSet();
+ foreach (var station in zone.Entities.OfType())
+ {
+ var orbit = zone.Orbits[station.OrbitData].Data;
+ if (hosts.Any(h => h.Data.Parent.Key.Equals(orbit.Parent.Key) && Math.Abs(h.Data.Distance - orbit.Distance) < .01f)) onSwarmHost++;
+ }
+ }
+ _out.WriteLine($"orbital entities on a swarm host's rail {onSwarmHost}");
+ Assert.True(onSwarmHost > 0, "no station or turret sits on a swarm host's rail");
+ }
+
+ // The digests were computed at 67517f40, before this cut, over the zones of each seed with the authored settings: a zone
+ // with no swarm is the base zone, and a zone with swarms is the base zone plus them.
+ private static readonly Dictionary BaseDigest = new Dictionary
+ {
+ [1] = "02a2bef42b5d4aeaf1e7004d0ab26620ee570ced95115abf98226ab4e0db23eb",
+ [2] = "de231f392e5724ecdce7e746957633bef2724252147ecbb5830db047b635aead",
+ [3] = "5c33fe9f748d6b0b34a7bec923216c7f9bbdfdf8f326c59b77fde9ec96ca2bcb",
+ [4] = "585284386657e4714ad325cb053a78853c5bcaf7eb1a02cbd4826b0e86ad3593",
+ [5] = "4a1aeecad09c61ff7daa43eda40a9c0109dc4b6833439b8400d8c72d939950c8",
+ [6] = "7fd3c5844144b73faf05cd6a53fe92274e4455f38da4aaca4624ea13d96a12c3",
+ };
+
+ [Fact]
+ public void ASwarmMovesNothingElseInTheZone()
+ {
+ using var swarmless = new ChildrenWithinReachTests.Zones(First, Last, z => z.TrojanProbability = 0);
+ using var live = new ChildrenWithinReachTests.Zones(First, Last, null);
+ Assert.Equal(swarmless.All.Count, live.All.Count);
+ int swarms = 0, grew = 0;
+ foreach (var seed in BaseDigest.Keys)
+ {
+ var a = swarmless.All.Where(x => x.Seed == seed).Select(x => x.Zone).ToList();
+ var b = live.All.Where(x => x.Seed == seed).Select(x => x.Zone).ToList();
+ Assert.Empty(a.SelectMany(z => z.AsteroidBelts.Where(x => x.Value.Data.RidesRail)));
+ Assert.Equal(BaseDigest[seed], ZoneFingerprint.Of(a, _ => false, _ => false));
+ string Swarmed(Zone z) => ZoneFingerprint.Of(new[] { z }, SwarmRails(z).Contains, IsSwarm);
+ for (var n = 0; n < a.Count; n++)
+ {
+ Assert.Equal(ZoneFingerprint.Of(new[] { a[n] }, _ => false, _ => false), Swarmed(b[n]));
+ swarms += SwarmRails(b[n]).Count;
+ Assert.True(b[n].PopulatedRadius >= a[n].PopulatedRadius, $"seed {seed}: a swarm shrank the populated radius");
+ if (b[n].PopulatedRadius > a[n].PopulatedRadius) grew++;
+ }
+ }
+ _out.WriteLine($"zones {live.All.Count}, swarms {swarms}, populated radius grown by a swarm's outer rock in {grew}");
+ Assert.True(swarms > 0);
+ }
+
+ // A pack saved before key 11 existed holds the belt as an 11-slot array; it reads as the annulus it was.
+ [Fact]
+ public void ABeltSavedBeforeTheFlagIsAnAnnulus()
+ {
+ var options = CultDocumentMessagePackSerialization.OptionsFor(typeof(ZonePack).Assembly);
+ var saved = MessagePackSerializer.Serialize(new AsteroidBeltData { Mass = 5, Asteroids = new[] { new Asteroid { Distance = 3, Phase = .2f } } }, options);
+ Assert.Equal(0x9C, saved[0]); // a fixarray of 12 slots, keys 0-11
+ Assert.Equal(0xC2, saved[^1]); // its last slot, RidesRail, false
+ var old = saved[..^1];
+ old[0] = 0x9B;
+ var belt = MessagePackSerializer.Deserialize(old, options);
+ Assert.False(belt.RidesRail);
+ Assert.Equal(3f, belt.Asteroids[0].Distance);
+ Assert.Equal(7f, new AsteroidBelt(belt).Extent(7f));
+ Assert.Equal(3f, new AsteroidBelt(belt).Extent(2f));
+ var swarm = MessagePackSerializer.Deserialize(MessagePackSerializer.Serialize(
+ new AsteroidBeltData { Asteroids = belt.Asteroids, RidesRail = true }, options), options);
+ Assert.True(swarm.RidesRail);
+ Assert.Equal(2f, new AsteroidBelt(swarm).Extent(2f));
+ }
+}
diff --git a/tests/Aetheria.Shared.Tests/ZoneFingerprint.cs b/tests/Aetheria.Shared.Tests/ZoneFingerprint.cs
new file mode 100644
index 000000000..3abc9ebc1
--- /dev/null
+++ b/tests/Aetheria.Shared.Tests/ZoneFingerprint.cs
@@ -0,0 +1,37 @@
+using System;
+using System.Collections.Generic;
+using System.Linq;
+using System.Security.Cryptography;
+using System.Text;
+using CultMath;
+using GameCult.Caching;
+using static CultMath.math;
+using float2 = CultMath.float2;
+
+// A digest of what a zone's generation and build decide, so a test can pin it to a value computed at an earlier commit:
+// every orbit's (Distance, Phase, Period, Reach, Support), every body's (type, Mass, rail Distance, belt rocks), the entity
+// count and GetHeight at three points round each non-belt body. Cache keys and names are left out; they are minted, not decided.
+public static class ZoneFingerprint
+{
+ public static string Of(IEnumerable zones, Func skipOrbit, Func skipBody)
+ {
+ var text = new StringBuilder();
+ foreach (var zone in zones) text.Append(Describe(zone, skipOrbit, skipBody)).Append('\n');
+ return Convert.ToHexString(SHA256.HashData(Encoding.UTF8.GetBytes(text.ToString()))).ToLowerInvariant();
+ }
+
+ private static string Describe(Zone z, Func skipOrbit, Func skipBody)
+ {
+ string F(float v) => v.ToString("R", System.Globalization.CultureInfo.InvariantCulture);
+ var orbits = z.Orbits.Where(o => !skipOrbit(o.Key))
+ .Select(o => $"{F(o.Value.Data.Distance)},{F(o.Value.Data.Phase)},{F(o.Value.Period)},{F(z.Wells.Reach(o.Key))},{F(z.Wells.Support(o.Key))}")
+ .OrderBy(x => x, StringComparer.Ordinal);
+ var bodies = z.Planets.Values.Where(b => !skipBody(b))
+ .Select(b => $"{b.GetType().Name},{F(b.Mass)},{F(z.Orbits[b.Orbit.Key].Data.Distance)}," +
+ (b is AsteroidBeltData belt ? string.Join("|", belt.Asteroids.Select(r => $"{F(r.Distance)}/{F(r.Phase)}/{F(r.Size)}")) : ""))
+ .OrderBy(x => x, StringComparer.Ordinal);
+ var heights = z.Planets.Values.Where(b => !(b is AsteroidBeltData)).Select(b => z.Orbits[b.Orbit.Key].Position)
+ .SelectMany(p => new[] { float2(0, 0), float2(37, 0), float2(0, -211) }.Select(o => F(z.GetHeight(p + o))));
+ return string.Join(";", orbits) + "#" + string.Join(";", bodies) + "#" + z.Entities.Count() + "#" + string.Join(",", heights);
+ }
+}
From ac835a8ab27faf2312383754ca3c66f87a6dbebd Mon Sep 17 00:00:00 2001
From: Meta Vens
Date: Sun, 18 Oct 2026 16:52:32 +0200
Subject: [PATCH 003/444] Research: agent-built hull art prior art (LLM 3D
workflows, ship generators, low-poly style, internal fit), for ruling
hull-programmer-art-stopgap
Co-Authored-By: Claude Opus 5.5
---
...26-10-18-agent-built-hull-art-prior-art.md | 164 ++++++++++++++++++
1 file changed, 164 insertions(+)
create mode 100644 docs/research/2026-10-18-agent-built-hull-art-prior-art.md
diff --git a/docs/research/2026-10-18-agent-built-hull-art-prior-art.md b/docs/research/2026-10-18-agent-built-hull-art-prior-art.md
new file mode 100644
index 000000000..a086c5508
--- /dev/null
+++ b/docs/research/2026-10-18-agent-built-hull-art-prior-art.md
@@ -0,0 +1,164 @@
+
+
+# Agent-built hull art: prior art and internal fit (Eyes facts file, 2026-10-18)
+
+Ruling served: `aetheria-release:ruling:hull-programmer-art-stopgap`. Operator quote: "We need a better stopgap for hulls than depending on my authorship for each. See what we can do in terms of 'programmer art', building up the mesh from simple yet expressive primitives that Opus can confidently arrange, maybe just some low-poly art. Do an Eyes pass on what skills or workflows people are using to get good results. Unfortunately using Tripo output directly is not the way, since it looks good enough to pretend to be final art while faceplanting on the details." The ruling leaves mechanism, primitive set and style open.
+
+Mind queried with `view` and key/field `query` only; nothing admitted.
+
+## A. LLM and agent driven 3D modelling
+
+### A1. Systems and what they report
+
+- **LL3M** (Chen et al., arXiv 2508.08228, Aug 2025): plan / retrieve / write / critique / verify agents writing Blender Python; a retrieval base over Blender API docs (BlenderRAG). https://arxiv.org/html/2508.08228v1 (read via WebFetch). Models: GPT-4o planner and retrieval, Claude 3.7 Sonnet coder, Gemini 2.0 Flash critic (Sec. 4.1; Appendix A.2 lists GPT-4o for critic, the paper does not reconcile the two). Repo https://github.com/threedle/ll3m notes the server is discontinued (search-summary).
+ - Quantitative evidence is one table: errors per run 2.43 with RAG versus 3.29 without, 17 objects (Table 1). Agent ablation (Fig. 16) and shared-code-context ablation (Fig. 17) are qualitative, single examples.
+- **BlenderGym** (Gu et al., CVPR 2025, arXiv 2504.01786): 245 start/goal scene pairs over object placement, lighting, procedural material, blend shape and procedural geometry editing. https://arxiv.org/abs/2504.01786 (abstract read), https://openaccess.thecvf.com/content/CVPR2025/html/Gu_BlenderGym_Benchmarking_Foundational_Model_Systems_for_Graphics_Editing_CVPR_2025_paper.html (search-summary). Abstract: even the strongest VLM system struggles on tasks "relatively easy for human Blender users"; scaling the verifier helps, and compute is better split between generation and verification. Models tested were GPT-4o, Claude 3.5 Sonnet, Gemini 1.5 Flash, InternVL2-8B, Qwen2-VL-7B (search-summary). The PDF fetch returned binary; per-task scores were not read.
+- **BlenderAlchemy** (Huang, Yang, Guibas, ECCV 2024, arXiv 2404.17672): VLM edit generator plus state evaluator searching edits to procedural materials, geometry and lighting; uses reference images from image generators to ground text. https://arxiv.org/html/2404.17672v1 (search-summary; benchmark numbers not read).
+- **SceneCraft** (Hu et al., ICML 2024, arXiv 2403.01248): scene graph as blueprint, spatial relations turned into numerical layout constraints, VLM render inspection, and a growing library of reusable script functions. https://proceedings.mlr.press/v235/hu24g.html (search-summary).
+- **3D-GPT** (arXiv 2310.12945): dispatch / conceptualization / modeling agents driving Infinigen procedural generators. https://arxiv.org/html/2310.12945v2 (search-summary). SceneCraft's paper is reported to say 3D-GPT's reliance on Infinigen limits it on large scenes and that diversity is limited by prompt expressiveness (search-summary, second-hand).
+- **CADCodeVerify** (arXiv 2410.05340, ICLR 2025): VLM asks and answers validation questions about its own CAD render, then corrects. On CADPrompt (200 prompts) reports a 7.30% point-cloud-distance drop and 5.5% compile-rate gain for GPT-4; off-the-shelf models "hallucinate on complex, out-of-distribution tasks". https://arxiv.org/html/2410.05340v2 (search-summary).
+- **Text2CAD-Bench** (arXiv 2605.18430): execution success does not guarantee geometric correctness; sweep, loft and shell cause widespread execution failures. https://arxiv.org/pdf/2605.18430 (search-summary).
+- **Text2CAD** (arXiv 2409.17106): ~1% invalid outputs; discrepancy from prompt greatest when prompts name objects instead of giving dimensions (search-summary).
+- **CAD-Recode** (Rukhovich et al., ICCV 2025, arXiv 2412.14042): point cloud to CadQuery Python via a Qwen2-1.5B decoder trained on one million synthetic CAD programs; reports ~10x lower mean Chamfer than prior work on DeepCAD and Fusion360. https://huggingface.co/papers/2412.14042 (search-summary). It reconstructs from a scan; it does not invent shapes from text.
+- **ShapeAssembly** (Jones et al., SIGGRAPH Asia 2020, arXiv 2009.08026): a DSL of cuboid part proxies with attachment operators and symmetry macros; generated by a hierarchical VAE, not an LLM. https://arxiv.org/abs/2009.08026 (search-summary). A search for LLM plus ShapeAssembly-style cuboid programs returned nothing.
+- **build123d-mcp** (PyPI): claims that rendering views and checking dimensions during the build lifts GPT-5.5 from 0.360 to 0.457 on CADGenBench and validity from 88% to 100%; the author's own claim, leaderboard not verified. https://pypi.org/project/build123d-mcp/0.3.70/ (search-summary).
+
+### A2. Failure modes documented
+
+From LL3M (https://arxiv.org/html/2508.08228v1, read):
+- Non-executing code is the main documented failure (Sec. 3.1); without retrieval the coder "solves" errors by deleting the offending lines (App. D).
+- Spatial placement: the critic and verifier can miss spatial problems; the limitations section shows watering-can handles disconnected or wrongly posed (Sec. 5, Fig. 18); an ice cream in a hand needed several follow-up prompts (Sec. 4.4, App. E).
+- Disconnected parts in first outputs (Fig. 5, Sec. 3.2); a bucket first came out a plain cylinder until refined (Sec. 4.2).
+- Symmetry and interpenetration are not discussed explicitly by the paper.
+
+From CAD-code work: valid-but-wrong geometry (Text2CAD-Bench, CADCodeVerify, above); numeric checks can pass while the render looks wrong (cc-blender-skill README, C3).
+
+Internal measurement on the same question (Opus, own tools): see Internal fit section "Mesh probe runs" for chamfer, silhouette-IoU and which parts of the loop contributed.
+
+### A3. Fixes reported, with the evidence strength noted
+
+| Fix | Where | Evidence |
+|---|---|---|
+| Documentation retrieval for the API | LL3M BlenderRAG | Table 1, 17 objects (quantitative) |
+| Render and critique with a VLM | LL3M, BlenderAlchemy, CADCodeVerify, build123d-mcp | LL3M qualitative; CADCodeVerify and build123d-mcp report gains (self-reported) |
+| Stronger verifier / verifier scaling | BlenderGym abstract | stated in abstract, no numbers read |
+| Shared code context, edit not rewrite | LL3M Fig. 17 | single qualitative example |
+| Scene graph to numeric layout constraints | SceneCraft | search-summary |
+| Reusable function library growing over time | SceneCraft | search-summary |
+| Incremental build, one feature per call | build123d-mcp page | vendor advice |
+| Procedural generator as the substrate the LLM parameterizes | 3D-GPT / Infinigen | search-summary |
+| Numeric fit tools beyond renders (plane fits, refine, winding-number labels) | internal mesh probe run 1 | see Internal fit |
+
+### A4. Blender MCP servers
+
+- ahujasid/blender-mcp: nine tools (execute_blender_code, look, get_scene_info, generate_3d, search_assets, import_asset, ...); `generate_3d` fronts Hyper3D Rodin, Tripo and Hunyuan3D; Poly Haven, Sketchfab and Poly Pizza import. README gives no quality guidance for generated models; its limits advice is "Complex operations might need to be broken down into smaller steps" and to save before running code. https://github.com/ahujasid/blender-mcp (read).
+- Official Blender Lab MCP add-on exists (a Japanese write-up, search-summary): https://techfeed.io/entries/67e9c23aad5903884eb209b7
+- No user report of Claude or GPT building spaceships through Blender MCP with proportion or symmetry findings was located; the one hit was a vendor-style post, https://dev.to/githubopensource/blendermcp-unleash-the-power-of-ai-in-your-3d-modeling-workflow-1ooa (search-summary, promotional).
+
+## B. Primitive and grammar approaches to ships
+
+### B1. Procedural ship generators
+
+- **a1studmuffin/SpaceshipGenerator** (MIT): start from a box; repeatedly extrude front and rear faces with random translate/scale/rotate; extrude random faces with shrinking scale for asymmetry; classify faces by orientation and add engines, antennae, turrets, lights; optional mirror; Bevel modifier; global-space cube UVs plus a greeble normal map and emissive window maps; seeded. README gives no parameter values or orientation rules; those are in `spaceship_generator.py`. https://github.com/a1studmuffin/SpaceshipGenerator (read). Forks: panzi, ldo, David-Kasilia (search-summary). A paid Geometry Nodes alternative exists on Blender Market (search-summary).
+- **Kinnear and Kaplan**, "Procedural generation of surface detail for science fiction spaceships", Computational Aesthetics 2010: https://cs.uwaterloo.ca/~csk/publications/Papers/kinnear_kaplan_2010.pdf. Already read in full by the earlier greebles pass: GameCult.Geometry.Csg `docs/research/greebles-prior-art.md` section 2 (commit 4877300, branch origin/eureka/brush-tree). Not repeated here.
+- The same greebles file records community hard-surface workflow order (blockout, primary volumes, panel breaks, boolean cuts, bevel, weighted normals, greebles), Hard Ops / Boxcutter / KIT OPS cutters, DECALmachine, and per-title poly figures (e.g. EVE cruisers about 5k polys, CCP dev post, search-summary there).
+- Shape and split grammars for vehicles (Wonka et al. 2003, Mueller CGA 2006): no vehicle-specific grammar source was fetched in this pass. Gap.
+
+### B2. Modular-part ship builders in games
+
+- **Kenney Space Kit**: CC0, 150+ models (modular parts, ships, turrets, characters), OBJ/FBX/DAE/STL/glTF. https://kenney-assets.itch.io/space-kit , https://opengameart.org/content/space-kit (search-summary). Also a 40-piece CC0 Modular Space Kit, https://opengameart.org/content/modular-space-kit .
+- **Cosmoteer**: grid-based module placement with mirroring and copy/paste; modules and weapons defined in text files and moddable; multi-part weapons. https://steamcommunity.com/sharedfiles/filedetails/changelog/2882914000 , https://wiki.cosmoteer.net (search-summary). Its art-style rationale was not found.
+- **Starfield ship builder**: snapping to attach points with per-module attach rules and an overlap-tolerance INI setting; players report modules looking attached when they are not. https://gamesradar.com/starfield-unattached-modules , https://primagames.com/tips/how-to-find-unattached-modules-in-starfield (player/guide anecdote, search-summary). No Bethesda design source found.
+- Starsector, Space Engineers, Avorion: nothing fetched. Gap.
+
+### B3. What makes low poly read as deliberate (practitioner opinion, no controlled study found)
+
+- https://bugnet.io/blog/low-poly-3d-a-style-guide-for-small-teams (search-summary): commitment is the dividing line; smoothing groups over low geometry gives a "cheap mobile game" impression; silhouette carries the read once interior detail is stripped, exaggerate proportions; set facet density per asset class and keep it even across the scene; a shared palette (often one small palette texture) unifies assets; flat shading is the style's signature; harsh light transitions on flat faces are mitigated with subtle vertex colour.
+- https://gamedeveloper.com/design/how-to-make-low-poly-look-good and https://rocketbrush.com/blog/low-poly-art-in-games (listed by search, not read).
+- The vendor claim that a stylized look reads as intentional while a half-finished photoreal one looks broken is from a vendor page (summerengine.com) and is marketing.
+- Internal operator statement on look: `aetheria-release:ruling:panel-lines-are-geometry` and the shippable bar (Internal fit) describe the operator's own finishing standard (one-segment bevel, face-weighted normals); nothing in the mind rules a low-poly flat-shaded style for Aetheria.
+
+## C. Skill and prompt packs for 3D work
+
+- **cc-blender-skill** (roble3): 30 chained Claude Code skills over a Blender MCP backend (official Blender Lab MCP or ahujasid). Groups: orchestration (text-to-blender, harmonizer, quality-refinement-autoloop, blender-pro-workflow), core production (modeling, materials, lighting, cameras, rendering, animation, export), reference-driven reconstruction (multiview-fit-loop, orthographic-registration, fit-repair-optimizer, landmark-fit-repair, contour-to-mesh, reference-analysis-validator with IoU/SSIM/bbox/centroid), UV and texturing. Rules: check backend status and stdout before reporting success; fixed backend per task; surface-coverage gate on closed assets; per-skill trigger evals (100% true positive, 4% false positive reported by the page). Documented limits: "design quality is not build correctness", primitive-built faces read as abstract avatars, numeric checks can pass while the render looks wrong. It does not name proportions, symmetry or interpenetration as failure modes. https://cdn.jsdelivr.net/gh/roble3/cc-blender-skill@main/README.md (read).
+- **claudskills.com blender-3d**: general SKILL.md covering headless bpy, bmesh, geometry nodes, Cycles/EEVEE, export, MCP; listing does not mention a render-verify loop. https://claudskills.com/skills/blender-3d/ (search-summary).
+- **davincidreams Blender skill**: interface, modeling, Unity/Unreal export pipelines. https://claudemarketplaces.com/skills/davincidreams/agent-team-plugins/blender (search-summary).
+- **Snyk roundup** of Claude skills for 3D / game dev: https://snyk.io/articles/top-claude-skills-3d-modeling-game-dev-shader-programming/ (listed, not read).
+- **hard-surface skill** (arjun988-blender-skills), cited by the greebles file for the community order blockout, primary volumes, panel breaks, booleans, bevel, weighted normals, greebles: https://www.claudepluginhub.com/skills/arjun988-blender-skills/hard-surface (single page, via greebles-prior-art.md).
+- No skill was found with an explicit low-poly mode or a documented render-and-compare gate as its core, per the search summary.
+
+## Internal fit
+
+### Rulings in force (read titles from the mind, schema `epiphany.pipeline.ruling.v2`; 295 in force across the two campaigns, 136 matched hull/material/model terms; those bearing on this ruling)
+
+aetheria-release:
+- `hull-programmer-art-stopgap` (2026-10-18), quoted above.
+- `hull-paint-mask-texture` (2026-10-03): operator quote "I want a mask map per ship for livery, I'll be rebuilding the nonsense geometry and unwrapping anyway, I want these to stand up to my own authoring standards even if tripo does a lot of the leg work." Ruling: per-ship mask texture on UV0, so any stopgap hull needs a UV0 unwrap if it is to take the livery mask.
+- `demo-content-bar` (2026-10-06): one player-usable hull per cast faction plus enough NPC role hulls (haulers, tenders, miners) to sell each faction's play style, boss hull, a dockable station. `content-bar-one-per-concept` (2026-10-03): 13 flyable hulls, 3 stations for the full game.
+- `demo-material-scope` (2026-10-06): demo hulls ship baked maps, a mask and the one hull shader.
+- `ship-authors-need-no-unity` (2026-10-06), `ships-editor-then-build` (2026-10-10), `ship-roles-are-tags` (2026-10-09), `thrusters-radiators-are-meshes` (2026-10-03), `schematic-and-model-are-separate-spaces` (2026-10-13), `schematic-is-designated-mesh` (2026-10-03), `ship-data-all-in-cc` (2026-10-03, after-demo per `demo-ship-cc-timing`).
+- `hardpoints-and-floating-greebles`, `hardpoint-sizes-are-footprint-palette`, `radiator-panels-molded-by-geonodes` (2026-10-13), `hardpoint-work-held-for-walkthrough` (2026-10-13, hardpoint socket work held until the operator shows the current mount model on Starfire).
+- `panel-lines-are-geometry` (2026-10-12).
+- `hull-concept-keepers-2026-10-08`: Bison, Squatter, Oboz r8f, Shchit, Key Light r8a, Tattletale.
+- `addon-tripo-fixture` (2026-10-08): a sample Tripo GLB is the add-on's bake test fixture.
+- `mesh-probe-exploit-not-measure` (2026-10-10), operator quote: "I'm not interested in measuring Opus's spatial reasoning performance, in interested in exploiting it. Give it access, let it build and use tools." Also `mesh-probe-both-vocabularies`, `brokkr-mesh-probe-ag-self`.
+
+brush-tree: `openscad-target` ("Trees are authored in OpenSCAD-shaped text"), `brokkr-beats-blender-mcp`, `cli-not-mcp`, `representation-per-hull` (subd limit surfaces for continuous bodies, primitives for mechanical hulls), `hand-cages-enter-the-tree`, `shippable-part-bar` ("clean unbroken flow, part-coherent low-distortion UVs, one-segment bevels with face-weighted normals"), `bake-only` (hulls lowered offline in Blender; no tree ships), `lowered-hull-through-package`, `art-tools-not-cad` (2026-10-14, "drop the cad thread, let's stick to art tools"), `no-unownable-tools-in-loop`, `tree-store-per-ship-cc`, `weld-*` and `panel-lines-by-split-boolean-r2`.
+
+### Brush tree (campaign `brush-tree:campaign:self`, repos GameCult.Geometry.Csg, Brokkr, Aetheria; branch `eureka/brush-tree`)
+
+- Target doc: `docs/brush-tree-target.md` r4 on GameCult.Geometry.Csg `origin/eureka/brush-tree` (40faab6); the campaign's recorded commit 49948c8 predates r4 and the file is not in the Aetheria or Brokkr checkouts. r4 describes: a tree per ship; each part `cage` or `kernel`; detail list of brushes with modes add / subtract / split; Blender evaluates cage Mirror and Subdivision and finishes with Bevel then Weighted Normal; Geometry.Csg does booleans and a local-owner weld run as a Rust wheel feeding a live result object. Text for a cage part is "not yet known"; OpenSCAD cannot render the limit surface or the weld (target, "Not yet known").
+- What the branch contains (git log, 12 commits read): target revisions and research notes only. No implementation commits were seen in those 12.
+- What `main` of GameCult.Geometry.Csg contains: `LevelDsl` with solid box, cut box, oriented boxes, cylinder_z, dome_cap_z, floret_arm; ordered additive/subtractive/intersect brush streams; mesh-array output; signed distance over the brush stream; OBJ/MTL/SVG example outputs (README; `src/dsl.rs`, `src/primitives.rs`). `grep -ril openscad src` found nothing: no OpenSCAD parser in the crate.
+- Brokkr: main has an agent-access cut (A1-A5 merged), a `surfaces/blender/brokkr_bridge` add-on target and a capability-bar note vs Blender MCP servers (`docs/research/blender-mcp-capability-bar.md`). Whether `brokkr-ctl blender run` exists as the ruled CLI was not established in this pass (README mentions sync commands only in what was grepped).
+- An agent can author a tree as text today only as OpenSCAD, which exists as an installed tool and as a probe vocabulary (below); the typed brush-tree text surface is ruled but its implementation was not found.
+
+### Mesh probe runs (internal prior art on Opus building hulls from primitives with tools)
+
+Files on `origin/eureka/brush-tree`, `docs/research/mesh-probe-run1/RESULT.md` through `mesh-probe-run7-im-field/`:
+- Run 1: agent rebuilt Tripo-derived hulls in OpenSCAD and in Geometry.Csg brushes from a measured target, with its own measurement tools. Best chamfer (percent of diagonal): control 0.015 (Csg) and 0.029 (OpenSCAD); Dexter Quiet 0.266 (OpenSCAD, table-of-offsets form) and 0.463 (Csg, 29 brushes); Dexter Quiet compact OpenSCAD 0.682; Headliner 1.121. Report states the gain came mostly from tools (numeric refine, plane fits, winding-number labels), render critique contributed less; control tests numeric recovery of a known structure, not discovering structure; run 2 is not independent of run 1; edit test discriminates only on the control.
+- Run 1 constructions: superellipse stations, planform points, wing planes, pods as hulls of spheres, chin fin (run 2 "Input" list).
+- Run 2 (shippable lowering): the OpenSCAD compact file (2836 triangles via Manifold) was lowered three ways (OpenSCAD plus Blender cleanup, Blender script, Geometry Nodes) against the operator's bar; contact sheets and metrics are in that directory. Result text was not read in full in this pass beyond its headline description.
+- All of this targets reconstruction against a reference mesh, not invention without a reference.
+
+### The ships Blender add-on and mod hull package
+
+- Add-on: `F:\Projects\Aetheria\tools\blender\aetheria_ships\` (`__init__.py`, `hull_grid.py`, `ship_cc.py`). Operators present: Load Ship Layout, Resize Grid, Add/Remove Hardpoint, Add Weapon, Add Muzzle, Mirror Mount, Remove Mount, Save Layout to .cc, Bind Ship Collection, Capture Ship Lines, Rasterise Hull, Flip Nose, Bake From Source, New Ship, Package Ship.
+- Release-map specs not yet landed (aetheria-release-map.md): `ships-addon-mesh-mounts`, `ships-addon-gizmos`, `ships-addon-link-package` (line 45, 63, 65), `ships-addon-paint` (line 47, blocked on `ships-hull-material`, premise obsolete under `mask-channels-place-materials`), `ships-addon-mask-bakes` (46). `ships-addon-mounts` merged at 26cdd578 (line 45).
+- What a mod hull needs beyond the mesh (docs/moddable-ship-authoring.md lines 88-215; docs/moddable-ships-cut.md B.3 S1-S2):
+ - a `.cc` with a `HullData` (cell shape, hardpoint footprints, stats) and a `ShipAuthoring` visual record (model path, anchors, schematic lines); `HullData.Visual` binds them;
+ - `ship.glb` beside the `.cc` for the demo path (the all-in-.cc move comes after per `demo-ship-cc-timing`);
+ - anchors tagged `aetheria.role` and `aetheria.id` (mount id) for weapon, radiator, thruster mounts only (`thruster-emitter`, `radiator-mesh`, `weapon-mount`, `weapon-muzzle` ...); a thruster or radiator anchor must be a mesh, the thruster's not rendered; each weapon needs a muzzle anchor with `ParentId` equal to its mount id;
+ - internal hardpoints carry no anchor, and an anchor taking their id is refused;
+ - validation `AetherDb ship-authoring validate` judges the GLB nodes;
+ - a playable package has hardpoints; the Quiet probe has four anchors and zero hardpoints (moddable-ship-authoring.md, S3 note in moddable-ships-cut.md);
+ - materials: demo uses baked maps, a livery mask on UV0 and one hull shader (`demo-material-scope`, `hull-paint-mask-texture`).
+- A prior programmatic GLB exists: the Unity batch smoke uses a generated `skiff.glb` with a tetrahedron mesh plus collider and thruster anchors (moddable-ship-authoring.md lines 256-268).
+
+### Release map lines (aetheria-release-map.md)
+
+- `demo-cast-hulls` (line 43): the operator's five packages under GameData/Mods, each its maker's ship; blocked on package-product, boss-hull-derived and operator packages.
+- `hardpoint-socket-palette` (73): one socket brush per standard footprint shape fixing pivot, facing, clearance, scale per mount, molded into the hull in the brush tree; waits on brush-tree weld; held for the operator walkthrough.
+- `gear-models-per-footprint` (75): item records gain a model reference; weapons, launchers and thrusters get gear models built to their footprint; held.
+- `floating-greeble-library` (77): operator seed pieces, model-written parametric families, placements as hull-local data, merged at export, kept out of socket clearance; waits on the operator seed set; research `greebles-prior-art.md` (4877300).
+- Related lines: `hull-bakes` (23, material lowering held post-demo), `ships-hull-livery` (26), `ships-studio-schematic` (25).
+- In the same document, the Tripo timing is stated: "one hull a day, and that clock starts only when the first Tripo mesh flies" (moddable-ships-cut.md line 358 context; text not read beyond the match line).
+
+### OpenSCAD
+
+- Nightly 2026.09.23 at `C:\Tools\OpenSCAD-2026.09.23-x86-64\`; use `openscad.com --backend Manifold --render all` (memory `openscad-nightly-installed`). Run 2 reports a 2836-triangle model rendering in 2 s with that backend.
+
+## Known gaps
+
+1. BlenderGym per-task scores and the VLM failure patterns were not read (PDF fetch returned binary); only the abstract.
+2. LL3M symmetry and interpenetration findings: the paper is silent; no other source on those two failure modes for LLM-written Blender code was found.
+3. No independent report of Claude/Opus driving Blender (MCP or bpy) to build vehicle or ship hulls with quality findings; nothing from player/forum sources located.
+4. CADCodeVerify, Text2CAD, CAD-Recode, SceneCraft, 3D-GPT, BlenderAlchemy were read only at search-summary level.
+5. Shape/split grammars for vehicles (CGA, Wonka) and any LLM + ShapeAssembly-style cuboid program work were not fetched or not found.
+6. Starsector, Space Engineers, Avorion, Cosmoteer art rationale, Starfield developer design sources: not found.
+7. Low-poly "reads as deliberate" evidence is practitioner blogs only, no controlled study; gamedeveloper.com and rocketbrush pages were listed but not read.
+8. Skill packs other than cc-blender-skill were read at listing level only; none inspected for 3D prompt content on proportions or symmetry.
+9. Internal: no implementation of the brush tree (OpenSCAD-shaped text, cage parts, weld wheel) was found in the commits read; the typed text surface of the tree and the `brokkr-ctl blender` CLI status were not established. The Geometry.Csg crate has no OpenSCAD parser.
+10. Internal: mesh-probe runs 2 to 7 result bodies were not read in full; `brush-tree-target.md` at the campaign's recorded commit (49948c8) was not retrieved, r4 at 40faab6 was.
+11. Internal: whether the 13-hull full-game set, the demo's hulls or only the demo's NPC role hulls are the stopgap's intended scope is not stated in the ruling.
+12. Tripo-specific failure documentation (vendor or user sources on detail faceplants) was not searched; the operator's ruling is the only statement.
From 6d10331c05478b93a0e0464b734d0da1f313ba0b Mon Sep 17 00:00:00 2001
From: Meta Vens
Date: Sun, 18 Oct 2026 17:24:54 +0200
Subject: [PATCH 004/444] aetheria-release map: boss hull derived, product
tiers, run seed (body facts BH/PT/RS, cut sequence; InfluenceScale moves to
SavedGame key 15)
Co-Authored-By: Claude Opus 5.5
---
docs/aetheria-release-map.md | 413 ++++++++++++++++++++++++++++++++++-
1 file changed, 407 insertions(+), 6 deletions(-)
diff --git a/docs/aetheria-release-map.md b/docs/aetheria-release-map.md
index bf181b4d6..ae5c32760 100644
--- a/docs/aetheria-release-map.md
+++ b/docs/aetheria-release-map.md
@@ -37,7 +37,6 @@ Cuts without a `cut_spec`, withdrawn 2026-10-12 under map-only-what-runs: a spec
- faction-play-4: Faction play 4 (r3): the tender loop on real rounds, full-game scope. A member low on magazines rearms from its tender. Waits on: faction-play-3, ballistic-ammo (blocked on faction-play-3 and ballistic-ammo).
- retire-prelude: Retire the prelude and the tutorial: Galaxy.IsPrelude, SavedGame.IsTutorial, TutorialGalaxy and. Waits on: demo-galaxy, boss-gate, retire-longinusx, gear-without-presence (refresh needed (96-commit drift)).
- autofire-threshold: Missiles 3 (r5): autofire in the sim. Each autofire gun picks its subject through ChooseSubject (munitions first) and. Waits on: munition-shots (blocked on munition-shots).
-- boss-hull-derived: A faction's boss hull is derived (r2): HullData.Boss on a hull the faction makes a product of, set by its own. Waits on: boss-gate, pirates-record, package-product (all merged; not blocked). Runs after faction-pair's faction-play-1 and maker-only-faction, which edit Faction in Corporations.cs and the main galaxy's faction draw in Galaxy.cs; like every catalog command it also edits tools/AetherDb/Program.cs. No Boss hull exists until the operator's Zhestokost boss package (follow-up zhestokost-boss-hull). Body facts: Products PL5-PL6.
- autofire-controls: Missiles 3b (r2): the player sets autofire; the HUD marks munitions and autofire groups; the Point Defense scenario. Waits on: autofire-threshold, controls-hud, pd-gear (blocked on autofire-threshold and controls-hud).
- broadside-gunboat: Broadside returns on a real gunboat: the operator's Zhestokost line-gunboat package (side-rotated Ballistic hardpoints). Waits on: mods-in-tree, package-product (blocked on package-product and the operator package).
- demo-cast-hulls: Demo cast hulls land (r3): the operator's five packages under GameData/Mods, each its maker's ship under its own name. Waits on: mods-in-tree, pirates-record, package-product, boss-hull-derived (blocked on package-product, boss-hull-derived and operator packages).
@@ -54,8 +53,12 @@ Cuts without a `cut_spec`, withdrawn 2026-10-12 under map-only-what-runs: a spec
- matrix-flamers: Weapon matrix, Jet row: Light Flamethrower, Flamethrower, Heavy Flamethrower and Inferno Projector, thermal pellet guns. Waits on: nothing in the mind (refresh needed; ruling catalog-breadth-now (superseded catalog-breadth-after-visibility 2026-10-13): runs now, refresh against master first).
- matrix-mines: Weapon matrix, Mine row: Mine Rack, Heavy Mine Layer and Minefield Projector beside the Mine Launcher, with warhead and. Waits on: roles-backfill (refresh needed; ruling catalog-breadth-now (superseded catalog-breadth-after-visibility 2026-10-13): runs now, refresh against master first).
- gear-lines (spec cut-gear-lines.r1, remaps withdrawn product-lines-gear): 22 second-maker gear products as 'product-lines gear' in ProductLinesCommand. The follow-up's rows less its two Compact Sensor rows, plus Medium Drive, Large Drive and Industrial Thermostatic Heater, so every role-bearing ship gear design that is sold has two makers (Muon Fusion and the four station reactors exempt). The command's no-roles refusal exempts cargo bays. Waits on: nothing; runs now. Replay: 'product-lines gear apply', either side of minor-power-factions, before assembly-from-maker. Body facts: Products PL3, PL10-PL12.
-- product-tiers: A maker sells several products of one design at different performance (ruling maker-design-tiers-allowed, 2026-10-16). In the operator's words, "segmentation via binning and sub-assembly selection": no new innovation or component roles. Maps per-tier ProductRole quality and a Price that follows quality, which today comes from the design (question maker-design-tiers, depends). The tier rows go through product-lines' ProductLinesCommand. Product names stay unique (NoTwoProductsShareAName). ProductRole's mean and deviation come from the maker's one MakerProfiles row, so two tiers at one maker need a per-tier offset. That offset is this cut's design, not a second profile table. Ship products are keyed one per maker per hull (ShipModCatalog.ProductKey(id, maker)); whether hull tiers widen that key is this cut's to map, not assumed. Waits on: product-lines-weapons (its command and the deleted test); product-lines-gear (same catalog writer). Since ruling quality-min-tolerance-binned-assembly a tier row may also set its own FactionProductData.Assembly (assembly-from-maker r3 adds the field; no product sets it yet, AFM3). Once quality-readers lands, a lot's price already follows its effective quality, so tiers that differ in role or assembly distributions differ in price with no per-product Price (map 'Quality readers at 70c7a497'); whether ChooseProduct weighs that price is question chooseproduct-affordability (option with-product-tiers).
-- run-seed: Drops mix a per-game seed stored in the save (ruling loot-seed-in-save, 2026-10-16). A new game rolls different drops from other games, reloading one save repeats its drops, and tests pass a fixed seed. Today Zone.CombatSeed is the zone name's StableHash (Zone.cs:144). It feeds three dice: the loot dice (Zone.cs:414, LootStream, by death ordinal), the mine lay scatter (FireControl.cs:791, MineStream) and the shot dice (FireControl.cs:930). The ruling covers drops, and the question named loot and mine fragments, so the seed mixes into the loot and mine streams. Whether hit rolls also take it is not ruled: the cut raises it. Do not change CombatSeed itself and carry that along silently. The seed lives on SavedGame, minted by the new-run branch. The run lifecycle is record-level (ActionGameManager.cs:58-60), so the seed must reach Zone at construction from the run, not from the catalog. Owns follow-up loot-dice-run-seed (its old owners, loot-1 r2 and sim-mines, are merged). Waits on: nothing in the mind.
+- product-tiers: spec cut-product-tiers.r1 (map section Product tiers). A binned product names its maker's line of one
+ design and a floor in deviations; the mint draws its parts and its final test from the line above that floor. Six
+ binned products, no hull tiers. Blocked on question tier-stock-share.
+- product-tiers-hulls (later, one line): ship hulls stay one product per maker per hull
+ (`ShipModCatalog.ProductKey(id, maker)`). A binned hull needs that key widened and its package's product record to
+ carry a Bin. Not mapped.
- mining-yield: Mining Cut 5 (docs/mining-cut-refresh.md 'Cut 5. Composition, the loot roll, the mined lot, and the same-path test agent'). Ruling mining-overflow-floats (2026-10-16) replaces that plan's deposit rule: units a shot extracts go into the shooter's cargo through TryStore, and what the hold cannot take is released through Zone.Release (Zone.cs:356) as floating items, which the player and NPCs collect through the one pickup path. The plan's AFullHoldLosesTheOverflow test becomes AFullHoldReleasesTheOverflow. Mining Cuts 4 and 7 stay with follow-up mining-cuts-4-5-7, which this cut's spec resolves with them. Waits on: mining Cut 4 (FireControl shots at chunks), full game (demo scope table: no buyer for ore in the demo).
- boost-gear-combat: Stat boosters, combat, tiered by size: a modest 1x1 and a strong 2x2 each for Damage and Range on every gun through. Waits on: boost-gear-systems (blocked on boost-gear-systems; ruling catalog-breadth-now (superseded catalog-breadth-after-visibility 2026-10-13): runs now, refresh against master first). After damage-falloff: a Range Extender multiplies Range only, so it lengthens the falloff ramp and leaves EffectiveRange where it was; the cut decides whether it also targets EffectiveRange (section "Damage falloff at fire time", DF8).
- boost-gear-systems: Stat boosters, systems, tiered by size: a modest 1x1 and a strong 2x2 each for Thrust, Signal, Emissivity and Reactor. Waits on: nothing in the mind (refresh needed; ruling catalog-breadth-now (superseded catalog-breadth-after-visibility 2026-10-13): runs now, refresh against master first).
@@ -860,7 +863,7 @@ deletes the flag with no behaviour change except where named.
**Why `IsPrelude` is split by rule, not renamed.** It bundles four unrelated rules, and each goes
to its owner:
- influence halving -> the cast's own `InfluenceScale` (the demo's cast sets .5, so its galaxy is
- unchanged), carried by the Galaxy and the save (key 14) because later zones read it (PR2);
+ unchanged), carried by the Galaxy and the save (key 15) because later zones read it (PR2);
- the entrance station -> `Scenario.EntranceStation`, a declaration beside `Ambient`, default true,
false for MainGalaxy, so every scenario's arena is unchanged; RunStart.GenerateArena applies it;
- availability -> the general rule of gear-without-presence;
@@ -875,7 +878,7 @@ supplier with no local presence carries the largest trade cost, so it is reachab
preferred. Zero distance (today's accident) would make absent makers the most preferred, inverting
the rule.
-**Old saves.** Key 14 absent decodes as 0 and the save constructor refuses a non-positive scale by
+**Old saves.** Key 15 absent decodes as 0 and the save constructor refuses a non-positive scale by
name. Per ruling `no-save-compatibility-before-players` nothing reads key 11.
### Model page rows
@@ -894,7 +897,7 @@ name. Per ruling `no-save-compatibility-before-players` nothing reads key 11.
| Ligatures per text | Each TMP component's `fontFeatures` | `liga` added once by `EmojiFont.EnableLigatures`; new components inherit TMP Settings | `EmojiFont.EnableLigatures`, then the component |
| EmojiOne sample | none | Deleted by `emoji-font` | none |
| Galaxy cast (replaces `TutorialGenerationSettings`) | A scenario's code constant (`DemoTerminus.Cast`) | Compiled with the game; no authored settings copy | The scenario |
-| `Galaxy.InfluenceScale` | Its galaxy | Set by the constructor (the cast's value, 1 for main), saved as `SavedGame` key 14 | The cast |
+| `Galaxy.InfluenceScale` | Its galaxy | Set by the constructor (the cast's value, 1 for main), saved as `SavedGame` key 15 | The cast |
| `Scenario.EntranceStation` | Its scenario | Compiled | The scenario; `RunStart` applies it |
| `SavedGame` key 11 (`IsTutorial`) | Retired | Not read, not written | none |
| `Galaxy.IsPrelude`, `TutorialGalaxy`, `TutorialGenerationSettings`, `GameSettings.TutorialGenerationSettings` | Deleted by `retire-prelude` | | none |
@@ -10779,3 +10782,401 @@ worktree C:\aeth-wt\ag6-quality-readers at 70c7a497. Catalog: GameData/Aetheria.
mind-commit-f9d28914...), base 70c7a497. The question is raised in follow-up
quality-layers-combine, not in the spec, so the spec does not wait on it. context-pack: 14
anchors SAME.
+
+## Boss hull derived
+
+Probes and source reads by imagination-ag6-boss-hull (session self-2026-10-18-ag6), 2026-10-18,
+in the detached worktree `C:\aeth-wt\ag6-boss-hull` at `origin/master` `eb27ced1`. `origin/master`
+moved to `ac835a8a` during the pass. That commit adds only
+`docs/research/2026-10-18-agent-built-hull-art-prior-art.md`, so every anchor below is the same
+at both. Read by `cut-boss-hull-derived.r3`. This section supersedes the anchors of "Demo hulls,
+the Pirates and the boss" H7 (`@2a5fc00b`) and of Products PL6. Its design rationale
+("The boss hull is derived, not named") stands, with the changes recorded under Rationale below.
+
+### Body facts
+
+- **BH1. What the cut waits on is on master.** boss-gate is merged at `39c7508e`
+ (cut-boss-gate r2, verdict s3). faction-play-1 is merged at `d7f30ed1` (r3, h6), and its catalog
+ replay is at `aa12cbac`. minor-power-factions is merged at `c486b06d`; it is the remap of
+ maker-only-faction. pirates-record is merged at `2c065937` and package-product at `3535660c`.
+ No cut that edits Faction or the main galaxy's faction draw is still in flight ahead of this
+ one.
+- **BH2. Faction keys at `eb27ced1`** (`Corporations.cs`): 1-5, 7, 8, a retired-key comment for 9
+ (`:39`), `BossHull` Key(10) at `:42-43`, then 11-15, 17 (Doctrine), 19 (Assembly) and 21 (Places).
+- **BH3. HullData keys** (`ItemData.cs:531-556`): 23, 24, 25, retired 26 (`:542`), 27, 28, 29 and
+ 32 (`Visual`, `:555-556`). Key 33 is free on HullData. Fuse's Key(32) at `:526` is on a sibling
+ type.
+- **BH4. Every BossHull reader at `eb27ced1`** (`git grep BossHull`):
+ `Galaxy.cs:293` (`PlaceFactionsMain` picks boss factions by `BossHull.IsSet()`, `:291-295`);
+ `LoadoutGenerator.cs:212-230` (`GenerateBossLoadout`);
+ `tools/AetherDb/Program.cs:586` (dangling comment) and `:709-712` (the factions boss column and
+ its `broken` count, the method's only use of `broken`, `:700-716`);
+ `RunGoalTests.cs:74-115` (two tests) and `:199-202` (Exit_open_only_in_the_exit_zone sets one).
+ No writer exists: no AetherDb command, no Pirates or minor-power command, no package path.
+ `GameData/Aetheria.cc` holds the string `bossHull`, which is its schema table.
+- **BH5. Retiring a key with stored values needs no catalog rewrite.** Precedents: `1d689adf`
+ retired Faction key 9 and `be7f2b07` retired HullData key 26, and neither touched
+ `GameData/Aetheria.cc`. The suite loads the shipped catalog in `RunStartTests`' constructor.
+ This is a precedent read; no decode was run in this pass.
+- **BH6. Every generation hull draw goes through one availability rule.** `RandomHull`
+ (`LoadoutGenerator.cs:165-170`) calls `RandomProduct`, then `RandomProducts` (`:176-185`), then
+ `AvailableProducts` (`:188-210`). Station stock (`:126-127`) and the boss fallback (`:226`) also
+ go through `AvailableProducts`. Its `candidates` filter (`:191-198`) runs before the
+ any-manufacturer fallback (`:201-206`). An exclusion placed there therefore holds on the
+ fallback too. `ChooseProduct` (`:262-273`) has its own `Where` and serves presets that name a
+ design.
+- **BH7. Explicit hull draws.** `ScenarioStage.Generated` (`ScenarioStage.cs:90-97`) and AetherDb
+ `loadout` (`Program.cs:805-818`, which runs every hull through `GenerateShipLoadout(candidate
+ => candidate == hull)`) both reach `AvailableProducts`. Many tests call
+ `GenerateShipLoadout(h => h == hull)` on shipped hulls (for example `RestoredHullsTests.cs:606`).
+ None of those hulls is a Boss hull.
+- **BH8. The boss path.** `ZoneGenerator.cs:398-412` calls `GenerateBossLoadout` for every
+ `BossZones` entry in the zone and marks the result `Boss`. `Galaxy.cs:150` sets the prelude and
+ demo antagonist's boss zone without reading any hull. `Galaxy` holds `_cache` (`:36`, set at
+ `:51`, `:102` and `:169`), so `PlaceFactionsMain` can call a static derivation over the catalog.
+- **BH9. The package writer.** `ShipAuthoringCommands.cs` (157 lines): `Usage` at `:11`, `inspect`
+ at `:29-35`, `price` at `:79-93` (it judges before it writes, and a refusal leaves the file
+ byte-identical), and `product` at `:94-125`. `ShipProductTests.ProductAndPriceCommands`
+ (`:259-300`) pins the price and product commands with `RefusedAndUntouched`. Compose copies a
+ package's HullData verbatim, so a member set in the package reaches the composed catalog.
+ `ComposeCopiesEveryProductAndEachMakerIsOfferedTheHull` (`:80-100`) byte-compares products.
+- **BH10. Zhestokost makes no ship hull.** Products PL6 still holds: its only hull product is the
+ Turret. Until a Zhestokost Boss hull exists, the demo boss is the logged fallback (the priciest
+ ordinary ship hull Zhestokost can be offered).
+- **BH11. Which hulls can be bosses.** Ruling `hull-programmer-art-stopgap` (2026-10-18) leaves
+ open whether boss hulls come from the operator's packages or an agent-built stopgap. The Eyes
+ facts file at `ac835a8a` places stopgap art on the package path. Nothing in the derivation
+ reads `Prefab`, `Visual` or a model, so a hull from either source, or with neither, can be a
+ boss.
+
+### Rationale
+
+- **One owner of which hull is a faction's boss:** `LoadoutGenerator.BossHullsOf(CultCache,
+ Faction)`. It returns the faction's products whose design is a `HullType.Ship` HullData with
+ `Boss` set, ordered by hull Price descending, then hull Name ordinal. The flag is authored on
+ the hull, and the faction comes from the product's maker. Nothing stores the pairing.
+ `GenerateBossLoadout`, `Galaxy.PlaceFactionsMain` and AetherDb `factions` all call it.
+- **One owner of "a Boss hull is never on offer":** `AvailableProducts`' candidate filter (BH6).
+ r2 put the exclusion on `RandomHull` and the stock filter, and gave `RandomHull` a boss bool.
+ That made two sites and one mode flag, and it missed the fallback's own
+ `AvailableProducts` call. r3 uses one clause instead. The boss is built straight from its
+ product through a new `ShipLoadout(FactionProductData)` builder, which is the body of
+ `GenerateShipLoadout` after its draw. So the boss path never asks the availability rule to
+ make an exception.
+- **The "named but unsold" branch goes.** It existed because BossHull named a design. A
+ derivation that starts from products cannot name an unsold hull, so its log line and its test
+ (`Boss_hull_nobody_sells_falls_back`) are deleted. They are not rewritten.
+- **A boss is not a market draw.** `ShipLoadout` from the boss product does not apply
+ `AvailableProducts`' `Price > 0` rule. A price-0 Boss hull is still a boss.
+- **Kept:** a sold Boss hull's hardpoints still count in `SoldHulls`, so gear only it can mount has
+ a home. That gear is loot. `ScenarioStage.Generated("")` fails like any unavailable
+ hull. `ChooseProduct` is untouched.
+- **Prior art (from memory, medium confidence; no Eyes pass this round):** Starsector tags unique
+ and boss hulls so markets never sell them. They are spawned by script, outside a faction's
+ known-ships draw. EVE's officer spawns fly hulls that no market sells. Both match the exclusive
+ reading. Question `boss-hull-exclusive` asks the operator to confirm it.
+
+### Model page rows
+
+Replace the two rows in "Demo scope, Model page rows" (`Boss hull`, `Faction.BossHull`) with:
+
+| Kind | Identity | Lifecycle | Authority |
+|---|---|---|---|
+| Boss hull | `HullData.Boss` (key 33, bool) on any HullData, whether shipped, package, or agent-built stopgap | Set by `ship-authoring boss on\|off` on packages, or in Studio's inspector on shipped hulls. Derived per generation; stored nowhere | The hull's author sets the flag. `LoadoutGenerator.BossHullsOf` derives the faction from the product's maker |
+| `Faction.BossHull` (key 10) | Retired; never reuse | Deleted by `boss-hull-derived`. The shipped records keep `''` in the slot (BH5) | none |
+
+### Cut sequence
+
+Remove the `boss-hull-derived` line. Change the `demo-cast-hulls` line's "boss-hull-derived" in
+"Waits on" to "boss-hull-derived (spec cut-boss-hull-derived.r3)" until it merges.
+
+## Product tiers
+
+Ruling maker-design-tiers-allowed (operator: "segmentation via binning and sub-assembly selection ... no new
+innovation or component roles"). Rulings that constrain this cut: quality-min-tolerance-binned-assembly (min(part,
+assembly); a product may carry its own assembly), effective-quality-mean-of-roles and price-follows-effective-quality
+(price and tier read Lot.EffectiveQuality, since quality-readers), quality-triangle-is-per-facility (paying for quality
+in cost or throughput is later crafting work, set per facility), outfitting-affordability-waits-for-budget
+(ChooseProduct gets no cost term), design-only-weighted-draw (ProductWeight is the draw's one weight),
+named-components-after-demo (role fills stay a statistical stand-in).
+
+### Body facts (probes and source reads at eb27ced1)
+
+- PT1 (source read, `Assets/Scripts/ServerShared/ItemManager.cs:130-154`): `Mint` draws `Lot.Quality` from
+ `product.Assembly ?? maker.Assembly ?? new AssemblyDistribution()` and each role fill from the product's
+ `ProductRole` for that role (default `new ProductRole()`, mean .5), both through `Random.NextGaussian` clamped to
+ [.01, 1]. A tier needs no game change to differ in quality: a product's own Roles and Assembly already drive the
+ draw. `CreateLot(product, quality)` (stated) draws nothing.
+- PT2 (source read, `ItemManager.cs:114-119, 226-236`; `Settings.asset:221-224`): price is
+ `QualityPriceModifier.Evaluate(EffectiveQuality) * design.Price`, with `ExponentialLerp` minimum .25, maximum 8 and
+ exponent 3, so the multiplier is `.25 + q^3 * 7.75`. Tier thresholds are Common .3, Uncommon .45, Rare .6, Epic
+ .75 and Legendary .9 (`Settings.asset:191-216`). A tier product prices from its lots with no per-product Price.
+- PT3 (catalog probe, `scratchpad/imagination-ag6-product-tiers/cat3.py` decoding `GameData/Aetheria.cc` with
+ msgpack): 153 products, and no maker sells two products of one design. FactionProductData's schema in the file has
+ slots 1-6 (6 = Assembly), and no product sets Assembly. Faction.Assembly (slot 19) is set for every maker except
+ Ewan Hart. Examples: Finch .76±.19, Lucent .66±.15, AU .64±.13, R&D .68±.15, NiteLife .66±.13, Zhestokost .62±.10,
+ Megiddo .85±.05. The six candidate lines each have two roles at their maker's profile: Sparrow (Light Laser, Finch
+ .65±.20), Spotlight (Light Laser, Lucent .60±.15), Rattler (Light Machine Gun, AU .50±.12), SM-6 Marksman (Slug
+ Rifle, R&D .60±.15), PowerHour 2.0 (High-Yield Reactor, NiteLife .55±.12) and Juggernaut (Large Drive, Zhestokost
+ .50±.08).
+- PT4 (source read, `LoadoutGenerator.cs:176-184, 255-272`): `ProductWeight(product)` is the maker's preference, used
+ per product by `RandomProducts` (stock and generated fits) and `ChooseProduct`. A second product of one maker and
+ design would double that maker's weight for the design.
+- PT5 (source read, `tools/AetherDb/ProductLinesCommand.cs:12-181`): a row is (design, name, maker, text), and its
+ roles are written at `Program.MakerProfiles[maker]`. A product's key is a hash of its name. The command already
+ accepts a maker that sells the design. `NoMakerSellsTwoProductsOfOneDesign` is no longer in the tree (grep at
+ eb27ced1). `NoTwoProductsShareAName` stands (`ProductLinesCatalogTests.cs:36-46`).
+- PT6 (source reads): three tool paths and two catalog tests assume that every product authors every role of its
+ design. They are the census role-gap loop (`Program.cs:131-145`), roles-migrate (`Program.cs:367-380`),
+ roles-backfill (`Program.cs:2219-2234`), `CatalogRoleTests.ShippedCatalogRolesAreLive` (`:62-67`) and
+ `RolesBackfillIsAuthored` (`:264-282`, which reads the Large Drive). `AssemblyFromMakerCommand.Derive` averages
+ product Roles, so a product with no roles adds nothing to it (`AssemblyFromMakerCommand.cs:23-34`).
+ `AssemblyFromMakerCatalogTests:25` asserts that every shipped product has a null Assembly.
+- PT7 (Monte Carlo, `mc.py`, 200k lots, 3 roles, moment-matched slices): this repeats Soul's s1 finding that the
+ final test alone barely matters. For Lucent, binning only the final test to its top half lifts mean effective
+ quality .556 to .591. Selecting only the parts lifts it to .639, because assembly caps the parts. Selecting both
+ lifts it to .704. A tight-QC maker has little to bin: for Megiddo (±.05) the best bin lifts .747 to .790.
+- PT8 (Monte Carlo, `mc2.py`, 200k lots, exact truncation by rejection at mean + z·sd, the six PT3 lines at their
+ catalog distributions). The table below shows the line, then the top half (z 0) and the top quarter (z .67) of
+ parts and final test, each as mean effective quality / mean price multiplier / worst tier / tier shares:
+
+ | line | line | top half (z 0) | top quarter (z .67) |
+ |---|---|---|---|
+ | Sparrow (Finch) | .587 / x2.04 / Common | .781 / x4.02 / Rare (Epic .64) | .883 / x5.62 / Epic (Leg .35) |
+ | Spotlight (Lucent) | .543 / x1.61 / Common | .694 / x2.87 / Rare (Rare .88) | .773 / x3.85 / Rare (Epic .73) |
+ | Rattler (AU) | .479 / x1.17 / Common | .592 / x1.88 / Uncommon | .651 / x2.41 / Uncommon (Rare .92) |
+ | SM-6 Marksman (R&D) | .550 / x1.66 / Common | .699 / x2.94 / Rare | .777 / x3.92 / Rare (Epic .75) |
+ | PowerHour 2.0 (NiteLife) | .521 / x1.42 / Common | .639 / x2.30 / Uncommon | .699 / x2.92 / Rare |
+ | Juggernaut (Zhestokost) | .488 / x1.18 / Common | .563 / x1.64 / Uncommon | .601 / x1.95 / Uncommon |
+
+ Isolating the stages on Spotlight's top half gives .587 for the final test alone, .616 for the parts alone and .694
+ for both. With the parts alone, the maker's margin is the headroom: Lucent's lift grows from +.060 at margin 0 to
+ +.083 at .1, +.102 at .2 and +.112 at .3.
+
+### What the operator sees in a shop
+
+A station that stocks Light Lasers can show four of them: Sparrow and Goldcrest (Finch), and Spotlight and Spotlight
+Premiere (Lucent). In TradeMenu each title is coloured by its tier. A plain line ranges from Common up. A binned one
+never shows below its floor tier: Spotlight Premiere is always Rare or better, at about 1.8 times Spotlight's average
+price (x2.87 against x1.61). Goldcrest is Epic or Legendary at about 2.75 times Sparrow's price. The properties panel
+reads, for example, "Spotlight Premiere (Rare++)". How much a bin buys follows the maker's quality control. Finch's
+loose QC (±.20) leaves a lot to bin, so its bins jump two tiers. Zhestokost's tight drives (±.08) bin only to a better
+Uncommon: Juggernaut Guards costs about 1.4 times Juggernaut and still looks Uncommon. How often a binned product
+appears beside its line is question tier-stock-share. Under the recommended split-evenly, the maker is as present as
+before and its stock divides evenly between line and bin.
+
+### Authority map
+
+- Owner: a line (a FactionProductData with no Bin) owns its design's role distributions for its maker, and its own
+ Assembly when it carries one. A binned product owns only its name, its text, which line it bins (`Bin.Line`) and its
+ floor (`Bin.Floor`, in deviations above each distribution's mean). `ItemManager.Mint` is the one place where a
+ binned lot's distributions are derived.
+- Inputs: product.Bin; the line's Roles and Assembly; the maker's Faction.Assembly; the run's Random.
+- Outputs: a Lot whose Product is the binned product (so Title and Brand read it), with every fill and the assembly
+ drawn at or above the floor. Price and tier follow from EffectiveQuality, unchanged.
+- Derived state: a binned product's quality distributions are derived and never stored. Its Roles stay empty and its
+ Assembly null. It has no per-product Price. Its draw weight is derived from its maker's preference (question
+ tier-stock-share).
+- Forbidden writers: ProductLinesCommand never writes Roles or Assembly onto a binned product. roles-migrate and
+ roles-backfill never backfill one. The census never reports its empty Roles as a gap. Studio edits to a line's roles
+ or a maker's AssemblyMean reach its bins through the derivation, with no copy to resync.
+- Shared paths: every unstated mint goes through `CreateLot(product)`: generation stock, generated and design-only
+ fits (ChooseProduct), loot, and the run start. A staged `CreateLot(product, quality)` bypasses the draw. A merge
+ replays `product-lines tiers apply` and never merges the blob (follow-up catalog-merge-replay-order).
+- Deletion line: none deleted. The comments that describe a segment as "a second product with a role pumped up"
+ (`FactionProduct.cs:11-13, 38-41`) and a binned line as a product's own assembly (`:32-33`) are rewritten to the
+ live rule.
+
+### Rationale
+
+- Why the bin is linked and not copied. A command could write a tier's Roles and Assembly as frozen slices of its line,
+ with no schema change. Each such slice would be a cache of live tunables. The operator tunes a maker's AssemblyMean
+ in Studio (ruling quality-min-tolerance-binned-assembly), and after that the frozen tier silently stops being the top
+ of its line. Her definition is "products that basically do the same thing ... with different performance
+ characteristics", so a bin derives from its line, and one owner makes drift structurally impossible. The cost is one
+ persisted type (two slots) and four sites that skip a binned product (PT6).
+- Why both stages are binned. PT7 and PT8: under the hard min, a better final test alone is nearly inert. Picked parts
+ alone are capped at the maker's margin, so for parts-only selection the margin is the headroom (PT8 margin rows),
+ and that is where the margin matters. The bins in this cut select both stages at one floor, as the operator's
+ premium-needs-a-matching-supply-chain note says (ruling quality-triangle-is-per-facility).
+- Why truncation and not moment matching. Rejection at mean + z·sd is the exact truncated normal of the line's own
+ Box-Muller draw, and it gives the player a guarantee they can read: a bin never shows below its floor tier. A
+ moment-matched gaussian has the same mean but can still roll a Common. The rejection loop is bounded at 64 tries per
+ draw and then mints at the cutoff, so the draw always ends and stays deterministic per seed. Unbinned products
+ consume the stream exactly as before.
+- Why the floor is in deviations. A floor in deviations needs no inverse normal: z 0 is the top half and z .67 the top
+ quarter. Its fraction (1 - Phi(z) per stage) is only needed for the bin-share option of question tier-stock-share.
+- Why the line is not sorted. In real binning the base SKU is what is left after sorting. Here the line keeps its full
+ distribution, so a plain Spotlight can still roll Epic. Sorting the line would re-author every binned line and add a
+ ceiling, and sorting a facility's own output is the crafting work that quality-triangle-is-per-facility defers. That
+ choice is option sorted-line of tier-stock-share, not built here.
+- Prior art (from general knowledge, confidence high on the shape and low on numbers). Semiconductor speed binning:
+ one die design is sold as several SKUs sorted by tested frequency and voltage, and the premium SKU is a guaranteed
+ floor, not a better average. Die harvesting: partly defective dies sell as lower SKUs. Makers also down-bin good
+ parts to meet demand, so a SKU's supply share is a commercial choice and not only yield, which is why split-evenly
+ is defensible for the demo.
+- Hulls are out. `ShipModCatalog.ProductKey(id, maker)` keys one product per maker per hull, and hull products live
+ in packages that the Python writer leaves untouched (`ship_cc.py:5`). Widening that key is its own cut.
+
+### Model page rows
+
+- FactionProductData: identity is the record key, a hash of the name. Lifecycle: authored by AetherDb commands and
+ tuned in Studio. Authority: a line owns its roles and its optional assembly. Slot 7 `Bin` (ProductBin: slot 0 Line,
+ a CultRecordRef; slot 1 Floor, a float) marks a binned product, which owns no distributions of
+ its own. Invariant: a Bin's Line has the same Design and Manufacturer and is not itself binned.
+- Lot (unchanged schema): a binned lot records the binned product, and its fills and assembly sit at or above the
+ floor of the line's distributions.
+- ProductWeight: the maker's preference, divided evenly among the maker's products of that design (pending
+ tier-stock-share). It is still the one weight of RandomProducts and ChooseProduct.
+
+## Run seed" section at the end of docs/aetheria-release-map.md (or beside "Mines in the sim").
+2. Remove the "- run-seed: ..." line from the first "## Cut sequence" (spec cut-run-seed.r1 now carries it).
+3. In "Retiring the prelude and the tutorial", the planned `Galaxy.InfluenceScale` moves from SavedGame key 14 to key 15:
+ run-seed takes key 14 (RS6). Two places: the "influence halving" bullet ("carried by the Galaxy and the save (key 14)"),
+ the "Old saves" paragraph ("Key 14 absent decodes as 0"), and the model-page row `Galaxy.InfluenceScale` ("saved as `SavedGame` key 14").
+4. The spec cites this as "map section Run seed (to be committed)"; fix its DocRefs if you want line ranges.
+-->
+
+## Run seed
+
+Ruling `loot-seed-in-save` (2026-10-16): a new game rolls different drops from other games,
+reloading one save repeats its drops, and tests pass a fixed seed. Spec `cut-run-seed.r1`,
+base `eb27ced1`. Question `run-seed-zone-dice-scope` (raised in the spec) asks whether the
+zone's other dice take the seed too.
+
+### Body facts (master `eb27ced1`, 2026-10-18)
+
+- **RS1. A run seed already exists, and nothing saves it.** `Galaxy.Seed` (`Galaxy.cs:31-33`) is
+ the seed a galaxy was generated at. `GalaxyStage.SeedFrom` (`GalaxyStage.cs:52-56`) mints it:
+ the scenario's `Seed` if non-zero, else the clock, never zero. `GalaxyStage.Main`/`Prelude`
+ (`:59-82`) pass it to the generation constructors. `RunStart.SeedItems` (`RunStart.cs:62-65`)
+ seeds the run's item draws from it. The save constructor (`Galaxy.cs:48-92`) never sets it, so a
+ continued run has `Seed == 0` (the comment at `:31-32` says so). `SavedGame` (`SavedGame.cs:9-53`,
+ `aetheria.savedgame` v1) has keys 0-13 and no seed. `RunSave.Capture` (`:60-96`) is its only
+ writer, and the save constructor its only reader (`MainMenu.cs:111` on Continue).
+- **RS2. Galaxy has a second minter.** Both generation constructors fall back to the clock when
+ passed 0: `Seed = seed == 0 ? (uint)(DateTime.Now.Ticks % uint.MaxValue) : seed`
+ (`Galaxy.cs:107`, `:174`), with `uint seed = 0` defaults (`:100`, `:167`). No game caller relies on
+ that: the only game callers are `GalaxyStage.cs:64,81`, which pass a non-zero seed. Every test
+ caller (33 calls over tests/ and Assets/) passes progressCallback and a seed positionally. No
+ Editor or `tools/` code builds a Galaxy.
+- **RS3. Zone dice are rooted in the zone name alone.** `Zone.CombatSeed` (`Zone.cs:127`) is
+ `galaxyZone?.Name.StableHash() ?? 1337u` (`:154`). The constructor receives the run's `Galaxy`
+ (`:144`). The only game construction is `ActionGameManager.cs:730`, which passes `CurrentGalaxy`;
+ most test fixtures pass `null`. Five dice read CombatSeed, each through its own stream
+ (`SimulationDice.cs:11-16`): loot (`Zone.cs:455`, `LootStream`, by death ordinal), mine lay
+ scatter (`FireControl.cs:792`, `MineStream`, by body id), patrol circuit (`Zone.cs:317`,
+ `PatrolStream`, by agent id), AI range-sample offset (`Combat.cs:178`, `CombatSampleStream`, by
+ agent id; ruling `ai-sample-offset-off-item-stream`), and shot dice (`FireControl.cs:931`, its own
+ mix of CombatSeed and ShotId). The map's earlier line numbers (`Zone.cs:144`, `:414`,
+ `FireControl.cs:791`, `:930`) have drifted to these.
+- **RS4. `SimulationDice.Mix(0) == 0`** (`SimulationDice.cs:23-31`; pinned by
+ `FloatingBodyTests.cs:119`). So `seed ^ Mix(runSeed)` leaves `seed` unchanged when `runSeed` is 0:
+ a zone with no galaxy, or a galaxy restored from a pre-run-seed save, keeps today's dice.
+- **RS5. Probes (ygg-verify, full suite `tests/Aetheria.Shared.Tests`, CultLib 45c2f40, CultMath
+ 9c1b06cd, catalog LFS staged; throwaway commits in `C:\aeth-wt\ag6-rseed-probe`, not for merge).**
+ - Base `eb27ced1`: `TheDirectWeaponKillsTheFragileHullWhileASmokeRoundIsStillInTheAir` passes
+ (`base2.log`).
+ - Probe A, `87329cd1`: one line, `CombatSeed = name hash ^ Mix(galaxy?.Seed ?? 0)`, so all five
+ dice take the run seed. Result: 2406 passed, 1 failed (`probeA.log`). The failure is
+ `TheDirectWeaponKillsTheFragileHullWhileASmokeRoundIsStillInTheAir` (`SmokeRoundsSteppedTests.cs:93-125`,
+ scenario FusedRounds, Seed 108), on `Assert.NotNull(deadAt)` (the failure names
+ `Nullable`, which is `deadAt`; `landed` is a nullable tuple). The smoke round landed
+ before the Cluster Cannon killed the fragile hull. The check's premise, that the cannon kills
+ inside the round's flight, holds at base only because of how seed 108's shot dice fall.
+ - Probe B, `8350e901`: `Zone.DropSeed = CombatSeed ^ Mix(galaxy?.Seed ?? 0)`, read by the loot
+ dice and the mine scatter only. Result: 2407 passed, 0 failed (`probeB.log`).
+ Logs are in the Imagination scratchpad `imagination-ag6-run-seed/`.
+- **RS6. SavedGame key 14 is free at master.** It is reserved only in prose, by the unspecced
+ `retire-prelude` plan for `Galaxy.InfluenceScale`. `run-seed` lands first and takes key 14, and
+ that plan moves to 15. MessagePack int keys decode an absent key as the default, so a pre-cut save
+ reads `Seed == 0`.
+- **RS7. Dice ordinals are runtime state, not saved.** `_deaths` (`Zone.cs:134`), `_lastBodyId`
+ (`:130`) and `_lastAgentId` (`:137`) restart at 0 whenever a Zone is built from its pack, which
+ happens on every Continue. Reloading one save repeats its drops (the ruled sense). It also means
+ that within one save, the first death after a Continue rerolls the dice of the zone's first death
+ before the save. That is not ruled (follow-up `zone-dice-ordinals-reset-on-load`).
+- **RS8. Two campaign follow-ups were checked and not folded.** `zone-content-seeded-by-name` is
+ about `ZoneGenerator.cs:52`, which this cut does not touch. Generation already mixes the zone's
+ per-run position there, and its CombatSeed half (a rename rerolls fights) is unchanged here,
+ because DropSeed keeps CombatSeed as its base. `generation-draw-enumeration-order` is about
+ `LoadoutGenerator.cs:174-209`, also not touched.
+
+### Prior art
+
+Single-player games that store a run seed put every random system under it, and split it into one
+stream per subsystem so systems cannot perturb each other:
+- Slay the Spire keeps one seed per run, shows it to the player and saves it. Separate generators
+ run from it for monsters, events, cards, relics, potions, treasure, the merchant, shuffles and
+ misc, and some are re-derived per floor from seed and floor. Reloading repeats outcomes. High
+ confidence.
+- XCOM: Enemy Unknown saves its generator state, so reloading and repeating an action repeats a hit
+ roll. Its "Save Scum" option re-seeds on load instead. The design space matches the question's
+ `seed-in-save` and `seed-per-session` options. Medium-high confidence.
+- Seeded roguelikes (Brogue, DCSS seeded mode) derive levels and items from one game seed. Medium
+ confidence on DCSS details.
+
+Here `SimulationDice` is already the per-subsystem split (one stream constant per system). What the
+precedent leaves open is only the scope: whether the run seed reaches some streams or all of them.
+
+### Authority map
+
+- **Owner.** `GalaxyStage.SeedFrom` mints a new run's seed (the scenario's, else the clock, never
+ zero). `Galaxy.Seed` carries it for the live run. `SavedGame.Seed` (key 14) persists it.
+- **Inputs.** `Scenario.Seed` and the injected clock (minting); `SavedGame.Seed` (Continue).
+- **Outputs.** Galaxy generation and the run's item draws, as today. New: `Zone.DropSeed`, the
+ zone's drop dice root, `CombatSeed ^ Mix(Galaxy.Seed)`, read by the loot dice and the mine scatter.
+- **Derived state.** `Zone.DropSeed` is derived at construction and never stored. `Zone.CombatSeed`
+ stays the zone's name-rooted identity for shot, patrol and range-sample dice (pending the
+ question). It no longer feeds any drop.
+- **Forbidden writers.** Galaxy's own clock fallback (`Galaxy.cs:107`, `:174`) is deleted: a
+ generated galaxy refuses seed 0 rather than minting one. Nothing writes `SavedGame.Seed` except
+ `RunSave.Capture`, and nothing sets `Galaxy.Seed` except its constructors. Zone takes no seed from
+ the catalog, from `ItemManager.Random`, or from a static.
+- **Shared paths.** New run (`RunStart.Generate`, then `GalaxyStage`, then Galaxy), Continue
+ (`MainMenu.cs:111`, `Galaxy(cache, saved)`), Save (`RunSave.Capture`, then `Commit`), and zone
+ build (`ActionGameManager.cs:730` and every fixture's `new Zone(..., galaxy)`) all read the one
+ `Galaxy.Seed`.
+- **Deletion line.** The clock fallback and the `= 0` seed defaults in both generation constructors
+ are cut before DropSeed is added.
+
+### Model page rows
+
+| Kind | What names it | Over time | Who decides |
+|---|---|---|---|
+| Run seed (`Galaxy.Seed`, `SavedGame` key 14) | Its run; non-zero for every run made after `run-seed` | Minted once at new run by `GalaxyStage.SeedFrom`; saved by `RunSave.Capture`; restored by the save constructor; never changed for the life of the run; a pre-cut save reads 0 and keeps name-only drops (ruling `no-save-compatibility-before-players`: no migration, no refusal) | `GalaxyStage.SeedFrom` (scenario seed or clock) |
+| `Zone.DropSeed` | Its zone in its run: `CombatSeed ^ Mix(Galaxy.Seed)` | Derived at Zone construction, never stored | `Zone`'s constructor; read by `DropLoot` and `FireControl.Lay` |
+| `Zone.CombatSeed` | Its zone name (`StableHash`), 1337 with no GalaxyZone | Derived at construction; feeds shot, patrol and range-sample dice and is DropSeed's base | `Zone`'s constructor; scope pending question `run-seed-zone-dice-scope` |
+
+### Rationale
+
+**Reuse `Galaxy.Seed`, don't mint a second seed.** The run already has one seed, fixed by the
+scenario or the clock, and it already seeds generation and item draws. A separate "loot seed" would
+be a second identity for the same run. With both saved, a scenario's fixed `Seed` would fix the
+galaxy and the gear but not the drops. "Tests pass a fixed seed" is satisfied by the existing
+`Scenario.Seed` and the Galaxy constructors' `seed` parameter. Seed streams cannot correlate, because
+every dice draw passes through `SimulationDice.For`'s triple fmix32 with its own stream constant.
+
+**Cut Galaxy's clock fallback.** It is a second minter that no game path uses, and with the seed now
+saved it would be a quiet way to make a galaxy whose drops nobody chose. `GalaxyStage.SeedFrom` is
+the one place a run's seed comes from.
+
+**XOR with `Mix(seed)`, not a new hash.** `Mix(0) == 0` (RS4), so every galaxy-less fixture and every
+pre-cut save keeps its dice bit for bit. Probe B measured zero churn over 2407 tests. This is
+arithmetic, not a special case: no branch tests for zero.
+
+**Drops only, for now (the question's recommendation).** The ruling covers drops, and probe B lands
+it with no fixture moved. Putting all five dice under the run seed is where the prior art points,
+and it is one line (probe A). But it moves the FusedRounds check, whose premise rides seed 108's
+shot dice. A re-settle that reseeds the scenario or widens the horizon is the "hide the coupling"
+fix that ruling `ai-sample-offset-off-item-stream` refused. The honest fix is to make that check's
+premise hold by construction, and that is its own piece of work. The player-visible difference
+between the two scopes is small. Shot dice are keyed by ShotId, which follows fight history. Patrol
+circuits pick from orbits that `ZoneGenerator` already generates per run (name hash ^ position). Only
+the AI's range-sample offset repeats by zone name across games, and with curated place names
+(ruling `faction-curated-place-names`) names can recur. So the recommendation is drops only now,
+with the wider scope taken when the FusedRounds premise is fixed.
+
+**Old saves.** A pre-cut save reads `Seed == 0` and continues with name-only drops. Under ruling
+`no-save-compatibility-before-players` the cut may refuse it, but a refusal gate would be code
+written for nobody.
From 87793044999446b618d7f35738171708e4aed221 Mon Sep 17 00:00:00 2001
From: Meta Vens
Date: Sun, 18 Oct 2026 17:28:36 +0200
Subject: [PATCH 005/444] Galaxy generation refuses seed 0: delete the clock
fallback and the seed defaults
GalaxyStage.SeedFrom is the one minter of a run's seed; both generation
constructors now take progressCallback and seed without defaults and throw
ArgumentOutOfRangeException on zero instead of reading the clock.
Co-Authored-By: Claude Sonnet 5.5
---
Assets/Scripts/ServerShared/Galaxy.cs | 12 ++++++------
tests/Aetheria.Shared.Tests/RunStartTests.cs | 10 ++++++++++
2 files changed, 16 insertions(+), 6 deletions(-)
diff --git a/Assets/Scripts/ServerShared/Galaxy.cs b/Assets/Scripts/ServerShared/Galaxy.cs
index 0b2f71387..b2d1e5dd0 100644
--- a/Assets/Scripts/ServerShared/Galaxy.cs
+++ b/Assets/Scripts/ServerShared/Galaxy.cs
@@ -96,15 +96,15 @@ public class Galaxy
SectorBackgroundSettings background,
CultCache cache,
Action log,
- Action progressCallback = null,
- uint seed = 0)
+ Action progressCallback,
+ uint seed)
{
_cache = cache;
IsPrelude = false;
Background = background;
Log = log;
var factions = cache.GetAll();
- Seed = seed == 0 ? (uint) (DateTime.Now.Ticks % uint.MaxValue) : seed;
+ Seed = seed != 0 ? seed : throw new ArgumentOutOfRangeException(nameof(seed), "a generated galaxy takes its run's seed from GalaxyStage, never zero");
var random = new Random(Seed);
Factions = factions.OrderBy(x => random.NextFloat()).Take(settings.MegaCount).ToArray();
foreach (var f in Factions) FactionRelationships[f] = FactionRelationship.Neutral;
@@ -163,15 +163,15 @@ public class Galaxy
PlayerSettings playerSettings,
DirectoryInfo narrativeDirectory,
Action log,
- Action progressCallback = null,
- uint seed = 0)
+ Action progressCallback,
+ uint seed)
{
_cache = cache;
IsPrelude = true;
Background = background;
Log = log;
- Seed = seed == 0 ? (uint) (DateTime.Now.Ticks % uint.MaxValue) : seed;
+ Seed = seed != 0 ? seed : throw new ArgumentOutOfRangeException(nameof(seed), "a generated galaxy takes its run's seed from GalaxyStage, never zero");
var random = new Random(Seed);
var factions = new List();
diff --git a/tests/Aetheria.Shared.Tests/RunStartTests.cs b/tests/Aetheria.Shared.Tests/RunStartTests.cs
index efedc20dd..7c22bb911 100644
--- a/tests/Aetheria.Shared.Tests/RunStartTests.cs
+++ b/tests/Aetheria.Shared.Tests/RunStartTests.cs
@@ -115,6 +115,16 @@ public sealed partial class RunStartTests : IDisposable
private const float Freezing = 273.15f;
+ // One minter: a generated galaxy never reads the clock, so seed 0 is refused by both generation constructors.
+ [Fact]
+ public void AGeneratedGalaxyRefusesSeedZero()
+ {
+ Assert.Throws(() =>
+ new Galaxy(new SectorGenerationSettings(), new SectorBackgroundSettings(), _cache, _ => { }, null, 0));
+ Assert.Throws(() =>
+ new Galaxy(new TutorialGenerationSettings(), new SectorBackgroundSettings(), _cache, new PlayerSettings(), new DirectoryInfo(_root), _ => { }, null, 0));
+ }
+
// A main-sector galaxy: each station's gear must come from manufacturers its faction can reach, the makers its
// allegiance names, present in the galaxy or not.
private Galaxy MainSectorGalaxy()
From 020dc58a812c04d6e8fb983d32d1bf0a1d3095ee Mon Sep 17 00:00:00 2001
From: Meta Vens
Date: Sun, 18 Oct 2026 17:29:17 +0200
Subject: [PATCH 006/444] The run's seed rides the save: SavedGame key 14,
written by Capture, read by Continue
Galaxy.Seed on a continued run is the saved seed instead of zero. A save
written before run-seed decodes Seed 0.
Co-Authored-By: Claude Sonnet 5.5
---
Assets/Scripts/ServerShared/Galaxy.cs | 5 ++-
Assets/Scripts/ServerShared/GalaxyStage.cs | 3 +-
Assets/Scripts/ServerShared/SavedGame.cs | 8 +++-
.../DemoTerminusTests.cs | 2 +
tests/Aetheria.Shared.Tests/RunSaveTests.cs | 37 +++++++++++++++++++
5 files changed, 51 insertions(+), 4 deletions(-)
diff --git a/Assets/Scripts/ServerShared/Galaxy.cs b/Assets/Scripts/ServerShared/Galaxy.cs
index b2d1e5dd0..e9e75b239 100644
--- a/Assets/Scripts/ServerShared/Galaxy.cs
+++ b/Assets/Scripts/ServerShared/Galaxy.cs
@@ -28,8 +28,8 @@ public class Galaxy
private Action Log { get; }
public bool IsPrelude { get; }
- // The seed this galaxy was generated at, which also seeds its run's item draws (RunStart); zero for a galaxy
- // restored from a save.
+ // The run's seed, minted once by GalaxyStage.SeedFrom and carried by the save (SavedGame.Seed). It seeds
+ // generation, the run's item draws (RunStart) and the zone's drop dice (Zone.DropSeed).
public uint Seed { get; }
private GalaxyZone[] _exitPath;
@@ -51,6 +51,7 @@ public class Galaxy
_cache = cultCache;
Log = log;
Background = savedGame.Background;
+ Seed = savedGame.Seed;
Factions = savedGame.Factions.Select(faction => cultCache.Get(faction)).ToArray();
for (var i = 0; i < Factions.Length; i++)
diff --git a/Assets/Scripts/ServerShared/GalaxyStage.cs b/Assets/Scripts/ServerShared/GalaxyStage.cs
index cd8feefed..a93e25f89 100644
--- a/Assets/Scripts/ServerShared/GalaxyStage.cs
+++ b/Assets/Scripts/ServerShared/GalaxyStage.cs
@@ -46,7 +46,8 @@ public sealed class GalaxyStage
_clock = clock ?? (() => (uint) (DateTime.Now.Ticks % uint.MaxValue));
}
- // The seed this generation runs at, never zero: the scenario's, or the clock's when the scenario's is zero.
+ // The run's one minter: the scenario's seed, or the clock's when the scenario's is zero, never zero. Galaxy carries
+ // what it mints and SavedGame keeps it, so Continue rolls the same drops.
public uint Seed { get; private set; }
internal void SeedFrom(Scenario scenario)
diff --git a/Assets/Scripts/ServerShared/SavedGame.cs b/Assets/Scripts/ServerShared/SavedGame.cs
index ba819e6ec..1758074fa 100644
--- a/Assets/Scripts/ServerShared/SavedGame.cs
+++ b/Assets/Scripts/ServerShared/SavedGame.cs
@@ -50,6 +50,11 @@ public class SavedGame
[Key(13)]
public int Credits;
+
+ // The run's seed (Galaxy.Seed), minted once by GalaxyStage.SeedFrom. Written only by RunSave.Capture and read only
+ // by Galaxy's save constructor; 0 in a save written before run-seed, which then rolls name-only drops.
+ [Key(14)]
+ public uint Seed;
}
// The run's lifecycle over the run store. Only Commit creates SavedGame, SavedZone and ProvenanceLedger records;
@@ -79,7 +84,8 @@ public static class RunSave
Exit = Array.IndexOf(galaxy.Zones, galaxy.Exit),
IsTutorial = galaxy.IsPrelude,
ActionBarBindings = actionBar,
- Credits = credits
+ Credits = credits,
+ Seed = galaxy.Seed
};
// A zone generated earlier but not loaded this session has no live Contents; its packed contents carry over.
diff --git a/tests/Aetheria.Shared.Tests/DemoTerminusTests.cs b/tests/Aetheria.Shared.Tests/DemoTerminusTests.cs
index ae1895e75..54f46a57c 100644
--- a/tests/Aetheria.Shared.Tests/DemoTerminusTests.cs
+++ b/tests/Aetheria.Shared.Tests/DemoTerminusTests.cs
@@ -51,6 +51,8 @@ public sealed partial class RunStartTests
var (game, zones) = RunSave.Capture(_cache, galaxy, arena, staged.Player, new SavedActionBarBinding[0], 0);
RunSave.Commit(_cache, game, zones, _items.Lots);
var restored = new Galaxy(_cache, _cache.GetGlobal(), _ => { });
+ Assert.NotEqual(0u, galaxy.Seed);
+ Assert.Equal(galaxy.Seed, restored.Seed);
Dictionary Standing(Galaxy g) => g.Factions.ToDictionary(faction => faction.Name, faction => g.FactionRelationships[faction]);
Assert.Equal(new Dictionary
diff --git a/tests/Aetheria.Shared.Tests/RunSaveTests.cs b/tests/Aetheria.Shared.Tests/RunSaveTests.cs
index 3bd075724..1320b7ddd 100644
--- a/tests/Aetheria.Shared.Tests/RunSaveTests.cs
+++ b/tests/Aetheria.Shared.Tests/RunSaveTests.cs
@@ -170,6 +170,43 @@ public sealed class RunSaveTests : IDisposable
Assert.Equal(4242, cache.GetGlobal().Credits);
}
+ // The run's seed rides the save: Continue restores it, Capture writes it back, and a save from before run-seed
+ // restores zero.
+ [Fact]
+ public void TheRunSeedRidesTheSave()
+ {
+ using (var cache = Open())
+ {
+ var game = Game(cache);
+ game.Seed = 0xC0FFEEu;
+ RunSave.Commit(cache, game, Zones(2, StageZoneContents(cache), 0), new ProvenanceLedger());
+ }
+
+ using (var cache = Open())
+ {
+ var galaxy = new Galaxy(cache, cache.GetGlobal(), _ => { });
+ Assert.Equal(0xC0FFEEu, galaxy.Seed);
+ var itemManager = new ItemManager(cache, new ProvenanceLedger(), TestSettings(), _ => { });
+ galaxy.Zones[0].Contents = new Zone(itemManager, new PlanetSettings(), new ZonePack(), galaxy.Zones[0], galaxy);
+ var (game, zones) = RunSave.Capture(cache, galaxy, galaxy.Zones[0].Contents, null, new SavedActionBarBinding[0], 0);
+ Assert.Equal(0xC0FFEEu, game.Seed);
+ RunSave.Commit(cache, game, zones, RunSave.Lots(cache));
+ }
+
+ using (var cache = Open())
+ Assert.Equal(0xC0FFEEu, cache.GetGlobal().Seed);
+
+ // A save written before run-seed holds no seed, and continues on name-only drops.
+ using (var cache = Open())
+ RunSave.Commit(cache, Game(cache), Zones(2, StageZoneContents(cache), 0), new ProvenanceLedger());
+
+ using (var cache = Open())
+ {
+ Assert.Equal(0u, cache.GetGlobal().Seed);
+ Assert.Equal(0u, new Galaxy(cache, cache.GetGlobal(), _ => { }).Seed);
+ }
+ }
+
[Fact]
public void ClearRemovesEveryRunRecord()
{
From f23d3c654204ccbebed90995883ab8e6976f8351 Mon Sep 17 00:00:00 2001
From: Meta Vens
Date: Sun, 18 Oct 2026 17:30:50 +0200
Subject: [PATCH 007/444] Drops roll on the run's seed: Zone.DropSeed feeds the
loot and mine dice
DropSeed = CombatSeed ^ Mix(Galaxy.Seed), derived at construction. A zone with
no galaxy, or a restored seed of 0, keeps its dice bit for bit; CombatSeed
still roots shot, patrol and range-sample dice. RunSeedFixture builds a test
zone's galaxy through RunSave.Commit and the Continue constructor.
Co-Authored-By: Claude Sonnet 5.5
---
Assets/Scripts/ServerShared/FireControl.cs | 4 +--
Assets/Scripts/ServerShared/Zone.cs | 9 +++--
tests/Aetheria.Shared.Tests/LootTests.cs | 33 +++++++++++++++++--
tests/Aetheria.Shared.Tests/MineTests.cs | 28 +++++++++++++---
tests/Aetheria.Shared.Tests/RunSaveTests.cs | 9 ++++-
tests/Aetheria.Shared.Tests/RunSeedFixture.cs | 26 +++++++++++++++
6 files changed, 98 insertions(+), 11 deletions(-)
create mode 100644 tests/Aetheria.Shared.Tests/RunSeedFixture.cs
diff --git a/Assets/Scripts/ServerShared/FireControl.cs b/Assets/Scripts/ServerShared/FireControl.cs
index 5e0a0dcc5..5ea06f455 100644
--- a/Assets/Scripts/ServerShared/FireControl.cs
+++ b/Assets/Scripts/ServerShared/FireControl.cs
@@ -775,7 +775,7 @@ public static class FireControl
// Lays a mine for a mine layer: freezes the payload as evaluated now (stats, the item's blast radius, the
// layer and its faction) and hands the body to Zone.Lay, the one adder. It launches at the shooter's velocity
// plus the mount direction turned by a dice angle within +-Spread/2, times Velocity; the dice are the mine's
- // own (zone seed, MineStream, body id), so no other roll is perturbed. No target, no PendingShot, no price:
+ // own (the zone's DropSeed, MineStream, body id), so no other roll is perturbed. No target, no PendingShot, no price:
// InstantWeapon has already paid. A layer whose item has no blast radius lays nothing.
public static void Lay(Weapon weapon, EquippedItem item, Entity source)
{
@@ -789,7 +789,7 @@ public static class FireControl
var id = zone.NextBodyId();
var half = weapon.Spread / 2f;
- var angle = radians(SimulationDice.For(zone.CombatSeed, SimulationDice.MineStream, id.Value).NextFloat(-half, half));
+ var angle = radians(SimulationDice.For(zone.DropSeed, SimulationDice.MineStream, id.Value).NextFloat(-half, half));
var s = sin(angle);
var c = cos(angle);
var mount = MountDirection(item).xz;
diff --git a/Assets/Scripts/ServerShared/Zone.cs b/Assets/Scripts/ServerShared/Zone.cs
index 4db63be46..719c37e6f 100644
--- a/Assets/Scripts/ServerShared/Zone.cs
+++ b/Assets/Scripts/ServerShared/Zone.cs
@@ -126,11 +126,15 @@ public class Zone
// of touching a shared stream.
public uint CombatSeed { get; }
+ // The zone's drop dice (loot and mine scatter): CombatSeed mixed with its run's seed (ruling loot-seed-in-save), so a
+ // new game drops differently and a reloaded save repeats. Mix(0) is 0, so a zone with no galaxy rolls on CombatSeed.
+ public uint DropSeed { get; }
+
// The one source of floating item and mine ids: zone-scoped, from 1, never reused. Runtime state, not saved.
private uint _lastBodyId;
public FloatingBodyId NextBodyId() => new FloatingBodyId(++_lastBodyId);
- // The death ordinal of the loot dice: the nth death in this zone rolls on SimulationDice.For(CombatSeed, LootStream, n).
+ // The death ordinal of the loot dice: the nth death in this zone rolls on SimulationDice.For(DropSeed, LootStream, n).
private uint _deaths;
// The zone-scoped agent ordinal, from 1, never reused: the key of a pilot's own dice.
@@ -152,6 +156,7 @@ public class Zone
Settings = settings;
_orbitPositionAt = OrbitPositionAt;
CombatSeed = galaxyZone?.Name.StableHash() ?? 1337u;
+ DropSeed = CombatSeed ^ SimulationDice.Mix(galaxy?.Seed ?? 0u);
var cache = itemManager.ItemData;
// Cut 5, 5.6 (docs/fire-control-cut.md, Soul finding 7; operator ruling Q3): death removes the ship,
@@ -452,7 +457,7 @@ public class Zone
private void DropLoot(Entity dead)
{
var settings = _itemManager.GameplaySettings;
- var random = SimulationDice.For(CombatSeed, SimulationDice.LootStream, ++_deaths);
+ var random = SimulationDice.For(DropSeed, SimulationDice.LootStream, ++_deaths);
var at = new float2(dead.Position.x, dead.Position.z);
float2 Heading() { var angle = random.NextFloat() * 2f * PI; return float2(cos(angle), sin(angle)); }
foreach (var equipped in dead.Equipment.ToArray())
diff --git a/tests/Aetheria.Shared.Tests/LootTests.cs b/tests/Aetheria.Shared.Tests/LootTests.cs
index f0b52f4ce..ae47f6b32 100644
--- a/tests/Aetheria.Shared.Tests/LootTests.cs
+++ b/tests/Aetheria.Shared.Tests/LootTests.cs
@@ -61,7 +61,7 @@ public sealed class LootTests : IDisposable
}
private Lab Build(string zoneName = "Loot", float probability = .5f, float lifetime = 30f, float velocity = 25f,
- bool well = false, float spread = 0f)
+ bool well = false, float spread = 0f, uint? runSeed = null)
{
var settings = new GameplaySettings
{
@@ -104,6 +104,7 @@ public sealed class LootTests : IDisposable
cache.Upsert(new CargoBayData { Name = "Crate", Shape = new Shape(), InteriorShape = Solid(4, 4), Durability = 4 });
cache.Upsert(new SimpleCommodityData { Name = "Ore", Shape = Solid(1, 1), MaxStack = 100 });
cache.FlushAsync().Wait();
+ var galaxy = runSeed.HasValue ? RunSeedFixture.Continue(cache, zoneName, runSeed.Value) : null;
var ledger = new ProvenanceLedger();
for (var i = 1; i <= 9000; i++) ledger.Lots[i] = new Lot { Origin = new Attributed(), Quality = 1 };
@@ -111,7 +112,9 @@ public sealed class LootTests : IDisposable
var planets = new PlanetSettings();
var pack = new ZonePack { Radius = well ? 1000 : 0 };
if (well) WellFixture.Dig(cache, pack, planets, 300, 100);
- var zone = new Zone(items, planets, pack, new GalaxyZone { Name = zoneName, Owner = null }, null);
+ var zone = galaxy != null
+ ? new Zone(items, planets, pack, galaxy.Zones[0], galaxy)
+ : new Zone(items, planets, pack, new GalaxyZone { Name = zoneName, Owner = null }, null);
return new Lab { Items = items, Zone = zone };
}
@@ -209,6 +212,32 @@ public sealed class LootTests : IDisposable
Assert.True(Differ(dropsA, Released(first, count)));
}
+ private List<(string Item, float2 Position, float2 Velocity)> DropsAt(string zoneName, uint runSeed)
+ {
+ var lab = Build(zoneName, runSeed: runSeed);
+ Kill(Spawn(lab, float3(31, 0, -17)));
+ return Released(lab, 0);
+ }
+
+ // A new game rolls different drops from other games: the same ship, killed in the same zone, at another run seed.
+ [Fact]
+ public void ANewGameRollsDifferentDrops()
+ {
+ var one = DropsAt("Dice", 1);
+ Assert.True(one.Count > 2, "the fixture must drop enough for a draw to show");
+ Assert.True(Differ(one, DropsAt("Dice", 2)));
+ }
+
+ // Reloading one save repeats its drops, and the run's other zones still roll on their own names.
+ [Fact]
+ public void ReloadingASaveRepeatsItsDrops()
+ {
+ var first = DropsAt("Dice", 7);
+ Assert.True(first.Count > 2, "the fixture must drop enough for a draw to show");
+ AssertSameDrops(first, DropsAt("Dice", 7));
+ Assert.True(Differ(first, DropsAt("Other dice", 7)));
+ }
+
[Fact]
public void GearDropsByProbabilityCargoAlways()
{
diff --git a/tests/Aetheria.Shared.Tests/MineTests.cs b/tests/Aetheria.Shared.Tests/MineTests.cs
index a2deeb5cf..24269af1a 100644
--- a/tests/Aetheria.Shared.Tests/MineTests.cs
+++ b/tests/Aetheria.Shared.Tests/MineTests.cs
@@ -67,7 +67,7 @@ public sealed class MineTests : IDisposable
private Lab Build(float blast = 4f, float arming = 2f, float fuse = 2f, float life = 30f, float spread = 0f, float velocity = 0f,
float damage = 100f, float range = 1000f, float cooldown = 1000f, Faction layerFaction = null,
WeaponFuse? fuseKind = WeaponFuse.Proximity, bool well = false, float3? layerAt = null, int magazine = 100,
- float minRange = 0f)
+ float minRange = 0f, uint? runSeed = null)
{
var settings = new GameplaySettings
{
@@ -114,6 +114,7 @@ public sealed class MineTests : IDisposable
cache.Upsert(Gear("Shield", new ShieldData { Efficiency = Constant(1), EnergyUsage = Constant(1), Capacity = Constant(1000000), RefillDuration = Constant(.01f), RestoreDuration = Constant(.01f) }, 10));
cache.Upsert(Gear("TinyShield", new ShieldData { Efficiency = Constant(1), EnergyUsage = Constant(1), Capacity = Constant(.001f), RefillDuration = Constant(.01f), RestoreDuration = Constant(.01f) }, 10));
cache.FlushAsync().Wait();
+ var galaxy = runSeed.HasValue ? RunSeedFixture.Continue(cache, "Mines", runSeed.Value) : null;
var ledger = new ProvenanceLedger();
for (var i = 1; i <= 500; i++) ledger.Lots[i] = new Lot { Origin = new Attributed(), Quality = 1 };
@@ -121,7 +122,9 @@ public sealed class MineTests : IDisposable
var planets = new PlanetSettings();
var pack = new ZonePack { Radius = well ? 1000 : 0 };
if (well) WellFixture.Dig(cache, pack, planets, 300, 100);
- var zone = new Zone(items, planets, pack, new GalaxyZone { Name = "Mines", Owner = null }, null);
+ var zone = galaxy != null
+ ? new Zone(items, planets, pack, galaxy.Zones[0], galaxy)
+ : new Zone(items, planets, pack, new GalaxyZone { Name = "Mines", Owner = null }, null);
var lab = new Lab { Items = items, Zone = zone, Data = data, ItemData = cache.GetByName("Mine Layer") };
var layerRef = items.ItemData.RefOf(items.ItemData.GetByName("Layer"));
@@ -683,6 +686,23 @@ public sealed class MineTests : IDisposable
Assert.False(FireControl.Refuses(lab.Weapon, lab.Layer));
}
+ // A new game scatters its mines differently: the same lay, at another run seed, launches at another velocity.
+ [Fact]
+ public void ANewGameScattersMinesDifferently()
+ {
+ float2 LaunchAt(uint runSeed)
+ {
+ var lab = Build(spread: 40f, velocity: 5f, layerFaction: A, runSeed: runSeed);
+ var atLay = default(KinematicBody);
+ using var sub = lab.Zone.Mines.ObserveAdd().Subscribe(e => atLay = e.Value.Body);
+ lab.Layer.Direction = normalize(float2(1, 2));
+ LayOne(lab);
+ return atLay.Velocity;
+ }
+
+ Assert.NotEqual(LaunchAt(1), LaunchAt(2));
+ }
+
[Fact]
public void TheLaunchAngleIsTheMinesOwnDice()
{
@@ -697,9 +717,9 @@ public sealed class MineTests : IDisposable
var mine = LayOne(lab);
- // The angle is the first draw of (zone seed, mine stream, body id), in +-Spread/2, turning the mount
+ // The angle is the first draw of (the zone's DropSeed, mine stream, body id), in +-Spread/2, turning the mount
// counterclockwise in the (x, y) plane.
- var degreesTurned = SimulationDice.For(lab.Zone.CombatSeed, SimulationDice.MineStream, mine.Id.Value).NextFloat(-20f, 20f);
+ var degreesTurned = SimulationDice.For(lab.Zone.DropSeed, SimulationDice.MineStream, mine.Id.Value).NextFloat(-20f, 20f);
var a = radians(degreesTurned);
var m = FireControl.MountDirection(lab.WeaponItem).xz;
var turned = float2(m.x * cos(a) - m.y * sin(a), m.x * sin(a) + m.y * cos(a));
diff --git a/tests/Aetheria.Shared.Tests/RunSaveTests.cs b/tests/Aetheria.Shared.Tests/RunSaveTests.cs
index 1320b7ddd..7e32c9928 100644
--- a/tests/Aetheria.Shared.Tests/RunSaveTests.cs
+++ b/tests/Aetheria.Shared.Tests/RunSaveTests.cs
@@ -188,6 +188,8 @@ public sealed class RunSaveTests : IDisposable
Assert.Equal(0xC0FFEEu, galaxy.Seed);
var itemManager = new ItemManager(cache, new ProvenanceLedger(), TestSettings(), _ => { });
galaxy.Zones[0].Contents = new Zone(itemManager, new PlanetSettings(), new ZonePack(), galaxy.Zones[0], galaxy);
+ // The zone's drop dice are its name's seed mixed with the run's.
+ Assert.Equal("Zone 0".StableHash() ^ SimulationDice.Mix(0xC0FFEEu), galaxy.Zones[0].Contents.DropSeed);
var (game, zones) = RunSave.Capture(cache, galaxy, galaxy.Zones[0].Contents, null, new SavedActionBarBinding[0], 0);
Assert.Equal(0xC0FFEEu, game.Seed);
RunSave.Commit(cache, game, zones, RunSave.Lots(cache));
@@ -203,7 +205,12 @@ public sealed class RunSaveTests : IDisposable
using (var cache = Open())
{
Assert.Equal(0u, cache.GetGlobal().Seed);
- Assert.Equal(0u, new Galaxy(cache, cache.GetGlobal(), _ => { }).Seed);
+ var galaxy = new Galaxy(cache, cache.GetGlobal(), _ => { });
+ Assert.Equal(0u, galaxy.Seed);
+ var itemManager = new ItemManager(cache, new ProvenanceLedger(), TestSettings(), _ => { });
+ var zone = new Zone(itemManager, new PlanetSettings(), new ZonePack(), galaxy.Zones[0], galaxy);
+ Assert.Equal("Zone 0".StableHash(), zone.CombatSeed);
+ Assert.Equal(zone.CombatSeed, zone.DropSeed);
}
}
diff --git a/tests/Aetheria.Shared.Tests/RunSeedFixture.cs b/tests/Aetheria.Shared.Tests/RunSeedFixture.cs
new file mode 100644
index 000000000..0152584b7
--- /dev/null
+++ b/tests/Aetheria.Shared.Tests/RunSeedFixture.cs
@@ -0,0 +1,26 @@
+using System.Collections.Generic;
+using GameCult.Caching;
+
+// A fixture's run at a fixed seed, restored as Continue restores it: one zone, committed through RunSave.Commit and
+// read back by the Galaxy save constructor, so a test's galaxy carries the seed the way a real run's does.
+internal static class RunSeedFixture
+{
+ public static Galaxy Continue(CultCache cache, string zoneName, uint seed)
+ {
+ var game = new SavedGame
+ {
+ Seed = seed,
+ Factions = new CultRecordRef[0],
+ Relationships = new FactionRelationship[0],
+ HomeZones = new Dictionary(),
+ BossZones = new Dictionary(),
+ DiscoveredZones = new[] { 0 },
+ ActionBarBindings = new SavedActionBarBinding[0],
+ Entrance = 0,
+ Exit = -1
+ };
+ var zone = new SavedZone { Name = zoneName, AdjacentZones = new int[0], Factions = new int[0], Owner = -1 };
+ RunSave.Commit(cache, game, new[] { zone }, new ProvenanceLedger());
+ return new Galaxy(cache, cache.GetGlobal(), _ => { });
+ }
+}
From 727c4096595cf589658c59d83a8fcf226b1b1888 Mon Sep 17 00:00:00 2001
From: Meta Vens
Date: Sun, 18 Oct 2026 17:33:11 +0200
Subject: [PATCH 008/444] Pin the dice of a zone without a run seed at their
pre-run-seed values
A zone with no galaxy and a zone of a save written before run-seed (seed 0)
drop the items and scatter the mines they did at eb27ced1; values computed
at that base.
Co-Authored-By: Claude Sonnet 5.5
---
tests/Aetheria.Shared.Tests/LootTests.cs | 25 ++++++++++++++++++
tests/Aetheria.Shared.Tests/MineTests.cs | 32 ++++++++++++++++--------
2 files changed, 47 insertions(+), 10 deletions(-)
diff --git a/tests/Aetheria.Shared.Tests/LootTests.cs b/tests/Aetheria.Shared.Tests/LootTests.cs
index ae47f6b32..e8a5016a7 100644
--- a/tests/Aetheria.Shared.Tests/LootTests.cs
+++ b/tests/Aetheria.Shared.Tests/LootTests.cs
@@ -238,6 +238,31 @@ public sealed class LootTests : IDisposable
Assert.True(Differ(first, DropsAt("Other dice", 7)));
}
+ // Pinned at eb27ced1, before run-seed: a zone with no galaxy, and a zone of a save written before run-seed (seed 0),
+ // roll on the zone's name alone and drop exactly what they dropped.
+ [Theory]
+ [InlineData(false)]
+ [InlineData(true)]
+ public void AZoneWithoutARunSeedKeepsItsDrops(bool restoredFromAnOldSave)
+ {
+ var drops = restoredFromAnOldSave ? DropsAt("Dice", 0) : Released(KillInNewLab("Dice"), 0);
+ Assert.Equal(new[] { "Spare", "Orex7", "Spare" }, drops.Select(d => d.Item));
+ Assert.All(drops, d => Assert.Equal(new float2(31, -17), d.Position));
+ var velocities = new[] { float2(-17.977364f, 17.372805f), float2(-5.908107f, 24.291857f), float2(-24.228783f, -6.1616626f) };
+ for (var i = 0; i < velocities.Length; i++)
+ {
+ Assert.Equal(velocities[i].x, drops[i].Velocity.x, 3);
+ Assert.Equal(velocities[i].y, drops[i].Velocity.y, 3);
+ }
+ }
+
+ private Lab KillInNewLab(string zoneName)
+ {
+ var lab = Build(zoneName);
+ Kill(Spawn(lab, float3(31, 0, -17)));
+ return lab;
+ }
+
[Fact]
public void GearDropsByProbabilityCargoAlways()
{
diff --git a/tests/Aetheria.Shared.Tests/MineTests.cs b/tests/Aetheria.Shared.Tests/MineTests.cs
index 24269af1a..137d3f84a 100644
--- a/tests/Aetheria.Shared.Tests/MineTests.cs
+++ b/tests/Aetheria.Shared.Tests/MineTests.cs
@@ -690,19 +690,31 @@ public sealed class MineTests : IDisposable
[Fact]
public void ANewGameScattersMinesDifferently()
{
- float2 LaunchAt(uint runSeed)
- {
- var lab = Build(spread: 40f, velocity: 5f, layerFaction: A, runSeed: runSeed);
- var atLay = default(KinematicBody);
- using var sub = lab.Zone.Mines.ObserveAdd().Subscribe(e => atLay = e.Value.Body);
- lab.Layer.Direction = normalize(float2(1, 2));
- LayOne(lab);
- return atLay.Velocity;
- }
-
Assert.NotEqual(LaunchAt(1), LaunchAt(2));
}
+ private float2 LaunchAt(uint? runSeed)
+ {
+ var lab = Build(spread: 40f, velocity: 5f, layerFaction: A, runSeed: runSeed);
+ var atLay = default(KinematicBody);
+ using var sub = lab.Zone.Mines.ObserveAdd().Subscribe(e => atLay = e.Value.Body);
+ lab.Layer.Direction = normalize(float2(1, 2));
+ LayOne(lab);
+ return atLay.Velocity;
+ }
+
+ // Pinned at eb27ced1, before run-seed: a zone with no galaxy, and a zone of a save written before run-seed (seed 0),
+ // scatter their mines on the zone's name alone.
+ [Theory]
+ [InlineData(false)]
+ [InlineData(true)]
+ public void AZoneWithoutARunSeedKeepsItsMineScatter(bool restoredFromAnOldSave)
+ {
+ var velocity = LaunchAt(restoredFromAnOldSave ? 0u : (uint?) null);
+ Assert.Equal(3.2546253f, velocity.x, 3);
+ Assert.Equal(3.795711f, velocity.y, 3);
+ }
+
[Fact]
public void TheLaunchAngleIsTheMinesOwnDice()
{
From 47f834ea23b4c8939316972c9033b208e57c4cfb Mon Sep 17 00:00:00 2001
From: Meta Vens
Date: Sun, 18 Oct 2026 17:39:52 +0200
Subject: [PATCH 009/444] Pin all five pre-run-seed drops of the dice zone (the
first pin kept three)
Co-Authored-By: Claude Sonnet 5.5
---
tests/Aetheria.Shared.Tests/LootTests.cs | 8 ++++++--
1 file changed, 6 insertions(+), 2 deletions(-)
diff --git a/tests/Aetheria.Shared.Tests/LootTests.cs b/tests/Aetheria.Shared.Tests/LootTests.cs
index e8a5016a7..ecd65f8e1 100644
--- a/tests/Aetheria.Shared.Tests/LootTests.cs
+++ b/tests/Aetheria.Shared.Tests/LootTests.cs
@@ -246,9 +246,13 @@ public sealed class LootTests : IDisposable
public void AZoneWithoutARunSeedKeepsItsDrops(bool restoredFromAnOldSave)
{
var drops = restoredFromAnOldSave ? DropsAt("Dice", 0) : Released(KillInNewLab("Dice"), 0);
- Assert.Equal(new[] { "Spare", "Orex7", "Spare" }, drops.Select(d => d.Item));
+ Assert.Equal(new[] { "Spare", "Spare", "Spare", "Orex7", "Spare" }, drops.Select(d => d.Item));
Assert.All(drops, d => Assert.Equal(new float2(31, -17), d.Position));
- var velocities = new[] { float2(-17.977364f, 17.372805f), float2(-5.908107f, 24.291857f), float2(-24.228783f, -6.1616626f) };
+ var velocities = new[]
+ {
+ float2(-14.870228f, -20.096674f), float2(-4.9265895f, -24.509768f), float2(-17.977364f, 17.372805f),
+ float2(-5.908107f, 24.291857f), float2(-24.228783f, -6.1616626f)
+ };
for (var i = 0; i < velocities.Length; i++)
{
Assert.Equal(velocities[i].x, drops[i].Velocity.x, 3);
From 4ab2ba15c7fab8ccab864ef77136d02d6e943cf5 Mon Sep 17 00:00:00 2001
From: Meta Vens
Date: Sun, 18 Oct 2026 17:49:46 +0200
Subject: [PATCH 010/444] Delete Faction.BossHull and its readers
Key 10 is retired (shipped values left in place). GenerateBossLoadout is
its fallback alone; the factions command no longer reads a named boss; the
two BossHull tests go (the named-but-unsold case cannot exist under a
product-first derivation). Galaxy.PlaceFactionsMain, the Exit_open test and
the derivation arrive in the next commit; this commit does not build.
Co-Authored-By: Claude Sonnet 5.5
---
Assets/Scripts/ServerShared/Corporations.cs | 3 +-
.../Scripts/ServerShared/LoadoutGenerator.cs | 14 ++----
tests/Aetheria.Shared.Tests/RunGoalTests.cs | 44 -------------------
tools/AetherDb/Program.cs | 12 ++---
4 files changed, 7 insertions(+), 66 deletions(-)
diff --git a/Assets/Scripts/ServerShared/Corporations.cs b/Assets/Scripts/ServerShared/Corporations.cs
index c242e3e42..7265941c9 100644
--- a/Assets/Scripts/ServerShared/Corporations.cs
+++ b/Assets/Scripts/ServerShared/Corporations.cs
@@ -39,8 +39,7 @@ public class Faction
// Key 9 was the faction's name-file link, retired by ruling faction-curated-place-names; never reuse it.
- [Inspectable, JsonProperty("bossHull"), Key(10)]
- public CultRecordRef BossHull;
+ // Key 10 was the faction's named boss hull, retired by boss-hull-derived: a faction's boss hulls are derived from the Boss hulls it makes; never reuse it.
[Inspectable, JsonProperty("influence"), Key(11)]
public int InfluenceDistance = 4;
diff --git a/Assets/Scripts/ServerShared/LoadoutGenerator.cs b/Assets/Scripts/ServerShared/LoadoutGenerator.cs
index 55eea27f8..67cc7f6b4 100644
--- a/Assets/Scripts/ServerShared/LoadoutGenerator.cs
+++ b/Assets/Scripts/ServerShared/LoadoutGenerator.cs
@@ -209,19 +209,11 @@ public class LoadoutGenerator
return (available, preferManufacturers);
}
- // A faction's boss: its BossHull when the faction sets one and a product sells it, else the most expensive ship hull
- // the faction can be offered, with the gap logged. Null only when no ship hull is on offer at all.
+ // A faction's boss: the most expensive ship hull the faction can be offered, with the gap logged. Null only when no
+ // ship hull is on offer at all.
public EntityPack GenerateBossLoadout()
{
- var bossHull = Faction != null && Faction.BossHull.IsSet() ? ItemManager.ItemData.Get(Faction.BossHull) : null;
- if (bossHull != null)
- {
- var boss = GenerateShipLoadout(hull => hull == bossHull);
- if (boss != null) return boss;
- ItemManager.Log($"{Faction.Name}'s BossHull {bossHull.Name} is not sold by any product; its boss flies the most expensive ship hull instead.");
- }
- else
- ItemManager.Log($"{Faction?.Name ?? "This faction"} has no BossHull; its boss flies the most expensive ship hull instead.");
+ ItemManager.Log($"{Faction?.Name ?? "This faction"} makes no boss hull; its boss flies the most expensive ship hull instead.");
var (available, _) = AvailableProducts(hull => hull.HullType == HullType.Ship, required: true);
if (available.Length == 0) return null;
diff --git a/tests/Aetheria.Shared.Tests/RunGoalTests.cs b/tests/Aetheria.Shared.Tests/RunGoalTests.cs
index 25b46fdd3..5d133893f 100644
--- a/tests/Aetheria.Shared.Tests/RunGoalTests.cs
+++ b/tests/Aetheria.Shared.Tests/RunGoalTests.cs
@@ -70,50 +70,6 @@ public sealed partial class RunStartTests
Assert.DoesNotContain(pack.Entities, entity => !entity.Boss && entity is ShipPack);
}
- [Fact]
- public void Boss_hull_from_BossHull_else_fallback()
- {
- var galaxy = DemoGalaxy(1);
- var antagonist = Zhestokost(galaxy);
- var hulls = SoldShipHulls().OrderByDescending(hull => hull.Price).ThenBy(hull => hull.Name, StringComparer.Ordinal).ToArray();
- var priciest = hulls.First();
- var cheaper = hulls.Last();
- Assert.True(cheaper.Price < priciest.Price, "the catalog needs two ship hulls of different price for this test to tell them apart");
-
- // Set and sold: the boss flies it, and nothing is logged about a gap.
- antagonist.BossHull = _cache.RefOf(cheaper);
- var log = new List();
- var items = LoggingItems(log);
- var boss = Assert.Single(BossZonePack(galaxy, items, galaxy.Exit).Entities, entity => entity.Boss);
- Assert.Equal(cheaper.Name, HullName(items, boss));
- Assert.DoesNotContain(log, line => line.Contains("BossHull"));
-
- // Unset: the most expensive ship hull on offer, and the log names the missing BossHull.
- antagonist.BossHull = default;
- log.Clear();
- boss = Assert.Single(BossZonePack(galaxy, items, galaxy.Exit).Entities, entity => entity.Boss);
- Assert.Equal(priciest.Name, HullName(items, boss));
- Assert.Contains(log, line => line.Contains("Zhestokost") && line.Contains("no BossHull"));
- }
-
- // Set but sold by no product: the boss falls back the same way, and the log says which hull has no seller.
- [Fact]
- public void Boss_hull_nobody_sells_falls_back()
- {
- var galaxy = DemoGalaxy(1);
- var antagonist = Zhestokost(galaxy);
- var sold = SoldShipHulls();
- // A ship hull no product sells, dearer than any that is sold: the fallback must not pick it.
- var unsold = new HullData { Name = "Unsold Test Hull", HullType = HullType.Ship, Price = sold.Max(hull => hull.Price) + 1 };
- _cache.Upsert(unsold);
- antagonist.BossHull = _cache.RefOf(unsold);
- var log = new List();
- var items = LoggingItems(log);
- var boss = Assert.Single(BossZonePack(galaxy, items, galaxy.Exit).Entities, entity => entity.Boss);
- Assert.Equal(sold.OrderByDescending(hull => hull.Price).ThenBy(hull => hull.Name, StringComparer.Ordinal).First().Name, HullName(items, boss));
- Assert.Contains(log, line => line.Contains(unsold.Name) && line.Contains("BossHull"));
- }
-
[Fact]
public void Exit_sealed_until_boss_dies()
{
diff --git a/tools/AetherDb/Program.cs b/tools/AetherDb/Program.cs
index 5df9ed99c..500d85a8e 100644
--- a/tools/AetherDb/Program.cs
+++ b/tools/AetherDb/Program.cs
@@ -583,8 +583,7 @@ public static class Program
// Every CultRecordRef in the catalog that resolves to nothing, as " -> ".
// Each "clear " argument unsets that member's dangling refs, or removes that dictionary's
- // dangling keys (Faction.BossHull clears boss hulls that would claim a chokepoint no boss can spawn in). Dry
- // run unless passed "apply", which alone opens the catalog writable and lands every changed record in one commit.
+ // dangling keys. Dry run unless passed "apply", which alone opens the catalog writable and lands every changed record in one commit.
private static int Dangling(string[] args)
{
var apply = args.Contains("apply");
@@ -702,19 +701,14 @@ public static class Program
var db = AetherDb.Open();
var products = db.Cache.GetAll().ToArray();
Console.WriteLine($"{"faction",-26} {"short",-13} {"places",-22} {"boss hull",-14} {"influence",-9} {"doctrine",-10} products");
- var broken = 0;
foreach (var faction in db.Cache.GetAll().OrderBy(f => f.Name))
{
var key = db.Cache.RefOf(faction).Key;
- var boss = !faction.BossHull.IsSet()
- ? "none"
- : db.Cache.Get(faction.BossHull)?.Name ?? "DANGLING";
- if (boss == "DANGLING") broken++;
+ var boss = "none";
Console.WriteLine($"{faction.Name,-26} {faction.ShortName,-13} {faction.Places.Length,-22} {boss,-14} {faction.InfluenceDistance,-9} {(faction.Doctrine == null ? "none" : faction.Doctrine.EngageOn.ToString()),-10} " +
products.Count(p => p.Manufacturer.Key.Equals(key)));
}
- Console.WriteLine($"\n{broken} factions carry a link that would break generation");
- return broken;
+ return 0;
}
// What the saved run actually holds per zone. A zone carrying orbits but no orbital entities was packed by a
From b19d6add89a086690e6080b43369f5b7e8b0254b Mon Sep 17 00:00:00 2001
From: Meta Vens
Date: Sun, 18 Oct 2026 17:51:50 +0200
Subject: [PATCH 011/444] aetheria-release map: product tiers follow ruling
bin-share-by-floor (spec r2)
Co-Authored-By: Claude Opus 5.5
---
docs/aetheria-release-map.md | 36 +++++++++++++++++++++---------------
1 file changed, 21 insertions(+), 15 deletions(-)
diff --git a/docs/aetheria-release-map.md b/docs/aetheria-release-map.md
index ae5c32760..b3a9f1c42 100644
--- a/docs/aetheria-release-map.md
+++ b/docs/aetheria-release-map.md
@@ -53,9 +53,10 @@ Cuts without a `cut_spec`, withdrawn 2026-10-12 under map-only-what-runs: a spec
- matrix-flamers: Weapon matrix, Jet row: Light Flamethrower, Flamethrower, Heavy Flamethrower and Inferno Projector, thermal pellet guns. Waits on: nothing in the mind (refresh needed; ruling catalog-breadth-now (superseded catalog-breadth-after-visibility 2026-10-13): runs now, refresh against master first).
- matrix-mines: Weapon matrix, Mine row: Mine Rack, Heavy Mine Layer and Minefield Projector beside the Mine Launcher, with warhead and. Waits on: roles-backfill (refresh needed; ruling catalog-breadth-now (superseded catalog-breadth-after-visibility 2026-10-13): runs now, refresh against master first).
- gear-lines (spec cut-gear-lines.r1, remaps withdrawn product-lines-gear): 22 second-maker gear products as 'product-lines gear' in ProductLinesCommand. The follow-up's rows less its two Compact Sensor rows, plus Medium Drive, Large Drive and Industrial Thermostatic Heater, so every role-bearing ship gear design that is sold has two makers (Muon Fusion and the four station reactors exempt). The command's no-roles refusal exempts cargo bays. Waits on: nothing; runs now. Replay: 'product-lines gear apply', either side of minor-power-factions, before assembly-from-maker. Body facts: Products PL3, PL10-PL12.
-- product-tiers: spec cut-product-tiers.r1 (map section Product tiers). A binned product names its maker's line of one
- design and a floor in deviations; the mint draws its parts and its final test from the line above that floor. Six
- binned products, no hull tiers. Blocked on question tier-stock-share.
+- product-tiers: spec cut-product-tiers.r2 (map section Product tiers). A binned product names its maker's line of one
+ design and a floor in deviations, and the mint draws its parts and its final test from the line above that floor. A
+ bin takes its pass fraction of the maker's preference and the line takes the rest (ruling bin-share-by-floor). Six
+ binned products, no hull tiers.
- product-tiers-hulls (later, one line): ship hulls stay one product per maker per hull
(`ShipModCatalog.ProductKey(id, maker)`). A binned hull needs that key widened and its package's product record to
carry a Bin. Not mapped.
@@ -10961,9 +10962,10 @@ never shows below its floor tier: Spotlight Premiere is always Rare or better, a
price (x2.87 against x1.61). Goldcrest is Epic or Legendary at about 2.75 times Sparrow's price. The properties panel
reads, for example, "Spotlight Premiere (Rare++)". How much a bin buys follows the maker's quality control. Finch's
loose QC (±.20) leaves a lot to bin, so its bins jump two tiers. Zhestokost's tight drives (±.08) bin only to a better
-Uncommon: Juggernaut Guards costs about 1.4 times Juggernaut and still looks Uncommon. How often a binned product
-appears beside its line is question tier-stock-share. Under the recommended split-evenly, the maker is as present as
-before and its stock divides evenly between line and bin.
+Uncommon: Juggernaut Guards costs about 1.4 times Juggernaut and still looks Uncommon. A bin is as scarce as its floor says
+(ruling bin-share-by-floor). In Lucent's stock of Light Lasers, half are Spotlight Premieres and half plain Spotlights.
+A top-quarter bin such as Goldcrest or PowerHour 2.0 VIP is about one in four of its maker's units. The maker's
+presence for the design does not change when it bins a line.
### Authority map
@@ -10975,8 +10977,9 @@ before and its stock divides evenly between line and bin.
- Outputs: a Lot whose Product is the binned product (so Title and Brand read it), with every fill and the assembly
drawn at or above the floor. Price and tier follow from EffectiveQuality, unchanged.
- Derived state: a binned product's quality distributions are derived and never stored. Its Roles stay empty and its
- Assembly null. It has no per-product Price. Its draw weight is derived from its maker's preference (question
- tier-stock-share).
+ Assembly null. It has no per-product Price. Its draw weight is derived: the bin weighs `1 - Phi(Floor)` of its
+ maker's preference and its line weighs the rest (ruling bin-share-by-floor). Phi is computed through CultMath's
+ `math.erf`, present at the pinned CultMath 9c1b06cd (`packages/cultmath/src/CultMath/math.cs:534`).
- Forbidden writers: ProductLinesCommand never writes Roles or Assembly onto a binned product. roles-migrate and
roles-backfill never backfill one. The census never reports its empty Roles as a gap. Studio edits to a line's roles
or a maker's AssemblyMean reach its bins through the derivation, with no copy to resync.
@@ -11005,16 +11008,17 @@ before and its stock divides evenly between line and bin.
draw and then mints at the cutoff, so the draw always ends and stays deterministic per seed. Unbinned products
consume the stream exactly as before.
- Why the floor is in deviations. A floor in deviations needs no inverse normal: z 0 is the top half and z .67 the top
- quarter. Its fraction (1 - Phi(z) per stage) is only needed for the bin-share option of question tier-stock-share.
+ quarter. The draw weight needs only the forward CDF, `1 - Phi(z) = (1 - erf(z / sqrt 2)) / 2`, from CultMath's erf.
+ It is .5 at z 0 and .2514 at z .67.
- Why the line is not sorted. In real binning the base SKU is what is left after sorting. Here the line keeps its full
distribution, so a plain Spotlight can still roll Epic. Sorting the line would re-author every binned line and add a
- ceiling, and sorting a facility's own output is the crafting work that quality-triangle-is-per-facility defers. That
- choice is option sorted-line of tier-stock-share, not built here.
+ ceiling, and sorting a facility's own output is the crafting work that quality-triangle-is-per-facility defers. Sorting
+ was option sorted-line of tier-stock-share, which the operator did not pick (ruling bin-share-by-floor).
- Prior art (from general knowledge, confidence high on the shape and low on numbers). Semiconductor speed binning:
one die design is sold as several SKUs sorted by tested frequency and voltage, and the premium SKU is a guaranteed
floor, not a better average. Die harvesting: partly defective dies sell as lower SKUs. Makers also down-bin good
- parts to meet demand, so a SKU's supply share is a commercial choice and not only yield, which is why split-evenly
- is defensible for the demo.
+ parts to meet demand, so a SKU's supply share is a commercial choice and not only yield, The operator chose
+ shares that follow the floor (ruling bin-share-by-floor), so a bin's scarcity reads as yield.
- Hulls are out. `ShipModCatalog.ProductKey(id, maker)` keys one product per maker per hull, and hull products live
in packages that the Python writer leaves untouched (`ship_cc.py:5`). Widening that key is its own cut.
@@ -11026,8 +11030,10 @@ before and its stock divides evenly between line and bin.
its own. Invariant: a Bin's Line has the same Design and Manufacturer and is not itself binned.
- Lot (unchanged schema): a binned lot records the binned product, and its fills and assembly sit at or above the
floor of the line's distributions.
-- ProductWeight: the maker's preference, divided evenly among the maker's products of that design (pending
- tier-stock-share). It is still the one weight of RandomProducts and ChooseProduct.
+- ProductWeight: the maker's preference times a share. A bin's share is its pass fraction `1 - Phi(Bin.Floor)`. A line's
+ share is 1 minus the sum of the pass fractions of its bins, floored at 0. Any other product's share is 1. Two
+ unbinned lines of one design and maker each weigh the full preference, as before. ProductWeight is still the one
+ weight of RandomProducts and ChooseProduct.
## Run seed" section at the end of docs/aetheria-release-map.md (or beside "Mines in the sim").
2. Remove the "- run-seed: ..." line from the first "## Cut sequence" (spec cut-run-seed.r1 now carries it).
From 1b5e989182dd97abeea54fc153e457e16853823f Mon Sep 17 00:00:00 2001
From: Meta Vens
Date: Sun, 18 Oct 2026 17:54:50 +0200
Subject: [PATCH 012/444] Derive a faction's boss hull from HullData.Boss and
its products
HullData.Boss (key 33) is the flag; LoadoutGenerator.BossHullsOf is the one
owner of which Ship hulls are a faction's boss (the Boss hulls among the
designs of its products). AvailableProducts' candidate filter drops Boss
hulls, so one is never stock, traffic, a generated fit or a fallback boss.
GenerateShipLoadout splits into the draw and ShipLoadout(product).
Galaxy.PlaceFactionsMain and AetherDb factions/loadout read the derivation.
Derivation tests replace the two deleted BossHull tests.
Co-Authored-By: Claude Sonnet 5.5
---
Assets/Scripts/ServerShared/Galaxy.cs | 4 +-
Assets/Scripts/ServerShared/ItemData.cs | 5 +
.../Scripts/ServerShared/LoadoutGenerator.cs | 32 ++-
tests/Aetheria.Shared.Tests/RunGoalTests.cs | 193 +++++++++++++++++-
tools/AetherDb/Program.cs | 13 +-
5 files changed, 235 insertions(+), 12 deletions(-)
diff --git a/Assets/Scripts/ServerShared/Galaxy.cs b/Assets/Scripts/ServerShared/Galaxy.cs
index 0b2f71387..6e28cf49e 100644
--- a/Assets/Scripts/ServerShared/Galaxy.cs
+++ b/Assets/Scripts/ServerShared/Galaxy.cs
@@ -288,9 +288,9 @@ public class Galaxy
progressCallback?.Invoke("Placing Factions");
if (progressCallback != null) Thread.Sleep(500); // Inserting Delay to make it seem like it's doing more work lmao
- // Choose some megas to have bosses placed based on whether a boss hull is assigned
+ // Choose some megas to have bosses placed: the factions that make a Boss hull
var bossMegas = Factions
- .Where(m => m.BossHull.IsSet())
+ .Where(m => LoadoutGenerator.BossHullsOf(_cache, m).Length > 0)
.Take(bossCount)
.ToArray();
diff --git a/Assets/Scripts/ServerShared/ItemData.cs b/Assets/Scripts/ServerShared/ItemData.cs
index ca1484e36..431acc7ce 100644
--- a/Assets/Scripts/ServerShared/ItemData.cs
+++ b/Assets/Scripts/ServerShared/ItemData.cs
@@ -555,6 +555,11 @@ public class HullData : EquippableItemData
[Inspectable, JsonProperty("visual"), Key(32)]
public CultRecordRef Visual;
+ // Only bosses fly this hull. Which factions it is a boss for comes from its products' makers
+ // (LoadoutGenerator.BossHullsOf), so the flag names no faction and reads no model.
+ [Inspectable, JsonProperty("boss"), Key(33)]
+ public bool Boss;
+
// The farthest corner of any occupied hull cell from the centre of mass the schematic frame uses, in world
// units: a true bound on how far the hull reaches from its position (the half-diagonal of the bounding box is not,
// as FireControl.Detonate's comment records). A cell at (x, y) spans x +/- .5 and y +/- .5.
diff --git a/Assets/Scripts/ServerShared/LoadoutGenerator.cs b/Assets/Scripts/ServerShared/LoadoutGenerator.cs
index 67cc7f6b4..34e1cd1c0 100644
--- a/Assets/Scripts/ServerShared/LoadoutGenerator.cs
+++ b/Assets/Scripts/ServerShared/LoadoutGenerator.cs
@@ -48,7 +48,13 @@ public class LoadoutGenerator
ItemManager.Log("Unable to generate ship loadout: no compatible hull found!");
return null;
}
- var hull = ItemManager.CreateInstance(hullProduct) as EquippableItem;
+ return ShipLoadout(hullProduct);
+ }
+
+ // The ship a hull product becomes: outfit, pickup, consumables, packed. Callers choose the product; this is the one build.
+ public EntityPack ShipLoadout(FactionProductData product)
+ {
+ var hull = ItemManager.CreateInstance(product) as EquippableItem;
if(hull==null)
ItemManager.Log("WHAT???");
var entity = new Ship(ItemManager, null, hull, ItemManager.GameplaySettings.DefaultEntitySettings);
@@ -184,7 +190,9 @@ public class LoadoutGenerator
}
// The products this faction can be offered for a design kind (only gear needs a home), and whether manufacturer preference applies (it does
- // not once a required item has fallen back to any manufacturer). The one availability rule for every selection.
+ // not once a required item has fallen back to any manufacturer). The one availability rule for every selection: a Boss
+ // hull is never on offer, so it is never station stock, ordinary traffic, a generated scenario fit or another faction's
+ // fallback boss, and only BossHullsOf reaches it.
private ((FactionProductData product, T design)[] available, bool preferManufacturers) AvailableProducts(Predicate filter, bool required) where T : CraftedItemData
{
var hulls = SoldHulls(ItemManager.ItemData);
@@ -193,6 +201,7 @@ public class LoadoutGenerator
.Where(entry =>
entry.design != null &&
entry.design.Price > 0 &&
+ !(entry.design is HullData { Boss: true }) &&
entry.product.Manufacturer.IsSet() &&
(!(entry.design is EquippableItemData equippable) || HasHome(equippable, hulls)) &&
(filter?.Invoke(entry.design) ?? true))
@@ -209,10 +218,12 @@ public class LoadoutGenerator
return (available, preferManufacturers);
}
- // A faction's boss: the most expensive ship hull the faction can be offered, with the gap logged. Null only when no
- // ship hull is on offer at all.
+ // A faction's boss: the Boss hull it makes (BossHullsOf, dearest first), else the most expensive ordinary ship hull the
+ // faction can be offered, with the gap logged. Null only when no ship hull is on offer at all.
public EntityPack GenerateBossLoadout()
{
+ var made = BossHullsOf(ItemManager.ItemData, Faction);
+ if (made.Length > 0) return ShipLoadout(made[0].product);
ItemManager.Log($"{Faction?.Name ?? "This faction"} makes no boss hull; its boss flies the most expensive ship hull instead.");
var (available, _) = AvailableProducts(hull => hull.HullType == HullType.Ship, required: true);
@@ -221,6 +232,19 @@ public class LoadoutGenerator
return GenerateShipLoadout(hull => hull == priciest);
}
+ // The one owner of which hull is a faction's boss: the Ship hulls flagged Boss among the designs of the products the
+ // faction manufactures, dearest first then by name. The flag lives on the hull and the maker on the product, so a Boss
+ // hull several makers sell is a boss of each; nothing here reads a model, a prefab or a visual.
+ public static (FactionProductData product, HullData hull)[] BossHullsOf(CultCache items, Faction faction) =>
+ faction == null
+ ? Array.Empty<(FactionProductData, HullData)>()
+ : items.GetAll()
+ .Where(product => items.Get(product.Manufacturer) == faction)
+ .Select(product => (product, hull: items.Get(product.Design) as HullData))
+ .Where(entry => entry.hull is { Boss: true, HullType: HullType.Ship })
+ .OrderByDescending(entry => entry.hull.Price).ThenBy(entry => entry.hull.Name, StringComparer.Ordinal).ThenBy(entry => entry.product.Name, StringComparer.Ordinal)
+ .ToArray();
+
// Hardpoint gear that no hull in the catalog can mount has no home (operator, 2026-09-30: "we'll need to author a
// bunch more hulls before all the gear variety in the game has a home"): generation never offers it, so no station
// stocks it. It gains one the moment a hull with a hardpoint that takes it exists. Tool gear goes in any interior.
diff --git a/tests/Aetheria.Shared.Tests/RunGoalTests.cs b/tests/Aetheria.Shared.Tests/RunGoalTests.cs
index 5d133893f..cbb7ac13b 100644
--- a/tests/Aetheria.Shared.Tests/RunGoalTests.cs
+++ b/tests/Aetheria.Shared.Tests/RunGoalTests.cs
@@ -8,6 +8,8 @@ using System.IO;
using System.Linq;
using System.Text.RegularExpressions;
using CultMath;
+using GameCult.Caching;
+using GameCult.Caching.MessagePack;
using UniRx;
using Xunit;
@@ -38,6 +40,191 @@ public sealed partial class RunStartTests
.Where(hull => hull != null && hull.HullType == HullType.Ship && hull.Price > 0)
.Distinct().ToArray();
+ // A Boss hull a faction makes, by the production path: a copy of a sold ship hull (so it builds into a real ship) flagged
+ // Boss under its own name and price, and one copy of that hull's product per maker. A copy rather than the shipped
+ // hull itself, because flagging a shipped hull would make it a Boss hull of every maker the catalog gives it.
+ private HullData MakeBoss(string name, int price, params Faction[] makers) => MakeBoss(HullType.Ship, name, price, makers);
+
+ private HullData MakeBoss(HullType type, string name, int price, params Faction[] makers)
+ {
+ var options = CultDocumentMessagePackSerialization.OptionsFor(typeof(HullData).Assembly);
+ T Copy(T document) => MessagePack.MessagePackSerializer.Deserialize(MessagePack.MessagePackSerializer.Serialize(document, options), options);
+
+ var like = SoldShipHulls().OrderBy(hull => hull.Name, StringComparer.Ordinal).First();
+ var likeProduct = _cache.GetAll().OrderBy(product => product.Name, StringComparer.Ordinal)
+ .First(product => _cache.Get(product.Design) == like);
+ var boss = Copy(like);
+ boss.Name = name;
+ boss.Price = price;
+ boss.HullType = type;
+ boss.Boss = true;
+ boss.Prefab = null;
+ boss.Visual = default;
+ var bossRef = _cache.Upsert(boss);
+ foreach (var maker in makers)
+ {
+ var product = Copy(likeProduct);
+ product.Name = $"{name} by {maker.Name}";
+ product.Design = bossRef;
+ product.Manufacturer = _cache.RefOf(maker);
+ _cache.Upsert(product);
+ }
+ return _cache.Get(bossRef);
+ }
+
+ private static Faction FactionNamed(Galaxy galaxy, string name) => galaxy.Factions.Single(faction => faction.Name == name);
+
+ private HullData PriciestSold() =>
+ SoldShipHulls().OrderByDescending(hull => hull.Price).ThenBy(hull => hull.Name, StringComparer.Ordinal).First();
+
+ private LoadoutGenerator GeneratorFor(Galaxy galaxy, ItemManager items, Faction faction, uint seed)
+ {
+ var random = new CultMath.Random(seed);
+ return new LoadoutGenerator(ref random, items, galaxy, galaxy.Exit, faction, .5f);
+ }
+
+ [Fact]
+ public void Boss_flies_the_boss_hull_its_faction_makes()
+ {
+ var galaxy = DemoGalaxy(1);
+ var antagonist = Zhestokost(galaxy);
+ var cheapest = SoldShipHulls().Min(hull => hull.Price);
+ var priciest = PriciestSold();
+ Assert.True(cheapest < priciest.Price, "the catalog needs two ship hulls of different price for this test to tell them apart");
+
+ // The faction also makes the dearest ordinary hull, and three Boss hulls: B and A tie on price, C is cheaper. The boss
+ // is the dearest Boss hull, ties by name: not the dearest hull it makes, not the first product, not the cheapest.
+ _cache.Upsert(new FactionProductData { Name = "Decoy", Design = _cache.RefOf(priciest), Manufacturer = _cache.RefOf(antagonist) });
+ MakeBoss("Boss B", cheapest + 10, antagonist);
+ MakeBoss("Boss C", cheapest, antagonist);
+ MakeBoss("Boss A", cheapest + 10, antagonist);
+
+ var log = new List();
+ var items = LoggingItems(log);
+ var boss = Assert.Single(BossZonePack(galaxy, items, galaxy.Exit).Entities, entity => entity.Boss);
+ Assert.Equal("Boss A", HullName(items, boss));
+ Assert.DoesNotContain(log, line => line.Contains("makes no boss hull"));
+ Assert.Equal(new[] { "Boss A", "Boss B", "Boss C" }, LoadoutGenerator.BossHullsOf(_cache, antagonist).Select(entry => entry.hull.Name));
+ }
+
+ [Fact]
+ public void Boss_without_a_boss_hull_falls_back()
+ {
+ var galaxy = DemoGalaxy(1);
+ var priciest = PriciestSold();
+
+ var log = new List();
+ var items = LoggingItems(log);
+ var boss = Assert.Single(BossZonePack(galaxy, items, galaxy.Exit).Entities, entity => entity.Boss);
+ Assert.Equal(priciest.Name, HullName(items, boss));
+ Assert.Contains(log, line => line.Contains("Zhestokost") && line.Contains("makes no boss hull"));
+ Assert.Empty(LoadoutGenerator.BossHullsOf(_cache, Zhestokost(galaxy)));
+ Assert.Empty(LoadoutGenerator.BossHullsOf(_cache, null));
+ }
+
+ [Fact]
+ public void Another_factions_boss_hull_is_never_used()
+ {
+ var galaxy = DemoGalaxy(1);
+ var antagonist = Zhestokost(galaxy);
+ var lucent = FactionNamed(galaxy, "Lucent");
+ var expected = PriciestSold().Name;
+ // Dearer than every ordinary hull, and Lucent's alone: Zhestokost neither derives it nor falls back to it.
+ var luxury = MakeBoss("Lucent Flagship", PriciestSold().Price + 1000, lucent);
+
+ var log = new List();
+ var items = LoggingItems(log);
+ var boss = Assert.Single(BossZonePack(galaxy, items, galaxy.Exit).Entities, entity => entity.Boss);
+ Assert.Equal(expected, HullName(items, boss));
+ Assert.Contains(log, line => line.Contains("Zhestokost") && line.Contains("makes no boss hull"));
+ Assert.Empty(LoadoutGenerator.BossHullsOf(_cache, antagonist));
+ Assert.Same(luxury, Assert.Single(LoadoutGenerator.BossHullsOf(_cache, lucent)).hull);
+ }
+
+ [Fact]
+ public void A_boss_hull_two_makers_sell_is_a_boss_of_each()
+ {
+ var galaxy = DemoGalaxy(1);
+ var antagonist = Zhestokost(galaxy);
+ var lucent = FactionNamed(galaxy, "Lucent");
+ var shared = MakeBoss("Shared Boss", PriciestSold().Price - 1, antagonist, lucent);
+ // A Boss station hull the antagonist also makes is no boss: a boss is a ship.
+ MakeBoss(HullType.Station, "Boss Station", PriciestSold().Price + 1, antagonist);
+
+ Assert.Same(shared, Assert.Single(LoadoutGenerator.BossHullsOf(_cache, antagonist)).hull);
+ Assert.Same(shared, Assert.Single(LoadoutGenerator.BossHullsOf(_cache, lucent)).hull);
+ var items = LoggingItems(new List());
+ var boss = Assert.Single(BossZonePack(galaxy, items, galaxy.Exit).Entities, entity => entity.Boss);
+ Assert.Equal("Shared Boss", HullName(items, boss));
+ }
+
+ // Ruling hull-programmer-art-stopgap: a boss hull may be an agent's stopgap with no authored model.
+ [Fact]
+ public void A_boss_hull_needs_no_authored_model()
+ {
+ var galaxy = DemoGalaxy(1);
+ var antagonist = Zhestokost(galaxy);
+ var bare = MakeBoss("Bare Boss", 1, antagonist);
+ Assert.Null(bare.Prefab);
+ Assert.False(bare.Visual.IsSet());
+
+ Assert.Same(bare, Assert.Single(LoadoutGenerator.BossHullsOf(_cache, antagonist)).hull);
+ var items = LoggingItems(new List());
+ var boss = Assert.Single(BossZonePack(galaxy, items, galaxy.Exit).Entities, entity => entity.Boss);
+ Assert.Equal("Bare Boss", HullName(items, boss));
+ }
+
+ // A Boss hull is flown only by bosses: with the cheapest price (the heaviest draw weight) and every relevant maker, it still
+ // never comes out of a generation draw, including the any-manufacturer fallback and a faction no maker is allied to.
+ [Fact]
+ public void Boss_hulls_are_never_stock_or_traffic()
+ {
+ var galaxy = DemoGalaxy(1);
+ var antagonist = Zhestokost(galaxy);
+ var lucent = FactionNamed(galaxy, "Lucent");
+ var bossShip = MakeBoss("Traffic Boss", 1, antagonist, lucent);
+ var bossStation = MakeBoss(HullType.Station, "Stock Boss", 1, antagonist, lucent);
+ var stranger = new Faction { Name = "Stranger" };
+ _cache.Upsert(stranger);
+ stranger = _cache.GetAll().Single(faction => faction.Name == "Stranger");
+ MakeBoss("Strangers Foe Boss", 1, lucent);
+ var log = new List();
+ var items = LoggingItems(log);
+
+ var drawn = 0;
+ for (uint seed = 1; seed <= 20; seed++)
+ foreach (var faction in new[] { antagonist, lucent, stranger, null })
+ {
+ var generator = GeneratorFor(galaxy, items, faction, seed);
+ for (var i = 0; i < 3; i++)
+ {
+ var pack = generator.GenerateShipLoadout();
+ Assert.NotNull(pack);
+ drawn++;
+ Assert.DoesNotContain(HullName(items, pack), new[] { "Traffic Boss", "Strangers Foe Boss" });
+ Assert.NotEqual("Stock Boss", generator.RandomHull(HullType.Station).design?.Name);
+ }
+ // Asked for by name, with the boss the only candidate: nothing is on offer, however the fallback is reached.
+ Assert.Null(generator.GenerateShipLoadout(hull => hull == bossShip));
+ Assert.Null(generator.GenerateShipLoadout(hull => hull.Name == "Strangers Foe Boss"));
+ Assert.Null(generator.RandomHull(HullType.Station, hull => hull == bossStation).design);
+ }
+ Assert.Equal(20 * 4 * 3, drawn);
+ }
+
+ [Fact]
+ public void Main_galaxy_bosses_are_factions_that_make_boss_hulls()
+ {
+ Assert.Empty(RunStart.Generate(new MainGalaxy(), Inputs(() => 1)).BossZones);
+
+ var maker = _cache.GetAll().OrderBy(faction => faction.Name, StringComparer.Ordinal).First();
+ MakeBoss("Main Boss", 100, maker);
+ var galaxy = RunStart.Generate(new MainGalaxy(), Inputs(() => 1));
+ var bossFaction = Assert.Single(galaxy.BossZones.Keys);
+ Assert.Equal(maker.Name, bossFaction.Name);
+ Assert.NotEmpty(LoadoutGenerator.BossHullsOf(_cache, bossFaction));
+ }
+
[Fact]
public void Boss_spawns_once_in_boss_zone()
{
@@ -152,10 +339,10 @@ public sealed partial class RunStartTests
public void Exit_open_only_in_the_exit_zone()
{
var galaxy = RunStart.Generate(new MainGalaxy(), Inputs(() => 1));
- // The shipped catalog sets no BossHull outside the demo's antagonist, so the main galaxy places no boss: give a
- // faction one and a boss zone that is not the exit, as the generator would for a faction with a boss hull.
+ // The shipped catalog has no Boss hull, so the main galaxy places no boss: give a faction one and a boss zone that is
+ // not the exit, as the generator would for a faction that makes a Boss hull.
var faction = galaxy.Factions.First();
- faction.BossHull = _cache.RefOf(SoldShipHulls().First());
+ MakeBoss("Gate Boss", 100, faction);
var bossZone = galaxy.Zones.First(zone => zone != galaxy.Exit);
galaxy.BossZones[faction] = bossZone;
var zone = ZoneOf(galaxy, bossZone);
diff --git a/tools/AetherDb/Program.cs b/tools/AetherDb/Program.cs
index 500d85a8e..583e811ad 100644
--- a/tools/AetherDb/Program.cs
+++ b/tools/AetherDb/Program.cs
@@ -695,7 +695,8 @@ public static class Program
}
// Each faction's generation-critical links and the size of its curated place list. An empty list is not broken:
- // Galaxy.NamePlaces falls back to catalogue designations for a faction without one.
+ // Galaxy.NamePlaces falls back to catalogue designations for a faction without one. The boss column is derived:
+ // the Boss hulls the faction makes (LoadoutGenerator.BossHullsOf), never a field on the faction.
private static int Factions()
{
var db = AetherDb.Open();
@@ -704,7 +705,8 @@ public static class Program
foreach (var faction in db.Cache.GetAll().OrderBy(f => f.Name))
{
var key = db.Cache.RefOf(faction).Key;
- var boss = "none";
+ var bosses = LoadoutGenerator.BossHullsOf(db.Cache, faction);
+ var boss = bosses.Length == 0 ? "none" : string.Join(',', bosses.Select(b => b.hull.Name));
Console.WriteLine($"{faction.Name,-26} {faction.ShortName,-13} {faction.Places.Length,-22} {boss,-14} {faction.InfluenceDistance,-9} {(faction.Doctrine == null ? "none" : faction.Doctrine.EngageOn.ToString()),-10} " +
products.Count(p => p.Manufacturer.Key.Equals(key)));
}
@@ -804,13 +806,18 @@ public static class Program
string outcome;
try
{
+ // Generation never offers a Boss hull, so one is built through its first product.
+ var bossProduct = hull.Boss && hull.HullType == HullType.Ship
+ ? db.Cache.GetAll().Where(p => db.Cache.Get(p.Design) == hull).OrderBy(p => p.Name, StringComparer.Ordinal).FirstOrDefault()
+ : null;
var pack = hull.HullType switch
{
+ HullType.Ship when hull.Boss => bossProduct == null ? null : generator.ShipLoadout(bossProduct),
HullType.Ship => generator.GenerateShipLoadout(candidate => candidate == hull),
HullType.Turret => generator.GenerateTurretLoadout(),
_ => generator.GenerateStationLoadout()
};
- outcome = pack == null ? "NO LOADOUT (nothing suitable found)" : "ok";
+ outcome = pack != null ? "ok" : hull.Boss && hull.HullType == HullType.Ship ? "NO LOADOUT (boss hull with no product)" : "NO LOADOUT (nothing suitable found)";
if (pack == null) failures++;
if (pack != null && log.Any(l => l.Contains("targeting system available", StringComparison.OrdinalIgnoreCase))) targetingGaps++;
}
From 6b602dd34a964ae7f139d668c0870b5b2676d78d Mon Sep 17 00:00:00 2001
From: Meta Vens
Date: Sun, 18 Oct 2026 17:54:52 +0200
Subject: [PATCH 013/444] ship-authoring boss on|off sets HullData.Boss on a
package hull
Judged before the file is touched, touches no product; inspect prints
", boss" when set. Tests: the command, and the package path through compose.
Co-Authored-By: Claude Sonnet 5.5
---
.../Aetheria.Shared.Tests/ShipProductTests.cs | 50 +++++++++++++++++++
tools/AetherDb/ShipAuthoringCommands.cs | 19 ++++++-
2 files changed, 67 insertions(+), 2 deletions(-)
diff --git a/tests/Aetheria.Shared.Tests/ShipProductTests.cs b/tests/Aetheria.Shared.Tests/ShipProductTests.cs
index f6f3bad10..a88a2541d 100644
--- a/tests/Aetheria.Shared.Tests/ShipProductTests.cs
+++ b/tests/Aetheria.Shared.Tests/ShipProductTests.cs
@@ -99,6 +99,25 @@ public sealed class ShipProductTests : IDisposable
}
}
+ // The package path of the derivation: the flag survives compose on the hull, and the maker is the package's product's.
+ [Fact]
+ public void ABossPackageIsItsMakersBossAfterCompose()
+ {
+ Environment.CurrentDirectory = _root.Path;
+ var path = Package("mod.boss", "Bosshull", 100, Product("Acme Boss", _acme, "mod.boss"));
+ Assert.Equal(0, Run("boss", path, "on").Code);
+ var composition = ShipModCatalog.Compose(Shipped, Derived, Mods);
+ Assert.Equal(new[] { "mod.boss" }, composition.Included);
+
+ using var cache = AetheriaStores.Open(Derived);
+ var hull = Assert.IsType(cache.Get(new CultRecordRef(ShipModCatalog.HullKey("mod.boss"))));
+ Assert.True(hull.Boss);
+ var acme = cache.Get(_acme);
+ var rival = cache.Get(_rival);
+ Assert.Same(hull, Assert.Single(LoadoutGenerator.BossHullsOf(cache, acme)).hull);
+ Assert.Empty(LoadoutGenerator.BossHullsOf(cache, rival));
+ }
+
[Fact]
public void APackageWithAnUnknownMakerIsExcluded()
{
@@ -299,6 +318,37 @@ public sealed class ShipProductTests : IDisposable
RefusedAndUntouched("product", path, "ACM", "--remove");
}
+ [Fact]
+ public void BossCommand()
+ {
+ Environment.CurrentDirectory = _root.Path;
+ var path = Package("mod.skiff", "Skiffhull", 100, Product("Acme Skiff", _acme, "mod.skiff"));
+ void RefusedAndUntouched(params string[] args)
+ {
+ var before = File.ReadAllBytes(path);
+ Assert.Equal(1, Run(args).Code);
+ Assert.Equal(before, File.ReadAllBytes(path));
+ }
+ var options = CultDocumentMessagePackSerialization.OptionsFor(typeof(FactionProductData).Assembly);
+ byte[] ProductBytes() => MessagePack.MessagePackSerializer.Serialize(ShipAuthoringStore.Load(path).Products.Single(), options);
+
+ var productBefore = ProductBytes();
+ Assert.False(ShipAuthoringStore.Read(path).Hull.Boss);
+ RefusedAndUntouched("boss", path, "yes");
+ RefusedAndUntouched("boss", path, "ON");
+ RefusedAndUntouched("boss", path);
+
+ Assert.Equal(0, Run("boss", path, "on").Code);
+ Assert.True(ShipAuthoringStore.Read(path).Hull.Boss);
+ Assert.Equal(productBefore, ProductBytes());
+ Assert.Contains(", boss", Run("inspect", path).Output);
+
+ Assert.Equal(0, Run("boss", path, "off").Code);
+ Assert.False(ShipAuthoringStore.Read(path).Hull.Boss);
+ Assert.Equal(productBefore, ProductBytes());
+ Assert.DoesNotContain(", boss", Run("inspect", path).Output);
+ }
+
[Fact]
public void ValidateJudgesProductsOnADraftAndInspectListsThem()
{
diff --git a/tools/AetherDb/ShipAuthoringCommands.cs b/tools/AetherDb/ShipAuthoringCommands.cs
index 9fd7e690a..769db37dc 100644
--- a/tools/AetherDb/ShipAuthoringCommands.cs
+++ b/tools/AetherDb/ShipAuthoringCommands.cs
@@ -8,7 +8,7 @@ using System.Linq;
// Uses the existing database executable; mod authoring does not earn another binary target.
public static class ShipAuthoringCommands
{
- private const string Usage = "ship-authoring create [--like ] | inspect | centre | validate | product (--name [--description ] | --remove) | price | compose ";
+ private const string Usage = "ship-authoring create [--like ] | inspect | centre | validate | product (--name [--description ] | --remove) | price | boss on|off | compose ";
public static int Run(string[] args)
{
@@ -29,7 +29,7 @@ public static class ShipAuthoringCommands
if (args[0] == "inspect" && args.Length == 2)
{
var (hull, ship, products) = ShipAuthoringStore.Load(args[1]);
- Console.WriteLine($"{ship.Id}: {hull.Name}, {hull.Hardpoints?.Count ?? 0} hardpoints, {ship.SchematicLines?.Count ?? 0} lines, {ship.SchematicLines?.Sum(line => line.Points?.Length / 3 ?? 0) ?? 0} points");
+ Console.WriteLine($"{ship.Id}: {hull.Name}, {hull.Hardpoints?.Count ?? 0} hardpoints, {ship.SchematicLines?.Count ?? 0} lines, {ship.SchematicLines?.Sum(line => line.Points?.Length / 3 ?? 0) ?? 0} points{(hull.Boss ? ", boss" : "")}");
foreach (var product in products) Console.WriteLine($"product {product.Manufacturer.Key.Value}: {product.Name}");
return 0;
}
@@ -91,6 +91,21 @@ public static class ShipAuthoringCommands
Console.WriteLine($"{ship.Id}: price {price}.");
return 0;
}
+ if (args[0] == "boss" && args.Length == 3)
+ {
+ // Judged before the file is touched: a refusal leaves it byte-identical. Sets the hull's flag and no product.
+ if (args[2] != "on" && args[2] != "off")
+ throw new InvalidOperationException("boss must be on or off.");
+ var (hull, ship, _) = ShipAuthoringStore.Load(args[1]);
+ hull.Boss = args[2] == "on";
+ using (var cache = ShipAuthoringStore.Open(args[1], writable: true))
+ {
+ ShipAuthoringStore.Write(cache, hull, ship);
+ cache.FlushAsync().Wait();
+ }
+ Console.WriteLine($"{ship.Id}: boss {args[2]}.");
+ return 0;
+ }
if (args[0] == "product" && args.Length >= 4)
{
var remove = args.Length == 4 && args[3] == "--remove";
From 095baf2fb5e5f8b9ec5c181f37a34d805a752bd4 Mon Sep 17 00:00:00 2001
From: Meta Vens
Date: Sun, 18 Oct 2026 17:57:23 +0200
Subject: [PATCH 014/444] RunGoalTests: product Design refs are CraftedItemData
refs
Co-Authored-By: Claude Sonnet 5.5
---
tests/Aetheria.Shared.Tests/RunGoalTests.cs | 4 ++--
1 file changed, 2 insertions(+), 2 deletions(-)
diff --git a/tests/Aetheria.Shared.Tests/RunGoalTests.cs b/tests/Aetheria.Shared.Tests/RunGoalTests.cs
index cbb7ac13b..541ee64dd 100644
--- a/tests/Aetheria.Shared.Tests/RunGoalTests.cs
+++ b/tests/Aetheria.Shared.Tests/RunGoalTests.cs
@@ -65,7 +65,7 @@ public sealed partial class RunStartTests
{
var product = Copy(likeProduct);
product.Name = $"{name} by {maker.Name}";
- product.Design = bossRef;
+ product.Design = new CultRecordRef(bossRef.Key);
product.Manufacturer = _cache.RefOf(maker);
_cache.Upsert(product);
}
@@ -94,7 +94,7 @@ public sealed partial class RunStartTests
// The faction also makes the dearest ordinary hull, and three Boss hulls: B and A tie on price, C is cheaper. The boss
// is the dearest Boss hull, ties by name: not the dearest hull it makes, not the first product, not the cheapest.
- _cache.Upsert(new FactionProductData { Name = "Decoy", Design = _cache.RefOf(priciest), Manufacturer = _cache.RefOf(antagonist) });
+ _cache.Upsert(new FactionProductData { Name = "Decoy", Design = new CultRecordRef(_cache.RefOf(priciest).Key), Manufacturer = _cache.RefOf(antagonist) });
MakeBoss("Boss B", cheapest + 10, antagonist);
MakeBoss("Boss C", cheapest, antagonist);
MakeBoss("Boss A", cheapest + 10, antagonist);
From 49853d3166bdeaaf1f784d447501eeff62efcf15 Mon Sep 17 00:00:00 2001
From: Meta Vens
Date: Sun, 18 Oct 2026 17:58:51 +0200
Subject: [PATCH 015/444] ProductBin: a binned product names its maker's line
and a floor; the mint draws above that floor
FactionProductData slot 7 Bin (schema id unchanged, additive as slot 6). Mint derives a bin's
distributions from its line on every lot, truncated by NextGaussianAbove (64 tries, then the cutoff).
Co-Authored-By: Claude Sonnet 5.5
---
Assets/Scripts/ServerShared/Extensions.cs | 15 ++
Assets/Scripts/ServerShared/FactionProduct.cs | 32 +++-
Assets/Scripts/ServerShared/ItemManager.cs | 19 +-
.../Aetheria.Shared.Tests/ProductBinTests.cs | 175 ++++++++++++++++++
4 files changed, 230 insertions(+), 11 deletions(-)
create mode 100644 tests/Aetheria.Shared.Tests/ProductBinTests.cs
diff --git a/Assets/Scripts/ServerShared/Extensions.cs b/Assets/Scripts/ServerShared/Extensions.cs
index 656e85ba2..97433a299 100644
--- a/Assets/Scripts/ServerShared/Extensions.cs
+++ b/Assets/Scripts/ServerShared/Extensions.cs
@@ -180,6 +180,21 @@ public static class Extensions
return mean + deviation * sqrt(-2 * log(u1)) * cos(2 * PI * u2);
}
+ // NextGaussian truncated below mean + floorDeviations * deviation: draws until one clears that cutoff, at most 64 times,
+ // then returns the cutoff itself. Exact for any reachable floor and bounded and deterministic per seed for an unreachable
+ // one. A binned product's fills and assembly (ProductBin). Callers clamp, as with NextGaussian.
+ public static float NextGaussianAbove(this ref Random random, float mean, float deviation, float floorDeviations)
+ {
+ var cutoff = mean + floorDeviations * deviation;
+ for (var tries = 0; tries < 64; tries++)
+ {
+ var draw = random.NextGaussian(mean, deviation);
+ if (draw >= cutoff) return draw;
+ }
+
+ return cutoff;
+ }
+
public static float NextUnbounded(this ref Random random) => 1 / (1 - random.NextFloat()) - 1;
public static float NextUnbounded(this ref Random random, float bias, float power, float ceiling) => 1 / (1 - pow(min(random.NextFloat(), ceiling), 1 - pow(clamp(bias,0,.99f), 1 / power))) - 1;
diff --git a/Assets/Scripts/ServerShared/FactionProduct.cs b/Assets/Scripts/ServerShared/FactionProduct.cs
index cdaed4f28..b9a3f0009 100644
--- a/Assets/Scripts/ServerShared/FactionProduct.cs
+++ b/Assets/Scripts/ServerShared/FactionProduct.cs
@@ -8,9 +8,9 @@ using GameCult.Caching;
using MessagePack;
using Newtonsoft.Json;
-// A manufacturer's branded variant of a generic design: its name, its flavor text, and how it builds each of
-// the design's roles. Which products exist is what a faction makes, so market segmentation and regional
-// progression are authored here rather than as duplicate item entries.
+// What a manufacturer sells of a generic design: its name, its flavor text, and either a line (how the maker builds each of the
+// design's roles) or a bin of one of its own lines (maker-design-tiers-allowed). Which products exist is what a faction makes, so
+// market segmentation and regional progression are authored here rather than as duplicate item entries.
[CultDocument("aetheria.factionproductdata", "1"), Inspectable, MessagePackObject, JsonObject(MemberSerialization.OptIn)]
public class FactionProductData
{
@@ -29,16 +29,21 @@ public class FactionProductData
[Inspectable, JsonProperty("roles"), Key(5)]
public List Roles = new List();
- // This line's own final test (maker-design-tiers-allowed): a binned or premium line is a better assembly as well as better
- // parts. Null assembles as its maker does.
+ // This line's own final test (maker-design-tiers-allowed): a premium line is a better assembly as well as better parts.
+ // Null assembles as its maker does. A binned product leaves it null and reads its line's.
[Inspectable, JsonProperty("assembly"), Key(6)]
public AssemblyDistribution Assembly;
+
+ // Set on a binned product: the line it bins and the floor above which it takes that line's output. A bin authors no Roles and
+ // no Assembly; the mint derives both from the line.
+ [Inspectable, JsonProperty("bin"), Key(7)]
+ public ProductBin Bin;
}
// How good this product's part is in one of its design's roles: a pseudo-gaussian distribution whose mean is
// the technology the manufacturer puts in and whose standard deviation is their quality control. A market
-// segment is a second product with a role pumped up; what makes that part better is the flavor text's to tell,
-// because a part here carries quality and nothing else.
+// segment is a bin of the line (ProductBin), not a second product with a role pumped up; what makes a part better is the
+// flavor text's to tell, because a part here carries quality and nothing else.
[MessagePackObject, JsonObject(MemberSerialization.OptIn)]
public class ProductRole
{
@@ -52,6 +57,19 @@ public class ProductRole
public float StandardDeviation = .15f;
}
+// A bin of one of the maker's lines (ruling maker-design-tiers-allowed): the line it bins (the same design and maker, itself
+// unbinned) and the floor, in standard deviations above the mean of each of the line's distributions, below which the bin
+// takes nothing. 0 is the top half of the line, .67 the top quarter.
+[MessagePackObject, JsonObject(MemberSerialization.OptIn)]
+public class ProductBin
+{
+ [Inspectable, JsonProperty("line"), Key(0)]
+ public CultRecordRef Line;
+
+ [Inspectable, JsonProperty("floor"), Key(1)]
+ public float Floor;
+}
+
// How well a maker puts the thing together: the technology and quality control of final assembly. A lot's own quality is drawn
// from it, and it is the ceiling on every role the lot fills. Defaults are a middling maker.
[MessagePackObject, JsonObject(MemberSerialization.OptIn)]
diff --git a/Assets/Scripts/ServerShared/ItemManager.cs b/Assets/Scripts/ServerShared/ItemManager.cs
index 12d45d101..b33b17224 100644
--- a/Assets/Scripts/ServerShared/ItemManager.cs
+++ b/Assets/Scripts/ServerShared/ItemManager.cs
@@ -121,6 +121,9 @@ public class ItemManager
// Builds one of a manufacturer's branded products (quality-is-assembly-over-supply-chain): the unit's own quality is its
// final assembly, drawn from the product's assembly distribution, else its maker's, else AssemblyDistribution's defaults,
// and each of the design's roles is filled with a part whose quality is drawn from the product's distribution for it.
+ // A binned product (maker-design-tiers-allowed) has no distributions of its own: it is its line's, derived here on every
+ // mint, with each draw (the assembly and every fill) truncated below the line's mean + Bin.Floor deviations. The lot is
+ // still the bin's: its Product, its title and so its brand.
public int CreateLot(FactionProductData product) => Mint(product, null);
// The same lot at a stated quality: the assembly and every role fill are that quality and nothing is drawn, so a staged
@@ -130,29 +133,37 @@ public class ItemManager
private int Mint(FactionProductData product, float? stated)
{
var design = ItemData.Get(product.Design);
- var assembly = product.Assembly ?? ItemData.Get(product.Manufacturer)?.Assembly ?? new AssemblyDistribution();
+ var line = product.Bin != null ? ItemData.Get(product.Bin.Line) : null;
+ if (product.Bin != null && line == null)
+ Log("A binned product names a line the catalog does not hold; it mints as a product with no roles of its own.");
+ var source = line ?? product;
+ var floor = line != null ? product.Bin.Floor : (float?) null;
+ var assembly = source.Assembly ?? ItemData.Get(product.Manufacturer)?.Assembly ?? new AssemblyDistribution();
var lot = new Lot
{
Design = ItemData.RefOf(design),
Origin = new Attributed { Faction = product.Manufacturer },
Product = ItemData.RefOf(product),
- Quality = stated ?? clamp(Random.NextGaussian(assembly.Mean, assembly.StandardDeviation), .01f, 1),
+ Quality = stated ?? clamp(Draw(assembly.Mean, assembly.StandardDeviation, floor), .01f, 1),
Roles = new List()
};
if (design.Roles != null)
foreach (var role in design.Roles)
{
- var build = product.Roles?.FirstOrDefault(b => b.Role == role.Name) ?? new ProductRole();
+ var build = source.Roles?.FirstOrDefault(b => b.Role == role.Name) ?? new ProductRole();
lot.Roles.Add(new RoleFill
{
Role = role.Name,
- Quality = stated ?? clamp(Random.NextGaussian(build.Mean, build.StandardDeviation), .01f, 1)
+ Quality = stated ?? clamp(Draw(build.Mean, build.StandardDeviation, floor), .01f, 1)
});
}
return Lots.Add(lot);
}
+ private float Draw(float mean, float deviation, float? floorDeviations) =>
+ floorDeviations is { } floor ? Random.NextGaussianAbove(mean, deviation, floor) : Random.NextGaussian(mean, deviation);
+
// No branded product: a lot attributed to maker (which may be unset) with no role fills, e.g. console `give`.
public int CreateLot(CraftedItemData design, CultRecordRef maker, float quality)
{
diff --git a/tests/Aetheria.Shared.Tests/ProductBinTests.cs b/tests/Aetheria.Shared.Tests/ProductBinTests.cs
new file mode 100644
index 000000000..44bbfe077
--- /dev/null
+++ b/tests/Aetheria.Shared.Tests/ProductBinTests.cs
@@ -0,0 +1,175 @@
+using System;
+using System.Collections.Generic;
+using System.IO;
+using System.Linq;
+using GameCult.Caching;
+using Xunit;
+
+// A bin is a product of its maker's line above a floor (aetheria-release, cut product-tiers; ruling maker-design-tiers-allowed):
+// the mint derives its distributions from the line on every lot, truncated below line mean + Floor deviations, and the lot is
+// still the bin's. Lots here come from CreateLot(product), the production mint, on the AssemblyFromMakerTests fixture.
+public sealed class ProductBinTests : IDisposable
+{
+ private readonly string _root = Path.Combine(Path.GetTempPath(), "aetheria-bins-" + Guid.NewGuid().ToString("N"));
+ private readonly CultCache _cache;
+ private readonly ItemManager _items;
+ private readonly CultRecordRef _lens;
+ private readonly List _log = new List();
+
+ public ProductBinTests()
+ {
+ Directory.CreateDirectory(_root);
+ _cache = AetheriaStores.Open(Path.Combine(_root, "Aetheria.cc"), catalogWritable: true);
+ var gear = _cache.Upsert(new GearData
+ {
+ Name = "Lensed", Hardpoint = HardpointType.Sensors, Shape = new Shape(), Price = 10,
+ Roles = { new ItemRole { Name = "lens" } }
+ });
+ _lens = new CultRecordRef(gear.Key);
+ _items = new ItemManager(_cache, new ProvenanceLedger(), RunSaveTests.TestSettings(), _log.Add) { Random = new CultMath.Random(7u) };
+ }
+
+ public void Dispose()
+ {
+ _cache.Dispose();
+ Directory.Delete(_root, true);
+ }
+
+ private static AssemblyDistribution A(float mean, float deviation) => new AssemblyDistribution { Mean = mean, StandardDeviation = deviation };
+
+ // A maker at assembly .6 +- .1 and a line of the Lensed design at lens .5 +- .1.
+ private FactionProductData Line(string name = "Line", float lensMean = .5f, float lensDeviation = .1f, AssemblyDistribution own = null, float makerMean = .6f)
+ {
+ var maker = _cache.Upsert(new Faction { Name = name + " Maker", Assembly = A(makerMean, .1f) });
+ _cache.Upsert(new FactionProductData
+ {
+ Name = name, Design = _lens, Manufacturer = maker, Assembly = own,
+ Roles = { new ProductRole { Role = "lens", Mean = lensMean, StandardDeviation = lensDeviation } }
+ });
+ return _cache.GetAll().Single(p => p.Name == name);
+ }
+
+ private FactionProductData Bin(FactionProductData line, string name, float floor)
+ {
+ _cache.Upsert(new FactionProductData
+ {
+ Name = name, Design = line.Design, Manufacturer = line.Manufacturer,
+ Bin = new ProductBin { Line = _cache.RefOf(line), Floor = floor }
+ });
+ return _cache.GetAll().Single(p => p.Name == name);
+ }
+
+ private Lot[] Mint(FactionProductData product, int count) =>
+ Enumerable.Range(0, count).Select(_ => _items.Lots[_items.CreateLot(product)]).ToArray();
+
+ private static float[] Fills(Lot[] lots) => lots.Select(l => l.Roles.Single().Quality).ToArray();
+
+ // Floor 0 is the top half of the line: nothing under the line's mean, in the fills or in the assembly.
+ [Fact]
+ public void BinnedFillsAndAssemblyNeverFallBelowTheFloor()
+ {
+ var lots = Mint(Bin(Line(), "Top Half", 0), 400);
+ Assert.All(lots, lot =>
+ {
+ Assert.True(lot.Roles.Single().Quality >= .5f - 1e-5f, "a fill fell under the line's mean");
+ Assert.True(lot.Quality >= .6f - 1e-5f, "an assembly fell under the maker's mean");
+ });
+ Assert.InRange(Fills(lots).Average(), .55f, .60f);
+ }
+
+ // The floor is in the line's standard deviations: .67 on .5 +- .1 cuts at .567 and the truncated mean is .627.
+ [Fact]
+ public void FloorIsInDeviations()
+ {
+ var fills = Fills(Mint(Bin(Line(), "Top Quarter", .67f), 400));
+ Assert.All(fills, fill => Assert.True(fill >= .567f - 1e-5f, "a fill fell under mean + floor deviations"));
+ Assert.InRange(fills.Average(), .61f, .645f);
+ }
+
+ // A bin keeps no distribution of its own: after it exists, the line and the maker move and the bin follows.
+ [Fact]
+ public void BinDerivesFromItsLineNotACopy()
+ {
+ var line = Line();
+ var bin = Bin(line, "Follower", 0);
+ Assert.Empty(bin.Roles);
+ Assert.Null(bin.Assembly);
+ line.Roles.Single().Mean = .7f;
+ var maker = _cache.Get(line.Manufacturer);
+ maker.Assembly = A(.8f, .1f);
+ _cache.Commit(batch =>
+ {
+ batch.Upsert(typeof(FactionProductData), line, _cache.RefOf(line).Key);
+ batch.Upsert(typeof(Faction), maker, _cache.RefOf(maker).Key);
+ });
+ var lots = Mint(bin, 300);
+ Assert.All(lots, lot =>
+ {
+ Assert.True(lot.Roles.Single().Quality >= .7f - 1e-5f, "the bin read a copy of its line's fill");
+ Assert.True(lot.Quality >= .8f - 1e-5f, "the bin read a copy of its maker's assembly");
+ });
+ }
+
+ // A line's own assembly is the final test a bin of it sits: the maker's is read only when the line sets none.
+ [Fact]
+ public void BinReadsTheLinesOwnAssemblyBeforeTheMakers()
+ {
+ var bin = Bin(Line(own: A(.85f, .05f), makerMean: .4f), "Certified", 0);
+ Assert.All(Mint(bin, 300), lot => Assert.True(lot.Quality >= .85f - 1e-5f, "the bin assembled as its maker, not its line"));
+ }
+
+ // 6 deviations above a .2 +- .01 line is out of reach in 64 draws: the mint returns the cutoff, .26, and does not loop.
+ [Fact]
+ public void UnreachableFloorMintsAtTheCutoff()
+ {
+ var bin = Bin(Line(lensMean: .2f, lensDeviation: .01f, own: A(.2f, .01f)), "Unreachable", 6);
+ Assert.All(Mint(bin, 20), lot =>
+ {
+ Assert.Equal(.26f, lot.Roles.Single().Quality, 5);
+ Assert.Equal(.26f, lot.Quality, 5);
+ });
+ }
+
+ // Snapshot of the first five assemblies and fills at base, before the bin existed (seed 20261018, lens .5 +- .1, maker .6 +- .1).
+ [Fact]
+ public void UnbinnedMintConsumesTheStreamAsBefore()
+ {
+ var line = Line();
+ _items.Random = new CultMath.Random(20261018u);
+ var lots = Mint(line, 5);
+ var assemblies = new[] { .59560823f, .68289334f, .57563853f, .5639112f, .59828424f };
+ var fills = new[] { .6311789f, .5835483f, .47641385f, .46416125f, .45124212f };
+ for (var i = 0; i < 5; i++)
+ {
+ Assert.Equal(assemblies[i], lots[i].Quality, 5);
+ Assert.Equal(fills[i], lots[i].Roles.Single().Quality, 5);
+ }
+ }
+
+ // The lot is the bin's: its product, its title, and the line's maker as its brand.
+ [Fact]
+ public void ABinnedLotIsBrandedAsTheBin()
+ {
+ var line = Line();
+ var bin = Bin(line, "Premiere", 0);
+ var lotId = _items.CreateLot(bin);
+ Assert.Equal(_cache.RefOf(bin), _items.Lots[lotId].Product);
+ var instance = _items.CreateInstance(lotId);
+ Assert.Equal("Premiere", _items.Title(instance));
+ var (maker, product) = _items.Brand(instance);
+ Assert.Equal(_cache.Get(line.Manufacturer).Name, maker.Name);
+ Assert.Equal("Premiere", product.Name);
+ }
+
+ // A bin whose line the catalog does not hold mints as a product with no roles of its own, and says so.
+ [Fact]
+ public void ABinOfALineTheCatalogLacksMintsAtDefaultsAndLogs()
+ {
+ var line = Line();
+ var bin = Bin(line, "Orphan", 0);
+ _cache.Remove(_cache.RefOf(line).Key);
+ var lots = Mint(bin, 5);
+ Assert.Equal(5, lots.Length);
+ Assert.NotEmpty(_log);
+ }
+}
From 7f09b05725f3f8fd6106e1955fe1a09a6c01228e Mon Sep 17 00:00:00 2001
From: Meta Vens
Date: Sun, 18 Oct 2026 18:04:08 +0200
Subject: [PATCH 016/444] ProductWeight: a bin takes its pass fraction of its
maker's preference and its line the rest (ruling bin-share-by-floor)
Phi comes from CultMath's math.erf.
Co-Authored-By: Claude Sonnet 5.5
---
.../Scripts/ServerShared/LoadoutGenerator.cs | 17 ++++++-
.../ProductChoiceTests.cs | 51 ++++++++++++++++++-
2 files changed, 65 insertions(+), 3 deletions(-)
diff --git a/Assets/Scripts/ServerShared/LoadoutGenerator.cs b/Assets/Scripts/ServerShared/LoadoutGenerator.cs
index 55eea27f8..0206935fb 100644
--- a/Assets/Scripts/ServerShared/LoadoutGenerator.cs
+++ b/Assets/Scripts/ServerShared/LoadoutGenerator.cs
@@ -252,8 +252,21 @@ public class LoadoutGenerator
Galaxy == null || Faction == null ||
ItemManager.ItemData.Get(product.Manufacturer) == Faction || Faction.Allegiance.ContainsKey(product.Manufacturer);
- // How much this outfitter favors a product: its maker's preference. The one weight of generation and of ChooseProduct.
- public float ProductWeight(FactionProductData product) => ManufacturerPreference(product.Manufacturer);
+ // How much this outfitter favors a product: its maker's preference times the product's share of it. The one weight of
+ // generation and of ChooseProduct. A bin takes the share of its maker's stock that would pass its floor and its line takes
+ // the rest (ruling bin-share-by-floor), so binning never changes a maker's presence for the design.
+ public float ProductWeight(FactionProductData product) => ManufacturerPreference(product.Manufacturer) * Share(product);
+
+ // 1 - Phi(floor): the fraction of a normal draw that lies at least floor deviations above its mean.
+ private static float PassFraction(float floor) => .5f * (1 - erf(floor / sqrt(2f)));
+
+ private float Share(FactionProductData product)
+ {
+ if (product.Bin != null) return PassFraction(product.Bin.Floor);
+ var key = ItemManager.ItemData.RefOf(product).Key;
+ var binned = ItemManager.ItemData.GetAll().Where(other => other.Bin != null && other.Bin.Line.Key.Equals(key)).Sum(bin => PassFraction(bin.Bin.Floor));
+ return max(0f, 1 - binned);
+ }
// The product a design named without one is built as (ruling loadout-product-optional-context-chooses): among the
// products of the design this outfitter can get, one draw from the run's seeded Random weighted by ProductWeight;
diff --git a/tests/Aetheria.Shared.Tests/ProductChoiceTests.cs b/tests/Aetheria.Shared.Tests/ProductChoiceTests.cs
index 888634f8e..a11708393 100644
--- a/tests/Aetheria.Shared.Tests/ProductChoiceTests.cs
+++ b/tests/Aetheria.Shared.Tests/ProductChoiceTests.cs
@@ -63,7 +63,10 @@ public sealed class ProductChoiceTests : IDisposable
}
}
- private Rig Build(params string[] lampOrder)
+ private Rig Build(params string[] lampOrder) => Build(new float[0], lampOrder);
+
+ // binFloors adds one bin of lamp-a's line per floor, named "lamp-a bin" and "lamp-a bin 2"..., by the same maker.
+ private Rig Build(float[] binFloors, string[] lampOrder)
{
var root = Path.Combine(_root, "rig" + _catalogs++);
Directory.CreateDirectory(root);
@@ -97,6 +100,12 @@ public sealed class ProductChoiceTests : IDisposable
// lampOrder holds product names; the record key of the i-th inserted is "k" + i, so keys and insertion order
// both follow the permutation.
for (var i = 0; i < lampOrder.Length; i++) Sell(lampOrder[i], lamp.Key, makers[lampOrder[i]], "k" + i);
+ for (var i = 0; i < binFloors.Length; i++)
+ seed.Commit(batch => batch.Upsert(typeof(FactionProductData), new FactionProductData
+ {
+ Name = i == 0 ? "lamp-a bin" : "lamp-a bin " + (i + 1), Design = new CultRecordRef(lamp.Key), Manufacturer = alpha,
+ Bin = new ProductBin { Line = new CultRecordRef(new CultRecordKey("k" + Array.IndexOf(lampOrder, "lamp-a"))), Floor = binFloors[i] }
+ }, new CultRecordKey("bin" + i)));
seed.FlushAsync().Wait();
}
@@ -249,6 +258,46 @@ public sealed class ProductChoiceTests : IDisposable
Assert.InRange(MakerDraws(rig, rig.OutEven, 1, "lamp-a", 300), 80, 120);
}
+ // Ruling bin-share-by-floor: a bin of lamp-a's line takes the fraction of lamp-a's stock that would pass its floor, 1 - Phi(Floor),
+ // of lamp-a's preference, and the line keeps the rest, so the maker's presence for the design is what it was without the bin.
+ [Fact]
+ public void ABinTakesItsPassFractionOfItsMakersPreference()
+ {
+ var alone = Build("lamp-z", "lamp-a");
+ var preference = alone.Outfitter(alone.OutEven, 1, 0).ProductWeight(alone.Product("lamp-a"));
+ Assert.True(preference > 0);
+
+ var quarter = Build(new[] { .67f }, new[] { "lamp-z", "lamp-a" });
+ var generator = quarter.Outfitter(quarter.OutEven, 1, 0);
+ Assert.Equal(.2514f * preference, generator.ProductWeight(quarter.Product("lamp-a bin")), 3);
+ Assert.Equal(.7486f * preference, generator.ProductWeight(quarter.Product("lamp-a")), 3);
+ Assert.Equal(preference, generator.ProductWeight(quarter.Product("lamp-a bin")) + generator.ProductWeight(quarter.Product("lamp-a")), 3);
+
+ var half = Build(new[] { 0f }, new[] { "lamp-z", "lamp-a" });
+ generator = half.Outfitter(half.OutEven, 1, 0);
+ Assert.Equal(.5f * preference, generator.ProductWeight(half.Product("lamp-a bin")), 3);
+ Assert.Equal(.5f * preference, generator.ProductWeight(half.Product("lamp-a")), 3);
+
+ // The maker keeps its presence: Alpha's line and bin together draw as lamp-a alone did.
+ var lamp = quarter.Cache.GetByName("Lamp");
+ var alpha = Enumerable.Range(1, 300).Count(i => quarter.Cache.Get(quarter.Outfitter(quarter.OutEven, Seed(i), 0).ChooseProduct(lamp).Manufacturer).Name == "Alpha");
+ Assert.InRange(alpha, 180, 220);
+ }
+
+ // A line gives up the sum of its bins' pass fractions and no more: two top-half bins leave it nothing, and a third does not
+ // take it below nothing.
+ [Fact]
+ public void ALineKeepsWhatItsBinsLeaveAndNeverLessThanNothing()
+ {
+ var two = Build(new[] { 0f, .67f }, new[] { "lamp-z", "lamp-a" });
+ var alone = Build("lamp-z", "lamp-a");
+ var preference = alone.Outfitter(alone.OutEven, 1, 0).ProductWeight(alone.Product("lamp-a"));
+ Assert.Equal((1 - .5f - .2514f) * preference, two.Outfitter(two.OutEven, 1, 0).ProductWeight(two.Product("lamp-a")), 3);
+
+ var three = Build(new[] { 0f, 0f, 0f }, new[] { "lamp-z", "lamp-a" });
+ Assert.Equal(0f, three.Outfitter(three.OutEven, 1, 0).ProductWeight(three.Product("lamp-a")));
+ }
+
// A weight that read the allegiance alone would give 1 : .25 here, an 80% draw; the zone and allegiance together
// give 1 : .125 for alpha at its own zone, 89%.
[Fact]
From 2806b443c9c20f58c7ffc615d1e672f73b358d59 Mon Sep 17 00:00:00 2001
From: Meta Vens
Date: Sun, 18 Oct 2026 18:06:07 +0200
Subject: [PATCH 017/444] Census, roles-migrate and roles-backfill skip a bin:
it authors no roles, it reads its line's
RolesMigrate takes an optional root, as RolesBackfill does, so its bin skip has a test.
Co-Authored-By: Claude Sonnet 5.5
---
.../Aetheria.Shared.Tests/CatalogRoleTests.cs | 8 +--
.../RolesBackfillCommandTests.cs | 53 +++++++++++++++++++
tools/AetherDb/Program.cs | 10 ++--
3 files changed, 64 insertions(+), 7 deletions(-)
diff --git a/tests/Aetheria.Shared.Tests/CatalogRoleTests.cs b/tests/Aetheria.Shared.Tests/CatalogRoleTests.cs
index b50e0142d..10056b421 100644
--- a/tests/Aetheria.Shared.Tests/CatalogRoleTests.cs
+++ b/tests/Aetheria.Shared.Tests/CatalogRoleTests.cs
@@ -36,7 +36,7 @@ public sealed class CatalogRoleTests
}
// The whole of CB-R1 over every design in the catalog: each non-flat stat reads exactly one declared role, every
- // declared role is read, and every seller authors each role once. Weapons, and designs of a kind the tool keeps a role
+ // declared role is read, and every seller authors each role once (a bin authors none: it reads its line's). Weapons, and designs of a kind the tool keeps a role
// map for, must also carry a non-flat stat (a design with no stat at all has nothing for quality to vary).
[Fact]
public void ShippedCatalogRolesAreLive()
@@ -59,13 +59,15 @@ public sealed class CatalogRoleTests
broken.Add($"{design.Name}: declares [{string.Join(", ", declared)}] but non-flat stats read [{string.Join(", ", read)}]");
if (varying.Length == 0 && stats.Length > 0 && (design is WeaponItemData || Program.HasRoleMap(design)))
broken.Add($"{design.Name}: every stat is flat");
- foreach (var product in products.Where(p => p.Design.Key.Equals(cache.RefOf(design).Key)))
+ foreach (var product in products.Where(p => p.Bin == null && p.Design.Key.Equals(cache.RefOf(design).Key)))
{
var authored = (product.Roles ?? new List()).Select(r => r.Role).OrderBy(r => r, StringComparer.Ordinal).ToArray();
if (!authored.SequenceEqual(declared))
broken.Add($"{design.Name}: product \"{product.Name}\" authors [{string.Join(", ", authored)}], not each of [{string.Join(", ", declared)}] once");
}
}
+ foreach (var bin in products.Where(p => p.Bin != null))
+ if (bin.Roles?.Count > 0 || bin.Assembly != null) broken.Add($"bin \"{bin.Name}\" authors roles or an assembly of its own");
Assert.True(broken.Count == 0, string.Join("\n", broken));
}
@@ -264,7 +266,7 @@ public sealed class CatalogRoleTests
var profiles = new Dictionary(Program.MakerProfiles) { ["Pirates"] = (.45f, .22f) };
foreach (var design in new[] { autocannon, lrm, srm, mine, drive })
{
- var sellers = cache.GetAll().Where(p => p.Design.Key.Equals(cache.RefOf(design).Key)).ToArray();
+ var sellers = cache.GetAll().Where(p => p.Bin == null && p.Design.Key.Equals(cache.RefOf(design).Key)).ToArray();
Assert.NotEmpty(sellers);
foreach (var product in sellers)
{
diff --git a/tests/Aetheria.Shared.Tests/RolesBackfillCommandTests.cs b/tests/Aetheria.Shared.Tests/RolesBackfillCommandTests.cs
index 1f276c639..1f0f974f5 100644
--- a/tests/Aetheria.Shared.Tests/RolesBackfillCommandTests.cs
+++ b/tests/Aetheria.Shared.Tests/RolesBackfillCommandTests.cs
@@ -207,6 +207,59 @@ public sealed class RolesBackfillCommandTests : IDisposable
finally { db.Cache.Dispose(); }
}
+ // A bin authors no roles (ProductBin): the backfill that restores its line's missing roles leaves the bin's list empty.
+ [Fact]
+ public void A_binned_seller_gains_no_roles()
+ {
+ Reset(flattened: false, unnamed: false);
+ var db = AetherDb.Open(catalogWritable: true, root: _root);
+ var autocannon = db.Cache.GetAll().Single(weapon => weapon.Name == "Autocannon");
+ var seller = db.Cache.GetAll().OrderBy(p => p.Name, StringComparer.Ordinal).First(p => p.Design.Key.Equals(db.Cache.RefOf(autocannon).Key) && p.Roles.Count == 2);
+ var sellerName = seller.Name;
+ seller.Roles = new List { seller.Roles.OrderBy(r => r.Role, StringComparer.Ordinal).First() };
+ var bin = new FactionProductData
+ {
+ Name = "Binned " + sellerName, Design = seller.Design, Manufacturer = seller.Manufacturer,
+ Bin = new ProductBin { Line = db.Cache.RefOf(seller), Floor = 0f }
+ };
+ db.Cache.Commit(batch =>
+ {
+ batch.Upsert(typeof(FactionProductData), seller, db.Cache.RefOf(seller).Key);
+ batch.Upsert(typeof(FactionProductData), bin);
+ });
+ db.Cache.Dispose();
+
+ Assert.Equal(0, Program.RolesBackfill(apply: true, root: _root));
+ db = AetherDb.Open(root: _root);
+ try
+ {
+ Assert.Equal(2, db.Cache.GetAll().Single(p => p.Name == sellerName).Roles.Count);
+ Assert.Empty(db.Cache.GetAll().Single(p => p.Name == "Binned " + sellerName).Roles);
+ }
+ finally { db.Cache.Dispose(); }
+ }
+
+ // roles-migrate authors a role on every product that sells a design lacking one; a bin sells none of its own.
+ [Fact]
+ public void A_binned_seller_gains_no_roles_from_roles_migrate()
+ {
+ var db = AetherDb.Open(catalogWritable: true, root: _root);
+ var autocannon = db.Cache.GetAll().Single(weapon => weapon.Name == "Autocannon");
+ var seller = db.Cache.GetAll().OrderBy(p => p.Name, StringComparer.Ordinal).First(p => p.Design.Key.Equals(db.Cache.RefOf(autocannon).Key));
+ var bin = new FactionProductData
+ {
+ Name = "Binned " + seller.Name, Design = seller.Design, Manufacturer = seller.Manufacturer,
+ Bin = new ProductBin { Line = db.Cache.RefOf(seller), Floor = 0f }
+ };
+ db.Cache.Commit(batch => batch.Upsert(typeof(FactionProductData), bin));
+ db.Cache.Dispose();
+
+ Assert.Equal(0, Program.RolesMigrate(apply: true, root: _root));
+ db = AetherDb.Open(root: _root);
+ try { Assert.Empty(db.Cache.GetAll().Single(p => p.Name == "Binned " + seller.Name).Roles); }
+ finally { db.Cache.Dispose(); }
+ }
+
[Fact]
public void A_design_the_catalog_does_not_hold_refuses_and_lands_nothing()
{
diff --git a/tools/AetherDb/Program.cs b/tools/AetherDb/Program.cs
index 5df9ed99c..46a812ba2 100644
--- a/tools/AetherDb/Program.cs
+++ b/tools/AetherDb/Program.cs
@@ -132,7 +132,8 @@ public static class Program
foreach (var item in items.Where(i => i.Roles != null && i.Roles.Count > 0))
{
var design = db.Cache.RefOf(item).Key;
- var sellers = products.Where(p => p.Design.Key.Equals(design)).ToArray();
+ // A bin authors no spread: it reads its line's (ProductBin).
+ var sellers = products.Where(p => p.Bin == null && p.Design.Key.Equals(design)).ToArray();
foreach (var product in sellers)
{
var covered = new HashSet((product.Roles ?? new List()).Select(r => r.Role));
@@ -309,9 +310,9 @@ public static class Program
// Dry run unless passed "apply", which alone opens the catalog writable; every record must derive cleanly or
// nothing is written, the same all-or-nothing contract ShieldMigrate and BrownoutMigrate already use. Prints
// the operator review table (grouped by item kind) either way.
- private static int RolesMigrate(bool apply)
+ public static int RolesMigrate(bool apply, string root = null)
{
- var db = AetherDb.Open(catalogWritable: apply);
+ var db = AetherDb.Open(catalogWritable: apply, root: root);
var items = db.Cache.GetAll().ToArray();
var products = db.Cache.GetAll().ToArray();
var changedDesigns = new List<(object Document, CultRecordKey Key)>();
@@ -366,6 +367,7 @@ public static class Program
var byDesign = items.ToDictionary(i => db.Cache.RefOf(i).Key, i => i);
foreach (var product in products.OrderBy(p => p.Name, StringComparer.Ordinal))
{
+ if (product.Bin != null) continue; // a bin authors no roles; the mint reads its line's
if (!byDesign.TryGetValue(product.Design.Key, out var design) || design.Roles == null || design.Roles.Count == 0)
continue;
var existing = new HashSet((product.Roles ?? new List()).Select(r => r.Role));
@@ -2218,7 +2220,7 @@ public static class Program
var changedProducts = new List<(FactionProductData Document, CultRecordKey Key)>();
foreach (var design in targets)
- foreach (var product in products.Where(p => p.Design.Key.Equals(db.Cache.RefOf(design).Key)).OrderBy(p => p.Name, StringComparer.Ordinal))
+ foreach (var product in products.Where(p => p.Bin == null && p.Design.Key.Equals(db.Cache.RefOf(design).Key)).OrderBy(p => p.Name, StringComparer.Ordinal))
{
var maker = db.Cache.Get(product.Manufacturer);
var profile = maker?.Name == "Pirates" ? PirateProfile
From 6768cdcb954d0ff018d82b70eebf58f1c0588d2c Mon Sep 17 00:00:00 2001
From: Meta Vens
Date: Sun, 18 Oct 2026 18:08:56 +0200
Subject: [PATCH 018/444] product-lines tiers: authors the six bins from a
table of (line, bin, floor, text)
A bin gets its line's design and maker, Bin = { Line, Floor }, and no roles or assembly. Resolve-then-write
as the other lines do; a replay is a no-op. The catalog is not yet applied.
Co-Authored-By: Claude Sonnet 5.5
---
.../ProductTiersCommandTests.cs | 177 ++++++++++++++++++
tools/AetherDb/ProductLinesCommand.cs | 88 ++++++++-
tools/AetherDb/Program.cs | 2 +-
3 files changed, 263 insertions(+), 4 deletions(-)
create mode 100644 tests/Aetheria.Shared.Tests/ProductTiersCommandTests.cs
diff --git a/tests/Aetheria.Shared.Tests/ProductTiersCommandTests.cs b/tests/Aetheria.Shared.Tests/ProductTiersCommandTests.cs
new file mode 100644
index 000000000..856cca7dc
--- /dev/null
+++ b/tests/Aetheria.Shared.Tests/ProductTiersCommandTests.cs
@@ -0,0 +1,177 @@
+using System;
+using System.Collections.Generic;
+using System.IO;
+using System.Linq;
+using GameCult.Caching;
+using GameCult.Caching.MessagePack;
+using Xunit;
+
+// AetherDb's product-lines tiers command (cut product-tiers; rulings maker-design-tiers-allowed, bin-share-by-floor), run on a
+// scratch copy of the shipped catalog with the table's bins removed from it. A standalone class: a tier row is not a Row.
+public sealed class ProductTiersCommandTests : IDisposable
+{
+ private static readonly string[] Apply = { "tiers", "apply" };
+ private static readonly string[] DryRun = { "tiers" };
+
+ private readonly string _root = Path.Combine(Path.GetTempPath(), "product-tiers-command-" + Guid.NewGuid().ToString("N"));
+ private string CatalogPath => Path.Combine(_root, "GameData", "Aetheria.cc");
+ private readonly string[] _names = ProductLinesCommand.TierTable().Select(r => r.Name).ToArray();
+
+ public ProductTiersCommandTests()
+ {
+ Directory.CreateDirectory(Path.Combine(_root, "GameData"));
+ Directory.CreateDirectory(Path.Combine(_root, "Assets", "Resources"));
+ var shipped = AetherDb.FindRoot();
+ File.Copy(Path.Combine(shipped, "GameData", "Aetheria.cc"), CatalogPath);
+ File.Copy(Path.Combine(shipped, "Assets", "Resources", "Settings.asset"), Path.Combine(_root, "Assets", "Resources", "Settings.asset"));
+ using var cache = Open();
+ foreach (var bin in cache.GetAll().Where(p => _names.Contains(p.Name)).ToArray()) Assert.True(cache.Remove(cache.RefOf(bin).Key));
+ cache.FlushAsync().Wait();
+ }
+
+ public void Dispose() => Directory.Delete(_root, true);
+
+ private CultCache Open()
+ {
+ var cache = new CultCache(TestCatalog.Registry());
+ cache.AddBackingStore(new SingleFileMessagePackBackingStore(CatalogPath), AetheriaStores.CatalogTypes);
+ return cache;
+ }
+
+ private Dictionary Records() =>
+ CultDocumentMessagePackSerialization.DeserializeSnapshot(File.ReadAllBytes(CatalogPath)).Records.ToDictionary(r => r.Key, StringComparer.Ordinal);
+
+ private FactionProductData[] Bins()
+ {
+ using var cache = Open();
+ return cache.GetAll().Where(p => _names.Contains(p.Name)).ToArray();
+ }
+
+ // Edits the scratch catalog before a refusal case; the bins the table authors stay out of it.
+ private void Edit(Action edit)
+ {
+ using var cache = Open();
+ edit(cache);
+ cache.FlushAsync().Wait();
+ }
+
+ // The command refuses the row with a message naming the rule, and the catalog's bytes are as they were.
+ private void Refused(ProductLinesCommand.TierRow row, string phrase)
+ {
+ var before = File.ReadAllBytes(CatalogPath);
+ var error = Assert.Throws(() => ProductLinesCommand.RunTiers(new[] { row }, true, _root));
+ Assert.Contains(phrase, error.Message);
+ Assert.Equal(before, File.ReadAllBytes(CatalogPath));
+ }
+
+ [Fact]
+ public void A_dry_run_writes_nothing_and_apply_lands_the_table_once_and_replays_as_a_no_op()
+ {
+ var before = File.ReadAllBytes(CatalogPath);
+ Assert.Empty(Bins());
+
+ Assert.Equal(0, ProductLinesCommand.Run(DryRun, _root));
+ Assert.Equal(before, File.ReadAllBytes(CatalogPath));
+
+ Assert.Equal(0, ProductLinesCommand.Run(Apply, _root));
+ var landed = File.ReadAllBytes(CatalogPath);
+ Assert.NotEqual(before, landed);
+ Assert.Equal(6, Bins().Length);
+
+ Assert.Equal(0, ProductLinesCommand.Run(Apply, _root));
+ Assert.Equal(landed, File.ReadAllBytes(CatalogPath));
+ Assert.Equal(0, ProductLinesCommand.Run(DryRun, _root));
+ Assert.Equal(landed, File.ReadAllBytes(CatalogPath));
+ }
+
+ [Fact]
+ public void Each_bin_is_its_lines_design_and_maker_at_its_floor_with_nothing_of_its_own()
+ {
+ Assert.Equal(0, ProductLinesCommand.Run(Apply, _root));
+ using var cache = Open();
+ foreach (var row in ProductLinesCommand.TierTable())
+ {
+ var bin = Assert.Single(cache.GetAll(), p => p.Name == row.Name);
+ var line = Assert.Single(cache.GetAll(), p => p.Name == row.Line);
+ Assert.Equal(cache.RefOf(line).Key, bin.Bin.Line.Key);
+ Assert.Equal(line.Design.Key, bin.Design.Key);
+ Assert.Equal(line.Manufacturer.Key, bin.Manufacturer.Key);
+ Assert.Equal(row.Floor, bin.Bin.Floor);
+ Assert.Equal(row.Description, bin.Description);
+ Assert.Empty(bin.Roles);
+ Assert.Null(bin.Assembly);
+ Assert.Null(line.Bin);
+ }
+ }
+
+ [Fact]
+ public void Apply_writes_only_the_records_it_authors()
+ {
+ var before = Records();
+ Assert.Equal(0, ProductLinesCommand.Run(Apply, _root));
+ var after = Records();
+ Assert.Equal(before.Count + 6, after.Count);
+ foreach (var (key, record) in before)
+ {
+ Assert.True(after.TryGetValue(key, out var kept), "a record the command did not author is gone");
+ Assert.Equal(record.StoredAt, kept.StoredAt);
+ Assert.True(record.Payload.AsSpan().SequenceEqual(kept.Payload), "a record the command did not author has changed bytes");
+ }
+ }
+
+ [Fact]
+ public void A_bin_already_authored_at_another_floor_is_refused()
+ {
+ Assert.Equal(0, ProductLinesCommand.Run(Apply, _root));
+ Refused(new ProductLinesCommand.TierRow("Sparrow", "Goldcrest", .5f, "x"), "already belongs");
+ }
+
+ [Fact]
+ public void A_line_the_catalog_does_not_hold_is_refused()
+ {
+ Refused(new ProductLinesCommand.TierRow("No Such Line", "Fresh Bin", 0f, "x"), "found 0");
+ }
+
+ [Fact]
+ public void An_ambiguous_line_is_refused()
+ {
+ Edit(cache =>
+ {
+ var sparrow = cache.GetAll().Single(p => p.Name == "Sparrow");
+ cache.Upsert(new FactionProductData { Name = "Sparrow", Design = sparrow.Design, Manufacturer = sparrow.Manufacturer });
+ });
+ Refused(new ProductLinesCommand.TierRow("Sparrow", "Fresh Bin", 0f, "x"), "found 2");
+ }
+
+ [Fact]
+ public void A_line_that_is_itself_a_bin_is_refused()
+ {
+ Assert.Equal(0, ProductLinesCommand.Run(Apply, _root));
+ Refused(new ProductLinesCommand.TierRow("Goldcrest", "Fresh Bin", 0f, "x"), "itself a bin");
+ }
+
+ [Theory]
+ [InlineData(-.1f)]
+ [InlineData(float.NaN)]
+ [InlineData(float.PositiveInfinity)]
+ public void A_floor_that_is_negative_or_not_finite_is_refused(float floor)
+ {
+ Refused(new ProductLinesCommand.TierRow("Sparrow", "Fresh Bin", floor, "x"), "finite number");
+ }
+
+ [Theory]
+ [InlineData("Spotlight")]
+ [InlineData("SPOTLIGHT")]
+ public void A_bin_name_held_by_another_product_is_refused_ignoring_case(string name)
+ {
+ Refused(new ProductLinesCommand.TierRow("Sparrow", name, 0f, "x"), "already belongs");
+ }
+
+ [Fact]
+ public void A_bin_name_that_is_a_designs_is_refused()
+ {
+ string design;
+ using (var cache = Open()) design = cache.GetAll().First().Name;
+ Refused(new ProductLinesCommand.TierRow("Sparrow", design.ToUpperInvariant(), 0f, "x"), "name of a design");
+ }
+}
diff --git a/tools/AetherDb/ProductLinesCommand.cs b/tools/AetherDb/ProductLinesCommand.cs
index 911f01ffb..5ee316a38 100644
--- a/tools/AetherDb/ProductLinesCommand.cs
+++ b/tools/AetherDb/ProductLinesCommand.cs
@@ -16,9 +16,13 @@ using GameCult.Caching;
// carries no quality numbers. A row already in the catalog (same name, design and maker) is skipped, so the same command is the
// first apply, the second apply and the replay onto a merged catalog. A maker that already sells the design is not refused: a
// tier is allowed. Dry run unless passed "apply".
+// product-lines tiers [apply] (cut product-tiers; rulings maker-design-tiers-allowed, bin-share-by-floor): authors the bins of
+// those lines. A tier row is (line product name, bin name, floor, text); the bin gets Design and Manufacturer from its line,
+// Bin = { Line, Floor }, and no Roles or Assembly of its own: the mint derives both from the line.
public static class ProductLinesCommand
{
internal sealed record Row(string Design, string Name, string Maker, string Description);
+ internal sealed record TierRow(string Line, string Name, float Floor, string Description);
private static readonly Row[] Weapons =
{
@@ -85,6 +89,19 @@ public static class ProductLinesCommand
new("Industrial Thermostatic Heater", "WarmUp+-", "NiteLife", "Room temperature, whatever the room."),
};
+ // The bins of the shipped lines. The names and text are drafts the operator may rename.
+ private static readonly TierRow[] Tiers =
+ {
+ new("Sparrow", "Goldcrest", .67f, "Only the Sparrows that sang on the test bench. The rest still fly."),
+ new("Spotlight", "Spotlight Premiere", 0f, "Hand-picked for opening night."),
+ new("Rattler", "Rattler Select", 0f, "Pick of the batch. Still loud."),
+ new("SM-6 Marksman", "SM-6C Marksman Certified", 0f, "Individually certified to published tolerance.*"),
+ new("PowerHour 2.0", "PowerHour 2.0 VIP", .67f, "Skip the line. Same party, better parts."),
+ new("Juggernaut", "Juggernaut Guards", 0f, "The column's best drives go to the front of the column."),
+ };
+
+ internal static IReadOnlyList TierTable() => Tiers;
+
// Every line the command knows, by the word that follows "product-lines".
private static readonly Dictionary Lines = new() { ["weapons"] = Weapons, ["minor-powers"] = MinorPowers, ["gear"] = Gear };
@@ -93,16 +110,19 @@ public static class ProductLinesCommand
// A product's record key is derived from its name, so the same table lands the same records every time it is applied: the
// replay at a merge reproduces the branch's blob, and nothing that orders products by key reads a different order after it.
- private static CultRecordKey KeyOf(Row row) =>
- new(new Guid(SHA256.HashData(Encoding.UTF8.GetBytes("product-line:" + row.Name)).AsSpan(0, 16)).ToString("N"));
+ private static CultRecordKey KeyOf(Row row) => KeyOf(row.Name);
+
+ private static CultRecordKey KeyOf(string name) =>
+ new(new Guid(SHA256.HashData(Encoding.UTF8.GetBytes("product-line:" + name)).AsSpan(0, 16)).ToString("N"));
public static int Run(string[] args, string root = null)
{
var apply = args.Contains("apply");
var line = args.FirstOrDefault(a => a != "apply");
+ if (line == "tiers") return RunTiers(Tiers, apply, root);
if (line == null || !Lines.TryGetValue(line, out var rows))
{
- Console.WriteLine($"usage: product-lines {string.Join("|", Lines.Keys)} [apply]");
+ Console.WriteLine($"usage: product-lines {string.Join("|", Lines.Keys)}|tiers [apply]");
return 1;
}
@@ -178,4 +198,66 @@ public static class ProductLinesCommand
Console.WriteLine($"\nAuthored {missing.Length} products in Aetheria.cc");
return 0;
}
+
+ // The tiers line has Run's shape: resolve every row first, so a refusal leaves the catalog untouched, then land the missing
+ // bins in one batch that expects each of their keys absent. A bin already in the catalog (this line, this floor) is skipped,
+ // so the same command is the first apply and the replay onto a merged catalog.
+ internal static int RunTiers(IReadOnlyList tiers, bool apply, string root)
+ {
+ var db = AetherDb.Open(catalogWritable: apply, root: root);
+ var designs = db.Cache.GetAll().ToArray();
+ var products = db.Cache.GetAll().ToArray();
+
+ var resolved = new List<(TierRow Row, FactionProductData Line, bool Present)>();
+ for (var i = 0; i < tiers.Count; i++)
+ {
+ var row = tiers[i];
+ var lines = products.Where(p => p.Name == row.Line).ToArray();
+ if (lines.Length != 1) throw new InvalidOperationException($"Tier row {i}: expected exactly one line product, found {lines.Length}.");
+ var parent = lines[0];
+ if (parent.Bin != null) throw new InvalidOperationException($"Tier row {i}: the line is itself a bin.");
+ if (!float.IsFinite(row.Floor) || row.Floor < 0) throw new InvalidOperationException($"Tier row {i}: the floor is not a finite number of at least 0.");
+
+ var named = products.Where(p => string.Equals(p.Name, row.Name, StringComparison.OrdinalIgnoreCase)).ToArray();
+ var present = named.Length == 1 && named[0].Name == row.Name && named[0].Bin != null &&
+ named[0].Bin.Line.Key.Equals(db.Cache.RefOf(parent).Key) && named[0].Bin.Floor == row.Floor;
+ if (named.Length != 0 && !present) throw new InvalidOperationException($"Tier row {i}: the bin name already belongs to another product.");
+ if (!present && designs.Any(d => string.Equals(d.Name, row.Name, StringComparison.OrdinalIgnoreCase)))
+ throw new InvalidOperationException($"Tier row {i}: the bin name is the name of a design.");
+ resolved.Add((row, parent, present));
+ }
+
+ var missing = resolved.Where(r => !r.Present).ToArray();
+ if (missing.Length == 0)
+ {
+ Console.WriteLine("The tiers are already authored; nothing to do.");
+ return 0;
+ }
+
+ Console.WriteLine("Product tiers:");
+ foreach (var (row, parent, present) in resolved)
+ Console.WriteLine($" {(present ? "present" : "new ")} {row.Line} -> \"{row.Name}\" by {db.Cache.Get(parent.Manufacturer)?.ShortName}, floor {row.Floor:0.##}: {row.Description}");
+ if (!apply)
+ {
+ Console.WriteLine($"\nDry run. Pass \"apply\" to author {missing.Length} bins.");
+ return 0;
+ }
+
+ if (!db.Cache.Commit(batch =>
+ {
+ foreach (var (row, _, _) in missing) batch.Expect(KeyOf(row.Name), null);
+ foreach (var (row, parent, _) in missing)
+ batch.Upsert(typeof(FactionProductData), new FactionProductData
+ {
+ Name = row.Name,
+ Description = row.Description,
+ Design = parent.Design,
+ Manufacturer = parent.Manufacturer,
+ Bin = new ProductBin { Line = db.Cache.RefOf(parent), Floor = row.Floor },
+ }, KeyOf(row.Name));
+ }))
+ throw new InvalidOperationException("The catalog changed while the tiers were being authored; nothing was written. Run it again.");
+ Console.WriteLine($"\nAuthored {missing.Length} bins in Aetheria.cc");
+ return 0;
+ }
}
diff --git a/tools/AetherDb/Program.cs b/tools/AetherDb/Program.cs
index 46a812ba2..d88718817 100644
--- a/tools/AetherDb/Program.cs
+++ b/tools/AetherDb/Program.cs
@@ -63,7 +63,7 @@ public static class Program
return PlaceNamesCommand.Run(args.Skip(1).ToArray());
case "doctrine-catalog": return DoctrineCatalog.Run(AetherDb.Open(catalogWritable: args.Contains("apply")).Cache, args.Contains("apply"), Console.Out);
default:
- Console.WriteLine("commands: census, factions, station-fit, hardpoint-fit, loadout [seed], save, settings, settings-dump, dangling [clear ]... [apply], shield-migrate [apply], brownout-migrate [apply], roles-migrate [apply], firing-arc-migrate [apply], range-falloff [apply] [--effective ] [--end ], targeting-catalog [apply], targeting-catalog-6c [apply], targeting-catalog-6d [apply], ship-authoring create|inspect|validate|compose, field-kinds [apply], mine-launcher [apply], smoke-hull [apply], smoke-rounds [apply], roles-backfill [apply], pd-gear [apply], consumables-first-set [apply], launch-suppressor [apply], pirates-faction [apply], verse-grammar [apply], generic-designs [apply], sensor-stat-set [apply], inspection-scanner [apply], minor-power-factions [apply], place-names [apply], product-lines weapons|minor-powers|gear [apply], doctrine-catalog [apply], assembly-from-maker [apply]");
+ Console.WriteLine("commands: census, factions, station-fit, hardpoint-fit, loadout [seed], save, settings, settings-dump, dangling [clear ]... [apply], shield-migrate [apply], brownout-migrate [apply], roles-migrate [apply], firing-arc-migrate [apply], range-falloff [apply] [--effective ] [--end ], targeting-catalog [apply], targeting-catalog-6c [apply], targeting-catalog-6d [apply], ship-authoring create|inspect|validate|compose, field-kinds [apply], mine-launcher [apply], smoke-hull [apply], smoke-rounds [apply], roles-backfill [apply], pd-gear [apply], consumables-first-set [apply], launch-suppressor [apply], pirates-faction [apply], verse-grammar [apply], generic-designs [apply], sensor-stat-set [apply], inspection-scanner [apply], minor-power-factions [apply], place-names [apply], product-lines weapons|minor-powers|gear|tiers [apply], doctrine-catalog [apply], assembly-from-maker [apply]");
return 1;
}
}
From 9b7a1c00dc2b7034a16a61c436463008f4fa9996 Mon Sep 17 00:00:00 2001
From: Meta Vens
Date: Sun, 18 Oct 2026 18:11:11 +0200
Subject: [PATCH 019/444] Catalog: the six tier bins (product-lines tiers
apply); TierRowsAreShipped; the tier step pinned from both sides
Aetheria.cc written only by the command, on the pinned CultLib. Replay the command at merges; never merge the blob.
Co-Authored-By: Claude Sonnet 5.5
---
GameData/Aetheria.cc | 4 +-
tests/Aetheria.Shared.Tests/LoadoutTests.cs | 7 +++
.../ProductLinesCatalogTests.cs | 59 +++++++++++++++++++
3 files changed, 68 insertions(+), 2 deletions(-)
diff --git a/GameData/Aetheria.cc b/GameData/Aetheria.cc
index 9f9ff69e5..a2ddaad5d 100644
--- a/GameData/Aetheria.cc
+++ b/GameData/Aetheria.cc
@@ -1,3 +1,3 @@
version https://git-lfs.github.com/spec/v1
-oid sha256:22342ea360c7f453de500da8650e6a23648f8c1250eb22e724765bbcf619eac0
-size 186037
+oid sha256:c327ef1bbabbd34ddb7b9f643f5db8cc54aa5730f232bd42a1fa5f2b03973e89
+size 189753
diff --git a/tests/Aetheria.Shared.Tests/LoadoutTests.cs b/tests/Aetheria.Shared.Tests/LoadoutTests.cs
index 5673b205d..86bf4e698 100644
--- a/tests/Aetheria.Shared.Tests/LoadoutTests.cs
+++ b/tests/Aetheria.Shared.Tests/LoadoutTests.cs
@@ -622,6 +622,13 @@ public sealed class LoadoutTests : IDisposable
var result = items.GetTier((CraftedItemInstance) items.CreateInstance(lot));
Assert.Equal(settings.Tiers[0].Name, result.tier.Name);
Assert.Equal(2, result.upgrades);
+
+ // The same width from the other side (follow-up tier-upgrade-step-two-sided): two steps less a hair is one upgrade, so a
+ // step narrower than .0499 reads two and a wider one reads none.
+ var under = items.Lots.Add(new Lot { Design = cache.RefOf(lamp), Origin = new Attributed(), Quality = settings.Tiers[0].Quality + 2 * .0499f - .0001f, Roles = new List() });
+ var below = items.GetTier((CraftedItemInstance) items.CreateInstance(under));
+ Assert.Equal(settings.Tiers[0].Name, below.tier.Name);
+ Assert.Equal(1, below.upgrades);
}
// S7 (docs/stats-and-power-cut.md Cut 1 findings): a Quality term must raise the lot's quality to its own
diff --git a/tests/Aetheria.Shared.Tests/ProductLinesCatalogTests.cs b/tests/Aetheria.Shared.Tests/ProductLinesCatalogTests.cs
index 6a91bb457..7ce78218f 100644
--- a/tests/Aetheria.Shared.Tests/ProductLinesCatalogTests.cs
+++ b/tests/Aetheria.Shared.Tests/ProductLinesCatalogTests.cs
@@ -2,6 +2,8 @@ using System;
using System.Collections.Generic;
using System.Linq;
using GameCult.Caching;
+using GameCult.Caching.MessagePack;
+using MessagePack;
using Xunit;
// The weapon product lines the shipped catalog holds (campaign aetheria-release, cut product-lines-weapons; rulings
@@ -99,4 +101,61 @@ public sealed class ProductLinesCatalogTests
}
}
}
+ // Cut product-tiers: the six bins of the command's tier table are in the catalog, each a single product that names its line.
+ [Fact]
+ public void TierRowsAreShipped()
+ {
+ using var cache = RestoredHullsTests.OpenCatalog();
+ var table = ProductLinesCommand.TierTable();
+ Assert.Equal(6, table.Count);
+ foreach (var row in table)
+ {
+ var bin = Assert.Single(cache.GetAll(), p => p.Name == row.Name);
+ var line = Assert.Single(cache.GetAll(), p => p.Name == row.Line);
+ Assert.Equal(cache.RefOf(line).Key, bin.Bin.Line.Key);
+ Assert.Equal(line.Design.Key, bin.Design.Key);
+ Assert.Equal(line.Manufacturer.Key, bin.Manufacturer.Key);
+ Assert.Equal(row.Floor, bin.Bin.Floor);
+ Assert.Equal(row.Description, bin.Description);
+ Assert.Empty(bin.Roles);
+ Assert.Null(bin.Assembly);
+ }
+ }
+
+ // A bin bins an unbinned line of its own design and maker, and the catalog holds no bin the table does not author.
+ [Fact]
+ public void BinsBinAnUnbinnedLineOfTheirOwnDesignAndMaker()
+ {
+ using var cache = RestoredHullsTests.OpenCatalog();
+ var bins = cache.GetAll().Where(p => p.Bin != null).ToArray();
+ Assert.Equal(ProductLinesCommand.TierTable().Select(r => r.Name).OrderBy(n => n, StringComparer.Ordinal), bins.Select(b => b.Name).OrderBy(n => n, StringComparer.Ordinal));
+ foreach (var bin in bins)
+ {
+ var line = cache.Get(bin.Bin.Line);
+ Assert.NotNull(line);
+ Assert.Null(line.Bin);
+ Assert.Equal(line.Design.Key, bin.Design.Key);
+ Assert.Equal(line.Manufacturer.Key, bin.Manufacturer.Key);
+ Assert.True(bin.Bin.Floor >= 0 && float.IsFinite(bin.Bin.Floor));
+ }
+ }
+
+ // FactionProductData slot 7 is additive under the same schema id: a bin round-trips, and a record written before the slot
+ // existed (an array one element shorter) reads no bin.
+ [Fact]
+ public void BinIsSlotSevenAndACatalogWithoutItReadsNoBin()
+ {
+ var options = CultDocumentMessagePackSerialization.OptionsFor(typeof(FactionProductData).Assembly);
+ var binned = MessagePackSerializer.Deserialize(
+ MessagePackSerializer.Serialize(new FactionProductData { Name = "Binned", Bin = new ProductBin { Floor = .67f } }, options), options);
+ Assert.Equal(.67f, binned.Bin.Floor);
+
+ var bytes = MessagePackSerializer.Serialize(new FactionProductData { Name = "Old", Description = "d" }, options);
+ Assert.Equal(0x98, bytes[0]);
+ Assert.Equal(0xC0, bytes[^1]);
+ var before = new byte[] { 0x97 }.Concat(bytes.Skip(1).Take(bytes.Length - 2)).ToArray();
+ var old = MessagePackSerializer.Deserialize(before, options);
+ Assert.Equal("Old", old.Name);
+ Assert.Null(old.Bin);
+ }
}
From aec7b3c019edfdc13e4b6c71cee04d9497a98be0 Mon Sep 17 00:00:00 2001
From: Meta Vens
Date: Sun, 18 Oct 2026 18:16:27 +0200
Subject: [PATCH 020/444] product-lines tiers lists every row, present or new,
before it reports there is nothing to do
Co-Authored-By: Claude Sonnet 5.5
---
tools/AetherDb/ProductLinesCommand.cs | 12 ++++++------
1 file changed, 6 insertions(+), 6 deletions(-)
diff --git a/tools/AetherDb/ProductLinesCommand.cs b/tools/AetherDb/ProductLinesCommand.cs
index 5ee316a38..2c82d5ebf 100644
--- a/tools/AetherDb/ProductLinesCommand.cs
+++ b/tools/AetherDb/ProductLinesCommand.cs
@@ -228,15 +228,15 @@ public static class ProductLinesCommand
}
var missing = resolved.Where(r => !r.Present).ToArray();
- if (missing.Length == 0)
- {
- Console.WriteLine("The tiers are already authored; nothing to do.");
- return 0;
- }
-
Console.WriteLine("Product tiers:");
foreach (var (row, parent, present) in resolved)
Console.WriteLine($" {(present ? "present" : "new ")} {row.Line} -> \"{row.Name}\" by {db.Cache.Get(parent.Manufacturer)?.ShortName}, floor {row.Floor:0.##}: {row.Description}");
+ if (missing.Length == 0)
+ {
+ Console.WriteLine("\nThe tiers are already authored; nothing to do.");
+ return 0;
+ }
+
if (!apply)
{
Console.WriteLine($"\nDry run. Pass \"apply\" to author {missing.Length} bins.");
From fb7b95fb0659fbf8bff77787bc997c4662d2b47f Mon Sep 17 00:00:00 2001
From: Meta Vens
Date: Sun, 18 Oct 2026 18:19:00 +0200
Subject: [PATCH 021/444] ProductChoiceTests: a bin takes nothing from another
maker's product
Co-Authored-By: Claude Sonnet 5.5
---
tests/Aetheria.Shared.Tests/ProductChoiceTests.cs | 2 ++
1 file changed, 2 insertions(+)
diff --git a/tests/Aetheria.Shared.Tests/ProductChoiceTests.cs b/tests/Aetheria.Shared.Tests/ProductChoiceTests.cs
index a11708393..5061d4603 100644
--- a/tests/Aetheria.Shared.Tests/ProductChoiceTests.cs
+++ b/tests/Aetheria.Shared.Tests/ProductChoiceTests.cs
@@ -272,6 +272,8 @@ public sealed class ProductChoiceTests : IDisposable
Assert.Equal(.2514f * preference, generator.ProductWeight(quarter.Product("lamp-a bin")), 3);
Assert.Equal(.7486f * preference, generator.ProductWeight(quarter.Product("lamp-a")), 3);
Assert.Equal(preference, generator.ProductWeight(quarter.Product("lamp-a bin")) + generator.ProductWeight(quarter.Product("lamp-a")), 3);
+ // Another maker's product, whose line has no bin, weighs what it did without any bin in the catalog.
+ Assert.Equal(alone.Outfitter(alone.OutEven, 1, 0).ProductWeight(alone.Product("lamp-z")), generator.ProductWeight(quarter.Product("lamp-z")), 3);
var half = Build(new[] { 0f }, new[] { "lamp-z", "lamp-a" });
generator = half.Outfitter(half.OutEven, 1, 0);
From 112dd2453614baec4e2b6cc1b77df460c32a22a4 Mon Sep 17 00:00:00 2001
From: Meta Vens
Date: Sun, 18 Oct 2026 18:20:07 +0200
Subject: [PATCH 022/444] aetheria-release map: boss hull derived r4 (fixed
Loadouts), stand-in hulls (SI) probe and cut sequence
Co-Authored-By: Claude Opus 5.5
---
docs/aetheria-release-map.md | 445 ++++++++++++++++++++++++++++-------
1 file changed, 360 insertions(+), 85 deletions(-)
diff --git a/docs/aetheria-release-map.md b/docs/aetheria-release-map.md
index b3a9f1c42..54699054e 100644
--- a/docs/aetheria-release-map.md
+++ b/docs/aetheria-release-map.md
@@ -10786,110 +10786,192 @@ worktree C:\aeth-wt\ag6-quality-readers at 70c7a497. Catalog: GameData/Aetheria.
## Boss hull derived
-Probes and source reads by imagination-ag6-boss-hull (session self-2026-10-18-ag6), 2026-10-18,
-in the detached worktree `C:\aeth-wt\ag6-boss-hull` at `origin/master` `eb27ced1`. `origin/master`
-moved to `ac835a8a` during the pass. That commit adds only
-`docs/research/2026-10-18-agent-built-hull-art-prior-art.md`, so every anchor below is the same
-at both. Read by `cut-boss-hull-derived.r3`. This section supersedes the anchors of "Demo hulls,
-the Pirates and the boss" H7 (`@2a5fc00b`) and of Products PL6. Its design rationale
-("The boss hull is derived, not named") stands, with the changes recorded under Rationale below.
+r3's probes and source reads were made by imagination-ag6-boss-hull (session self-2026-10-18-ag6),
+2026-10-18, in the detached worktree `C:\aeth-wt\ag6-boss-hull` at `origin/master` `eb27ced1`
+(BH1-BH11). r4's probes were made by imagination-ag6-boss-hull-r4 in the same worktree at the
+branch head `095baf2f`, which is r3's h1 (BH12-BH17). Read by `cut-boss-hull-derived.r4`. This
+section supersedes the anchors of "Demo hulls, the Pirates and the boss" H7 (`@2a5fc00b`) and of
+Products PL6. Under Rationale below, r4 replaces r3's boss-loadout path. Ruling
+`boss-gear-is-a-loadout` revokes that path; it replaced `boss-gear-is-fixed`.
### Body facts
- **BH1. What the cut waits on is on master.** boss-gate is merged at `39c7508e`
(cut-boss-gate r2, verdict s3). faction-play-1 is merged at `d7f30ed1` (r3, h6), and its catalog
- replay is at `aa12cbac`. minor-power-factions is merged at `c486b06d`; it is the remap of
- maker-only-faction. pirates-record is merged at `2c065937` and package-product at `3535660c`.
- No cut that edits Faction or the main galaxy's faction draw is still in flight ahead of this
- one.
+ replay is at `aa12cbac`. minor-power-factions is merged at `c486b06d`. pirates-record is merged
+ at `2c065937` and package-product at `3535660c`.
- **BH2. Faction keys at `eb27ced1`** (`Corporations.cs`): 1-5, 7, 8, a retired-key comment for 9
- (`:39`), `BossHull` Key(10) at `:42-43`, then 11-15, 17 (Doctrine), 19 (Assembly) and 21 (Places).
-- **BH3. HullData keys** (`ItemData.cs:531-556`): 23, 24, 25, retired 26 (`:542`), 27, 28, 29 and
- 32 (`Visual`, `:555-556`). Key 33 is free on HullData. Fuse's Key(32) at `:526` is on a sibling
- type.
-- **BH4. Every BossHull reader at `eb27ced1`** (`git grep BossHull`):
- `Galaxy.cs:293` (`PlaceFactionsMain` picks boss factions by `BossHull.IsSet()`, `:291-295`);
- `LoadoutGenerator.cs:212-230` (`GenerateBossLoadout`);
- `tools/AetherDb/Program.cs:586` (dangling comment) and `:709-712` (the factions boss column and
- its `broken` count, the method's only use of `broken`, `:700-716`);
- `RunGoalTests.cs:74-115` (two tests) and `:199-202` (Exit_open_only_in_the_exit_zone sets one).
- No writer exists: no AetherDb command, no Pirates or minor-power command, no package path.
- `GameData/Aetheria.cc` holds the string `bossHull`, which is its schema table.
+ (`:39`), `BossHull` Key(10) at `:42-43`, then 11-15, 17 (Doctrine), 19 (Assembly) and 21
+ (Places). h1 deleted `BossHull` and retired key 10.
+- **BH3. HullData keys** (`ItemData.cs:531-556` at `eb27ced1`): 23, 24, 25, retired 26, 27, 28,
+ 29 and 32 (`Visual`). h1 added `Boss` at Key(33) (`ItemData.cs:557-560` at `095baf2f`).
+- **BH4. Every BossHull reader at `eb27ced1`** was `Galaxy.cs:293`,
+ `LoadoutGenerator.cs:212-230`, `tools/AetherDb/Program.cs:586` and `:709-712`, and
+ `RunGoalTests.cs:74-115` and `:199-202`. h1 removed every one.
- **BH5. Retiring a key with stored values needs no catalog rewrite.** Precedents: `1d689adf`
- retired Faction key 9 and `be7f2b07` retired HullData key 26, and neither touched
- `GameData/Aetheria.cc`. The suite loads the shipped catalog in `RunStartTests`' constructor.
- This is a precedent read; no decode was run in this pass.
-- **BH6. Every generation hull draw goes through one availability rule.** `RandomHull`
- (`LoadoutGenerator.cs:165-170`) calls `RandomProduct`, then `RandomProducts` (`:176-185`), then
- `AvailableProducts` (`:188-210`). Station stock (`:126-127`) and the boss fallback (`:226`) also
- go through `AvailableProducts`. Its `candidates` filter (`:191-198`) runs before the
- any-manufacturer fallback (`:201-206`). An exclusion placed there therefore holds on the
- fallback too. `ChooseProduct` (`:262-273`) has its own `Where` and serves presets that name a
- design.
-- **BH7. Explicit hull draws.** `ScenarioStage.Generated` (`ScenarioStage.cs:90-97`) and AetherDb
- `loadout` (`Program.cs:805-818`, which runs every hull through `GenerateShipLoadout(candidate
- => candidate == hull)`) both reach `AvailableProducts`. Many tests call
- `GenerateShipLoadout(h => h == hull)` on shipped hulls (for example `RestoredHullsTests.cs:606`).
- None of those hulls is a Boss hull.
-- **BH8. The boss path.** `ZoneGenerator.cs:398-412` calls `GenerateBossLoadout` for every
- `BossZones` entry in the zone and marks the result `Boss`. `Galaxy.cs:150` sets the prelude and
- demo antagonist's boss zone without reading any hull. `Galaxy` holds `_cache` (`:36`, set at
- `:51`, `:102` and `:169`), so `PlaceFactionsMain` can call a static derivation over the catalog.
-- **BH9. The package writer.** `ShipAuthoringCommands.cs` (157 lines): `Usage` at `:11`, `inspect`
- at `:29-35`, `price` at `:79-93` (it judges before it writes, and a refusal leaves the file
- byte-identical), and `product` at `:94-125`. `ShipProductTests.ProductAndPriceCommands`
- (`:259-300`) pins the price and product commands with `RefusedAndUntouched`. Compose copies a
- package's HullData verbatim, so a member set in the package reaches the composed catalog.
- `ComposeCopiesEveryProductAndEachMakerIsOfferedTheHull` (`:80-100`) byte-compares products.
-- **BH10. Zhestokost makes no ship hull.** Products PL6 still holds: its only hull product is the
- Turret. Until a Zhestokost Boss hull exists, the demo boss is the logged fallback (the priciest
- ordinary ship hull Zhestokost can be offered).
-- **BH11. Which hulls can be bosses.** Ruling `hull-programmer-art-stopgap` (2026-10-18) leaves
- open whether boss hulls come from the operator's packages or an agent-built stopgap. The Eyes
- facts file at `ac835a8a` places stopgap art on the package path. Nothing in the derivation
- reads `Prefab`, `Visual` or a model, so a hull from either source, or with neither, can be a
- boss.
+ (Faction key 9) and `be7f2b07` (HullData key 26).
+- **BH6. Every generation hull draw goes through one availability rule**, `AvailableProducts`
+ (`LoadoutGenerator.cs:196-219` at `095baf2f`). Its candidate filter carries h1's
+ `!(entry.design is HullData { Boss: true })` (`:204`) ahead of the any-manufacturer fallback.
+ `ChooseProduct` (`:278-289`) has its own `Where` and does not exclude Boss hulls. r4 never asks
+ it for a hull, because `BossOf` returns only a Loadout whose `HullProduct` is set.
+- **BH7. Explicit hull draws.** `ScenarioStage.Generated` (`ScenarioStage.cs:93`) and AetherDb
+ `loadout` reach `AvailableProducts`. Many tests call `GenerateShipLoadout(h => h == hull)` on
+ shipped hulls, and none of those hulls is a Boss hull.
+- **BH8. The boss path at `095baf2f`.** `ZoneGenerator.cs:401-411` calls
+ `GetLoadoutGenerator(bossFaction).GenerateBossLoadout()` for every `BossZones` entry in the zone.
+ It logs "No boss could be generated" on null and otherwise marks the pack `Boss` and the zone
+ `BossSpawned`. `:414-415` logs exit-without-boss. `GetLoadoutGenerator` (`:282-287`) caches one
+ generator per faction over the zone's `random`. `GenerateBossLoadout` (`LoadoutGenerator.cs:221-233`)
+ builds `BossHullsOf[0]` through `ShipLoadout(product)` (`:55-66`: instance, Ship,
+ `OutfitEntity`, `EquipPickup`, `StockConsumables`, Pack). Otherwise it logs "makes no boss
+ hull" and generates the priciest ship hull on offer. Its readers are only `ZoneGenerator.cs:403`
+ and AetherDb `loadout` (`Program.cs:815`).
+- **BH9. The package writer.** `ShipAuthoringCommands.cs`: `Usage` at `:11`; h1 added `boss
+ on|off`. Compose copies a package's HullData verbatim.
+ `ShipProductTests.ABossPackageIsItsMakersBossAfterCompose` passes at `095baf2f`.
+- **BH10. Zhestokost makes no ship hull, and the shipped catalog has no Boss hull.** Products PL6
+ still holds. Today's demo boss is the generated fallback: the priciest ordinary ship hull
+ Zhestokost can be offered. Under r4 there is no fallback, so the demo exit has no boss until a
+ Zhestokost Boss hull and its Loadout exist (question `demo-boss-before-its-hull`).
+- **BH11. Which hulls can be bosses.** Ruling `hull-programmer-art-reads-as-stand-in` (it
+ supersedes `hull-programmer-art-stopgap`) leaves the stopgap mechanism open. The Eyes facts file
+ at `ac835a8a` places stopgap art on the package path. Nothing in either derivation reads
+ `Prefab`, `Visual` or a model.
+- **BH12. The three failures in h1 are a fixture defect.** Probe p1 ran ygg-verify at `095baf2f`
+ with the filter of the three tests and the detailed console logger. All three throw
+ `InvalidOperationException: Sequence contains no matching element` at
+ `RunStartTests.FactionNamed` (`RunGoalTests.cs:75`), called with `"Lucent"` at `:130`, `:149`
+ and `:185`. The shipped faction is `"Lucent Media"`, and `"Lucent"` is its ShortName
+ (`DemoTerminusTests.cs:23`, `CatalogRoleTests.cs:368`). Probes p2 and p3 used a scratch clone
+ with only that string changed (`f683baa4`). All three passed: `Passed
+ A_boss_hull_two_makers_sell_is_a_boss_of_each`, `Passed Boss_hulls_are_never_stock_or_traffic`
+ and `Passed Another_factions_boss_hull_is_never_used`. The h1 job hid the messages because its
+ grep kept only `Assert|Expected|Actual` lines. Verdicts:
+ - `Boss_hulls_are_never_stock_or_traffic` is a real fixture defect. Its rule (ruling
+ `boss-hulls-exclusive`) holds once the name is fixed, and the test survives unchanged
+ otherwise.
+ - `Another_factions_boss_hull_is_never_used` is a real fixture defect. Its fallback assertions
+ die with the revoked path. Its rule, that another maker's Boss hull is not this faction's,
+ survives as a `NoBossHull` refusal.
+ - `A_boss_hull_two_makers_sell_is_a_boss_of_each` is a real fixture defect. Its `BossHullsOf`
+ rule survives. Its last assertion (the boss flies "Shared Boss") becomes "flies its Loadout".
+- **BH13. The Loadout record already carries the key from a boss hull to its gear.** `Loadout`
+ (`Loadout.cs:14-22`, schema `aetheria.loadout` v1) has `Name` (Key 0), `Hull` (Key 1),
+ `Slots` (Key 2: `Position`, `Rotation`, `Design`, optional `Product`), `WeaponGroups` (Key 3)
+ and `HullProduct` (Key 4, optional). `HullProduct` names the product, and therefore the maker,
+ of the hull. So "the Loadout whose `HullProduct` is the faction's boss hull product" needs no new
+ field on `Loadout`, `Faction`, `FactionProductData` or `HullData`. It also tells apart two makers
+ of one shared Boss hull.
+- **BH14. `Loadouts.Materialize` is already the builder a fixed boss needs.** It is all or
+ nothing, pins a set product as named, and builds a design-only slot through the passed chooser.
+ Its doc says a game spawner passes `LoadoutGenerator.ChooseProduct`. It builds a Ship from a ship
+ hull with the given zone, null included, as `ShipLoadout` did, and pads to `WeaponGroupCount`.
+ It adds no pickup, consumables or outfit. Its callers today are `ScenarioStage.cs:179` (fits)
+ and the capture round trip in tests.
+- **BH15. A catalog Loadout cannot name a package hull.** `Loadouts.Commit` (`Loadout.cs:78-98`)
+ is the only writer of Loadout records, through the in-game capture command
+ (`ActionGameManager.cs:643`). It refuses any loadout whose hull or slot design is a mod key.
+ Package hulls and products are keyed `mod-hull:`, `mod-product:` and `mod-ship:`
+ (`ShipModCatalog.cs:23-30`), and "the shipped catalog ... never reference[s] one". A package
+ carries only its HullData, its ShipAuthoring and its products (`ShipModCatalog.Package`,
+ `:13-21`). So a boss Loadout for a package Boss hull has to ride in the package. That is the
+ next cut line below, not r4.
+- **BH16. Boss-dependent tests.** `RunGoalTests.cs` holds 18 tests. Those that read the demo's
+ fallback boss are `Boss_spawns_once_in_boss_zone`, `Boss_spawns_in_a_zone_that_is_not_ambient`,
+ `Exit_sealed_until_boss_dies`, `Boss_mark_survives_continue` and
+ `Exit_open_after_save_of_a_slain_boss`. `Exit_sealed_when_no_ship_hull_is_on_offer` pins the
+ fallback's null branch. No other test file references `IsBoss`, `BossSpawned` or `ExitOpen`.
+- **BH17. CultCache variants are not available to Aetheria yet.** CultLib `src` has variant
+ records (`CultCache.cs:59-60`, `CultVariantDelta`, "a variant record (slot 4): its base and
+ overrides"). Aetheria pins CultLib `45c2f4006f5f` (`Directory.Build.props:6`), and
+ `git grep CultVariantDelta 45c2f40` finds nothing. An interim Boss hull copied from a shipped
+ hull would be a plain copy until the pin moves. "Variant" is that record kind, never a loadout
+ (ruling `boss-gear-is-a-loadout`).
-### Rationale
+### Rationale (r4)
-- **One owner of which hull is a faction's boss:** `LoadoutGenerator.BossHullsOf(CultCache,
- Faction)`. It returns the faction's products whose design is a `HullType.Ship` HullData with
- `Boss` set, ordered by hull Price descending, then hull Name ordinal. The flag is authored on
- the hull, and the faction comes from the product's maker. Nothing stores the pairing.
- `GenerateBossLoadout`, `Galaxy.PlaceFactionsMain` and AetherDb `factions` all call it.
-- **One owner of "a Boss hull is never on offer":** `AvailableProducts`' candidate filter (BH6).
- r2 put the exclusion on `RandomHull` and the stock filter, and gave `RandomHull` a boss bool.
- That made two sites and one mode flag, and it missed the fallback's own
- `AvailableProducts` call. r3 uses one clause instead. The boss is built straight from its
- product through a new `ShipLoadout(FactionProductData)` builder, which is the body of
- `GenerateShipLoadout` after its draw. So the boss path never asks the availability rule to
- make an exception.
-- **The "named but unsold" branch goes.** It existed because BossHull named a design. A
- derivation that starts from products cannot name an unsold hull, so its log line and its test
- (`Boss_hull_nobody_sells_falls_back`) are deleted. They are not rewritten.
-- **A boss is not a market draw.** `ShipLoadout` from the boss product does not apply
- `AvailableProducts`' `Price > 0` rule. A price-0 Boss hull is still a boss.
-- **Kept:** a sold Boss hull's hardpoints still count in `SoldHulls`, so gear only it can mount has
- a home. That gear is loot. `ScenarioStage.Generated("")` fails like any unavailable
- hull. `ChooseProduct` is untouched.
-- **Prior art (from memory, medium confidence; no Eyes pass this round):** Starsector tags unique
- and boss hulls so markets never sell them. They are spawned by script, outside a faction's
- known-ships draw. EVE's officer spawns fly hulls that no market sells. Both match the exclusive
- reading. Question `boss-hull-exclusive` asks the operator to confirm it.
+- **One owner of what a boss flies:** `Loadouts.BossOf(CultCache, Faction, out BossRefusal)`, in
+ `Loadout.cs` beside the other owners of Loadout records. It takes `BossHullsOf(items, faction)[0]`
+ and returns the Loadout whose `HullProduct` key is that product's key, first by Name ordinal.
+ When there is none, it returns null with `NoBossHull` or `NoLoadout`. It reads no `Random`, no
+ model and no availability rule. `ZoneGenerator`'s boss block, `Galaxy.PlaceFactionsMain` and
+ AetherDb `factions` all call it. `LoadoutGenerator.BossHullsOf` stays the one owner of which hull
+ is a boss.
+- **Keyed on the product, not the hull** (BH13). A Boss hull two makers sell is a boss of each,
+ and each maker's boss is the Loadout on its own product. No field on Faction or the product
+ points at a Loadout, so no second record can disagree with the Loadout.
+- **No generator on the boss path.** `GenerateBossLoadout`, its priciest-hull fallback and
+ `ShipLoadout(product)` are deleted. `GenerateShipLoadout` gets back its single body, because the
+ boss was `ShipLoadout`'s only other caller. The boss is built by `Loadouts.Materialize`, the one
+ builder from a Loadout (BH14). It carries exactly its cells: no tractor pickup, no consumables
+ and no outfit pass.
+- **The chooser is not a draw of gear.** Materialize needs a chooser only for a design-only slot.
+ Ruling `loadout-product-optional-context-chooses` says that slot's product is chosen by context,
+ and `ChooseProduct` is that rule's owner. It never picks a design, a cell or the hull. The
+ negative check forbids every generator draw on the boss path (`GenerateShipLoadout`,
+ `RandomHull`, `RandomProduct(s)`, `OutfitEntity`, `EquipPickup`, `StockConsumables`). An agent
+ who wants the brand fixed pins the slot's product.
+- **A typed refusal, never a fallback.** `BossRefusal { None, NoBossHull, NoLoadout }`.
+ `Loadouts.Describe` names the missing field (`HullData.Boss`, `Loadout.HullProduct`) and echoes
+ no product name or record key. The zone logs ` has no boss: it .`, spawns
+ nothing and leaves `BossSpawned` false, so the exit gate fails closed through boss-gate's
+ existing rule. A Loadout that does not fit is logged with Materialize's failures and also spawns
+ nothing.
+- **Galaxy picks factions that have a boss to fly.** In the main galaxy, a faction with a Boss
+ hull but no Loadout gets no boss zone. That keeps a placed boss zone from being empty by
+ construction.
+- **Authoring** (ruling `boss-loadouts-agent-authored`). AetherDb `loadout-commit [,,,[@]]... [--group i,j]... [--replace]` writes one
+ Loadout through `Loadouts.Commit`, which stays the only writer. The command first resolves every
+ name exactly and test-materializes the Loadout, so a typo or a misfit refuses before anything is
+ written. The operator adjusts the record in Studio afterwards. The command is argument-driven,
+ not a coded table like `product-lines tiers`. A coded table replayed at merges would fight her
+ Studio edits. The record in the catalog is the authority, and the commit message records the
+ command line for a merge replay.
+- **Vocabulary.** `Loadout.cs` says "Loadout" throughout. "Preset" and "variant" go from its
+ comments and messages. The wider sweep (the `capturepreset` console command, `ScenarioStage`,
+ `Provenance`, `LoadoutGenerator` and `ActionGameManager` comments) is follow-up
+ `loadout-vocabulary-sweep`.
+- **Kept from r3 (h1, landed):** Faction.BossHull is deleted with key 10 retired, `HullData.Boss`
+ Key(33) stays, `BossHullsOf` stays, the exclusive clause in `AvailableProducts` stays (ruling
+ `boss-hulls-exclusive`), and `ship-authoring boss`, the compose test and the no-authored-model
+ test stay (ruling `hull-programmer-art-reads-as-stand-in`).
+- **Revoked from r3:** `GenerateBossLoadout` through `BossHullsOf` and `ShipLoadout(product)`, the
+ AetherDb `loadout` boss branch, `Boss_without_a_boss_hull_falls_back`, and the "makes no boss
+ hull" log.
+- **Prior art (from memory, medium confidence; no Eyes pass this round):** Starsector's unique
+ bosses (for example the Ziggurat and the Omega flagships) fly hand-authored variant files
+ attached to a scripted fleet, never the faction's autofit. EVE's officer and faction-commander
+ spawns have fixed fittings per NPC type. Both match a fixed record keyed to the boss, with no
+ generator.
### Model page rows
-Replace the two rows in "Demo scope, Model page rows" (`Boss hull`, `Faction.BossHull`) with:
+Replace the rows in "Demo scope, Model page rows" (`Boss hull`, `Faction.BossHull`), and add the
+boss Loadout row:
| Kind | Identity | Lifecycle | Authority |
|---|---|---|---|
-| Boss hull | `HullData.Boss` (key 33, bool) on any HullData, whether shipped, package, or agent-built stopgap | Set by `ship-authoring boss on\|off` on packages, or in Studio's inspector on shipped hulls. Derived per generation; stored nowhere | The hull's author sets the flag. `LoadoutGenerator.BossHullsOf` derives the faction from the product's maker |
+| Boss hull | `HullData.Boss` (key 33, bool) on any HullData: shipped, package, or agent-built stopgap | Set by `ship-authoring boss on\|off` on packages, or in Studio's inspector on catalog hulls. Derived per generation; stored nowhere | The hull's author sets the flag. `LoadoutGenerator.BossHullsOf` derives the faction from the product's maker |
+| Boss Loadout | A `Loadout` record (`aetheria.loadout` v1) whose `HullProduct` is a faction's Boss hull product, at `Loadouts.KeyOf(name)` | Written by AetherDb `loadout-commit` (agents) or the in-game capture, both through `Loadouts.Commit`. Studio edits after that. Read at zone generation by `Loadouts.BossOf` | `Loadouts.BossOf` alone decides what a boss flies, and `Loadouts.Materialize` builds it. No generator draw |
| `Faction.BossHull` (key 10) | Retired; never reuse | Deleted by `boss-hull-derived`. The shipped records keep `''` in the slot (BH5) | none |
### Cut sequence
-Remove the `boss-hull-derived` line. Change the `demo-cast-hulls` line's "boss-hull-derived" in
-"Waits on" to "boss-hull-derived (spec cut-boss-hull-derived.r3)" until it merges.
+- `boss-hull-derived` (spec `cut-boss-hull-derived.r4`, branch at `095baf2f`). Its merge waits on
+ question `demo-boss-before-its-hull`.
+- If `interim-catalog-boss` is chosen: `zhestokost-interim-boss`, a sibling content cut that lands
+ with r4. Through AetherDb it writes a catalog Boss hull copied from the hull today's demo boss
+ flies (BH10), plus its Zhestokost product and its Loadout (`loadout-commit`). It is replayed at
+ merges and replaced when the real Zhestokost Boss hull lands.
+- `package-boss-loadout`: a package carries its boss Loadout, as it carries its product (ruling
+ `package-carries-product`, BH15). `ship.cc` gains Loadout records, Compose writes them under a
+ `mod-loadout:` key that `IsModKey` knows, and `ship-authoring loadout` writes them. It is needed
+ before the first package Boss hull can be a boss. `BossOf` already matches it unchanged, by
+ `HullProduct` key in the composed cache.
+- `demo-cast-hulls`'s "Waits on" reads "boss-hull-derived (spec cut-boss-hull-derived.r4)" until it
+ merges.
## Product tiers
@@ -11186,3 +11268,196 @@ with the wider scope taken when the FusedRounds premise is fixed.
**Old saves.** A pre-cut save reads `Seed == 0` and continues with name-only drops. Under ruling
`no-save-compatibility-before-players` the cut may refuse it, but a refusal gate would be code
written for nobody.
+
+## Stand-in hulls (SI)
+
+Imagination pass `imagination-ag6-hull-art`, session `self-2026-10-18-ag6`, 2026-10-18. Pinned to Aetheria
+`origin/master` 6d10331c (worktree `C:\aeth-wt\ag6-hull-art`). Serves ruling
+`hull-programmer-art-reads-as-stand-in`, which supersedes `hull-programmer-art-stopgap`.
+Research input: `docs/research/2026-10-18-agent-built-hull-art-prior-art.md`. That file surveys making agent-built
+art look good or deliberate. This ruling asks for the opposite, so the research's §B3 (low poly that reads as
+deliberate) is evidence about what to avoid. The greybox "dev texture" convention below comes from general practice,
+not from that file (one of the gaps listed at the end).
+
+Scratch root `S` = `C:\Users\Meta\AppData\Local\Temp\claude\F--Projects-CultLib\1b477360-19e3-41e2-bde4-8932ace0b8fd\scratchpad\imagination-ag6-hull-art`.
+
+### Renders for the operator
+
+- `S\probe\out\longinus_top_standin_vs_existing.png`: top view, stand-in (left) and the shipped `Longinus.fbx` (right), each at its own scale.
+- `S\probe\out\longinus_iso_standin_vs_existing.png`: three-quarter view, same pair.
+- `S\probe\out\longinus_combat_zoom_proxy.png`: both top views at ship lengths of 48, 80 and 128 px.
+- `S\probe\out\longinus_top_same_metres.png`: both in one 40 m frame. The stand-in uses 2 m cells and the shipped mesh about 1 m per cell (SI-B5).
+
+All four come from one scratch rasterizer (`S\probe\render.py`) with the same camera and light. The stand-in side is
+read back from the `ship.glb` the package path accepted, so it is not a separate preview.
+
+### What the game reads (body facts)
+
+- **SI-B1. Package shape.** A mod hull is `GameData/Mods//ship.cc` plus `ship.glb`. The `.cc` holds `HullData`
+ (`mod-hull:`) and `ShipAuthoring` (`mod-ship:`; `ModelAsset`, `Anchors`, `SchematicLines`, `Joints`).
+ Source: `ShipAuthoring.cs:12-21`, `docs/moddable-ship-authoring.md:160-200`.
+- **SI-B2. Anchors the game needs.** Exactly one each of `map-icon`, `hull-collider`, `shield` and `tractor`
+ (`ShipAuthoring.cs:288-290`). Each visible hardpoint has a mount anchor whose id is its mount id: `thruster-emitter`,
+ `radiator-mesh` or `weapon-mount`. Each weapon needs at least one `weapon-muzzle` whose ParentId is the mount
+ (`:325-356`). These nodes must carry a mesh: thruster-emitter, radiator-mesh, map-icon and hull-collider. The
+ shield node needs three positive scale radii (`ShipModCatalog.cs:256-262`). Weapon mounts and the tractor may be
+ empties. Internal hardpoints have no anchor. Every hardpoint needs a non-empty mount id.
+- **SI-B3. Frame.** Blender Ship Root has the nose toward -Y, starboard toward -X and Z up. Cell (x, y) is centred at
+ `origin - 2(x, y)`, schematic +x is starboard and +y is forward, so row 0 is the stern
+ (`tools/blender/aetheria_ships/hull_grid.py:3-20`). Package exports with `export_yup` (`__init__.py:791`), so the
+ glTF nose is +Z. Hardpoint `Rotation` gives the facing: 0 nose, 1 port, 2 aft, 3 starboard
+ (`hull_grid.py:141-145`). A footprint shape is stored as placed, and validation reads it at `Position` without
+ rotating it (`ShipAuthoring.cs:310-320`).
+- **SI-B4. Longinus HullData** (decoded from `GameData/Aetheria.cc` with cultcache-py, `S\grid.py`). It is 6 x 17
+ cells, symmetric, with 13 hardpoints. Visible: Th.L and Th.R (2x2, rows 0-1, aft), Th.CW and Th.CCW (1x2, row 14,
+ port and starboard), En.L and En.R (1x2, row 8), La.L and La.R (1x3, flanks, rows 5-7), Ra.L and Ra.R (1x2,
+ row 2). Internal: Reactor, ControlModule, and Sensors, which has a **null mount id**. Because of that null id, a
+ package copy of the shipped Longinus is refused until the Sensors hardpoint is given an id (`ship_cc.replace_layout`
+ raised "needs 7 typed fields and a stable mount ID"). The probe gave it the id `Sensors`.
+- **SI-B5. Scale.** The add-on authors at 2 m per cell (`hull_grid.py:11`, "Settings.SchematicCellSize"; New Ship
+ sizes "this many 2 m cells"). The shipped `Longinus.fbx` body is 5.2 x 16.8 m for 6 x 17 cells, about 1 m per cell
+ (`S\fbx.py`, mesh `Cube.043`). Ruling `schematic-and-model-are-separate-spaces` already allows the two to differ.
+ The stand-in uses the add-on's 2 m.
+- **SI-B6. Materials today.** `ShipModVisual` imports the GLB with glTFast and keeps its glTF materials. The only
+ material it adds is the schematic line material (`ShipModVisual.cs:55-64`). `Aetheria/Hull` and the UV0 livery mask
+ belong to `ships-hull-material`, which has a spec but has not landed. So a stand-in's look today is whatever its
+ glTF materials say.
+- **SI-B7. `ship-authoring create --like`** copies a shipped hull's stats with an empty shape and no hardpoints
+ (`ShipAuthoringCommands.cs:57-75`, `HullLike` clears `Shape` and `Hardpoints`). The layout comes from Blender or
+ from `ship_cc.replace_layout`.
+- **SI-B8. OpenSCAD over LevelDsl, decided from source.** The Geometry.Csg `LevelDsl` has box, cut box, oriented box,
+ cylinder_z, dome_cap_z and floret_arm, with no hull or free-axis cone (research, Internal fit), and it needs a
+ cargo build. OpenSCAD nightly with Manifold is installed, and brush-tree's `openscad-target` already rules
+ OpenSCAD-shaped text. LevelDsl was not probed.
+
+### The probe (Longinus)
+
+The stand-in source has three files (`S\probe\`):
+- `standin.scad`: the vocabulary. Glyph per kind, kind colours, cell frame, part dispatch.
+- `longinus.layout.scad`: generated from HullData by `gen_layout.py`, and never edited by hand. It holds the visible
+ mounts: id, kind, position, placed footprint and rotation.
+- `longinus.standin.scad`: agent-authored, 25 lines. Four `hull()` segments make the body (stern block, flared
+ weapons section, spine, nose), plus a `deck()` height function.
+
+`build.py` renders each part separately with `openscad.com --backend Manifold --render all -D part="…"` (one process
+at a time, about 22 runs). It then writes `ship.glb`: a flat-faceted mesh per part, UV0 as a top-down projection in
+cell units, and nodes tagged `extras.aetheria.id`. It also writes `anchors.json`.
+
+- **SI-P1. Measurements** (`S\probe\build.py` output):
+ - The body has 104 triangles. 0 of its 60 vertices lie outside the hull cells, and the worst miss of the mirror
+ test is 0.0000 m (the body is symmetric).
+ - The GLB is 84 KB, with 28 nodes and 18 anchors.
+ - All 10 visible mounts project into their own footprint: weapons and radiators at the footprint centre, thrusters
+ on the footprint edge they face.
+ - The probe checked vertices, not edges, against the cells. Each segment is convex inside a rectangular run of
+ cells, which is why the vertex check suffices here; a stepped outline needs an edge check.
+- **SI-P2. Package path, on Yggdrasil.** `ygg-verify` ran on a scratch clone at `2329782244d3`. That clone is
+ 6d10331c plus `standin-probe/`; its commits are throwaway and were never pushed to origin. Image
+ `mcr.microsoft.com/dotnet/sdk:10.0`, with pins `/pins/cultlib-git-45c2f40` and `/pins/cultlib-9c1b06cd` and the
+ `/lfs` catalog. Log: `S\ygg-standin-6.log`, GREEN. The steps were:
+ 1. `ship-authoring create … --like Longinus`.
+ 2. `ship_cc.replace_layout` with Longinus' shape and hardpoints, then `ship_cc.replace_visual` with the 18 anchors
+ (the add-on's own Python).
+ 3. `validate`, which reported "13 hardpoints, 18 anchors, 0 lines (records and model)", meaning
+ `ShipModCatalog.ReadPackage` judged the GLB.
+ 4. `compose`, which reported "Composed 1 mod ships", with no exclusions.
+
+ Runs 1-5 were red on job plumbing: LFS pointer, CultLib pins, empty layout from `--like`, and the null Sensors id.
+- **SI-P3. Not proven.** The Unity side was not run: `ShipModPreview.Smoke -assemble` and `ShipModPlaySmoke.Run`
+ (thrusters, weapons with muzzles, collider, map icon). The probe's GLB writer is scratch Python, not the add-on's
+ Package Ship. Blender was not in the loop, because no current Blender is on Starfire (only 2.77 and 2.78 under
+ `D:\Applications`). Glyph proportions were checked only in the proxy renders, never at the real combat camera.
+- **SI-P4. What the renders show.**
+ - From above, the stand-in states where every gun, launcher, engine and radiator is, by colour. At 48 px the
+ shipped mesh reads only as a silhouette.
+ - The body reads as an unfinished blockout: slab facets, a cell grid, no surface detail. Nobody would take it for
+ the shipped hull.
+ - The grid is texture noise at 48 px. In engine, mips average it away at distance.
+ - The first radiator fins were edge-on from above and vanished. They became panels tilted 30° outward. Energy
+ barrels were thickened from 0.14 to 0.28 m for the same reason.
+
+### Visual grammar (proposal; the look is question `standin-visual-mark`)
+
+1. **Body.** A few convex `hull()` segments that follow the hull's outline and its concept (Concept Mill image or
+ description). Flat facets at `$fn = 8`. No bevels, panel lines, greebles or invented surface detail. One neutral
+ grey, carrying a dark line every 2 m cell on UV0, which is projected top-down in cell units. The grid is the
+ greybox mark, and because the cells are the schematic's, it also states size class.
+2. **Mount glyphs by kind,** each in one saturated colour, on its footprint, facing its Rotation:
+ - energy: a puck and one long barrel;
+ - ballistic: a puck and two short thick barrels;
+ - launcher: a box with tube mouths on its facing face;
+ - thruster: a bell opening toward its facing, whose mouth disc is the `thruster-emitter` mesh;
+ - radiator: a panel tilted outward, which is itself the `radiator-mesh`.
+
+ Internal hardpoints are not drawn, because the schematic UI owns them.
+3. **Size.** Model metres are cells x 2 m (the add-on's rule).
+4. **Faction.** None in the mesh. Once `ships-hull-material` lands, the body's UV0 is where the livery mask goes
+ (primary channel on the body). Until then the body is neutral grey.
+
+### Model (SKILL step 0b): rows for the persistent kinds this adds
+
+| Kind | Identity | Lifecycle | Authority |
+|---|---|---|---|
+| Stand-in source, `GameData/Mods//standin.scad` | The package directory, which equals `ShipAuthoring.Id`, names it. One per package, at a fixed name. Injective because package ids are. | Created by an agent pass for a hull with no operator art. Revised by agents. Deleted in the same change that lands operator art for that package. | The agent pass (Imagination for content, Hands for the cut) authors the body. The operator decides when it dies, by packaging her art. Forbidden writer: nothing hand-edits the layout. |
+| Layout, generated | Derived from that package's HullData. | Regenerated on every build. Not committed, never edited. | HullData. Owner only under `standin-layout-follows-schematic` = follow-schematic. Under free-like-real-art this row disappears and the source holds mount placement. |
+| Shared vocabulary, `tools/blender/aetheria_ships/standin/standin.scad` | Single file. | Revised by cuts; a revision re-renders every stand-in at its next build. | The ship add-on (Aetheria). |
+| `ship.glb` | `ShipAuthoring.ModelAsset`: one slot per package. | While `standin.scad` exists, a build output of it. Afterwards, the output of the operator's Package. | Exactly one writer at a time. The slot holds one mesh, so two meshes can never both decide. |
+| "This hull is a stand-in" | The presence of `standin.scad` in the package. **No new field.** | Ends when the file is deleted. | Derived. Build Stand-in refuses a package without the file, so it can never overwrite operator art. Any "which hulls are still stand-ins" view is a directory listing. |
+| Anchors | `ShipAuthoring.Anchors`, ids = mount ids. | Rewritten by Package on each build, stand-in or art. Ids are stable across the swap. | Package Ship (`ship_cc.replace_visual`). The same anchor ids keep loadouts and saves valid across the swap. |
+
+Replacement when real art arrives: the operator packages her collection into the same package. Package writes
+`ship.glb` and the anchors, and the same change deletes `standin.scad`. That is one act and one owner.
+Open choice inside the cut, not an operator fork: whether Package deletes `standin.scad` itself or refuses until it is
+deleted. Recommendation: Package deletes it when the bound collection was not built by Build Stand-in, and says so.
+
+`standin.scad` ships inside the mod directory, so it may reach player builds. That is harmless, and it doubles as an
+example for modders. The alternative is `tools/standin/hulls/.scad`, which breaks "presence in the package is the
+status".
+
+### Rationale
+
+- **Why not Tripo or "deliberate low poly".** The ruling's test is "never mistaken for final art". Every element of
+ the grammar is chosen to fail the polish test on purpose: the grid, flat facets, kind colours, and no surface
+ detail. Each element also carries information (scale, mount kind, facing), so the look is utility.
+- **Why the layout is generated.**
+ - Readability: the mounts the player sees agree with the schematic UI.
+ - Upkeep: a schematic edit re-renders the stand-in with no agent pass.
+ - Opus's weak spot: spatial placement is the failure mode the prior art documents most (LL3M §5; CAD
+ valid-but-wrong), and generation leaves only the body to judgment.
+
+ This is model geometry derived from cells, against `schematic-and-model-are-separate-spaces`, hence question
+ `standin-layout-follows-schematic`.
+- **Why Blender for the target, not the probe's Python.** Package Ship already writes `ship.glb` and the anchors,
+ and runs validate. A second GLB writer would be a second authority over the same file. The cut imports OpenSCAD
+ part meshes into the bound collection with role tags, then calls Package. Brush-tree's `bake-only` and
+ `lowered-hull-through-package` point the same way.
+
+### Cut sequence (one line each; no spec admitted, because `standin-build` waits on both questions)
+
+1. `standin-build`. Add-on operator Build Stand-in (headless-capable): checks that `standin.scad` exists, generates
+ the layout from the bound `ship.cc`, runs `openscad.com` per part, imports the parts into the bound collection with
+ `aetheria.role`/`aetheria.id` tags, and calls Package Ship. It carries the vocabulary file and Longinus as its test
+ hull, gives the shipped Longinus Sensors hardpoint a mount id (SI-B4), and adds a `smoke_standin.py` beside
+ `smoke_package.py`. Verification:
+ - `ShipModPreview.Smoke -assemble` and `ShipModPlaySmoke.Run` on the package;
+ - the in-cells, mirror and in-footprint checks from SI-P1 (the last only under follow-schematic).
+
+ Deploy owner: OpenSCAD in whatever image runs Blender smokes on Yggdrasil. Not established, because the Blender
+ smoke's host was not found.
+2. `standin-package-retire`. Package Ship, on a package that still has `standin.scad`, deletes the file when the
+ collection is the operator's (model row above). It can fold into cut 1 if small.
+3. `standin-role-hulls` (content). Stand-ins for the demo role hulls as their HullData land (follow-up
+ `demo-role-hulls`), and for the cast flyables until her art arrives. Each body is authored from the hull's
+ Concept Mill keeper or description (follow-up `hulls-through-concept-mill`).
+4. After `ships-hull-material`: the stand-in mask, with primary channel = body, so faction liveries colour stand-ins.
+ A line in that spec's next revision, not a cut of its own.
+
+### Gaps
+
+- Combat camera distance and angle were not read, so the zoom panel is a proxy.
+- The greybox dev-texture convention is general practice, not evidence in the Eyes file.
+- Where a current Blender runs headless for agents is not established on Starfire. The Package smoke's host was
+ not checked.
+- Settings.SchematicCellSize = 2 m is cited from `hull_grid.py`, not read from `Settings.asset`.
+- Scope reading, not a ruling: stand-ins are for hulls without operator art (demo role hulls; cast flyables until
+ hers land). Shipped legacy hulls keep their meshes.
From bfcde46c0f3a87d233a2edfdf3ed5d4791e0e0c2 Mon Sep 17 00:00:00 2001
From: Meta Vens
Date: Sun, 18 Oct 2026 18:22:15 +0200
Subject: [PATCH 023/444] Delete GenerateBossLoadout, ShipLoadout and the boss
fallback tests (does not build: ZoneGenerator and Program still call them
until the next commit)
Boss gear is never drawn (ruling boss-gear-is-a-loadout). GenerateShipLoadout gets its single body back.
Co-Authored-By: Claude Sonnet 5.5
---
.../Scripts/ServerShared/LoadoutGenerator.cs | 29 +++--------------
tests/Aetheria.Shared.Tests/RunGoalTests.cs | 31 -------------------
tools/AetherDb/Program.cs | 7 +----
3 files changed, 6 insertions(+), 61 deletions(-)
diff --git a/Assets/Scripts/ServerShared/LoadoutGenerator.cs b/Assets/Scripts/ServerShared/LoadoutGenerator.cs
index 34e1cd1c0..86b300931 100644
--- a/Assets/Scripts/ServerShared/LoadoutGenerator.cs
+++ b/Assets/Scripts/ServerShared/LoadoutGenerator.cs
@@ -48,13 +48,7 @@ public class LoadoutGenerator
ItemManager.Log("Unable to generate ship loadout: no compatible hull found!");
return null;
}
- return ShipLoadout(hullProduct);
- }
-
- // The ship a hull product becomes: outfit, pickup, consumables, packed. Callers choose the product; this is the one build.
- public EntityPack ShipLoadout(FactionProductData product)
- {
- var hull = ItemManager.CreateInstance(product) as EquippableItem;
+ var hull = ItemManager.CreateInstance(hullProduct) as EquippableItem;
if(hull==null)
ItemManager.Log("WHAT???");
var entity = new Ship(ItemManager, null, hull, ItemManager.GameplaySettings.DefaultEntitySettings);
@@ -191,8 +185,8 @@ public class LoadoutGenerator
// The products this faction can be offered for a design kind (only gear needs a home), and whether manufacturer preference applies (it does
// not once a required item has fallen back to any manufacturer). The one availability rule for every selection: a Boss
- // hull is never on offer, so it is never station stock, ordinary traffic, a generated scenario fit or another faction's
- // fallback boss, and only BossHullsOf reaches it.
+ // hull is never on offer, so it is never station stock, ordinary traffic or a generated scenario fit; a Boss hull
+ // reaches a ship only through its faction's boss Loadout (Loadouts.BossOf).
private ((FactionProductData product, T design)[] available, bool preferManufacturers) AvailableProducts(Predicate filter, bool required) where T : CraftedItemData
{
var hulls = SoldHulls(ItemManager.ItemData);
@@ -218,23 +212,10 @@ public class LoadoutGenerator
return (available, preferManufacturers);
}
- // A faction's boss: the Boss hull it makes (BossHullsOf, dearest first), else the most expensive ordinary ship hull the
- // faction can be offered, with the gap logged. Null only when no ship hull is on offer at all.
- public EntityPack GenerateBossLoadout()
- {
- var made = BossHullsOf(ItemManager.ItemData, Faction);
- if (made.Length > 0) return ShipLoadout(made[0].product);
- ItemManager.Log($"{Faction?.Name ?? "This faction"} makes no boss hull; its boss flies the most expensive ship hull instead.");
-
- var (available, _) = AvailableProducts(hull => hull.HullType == HullType.Ship, required: true);
- if (available.Length == 0) return null;
- var priciest = available.OrderByDescending(entry => entry.design.Price).ThenBy(entry => entry.design.Name, StringComparer.Ordinal).First().design;
- return GenerateShipLoadout(hull => hull == priciest);
- }
-
// The one owner of which hull is a faction's boss: the Ship hulls flagged Boss among the designs of the products the
// faction manufactures, dearest first then by name. The flag lives on the hull and the maker on the product, so a Boss
- // hull several makers sell is a boss of each; nothing here reads a model, a prefab or a visual.
+ // hull several makers sell is a boss of each; nothing here reads a model, a prefab or a visual. Its readers are
+ // Loadouts.BossOf and AetherDb factions.
public static (FactionProductData product, HullData hull)[] BossHullsOf(CultCache items, Faction faction) =>
faction == null
? Array.Empty<(FactionProductData, HullData)>()
diff --git a/tests/Aetheria.Shared.Tests/RunGoalTests.cs b/tests/Aetheria.Shared.Tests/RunGoalTests.cs
index 541ee64dd..460becc92 100644
--- a/tests/Aetheria.Shared.Tests/RunGoalTests.cs
+++ b/tests/Aetheria.Shared.Tests/RunGoalTests.cs
@@ -107,21 +107,6 @@ public sealed partial class RunStartTests
Assert.Equal(new[] { "Boss A", "Boss B", "Boss C" }, LoadoutGenerator.BossHullsOf(_cache, antagonist).Select(entry => entry.hull.Name));
}
- [Fact]
- public void Boss_without_a_boss_hull_falls_back()
- {
- var galaxy = DemoGalaxy(1);
- var priciest = PriciestSold();
-
- var log = new List();
- var items = LoggingItems(log);
- var boss = Assert.Single(BossZonePack(galaxy, items, galaxy.Exit).Entities, entity => entity.Boss);
- Assert.Equal(priciest.Name, HullName(items, boss));
- Assert.Contains(log, line => line.Contains("Zhestokost") && line.Contains("makes no boss hull"));
- Assert.Empty(LoadoutGenerator.BossHullsOf(_cache, Zhestokost(galaxy)));
- Assert.Empty(LoadoutGenerator.BossHullsOf(_cache, null));
- }
-
[Fact]
public void Another_factions_boss_hull_is_never_used()
{
@@ -354,22 +339,6 @@ public sealed partial class RunStartTests
Assert.False(RunGoal.ExitOpen(zone));
}
- // A boss that cannot be generated (no ship hull on offer at all) never marks the zone as having spawned one.
- [Fact]
- public void Exit_sealed_when_no_ship_hull_is_on_offer()
- {
- var galaxy = DemoGalaxy(1);
- foreach (var product in _cache.GetAll().Where(product => _cache.Get(product.Design) is HullData { HullType: HullType.Ship }).ToArray())
- Assert.True(_cache.Remove(_cache.RefOf(product).Key));
- var log = new List();
- var items = LoggingItems(log);
- var pack = BossZonePack(galaxy, items, galaxy.Exit, ambient: false);
- Assert.False(pack.BossSpawned);
- Assert.DoesNotContain(pack.Entities, entity => entity.Boss);
- Assert.Contains(log, line => line.Contains("No boss could be generated for Zhestokost"));
- Assert.False(RunGoal.ExitOpen(new Zone(items, _planetSettings, pack, galaxy.Exit, galaxy)));
- }
-
// A boss killed before the save stays dead after it, and the gate stays open: the spawn fact travels with the zone.
[Fact]
public void Exit_open_after_save_of_a_slain_boss()
diff --git a/tools/AetherDb/Program.cs b/tools/AetherDb/Program.cs
index 583e811ad..5f2324471 100644
--- a/tools/AetherDb/Program.cs
+++ b/tools/AetherDb/Program.cs
@@ -806,18 +806,13 @@ public static class Program
string outcome;
try
{
- // Generation never offers a Boss hull, so one is built through its first product.
- var bossProduct = hull.Boss && hull.HullType == HullType.Ship
- ? db.Cache.GetAll().Where(p => db.Cache.Get(p.Design) == hull).OrderBy(p => p.Name, StringComparer.Ordinal).FirstOrDefault()
- : null;
var pack = hull.HullType switch
{
- HullType.Ship when hull.Boss => bossProduct == null ? null : generator.ShipLoadout(bossProduct),
HullType.Ship => generator.GenerateShipLoadout(candidate => candidate == hull),
HullType.Turret => generator.GenerateTurretLoadout(),
_ => generator.GenerateStationLoadout()
};
- outcome = pack != null ? "ok" : hull.Boss && hull.HullType == HullType.Ship ? "NO LOADOUT (boss hull with no product)" : "NO LOADOUT (nothing suitable found)";
+ outcome = pack != null ? "ok" : "NO LOADOUT (nothing suitable found)";
if (pack == null) failures++;
if (pack != null && log.Any(l => l.Contains("targeting system available", StringComparison.OrdinalIgnoreCase))) targetingGaps++;
}
From 5d624f9e6d1bdf212de1adc3e8845b05facefa62 Mon Sep 17 00:00:00 2001
From: Meta Vens
Date: Sun, 18 Oct 2026 18:25:42 +0200
Subject: [PATCH 024/444] Loadouts.BossOf: a faction's boss flies the Loadout
on its boss hull's product (builds not yet checked)
BossOf keys on BossHullsOf(items, faction)[0] and Loadout.HullProduct, refuses typed (BossRefusal),
reads no Random, Prefab or Visual. ZoneGenerator builds the boss through Loadouts.Materialize with
ChooseProduct as the only generator member; Galaxy picks boss factions through BossOf. Loadout.cs
vocabulary: loadout, not preset or variant.
Co-Authored-By: Claude Sonnet 5.5
---
Assets/Scripts/ServerShared/Galaxy.cs | 4 +-
Assets/Scripts/ServerShared/ItemData.cs | 3 +-
Assets/Scripts/ServerShared/Loadout.cs | 65 +++++++++++++++-----
Assets/Scripts/ServerShared/ZoneGenerator.cs | 23 +++++--
4 files changed, 70 insertions(+), 25 deletions(-)
diff --git a/Assets/Scripts/ServerShared/Galaxy.cs b/Assets/Scripts/ServerShared/Galaxy.cs
index 6e28cf49e..86cd34125 100644
--- a/Assets/Scripts/ServerShared/Galaxy.cs
+++ b/Assets/Scripts/ServerShared/Galaxy.cs
@@ -288,9 +288,9 @@ public class Galaxy
progressCallback?.Invoke("Placing Factions");
if (progressCallback != null) Thread.Sleep(500); // Inserting Delay to make it seem like it's doing more work lmao
- // Choose some megas to have bosses placed: the factions that make a Boss hull
+ // Choose some megas to have bosses placed: the factions with a boss to fly, a Boss hull and its Loadout
var bossMegas = Factions
- .Where(m => LoadoutGenerator.BossHullsOf(_cache, m).Length > 0)
+ .Where(m => Loadouts.BossOf(_cache, m, out _) != null)
.Take(bossCount)
.ToArray();
diff --git a/Assets/Scripts/ServerShared/ItemData.cs b/Assets/Scripts/ServerShared/ItemData.cs
index 431acc7ce..6d9e79ef9 100644
--- a/Assets/Scripts/ServerShared/ItemData.cs
+++ b/Assets/Scripts/ServerShared/ItemData.cs
@@ -556,7 +556,8 @@ public class HullData : EquippableItemData
public CultRecordRef Visual;
// Only bosses fly this hull. Which factions it is a boss for comes from its products' makers
- // (LoadoutGenerator.BossHullsOf), so the flag names no faction and reads no model.
+ // (LoadoutGenerator.BossHullsOf), so the flag names no faction and reads no model. A boss's gear is its faction's
+ // fixed Loadout on this hull's product (Loadouts.BossOf), never generated.
[Inspectable, JsonProperty("boss"), Key(33)]
public bool Boss;
diff --git a/Assets/Scripts/ServerShared/Loadout.cs b/Assets/Scripts/ServerShared/Loadout.cs
index bf8a2ac47..c2252087f 100644
--- a/Assets/Scripts/ServerShared/Loadout.cs
+++ b/Assets/Scripts/ServerShared/Loadout.cs
@@ -6,11 +6,11 @@ using GameCult.Caching;
using MessagePack;
using CultMath;
-// A ship preset (a variant): the hull's design, and per hull cell a design and its rotation. A slot names a design and
-// may name a product of it; a design-only slot is open on purpose, and whoever materializes the preset chooses its
-// product from the galaxy it is built in (ruling loadout-product-optional-context-chooses), which keeps a preset
-// portable across galaxies. A preset that needs a brand names it. Authored catalog data: written in CultCache Studio or by the editor capture
-// command, read at runtime.
+// A Loadout: a hull together with item designs or products equipped to specific cells (ruling boss-gear-is-a-loadout).
+// A slot names a design, and names a product only where the loadout demands one; a design-only slot's product is chosen
+// by whoever materializes it from the galaxy it is built in (ruling loadout-product-optional-context-chooses), which
+// keeps a loadout portable across galaxies. Authored catalog data: written in CultCache Studio or by the AetherDb and
+// editor capture commands, read at runtime.
[CultDocument("aetheria.loadout", "1"), MessagePackObject]
public class Loadout
{
@@ -30,6 +30,8 @@ public class LoadoutSlot
[Key(3)] public CultRecordRef Product; // unset: design-only, chosen at materialization
}
+public enum BossRefusal { None, NoBossHull, NoLoadout }
+
// The owner of loadouts: capture from a live ship, the only writer, and the only builder of an entity from a loadout.
public static class Loadouts
{
@@ -66,30 +68,60 @@ public static class Loadouts
private static CultRecordRef ProductRef(ItemManager itemManager, CraftedItemInstance unit) =>
itemManager.Brand(unit).Product is { } product ? itemManager.ItemData.RefOf(product) : default;
- // A preset's key derives from its name, so capturing the same name again addresses the same record.
+ // A loadout's key derives from its name, so capturing the same name again addresses the same record.
public static CultRecordKey KeyOf(string name) => new CultRecordKey("loadout:" + name);
- // The only writer of presets. It opens its own short-lived cache over the catalog file, writable, and disposes it
+ // The one owner of what a faction's boss flies: the Loadout whose HullProduct is the product of the faction's first boss
+ // hull (LoadoutGenerator.BossHullsOf, dearest first), first by Name ordinal. Keyed on the boss hull's product through
+ // Loadout.HullProduct, never generated; reads no Prefab, Visual or Random. Null with the refusal when there is no boss
+ // hull or no Loadout for it.
+ public static Loadout BossOf(CultCache items, Faction faction, out BossRefusal refusal)
+ {
+ var hulls = LoadoutGenerator.BossHullsOf(items, faction);
+ if (hulls.Length == 0)
+ {
+ refusal = BossRefusal.NoBossHull;
+ return null;
+ }
+
+ var key = items.RefOf(hulls[0].product).Key;
+ var loadout = items.GetAll()
+ .Where(candidate => candidate.HullProduct.IsSet() && candidate.HullProduct.Key.Equals(key))
+ .OrderBy(candidate => candidate.Name, StringComparer.Ordinal)
+ .FirstOrDefault();
+ refusal = loadout == null ? BossRefusal.NoLoadout : BossRefusal.None;
+ return loadout;
+ }
+
+ // Names the field that is missing; echoes no input value and no record key.
+ public static string Describe(BossRefusal refusal) => refusal switch
+ {
+ BossRefusal.NoBossHull => "makes no ship hull with HullData.Boss set",
+ BossRefusal.NoLoadout => "no Loadout's HullProduct names its boss hull's product",
+ _ => ""
+ };
+
+ // The only writer of Loadout records. It opens its own short-lived cache over the catalog file, writable, and disposes it
// after the one commit. No other cache is touched, so no catalog instance play holds (or has mutated) can reach the
- // file; a cache that already has the catalog open sees the preset only once it reloads.
- // Throws and writes nothing when the loadout names a mod design (hull or slot), when the file is missing (capture never creates a catalog), when a preset of this name
+ // file; a cache that already has the catalog open sees the loadout only once it reloads.
+ // Throws and writes nothing when the loadout names a mod design (hull or slot), when the file is missing (capture never creates a catalog), when a loadout of this name
// exists under another key, or when one exists under KeyOf(Name) and replace is not set. The commit is conditional
- // on the record at KeyOf(Name), so a preset changed on disk since this open is not clobbered: returns false then.
+ // on the record at KeyOf(Name), so a loadout changed on disk since this open is not clobbered: returns false then.
public static bool Commit(string catalogPath, Loadout loadout, bool replace)
{
var modDesigns = new[] { loadout.Hull.Key }.Concat(loadout.Slots.Select(slot => slot.Design.Key)).Where(ShipModCatalog.IsModKey).Select(key => key.Value).Distinct().ToArray();
if (modDesigns.Length > 0)
- throw new InvalidOperationException($"Preset '{loadout.Name}' names mod designs ({string.Join(", ", modDesigns)}); the shipped catalog never references a mod, so a mod ship cannot be captured as a preset.");
+ throw new InvalidOperationException($"Loadout '{loadout.Name}' names mod designs ({string.Join(", ", modDesigns)}); the shipped catalog never references a mod, so a mod ship cannot be captured as a loadout.");
if (!File.Exists(catalogPath))
- throw new InvalidOperationException($"Catalog {catalogPath} does not exist; a preset capture never creates one.");
+ throw new InvalidOperationException($"Catalog {catalogPath} does not exist; a loadout commit never creates one.");
using var cache = AetheriaStores.Open(catalogPath, catalogWritable: true);
var key = KeyOf(loadout.Name);
var namesake = cache.GetAll().FirstOrDefault(other => other.Name == loadout.Name && !cache.RefOf(other).Key.Equals(key));
if (namesake != null)
- throw new InvalidOperationException($"Preset '{loadout.Name}' already exists under key {cache.RefOf(namesake).Key}; rename or remove it first.");
+ throw new InvalidOperationException($"Loadout '{loadout.Name}' already exists under key {cache.RefOf(namesake).Key}; rename or remove it first.");
var existing = cache.Get(key);
if (existing != null && !replace)
- throw new InvalidOperationException($"Preset '{loadout.Name}' already exists; replace it explicitly.");
+ throw new InvalidOperationException($"Loadout '{loadout.Name}' already exists; replace it explicitly.");
return cache.Commit(batch =>
{
batch.Expect(key, existing);
@@ -104,7 +136,8 @@ public static class Loadouts
// WeaponGroupCount weapon groups, and every group index names a weapon slot. Every failure is listed; on any failure
// nothing is returned and the zone and cache are unchanged, but a failed fit has already drawn from
// itemManager.Random. How each design is built is Resolve's rule. A design-only slot is chosen by choose; a game
- // spawner is to pass LoadoutGenerator.ChooseProduct, with no fallback to any manufacturer; RunStart does. The hull's type
+ // spawner is to pass LoadoutGenerator.ChooseProduct, with no fallback to any manufacturer; RunStart does, and so does the
+ // boss in ZoneGenerator, which is built here too from its faction's Loadout (Loadouts.BossOf). The hull's type
// picks the entity: a ship hull builds a Ship, a turret hull an OrbitalEntity with no orbit, which stays where it is
// put; a station hull is refused. The entity always gets exactly WeaponGroupCount groups; a loadout with fewer is
// padded with empty groups. A failed materialization leaves any minted lots in the live ledger; they are not roots,
@@ -119,7 +152,7 @@ public static class Loadouts
var hullBuild = Resolve(itemManager, loadout.Hull, loadout.HullProduct, choose, "hull", failures, quality);
var hullData = cache.Get(loadout.Hull);
if (hullData is { HullType: HullType.Station })
- failures.Add($"hull: {hullData.Name} is a station hull; a preset builds a ship or a turret");
+ failures.Add($"hull: {hullData.Name} is a station hull; a loadout builds a ship or a turret");
var slotBuilds = loadout.Slots.Select(slot => Resolve(itemManager, slot.Design, slot.Product, choose, Cell(slot), failures, quality)).ToArray();
var groups = loadout.WeaponGroups ?? new int[0][];
var groupCount = itemManager.GameplaySettings.WeaponGroupCount;
diff --git a/Assets/Scripts/ServerShared/ZoneGenerator.cs b/Assets/Scripts/ServerShared/ZoneGenerator.cs
index c0c8cf16f..80ec0d372 100644
--- a/Assets/Scripts/ServerShared/ZoneGenerator.cs
+++ b/Assets/Scripts/ServerShared/ZoneGenerator.cs
@@ -397,17 +397,28 @@ public static class ZoneGenerator
}
// A boss zone gets its faction's boss whether or not the zone is ambient: the boss is the run's goal, not
- // wandering traffic. RunGoal reads the mark; nothing else decides whether a boss lives.
+ // wandering traffic. A boss flies its faction's fixed Loadout, never generated gear (ruling boss-gear-is-a-loadout);
+ // ChooseProduct only picks the brand of a design-only slot. RunGoal reads the mark; nothing else decides whether a
+ // boss lives.
foreach (var bossFaction in galaxy.BossZones.Where(entry => entry.Value == galaxyZone).Select(entry => entry.Key))
{
- var boss = GetLoadoutGenerator(bossFaction).GenerateBossLoadout();
- if (boss == null)
+ var loadout = Loadouts.BossOf(itemManager.ItemData, bossFaction, out var refusal);
+ if (loadout == null)
{
- itemManager.Log($"No boss could be generated for {bossFaction.Name}; its boss zone has no boss to kill!");
+ itemManager.Log($"{bossFaction.Name} has no boss: it {Loadouts.Describe(refusal)}.");
continue;
}
- boss.Boss = true;
- pack.Entities.Add(boss);
+ var failures = new List();
+ var boss = Loadouts.Materialize(itemManager, null, loadout, GetLoadoutGenerator(bossFaction).ChooseProduct, failures);
+ if (boss == null)
+ {
+ itemManager.Log($"{bossFaction.Name}'s boss Loadout {loadout.Name} cannot be built: {string.Join("; ", failures)}");
+ continue;
+ }
+ boss.Faction = bossFaction;
+ var bossPack = EntitySerializer.Pack(boss);
+ bossPack.Boss = true;
+ pack.Entities.Add(bossPack);
pack.BossSpawned = true;
}
From e71667aab107c69ef5d01bfb026668e626974751 Mon Sep 17 00:00:00 2001
From: Meta Vens
Date: Sun, 18 Oct 2026 18:25:46 +0200
Subject: [PATCH 025/444] AetherDb loadout-commit; factions and loadout show
the derived boss Loadout
loadout-commit resolves every name exactly, test-materializes, then Loadouts.Commit. factions prints
BossOf's Loadout (none / NO LOADOUT); loadout no longer generates a Boss hull.
Co-Authored-By: Claude Sonnet 5.5
---
tools/AetherDb/LoadoutCommitCommand.cs | 124 +++++++++++++++++++++++++
tools/AetherDb/Program.cs | 22 +++--
2 files changed, 140 insertions(+), 6 deletions(-)
create mode 100644 tools/AetherDb/LoadoutCommitCommand.cs
diff --git a/tools/AetherDb/LoadoutCommitCommand.cs b/tools/AetherDb/LoadoutCommitCommand.cs
new file mode 100644
index 000000000..1237e834a
--- /dev/null
+++ b/tools/AetherDb/LoadoutCommitCommand.cs
@@ -0,0 +1,124 @@
+/* This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
+ * file, You can obtain one at https://mozilla.org/MPL/2.0/. */
+
+using System;
+using System.Collections.Generic;
+using System.IO;
+using System.Linq;
+using CultMath;
+using GameCult.Caching;
+
+// loadout-commit [,,,[@]]... [--group ,...]... [--replace]
+// (aetheria-release, cut boss-hull-derived; ruling boss-loadouts-agent-authored): writes one Loadout record into
+// GameData/Aetheria.cc through Loadouts.Commit, the only writer. Every name is resolved exactly (one hull product, one design per
+// slot, one product where a slot names one, a rotation by its ItemRotation name), then the loadout is built once on a throwaway
+// ItemManager with Loadouts.Materialize, so a slot that does not fit or a weapon group that names a non-weapon is refused here.
+// Any refusal happens before Commit: the catalog stays byte-identical. A refusal names the field and the slot, never the value
+// it was given.
+public static class LoadoutCommitCommand
+{
+ private const string Usage = "usage: loadout-commit [,,,