From 92d6153c2e60120233afe4e02e94214be4d4de87 Mon Sep 17 00:00:00 2001
From: Meta Vens
Date: Sun, 18 Oct 2026 16:28:42 +0200
Subject: [PATCH 001/429] 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/429] 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/429] 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/429] 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/429] 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/429] 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/429] 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/429] 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/429] 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 b19d6add89a086690e6080b43369f5b7e8b0254b Mon Sep 17 00:00:00 2001
From: Meta Vens
Date: Sun, 18 Oct 2026 17:51:50 +0200
Subject: [PATCH 010/429] 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 49853d3166bdeaaf1f784d447501eeff62efcf15 Mon Sep 17 00:00:00 2001
From: Meta Vens
Date: Sun, 18 Oct 2026 17:58:51 +0200
Subject: [PATCH 011/429] 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 012/429] 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 013/429] 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 014/429] 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 015/429] 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 016/429] 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 017/429] 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 018/429] 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 de3ecd9bb03e92aa38923415551a19c3a1527b52 Mon Sep 17 00:00:00 2001
From: Meta Vens
Date: Sun, 18 Oct 2026 18:42:44 +0200
Subject: [PATCH 019/429] Trojan swarm tests: host class, rock side parity,
width and arc reach
Stryker survivors on the generator showed the host-mass gate, the side of each
rock and the width/arc scale were unpinned. Builds.
Co-Authored-By: Claude Sonnet 5.5
---
tests/Aetheria.Shared.Tests/TrojanSwarmTests.cs | 10 +++++++++-
1 file changed, 9 insertions(+), 1 deletion(-)
diff --git a/tests/Aetheria.Shared.Tests/TrojanSwarmTests.cs b/tests/Aetheria.Shared.Tests/TrojanSwarmTests.cs
index 6691d6380..8500ff983 100644
--- a/tests/Aetheria.Shared.Tests/TrojanSwarmTests.cs
+++ b/tests/Aetheria.Shared.Tests/TrojanSwarmTests.cs
@@ -44,22 +44,28 @@ public class TrojanSwarmTests
using var zones = new ChildrenWithinReachTests.Zones(First, Last, null);
var s = zones.Settings;
int swarms = 0, l4 = 0, l5 = 0;
- var drift = 0f;
+ float drift = 0f, widest = 0f, longest = 0f;
foreach (var (seed, zone) in zones.All)
foreach (var (host, rail, swarm) in Swarms(zone))
{
swarms++;
var d = host.Data.Distance;
+ // The host is a planet or a gas giant: heavier than a moon, no heavier than a sun.
+ var hostMass = zone.Planets.Values.Where(b => !(b is AsteroidBeltData) && zone.Orbits[b.Orbit.Key] == host).Select(b => b.Mass).Single();
+ Assert.True(hostMass > s.PlanetMass && hostMass <= s.SunMass, $"seed {seed}: a swarm host of mass {hostMass}");
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}");
+ widest = Math.Max(widest, Math.Abs(rock.Distance / d - 1));
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.Equal(i % 2 == 0, side > 0); // even rocks round L4 (a sixth of a turn ahead), odd round L5
+ longest = Math.Max(longest, Math.Abs(at0 - side));
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)));
}
@@ -68,6 +74,8 @@ public class TrojanSwarmTests
_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(widest > s.TrojanHalfWidth / 2, $"the widest rock sits {widest} off its rail, half-width {s.TrojanHalfWidth}");
+ Assert.True(longest > s.TrojanArc / 4, $"the farthest rock sits {longest} turns from its point, half-arc {s.TrojanArc / 2}");
Assert.True(drift < 1e-3f, $"a swarm rock drifts {drift} turns against its host in an hour");
}
From beb733f4b50f556012983d7bb60d43760721c891 Mon Sep 17 00:00:00 2001
From: Meta Vens
Date: Sun, 18 Oct 2026 18:46:05 +0200
Subject: [PATCH 020/429] Census role gaps become RoleGaps, so a bin skipped by
the census has a test; a bin authored on another line is refused
Co-Authored-By: Claude Sonnet 5.5
---
.../ProductTiersCommandTests.cs | 7 ++++
.../RolesBackfillCommandTests.cs | 30 ++++++++++++++++
tools/AetherDb/Program.cs | 34 ++++++++++++-------
3 files changed, 58 insertions(+), 13 deletions(-)
diff --git a/tests/Aetheria.Shared.Tests/ProductTiersCommandTests.cs b/tests/Aetheria.Shared.Tests/ProductTiersCommandTests.cs
index 856cca7dc..2899737e0 100644
--- a/tests/Aetheria.Shared.Tests/ProductTiersCommandTests.cs
+++ b/tests/Aetheria.Shared.Tests/ProductTiersCommandTests.cs
@@ -126,6 +126,13 @@ public sealed class ProductTiersCommandTests : IDisposable
Refused(new ProductLinesCommand.TierRow("Sparrow", "Goldcrest", .5f, "x"), "already belongs");
}
+ [Fact]
+ public void A_bin_already_authored_on_another_line_is_refused()
+ {
+ Assert.Equal(0, ProductLinesCommand.Run(Apply, _root));
+ Refused(new ProductLinesCommand.TierRow("Spotlight", "Goldcrest", .67f, "x"), "already belongs");
+ }
+
[Fact]
public void A_line_the_catalog_does_not_hold_is_refused()
{
diff --git a/tests/Aetheria.Shared.Tests/RolesBackfillCommandTests.cs b/tests/Aetheria.Shared.Tests/RolesBackfillCommandTests.cs
index 1f0f974f5..31152b63c 100644
--- a/tests/Aetheria.Shared.Tests/RolesBackfillCommandTests.cs
+++ b/tests/Aetheria.Shared.Tests/RolesBackfillCommandTests.cs
@@ -239,6 +239,36 @@ public sealed class RolesBackfillCommandTests : IDisposable
finally { db.Cache.Dispose(); }
}
+ // The census reports a seller that authors no spread for a role of its design as a gap, and never a bin: a bin reads its line's.
+ [Fact]
+ public void The_census_reports_a_roleless_seller_and_never_a_bin()
+ {
+ 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 sellerName = seller.Name;
+ seller.Roles = new List();
+ db.Cache.Commit(batch =>
+ {
+ batch.Upsert(typeof(FactionProductData), seller, db.Cache.RefOf(seller).Key);
+ batch.Upsert(typeof(FactionProductData), new FactionProductData
+ {
+ Name = "Binned " + sellerName, Design = seller.Design, Manufacturer = seller.Manufacturer,
+ Bin = new ProductBin { Line = db.Cache.RefOf(seller), Floor = 0f }
+ });
+ });
+ db.Cache.Dispose();
+
+ db = AetherDb.Open(root: _root);
+ try
+ {
+ var gaps = Program.RoleGaps(db.Cache, db.Cache.GetAll().ToArray(), db.Cache.GetAll().ToArray()).ToArray();
+ Assert.Contains(gaps, gap => gap.Product.Name == sellerName && gap.Missing.Length == 2);
+ Assert.DoesNotContain(gaps, gap => gap.Product.Name == "Binned " + sellerName);
+ }
+ 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()
diff --git a/tools/AetherDb/Program.cs b/tools/AetherDb/Program.cs
index d88718817..7a28b1aa0 100644
--- a/tools/AetherDb/Program.cs
+++ b/tools/AetherDb/Program.cs
@@ -129,25 +129,33 @@ public static class Program
// or a product added after roles landed), so every sold, role-bearing design is checked against every
// product that actually sells it, not only the ones this run's roles-migrate happened to touch.
var roleGaps = 0;
- foreach (var item in items.Where(i => i.Roles != null && i.Roles.Count > 0))
+ foreach (var (product, item, missing) in RoleGaps(db.Cache, items, products))
{
- var design = db.Cache.RefOf(item).Key;
- // 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));
- var missing = item.Roles.Select(r => r.Name).Where(r => !covered.Contains(r)).ToArray();
- if (missing.Length == 0) continue;
- roleGaps++;
- Console.WriteLine($" GAP: {product.Name} sells {item.Name} but authors no spread for: {string.Join(", ", missing)}" +
- " -- a newly minted lot rolls the ProductRole default (mean .5) for these, not this maker's own quality");
- }
+ roleGaps++;
+ Console.WriteLine($" GAP: {product.Name} sells {item.Name} but authors no spread for: {string.Join(", ", missing)}" +
+ " -- a newly minted lot rolls the ProductRole default (mean .5) for these, not this maker's own quality");
}
Console.WriteLine($"\n{roleGaps} (product, role) pairs where a sold, role-bearing design's product authors no matching spread");
return 0;
}
+ // Every seller of a role-bearing design that authors no spread for one of its roles. A bin authors no spread: it reads its
+ // line's (ProductBin), so it is never a gap.
+ internal static IEnumerable<(FactionProductData Product, EquippableItemData Design, string[] Missing)> RoleGaps(
+ GameCult.Caching.CultCache cache, IEnumerable items, IEnumerable products)
+ {
+ foreach (var item in items.Where(i => i.Roles != null && i.Roles.Count > 0))
+ {
+ var design = cache.RefOf(item).Key;
+ foreach (var product in products.Where(p => p.Bin == null && p.Design.Key.Equals(design)))
+ {
+ var covered = new HashSet((product.Roles ?? new List()).Select(r => r.Role));
+ var missing = item.Roles.Select(r => r.Name).Where(r => !covered.Contains(r)).ToArray();
+ if (missing.Length > 0) yield return (product, item, missing);
+ }
+ }
+ }
+
// Cut 7 (docs/stats-and-power-cut.md): "products author quality, designs author roles." Zero designs
// declared a role and zero products authored role quality before this command; every field->role table below
// is the one place that content is decided, so the operator reviews a legible table instead of hand-picking
From 9457e4c8f93e90fb5e6b6857667f7bd9d2f097f3 Mon Sep 17 00:00:00 2001
From: Meta Vens
Date: Sun, 18 Oct 2026 19:51:40 +0200
Subject: [PATCH 021/429] ProductBinTests: the search's 64-draw bound and its
stop at the first clearing draw, and the missing-line log, are pinned
Co-Authored-By: Claude Sonnet 5.5
---
.../Aetheria.Shared.Tests/ProductBinTests.cs | 25 ++++++++++++++++++-
1 file changed, 24 insertions(+), 1 deletion(-)
diff --git a/tests/Aetheria.Shared.Tests/ProductBinTests.cs b/tests/Aetheria.Shared.Tests/ProductBinTests.cs
index 44bbfe077..03762fdf2 100644
--- a/tests/Aetheria.Shared.Tests/ProductBinTests.cs
+++ b/tests/Aetheria.Shared.Tests/ProductBinTests.cs
@@ -130,6 +130,27 @@ public sealed class ProductBinTests : IDisposable
});
}
+ // The search draws at most 64 times, and stops at the first draw that clears the cutoff: the stream after it is what shows how
+ // many draws it took. An unreachable cutoff takes exactly 64, a deviation of 0 (draw equal to the cutoff) takes 1.
+ [Fact]
+ public void TheSearchDrawsAtMostSixtyFourTimesAndStopsAtTheFirstDrawThatClearsTheCutoff()
+ {
+ float AfterDraws(int draws)
+ {
+ var random = new CultMath.Random(11u);
+ for (var i = 0; i < draws; i++) random.NextGaussian(0, 1);
+ return random.NextGaussian(0, 1);
+ }
+
+ var unreachable = new CultMath.Random(11u);
+ Assert.Equal(40f, unreachable.NextGaussianAbove(0, 1, 40f));
+ Assert.Equal(AfterDraws(64), unreachable.NextGaussian(0, 1));
+
+ var immediate = new CultMath.Random(11u);
+ Assert.Equal(.5f, immediate.NextGaussianAbove(.5f, 0, 0));
+ Assert.Equal(AfterDraws(1), immediate.NextGaussian(0, 1));
+ }
+
// 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()
@@ -137,6 +158,7 @@ public sealed class ProductBinTests : IDisposable
var line = Line();
_items.Random = new CultMath.Random(20261018u);
var lots = Mint(line, 5);
+ Assert.Empty(_log);
var assemblies = new[] { .59560823f, .68289334f, .57563853f, .5639112f, .59828424f };
var fills = new[] { .6311789f, .5835483f, .47641385f, .46416125f, .45124212f };
for (var i = 0; i < 5; i++)
@@ -170,6 +192,7 @@ public sealed class ProductBinTests : IDisposable
_cache.Remove(_cache.RefOf(line).Key);
var lots = Mint(bin, 5);
Assert.Equal(5, lots.Length);
- Assert.NotEmpty(_log);
+ Assert.Equal(5, _log.Count);
+ Assert.All(_log, message => Assert.False(string.IsNullOrWhiteSpace(message)));
}
}
From 0ed0605aa5a88af20af27ebe200c0814d8a908ca Mon Sep 17 00:00:00 2001
From: Meta Vens
Date: Sun, 18 Oct 2026 20:13:03 +0200
Subject: [PATCH 022/429] Pin the run seed's edges: AI dice stay on CombatSeed,
full-width seed, refusal names its field, refusal test cannot hang
- ConsumableAiTests.ThePilotsDiceDoNotMoveWithTheRunSeed: sample offset and patrol circuit equal at different run seeds.
- RunSaveTests.TheRunSeedRidesTheSave uses a seed with the top bit set.
- RunStartTests.AGeneratedGalaxyRefusesSeedZero builds from authored settings (small ZoneCount) so a constructor that stops refusing fails by assertion, and pins ParamName and no ActualValue.
Co-Authored-By: Claude Sonnet 5.5
---
.../ConsumableAiTests.cs | 31 +++++++++++++++++--
tests/Aetheria.Shared.Tests/RunSaveTests.cs | 10 +++---
tests/Aetheria.Shared.Tests/RunStartTests.cs | 22 ++++++++++---
3 files changed, 50 insertions(+), 13 deletions(-)
diff --git a/tests/Aetheria.Shared.Tests/ConsumableAiTests.cs b/tests/Aetheria.Shared.Tests/ConsumableAiTests.cs
index 6ad889fda..2f5863958 100644
--- a/tests/Aetheria.Shared.Tests/ConsumableAiTests.cs
+++ b/tests/Aetheria.Shared.Tests/ConsumableAiTests.cs
@@ -50,6 +50,7 @@ public sealed class ConsumableAiTests : IDisposable
public GearData HeavyGun;
public CultRecordRef Maker;
public float Optimum;
+ public CultRecordKey[] Circuit;
public void Store(ConsumableItemData design) =>
Assert.True(Hold.TryStore((ItemInstance) Items.CreateInstance(Items.CreateLot(design, Maker, .5f))));
@@ -97,7 +98,7 @@ public sealed class ConsumableAiTests : IDisposable
}
};
- private Scene Build(bool carryVent = false, bool carryOverdrive = false, bool carryDecoy = false, int stackableVents = 0, float effective = 500f)
+ private Scene Build(bool carryVent = false, bool carryOverdrive = false, bool carryDecoy = false, int stackableVents = 0, float effective = 500f, uint? runSeed = null, int orbits = 1)
{
var cache = AetheriaStores.Open(Path.Combine(_root, Guid.NewGuid().ToString("N") + ".cc"), catalogWritable: true);
_caches.Add(cache);
@@ -150,7 +151,10 @@ public sealed class ConsumableAiTests : IDisposable
var maker = cache.RefOf(cache.GetByName("Maker"));
EquippableItem Make(string name) where T : CraftedItemData =>
(EquippableItem) items.CreateInstance(items.CreateLot(cache.GetByName(name), maker, .5f));
- var zone = new Zone(items, new PlanetSettings(), new ZonePack(), new GalaxyZone { Name = "Test Zone", Owner = null }, null);
+ var galaxy = runSeed.HasValue ? RunSeedFixture.Continue(cache, "Test Zone", runSeed.Value) : null;
+ var zone = galaxy != null
+ ? new Zone(items, new PlanetSettings(), new ZonePack(), galaxy.Zones[0], galaxy)
+ : new Zone(items, new PlanetSettings(), new ZonePack(), new GalaxyZone { Name = "Test Zone", Owner = null }, null);
Ship NewShip(string gear)
{
var ship = new Ship(items, zone, Make("Skiff"), new EntitySettings())
@@ -174,8 +178,10 @@ public sealed class ConsumableAiTests : IDisposable
// The agent joins as the zone admits a piloted ship (its Minion and CombatState come from production); the
// locker is a gun ship nobody pilots.
// One fixed orbit far from the fight, before the agent joins: a pilot that is not engaged patrols it, as production's would.
- zone.Orbits.Add(new CultRecordKey("test-orbit"), new Orbit(new OrbitData { FixedPosition = float2(0, 5000) }));
+ for (var i = 0; i < orbits; i++)
+ zone.Orbits.Add(new CultRecordKey(orbits == 1 ? "test-orbit" : "test-orbit-" + i), new Orbit(new OrbitData { FixedPosition = float2(0, 5000) }));
zone.Admit(scene.Agent, piloted: true);
+ scene.Circuit = ((PatrolOrbitsTask) zone.Agents.Single().Task).Circuit;
zone.Admit(scene.Prey, piloted: false);
zone.Admit(scene.Locker, piloted: false);
// The flight is the only writer of the agent's Target: the agent senses the prey and holds a grudge on it, so the
@@ -356,6 +362,25 @@ public sealed class ConsumableAiTests : IDisposable
// The production path for the optimum: a ship the zone admits as piloted derives it from its gun's live stats
// before it needs it, and re-derives it when its loadout changes. The gun's effective range is 500 and the
// heavy gun's is 800, so the optimum moves with the weapons it has now.
+ // The run seed feeds drops and mines only (ruling run-seed-drops-only): the same pilot in the same zone samples its
+ // range with the same offset and draws the same patrol circuit at every run seed. Kills: the combat-sample dice or
+ // the patrol dice read DropSeed.
+ [Theory]
+ [InlineData(1u, 2u)]
+ [InlineData(0xC0FFEE42u, 0x2468ACE1u)]
+ public void ThePilotsDiceDoNotMoveWithTheRunSeed(uint first, uint second)
+ {
+ var a = Build(runSeed: first, orbits: 8);
+ var b = Build(runSeed: second, orbits: 8);
+ a.AssertInCombat();
+ b.AssertInCombat();
+ Assert.NotEqual(a.Zone.DropSeed, b.Zone.DropSeed);
+ Assert.Equal(a.Zone.CombatSeed, b.Zone.CombatSeed);
+ Assert.Equal(a.Optimum, b.Optimum);
+ Assert.Equal(4, a.Circuit.Length);
+ Assert.Equal(a.Circuit, b.Circuit);
+ }
+
[Fact]
public void ASpawnedShipsOptimumFollowsItsWeaponsAndNeverReadsZero()
{
diff --git a/tests/Aetheria.Shared.Tests/RunSaveTests.cs b/tests/Aetheria.Shared.Tests/RunSaveTests.cs
index 7e32c9928..456561d76 100644
--- a/tests/Aetheria.Shared.Tests/RunSaveTests.cs
+++ b/tests/Aetheria.Shared.Tests/RunSaveTests.cs
@@ -178,25 +178,25 @@ public sealed class RunSaveTests : IDisposable
using (var cache = Open())
{
var game = Game(cache);
- game.Seed = 0xC0FFEEu;
+ game.Seed = 0xC0FFEE42u;
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);
+ Assert.Equal(0xC0FFEE42u, 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);
+ Assert.Equal("Zone 0".StableHash() ^ SimulationDice.Mix(0xC0FFEE42u), 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);
+ Assert.Equal(0xC0FFEE42u, game.Seed);
RunSave.Commit(cache, game, zones, RunSave.Lots(cache));
}
using (var cache = Open())
- Assert.Equal(0xC0FFEEu, cache.GetGlobal().Seed);
+ Assert.Equal(0xC0FFEE42u, cache.GetGlobal().Seed);
// A save written before run-seed holds no seed, and continues on name-only drops.
using (var cache = Open())
diff --git a/tests/Aetheria.Shared.Tests/RunStartTests.cs b/tests/Aetheria.Shared.Tests/RunStartTests.cs
index 7c22bb911..4b181f67e 100644
--- a/tests/Aetheria.Shared.Tests/RunStartTests.cs
+++ b/tests/Aetheria.Shared.Tests/RunStartTests.cs
@@ -115,14 +115,26 @@ 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.
+ // One minter: a generated galaxy never reads the clock, so seed 0 is refused by both generation constructors, naming
+ // the field and echoing no input (no ActualValue). The settings are the authored ones: a galaxy that stops refusing must be able to
+ // finish generating, so the test fails by assertion rather than wedging in the generator.
[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));
+ var authored = AuthoredSettings.Load(FindRepoRoot());
+ var background = authored.Read("SectorBackgroundSettings");
+ var sector = authored.Read("SectorGenerationSettings");
+ sector.ZoneCount = 8;
+ var tutorial = authored.Read("TutorialGenerationSettings");
+
+ var main = Assert.Throws(() => new Galaxy(sector, background, _cache, _ => { }, null, 0));
+ var prelude = Assert.Throws(() =>
+ new Galaxy(tutorial, background, _cache, new PlayerSettings(), new DirectoryInfo(_root), _ => { }, null, 0));
+ foreach (var refusal in new[] { main, prelude })
+ {
+ Assert.Equal("seed", refusal.ParamName);
+ Assert.Null(refusal.ActualValue);
+ }
}
// A main-sector galaxy: each station's gear must come from manufacturers its faction can reach, the makers its
From cb1c093e84b90ea634a708a0eb6bf06ba30f0dce Mon Sep 17 00:00:00 2001
From: Meta Vens
Date: Sun, 18 Oct 2026 20:21:31 +0200
Subject: [PATCH 023/429] ConsumableAiTests: open the run store so a scene can
continue a run at a fixed seed
Co-Authored-By: Claude Sonnet 5.5
---
tests/Aetheria.Shared.Tests/ConsumableAiTests.cs | 2 +-
1 file changed, 1 insertion(+), 1 deletion(-)
diff --git a/tests/Aetheria.Shared.Tests/ConsumableAiTests.cs b/tests/Aetheria.Shared.Tests/ConsumableAiTests.cs
index 2f5863958..7478b3440 100644
--- a/tests/Aetheria.Shared.Tests/ConsumableAiTests.cs
+++ b/tests/Aetheria.Shared.Tests/ConsumableAiTests.cs
@@ -100,7 +100,7 @@ public sealed class ConsumableAiTests : IDisposable
private Scene Build(bool carryVent = false, bool carryOverdrive = false, bool carryDecoy = false, int stackableVents = 0, float effective = 500f, uint? runSeed = null, int orbits = 1)
{
- var cache = AetheriaStores.Open(Path.Combine(_root, Guid.NewGuid().ToString("N") + ".cc"), catalogWritable: true);
+ var cache = AetheriaStores.Open(Path.Combine(_root, Guid.NewGuid().ToString("N") + ".cc"), Path.Combine(_root, Guid.NewGuid().ToString("N") + ".run.cc"), catalogWritable: true);
_caches.Add(cache);
cache.Upsert(new VerseGrammar { Revision = 1 });
cache.Upsert(new Faction { Name = "Maker", ShortName = "MKR" });
From a40072d9ab8cf5fa4af63b930f158ee7faf8140f Mon Sep 17 00:00:00 2001
From: Meta Vens
Date: Sun, 18 Oct 2026 20:30:06 +0200
Subject: [PATCH 024/429] aetheria-release map: mark the stand-in section
superseded by ruling hull-art-communicates-form
Co-Authored-By: Claude Opus 5.5
---
docs/aetheria-release-map.md | 2 ++
1 file changed, 2 insertions(+)
diff --git a/docs/aetheria-release-map.md b/docs/aetheria-release-map.md
index 54699054e..b5e91ddaa 100644
--- a/docs/aetheria-release-map.md
+++ b/docs/aetheria-release-map.md
@@ -11271,6 +11271,8 @@ written for nobody.
## Stand-in hulls (SI)
+**Superseded 2026-10-18 by ruling `hull-art-communicates-form`:** the placeholder-look goal and the schematic-generated body below are set aside; agent-built hulls are low-poly models that communicate each ship's form and faction, beside hero assets. The probe's package path, anchors and replace-by-real-art lifecycle are evidence for the re-map; its visual grammar and cut sequence are not live.
+
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`.
From d28ce42df9aef7338a1c9b8165bd57ca8227ec09 Mon Sep 17 00:00:00 2001
From: Meta Vens
Date: Sun, 18 Oct 2026 20:37:22 +0200
Subject: [PATCH 025/429] Research: faction form language and role readability
for agent-built hulls, for ruling hull-art-communicates-form
Co-Authored-By: Claude Opus 5.5
---
...6-10-18-faction-form-language-prior-art.md | 130 ++++++++++++++++++
1 file changed, 130 insertions(+)
create mode 100644 docs/research/2026-10-18-faction-form-language-prior-art.md
diff --git a/docs/research/2026-10-18-faction-form-language-prior-art.md b/docs/research/2026-10-18-faction-form-language-prior-art.md
new file mode 100644
index 000000000..63ca31a2b
--- /dev/null
+++ b/docs/research/2026-10-18-faction-form-language-prior-art.md
@@ -0,0 +1,130 @@
+
+
+# Faction form language for agent-built hulls: prior art and internal evidence (Eyes facts file, 2026-10-18)
+
+Ruling served: `aetheria-release:ruling:hull-art-communicates-form`. Operator quote (abridged): agent-built hulls should communicate the ship's intended form; "A Zhestokost ammo tender shouldn't look the same as a Pirate fighter, and with this generate-from-schematic approach there's no way to differentiate factions visually"; nice low-poly models are acceptable "as long as we do have a couple of fully-produced hero assets to compare them with"; "I just don't want to gate hull *variety* on my own manual throughput". The ruling leaves mechanism and per-faction form language open, keeps raw Tripo output out, and supersedes `hull-programmer-art-reads-as-stand-in`.
+
+Extends, does not repeat, `F:\Projects\Aetheria\docs\research\2026-10-18-agent-built-hull-art-prior-art.md` (LLM 3D workflows, ship generators, low-poly style, mesh-probe runs).
+
+## A. How games specify faction visual language
+
+Evidence quality is uneven: the best primary sources are one CCP dev blog, one Stardock dev diary and two Valve papers; most others are search summaries. Few studios publish a written shape-vocabulary document; the public record is mostly dev diaries.
+
+### A1. EVE Online (CCP)
+
+- CCP dev blog, "Iconic Ships Reimagined - The Minmatar Rupture" (2017), https://www.eveonline.com/news/view/iconic-ships-reimagined-the-minmatar-rupture (fetched, answered by a summarizing model; paraphrase). Minmatar is specified by a shape vocabulary plus a construction idea: aggressive wedge front, high rear with a transom, chunky segmented plates with exposed structural beams and dark gaps, bulk made largely of empty space (reference: spaced armour on WWII vehicles), warm orange window glow, oversized engines. Caldari is the contrast: smooth, uniform, enclosed shells, few exposed electronics, sparse windows. Gallente is mentioned only as voluminous hull and structure. The post says the redesign risked overlapping Caldari because both used large flat shapes, which pushed Minmatar toward exposure and negative space. It says the redesign looked too monolithic from front and top, which led to angled gun-port covers. Tech 1 hulls stay near the original silhouette; Tech 2 may deviate more. It does not address legibility at small size.
+- Amarr and Gallente design language: not found in this pass (search returned only this post).
+
+### A2. Sins of a Solar Empire II (Stardock)
+
+- Vasari dev diary, https://www.stardock.com/games/article/533639/sins-ii-behind-the-scenes---designing-the-vasari-empire-pt-1 (fetched). No formal style guide described; consistency comes from an art-director brief ("rounded, blade-like forms"), convex armoured shapes tapering to points, ridged organic surfaces, segmented plating, curves set against sharp edges and glowing conduits, thrusters echoing spider weaponry, wasp/beetle warning markings. Thumbnail silhouettes are explored first, tailored per role (nimble attacker, heavy bruiser, massive support). Palettes vary by ship and are partly chosen to place team colours; no fleet-wide colour rule is stated.
+- Pirate dev diary, https://forums.sinsofasolarempire2.com/532947/sins-ii-behind-the-scenes-avast-ye-buccaneers-pirate-concepts-ahead (direct fetch refused, HTTP 403; content from search-summary): pirates repurpose stolen TEC hulls; spikes, studs and reinforced armour are the signature modifications, with shared styling keeping the fleet cohesive.
+- TEC and Advent design language: not found. Original Sins concept artists: not found.
+
+### A3. Starsector (Fractal Softworks)
+
+- Three "design types" (low tech, midline, high tech) are a gameplay-and-style axis: low tech is high armour with ballistic/missile mixes, midline mixed, high tech speed and shields (search-summary of https://starsector.wiki.gg/wiki/Ships and https://fractalsoftworks.com/tag/ship-design).
+- "Uniquifying the Factions, Part 1" (2022-03-18), https://fractalsoftworks.com/2022/03/18/uniquifying-the-factions-part-1 (direct fetch refused, HTTP 402; search-summary only): factions with the strongest identity are those that restrict themselves to one tech level or ship type for doctrinal reasons; a faction fielding much the same ships as others with small stat tweaks feels muddy. The summary is about gameplay identity; sprite-level shape rules were not found.
+- Modder-side sprite style guidance for Starsector: not found.
+
+### A4. Homeworld (Relic)
+
+- Fan-site claim (unverified, search-summary): Aaron Kambeitz designed the Taiidan ships and Rob Cunningham the Kushan; Homeworld 2's Vaygr and Hiigaran differ more than the original's Kushan and Taiidan. https://www.parkablogs.com/index.php/content/book-review-art-of-homeworld , https://en.wikipedia.org/wiki/Homeworld_2.
+- The Parka review (fetched) names the races and says the book (The Art of Homeworld) holds the design commentary but gives no principles or silhouettes. A fan review is quoted by search as calling the ships "utilitarian, asymmetrical". The design commentary itself was not located online.
+
+### A5. Other titles
+
+- Ashes of the Singularity II dev journal #8 (Stardock): each faction gets its own materials, shaders and animation under shared lighting and camera distances; the UEF leans on blocky silhouettes and low-reflectivity weathered surfaces; rationale is that hundreds of units fill the screen so strong silhouettes and consistent materials help read the crowd; blocks at distance, detail up close. https://www.stardock.com/games/article/539126/ashes-ii-dev-journal-8-the-actors-of-war-on-stage-pt-1 (search-summary). Stardock's "Designing Alien Units": Seraphim and Substrate separated by palette and weapon effects, https://www.stardock.com/games/article/487404/dev-journal-designing-alien-units (search-summary).
+- Battlefleet Gothic: described by fan/review sources as Imperial cathedral ships with arches, spires and an aquila prow, broadside macrocannons, versus Ork hulks and roks; official design material not located. https://goonhammer.com/battlefleet-gothic-part-3-the-best-expansion-to-the-best-game-gw-ever-made , https://pcgamer.com/battlefleet-gothic-armada-unleashes-the-imperium (search-summary, secondhand).
+- Stellaris ship sets: modders select a graphical_culture/shipset and ship_kinds per species; ship sections (bow/mid/stern, "core") are the modular unit in community mods; base-game sets are the recommended modding templates. Steam Workshop comments (fan/anecdote): https://steamcommunity.com/sharedfiles/filedetails/comments/2866658484 (search-summary). No official shape-language material found.
+- Star Citizen: CIG describes visual language as a deliberate part of brand/manufacturer creation (an Inside Star Citizen episode, https://massivelyop.com/?p=293041, search-summary); the rules themselves were not found.
+- Elite Dangerous: no official per-manufacturer design language found; a forum thread (opinion) says older ships have atmospheric shaping and newer ones modern-sci-fi shaping, https://forums.frontier.co.uk/threads/thoughts-on-ship-shapes.639903 (search-summary, forum opinion).
+- FTL: not searched. Gap.
+
+### A6. How such languages are specified, as the sources show them
+
+- Shape vocabulary stated as a short list of forms per faction (EVE Minmatar: wedge front, transom, exposed beams; Vasari: blade-like convex tapers; Ashes UEF: blocky). Contrast against a neighbouring faction is used as a rule (EVE Minmatar vs Caldari).
+- Construction idea or reference object (EVE: WWII spaced armour; Vasari: spider and beetle motifs).
+- Modification vocabulary for scavenged/derived factions (Sins pirates: spikes, studs, reinforced armour on captured hulls).
+- Gameplay-axis style tiers (Starsector design types) carry visual style partly through the role mix.
+- Palette as a separate layer from shape (Sins: per-ship palettes tied to team colour placement; Stardock aliens: palette plus effects).
+
+## B. Role readability by silhouette and proportion
+
+- Valve, "Illustrative Rendering in Team Fortress 2" (Mitchell, Francke, Eng, NPAR 2007) and "Stylization with a Purpose" (GDC 2008): class differentiation is the stated reason for the style; each class is designed to have a distinct silhouette that stays identifiable without lighting, built from body proportions, weapons and clothing; value contrast and simple forms carry readability. https://cdn.fastly.steamstatic.com/apps/valve/2007/NPAR07_IllustrativeRenderingInTeamFortress2_Slides.pdf , https://cdn.fastly.steamstatic.com/apps/valve/2008/GDC2008_StylizationWithAPurpose_TF2.pdf , https://wiki.teamfortress.com/wiki/Illustrative_Rendering_in_Team_Fortress_2 (search-summary; PDFs not opened). These concern characters, not ships; no ship-specific equivalent was found.
+- Sins II Vasari diary (A2): silhouette thumbnails per role before detail.
+- Ashes II (A5): silhouette plus consistent material for crowd reading.
+- Concept-art practice (anecdote, search-summary): a trading-card capital-ship sheet began as black silhouettes and aimed at readability at fleet scale, https://jordan_taylor_arts.artstation.com/projects/V2rn95 ; a concept-art overview says fighters tend to show aggressive silhouettes and weapons, transports large cargo holds and robust structure, https://try.gocheetah.com/?p=12134 (SEO page, low quality); a Frontier forum thread observes a recurring spearhead-with-split-prongs capital-ship shorthand across games, https://forums.frontier.co.uk/threads/capital-ship-design.324746 (forum opinion).
+- Blog guidance (opinion, search-summary): players recognise threats by outline before colour or interior detail; most readability failures are value problems, check in greyscale: https://bugnet.io/blog/readable-game-art-why-clarity-beats-detail , https://rocketbrush.com/blog/shape-language-in-game-character-design-how-to-make-characters-readable-and-consistent.
+- EVE Rupture post (A1): front/top views read as monolithic until angled gun-port covers broke them up.
+- No GDC talk specific to spaceship role silhouettes was located.
+
+## C. Producing many consistent variants from a style
+
+- Kit/part libraries: commercial kitbash packs show the pattern at the asset level (snap/stack pieces; separate bodies, wings and weapons; colour/wear variants for faction), https://superhivemarket.com/products/600-hard-surface-piece-kitbash-set , https://superhivemarket.com/products/130-sci-fi-hard-surface-kitbash-set (vendor pages, search-summary). One vendor notes the packs target offline renders and may need adjustment for gameplay. This is marketing, not studio practice.
+- Section-based assembly: Stellaris community mods use bow/mid/stern(core) section slots (A5, fan mods). Starsector/Starfield-style attach-point builders are covered in the earlier note (B2).
+- Procedural variation within a style: the earlier note records a1studmuffin/SpaceshipGenerator and Kinnear-Kaplan greebling. 3D-GPT's reported limit (diversity bounded by the procedural generator and prompt expressiveness, second-hand) is in that note. No No Man's Sky parts-library source was found (search returned only fan/mod pages).
+- Reference-conditioned generation: Sloyd's custom style takes a reference image as the style guide and a text prompt for the object; reported example: identical prompts rebuilt one dungeon in two moods by swapping the reference image; the article lists no failure modes. https://sloyd.ai/blog/how-to-style-consistent (fetched; vendor). A Virtuall guide recommends separating fixed style rules ("constants") from per-asset variables, with turnarounds and orthographic views for 3D assets, https://virtuall.pro/blog/how-to-create-ai-game-assets-that-match-your-art-style (search-summary, vendor). Ludo.ai says it does not train on your art, so a locked house style rests on reference images (search-summary).
+- LLM or agent loops that keep a style via a style sheet plus a parts library plus render-and-compare against a hero asset: none located. Searches returned no source describing such a loop; the nearest are render-and-critique loops for Blender code (earlier note A1/A3) and BlenderGym's finding that verifier scale helps.
+- Failure modes reported: sameness from a bounded generator (3D-GPT, second-hand); Starsector's "same ships with tweaks feels muddy" (A3, search-summary); EVE's Minmatar/Caldari overlap when both leaned on large flat shapes (A1, fetched); style carried only by colour/markings over shared geometry is what the kit-pack vendors describe (A, vendor). Internal measured evidence on style leakage and drift is in the Internal section (Prompt lab rule 3).
+
+## Internal
+
+### I1. Stated aesthetics of the factions that matter
+
+Source for shapes, finish, palette: `F:\Projects\AetheriaLore\Aetheria\Brainstorming\Faction Flavor and Visual Identity.md` (sections by faction; "Silhouette and construction" and "Materials, color, and markings" paragraphs). Doctrine and play quirks: `F:\Projects\AetheriaLore\Aetheria\Game Design\Faction Play.md`. Lore: `F:\Projects\AetheriaLore\Aetheria\Worldbuilding\Pre-Elysium\Factions\Powers\...`. Per-faction prompt-ready blocks and registers: worktree `F:\Projects\AetheriaLore-wt-ship-language\Aetheria\Brainstorming\Ship Design Language.md` (branch eureka/ship-language-registers, status "brainstorming, not canon"; later rules "agent-promoted, pending operator review").
+
+Cast (rulings `aetheria-release:ruling:demo-cast`, `demo-content-bar`, `demo-start-hull-longinus`): Pirates allied; Zhestokost antagonist and boss faction; Lucent and AU featured neutrals; "none of the four has authored ship hulls today"; the player starts in an existing brand hull (Longinus). Miss Terri's is not in the demo cast.
+
+Per faction, brief:
+- **Zhestokost** (heavy ballistics, militarised Martian state). Silhouette: "Low, broad hulls built from stepped rectangular masses"; thick overlapping armour around recessed machinery; barrels, ammunition feeds, towing points, replaceable plates "remain easy to identify"; weight from deep seams, not surface clutter. Finish: rough cast metal, dark steel, ceramic armour; charcoal, iron gray, dull oxide red, cream serial numbers, block lettering. Identity "should survive in silhouette without relying on red paint or political symbols." Manufacturing: lore says advantage comes from coordinating foundries, stockpiles, supply routes and repair crews (`Zhestokost.md` line 35). Play: warships fly in a column around an ammunition tender and fall back to rearm (`Faction Play.md` Zhestokost quirk; a "Quartermaster (tender)" prompt exists in Bench 2, per `Ship Prompt Bench 3.md` line 36). Register: serious.
+- **Pirate Coalition** (allied). "An identifiable donor hull with asymmetrical additions, cut-down modules, adapted hardpoints, exposed cable routes, and bolted salvage plates. Each major addition has a plausible mounting and supply path." Finish: several manufacturer finishes interrupted by primer, bare welds, heat discolouration, a crew accent colour, crossed-out serials, emoji marks, repeated crew markings. Lore: loose network of crews, "captured industrial components", salvage, yards in hidden docks (`Pirate Coalition.md`). Doctrine: want cargo not death, hit drives (`Faction Play.md` line ~337). Register: Pirate, "wild and maximalist: a stolen donor hull piled with additions"; Bench 3 pirate prompts are built on named stolen donors (Welcome Mat from an AU miner, Jumble Sale from an R&D wedge). Needs a Faction record (`Faction Play.md` line 335; ruling demo-cast "need a catalog record").
+- **Lucent Media** (spectacle). Compact central body in overlapping shells, swept prongs, upflared rear fins, "detached-looking side plates held on visible supports", deep openings exposing a dark chassis; gaming-mouse vocabulary (palm covers, ribs, knurled wheels). Palette by component: golden yellow, white or pale lavender, cobalt blades, red-orange tips, navy interior, teal pods. Concept anchor: `D:/Google Drive/Gamecult/Aetheria/Concepts/Ships/lucent_media/transparent_03.png` (not read; outside the repos). Play: one headliner duels while others hold; outline changes when radiator shutters open.
+- **Aeronautics Unlimited** (vanilla all-rounder). "Cylinders, rounded rectangles, simple arcs, and compact wedges combined into balanced working machines"; tool sockets, lifting points, trusses, removable modules; chalk white, slate blue, bare aluminium, ochre safety panels. Lore: builds shipyards, habitats, extraction, salvage; works with Orbital Forge (`Aeronautics Unlimited.md` lines 1, 29). Play: haulers flee, escorts fire dumbfire swarms. Hulls named "Jason" in loadout notes.
+- **Finch Cybernetics**. "Continuous, tapered forms with the fit and articulation of a joint or sensory organ"; flush apertures, recessed seams; pearl ceramic, satin titanium, smoky membranes, celadon indicators; play: fragile shadowing ships.
+- **Rossum & Douglas ("R&D")**. "The flying wedge": shallow triangular/trapezoidal hull, blunt nose, wide engine face, flat dorsal planes, bevelled edges, small recessed windows; range scales the same outline longer, wider or thicker; light gray, blue-gray, navy, off-white with one stripe. Intended to read as "thoroughly unremarkable production ship".
+- **NiteLife Energy**. Full flowing volumes interrupted by crisp slots, flat collars, abrupt cuts, docking reservoirs; midnight blue, petroleum teal, lime. Distinguished from Finch: Finch is continuous, NiteLife deliberately interrupts curves.
+- **Aya Collective**. Rounded protective frames around replaceable cells, branching fluid paths, accessible couplings; terracotta, cream, deep green, charcoal, copper; supplied in part by Orbital Forge yards.
+- **Megiddo**. Nested protective volumes, shutters, separate service sections; blue-black, warm pale ceramic, aged metal, silver inlays. No Faction record (`docs/content-batch-one.md` section 9, per follow-up `content-batch-one-reconcile`).
+- **Alakrita**. Long creased bodies, swept blades, thin layered fins, aligned engines; ivory lacquer, wine red, black glass, bronze/gold inlay. Prestige supplier under Lucent. Its Longinus and LonginusX hulls exist (follow-up `demo-cast-hulls`).
+- **Orbital Forge**. No entry in Faction Flavor; mentioned as an independent yard supplier (Aya line 458; AU lore line 29). Its own lore page `Pre-Elysium\Factions\Powers\Minor\Orbital Forge.md` was not read. Cavity-building and salvage methods through open standards (AU page).
+- Others in Faction Flavor: Sol Dominion (axial spine, repeated module bands), Cryonix, Lightsail, Cetacean Navigators, Adrasteia, Ewan Hart, Death Monkey Explosives (own intact hull with exposed serviceable machinery), Miss Terri's, Corriedales, Framgång.
+
+Registers (Ship Design Language.md "Registers" table): Corriedales, Miss Terri's, Framgång high whimsy; Lightsail, Ewan Hart, Cetacean whimsy turned up; Lucent spectacle; Zhestokost, AU, Finch, R&D, NiteLife, Alakrita, Adrasteia, Sol Dominion, Cryonix, Aya, Megiddo serious; Pirates wild; DME reserved.
+
+### I2. Prompt-lab findings on keeping a faction read (internal measured evidence)
+
+From `Ship Design Language.md` "Rules From the Prompt Lab, Rounds 5 to 8" and Bench 4 (24 FLUX 3 renders, agent-promoted, pending operator review):
+- Rule 3: faction blocks carry livery and register only; shape language in a faction block "leaks onto every hull of that faction" (18 renders, 10 ships, 7 factions held the faction read on livery-only blocks). Failure: Lucent's identity lives in shapes, so Key Light went generic with motifs cut (r7a) and recovered when the ship prompt carried the gaming-mouse layout (r8a).
+- Rule 4: wear belongs to the faction block (16 renders, 4 factions).
+- Rule 1: vacuum hulls have no aero surfaces (21 of 22 renders held); a Pirate racer donor with no other identity grew a wing; livery identity fixed it on the next four.
+- Rule 2: weapons appear only where named; named counts drifted until each gun was given a position.
+- Craft rules for image-to-3D in `Faction Ship Concept Prompts.md` ("Prompting for Image-to-3D"): one familiar object as analogy supplies layout ("Headliner and Quiet Sense worked"); thick, closed, symmetric; four or fewer counts, each tied to a place; three or four colour zones each tied to a part.
+- Global operator-side facts: the operator adds thruster ports, attitude jets and radiators herself (`Ship Design Language.md`, "Thrust is named only where the concept needs it").
+These measure image-model prompts, not mesh authoring; no render-and-compare against a mesh hero asset has been run in this repo.
+
+### I3. Existing meshes and possible hero assets
+
+Shipped in the Unity project (`F:\Projects\Aetheria\Assets\Models\`, sizes from `ls`): `Longinus.fbx` 1.1 MB (Alakrita; hulls Longinus and LonginusX per follow-up `demo-cast-hulls`), `Djinni.fbx` 1.9 MB (Rossum & Douglas per the same follow-up), `Turret.fbx` 0.36 MB (Zhestokost turret per the same follow-up), `Zenith_Blender.fbx` 18 MB and `Zenith_Pad.fbx` 0.7 MB (AU's Zenith, a station), `mine.fbx`, `primitives.fbx`, spheres, `CatmullGrid.fbx`, `Discobolus` statue, Plugins/LowPoly_AsteroidsPack (asteroids only). Per the follow-up, the only ships among these are Longinus, LonginusX and Djinni. Operator quote on Longinus: "Honestly I'd be happy to never see that Longinus model again" (ruling `demo-player-start-hull`, since superseded by `demo-start-hull-longinus` because the Djinni was too strong).
+Operator sources (`F:\Projects\Aetheria\Asset Sources\`, names and sizes only; .blend files not opened): `Alakrita1.blend` 96 MB, `Adrasteia1.blend` 24 MB (+ `Adrasteia1_ContourRoute.blend`, `adra1.png`), `Ships.blend` 47 MB, `Turret.blend`, `Zhestokost.psd` 44 MB and `Zhestokost.png` 8 MB, `Lucent.psd`, `Mouse.psd`, `mine.blend`, `Royal_Edges.blend`.
+Not in tree: `GameData/Mods` does not exist in the repo checkout; the release map says the operator's five packages are to arrive there (`aetheria-release-map.md` line 42, `demo-cast-hulls` r3, waiting on `mods-in-tree`). Keepers from the concept pipeline (ruling `hull-concept-keepers-2026-10-08`): Bison, Squatter, Oboz r8f, Shchit, Key Light r8a, Tattletale, each going Tripo, Blender, package; their faction assignment was not established here (Key Light is a Lucent concept per the gaming-mouse layout in rule 3). The Tripo fixture `F:\Projects\Aetheria\tools\blender\tests\fixtures\tripo-hull.glb` exists as the add-on's bake fixture (`addon-tripo-fixture`). Which of these are "fully produced" is not recorded in the files read; the operator has said she is "rebuilding the nonsense geometry and unwrapping anyway" for Tripo-derived hulls (`hull-paint-mask-texture`).
+
+### I4. Rulings in force bearing on this (titles; quoted where read)
+
+- `hull-art-communicates-form` (this ruling). Supersedes `hull-programmer-art-reads-as-stand-in` and, before it, `hull-programmer-art-stopgap`.
+- `hardpoints-and-floating-greebles`: "Two separate systems": hardpoints from one standard size set, molded into the hull by boolean; a library of floating greebles to decorate, independent of fitted gear.
+- `schematic-and-model-are-separate-spaces`: schematic authored for gameplay; "No schematic fact is derived from model geometry and no model geometry from schematic cells"; the two are linked only by mount name; no consistent scale between ships.
+- `shippable-part-bar` (brush-tree): "clean unbroken flow, part-coherent low-distortion UVs, one-segment bevels with face-weighted normals". `representation-per-hull`: subd limit surfaces for continuous bodies, primitives for mechanical hulls. `openscad-target`: trees authored in OpenSCAD-shaped text. `art-tools-not-cad` (2026-10-14): "drop the cad thread, let's stick to art tools". Titles only read for these three; bodies not opened.
+- `mesh-probe-exploit-not-measure`: "I'm not interested in measuring Opus's spatial reasoning performance, in interested in exploiting it."
+- `demo-cast`, `demo-content-bar` (one player-usable hull per cast faction plus role hulls "haulers, tenders, miners"; follow-up `demo-role-hulls` says no spec owns them and quantity comes from faction-play doctrine and roles), `content-bar-one-per-concept` (13 hulls for the full game), `boss-hulls-exclusive`, `package-carries-product` (hulls ship as first-party packages with a product record), `demo-material-scope` (baked maps, a mask, one hull shader), `hull-paint-mask-texture` (per-ship livery mask on UV0), `hull-runtime-materials`, `ship-authors-need-no-unity`, `hardpoint-work-held-for-walkthrough`.
+- Release map: section "Stand-in hulls (SI)" at `aetheria-release-map.md` line 11272 is marked superseded; "its package path, anchors and replace-by-real-art lifecycle are evidence for the re-map; its visual grammar and cut sequence are not live."
+
+## Known gaps
+
+- No primary source for Homeworld race design language (Art of Homeworld book not read), Amarr/Gallente (EVE), TEC/Advent (Sins), FTL, Star Citizen or Elite manufacturer rules, Battlefleet Gothic official material, Starsector sprite-level style rules.
+- Direct fetch refused for the Starsector blog and Sins II pirate post; both are search-summary only.
+- Valve TF2 PDFs and EVE/Stardock items beyond the fetched pages were not opened. Valve material is about characters, not ships.
+- No source found for the exact mechanisms the ruling asks about: per-faction kit libraries driven by an LLM, style sheet plus parts library plus render-and-compare against a hero asset, or measured style-drift rates in agent-built 3D. Searched with several phrasings.
+- Silhouette-at-small-size evidence for ships is mostly blog/forum opinion.
+- Internally: Orbital Forge, Pirate Coalition-as-catalog-faction, and Megiddo have no ship concepts in the files read; the Lucent concept PNG, keeper concept images, and Bench renders (`Renders` dir) were not viewed; which assets count as "fully produced" is not recorded; the operator's five packages are not in the tree; Zhestokost, Pirate and AU lore pages were only grepped, not read in full; `Ship Prompt Bench 3/4` entries for Pirates and Zhestokost tenders were seen by heading only.
+- Pointers marked search-summary were not verified at the primary page.
From 129e5ff4dc506737721e28c2e1a46445bf8a03d1 Mon Sep 17 00:00:00 2001
From: Meta Vens
Date: Sun, 18 Oct 2026 21:08:09 +0200
Subject: [PATCH 026/429] ProductWeight is the one weight of every design-only
draw; a line's bin shares are read once per pass
The all-weights-zero branch of ChooseProduct and the required-item fallback of RandomProducts weighed
every product 1, so a bin doubled its maker's presence there. Both now weigh by Share alone (the
preference drops out, the bins' shares do not). The sum of a line's bins' pass fractions is computed
once per weighting pass instead of scanning the product table per candidate.
Co-Authored-By: Claude Sonnet 5.5
---
.../Scripts/ServerShared/LoadoutGenerator.cs | 29 +++++++++----
.../ProductChoiceTests.cs | 42 +++++++++++++++++++
2 files changed, 62 insertions(+), 9 deletions(-)
diff --git a/Assets/Scripts/ServerShared/LoadoutGenerator.cs b/Assets/Scripts/ServerShared/LoadoutGenerator.cs
index 0206935fb..834e4dc7c 100644
--- a/Assets/Scripts/ServerShared/LoadoutGenerator.cs
+++ b/Assets/Scripts/ServerShared/LoadoutGenerator.cs
@@ -176,8 +176,9 @@ public class LoadoutGenerator
public (FactionProductData product, T design)[] RandomProducts(int count, float sizeExponent, Predicate filter = null, bool required = false) where T : CraftedItemData
{
var (available, preferManufacturers) = AvailableProducts(filter, required);
+ var shares = BinShares();
return available.WeightedRandomElements(ref Random, entry =>
- (preferManufacturers ? ProductWeight(entry.product) : 1) *
+ Weight(entry.product, shares, preferManufacturers) *
pow(entry.design.Shape.Coordinates.Length, sizeExponent) / // Prioritize larger items
pow(entry.design.Price, PriceExponent), // Penalize item price to a controllable degree
count);
@@ -255,22 +256,31 @@ public class LoadoutGenerator
// 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);
+ public float ProductWeight(FactionProductData product) => Weight(product, BinShares(), true);
+
+ // The one weight, with the share of the stock computed from shares (BinShares, once per weighting pass). A draw that has
+ // fallen back to any manufacturer weighs by share alone: the maker's preference no longer applies, its bins' shares still do.
+ private float Weight(FactionProductData product, Dictionary shares, bool preferManufacturers) =>
+ (preferManufacturers ? ManufacturerPreference(product.Manufacturer) : 1) * Share(product, shares);
// 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)
+ // Each line's key to the sum of its bins' pass fractions: read from the catalog once per weighting pass, never kept.
+ private Dictionary BinShares() => ItemManager.ItemData.GetAll()
+ .Where(product => product.Bin != null)
+ .GroupBy(product => product.Bin.Line.Key.Value)
+ .ToDictionary(group => group.Key, group => group.Sum(bin => PassFraction(bin.Bin.Floor)));
+
+ private float Share(FactionProductData product, Dictionary shares)
{
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);
+ return max(0f, 1 - shares.GetValueOrDefault(ItemManager.ItemData.RefOf(product).Key.Value));
}
// 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;
- // null when none, and uniform over them when every weight is 0. Candidates are ordered by name so the draw's
+ // null when none, and by share alone when every weight is 0. Candidates are ordered by name so the draw's
// enumeration, and so its result, never depends on record keys. All of the policy is this body.
public FactionProductData ChooseProduct(CraftedItemData design)
{
@@ -280,8 +290,9 @@ public class LoadoutGenerator
.OrderBy(product => product.Name, StringComparer.Ordinal)
.ToArray();
if (candidates.Length == 0) return null;
- var uniform = candidates.All(product => ProductWeight(product) <= 0);
- return candidates.WeightedRandomElements(ref Random, product => uniform ? 1 : ProductWeight(product), 1)[0];
+ var shares = BinShares();
+ var uniform = candidates.All(product => Weight(product, shares, true) <= 0);
+ return candidates.WeightedRandomElements(ref Random, product => Weight(product, shares, !uniform), 1)[0];
}
// Prioritize products from the zone faction and its allies, penalizing distance to the manufacturer's headquarters.
diff --git a/tests/Aetheria.Shared.Tests/ProductChoiceTests.cs b/tests/Aetheria.Shared.Tests/ProductChoiceTests.cs
index 5061d4603..89f262c62 100644
--- a/tests/Aetheria.Shared.Tests/ProductChoiceTests.cs
+++ b/tests/Aetheria.Shared.Tests/ProductChoiceTests.cs
@@ -300,6 +300,48 @@ public sealed class ProductChoiceTests : IDisposable
Assert.Equal(0f, three.Outfitter(three.OutEven, 1, 0).ProductWeight(three.Product("lamp-a")));
}
+ // Ruling bin-share-by-floor holds on every draw of a design, not only the preference-weighted one. When every reachable maker
+ // weighs 0 the ChooseProduct draw is by share alone, and Alpha's line and bin together keep the half they held as one product.
+ [Fact]
+ public void ABinLeavesItsMakersShareOfTheAllZeroWeightDrawUnchanged()
+ {
+ int AlphaDraws(Rig rig)
+ {
+ var lamp = rig.Cache.GetByName("Lamp");
+ var zero = rig.Cache.GetByName("OutZero");
+ return Enumerable.Range(1, 1000).Count(i => rig.Cache.Get(rig.Outfitter(zero, Seed(i), 0).ChooseProduct(lamp).Manufacturer).Name == "Alpha");
+ }
+
+ Assert.InRange(AlphaDraws(Build("lamp-z", "lamp-a")), 440, 560);
+ Assert.InRange(AlphaDraws(Build(new[] { .67f }, new[] { "lamp-z", "lamp-a" })), 440, 560);
+ Assert.InRange(AlphaDraws(Build(new[] { 0f }, new[] { "lamp-z", "lamp-a" })), 440, 560);
+ }
+
+ // The same for a required item that has fallen back to any manufacturer (OutNone is allied to nobody, so no product is on offer
+ // and the fallback weighs every product alike, but for the bins' shares).
+ [Fact]
+ public void ABinLeavesItsMakersShareOfTheRequiredFallbackDrawUnchanged()
+ {
+ int AlphaDraws(Rig rig)
+ {
+ var log = new List();
+ var items = new ItemManager(rig.Cache, new ProvenanceLedger(), RunSaveTests.TestSettings(), log.Add);
+ var lamp = rig.Cache.GetByName("Lamp");
+ var alpha = Enumerable.Range(1, 1000).Count(i =>
+ {
+ var random = new Random(Seed(i));
+ var generator = new LoadoutGenerator(ref random, items, rig.Galaxy, null, rig.OutNone, .5f);
+ return rig.Cache.Get(generator.RandomProduct(0, design => design == lamp, required: true).product.Manufacturer).Name == "Alpha";
+ });
+ Assert.NotEmpty(log);
+ return alpha;
+ }
+
+ Assert.InRange(AlphaDraws(Build("lamp-z", "lamp-a")), 440, 560);
+ Assert.InRange(AlphaDraws(Build(new[] { .67f }, new[] { "lamp-z", "lamp-a" })), 440, 560);
+ Assert.InRange(AlphaDraws(Build(new[] { 0f }, new[] { "lamp-z", "lamp-a" })), 440, 560);
+ }
+
// 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 f73507ff2fa847ac0cd0d0eafcc9d02f32e7f78e Mon Sep 17 00:00:00 2001
From: Meta Vens
Date: Sun, 18 Oct 2026 21:08:10 +0200
Subject: [PATCH 027/429] Catalog read refuses a bin with a non-finite Floor, a
missing Line or a Line that is a bin; a bin mints from exactly its named line
(tested)
The runtime no longer trusts the authoring tool for a bin: a NaN Floor weighed NaN and nulled every
draw of its design. AetheriaStores.Open names the field and echoes no value.
Co-Authored-By: Claude Sonnet 5.5
---
Assets/Scripts/ServerShared/AetheriaStores.cs | 19 +++++
.../Aetheria.Shared.Tests/ProductBinTests.cs | 78 +++++++++++++++++++
2 files changed, 97 insertions(+)
diff --git a/Assets/Scripts/ServerShared/AetheriaStores.cs b/Assets/Scripts/ServerShared/AetheriaStores.cs
index 4afb00b1d..525894ac9 100644
--- a/Assets/Scripts/ServerShared/AetheriaStores.cs
+++ b/Assets/Scripts/ServerShared/AetheriaStores.cs
@@ -41,6 +41,7 @@ public static class AetheriaStores
foreach (var data in cache.GetAll()) CultRecordRefs.Validate(data);
foreach (var data in cache.GetAll()) CultRecordRefs.Validate(data);
VerseGrammarValidation.ValidateAll(cache.GetAll());
+ ValidateBins(cache);
return cache;
}
catch
@@ -49,6 +50,24 @@ public static class AetheriaStores
throw;
}
}
+
+ // The mint and the weighting trust a bin: they never check its Floor or its Line. So the catalog read refuses one whose Floor is
+ // not a finite number (it would weigh NaN and poison every draw of its design), whose Line names no product the catalog holds, or
+ // whose Line is itself a bin (a bin is of a line, never of a bin). The refusals name the field and echo no value.
+ private static void ValidateBins(CultCache cache)
+ {
+ foreach (var product in cache.GetAll())
+ {
+ if (product.Bin == null) continue;
+ if (!float.IsFinite(product.Bin.Floor))
+ throw new InvalidOperationException("A product bin's Floor is not a finite number.");
+ var line = cache.Get(product.Bin.Line);
+ if (line == null)
+ throw new InvalidOperationException("A product bin's Line names no product this catalog holds.");
+ if (line.Bin != null)
+ throw new InvalidOperationException("A product bin's Line names another bin; a bin is of a line.");
+ }
+ }
}
public static class CultRecordRefs
diff --git a/tests/Aetheria.Shared.Tests/ProductBinTests.cs b/tests/Aetheria.Shared.Tests/ProductBinTests.cs
index 03762fdf2..c7b7de1fb 100644
--- a/tests/Aetheria.Shared.Tests/ProductBinTests.cs
+++ b/tests/Aetheria.Shared.Tests/ProductBinTests.cs
@@ -183,6 +183,84 @@ public sealed class ProductBinTests : IDisposable
Assert.Equal("Premiere", product.Name);
}
+ // The line a bin draws from is the one Bin.Line names, not another line of the design: three lines of one design, by three
+ // makers, with the bin on the middle one by name, so a bin that reads the first or the last line of the design is far off.
+ [Fact]
+ public void ABinMintsFromExactlyTheLineItNames()
+ {
+ Line("A line", lensMean: .3f, makerMean: .2f);
+ var middle = Line("M line", lensMean: .6f, makerMean: .5f);
+ Line("Z line", lensMean: .9f, makerMean: .9f);
+ var lots = Mint(Bin(middle, "Middle Premiere", 0), 400);
+ Assert.All(lots, lot =>
+ {
+ Assert.True(lot.Roles.Single().Quality >= .6f - 1e-5f, "a fill came from a line below the named one");
+ Assert.True(lot.Quality >= .5f - 1e-5f, "an assembly came from a maker below the named line's");
+ });
+ Assert.InRange(Fills(lots).Average(), .64f, .72f);
+ Assert.InRange(lots.Average(lot => lot.Quality), .55f, .62f);
+ }
+
+ private const string Canary = "canary-7f3a91";
+
+ private enum Damage { None, LineRemoved, LineIsBin }
+
+ // A catalog on disk holding one line and a bin of it, damaged as asked, for AetheriaStores.Open to read.
+ private string DamagedCatalog(float floor, Damage damage)
+ {
+ var path = Path.Combine(_root, Guid.NewGuid().ToString("N"), "Aetheria.cc");
+ Directory.CreateDirectory(Path.GetDirectoryName(path));
+ using var seed = AetheriaStores.Open(path, catalogWritable: true);
+ seed.Upsert(new VerseGrammar { Revision = 1 });
+ var maker = seed.Upsert(new Faction { Name = Canary + " Maker" });
+ var gear = seed.Upsert(new GearData { Name = "Lensed", Hardpoint = HardpointType.Sensors, Shape = new Shape(), Price = 10, Roles = { new ItemRole { Name = "lens" } } });
+ var design = new CultRecordRef(gear.Key);
+ var line = seed.Upsert(new FactionProductData { Name = Canary + " Line", Design = design, Manufacturer = maker, Roles = { new ProductRole { Role = "lens" } } });
+ var bin = seed.Upsert(new FactionProductData { Name = Canary, Design = design, Manufacturer = maker, Bin = new ProductBin { Line = line, Floor = floor } });
+ if (damage == Damage.LineIsBin)
+ seed.Upsert(new FactionProductData { Name = Canary + " Nested", Design = design, Manufacturer = maker, Bin = new ProductBin { Line = bin, Floor = 0 } });
+ if (damage == Damage.LineRemoved) seed.Remove(line.Key);
+ seed.FlushAsync().Wait();
+ return path;
+ }
+
+ // The runtime does not trust authoring for a bin: a NaN floor would weigh NaN and null every draw of its design.
+ [Theory]
+ [InlineData(float.NaN)]
+ [InlineData(float.PositiveInfinity)]
+ [InlineData(float.NegativeInfinity)]
+ public void ACatalogWhoseBinFloorIsNotFiniteIsRefusedWhereItIsRead(float floor)
+ {
+ var refusal = Assert.Throws(() => AetheriaStores.Open(DamagedCatalog(floor, Damage.None)));
+ Assert.Contains("Floor", refusal.Message);
+ Assert.DoesNotContain(Canary, refusal.Message);
+ Assert.DoesNotContain(floor.ToString(), refusal.Message);
+ }
+
+ [Fact]
+ public void ACatalogWhoseBinNamesNoLineIsRefusedWhereItIsRead()
+ {
+ var refusal = Assert.Throws(() => AetheriaStores.Open(DamagedCatalog(.67f, Damage.LineRemoved)));
+ Assert.Contains("Line", refusal.Message);
+ Assert.DoesNotContain(Canary, refusal.Message);
+ }
+
+ [Fact]
+ public void ACatalogWhoseBinNamesAnotherBinIsRefusedWhereItIsRead()
+ {
+ var refusal = Assert.Throws(() => AetheriaStores.Open(DamagedCatalog(.67f, Damage.LineIsBin)));
+ Assert.Contains("Line", refusal.Message);
+ Assert.DoesNotContain(Canary, refusal.Message);
+ }
+
+ // The refusals are of damage, not of bins: a sound bin on a sound line reads.
+ [Fact]
+ public void ACatalogWithASoundBinIsRead()
+ {
+ using var cache = AetheriaStores.Open(DamagedCatalog(.67f, Damage.None));
+ Assert.Equal(.67f, cache.GetAll().Single(product => product.Name == Canary).Bin.Floor);
+ }
+
// 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()
From 8d65e957f81764605416202aab1f31b7f6125678 Mon Sep 17 00:00:00 2001
From: Meta Vens
Date: Sun, 18 Oct 2026 21:08:11 +0200
Subject: [PATCH 028/429] sensor-stat-set skips a bin, as the other role
writers do; it takes an optional root for its test
Co-Authored-By: Claude Sonnet 5.5
---
.../RolesBackfillCommandTests.cs | 21 +++++++++++++++++++
tools/AetherDb/Program.cs | 6 +++---
2 files changed, 24 insertions(+), 3 deletions(-)
diff --git a/tests/Aetheria.Shared.Tests/RolesBackfillCommandTests.cs b/tests/Aetheria.Shared.Tests/RolesBackfillCommandTests.cs
index 31152b63c..58c823af4 100644
--- a/tests/Aetheria.Shared.Tests/RolesBackfillCommandTests.cs
+++ b/tests/Aetheria.Shared.Tests/RolesBackfillCommandTests.cs
@@ -290,6 +290,27 @@ public sealed class RolesBackfillCommandTests : IDisposable
finally { db.Cache.Dispose(); }
}
+ // sensor-stat-set authors an emission-discipline role on every product of a sensor design; a bin of one authors none.
+ [Fact]
+ public void A_binned_seller_gains_no_roles_from_sensor_stat_set()
+ {
+ var db = AetherDb.Open(catalogWritable: true, root: _root);
+ var sensor = db.Cache.GetAll().Where(d => d.Behaviors != null && d.Behaviors.OfType().Any()).OrderBy(d => d.Name, StringComparer.Ordinal).First();
+ var seller = db.Cache.GetAll().OrderBy(p => p.Name, StringComparer.Ordinal).First(p => p.Bin == null && p.Design.Key.Equals(db.Cache.RefOf(sensor).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.SensorStatSet(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 7a28b1aa0..fec3d6d3e 100644
--- a/tools/AetherDb/Program.cs
+++ b/tools/AetherDb/Program.cs
@@ -1719,9 +1719,9 @@ public static class Program
// product of such a design authors that role, and the Passive Array (a generic listener gear, ListenerData) is
// authored with two products from the stealth makers. Prints the sensor census first. Dry run unless passed
// "apply"; idempotent, so a second apply writes nothing and a catalog another cut changed is rerun, never merged.
- private static int SensorStatSet(bool apply)
+ internal static int SensorStatSet(bool apply, string root = null)
{
- var db = AetherDb.Open(catalogWritable: apply);
+ var db = AetherDb.Open(catalogWritable: apply, root: root);
var designs = db.Cache.GetAll().ToArray();
var products = db.Cache.GetAll().ToArray();
Faction MakerOf(FactionProductData p) => db.Cache.Get(p.Manufacturer);
@@ -1760,7 +1760,7 @@ public static class Program
design.Roles ??= new List();
if (!design.Roles.Any(r => r.Name == EmissionDiscipline)) { design.Roles.Add(new ItemRole { Name = EmissionDiscipline }); designChanged = true; }
if (designChanged) changedDesigns.Add((design, key));
- foreach (var product in products.Where(p => p.Design.Key.Equals(key)))
+ foreach (var product in products.Where(p => p.Bin == null && p.Design.Key.Equals(key)))
{
var maker = MakerOf(product) ?? throw new InvalidOperationException($"Product \"{product.Name}\" has no maker.");
product.Roles ??= new List();
From 0769314ee6479401e323c0cd39ae9098ea025693 Mon Sep 17 00:00:00 2001
From: Meta Vens
Date: Sun, 18 Oct 2026 21:11:01 +0200
Subject: [PATCH 029/429] aetheria-release map: boss loadout replaces boss hull
derived (Faction.BossLoadout, off-market by product)
Co-Authored-By: Claude Opus 5.5
---
docs/aetheria-release-map.md | 285 +++++++++++++----------------------
1 file changed, 103 insertions(+), 182 deletions(-)
diff --git a/docs/aetheria-release-map.md b/docs/aetheria-release-map.md
index b5e91ddaa..37344be87 100644
--- a/docs/aetheria-release-map.md
+++ b/docs/aetheria-release-map.md
@@ -888,8 +888,8 @@ name. Per ruling `no-save-compatibility-before-players` nothing reads key 11.
|---|---|---|---|
| Pirates `Faction` | Catalog record, name `Pirates` | Authored once by `AetherDb pirates-faction apply`; doctrine is added by faction-play r2 | Ruling `pirates-record-content`; the operator sees the dry run |
| Package product | `mod-product::` in the package's `ship.cc` | Written by `AetherDb ship-authoring product`; copied verbatim by compose; Blender never edits it | The package author |
-| Boss hull | `HullData.Boss` (key 33) | Set by `ship-authoring boss`; a faction's boss hulls are derived per generation from the products it makes | The package author; `BossHullsOf` derives |
-| `Faction.BossHull` (key 10) | Retired | Deleted by `boss-hull-derived` | none |
+| Boss loadout | `Faction.BossLoadout` (key 22), a reference to one Loadout; the Loadout names its hull by design | Set by an agent-authored catalog write (follow-up `zhestokost-boss-loadout`); resolved by `Loadouts.BossOf`, which refuses Unset or Missing | Agents write it, the operator tweaks in Studio (rulings `faction-boss-is-a-loadout-reference`, `boss-loadouts-agent-authored`) |
+| `Faction.BossHull` (key 10) | Retired, never reused | Deleted by `boss-loadout` (the derived-boss design with `HullData.Boss` was withdrawn unmerged) | none |
| Pirates products | 8 `FactionProductData`, emoji names, over other makers' designs | Authored with the faction in one commit | Ruling `pirates-record-content` |
| Package hull price | `HullData.Price` | Written by `ship-authoring price` (or `create --like`) | The package author |
| Emoji font file | `Assets/Fonts/Emoji/.ttf` + `emoji-font.lock` | Rebuilt only by `tools/emoji` at a new pinned version | The operator's `emoji-art-set` ruling |
@@ -10784,194 +10784,115 @@ worktree C:\aeth-wt\ag6-quality-readers at 70c7a497. Catalog: GameData/Aetheria.
quality-layers-combine, not in the spec, so the spec does not wait on it. context-pack: 14
anchors SAME.
-## Boss hull derived
+## Boss loadout
-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`.
+This section replaces "Boss hull derived" (the `cut-boss-hull-derived` r1-r4 design: `HullData.Boss`,
+`BossHullsOf`, a Loadout found by hull product). Ruling `faction-boss-is-a-loadout-reference` replaced that
+design; r4 is withdrawn. Branch `eureka/aetheria-release-boss-hull-derived` (head `90f977bf`, reports h1, h2)
+is unmerged evidence only. Probes and source reads by imagination-ag6-boss-loadout (session
+self-2026-10-18-ag6), 2026-10-18, in the detached worktree `C:\aeth-wt\ag6-boss-loadout` at `origin/master`
+`a40072d9`, then `d28ce42d` (that commit adds one research doc only; every anchor below is the same at both).
+Read by `cut-boss-loadout.r1`. The model-page row `Faction.BossHull (key 10)` (about line 892) and the r4
+table row (about line 10958) now say: retired by `boss-loadout`, never reuse; add the row
+`Faction.BossLoadout (key 22) | CultRecordRef | the one owner of which Loadout is a faction's boss`.
### 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`. 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). 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`
- (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`).
+- **BL1. What decides the boss today.** `ZoneGenerator.GenerateZone`'s boss block (`ZoneGenerator.cs:423-439`)
+ calls `LoadoutGenerator.GenerateBossLoadout` (`LoadoutGenerator.cs:212-230`). That method flies
+ `Faction.BossHull` (`Corporations.cs:42-43`, key 10) through `GenerateShipLoadout`. When that is unset or
+ unsold it flies the dearest ship hull on offer. So every demo boss today is the generated fallback. The
+ main galaxy picks boss factions by `BossHull.IsSet()` (`Galaxy.cs:291-295`). AetherDb `factions` prints the
+ hull (`Program.cs:700-718`), and the `dangling` comment names it (`:586`).
+- **BL2. Catalog state (probe: msgpack decode of `GameData/Aetheria.cc`, 186,037 bytes).** There are 15 Faction
+ rows, each a 22-element array (keys 0-21). Index 10 is `''` (unset) on all 15, so deleting `BossHull`
+ loses no data. Index 20 is nil on all; it was `assemblyDeviation`, retired by `30597b0e`, with no
+ never-reuse note in the source. The catalog holds no `aetheria.loadout` records. The new reference
+ therefore takes key **22**.
+- **BL3. A Loadout names its hull by design.** `Loadout.Hull` (key 1) is the design and `HullProduct` (key 4)
+ the optional brand (`Loadout.cs:14-22`). `Loadouts.Materialize` (`:113-167`) builds hull and slots through
+ `Resolve` (`:174-214`). A pinned product is built as named whatever the chooser says. A design with no
+ product at all is built unbranded, with no maker, at `UnbrandedQuality` .95. Otherwise the chooser picks.
+ So a boss Loadout needs no format change, and the boss is built by the same builder as scenario fits and
+ capture. Header and comments still say "preset (a variant)", which ruling `boss-gear-is-a-loadout`
+ corrects. `ActionGameManager.cs:645-647` prints "Preset '...'".
+- **BL4. What is on the market today (probe: every enumeration of designs and products).** Every generated
+ draw goes through `LoadoutGenerator.AvailableProducts` (`:188-210`): hulls, gear, station stock and
+ consumables. It enumerates `FactionProductData` and filters by `IsAvailable` (`:251-253`), the design's
+ price > 0 and a gear home. `ChooseProduct` (`:262-272`) is the chooser for design-only slots. It
+ enumerates products and filters by the same `IsAvailable`. `SoldHulls` (`:237-238`), the hulls that
+ give gear a home, enumerates products. No game path offers a design except through a product. The design
+ enumerations found are `ShipModShips.cs:44` (visual prototypes), `ShipAuthoring.cs:181` (package load),
+ `ShipModCatalog.cs:139`, `ZoneGenerator.cs:274` (station reach), console `give` (`ActionGameManager.cs:539`)
+ and AetherDb reports. None of them stocks, spawns or sells. So **an item is on the market iff some product
+ of it is on offer** is already the rule, and its one owner is the product set read through
+ `AvailableProducts`, `ChooseProduct` and `SoldHulls`. The price-0 filter in `AvailableProducts` is a
+ second, partial gate. `ChooseProduct` and `SoldHulls` ignore it, and package validation refuses price 0
+ for a hull with products (`ShipAuthoring.cs:220-229`). It is value, not market membership, and is not a
+ candidate mechanism.
+- **BL5. "No product" already has a meaning.** Scenarios ruling Q3 (`docs/scenarios-cut.md:20`, `:155`) says
+ "a design no product sells is a scenario test design and nothing else". `ScenarioTests.cs:576-610`
+ (`EveryUnsoldDesignIsAScenarioTestDesign`) and `ProductLinesCatalogTests.cs:14-34` (weapon designs: Smoke
+ only) pin it. A package hull may ship with no products (`ShipAuthoring.cs:222`, `if (products.Length == 0)
+ return;`).
+- **BL6. Brand rulings.** `designs-are-tech-nodes`: flavour names belong to products.
+ `catalog-grows-generic-designs-branded-products`: branding lives in products. With no product, a boss
+ hull's only name is its design's, and its quality is .95 rather than drawn from its maker's
+ `Faction.Assembly` (key 19).
+- **BL7. Boss consumers that stay.** `Galaxy.PlaceGate` (`Galaxy.cs:148-151`) maps the demo antagonist's home
+ to `BossZones` and `Exit` whatever the antagonist flies. `RunGoal` (`RunGoal.cs:10-17`) opens the gate only
+ when the exit zone spawned a boss and it is dead (it fails closed). `EntityPack.Boss` and
+ `EntitySerializer` (`:31`, `:85`) are unchanged. `ActionGameManager.cs:365` says "Its guardian holds it
+ shut" when no boss is present.
+- **BL8. Tests on the old path.** `RunGoalTests.cs` (329 lines): `Boss_hull_from_BossHull_else_fallback`
+ (`:73-97`), `Boss_hull_nobody_sells_falls_back` (`:99-115`) and `Exit_sealed_when_no_ship_hull_is_on_offer`
+ (`:214-228`) pin the fallback. `Exit_open_only_in_the_exit_zone` (`:193-212`) sets `BossHull`. Five tests
+ rely on a demo boss existing on the bare catalog (`:41-71`, `:116-160`, `:230-240`). No other test reads
+ `BossHull` or `GenerateBossLoadout` (grep). Other tests touch `BossZones` only as saved-game fields.
+- **BL9. Reusable from `90f977bf`.** The Loadout.cs header rewrite (`git show 90f977bf:Assets/Scripts/ServerShared/Loadout.cs`,
+ lines 9-13). The ZoneGenerator boss block shape (Materialize with `liveZone: null` and
+ `GetLoadoutGenerator(bossFaction).ChooseProduct`; green in h2's full run at `d912cf44`, 2431 passed).
+ The fixture pattern `MakeBossLoadout` (capture a generated fit, keep its weapon slots, renumber groups) and
+ `StoreLoadout` through `_cache.Commit(batch => batch.Upsert(typeof(Loadout), ...))` (`fc992f5a`).
+ h2's diagnosis that three r3 failures were the fixture name "Lucent" for "Lucent Media". h2's
+ `LoadoutCommitCommand` (125 lines, plus 210 lines of tests) takes a hull *product*. It is for the next
+ cut and must accept a hull design.
-### Rationale (r4)
+### Rationale (r1)
-- **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.
+- **Which Loadout is a faction's boss.** One owner: the field `Faction.BossLoadout` (key 22), authored in
+ Studio or by an agent command (ruling `boss-loadouts-agent-authored`). `Loadouts.BossOf(items, faction, out
+ BossRefusal)` is its one resolver (Unset, Missing). ZoneGenerator, Galaxy's main-galaxy pick and AetherDb
+ `factions` call it. Nothing derives a boss from hulls, products or flags.
+- **What a boss flies.** Its Loadout, through `Loadouts.Materialize`, whose chooser
+ (`ChooseProduct`) only brands a design-only slot (ruling `loadout-product-optional-context-chooses`). No
+ generator draw sits on the boss path, and nothing is outfitted, picked up or stocked.
+- **What is on the market.** BL4: on the market iff a product of the item is on offer, owned by the product
+ set through `AvailableProducts`, `ChooseProduct` and `SoldHulls`. A boss hull reaches a ship only through a
+ Loadout, which names it by design (`Loadout.Hull`) or pins its product. This cut adds no off-market code. No
+ boss hull exists yet (ruling `demo-boss-hull-invented`), so nothing in this cut depends on how an
+ off-market item is marked. Question `off-market-items-keep-a-maker` asks whether an off-market item keeps a
+ maker, a brand and a drawn quality. If it does, its product carries one general not-offered field read by
+ `IsAvailable` and `SoldHulls`. If it does not, it has no product, and the Q3 census accepts a design a
+ Loadout names. The recommendation is the field (BL5, BL6). The answer lands with the boss-hull authoring
+ cut.
+- **The demo exit meanwhile.** After this cut and until a Zhestokost boss Loadout is authored, the demo exit
+ zone logs "Zhestokost has no boss: it sets no Faction.BossLoadout." and the exit-without-boss line. No boss
+ spawns, `BossSpawned` stays false, and the gate stays sealed (RunGoal fails closed). **The demo cannot be
+ won.** The sealed-gate dialog says "Its guardian holds it shut", which is wrong while no guardian exists.
+ That stands until the boss lands; it is not reworded here.
+- **Rejected.** A boss flag on hulls (operator, ruling `faction-boss-is-a-loadout-reference`). Excluding
+ hulls named by any boss Loadout from `AvailableProducts`: a boss-specific clause, barred by ruling
+ `off-market-is-general`. Price 0 as the off-market mark (BL4). Keeping a generated fallback: barred by
+ rulings `boss-gear-is-a-loadout` and `demo-boss-hull-invented`.
-### Model page rows
+### Cut sequence (boss strand)
-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: 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
-
-- `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.
+- `boss-loadout` (spec `cut-boss-loadout.r1`): `Faction.BossLoadout` replaces `BossHull`;
+ `GenerateBossLoadout` is deleted; the boss refuses typed.
+- Next: boss Loadout authoring. AetherDb `loadout-commit` (from h2, taking a hull design with an optional
+ `@product`) and a way to set `Faction.BossLoadout`. It lands with the answer to
+ `off-market-items-keep-a-maker` and the invented Zhestokost hull (follow-up `zhestokost-boss-loadout`).
## Product tiers
From 900fa51e437f1a140b548e292135fa083836b47e Mon Sep 17 00:00:00 2001
From: Meta Vens
Date: Sun, 18 Oct 2026 21:12:58 +0200
Subject: [PATCH 030/429] Delete Faction.BossHull (key 10 retired),
GenerateBossLoadout and the three tests that pin its fallback
A boss is a Loadout the faction references, never drawn (rulings faction-boss-is-a-loadout-reference,
boss-gear-is-a-loadout, demo-boss-hull-invented). This commit does not build: ZoneGenerator, Galaxy,
AetherDb and the tests still read the deleted members; the next commit replaces them.
Co-Authored-By: Claude Sonnet 5.5
---
Assets/Scripts/ServerShared/Corporations.cs | 3 +-
.../Scripts/ServerShared/LoadoutGenerator.cs | 20 -------
tests/Aetheria.Shared.Tests/RunGoalTests.cs | 60 -------------------
3 files changed, 1 insertion(+), 82 deletions(-)
diff --git a/Assets/Scripts/ServerShared/Corporations.cs b/Assets/Scripts/ServerShared/Corporations.cs
index c242e3e42..9d24bc39e 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 boss hull reference, retired by ruling faction-boss-is-a-loadout-reference; 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..7de56b40b 100644
--- a/Assets/Scripts/ServerShared/LoadoutGenerator.cs
+++ b/Assets/Scripts/ServerShared/LoadoutGenerator.cs
@@ -209,26 +209,6 @@ 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.
- 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.");
-
- 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);
- }
-
// 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 25b46fdd3..4c51eca72 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()
{
@@ -211,22 +167,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()
From 352e221453bd2d5962c3ba2801d3f1d4ed37d66f Mon Sep 17 00:00:00 2001
From: Meta Vens
Date: Sun, 18 Oct 2026 21:14:09 +0200
Subject: [PATCH 031/429] Faction.BossLoadout (key 22): the boss is its
faction's Loadout, resolved by Loadouts.BossOf and built by Materialize
BossOf refuses typed (BossRefusal Unset or Missing, Describe names the field and echoes no input).
ZoneGenerator builds the boss from the Loadout with ChooseProduct as the chooser; a refused or
unbuildable boss logs and spawns none. Galaxy picks boss factions through BossOf; AetherDb factions
prints the boss Loadout. Loadout vocabulary: no preset, no variant. Production builds; the tests
project does not until the next commit (RunGoalTests still sets BossHull).
Co-Authored-By: Claude Sonnet 5.5
---
Assets/Scripts/Gameplay/ActionGameManager.cs | 6 +-
Assets/Scripts/ServerShared/Corporations.cs | 7 ++-
Assets/Scripts/ServerShared/Galaxy.cs | 4 +-
Assets/Scripts/ServerShared/Loadout.cs | 60 ++++++++++++++-----
.../Scripts/ServerShared/LoadoutGenerator.cs | 2 +
Assets/Scripts/ServerShared/ZoneGenerator.cs | 21 +++++--
tools/AetherDb/Program.cs | 11 ++--
7 files changed, 78 insertions(+), 33 deletions(-)
diff --git a/Assets/Scripts/Gameplay/ActionGameManager.cs b/Assets/Scripts/Gameplay/ActionGameManager.cs
index e24a6e395..9b4c6ad59 100644
--- a/Assets/Scripts/Gameplay/ActionGameManager.cs
+++ b/Assets/Scripts/Gameplay/ActionGameManager.cs
@@ -642,9 +642,9 @@ public class ActionGameManager : MonoBehaviour
{
var written = Loadouts.Commit(CatalogPath, Loadouts.Capture(ItemManager, ship, name), replace);
console.AppendLogLine(written
- ? $"Preset '{name}' written to {CatalogPath}. This session sees it after the catalog reloads (next launch). " +
- "Reopen CultCache Studio before saving there: a Studio session opened earlier overwrites captured presets."
- : $"Preset '{name}' changed on disk since load; nothing written");
+ ? $"Loadout '{name}' written to {CatalogPath}. This session sees it after the catalog reloads (next launch). " +
+ "Reopen CultCache Studio before saving there: a Studio session opened earlier overwrites captured loadouts."
+ : $"Loadout '{name}' changed on disk since load; nothing written");
}
catch (InvalidOperationException e)
{
diff --git a/Assets/Scripts/ServerShared/Corporations.cs b/Assets/Scripts/ServerShared/Corporations.cs
index 9d24bc39e..3b676aef4 100644
--- a/Assets/Scripts/ServerShared/Corporations.cs
+++ b/Assets/Scripts/ServerShared/Corporations.cs
@@ -65,7 +65,7 @@ public class Faction
// The maker's final assembly: the distribution every lot of theirs draws its own quality from, and the hard ceiling on each
// of its roles (quality-lean-assembly-hard-ceiling). Null is not authored: a lot of theirs assembles at AssemblyDistribution's
// defaults. assembly-from-maker derives it once at the part mean plus a tolerance margin (quality-min-tolerance-binned-assembly);
- // tune it in Studio after that. Key 16 is reserved for Relations and 18 for Livery.
+ // tune it in Studio after that. Key 16 is reserved for Relations and 18 for Livery. Key 20 was assemblyDeviation (retired); never reuse it.
[Inspectable, JsonProperty("assembly"), Key(19)]
public AssemblyDistribution Assembly;
@@ -73,6 +73,11 @@ public class Faction
// Authored by AetherDb place-names, which restores the table on every apply.
[Inspectable, JsonProperty("places"), Key(21)]
public FactionPlace[] Places = Array.Empty();
+
+ // The Loadout this faction's boss flies; unset means the faction has no boss (ruling faction-boss-is-a-loadout-reference).
+ // The one owner of which Loadout is its boss, read through Loadouts.BossOf.
+ [Inspectable, JsonProperty("bossLoadout"), Key(22)]
+ public CultRecordRef BossLoadout;
}
// Historical, narrative and strategic intent join as keys 2+ when economics-driven placement lands (ruling faction-curated-place-names).
diff --git a/Assets/Scripts/ServerShared/Galaxy.cs b/Assets/Scripts/ServerShared/Galaxy.cs
index 0b2f71387..ce7b30630 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 whose boss Loadout resolves
var bossMegas = Factions
- .Where(m => m.BossHull.IsSet())
+ .Where(m => Loadouts.BossOf(_cache, m, out _) != null)
.Take(bossCount)
.ToArray();
diff --git a/Assets/Scripts/ServerShared/Loadout.cs b/Assets/Scripts/ServerShared/Loadout.cs
index bf8a2ac47..592c827e2 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. A faction's boss flies one (Faction.BossLoadout). 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,9 @@ public class LoadoutSlot
[Key(3)] public CultRecordRef Product; // unset: design-only, chosen at materialization
}
+// Why a faction has no boss Loadout to build; see Loadouts.BossOf.
+public enum BossRefusal { None, Unset, Missing }
+
// 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 +69,54 @@ 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 resolver of a faction's boss reference (ruling faction-boss-is-a-loadout-reference): the Loadout
+ // Faction.BossLoadout names. The boss is never generated, so this reads nothing but the reference and the catalog. Null with
+ // Unset when the faction sets none, and null with Missing when it sets one the catalog does not hold.
+ public static Loadout BossOf(CultCache items, Faction faction, out BossRefusal refusal)
+ {
+ if (!faction.BossLoadout.IsSet())
+ {
+ refusal = BossRefusal.Unset;
+ return null;
+ }
+
+ var loadout = items.Get(faction.BossLoadout);
+ refusal = loadout == null ? BossRefusal.Missing : BossRefusal.None;
+ return loadout;
+ }
+
+ // Names the faction's field, and echoes no key or name of the input.
+ public static string Describe(BossRefusal refusal) => refusal switch
+ {
+ BossRefusal.Unset => "sets no Faction.BossLoadout",
+ BossRefusal.Missing => "names a Faction.BossLoadout that is not in the catalog",
+ _ => ""
+ };
+
+ // 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 +131,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 +147,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/LoadoutGenerator.cs b/Assets/Scripts/ServerShared/LoadoutGenerator.cs
index 7de56b40b..849f0c352 100644
--- a/Assets/Scripts/ServerShared/LoadoutGenerator.cs
+++ b/Assets/Scripts/ServerShared/LoadoutGenerator.cs
@@ -183,6 +183,8 @@ public class LoadoutGenerator
count);
}
+ // An item is on offer only through a product, so a design no product makes is never drawn, stocked or chosen; it reaches a ship
+ // only through a Loadout (ruling off-market-is-general).
// 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.
private ((FactionProductData product, T design)[] available, bool preferManufacturers) AvailableProducts(Predicate filter, bool required) where T : CraftedItemData
diff --git a/Assets/Scripts/ServerShared/ZoneGenerator.cs b/Assets/Scripts/ServerShared/ZoneGenerator.cs
index 4d4c2613e..089ce3026 100644
--- a/Assets/Scripts/ServerShared/ZoneGenerator.cs
+++ b/Assets/Scripts/ServerShared/ZoneGenerator.cs
@@ -421,22 +421,33 @@ 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. The boss is its faction's boss Loadout, built whole by Loadouts.Materialize and never drawn
+ // (ruling boss-gear-is-a-loadout); ChooseProduct only brands 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)}. Its boss zone has no boss to kill.");
continue;
}
+ var failures = new List();
+ var entity = Loadouts.Materialize(itemManager, null, loadout, GetLoadoutGenerator(bossFaction).ChooseProduct, failures);
+ if (entity == null)
+ {
+ itemManager.Log($"{bossFaction.Name}'s boss Loadout {loadout.Name} cannot be built: {string.Join("; ", failures)}");
+ continue;
+ }
+ entity.Faction = bossFaction;
+ var boss = EntitySerializer.Pack(entity);
boss.Boss = true;
pack.Entities.Add(boss);
pack.BossSpawned = true;
}
if (galaxyZone == galaxy.Exit && !pack.BossSpawned)
- itemManager.Log("Galaxy.Exit has no boss: Galaxy.BossZones does not map a boss faction to the exit zone, or its boss could not be generated (ZoneGenerator exit-without-boss). The exit gate stays sealed.");
+ itemManager.Log("Galaxy.Exit has no boss: Galaxy.BossZones does not map a boss faction to the exit zone, or its boss Loadout is unset, missing or cannot be built (ZoneGenerator exit-without-boss). The exit gate stays sealed.");
if (!ambient) return pack;
diff --git a/tools/AetherDb/Program.cs b/tools/AetherDb/Program.cs
index 5df9ed99c..103915d86 100644
--- a/tools/AetherDb/Program.cs
+++ b/tools/AetherDb/Program.cs
@@ -583,7 +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
+ // dangling keys (a faction's BossLoadout member clears boss references that name no Loadout). 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)
{
@@ -701,15 +701,14 @@ public static class Program
{
var db = AetherDb.Open();
var products = db.Cache.GetAll