From 92d6153c2e60120233afe4e02e94214be4d4de87 Mon Sep 17 00:00:00 2001 From: Meta Vens Date: Sun, 18 Oct 2026 16:28:42 +0200 Subject: [PATCH 001/444] Trojan swarms: a belt on a copy of its host's rail whose rocks ride the rail's period Rulings trojans-first, trojans-ride-host-period-with-stations. AsteroidBeltData key 11 RidesRail; AsteroidBelt.Extent is the one belt extent rule (replaces Zone's inline farthest-rock loop and the Math.Max at the well node); a swarm's Periods are its rail's period. The generator draws swarms from a stream of its own (TrojanStream), so no other draw in the zone moves. Tuning defaults are the spec's proposals; the code default TrojanProbability 0 draws none. Existing annulus tests skip swarm rails. Builds. Co-Authored-By: Claude Sonnet 5.5 --- Assets/Resources/Settings.asset | 4 +- Assets/Scripts/ServerShared/Settings.cs | 8 ++++ Assets/Scripts/ServerShared/Zone.cs | 16 ++++--- Assets/Scripts/ServerShared/ZoneData.cs | 6 +++ Assets/Scripts/ServerShared/ZoneGenerator.cs | 44 ++++++++++++++++++- .../ChildrenWithinReachTests.cs | 10 ++--- .../StationPlacementTests.cs | 14 +++--- 7 files changed, 83 insertions(+), 19 deletions(-) diff --git a/Assets/Resources/Settings.asset b/Assets/Resources/Settings.asset index f4c21ce13..78735dab3 100644 --- a/Assets/Resources/Settings.asset +++ b/Assets/Resources/Settings.asset @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:95d1ba64331895cc66862010f94a8cd4598ec9dd211a83b14b6091ac948afd56 -size 12327 +oid sha256:7a0a0d2e7b8367e7c402975e5683434068f99b8c13c848764bbac39b43886411 +size 12477 diff --git a/Assets/Scripts/ServerShared/Settings.cs b/Assets/Scripts/ServerShared/Settings.cs index 2f329e316..980828e53 100644 --- a/Assets/Scripts/ServerShared/Settings.cs +++ b/Assets/Scripts/ServerShared/Settings.cs @@ -126,6 +126,14 @@ public class ZoneGenerationSettings public ExponentialCurve AsteroidCount; public ExponentialLerp AsteroidRotationSpeed; + // Trojan swarms (ruling trojans-ride-host-period-with-stations): the chance a planet or gas giant with a body for a parent + // carries one, each swarm's length along the orbit (turns) and radial half-width (a fraction of the host's distance), and + // the rocks round each of L4 and L5 by host distance. 0 here: a settings object built in code draws none. + public float TrojanProbability = 0f; + public float TrojanArc = .07f; + public float TrojanHalfWidth = .06f; + public ExponentialCurve TrojanCount = new ExponentialCurve { Exponent = 1f, Multiplier = .04f, Constant = 8f }; + public float ResourceDensityMinimum = .1f; public float ResourceDensityMaximum = 1.5f; diff --git a/Assets/Scripts/ServerShared/Zone.cs b/Assets/Scripts/ServerShared/Zone.cs index 4db63be46..f68d3a1a9 100644 --- a/Assets/Scripts/ServerShared/Zone.cs +++ b/Assets/Scripts/ServerShared/Zone.cs @@ -212,13 +212,11 @@ public class Zone foreach (var body in PlanetInstances) if (body.Value.WellStrength > 0) wellMass.TryAdd(Planets[body.Key].Orbit.Key, body.Value.BodyData.Mass); - // A belt's rail carries its asteroids, which spread beyond the rail's own distance: its extent is the outermost. var beltExtent = new Dictionary(); foreach (var belt in AsteroidBelts) { - var extent = 0f; - foreach (var asteroid in belt.Value.Data.Asteroids) extent = Math.Max(extent, asteroid.Distance); - beltExtent[Planets[belt.Key].Orbit.Key] = extent; + var rail = Planets[belt.Key].Orbit.Key; + beltExtent[rail] = belt.Value.Extent(Orbits[rail].Data.Distance); } var wellNodes = new List(); foreach (var orbit in Orbits) @@ -226,7 +224,7 @@ public class Zone var data = orbit.Value.Data; wellNodes.Add(new WellNode(orbit.Key, data.Parent.Key, data.Distance, data.Phase, wellMass.TryGetValue(orbit.Key, out var mass) ? mass : 0f, - beltExtent.TryGetValue(orbit.Key, out var reach) ? Math.Max(reach, data.Distance) : data.Distance, + beltExtent.TryGetValue(orbit.Key, out var reach) ? reach : data.Distance, data.FixedPosition + OrbitData.Evaluate(frac(data.Phase)) * data.Distance)); // a root's stationary place: Offset at an infinite period } Wells = WellTree.Build(wellNodes, settings, pack.Radius); @@ -239,7 +237,9 @@ public class Zone var beltOrbit = Planets[belt.Key].Orbit.Key; var beltParent = Orbits[beltOrbit].Data.Parent.Key; for (var i = 0; i < belt.Value.Periods.Length; i++) - belt.Value.Periods[i] = PeriodAround(beltParent, belt.Value.Data.Asteroids[i].Distance, beltOrbit); + belt.Value.Periods[i] = belt.Value.Data.RidesRail + ? Orbits[beltOrbit].Period // a swarm's rocks ride their rail (trojans-ride-host-period-with-stations) + : PeriodAround(beltParent, belt.Value.Data.Asteroids[i].Distance, beltOrbit); } PopulatedRadius = ComputePopulatedRadius(); @@ -882,6 +882,10 @@ public class AsteroidBelt Radius = data.Asteroids.Max(a => a.Distance); } + // What the belt's rail adds to its parent's L_F (WellTree): an annulus reaches its outermost rock, whose period reads the + // field there; a swarm riding its rail reaches only the rail, whose period every rock shares. + public float Extent(float railDistance) => Data.RidesRail ? railDistance : Math.Max(Radius, railDistance); + // Cut 1 (docs/mining-cut.md): the formula of Zone.cs:269-271 verbatim, `time` in double as `_time` was used. // The only place a chunk pose is computed; no per-tick copy survives it. Cut 2: size is no longer read from // per-belt storage -- Zone owns wear now, so the caller (Zone.ChunkPose/EvaluateBelt) computes it through diff --git a/Assets/Scripts/ServerShared/ZoneData.cs b/Assets/Scripts/ServerShared/ZoneData.cs index dd098862f..646347af6 100644 --- a/Assets/Scripts/ServerShared/ZoneData.cs +++ b/Assets/Scripts/ServerShared/ZoneData.cs @@ -105,6 +105,12 @@ public class AsteroidBeltData : BodyData // it on first load through FieldKinds.Ensure, the same function generation uses. [JsonProperty("kind"), Key(10)] public CultRecordRef Kind; + + // A Trojan swarm (ruling trojans-ride-host-period-with-stations): every rock rides the belt's rail at the rail's period, so + // the swarm keeps its shape. False on a belt from before this key (MessagePack leaves a missing trailing key at its default), + // which keeps each rock on its own distance's period: an annulus. + [JsonProperty("ridesRail"), Key(11)] + public bool RidesRail; } // Mining Cut 3 (docs/mining-cut-refresh.md, Q11): a kind of debris field, an authored catalog record. A chunk's diff --git a/Assets/Scripts/ServerShared/ZoneGenerator.cs b/Assets/Scripts/ServerShared/ZoneGenerator.cs index c0c8cf16f..4d4c2613e 100644 --- a/Assets/Scripts/ServerShared/ZoneGenerator.cs +++ b/Assets/Scripts/ServerShared/ZoneGenerator.cs @@ -49,7 +49,8 @@ public static class ZoneGenerator var pack = new ZonePack(); var cache = itemManager.ItemData; - var random = new Random(galaxyZone.Name.StableHash() ^ (uint) pcg3d(galaxyZone.Position).x); + var seed = galaxyZone.Name.StableHash() ^ (uint) pcg3d(galaxyZone.Position).x; + var random = new Random(seed); var density = saturate(galaxy.Background.CloudDensity(galaxyZone.Position)/2); pack.Radius = zoneSettings.ZoneRadius.Evaluate(density); @@ -214,6 +215,29 @@ public static class ZoneGenerator return cache.Upsert(planetData); }).ToList(); + // Trojan swarms (rulings trojans-first, trojans-ride-host-period-with-stations): a belt on a copy of its host's rail, its + // rocks round both L4 and L5. Drawn from a stream of its own, so no other draw in the zone moves; not a GeneratorPlanet, + // so it takes no slot and no station candidacy sees it. + var swarmRandom = new Random(seed ^ TrojanStream); + foreach (var host in planets.Where(p => p.Parent != null && !p.Parent.Empty && !p.Empty && !p.Belt && p.Distance > 0 + && p.Mass > zoneSettings.PlanetMass && p.Mass <= zoneSettings.SunMass)) + { + if (swarmRandom.NextFloat() >= zoneSettings.TrojanProbability) continue; + var hostOrbit = orbitMap[host]; + var rail = new OrbitData { Parent = hostOrbit.Parent, Distance = hostOrbit.Distance, Phase = hostOrbit.Phase }; + pack.Orbits.Add(cache.Upsert(rail)); + var swarm = new AsteroidBeltData + { + Orbit = cache.RefOf(rail), RidesRail = true, + Asteroids = TrojanRocks(zoneSettings, ref swarmRandom, rail.Distance, rail.Phase) + }; + BodyData swarmBody = swarm; + var swarmKey = cache.Upsert(swarmBody).Key; + swarm.Name = swarmKey.Value.Substring(0, 8); + FieldKinds.Ensure(swarmKey, swarm, cache); + pack.Planets.Add(cache.Upsert(swarmBody)); + } + // The generator's own well tree, for clearance only (the Zone builds its own): what a rail must stay out of is the body's // support, or its Roche margin if more. var wellNodes = planets.Select(p => new WellNode(cache.RefOf(orbitMap[p]).Key, p.Parent == null ? default : cache.RefOf(orbitMap[p.Parent]).Key, @@ -577,6 +601,24 @@ public static class ZoneGenerator // limits out; a massive child of a body also far enough that its own pull peak beats CuspMargin times its parent's pull there; // siblings sit SiblingSpacing of a reach apart. Every other number is a GravityField constant. public const float RocheMargin = 1.5f; + + private const uint TrojanStream = 0x9E3779B9u; + + // A swarm's rocks: alternately round L4 and L5 of the rail at `distance` and `phase`, each within half an arc of its point + // along the orbit and a half-width of the rail across it, thickest at the point. + public static Asteroid[] TrojanRocks(ZoneGenerationSettings settings, ref Random random, float distance, float phase) + { + var rocks = new Asteroid[2 * (int) settings.TrojanCount.Evaluate(distance)]; + for (var i = 0; i < rocks.Length; i++) + rocks[i] = new Asteroid + { + Distance = distance * (1 + random.NextFloat() * (random.NextFloat() - .5f) * 2 * settings.TrojanHalfWidth), + Phase = frac(phase + (i % 2 == 0 ? 1f : -1f) / 6f + random.NextFloat() * (random.NextFloat() - .5f) * settings.TrojanArc), + Size = random.NextFloat(), + RotationSpeed = settings.AsteroidRotationSpeed.Evaluate(random.NextFloat()) + }; + return rocks; + } public const float CuspMargin = 1.5f; public const float SiblingSpacing = .5f; diff --git a/tests/Aetheria.Shared.Tests/ChildrenWithinReachTests.cs b/tests/Aetheria.Shared.Tests/ChildrenWithinReachTests.cs index 6f6dcb2b4..22646a129 100644 --- a/tests/Aetheria.Shared.Tests/ChildrenWithinReachTests.cs +++ b/tests/Aetheria.Shared.Tests/ChildrenWithinReachTests.cs @@ -79,11 +79,11 @@ public sealed class ChildrenWithinReachTests : IClassFixture BodyMass(Zone zone) => zone.Planets.Values.Where(b => !(b is AsteroidBeltData)).GroupBy(b => b.Orbit.Key).ToDictionary(g => g.Key, g => g.First().Mass); - // The generated orbits: every body's and its ancestors', which leaves out stations, turrets and Lagrange copies. + // The generated orbits: every body's and its ancestors', which leaves out stations, turrets, Lagrange copies and swarm rails (a copy of the host's rail, like a Lagrange copy). private static HashSet Placed(Zone zone) { var placed = new HashSet(); - foreach (var body in zone.Planets.Values) + foreach (var body in zone.Planets.Values.Where(b => !(b is AsteroidBeltData { RidesRail: true }))) for (var key = body.Orbit.Key; placed.Add(key) && zone.Orbits[key].Data.Parent.IsSet();) key = zone.Orbits[key].Data.Parent.Key; return placed; } @@ -332,7 +332,7 @@ public sealed class ChildrenWithinReachTests : IClassFixture !b.Value.Data.RidesRail)) { belts++; var orbit = zone.Planets[beltKey].Orbit.Key; @@ -374,7 +374,7 @@ public sealed class ChildrenWithinReachTests : IClassFixture zone.Planets[b.Key].Orbit.Key, b => b.Value.Data.Asteroids); + var beltAt = zone.AsteroidBelts.Where(b => !b.Value.Data.RidesRail).ToDictionary(b => zone.Planets[b.Key].Orbit.Key, b => b.Value.Data.Asteroids); foreach (var (orbit, rocks) in beltAt) { var parent = zone.Orbits[orbit].Data.Parent.Key; @@ -424,7 +424,7 @@ public sealed class ChildrenWithinReachTests : IClassFixture !b.Value.Data.RidesRail)) { var rocks = belt.Data.Asteroids; if (rocks.Length < 2) continue; diff --git a/tests/Aetheria.Shared.Tests/StationPlacementTests.cs b/tests/Aetheria.Shared.Tests/StationPlacementTests.cs index 437e7c1b7..0157f6a70 100644 --- a/tests/Aetheria.Shared.Tests/StationPlacementTests.cs +++ b/tests/Aetheria.Shared.Tests/StationPlacementTests.cs @@ -81,6 +81,10 @@ public sealed class StationPlacementTests : IDisposable private static float Turns(float turns) => turns - (float) Math.Floor(turns); + // A Trojan swarm's rail is a copy of its host's, not a station's source. + private static bool SwarmRail(Zone zone, CultRecordKey orbit) => + zone.Planets.Values.Any(b => b is AsteroidBeltData { RidesRail: true } && b.Orbit.Key.Equals(orbit)); + [Fact] public void AStationSitsAtItsPlanetsL4OrL5() { @@ -96,7 +100,7 @@ public sealed class StationPlacementTests : IDisposable foreach (var station in zone.Entities.OfType().Where(e => TypeOf(built.Items, e) == HullType.Station)) { var orbit = zone.Orbits[station.OrbitData]; - var sources = zone.Orbits.Where(o => !ridden.Contains(o.Key) && SameRing(o.Value, orbit)).ToList(); + var sources = zone.Orbits.Where(o => !ridden.Contains(o.Key) && !SwarmRail(zone, o.Key) && SameRing(o.Value, orbit)).ToList(); if (sources.Count == 0) { // The tutorial entrance's one exception: a station on its own orbit round a root node, beyond the node's ring @@ -156,7 +160,7 @@ public sealed class StationPlacementTests : IDisposable foreach (var station in Orbitals(built).Where(e => TypeOf(built.Items, e) == HullType.Station)) { var orbit = zone.Orbits[station.OrbitData]; - var sources = zone.Orbits.Where(o => !ridden.Contains(o.Key) && SameRing(o.Value, orbit)).ToList(); + var sources = zone.Orbits.Where(o => !ridden.Contains(o.Key) && !SwarmRail(zone, o.Key) && SameRing(o.Value, orbit)).ToList(); if (sources.Count == 0) continue; // the tutorial entrance's own orbit round a root stations++; Assert.Contains(bodyOrbits, key => key.Equals(sources[0].Key)); @@ -173,12 +177,12 @@ public sealed class StationPlacementTests : IDisposable var placed = new HashSet(); foreach (var body in zone.Planets.Values) for (var key = body.Orbit.Key; placed.Add(key) && zone.Orbits[key].Data.Parent.IsSet();) key = zone.Orbits[key].Data.Parent.Key; - var beltExtent = zone.AsteroidBelts.ToDictionary(b => zone.Planets[b.Key].Orbit.Key, b => b.Value.Data.Asteroids.Select(a => a.Distance).DefaultIfEmpty(0f).Max()); + var beltExtent = zone.AsteroidBelts.ToDictionary(b => zone.Planets[b.Key].Orbit.Key, b => b.Value.Extent(zone.Orbits[zone.Planets[b.Key].Orbit.Key].Data.Distance)); var nodes = placed.Select(k => { var d = zone.Orbits[k].Data; return new WellNode(k, d.Parent.Key, d.Distance, d.Phase, mass.TryGetValue(k, out var m) ? m : 0f, - beltExtent.TryGetValue(k, out var e) ? Math.Max(e, d.Distance) : d.Distance, d.FixedPosition + OrbitData.Evaluate(frac(d.Phase)) * d.Distance); + beltExtent.TryGetValue(k, out var e) ? e : d.Distance, d.FixedPosition + OrbitData.Evaluate(frac(d.Phase)) * d.Distance); }).ToList(); var wells = WellTree.Build(nodes, zone.Settings, zone.Pack.Radius); return Math.Max(wells.Support(orbit), 1.5f * zone.Settings.RocheLimit.Evaluate(mass[orbit])); @@ -353,7 +357,7 @@ public sealed class StationPlacementTests : IDisposable foreach (var station in Orbitals(built).Where(e => TypeOf(built.Items, e) == HullType.Station)) { var orbit = zone.Orbits[station.OrbitData]; - var strays = zone.Orbits.Count(o => !ridden.Contains(o.Key) && SameRing(o.Value, orbit)); + var strays = zone.Orbits.Count(o => !ridden.Contains(o.Key) && !SwarmRail(zone, o.Key) && SameRing(o.Value, orbit)); Assert.True(strays == 1, $"seed {built.Seed}: a station's ring holds {strays} orbits no entity rides, not just its source's"); stations++; } From 9cdd885af06e02a2f72729acf8d3df9abcf82c8c Mon Sep 17 00:00:00 2001 From: Meta Vens Date: Sun, 18 Oct 2026 16:28:44 +0200 Subject: [PATCH 002/444] Trojan swarms: tests for the period rule, stations, the unmoved zone and the save format ZoneFingerprint pins a swarmless zone to digests computed at 67517f40 over seeds 1-6. Builds. Co-Authored-By: Claude Sonnet 5.5 --- .../Aetheria.Shared.Tests/TrojanSwarmTests.cs | 150 ++++++++++++++++++ .../Aetheria.Shared.Tests/ZoneFingerprint.cs | 37 +++++ 2 files changed, 187 insertions(+) create mode 100644 tests/Aetheria.Shared.Tests/TrojanSwarmTests.cs create mode 100644 tests/Aetheria.Shared.Tests/ZoneFingerprint.cs diff --git a/tests/Aetheria.Shared.Tests/TrojanSwarmTests.cs b/tests/Aetheria.Shared.Tests/TrojanSwarmTests.cs new file mode 100644 index 000000000..6691d6380 --- /dev/null +++ b/tests/Aetheria.Shared.Tests/TrojanSwarmTests.cs @@ -0,0 +1,150 @@ +using System; +using System.Collections.Generic; +using System.Linq; +using CultMath; +using GameCult.Caching; +using GameCult.Caching.MessagePack; +using MessagePack; +using Xunit; +using Xunit.Abstractions; +using static CultMath.math; +using float2 = CultMath.float2; + +// Trojan swarms (rulings trojans-first, trojans-ride-host-period-with-stations; map B60, B61): a belt on a copy of its host's +// rail whose rocks round L4 and L5 ride the rail's period, so the swarm keeps its shape, adds nothing to the well tree, and +// leaves every other draw in the zone where it was. +public class TrojanSwarmTests +{ + private const uint First = 1, Last = 6; + private readonly ITestOutputHelper _out; + public TrojanSwarmTests(ITestOutputHelper output) => _out = output; + + private static bool IsSwarm(BodyData body) => body is AsteroidBeltData { RidesRail: true }; + + private static IEnumerable<(Orbit Host, Orbit Rail, AsteroidBelt Swarm)> Swarms(Zone zone) + { + foreach (var (key, belt) in zone.AsteroidBelts.Where(b => b.Value.Data.RidesRail)) + { + var rail = zone.Orbits[zone.Planets[key].Orbit.Key]; + var host = zone.Planets.Values.Where(b => !(b is AsteroidBeltData)).Select(b => zone.Orbits[b.Orbit.Key]) + .Single(o => o.Data.Parent.Key.Equals(rail.Data.Parent.Key) && o.Data.Distance == rail.Data.Distance && o.Data.Phase == rail.Data.Phase); + yield return (host, rail, belt); + } + } + + private static HashSet SwarmRails(Zone zone) => + zone.AsteroidBelts.Where(x => x.Value.Data.RidesRail).Select(x => zone.Planets[x.Key].Orbit.Key).ToHashSet(); + + private static float Turn(float2 p) => frac((float) (Math.Atan2(p.y, p.x) / (2 * Math.PI))); + private static float Wrap(float t) => t - (float) Math.Round(t); + + [Fact] + public void ASwarmKeepsItsShapeOverAnHour() + { + using var zones = new ChildrenWithinReachTests.Zones(First, Last, null); + var s = zones.Settings; + int swarms = 0, l4 = 0, l5 = 0; + var drift = 0f; + foreach (var (seed, zone) in zones.All) + foreach (var (host, rail, swarm) in Swarms(zone)) + { + swarms++; + var d = host.Data.Distance; + Assert.Equal(2 * (int) s.TrojanCount.Evaluate(d), swarm.Data.Asteroids.Length); + int sideA = 0, sideB = 0; + for (var i = 0; i < swarm.Data.Asteroids.Length; i++) + { + var rock = swarm.Data.Asteroids[i]; + Assert.True(Math.Abs(rock.Distance / d - 1) <= s.TrojanHalfWidth * 1.0001f, $"seed {seed}: a rock sits {rock.Distance} from a rail at {d}"); + float Offset(double t) => Wrap(Turn(swarm.Pose(i, t, float2.zero, 1).xy) - Turn(OrbitData.Evaluate((float) frac(t / host.Period + host.Data.Phase)))); + var at0 = Offset(0); + var side = at0 > 0 ? 1f / 6 : -1f / 6; + if (side > 0) { l4++; sideA++; } else { l5++; sideB++; } + Assert.True(Math.Abs(at0 - side) <= s.TrojanArc / 2 + 1e-4f, $"seed {seed}: a rock sits {at0} turns from its host"); + drift = Math.Max(drift, Math.Abs(Wrap(Offset(3600) - at0))); + } + Assert.Equal(sideA, sideB); + } + _out.WriteLine($"swarms {swarms}, rocks at L4 {l4}, L5 {l5}, largest drift against the host over an hour {drift} turns"); + Assert.True(swarms >= 10, $"only {swarms} swarms in seeds {First}-{Last}"); + Assert.True(l4 > 0 && l5 > 0); + Assert.True(drift < 1e-3f, $"a swarm rock drifts {drift} turns against its host in an hour"); + } + + [Fact] + public void StationsStillLandOnSwarmHosts() + { + using var zones = new ChildrenWithinReachTests.Zones(First, Last, null); + var onSwarmHost = 0; + foreach (var (seed, zone) in zones.All) + { + var hosts = Swarms(zone).Select(x => x.Host).ToHashSet(); + foreach (var station in zone.Entities.OfType()) + { + var orbit = zone.Orbits[station.OrbitData].Data; + if (hosts.Any(h => h.Data.Parent.Key.Equals(orbit.Parent.Key) && Math.Abs(h.Data.Distance - orbit.Distance) < .01f)) onSwarmHost++; + } + } + _out.WriteLine($"orbital entities on a swarm host's rail {onSwarmHost}"); + Assert.True(onSwarmHost > 0, "no station or turret sits on a swarm host's rail"); + } + + // The digests were computed at 67517f40, before this cut, over the zones of each seed with the authored settings: a zone + // with no swarm is the base zone, and a zone with swarms is the base zone plus them. + private static readonly Dictionary BaseDigest = new Dictionary + { + [1] = "02a2bef42b5d4aeaf1e7004d0ab26620ee570ced95115abf98226ab4e0db23eb", + [2] = "de231f392e5724ecdce7e746957633bef2724252147ecbb5830db047b635aead", + [3] = "5c33fe9f748d6b0b34a7bec923216c7f9bbdfdf8f326c59b77fde9ec96ca2bcb", + [4] = "585284386657e4714ad325cb053a78853c5bcaf7eb1a02cbd4826b0e86ad3593", + [5] = "4a1aeecad09c61ff7daa43eda40a9c0109dc4b6833439b8400d8c72d939950c8", + [6] = "7fd3c5844144b73faf05cd6a53fe92274e4455f38da4aaca4624ea13d96a12c3", + }; + + [Fact] + public void ASwarmMovesNothingElseInTheZone() + { + using var swarmless = new ChildrenWithinReachTests.Zones(First, Last, z => z.TrojanProbability = 0); + using var live = new ChildrenWithinReachTests.Zones(First, Last, null); + Assert.Equal(swarmless.All.Count, live.All.Count); + int swarms = 0, grew = 0; + foreach (var seed in BaseDigest.Keys) + { + var a = swarmless.All.Where(x => x.Seed == seed).Select(x => x.Zone).ToList(); + var b = live.All.Where(x => x.Seed == seed).Select(x => x.Zone).ToList(); + Assert.Empty(a.SelectMany(z => z.AsteroidBelts.Where(x => x.Value.Data.RidesRail))); + Assert.Equal(BaseDigest[seed], ZoneFingerprint.Of(a, _ => false, _ => false)); + string Swarmed(Zone z) => ZoneFingerprint.Of(new[] { z }, SwarmRails(z).Contains, IsSwarm); + for (var n = 0; n < a.Count; n++) + { + Assert.Equal(ZoneFingerprint.Of(new[] { a[n] }, _ => false, _ => false), Swarmed(b[n])); + swarms += SwarmRails(b[n]).Count; + Assert.True(b[n].PopulatedRadius >= a[n].PopulatedRadius, $"seed {seed}: a swarm shrank the populated radius"); + if (b[n].PopulatedRadius > a[n].PopulatedRadius) grew++; + } + } + _out.WriteLine($"zones {live.All.Count}, swarms {swarms}, populated radius grown by a swarm's outer rock in {grew}"); + Assert.True(swarms > 0); + } + + // A pack saved before key 11 existed holds the belt as an 11-slot array; it reads as the annulus it was. + [Fact] + public void ABeltSavedBeforeTheFlagIsAnAnnulus() + { + var options = CultDocumentMessagePackSerialization.OptionsFor(typeof(ZonePack).Assembly); + var saved = MessagePackSerializer.Serialize(new AsteroidBeltData { Mass = 5, Asteroids = new[] { new Asteroid { Distance = 3, Phase = .2f } } }, options); + Assert.Equal(0x9C, saved[0]); // a fixarray of 12 slots, keys 0-11 + Assert.Equal(0xC2, saved[^1]); // its last slot, RidesRail, false + var old = saved[..^1]; + old[0] = 0x9B; + var belt = MessagePackSerializer.Deserialize(old, options); + Assert.False(belt.RidesRail); + Assert.Equal(3f, belt.Asteroids[0].Distance); + Assert.Equal(7f, new AsteroidBelt(belt).Extent(7f)); + Assert.Equal(3f, new AsteroidBelt(belt).Extent(2f)); + var swarm = MessagePackSerializer.Deserialize(MessagePackSerializer.Serialize( + new AsteroidBeltData { Asteroids = belt.Asteroids, RidesRail = true }, options), options); + Assert.True(swarm.RidesRail); + Assert.Equal(2f, new AsteroidBelt(swarm).Extent(2f)); + } +} diff --git a/tests/Aetheria.Shared.Tests/ZoneFingerprint.cs b/tests/Aetheria.Shared.Tests/ZoneFingerprint.cs new file mode 100644 index 000000000..3abc9ebc1 --- /dev/null +++ b/tests/Aetheria.Shared.Tests/ZoneFingerprint.cs @@ -0,0 +1,37 @@ +using System; +using System.Collections.Generic; +using System.Linq; +using System.Security.Cryptography; +using System.Text; +using CultMath; +using GameCult.Caching; +using static CultMath.math; +using float2 = CultMath.float2; + +// A digest of what a zone's generation and build decide, so a test can pin it to a value computed at an earlier commit: +// every orbit's (Distance, Phase, Period, Reach, Support), every body's (type, Mass, rail Distance, belt rocks), the entity +// count and GetHeight at three points round each non-belt body. Cache keys and names are left out; they are minted, not decided. +public static class ZoneFingerprint +{ + public static string Of(IEnumerable zones, Func skipOrbit, Func skipBody) + { + var text = new StringBuilder(); + foreach (var zone in zones) text.Append(Describe(zone, skipOrbit, skipBody)).Append('\n'); + return Convert.ToHexString(SHA256.HashData(Encoding.UTF8.GetBytes(text.ToString()))).ToLowerInvariant(); + } + + private static string Describe(Zone z, Func skipOrbit, Func skipBody) + { + string F(float v) => v.ToString("R", System.Globalization.CultureInfo.InvariantCulture); + var orbits = z.Orbits.Where(o => !skipOrbit(o.Key)) + .Select(o => $"{F(o.Value.Data.Distance)},{F(o.Value.Data.Phase)},{F(o.Value.Period)},{F(z.Wells.Reach(o.Key))},{F(z.Wells.Support(o.Key))}") + .OrderBy(x => x, StringComparer.Ordinal); + var bodies = z.Planets.Values.Where(b => !skipBody(b)) + .Select(b => $"{b.GetType().Name},{F(b.Mass)},{F(z.Orbits[b.Orbit.Key].Data.Distance)}," + + (b is AsteroidBeltData belt ? string.Join("|", belt.Asteroids.Select(r => $"{F(r.Distance)}/{F(r.Phase)}/{F(r.Size)}")) : "")) + .OrderBy(x => x, StringComparer.Ordinal); + var heights = z.Planets.Values.Where(b => !(b is AsteroidBeltData)).Select(b => z.Orbits[b.Orbit.Key].Position) + .SelectMany(p => new[] { float2(0, 0), float2(37, 0), float2(0, -211) }.Select(o => F(z.GetHeight(p + o)))); + return string.Join(";", orbits) + "#" + string.Join(";", bodies) + "#" + z.Entities.Count() + "#" + string.Join(",", heights); + } +} From ac835a8ab27faf2312383754ca3c66f87a6dbebd Mon Sep 17 00:00:00 2001 From: Meta Vens Date: Sun, 18 Oct 2026 16:52:32 +0200 Subject: [PATCH 003/444] Research: agent-built hull art prior art (LLM 3D workflows, ship generators, low-poly style, internal fit), for ruling hull-programmer-art-stopgap Co-Authored-By: Claude Opus 5.5 --- ...26-10-18-agent-built-hull-art-prior-art.md | 164 ++++++++++++++++++ 1 file changed, 164 insertions(+) create mode 100644 docs/research/2026-10-18-agent-built-hull-art-prior-art.md diff --git a/docs/research/2026-10-18-agent-built-hull-art-prior-art.md b/docs/research/2026-10-18-agent-built-hull-art-prior-art.md new file mode 100644 index 000000000..a086c5508 --- /dev/null +++ b/docs/research/2026-10-18-agent-built-hull-art-prior-art.md @@ -0,0 +1,164 @@ + + +# Agent-built hull art: prior art and internal fit (Eyes facts file, 2026-10-18) + +Ruling served: `aetheria-release:ruling:hull-programmer-art-stopgap`. Operator quote: "We need a better stopgap for hulls than depending on my authorship for each. See what we can do in terms of 'programmer art', building up the mesh from simple yet expressive primitives that Opus can confidently arrange, maybe just some low-poly art. Do an Eyes pass on what skills or workflows people are using to get good results. Unfortunately using Tripo output directly is not the way, since it looks good enough to pretend to be final art while faceplanting on the details." The ruling leaves mechanism, primitive set and style open. + +Mind queried with `view` and key/field `query` only; nothing admitted. + +## A. LLM and agent driven 3D modelling + +### A1. Systems and what they report + +- **LL3M** (Chen et al., arXiv 2508.08228, Aug 2025): plan / retrieve / write / critique / verify agents writing Blender Python; a retrieval base over Blender API docs (BlenderRAG). https://arxiv.org/html/2508.08228v1 (read via WebFetch). Models: GPT-4o planner and retrieval, Claude 3.7 Sonnet coder, Gemini 2.0 Flash critic (Sec. 4.1; Appendix A.2 lists GPT-4o for critic, the paper does not reconcile the two). Repo https://github.com/threedle/ll3m notes the server is discontinued (search-summary). + - Quantitative evidence is one table: errors per run 2.43 with RAG versus 3.29 without, 17 objects (Table 1). Agent ablation (Fig. 16) and shared-code-context ablation (Fig. 17) are qualitative, single examples. +- **BlenderGym** (Gu et al., CVPR 2025, arXiv 2504.01786): 245 start/goal scene pairs over object placement, lighting, procedural material, blend shape and procedural geometry editing. https://arxiv.org/abs/2504.01786 (abstract read), https://openaccess.thecvf.com/content/CVPR2025/html/Gu_BlenderGym_Benchmarking_Foundational_Model_Systems_for_Graphics_Editing_CVPR_2025_paper.html (search-summary). Abstract: even the strongest VLM system struggles on tasks "relatively easy for human Blender users"; scaling the verifier helps, and compute is better split between generation and verification. Models tested were GPT-4o, Claude 3.5 Sonnet, Gemini 1.5 Flash, InternVL2-8B, Qwen2-VL-7B (search-summary). The PDF fetch returned binary; per-task scores were not read. +- **BlenderAlchemy** (Huang, Yang, Guibas, ECCV 2024, arXiv 2404.17672): VLM edit generator plus state evaluator searching edits to procedural materials, geometry and lighting; uses reference images from image generators to ground text. https://arxiv.org/html/2404.17672v1 (search-summary; benchmark numbers not read). +- **SceneCraft** (Hu et al., ICML 2024, arXiv 2403.01248): scene graph as blueprint, spatial relations turned into numerical layout constraints, VLM render inspection, and a growing library of reusable script functions. https://proceedings.mlr.press/v235/hu24g.html (search-summary). +- **3D-GPT** (arXiv 2310.12945): dispatch / conceptualization / modeling agents driving Infinigen procedural generators. https://arxiv.org/html/2310.12945v2 (search-summary). SceneCraft's paper is reported to say 3D-GPT's reliance on Infinigen limits it on large scenes and that diversity is limited by prompt expressiveness (search-summary, second-hand). +- **CADCodeVerify** (arXiv 2410.05340, ICLR 2025): VLM asks and answers validation questions about its own CAD render, then corrects. On CADPrompt (200 prompts) reports a 7.30% point-cloud-distance drop and 5.5% compile-rate gain for GPT-4; off-the-shelf models "hallucinate on complex, out-of-distribution tasks". https://arxiv.org/html/2410.05340v2 (search-summary). +- **Text2CAD-Bench** (arXiv 2605.18430): execution success does not guarantee geometric correctness; sweep, loft and shell cause widespread execution failures. https://arxiv.org/pdf/2605.18430 (search-summary). +- **Text2CAD** (arXiv 2409.17106): ~1% invalid outputs; discrepancy from prompt greatest when prompts name objects instead of giving dimensions (search-summary). +- **CAD-Recode** (Rukhovich et al., ICCV 2025, arXiv 2412.14042): point cloud to CadQuery Python via a Qwen2-1.5B decoder trained on one million synthetic CAD programs; reports ~10x lower mean Chamfer than prior work on DeepCAD and Fusion360. https://huggingface.co/papers/2412.14042 (search-summary). It reconstructs from a scan; it does not invent shapes from text. +- **ShapeAssembly** (Jones et al., SIGGRAPH Asia 2020, arXiv 2009.08026): a DSL of cuboid part proxies with attachment operators and symmetry macros; generated by a hierarchical VAE, not an LLM. https://arxiv.org/abs/2009.08026 (search-summary). A search for LLM plus ShapeAssembly-style cuboid programs returned nothing. +- **build123d-mcp** (PyPI): claims that rendering views and checking dimensions during the build lifts GPT-5.5 from 0.360 to 0.457 on CADGenBench and validity from 88% to 100%; the author's own claim, leaderboard not verified. https://pypi.org/project/build123d-mcp/0.3.70/ (search-summary). + +### A2. Failure modes documented + +From LL3M (https://arxiv.org/html/2508.08228v1, read): +- Non-executing code is the main documented failure (Sec. 3.1); without retrieval the coder "solves" errors by deleting the offending lines (App. D). +- Spatial placement: the critic and verifier can miss spatial problems; the limitations section shows watering-can handles disconnected or wrongly posed (Sec. 5, Fig. 18); an ice cream in a hand needed several follow-up prompts (Sec. 4.4, App. E). +- Disconnected parts in first outputs (Fig. 5, Sec. 3.2); a bucket first came out a plain cylinder until refined (Sec. 4.2). +- Symmetry and interpenetration are not discussed explicitly by the paper. + +From CAD-code work: valid-but-wrong geometry (Text2CAD-Bench, CADCodeVerify, above); numeric checks can pass while the render looks wrong (cc-blender-skill README, C3). + +Internal measurement on the same question (Opus, own tools): see Internal fit section "Mesh probe runs" for chamfer, silhouette-IoU and which parts of the loop contributed. + +### A3. Fixes reported, with the evidence strength noted + +| Fix | Where | Evidence | +|---|---|---| +| Documentation retrieval for the API | LL3M BlenderRAG | Table 1, 17 objects (quantitative) | +| Render and critique with a VLM | LL3M, BlenderAlchemy, CADCodeVerify, build123d-mcp | LL3M qualitative; CADCodeVerify and build123d-mcp report gains (self-reported) | +| Stronger verifier / verifier scaling | BlenderGym abstract | stated in abstract, no numbers read | +| Shared code context, edit not rewrite | LL3M Fig. 17 | single qualitative example | +| Scene graph to numeric layout constraints | SceneCraft | search-summary | +| Reusable function library growing over time | SceneCraft | search-summary | +| Incremental build, one feature per call | build123d-mcp page | vendor advice | +| Procedural generator as the substrate the LLM parameterizes | 3D-GPT / Infinigen | search-summary | +| Numeric fit tools beyond renders (plane fits, refine, winding-number labels) | internal mesh probe run 1 | see Internal fit | + +### A4. Blender MCP servers + +- ahujasid/blender-mcp: nine tools (execute_blender_code, look, get_scene_info, generate_3d, search_assets, import_asset, ...); `generate_3d` fronts Hyper3D Rodin, Tripo and Hunyuan3D; Poly Haven, Sketchfab and Poly Pizza import. README gives no quality guidance for generated models; its limits advice is "Complex operations might need to be broken down into smaller steps" and to save before running code. https://github.com/ahujasid/blender-mcp (read). +- Official Blender Lab MCP add-on exists (a Japanese write-up, search-summary): https://techfeed.io/entries/67e9c23aad5903884eb209b7 +- No user report of Claude or GPT building spaceships through Blender MCP with proportion or symmetry findings was located; the one hit was a vendor-style post, https://dev.to/githubopensource/blendermcp-unleash-the-power-of-ai-in-your-3d-modeling-workflow-1ooa (search-summary, promotional). + +## B. Primitive and grammar approaches to ships + +### B1. Procedural ship generators + +- **a1studmuffin/SpaceshipGenerator** (MIT): start from a box; repeatedly extrude front and rear faces with random translate/scale/rotate; extrude random faces with shrinking scale for asymmetry; classify faces by orientation and add engines, antennae, turrets, lights; optional mirror; Bevel modifier; global-space cube UVs plus a greeble normal map and emissive window maps; seeded. README gives no parameter values or orientation rules; those are in `spaceship_generator.py`. https://github.com/a1studmuffin/SpaceshipGenerator (read). Forks: panzi, ldo, David-Kasilia (search-summary). A paid Geometry Nodes alternative exists on Blender Market (search-summary). +- **Kinnear and Kaplan**, "Procedural generation of surface detail for science fiction spaceships", Computational Aesthetics 2010: https://cs.uwaterloo.ca/~csk/publications/Papers/kinnear_kaplan_2010.pdf. Already read in full by the earlier greebles pass: GameCult.Geometry.Csg `docs/research/greebles-prior-art.md` section 2 (commit 4877300, branch origin/eureka/brush-tree). Not repeated here. +- The same greebles file records community hard-surface workflow order (blockout, primary volumes, panel breaks, boolean cuts, bevel, weighted normals, greebles), Hard Ops / Boxcutter / KIT OPS cutters, DECALmachine, and per-title poly figures (e.g. EVE cruisers about 5k polys, CCP dev post, search-summary there). +- Shape and split grammars for vehicles (Wonka et al. 2003, Mueller CGA 2006): no vehicle-specific grammar source was fetched in this pass. Gap. + +### B2. Modular-part ship builders in games + +- **Kenney Space Kit**: CC0, 150+ models (modular parts, ships, turrets, characters), OBJ/FBX/DAE/STL/glTF. https://kenney-assets.itch.io/space-kit , https://opengameart.org/content/space-kit (search-summary). Also a 40-piece CC0 Modular Space Kit, https://opengameart.org/content/modular-space-kit . +- **Cosmoteer**: grid-based module placement with mirroring and copy/paste; modules and weapons defined in text files and moddable; multi-part weapons. https://steamcommunity.com/sharedfiles/filedetails/changelog/2882914000 , https://wiki.cosmoteer.net (search-summary). Its art-style rationale was not found. +- **Starfield ship builder**: snapping to attach points with per-module attach rules and an overlap-tolerance INI setting; players report modules looking attached when they are not. https://gamesradar.com/starfield-unattached-modules , https://primagames.com/tips/how-to-find-unattached-modules-in-starfield (player/guide anecdote, search-summary). No Bethesda design source found. +- Starsector, Space Engineers, Avorion: nothing fetched. Gap. + +### B3. What makes low poly read as deliberate (practitioner opinion, no controlled study found) + +- https://bugnet.io/blog/low-poly-3d-a-style-guide-for-small-teams (search-summary): commitment is the dividing line; smoothing groups over low geometry gives a "cheap mobile game" impression; silhouette carries the read once interior detail is stripped, exaggerate proportions; set facet density per asset class and keep it even across the scene; a shared palette (often one small palette texture) unifies assets; flat shading is the style's signature; harsh light transitions on flat faces are mitigated with subtle vertex colour. +- https://gamedeveloper.com/design/how-to-make-low-poly-look-good and https://rocketbrush.com/blog/low-poly-art-in-games (listed by search, not read). +- The vendor claim that a stylized look reads as intentional while a half-finished photoreal one looks broken is from a vendor page (summerengine.com) and is marketing. +- Internal operator statement on look: `aetheria-release:ruling:panel-lines-are-geometry` and the shippable bar (Internal fit) describe the operator's own finishing standard (one-segment bevel, face-weighted normals); nothing in the mind rules a low-poly flat-shaded style for Aetheria. + +## C. Skill and prompt packs for 3D work + +- **cc-blender-skill** (roble3): 30 chained Claude Code skills over a Blender MCP backend (official Blender Lab MCP or ahujasid). Groups: orchestration (text-to-blender, harmonizer, quality-refinement-autoloop, blender-pro-workflow), core production (modeling, materials, lighting, cameras, rendering, animation, export), reference-driven reconstruction (multiview-fit-loop, orthographic-registration, fit-repair-optimizer, landmark-fit-repair, contour-to-mesh, reference-analysis-validator with IoU/SSIM/bbox/centroid), UV and texturing. Rules: check backend status and stdout before reporting success; fixed backend per task; surface-coverage gate on closed assets; per-skill trigger evals (100% true positive, 4% false positive reported by the page). Documented limits: "design quality is not build correctness", primitive-built faces read as abstract avatars, numeric checks can pass while the render looks wrong. It does not name proportions, symmetry or interpenetration as failure modes. https://cdn.jsdelivr.net/gh/roble3/cc-blender-skill@main/README.md (read). +- **claudskills.com blender-3d**: general SKILL.md covering headless bpy, bmesh, geometry nodes, Cycles/EEVEE, export, MCP; listing does not mention a render-verify loop. https://claudskills.com/skills/blender-3d/ (search-summary). +- **davincidreams Blender skill**: interface, modeling, Unity/Unreal export pipelines. https://claudemarketplaces.com/skills/davincidreams/agent-team-plugins/blender (search-summary). +- **Snyk roundup** of Claude skills for 3D / game dev: https://snyk.io/articles/top-claude-skills-3d-modeling-game-dev-shader-programming/ (listed, not read). +- **hard-surface skill** (arjun988-blender-skills), cited by the greebles file for the community order blockout, primary volumes, panel breaks, booleans, bevel, weighted normals, greebles: https://www.claudepluginhub.com/skills/arjun988-blender-skills/hard-surface (single page, via greebles-prior-art.md). +- No skill was found with an explicit low-poly mode or a documented render-and-compare gate as its core, per the search summary. + +## Internal fit + +### Rulings in force (read titles from the mind, schema `epiphany.pipeline.ruling.v2`; 295 in force across the two campaigns, 136 matched hull/material/model terms; those bearing on this ruling) + +aetheria-release: +- `hull-programmer-art-stopgap` (2026-10-18), quoted above. +- `hull-paint-mask-texture` (2026-10-03): operator quote "I want a mask map per ship for livery, I'll be rebuilding the nonsense geometry and unwrapping anyway, I want these to stand up to my own authoring standards even if tripo does a lot of the leg work." Ruling: per-ship mask texture on UV0, so any stopgap hull needs a UV0 unwrap if it is to take the livery mask. +- `demo-content-bar` (2026-10-06): one player-usable hull per cast faction plus enough NPC role hulls (haulers, tenders, miners) to sell each faction's play style, boss hull, a dockable station. `content-bar-one-per-concept` (2026-10-03): 13 flyable hulls, 3 stations for the full game. +- `demo-material-scope` (2026-10-06): demo hulls ship baked maps, a mask and the one hull shader. +- `ship-authors-need-no-unity` (2026-10-06), `ships-editor-then-build` (2026-10-10), `ship-roles-are-tags` (2026-10-09), `thrusters-radiators-are-meshes` (2026-10-03), `schematic-and-model-are-separate-spaces` (2026-10-13), `schematic-is-designated-mesh` (2026-10-03), `ship-data-all-in-cc` (2026-10-03, after-demo per `demo-ship-cc-timing`). +- `hardpoints-and-floating-greebles`, `hardpoint-sizes-are-footprint-palette`, `radiator-panels-molded-by-geonodes` (2026-10-13), `hardpoint-work-held-for-walkthrough` (2026-10-13, hardpoint socket work held until the operator shows the current mount model on Starfire). +- `panel-lines-are-geometry` (2026-10-12). +- `hull-concept-keepers-2026-10-08`: Bison, Squatter, Oboz r8f, Shchit, Key Light r8a, Tattletale. +- `addon-tripo-fixture` (2026-10-08): a sample Tripo GLB is the add-on's bake test fixture. +- `mesh-probe-exploit-not-measure` (2026-10-10), operator quote: "I'm not interested in measuring Opus's spatial reasoning performance, in interested in exploiting it. Give it access, let it build and use tools." Also `mesh-probe-both-vocabularies`, `brokkr-mesh-probe-ag-self`. + +brush-tree: `openscad-target` ("Trees are authored in OpenSCAD-shaped text"), `brokkr-beats-blender-mcp`, `cli-not-mcp`, `representation-per-hull` (subd limit surfaces for continuous bodies, primitives for mechanical hulls), `hand-cages-enter-the-tree`, `shippable-part-bar` ("clean unbroken flow, part-coherent low-distortion UVs, one-segment bevels with face-weighted normals"), `bake-only` (hulls lowered offline in Blender; no tree ships), `lowered-hull-through-package`, `art-tools-not-cad` (2026-10-14, "drop the cad thread, let's stick to art tools"), `no-unownable-tools-in-loop`, `tree-store-per-ship-cc`, `weld-*` and `panel-lines-by-split-boolean-r2`. + +### Brush tree (campaign `brush-tree:campaign:self`, repos GameCult.Geometry.Csg, Brokkr, Aetheria; branch `eureka/brush-tree`) + +- Target doc: `docs/brush-tree-target.md` r4 on GameCult.Geometry.Csg `origin/eureka/brush-tree` (40faab6); the campaign's recorded commit 49948c8 predates r4 and the file is not in the Aetheria or Brokkr checkouts. r4 describes: a tree per ship; each part `cage` or `kernel`; detail list of brushes with modes add / subtract / split; Blender evaluates cage Mirror and Subdivision and finishes with Bevel then Weighted Normal; Geometry.Csg does booleans and a local-owner weld run as a Rust wheel feeding a live result object. Text for a cage part is "not yet known"; OpenSCAD cannot render the limit surface or the weld (target, "Not yet known"). +- What the branch contains (git log, 12 commits read): target revisions and research notes only. No implementation commits were seen in those 12. +- What `main` of GameCult.Geometry.Csg contains: `LevelDsl` with solid box, cut box, oriented boxes, cylinder_z, dome_cap_z, floret_arm; ordered additive/subtractive/intersect brush streams; mesh-array output; signed distance over the brush stream; OBJ/MTL/SVG example outputs (README; `src/dsl.rs`, `src/primitives.rs`). `grep -ril openscad src` found nothing: no OpenSCAD parser in the crate. +- Brokkr: main has an agent-access cut (A1-A5 merged), a `surfaces/blender/brokkr_bridge` add-on target and a capability-bar note vs Blender MCP servers (`docs/research/blender-mcp-capability-bar.md`). Whether `brokkr-ctl blender run` exists as the ruled CLI was not established in this pass (README mentions sync commands only in what was grepped). +- An agent can author a tree as text today only as OpenSCAD, which exists as an installed tool and as a probe vocabulary (below); the typed brush-tree text surface is ruled but its implementation was not found. + +### Mesh probe runs (internal prior art on Opus building hulls from primitives with tools) + +Files on `origin/eureka/brush-tree`, `docs/research/mesh-probe-run1/RESULT.md` through `mesh-probe-run7-im-field/`: +- Run 1: agent rebuilt Tripo-derived hulls in OpenSCAD and in Geometry.Csg brushes from a measured target, with its own measurement tools. Best chamfer (percent of diagonal): control 0.015 (Csg) and 0.029 (OpenSCAD); Dexter Quiet 0.266 (OpenSCAD, table-of-offsets form) and 0.463 (Csg, 29 brushes); Dexter Quiet compact OpenSCAD 0.682; Headliner 1.121. Report states the gain came mostly from tools (numeric refine, plane fits, winding-number labels), render critique contributed less; control tests numeric recovery of a known structure, not discovering structure; run 2 is not independent of run 1; edit test discriminates only on the control. +- Run 1 constructions: superellipse stations, planform points, wing planes, pods as hulls of spheres, chin fin (run 2 "Input" list). +- Run 2 (shippable lowering): the OpenSCAD compact file (2836 triangles via Manifold) was lowered three ways (OpenSCAD plus Blender cleanup, Blender script, Geometry Nodes) against the operator's bar; contact sheets and metrics are in that directory. Result text was not read in full in this pass beyond its headline description. +- All of this targets reconstruction against a reference mesh, not invention without a reference. + +### The ships Blender add-on and mod hull package + +- Add-on: `F:\Projects\Aetheria\tools\blender\aetheria_ships\` (`__init__.py`, `hull_grid.py`, `ship_cc.py`). Operators present: Load Ship Layout, Resize Grid, Add/Remove Hardpoint, Add Weapon, Add Muzzle, Mirror Mount, Remove Mount, Save Layout to .cc, Bind Ship Collection, Capture Ship Lines, Rasterise Hull, Flip Nose, Bake From Source, New Ship, Package Ship. +- Release-map specs not yet landed (aetheria-release-map.md): `ships-addon-mesh-mounts`, `ships-addon-gizmos`, `ships-addon-link-package` (line 45, 63, 65), `ships-addon-paint` (line 47, blocked on `ships-hull-material`, premise obsolete under `mask-channels-place-materials`), `ships-addon-mask-bakes` (46). `ships-addon-mounts` merged at 26cdd578 (line 45). +- What a mod hull needs beyond the mesh (docs/moddable-ship-authoring.md lines 88-215; docs/moddable-ships-cut.md B.3 S1-S2): + - a `.cc` with a `HullData` (cell shape, hardpoint footprints, stats) and a `ShipAuthoring` visual record (model path, anchors, schematic lines); `HullData.Visual` binds them; + - `ship.glb` beside the `.cc` for the demo path (the all-in-.cc move comes after per `demo-ship-cc-timing`); + - anchors tagged `aetheria.role` and `aetheria.id` (mount id) for weapon, radiator, thruster mounts only (`thruster-emitter`, `radiator-mesh`, `weapon-mount`, `weapon-muzzle` ...); a thruster or radiator anchor must be a mesh, the thruster's not rendered; each weapon needs a muzzle anchor with `ParentId` equal to its mount id; + - internal hardpoints carry no anchor, and an anchor taking their id is refused; + - validation `AetherDb ship-authoring validate` judges the GLB nodes; + - a playable package has hardpoints; the Quiet probe has four anchors and zero hardpoints (moddable-ship-authoring.md, S3 note in moddable-ships-cut.md); + - materials: demo uses baked maps, a livery mask on UV0 and one hull shader (`demo-material-scope`, `hull-paint-mask-texture`). +- A prior programmatic GLB exists: the Unity batch smoke uses a generated `skiff.glb` with a tetrahedron mesh plus collider and thruster anchors (moddable-ship-authoring.md lines 256-268). + +### Release map lines (aetheria-release-map.md) + +- `demo-cast-hulls` (line 43): the operator's five packages under GameData/Mods, each its maker's ship; blocked on package-product, boss-hull-derived and operator packages. +- `hardpoint-socket-palette` (73): one socket brush per standard footprint shape fixing pivot, facing, clearance, scale per mount, molded into the hull in the brush tree; waits on brush-tree weld; held for the operator walkthrough. +- `gear-models-per-footprint` (75): item records gain a model reference; weapons, launchers and thrusters get gear models built to their footprint; held. +- `floating-greeble-library` (77): operator seed pieces, model-written parametric families, placements as hull-local data, merged at export, kept out of socket clearance; waits on the operator seed set; research `greebles-prior-art.md` (4877300). +- Related lines: `hull-bakes` (23, material lowering held post-demo), `ships-hull-livery` (26), `ships-studio-schematic` (25). +- In the same document, the Tripo timing is stated: "one hull a day, and that clock starts only when the first Tripo mesh flies" (moddable-ships-cut.md line 358 context; text not read beyond the match line). + +### OpenSCAD + +- Nightly 2026.09.23 at `C:\Tools\OpenSCAD-2026.09.23-x86-64\`; use `openscad.com --backend Manifold --render all` (memory `openscad-nightly-installed`). Run 2 reports a 2836-triangle model rendering in 2 s with that backend. + +## Known gaps + +1. BlenderGym per-task scores and the VLM failure patterns were not read (PDF fetch returned binary); only the abstract. +2. LL3M symmetry and interpenetration findings: the paper is silent; no other source on those two failure modes for LLM-written Blender code was found. +3. No independent report of Claude/Opus driving Blender (MCP or bpy) to build vehicle or ship hulls with quality findings; nothing from player/forum sources located. +4. CADCodeVerify, Text2CAD, CAD-Recode, SceneCraft, 3D-GPT, BlenderAlchemy were read only at search-summary level. +5. Shape/split grammars for vehicles (CGA, Wonka) and any LLM + ShapeAssembly-style cuboid program work were not fetched or not found. +6. Starsector, Space Engineers, Avorion, Cosmoteer art rationale, Starfield developer design sources: not found. +7. Low-poly "reads as deliberate" evidence is practitioner blogs only, no controlled study; gamedeveloper.com and rocketbrush pages were listed but not read. +8. Skill packs other than cc-blender-skill were read at listing level only; none inspected for 3D prompt content on proportions or symmetry. +9. Internal: no implementation of the brush tree (OpenSCAD-shaped text, cage parts, weld wheel) was found in the commits read; the typed text surface of the tree and the `brokkr-ctl blender` CLI status were not established. The Geometry.Csg crate has no OpenSCAD parser. +10. Internal: mesh-probe runs 2 to 7 result bodies were not read in full; `brush-tree-target.md` at the campaign's recorded commit (49948c8) was not retrieved, r4 at 40faab6 was. +11. Internal: whether the 13-hull full-game set, the demo's hulls or only the demo's NPC role hulls are the stopgap's intended scope is not stated in the ruling. +12. Tripo-specific failure documentation (vendor or user sources on detail faceplants) was not searched; the operator's ruling is the only statement. From 6d10331c05478b93a0e0464b734d0da1f313ba0b Mon Sep 17 00:00:00 2001 From: Meta Vens Date: Sun, 18 Oct 2026 17:24:54 +0200 Subject: [PATCH 004/444] aetheria-release map: boss hull derived, product tiers, run seed (body facts BH/PT/RS, cut sequence; InfluenceScale moves to SavedGame key 15) Co-Authored-By: Claude Opus 5.5 --- docs/aetheria-release-map.md | 413 ++++++++++++++++++++++++++++++++++- 1 file changed, 407 insertions(+), 6 deletions(-) diff --git a/docs/aetheria-release-map.md b/docs/aetheria-release-map.md index bf181b4d6..ae5c32760 100644 --- a/docs/aetheria-release-map.md +++ b/docs/aetheria-release-map.md @@ -37,7 +37,6 @@ Cuts without a `cut_spec`, withdrawn 2026-10-12 under map-only-what-runs: a spec - faction-play-4: Faction play 4 (r3): the tender loop on real rounds, full-game scope. A member low on magazines rearms from its tender. Waits on: faction-play-3, ballistic-ammo (blocked on faction-play-3 and ballistic-ammo). - retire-prelude: Retire the prelude and the tutorial: Galaxy.IsPrelude, SavedGame.IsTutorial, TutorialGalaxy and. Waits on: demo-galaxy, boss-gate, retire-longinusx, gear-without-presence (refresh needed (96-commit drift)). - autofire-threshold: Missiles 3 (r5): autofire in the sim. Each autofire gun picks its subject through ChooseSubject (munitions first) and. Waits on: munition-shots (blocked on munition-shots). -- boss-hull-derived: A faction's boss hull is derived (r2): HullData.Boss on a hull the faction makes a product of, set by its own. Waits on: boss-gate, pirates-record, package-product (all merged; not blocked). Runs after faction-pair's faction-play-1 and maker-only-faction, which edit Faction in Corporations.cs and the main galaxy's faction draw in Galaxy.cs; like every catalog command it also edits tools/AetherDb/Program.cs. No Boss hull exists until the operator's Zhestokost boss package (follow-up zhestokost-boss-hull). Body facts: Products PL5-PL6. - autofire-controls: Missiles 3b (r2): the player sets autofire; the HUD marks munitions and autofire groups; the Point Defense scenario. Waits on: autofire-threshold, controls-hud, pd-gear (blocked on autofire-threshold and controls-hud). - broadside-gunboat: Broadside returns on a real gunboat: the operator's Zhestokost line-gunboat package (side-rotated Ballistic hardpoints). Waits on: mods-in-tree, package-product (blocked on package-product and the operator package). - demo-cast-hulls: Demo cast hulls land (r3): the operator's five packages under GameData/Mods, each its maker's ship under its own name. Waits on: mods-in-tree, pirates-record, package-product, boss-hull-derived (blocked on package-product, boss-hull-derived and operator packages). @@ -54,8 +53,12 @@ Cuts without a `cut_spec`, withdrawn 2026-10-12 under map-only-what-runs: a spec - matrix-flamers: Weapon matrix, Jet row: Light Flamethrower, Flamethrower, Heavy Flamethrower and Inferno Projector, thermal pellet guns. Waits on: nothing in the mind (refresh needed; ruling catalog-breadth-now (superseded catalog-breadth-after-visibility 2026-10-13): runs now, refresh against master first). - matrix-mines: Weapon matrix, Mine row: Mine Rack, Heavy Mine Layer and Minefield Projector beside the Mine Launcher, with warhead and. Waits on: roles-backfill (refresh needed; ruling catalog-breadth-now (superseded catalog-breadth-after-visibility 2026-10-13): runs now, refresh against master first). - gear-lines (spec cut-gear-lines.r1, remaps withdrawn product-lines-gear): 22 second-maker gear products as 'product-lines gear' in ProductLinesCommand. The follow-up's rows less its two Compact Sensor rows, plus Medium Drive, Large Drive and Industrial Thermostatic Heater, so every role-bearing ship gear design that is sold has two makers (Muon Fusion and the four station reactors exempt). The command's no-roles refusal exempts cargo bays. Waits on: nothing; runs now. Replay: 'product-lines gear apply', either side of minor-power-factions, before assembly-from-maker. Body facts: Products PL3, PL10-PL12. -- product-tiers: A maker sells several products of one design at different performance (ruling maker-design-tiers-allowed, 2026-10-16). In the operator's words, "segmentation via binning and sub-assembly selection": no new innovation or component roles. Maps per-tier ProductRole quality and a Price that follows quality, which today comes from the design (question maker-design-tiers, depends). The tier rows go through product-lines' ProductLinesCommand. Product names stay unique (NoTwoProductsShareAName). ProductRole's mean and deviation come from the maker's one MakerProfiles row, so two tiers at one maker need a per-tier offset. That offset is this cut's design, not a second profile table. Ship products are keyed one per maker per hull (ShipModCatalog.ProductKey(id, maker)); whether hull tiers widen that key is this cut's to map, not assumed. Waits on: product-lines-weapons (its command and the deleted test); product-lines-gear (same catalog writer). Since ruling quality-min-tolerance-binned-assembly a tier row may also set its own FactionProductData.Assembly (assembly-from-maker r3 adds the field; no product sets it yet, AFM3). Once quality-readers lands, a lot's price already follows its effective quality, so tiers that differ in role or assembly distributions differ in price with no per-product Price (map 'Quality readers at 70c7a497'); whether ChooseProduct weighs that price is question chooseproduct-affordability (option with-product-tiers). -- run-seed: Drops mix a per-game seed stored in the save (ruling loot-seed-in-save, 2026-10-16). A new game rolls different drops from other games, reloading one save repeats its drops, and tests pass a fixed seed. Today Zone.CombatSeed is the zone name's StableHash (Zone.cs:144). It feeds three dice: the loot dice (Zone.cs:414, LootStream, by death ordinal), the mine lay scatter (FireControl.cs:791, MineStream) and the shot dice (FireControl.cs:930). The ruling covers drops, and the question named loot and mine fragments, so the seed mixes into the loot and mine streams. Whether hit rolls also take it is not ruled: the cut raises it. Do not change CombatSeed itself and carry that along silently. The seed lives on SavedGame, minted by the new-run branch. The run lifecycle is record-level (ActionGameManager.cs:58-60), so the seed must reach Zone at construction from the run, not from the catalog. Owns follow-up loot-dice-run-seed (its old owners, loot-1 r2 and sim-mines, are merged). Waits on: nothing in the mind. +- product-tiers: spec cut-product-tiers.r1 (map section Product tiers). A binned product names its maker's line of one + design and a floor in deviations; the mint draws its parts and its final test from the line above that floor. Six + binned products, no hull tiers. Blocked on question tier-stock-share. +- product-tiers-hulls (later, one line): ship hulls stay one product per maker per hull + (`ShipModCatalog.ProductKey(id, maker)`). A binned hull needs that key widened and its package's product record to + carry a Bin. Not mapped. - mining-yield: Mining Cut 5 (docs/mining-cut-refresh.md 'Cut 5. Composition, the loot roll, the mined lot, and the same-path test agent'). Ruling mining-overflow-floats (2026-10-16) replaces that plan's deposit rule: units a shot extracts go into the shooter's cargo through TryStore, and what the hold cannot take is released through Zone.Release (Zone.cs:356) as floating items, which the player and NPCs collect through the one pickup path. The plan's AFullHoldLosesTheOverflow test becomes AFullHoldReleasesTheOverflow. Mining Cuts 4 and 7 stay with follow-up mining-cuts-4-5-7, which this cut's spec resolves with them. Waits on: mining Cut 4 (FireControl shots at chunks), full game (demo scope table: no buyer for ore in the demo). - boost-gear-combat: Stat boosters, combat, tiered by size: a modest 1x1 and a strong 2x2 each for Damage and Range on every gun through. Waits on: boost-gear-systems (blocked on boost-gear-systems; ruling catalog-breadth-now (superseded catalog-breadth-after-visibility 2026-10-13): runs now, refresh against master first). After damage-falloff: a Range Extender multiplies Range only, so it lengthens the falloff ramp and leaves EffectiveRange where it was; the cut decides whether it also targets EffectiveRange (section "Damage falloff at fire time", DF8). - boost-gear-systems: Stat boosters, systems, tiered by size: a modest 1x1 and a strong 2x2 each for Thrust, Signal, Emissivity and Reactor. Waits on: nothing in the mind (refresh needed; ruling catalog-breadth-now (superseded catalog-breadth-after-visibility 2026-10-13): runs now, refresh against master first). @@ -860,7 +863,7 @@ deletes the flag with no behaviour change except where named. **Why `IsPrelude` is split by rule, not renamed.** It bundles four unrelated rules, and each goes to its owner: - influence halving -> the cast's own `InfluenceScale` (the demo's cast sets .5, so its galaxy is - unchanged), carried by the Galaxy and the save (key 14) because later zones read it (PR2); + unchanged), carried by the Galaxy and the save (key 15) because later zones read it (PR2); - the entrance station -> `Scenario.EntranceStation`, a declaration beside `Ambient`, default true, false for MainGalaxy, so every scenario's arena is unchanged; RunStart.GenerateArena applies it; - availability -> the general rule of gear-without-presence; @@ -875,7 +878,7 @@ supplier with no local presence carries the largest trade cost, so it is reachab preferred. Zero distance (today's accident) would make absent makers the most preferred, inverting the rule. -**Old saves.** Key 14 absent decodes as 0 and the save constructor refuses a non-positive scale by +**Old saves.** Key 15 absent decodes as 0 and the save constructor refuses a non-positive scale by name. Per ruling `no-save-compatibility-before-players` nothing reads key 11. ### Model page rows @@ -894,7 +897,7 @@ name. Per ruling `no-save-compatibility-before-players` nothing reads key 11. | Ligatures per text | Each TMP component's `fontFeatures` | `liga` added once by `EmojiFont.EnableLigatures`; new components inherit TMP Settings | `EmojiFont.EnableLigatures`, then the component | | EmojiOne sample | none | Deleted by `emoji-font` | none | | Galaxy cast (replaces `TutorialGenerationSettings`) | A scenario's code constant (`DemoTerminus.Cast`) | Compiled with the game; no authored settings copy | The scenario | -| `Galaxy.InfluenceScale` | Its galaxy | Set by the constructor (the cast's value, 1 for main), saved as `SavedGame` key 14 | The cast | +| `Galaxy.InfluenceScale` | Its galaxy | Set by the constructor (the cast's value, 1 for main), saved as `SavedGame` key 15 | The cast | | `Scenario.EntranceStation` | Its scenario | Compiled | The scenario; `RunStart` applies it | | `SavedGame` key 11 (`IsTutorial`) | Retired | Not read, not written | none | | `Galaxy.IsPrelude`, `TutorialGalaxy`, `TutorialGenerationSettings`, `GameSettings.TutorialGenerationSettings` | Deleted by `retire-prelude` | | none | @@ -10779,3 +10782,401 @@ worktree C:\aeth-wt\ag6-quality-readers at 70c7a497. Catalog: GameData/Aetheria. mind-commit-f9d28914...), base 70c7a497. The question is raised in follow-up quality-layers-combine, not in the spec, so the spec does not wait on it. context-pack: 14 anchors SAME. + +## Boss hull derived + +Probes and source reads by imagination-ag6-boss-hull (session self-2026-10-18-ag6), 2026-10-18, +in the detached worktree `C:\aeth-wt\ag6-boss-hull` at `origin/master` `eb27ced1`. `origin/master` +moved to `ac835a8a` during the pass. That commit adds only +`docs/research/2026-10-18-agent-built-hull-art-prior-art.md`, so every anchor below is the same +at both. Read by `cut-boss-hull-derived.r3`. This section supersedes the anchors of "Demo hulls, +the Pirates and the boss" H7 (`@2a5fc00b`) and of Products PL6. Its design rationale +("The boss hull is derived, not named") stands, with the changes recorded under Rationale below. + +### Body facts + +- **BH1. What the cut waits on is on master.** boss-gate is merged at `39c7508e` + (cut-boss-gate r2, verdict s3). faction-play-1 is merged at `d7f30ed1` (r3, h6), and its catalog + replay is at `aa12cbac`. minor-power-factions is merged at `c486b06d`; it is the remap of + maker-only-faction. pirates-record is merged at `2c065937` and package-product at `3535660c`. + No cut that edits Faction or the main galaxy's faction draw is still in flight ahead of this + one. +- **BH2. Faction keys at `eb27ced1`** (`Corporations.cs`): 1-5, 7, 8, a retired-key comment for 9 + (`:39`), `BossHull` Key(10) at `:42-43`, then 11-15, 17 (Doctrine), 19 (Assembly) and 21 (Places). +- **BH3. HullData keys** (`ItemData.cs:531-556`): 23, 24, 25, retired 26 (`:542`), 27, 28, 29 and + 32 (`Visual`, `:555-556`). Key 33 is free on HullData. Fuse's Key(32) at `:526` is on a sibling + type. +- **BH4. Every BossHull reader at `eb27ced1`** (`git grep BossHull`): + `Galaxy.cs:293` (`PlaceFactionsMain` picks boss factions by `BossHull.IsSet()`, `:291-295`); + `LoadoutGenerator.cs:212-230` (`GenerateBossLoadout`); + `tools/AetherDb/Program.cs:586` (dangling comment) and `:709-712` (the factions boss column and + its `broken` count, the method's only use of `broken`, `:700-716`); + `RunGoalTests.cs:74-115` (two tests) and `:199-202` (Exit_open_only_in_the_exit_zone sets one). + No writer exists: no AetherDb command, no Pirates or minor-power command, no package path. + `GameData/Aetheria.cc` holds the string `bossHull`, which is its schema table. +- **BH5. Retiring a key with stored values needs no catalog rewrite.** Precedents: `1d689adf` + retired Faction key 9 and `be7f2b07` retired HullData key 26, and neither touched + `GameData/Aetheria.cc`. The suite loads the shipped catalog in `RunStartTests`' constructor. + This is a precedent read; no decode was run in this pass. +- **BH6. Every generation hull draw goes through one availability rule.** `RandomHull` + (`LoadoutGenerator.cs:165-170`) calls `RandomProduct`, then `RandomProducts` (`:176-185`), then + `AvailableProducts` (`:188-210`). Station stock (`:126-127`) and the boss fallback (`:226`) also + go through `AvailableProducts`. Its `candidates` filter (`:191-198`) runs before the + any-manufacturer fallback (`:201-206`). An exclusion placed there therefore holds on the + fallback too. `ChooseProduct` (`:262-273`) has its own `Where` and serves presets that name a + design. +- **BH7. Explicit hull draws.** `ScenarioStage.Generated` (`ScenarioStage.cs:90-97`) and AetherDb + `loadout` (`Program.cs:805-818`, which runs every hull through `GenerateShipLoadout(candidate + => candidate == hull)`) both reach `AvailableProducts`. Many tests call + `GenerateShipLoadout(h => h == hull)` on shipped hulls (for example `RestoredHullsTests.cs:606`). + None of those hulls is a Boss hull. +- **BH8. The boss path.** `ZoneGenerator.cs:398-412` calls `GenerateBossLoadout` for every + `BossZones` entry in the zone and marks the result `Boss`. `Galaxy.cs:150` sets the prelude and + demo antagonist's boss zone without reading any hull. `Galaxy` holds `_cache` (`:36`, set at + `:51`, `:102` and `:169`), so `PlaceFactionsMain` can call a static derivation over the catalog. +- **BH9. The package writer.** `ShipAuthoringCommands.cs` (157 lines): `Usage` at `:11`, `inspect` + at `:29-35`, `price` at `:79-93` (it judges before it writes, and a refusal leaves the file + byte-identical), and `product` at `:94-125`. `ShipProductTests.ProductAndPriceCommands` + (`:259-300`) pins the price and product commands with `RefusedAndUntouched`. Compose copies a + package's HullData verbatim, so a member set in the package reaches the composed catalog. + `ComposeCopiesEveryProductAndEachMakerIsOfferedTheHull` (`:80-100`) byte-compares products. +- **BH10. Zhestokost makes no ship hull.** Products PL6 still holds: its only hull product is the + Turret. Until a Zhestokost Boss hull exists, the demo boss is the logged fallback (the priciest + ordinary ship hull Zhestokost can be offered). +- **BH11. Which hulls can be bosses.** Ruling `hull-programmer-art-stopgap` (2026-10-18) leaves + open whether boss hulls come from the operator's packages or an agent-built stopgap. The Eyes + facts file at `ac835a8a` places stopgap art on the package path. Nothing in the derivation + reads `Prefab`, `Visual` or a model, so a hull from either source, or with neither, can be a + boss. + +### Rationale + +- **One owner of which hull is a faction's boss:** `LoadoutGenerator.BossHullsOf(CultCache, + Faction)`. It returns the faction's products whose design is a `HullType.Ship` HullData with + `Boss` set, ordered by hull Price descending, then hull Name ordinal. The flag is authored on + the hull, and the faction comes from the product's maker. Nothing stores the pairing. + `GenerateBossLoadout`, `Galaxy.PlaceFactionsMain` and AetherDb `factions` all call it. +- **One owner of "a Boss hull is never on offer":** `AvailableProducts`' candidate filter (BH6). + r2 put the exclusion on `RandomHull` and the stock filter, and gave `RandomHull` a boss bool. + That made two sites and one mode flag, and it missed the fallback's own + `AvailableProducts` call. r3 uses one clause instead. The boss is built straight from its + product through a new `ShipLoadout(FactionProductData)` builder, which is the body of + `GenerateShipLoadout` after its draw. So the boss path never asks the availability rule to + make an exception. +- **The "named but unsold" branch goes.** It existed because BossHull named a design. A + derivation that starts from products cannot name an unsold hull, so its log line and its test + (`Boss_hull_nobody_sells_falls_back`) are deleted. They are not rewritten. +- **A boss is not a market draw.** `ShipLoadout` from the boss product does not apply + `AvailableProducts`' `Price > 0` rule. A price-0 Boss hull is still a boss. +- **Kept:** a sold Boss hull's hardpoints still count in `SoldHulls`, so gear only it can mount has + a home. That gear is loot. `ScenarioStage.Generated("")` fails like any unavailable + hull. `ChooseProduct` is untouched. +- **Prior art (from memory, medium confidence; no Eyes pass this round):** Starsector tags unique + and boss hulls so markets never sell them. They are spawned by script, outside a faction's + known-ships draw. EVE's officer spawns fly hulls that no market sells. Both match the exclusive + reading. Question `boss-hull-exclusive` asks the operator to confirm it. + +### Model page rows + +Replace the two rows in "Demo scope, Model page rows" (`Boss hull`, `Faction.BossHull`) with: + +| Kind | Identity | Lifecycle | Authority | +|---|---|---|---| +| Boss hull | `HullData.Boss` (key 33, bool) on any HullData, whether shipped, package, or agent-built stopgap | Set by `ship-authoring boss on\|off` on packages, or in Studio's inspector on shipped hulls. Derived per generation; stored nowhere | The hull's author sets the flag. `LoadoutGenerator.BossHullsOf` derives the faction from the product's maker | +| `Faction.BossHull` (key 10) | Retired; never reuse | Deleted by `boss-hull-derived`. The shipped records keep `''` in the slot (BH5) | none | + +### Cut sequence + +Remove the `boss-hull-derived` line. Change the `demo-cast-hulls` line's "boss-hull-derived" in +"Waits on" to "boss-hull-derived (spec cut-boss-hull-derived.r3)" until it merges. + +## Product tiers + +Ruling maker-design-tiers-allowed (operator: "segmentation via binning and sub-assembly selection ... no new +innovation or component roles"). Rulings that constrain this cut: quality-min-tolerance-binned-assembly (min(part, +assembly); a product may carry its own assembly), effective-quality-mean-of-roles and price-follows-effective-quality +(price and tier read Lot.EffectiveQuality, since quality-readers), quality-triangle-is-per-facility (paying for quality +in cost or throughput is later crafting work, set per facility), outfitting-affordability-waits-for-budget +(ChooseProduct gets no cost term), design-only-weighted-draw (ProductWeight is the draw's one weight), +named-components-after-demo (role fills stay a statistical stand-in). + +### Body facts (probes and source reads at eb27ced1) + +- PT1 (source read, `Assets/Scripts/ServerShared/ItemManager.cs:130-154`): `Mint` draws `Lot.Quality` from + `product.Assembly ?? maker.Assembly ?? new AssemblyDistribution()` and each role fill from the product's + `ProductRole` for that role (default `new ProductRole()`, mean .5), both through `Random.NextGaussian` clamped to + [.01, 1]. A tier needs no game change to differ in quality: a product's own Roles and Assembly already drive the + draw. `CreateLot(product, quality)` (stated) draws nothing. +- PT2 (source read, `ItemManager.cs:114-119, 226-236`; `Settings.asset:221-224`): price is + `QualityPriceModifier.Evaluate(EffectiveQuality) * design.Price`, with `ExponentialLerp` minimum .25, maximum 8 and + exponent 3, so the multiplier is `.25 + q^3 * 7.75`. Tier thresholds are Common .3, Uncommon .45, Rare .6, Epic + .75 and Legendary .9 (`Settings.asset:191-216`). A tier product prices from its lots with no per-product Price. +- PT3 (catalog probe, `scratchpad/imagination-ag6-product-tiers/cat3.py` decoding `GameData/Aetheria.cc` with + msgpack): 153 products, and no maker sells two products of one design. FactionProductData's schema in the file has + slots 1-6 (6 = Assembly), and no product sets Assembly. Faction.Assembly (slot 19) is set for every maker except + Ewan Hart. Examples: Finch .76±.19, Lucent .66±.15, AU .64±.13, R&D .68±.15, NiteLife .66±.13, Zhestokost .62±.10, + Megiddo .85±.05. The six candidate lines each have two roles at their maker's profile: Sparrow (Light Laser, Finch + .65±.20), Spotlight (Light Laser, Lucent .60±.15), Rattler (Light Machine Gun, AU .50±.12), SM-6 Marksman (Slug + Rifle, R&D .60±.15), PowerHour 2.0 (High-Yield Reactor, NiteLife .55±.12) and Juggernaut (Large Drive, Zhestokost + .50±.08). +- PT4 (source read, `LoadoutGenerator.cs:176-184, 255-272`): `ProductWeight(product)` is the maker's preference, used + per product by `RandomProducts` (stock and generated fits) and `ChooseProduct`. A second product of one maker and + design would double that maker's weight for the design. +- PT5 (source read, `tools/AetherDb/ProductLinesCommand.cs:12-181`): a row is (design, name, maker, text), and its + roles are written at `Program.MakerProfiles[maker]`. A product's key is a hash of its name. The command already + accepts a maker that sells the design. `NoMakerSellsTwoProductsOfOneDesign` is no longer in the tree (grep at + eb27ced1). `NoTwoProductsShareAName` stands (`ProductLinesCatalogTests.cs:36-46`). +- PT6 (source reads): three tool paths and two catalog tests assume that every product authors every role of its + design. They are the census role-gap loop (`Program.cs:131-145`), roles-migrate (`Program.cs:367-380`), + roles-backfill (`Program.cs:2219-2234`), `CatalogRoleTests.ShippedCatalogRolesAreLive` (`:62-67`) and + `RolesBackfillIsAuthored` (`:264-282`, which reads the Large Drive). `AssemblyFromMakerCommand.Derive` averages + product Roles, so a product with no roles adds nothing to it (`AssemblyFromMakerCommand.cs:23-34`). + `AssemblyFromMakerCatalogTests:25` asserts that every shipped product has a null Assembly. +- PT7 (Monte Carlo, `mc.py`, 200k lots, 3 roles, moment-matched slices): this repeats Soul's s1 finding that the + final test alone barely matters. For Lucent, binning only the final test to its top half lifts mean effective + quality .556 to .591. Selecting only the parts lifts it to .639, because assembly caps the parts. Selecting both + lifts it to .704. A tight-QC maker has little to bin: for Megiddo (±.05) the best bin lifts .747 to .790. +- PT8 (Monte Carlo, `mc2.py`, 200k lots, exact truncation by rejection at mean + z·sd, the six PT3 lines at their + catalog distributions). The table below shows the line, then the top half (z 0) and the top quarter (z .67) of + parts and final test, each as mean effective quality / mean price multiplier / worst tier / tier shares: + + | line | line | top half (z 0) | top quarter (z .67) | + |---|---|---|---| + | Sparrow (Finch) | .587 / x2.04 / Common | .781 / x4.02 / Rare (Epic .64) | .883 / x5.62 / Epic (Leg .35) | + | Spotlight (Lucent) | .543 / x1.61 / Common | .694 / x2.87 / Rare (Rare .88) | .773 / x3.85 / Rare (Epic .73) | + | Rattler (AU) | .479 / x1.17 / Common | .592 / x1.88 / Uncommon | .651 / x2.41 / Uncommon (Rare .92) | + | SM-6 Marksman (R&D) | .550 / x1.66 / Common | .699 / x2.94 / Rare | .777 / x3.92 / Rare (Epic .75) | + | PowerHour 2.0 (NiteLife) | .521 / x1.42 / Common | .639 / x2.30 / Uncommon | .699 / x2.92 / Rare | + | Juggernaut (Zhestokost) | .488 / x1.18 / Common | .563 / x1.64 / Uncommon | .601 / x1.95 / Uncommon | + + Isolating the stages on Spotlight's top half gives .587 for the final test alone, .616 for the parts alone and .694 + for both. With the parts alone, the maker's margin is the headroom: Lucent's lift grows from +.060 at margin 0 to + +.083 at .1, +.102 at .2 and +.112 at .3. + +### What the operator sees in a shop + +A station that stocks Light Lasers can show four of them: Sparrow and Goldcrest (Finch), and Spotlight and Spotlight +Premiere (Lucent). In TradeMenu each title is coloured by its tier. A plain line ranges from Common up. A binned one +never shows below its floor tier: Spotlight Premiere is always Rare or better, at about 1.8 times Spotlight's average +price (x2.87 against x1.61). Goldcrest is Epic or Legendary at about 2.75 times Sparrow's price. The properties panel +reads, for example, "Spotlight Premiere (Rare++)". How much a bin buys follows the maker's quality control. Finch's +loose QC (±.20) leaves a lot to bin, so its bins jump two tiers. Zhestokost's tight drives (±.08) bin only to a better +Uncommon: Juggernaut Guards costs about 1.4 times Juggernaut and still looks Uncommon. How often a binned product +appears beside its line is question tier-stock-share. Under the recommended split-evenly, the maker is as present as +before and its stock divides evenly between line and bin. + +### Authority map + +- Owner: a line (a FactionProductData with no Bin) owns its design's role distributions for its maker, and its own + Assembly when it carries one. A binned product owns only its name, its text, which line it bins (`Bin.Line`) and its + floor (`Bin.Floor`, in deviations above each distribution's mean). `ItemManager.Mint` is the one place where a + binned lot's distributions are derived. +- Inputs: product.Bin; the line's Roles and Assembly; the maker's Faction.Assembly; the run's Random. +- Outputs: a Lot whose Product is the binned product (so Title and Brand read it), with every fill and the assembly + drawn at or above the floor. Price and tier follow from EffectiveQuality, unchanged. +- Derived state: a binned product's quality distributions are derived and never stored. Its Roles stay empty and its + Assembly null. It has no per-product Price. Its draw weight is derived from its maker's preference (question + tier-stock-share). +- Forbidden writers: ProductLinesCommand never writes Roles or Assembly onto a binned product. roles-migrate and + roles-backfill never backfill one. The census never reports its empty Roles as a gap. Studio edits to a line's roles + or a maker's AssemblyMean reach its bins through the derivation, with no copy to resync. +- Shared paths: every unstated mint goes through `CreateLot(product)`: generation stock, generated and design-only + fits (ChooseProduct), loot, and the run start. A staged `CreateLot(product, quality)` bypasses the draw. A merge + replays `product-lines tiers apply` and never merges the blob (follow-up catalog-merge-replay-order). +- Deletion line: none deleted. The comments that describe a segment as "a second product with a role pumped up" + (`FactionProduct.cs:11-13, 38-41`) and a binned line as a product's own assembly (`:32-33`) are rewritten to the + live rule. + +### Rationale + +- Why the bin is linked and not copied. A command could write a tier's Roles and Assembly as frozen slices of its line, + with no schema change. Each such slice would be a cache of live tunables. The operator tunes a maker's AssemblyMean + in Studio (ruling quality-min-tolerance-binned-assembly), and after that the frozen tier silently stops being the top + of its line. Her definition is "products that basically do the same thing ... with different performance + characteristics", so a bin derives from its line, and one owner makes drift structurally impossible. The cost is one + persisted type (two slots) and four sites that skip a binned product (PT6). +- Why both stages are binned. PT7 and PT8: under the hard min, a better final test alone is nearly inert. Picked parts + alone are capped at the maker's margin, so for parts-only selection the margin is the headroom (PT8 margin rows), + and that is where the margin matters. The bins in this cut select both stages at one floor, as the operator's + premium-needs-a-matching-supply-chain note says (ruling quality-triangle-is-per-facility). +- Why truncation and not moment matching. Rejection at mean + z·sd is the exact truncated normal of the line's own + Box-Muller draw, and it gives the player a guarantee they can read: a bin never shows below its floor tier. A + moment-matched gaussian has the same mean but can still roll a Common. The rejection loop is bounded at 64 tries per + draw and then mints at the cutoff, so the draw always ends and stays deterministic per seed. Unbinned products + consume the stream exactly as before. +- Why the floor is in deviations. A floor in deviations needs no inverse normal: z 0 is the top half and z .67 the top + quarter. Its fraction (1 - Phi(z) per stage) is only needed for the bin-share option of question tier-stock-share. +- Why the line is not sorted. In real binning the base SKU is what is left after sorting. Here the line keeps its full + distribution, so a plain Spotlight can still roll Epic. Sorting the line would re-author every binned line and add a + ceiling, and sorting a facility's own output is the crafting work that quality-triangle-is-per-facility defers. That + choice is option sorted-line of tier-stock-share, not built here. +- Prior art (from general knowledge, confidence high on the shape and low on numbers). Semiconductor speed binning: + one die design is sold as several SKUs sorted by tested frequency and voltage, and the premium SKU is a guaranteed + floor, not a better average. Die harvesting: partly defective dies sell as lower SKUs. Makers also down-bin good + parts to meet demand, so a SKU's supply share is a commercial choice and not only yield, which is why split-evenly + is defensible for the demo. +- Hulls are out. `ShipModCatalog.ProductKey(id, maker)` keys one product per maker per hull, and hull products live + in packages that the Python writer leaves untouched (`ship_cc.py:5`). Widening that key is its own cut. + +### Model page rows + +- FactionProductData: identity is the record key, a hash of the name. Lifecycle: authored by AetherDb commands and + tuned in Studio. Authority: a line owns its roles and its optional assembly. Slot 7 `Bin` (ProductBin: slot 0 Line, + a CultRecordRef; slot 1 Floor, a float) marks a binned product, which owns no distributions of + its own. Invariant: a Bin's Line has the same Design and Manufacturer and is not itself binned. +- Lot (unchanged schema): a binned lot records the binned product, and its fills and assembly sit at or above the + floor of the line's distributions. +- ProductWeight: the maker's preference, divided evenly among the maker's products of that design (pending + tier-stock-share). It is still the one weight of RandomProducts and ChooseProduct. + +## Run seed" section at the end of docs/aetheria-release-map.md (or beside "Mines in the sim"). +2. Remove the "- run-seed: ..." line from the first "## Cut sequence" (spec cut-run-seed.r1 now carries it). +3. In "Retiring the prelude and the tutorial", the planned `Galaxy.InfluenceScale` moves from SavedGame key 14 to key 15: + run-seed takes key 14 (RS6). Two places: the "influence halving" bullet ("carried by the Galaxy and the save (key 14)"), + the "Old saves" paragraph ("Key 14 absent decodes as 0"), and the model-page row `Galaxy.InfluenceScale` ("saved as `SavedGame` key 14"). +4. The spec cites this as "map section Run seed (to be committed)"; fix its DocRefs if you want line ranges. +--> + +## Run seed + +Ruling `loot-seed-in-save` (2026-10-16): a new game rolls different drops from other games, +reloading one save repeats its drops, and tests pass a fixed seed. Spec `cut-run-seed.r1`, +base `eb27ced1`. Question `run-seed-zone-dice-scope` (raised in the spec) asks whether the +zone's other dice take the seed too. + +### Body facts (master `eb27ced1`, 2026-10-18) + +- **RS1. A run seed already exists, and nothing saves it.** `Galaxy.Seed` (`Galaxy.cs:31-33`) is + the seed a galaxy was generated at. `GalaxyStage.SeedFrom` (`GalaxyStage.cs:52-56`) mints it: + the scenario's `Seed` if non-zero, else the clock, never zero. `GalaxyStage.Main`/`Prelude` + (`:59-82`) pass it to the generation constructors. `RunStart.SeedItems` (`RunStart.cs:62-65`) + seeds the run's item draws from it. The save constructor (`Galaxy.cs:48-92`) never sets it, so a + continued run has `Seed == 0` (the comment at `:31-32` says so). `SavedGame` (`SavedGame.cs:9-53`, + `aetheria.savedgame` v1) has keys 0-13 and no seed. `RunSave.Capture` (`:60-96`) is its only + writer, and the save constructor its only reader (`MainMenu.cs:111` on Continue). +- **RS2. Galaxy has a second minter.** Both generation constructors fall back to the clock when + passed 0: `Seed = seed == 0 ? (uint)(DateTime.Now.Ticks % uint.MaxValue) : seed` + (`Galaxy.cs:107`, `:174`), with `uint seed = 0` defaults (`:100`, `:167`). No game caller relies on + that: the only game callers are `GalaxyStage.cs:64,81`, which pass a non-zero seed. Every test + caller (33 calls over tests/ and Assets/) passes progressCallback and a seed positionally. No + Editor or `tools/` code builds a Galaxy. +- **RS3. Zone dice are rooted in the zone name alone.** `Zone.CombatSeed` (`Zone.cs:127`) is + `galaxyZone?.Name.StableHash() ?? 1337u` (`:154`). The constructor receives the run's `Galaxy` + (`:144`). The only game construction is `ActionGameManager.cs:730`, which passes `CurrentGalaxy`; + most test fixtures pass `null`. Five dice read CombatSeed, each through its own stream + (`SimulationDice.cs:11-16`): loot (`Zone.cs:455`, `LootStream`, by death ordinal), mine lay + scatter (`FireControl.cs:792`, `MineStream`, by body id), patrol circuit (`Zone.cs:317`, + `PatrolStream`, by agent id), AI range-sample offset (`Combat.cs:178`, `CombatSampleStream`, by + agent id; ruling `ai-sample-offset-off-item-stream`), and shot dice (`FireControl.cs:931`, its own + mix of CombatSeed and ShotId). The map's earlier line numbers (`Zone.cs:144`, `:414`, + `FireControl.cs:791`, `:930`) have drifted to these. +- **RS4. `SimulationDice.Mix(0) == 0`** (`SimulationDice.cs:23-31`; pinned by + `FloatingBodyTests.cs:119`). So `seed ^ Mix(runSeed)` leaves `seed` unchanged when `runSeed` is 0: + a zone with no galaxy, or a galaxy restored from a pre-run-seed save, keeps today's dice. +- **RS5. Probes (ygg-verify, full suite `tests/Aetheria.Shared.Tests`, CultLib 45c2f40, CultMath + 9c1b06cd, catalog LFS staged; throwaway commits in `C:\aeth-wt\ag6-rseed-probe`, not for merge).** + - Base `eb27ced1`: `TheDirectWeaponKillsTheFragileHullWhileASmokeRoundIsStillInTheAir` passes + (`base2.log`). + - Probe A, `87329cd1`: one line, `CombatSeed = name hash ^ Mix(galaxy?.Seed ?? 0)`, so all five + dice take the run seed. Result: 2406 passed, 1 failed (`probeA.log`). The failure is + `TheDirectWeaponKillsTheFragileHullWhileASmokeRoundIsStillInTheAir` (`SmokeRoundsSteppedTests.cs:93-125`, + scenario FusedRounds, Seed 108), on `Assert.NotNull(deadAt)` (the failure names + `Nullable`, which is `deadAt`; `landed` is a nullable tuple). The smoke round landed + before the Cluster Cannon killed the fragile hull. The check's premise, that the cannon kills + inside the round's flight, holds at base only because of how seed 108's shot dice fall. + - Probe B, `8350e901`: `Zone.DropSeed = CombatSeed ^ Mix(galaxy?.Seed ?? 0)`, read by the loot + dice and the mine scatter only. Result: 2407 passed, 0 failed (`probeB.log`). + Logs are in the Imagination scratchpad `imagination-ag6-run-seed/`. +- **RS6. SavedGame key 14 is free at master.** It is reserved only in prose, by the unspecced + `retire-prelude` plan for `Galaxy.InfluenceScale`. `run-seed` lands first and takes key 14, and + that plan moves to 15. MessagePack int keys decode an absent key as the default, so a pre-cut save + reads `Seed == 0`. +- **RS7. Dice ordinals are runtime state, not saved.** `_deaths` (`Zone.cs:134`), `_lastBodyId` + (`:130`) and `_lastAgentId` (`:137`) restart at 0 whenever a Zone is built from its pack, which + happens on every Continue. Reloading one save repeats its drops (the ruled sense). It also means + that within one save, the first death after a Continue rerolls the dice of the zone's first death + before the save. That is not ruled (follow-up `zone-dice-ordinals-reset-on-load`). +- **RS8. Two campaign follow-ups were checked and not folded.** `zone-content-seeded-by-name` is + about `ZoneGenerator.cs:52`, which this cut does not touch. Generation already mixes the zone's + per-run position there, and its CombatSeed half (a rename rerolls fights) is unchanged here, + because DropSeed keeps CombatSeed as its base. `generation-draw-enumeration-order` is about + `LoadoutGenerator.cs:174-209`, also not touched. + +### Prior art + +Single-player games that store a run seed put every random system under it, and split it into one +stream per subsystem so systems cannot perturb each other: +- Slay the Spire keeps one seed per run, shows it to the player and saves it. Separate generators + run from it for monsters, events, cards, relics, potions, treasure, the merchant, shuffles and + misc, and some are re-derived per floor from seed and floor. Reloading repeats outcomes. High + confidence. +- XCOM: Enemy Unknown saves its generator state, so reloading and repeating an action repeats a hit + roll. Its "Save Scum" option re-seeds on load instead. The design space matches the question's + `seed-in-save` and `seed-per-session` options. Medium-high confidence. +- Seeded roguelikes (Brogue, DCSS seeded mode) derive levels and items from one game seed. Medium + confidence on DCSS details. + +Here `SimulationDice` is already the per-subsystem split (one stream constant per system). What the +precedent leaves open is only the scope: whether the run seed reaches some streams or all of them. + +### Authority map + +- **Owner.** `GalaxyStage.SeedFrom` mints a new run's seed (the scenario's, else the clock, never + zero). `Galaxy.Seed` carries it for the live run. `SavedGame.Seed` (key 14) persists it. +- **Inputs.** `Scenario.Seed` and the injected clock (minting); `SavedGame.Seed` (Continue). +- **Outputs.** Galaxy generation and the run's item draws, as today. New: `Zone.DropSeed`, the + zone's drop dice root, `CombatSeed ^ Mix(Galaxy.Seed)`, read by the loot dice and the mine scatter. +- **Derived state.** `Zone.DropSeed` is derived at construction and never stored. `Zone.CombatSeed` + stays the zone's name-rooted identity for shot, patrol and range-sample dice (pending the + question). It no longer feeds any drop. +- **Forbidden writers.** Galaxy's own clock fallback (`Galaxy.cs:107`, `:174`) is deleted: a + generated galaxy refuses seed 0 rather than minting one. Nothing writes `SavedGame.Seed` except + `RunSave.Capture`, and nothing sets `Galaxy.Seed` except its constructors. Zone takes no seed from + the catalog, from `ItemManager.Random`, or from a static. +- **Shared paths.** New run (`RunStart.Generate`, then `GalaxyStage`, then Galaxy), Continue + (`MainMenu.cs:111`, `Galaxy(cache, saved)`), Save (`RunSave.Capture`, then `Commit`), and zone + build (`ActionGameManager.cs:730` and every fixture's `new Zone(..., galaxy)`) all read the one + `Galaxy.Seed`. +- **Deletion line.** The clock fallback and the `= 0` seed defaults in both generation constructors + are cut before DropSeed is added. + +### Model page rows + +| Kind | What names it | Over time | Who decides | +|---|---|---|---| +| Run seed (`Galaxy.Seed`, `SavedGame` key 14) | Its run; non-zero for every run made after `run-seed` | Minted once at new run by `GalaxyStage.SeedFrom`; saved by `RunSave.Capture`; restored by the save constructor; never changed for the life of the run; a pre-cut save reads 0 and keeps name-only drops (ruling `no-save-compatibility-before-players`: no migration, no refusal) | `GalaxyStage.SeedFrom` (scenario seed or clock) | +| `Zone.DropSeed` | Its zone in its run: `CombatSeed ^ Mix(Galaxy.Seed)` | Derived at Zone construction, never stored | `Zone`'s constructor; read by `DropLoot` and `FireControl.Lay` | +| `Zone.CombatSeed` | Its zone name (`StableHash`), 1337 with no GalaxyZone | Derived at construction; feeds shot, patrol and range-sample dice and is DropSeed's base | `Zone`'s constructor; scope pending question `run-seed-zone-dice-scope` | + +### Rationale + +**Reuse `Galaxy.Seed`, don't mint a second seed.** The run already has one seed, fixed by the +scenario or the clock, and it already seeds generation and item draws. A separate "loot seed" would +be a second identity for the same run. With both saved, a scenario's fixed `Seed` would fix the +galaxy and the gear but not the drops. "Tests pass a fixed seed" is satisfied by the existing +`Scenario.Seed` and the Galaxy constructors' `seed` parameter. Seed streams cannot correlate, because +every dice draw passes through `SimulationDice.For`'s triple fmix32 with its own stream constant. + +**Cut Galaxy's clock fallback.** It is a second minter that no game path uses, and with the seed now +saved it would be a quiet way to make a galaxy whose drops nobody chose. `GalaxyStage.SeedFrom` is +the one place a run's seed comes from. + +**XOR with `Mix(seed)`, not a new hash.** `Mix(0) == 0` (RS4), so every galaxy-less fixture and every +pre-cut save keeps its dice bit for bit. Probe B measured zero churn over 2407 tests. This is +arithmetic, not a special case: no branch tests for zero. + +**Drops only, for now (the question's recommendation).** The ruling covers drops, and probe B lands +it with no fixture moved. Putting all five dice under the run seed is where the prior art points, +and it is one line (probe A). But it moves the FusedRounds check, whose premise rides seed 108's +shot dice. A re-settle that reseeds the scenario or widens the horizon is the "hide the coupling" +fix that ruling `ai-sample-offset-off-item-stream` refused. The honest fix is to make that check's +premise hold by construction, and that is its own piece of work. The player-visible difference +between the two scopes is small. Shot dice are keyed by ShotId, which follows fight history. Patrol +circuits pick from orbits that `ZoneGenerator` already generates per run (name hash ^ position). Only +the AI's range-sample offset repeats by zone name across games, and with curated place names +(ruling `faction-curated-place-names`) names can recur. So the recommendation is drops only now, +with the wider scope taken when the FusedRounds premise is fixed. + +**Old saves.** A pre-cut save reads `Seed == 0` and continues with name-only drops. Under ruling +`no-save-compatibility-before-players` the cut may refuse it, but a refusal gate would be code +written for nobody. From 87793044999446b618d7f35738171708e4aed221 Mon Sep 17 00:00:00 2001 From: Meta Vens Date: Sun, 18 Oct 2026 17:28:36 +0200 Subject: [PATCH 005/444] Galaxy generation refuses seed 0: delete the clock fallback and the seed defaults GalaxyStage.SeedFrom is the one minter of a run's seed; both generation constructors now take progressCallback and seed without defaults and throw ArgumentOutOfRangeException on zero instead of reading the clock. Co-Authored-By: Claude Sonnet 5.5 --- Assets/Scripts/ServerShared/Galaxy.cs | 12 ++++++------ tests/Aetheria.Shared.Tests/RunStartTests.cs | 10 ++++++++++ 2 files changed, 16 insertions(+), 6 deletions(-) diff --git a/Assets/Scripts/ServerShared/Galaxy.cs b/Assets/Scripts/ServerShared/Galaxy.cs index 0b2f71387..b2d1e5dd0 100644 --- a/Assets/Scripts/ServerShared/Galaxy.cs +++ b/Assets/Scripts/ServerShared/Galaxy.cs @@ -96,15 +96,15 @@ public class Galaxy SectorBackgroundSettings background, CultCache cache, Action log, - Action progressCallback = null, - uint seed = 0) + Action progressCallback, + uint seed) { _cache = cache; IsPrelude = false; Background = background; Log = log; var factions = cache.GetAll(); - Seed = seed == 0 ? (uint) (DateTime.Now.Ticks % uint.MaxValue) : seed; + Seed = seed != 0 ? seed : throw new ArgumentOutOfRangeException(nameof(seed), "a generated galaxy takes its run's seed from GalaxyStage, never zero"); var random = new Random(Seed); Factions = factions.OrderBy(x => random.NextFloat()).Take(settings.MegaCount).ToArray(); foreach (var f in Factions) FactionRelationships[f] = FactionRelationship.Neutral; @@ -163,15 +163,15 @@ public class Galaxy PlayerSettings playerSettings, DirectoryInfo narrativeDirectory, Action log, - Action progressCallback = null, - uint seed = 0) + Action progressCallback, + uint seed) { _cache = cache; IsPrelude = true; Background = background; Log = log; - Seed = seed == 0 ? (uint) (DateTime.Now.Ticks % uint.MaxValue) : seed; + Seed = seed != 0 ? seed : throw new ArgumentOutOfRangeException(nameof(seed), "a generated galaxy takes its run's seed from GalaxyStage, never zero"); var random = new Random(Seed); var factions = new List(); diff --git a/tests/Aetheria.Shared.Tests/RunStartTests.cs b/tests/Aetheria.Shared.Tests/RunStartTests.cs index efedc20dd..7c22bb911 100644 --- a/tests/Aetheria.Shared.Tests/RunStartTests.cs +++ b/tests/Aetheria.Shared.Tests/RunStartTests.cs @@ -115,6 +115,16 @@ public sealed partial class RunStartTests : IDisposable private const float Freezing = 273.15f; + // One minter: a generated galaxy never reads the clock, so seed 0 is refused by both generation constructors. + [Fact] + public void AGeneratedGalaxyRefusesSeedZero() + { + Assert.Throws(() => + new Galaxy(new SectorGenerationSettings(), new SectorBackgroundSettings(), _cache, _ => { }, null, 0)); + Assert.Throws(() => + new Galaxy(new TutorialGenerationSettings(), new SectorBackgroundSettings(), _cache, new PlayerSettings(), new DirectoryInfo(_root), _ => { }, null, 0)); + } + // A main-sector galaxy: each station's gear must come from manufacturers its faction can reach, the makers its // allegiance names, present in the galaxy or not. private Galaxy MainSectorGalaxy() From 020dc58a812c04d6e8fb983d32d1bf0a1d3095ee Mon Sep 17 00:00:00 2001 From: Meta Vens Date: Sun, 18 Oct 2026 17:29:17 +0200 Subject: [PATCH 006/444] The run's seed rides the save: SavedGame key 14, written by Capture, read by Continue Galaxy.Seed on a continued run is the saved seed instead of zero. A save written before run-seed decodes Seed 0. Co-Authored-By: Claude Sonnet 5.5 --- Assets/Scripts/ServerShared/Galaxy.cs | 5 ++- Assets/Scripts/ServerShared/GalaxyStage.cs | 3 +- Assets/Scripts/ServerShared/SavedGame.cs | 8 +++- .../DemoTerminusTests.cs | 2 + tests/Aetheria.Shared.Tests/RunSaveTests.cs | 37 +++++++++++++++++++ 5 files changed, 51 insertions(+), 4 deletions(-) diff --git a/Assets/Scripts/ServerShared/Galaxy.cs b/Assets/Scripts/ServerShared/Galaxy.cs index b2d1e5dd0..e9e75b239 100644 --- a/Assets/Scripts/ServerShared/Galaxy.cs +++ b/Assets/Scripts/ServerShared/Galaxy.cs @@ -28,8 +28,8 @@ public class Galaxy private Action Log { get; } public bool IsPrelude { get; } - // The seed this galaxy was generated at, which also seeds its run's item draws (RunStart); zero for a galaxy - // restored from a save. + // The run's seed, minted once by GalaxyStage.SeedFrom and carried by the save (SavedGame.Seed). It seeds + // generation, the run's item draws (RunStart) and the zone's drop dice (Zone.DropSeed). public uint Seed { get; } private GalaxyZone[] _exitPath; @@ -51,6 +51,7 @@ public class Galaxy _cache = cultCache; Log = log; Background = savedGame.Background; + Seed = savedGame.Seed; Factions = savedGame.Factions.Select(faction => cultCache.Get(faction)).ToArray(); for (var i = 0; i < Factions.Length; i++) diff --git a/Assets/Scripts/ServerShared/GalaxyStage.cs b/Assets/Scripts/ServerShared/GalaxyStage.cs index cd8feefed..a93e25f89 100644 --- a/Assets/Scripts/ServerShared/GalaxyStage.cs +++ b/Assets/Scripts/ServerShared/GalaxyStage.cs @@ -46,7 +46,8 @@ public sealed class GalaxyStage _clock = clock ?? (() => (uint) (DateTime.Now.Ticks % uint.MaxValue)); } - // The seed this generation runs at, never zero: the scenario's, or the clock's when the scenario's is zero. + // The run's one minter: the scenario's seed, or the clock's when the scenario's is zero, never zero. Galaxy carries + // what it mints and SavedGame keeps it, so Continue rolls the same drops. public uint Seed { get; private set; } internal void SeedFrom(Scenario scenario) diff --git a/Assets/Scripts/ServerShared/SavedGame.cs b/Assets/Scripts/ServerShared/SavedGame.cs index ba819e6ec..1758074fa 100644 --- a/Assets/Scripts/ServerShared/SavedGame.cs +++ b/Assets/Scripts/ServerShared/SavedGame.cs @@ -50,6 +50,11 @@ public class SavedGame [Key(13)] public int Credits; + + // The run's seed (Galaxy.Seed), minted once by GalaxyStage.SeedFrom. Written only by RunSave.Capture and read only + // by Galaxy's save constructor; 0 in a save written before run-seed, which then rolls name-only drops. + [Key(14)] + public uint Seed; } // The run's lifecycle over the run store. Only Commit creates SavedGame, SavedZone and ProvenanceLedger records; @@ -79,7 +84,8 @@ public static class RunSave Exit = Array.IndexOf(galaxy.Zones, galaxy.Exit), IsTutorial = galaxy.IsPrelude, ActionBarBindings = actionBar, - Credits = credits + Credits = credits, + Seed = galaxy.Seed }; // A zone generated earlier but not loaded this session has no live Contents; its packed contents carry over. diff --git a/tests/Aetheria.Shared.Tests/DemoTerminusTests.cs b/tests/Aetheria.Shared.Tests/DemoTerminusTests.cs index ae1895e75..54f46a57c 100644 --- a/tests/Aetheria.Shared.Tests/DemoTerminusTests.cs +++ b/tests/Aetheria.Shared.Tests/DemoTerminusTests.cs @@ -51,6 +51,8 @@ public sealed partial class RunStartTests var (game, zones) = RunSave.Capture(_cache, galaxy, arena, staged.Player, new SavedActionBarBinding[0], 0); RunSave.Commit(_cache, game, zones, _items.Lots); var restored = new Galaxy(_cache, _cache.GetGlobal(), _ => { }); + Assert.NotEqual(0u, galaxy.Seed); + Assert.Equal(galaxy.Seed, restored.Seed); Dictionary Standing(Galaxy g) => g.Factions.ToDictionary(faction => faction.Name, faction => g.FactionRelationships[faction]); Assert.Equal(new Dictionary diff --git a/tests/Aetheria.Shared.Tests/RunSaveTests.cs b/tests/Aetheria.Shared.Tests/RunSaveTests.cs index 3bd075724..1320b7ddd 100644 --- a/tests/Aetheria.Shared.Tests/RunSaveTests.cs +++ b/tests/Aetheria.Shared.Tests/RunSaveTests.cs @@ -170,6 +170,43 @@ public sealed class RunSaveTests : IDisposable Assert.Equal(4242, cache.GetGlobal().Credits); } + // The run's seed rides the save: Continue restores it, Capture writes it back, and a save from before run-seed + // restores zero. + [Fact] + public void TheRunSeedRidesTheSave() + { + using (var cache = Open()) + { + var game = Game(cache); + game.Seed = 0xC0FFEEu; + RunSave.Commit(cache, game, Zones(2, StageZoneContents(cache), 0), new ProvenanceLedger()); + } + + using (var cache = Open()) + { + var galaxy = new Galaxy(cache, cache.GetGlobal(), _ => { }); + Assert.Equal(0xC0FFEEu, galaxy.Seed); + var itemManager = new ItemManager(cache, new ProvenanceLedger(), TestSettings(), _ => { }); + galaxy.Zones[0].Contents = new Zone(itemManager, new PlanetSettings(), new ZonePack(), galaxy.Zones[0], galaxy); + var (game, zones) = RunSave.Capture(cache, galaxy, galaxy.Zones[0].Contents, null, new SavedActionBarBinding[0], 0); + Assert.Equal(0xC0FFEEu, game.Seed); + RunSave.Commit(cache, game, zones, RunSave.Lots(cache)); + } + + using (var cache = Open()) + Assert.Equal(0xC0FFEEu, cache.GetGlobal().Seed); + + // A save written before run-seed holds no seed, and continues on name-only drops. + using (var cache = Open()) + RunSave.Commit(cache, Game(cache), Zones(2, StageZoneContents(cache), 0), new ProvenanceLedger()); + + using (var cache = Open()) + { + Assert.Equal(0u, cache.GetGlobal().Seed); + Assert.Equal(0u, new Galaxy(cache, cache.GetGlobal(), _ => { }).Seed); + } + } + [Fact] public void ClearRemovesEveryRunRecord() { From f23d3c654204ccbebed90995883ab8e6976f8351 Mon Sep 17 00:00:00 2001 From: Meta Vens Date: Sun, 18 Oct 2026 17:30:50 +0200 Subject: [PATCH 007/444] Drops roll on the run's seed: Zone.DropSeed feeds the loot and mine dice DropSeed = CombatSeed ^ Mix(Galaxy.Seed), derived at construction. A zone with no galaxy, or a restored seed of 0, keeps its dice bit for bit; CombatSeed still roots shot, patrol and range-sample dice. RunSeedFixture builds a test zone's galaxy through RunSave.Commit and the Continue constructor. Co-Authored-By: Claude Sonnet 5.5 --- Assets/Scripts/ServerShared/FireControl.cs | 4 +-- Assets/Scripts/ServerShared/Zone.cs | 9 +++-- tests/Aetheria.Shared.Tests/LootTests.cs | 33 +++++++++++++++++-- tests/Aetheria.Shared.Tests/MineTests.cs | 28 +++++++++++++--- tests/Aetheria.Shared.Tests/RunSaveTests.cs | 9 ++++- tests/Aetheria.Shared.Tests/RunSeedFixture.cs | 26 +++++++++++++++ 6 files changed, 98 insertions(+), 11 deletions(-) create mode 100644 tests/Aetheria.Shared.Tests/RunSeedFixture.cs diff --git a/Assets/Scripts/ServerShared/FireControl.cs b/Assets/Scripts/ServerShared/FireControl.cs index 5e0a0dcc5..5ea06f455 100644 --- a/Assets/Scripts/ServerShared/FireControl.cs +++ b/Assets/Scripts/ServerShared/FireControl.cs @@ -775,7 +775,7 @@ public static class FireControl // Lays a mine for a mine layer: freezes the payload as evaluated now (stats, the item's blast radius, the // layer and its faction) and hands the body to Zone.Lay, the one adder. It launches at the shooter's velocity // plus the mount direction turned by a dice angle within +-Spread/2, times Velocity; the dice are the mine's - // own (zone seed, MineStream, body id), so no other roll is perturbed. No target, no PendingShot, no price: + // own (the zone's DropSeed, MineStream, body id), so no other roll is perturbed. No target, no PendingShot, no price: // InstantWeapon has already paid. A layer whose item has no blast radius lays nothing. public static void Lay(Weapon weapon, EquippedItem item, Entity source) { @@ -789,7 +789,7 @@ public static class FireControl var id = zone.NextBodyId(); var half = weapon.Spread / 2f; - var angle = radians(SimulationDice.For(zone.CombatSeed, SimulationDice.MineStream, id.Value).NextFloat(-half, half)); + var angle = radians(SimulationDice.For(zone.DropSeed, SimulationDice.MineStream, id.Value).NextFloat(-half, half)); var s = sin(angle); var c = cos(angle); var mount = MountDirection(item).xz; diff --git a/Assets/Scripts/ServerShared/Zone.cs b/Assets/Scripts/ServerShared/Zone.cs index 4db63be46..719c37e6f 100644 --- a/Assets/Scripts/ServerShared/Zone.cs +++ b/Assets/Scripts/ServerShared/Zone.cs @@ -126,11 +126,15 @@ public class Zone // of touching a shared stream. public uint CombatSeed { get; } + // The zone's drop dice (loot and mine scatter): CombatSeed mixed with its run's seed (ruling loot-seed-in-save), so a + // new game drops differently and a reloaded save repeats. Mix(0) is 0, so a zone with no galaxy rolls on CombatSeed. + public uint DropSeed { get; } + // The one source of floating item and mine ids: zone-scoped, from 1, never reused. Runtime state, not saved. private uint _lastBodyId; public FloatingBodyId NextBodyId() => new FloatingBodyId(++_lastBodyId); - // The death ordinal of the loot dice: the nth death in this zone rolls on SimulationDice.For(CombatSeed, LootStream, n). + // The death ordinal of the loot dice: the nth death in this zone rolls on SimulationDice.For(DropSeed, LootStream, n). private uint _deaths; // The zone-scoped agent ordinal, from 1, never reused: the key of a pilot's own dice. @@ -152,6 +156,7 @@ public class Zone Settings = settings; _orbitPositionAt = OrbitPositionAt; CombatSeed = galaxyZone?.Name.StableHash() ?? 1337u; + DropSeed = CombatSeed ^ SimulationDice.Mix(galaxy?.Seed ?? 0u); var cache = itemManager.ItemData; // Cut 5, 5.6 (docs/fire-control-cut.md, Soul finding 7; operator ruling Q3): death removes the ship, @@ -452,7 +457,7 @@ public class Zone private void DropLoot(Entity dead) { var settings = _itemManager.GameplaySettings; - var random = SimulationDice.For(CombatSeed, SimulationDice.LootStream, ++_deaths); + var random = SimulationDice.For(DropSeed, SimulationDice.LootStream, ++_deaths); var at = new float2(dead.Position.x, dead.Position.z); float2 Heading() { var angle = random.NextFloat() * 2f * PI; return float2(cos(angle), sin(angle)); } foreach (var equipped in dead.Equipment.ToArray()) diff --git a/tests/Aetheria.Shared.Tests/LootTests.cs b/tests/Aetheria.Shared.Tests/LootTests.cs index f0b52f4ce..ae47f6b32 100644 --- a/tests/Aetheria.Shared.Tests/LootTests.cs +++ b/tests/Aetheria.Shared.Tests/LootTests.cs @@ -61,7 +61,7 @@ public sealed class LootTests : IDisposable } private Lab Build(string zoneName = "Loot", float probability = .5f, float lifetime = 30f, float velocity = 25f, - bool well = false, float spread = 0f) + bool well = false, float spread = 0f, uint? runSeed = null) { var settings = new GameplaySettings { @@ -104,6 +104,7 @@ public sealed class LootTests : IDisposable cache.Upsert(new CargoBayData { Name = "Crate", Shape = new Shape(), InteriorShape = Solid(4, 4), Durability = 4 }); cache.Upsert(new SimpleCommodityData { Name = "Ore", Shape = Solid(1, 1), MaxStack = 100 }); cache.FlushAsync().Wait(); + var galaxy = runSeed.HasValue ? RunSeedFixture.Continue(cache, zoneName, runSeed.Value) : null; var ledger = new ProvenanceLedger(); for (var i = 1; i <= 9000; i++) ledger.Lots[i] = new Lot { Origin = new Attributed(), Quality = 1 }; @@ -111,7 +112,9 @@ public sealed class LootTests : IDisposable var planets = new PlanetSettings(); var pack = new ZonePack { Radius = well ? 1000 : 0 }; if (well) WellFixture.Dig(cache, pack, planets, 300, 100); - var zone = new Zone(items, planets, pack, new GalaxyZone { Name = zoneName, Owner = null }, null); + var zone = galaxy != null + ? new Zone(items, planets, pack, galaxy.Zones[0], galaxy) + : new Zone(items, planets, pack, new GalaxyZone { Name = zoneName, Owner = null }, null); return new Lab { Items = items, Zone = zone }; } @@ -209,6 +212,32 @@ public sealed class LootTests : IDisposable Assert.True(Differ(dropsA, Released(first, count))); } + private List<(string Item, float2 Position, float2 Velocity)> DropsAt(string zoneName, uint runSeed) + { + var lab = Build(zoneName, runSeed: runSeed); + Kill(Spawn(lab, float3(31, 0, -17))); + return Released(lab, 0); + } + + // A new game rolls different drops from other games: the same ship, killed in the same zone, at another run seed. + [Fact] + public void ANewGameRollsDifferentDrops() + { + var one = DropsAt("Dice", 1); + Assert.True(one.Count > 2, "the fixture must drop enough for a draw to show"); + Assert.True(Differ(one, DropsAt("Dice", 2))); + } + + // Reloading one save repeats its drops, and the run's other zones still roll on their own names. + [Fact] + public void ReloadingASaveRepeatsItsDrops() + { + var first = DropsAt("Dice", 7); + Assert.True(first.Count > 2, "the fixture must drop enough for a draw to show"); + AssertSameDrops(first, DropsAt("Dice", 7)); + Assert.True(Differ(first, DropsAt("Other dice", 7))); + } + [Fact] public void GearDropsByProbabilityCargoAlways() { diff --git a/tests/Aetheria.Shared.Tests/MineTests.cs b/tests/Aetheria.Shared.Tests/MineTests.cs index a2deeb5cf..24269af1a 100644 --- a/tests/Aetheria.Shared.Tests/MineTests.cs +++ b/tests/Aetheria.Shared.Tests/MineTests.cs @@ -67,7 +67,7 @@ public sealed class MineTests : IDisposable private Lab Build(float blast = 4f, float arming = 2f, float fuse = 2f, float life = 30f, float spread = 0f, float velocity = 0f, float damage = 100f, float range = 1000f, float cooldown = 1000f, Faction layerFaction = null, WeaponFuse? fuseKind = WeaponFuse.Proximity, bool well = false, float3? layerAt = null, int magazine = 100, - float minRange = 0f) + float minRange = 0f, uint? runSeed = null) { var settings = new GameplaySettings { @@ -114,6 +114,7 @@ public sealed class MineTests : IDisposable cache.Upsert(Gear("Shield", new ShieldData { Efficiency = Constant(1), EnergyUsage = Constant(1), Capacity = Constant(1000000), RefillDuration = Constant(.01f), RestoreDuration = Constant(.01f) }, 10)); cache.Upsert(Gear("TinyShield", new ShieldData { Efficiency = Constant(1), EnergyUsage = Constant(1), Capacity = Constant(.001f), RefillDuration = Constant(.01f), RestoreDuration = Constant(.01f) }, 10)); cache.FlushAsync().Wait(); + var galaxy = runSeed.HasValue ? RunSeedFixture.Continue(cache, "Mines", runSeed.Value) : null; var ledger = new ProvenanceLedger(); for (var i = 1; i <= 500; i++) ledger.Lots[i] = new Lot { Origin = new Attributed(), Quality = 1 }; @@ -121,7 +122,9 @@ public sealed class MineTests : IDisposable var planets = new PlanetSettings(); var pack = new ZonePack { Radius = well ? 1000 : 0 }; if (well) WellFixture.Dig(cache, pack, planets, 300, 100); - var zone = new Zone(items, planets, pack, new GalaxyZone { Name = "Mines", Owner = null }, null); + var zone = galaxy != null + ? new Zone(items, planets, pack, galaxy.Zones[0], galaxy) + : new Zone(items, planets, pack, new GalaxyZone { Name = "Mines", Owner = null }, null); var lab = new Lab { Items = items, Zone = zone, Data = data, ItemData = cache.GetByName("Mine Layer") }; var layerRef = items.ItemData.RefOf(items.ItemData.GetByName("Layer")); @@ -683,6 +686,23 @@ public sealed class MineTests : IDisposable Assert.False(FireControl.Refuses(lab.Weapon, lab.Layer)); } + // A new game scatters its mines differently: the same lay, at another run seed, launches at another velocity. + [Fact] + public void ANewGameScattersMinesDifferently() + { + float2 LaunchAt(uint runSeed) + { + var lab = Build(spread: 40f, velocity: 5f, layerFaction: A, runSeed: runSeed); + var atLay = default(KinematicBody); + using var sub = lab.Zone.Mines.ObserveAdd().Subscribe(e => atLay = e.Value.Body); + lab.Layer.Direction = normalize(float2(1, 2)); + LayOne(lab); + return atLay.Velocity; + } + + Assert.NotEqual(LaunchAt(1), LaunchAt(2)); + } + [Fact] public void TheLaunchAngleIsTheMinesOwnDice() { @@ -697,9 +717,9 @@ public sealed class MineTests : IDisposable var mine = LayOne(lab); - // The angle is the first draw of (zone seed, mine stream, body id), in +-Spread/2, turning the mount + // The angle is the first draw of (the zone's DropSeed, mine stream, body id), in +-Spread/2, turning the mount // counterclockwise in the (x, y) plane. - var degreesTurned = SimulationDice.For(lab.Zone.CombatSeed, SimulationDice.MineStream, mine.Id.Value).NextFloat(-20f, 20f); + var degreesTurned = SimulationDice.For(lab.Zone.DropSeed, SimulationDice.MineStream, mine.Id.Value).NextFloat(-20f, 20f); var a = radians(degreesTurned); var m = FireControl.MountDirection(lab.WeaponItem).xz; var turned = float2(m.x * cos(a) - m.y * sin(a), m.x * sin(a) + m.y * cos(a)); diff --git a/tests/Aetheria.Shared.Tests/RunSaveTests.cs b/tests/Aetheria.Shared.Tests/RunSaveTests.cs index 1320b7ddd..7e32c9928 100644 --- a/tests/Aetheria.Shared.Tests/RunSaveTests.cs +++ b/tests/Aetheria.Shared.Tests/RunSaveTests.cs @@ -188,6 +188,8 @@ public sealed class RunSaveTests : IDisposable Assert.Equal(0xC0FFEEu, galaxy.Seed); var itemManager = new ItemManager(cache, new ProvenanceLedger(), TestSettings(), _ => { }); galaxy.Zones[0].Contents = new Zone(itemManager, new PlanetSettings(), new ZonePack(), galaxy.Zones[0], galaxy); + // The zone's drop dice are its name's seed mixed with the run's. + Assert.Equal("Zone 0".StableHash() ^ SimulationDice.Mix(0xC0FFEEu), galaxy.Zones[0].Contents.DropSeed); var (game, zones) = RunSave.Capture(cache, galaxy, galaxy.Zones[0].Contents, null, new SavedActionBarBinding[0], 0); Assert.Equal(0xC0FFEEu, game.Seed); RunSave.Commit(cache, game, zones, RunSave.Lots(cache)); @@ -203,7 +205,12 @@ public sealed class RunSaveTests : IDisposable using (var cache = Open()) { Assert.Equal(0u, cache.GetGlobal().Seed); - Assert.Equal(0u, new Galaxy(cache, cache.GetGlobal(), _ => { }).Seed); + var galaxy = new Galaxy(cache, cache.GetGlobal(), _ => { }); + Assert.Equal(0u, galaxy.Seed); + var itemManager = new ItemManager(cache, new ProvenanceLedger(), TestSettings(), _ => { }); + var zone = new Zone(itemManager, new PlanetSettings(), new ZonePack(), galaxy.Zones[0], galaxy); + Assert.Equal("Zone 0".StableHash(), zone.CombatSeed); + Assert.Equal(zone.CombatSeed, zone.DropSeed); } } diff --git a/tests/Aetheria.Shared.Tests/RunSeedFixture.cs b/tests/Aetheria.Shared.Tests/RunSeedFixture.cs new file mode 100644 index 000000000..0152584b7 --- /dev/null +++ b/tests/Aetheria.Shared.Tests/RunSeedFixture.cs @@ -0,0 +1,26 @@ +using System.Collections.Generic; +using GameCult.Caching; + +// A fixture's run at a fixed seed, restored as Continue restores it: one zone, committed through RunSave.Commit and +// read back by the Galaxy save constructor, so a test's galaxy carries the seed the way a real run's does. +internal static class RunSeedFixture +{ + public static Galaxy Continue(CultCache cache, string zoneName, uint seed) + { + var game = new SavedGame + { + Seed = seed, + Factions = new CultRecordRef[0], + Relationships = new FactionRelationship[0], + HomeZones = new Dictionary(), + BossZones = new Dictionary(), + DiscoveredZones = new[] { 0 }, + ActionBarBindings = new SavedActionBarBinding[0], + Entrance = 0, + Exit = -1 + }; + var zone = new SavedZone { Name = zoneName, AdjacentZones = new int[0], Factions = new int[0], Owner = -1 }; + RunSave.Commit(cache, game, new[] { zone }, new ProvenanceLedger()); + return new Galaxy(cache, cache.GetGlobal(), _ => { }); + } +} From 727c4096595cf589658c59d83a8fcf226b1b1888 Mon Sep 17 00:00:00 2001 From: Meta Vens Date: Sun, 18 Oct 2026 17:33:11 +0200 Subject: [PATCH 008/444] Pin the dice of a zone without a run seed at their pre-run-seed values A zone with no galaxy and a zone of a save written before run-seed (seed 0) drop the items and scatter the mines they did at eb27ced1; values computed at that base. Co-Authored-By: Claude Sonnet 5.5 --- tests/Aetheria.Shared.Tests/LootTests.cs | 25 ++++++++++++++++++ tests/Aetheria.Shared.Tests/MineTests.cs | 32 ++++++++++++++++-------- 2 files changed, 47 insertions(+), 10 deletions(-) diff --git a/tests/Aetheria.Shared.Tests/LootTests.cs b/tests/Aetheria.Shared.Tests/LootTests.cs index ae47f6b32..e8a5016a7 100644 --- a/tests/Aetheria.Shared.Tests/LootTests.cs +++ b/tests/Aetheria.Shared.Tests/LootTests.cs @@ -238,6 +238,31 @@ public sealed class LootTests : IDisposable Assert.True(Differ(first, DropsAt("Other dice", 7))); } + // Pinned at eb27ced1, before run-seed: a zone with no galaxy, and a zone of a save written before run-seed (seed 0), + // roll on the zone's name alone and drop exactly what they dropped. + [Theory] + [InlineData(false)] + [InlineData(true)] + public void AZoneWithoutARunSeedKeepsItsDrops(bool restoredFromAnOldSave) + { + var drops = restoredFromAnOldSave ? DropsAt("Dice", 0) : Released(KillInNewLab("Dice"), 0); + Assert.Equal(new[] { "Spare", "Orex7", "Spare" }, drops.Select(d => d.Item)); + Assert.All(drops, d => Assert.Equal(new float2(31, -17), d.Position)); + var velocities = new[] { float2(-17.977364f, 17.372805f), float2(-5.908107f, 24.291857f), float2(-24.228783f, -6.1616626f) }; + for (var i = 0; i < velocities.Length; i++) + { + Assert.Equal(velocities[i].x, drops[i].Velocity.x, 3); + Assert.Equal(velocities[i].y, drops[i].Velocity.y, 3); + } + } + + private Lab KillInNewLab(string zoneName) + { + var lab = Build(zoneName); + Kill(Spawn(lab, float3(31, 0, -17))); + return lab; + } + [Fact] public void GearDropsByProbabilityCargoAlways() { diff --git a/tests/Aetheria.Shared.Tests/MineTests.cs b/tests/Aetheria.Shared.Tests/MineTests.cs index 24269af1a..137d3f84a 100644 --- a/tests/Aetheria.Shared.Tests/MineTests.cs +++ b/tests/Aetheria.Shared.Tests/MineTests.cs @@ -690,19 +690,31 @@ public sealed class MineTests : IDisposable [Fact] public void ANewGameScattersMinesDifferently() { - float2 LaunchAt(uint runSeed) - { - var lab = Build(spread: 40f, velocity: 5f, layerFaction: A, runSeed: runSeed); - var atLay = default(KinematicBody); - using var sub = lab.Zone.Mines.ObserveAdd().Subscribe(e => atLay = e.Value.Body); - lab.Layer.Direction = normalize(float2(1, 2)); - LayOne(lab); - return atLay.Velocity; - } - Assert.NotEqual(LaunchAt(1), LaunchAt(2)); } + private float2 LaunchAt(uint? runSeed) + { + var lab = Build(spread: 40f, velocity: 5f, layerFaction: A, runSeed: runSeed); + var atLay = default(KinematicBody); + using var sub = lab.Zone.Mines.ObserveAdd().Subscribe(e => atLay = e.Value.Body); + lab.Layer.Direction = normalize(float2(1, 2)); + LayOne(lab); + return atLay.Velocity; + } + + // Pinned at eb27ced1, before run-seed: a zone with no galaxy, and a zone of a save written before run-seed (seed 0), + // scatter their mines on the zone's name alone. + [Theory] + [InlineData(false)] + [InlineData(true)] + public void AZoneWithoutARunSeedKeepsItsMineScatter(bool restoredFromAnOldSave) + { + var velocity = LaunchAt(restoredFromAnOldSave ? 0u : (uint?) null); + Assert.Equal(3.2546253f, velocity.x, 3); + Assert.Equal(3.795711f, velocity.y, 3); + } + [Fact] public void TheLaunchAngleIsTheMinesOwnDice() { From 47f834ea23b4c8939316972c9033b208e57c4cfb Mon Sep 17 00:00:00 2001 From: Meta Vens Date: Sun, 18 Oct 2026 17:39:52 +0200 Subject: [PATCH 009/444] Pin all five pre-run-seed drops of the dice zone (the first pin kept three) Co-Authored-By: Claude Sonnet 5.5 --- tests/Aetheria.Shared.Tests/LootTests.cs | 8 ++++++-- 1 file changed, 6 insertions(+), 2 deletions(-) diff --git a/tests/Aetheria.Shared.Tests/LootTests.cs b/tests/Aetheria.Shared.Tests/LootTests.cs index e8a5016a7..ecd65f8e1 100644 --- a/tests/Aetheria.Shared.Tests/LootTests.cs +++ b/tests/Aetheria.Shared.Tests/LootTests.cs @@ -246,9 +246,13 @@ public sealed class LootTests : IDisposable public void AZoneWithoutARunSeedKeepsItsDrops(bool restoredFromAnOldSave) { var drops = restoredFromAnOldSave ? DropsAt("Dice", 0) : Released(KillInNewLab("Dice"), 0); - Assert.Equal(new[] { "Spare", "Orex7", "Spare" }, drops.Select(d => d.Item)); + Assert.Equal(new[] { "Spare", "Spare", "Spare", "Orex7", "Spare" }, drops.Select(d => d.Item)); Assert.All(drops, d => Assert.Equal(new float2(31, -17), d.Position)); - var velocities = new[] { float2(-17.977364f, 17.372805f), float2(-5.908107f, 24.291857f), float2(-24.228783f, -6.1616626f) }; + var velocities = new[] + { + float2(-14.870228f, -20.096674f), float2(-4.9265895f, -24.509768f), float2(-17.977364f, 17.372805f), + float2(-5.908107f, 24.291857f), float2(-24.228783f, -6.1616626f) + }; for (var i = 0; i < velocities.Length; i++) { Assert.Equal(velocities[i].x, drops[i].Velocity.x, 3); From 4ab2ba15c7fab8ccab864ef77136d02d6e943cf5 Mon Sep 17 00:00:00 2001 From: Meta Vens Date: Sun, 18 Oct 2026 17:49:46 +0200 Subject: [PATCH 010/444] Delete Faction.BossHull and its readers Key 10 is retired (shipped values left in place). GenerateBossLoadout is its fallback alone; the factions command no longer reads a named boss; the two BossHull tests go (the named-but-unsold case cannot exist under a product-first derivation). Galaxy.PlaceFactionsMain, the Exit_open test and the derivation arrive in the next commit; this commit does not build. Co-Authored-By: Claude Sonnet 5.5 --- Assets/Scripts/ServerShared/Corporations.cs | 3 +- .../Scripts/ServerShared/LoadoutGenerator.cs | 14 ++---- tests/Aetheria.Shared.Tests/RunGoalTests.cs | 44 ------------------- tools/AetherDb/Program.cs | 12 ++--- 4 files changed, 7 insertions(+), 66 deletions(-) diff --git a/Assets/Scripts/ServerShared/Corporations.cs b/Assets/Scripts/ServerShared/Corporations.cs index c242e3e42..7265941c9 100644 --- a/Assets/Scripts/ServerShared/Corporations.cs +++ b/Assets/Scripts/ServerShared/Corporations.cs @@ -39,8 +39,7 @@ public class Faction // Key 9 was the faction's name-file link, retired by ruling faction-curated-place-names; never reuse it. - [Inspectable, JsonProperty("bossHull"), Key(10)] - public CultRecordRef BossHull; + // Key 10 was the faction's named boss hull, retired by boss-hull-derived: a faction's boss hulls are derived from the Boss hulls it makes; never reuse it. [Inspectable, JsonProperty("influence"), Key(11)] public int InfluenceDistance = 4; diff --git a/Assets/Scripts/ServerShared/LoadoutGenerator.cs b/Assets/Scripts/ServerShared/LoadoutGenerator.cs index 55eea27f8..67cc7f6b4 100644 --- a/Assets/Scripts/ServerShared/LoadoutGenerator.cs +++ b/Assets/Scripts/ServerShared/LoadoutGenerator.cs @@ -209,19 +209,11 @@ public class LoadoutGenerator return (available, preferManufacturers); } - // A faction's boss: its BossHull when the faction sets one and a product sells it, else the most expensive ship hull - // the faction can be offered, with the gap logged. Null only when no ship hull is on offer at all. + // A faction's boss: the most expensive ship hull the faction can be offered, with the gap logged. Null only when no + // ship hull is on offer at all. public EntityPack GenerateBossLoadout() { - var bossHull = Faction != null && Faction.BossHull.IsSet() ? ItemManager.ItemData.Get(Faction.BossHull) : null; - if (bossHull != null) - { - var boss = GenerateShipLoadout(hull => hull == bossHull); - if (boss != null) return boss; - ItemManager.Log($"{Faction.Name}'s BossHull {bossHull.Name} is not sold by any product; its boss flies the most expensive ship hull instead."); - } - else - ItemManager.Log($"{Faction?.Name ?? "This faction"} has no BossHull; its boss flies the most expensive ship hull instead."); + ItemManager.Log($"{Faction?.Name ?? "This faction"} makes no boss hull; its boss flies the most expensive ship hull instead."); var (available, _) = AvailableProducts(hull => hull.HullType == HullType.Ship, required: true); if (available.Length == 0) return null; diff --git a/tests/Aetheria.Shared.Tests/RunGoalTests.cs b/tests/Aetheria.Shared.Tests/RunGoalTests.cs index 25b46fdd3..5d133893f 100644 --- a/tests/Aetheria.Shared.Tests/RunGoalTests.cs +++ b/tests/Aetheria.Shared.Tests/RunGoalTests.cs @@ -70,50 +70,6 @@ public sealed partial class RunStartTests Assert.DoesNotContain(pack.Entities, entity => !entity.Boss && entity is ShipPack); } - [Fact] - public void Boss_hull_from_BossHull_else_fallback() - { - var galaxy = DemoGalaxy(1); - var antagonist = Zhestokost(galaxy); - var hulls = SoldShipHulls().OrderByDescending(hull => hull.Price).ThenBy(hull => hull.Name, StringComparer.Ordinal).ToArray(); - var priciest = hulls.First(); - var cheaper = hulls.Last(); - Assert.True(cheaper.Price < priciest.Price, "the catalog needs two ship hulls of different price for this test to tell them apart"); - - // Set and sold: the boss flies it, and nothing is logged about a gap. - antagonist.BossHull = _cache.RefOf(cheaper); - var log = new List(); - var items = LoggingItems(log); - var boss = Assert.Single(BossZonePack(galaxy, items, galaxy.Exit).Entities, entity => entity.Boss); - Assert.Equal(cheaper.Name, HullName(items, boss)); - Assert.DoesNotContain(log, line => line.Contains("BossHull")); - - // Unset: the most expensive ship hull on offer, and the log names the missing BossHull. - antagonist.BossHull = default; - log.Clear(); - boss = Assert.Single(BossZonePack(galaxy, items, galaxy.Exit).Entities, entity => entity.Boss); - Assert.Equal(priciest.Name, HullName(items, boss)); - Assert.Contains(log, line => line.Contains("Zhestokost") && line.Contains("no BossHull")); - } - - // Set but sold by no product: the boss falls back the same way, and the log says which hull has no seller. - [Fact] - public void Boss_hull_nobody_sells_falls_back() - { - var galaxy = DemoGalaxy(1); - var antagonist = Zhestokost(galaxy); - var sold = SoldShipHulls(); - // A ship hull no product sells, dearer than any that is sold: the fallback must not pick it. - var unsold = new HullData { Name = "Unsold Test Hull", HullType = HullType.Ship, Price = sold.Max(hull => hull.Price) + 1 }; - _cache.Upsert(unsold); - antagonist.BossHull = _cache.RefOf(unsold); - var log = new List(); - var items = LoggingItems(log); - var boss = Assert.Single(BossZonePack(galaxy, items, galaxy.Exit).Entities, entity => entity.Boss); - Assert.Equal(sold.OrderByDescending(hull => hull.Price).ThenBy(hull => hull.Name, StringComparer.Ordinal).First().Name, HullName(items, boss)); - Assert.Contains(log, line => line.Contains(unsold.Name) && line.Contains("BossHull")); - } - [Fact] public void Exit_sealed_until_boss_dies() { diff --git a/tools/AetherDb/Program.cs b/tools/AetherDb/Program.cs index 5df9ed99c..500d85a8e 100644 --- a/tools/AetherDb/Program.cs +++ b/tools/AetherDb/Program.cs @@ -583,8 +583,7 @@ public static class Program // Every CultRecordRef in the catalog that resolves to nothing, as " -> ". // Each "clear " argument unsets that member's dangling refs, or removes that dictionary's - // dangling keys (Faction.BossHull clears boss hulls that would claim a chokepoint no boss can spawn in). Dry - // run unless passed "apply", which alone opens the catalog writable and lands every changed record in one commit. + // dangling keys. Dry run unless passed "apply", which alone opens the catalog writable and lands every changed record in one commit. private static int Dangling(string[] args) { var apply = args.Contains("apply"); @@ -702,19 +701,14 @@ public static class Program var db = AetherDb.Open(); var products = db.Cache.GetAll().ToArray(); Console.WriteLine($"{"faction",-26} {"short",-13} {"places",-22} {"boss hull",-14} {"influence",-9} {"doctrine",-10} products"); - var broken = 0; foreach (var faction in db.Cache.GetAll().OrderBy(f => f.Name)) { var key = db.Cache.RefOf(faction).Key; - var boss = !faction.BossHull.IsSet() - ? "none" - : db.Cache.Get(faction.BossHull)?.Name ?? "DANGLING"; - if (boss == "DANGLING") broken++; + var boss = "none"; Console.WriteLine($"{faction.Name,-26} {faction.ShortName,-13} {faction.Places.Length,-22} {boss,-14} {faction.InfluenceDistance,-9} {(faction.Doctrine == null ? "none" : faction.Doctrine.EngageOn.ToString()),-10} " + products.Count(p => p.Manufacturer.Key.Equals(key))); } - Console.WriteLine($"\n{broken} factions carry a link that would break generation"); - return broken; + return 0; } // What the saved run actually holds per zone. A zone carrying orbits but no orbital entities was packed by a From b19d6add89a086690e6080b43369f5b7e8b0254b Mon Sep 17 00:00:00 2001 From: Meta Vens Date: Sun, 18 Oct 2026 17:51:50 +0200 Subject: [PATCH 011/444] aetheria-release map: product tiers follow ruling bin-share-by-floor (spec r2) Co-Authored-By: Claude Opus 5.5 --- docs/aetheria-release-map.md | 36 +++++++++++++++++++++--------------- 1 file changed, 21 insertions(+), 15 deletions(-) diff --git a/docs/aetheria-release-map.md b/docs/aetheria-release-map.md index ae5c32760..b3a9f1c42 100644 --- a/docs/aetheria-release-map.md +++ b/docs/aetheria-release-map.md @@ -53,9 +53,10 @@ Cuts without a `cut_spec`, withdrawn 2026-10-12 under map-only-what-runs: a spec - matrix-flamers: Weapon matrix, Jet row: Light Flamethrower, Flamethrower, Heavy Flamethrower and Inferno Projector, thermal pellet guns. Waits on: nothing in the mind (refresh needed; ruling catalog-breadth-now (superseded catalog-breadth-after-visibility 2026-10-13): runs now, refresh against master first). - matrix-mines: Weapon matrix, Mine row: Mine Rack, Heavy Mine Layer and Minefield Projector beside the Mine Launcher, with warhead and. Waits on: roles-backfill (refresh needed; ruling catalog-breadth-now (superseded catalog-breadth-after-visibility 2026-10-13): runs now, refresh against master first). - gear-lines (spec cut-gear-lines.r1, remaps withdrawn product-lines-gear): 22 second-maker gear products as 'product-lines gear' in ProductLinesCommand. The follow-up's rows less its two Compact Sensor rows, plus Medium Drive, Large Drive and Industrial Thermostatic Heater, so every role-bearing ship gear design that is sold has two makers (Muon Fusion and the four station reactors exempt). The command's no-roles refusal exempts cargo bays. Waits on: nothing; runs now. Replay: 'product-lines gear apply', either side of minor-power-factions, before assembly-from-maker. Body facts: Products PL3, PL10-PL12. -- product-tiers: spec cut-product-tiers.r1 (map section Product tiers). A binned product names its maker's line of one - design and a floor in deviations; the mint draws its parts and its final test from the line above that floor. Six - binned products, no hull tiers. Blocked on question tier-stock-share. +- product-tiers: spec cut-product-tiers.r2 (map section Product tiers). A binned product names its maker's line of one + design and a floor in deviations, and the mint draws its parts and its final test from the line above that floor. A + bin takes its pass fraction of the maker's preference and the line takes the rest (ruling bin-share-by-floor). Six + binned products, no hull tiers. - product-tiers-hulls (later, one line): ship hulls stay one product per maker per hull (`ShipModCatalog.ProductKey(id, maker)`). A binned hull needs that key widened and its package's product record to carry a Bin. Not mapped. @@ -10961,9 +10962,10 @@ never shows below its floor tier: Spotlight Premiere is always Rare or better, a price (x2.87 against x1.61). Goldcrest is Epic or Legendary at about 2.75 times Sparrow's price. The properties panel reads, for example, "Spotlight Premiere (Rare++)". How much a bin buys follows the maker's quality control. Finch's loose QC (±.20) leaves a lot to bin, so its bins jump two tiers. Zhestokost's tight drives (±.08) bin only to a better -Uncommon: Juggernaut Guards costs about 1.4 times Juggernaut and still looks Uncommon. How often a binned product -appears beside its line is question tier-stock-share. Under the recommended split-evenly, the maker is as present as -before and its stock divides evenly between line and bin. +Uncommon: Juggernaut Guards costs about 1.4 times Juggernaut and still looks Uncommon. A bin is as scarce as its floor says +(ruling bin-share-by-floor). In Lucent's stock of Light Lasers, half are Spotlight Premieres and half plain Spotlights. +A top-quarter bin such as Goldcrest or PowerHour 2.0 VIP is about one in four of its maker's units. The maker's +presence for the design does not change when it bins a line. ### Authority map @@ -10975,8 +10977,9 @@ before and its stock divides evenly between line and bin. - Outputs: a Lot whose Product is the binned product (so Title and Brand read it), with every fill and the assembly drawn at or above the floor. Price and tier follow from EffectiveQuality, unchanged. - Derived state: a binned product's quality distributions are derived and never stored. Its Roles stay empty and its - Assembly null. It has no per-product Price. Its draw weight is derived from its maker's preference (question - tier-stock-share). + Assembly null. It has no per-product Price. Its draw weight is derived: the bin weighs `1 - Phi(Floor)` of its + maker's preference and its line weighs the rest (ruling bin-share-by-floor). Phi is computed through CultMath's + `math.erf`, present at the pinned CultMath 9c1b06cd (`packages/cultmath/src/CultMath/math.cs:534`). - Forbidden writers: ProductLinesCommand never writes Roles or Assembly onto a binned product. roles-migrate and roles-backfill never backfill one. The census never reports its empty Roles as a gap. Studio edits to a line's roles or a maker's AssemblyMean reach its bins through the derivation, with no copy to resync. @@ -11005,16 +11008,17 @@ before and its stock divides evenly between line and bin. draw and then mints at the cutoff, so the draw always ends and stays deterministic per seed. Unbinned products consume the stream exactly as before. - Why the floor is in deviations. A floor in deviations needs no inverse normal: z 0 is the top half and z .67 the top - quarter. Its fraction (1 - Phi(z) per stage) is only needed for the bin-share option of question tier-stock-share. + quarter. The draw weight needs only the forward CDF, `1 - Phi(z) = (1 - erf(z / sqrt 2)) / 2`, from CultMath's erf. + It is .5 at z 0 and .2514 at z .67. - Why the line is not sorted. In real binning the base SKU is what is left after sorting. Here the line keeps its full distribution, so a plain Spotlight can still roll Epic. Sorting the line would re-author every binned line and add a - ceiling, and sorting a facility's own output is the crafting work that quality-triangle-is-per-facility defers. That - choice is option sorted-line of tier-stock-share, not built here. + ceiling, and sorting a facility's own output is the crafting work that quality-triangle-is-per-facility defers. Sorting + was option sorted-line of tier-stock-share, which the operator did not pick (ruling bin-share-by-floor). - Prior art (from general knowledge, confidence high on the shape and low on numbers). Semiconductor speed binning: one die design is sold as several SKUs sorted by tested frequency and voltage, and the premium SKU is a guaranteed floor, not a better average. Die harvesting: partly defective dies sell as lower SKUs. Makers also down-bin good - parts to meet demand, so a SKU's supply share is a commercial choice and not only yield, which is why split-evenly - is defensible for the demo. + parts to meet demand, so a SKU's supply share is a commercial choice and not only yield, The operator chose + shares that follow the floor (ruling bin-share-by-floor), so a bin's scarcity reads as yield. - Hulls are out. `ShipModCatalog.ProductKey(id, maker)` keys one product per maker per hull, and hull products live in packages that the Python writer leaves untouched (`ship_cc.py:5`). Widening that key is its own cut. @@ -11026,8 +11030,10 @@ before and its stock divides evenly between line and bin. its own. Invariant: a Bin's Line has the same Design and Manufacturer and is not itself binned. - Lot (unchanged schema): a binned lot records the binned product, and its fills and assembly sit at or above the floor of the line's distributions. -- ProductWeight: the maker's preference, divided evenly among the maker's products of that design (pending - tier-stock-share). It is still the one weight of RandomProducts and ChooseProduct. +- ProductWeight: the maker's preference times a share. A bin's share is its pass fraction `1 - Phi(Bin.Floor)`. A line's + share is 1 minus the sum of the pass fractions of its bins, floored at 0. Any other product's share is 1. Two + unbinned lines of one design and maker each weigh the full preference, as before. ProductWeight is still the one + weight of RandomProducts and ChooseProduct. ## Run seed" section at the end of docs/aetheria-release-map.md (or beside "Mines in the sim"). 2. Remove the "- run-seed: ..." line from the first "## Cut sequence" (spec cut-run-seed.r1 now carries it). From 1b5e989182dd97abeea54fc153e457e16853823f Mon Sep 17 00:00:00 2001 From: Meta Vens Date: Sun, 18 Oct 2026 17:54:50 +0200 Subject: [PATCH 012/444] Derive a faction's boss hull from HullData.Boss and its products HullData.Boss (key 33) is the flag; LoadoutGenerator.BossHullsOf is the one owner of which Ship hulls are a faction's boss (the Boss hulls among the designs of its products). AvailableProducts' candidate filter drops Boss hulls, so one is never stock, traffic, a generated fit or a fallback boss. GenerateShipLoadout splits into the draw and ShipLoadout(product). Galaxy.PlaceFactionsMain and AetherDb factions/loadout read the derivation. Derivation tests replace the two deleted BossHull tests. Co-Authored-By: Claude Sonnet 5.5 --- Assets/Scripts/ServerShared/Galaxy.cs | 4 +- Assets/Scripts/ServerShared/ItemData.cs | 5 + .../Scripts/ServerShared/LoadoutGenerator.cs | 32 ++- tests/Aetheria.Shared.Tests/RunGoalTests.cs | 193 +++++++++++++++++- tools/AetherDb/Program.cs | 13 +- 5 files changed, 235 insertions(+), 12 deletions(-) diff --git a/Assets/Scripts/ServerShared/Galaxy.cs b/Assets/Scripts/ServerShared/Galaxy.cs index 0b2f71387..6e28cf49e 100644 --- a/Assets/Scripts/ServerShared/Galaxy.cs +++ b/Assets/Scripts/ServerShared/Galaxy.cs @@ -288,9 +288,9 @@ public class Galaxy progressCallback?.Invoke("Placing Factions"); if (progressCallback != null) Thread.Sleep(500); // Inserting Delay to make it seem like it's doing more work lmao - // Choose some megas to have bosses placed based on whether a boss hull is assigned + // Choose some megas to have bosses placed: the factions that make a Boss hull var bossMegas = Factions - .Where(m => m.BossHull.IsSet()) + .Where(m => LoadoutGenerator.BossHullsOf(_cache, m).Length > 0) .Take(bossCount) .ToArray(); diff --git a/Assets/Scripts/ServerShared/ItemData.cs b/Assets/Scripts/ServerShared/ItemData.cs index ca1484e36..431acc7ce 100644 --- a/Assets/Scripts/ServerShared/ItemData.cs +++ b/Assets/Scripts/ServerShared/ItemData.cs @@ -555,6 +555,11 @@ public class HullData : EquippableItemData [Inspectable, JsonProperty("visual"), Key(32)] public CultRecordRef Visual; + // Only bosses fly this hull. Which factions it is a boss for comes from its products' makers + // (LoadoutGenerator.BossHullsOf), so the flag names no faction and reads no model. + [Inspectable, JsonProperty("boss"), Key(33)] + public bool Boss; + // The farthest corner of any occupied hull cell from the centre of mass the schematic frame uses, in world // units: a true bound on how far the hull reaches from its position (the half-diagonal of the bounding box is not, // as FireControl.Detonate's comment records). A cell at (x, y) spans x +/- .5 and y +/- .5. diff --git a/Assets/Scripts/ServerShared/LoadoutGenerator.cs b/Assets/Scripts/ServerShared/LoadoutGenerator.cs index 67cc7f6b4..34e1cd1c0 100644 --- a/Assets/Scripts/ServerShared/LoadoutGenerator.cs +++ b/Assets/Scripts/ServerShared/LoadoutGenerator.cs @@ -48,7 +48,13 @@ public class LoadoutGenerator ItemManager.Log("Unable to generate ship loadout: no compatible hull found!"); return null; } - var hull = ItemManager.CreateInstance(hullProduct) as EquippableItem; + return ShipLoadout(hullProduct); + } + + // The ship a hull product becomes: outfit, pickup, consumables, packed. Callers choose the product; this is the one build. + public EntityPack ShipLoadout(FactionProductData product) + { + var hull = ItemManager.CreateInstance(product) as EquippableItem; if(hull==null) ItemManager.Log("WHAT???"); var entity = new Ship(ItemManager, null, hull, ItemManager.GameplaySettings.DefaultEntitySettings); @@ -184,7 +190,9 @@ public class LoadoutGenerator } // The products this faction can be offered for a design kind (only gear needs a home), and whether manufacturer preference applies (it does - // not once a required item has fallen back to any manufacturer). The one availability rule for every selection. + // not once a required item has fallen back to any manufacturer). The one availability rule for every selection: a Boss + // hull is never on offer, so it is never station stock, ordinary traffic, a generated scenario fit or another faction's + // fallback boss, and only BossHullsOf reaches it. private ((FactionProductData product, T design)[] available, bool preferManufacturers) AvailableProducts(Predicate filter, bool required) where T : CraftedItemData { var hulls = SoldHulls(ItemManager.ItemData); @@ -193,6 +201,7 @@ public class LoadoutGenerator .Where(entry => entry.design != null && entry.design.Price > 0 && + !(entry.design is HullData { Boss: true }) && entry.product.Manufacturer.IsSet() && (!(entry.design is EquippableItemData equippable) || HasHome(equippable, hulls)) && (filter?.Invoke(entry.design) ?? true)) @@ -209,10 +218,12 @@ public class LoadoutGenerator return (available, preferManufacturers); } - // A faction's boss: the most expensive ship hull the faction can be offered, with the gap logged. Null only when no - // ship hull is on offer at all. + // A faction's boss: the Boss hull it makes (BossHullsOf, dearest first), else the most expensive ordinary ship hull the + // faction can be offered, with the gap logged. Null only when no ship hull is on offer at all. public EntityPack GenerateBossLoadout() { + var made = BossHullsOf(ItemManager.ItemData, Faction); + if (made.Length > 0) return ShipLoadout(made[0].product); ItemManager.Log($"{Faction?.Name ?? "This faction"} makes no boss hull; its boss flies the most expensive ship hull instead."); var (available, _) = AvailableProducts(hull => hull.HullType == HullType.Ship, required: true); @@ -221,6 +232,19 @@ public class LoadoutGenerator return GenerateShipLoadout(hull => hull == priciest); } + // The one owner of which hull is a faction's boss: the Ship hulls flagged Boss among the designs of the products the + // faction manufactures, dearest first then by name. The flag lives on the hull and the maker on the product, so a Boss + // hull several makers sell is a boss of each; nothing here reads a model, a prefab or a visual. + public static (FactionProductData product, HullData hull)[] BossHullsOf(CultCache items, Faction faction) => + faction == null + ? Array.Empty<(FactionProductData, HullData)>() + : items.GetAll() + .Where(product => items.Get(product.Manufacturer) == faction) + .Select(product => (product, hull: items.Get(product.Design) as HullData)) + .Where(entry => entry.hull is { Boss: true, HullType: HullType.Ship }) + .OrderByDescending(entry => entry.hull.Price).ThenBy(entry => entry.hull.Name, StringComparer.Ordinal).ThenBy(entry => entry.product.Name, StringComparer.Ordinal) + .ToArray(); + // Hardpoint gear that no hull in the catalog can mount has no home (operator, 2026-09-30: "we'll need to author a // bunch more hulls before all the gear variety in the game has a home"): generation never offers it, so no station // stocks it. It gains one the moment a hull with a hardpoint that takes it exists. Tool gear goes in any interior. diff --git a/tests/Aetheria.Shared.Tests/RunGoalTests.cs b/tests/Aetheria.Shared.Tests/RunGoalTests.cs index 5d133893f..cbb7ac13b 100644 --- a/tests/Aetheria.Shared.Tests/RunGoalTests.cs +++ b/tests/Aetheria.Shared.Tests/RunGoalTests.cs @@ -8,6 +8,8 @@ using System.IO; using System.Linq; using System.Text.RegularExpressions; using CultMath; +using GameCult.Caching; +using GameCult.Caching.MessagePack; using UniRx; using Xunit; @@ -38,6 +40,191 @@ public sealed partial class RunStartTests .Where(hull => hull != null && hull.HullType == HullType.Ship && hull.Price > 0) .Distinct().ToArray(); + // A Boss hull a faction makes, by the production path: a copy of a sold ship hull (so it builds into a real ship) flagged + // Boss under its own name and price, and one copy of that hull's product per maker. A copy rather than the shipped + // hull itself, because flagging a shipped hull would make it a Boss hull of every maker the catalog gives it. + private HullData MakeBoss(string name, int price, params Faction[] makers) => MakeBoss(HullType.Ship, name, price, makers); + + private HullData MakeBoss(HullType type, string name, int price, params Faction[] makers) + { + var options = CultDocumentMessagePackSerialization.OptionsFor(typeof(HullData).Assembly); + T Copy(T document) => MessagePack.MessagePackSerializer.Deserialize(MessagePack.MessagePackSerializer.Serialize(document, options), options); + + var like = SoldShipHulls().OrderBy(hull => hull.Name, StringComparer.Ordinal).First(); + var likeProduct = _cache.GetAll().OrderBy(product => product.Name, StringComparer.Ordinal) + .First(product => _cache.Get(product.Design) == like); + var boss = Copy(like); + boss.Name = name; + boss.Price = price; + boss.HullType = type; + boss.Boss = true; + boss.Prefab = null; + boss.Visual = default; + var bossRef = _cache.Upsert(boss); + foreach (var maker in makers) + { + var product = Copy(likeProduct); + product.Name = $"{name} by {maker.Name}"; + product.Design = bossRef; + product.Manufacturer = _cache.RefOf(maker); + _cache.Upsert(product); + } + return _cache.Get(bossRef); + } + + private static Faction FactionNamed(Galaxy galaxy, string name) => galaxy.Factions.Single(faction => faction.Name == name); + + private HullData PriciestSold() => + SoldShipHulls().OrderByDescending(hull => hull.Price).ThenBy(hull => hull.Name, StringComparer.Ordinal).First(); + + private LoadoutGenerator GeneratorFor(Galaxy galaxy, ItemManager items, Faction faction, uint seed) + { + var random = new CultMath.Random(seed); + return new LoadoutGenerator(ref random, items, galaxy, galaxy.Exit, faction, .5f); + } + + [Fact] + public void Boss_flies_the_boss_hull_its_faction_makes() + { + var galaxy = DemoGalaxy(1); + var antagonist = Zhestokost(galaxy); + var cheapest = SoldShipHulls().Min(hull => hull.Price); + var priciest = PriciestSold(); + Assert.True(cheapest < priciest.Price, "the catalog needs two ship hulls of different price for this test to tell them apart"); + + // The faction also makes the dearest ordinary hull, and three Boss hulls: B and A tie on price, C is cheaper. The boss + // is the dearest Boss hull, ties by name: not the dearest hull it makes, not the first product, not the cheapest. + _cache.Upsert(new FactionProductData { Name = "Decoy", Design = _cache.RefOf(priciest), Manufacturer = _cache.RefOf(antagonist) }); + MakeBoss("Boss B", cheapest + 10, antagonist); + MakeBoss("Boss C", cheapest, antagonist); + MakeBoss("Boss A", cheapest + 10, antagonist); + + var log = new List(); + var items = LoggingItems(log); + var boss = Assert.Single(BossZonePack(galaxy, items, galaxy.Exit).Entities, entity => entity.Boss); + Assert.Equal("Boss A", HullName(items, boss)); + Assert.DoesNotContain(log, line => line.Contains("makes no boss hull")); + Assert.Equal(new[] { "Boss A", "Boss B", "Boss C" }, LoadoutGenerator.BossHullsOf(_cache, antagonist).Select(entry => entry.hull.Name)); + } + + [Fact] + public void Boss_without_a_boss_hull_falls_back() + { + var galaxy = DemoGalaxy(1); + var priciest = PriciestSold(); + + var log = new List(); + var items = LoggingItems(log); + var boss = Assert.Single(BossZonePack(galaxy, items, galaxy.Exit).Entities, entity => entity.Boss); + Assert.Equal(priciest.Name, HullName(items, boss)); + Assert.Contains(log, line => line.Contains("Zhestokost") && line.Contains("makes no boss hull")); + Assert.Empty(LoadoutGenerator.BossHullsOf(_cache, Zhestokost(galaxy))); + Assert.Empty(LoadoutGenerator.BossHullsOf(_cache, null)); + } + + [Fact] + public void Another_factions_boss_hull_is_never_used() + { + var galaxy = DemoGalaxy(1); + var antagonist = Zhestokost(galaxy); + var lucent = FactionNamed(galaxy, "Lucent"); + var expected = PriciestSold().Name; + // Dearer than every ordinary hull, and Lucent's alone: Zhestokost neither derives it nor falls back to it. + var luxury = MakeBoss("Lucent Flagship", PriciestSold().Price + 1000, lucent); + + var log = new List(); + var items = LoggingItems(log); + var boss = Assert.Single(BossZonePack(galaxy, items, galaxy.Exit).Entities, entity => entity.Boss); + Assert.Equal(expected, HullName(items, boss)); + Assert.Contains(log, line => line.Contains("Zhestokost") && line.Contains("makes no boss hull")); + Assert.Empty(LoadoutGenerator.BossHullsOf(_cache, antagonist)); + Assert.Same(luxury, Assert.Single(LoadoutGenerator.BossHullsOf(_cache, lucent)).hull); + } + + [Fact] + public void A_boss_hull_two_makers_sell_is_a_boss_of_each() + { + var galaxy = DemoGalaxy(1); + var antagonist = Zhestokost(galaxy); + var lucent = FactionNamed(galaxy, "Lucent"); + var shared = MakeBoss("Shared Boss", PriciestSold().Price - 1, antagonist, lucent); + // A Boss station hull the antagonist also makes is no boss: a boss is a ship. + MakeBoss(HullType.Station, "Boss Station", PriciestSold().Price + 1, antagonist); + + Assert.Same(shared, Assert.Single(LoadoutGenerator.BossHullsOf(_cache, antagonist)).hull); + Assert.Same(shared, Assert.Single(LoadoutGenerator.BossHullsOf(_cache, lucent)).hull); + var items = LoggingItems(new List()); + var boss = Assert.Single(BossZonePack(galaxy, items, galaxy.Exit).Entities, entity => entity.Boss); + Assert.Equal("Shared Boss", HullName(items, boss)); + } + + // Ruling hull-programmer-art-stopgap: a boss hull may be an agent's stopgap with no authored model. + [Fact] + public void A_boss_hull_needs_no_authored_model() + { + var galaxy = DemoGalaxy(1); + var antagonist = Zhestokost(galaxy); + var bare = MakeBoss("Bare Boss", 1, antagonist); + Assert.Null(bare.Prefab); + Assert.False(bare.Visual.IsSet()); + + Assert.Same(bare, Assert.Single(LoadoutGenerator.BossHullsOf(_cache, antagonist)).hull); + var items = LoggingItems(new List()); + var boss = Assert.Single(BossZonePack(galaxy, items, galaxy.Exit).Entities, entity => entity.Boss); + Assert.Equal("Bare Boss", HullName(items, boss)); + } + + // A Boss hull is flown only by bosses: with the cheapest price (the heaviest draw weight) and every relevant maker, it still + // never comes out of a generation draw, including the any-manufacturer fallback and a faction no maker is allied to. + [Fact] + public void Boss_hulls_are_never_stock_or_traffic() + { + var galaxy = DemoGalaxy(1); + var antagonist = Zhestokost(galaxy); + var lucent = FactionNamed(galaxy, "Lucent"); + var bossShip = MakeBoss("Traffic Boss", 1, antagonist, lucent); + var bossStation = MakeBoss(HullType.Station, "Stock Boss", 1, antagonist, lucent); + var stranger = new Faction { Name = "Stranger" }; + _cache.Upsert(stranger); + stranger = _cache.GetAll().Single(faction => faction.Name == "Stranger"); + MakeBoss("Strangers Foe Boss", 1, lucent); + var log = new List(); + var items = LoggingItems(log); + + var drawn = 0; + for (uint seed = 1; seed <= 20; seed++) + foreach (var faction in new[] { antagonist, lucent, stranger, null }) + { + var generator = GeneratorFor(galaxy, items, faction, seed); + for (var i = 0; i < 3; i++) + { + var pack = generator.GenerateShipLoadout(); + Assert.NotNull(pack); + drawn++; + Assert.DoesNotContain(HullName(items, pack), new[] { "Traffic Boss", "Strangers Foe Boss" }); + Assert.NotEqual("Stock Boss", generator.RandomHull(HullType.Station).design?.Name); + } + // Asked for by name, with the boss the only candidate: nothing is on offer, however the fallback is reached. + Assert.Null(generator.GenerateShipLoadout(hull => hull == bossShip)); + Assert.Null(generator.GenerateShipLoadout(hull => hull.Name == "Strangers Foe Boss")); + Assert.Null(generator.RandomHull(HullType.Station, hull => hull == bossStation).design); + } + Assert.Equal(20 * 4 * 3, drawn); + } + + [Fact] + public void Main_galaxy_bosses_are_factions_that_make_boss_hulls() + { + Assert.Empty(RunStart.Generate(new MainGalaxy(), Inputs(() => 1)).BossZones); + + var maker = _cache.GetAll().OrderBy(faction => faction.Name, StringComparer.Ordinal).First(); + MakeBoss("Main Boss", 100, maker); + var galaxy = RunStart.Generate(new MainGalaxy(), Inputs(() => 1)); + var bossFaction = Assert.Single(galaxy.BossZones.Keys); + Assert.Equal(maker.Name, bossFaction.Name); + Assert.NotEmpty(LoadoutGenerator.BossHullsOf(_cache, bossFaction)); + } + [Fact] public void Boss_spawns_once_in_boss_zone() { @@ -152,10 +339,10 @@ public sealed partial class RunStartTests public void Exit_open_only_in_the_exit_zone() { var galaxy = RunStart.Generate(new MainGalaxy(), Inputs(() => 1)); - // The shipped catalog sets no BossHull outside the demo's antagonist, so the main galaxy places no boss: give a - // faction one and a boss zone that is not the exit, as the generator would for a faction with a boss hull. + // The shipped catalog has no Boss hull, so the main galaxy places no boss: give a faction one and a boss zone that is + // not the exit, as the generator would for a faction that makes a Boss hull. var faction = galaxy.Factions.First(); - faction.BossHull = _cache.RefOf(SoldShipHulls().First()); + MakeBoss("Gate Boss", 100, faction); var bossZone = galaxy.Zones.First(zone => zone != galaxy.Exit); galaxy.BossZones[faction] = bossZone; var zone = ZoneOf(galaxy, bossZone); diff --git a/tools/AetherDb/Program.cs b/tools/AetherDb/Program.cs index 500d85a8e..583e811ad 100644 --- a/tools/AetherDb/Program.cs +++ b/tools/AetherDb/Program.cs @@ -695,7 +695,8 @@ public static class Program } // Each faction's generation-critical links and the size of its curated place list. An empty list is not broken: - // Galaxy.NamePlaces falls back to catalogue designations for a faction without one. + // Galaxy.NamePlaces falls back to catalogue designations for a faction without one. The boss column is derived: + // the Boss hulls the faction makes (LoadoutGenerator.BossHullsOf), never a field on the faction. private static int Factions() { var db = AetherDb.Open(); @@ -704,7 +705,8 @@ public static class Program foreach (var faction in db.Cache.GetAll().OrderBy(f => f.Name)) { var key = db.Cache.RefOf(faction).Key; - var boss = "none"; + var bosses = LoadoutGenerator.BossHullsOf(db.Cache, faction); + var boss = bosses.Length == 0 ? "none" : string.Join(',', bosses.Select(b => b.hull.Name)); Console.WriteLine($"{faction.Name,-26} {faction.ShortName,-13} {faction.Places.Length,-22} {boss,-14} {faction.InfluenceDistance,-9} {(faction.Doctrine == null ? "none" : faction.Doctrine.EngageOn.ToString()),-10} " + products.Count(p => p.Manufacturer.Key.Equals(key))); } @@ -804,13 +806,18 @@ public static class Program string outcome; try { + // Generation never offers a Boss hull, so one is built through its first product. + var bossProduct = hull.Boss && hull.HullType == HullType.Ship + ? db.Cache.GetAll().Where(p => db.Cache.Get(p.Design) == hull).OrderBy(p => p.Name, StringComparer.Ordinal).FirstOrDefault() + : null; var pack = hull.HullType switch { + HullType.Ship when hull.Boss => bossProduct == null ? null : generator.ShipLoadout(bossProduct), HullType.Ship => generator.GenerateShipLoadout(candidate => candidate == hull), HullType.Turret => generator.GenerateTurretLoadout(), _ => generator.GenerateStationLoadout() }; - outcome = pack == null ? "NO LOADOUT (nothing suitable found)" : "ok"; + outcome = pack != null ? "ok" : hull.Boss && hull.HullType == HullType.Ship ? "NO LOADOUT (boss hull with no product)" : "NO LOADOUT (nothing suitable found)"; if (pack == null) failures++; if (pack != null && log.Any(l => l.Contains("targeting system available", StringComparison.OrdinalIgnoreCase))) targetingGaps++; } From 6b602dd34a964ae7f139d668c0870b5b2676d78d Mon Sep 17 00:00:00 2001 From: Meta Vens Date: Sun, 18 Oct 2026 17:54:52 +0200 Subject: [PATCH 013/444] ship-authoring boss on|off sets HullData.Boss on a package hull Judged before the file is touched, touches no product; inspect prints ", boss" when set. Tests: the command, and the package path through compose. Co-Authored-By: Claude Sonnet 5.5 --- .../Aetheria.Shared.Tests/ShipProductTests.cs | 50 +++++++++++++++++++ tools/AetherDb/ShipAuthoringCommands.cs | 19 ++++++- 2 files changed, 67 insertions(+), 2 deletions(-) diff --git a/tests/Aetheria.Shared.Tests/ShipProductTests.cs b/tests/Aetheria.Shared.Tests/ShipProductTests.cs index f6f3bad10..a88a2541d 100644 --- a/tests/Aetheria.Shared.Tests/ShipProductTests.cs +++ b/tests/Aetheria.Shared.Tests/ShipProductTests.cs @@ -99,6 +99,25 @@ public sealed class ShipProductTests : IDisposable } } + // The package path of the derivation: the flag survives compose on the hull, and the maker is the package's product's. + [Fact] + public void ABossPackageIsItsMakersBossAfterCompose() + { + Environment.CurrentDirectory = _root.Path; + var path = Package("mod.boss", "Bosshull", 100, Product("Acme Boss", _acme, "mod.boss")); + Assert.Equal(0, Run("boss", path, "on").Code); + var composition = ShipModCatalog.Compose(Shipped, Derived, Mods); + Assert.Equal(new[] { "mod.boss" }, composition.Included); + + using var cache = AetheriaStores.Open(Derived); + var hull = Assert.IsType(cache.Get(new CultRecordRef(ShipModCatalog.HullKey("mod.boss")))); + Assert.True(hull.Boss); + var acme = cache.Get(_acme); + var rival = cache.Get(_rival); + Assert.Same(hull, Assert.Single(LoadoutGenerator.BossHullsOf(cache, acme)).hull); + Assert.Empty(LoadoutGenerator.BossHullsOf(cache, rival)); + } + [Fact] public void APackageWithAnUnknownMakerIsExcluded() { @@ -299,6 +318,37 @@ public sealed class ShipProductTests : IDisposable RefusedAndUntouched("product", path, "ACM", "--remove"); } + [Fact] + public void BossCommand() + { + Environment.CurrentDirectory = _root.Path; + var path = Package("mod.skiff", "Skiffhull", 100, Product("Acme Skiff", _acme, "mod.skiff")); + void RefusedAndUntouched(params string[] args) + { + var before = File.ReadAllBytes(path); + Assert.Equal(1, Run(args).Code); + Assert.Equal(before, File.ReadAllBytes(path)); + } + var options = CultDocumentMessagePackSerialization.OptionsFor(typeof(FactionProductData).Assembly); + byte[] ProductBytes() => MessagePack.MessagePackSerializer.Serialize(ShipAuthoringStore.Load(path).Products.Single(), options); + + var productBefore = ProductBytes(); + Assert.False(ShipAuthoringStore.Read(path).Hull.Boss); + RefusedAndUntouched("boss", path, "yes"); + RefusedAndUntouched("boss", path, "ON"); + RefusedAndUntouched("boss", path); + + Assert.Equal(0, Run("boss", path, "on").Code); + Assert.True(ShipAuthoringStore.Read(path).Hull.Boss); + Assert.Equal(productBefore, ProductBytes()); + Assert.Contains(", boss", Run("inspect", path).Output); + + Assert.Equal(0, Run("boss", path, "off").Code); + Assert.False(ShipAuthoringStore.Read(path).Hull.Boss); + Assert.Equal(productBefore, ProductBytes()); + Assert.DoesNotContain(", boss", Run("inspect", path).Output); + } + [Fact] public void ValidateJudgesProductsOnADraftAndInspectListsThem() { diff --git a/tools/AetherDb/ShipAuthoringCommands.cs b/tools/AetherDb/ShipAuthoringCommands.cs index 9fd7e690a..769db37dc 100644 --- a/tools/AetherDb/ShipAuthoringCommands.cs +++ b/tools/AetherDb/ShipAuthoringCommands.cs @@ -8,7 +8,7 @@ using System.Linq; // Uses the existing database executable; mod authoring does not earn another binary target. public static class ShipAuthoringCommands { - private const string Usage = "ship-authoring create [--like ] | inspect | centre | validate | product (--name [--description ] | --remove) | price | compose "; + private const string Usage = "ship-authoring create [--like ] | inspect | centre | validate | product (--name [--description ] | --remove) | price | boss on|off | compose "; public static int Run(string[] args) { @@ -29,7 +29,7 @@ public static class ShipAuthoringCommands if (args[0] == "inspect" && args.Length == 2) { var (hull, ship, products) = ShipAuthoringStore.Load(args[1]); - Console.WriteLine($"{ship.Id}: {hull.Name}, {hull.Hardpoints?.Count ?? 0} hardpoints, {ship.SchematicLines?.Count ?? 0} lines, {ship.SchematicLines?.Sum(line => line.Points?.Length / 3 ?? 0) ?? 0} points"); + Console.WriteLine($"{ship.Id}: {hull.Name}, {hull.Hardpoints?.Count ?? 0} hardpoints, {ship.SchematicLines?.Count ?? 0} lines, {ship.SchematicLines?.Sum(line => line.Points?.Length / 3 ?? 0) ?? 0} points{(hull.Boss ? ", boss" : "")}"); foreach (var product in products) Console.WriteLine($"product {product.Manufacturer.Key.Value}: {product.Name}"); return 0; } @@ -91,6 +91,21 @@ public static class ShipAuthoringCommands Console.WriteLine($"{ship.Id}: price {price}."); return 0; } + if (args[0] == "boss" && args.Length == 3) + { + // Judged before the file is touched: a refusal leaves it byte-identical. Sets the hull's flag and no product. + if (args[2] != "on" && args[2] != "off") + throw new InvalidOperationException("boss must be on or off."); + var (hull, ship, _) = ShipAuthoringStore.Load(args[1]); + hull.Boss = args[2] == "on"; + using (var cache = ShipAuthoringStore.Open(args[1], writable: true)) + { + ShipAuthoringStore.Write(cache, hull, ship); + cache.FlushAsync().Wait(); + } + Console.WriteLine($"{ship.Id}: boss {args[2]}."); + return 0; + } if (args[0] == "product" && args.Length >= 4) { var remove = args.Length == 4 && args[3] == "--remove"; From 095baf2fb5e5f8b9ec5c181f37a34d805a752bd4 Mon Sep 17 00:00:00 2001 From: Meta Vens Date: Sun, 18 Oct 2026 17:57:23 +0200 Subject: [PATCH 014/444] RunGoalTests: product Design refs are CraftedItemData refs Co-Authored-By: Claude Sonnet 5.5 --- tests/Aetheria.Shared.Tests/RunGoalTests.cs | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/tests/Aetheria.Shared.Tests/RunGoalTests.cs b/tests/Aetheria.Shared.Tests/RunGoalTests.cs index cbb7ac13b..541ee64dd 100644 --- a/tests/Aetheria.Shared.Tests/RunGoalTests.cs +++ b/tests/Aetheria.Shared.Tests/RunGoalTests.cs @@ -65,7 +65,7 @@ public sealed partial class RunStartTests { var product = Copy(likeProduct); product.Name = $"{name} by {maker.Name}"; - product.Design = bossRef; + product.Design = new CultRecordRef(bossRef.Key); product.Manufacturer = _cache.RefOf(maker); _cache.Upsert(product); } @@ -94,7 +94,7 @@ public sealed partial class RunStartTests // The faction also makes the dearest ordinary hull, and three Boss hulls: B and A tie on price, C is cheaper. The boss // is the dearest Boss hull, ties by name: not the dearest hull it makes, not the first product, not the cheapest. - _cache.Upsert(new FactionProductData { Name = "Decoy", Design = _cache.RefOf(priciest), Manufacturer = _cache.RefOf(antagonist) }); + _cache.Upsert(new FactionProductData { Name = "Decoy", Design = new CultRecordRef(_cache.RefOf(priciest).Key), Manufacturer = _cache.RefOf(antagonist) }); MakeBoss("Boss B", cheapest + 10, antagonist); MakeBoss("Boss C", cheapest, antagonist); MakeBoss("Boss A", cheapest + 10, antagonist); From 49853d3166bdeaaf1f784d447501eeff62efcf15 Mon Sep 17 00:00:00 2001 From: Meta Vens Date: Sun, 18 Oct 2026 17:58:51 +0200 Subject: [PATCH 015/444] ProductBin: a binned product names its maker's line and a floor; the mint draws above that floor FactionProductData slot 7 Bin (schema id unchanged, additive as slot 6). Mint derives a bin's distributions from its line on every lot, truncated by NextGaussianAbove (64 tries, then the cutoff). Co-Authored-By: Claude Sonnet 5.5 --- Assets/Scripts/ServerShared/Extensions.cs | 15 ++ Assets/Scripts/ServerShared/FactionProduct.cs | 32 +++- Assets/Scripts/ServerShared/ItemManager.cs | 19 +- .../Aetheria.Shared.Tests/ProductBinTests.cs | 175 ++++++++++++++++++ 4 files changed, 230 insertions(+), 11 deletions(-) create mode 100644 tests/Aetheria.Shared.Tests/ProductBinTests.cs diff --git a/Assets/Scripts/ServerShared/Extensions.cs b/Assets/Scripts/ServerShared/Extensions.cs index 656e85ba2..97433a299 100644 --- a/Assets/Scripts/ServerShared/Extensions.cs +++ b/Assets/Scripts/ServerShared/Extensions.cs @@ -180,6 +180,21 @@ public static class Extensions return mean + deviation * sqrt(-2 * log(u1)) * cos(2 * PI * u2); } + // NextGaussian truncated below mean + floorDeviations * deviation: draws until one clears that cutoff, at most 64 times, + // then returns the cutoff itself. Exact for any reachable floor and bounded and deterministic per seed for an unreachable + // one. A binned product's fills and assembly (ProductBin). Callers clamp, as with NextGaussian. + public static float NextGaussianAbove(this ref Random random, float mean, float deviation, float floorDeviations) + { + var cutoff = mean + floorDeviations * deviation; + for (var tries = 0; tries < 64; tries++) + { + var draw = random.NextGaussian(mean, deviation); + if (draw >= cutoff) return draw; + } + + return cutoff; + } + public static float NextUnbounded(this ref Random random) => 1 / (1 - random.NextFloat()) - 1; public static float NextUnbounded(this ref Random random, float bias, float power, float ceiling) => 1 / (1 - pow(min(random.NextFloat(), ceiling), 1 - pow(clamp(bias,0,.99f), 1 / power))) - 1; diff --git a/Assets/Scripts/ServerShared/FactionProduct.cs b/Assets/Scripts/ServerShared/FactionProduct.cs index cdaed4f28..b9a3f0009 100644 --- a/Assets/Scripts/ServerShared/FactionProduct.cs +++ b/Assets/Scripts/ServerShared/FactionProduct.cs @@ -8,9 +8,9 @@ using GameCult.Caching; using MessagePack; using Newtonsoft.Json; -// A manufacturer's branded variant of a generic design: its name, its flavor text, and how it builds each of -// the design's roles. Which products exist is what a faction makes, so market segmentation and regional -// progression are authored here rather than as duplicate item entries. +// What a manufacturer sells of a generic design: its name, its flavor text, and either a line (how the maker builds each of the +// design's roles) or a bin of one of its own lines (maker-design-tiers-allowed). Which products exist is what a faction makes, so +// market segmentation and regional progression are authored here rather than as duplicate item entries. [CultDocument("aetheria.factionproductdata", "1"), Inspectable, MessagePackObject, JsonObject(MemberSerialization.OptIn)] public class FactionProductData { @@ -29,16 +29,21 @@ public class FactionProductData [Inspectable, JsonProperty("roles"), Key(5)] public List Roles = new List(); - // This line's own final test (maker-design-tiers-allowed): a binned or premium line is a better assembly as well as better - // parts. Null assembles as its maker does. + // This line's own final test (maker-design-tiers-allowed): a premium line is a better assembly as well as better parts. + // Null assembles as its maker does. A binned product leaves it null and reads its line's. [Inspectable, JsonProperty("assembly"), Key(6)] public AssemblyDistribution Assembly; + + // Set on a binned product: the line it bins and the floor above which it takes that line's output. A bin authors no Roles and + // no Assembly; the mint derives both from the line. + [Inspectable, JsonProperty("bin"), Key(7)] + public ProductBin Bin; } // How good this product's part is in one of its design's roles: a pseudo-gaussian distribution whose mean is // the technology the manufacturer puts in and whose standard deviation is their quality control. A market -// segment is a second product with a role pumped up; what makes that part better is the flavor text's to tell, -// because a part here carries quality and nothing else. +// segment is a bin of the line (ProductBin), not a second product with a role pumped up; what makes a part better is the +// flavor text's to tell, because a part here carries quality and nothing else. [MessagePackObject, JsonObject(MemberSerialization.OptIn)] public class ProductRole { @@ -52,6 +57,19 @@ public class ProductRole public float StandardDeviation = .15f; } +// A bin of one of the maker's lines (ruling maker-design-tiers-allowed): the line it bins (the same design and maker, itself +// unbinned) and the floor, in standard deviations above the mean of each of the line's distributions, below which the bin +// takes nothing. 0 is the top half of the line, .67 the top quarter. +[MessagePackObject, JsonObject(MemberSerialization.OptIn)] +public class ProductBin +{ + [Inspectable, JsonProperty("line"), Key(0)] + public CultRecordRef Line; + + [Inspectable, JsonProperty("floor"), Key(1)] + public float Floor; +} + // How well a maker puts the thing together: the technology and quality control of final assembly. A lot's own quality is drawn // from it, and it is the ceiling on every role the lot fills. Defaults are a middling maker. [MessagePackObject, JsonObject(MemberSerialization.OptIn)] diff --git a/Assets/Scripts/ServerShared/ItemManager.cs b/Assets/Scripts/ServerShared/ItemManager.cs index 12d45d101..b33b17224 100644 --- a/Assets/Scripts/ServerShared/ItemManager.cs +++ b/Assets/Scripts/ServerShared/ItemManager.cs @@ -121,6 +121,9 @@ public class ItemManager // Builds one of a manufacturer's branded products (quality-is-assembly-over-supply-chain): the unit's own quality is its // final assembly, drawn from the product's assembly distribution, else its maker's, else AssemblyDistribution's defaults, // and each of the design's roles is filled with a part whose quality is drawn from the product's distribution for it. + // A binned product (maker-design-tiers-allowed) has no distributions of its own: it is its line's, derived here on every + // mint, with each draw (the assembly and every fill) truncated below the line's mean + Bin.Floor deviations. The lot is + // still the bin's: its Product, its title and so its brand. public int CreateLot(FactionProductData product) => Mint(product, null); // The same lot at a stated quality: the assembly and every role fill are that quality and nothing is drawn, so a staged @@ -130,29 +133,37 @@ public class ItemManager private int Mint(FactionProductData product, float? stated) { var design = ItemData.Get(product.Design); - var assembly = product.Assembly ?? ItemData.Get(product.Manufacturer)?.Assembly ?? new AssemblyDistribution(); + var line = product.Bin != null ? ItemData.Get(product.Bin.Line) : null; + if (product.Bin != null && line == null) + Log("A binned product names a line the catalog does not hold; it mints as a product with no roles of its own."); + var source = line ?? product; + var floor = line != null ? product.Bin.Floor : (float?) null; + var assembly = source.Assembly ?? ItemData.Get(product.Manufacturer)?.Assembly ?? new AssemblyDistribution(); var lot = new Lot { Design = ItemData.RefOf(design), Origin = new Attributed { Faction = product.Manufacturer }, Product = ItemData.RefOf(product), - Quality = stated ?? clamp(Random.NextGaussian(assembly.Mean, assembly.StandardDeviation), .01f, 1), + Quality = stated ?? clamp(Draw(assembly.Mean, assembly.StandardDeviation, floor), .01f, 1), Roles = new List() }; if (design.Roles != null) foreach (var role in design.Roles) { - var build = product.Roles?.FirstOrDefault(b => b.Role == role.Name) ?? new ProductRole(); + var build = source.Roles?.FirstOrDefault(b => b.Role == role.Name) ?? new ProductRole(); lot.Roles.Add(new RoleFill { Role = role.Name, - Quality = stated ?? clamp(Random.NextGaussian(build.Mean, build.StandardDeviation), .01f, 1) + Quality = stated ?? clamp(Draw(build.Mean, build.StandardDeviation, floor), .01f, 1) }); } return Lots.Add(lot); } + private float Draw(float mean, float deviation, float? floorDeviations) => + floorDeviations is { } floor ? Random.NextGaussianAbove(mean, deviation, floor) : Random.NextGaussian(mean, deviation); + // No branded product: a lot attributed to maker (which may be unset) with no role fills, e.g. console `give`. public int CreateLot(CraftedItemData design, CultRecordRef maker, float quality) { diff --git a/tests/Aetheria.Shared.Tests/ProductBinTests.cs b/tests/Aetheria.Shared.Tests/ProductBinTests.cs new file mode 100644 index 000000000..44bbfe077 --- /dev/null +++ b/tests/Aetheria.Shared.Tests/ProductBinTests.cs @@ -0,0 +1,175 @@ +using System; +using System.Collections.Generic; +using System.IO; +using System.Linq; +using GameCult.Caching; +using Xunit; + +// A bin is a product of its maker's line above a floor (aetheria-release, cut product-tiers; ruling maker-design-tiers-allowed): +// the mint derives its distributions from the line on every lot, truncated below line mean + Floor deviations, and the lot is +// still the bin's. Lots here come from CreateLot(product), the production mint, on the AssemblyFromMakerTests fixture. +public sealed class ProductBinTests : IDisposable +{ + private readonly string _root = Path.Combine(Path.GetTempPath(), "aetheria-bins-" + Guid.NewGuid().ToString("N")); + private readonly CultCache _cache; + private readonly ItemManager _items; + private readonly CultRecordRef _lens; + private readonly List _log = new List(); + + public ProductBinTests() + { + Directory.CreateDirectory(_root); + _cache = AetheriaStores.Open(Path.Combine(_root, "Aetheria.cc"), catalogWritable: true); + var gear = _cache.Upsert(new GearData + { + Name = "Lensed", Hardpoint = HardpointType.Sensors, Shape = new Shape(), Price = 10, + Roles = { new ItemRole { Name = "lens" } } + }); + _lens = new CultRecordRef(gear.Key); + _items = new ItemManager(_cache, new ProvenanceLedger(), RunSaveTests.TestSettings(), _log.Add) { Random = new CultMath.Random(7u) }; + } + + public void Dispose() + { + _cache.Dispose(); + Directory.Delete(_root, true); + } + + private static AssemblyDistribution A(float mean, float deviation) => new AssemblyDistribution { Mean = mean, StandardDeviation = deviation }; + + // A maker at assembly .6 +- .1 and a line of the Lensed design at lens .5 +- .1. + private FactionProductData Line(string name = "Line", float lensMean = .5f, float lensDeviation = .1f, AssemblyDistribution own = null, float makerMean = .6f) + { + var maker = _cache.Upsert(new Faction { Name = name + " Maker", Assembly = A(makerMean, .1f) }); + _cache.Upsert(new FactionProductData + { + Name = name, Design = _lens, Manufacturer = maker, Assembly = own, + Roles = { new ProductRole { Role = "lens", Mean = lensMean, StandardDeviation = lensDeviation } } + }); + return _cache.GetAll().Single(p => p.Name == name); + } + + private FactionProductData Bin(FactionProductData line, string name, float floor) + { + _cache.Upsert(new FactionProductData + { + Name = name, Design = line.Design, Manufacturer = line.Manufacturer, + Bin = new ProductBin { Line = _cache.RefOf(line), Floor = floor } + }); + return _cache.GetAll().Single(p => p.Name == name); + } + + private Lot[] Mint(FactionProductData product, int count) => + Enumerable.Range(0, count).Select(_ => _items.Lots[_items.CreateLot(product)]).ToArray(); + + private static float[] Fills(Lot[] lots) => lots.Select(l => l.Roles.Single().Quality).ToArray(); + + // Floor 0 is the top half of the line: nothing under the line's mean, in the fills or in the assembly. + [Fact] + public void BinnedFillsAndAssemblyNeverFallBelowTheFloor() + { + var lots = Mint(Bin(Line(), "Top Half", 0), 400); + Assert.All(lots, lot => + { + Assert.True(lot.Roles.Single().Quality >= .5f - 1e-5f, "a fill fell under the line's mean"); + Assert.True(lot.Quality >= .6f - 1e-5f, "an assembly fell under the maker's mean"); + }); + Assert.InRange(Fills(lots).Average(), .55f, .60f); + } + + // The floor is in the line's standard deviations: .67 on .5 +- .1 cuts at .567 and the truncated mean is .627. + [Fact] + public void FloorIsInDeviations() + { + var fills = Fills(Mint(Bin(Line(), "Top Quarter", .67f), 400)); + Assert.All(fills, fill => Assert.True(fill >= .567f - 1e-5f, "a fill fell under mean + floor deviations")); + Assert.InRange(fills.Average(), .61f, .645f); + } + + // A bin keeps no distribution of its own: after it exists, the line and the maker move and the bin follows. + [Fact] + public void BinDerivesFromItsLineNotACopy() + { + var line = Line(); + var bin = Bin(line, "Follower", 0); + Assert.Empty(bin.Roles); + Assert.Null(bin.Assembly); + line.Roles.Single().Mean = .7f; + var maker = _cache.Get(line.Manufacturer); + maker.Assembly = A(.8f, .1f); + _cache.Commit(batch => + { + batch.Upsert(typeof(FactionProductData), line, _cache.RefOf(line).Key); + batch.Upsert(typeof(Faction), maker, _cache.RefOf(maker).Key); + }); + var lots = Mint(bin, 300); + Assert.All(lots, lot => + { + Assert.True(lot.Roles.Single().Quality >= .7f - 1e-5f, "the bin read a copy of its line's fill"); + Assert.True(lot.Quality >= .8f - 1e-5f, "the bin read a copy of its maker's assembly"); + }); + } + + // A line's own assembly is the final test a bin of it sits: the maker's is read only when the line sets none. + [Fact] + public void BinReadsTheLinesOwnAssemblyBeforeTheMakers() + { + var bin = Bin(Line(own: A(.85f, .05f), makerMean: .4f), "Certified", 0); + Assert.All(Mint(bin, 300), lot => Assert.True(lot.Quality >= .85f - 1e-5f, "the bin assembled as its maker, not its line")); + } + + // 6 deviations above a .2 +- .01 line is out of reach in 64 draws: the mint returns the cutoff, .26, and does not loop. + [Fact] + public void UnreachableFloorMintsAtTheCutoff() + { + var bin = Bin(Line(lensMean: .2f, lensDeviation: .01f, own: A(.2f, .01f)), "Unreachable", 6); + Assert.All(Mint(bin, 20), lot => + { + Assert.Equal(.26f, lot.Roles.Single().Quality, 5); + Assert.Equal(.26f, lot.Quality, 5); + }); + } + + // Snapshot of the first five assemblies and fills at base, before the bin existed (seed 20261018, lens .5 +- .1, maker .6 +- .1). + [Fact] + public void UnbinnedMintConsumesTheStreamAsBefore() + { + var line = Line(); + _items.Random = new CultMath.Random(20261018u); + var lots = Mint(line, 5); + var assemblies = new[] { .59560823f, .68289334f, .57563853f, .5639112f, .59828424f }; + var fills = new[] { .6311789f, .5835483f, .47641385f, .46416125f, .45124212f }; + for (var i = 0; i < 5; i++) + { + Assert.Equal(assemblies[i], lots[i].Quality, 5); + Assert.Equal(fills[i], lots[i].Roles.Single().Quality, 5); + } + } + + // The lot is the bin's: its product, its title, and the line's maker as its brand. + [Fact] + public void ABinnedLotIsBrandedAsTheBin() + { + var line = Line(); + var bin = Bin(line, "Premiere", 0); + var lotId = _items.CreateLot(bin); + Assert.Equal(_cache.RefOf(bin), _items.Lots[lotId].Product); + var instance = _items.CreateInstance(lotId); + Assert.Equal("Premiere", _items.Title(instance)); + var (maker, product) = _items.Brand(instance); + Assert.Equal(_cache.Get(line.Manufacturer).Name, maker.Name); + Assert.Equal("Premiere", product.Name); + } + + // A bin whose line the catalog does not hold mints as a product with no roles of its own, and says so. + [Fact] + public void ABinOfALineTheCatalogLacksMintsAtDefaultsAndLogs() + { + var line = Line(); + var bin = Bin(line, "Orphan", 0); + _cache.Remove(_cache.RefOf(line).Key); + var lots = Mint(bin, 5); + Assert.Equal(5, lots.Length); + Assert.NotEmpty(_log); + } +} From 7f09b05725f3f8fd6106e1955fe1a09a6c01228e Mon Sep 17 00:00:00 2001 From: Meta Vens Date: Sun, 18 Oct 2026 18:04:08 +0200 Subject: [PATCH 016/444] ProductWeight: a bin takes its pass fraction of its maker's preference and its line the rest (ruling bin-share-by-floor) Phi comes from CultMath's math.erf. Co-Authored-By: Claude Sonnet 5.5 --- .../Scripts/ServerShared/LoadoutGenerator.cs | 17 ++++++- .../ProductChoiceTests.cs | 51 ++++++++++++++++++- 2 files changed, 65 insertions(+), 3 deletions(-) diff --git a/Assets/Scripts/ServerShared/LoadoutGenerator.cs b/Assets/Scripts/ServerShared/LoadoutGenerator.cs index 55eea27f8..0206935fb 100644 --- a/Assets/Scripts/ServerShared/LoadoutGenerator.cs +++ b/Assets/Scripts/ServerShared/LoadoutGenerator.cs @@ -252,8 +252,21 @@ public class LoadoutGenerator Galaxy == null || Faction == null || ItemManager.ItemData.Get(product.Manufacturer) == Faction || Faction.Allegiance.ContainsKey(product.Manufacturer); - // How much this outfitter favors a product: its maker's preference. The one weight of generation and of ChooseProduct. - public float ProductWeight(FactionProductData product) => ManufacturerPreference(product.Manufacturer); + // How much this outfitter favors a product: its maker's preference times the product's share of it. The one weight of + // generation and of ChooseProduct. A bin takes the share of its maker's stock that would pass its floor and its line takes + // the rest (ruling bin-share-by-floor), so binning never changes a maker's presence for the design. + public float ProductWeight(FactionProductData product) => ManufacturerPreference(product.Manufacturer) * Share(product); + + // 1 - Phi(floor): the fraction of a normal draw that lies at least floor deviations above its mean. + private static float PassFraction(float floor) => .5f * (1 - erf(floor / sqrt(2f))); + + private float Share(FactionProductData product) + { + if (product.Bin != null) return PassFraction(product.Bin.Floor); + var key = ItemManager.ItemData.RefOf(product).Key; + var binned = ItemManager.ItemData.GetAll().Where(other => other.Bin != null && other.Bin.Line.Key.Equals(key)).Sum(bin => PassFraction(bin.Bin.Floor)); + return max(0f, 1 - binned); + } // The product a design named without one is built as (ruling loadout-product-optional-context-chooses): among the // products of the design this outfitter can get, one draw from the run's seeded Random weighted by ProductWeight; diff --git a/tests/Aetheria.Shared.Tests/ProductChoiceTests.cs b/tests/Aetheria.Shared.Tests/ProductChoiceTests.cs index 888634f8e..a11708393 100644 --- a/tests/Aetheria.Shared.Tests/ProductChoiceTests.cs +++ b/tests/Aetheria.Shared.Tests/ProductChoiceTests.cs @@ -63,7 +63,10 @@ public sealed class ProductChoiceTests : IDisposable } } - private Rig Build(params string[] lampOrder) + private Rig Build(params string[] lampOrder) => Build(new float[0], lampOrder); + + // binFloors adds one bin of lamp-a's line per floor, named "lamp-a bin" and "lamp-a bin 2"..., by the same maker. + private Rig Build(float[] binFloors, string[] lampOrder) { var root = Path.Combine(_root, "rig" + _catalogs++); Directory.CreateDirectory(root); @@ -97,6 +100,12 @@ public sealed class ProductChoiceTests : IDisposable // lampOrder holds product names; the record key of the i-th inserted is "k" + i, so keys and insertion order // both follow the permutation. for (var i = 0; i < lampOrder.Length; i++) Sell(lampOrder[i], lamp.Key, makers[lampOrder[i]], "k" + i); + for (var i = 0; i < binFloors.Length; i++) + seed.Commit(batch => batch.Upsert(typeof(FactionProductData), new FactionProductData + { + Name = i == 0 ? "lamp-a bin" : "lamp-a bin " + (i + 1), Design = new CultRecordRef(lamp.Key), Manufacturer = alpha, + Bin = new ProductBin { Line = new CultRecordRef(new CultRecordKey("k" + Array.IndexOf(lampOrder, "lamp-a"))), Floor = binFloors[i] } + }, new CultRecordKey("bin" + i))); seed.FlushAsync().Wait(); } @@ -249,6 +258,46 @@ public sealed class ProductChoiceTests : IDisposable Assert.InRange(MakerDraws(rig, rig.OutEven, 1, "lamp-a", 300), 80, 120); } + // Ruling bin-share-by-floor: a bin of lamp-a's line takes the fraction of lamp-a's stock that would pass its floor, 1 - Phi(Floor), + // of lamp-a's preference, and the line keeps the rest, so the maker's presence for the design is what it was without the bin. + [Fact] + public void ABinTakesItsPassFractionOfItsMakersPreference() + { + var alone = Build("lamp-z", "lamp-a"); + var preference = alone.Outfitter(alone.OutEven, 1, 0).ProductWeight(alone.Product("lamp-a")); + Assert.True(preference > 0); + + var quarter = Build(new[] { .67f }, new[] { "lamp-z", "lamp-a" }); + var generator = quarter.Outfitter(quarter.OutEven, 1, 0); + Assert.Equal(.2514f * preference, generator.ProductWeight(quarter.Product("lamp-a bin")), 3); + Assert.Equal(.7486f * preference, generator.ProductWeight(quarter.Product("lamp-a")), 3); + Assert.Equal(preference, generator.ProductWeight(quarter.Product("lamp-a bin")) + generator.ProductWeight(quarter.Product("lamp-a")), 3); + + var half = Build(new[] { 0f }, new[] { "lamp-z", "lamp-a" }); + generator = half.Outfitter(half.OutEven, 1, 0); + Assert.Equal(.5f * preference, generator.ProductWeight(half.Product("lamp-a bin")), 3); + Assert.Equal(.5f * preference, generator.ProductWeight(half.Product("lamp-a")), 3); + + // The maker keeps its presence: Alpha's line and bin together draw as lamp-a alone did. + var lamp = quarter.Cache.GetByName("Lamp"); + var alpha = Enumerable.Range(1, 300).Count(i => quarter.Cache.Get(quarter.Outfitter(quarter.OutEven, Seed(i), 0).ChooseProduct(lamp).Manufacturer).Name == "Alpha"); + Assert.InRange(alpha, 180, 220); + } + + // A line gives up the sum of its bins' pass fractions and no more: two top-half bins leave it nothing, and a third does not + // take it below nothing. + [Fact] + public void ALineKeepsWhatItsBinsLeaveAndNeverLessThanNothing() + { + var two = Build(new[] { 0f, .67f }, new[] { "lamp-z", "lamp-a" }); + var alone = Build("lamp-z", "lamp-a"); + var preference = alone.Outfitter(alone.OutEven, 1, 0).ProductWeight(alone.Product("lamp-a")); + Assert.Equal((1 - .5f - .2514f) * preference, two.Outfitter(two.OutEven, 1, 0).ProductWeight(two.Product("lamp-a")), 3); + + var three = Build(new[] { 0f, 0f, 0f }, new[] { "lamp-z", "lamp-a" }); + Assert.Equal(0f, three.Outfitter(three.OutEven, 1, 0).ProductWeight(three.Product("lamp-a"))); + } + // A weight that read the allegiance alone would give 1 : .25 here, an 80% draw; the zone and allegiance together // give 1 : .125 for alpha at its own zone, 89%. [Fact] From 2806b443c9c20f58c7ffc615d1e672f73b358d59 Mon Sep 17 00:00:00 2001 From: Meta Vens Date: Sun, 18 Oct 2026 18:06:07 +0200 Subject: [PATCH 017/444] Census, roles-migrate and roles-backfill skip a bin: it authors no roles, it reads its line's RolesMigrate takes an optional root, as RolesBackfill does, so its bin skip has a test. Co-Authored-By: Claude Sonnet 5.5 --- .../Aetheria.Shared.Tests/CatalogRoleTests.cs | 8 +-- .../RolesBackfillCommandTests.cs | 53 +++++++++++++++++++ tools/AetherDb/Program.cs | 10 ++-- 3 files changed, 64 insertions(+), 7 deletions(-) diff --git a/tests/Aetheria.Shared.Tests/CatalogRoleTests.cs b/tests/Aetheria.Shared.Tests/CatalogRoleTests.cs index b50e0142d..10056b421 100644 --- a/tests/Aetheria.Shared.Tests/CatalogRoleTests.cs +++ b/tests/Aetheria.Shared.Tests/CatalogRoleTests.cs @@ -36,7 +36,7 @@ public sealed class CatalogRoleTests } // The whole of CB-R1 over every design in the catalog: each non-flat stat reads exactly one declared role, every - // declared role is read, and every seller authors each role once. Weapons, and designs of a kind the tool keeps a role + // declared role is read, and every seller authors each role once (a bin authors none: it reads its line's). Weapons, and designs of a kind the tool keeps a role // map for, must also carry a non-flat stat (a design with no stat at all has nothing for quality to vary). [Fact] public void ShippedCatalogRolesAreLive() @@ -59,13 +59,15 @@ public sealed class CatalogRoleTests broken.Add($"{design.Name}: declares [{string.Join(", ", declared)}] but non-flat stats read [{string.Join(", ", read)}]"); if (varying.Length == 0 && stats.Length > 0 && (design is WeaponItemData || Program.HasRoleMap(design))) broken.Add($"{design.Name}: every stat is flat"); - foreach (var product in products.Where(p => p.Design.Key.Equals(cache.RefOf(design).Key))) + foreach (var product in products.Where(p => p.Bin == null && p.Design.Key.Equals(cache.RefOf(design).Key))) { var authored = (product.Roles ?? new List()).Select(r => r.Role).OrderBy(r => r, StringComparer.Ordinal).ToArray(); if (!authored.SequenceEqual(declared)) broken.Add($"{design.Name}: product \"{product.Name}\" authors [{string.Join(", ", authored)}], not each of [{string.Join(", ", declared)}] once"); } } + foreach (var bin in products.Where(p => p.Bin != null)) + if (bin.Roles?.Count > 0 || bin.Assembly != null) broken.Add($"bin \"{bin.Name}\" authors roles or an assembly of its own"); Assert.True(broken.Count == 0, string.Join("\n", broken)); } @@ -264,7 +266,7 @@ public sealed class CatalogRoleTests var profiles = new Dictionary(Program.MakerProfiles) { ["Pirates"] = (.45f, .22f) }; foreach (var design in new[] { autocannon, lrm, srm, mine, drive }) { - var sellers = cache.GetAll().Where(p => p.Design.Key.Equals(cache.RefOf(design).Key)).ToArray(); + var sellers = cache.GetAll().Where(p => p.Bin == null && p.Design.Key.Equals(cache.RefOf(design).Key)).ToArray(); Assert.NotEmpty(sellers); foreach (var product in sellers) { diff --git a/tests/Aetheria.Shared.Tests/RolesBackfillCommandTests.cs b/tests/Aetheria.Shared.Tests/RolesBackfillCommandTests.cs index 1f276c639..1f0f974f5 100644 --- a/tests/Aetheria.Shared.Tests/RolesBackfillCommandTests.cs +++ b/tests/Aetheria.Shared.Tests/RolesBackfillCommandTests.cs @@ -207,6 +207,59 @@ public sealed class RolesBackfillCommandTests : IDisposable finally { db.Cache.Dispose(); } } + // A bin authors no roles (ProductBin): the backfill that restores its line's missing roles leaves the bin's list empty. + [Fact] + public void A_binned_seller_gains_no_roles() + { + Reset(flattened: false, unnamed: false); + var db = AetherDb.Open(catalogWritable: true, root: _root); + var autocannon = db.Cache.GetAll().Single(weapon => weapon.Name == "Autocannon"); + var seller = db.Cache.GetAll().OrderBy(p => p.Name, StringComparer.Ordinal).First(p => p.Design.Key.Equals(db.Cache.RefOf(autocannon).Key) && p.Roles.Count == 2); + var sellerName = seller.Name; + seller.Roles = new List { seller.Roles.OrderBy(r => r.Role, StringComparer.Ordinal).First() }; + var bin = new FactionProductData + { + Name = "Binned " + sellerName, Design = seller.Design, Manufacturer = seller.Manufacturer, + Bin = new ProductBin { Line = db.Cache.RefOf(seller), Floor = 0f } + }; + db.Cache.Commit(batch => + { + batch.Upsert(typeof(FactionProductData), seller, db.Cache.RefOf(seller).Key); + batch.Upsert(typeof(FactionProductData), bin); + }); + db.Cache.Dispose(); + + Assert.Equal(0, Program.RolesBackfill(apply: true, root: _root)); + db = AetherDb.Open(root: _root); + try + { + Assert.Equal(2, db.Cache.GetAll().Single(p => p.Name == sellerName).Roles.Count); + Assert.Empty(db.Cache.GetAll().Single(p => p.Name == "Binned " + sellerName).Roles); + } + finally { db.Cache.Dispose(); } + } + + // roles-migrate authors a role on every product that sells a design lacking one; a bin sells none of its own. + [Fact] + public void A_binned_seller_gains_no_roles_from_roles_migrate() + { + var db = AetherDb.Open(catalogWritable: true, root: _root); + var autocannon = db.Cache.GetAll().Single(weapon => weapon.Name == "Autocannon"); + var seller = db.Cache.GetAll().OrderBy(p => p.Name, StringComparer.Ordinal).First(p => p.Design.Key.Equals(db.Cache.RefOf(autocannon).Key)); + var bin = new FactionProductData + { + Name = "Binned " + seller.Name, Design = seller.Design, Manufacturer = seller.Manufacturer, + Bin = new ProductBin { Line = db.Cache.RefOf(seller), Floor = 0f } + }; + db.Cache.Commit(batch => batch.Upsert(typeof(FactionProductData), bin)); + db.Cache.Dispose(); + + Assert.Equal(0, Program.RolesMigrate(apply: true, root: _root)); + db = AetherDb.Open(root: _root); + try { Assert.Empty(db.Cache.GetAll().Single(p => p.Name == "Binned " + seller.Name).Roles); } + finally { db.Cache.Dispose(); } + } + [Fact] public void A_design_the_catalog_does_not_hold_refuses_and_lands_nothing() { diff --git a/tools/AetherDb/Program.cs b/tools/AetherDb/Program.cs index 5df9ed99c..46a812ba2 100644 --- a/tools/AetherDb/Program.cs +++ b/tools/AetherDb/Program.cs @@ -132,7 +132,8 @@ public static class Program foreach (var item in items.Where(i => i.Roles != null && i.Roles.Count > 0)) { var design = db.Cache.RefOf(item).Key; - var sellers = products.Where(p => p.Design.Key.Equals(design)).ToArray(); + // A bin authors no spread: it reads its line's (ProductBin). + var sellers = products.Where(p => p.Bin == null && p.Design.Key.Equals(design)).ToArray(); foreach (var product in sellers) { var covered = new HashSet((product.Roles ?? new List()).Select(r => r.Role)); @@ -309,9 +310,9 @@ public static class Program // Dry run unless passed "apply", which alone opens the catalog writable; every record must derive cleanly or // nothing is written, the same all-or-nothing contract ShieldMigrate and BrownoutMigrate already use. Prints // the operator review table (grouped by item kind) either way. - private static int RolesMigrate(bool apply) + public static int RolesMigrate(bool apply, string root = null) { - var db = AetherDb.Open(catalogWritable: apply); + var db = AetherDb.Open(catalogWritable: apply, root: root); var items = db.Cache.GetAll().ToArray(); var products = db.Cache.GetAll().ToArray(); var changedDesigns = new List<(object Document, CultRecordKey Key)>(); @@ -366,6 +367,7 @@ public static class Program var byDesign = items.ToDictionary(i => db.Cache.RefOf(i).Key, i => i); foreach (var product in products.OrderBy(p => p.Name, StringComparer.Ordinal)) { + if (product.Bin != null) continue; // a bin authors no roles; the mint reads its line's if (!byDesign.TryGetValue(product.Design.Key, out var design) || design.Roles == null || design.Roles.Count == 0) continue; var existing = new HashSet((product.Roles ?? new List()).Select(r => r.Role)); @@ -2218,7 +2220,7 @@ public static class Program var changedProducts = new List<(FactionProductData Document, CultRecordKey Key)>(); foreach (var design in targets) - foreach (var product in products.Where(p => p.Design.Key.Equals(db.Cache.RefOf(design).Key)).OrderBy(p => p.Name, StringComparer.Ordinal)) + foreach (var product in products.Where(p => p.Bin == null && p.Design.Key.Equals(db.Cache.RefOf(design).Key)).OrderBy(p => p.Name, StringComparer.Ordinal)) { var maker = db.Cache.Get(product.Manufacturer); var profile = maker?.Name == "Pirates" ? PirateProfile From 6768cdcb954d0ff018d82b70eebf58f1c0588d2c Mon Sep 17 00:00:00 2001 From: Meta Vens Date: Sun, 18 Oct 2026 18:08:56 +0200 Subject: [PATCH 018/444] product-lines tiers: authors the six bins from a table of (line, bin, floor, text) A bin gets its line's design and maker, Bin = { Line, Floor }, and no roles or assembly. Resolve-then-write as the other lines do; a replay is a no-op. The catalog is not yet applied. Co-Authored-By: Claude Sonnet 5.5 --- .../ProductTiersCommandTests.cs | 177 ++++++++++++++++++ tools/AetherDb/ProductLinesCommand.cs | 88 ++++++++- tools/AetherDb/Program.cs | 2 +- 3 files changed, 263 insertions(+), 4 deletions(-) create mode 100644 tests/Aetheria.Shared.Tests/ProductTiersCommandTests.cs diff --git a/tests/Aetheria.Shared.Tests/ProductTiersCommandTests.cs b/tests/Aetheria.Shared.Tests/ProductTiersCommandTests.cs new file mode 100644 index 000000000..856cca7dc --- /dev/null +++ b/tests/Aetheria.Shared.Tests/ProductTiersCommandTests.cs @@ -0,0 +1,177 @@ +using System; +using System.Collections.Generic; +using System.IO; +using System.Linq; +using GameCult.Caching; +using GameCult.Caching.MessagePack; +using Xunit; + +// AetherDb's product-lines tiers command (cut product-tiers; rulings maker-design-tiers-allowed, bin-share-by-floor), run on a +// scratch copy of the shipped catalog with the table's bins removed from it. A standalone class: a tier row is not a Row. +public sealed class ProductTiersCommandTests : IDisposable +{ + private static readonly string[] Apply = { "tiers", "apply" }; + private static readonly string[] DryRun = { "tiers" }; + + private readonly string _root = Path.Combine(Path.GetTempPath(), "product-tiers-command-" + Guid.NewGuid().ToString("N")); + private string CatalogPath => Path.Combine(_root, "GameData", "Aetheria.cc"); + private readonly string[] _names = ProductLinesCommand.TierTable().Select(r => r.Name).ToArray(); + + public ProductTiersCommandTests() + { + Directory.CreateDirectory(Path.Combine(_root, "GameData")); + Directory.CreateDirectory(Path.Combine(_root, "Assets", "Resources")); + var shipped = AetherDb.FindRoot(); + File.Copy(Path.Combine(shipped, "GameData", "Aetheria.cc"), CatalogPath); + File.Copy(Path.Combine(shipped, "Assets", "Resources", "Settings.asset"), Path.Combine(_root, "Assets", "Resources", "Settings.asset")); + using var cache = Open(); + foreach (var bin in cache.GetAll().Where(p => _names.Contains(p.Name)).ToArray()) Assert.True(cache.Remove(cache.RefOf(bin).Key)); + cache.FlushAsync().Wait(); + } + + public void Dispose() => Directory.Delete(_root, true); + + private CultCache Open() + { + var cache = new CultCache(TestCatalog.Registry()); + cache.AddBackingStore(new SingleFileMessagePackBackingStore(CatalogPath), AetheriaStores.CatalogTypes); + return cache; + } + + private Dictionary Records() => + CultDocumentMessagePackSerialization.DeserializeSnapshot(File.ReadAllBytes(CatalogPath)).Records.ToDictionary(r => r.Key, StringComparer.Ordinal); + + private FactionProductData[] Bins() + { + using var cache = Open(); + return cache.GetAll().Where(p => _names.Contains(p.Name)).ToArray(); + } + + // Edits the scratch catalog before a refusal case; the bins the table authors stay out of it. + private void Edit(Action edit) + { + using var cache = Open(); + edit(cache); + cache.FlushAsync().Wait(); + } + + // The command refuses the row with a message naming the rule, and the catalog's bytes are as they were. + private void Refused(ProductLinesCommand.TierRow row, string phrase) + { + var before = File.ReadAllBytes(CatalogPath); + var error = Assert.Throws(() => ProductLinesCommand.RunTiers(new[] { row }, true, _root)); + Assert.Contains(phrase, error.Message); + Assert.Equal(before, File.ReadAllBytes(CatalogPath)); + } + + [Fact] + public void A_dry_run_writes_nothing_and_apply_lands_the_table_once_and_replays_as_a_no_op() + { + var before = File.ReadAllBytes(CatalogPath); + Assert.Empty(Bins()); + + Assert.Equal(0, ProductLinesCommand.Run(DryRun, _root)); + Assert.Equal(before, File.ReadAllBytes(CatalogPath)); + + Assert.Equal(0, ProductLinesCommand.Run(Apply, _root)); + var landed = File.ReadAllBytes(CatalogPath); + Assert.NotEqual(before, landed); + Assert.Equal(6, Bins().Length); + + Assert.Equal(0, ProductLinesCommand.Run(Apply, _root)); + Assert.Equal(landed, File.ReadAllBytes(CatalogPath)); + Assert.Equal(0, ProductLinesCommand.Run(DryRun, _root)); + Assert.Equal(landed, File.ReadAllBytes(CatalogPath)); + } + + [Fact] + public void Each_bin_is_its_lines_design_and_maker_at_its_floor_with_nothing_of_its_own() + { + Assert.Equal(0, ProductLinesCommand.Run(Apply, _root)); + using var cache = Open(); + foreach (var row in ProductLinesCommand.TierTable()) + { + var bin = Assert.Single(cache.GetAll(), p => p.Name == row.Name); + var line = Assert.Single(cache.GetAll(), p => p.Name == row.Line); + Assert.Equal(cache.RefOf(line).Key, bin.Bin.Line.Key); + Assert.Equal(line.Design.Key, bin.Design.Key); + Assert.Equal(line.Manufacturer.Key, bin.Manufacturer.Key); + Assert.Equal(row.Floor, bin.Bin.Floor); + Assert.Equal(row.Description, bin.Description); + Assert.Empty(bin.Roles); + Assert.Null(bin.Assembly); + Assert.Null(line.Bin); + } + } + + [Fact] + public void Apply_writes_only_the_records_it_authors() + { + var before = Records(); + Assert.Equal(0, ProductLinesCommand.Run(Apply, _root)); + var after = Records(); + Assert.Equal(before.Count + 6, after.Count); + foreach (var (key, record) in before) + { + Assert.True(after.TryGetValue(key, out var kept), "a record the command did not author is gone"); + Assert.Equal(record.StoredAt, kept.StoredAt); + Assert.True(record.Payload.AsSpan().SequenceEqual(kept.Payload), "a record the command did not author has changed bytes"); + } + } + + [Fact] + public void A_bin_already_authored_at_another_floor_is_refused() + { + Assert.Equal(0, ProductLinesCommand.Run(Apply, _root)); + Refused(new ProductLinesCommand.TierRow("Sparrow", "Goldcrest", .5f, "x"), "already belongs"); + } + + [Fact] + public void A_line_the_catalog_does_not_hold_is_refused() + { + Refused(new ProductLinesCommand.TierRow("No Such Line", "Fresh Bin", 0f, "x"), "found 0"); + } + + [Fact] + public void An_ambiguous_line_is_refused() + { + Edit(cache => + { + var sparrow = cache.GetAll().Single(p => p.Name == "Sparrow"); + cache.Upsert(new FactionProductData { Name = "Sparrow", Design = sparrow.Design, Manufacturer = sparrow.Manufacturer }); + }); + Refused(new ProductLinesCommand.TierRow("Sparrow", "Fresh Bin", 0f, "x"), "found 2"); + } + + [Fact] + public void A_line_that_is_itself_a_bin_is_refused() + { + Assert.Equal(0, ProductLinesCommand.Run(Apply, _root)); + Refused(new ProductLinesCommand.TierRow("Goldcrest", "Fresh Bin", 0f, "x"), "itself a bin"); + } + + [Theory] + [InlineData(-.1f)] + [InlineData(float.NaN)] + [InlineData(float.PositiveInfinity)] + public void A_floor_that_is_negative_or_not_finite_is_refused(float floor) + { + Refused(new ProductLinesCommand.TierRow("Sparrow", "Fresh Bin", floor, "x"), "finite number"); + } + + [Theory] + [InlineData("Spotlight")] + [InlineData("SPOTLIGHT")] + public void A_bin_name_held_by_another_product_is_refused_ignoring_case(string name) + { + Refused(new ProductLinesCommand.TierRow("Sparrow", name, 0f, "x"), "already belongs"); + } + + [Fact] + public void A_bin_name_that_is_a_designs_is_refused() + { + string design; + using (var cache = Open()) design = cache.GetAll().First().Name; + Refused(new ProductLinesCommand.TierRow("Sparrow", design.ToUpperInvariant(), 0f, "x"), "name of a design"); + } +} diff --git a/tools/AetherDb/ProductLinesCommand.cs b/tools/AetherDb/ProductLinesCommand.cs index 911f01ffb..5ee316a38 100644 --- a/tools/AetherDb/ProductLinesCommand.cs +++ b/tools/AetherDb/ProductLinesCommand.cs @@ -16,9 +16,13 @@ using GameCult.Caching; // carries no quality numbers. A row already in the catalog (same name, design and maker) is skipped, so the same command is the // first apply, the second apply and the replay onto a merged catalog. A maker that already sells the design is not refused: a // tier is allowed. Dry run unless passed "apply". +// product-lines tiers [apply] (cut product-tiers; rulings maker-design-tiers-allowed, bin-share-by-floor): authors the bins of +// those lines. A tier row is (line product name, bin name, floor, text); the bin gets Design and Manufacturer from its line, +// Bin = { Line, Floor }, and no Roles or Assembly of its own: the mint derives both from the line. public static class ProductLinesCommand { internal sealed record Row(string Design, string Name, string Maker, string Description); + internal sealed record TierRow(string Line, string Name, float Floor, string Description); private static readonly Row[] Weapons = { @@ -85,6 +89,19 @@ public static class ProductLinesCommand new("Industrial Thermostatic Heater", "WarmUp+-", "NiteLife", "Room temperature, whatever the room."), }; + // The bins of the shipped lines. The names and text are drafts the operator may rename. + private static readonly TierRow[] Tiers = + { + new("Sparrow", "Goldcrest", .67f, "Only the Sparrows that sang on the test bench. The rest still fly."), + new("Spotlight", "Spotlight Premiere", 0f, "Hand-picked for opening night."), + new("Rattler", "Rattler Select", 0f, "Pick of the batch. Still loud."), + new("SM-6 Marksman", "SM-6C Marksman Certified", 0f, "Individually certified to published tolerance.*"), + new("PowerHour 2.0", "PowerHour 2.0 VIP", .67f, "Skip the line. Same party, better parts."), + new("Juggernaut", "Juggernaut Guards", 0f, "The column's best drives go to the front of the column."), + }; + + internal static IReadOnlyList TierTable() => Tiers; + // Every line the command knows, by the word that follows "product-lines". private static readonly Dictionary Lines = new() { ["weapons"] = Weapons, ["minor-powers"] = MinorPowers, ["gear"] = Gear }; @@ -93,16 +110,19 @@ public static class ProductLinesCommand // A product's record key is derived from its name, so the same table lands the same records every time it is applied: the // replay at a merge reproduces the branch's blob, and nothing that orders products by key reads a different order after it. - private static CultRecordKey KeyOf(Row row) => - new(new Guid(SHA256.HashData(Encoding.UTF8.GetBytes("product-line:" + row.Name)).AsSpan(0, 16)).ToString("N")); + private static CultRecordKey KeyOf(Row row) => KeyOf(row.Name); + + private static CultRecordKey KeyOf(string name) => + new(new Guid(SHA256.HashData(Encoding.UTF8.GetBytes("product-line:" + name)).AsSpan(0, 16)).ToString("N")); public static int Run(string[] args, string root = null) { var apply = args.Contains("apply"); var line = args.FirstOrDefault(a => a != "apply"); + if (line == "tiers") return RunTiers(Tiers, apply, root); if (line == null || !Lines.TryGetValue(line, out var rows)) { - Console.WriteLine($"usage: product-lines {string.Join("|", Lines.Keys)} [apply]"); + Console.WriteLine($"usage: product-lines {string.Join("|", Lines.Keys)}|tiers [apply]"); return 1; } @@ -178,4 +198,66 @@ public static class ProductLinesCommand Console.WriteLine($"\nAuthored {missing.Length} products in Aetheria.cc"); return 0; } + + // The tiers line has Run's shape: resolve every row first, so a refusal leaves the catalog untouched, then land the missing + // bins in one batch that expects each of their keys absent. A bin already in the catalog (this line, this floor) is skipped, + // so the same command is the first apply and the replay onto a merged catalog. + internal static int RunTiers(IReadOnlyList tiers, bool apply, string root) + { + var db = AetherDb.Open(catalogWritable: apply, root: root); + var designs = db.Cache.GetAll().ToArray(); + var products = db.Cache.GetAll().ToArray(); + + var resolved = new List<(TierRow Row, FactionProductData Line, bool Present)>(); + for (var i = 0; i < tiers.Count; i++) + { + var row = tiers[i]; + var lines = products.Where(p => p.Name == row.Line).ToArray(); + if (lines.Length != 1) throw new InvalidOperationException($"Tier row {i}: expected exactly one line product, found {lines.Length}."); + var parent = lines[0]; + if (parent.Bin != null) throw new InvalidOperationException($"Tier row {i}: the line is itself a bin."); + if (!float.IsFinite(row.Floor) || row.Floor < 0) throw new InvalidOperationException($"Tier row {i}: the floor is not a finite number of at least 0."); + + var named = products.Where(p => string.Equals(p.Name, row.Name, StringComparison.OrdinalIgnoreCase)).ToArray(); + var present = named.Length == 1 && named[0].Name == row.Name && named[0].Bin != null && + named[0].Bin.Line.Key.Equals(db.Cache.RefOf(parent).Key) && named[0].Bin.Floor == row.Floor; + if (named.Length != 0 && !present) throw new InvalidOperationException($"Tier row {i}: the bin name already belongs to another product."); + if (!present && designs.Any(d => string.Equals(d.Name, row.Name, StringComparison.OrdinalIgnoreCase))) + throw new InvalidOperationException($"Tier row {i}: the bin name is the name of a design."); + resolved.Add((row, parent, present)); + } + + var missing = resolved.Where(r => !r.Present).ToArray(); + if (missing.Length == 0) + { + Console.WriteLine("The tiers are already authored; nothing to do."); + return 0; + } + + Console.WriteLine("Product tiers:"); + foreach (var (row, parent, present) in resolved) + Console.WriteLine($" {(present ? "present" : "new ")} {row.Line} -> \"{row.Name}\" by {db.Cache.Get(parent.Manufacturer)?.ShortName}, floor {row.Floor:0.##}: {row.Description}"); + if (!apply) + { + Console.WriteLine($"\nDry run. Pass \"apply\" to author {missing.Length} bins."); + return 0; + } + + if (!db.Cache.Commit(batch => + { + foreach (var (row, _, _) in missing) batch.Expect(KeyOf(row.Name), null); + foreach (var (row, parent, _) in missing) + batch.Upsert(typeof(FactionProductData), new FactionProductData + { + Name = row.Name, + Description = row.Description, + Design = parent.Design, + Manufacturer = parent.Manufacturer, + Bin = new ProductBin { Line = db.Cache.RefOf(parent), Floor = row.Floor }, + }, KeyOf(row.Name)); + })) + throw new InvalidOperationException("The catalog changed while the tiers were being authored; nothing was written. Run it again."); + Console.WriteLine($"\nAuthored {missing.Length} bins in Aetheria.cc"); + return 0; + } } diff --git a/tools/AetherDb/Program.cs b/tools/AetherDb/Program.cs index 46a812ba2..d88718817 100644 --- a/tools/AetherDb/Program.cs +++ b/tools/AetherDb/Program.cs @@ -63,7 +63,7 @@ public static class Program return PlaceNamesCommand.Run(args.Skip(1).ToArray()); case "doctrine-catalog": return DoctrineCatalog.Run(AetherDb.Open(catalogWritable: args.Contains("apply")).Cache, args.Contains("apply"), Console.Out); default: - Console.WriteLine("commands: census, factions, station-fit, hardpoint-fit, loadout [seed], save, settings, settings-dump, dangling [clear ]... [apply], shield-migrate [apply], brownout-migrate [apply], roles-migrate [apply], firing-arc-migrate [apply], range-falloff [apply] [--effective ] [--end ], targeting-catalog [apply], targeting-catalog-6c [apply], targeting-catalog-6d [apply], ship-authoring create|inspect|validate|compose, field-kinds [apply], mine-launcher [apply], smoke-hull [apply], smoke-rounds [apply], roles-backfill [apply], pd-gear [apply], consumables-first-set [apply], launch-suppressor [apply], pirates-faction [apply], verse-grammar [apply], generic-designs [apply], sensor-stat-set [apply], inspection-scanner [apply], minor-power-factions [apply], place-names [apply], product-lines weapons|minor-powers|gear [apply], doctrine-catalog [apply], assembly-from-maker [apply]"); + Console.WriteLine("commands: census, factions, station-fit, hardpoint-fit, loadout [seed], save, settings, settings-dump, dangling [clear ]... [apply], shield-migrate [apply], brownout-migrate [apply], roles-migrate [apply], firing-arc-migrate [apply], range-falloff [apply] [--effective ] [--end ], targeting-catalog [apply], targeting-catalog-6c [apply], targeting-catalog-6d [apply], ship-authoring create|inspect|validate|compose, field-kinds [apply], mine-launcher [apply], smoke-hull [apply], smoke-rounds [apply], roles-backfill [apply], pd-gear [apply], consumables-first-set [apply], launch-suppressor [apply], pirates-faction [apply], verse-grammar [apply], generic-designs [apply], sensor-stat-set [apply], inspection-scanner [apply], minor-power-factions [apply], place-names [apply], product-lines weapons|minor-powers|gear|tiers [apply], doctrine-catalog [apply], assembly-from-maker [apply]"); return 1; } } From 9b7a1c00dc2b7034a16a61c436463008f4fa9996 Mon Sep 17 00:00:00 2001 From: Meta Vens Date: Sun, 18 Oct 2026 18:11:11 +0200 Subject: [PATCH 019/444] Catalog: the six tier bins (product-lines tiers apply); TierRowsAreShipped; the tier step pinned from both sides Aetheria.cc written only by the command, on the pinned CultLib. Replay the command at merges; never merge the blob. Co-Authored-By: Claude Sonnet 5.5 --- GameData/Aetheria.cc | 4 +- tests/Aetheria.Shared.Tests/LoadoutTests.cs | 7 +++ .../ProductLinesCatalogTests.cs | 59 +++++++++++++++++++ 3 files changed, 68 insertions(+), 2 deletions(-) diff --git a/GameData/Aetheria.cc b/GameData/Aetheria.cc index 9f9ff69e5..a2ddaad5d 100644 --- a/GameData/Aetheria.cc +++ b/GameData/Aetheria.cc @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:22342ea360c7f453de500da8650e6a23648f8c1250eb22e724765bbcf619eac0 -size 186037 +oid sha256:c327ef1bbabbd34ddb7b9f643f5db8cc54aa5730f232bd42a1fa5f2b03973e89 +size 189753 diff --git a/tests/Aetheria.Shared.Tests/LoadoutTests.cs b/tests/Aetheria.Shared.Tests/LoadoutTests.cs index 5673b205d..86bf4e698 100644 --- a/tests/Aetheria.Shared.Tests/LoadoutTests.cs +++ b/tests/Aetheria.Shared.Tests/LoadoutTests.cs @@ -622,6 +622,13 @@ public sealed class LoadoutTests : IDisposable var result = items.GetTier((CraftedItemInstance) items.CreateInstance(lot)); Assert.Equal(settings.Tiers[0].Name, result.tier.Name); Assert.Equal(2, result.upgrades); + + // The same width from the other side (follow-up tier-upgrade-step-two-sided): two steps less a hair is one upgrade, so a + // step narrower than .0499 reads two and a wider one reads none. + var under = items.Lots.Add(new Lot { Design = cache.RefOf(lamp), Origin = new Attributed(), Quality = settings.Tiers[0].Quality + 2 * .0499f - .0001f, Roles = new List() }); + var below = items.GetTier((CraftedItemInstance) items.CreateInstance(under)); + Assert.Equal(settings.Tiers[0].Name, below.tier.Name); + Assert.Equal(1, below.upgrades); } // S7 (docs/stats-and-power-cut.md Cut 1 findings): a Quality term must raise the lot's quality to its own diff --git a/tests/Aetheria.Shared.Tests/ProductLinesCatalogTests.cs b/tests/Aetheria.Shared.Tests/ProductLinesCatalogTests.cs index 6a91bb457..7ce78218f 100644 --- a/tests/Aetheria.Shared.Tests/ProductLinesCatalogTests.cs +++ b/tests/Aetheria.Shared.Tests/ProductLinesCatalogTests.cs @@ -2,6 +2,8 @@ using System; using System.Collections.Generic; using System.Linq; using GameCult.Caching; +using GameCult.Caching.MessagePack; +using MessagePack; using Xunit; // The weapon product lines the shipped catalog holds (campaign aetheria-release, cut product-lines-weapons; rulings @@ -99,4 +101,61 @@ public sealed class ProductLinesCatalogTests } } } + // Cut product-tiers: the six bins of the command's tier table are in the catalog, each a single product that names its line. + [Fact] + public void TierRowsAreShipped() + { + using var cache = RestoredHullsTests.OpenCatalog(); + var table = ProductLinesCommand.TierTable(); + Assert.Equal(6, table.Count); + foreach (var row in table) + { + var bin = Assert.Single(cache.GetAll(), p => p.Name == row.Name); + var line = Assert.Single(cache.GetAll(), p => p.Name == row.Line); + Assert.Equal(cache.RefOf(line).Key, bin.Bin.Line.Key); + Assert.Equal(line.Design.Key, bin.Design.Key); + Assert.Equal(line.Manufacturer.Key, bin.Manufacturer.Key); + Assert.Equal(row.Floor, bin.Bin.Floor); + Assert.Equal(row.Description, bin.Description); + Assert.Empty(bin.Roles); + Assert.Null(bin.Assembly); + } + } + + // A bin bins an unbinned line of its own design and maker, and the catalog holds no bin the table does not author. + [Fact] + public void BinsBinAnUnbinnedLineOfTheirOwnDesignAndMaker() + { + using var cache = RestoredHullsTests.OpenCatalog(); + var bins = cache.GetAll().Where(p => p.Bin != null).ToArray(); + Assert.Equal(ProductLinesCommand.TierTable().Select(r => r.Name).OrderBy(n => n, StringComparer.Ordinal), bins.Select(b => b.Name).OrderBy(n => n, StringComparer.Ordinal)); + foreach (var bin in bins) + { + var line = cache.Get(bin.Bin.Line); + Assert.NotNull(line); + Assert.Null(line.Bin); + Assert.Equal(line.Design.Key, bin.Design.Key); + Assert.Equal(line.Manufacturer.Key, bin.Manufacturer.Key); + Assert.True(bin.Bin.Floor >= 0 && float.IsFinite(bin.Bin.Floor)); + } + } + + // FactionProductData slot 7 is additive under the same schema id: a bin round-trips, and a record written before the slot + // existed (an array one element shorter) reads no bin. + [Fact] + public void BinIsSlotSevenAndACatalogWithoutItReadsNoBin() + { + var options = CultDocumentMessagePackSerialization.OptionsFor(typeof(FactionProductData).Assembly); + var binned = MessagePackSerializer.Deserialize( + MessagePackSerializer.Serialize(new FactionProductData { Name = "Binned", Bin = new ProductBin { Floor = .67f } }, options), options); + Assert.Equal(.67f, binned.Bin.Floor); + + var bytes = MessagePackSerializer.Serialize(new FactionProductData { Name = "Old", Description = "d" }, options); + Assert.Equal(0x98, bytes[0]); + Assert.Equal(0xC0, bytes[^1]); + var before = new byte[] { 0x97 }.Concat(bytes.Skip(1).Take(bytes.Length - 2)).ToArray(); + var old = MessagePackSerializer.Deserialize(before, options); + Assert.Equal("Old", old.Name); + Assert.Null(old.Bin); + } } From aec7b3c019edfdc13e4b6c71cee04d9497a98be0 Mon Sep 17 00:00:00 2001 From: Meta Vens Date: Sun, 18 Oct 2026 18:16:27 +0200 Subject: [PATCH 020/444] product-lines tiers lists every row, present or new, before it reports there is nothing to do Co-Authored-By: Claude Sonnet 5.5 --- tools/AetherDb/ProductLinesCommand.cs | 12 ++++++------ 1 file changed, 6 insertions(+), 6 deletions(-) diff --git a/tools/AetherDb/ProductLinesCommand.cs b/tools/AetherDb/ProductLinesCommand.cs index 5ee316a38..2c82d5ebf 100644 --- a/tools/AetherDb/ProductLinesCommand.cs +++ b/tools/AetherDb/ProductLinesCommand.cs @@ -228,15 +228,15 @@ public static class ProductLinesCommand } var missing = resolved.Where(r => !r.Present).ToArray(); - if (missing.Length == 0) - { - Console.WriteLine("The tiers are already authored; nothing to do."); - return 0; - } - Console.WriteLine("Product tiers:"); foreach (var (row, parent, present) in resolved) Console.WriteLine($" {(present ? "present" : "new ")} {row.Line} -> \"{row.Name}\" by {db.Cache.Get(parent.Manufacturer)?.ShortName}, floor {row.Floor:0.##}: {row.Description}"); + if (missing.Length == 0) + { + Console.WriteLine("\nThe tiers are already authored; nothing to do."); + return 0; + } + if (!apply) { Console.WriteLine($"\nDry run. Pass \"apply\" to author {missing.Length} bins."); From fb7b95fb0659fbf8bff77787bc997c4662d2b47f Mon Sep 17 00:00:00 2001 From: Meta Vens Date: Sun, 18 Oct 2026 18:19:00 +0200 Subject: [PATCH 021/444] ProductChoiceTests: a bin takes nothing from another maker's product Co-Authored-By: Claude Sonnet 5.5 --- tests/Aetheria.Shared.Tests/ProductChoiceTests.cs | 2 ++ 1 file changed, 2 insertions(+) diff --git a/tests/Aetheria.Shared.Tests/ProductChoiceTests.cs b/tests/Aetheria.Shared.Tests/ProductChoiceTests.cs index a11708393..5061d4603 100644 --- a/tests/Aetheria.Shared.Tests/ProductChoiceTests.cs +++ b/tests/Aetheria.Shared.Tests/ProductChoiceTests.cs @@ -272,6 +272,8 @@ public sealed class ProductChoiceTests : IDisposable Assert.Equal(.2514f * preference, generator.ProductWeight(quarter.Product("lamp-a bin")), 3); Assert.Equal(.7486f * preference, generator.ProductWeight(quarter.Product("lamp-a")), 3); Assert.Equal(preference, generator.ProductWeight(quarter.Product("lamp-a bin")) + generator.ProductWeight(quarter.Product("lamp-a")), 3); + // Another maker's product, whose line has no bin, weighs what it did without any bin in the catalog. + Assert.Equal(alone.Outfitter(alone.OutEven, 1, 0).ProductWeight(alone.Product("lamp-z")), generator.ProductWeight(quarter.Product("lamp-z")), 3); var half = Build(new[] { 0f }, new[] { "lamp-z", "lamp-a" }); generator = half.Outfitter(half.OutEven, 1, 0); From 112dd2453614baec4e2b6cc1b77df460c32a22a4 Mon Sep 17 00:00:00 2001 From: Meta Vens Date: Sun, 18 Oct 2026 18:20:07 +0200 Subject: [PATCH 022/444] aetheria-release map: boss hull derived r4 (fixed Loadouts), stand-in hulls (SI) probe and cut sequence Co-Authored-By: Claude Opus 5.5 --- docs/aetheria-release-map.md | 445 ++++++++++++++++++++++++++++------- 1 file changed, 360 insertions(+), 85 deletions(-) diff --git a/docs/aetheria-release-map.md b/docs/aetheria-release-map.md index b3a9f1c42..54699054e 100644 --- a/docs/aetheria-release-map.md +++ b/docs/aetheria-release-map.md @@ -10786,110 +10786,192 @@ worktree C:\aeth-wt\ag6-quality-readers at 70c7a497. Catalog: GameData/Aetheria. ## Boss hull derived -Probes and source reads by imagination-ag6-boss-hull (session self-2026-10-18-ag6), 2026-10-18, -in the detached worktree `C:\aeth-wt\ag6-boss-hull` at `origin/master` `eb27ced1`. `origin/master` -moved to `ac835a8a` during the pass. That commit adds only -`docs/research/2026-10-18-agent-built-hull-art-prior-art.md`, so every anchor below is the same -at both. Read by `cut-boss-hull-derived.r3`. This section supersedes the anchors of "Demo hulls, -the Pirates and the boss" H7 (`@2a5fc00b`) and of Products PL6. Its design rationale -("The boss hull is derived, not named") stands, with the changes recorded under Rationale below. +r3's probes and source reads were made by imagination-ag6-boss-hull (session self-2026-10-18-ag6), +2026-10-18, in the detached worktree `C:\aeth-wt\ag6-boss-hull` at `origin/master` `eb27ced1` +(BH1-BH11). r4's probes were made by imagination-ag6-boss-hull-r4 in the same worktree at the +branch head `095baf2f`, which is r3's h1 (BH12-BH17). Read by `cut-boss-hull-derived.r4`. This +section supersedes the anchors of "Demo hulls, the Pirates and the boss" H7 (`@2a5fc00b`) and of +Products PL6. Under Rationale below, r4 replaces r3's boss-loadout path. Ruling +`boss-gear-is-a-loadout` revokes that path; it replaced `boss-gear-is-fixed`. ### Body facts - **BH1. What the cut waits on is on master.** boss-gate is merged at `39c7508e` (cut-boss-gate r2, verdict s3). faction-play-1 is merged at `d7f30ed1` (r3, h6), and its catalog - replay is at `aa12cbac`. minor-power-factions is merged at `c486b06d`; it is the remap of - maker-only-faction. pirates-record is merged at `2c065937` and package-product at `3535660c`. - No cut that edits Faction or the main galaxy's faction draw is still in flight ahead of this - one. + replay is at `aa12cbac`. minor-power-factions is merged at `c486b06d`. pirates-record is merged + at `2c065937` and package-product at `3535660c`. - **BH2. Faction keys at `eb27ced1`** (`Corporations.cs`): 1-5, 7, 8, a retired-key comment for 9 - (`:39`), `BossHull` Key(10) at `:42-43`, then 11-15, 17 (Doctrine), 19 (Assembly) and 21 (Places). -- **BH3. HullData keys** (`ItemData.cs:531-556`): 23, 24, 25, retired 26 (`:542`), 27, 28, 29 and - 32 (`Visual`, `:555-556`). Key 33 is free on HullData. Fuse's Key(32) at `:526` is on a sibling - type. -- **BH4. Every BossHull reader at `eb27ced1`** (`git grep BossHull`): - `Galaxy.cs:293` (`PlaceFactionsMain` picks boss factions by `BossHull.IsSet()`, `:291-295`); - `LoadoutGenerator.cs:212-230` (`GenerateBossLoadout`); - `tools/AetherDb/Program.cs:586` (dangling comment) and `:709-712` (the factions boss column and - its `broken` count, the method's only use of `broken`, `:700-716`); - `RunGoalTests.cs:74-115` (two tests) and `:199-202` (Exit_open_only_in_the_exit_zone sets one). - No writer exists: no AetherDb command, no Pirates or minor-power command, no package path. - `GameData/Aetheria.cc` holds the string `bossHull`, which is its schema table. + (`:39`), `BossHull` Key(10) at `:42-43`, then 11-15, 17 (Doctrine), 19 (Assembly) and 21 + (Places). h1 deleted `BossHull` and retired key 10. +- **BH3. HullData keys** (`ItemData.cs:531-556` at `eb27ced1`): 23, 24, 25, retired 26, 27, 28, + 29 and 32 (`Visual`). h1 added `Boss` at Key(33) (`ItemData.cs:557-560` at `095baf2f`). +- **BH4. Every BossHull reader at `eb27ced1`** was `Galaxy.cs:293`, + `LoadoutGenerator.cs:212-230`, `tools/AetherDb/Program.cs:586` and `:709-712`, and + `RunGoalTests.cs:74-115` and `:199-202`. h1 removed every one. - **BH5. Retiring a key with stored values needs no catalog rewrite.** Precedents: `1d689adf` - retired Faction key 9 and `be7f2b07` retired HullData key 26, and neither touched - `GameData/Aetheria.cc`. The suite loads the shipped catalog in `RunStartTests`' constructor. - This is a precedent read; no decode was run in this pass. -- **BH6. Every generation hull draw goes through one availability rule.** `RandomHull` - (`LoadoutGenerator.cs:165-170`) calls `RandomProduct`, then `RandomProducts` (`:176-185`), then - `AvailableProducts` (`:188-210`). Station stock (`:126-127`) and the boss fallback (`:226`) also - go through `AvailableProducts`. Its `candidates` filter (`:191-198`) runs before the - any-manufacturer fallback (`:201-206`). An exclusion placed there therefore holds on the - fallback too. `ChooseProduct` (`:262-273`) has its own `Where` and serves presets that name a - design. -- **BH7. Explicit hull draws.** `ScenarioStage.Generated` (`ScenarioStage.cs:90-97`) and AetherDb - `loadout` (`Program.cs:805-818`, which runs every hull through `GenerateShipLoadout(candidate - => candidate == hull)`) both reach `AvailableProducts`. Many tests call - `GenerateShipLoadout(h => h == hull)` on shipped hulls (for example `RestoredHullsTests.cs:606`). - None of those hulls is a Boss hull. -- **BH8. The boss path.** `ZoneGenerator.cs:398-412` calls `GenerateBossLoadout` for every - `BossZones` entry in the zone and marks the result `Boss`. `Galaxy.cs:150` sets the prelude and - demo antagonist's boss zone without reading any hull. `Galaxy` holds `_cache` (`:36`, set at - `:51`, `:102` and `:169`), so `PlaceFactionsMain` can call a static derivation over the catalog. -- **BH9. The package writer.** `ShipAuthoringCommands.cs` (157 lines): `Usage` at `:11`, `inspect` - at `:29-35`, `price` at `:79-93` (it judges before it writes, and a refusal leaves the file - byte-identical), and `product` at `:94-125`. `ShipProductTests.ProductAndPriceCommands` - (`:259-300`) pins the price and product commands with `RefusedAndUntouched`. Compose copies a - package's HullData verbatim, so a member set in the package reaches the composed catalog. - `ComposeCopiesEveryProductAndEachMakerIsOfferedTheHull` (`:80-100`) byte-compares products. -- **BH10. Zhestokost makes no ship hull.** Products PL6 still holds: its only hull product is the - Turret. Until a Zhestokost Boss hull exists, the demo boss is the logged fallback (the priciest - ordinary ship hull Zhestokost can be offered). -- **BH11. Which hulls can be bosses.** Ruling `hull-programmer-art-stopgap` (2026-10-18) leaves - open whether boss hulls come from the operator's packages or an agent-built stopgap. The Eyes - facts file at `ac835a8a` places stopgap art on the package path. Nothing in the derivation - reads `Prefab`, `Visual` or a model, so a hull from either source, or with neither, can be a - boss. + (Faction key 9) and `be7f2b07` (HullData key 26). +- **BH6. Every generation hull draw goes through one availability rule**, `AvailableProducts` + (`LoadoutGenerator.cs:196-219` at `095baf2f`). Its candidate filter carries h1's + `!(entry.design is HullData { Boss: true })` (`:204`) ahead of the any-manufacturer fallback. + `ChooseProduct` (`:278-289`) has its own `Where` and does not exclude Boss hulls. r4 never asks + it for a hull, because `BossOf` returns only a Loadout whose `HullProduct` is set. +- **BH7. Explicit hull draws.** `ScenarioStage.Generated` (`ScenarioStage.cs:93`) and AetherDb + `loadout` reach `AvailableProducts`. Many tests call `GenerateShipLoadout(h => h == hull)` on + shipped hulls, and none of those hulls is a Boss hull. +- **BH8. The boss path at `095baf2f`.** `ZoneGenerator.cs:401-411` calls + `GetLoadoutGenerator(bossFaction).GenerateBossLoadout()` for every `BossZones` entry in the zone. + It logs "No boss could be generated" on null and otherwise marks the pack `Boss` and the zone + `BossSpawned`. `:414-415` logs exit-without-boss. `GetLoadoutGenerator` (`:282-287`) caches one + generator per faction over the zone's `random`. `GenerateBossLoadout` (`LoadoutGenerator.cs:221-233`) + builds `BossHullsOf[0]` through `ShipLoadout(product)` (`:55-66`: instance, Ship, + `OutfitEntity`, `EquipPickup`, `StockConsumables`, Pack). Otherwise it logs "makes no boss + hull" and generates the priciest ship hull on offer. Its readers are only `ZoneGenerator.cs:403` + and AetherDb `loadout` (`Program.cs:815`). +- **BH9. The package writer.** `ShipAuthoringCommands.cs`: `Usage` at `:11`; h1 added `boss + on|off`. Compose copies a package's HullData verbatim. + `ShipProductTests.ABossPackageIsItsMakersBossAfterCompose` passes at `095baf2f`. +- **BH10. Zhestokost makes no ship hull, and the shipped catalog has no Boss hull.** Products PL6 + still holds. Today's demo boss is the generated fallback: the priciest ordinary ship hull + Zhestokost can be offered. Under r4 there is no fallback, so the demo exit has no boss until a + Zhestokost Boss hull and its Loadout exist (question `demo-boss-before-its-hull`). +- **BH11. Which hulls can be bosses.** Ruling `hull-programmer-art-reads-as-stand-in` (it + supersedes `hull-programmer-art-stopgap`) leaves the stopgap mechanism open. The Eyes facts file + at `ac835a8a` places stopgap art on the package path. Nothing in either derivation reads + `Prefab`, `Visual` or a model. +- **BH12. The three failures in h1 are a fixture defect.** Probe p1 ran ygg-verify at `095baf2f` + with the filter of the three tests and the detailed console logger. All three throw + `InvalidOperationException: Sequence contains no matching element` at + `RunStartTests.FactionNamed` (`RunGoalTests.cs:75`), called with `"Lucent"` at `:130`, `:149` + and `:185`. The shipped faction is `"Lucent Media"`, and `"Lucent"` is its ShortName + (`DemoTerminusTests.cs:23`, `CatalogRoleTests.cs:368`). Probes p2 and p3 used a scratch clone + with only that string changed (`f683baa4`). All three passed: `Passed + A_boss_hull_two_makers_sell_is_a_boss_of_each`, `Passed Boss_hulls_are_never_stock_or_traffic` + and `Passed Another_factions_boss_hull_is_never_used`. The h1 job hid the messages because its + grep kept only `Assert|Expected|Actual` lines. Verdicts: + - `Boss_hulls_are_never_stock_or_traffic` is a real fixture defect. Its rule (ruling + `boss-hulls-exclusive`) holds once the name is fixed, and the test survives unchanged + otherwise. + - `Another_factions_boss_hull_is_never_used` is a real fixture defect. Its fallback assertions + die with the revoked path. Its rule, that another maker's Boss hull is not this faction's, + survives as a `NoBossHull` refusal. + - `A_boss_hull_two_makers_sell_is_a_boss_of_each` is a real fixture defect. Its `BossHullsOf` + rule survives. Its last assertion (the boss flies "Shared Boss") becomes "flies its Loadout". +- **BH13. The Loadout record already carries the key from a boss hull to its gear.** `Loadout` + (`Loadout.cs:14-22`, schema `aetheria.loadout` v1) has `Name` (Key 0), `Hull` (Key 1), + `Slots` (Key 2: `Position`, `Rotation`, `Design`, optional `Product`), `WeaponGroups` (Key 3) + and `HullProduct` (Key 4, optional). `HullProduct` names the product, and therefore the maker, + of the hull. So "the Loadout whose `HullProduct` is the faction's boss hull product" needs no new + field on `Loadout`, `Faction`, `FactionProductData` or `HullData`. It also tells apart two makers + of one shared Boss hull. +- **BH14. `Loadouts.Materialize` is already the builder a fixed boss needs.** It is all or + nothing, pins a set product as named, and builds a design-only slot through the passed chooser. + Its doc says a game spawner passes `LoadoutGenerator.ChooseProduct`. It builds a Ship from a ship + hull with the given zone, null included, as `ShipLoadout` did, and pads to `WeaponGroupCount`. + It adds no pickup, consumables or outfit. Its callers today are `ScenarioStage.cs:179` (fits) + and the capture round trip in tests. +- **BH15. A catalog Loadout cannot name a package hull.** `Loadouts.Commit` (`Loadout.cs:78-98`) + is the only writer of Loadout records, through the in-game capture command + (`ActionGameManager.cs:643`). It refuses any loadout whose hull or slot design is a mod key. + Package hulls and products are keyed `mod-hull:`, `mod-product:` and `mod-ship:` + (`ShipModCatalog.cs:23-30`), and "the shipped catalog ... never reference[s] one". A package + carries only its HullData, its ShipAuthoring and its products (`ShipModCatalog.Package`, + `:13-21`). So a boss Loadout for a package Boss hull has to ride in the package. That is the + next cut line below, not r4. +- **BH16. Boss-dependent tests.** `RunGoalTests.cs` holds 18 tests. Those that read the demo's + fallback boss are `Boss_spawns_once_in_boss_zone`, `Boss_spawns_in_a_zone_that_is_not_ambient`, + `Exit_sealed_until_boss_dies`, `Boss_mark_survives_continue` and + `Exit_open_after_save_of_a_slain_boss`. `Exit_sealed_when_no_ship_hull_is_on_offer` pins the + fallback's null branch. No other test file references `IsBoss`, `BossSpawned` or `ExitOpen`. +- **BH17. CultCache variants are not available to Aetheria yet.** CultLib `src` has variant + records (`CultCache.cs:59-60`, `CultVariantDelta`, "a variant record (slot 4): its base and + overrides"). Aetheria pins CultLib `45c2f4006f5f` (`Directory.Build.props:6`), and + `git grep CultVariantDelta 45c2f40` finds nothing. An interim Boss hull copied from a shipped + hull would be a plain copy until the pin moves. "Variant" is that record kind, never a loadout + (ruling `boss-gear-is-a-loadout`). -### Rationale +### Rationale (r4) -- **One owner of which hull is a faction's boss:** `LoadoutGenerator.BossHullsOf(CultCache, - Faction)`. It returns the faction's products whose design is a `HullType.Ship` HullData with - `Boss` set, ordered by hull Price descending, then hull Name ordinal. The flag is authored on - the hull, and the faction comes from the product's maker. Nothing stores the pairing. - `GenerateBossLoadout`, `Galaxy.PlaceFactionsMain` and AetherDb `factions` all call it. -- **One owner of "a Boss hull is never on offer":** `AvailableProducts`' candidate filter (BH6). - r2 put the exclusion on `RandomHull` and the stock filter, and gave `RandomHull` a boss bool. - That made two sites and one mode flag, and it missed the fallback's own - `AvailableProducts` call. r3 uses one clause instead. The boss is built straight from its - product through a new `ShipLoadout(FactionProductData)` builder, which is the body of - `GenerateShipLoadout` after its draw. So the boss path never asks the availability rule to - make an exception. -- **The "named but unsold" branch goes.** It existed because BossHull named a design. A - derivation that starts from products cannot name an unsold hull, so its log line and its test - (`Boss_hull_nobody_sells_falls_back`) are deleted. They are not rewritten. -- **A boss is not a market draw.** `ShipLoadout` from the boss product does not apply - `AvailableProducts`' `Price > 0` rule. A price-0 Boss hull is still a boss. -- **Kept:** a sold Boss hull's hardpoints still count in `SoldHulls`, so gear only it can mount has - a home. That gear is loot. `ScenarioStage.Generated("")` fails like any unavailable - hull. `ChooseProduct` is untouched. -- **Prior art (from memory, medium confidence; no Eyes pass this round):** Starsector tags unique - and boss hulls so markets never sell them. They are spawned by script, outside a faction's - known-ships draw. EVE's officer spawns fly hulls that no market sells. Both match the exclusive - reading. Question `boss-hull-exclusive` asks the operator to confirm it. +- **One owner of what a boss flies:** `Loadouts.BossOf(CultCache, Faction, out BossRefusal)`, in + `Loadout.cs` beside the other owners of Loadout records. It takes `BossHullsOf(items, faction)[0]` + and returns the Loadout whose `HullProduct` key is that product's key, first by Name ordinal. + When there is none, it returns null with `NoBossHull` or `NoLoadout`. It reads no `Random`, no + model and no availability rule. `ZoneGenerator`'s boss block, `Galaxy.PlaceFactionsMain` and + AetherDb `factions` all call it. `LoadoutGenerator.BossHullsOf` stays the one owner of which hull + is a boss. +- **Keyed on the product, not the hull** (BH13). A Boss hull two makers sell is a boss of each, + and each maker's boss is the Loadout on its own product. No field on Faction or the product + points at a Loadout, so no second record can disagree with the Loadout. +- **No generator on the boss path.** `GenerateBossLoadout`, its priciest-hull fallback and + `ShipLoadout(product)` are deleted. `GenerateShipLoadout` gets back its single body, because the + boss was `ShipLoadout`'s only other caller. The boss is built by `Loadouts.Materialize`, the one + builder from a Loadout (BH14). It carries exactly its cells: no tractor pickup, no consumables + and no outfit pass. +- **The chooser is not a draw of gear.** Materialize needs a chooser only for a design-only slot. + Ruling `loadout-product-optional-context-chooses` says that slot's product is chosen by context, + and `ChooseProduct` is that rule's owner. It never picks a design, a cell or the hull. The + negative check forbids every generator draw on the boss path (`GenerateShipLoadout`, + `RandomHull`, `RandomProduct(s)`, `OutfitEntity`, `EquipPickup`, `StockConsumables`). An agent + who wants the brand fixed pins the slot's product. +- **A typed refusal, never a fallback.** `BossRefusal { None, NoBossHull, NoLoadout }`. + `Loadouts.Describe` names the missing field (`HullData.Boss`, `Loadout.HullProduct`) and echoes + no product name or record key. The zone logs ` has no boss: it .`, spawns + nothing and leaves `BossSpawned` false, so the exit gate fails closed through boss-gate's + existing rule. A Loadout that does not fit is logged with Materialize's failures and also spawns + nothing. +- **Galaxy picks factions that have a boss to fly.** In the main galaxy, a faction with a Boss + hull but no Loadout gets no boss zone. That keeps a placed boss zone from being empty by + construction. +- **Authoring** (ruling `boss-loadouts-agent-authored`). AetherDb `loadout-commit [,,,[@]]... [--group i,j]... [--replace]` writes one + Loadout through `Loadouts.Commit`, which stays the only writer. The command first resolves every + name exactly and test-materializes the Loadout, so a typo or a misfit refuses before anything is + written. The operator adjusts the record in Studio afterwards. The command is argument-driven, + not a coded table like `product-lines tiers`. A coded table replayed at merges would fight her + Studio edits. The record in the catalog is the authority, and the commit message records the + command line for a merge replay. +- **Vocabulary.** `Loadout.cs` says "Loadout" throughout. "Preset" and "variant" go from its + comments and messages. The wider sweep (the `capturepreset` console command, `ScenarioStage`, + `Provenance`, `LoadoutGenerator` and `ActionGameManager` comments) is follow-up + `loadout-vocabulary-sweep`. +- **Kept from r3 (h1, landed):** Faction.BossHull is deleted with key 10 retired, `HullData.Boss` + Key(33) stays, `BossHullsOf` stays, the exclusive clause in `AvailableProducts` stays (ruling + `boss-hulls-exclusive`), and `ship-authoring boss`, the compose test and the no-authored-model + test stay (ruling `hull-programmer-art-reads-as-stand-in`). +- **Revoked from r3:** `GenerateBossLoadout` through `BossHullsOf` and `ShipLoadout(product)`, the + AetherDb `loadout` boss branch, `Boss_without_a_boss_hull_falls_back`, and the "makes no boss + hull" log. +- **Prior art (from memory, medium confidence; no Eyes pass this round):** Starsector's unique + bosses (for example the Ziggurat and the Omega flagships) fly hand-authored variant files + attached to a scripted fleet, never the faction's autofit. EVE's officer and faction-commander + spawns have fixed fittings per NPC type. Both match a fixed record keyed to the boss, with no + generator. ### Model page rows -Replace the two rows in "Demo scope, Model page rows" (`Boss hull`, `Faction.BossHull`) with: +Replace the rows in "Demo scope, Model page rows" (`Boss hull`, `Faction.BossHull`), and add the +boss Loadout row: | Kind | Identity | Lifecycle | Authority | |---|---|---|---| -| Boss hull | `HullData.Boss` (key 33, bool) on any HullData, whether shipped, package, or agent-built stopgap | Set by `ship-authoring boss on\|off` on packages, or in Studio's inspector on shipped hulls. Derived per generation; stored nowhere | The hull's author sets the flag. `LoadoutGenerator.BossHullsOf` derives the faction from the product's maker | +| Boss hull | `HullData.Boss` (key 33, bool) on any HullData: shipped, package, or agent-built stopgap | Set by `ship-authoring boss on\|off` on packages, or in Studio's inspector on catalog hulls. Derived per generation; stored nowhere | The hull's author sets the flag. `LoadoutGenerator.BossHullsOf` derives the faction from the product's maker | +| Boss Loadout | A `Loadout` record (`aetheria.loadout` v1) whose `HullProduct` is a faction's Boss hull product, at `Loadouts.KeyOf(name)` | Written by AetherDb `loadout-commit` (agents) or the in-game capture, both through `Loadouts.Commit`. Studio edits after that. Read at zone generation by `Loadouts.BossOf` | `Loadouts.BossOf` alone decides what a boss flies, and `Loadouts.Materialize` builds it. No generator draw | | `Faction.BossHull` (key 10) | Retired; never reuse | Deleted by `boss-hull-derived`. The shipped records keep `''` in the slot (BH5) | none | ### Cut sequence -Remove the `boss-hull-derived` line. Change the `demo-cast-hulls` line's "boss-hull-derived" in -"Waits on" to "boss-hull-derived (spec cut-boss-hull-derived.r3)" until it merges. +- `boss-hull-derived` (spec `cut-boss-hull-derived.r4`, branch at `095baf2f`). Its merge waits on + question `demo-boss-before-its-hull`. +- If `interim-catalog-boss` is chosen: `zhestokost-interim-boss`, a sibling content cut that lands + with r4. Through AetherDb it writes a catalog Boss hull copied from the hull today's demo boss + flies (BH10), plus its Zhestokost product and its Loadout (`loadout-commit`). It is replayed at + merges and replaced when the real Zhestokost Boss hull lands. +- `package-boss-loadout`: a package carries its boss Loadout, as it carries its product (ruling + `package-carries-product`, BH15). `ship.cc` gains Loadout records, Compose writes them under a + `mod-loadout:` key that `IsModKey` knows, and `ship-authoring loadout` writes them. It is needed + before the first package Boss hull can be a boss. `BossOf` already matches it unchanged, by + `HullProduct` key in the composed cache. +- `demo-cast-hulls`'s "Waits on" reads "boss-hull-derived (spec cut-boss-hull-derived.r4)" until it + merges. ## Product tiers @@ -11186,3 +11268,196 @@ with the wider scope taken when the FusedRounds premise is fixed. **Old saves.** A pre-cut save reads `Seed == 0` and continues with name-only drops. Under ruling `no-save-compatibility-before-players` the cut may refuse it, but a refusal gate would be code written for nobody. + +## Stand-in hulls (SI) + +Imagination pass `imagination-ag6-hull-art`, session `self-2026-10-18-ag6`, 2026-10-18. Pinned to Aetheria +`origin/master` 6d10331c (worktree `C:\aeth-wt\ag6-hull-art`). Serves ruling +`hull-programmer-art-reads-as-stand-in`, which supersedes `hull-programmer-art-stopgap`. +Research input: `docs/research/2026-10-18-agent-built-hull-art-prior-art.md`. That file surveys making agent-built +art look good or deliberate. This ruling asks for the opposite, so the research's §B3 (low poly that reads as +deliberate) is evidence about what to avoid. The greybox "dev texture" convention below comes from general practice, +not from that file (one of the gaps listed at the end). + +Scratch root `S` = `C:\Users\Meta\AppData\Local\Temp\claude\F--Projects-CultLib\1b477360-19e3-41e2-bde4-8932ace0b8fd\scratchpad\imagination-ag6-hull-art`. + +### Renders for the operator + +- `S\probe\out\longinus_top_standin_vs_existing.png`: top view, stand-in (left) and the shipped `Longinus.fbx` (right), each at its own scale. +- `S\probe\out\longinus_iso_standin_vs_existing.png`: three-quarter view, same pair. +- `S\probe\out\longinus_combat_zoom_proxy.png`: both top views at ship lengths of 48, 80 and 128 px. +- `S\probe\out\longinus_top_same_metres.png`: both in one 40 m frame. The stand-in uses 2 m cells and the shipped mesh about 1 m per cell (SI-B5). + +All four come from one scratch rasterizer (`S\probe\render.py`) with the same camera and light. The stand-in side is +read back from the `ship.glb` the package path accepted, so it is not a separate preview. + +### What the game reads (body facts) + +- **SI-B1. Package shape.** A mod hull is `GameData/Mods//ship.cc` plus `ship.glb`. The `.cc` holds `HullData` + (`mod-hull:`) and `ShipAuthoring` (`mod-ship:`; `ModelAsset`, `Anchors`, `SchematicLines`, `Joints`). + Source: `ShipAuthoring.cs:12-21`, `docs/moddable-ship-authoring.md:160-200`. +- **SI-B2. Anchors the game needs.** Exactly one each of `map-icon`, `hull-collider`, `shield` and `tractor` + (`ShipAuthoring.cs:288-290`). Each visible hardpoint has a mount anchor whose id is its mount id: `thruster-emitter`, + `radiator-mesh` or `weapon-mount`. Each weapon needs at least one `weapon-muzzle` whose ParentId is the mount + (`:325-356`). These nodes must carry a mesh: thruster-emitter, radiator-mesh, map-icon and hull-collider. The + shield node needs three positive scale radii (`ShipModCatalog.cs:256-262`). Weapon mounts and the tractor may be + empties. Internal hardpoints have no anchor. Every hardpoint needs a non-empty mount id. +- **SI-B3. Frame.** Blender Ship Root has the nose toward -Y, starboard toward -X and Z up. Cell (x, y) is centred at + `origin - 2(x, y)`, schematic +x is starboard and +y is forward, so row 0 is the stern + (`tools/blender/aetheria_ships/hull_grid.py:3-20`). Package exports with `export_yup` (`__init__.py:791`), so the + glTF nose is +Z. Hardpoint `Rotation` gives the facing: 0 nose, 1 port, 2 aft, 3 starboard + (`hull_grid.py:141-145`). A footprint shape is stored as placed, and validation reads it at `Position` without + rotating it (`ShipAuthoring.cs:310-320`). +- **SI-B4. Longinus HullData** (decoded from `GameData/Aetheria.cc` with cultcache-py, `S\grid.py`). It is 6 x 17 + cells, symmetric, with 13 hardpoints. Visible: Th.L and Th.R (2x2, rows 0-1, aft), Th.CW and Th.CCW (1x2, row 14, + port and starboard), En.L and En.R (1x2, row 8), La.L and La.R (1x3, flanks, rows 5-7), Ra.L and Ra.R (1x2, + row 2). Internal: Reactor, ControlModule, and Sensors, which has a **null mount id**. Because of that null id, a + package copy of the shipped Longinus is refused until the Sensors hardpoint is given an id (`ship_cc.replace_layout` + raised "needs 7 typed fields and a stable mount ID"). The probe gave it the id `Sensors`. +- **SI-B5. Scale.** The add-on authors at 2 m per cell (`hull_grid.py:11`, "Settings.SchematicCellSize"; New Ship + sizes "this many 2 m cells"). The shipped `Longinus.fbx` body is 5.2 x 16.8 m for 6 x 17 cells, about 1 m per cell + (`S\fbx.py`, mesh `Cube.043`). Ruling `schematic-and-model-are-separate-spaces` already allows the two to differ. + The stand-in uses the add-on's 2 m. +- **SI-B6. Materials today.** `ShipModVisual` imports the GLB with glTFast and keeps its glTF materials. The only + material it adds is the schematic line material (`ShipModVisual.cs:55-64`). `Aetheria/Hull` and the UV0 livery mask + belong to `ships-hull-material`, which has a spec but has not landed. So a stand-in's look today is whatever its + glTF materials say. +- **SI-B7. `ship-authoring create --like`** copies a shipped hull's stats with an empty shape and no hardpoints + (`ShipAuthoringCommands.cs:57-75`, `HullLike` clears `Shape` and `Hardpoints`). The layout comes from Blender or + from `ship_cc.replace_layout`. +- **SI-B8. OpenSCAD over LevelDsl, decided from source.** The Geometry.Csg `LevelDsl` has box, cut box, oriented box, + cylinder_z, dome_cap_z and floret_arm, with no hull or free-axis cone (research, Internal fit), and it needs a + cargo build. OpenSCAD nightly with Manifold is installed, and brush-tree's `openscad-target` already rules + OpenSCAD-shaped text. LevelDsl was not probed. + +### The probe (Longinus) + +The stand-in source has three files (`S\probe\`): +- `standin.scad`: the vocabulary. Glyph per kind, kind colours, cell frame, part dispatch. +- `longinus.layout.scad`: generated from HullData by `gen_layout.py`, and never edited by hand. It holds the visible + mounts: id, kind, position, placed footprint and rotation. +- `longinus.standin.scad`: agent-authored, 25 lines. Four `hull()` segments make the body (stern block, flared + weapons section, spine, nose), plus a `deck()` height function. + +`build.py` renders each part separately with `openscad.com --backend Manifold --render all -D part="…"` (one process +at a time, about 22 runs). It then writes `ship.glb`: a flat-faceted mesh per part, UV0 as a top-down projection in +cell units, and nodes tagged `extras.aetheria.id`. It also writes `anchors.json`. + +- **SI-P1. Measurements** (`S\probe\build.py` output): + - The body has 104 triangles. 0 of its 60 vertices lie outside the hull cells, and the worst miss of the mirror + test is 0.0000 m (the body is symmetric). + - The GLB is 84 KB, with 28 nodes and 18 anchors. + - All 10 visible mounts project into their own footprint: weapons and radiators at the footprint centre, thrusters + on the footprint edge they face. + - The probe checked vertices, not edges, against the cells. Each segment is convex inside a rectangular run of + cells, which is why the vertex check suffices here; a stepped outline needs an edge check. +- **SI-P2. Package path, on Yggdrasil.** `ygg-verify` ran on a scratch clone at `2329782244d3`. That clone is + 6d10331c plus `standin-probe/`; its commits are throwaway and were never pushed to origin. Image + `mcr.microsoft.com/dotnet/sdk:10.0`, with pins `/pins/cultlib-git-45c2f40` and `/pins/cultlib-9c1b06cd` and the + `/lfs` catalog. Log: `S\ygg-standin-6.log`, GREEN. The steps were: + 1. `ship-authoring create … --like Longinus`. + 2. `ship_cc.replace_layout` with Longinus' shape and hardpoints, then `ship_cc.replace_visual` with the 18 anchors + (the add-on's own Python). + 3. `validate`, which reported "13 hardpoints, 18 anchors, 0 lines (records and model)", meaning + `ShipModCatalog.ReadPackage` judged the GLB. + 4. `compose`, which reported "Composed 1 mod ships", with no exclusions. + + Runs 1-5 were red on job plumbing: LFS pointer, CultLib pins, empty layout from `--like`, and the null Sensors id. +- **SI-P3. Not proven.** The Unity side was not run: `ShipModPreview.Smoke -assemble` and `ShipModPlaySmoke.Run` + (thrusters, weapons with muzzles, collider, map icon). The probe's GLB writer is scratch Python, not the add-on's + Package Ship. Blender was not in the loop, because no current Blender is on Starfire (only 2.77 and 2.78 under + `D:\Applications`). Glyph proportions were checked only in the proxy renders, never at the real combat camera. +- **SI-P4. What the renders show.** + - From above, the stand-in states where every gun, launcher, engine and radiator is, by colour. At 48 px the + shipped mesh reads only as a silhouette. + - The body reads as an unfinished blockout: slab facets, a cell grid, no surface detail. Nobody would take it for + the shipped hull. + - The grid is texture noise at 48 px. In engine, mips average it away at distance. + - The first radiator fins were edge-on from above and vanished. They became panels tilted 30° outward. Energy + barrels were thickened from 0.14 to 0.28 m for the same reason. + +### Visual grammar (proposal; the look is question `standin-visual-mark`) + +1. **Body.** A few convex `hull()` segments that follow the hull's outline and its concept (Concept Mill image or + description). Flat facets at `$fn = 8`. No bevels, panel lines, greebles or invented surface detail. One neutral + grey, carrying a dark line every 2 m cell on UV0, which is projected top-down in cell units. The grid is the + greybox mark, and because the cells are the schematic's, it also states size class. +2. **Mount glyphs by kind,** each in one saturated colour, on its footprint, facing its Rotation: + - energy: a puck and one long barrel; + - ballistic: a puck and two short thick barrels; + - launcher: a box with tube mouths on its facing face; + - thruster: a bell opening toward its facing, whose mouth disc is the `thruster-emitter` mesh; + - radiator: a panel tilted outward, which is itself the `radiator-mesh`. + + Internal hardpoints are not drawn, because the schematic UI owns them. +3. **Size.** Model metres are cells x 2 m (the add-on's rule). +4. **Faction.** None in the mesh. Once `ships-hull-material` lands, the body's UV0 is where the livery mask goes + (primary channel on the body). Until then the body is neutral grey. + +### Model (SKILL step 0b): rows for the persistent kinds this adds + +| Kind | Identity | Lifecycle | Authority | +|---|---|---|---| +| Stand-in source, `GameData/Mods//standin.scad` | The package directory, which equals `ShipAuthoring.Id`, names it. One per package, at a fixed name. Injective because package ids are. | Created by an agent pass for a hull with no operator art. Revised by agents. Deleted in the same change that lands operator art for that package. | The agent pass (Imagination for content, Hands for the cut) authors the body. The operator decides when it dies, by packaging her art. Forbidden writer: nothing hand-edits the layout. | +| Layout, generated | Derived from that package's HullData. | Regenerated on every build. Not committed, never edited. | HullData. Owner only under `standin-layout-follows-schematic` = follow-schematic. Under free-like-real-art this row disappears and the source holds mount placement. | +| Shared vocabulary, `tools/blender/aetheria_ships/standin/standin.scad` | Single file. | Revised by cuts; a revision re-renders every stand-in at its next build. | The ship add-on (Aetheria). | +| `ship.glb` | `ShipAuthoring.ModelAsset`: one slot per package. | While `standin.scad` exists, a build output of it. Afterwards, the output of the operator's Package. | Exactly one writer at a time. The slot holds one mesh, so two meshes can never both decide. | +| "This hull is a stand-in" | The presence of `standin.scad` in the package. **No new field.** | Ends when the file is deleted. | Derived. Build Stand-in refuses a package without the file, so it can never overwrite operator art. Any "which hulls are still stand-ins" view is a directory listing. | +| Anchors | `ShipAuthoring.Anchors`, ids = mount ids. | Rewritten by Package on each build, stand-in or art. Ids are stable across the swap. | Package Ship (`ship_cc.replace_visual`). The same anchor ids keep loadouts and saves valid across the swap. | + +Replacement when real art arrives: the operator packages her collection into the same package. Package writes +`ship.glb` and the anchors, and the same change deletes `standin.scad`. That is one act and one owner. +Open choice inside the cut, not an operator fork: whether Package deletes `standin.scad` itself or refuses until it is +deleted. Recommendation: Package deletes it when the bound collection was not built by Build Stand-in, and says so. + +`standin.scad` ships inside the mod directory, so it may reach player builds. That is harmless, and it doubles as an +example for modders. The alternative is `tools/standin/hulls/.scad`, which breaks "presence in the package is the +status". + +### Rationale + +- **Why not Tripo or "deliberate low poly".** The ruling's test is "never mistaken for final art". Every element of + the grammar is chosen to fail the polish test on purpose: the grid, flat facets, kind colours, and no surface + detail. Each element also carries information (scale, mount kind, facing), so the look is utility. +- **Why the layout is generated.** + - Readability: the mounts the player sees agree with the schematic UI. + - Upkeep: a schematic edit re-renders the stand-in with no agent pass. + - Opus's weak spot: spatial placement is the failure mode the prior art documents most (LL3M §5; CAD + valid-but-wrong), and generation leaves only the body to judgment. + + This is model geometry derived from cells, against `schematic-and-model-are-separate-spaces`, hence question + `standin-layout-follows-schematic`. +- **Why Blender for the target, not the probe's Python.** Package Ship already writes `ship.glb` and the anchors, + and runs validate. A second GLB writer would be a second authority over the same file. The cut imports OpenSCAD + part meshes into the bound collection with role tags, then calls Package. Brush-tree's `bake-only` and + `lowered-hull-through-package` point the same way. + +### Cut sequence (one line each; no spec admitted, because `standin-build` waits on both questions) + +1. `standin-build`. Add-on operator Build Stand-in (headless-capable): checks that `standin.scad` exists, generates + the layout from the bound `ship.cc`, runs `openscad.com` per part, imports the parts into the bound collection with + `aetheria.role`/`aetheria.id` tags, and calls Package Ship. It carries the vocabulary file and Longinus as its test + hull, gives the shipped Longinus Sensors hardpoint a mount id (SI-B4), and adds a `smoke_standin.py` beside + `smoke_package.py`. Verification: + - `ShipModPreview.Smoke -assemble` and `ShipModPlaySmoke.Run` on the package; + - the in-cells, mirror and in-footprint checks from SI-P1 (the last only under follow-schematic). + + Deploy owner: OpenSCAD in whatever image runs Blender smokes on Yggdrasil. Not established, because the Blender + smoke's host was not found. +2. `standin-package-retire`. Package Ship, on a package that still has `standin.scad`, deletes the file when the + collection is the operator's (model row above). It can fold into cut 1 if small. +3. `standin-role-hulls` (content). Stand-ins for the demo role hulls as their HullData land (follow-up + `demo-role-hulls`), and for the cast flyables until her art arrives. Each body is authored from the hull's + Concept Mill keeper or description (follow-up `hulls-through-concept-mill`). +4. After `ships-hull-material`: the stand-in mask, with primary channel = body, so faction liveries colour stand-ins. + A line in that spec's next revision, not a cut of its own. + +### Gaps + +- Combat camera distance and angle were not read, so the zoom panel is a proxy. +- The greybox dev-texture convention is general practice, not evidence in the Eyes file. +- Where a current Blender runs headless for agents is not established on Starfire. The Package smoke's host was + not checked. +- Settings.SchematicCellSize = 2 m is cited from `hull_grid.py`, not read from `Settings.asset`. +- Scope reading, not a ruling: stand-ins are for hulls without operator art (demo role hulls; cast flyables until + hers land). Shipped legacy hulls keep their meshes. From bfcde46c0f3a87d233a2edfdf3ed5d4791e0e0c2 Mon Sep 17 00:00:00 2001 From: Meta Vens Date: Sun, 18 Oct 2026 18:22:15 +0200 Subject: [PATCH 023/444] Delete GenerateBossLoadout, ShipLoadout and the boss fallback tests (does not build: ZoneGenerator and Program still call them until the next commit) Boss gear is never drawn (ruling boss-gear-is-a-loadout). GenerateShipLoadout gets its single body back. Co-Authored-By: Claude Sonnet 5.5 --- .../Scripts/ServerShared/LoadoutGenerator.cs | 29 +++-------------- tests/Aetheria.Shared.Tests/RunGoalTests.cs | 31 ------------------- tools/AetherDb/Program.cs | 7 +---- 3 files changed, 6 insertions(+), 61 deletions(-) diff --git a/Assets/Scripts/ServerShared/LoadoutGenerator.cs b/Assets/Scripts/ServerShared/LoadoutGenerator.cs index 34e1cd1c0..86b300931 100644 --- a/Assets/Scripts/ServerShared/LoadoutGenerator.cs +++ b/Assets/Scripts/ServerShared/LoadoutGenerator.cs @@ -48,13 +48,7 @@ public class LoadoutGenerator ItemManager.Log("Unable to generate ship loadout: no compatible hull found!"); return null; } - return ShipLoadout(hullProduct); - } - - // The ship a hull product becomes: outfit, pickup, consumables, packed. Callers choose the product; this is the one build. - public EntityPack ShipLoadout(FactionProductData product) - { - var hull = ItemManager.CreateInstance(product) as EquippableItem; + var hull = ItemManager.CreateInstance(hullProduct) as EquippableItem; if(hull==null) ItemManager.Log("WHAT???"); var entity = new Ship(ItemManager, null, hull, ItemManager.GameplaySettings.DefaultEntitySettings); @@ -191,8 +185,8 @@ public class LoadoutGenerator // The products this faction can be offered for a design kind (only gear needs a home), and whether manufacturer preference applies (it does // not once a required item has fallen back to any manufacturer). The one availability rule for every selection: a Boss - // hull is never on offer, so it is never station stock, ordinary traffic, a generated scenario fit or another faction's - // fallback boss, and only BossHullsOf reaches it. + // hull is never on offer, so it is never station stock, ordinary traffic or a generated scenario fit; a Boss hull + // reaches a ship only through its faction's boss Loadout (Loadouts.BossOf). private ((FactionProductData product, T design)[] available, bool preferManufacturers) AvailableProducts(Predicate filter, bool required) where T : CraftedItemData { var hulls = SoldHulls(ItemManager.ItemData); @@ -218,23 +212,10 @@ public class LoadoutGenerator return (available, preferManufacturers); } - // A faction's boss: the Boss hull it makes (BossHullsOf, dearest first), else the most expensive ordinary ship hull the - // faction can be offered, with the gap logged. Null only when no ship hull is on offer at all. - public EntityPack GenerateBossLoadout() - { - var made = BossHullsOf(ItemManager.ItemData, Faction); - if (made.Length > 0) return ShipLoadout(made[0].product); - ItemManager.Log($"{Faction?.Name ?? "This faction"} makes no boss hull; its boss flies the most expensive ship hull instead."); - - var (available, _) = AvailableProducts(hull => hull.HullType == HullType.Ship, required: true); - if (available.Length == 0) return null; - var priciest = available.OrderByDescending(entry => entry.design.Price).ThenBy(entry => entry.design.Name, StringComparer.Ordinal).First().design; - return GenerateShipLoadout(hull => hull == priciest); - } - // The one owner of which hull is a faction's boss: the Ship hulls flagged Boss among the designs of the products the // faction manufactures, dearest first then by name. The flag lives on the hull and the maker on the product, so a Boss - // hull several makers sell is a boss of each; nothing here reads a model, a prefab or a visual. + // hull several makers sell is a boss of each; nothing here reads a model, a prefab or a visual. Its readers are + // Loadouts.BossOf and AetherDb factions. public static (FactionProductData product, HullData hull)[] BossHullsOf(CultCache items, Faction faction) => faction == null ? Array.Empty<(FactionProductData, HullData)>() diff --git a/tests/Aetheria.Shared.Tests/RunGoalTests.cs b/tests/Aetheria.Shared.Tests/RunGoalTests.cs index 541ee64dd..460becc92 100644 --- a/tests/Aetheria.Shared.Tests/RunGoalTests.cs +++ b/tests/Aetheria.Shared.Tests/RunGoalTests.cs @@ -107,21 +107,6 @@ public sealed partial class RunStartTests Assert.Equal(new[] { "Boss A", "Boss B", "Boss C" }, LoadoutGenerator.BossHullsOf(_cache, antagonist).Select(entry => entry.hull.Name)); } - [Fact] - public void Boss_without_a_boss_hull_falls_back() - { - var galaxy = DemoGalaxy(1); - var priciest = PriciestSold(); - - var log = new List(); - var items = LoggingItems(log); - var boss = Assert.Single(BossZonePack(galaxy, items, galaxy.Exit).Entities, entity => entity.Boss); - Assert.Equal(priciest.Name, HullName(items, boss)); - Assert.Contains(log, line => line.Contains("Zhestokost") && line.Contains("makes no boss hull")); - Assert.Empty(LoadoutGenerator.BossHullsOf(_cache, Zhestokost(galaxy))); - Assert.Empty(LoadoutGenerator.BossHullsOf(_cache, null)); - } - [Fact] public void Another_factions_boss_hull_is_never_used() { @@ -354,22 +339,6 @@ public sealed partial class RunStartTests Assert.False(RunGoal.ExitOpen(zone)); } - // A boss that cannot be generated (no ship hull on offer at all) never marks the zone as having spawned one. - [Fact] - public void Exit_sealed_when_no_ship_hull_is_on_offer() - { - var galaxy = DemoGalaxy(1); - foreach (var product in _cache.GetAll().Where(product => _cache.Get(product.Design) is HullData { HullType: HullType.Ship }).ToArray()) - Assert.True(_cache.Remove(_cache.RefOf(product).Key)); - var log = new List(); - var items = LoggingItems(log); - var pack = BossZonePack(galaxy, items, galaxy.Exit, ambient: false); - Assert.False(pack.BossSpawned); - Assert.DoesNotContain(pack.Entities, entity => entity.Boss); - Assert.Contains(log, line => line.Contains("No boss could be generated for Zhestokost")); - Assert.False(RunGoal.ExitOpen(new Zone(items, _planetSettings, pack, galaxy.Exit, galaxy))); - } - // A boss killed before the save stays dead after it, and the gate stays open: the spawn fact travels with the zone. [Fact] public void Exit_open_after_save_of_a_slain_boss() diff --git a/tools/AetherDb/Program.cs b/tools/AetherDb/Program.cs index 583e811ad..5f2324471 100644 --- a/tools/AetherDb/Program.cs +++ b/tools/AetherDb/Program.cs @@ -806,18 +806,13 @@ public static class Program string outcome; try { - // Generation never offers a Boss hull, so one is built through its first product. - var bossProduct = hull.Boss && hull.HullType == HullType.Ship - ? db.Cache.GetAll().Where(p => db.Cache.Get(p.Design) == hull).OrderBy(p => p.Name, StringComparer.Ordinal).FirstOrDefault() - : null; var pack = hull.HullType switch { - HullType.Ship when hull.Boss => bossProduct == null ? null : generator.ShipLoadout(bossProduct), HullType.Ship => generator.GenerateShipLoadout(candidate => candidate == hull), HullType.Turret => generator.GenerateTurretLoadout(), _ => generator.GenerateStationLoadout() }; - outcome = pack != null ? "ok" : hull.Boss && hull.HullType == HullType.Ship ? "NO LOADOUT (boss hull with no product)" : "NO LOADOUT (nothing suitable found)"; + outcome = pack != null ? "ok" : "NO LOADOUT (nothing suitable found)"; if (pack == null) failures++; if (pack != null && log.Any(l => l.Contains("targeting system available", StringComparison.OrdinalIgnoreCase))) targetingGaps++; } From 5d624f9e6d1bdf212de1adc3e8845b05facefa62 Mon Sep 17 00:00:00 2001 From: Meta Vens Date: Sun, 18 Oct 2026 18:25:42 +0200 Subject: [PATCH 024/444] Loadouts.BossOf: a faction's boss flies the Loadout on its boss hull's product (builds not yet checked) BossOf keys on BossHullsOf(items, faction)[0] and Loadout.HullProduct, refuses typed (BossRefusal), reads no Random, Prefab or Visual. ZoneGenerator builds the boss through Loadouts.Materialize with ChooseProduct as the only generator member; Galaxy picks boss factions through BossOf. Loadout.cs vocabulary: loadout, not preset or variant. Co-Authored-By: Claude Sonnet 5.5 --- Assets/Scripts/ServerShared/Galaxy.cs | 4 +- Assets/Scripts/ServerShared/ItemData.cs | 3 +- Assets/Scripts/ServerShared/Loadout.cs | 65 +++++++++++++++----- Assets/Scripts/ServerShared/ZoneGenerator.cs | 23 +++++-- 4 files changed, 70 insertions(+), 25 deletions(-) diff --git a/Assets/Scripts/ServerShared/Galaxy.cs b/Assets/Scripts/ServerShared/Galaxy.cs index 6e28cf49e..86cd34125 100644 --- a/Assets/Scripts/ServerShared/Galaxy.cs +++ b/Assets/Scripts/ServerShared/Galaxy.cs @@ -288,9 +288,9 @@ public class Galaxy progressCallback?.Invoke("Placing Factions"); if (progressCallback != null) Thread.Sleep(500); // Inserting Delay to make it seem like it's doing more work lmao - // Choose some megas to have bosses placed: the factions that make a Boss hull + // Choose some megas to have bosses placed: the factions with a boss to fly, a Boss hull and its Loadout var bossMegas = Factions - .Where(m => LoadoutGenerator.BossHullsOf(_cache, m).Length > 0) + .Where(m => Loadouts.BossOf(_cache, m, out _) != null) .Take(bossCount) .ToArray(); diff --git a/Assets/Scripts/ServerShared/ItemData.cs b/Assets/Scripts/ServerShared/ItemData.cs index 431acc7ce..6d9e79ef9 100644 --- a/Assets/Scripts/ServerShared/ItemData.cs +++ b/Assets/Scripts/ServerShared/ItemData.cs @@ -556,7 +556,8 @@ public class HullData : EquippableItemData public CultRecordRef Visual; // Only bosses fly this hull. Which factions it is a boss for comes from its products' makers - // (LoadoutGenerator.BossHullsOf), so the flag names no faction and reads no model. + // (LoadoutGenerator.BossHullsOf), so the flag names no faction and reads no model. A boss's gear is its faction's + // fixed Loadout on this hull's product (Loadouts.BossOf), never generated. [Inspectable, JsonProperty("boss"), Key(33)] public bool Boss; diff --git a/Assets/Scripts/ServerShared/Loadout.cs b/Assets/Scripts/ServerShared/Loadout.cs index bf8a2ac47..c2252087f 100644 --- a/Assets/Scripts/ServerShared/Loadout.cs +++ b/Assets/Scripts/ServerShared/Loadout.cs @@ -6,11 +6,11 @@ using GameCult.Caching; using MessagePack; using CultMath; -// A ship preset (a variant): the hull's design, and per hull cell a design and its rotation. A slot names a design and -// may name a product of it; a design-only slot is open on purpose, and whoever materializes the preset chooses its -// product from the galaxy it is built in (ruling loadout-product-optional-context-chooses), which keeps a preset -// portable across galaxies. A preset that needs a brand names it. Authored catalog data: written in CultCache Studio or by the editor capture -// command, read at runtime. +// A Loadout: a hull together with item designs or products equipped to specific cells (ruling boss-gear-is-a-loadout). +// A slot names a design, and names a product only where the loadout demands one; a design-only slot's product is chosen +// by whoever materializes it from the galaxy it is built in (ruling loadout-product-optional-context-chooses), which +// keeps a loadout portable across galaxies. Authored catalog data: written in CultCache Studio or by the AetherDb and +// editor capture commands, read at runtime. [CultDocument("aetheria.loadout", "1"), MessagePackObject] public class Loadout { @@ -30,6 +30,8 @@ public class LoadoutSlot [Key(3)] public CultRecordRef Product; // unset: design-only, chosen at materialization } +public enum BossRefusal { None, NoBossHull, NoLoadout } + // The owner of loadouts: capture from a live ship, the only writer, and the only builder of an entity from a loadout. public static class Loadouts { @@ -66,30 +68,60 @@ public static class Loadouts private static CultRecordRef ProductRef(ItemManager itemManager, CraftedItemInstance unit) => itemManager.Brand(unit).Product is { } product ? itemManager.ItemData.RefOf(product) : default; - // A preset's key derives from its name, so capturing the same name again addresses the same record. + // A loadout's key derives from its name, so capturing the same name again addresses the same record. public static CultRecordKey KeyOf(string name) => new CultRecordKey("loadout:" + name); - // The only writer of presets. It opens its own short-lived cache over the catalog file, writable, and disposes it + // The one owner of what a faction's boss flies: the Loadout whose HullProduct is the product of the faction's first boss + // hull (LoadoutGenerator.BossHullsOf, dearest first), first by Name ordinal. Keyed on the boss hull's product through + // Loadout.HullProduct, never generated; reads no Prefab, Visual or Random. Null with the refusal when there is no boss + // hull or no Loadout for it. + public static Loadout BossOf(CultCache items, Faction faction, out BossRefusal refusal) + { + var hulls = LoadoutGenerator.BossHullsOf(items, faction); + if (hulls.Length == 0) + { + refusal = BossRefusal.NoBossHull; + return null; + } + + var key = items.RefOf(hulls[0].product).Key; + var loadout = items.GetAll() + .Where(candidate => candidate.HullProduct.IsSet() && candidate.HullProduct.Key.Equals(key)) + .OrderBy(candidate => candidate.Name, StringComparer.Ordinal) + .FirstOrDefault(); + refusal = loadout == null ? BossRefusal.NoLoadout : BossRefusal.None; + return loadout; + } + + // Names the field that is missing; echoes no input value and no record key. + public static string Describe(BossRefusal refusal) => refusal switch + { + BossRefusal.NoBossHull => "makes no ship hull with HullData.Boss set", + BossRefusal.NoLoadout => "no Loadout's HullProduct names its boss hull's product", + _ => "" + }; + + // The only writer of Loadout records. It opens its own short-lived cache over the catalog file, writable, and disposes it // after the one commit. No other cache is touched, so no catalog instance play holds (or has mutated) can reach the - // file; a cache that already has the catalog open sees the preset only once it reloads. - // Throws and writes nothing when the loadout names a mod design (hull or slot), when the file is missing (capture never creates a catalog), when a preset of this name + // file; a cache that already has the catalog open sees the loadout only once it reloads. + // Throws and writes nothing when the loadout names a mod design (hull or slot), when the file is missing (capture never creates a catalog), when a loadout of this name // exists under another key, or when one exists under KeyOf(Name) and replace is not set. The commit is conditional - // on the record at KeyOf(Name), so a preset changed on disk since this open is not clobbered: returns false then. + // on the record at KeyOf(Name), so a loadout changed on disk since this open is not clobbered: returns false then. public static bool Commit(string catalogPath, Loadout loadout, bool replace) { var modDesigns = new[] { loadout.Hull.Key }.Concat(loadout.Slots.Select(slot => slot.Design.Key)).Where(ShipModCatalog.IsModKey).Select(key => key.Value).Distinct().ToArray(); if (modDesigns.Length > 0) - throw new InvalidOperationException($"Preset '{loadout.Name}' names mod designs ({string.Join(", ", modDesigns)}); the shipped catalog never references a mod, so a mod ship cannot be captured as a preset."); + throw new InvalidOperationException($"Loadout '{loadout.Name}' names mod designs ({string.Join(", ", modDesigns)}); the shipped catalog never references a mod, so a mod ship cannot be captured as a loadout."); if (!File.Exists(catalogPath)) - throw new InvalidOperationException($"Catalog {catalogPath} does not exist; a preset capture never creates one."); + throw new InvalidOperationException($"Catalog {catalogPath} does not exist; a loadout commit never creates one."); using var cache = AetheriaStores.Open(catalogPath, catalogWritable: true); var key = KeyOf(loadout.Name); var namesake = cache.GetAll().FirstOrDefault(other => other.Name == loadout.Name && !cache.RefOf(other).Key.Equals(key)); if (namesake != null) - throw new InvalidOperationException($"Preset '{loadout.Name}' already exists under key {cache.RefOf(namesake).Key}; rename or remove it first."); + throw new InvalidOperationException($"Loadout '{loadout.Name}' already exists under key {cache.RefOf(namesake).Key}; rename or remove it first."); var existing = cache.Get(key); if (existing != null && !replace) - throw new InvalidOperationException($"Preset '{loadout.Name}' already exists; replace it explicitly."); + throw new InvalidOperationException($"Loadout '{loadout.Name}' already exists; replace it explicitly."); return cache.Commit(batch => { batch.Expect(key, existing); @@ -104,7 +136,8 @@ public static class Loadouts // WeaponGroupCount weapon groups, and every group index names a weapon slot. Every failure is listed; on any failure // nothing is returned and the zone and cache are unchanged, but a failed fit has already drawn from // itemManager.Random. How each design is built is Resolve's rule. A design-only slot is chosen by choose; a game - // spawner is to pass LoadoutGenerator.ChooseProduct, with no fallback to any manufacturer; RunStart does. The hull's type + // spawner is to pass LoadoutGenerator.ChooseProduct, with no fallback to any manufacturer; RunStart does, and so does the + // boss in ZoneGenerator, which is built here too from its faction's Loadout (Loadouts.BossOf). The hull's type // picks the entity: a ship hull builds a Ship, a turret hull an OrbitalEntity with no orbit, which stays where it is // put; a station hull is refused. The entity always gets exactly WeaponGroupCount groups; a loadout with fewer is // padded with empty groups. A failed materialization leaves any minted lots in the live ledger; they are not roots, @@ -119,7 +152,7 @@ public static class Loadouts var hullBuild = Resolve(itemManager, loadout.Hull, loadout.HullProduct, choose, "hull", failures, quality); var hullData = cache.Get(loadout.Hull); if (hullData is { HullType: HullType.Station }) - failures.Add($"hull: {hullData.Name} is a station hull; a preset builds a ship or a turret"); + failures.Add($"hull: {hullData.Name} is a station hull; a loadout builds a ship or a turret"); var slotBuilds = loadout.Slots.Select(slot => Resolve(itemManager, slot.Design, slot.Product, choose, Cell(slot), failures, quality)).ToArray(); var groups = loadout.WeaponGroups ?? new int[0][]; var groupCount = itemManager.GameplaySettings.WeaponGroupCount; diff --git a/Assets/Scripts/ServerShared/ZoneGenerator.cs b/Assets/Scripts/ServerShared/ZoneGenerator.cs index c0c8cf16f..80ec0d372 100644 --- a/Assets/Scripts/ServerShared/ZoneGenerator.cs +++ b/Assets/Scripts/ServerShared/ZoneGenerator.cs @@ -397,17 +397,28 @@ public static class ZoneGenerator } // A boss zone gets its faction's boss whether or not the zone is ambient: the boss is the run's goal, not - // wandering traffic. RunGoal reads the mark; nothing else decides whether a boss lives. + // wandering traffic. A boss flies its faction's fixed Loadout, never generated gear (ruling boss-gear-is-a-loadout); + // ChooseProduct only picks the brand of a design-only slot. RunGoal reads the mark; nothing else decides whether a + // boss lives. foreach (var bossFaction in galaxy.BossZones.Where(entry => entry.Value == galaxyZone).Select(entry => entry.Key)) { - var boss = GetLoadoutGenerator(bossFaction).GenerateBossLoadout(); - if (boss == null) + var loadout = Loadouts.BossOf(itemManager.ItemData, bossFaction, out var refusal); + if (loadout == null) { - itemManager.Log($"No boss could be generated for {bossFaction.Name}; its boss zone has no boss to kill!"); + itemManager.Log($"{bossFaction.Name} has no boss: it {Loadouts.Describe(refusal)}."); continue; } - boss.Boss = true; - pack.Entities.Add(boss); + var failures = new List(); + var boss = Loadouts.Materialize(itemManager, null, loadout, GetLoadoutGenerator(bossFaction).ChooseProduct, failures); + if (boss == null) + { + itemManager.Log($"{bossFaction.Name}'s boss Loadout {loadout.Name} cannot be built: {string.Join("; ", failures)}"); + continue; + } + boss.Faction = bossFaction; + var bossPack = EntitySerializer.Pack(boss); + bossPack.Boss = true; + pack.Entities.Add(bossPack); pack.BossSpawned = true; } From e71667aab107c69ef5d01bfb026668e626974751 Mon Sep 17 00:00:00 2001 From: Meta Vens Date: Sun, 18 Oct 2026 18:25:46 +0200 Subject: [PATCH 025/444] AetherDb loadout-commit; factions and loadout show the derived boss Loadout loadout-commit resolves every name exactly, test-materializes, then Loadouts.Commit. factions prints BossOf's Loadout (none / NO LOADOUT); loadout no longer generates a Boss hull. Co-Authored-By: Claude Sonnet 5.5 --- tools/AetherDb/LoadoutCommitCommand.cs | 124 +++++++++++++++++++++++++ tools/AetherDb/Program.cs | 22 +++-- 2 files changed, 140 insertions(+), 6 deletions(-) create mode 100644 tools/AetherDb/LoadoutCommitCommand.cs diff --git a/tools/AetherDb/LoadoutCommitCommand.cs b/tools/AetherDb/LoadoutCommitCommand.cs new file mode 100644 index 000000000..1237e834a --- /dev/null +++ b/tools/AetherDb/LoadoutCommitCommand.cs @@ -0,0 +1,124 @@ +/* This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at https://mozilla.org/MPL/2.0/. */ + +using System; +using System.Collections.Generic; +using System.IO; +using System.Linq; +using CultMath; +using GameCult.Caching; + +// loadout-commit [,,,[@]]... [--group ,...]... [--replace] +// (aetheria-release, cut boss-hull-derived; ruling boss-loadouts-agent-authored): writes one Loadout record into +// GameData/Aetheria.cc through Loadouts.Commit, the only writer. Every name is resolved exactly (one hull product, one design per +// slot, one product where a slot names one, a rotation by its ItemRotation name), then the loadout is built once on a throwaway +// ItemManager with Loadouts.Materialize, so a slot that does not fit or a weapon group that names a non-weapon is refused here. +// Any refusal happens before Commit: the catalog stays byte-identical. A refusal names the field and the slot, never the value +// it was given. +public static class LoadoutCommitCommand +{ + private const string Usage = "usage: loadout-commit [,,,[@]]... [--group ,...]... [--replace]"; + + public static int Run(string[] args, string root = null) + { + try + { + return Commit(args, root); + } + catch (Exception e) when (e is InvalidOperationException or ArgumentException or FormatException) + { + Console.WriteLine($"refused: {e.Message}"); + return 1; + } + } + + private static int Commit(string[] args, string root) + { + var replace = false; + var groups = new List(); + var positional = new List(); + for (var i = 0; i < args.Length; i++) + { + if (args[i] == "--replace") replace = true; + else if (args[i] == "--group") + { + if (++i >= args.Length) throw new ArgumentException("--group needs a list of slot indices"); + groups.Add(args[i].Split(',').Select(part => int.TryParse(part, out var index) ? index : throw new FormatException($"group {groups.Count}: an index is not a number")).ToArray()); + } + else positional.Add(args[i]); + } + + if (positional.Count < 2) + { + Console.WriteLine(Usage); + return 1; + } + + var db = AetherDb.Open(root: root); + var catalogPath = Path.Combine(db.Root, "GameData", "Aetheria.cc"); + var loadout = Build(db.Cache, positional[0], positional[1], positional.Skip(2).ToArray(), groups); + + var failures = new List(); + var settings = AuthoredSettings.Load(db.Root).Read("GameplaySettings"); + var itemManager = new ItemManager(db.Cache, new ProvenanceLedger(), settings, _ => { }); + var entity = Loadouts.Materialize(itemManager, null, loadout, design => ProductsOf(db.Cache, design).FirstOrDefault(), failures); + if (entity == null) throw new InvalidOperationException($"the loadout cannot be built: {string.Join("; ", failures)}"); + + var hull = db.Cache.Get(loadout.Hull); + var product = db.Cache.Get(loadout.HullProduct); + var slots = loadout.Slots.Count; + (db.Cache as IDisposable)?.Dispose(); + + if (!Loadouts.Commit(catalogPath, loadout, replace)) + throw new InvalidOperationException("the catalog changed since it was read; nothing was written"); + Console.WriteLine($"{loadout.Name}: committed, hull {hull.Name} ({product.Name}), {slots} slots"); + return 0; + } + + // Validation only: a design-only slot is built at its first product by Name, so the throwaway build proves the fit. + private static IEnumerable ProductsOf(CultCache cache, CraftedItemData design) + { + var key = cache.RefOf(design).Key; + return cache.GetAll().Where(product => product.Design.Key.Equals(key)).OrderBy(product => product.Name, StringComparer.Ordinal); + } + + private static Loadout Build(CultCache cache, string name, string hullProductName, string[] slotArgs, List groups) + { + if (string.IsNullOrWhiteSpace(name)) throw new ArgumentException("the loadout name is empty"); + var hullProduct = ExactlyOne(cache.GetAll().Where(product => product.Name == hullProductName).ToArray(), "hull product"); + var hull = cache.Get(hullProduct.Design) as HullData ?? throw new InvalidOperationException("hull product: its design is not a hull"); + var slots = new List(); + for (var i = 0; i < slotArgs.Length; i++) + { + var where = $"slot {i}"; + var parts = slotArgs[i].Split(',', 4); + if (parts.Length != 4 || !int.TryParse(parts[0], out var x) || !int.TryParse(parts[1], out var y)) + throw new FormatException($"{where}: expected ,,,[@]"); + if (!Enum.TryParse(parts[2], out var rotation) || !Enum.IsDefined(typeof(ItemRotation), rotation) || int.TryParse(parts[2], out _)) + throw new FormatException($"{where}: rotation is not an ItemRotation name"); + var at = parts[3].IndexOf('@'); + var designName = at < 0 ? parts[3] : parts[3][..at]; + var design = ExactlyOne(cache.GetAll().Where(d => d.Name == designName).ToArray(), $"{where} design"); + var slot = new LoadoutSlot { Position = new int2(x, y), Rotation = rotation, Design = cache.RefOf(design) }; + if (at >= 0) + { + var productName = parts[3][(at + 1)..]; + slot.Product = cache.RefOf(ExactlyOne(cache.GetAll().Where(p => p.Name == productName).ToArray(), $"{where} product")); + } + slots.Add(slot); + } + + return new Loadout + { + Name = name, + Hull = cache.RefOf(hull), + HullProduct = cache.RefOf(hullProduct), + Slots = slots, + WeaponGroups = groups.Count == 0 ? null : groups.ToArray() + }; + } + + private static T ExactlyOne(T[] found, string field) => + found.Length == 1 ? found[0] : throw new InvalidOperationException(found.Length == 0 ? $"{field}: no record has that name" : $"{field}: more than one record has that name"); +} diff --git a/tools/AetherDb/Program.cs b/tools/AetherDb/Program.cs index 5f2324471..baf782546 100644 --- a/tools/AetherDb/Program.cs +++ b/tools/AetherDb/Program.cs @@ -26,6 +26,7 @@ public static class Program case "station-fit": return StationFit(); case "hardpoint-fit": return HardpointFit(); case "loadout": return Loadout(args.Skip(1).ToArray()); + case "loadout-commit": return LoadoutCommitCommand.Run(args.Skip(1).ToArray()); case "save": return Save(); case "factions": return Factions(); case "dangling": return Dangling(args.Skip(1).ToArray()); @@ -63,7 +64,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], loadout-commit [,,,[@]]... [--group ,...]... [--replace], 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]"); return 1; } } @@ -695,18 +696,18 @@ public static class Program } // Each faction's generation-critical links and the size of its curated place list. An empty list is not broken: - // Galaxy.NamePlaces falls back to catalogue designations for a faction without one. The boss column is derived: - // the Boss hulls the faction makes (LoadoutGenerator.BossHullsOf), never a field on the faction. + // Galaxy.NamePlaces falls back to catalogue designations for a faction without one. The boss column is derived + // through Loadouts.BossOf, never a field on the faction, and is display only. private static int Factions() { var db = AetherDb.Open(); var products = db.Cache.GetAll().ToArray(); - Console.WriteLine($"{"faction",-26} {"short",-13} {"places",-22} {"boss hull",-14} {"influence",-9} {"doctrine",-10} products"); + Console.WriteLine($"{"faction",-26} {"short",-13} {"places",-22} {"boss",-14} {"influence",-9} {"doctrine",-10} products"); foreach (var faction in db.Cache.GetAll().OrderBy(f => f.Name)) { var key = db.Cache.RefOf(faction).Key; - var bosses = LoadoutGenerator.BossHullsOf(db.Cache, faction); - var boss = bosses.Length == 0 ? "none" : string.Join(',', bosses.Select(b => b.hull.Name)); + var bossLoadout = Loadouts.BossOf(db.Cache, faction, out var refusal); + var boss = bossLoadout?.Name ?? (refusal == BossRefusal.NoLoadout ? "NO LOADOUT" : "none"); Console.WriteLine($"{faction.Name,-26} {faction.ShortName,-13} {faction.Places.Length,-22} {boss,-14} {faction.InfluenceDistance,-9} {(faction.Doctrine == null ? "none" : faction.Doctrine.EngageOn.ToString()),-10} " + products.Count(p => p.Manufacturer.Key.Equals(key))); } @@ -804,6 +805,15 @@ public static class Program var generator = new LoadoutGenerator(ref random, itemManager, null, null, null, .5f); log.Clear(); string outcome; + if (hull.Boss && hull.HullType == HullType.Ship) + { + // A boss hull is never generated: it flies the Loadout authored on one of its products. + var productKeys = db.Cache.GetAll().Where(p => db.Cache.Get(p.Design) == hull).Select(p => db.Cache.RefOf(p).Key).ToArray(); + var authored = db.Cache.GetAll().Where(l => l.HullProduct.IsSet() && productKeys.Contains(l.HullProduct.Key)).Select(l => l.Name).OrderBy(n => n, StringComparer.Ordinal).ToArray(); + if (authored.Length == 0) failures++; + Console.WriteLine($"{hull.Name,-12} ({hull.HullType,-7}) {(authored.Length == 0 ? "NO LOADOUT (boss hull: author one with loadout-commit)" : "boss loadouts: " + string.Join(", ", authored))}"); + continue; + } try { var pack = hull.HullType switch From e385e7ce0eef1fcf140ec4de6f3b3a9c0079aa42 Mon Sep 17 00:00:00 2001 From: Meta Vens Date: Sun, 18 Oct 2026 18:32:37 +0200 Subject: [PATCH 026/444] Boss tests: fixed Loadout bosses, refusals, loadout-commit tests; "Lucent" is "Lucent Media" (builds not yet checked) RunGoalTests rewritten for Loadouts.BossOf (the three h1 failures were the fixture name "Lucent" against the shipped "Lucent Media"). LoadoutCommitCommandTests covers the command, its refusals and the maker's boss end to end. Co-Authored-By: Claude Sonnet 5.5 --- .../LoadoutCommitCommandTests.cs | 208 ++++++++++++++ tests/Aetheria.Shared.Tests/RunGoalTests.cs | 259 ++++++++++++++++-- tools/AetherDb/LoadoutCommitCommand.cs | 13 +- 3 files changed, 449 insertions(+), 31 deletions(-) create mode 100644 tests/Aetheria.Shared.Tests/LoadoutCommitCommandTests.cs diff --git a/tests/Aetheria.Shared.Tests/LoadoutCommitCommandTests.cs b/tests/Aetheria.Shared.Tests/LoadoutCommitCommandTests.cs new file mode 100644 index 000000000..5158b0983 --- /dev/null +++ b/tests/Aetheria.Shared.Tests/LoadoutCommitCommandTests.cs @@ -0,0 +1,208 @@ +using System; +using System.IO; +using System.Linq; +using System.Security.Cryptography; +using GameCult.Caching; +using GameCult.Caching.MessagePack; +using Xunit; + +// AetherDb's loadout-commit command (aetheria-release, cut boss-hull-derived; ruling boss-loadouts-agent-authored), run on a scratch +// copy of the shipped catalog. Commit writes one Loadout at Loadouts.KeyOf(name) with the records its names resolve to; every +// refusal leaves the catalog byte-identical; a Loadout it commits on a Boss hull's product is that maker's boss (Loadouts.BossOf). +public sealed class LoadoutCommitCommandTests : IDisposable +{ + private readonly string _root = Path.Combine(Path.GetTempPath(), "loadout-commit-" + Guid.NewGuid().ToString("N")); + private string CatalogPath => Path.Combine(_root, "GameData", "Aetheria.cc"); + + public LoadoutCommitCommandTests() + { + 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")); + } + + 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 static string Hash(string path) => Convert.ToHexString(SHA256.HashData(File.ReadAllBytes(path))); + + // A hull product and a weapon slot of the shipped catalog that fit together, found the production way: a generated fit, + // captured. Every name in them is unique in the catalog, so the command resolves it exactly. + private (string hullProduct, string slot, string openSlot, string designName, string productName) Fit() + { + using var cache = Open(); + var settings = AuthoredSettings.Load(_root).Read("GameplaySettings"); + var items = new ItemManager(cache, new ProvenanceLedger(), settings, _ => { }); + bool UniqueProduct(string name) => cache.GetAll().Count(item => item.Name == name) == 1; + bool UniqueDesign(string name) => cache.GetAll().Count(item => item.Name == name) == 1; + foreach (var hull in cache.GetAll().Where(h => h.HullType == HullType.Ship && h.Price > 0).OrderBy(h => h.Name, StringComparer.Ordinal)) + { + var random = new CultMath.Random(1); + var generator = new LoadoutGenerator(ref random, items, null, null, null, .5f); + var pack = generator.GenerateShipLoadout(candidate => candidate == hull); + if (pack == null) continue; + var fit = Loadouts.Capture(items, (Ship) EntitySerializer.Unpack(items, null, pack), "probe"); + var hullProduct = cache.Get(fit.HullProduct); + if (hullProduct == null || !UniqueProduct(hullProduct.Name)) continue; + foreach (var slot in fit.Slots.Where(s => s.Product.IsSet() && cache.Get(s.Design).Behaviors.Any(b => b is WeaponData))) + { + var design = cache.Get(slot.Design); + var product = cache.Get(slot.Product); + if (!UniqueDesign(design.Name) || !UniqueProduct(product.Name)) continue; + var cell = $"{slot.Position.x},{slot.Position.y},{slot.Rotation},{design.Name}"; + return (hullProduct.Name, $"{cell}@{product.Name}", cell, design.Name, product.Name); + } + } + throw new InvalidOperationException("the shipped catalog has no ship hull with a uniquely named weapon fit"); + } + + private int Run(out string output, params string[] args) + { + var writer = new StringWriter(); + var code = LoadoutCommitCommand.Run(args, _root, writer); + output = writer.ToString(); + return code; + } + + [Fact] + public void A_commit_writes_one_loadout_with_the_records_its_names_resolve_to() + { + var fit = Fit(); + Assert.Equal(0, Run(out var output, "Test boss", fit.hullProduct, fit.slot, "--group", "0")); + Assert.StartsWith("Test boss: committed, hull ", output); + + using var cache = Open(); + var loadout = Assert.Single(cache.GetAll()); + Assert.Equal(Loadouts.KeyOf("Test boss"), cache.RefOf(loadout).Key); + var product = cache.GetAll().Single(p => p.Name == fit.hullProduct); + Assert.Equal(cache.RefOf(product).Key, loadout.HullProduct.Key); + Assert.Equal(product.Design.Key, loadout.Hull.Key); + var slot = Assert.Single(loadout.Slots); + Assert.Equal(fit.designName, cache.Get(slot.Design).Name); + Assert.Equal(fit.productName, cache.Get(slot.Product).Name); + Assert.Equal(new[] { new[] { 0 } }, loadout.WeaponGroups); + } + + [Fact] + public void A_design_only_slot_commits_with_no_product_and_replace_overwrites() + { + var fit = Fit(); + Assert.Equal(0, Run(out _, "Test boss", fit.hullProduct, fit.slot)); + var written = Hash(CatalogPath); + Assert.Equal(1, Run(out var refused, "Test boss", fit.hullProduct, fit.openSlot)); + Assert.Contains("already exists", refused); + Assert.Equal(written, Hash(CatalogPath)); + + Assert.Equal(0, Run(out _, "Test boss", fit.hullProduct, fit.openSlot, "--replace")); + using var cache = Open(); + var loadout = Assert.Single(cache.GetAll()); + Assert.False(Assert.Single(loadout.Slots).Product.IsSet()); + Assert.True(loadout.WeaponGroups == null || loadout.WeaponGroups.Length == 0); + } + + // Every refusal happens before Loadouts.Commit, so the catalog is byte-identical; none echoes the name it was given. + [Theory] + [InlineData("unknown-hull-product")] + [InlineData("ambiguous-hull-product")] + [InlineData("unknown-design")] + [InlineData("ambiguous-design")] + [InlineData("unknown-slot-product")] + [InlineData("rotation-not-a-name")] + [InlineData("rotation-a-number")] + [InlineData("slot-does-not-fit")] + [InlineData("group-names-no-slot")] + [InlineData("mod-design")] + public void A_name_that_does_not_resolve_or_a_loadout_that_cannot_be_built_is_refused_and_nothing_is_written(string refusal) + { + var fit = Fit(); + var canary = "Canary9c1e"; + var options = CultDocumentMessagePackSerialization.OptionsFor(typeof(HullData).Assembly); + T Copy(T document) => MessagePack.MessagePackSerializer.Deserialize(MessagePack.MessagePackSerializer.Serialize(document, options), options); + using (var cache = Open()) + { + switch (refusal) + { + case "ambiguous-hull-product": + cache.Upsert(Copy(cache.GetAll().Single(p => p.Name == fit.hullProduct))); + break; + case "ambiguous-design": + cache.Upsert(Copy(cache.GetAll().Single(d => d.Name == fit.designName))); + break; + case "mod-design": + var mod = Copy(cache.GetAll().Single(d => d.Name == fit.designName)); + mod.Name = "Mod gadget"; + cache.Upsert(mod.GetType(), mod, new CultRecordKey("mod-ship:mod.gadget")); + break; + } + cache.FlushAsync().Wait(); + } + + var cell = fit.openSlot.Split(','); + var args = refusal switch + { + "unknown-hull-product" => new[] { "Test boss", canary, fit.slot }, + "unknown-design" => new[] { "Test boss", fit.hullProduct, $"{cell[0]},{cell[1]},{cell[2]},{canary}" }, + "unknown-slot-product" => new[] { "Test boss", fit.hullProduct, $"{fit.openSlot}@{canary}" }, + "rotation-not-a-name" => new[] { "Test boss", fit.hullProduct, $"{cell[0]},{cell[1]},{canary},{fit.designName}" }, + "rotation-a-number" => new[] { "Test boss", fit.hullProduct, $"{cell[0]},{cell[1]},1,{fit.designName}" }, + "slot-does-not-fit" => new[] { "Test boss", fit.hullProduct, $"999,999,{cell[2]},{fit.designName}" }, + "group-names-no-slot" => new[] { "Test boss", fit.hullProduct, fit.slot, "--group", "5" }, + "mod-design" => new[] { "Test boss", fit.hullProduct, $"{cell[0]},{cell[1]},{cell[2]},Mod gadget" }, + _ => new[] { "Test boss", fit.hullProduct, fit.slot } + }; + var before = Hash(CatalogPath); + + Assert.Equal(1, Run(out var output, args)); + + Assert.StartsWith("refused: ", output); + Assert.DoesNotContain(canary, output); + if (refusal == "mod-design") Assert.Contains("names mod designs", output); + Assert.Equal(before, Hash(CatalogPath)); + using var after = Open(); + Assert.Empty(after.GetAll()); + } + + // Ruling boss-loadouts-agent-authored end to end: a Boss hull and its maker's product in the catalog, a Loadout written by the + // command on that product, and the maker's boss is that Loadout. + [Fact] + public void A_loadout_committed_on_a_boss_hulls_product_is_its_makers_boss() + { + var fit = Fit(); + var options = CultDocumentMessagePackSerialization.OptionsFor(typeof(HullData).Assembly); + T Copy(T document) => MessagePack.MessagePackSerializer.Deserialize(MessagePack.MessagePackSerializer.Serialize(document, options), options); + using (var cache = Open()) + { + var likeProduct = cache.GetAll().Single(p => p.Name == fit.hullProduct); + var maker = cache.GetAll().OrderBy(f => f.Name, StringComparer.Ordinal).Last(); + var boss = Copy((HullData) cache.Get(likeProduct.Design)); + boss.Name = "Command Boss"; + boss.Boss = true; + var bossRef = cache.Upsert(boss); + var product = Copy(likeProduct); + product.Name = "Command Boss Mk I"; + product.Design = new CultRecordRef(bossRef.Key); + product.Manufacturer = cache.RefOf(maker); + cache.Upsert(product); + cache.FlushAsync().Wait(); + } + + Assert.Equal(0, Run(out _, "Command Boss loadout", "Command Boss Mk I", fit.slot)); + + using var reopened = Open(); + var makerAfter = reopened.GetAll().OrderBy(f => f.Name, StringComparer.Ordinal).Last(); + var found = Loadouts.BossOf(reopened, makerAfter, out var refusal); + Assert.Equal(BossRefusal.None, refusal); + Assert.Equal("Command Boss loadout", found.Name); + Assert.Equal("Command Boss", reopened.Get(found.Hull).Name); + foreach (var other in reopened.GetAll().Where(f => f != makerAfter)) + Assert.Null(Loadouts.BossOf(reopened, other, out _)); + } +} diff --git a/tests/Aetheria.Shared.Tests/RunGoalTests.cs b/tests/Aetheria.Shared.Tests/RunGoalTests.cs index 460becc92..ccaea485d 100644 --- a/tests/Aetheria.Shared.Tests/RunGoalTests.cs +++ b/tests/Aetheria.Shared.Tests/RunGoalTests.cs @@ -18,9 +18,9 @@ using Xunit; // and the run save. public sealed partial class RunStartTests { - private ZonePack BossZonePack(Galaxy galaxy, ItemManager items, GalaxyZone zone, bool ambient = true) + private ZonePack BossZonePack(Galaxy galaxy, ItemManager items, GalaxyZone zone, bool ambient = true, uint seed = 7) { - items.Random = new CultMath.Random(7); + items.Random = new CultMath.Random(seed); return ZoneGenerator.GenerateZone(items, _zoneSettings, _planetSettings, galaxy, zone, galaxy.IsPrelude, ambient); } @@ -72,6 +72,49 @@ public sealed partial class RunStartTests return _cache.Get(bossRef); } + // A boss's fixed Loadout (ruling boss-gear-is-a-loadout), by the production capture: a generated fit of the hull MakeBoss + // copied, captured as a Loadout (Loadouts.Capture), cut down to its weapons so it is smaller than any generated fit (the + // tractor pickup and the rest are not in it), moved onto the boss hull and the maker's product of it, and stored at + // Loadouts.KeyOf(name), the key Loadouts.Commit writes. openIf leaves the first slot whose design satisfies it design-only. + // The shipped catalog has no Boss hull, so this and MakeBoss are fixtures. + private Loadout MakeBossLoadout(HullData boss, Faction maker, string name, Func openIf = null) + { + var like = SoldShipHulls().OrderBy(hull => hull.Name, StringComparer.Ordinal).First(); + var random = new CultMath.Random(11); + var generator = new LoadoutGenerator(ref random, _items, null, null, null, .5f); + var ship = (Ship) EntitySerializer.Unpack(_items, null, generator.GenerateShipLoadout(hull => hull == like)); + var fit = Loadouts.Capture(_items, ship, name); + var armed = fit.Slots.Select((slot, index) => (slot, index)) + .Where(entry => _cache.Get(entry.slot.Design).Behaviors.Any(behavior => behavior is WeaponData)).ToArray(); + Assert.NotEmpty(armed); + Assert.True(armed.Length < fit.Slots.Count, "the generated fit needs gear besides weapons for this fixture to tell a Loadout from a fit"); + var renumbered = armed.Select((entry, number) => (entry.index, number)).ToDictionary(entry => entry.index, entry => entry.number); + fit.WeaponGroups = fit.WeaponGroups.Select(group => group.Where(renumbered.ContainsKey).Select(index => renumbered[index]).ToArray()).ToArray(); + fit.Slots = armed.Select(entry => entry.slot).ToList(); + if (openIf != null) fit.Slots.First(slot => openIf(_cache.Get(slot.Design))).Product = default; + fit.Hull = _cache.RefOf(boss); + fit.HullProduct = _cache.RefOf(_cache.GetAll().Single(product => _cache.Get(product.Design) == boss && _cache.Get(product.Manufacturer) == maker)); + _cache.Upsert(typeof(Loadout), fit, Loadouts.KeyOf(name)); + return fit; + } + + // The demo's antagonist gets a Boss hull and its Loadout: the shipped catalog has neither. + private HullData GiveAntagonistABoss(Galaxy galaxy) + { + var antagonist = Zhestokost(galaxy); + var boss = MakeBoss("Antagonist Boss", 100, antagonist); + MakeBossLoadout(boss, antagonist, "Antagonist Boss loadout"); + return boss; + } + + // What a ship carries, cell by cell, as a Loadout would name it: position, rotation, design, product. + private static string[] Cells(ItemManager items, EntityPack pack) + { + var captured = Loadouts.Capture(items, (Ship) EntitySerializer.Unpack(items, null, pack), "probe"); + return captured.Slots.OrderBy(slot => slot.Position.x).ThenBy(slot => slot.Position.y) + .Select(slot => $"{slot.Position.x},{slot.Position.y},{slot.Rotation},{items.ItemData.Get(slot.Design).Name},{(slot.Product.IsSet() ? items.ItemData.Get(slot.Product).Name : "-")}").ToArray(); + } + private static Faction FactionNamed(Galaxy galaxy, string name) => galaxy.Factions.Single(faction => faction.Name == name); private HullData PriciestSold() => @@ -83,8 +126,10 @@ public sealed partial class RunStartTests return new LoadoutGenerator(ref random, items, galaxy, galaxy.Exit, faction, .5f); } + // BossOf owns the boss. Zhestokost makes Boss A, B and C and a decoy ordinary hull. Loadouts sit on the decoy's product, + // on Boss B's and C's, and two on Boss A's: only Boss A's, first by name, is the boss's, whatever order the names fall in. [Fact] - public void Boss_flies_the_boss_hull_its_faction_makes() + public void Boss_flies_its_factions_boss_loadout() { var galaxy = DemoGalaxy(1); var antagonist = Zhestokost(galaxy); @@ -92,19 +137,141 @@ public sealed partial class RunStartTests var priciest = PriciestSold(); Assert.True(cheapest < priciest.Price, "the catalog needs two ship hulls of different price for this test to tell them apart"); - // The faction also makes the dearest ordinary hull, and three Boss hulls: B and A tie on price, C is cheaper. The boss - // is the dearest Boss hull, ties by name: not the dearest hull it makes, not the first product, not the cheapest. - _cache.Upsert(new FactionProductData { Name = "Decoy", Design = new CultRecordRef(_cache.RefOf(priciest).Key), Manufacturer = _cache.RefOf(antagonist) }); - MakeBoss("Boss B", cheapest + 10, antagonist); - MakeBoss("Boss C", cheapest, antagonist); - MakeBoss("Boss A", cheapest + 10, antagonist); + // B and A tie on price, C is cheaper: the boss is the dearest Boss hull, ties by name. + var decoy = new FactionProductData { Name = "Decoy", Design = new CultRecordRef(_cache.RefOf(priciest).Key), Manufacturer = _cache.RefOf(antagonist) }; + var decoyRef = _cache.Upsert(decoy); + var b = MakeBoss("Boss B", cheapest + 10, antagonist); + var c = MakeBoss("Boss C", cheapest, antagonist); + var a = MakeBoss("Boss A", cheapest + 10, antagonist); + MakeBossLoadout(b, antagonist, "Aaa loadout of Boss B"); + MakeBossLoadout(c, antagonist, "Aab loadout of Boss C"); + MakeBossLoadout(a, antagonist, "Zz second loadout of Boss A"); + MakeBossLoadout(a, antagonist, "Zz first loadout of Boss A"); + _cache.Upsert(typeof(Loadout), new Loadout + { + Name = "Aac loadout of the decoy", Hull = _cache.RefOf(priciest), + HullProduct = new CultRecordRef(decoyRef.Key) + }, Loadouts.KeyOf("Aac loadout of the decoy")); + + Assert.Equal(new[] { "Boss A", "Boss B", "Boss C" }, LoadoutGenerator.BossHullsOf(_cache, antagonist).Select(entry => entry.hull.Name)); + var loadout = Loadouts.BossOf(_cache, antagonist, out var refusal); + Assert.Equal(BossRefusal.None, refusal); + Assert.Equal("Zz first loadout of Boss A", loadout.Name); var log = new List(); var items = LoggingItems(log); var boss = Assert.Single(BossZonePack(galaxy, items, galaxy.Exit).Entities, entity => entity.Boss); Assert.Equal("Boss A", HullName(items, boss)); Assert.DoesNotContain(log, line => line.Contains("makes no boss hull")); - Assert.Equal(new[] { "Boss A", "Boss B", "Boss C" }, LoadoutGenerator.BossHullsOf(_cache, antagonist).Select(entry => entry.hull.Name)); + } + + // Ruling boss-gear-is-a-loadout: the boss carries its Loadout's cells and nothing else. The generated fit of the same hull + // carries more (a tractor pickup, consumables); none of it is on the boss. No generator draw decides it: another seed gives + // the same cells. A design-only slot takes its brand from the context's ChooseProduct (ruling + // loadout-product-optional-context-chooses); a pinned one keeps the product it names. + [Fact] + public void Boss_carries_exactly_its_loadouts_cells() + { + var galaxy = DemoGalaxy(1); + var antagonist = Zhestokost(galaxy); + var hull = MakeBoss("Boss A", 100, antagonist); + var loadout = MakeBossLoadout(hull, antagonist, "Boss A loadout", + openIf: design => GeneratorFor(galaxy, LoggingItems(new List()), antagonist, 1).ChooseProduct(design) != null); + Assert.True(loadout.Slots.Count > 1); + var open = Assert.Single(loadout.Slots, slot => !slot.Product.IsSet()); + + string Cell(LoadoutSlot slot, string brand) => $"{slot.Position.x},{slot.Position.y},{slot.Rotation},{_cache.Get(slot.Design).Name},{brand}"; + var pinned = loadout.Slots.Where(slot => slot != open).Select(slot => Cell(slot, _cache.Get(slot.Product).Name)).OrderBy(x => x, StringComparer.Ordinal).ToArray(); + var brands = _cache.GetAll().Where(product => product.Design.Key.Equals(open.Design.Key)).Select(product => product.Name).ToArray(); + + var items = LoggingItems(new List()); + var boss = Assert.Single(BossZonePack(galaxy, items, galaxy.Exit).Entities, entity => entity.Boss); + var cells = Cells(items, boss); + Assert.Equal(loadout.Slots.Count, cells.Length); + Assert.Equal(pinned, cells.Where(cell => !cell.StartsWith($"{open.Position.x},{open.Position.y},")).OrderBy(x => x, StringComparer.Ordinal)); + var openCell = Assert.Single(cells, cell => cell.StartsWith($"{open.Position.x},{open.Position.y},")); + Assert.StartsWith(Cell(open, ""), openCell); + Assert.Contains(openCell[(openCell.LastIndexOf(',') + 1)..], brands); + + var captured = Loadouts.Capture(items, (Ship) EntitySerializer.Unpack(items, null, boss), "probe"); + Assert.Equal(_cache.Get(loadout.HullProduct).Name, _cache.Get(captured.HullProduct).Name); + Assert.Equal(hull.Name, _cache.Get(captured.Hull).Name); + string[][] Groups(Loadout l) => l.WeaponGroups.Select(group => group.Select(i => $"{l.Slots[i].Position.x},{l.Slots[i].Position.y}").OrderBy(x => x, StringComparer.Ordinal).ToArray()).ToArray(); + Assert.Equal(Groups(loadout).Where(group => group.Length > 0), Groups(captured).Where(group => group.Length > 0)); + + // No generator draw decides the gear: another seed builds the same pinned cells. Only the open slot's brand is the context's pick. + var otherItems = LoggingItems(new List()); + var other = Assert.Single(BossZonePack(galaxy, otherItems, galaxy.Exit, seed: 8).Entities, entity => entity.Boss); + var otherCells = Cells(otherItems, other); + Assert.Equal(pinned, otherCells.Where(cell => !cell.StartsWith($"{open.Position.x},{open.Position.y},")).OrderBy(x => x, StringComparer.Ordinal)); + } + + // No generated fallback: the shipped catalog has no Boss hull, so Zhestokost has no boss, however many ordinary hulls it can be offered. + [Fact] + public void Boss_without_a_boss_hull_is_refused() + { + var galaxy = DemoGalaxy(1); + var antagonist = Zhestokost(galaxy); + Assert.Null(Loadouts.BossOf(_cache, antagonist, out var refusal)); + Assert.Equal(BossRefusal.NoBossHull, refusal); + + var log = new List(); + var items = LoggingItems(log); + var pack = BossZonePack(galaxy, items, galaxy.Exit); + Assert.False(pack.BossSpawned); + Assert.DoesNotContain(pack.Entities, entity => entity.Boss); + Assert.Contains(log, line => line.Contains("Zhestokost") && line.Contains("HullData.Boss")); + Assert.Contains(log, line => line.Contains("Galaxy.Exit") && line.Contains("exit-without-boss")); + Assert.DoesNotContain(log, line => line.Contains("makes no boss hull")); + Assert.Empty(LoadoutGenerator.BossHullsOf(_cache, null)); + } + + // A Boss hull whose product has no Loadout is refused typed at the derivation. The refusal names the missing field and + // echoes no input: the canary hull name, its product's name and the record keys reach no log line. + [Fact] + public void A_boss_hull_without_a_loadout_is_refused() + { + var galaxy = DemoGalaxy(1); + var antagonist = Zhestokost(galaxy); + var hull = MakeBoss("Canary7f3a hull", 100, antagonist); + var product = _cache.GetAll().Single(p => _cache.Get(p.Design) == hull); + // A Loadout on another maker's boss product is not this boss's. + var lucent = FactionNamed(galaxy, "Lucent Media"); + MakeBossLoadout(MakeBoss("Other boss", 50, lucent), lucent, "Other boss loadout"); + + Assert.Null(Loadouts.BossOf(_cache, antagonist, out var refusal)); + Assert.Equal(BossRefusal.NoLoadout, refusal); + var described = Loadouts.Describe(refusal); + Assert.Contains("HullProduct", described); + + var log = new List(); + var items = LoggingItems(log); + var pack = BossZonePack(galaxy, items, galaxy.Exit); + Assert.False(pack.BossSpawned); + Assert.DoesNotContain(pack.Entities, entity => entity.Boss); + Assert.Contains(log, line => line.Contains("Zhestokost") && line.Contains("HullProduct")); + foreach (var canary in new[] { "Canary7f3a", product.Name, _cache.RefOf(product).Key.Value, _cache.RefOf(hull).Key.Value }) + { + Assert.DoesNotContain(log, line => line.Contains(canary)); + Assert.DoesNotContain(canary, described); + } + } + + // The key is the product, not the hull: a Boss hull both makers sell, with a Loadout on Lucent Media's product only. + [Fact] + public void A_loadout_on_another_makers_product_is_not_this_bosses() + { + var galaxy = DemoGalaxy(1); + var antagonist = Zhestokost(galaxy); + var lucent = FactionNamed(galaxy, "Lucent Media"); + var shared = MakeBoss("Shared Boss", 100, antagonist, lucent); + var loadout = MakeBossLoadout(shared, lucent, "Aaa Lucent only"); + + Assert.Equal(loadout.Name, Loadouts.BossOf(_cache, lucent, out var lucentRefusal).Name); + Assert.Equal(BossRefusal.None, lucentRefusal); + Assert.Null(Loadouts.BossOf(_cache, antagonist, out var refusal)); + Assert.Equal(BossRefusal.NoLoadout, refusal); + Assert.False(BossZonePack(galaxy, LoggingItems(new List()), galaxy.Exit).BossSpawned); } [Fact] @@ -112,18 +279,22 @@ public sealed partial class RunStartTests { var galaxy = DemoGalaxy(1); var antagonist = Zhestokost(galaxy); - var lucent = FactionNamed(galaxy, "Lucent"); - var expected = PriciestSold().Name; - // Dearer than every ordinary hull, and Lucent's alone: Zhestokost neither derives it nor falls back to it. + var lucent = FactionNamed(galaxy, "Lucent Media"); + // Dearer than every ordinary hull, and Lucent's alone, with its own Loadout: Zhestokost neither derives it nor falls back to it. var luxury = MakeBoss("Lucent Flagship", PriciestSold().Price + 1000, lucent); + MakeBossLoadout(luxury, lucent, "Lucent Flagship loadout"); var log = new List(); var items = LoggingItems(log); - var boss = Assert.Single(BossZonePack(galaxy, items, galaxy.Exit).Entities, entity => entity.Boss); - Assert.Equal(expected, HullName(items, boss)); - Assert.Contains(log, line => line.Contains("Zhestokost") && line.Contains("makes no boss hull")); + var pack = BossZonePack(galaxy, items, galaxy.Exit); + Assert.False(pack.BossSpawned); + Assert.DoesNotContain(pack.Entities, entity => entity.Boss); + Assert.Contains(log, line => line.Contains("Zhestokost") && line.Contains("HullData.Boss")); + Assert.Null(Loadouts.BossOf(_cache, antagonist, out var refusal)); + Assert.Equal(BossRefusal.NoBossHull, refusal); Assert.Empty(LoadoutGenerator.BossHullsOf(_cache, antagonist)); Assert.Same(luxury, Assert.Single(LoadoutGenerator.BossHullsOf(_cache, lucent)).hull); + Assert.Equal("Lucent Flagship loadout", Loadouts.BossOf(_cache, lucent, out _).Name); } [Fact] @@ -131,25 +302,30 @@ public sealed partial class RunStartTests { var galaxy = DemoGalaxy(1); var antagonist = Zhestokost(galaxy); - var lucent = FactionNamed(galaxy, "Lucent"); + var lucent = FactionNamed(galaxy, "Lucent Media"); var shared = MakeBoss("Shared Boss", PriciestSold().Price - 1, antagonist, lucent); // A Boss station hull the antagonist also makes is no boss: a boss is a ship. MakeBoss(HullType.Station, "Boss Station", PriciestSold().Price + 1, antagonist); + MakeBossLoadout(shared, lucent, "Aaa loadout of Lucent"); + MakeBossLoadout(shared, antagonist, "Zzz loadout of Zhestokost"); Assert.Same(shared, Assert.Single(LoadoutGenerator.BossHullsOf(_cache, antagonist)).hull); Assert.Same(shared, Assert.Single(LoadoutGenerator.BossHullsOf(_cache, lucent)).hull); + Assert.Equal("Zzz loadout of Zhestokost", Loadouts.BossOf(_cache, antagonist, out _).Name); + Assert.Equal("Aaa loadout of Lucent", Loadouts.BossOf(_cache, lucent, out _).Name); var items = LoggingItems(new List()); var boss = Assert.Single(BossZonePack(galaxy, items, galaxy.Exit).Entities, entity => entity.Boss); Assert.Equal("Shared Boss", HullName(items, boss)); } - // Ruling hull-programmer-art-stopgap: a boss hull may be an agent's stopgap with no authored model. + // Ruling hull-programmer-art-reads-as-stand-in: a boss hull may be an agent's stopgap with no authored model. [Fact] public void A_boss_hull_needs_no_authored_model() { var galaxy = DemoGalaxy(1); var antagonist = Zhestokost(galaxy); var bare = MakeBoss("Bare Boss", 1, antagonist); + MakeBossLoadout(bare, antagonist, "Bare Boss loadout"); Assert.Null(bare.Prefab); Assert.False(bare.Visual.IsSet()); @@ -166,7 +342,7 @@ public sealed partial class RunStartTests { var galaxy = DemoGalaxy(1); var antagonist = Zhestokost(galaxy); - var lucent = FactionNamed(galaxy, "Lucent"); + var lucent = FactionNamed(galaxy, "Lucent Media"); var bossShip = MakeBoss("Traffic Boss", 1, antagonist, lucent); var bossStation = MakeBoss(HullType.Station, "Stock Boss", 1, antagonist, lucent); var stranger = new Faction { Name = "Stranger" }; @@ -197,22 +373,29 @@ public sealed partial class RunStartTests Assert.Equal(20 * 4 * 3, drawn); } + // PlaceFactionsMain picks through BossOf: a faction with a Boss hull and no Loadout places nothing, and the faction that + // also has the Loadout is the one entry, though another faction sorts first. [Fact] - public void Main_galaxy_bosses_are_factions_that_make_boss_hulls() + public void Main_galaxy_bosses_are_factions_with_a_boss_loadout() { Assert.Empty(RunStart.Generate(new MainGalaxy(), Inputs(() => 1)).BossZones); - var maker = _cache.GetAll().OrderBy(faction => faction.Name, StringComparer.Ordinal).First(); - MakeBoss("Main Boss", 100, maker); + var makers = _cache.GetAll().OrderBy(faction => faction.Name, StringComparer.Ordinal).Take(2).ToArray(); + MakeBoss("Unarmed Boss", 100, makers[0]); + var armed = MakeBoss("Main Boss", 100, makers[1]); + Assert.Empty(RunStart.Generate(new MainGalaxy(), Inputs(() => 1)).BossZones); + + MakeBossLoadout(armed, makers[1], "Main Boss loadout"); var galaxy = RunStart.Generate(new MainGalaxy(), Inputs(() => 1)); var bossFaction = Assert.Single(galaxy.BossZones.Keys); - Assert.Equal(maker.Name, bossFaction.Name); - Assert.NotEmpty(LoadoutGenerator.BossHullsOf(_cache, bossFaction)); + Assert.Equal(makers[1].Name, bossFaction.Name); + Assert.NotNull(Loadouts.BossOf(_cache, bossFaction, out _)); } [Fact] public void Boss_spawns_once_in_boss_zone() { + var antagonistsBoss = GiveAntagonistABoss(DemoGalaxy(DemoSeeds.First())); foreach (var seed in DemoSeeds.Take(3)) { var galaxy = DemoGalaxy(seed); @@ -225,6 +408,7 @@ public sealed partial class RunStartTests var boss = Assert.Single(bosses); Assert.IsType(boss); Assert.Same(antagonist, _cache.Get(boss.Faction)); + Assert.Equal(antagonistsBoss.Name, HullName(_items, boss)); // Generation is repeatable per zone: the same zone yields the same boss hull. var again = Assert.Single(BossZonePack(galaxy, _items, zone).Entities, entity => entity.Boss); Assert.Equal(HullName(_items, boss), HullName(_items, again)); @@ -237,6 +421,7 @@ public sealed partial class RunStartTests public void Boss_spawns_in_a_zone_that_is_not_ambient() { var galaxy = DemoGalaxy(1); + GiveAntagonistABoss(galaxy); var pack = BossZonePack(galaxy, _items, galaxy.Exit, ambient: false); Assert.Single(pack.Entities, entity => entity.Boss); Assert.DoesNotContain(pack.Entities, entity => !entity.Boss && entity is ShipPack); @@ -246,6 +431,7 @@ public sealed partial class RunStartTests public void Exit_sealed_until_boss_dies() { var galaxy = DemoGalaxy(1); + GiveAntagonistABoss(galaxy); var exit = ZoneOf(galaxy, galaxy.Exit); var boss = Assert.Single(exit.Entities, entity => entity.IsBoss); Assert.True(RunGoal.BossAlive(exit)); @@ -266,6 +452,7 @@ public sealed partial class RunStartTests [Fact] public void Boss_mark_survives_continue() { + GiveAntagonistABoss(DemoGalaxy(1)); var (galaxy, arena, staged, failures) = Launch(new DemoTerminus(), Inputs(() => 1)); Assert.True(failures.Count == 0, string.Join("; ", failures)); galaxy.Entrance.Contents = arena; @@ -327,7 +514,7 @@ public sealed partial class RunStartTests // The shipped catalog has no Boss hull, so the main galaxy places no boss: give a faction one and a boss zone that is // not the exit, as the generator would for a faction that makes a Boss hull. var faction = galaxy.Factions.First(); - MakeBoss("Gate Boss", 100, faction); + MakeBossLoadout(MakeBoss("Gate Boss", 100, faction), faction, "Gate Boss loadout"); var bossZone = galaxy.Zones.First(zone => zone != galaxy.Exit); galaxy.BossZones[faction] = bossZone; var zone = ZoneOf(galaxy, bossZone); @@ -339,11 +526,33 @@ public sealed partial class RunStartTests Assert.False(RunGoal.ExitOpen(zone)); } + // A boss Loadout whose slot does not fit gives no boss: the log names the Loadout and the fit failure, and the gate stays sealed. + [Fact] + public void An_unbuildable_boss_loadout_spawns_no_boss() + { + var galaxy = DemoGalaxy(1); + var antagonist = Zhestokost(galaxy); + var hull = MakeBoss("Boss A", 100, antagonist); + var loadout = MakeBossLoadout(hull, antagonist, "Unfittable loadout"); + loadout.Slots[0].Position = new int2(999, 999); + _cache.Upsert(typeof(Loadout), loadout, Loadouts.KeyOf(loadout.Name)); + Assert.Equal("Unfittable loadout", Loadouts.BossOf(_cache, antagonist, out _).Name); + + 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("Unfittable loadout") && line.Contains("does not fit")); + 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() { var galaxy = DemoGalaxy(1); + GiveAntagonistABoss(galaxy); var exit = ZoneOf(galaxy, galaxy.Exit); Assert.Single(exit.Entities, entity => entity.IsBoss).HeatstrokeDeath.OnNext(Unit.Default); var repacked = exit.PackZone(); diff --git a/tools/AetherDb/LoadoutCommitCommand.cs b/tools/AetherDb/LoadoutCommitCommand.cs index 1237e834a..7343dc9d8 100644 --- a/tools/AetherDb/LoadoutCommitCommand.cs +++ b/tools/AetherDb/LoadoutCommitCommand.cs @@ -20,20 +20,21 @@ public static class LoadoutCommitCommand { private const string Usage = "usage: loadout-commit [,,,[@]]... [--group ,...]... [--replace]"; - public static int Run(string[] args, string root = null) + public static int Run(string[] args, string root = null, TextWriter output = null) { + output ??= Console.Out; try { - return Commit(args, root); + return Commit(args, root, output); } catch (Exception e) when (e is InvalidOperationException or ArgumentException or FormatException) { - Console.WriteLine($"refused: {e.Message}"); + output.WriteLine($"refused: {e.Message}"); return 1; } } - private static int Commit(string[] args, string root) + private static int Commit(string[] args, string root, TextWriter output) { var replace = false; var groups = new List(); @@ -51,7 +52,7 @@ public static class LoadoutCommitCommand if (positional.Count < 2) { - Console.WriteLine(Usage); + output.WriteLine(Usage); return 1; } @@ -72,7 +73,7 @@ public static class LoadoutCommitCommand if (!Loadouts.Commit(catalogPath, loadout, replace)) throw new InvalidOperationException("the catalog changed since it was read; nothing was written"); - Console.WriteLine($"{loadout.Name}: committed, hull {hull.Name} ({product.Name}), {slots} slots"); + output.WriteLine($"{loadout.Name}: committed, hull {hull.Name} ({product.Name}), {slots} slots"); return 0; } From fc992f5abe6943a11cfb909dd4fba104aee097ff Mon Sep 17 00:00:00 2001 From: Meta Vens Date: Sun, 18 Oct 2026 18:40:09 +0200 Subject: [PATCH 027/444] Boss tests store Loadouts through the cache batch at Loadouts.KeyOf (the cache has no keyed Upsert) Co-Authored-By: Claude Sonnet 5.5 --- .../LoadoutCommitCommandTests.cs | 2 +- tests/Aetheria.Shared.Tests/RunGoalTests.cs | 12 ++++++++---- 2 files changed, 9 insertions(+), 5 deletions(-) diff --git a/tests/Aetheria.Shared.Tests/LoadoutCommitCommandTests.cs b/tests/Aetheria.Shared.Tests/LoadoutCommitCommandTests.cs index 5158b0983..45f64e1e0 100644 --- a/tests/Aetheria.Shared.Tests/LoadoutCommitCommandTests.cs +++ b/tests/Aetheria.Shared.Tests/LoadoutCommitCommandTests.cs @@ -139,7 +139,7 @@ public sealed class LoadoutCommitCommandTests : IDisposable case "mod-design": var mod = Copy(cache.GetAll().Single(d => d.Name == fit.designName)); mod.Name = "Mod gadget"; - cache.Upsert(mod.GetType(), mod, new CultRecordKey("mod-ship:mod.gadget")); + Assert.True(cache.Commit(batch => batch.Upsert(mod.GetType(), mod, new CultRecordKey("mod-ship:mod.gadget")))); break; } cache.FlushAsync().Wait(); diff --git a/tests/Aetheria.Shared.Tests/RunGoalTests.cs b/tests/Aetheria.Shared.Tests/RunGoalTests.cs index ccaea485d..d60261a56 100644 --- a/tests/Aetheria.Shared.Tests/RunGoalTests.cs +++ b/tests/Aetheria.Shared.Tests/RunGoalTests.cs @@ -94,10 +94,14 @@ public sealed partial class RunStartTests if (openIf != null) fit.Slots.First(slot => openIf(_cache.Get(slot.Design))).Product = default; fit.Hull = _cache.RefOf(boss); fit.HullProduct = _cache.RefOf(_cache.GetAll().Single(product => _cache.Get(product.Design) == boss && _cache.Get(product.Manufacturer) == maker)); - _cache.Upsert(typeof(Loadout), fit, Loadouts.KeyOf(name)); + StoreLoadout(fit); return fit; } + // A Loadout record at Loadouts.KeyOf(Name), the key Loadouts.Commit writes it under. + private void StoreLoadout(Loadout loadout) => + Assert.True(_cache.Commit(batch => batch.Upsert(typeof(Loadout), loadout, Loadouts.KeyOf(loadout.Name)))); + // The demo's antagonist gets a Boss hull and its Loadout: the shipped catalog has neither. private HullData GiveAntagonistABoss(Galaxy galaxy) { @@ -147,11 +151,11 @@ public sealed partial class RunStartTests MakeBossLoadout(c, antagonist, "Aab loadout of Boss C"); MakeBossLoadout(a, antagonist, "Zz second loadout of Boss A"); MakeBossLoadout(a, antagonist, "Zz first loadout of Boss A"); - _cache.Upsert(typeof(Loadout), new Loadout + StoreLoadout(new Loadout { Name = "Aac loadout of the decoy", Hull = _cache.RefOf(priciest), HullProduct = new CultRecordRef(decoyRef.Key) - }, Loadouts.KeyOf("Aac loadout of the decoy")); + }); Assert.Equal(new[] { "Boss A", "Boss B", "Boss C" }, LoadoutGenerator.BossHullsOf(_cache, antagonist).Select(entry => entry.hull.Name)); var loadout = Loadouts.BossOf(_cache, antagonist, out var refusal); @@ -535,7 +539,7 @@ public sealed partial class RunStartTests var hull = MakeBoss("Boss A", 100, antagonist); var loadout = MakeBossLoadout(hull, antagonist, "Unfittable loadout"); loadout.Slots[0].Position = new int2(999, 999); - _cache.Upsert(typeof(Loadout), loadout, Loadouts.KeyOf(loadout.Name)); + StoreLoadout(loadout); Assert.Equal("Unfittable loadout", Loadouts.BossOf(_cache, antagonist, out _).Name); var log = new List(); From de3ecd9bb03e92aa38923415551a19c3a1527b52 Mon Sep 17 00:00:00 2001 From: Meta Vens Date: Sun, 18 Oct 2026 18:42:44 +0200 Subject: [PATCH 028/444] 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 029/444] 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 6463e79317098081dea695554716d370c319cdb7 Mon Sep 17 00:00:00 2001 From: Meta Vens Date: Sun, 18 Oct 2026 18:47:21 +0200 Subject: [PATCH 030/444] Boss fixtures: ordinary hulls only as the copied "like" hull; refusal tests copy a design by its runtime type Co-Authored-By: Claude Sonnet 5.5 --- .../Aetheria.Shared.Tests/LoadoutCommitCommandTests.cs | 10 ++++++---- tests/Aetheria.Shared.Tests/RunGoalTests.cs | 2 +- 2 files changed, 7 insertions(+), 5 deletions(-) diff --git a/tests/Aetheria.Shared.Tests/LoadoutCommitCommandTests.cs b/tests/Aetheria.Shared.Tests/LoadoutCommitCommandTests.cs index 45f64e1e0..0c577d80f 100644 --- a/tests/Aetheria.Shared.Tests/LoadoutCommitCommandTests.cs +++ b/tests/Aetheria.Shared.Tests/LoadoutCommitCommandTests.cs @@ -125,19 +125,21 @@ public sealed class LoadoutCommitCommandTests : IDisposable var fit = Fit(); var canary = "Canary9c1e"; var options = CultDocumentMessagePackSerialization.OptionsFor(typeof(HullData).Assembly); - T Copy(T document) => MessagePack.MessagePackSerializer.Deserialize(MessagePack.MessagePackSerializer.Serialize(document, options), options); + object Copy(object document) => MessagePack.MessagePackSerializer.Deserialize(document.GetType(), MessagePack.MessagePackSerializer.Serialize(document.GetType(), document, options), options); using (var cache = Open()) { + // Under a key of its own, so the copy is a second record of the same name, not the first overwritten. + void Add(object document) => Assert.True(cache.Commit(batch => batch.Upsert(document.GetType(), document, new CultRecordKey("duplicate:" + Guid.NewGuid().ToString("N"))))); switch (refusal) { case "ambiguous-hull-product": - cache.Upsert(Copy(cache.GetAll().Single(p => p.Name == fit.hullProduct))); + Add(Copy(cache.GetAll().Single(p => p.Name == fit.hullProduct))); break; case "ambiguous-design": - cache.Upsert(Copy(cache.GetAll().Single(d => d.Name == fit.designName))); + Add(Copy(cache.GetAll().Single(d => d.Name == fit.designName))); break; case "mod-design": - var mod = Copy(cache.GetAll().Single(d => d.Name == fit.designName)); + var mod = (EquippableItemData) Copy(cache.GetAll().Single(d => d.Name == fit.designName)); mod.Name = "Mod gadget"; Assert.True(cache.Commit(batch => batch.Upsert(mod.GetType(), mod, new CultRecordKey("mod-ship:mod.gadget")))); break; diff --git a/tests/Aetheria.Shared.Tests/RunGoalTests.cs b/tests/Aetheria.Shared.Tests/RunGoalTests.cs index d60261a56..5eb9af5d3 100644 --- a/tests/Aetheria.Shared.Tests/RunGoalTests.cs +++ b/tests/Aetheria.Shared.Tests/RunGoalTests.cs @@ -37,7 +37,7 @@ public sealed partial class RunStartTests private HullData[] SoldShipHulls() => _cache.GetAll() .Where(product => product.Manufacturer.IsSet()) .Select(product => _cache.Get(product.Design) as HullData) - .Where(hull => hull != null && hull.HullType == HullType.Ship && hull.Price > 0) + .Where(hull => hull != null && hull.HullType == HullType.Ship && hull.Price > 0 && !hull.Boss) .Distinct().ToArray(); // A Boss hull a faction makes, by the production path: a copy of a sold ship hull (so it builds into a real ship) flagged From a0a650db7a58e4afd3ff6ad3aaabbaaac89eff1f Mon Sep 17 00:00:00 2001 From: Meta Vens Date: Sun, 18 Oct 2026 18:56:36 +0200 Subject: [PATCH 031/444] Boss_carries_exactly: the open slot's brand is one the antagonist's outfitter can choose, not any product of the design Co-Authored-By: Claude Sonnet 5.5 --- tests/Aetheria.Shared.Tests/RunGoalTests.cs | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/tests/Aetheria.Shared.Tests/RunGoalTests.cs b/tests/Aetheria.Shared.Tests/RunGoalTests.cs index 5eb9af5d3..c4f7eaf2e 100644 --- a/tests/Aetheria.Shared.Tests/RunGoalTests.cs +++ b/tests/Aetheria.Shared.Tests/RunGoalTests.cs @@ -186,7 +186,10 @@ public sealed partial class RunStartTests string Cell(LoadoutSlot slot, string brand) => $"{slot.Position.x},{slot.Position.y},{slot.Rotation},{_cache.Get(slot.Design).Name},{brand}"; var pinned = loadout.Slots.Where(slot => slot != open).Select(slot => Cell(slot, _cache.Get(slot.Product).Name)).OrderBy(x => x, StringComparer.Ordinal).ToArray(); - var brands = _cache.GetAll().Where(product => product.Design.Key.Equals(open.Design.Key)).Select(product => product.Name).ToArray(); + // The brands the context can choose for it: what the antagonist's own outfitter picks over many draws, never by key order. + var brands = Enumerable.Range(1, 64).Select(seed => GeneratorFor(galaxy, _items, antagonist, (uint) seed).ChooseProduct(_cache.Get(open.Design))?.Name) + .Where(name => name != null).Distinct().ToArray(); + Assert.NotEmpty(brands); var items = LoggingItems(new List()); var boss = Assert.Single(BossZonePack(galaxy, items, galaxy.Exit).Entities, entity => entity.Boss); From a74de35c82717aa713112fc5bfbfb65b0e1b5575 Mon Sep 17 00:00:00 2001 From: Meta Vens Date: Sun, 18 Oct 2026 19:08:22 +0200 Subject: [PATCH 032/444] Boss_carries_exactly: the open slot's brand is an available product of its design (a weighted draw need not reach every one) Co-Authored-By: Claude Sonnet 5.5 --- tests/Aetheria.Shared.Tests/RunGoalTests.cs | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/tests/Aetheria.Shared.Tests/RunGoalTests.cs b/tests/Aetheria.Shared.Tests/RunGoalTests.cs index c4f7eaf2e..80f9ab1f9 100644 --- a/tests/Aetheria.Shared.Tests/RunGoalTests.cs +++ b/tests/Aetheria.Shared.Tests/RunGoalTests.cs @@ -186,9 +186,9 @@ public sealed partial class RunStartTests string Cell(LoadoutSlot slot, string brand) => $"{slot.Position.x},{slot.Position.y},{slot.Rotation},{_cache.Get(slot.Design).Name},{brand}"; var pinned = loadout.Slots.Where(slot => slot != open).Select(slot => Cell(slot, _cache.Get(slot.Product).Name)).OrderBy(x => x, StringComparer.Ordinal).ToArray(); - // The brands the context can choose for it: what the antagonist's own outfitter picks over many draws, never by key order. - var brands = Enumerable.Range(1, 64).Select(seed => GeneratorFor(galaxy, _items, antagonist, (uint) seed).ChooseProduct(_cache.Get(open.Design))?.Name) - .Where(name => name != null).Distinct().ToArray(); + // The brands the context can choose for it: the products of the design the antagonist's outfitter can get, never any maker's. + var outfitter = GeneratorFor(galaxy, _items, antagonist, 1); + var brands = _cache.GetAll().Where(product => product.Design.Key.Equals(open.Design.Key) && outfitter.IsAvailable(product)).Select(product => product.Name).ToArray(); Assert.NotEmpty(brands); var items = LoggingItems(new List()); From 9457e4c8f93e90fb5e6b6857667f7bd9d2f097f3 Mon Sep 17 00:00:00 2001 From: Meta Vens Date: Sun, 18 Oct 2026 19:51:40 +0200 Subject: [PATCH 033/444] 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 034/444] 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 d912cf44d23fd8de4df757f9e0c5d8a8bba6bb06 Mon Sep 17 00:00:00 2001 From: Meta Vens Date: Sun, 18 Oct 2026 20:16:47 +0200 Subject: [PATCH 035/444] Boss tie-break test; the carried boss's open slot is a design with products the maker cannot get Co-Authored-By: Claude Sonnet 5.5 --- tests/Aetheria.Shared.Tests/RunGoalTests.cs | 44 +++++++++++++++++++-- 1 file changed, 40 insertions(+), 4 deletions(-) diff --git a/tests/Aetheria.Shared.Tests/RunGoalTests.cs b/tests/Aetheria.Shared.Tests/RunGoalTests.cs index 80f9ab1f9..604622e44 100644 --- a/tests/Aetheria.Shared.Tests/RunGoalTests.cs +++ b/tests/Aetheria.Shared.Tests/RunGoalTests.cs @@ -75,9 +75,9 @@ public sealed partial class RunStartTests // A boss's fixed Loadout (ruling boss-gear-is-a-loadout), by the production capture: a generated fit of the hull MakeBoss // copied, captured as a Loadout (Loadouts.Capture), cut down to its weapons so it is smaller than any generated fit (the // tractor pickup and the rest are not in it), moved onto the boss hull and the maker's product of it, and stored at - // Loadouts.KeyOf(name), the key Loadouts.Commit writes. openIf leaves the first slot whose design satisfies it design-only. + // Loadouts.KeyOf(name), the key Loadouts.Commit writes. openRank leaves the slot whose design ranks highest (above 0) design-only. // The shipped catalog has no Boss hull, so this and MakeBoss are fixtures. - private Loadout MakeBossLoadout(HullData boss, Faction maker, string name, Func openIf = null) + private Loadout MakeBossLoadout(HullData boss, Faction maker, string name, Func openRank = null) { var like = SoldShipHulls().OrderBy(hull => hull.Name, StringComparer.Ordinal).First(); var random = new CultMath.Random(11); @@ -91,7 +91,12 @@ public sealed partial class RunStartTests var renumbered = armed.Select((entry, number) => (entry.index, number)).ToDictionary(entry => entry.index, entry => entry.number); fit.WeaponGroups = fit.WeaponGroups.Select(group => group.Where(renumbered.ContainsKey).Select(index => renumbered[index]).ToArray()).ToArray(); fit.Slots = armed.Select(entry => entry.slot).ToList(); - if (openIf != null) fit.Slots.First(slot => openIf(_cache.Get(slot.Design))).Product = default; + if (openRank != null) + { + var open = fit.Slots.OrderByDescending(slot => openRank(_cache.Get(slot.Design))).First(); + Assert.True(openRank(_cache.Get(open.Design)) > 0, "no weapon of the fit can be left design-only for this maker"); + open.Product = default; + } fit.Hull = _cache.RefOf(boss); fit.HullProduct = _cache.RefOf(_cache.GetAll().Single(product => _cache.Get(product.Design) == boss && _cache.Get(product.Manufacturer) == maker)); StoreLoadout(fit); @@ -169,6 +174,31 @@ public sealed partial class RunStartTests Assert.DoesNotContain(log, line => line.Contains("makes no boss hull")); } + // Equal-priced Boss hulls go by hull name, and a hull sold under two products by product name, whatever order the products + // were authored or are named in: the first Boss hull is the same hull for every reader. + [Fact] + public void Equal_priced_boss_hulls_go_by_hull_name_then_product_name() + { + var galaxy = DemoGalaxy(1); + var antagonist = Zhestokost(galaxy); + var y = MakeBoss("Tie Y", 100, antagonist); + var z = MakeBoss("Tie Z", 100, antagonist); + void Rename(HullData hull, string name) + { + var product = _cache.GetAll().Single(p => _cache.Get(p.Design) == hull); + product.Name = name; + Assert.True(_cache.Commit(batch => batch.Upsert(typeof(FactionProductData), product, _cache.RefOf(product).Key))); + } + Rename(y, "zz product of Y"); + Rename(z, "aa product of Z"); + var yProduct = _cache.GetAll().Single(p => p.Name == "zz product of Y"); + _cache.Upsert(new FactionProductData { Name = "a second product of Y", Design = yProduct.Design, Manufacturer = yProduct.Manufacturer }); + + var made = LoadoutGenerator.BossHullsOf(_cache, antagonist); + Assert.Equal(new[] { "Tie Y", "Tie Y", "Tie Z" }, made.Select(entry => entry.hull.Name)); + Assert.Equal(new[] { "a second product of Y", "zz product of Y", "aa product of Z" }, made.Select(entry => entry.product.Name)); + } + // Ruling boss-gear-is-a-loadout: the boss carries its Loadout's cells and nothing else. The generated fit of the same hull // carries more (a tractor pickup, consumables); none of it is on the boss. No generator draw decides it: another seed gives // the same cells. A design-only slot takes its brand from the context's ChooseProduct (ruling @@ -180,7 +210,13 @@ public sealed partial class RunStartTests var antagonist = Zhestokost(galaxy); var hull = MakeBoss("Boss A", 100, antagonist); var loadout = MakeBossLoadout(hull, antagonist, "Boss A loadout", - openIf: design => GeneratorFor(galaxy, LoggingItems(new List()), antagonist, 1).ChooseProduct(design) != null); + openRank: design => + { + // A design the antagonist's outfitter can get, best one that also has a product it cannot: its pick is then a constrained choice. + var outfitter = GeneratorFor(galaxy, _items, antagonist, 1); + var made = _cache.GetAll().Where(product => product.Design.Key.Equals(_cache.RefOf(design).Key)).ToArray(); + return made.Any(outfitter.IsAvailable) ? (made.Any(product => !outfitter.IsAvailable(product)) ? 2 : 1) : 0; + }); Assert.True(loadout.Slots.Count > 1); var open = Assert.Single(loadout.Slots, slot => !slot.Product.IsSet()); From cb1c093e84b90ea634a708a0eb6bf06ba30f0dce Mon Sep 17 00:00:00 2001 From: Meta Vens Date: Sun, 18 Oct 2026 20:21:31 +0200 Subject: [PATCH 036/444] 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 037/444] 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 e47bb27ef8c8bbeb6a6b168a7b3036d61165cb99 Mon Sep 17 00:00:00 2001 From: Meta Vens Date: Sun, 18 Oct 2026 20:31:55 +0200 Subject: [PATCH 038/444] Boss_carries_exactly: the open slot is built as the one product its antagonist can get, with an outsider's listed first Co-Authored-By: Claude Sonnet 5.5 --- tests/Aetheria.Shared.Tests/RunGoalTests.cs | 38 ++++++++++++++------- 1 file changed, 25 insertions(+), 13 deletions(-) diff --git a/tests/Aetheria.Shared.Tests/RunGoalTests.cs b/tests/Aetheria.Shared.Tests/RunGoalTests.cs index 604622e44..5e0d6b521 100644 --- a/tests/Aetheria.Shared.Tests/RunGoalTests.cs +++ b/tests/Aetheria.Shared.Tests/RunGoalTests.cs @@ -93,7 +93,8 @@ public sealed partial class RunStartTests fit.Slots = armed.Select(entry => entry.slot).ToList(); if (openRank != null) { - var open = fit.Slots.OrderByDescending(slot => openRank(_cache.Get(slot.Design))).First(); + // Only a design the Loadout fits once can be left open, so its products can be rearranged without touching a pinned slot. + var open = fit.Slots.Where(slot => fit.Slots.Count(other => other.Design.Key.Equals(slot.Design.Key)) == 1).OrderByDescending(slot => openRank(_cache.Get(slot.Design))).First(); Assert.True(openRank(_cache.Get(open.Design)) > 0, "no weapon of the fit can be left design-only for this maker"); open.Product = default; } @@ -210,22 +211,34 @@ public sealed partial class RunStartTests var antagonist = Zhestokost(galaxy); var hull = MakeBoss("Boss A", 100, antagonist); var loadout = MakeBossLoadout(hull, antagonist, "Boss A loadout", - openRank: design => - { - // A design the antagonist's outfitter can get, best one that also has a product it cannot: its pick is then a constrained choice. - var outfitter = GeneratorFor(galaxy, _items, antagonist, 1); - var made = _cache.GetAll().Where(product => product.Design.Key.Equals(_cache.RefOf(design).Key)).ToArray(); - return made.Any(outfitter.IsAvailable) ? (made.Any(product => !outfitter.IsAvailable(product)) ? 2 : 1) : 0; - }); + openRank: design => GeneratorFor(galaxy, _items, antagonist, 1) is var offered + && _cache.GetAll().Any(product => product.Design.Key.Equals(_cache.RefOf(design).Key) && offered.IsAvailable(product)) ? 1 : 0); Assert.True(loadout.Slots.Count > 1); var open = Assert.Single(loadout.Slots, slot => !slot.Product.IsSet()); string Cell(LoadoutSlot slot, string brand) => $"{slot.Position.x},{slot.Position.y},{slot.Rotation},{_cache.Get(slot.Design).Name},{brand}"; var pinned = loadout.Slots.Where(slot => slot != open).Select(slot => Cell(slot, _cache.Get(slot.Product).Name)).OrderBy(x => x, StringComparer.Ordinal).ToArray(); - // The brands the context can choose for it: the products of the design the antagonist's outfitter can get, never any maker's. + // The context's choice, made unmistakable: the design has two products, one the antagonist's outfitter cannot get (listed + // first) and one it can. The open slot is built as the one it can, whatever the key order or the draw. + var template = _cache.GetAll().First(product => product.Design.Key.Equals(open.Design.Key)); + var options = CultDocumentMessagePackSerialization.OptionsFor(typeof(HullData).Assembly); + FactionProductData Brand(string name, Faction maker) + { + var brand = MessagePack.MessagePackSerializer.Deserialize(MessagePack.MessagePackSerializer.Serialize(template, options), options); + brand.Name = name; + brand.Manufacturer = _cache.RefOf(maker); + return brand; + } var outfitter = GeneratorFor(galaxy, _items, antagonist, 1); - var brands = _cache.GetAll().Where(product => product.Design.Key.Equals(open.Design.Key) && outfitter.IsAvailable(product)).Select(product => product.Name).ToArray(); - Assert.NotEmpty(brands); + var outsider = _cache.GetAll().First(faction => !outfitter.IsAvailable(Brand("probe", faction))); + var made = Brand("A brand of an outsider", outsider); + var chosen = Brand("B brand of the antagonist", antagonist); + Assert.False(outfitter.IsAvailable(made)); + Assert.True(outfitter.IsAvailable(chosen)); + foreach (var product in _cache.GetAll().Where(product => product.Design.Key.Equals(open.Design.Key)).ToArray()) + Assert.True(_cache.Remove(_cache.RefOf(product).Key)); + _cache.Upsert(made); + _cache.Upsert(chosen); var items = LoggingItems(new List()); var boss = Assert.Single(BossZonePack(galaxy, items, galaxy.Exit).Entities, entity => entity.Boss); @@ -233,8 +246,7 @@ public sealed partial class RunStartTests Assert.Equal(loadout.Slots.Count, cells.Length); Assert.Equal(pinned, cells.Where(cell => !cell.StartsWith($"{open.Position.x},{open.Position.y},")).OrderBy(x => x, StringComparer.Ordinal)); var openCell = Assert.Single(cells, cell => cell.StartsWith($"{open.Position.x},{open.Position.y},")); - Assert.StartsWith(Cell(open, ""), openCell); - Assert.Contains(openCell[(openCell.LastIndexOf(',') + 1)..], brands); + Assert.Equal(Cell(open, "B brand of the antagonist"), openCell); var captured = Loadouts.Capture(items, (Ship) EntitySerializer.Unpack(items, null, boss), "probe"); Assert.Equal(_cache.Get(loadout.HullProduct).Name, _cache.Get(captured.HullProduct).Name); From 90f977bf19a6bd42741459cf371ddee5a492462d Mon Sep 17 00:00:00 2001 From: Meta Vens Date: Sun, 18 Oct 2026 20:33:09 +0200 Subject: [PATCH 039/444] Boss tests: a null faction has no boss hull; an unbuildable boss Loadout lists every failure Co-Authored-By: Claude Sonnet 5.5 --- tests/Aetheria.Shared.Tests/RunGoalTests.cs | 20 +++++++++++++++++++- 1 file changed, 19 insertions(+), 1 deletion(-) diff --git a/tests/Aetheria.Shared.Tests/RunGoalTests.cs b/tests/Aetheria.Shared.Tests/RunGoalTests.cs index 5e0d6b521..6e171b33a 100644 --- a/tests/Aetheria.Shared.Tests/RunGoalTests.cs +++ b/tests/Aetheria.Shared.Tests/RunGoalTests.cs @@ -281,6 +281,21 @@ public sealed partial class RunStartTests Assert.Empty(LoadoutGenerator.BossHullsOf(_cache, null)); } + // No faction has no boss hulls, even when a Boss hull has a product with no maker (which a null faction would equal). + [Fact] + public void No_faction_has_no_boss_hull() + { + var galaxy = DemoGalaxy(1); + var orphan = MakeBoss("Orphan Boss", 100); + var key = _cache.RefOf(orphan).Key; + _cache.Upsert(new FactionProductData { Name = "Orphan product", Design = new CultRecordRef(key) }); + + Assert.Empty(LoadoutGenerator.BossHullsOf(_cache, null)); + Assert.Null(Loadouts.BossOf(_cache, null, out var refusal)); + Assert.Equal(BossRefusal.NoBossHull, refusal); + Assert.Empty(LoadoutGenerator.BossHullsOf(_cache, Zhestokost(galaxy))); + } + // A Boss hull whose product has no Loadout is refused typed at the derivation. The refusal names the missing field and // echoes no input: the canary hull name, its product's name and the record keys reach no log line. [Fact] @@ -590,6 +605,7 @@ public sealed partial class RunStartTests var hull = MakeBoss("Boss A", 100, antagonist); var loadout = MakeBossLoadout(hull, antagonist, "Unfittable loadout"); loadout.Slots[0].Position = new int2(999, 999); + loadout.Slots[1].Position = new int2(998, 998); StoreLoadout(loadout); Assert.Equal("Unfittable loadout", Loadouts.BossOf(_cache, antagonist, out _).Name); @@ -598,7 +614,9 @@ public sealed partial class RunStartTests 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("Unfittable loadout") && line.Contains("does not fit")); + var refusal = Assert.Single(log, line => line.Contains("Unfittable loadout") && line.Contains("does not fit")); + Assert.Equal(2, Regex.Matches(refusal, "does not fit").Count); + Assert.Contains("; ", refusal); Assert.False(RunGoal.ExitOpen(new Zone(items, _planetSettings, pack, galaxy.Exit, galaxy))); } From d28ce42df9aef7338a1c9b8165bd57ca8227ec09 Mon Sep 17 00:00:00 2001 From: Meta Vens Date: Sun, 18 Oct 2026 20:37:22 +0200 Subject: [PATCH 040/444] 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 041/444] 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 042/444] 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 043/444] 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 044/444] 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 045/444] 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 046/444] 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().ToArray(); - Console.WriteLine($"{"faction",-26} {"short",-13} {"places",-22} {"boss hull",-14} {"influence",-9} {"doctrine",-10} products"); + Console.WriteLine($"{"faction",-26} {"short",-13} {"places",-22} {"boss loadout",-14} {"influence",-9} {"doctrine",-10} products"); var broken = 0; foreach (var faction in db.Cache.GetAll().OrderBy(f => f.Name)) { var key = db.Cache.RefOf(faction).Key; - var boss = !faction.BossHull.IsSet() - ? "none" - : db.Cache.Get(faction.BossHull)?.Name ?? "DANGLING"; - if (boss == "DANGLING") broken++; + var loadout = Loadouts.BossOf(db.Cache, faction, out var refusal); + var boss = refusal == BossRefusal.Unset ? "none" : loadout?.Name ?? "MISSING"; + if (refusal == BossRefusal.Missing) broken++; Console.WriteLine($"{faction.Name,-26} {faction.ShortName,-13} {faction.Places.Length,-22} {boss,-14} {faction.InfluenceDistance,-9} {(faction.Doctrine == null ? "none" : faction.Doctrine.EngageOn.ToString()),-10} " + products.Count(p => p.Manufacturer.Key.Equals(key))); } From e6a17a5e01dd4d34722de7d857f2ecb95f5f1203 Mon Sep 17 00:00:00 2001 From: Meta Vens Date: Sun, 18 Oct 2026 21:14:14 +0200 Subject: [PATCH 047/444] ProductLinesCommandTests: the bins of a removed line go with it, as the catalog read now requires Co-Authored-By: Claude Sonnet 5.5 --- tests/Aetheria.Shared.Tests/ProductLinesCommandTests.cs | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/tests/Aetheria.Shared.Tests/ProductLinesCommandTests.cs b/tests/Aetheria.Shared.Tests/ProductLinesCommandTests.cs index 53b8fe773..fd8e02aa1 100644 --- a/tests/Aetheria.Shared.Tests/ProductLinesCommandTests.cs +++ b/tests/Aetheria.Shared.Tests/ProductLinesCommandTests.cs @@ -42,7 +42,9 @@ public abstract class ProductLinesCommandTests : IDisposable Assert.Equal(Names.Length, products.Length); _shipped = products.Select(p => Describe(cache, p)).OrderBy(x => x, StringComparer.Ordinal).ToArray(); _shippedKeys = products.Select(p => cache.RefOf(p).Key.Value).OrderBy(x => x, StringComparer.Ordinal).ToArray(); - foreach (var product in products) Assert.True(cache.Remove(cache.RefOf(product).Key)); + // A bin cannot outlive its line (the catalog read refuses it), so the bins of the removed lines go with them. + var binned = cache.GetAll().Where(p => p.Bin != null && products.Any(line => cache.RefOf(line).Key.Equals(p.Bin.Line.Key))).ToArray(); + foreach (var product in products.Concat(binned)) Assert.True(cache.Remove(cache.RefOf(product).Key)); cache.FlushAsync().Wait(); } From e2b1b9ca7b2cc9735ca72c5dbffdf2e1d38aad19 Mon Sep 17 00:00:00 2001 From: Meta Vens Date: Sun, 18 Oct 2026 21:17:05 +0200 Subject: [PATCH 048/444] RunGoalTests: the boss flies its faction's boss Loadout; refusals, exact cells, brand by context, market rule, main-galaxy pick Co-Authored-By: Claude Sonnet 5.5 --- tests/Aetheria.Shared.Tests/RunGoalTests.cs | 292 +++++++++++++++++++- 1 file changed, 285 insertions(+), 7 deletions(-) diff --git a/tests/Aetheria.Shared.Tests/RunGoalTests.cs b/tests/Aetheria.Shared.Tests/RunGoalTests.cs index 4c51eca72..d2bc84a8f 100644 --- a/tests/Aetheria.Shared.Tests/RunGoalTests.cs +++ b/tests/Aetheria.Shared.Tests/RunGoalTests.cs @@ -12,13 +12,13 @@ using UniRx; using Xunit; // The demo's goal (aetheria-release, cut boss-gate): the boss zone spawns its faction's boss, and the exit gate opens -// only when that boss is dead. Every input is the production one: the demo galaxy, GenerateZone, the Zone death path, +// only when that boss is dead. The boss flies its faction's boss Loadout (cut boss-loadout). Every input is the production one: the demo galaxy, GenerateZone, the Zone death path, // and the run save. public sealed partial class RunStartTests { - private ZonePack BossZonePack(Galaxy galaxy, ItemManager items, GalaxyZone zone, bool ambient = true) + private ZonePack BossZonePack(Galaxy galaxy, ItemManager items, GalaxyZone zone, bool ambient = true, uint seed = 7) { - items.Random = new CultMath.Random(7); + items.Random = new CultMath.Random(seed); return ZoneGenerator.GenerateZone(items, _zoneSettings, _planetSettings, galaxy, zone, galaxy.IsPrelude, ambient); } @@ -38,12 +38,87 @@ public sealed partial class RunStartTests .Where(hull => hull != null && hull.HullType == HullType.Ship && hull.Price > 0) .Distinct().ToArray(); + // A hull no product makes, so the market never offers it: a MessagePack copy of the first sold ship hull by name, renamed. It has + // no authored model, which a boss does not need. Stored once; asking again returns the same record. + private HullData BossTestHull() + { + var existing = _cache.GetAll().FirstOrDefault(hull => hull.Name == "Boss Test Hull"); + if (existing != null) return existing; + var options = CultDocumentMessagePackSerialization.OptionsFor(typeof(HullData).Assembly); + var like = SoldShipHulls().OrderBy(hull => hull.Name, StringComparer.Ordinal).First(); + var boss = MessagePack.MessagePackSerializer.Deserialize(MessagePack.MessagePackSerializer.Serialize(like, options), options); + boss.Name = "Boss Test Hull"; + boss.Prefab = null; + boss.Visual = default; + return _cache.Get(_cache.Upsert(boss)); + } + + // A Loadout record at Loadouts.KeyOf(Name), the key Loadouts.Commit writes it under. + private void StoreLoadout(Loadout loadout) => + Assert.True(_cache.Commit(batch => batch.Upsert(typeof(Loadout), loadout, Loadouts.KeyOf(loadout.Name)))); + + // A boss's fixed Loadout (ruling boss-gear-is-a-loadout), by the production capture: a seed-11 generated fit of the first sold + // ship hull by name, captured (Loadouts.Capture), cut down to its weapons so it is smaller than any generated fit (the tractor + // pickup and the rest are not in it), moved onto the given hull with no hull product, and stored under its name. + private Loadout MakeBossLoadout(HullData hull, string name) + { + var like = SoldShipHulls().OrderBy(h => h.Name, StringComparer.Ordinal).First(); + var random = new CultMath.Random(11); + var generator = new LoadoutGenerator(ref random, _items, null, null, null, .5f); + var ship = (Ship) EntitySerializer.Unpack(_items, null, generator.GenerateShipLoadout(h => h == like)); + var fit = Loadouts.Capture(_items, ship, name); + var armed = fit.Slots.Select((slot, index) => (slot, index)) + .Where(entry => _cache.Get(entry.slot.Design).Behaviors.Any(behavior => behavior is WeaponData)).ToArray(); + Assert.NotEmpty(armed); + Assert.True(armed.Length < fit.Slots.Count, "the generated fit needs gear besides weapons for this fixture to tell a Loadout from a fit"); + var renumbered = armed.Select((entry, number) => (entry.index, number)).ToDictionary(entry => entry.index, entry => entry.number); + fit.WeaponGroups = fit.WeaponGroups.Select(group => group.Where(renumbered.ContainsKey).Select(index => renumbered[index]).ToArray()).ToArray(); + fit.Slots = armed.Select(entry => entry.slot).ToList(); + fit.Hull = _cache.RefOf(hull); + fit.HullProduct = default; + StoreLoadout(fit); + return fit; + } + + private static void SetBoss(Faction faction, Loadout loadout) => faction.BossLoadout = new CultRecordRef(Loadouts.KeyOf(loadout.Name)); + + // The demo's antagonist gets a boss Loadout on a hull nothing sells: the shipped catalog authors none. + private Loadout GiveAntagonistABoss(Galaxy galaxy) + { + var boss = MakeBossLoadout(BossTestHull(), "Antagonist Boss"); + SetBoss(Zhestokost(galaxy), boss); + return boss; + } + + // What a ship carries, cell by cell, as a Loadout would name it: position, rotation, design, product. + private static string[] Cells(ItemManager items, EntityPack pack) + { + var captured = Loadouts.Capture(items, (Ship) EntitySerializer.Unpack(items, null, pack), "probe"); + return captured.Slots.OrderBy(slot => slot.Position.x).ThenBy(slot => slot.Position.y) + .Select(slot => $"{slot.Position.x},{slot.Position.y},{slot.Rotation},{items.ItemData.Get(slot.Design).Name},{(slot.Product.IsSet() ? items.ItemData.Get(slot.Product).Name : "-")}").ToArray(); + } + + private static string[] LoadoutCells(CultCache cache, Loadout loadout) => + loadout.Slots.OrderBy(slot => slot.Position.x).ThenBy(slot => slot.Position.y) + .Select(slot => $"{slot.Position.x},{slot.Position.y},{slot.Rotation},{cache.Get(slot.Design).Name},{(slot.Product.IsSet() ? cache.Get(slot.Product).Name : "-")}").ToArray(); + + private LoadoutGenerator GeneratorFor(Galaxy galaxy, ItemManager items, Faction faction, uint seed) + { + var random = new CultMath.Random(seed); + return new LoadoutGenerator(ref random, items, galaxy, galaxy.Exit, faction, .5f); + } + + // What the pack holds, in order, by hull and faction: two zone packs that drew the same stream hold the same list. + private static string[] Roster(ItemManager items, ZonePack pack) => + pack.Entities.Select(entity => $"{HullName(items, entity)}|{(entity.Faction.IsSet() ? entity.Faction.Key.ToString() : "-")}").ToArray(); + [Fact] public void Boss_spawns_once_in_boss_zone() { foreach (var seed in DemoSeeds.Take(3)) { var galaxy = DemoGalaxy(seed); + GiveAntagonistABoss(galaxy); var antagonist = Zhestokost(galaxy); foreach (var zone in galaxy.Zones) { @@ -53,6 +128,7 @@ public sealed partial class RunStartTests var boss = Assert.Single(bosses); Assert.IsType(boss); Assert.Same(antagonist, _cache.Get(boss.Faction)); + Assert.Equal("Boss Test Hull", HullName(_items, boss)); // Generation is repeatable per zone: the same zone yields the same boss hull. var again = Assert.Single(BossZonePack(galaxy, _items, zone).Entities, entity => entity.Boss); Assert.Equal(HullName(_items, boss), HullName(_items, again)); @@ -65,15 +141,215 @@ public sealed partial class RunStartTests public void Boss_spawns_in_a_zone_that_is_not_ambient() { var galaxy = DemoGalaxy(1); + GiveAntagonistABoss(galaxy); var pack = BossZonePack(galaxy, _items, galaxy.Exit, ambient: false); Assert.Single(pack.Entities, entity => entity.Boss); Assert.DoesNotContain(pack.Entities, entity => !entity.Boss && entity is ShipPack); } + // Faction.BossLoadout owns the boss. A decoy Loadout sorts before it by name and by key, so a resolver that takes the first + // Loadout in the cache, or any Loadout but the referenced one, flies the wrong hull. + [Fact] + public void Boss_flies_its_factions_boss_loadout() + { + var galaxy = DemoGalaxy(1); + var antagonist = Zhestokost(galaxy); + var sold = SoldShipHulls().OrderBy(hull => hull.Name, StringComparer.Ordinal).First(); + StoreLoadout(new Loadout { Name = "Aaa Decoy", Hull = _cache.RefOf(sold) }); + var loadout = GiveAntagonistABoss(galaxy); + + Assert.Same(_cache.Get(antagonist.BossLoadout), Loadouts.BossOf(_cache, antagonist, out var refusal)); + Assert.Equal(BossRefusal.None, refusal); + Assert.Equal("Antagonist Boss", loadout.Name); + var boss = Assert.Single(BossZonePack(galaxy, _items, galaxy.Exit).Entities, entity => entity.Boss); + Assert.IsType(boss); + Assert.Same(antagonist, _cache.Get(boss.Faction)); + Assert.Equal("Boss Test Hull", HullName(_items, boss)); + var again = Assert.Single(BossZonePack(galaxy, _items, galaxy.Exit).Entities, entity => entity.Boss); + Assert.Equal(Cells(_items, boss), Cells(_items, again)); + } + + // Ruling boss-gear-is-a-loadout: the boss carries exactly its Loadout, nothing drawn. The Loadout is weapons only, so any + // pickup, extra gear, cargo or consumable on the boss is a generator pass that should not run. + [Fact] + public void Boss_carries_exactly_its_loadouts_cells() + { + var galaxy = DemoGalaxy(1); + var loadout = GiveAntagonistABoss(galaxy); + Assert.True(loadout.Slots.Count > 1); + Assert.All(loadout.Slots, slot => Assert.True(slot.Product.IsSet(), "a captured fit pins every product")); + + foreach (var seed in new uint[] { 7, 8 }) + { + var items = LoggingItems(new List()); + var boss = Assert.Single(BossZonePack(galaxy, items, galaxy.Exit, seed: seed).Entities, entity => entity.Boss); + Assert.Equal(LoadoutCells(_cache, loadout), Cells(items, boss)); + Assert.All(boss.Equipment, entry => Assert.DoesNotContain(items.GetData(entry.item).Behaviors, behavior => behavior is PickupData)); + Assert.Empty(boss.CargoContents.SelectMany(bay => bay)); + Assert.Empty(boss.DockingBayContents.SelectMany(bay => bay)); + + var captured = Loadouts.Capture(items, (Ship) EntitySerializer.Unpack(items, null, boss), "probe"); + string[][] Groups(Loadout l) => l.WeaponGroups.Select(group => group.Select(i => $"{l.Slots[i].Position.x},{l.Slots[i].Position.y}").OrderBy(x => x, StringComparer.Ordinal).ToArray()).Where(group => group.Length > 0).ToArray(); + Assert.Equal(Groups(loadout), Groups(captured)); + } + } + + // Ruling loadout-product-optional-context-chooses: a design-only slot takes the brand the boss's faction can get. The design has + // an outsider's product that sorts first by name, so a chooser that takes the first product by name builds the wrong brand. + [Fact] + public void A_design_only_boss_slot_takes_a_brand_its_faction_can_get() + { + var galaxy = DemoGalaxy(1); + var antagonist = Zhestokost(galaxy); + var loadout = GiveAntagonistABoss(galaxy); + var outfitter = GeneratorFor(galaxy, _items, antagonist, 1); + var open = loadout.Slots.First(slot => _cache.GetAll().Any(product => product.Design.Key.Equals(slot.Design.Key) && outfitter.IsAvailable(product))); + var template = _cache.GetAll().First(product => product.Design.Key.Equals(open.Design.Key)); + open.Product = default; + StoreLoadout(loadout); + + var options = CultDocumentMessagePackSerialization.OptionsFor(typeof(HullData).Assembly); + var outsider = MessagePack.MessagePackSerializer.Deserialize(MessagePack.MessagePackSerializer.Serialize(template, options), options); + outsider.Name = "Aaa Outsider"; + foreach (var faction in _cache.GetAll().OrderBy(faction => faction.Name, StringComparer.Ordinal)) + { + outsider.Manufacturer = _cache.RefOf(faction); + if (!outfitter.IsAvailable(outsider)) break; + } + Assert.False(outfitter.IsAvailable(outsider), "the outsider's maker is neither Zhestokost nor in its allegiance"); + _cache.Upsert(outsider); + + var gettable = _cache.GetAll().Where(product => product.Design.Key.Equals(open.Design.Key) && outfitter.IsAvailable(product)).Select(product => product.Name).ToArray(); + Assert.NotEmpty(gettable); + foreach (var seed in new uint[] { 1, 2, 3, 4, 5 }) + { + var items = LoggingItems(new List()); + var boss = Assert.Single(BossZonePack(galaxy, items, galaxy.Exit, seed: seed).Entities, entity => entity.Boss); + var cell = Assert.Single(Cells(items, boss), c => c.StartsWith($"{open.Position.x},{open.Position.y},")); + var brand = cell.Split(',').Last(); + Assert.NotEqual("Aaa Outsider", brand); + Assert.Contains(brand, gettable); + } + } + + // No generated fallback (rulings boss-gear-is-a-loadout, demo-boss-hull-invented): the shipped catalog authors no boss Loadout, + // so Zhestokost has no boss. The refusal happens before anything is drawn. + [Fact] + public void Boss_without_a_boss_loadout_is_refused() + { + var galaxy = DemoGalaxy(1); + var antagonist = Zhestokost(galaxy); + Assert.Null(Loadouts.BossOf(_cache, antagonist, out var refusal)); + Assert.Equal(BossRefusal.Unset, refusal); + + var log = new List(); + var items = LoggingItems(log); + var pack = BossZonePack(galaxy, items, galaxy.Exit); + Assert.False(pack.BossSpawned); + Assert.DoesNotContain(pack.Entities, entity => entity.Boss); + Assert.Contains(log, line => line.Contains("Zhestokost has no boss") && line.Contains("sets no Faction.BossLoadout")); + Assert.Contains(log, line => line.Contains("Galaxy.Exit") && line.Contains("exit-without-boss")); + Assert.False(RunGoal.ExitOpen(new Zone(items, _planetSettings, pack, galaxy.Exit, galaxy))); + + // Nothing was drawn for the refused boss: the zone is the zone with no boss mapped at all. + galaxy.BossZones.Clear(); + var bare = BossZonePack(galaxy, LoggingItems(new List()), galaxy.Exit); + Assert.NotEmpty(bare.Entities); + Assert.Equal(Roster(items, bare), Roster(items, pack)); + } + + // A reference to a Loadout the catalog does not hold is refused like no reference, and the log names the field, not the value. + [Fact] + public void A_missing_boss_loadout_is_refused() + { + var galaxy = DemoGalaxy(1); + var antagonist = Zhestokost(galaxy); + antagonist.BossLoadout = new CultRecordRef(Loadouts.KeyOf("No Such Loadout")); + Assert.Null(Loadouts.BossOf(_cache, antagonist, out var refusal)); + Assert.Equal(BossRefusal.Missing, refusal); + + var log = new List(); + var items = LoggingItems(log); + var pack = BossZonePack(galaxy, items, galaxy.Exit); + Assert.False(pack.BossSpawned); + Assert.DoesNotContain(pack.Entities, entity => entity.Boss); + Assert.Contains(log, line => line.Contains("Zhestokost has no boss") && line.Contains("names a Faction.BossLoadout that is not in the catalog")); + Assert.DoesNotContain(log, line => line.Contains("No Such Loadout") || line.Contains("sets no")); + Assert.DoesNotContain(log, line => line.Contains("Zhestokost has no boss") && line.Contains("No Such")); + } + + // A boss Loadout whose slots do not fit gives no boss: the log names the Loadout and every fit failure, and the gate stays sealed. + [Fact] + public void An_unbuildable_boss_loadout_spawns_no_boss() + { + var galaxy = DemoGalaxy(1); + var loadout = GiveAntagonistABoss(galaxy); + loadout.Slots[0].Position = new int2(999, 999); + loadout.Slots[1].Position = new int2(998, 998); + StoreLoadout(loadout); + + 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); + var refusal = Assert.Single(log, line => line.Contains("Antagonist Boss") && line.Contains("does not fit")); + Assert.Equal(2, Regex.Matches(refusal, "does not fit").Count); + Assert.Contains("; ", refusal); + Assert.False(RunGoal.ExitOpen(new Zone(items, _planetSettings, pack, galaxy.Exit, galaxy))); + } + + // The existing market rule (ruling off-market-is-general): an item is on offer iff a product of it is. A hull no product makes + // is never drawn, stocked or chosen, yet the boss whose Loadout names it flies it. + [Fact] + public void A_hull_no_product_makes_is_never_on_offer() + { + var galaxy = DemoGalaxy(1); + var hull = BossTestHull(); + GiveAntagonistABoss(galaxy); + Assert.DoesNotContain(hull, LoadoutGenerator.SoldHulls(_cache)); + var items = LoggingItems(new List()); + + for (uint seed = 1; seed <= 20; seed++) + { + var generator = GeneratorFor(galaxy, items, Zhestokost(galaxy), seed); + var ship = generator.GenerateShipLoadout(); + Assert.NotNull(ship); + Assert.NotEqual(hull.Name, HullName(items, ship)); + var station = generator.GenerateStationLoadout(); + Assert.DoesNotContain(EntitySerializer.Items(station), item => items.GetData(item).Name == hull.Name); + Assert.Null(generator.ChooseProduct(hull)); + Assert.Null(generator.GenerateShipLoadout(candidate => candidate == hull)); + } + + var boss = Assert.Single(BossZonePack(galaxy, items, galaxy.Exit).Entities, entity => entity.Boss); + Assert.Equal(hull.Name, HullName(items, boss)); + } + + // PlaceFactionsMain reads BossOf: with no reference set there is no boss zone, a reference to nothing places none, and the one + // faction whose boss Loadout resolves is the one entry though another faction sorts first. + [Fact] + public void Main_galaxy_bosses_are_factions_with_a_boss_loadout() + { + Assert.Empty(RunStart.Generate(new MainGalaxy(), Inputs(() => 1)).BossZones); + + var factions = _cache.GetAll().OrderBy(faction => faction.Name, StringComparer.Ordinal).ToArray(); + factions[0].BossLoadout = new CultRecordRef(Loadouts.KeyOf("No Such Loadout")); + Assert.Empty(RunStart.Generate(new MainGalaxy(), Inputs(() => 1)).BossZones); + + var armed = MakeBossLoadout(BossTestHull(), "Main Boss"); + SetBoss(factions[1], armed); + var galaxy = RunStart.Generate(new MainGalaxy(), Inputs(() => 1)); + var bossFaction = Assert.Single(galaxy.BossZones.Keys); + Assert.Equal(factions[1].Name, bossFaction.Name); + Assert.NotNull(Loadouts.BossOf(_cache, bossFaction, out _)); + } + [Fact] public void Exit_sealed_until_boss_dies() { var galaxy = DemoGalaxy(1); + GiveAntagonistABoss(galaxy); var exit = ZoneOf(galaxy, galaxy.Exit); var boss = Assert.Single(exit.Entities, entity => entity.IsBoss); Assert.True(RunGoal.BossAlive(exit)); @@ -96,6 +372,7 @@ public sealed partial class RunStartTests { var (galaxy, arena, staged, failures) = Launch(new DemoTerminus(), Inputs(() => 1)); Assert.True(failures.Count == 0, string.Join("; ", failures)); + GiveAntagonistABoss(galaxy); galaxy.Entrance.Contents = arena; galaxy.Exit.Contents = ZoneOf(galaxy, galaxy.Exit); var before = Assert.Single(galaxy.Exit.Contents.Entities, entity => entity.IsBoss); @@ -131,7 +408,7 @@ public sealed partial class RunStartTests } } - // The same for a demo exit whose boss could not be generated: the zone logs the gap by name and the gate stays sealed. + // The same for a demo exit whose boss could not be built: the zone logs the gap by name and the gate stays sealed. [Fact] public void Exit_sealed_and_logged_when_the_boss_cannot_be_generated() { @@ -152,10 +429,10 @@ public sealed partial class RunStartTests public void Exit_open_only_in_the_exit_zone() { var galaxy = RunStart.Generate(new MainGalaxy(), Inputs(() => 1)); - // The shipped catalog sets no BossHull outside the demo's antagonist, so the main galaxy places no boss: give a - // faction one and a boss zone that is not the exit, as the generator would for a faction with a boss hull. + // The shipped catalog sets no boss Loadout, so the main galaxy places no boss: give a faction one and a boss zone that is + // not the exit, as the generator would for a faction with a boss Loadout. var faction = galaxy.Factions.First(); - faction.BossHull = _cache.RefOf(SoldShipHulls().First()); + SetBoss(faction, MakeBossLoadout(SoldShipHulls().First(), "Gate Boss")); var bossZone = galaxy.Zones.First(zone => zone != galaxy.Exit); galaxy.BossZones[faction] = bossZone; var zone = ZoneOf(galaxy, bossZone); @@ -172,6 +449,7 @@ public sealed partial class RunStartTests public void Exit_open_after_save_of_a_slain_boss() { var galaxy = DemoGalaxy(1); + GiveAntagonistABoss(galaxy); var exit = ZoneOf(galaxy, galaxy.Exit); Assert.Single(exit.Entities, entity => entity.IsBoss).HeatstrokeDeath.OnNext(Unit.Default); var repacked = exit.PackZone(); From e5485bb3e0c18eb452374764447fef6106ca2f8c Mon Sep 17 00:00:00 2001 From: Meta Vens Date: Sun, 18 Oct 2026 21:17:42 +0200 Subject: [PATCH 049/444] aetheria-release map: agent-built hulls replace the stand-in section (form languages, probe, first cut) Co-Authored-By: Claude Opus 5.5 --- docs/aetheria-release-map.md | 315 +++++++++++++++++------------------ 1 file changed, 150 insertions(+), 165 deletions(-) diff --git a/docs/aetheria-release-map.md b/docs/aetheria-release-map.md index 37344be87..50128adac 100644 --- a/docs/aetheria-release-map.md +++ b/docs/aetheria-release-map.md @@ -11190,197 +11190,182 @@ with the wider scope taken when the FusedRounds premise is fixed. `no-save-compatibility-before-players` the cut may refuse it, but a refusal gate would be code written for nobody. -## Stand-in hulls (SI) +## Agent-built hulls -**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. +Replaces section "Stand-in hulls (SI)". Imagination pass `imagination-ag6-hull-art2`, session `self-2026-10-18-ag6`, +2026-10-18, pinned to Aetheria `origin/master` d28ce42d (worktree `C:\aeth-wt\ag6-hull-art2`). Serves ruling +`hull-art-communicates-form`. It supersedes `hull-programmer-art-reads-as-stand-in`. The ruling asks for agent-built +low-poly hulls that communicate each ship's intended form and its faction ("A Zhestokost ammo tender shouldn't look the +same as a Pirate fighter"). It also asks for a couple of fully produced hero assets to compare them with, so that hull +variety is not gated on the operator's manual throughput. Research: +`docs/research/2026-10-18-agent-built-hull-art-prior-art.md` and `docs/research/2026-10-18-faction-form-language-prior-art.md`. -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). +Kept from SI: the package path, the anchor rules (SI-B1 to SI-B3), and replace-by-real-art as one act by one owner. +Dropped from SI: the body generated from the schematic, the grey grid mark, and glyphs by mount kind. -Scratch root `S` = `C:\Users\Meta\AppData\Local\Temp\claude\F--Projects-CultLib\1b477360-19e3-41e2-bde4-8932ace0b8fd\scratchpad\imagination-ag6-hull-art`. +Scratch root `S` = `C:\Users\Meta\AppData\Local\Temp\claude\F--Projects-CultLib\1b477360-19e3-41e2-bde4-8932ace0b8fd\scratchpad\imagination-ag6-hull-art2`. ### 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). +- `S\out\sheets\1_pair_same_scale.png`: the two probe hulls in three views (three-quarter, top, port side). Both use + the same camera, light and metres per pixel. On the left is the Zhestokost ammo tender "Podvoz", 23 m. On the right + is the Pirate fighter "Markdown", 15 m, built on a stolen Rossum & Douglas wedge. +- `S\out\sheets\3_pirate_vs_djinni_hero.png`: the Pirate fighter beside `Djinni.fbx`, the shipped Rossum & Douglas + hull. Djinni is grey because its materials are not read. +- `S\out\sheets\4_zhestokost_vs_operator_art.png`: the tender beside the operator's Zhestokost faction sheet + (`Asset Sources/Zhestokost.png`, cropped) and the shipped `Turret.fbx`. +- `S\out\sheets\5_combat_zoom_proxy.png`: both top views at ship lengths of 48, 80 and 128 px. +- `S\out\sheets\2_pair_own_framing.png`: each hull framed to its own size. -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. +All renders come from one scratch flat-shaded rasterizer (`S\render.py`). They are read back from the `ship.glb` +files that the package path accepted. -### What the game reads (body facts) +### Probe (AH-P) -- **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 has three layers, each a separate file: +- **Faction languages**, in `S\lang\`. `zhestokost.kit.scad`, `rnd.kit.scad` and `pirate.kit.scad` hold the shape + vocabulary as OpenSCAD modules. `*.lang.json` holds the identity and forbidden lines quoted from the lore, the + proportion ranges, the symmetry rule and the palette zones. The target folds each faction's JSON into its `.scad`; + see cut `agent-hull-source`. +- **Role briefs**, in `S\hulls\*.brief.json`. Each gives the faction, donor, role, the play line from + `Faction Play.md`, the reads the hull must show, the size class, the triangle budget and the mounts. +- **Hull sources**, in `S\hulls\*.scad`, written by the agent. They are about 40 lines each, compose only kit modules, + are split by colour zone, and echo their anchors. -### The probe (Longinus) +`S\build.py` renders each part through `openscad.com --backend Manifold --render all`, one process at a time. All 28 +PIDs are in `S\out\pids.log`, the first 3404 and the last 20348. It then writes `ship.glb` and runs the checks. -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. +- **AH-P1. Checks.** All pass (`S\out\\checks.json`). + - Tender: 3468 triangles. The bbox is 10.5 x 23.4 x 5.9 m, inside its 18-28 m size class. Body h/w is 0.42 and w/l + is 0.61, inside Zhestokost's ranges [0.25, 0.6] and [0.35, 0.75]. Bilateral symmetry has a worst mirror miss of + 0.0000 m. It has five mount anchors, and every weapon has a muzzle nose-ward of its mount. + - Fighter: 1466 triangles and 15 m long. The donor body's h/w is 0.20 and w/l 0.69, inside the R&D ranges. The + asymmetry rule holds. + - The vertex-based asymmetry figure (0.91 unmirrored) is too lenient, because tessellation alone breaks vertex + pairs. The silhouette measure below is the one to keep. +- **AH-P2. Package path, on Yggdrasil, GREEN** (`S\ygg-agenthull-2.log`). The run used the scratch clone + `...\imagination-ag6-hull-art\vclone` at a10b5d64. That is 6d10331c plus throwaway commits, never pushed; + `git diff 6d10331c d28ce42d` touches no ship code. The job: + 1. `ship-authoring create --like Longinus` for `agent.zhammotender` and `agent.pifighter`. + 2. A gameplay layout authored separately, with mount ids matching the hull source's anchors. + 3. `ship_cc.replace_visual` with the anchors. + 4. `validate`: "7 hardpoints, 10 anchors" and "5 hardpoints, 8 anchors (records and model)". + 5. `compose`: "Composed 2 mod ships". -`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`. + Run 1 was red because it had no `/pins` or `/lfs` mounts (the `DOCKER_ARGS` recipe in briefs.md). +- **AH-P3. Silhouettes** (`S\out\sheets\metrics.json`). The silhouettes are scaled to their own bounding box, so the + comparison is of shape, not size. Tender against fighter: IoU 0.69 from the top and 0.52 from the side. Mirror IoU + from the top: tender 1.00, fighter 0.80, Djinni 1.00. These numbers are guardrails, not the judge. +- **AH-P4. What the renders show.** + - The two read as different ships at every size, including 48 px. The tender is a dark, broad stepped block with + twin handling booms and eight oxide-red magazines hung outboard. A top-down camera sees those magazines; the + first version hung them under the booms, where they were hidden from above, and was moved. The fighter is a pale + wedge with a gun as long as the hull on the port side, a mismatched bell drive on the starboard quarter, a teal + crew slash and primer plates. + - The faction read holds without labels: + - Zhestokost shows stepped masses, deep seams, plates, tow lugs, recessed machinery and a low broad body. + - The Pirate fighter shows the donor wedge plus additions, each with a mount: a saddle under the gun, struts on + the drive, a strut under the sensor pod. A cable run joins the gun to the drive. + - Weaknesses: + - The charcoal Zhestokost body is low-contrast on dark space at 48 px. + - The fighter's thin side profile carries little. + - Nothing yet has the operator's one-segment-bevel and weighted-normal finish. Box chamfers stand in for it. + Blender's Bevel and Weighted Normal at Package would supply the real finish (`shippable-part-bar`, brush-tree). +- **AH-P5. Not proven.** + - Unity: the ships were not flown, and `ShipModPlaySmoke` was not run. + - Blender was not in the loop. + - The real combat camera was not used. + - Only one agent authored both hulls. No second hull per faction tests whether the language holds a family + together; that is the next probe line. -- **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. +### Body facts found on the way (AH-B) - 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. +- **AH-B1. A current Blender is installed.** `C:\Program Files (x86)\Steam\steamapps\common\Blender\blender.exe` + exists. Follow-up `blender-pin-operator` records it as Steam's 5.2.2 LTS. The brief's premise ("only 2.77/2.78") + came from `D:\Applications` and is wrong, so no install question is raised. +- **AH-B2. Package already derives map-icon, shield and tractor** (`_regenerate_anchors`, `__init__.py:1009`). A + hull source therefore emits only mounts and mesh anchors. That subtracts four anchors from what the probe built. +- **AH-B3. The add-on derives schematic Positions from the placement of model mounts.** `_plan_mounts` + (`__init__.py:409`) writes the HullData Position from where a weapon-mount empty sits. This conflicts with + `schematic-and-model-are-separate-spaces`, under which nothing is derived from model geometry, and it is pre-existing. + Cut `agent-hull-source` measures it first and reports it as a fork rather than bypassing it. Its owner is the + hardpoint walkthrough (`hardpoint-work-held-for-walkthrough`). +- **AH-B4. No shipped ship embodies a current faction language.** + - Djinni (R&D) is a rounded, bulbous body, not the lore's flying wedge. + - The operator said of Longinus (Alakrita) that she would be happy never to see it again. + - Turret is a Zhestokost turret, not a ship. -### Visual grammar (proposal; the look is question `standin-visual-mark`) + So the heroes are a real fork (question `agent-hull-hero-assets`). +- **AH-B5. The two Zhestokost sources disagree.** + - The lore page gives charcoal, iron and oxide, a low broad hull, and identity "without relying on red paint". + - The operator's faction sheet shows a tan body with dark breakup, orange-red stripes, a tall stepped tower, and a + forward boom with hanging magazines. -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`. + The probe took its palette from the lore and the boom and magazines from the sheet (question + `zhestokost-language-source`). +- **AH-B6. A Pirate language is a composition.** The lore makes the Pirate body another faction's hull ("an + identifiable donor hull"). Bench 3 names donors: an AU miner, an R&D wedge. So the Pirate language is an additions + kit plus a donor language, never a body of its own. The checker reads the donor's proportions and the Pirates' + asymmetry rule. +- **AH-B7. The prompt-lab leak finding does not carry over to meshes.** For image models, shape motifs in a faction + block leak onto every hull (prompt-lab rule 3). For meshes nothing applies itself: a kit module appears only where a + hull source calls it. The language file can therefore hold shape vocabulary, and the role decides which pieces a + hull uses. - 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. +### Pipeline (target) -### Model (SKILL step 0b): rows for the persistent kinds this adds +1. **Faction language** `tools/blender/aetheria_ships/factions/.scad`. One file holds the kit modules and a + header of constants: zone palette, symmetry rule, proportion ranges (a Pirate hull uses its donor's). The lore + sources are cited in comments. The operator owns it; agents draft it. +2. **Hull source** `GameData/Mods//hull.scad`. Its brief header gives faction, donor, role, length range, budget + and reads. It has one part per zone, and its anchors are echoed. An agent owns it. +3. **Build From Hull Source**, a new add-on operator. It runs OpenSCAD per part, one process at a time, and checks + against the language. It then makes the zone meshes (one material per zone) and the anchor objects under Ship Root. +4. **Save Layout** (the gameplay schematic, authored separately) and **Package Ship**, both existing: + - Package writes `ship.glb`, derives the map icon, shield and tractor, writes the anchors and runs validate. + - Zones become livery mask channels when `ships-hull-material` lands (`hull-paint-mask-texture`). +5. **Render and compare.** A contact sheet puts the hull beside its faction's hero and beside a different-faction + hull, at one scale and at combat size. A model reads the sheet and judges whether the hull reads as its faction and + role. No rule decides that, because it is a question of meaning. The operator rules on the first few. + +### Model (SKILL step 0b) | 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. | +| Faction language, `factions/.scad` | The faction slug. One file per faction. A Pirate hull includes its donor's file as well. | Drafted by an agent from the lore and the operator's art, marked draft, then approved by an operator ruling. A revision rebuilds every hull of that faction at its next Build. | The operator decides the language (`zhestokost-language-source` is the first). Agents propose; nothing else edits it. | +| Hull source, `GameData/Mods//hull.scad` | The package id, equal to `ShipAuthoring.Id`. One per package. | Created by an agent pass for a hull with no operator art and revised by agents. Deleted by Package in the same act that packages operator art into that package. | Agents (Imagination designs, Hands lands). The operator decides when it dies, by packaging her art. | +| "Agent-built" status | Presence of `hull.scad`. No field. | Ends when the file is deleted. | Derived. "Which hulls are agent-built" is a directory listing. | +| Zone meshes and anchor objects in Blender | Marked `aetheria.hull_source`, with ids `aetheria.id`. | Replaced on every Build. Never hand-edited. | Build From Hull Source. Build refuses a collection holding meshes it did not make. | +| `ship.glb` and ShipAuthoring anchors | `ShipAuthoring.ModelAsset`, with anchor ids equal to mount ids. | Rewritten by every Package, whether from an agent hull or operator art. The ids are stable across the swap, so loadouts and saves survive it. | Package Ship, the only writer. | +| HullData layout (schematic) | `mod-hull:`. | Authored through Save Layout. Linked to the model by mount name only. | The schematic author: agents for agent hulls, the operator for hers. Never Build. AH-B3 is the open conflict. | +| Hero asset | A package with no `hull.scad` that is named as its faction's hero. | Set by an operator ruling (`agent-hull-hero-assets`). | The operator. | -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. +### Questions admitted -`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". +- `aetheria-release:question:agent-hull-hero-assets`: which meshes are the heroes. Recommended: `keepers-finished`. +- `aetheria-release:question:zhestokost-language-source`: whether Zhestokost follows the lore text, the faction sheet, + or both. Recommended: `sheet-palette-both-shapes`. -### Rationale +Neither blocks the first cut. -- **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. +### Cut sequence - 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. +- `agent-hull-source` (spec `aetheria-release:cut_spec:cut-agent-hull-source.r1`): the factions folder, + `hull_source.py`, Build From Hull Source, and Package deleting `hull.scad` when it packages operator art. The two + probe hulls become fixtures. Its first step measures AH-B3. +- `agent-hull-family-probe`: one more hull for each of the two factions (a Zhestokost gunboat, a Pirate tender on an + AU miner donor). It tests whether a language holds a family together, judged on a sheet beside the hero. Waits on + agent-hull-source. +- `agent-hulls-demo-roles`: hull sources for the demo's role hulls (follow-up `demo-role-hulls`) and the invented + Zhestokost boss hull (`demo-boss-hull-invented`), as packages through Package. Waits on agent-hull-family-probe, + `mods-in-tree` and `zhestokost-language-source`. +- `agent-hull-finish`: Bevel and Weighted Normal on agent hulls at Package, and zones into the livery mask. Waits on + `ships-hull-material`. ### 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. +- No Unity flight of either hull. +- No real combat camera. +- The model read was one agent judging its own sheets. +- Lucent and AU languages are not drafted. +- The hero comparison is weak until the heroes exist. From ad503059e5f75d3ff298ff90bf7673c4f7340fc9 Mon Sep 17 00:00:00 2001 From: Meta Vens Date: Sun, 18 Oct 2026 21:18:20 +0200 Subject: [PATCH 050/444] RunGoalTests: LoadoutCells reads the fixture's cache Co-Authored-By: Claude Sonnet 5.5 --- tests/Aetheria.Shared.Tests/RunGoalTests.cs | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/tests/Aetheria.Shared.Tests/RunGoalTests.cs b/tests/Aetheria.Shared.Tests/RunGoalTests.cs index d2bc84a8f..9db173508 100644 --- a/tests/Aetheria.Shared.Tests/RunGoalTests.cs +++ b/tests/Aetheria.Shared.Tests/RunGoalTests.cs @@ -98,9 +98,9 @@ public sealed partial class RunStartTests .Select(slot => $"{slot.Position.x},{slot.Position.y},{slot.Rotation},{items.ItemData.Get(slot.Design).Name},{(slot.Product.IsSet() ? items.ItemData.Get(slot.Product).Name : "-")}").ToArray(); } - private static string[] LoadoutCells(CultCache cache, Loadout loadout) => + private string[] LoadoutCells(Loadout loadout) => loadout.Slots.OrderBy(slot => slot.Position.x).ThenBy(slot => slot.Position.y) - .Select(slot => $"{slot.Position.x},{slot.Position.y},{slot.Rotation},{cache.Get(slot.Design).Name},{(slot.Product.IsSet() ? cache.Get(slot.Product).Name : "-")}").ToArray(); + .Select(slot => $"{slot.Position.x},{slot.Position.y},{slot.Rotation},{_cache.Get(slot.Design).Name},{(slot.Product.IsSet() ? _cache.Get(slot.Product).Name : "-")}").ToArray(); private LoadoutGenerator GeneratorFor(Galaxy galaxy, ItemManager items, Faction faction, uint seed) { @@ -183,7 +183,7 @@ public sealed partial class RunStartTests { var items = LoggingItems(new List()); var boss = Assert.Single(BossZonePack(galaxy, items, galaxy.Exit, seed: seed).Entities, entity => entity.Boss); - Assert.Equal(LoadoutCells(_cache, loadout), Cells(items, boss)); + Assert.Equal(LoadoutCells(loadout), Cells(items, boss)); Assert.All(boss.Equipment, entry => Assert.DoesNotContain(items.GetData(entry.item).Behaviors, behavior => behavior is PickupData)); Assert.Empty(boss.CargoContents.SelectMany(bay => bay)); Assert.Empty(boss.DockingBayContents.SelectMany(bay => bay)); From d2ec3a6678f9c549f956bfdb6c80db624ff5bdb0 Mon Sep 17 00:00:00 2001 From: Meta Vens Date: Sun, 18 Oct 2026 21:18:49 +0200 Subject: [PATCH 051/444] RunGoalTests: using GameCult.Caching Co-Authored-By: Claude Sonnet 5.5 --- tests/Aetheria.Shared.Tests/RunGoalTests.cs | 1 + 1 file changed, 1 insertion(+) diff --git a/tests/Aetheria.Shared.Tests/RunGoalTests.cs b/tests/Aetheria.Shared.Tests/RunGoalTests.cs index 9db173508..8c283213f 100644 --- a/tests/Aetheria.Shared.Tests/RunGoalTests.cs +++ b/tests/Aetheria.Shared.Tests/RunGoalTests.cs @@ -8,6 +8,7 @@ using System.IO; using System.Linq; using System.Text.RegularExpressions; using CultMath; +using GameCult.Caching; using UniRx; using Xunit; From 3ad3fcc505288f926dbc7ce63c39eddb33d9df6e Mon Sep 17 00:00:00 2001 From: Meta Vens Date: Sun, 18 Oct 2026 21:19:14 +0200 Subject: [PATCH 052/444] RunGoalTests: using GameCult.Caching.MessagePack Co-Authored-By: Claude Sonnet 5.5 --- tests/Aetheria.Shared.Tests/RunGoalTests.cs | 1 + 1 file changed, 1 insertion(+) diff --git a/tests/Aetheria.Shared.Tests/RunGoalTests.cs b/tests/Aetheria.Shared.Tests/RunGoalTests.cs index 8c283213f..ef714a5e4 100644 --- a/tests/Aetheria.Shared.Tests/RunGoalTests.cs +++ b/tests/Aetheria.Shared.Tests/RunGoalTests.cs @@ -9,6 +9,7 @@ using System.Linq; using System.Text.RegularExpressions; using CultMath; using GameCult.Caching; +using GameCult.Caching.MessagePack; using UniRx; using Xunit; From f235eea3d98ad2b0bd886aed97c1885fa1954fe3 Mon Sep 17 00:00:00 2001 From: Meta Vens Date: Sun, 18 Oct 2026 21:22:05 +0200 Subject: [PATCH 053/444] Exit_open_only_in_the_exit_zone: the faction with the boss Loadout is one that can get its hull's brand Co-Authored-By: Claude Sonnet 5.5 --- tests/Aetheria.Shared.Tests/RunGoalTests.cs | 9 ++++++--- 1 file changed, 6 insertions(+), 3 deletions(-) diff --git a/tests/Aetheria.Shared.Tests/RunGoalTests.cs b/tests/Aetheria.Shared.Tests/RunGoalTests.cs index ef714a5e4..804576a46 100644 --- a/tests/Aetheria.Shared.Tests/RunGoalTests.cs +++ b/tests/Aetheria.Shared.Tests/RunGoalTests.cs @@ -432,9 +432,12 @@ public sealed partial class RunStartTests { var galaxy = RunStart.Generate(new MainGalaxy(), Inputs(() => 1)); // The shipped catalog sets no boss Loadout, so the main galaxy places no boss: give a faction one and a boss zone that is - // not the exit, as the generator would for a faction with a boss Loadout. - var faction = galaxy.Factions.First(); - SetBoss(faction, MakeBossLoadout(SoldShipHulls().First(), "Gate Boss")); + // not the exit, as the generator would for a faction with a boss Loadout. The Loadout leaves its hull's brand to the + // context, so the faction is one that can get a product of the hull: its own maker. + var hull = SoldShipHulls().OrderBy(h => h.Name, StringComparer.Ordinal).First(); + var maker = _cache.Get(_cache.GetAll().First(product => _cache.Get(product.Design) == hull).Manufacturer); + var faction = galaxy.Factions.Single(candidate => candidate.Name == maker.Name); + SetBoss(faction, MakeBossLoadout(hull, "Gate Boss")); var bossZone = galaxy.Zones.First(zone => zone != galaxy.Exit); galaxy.BossZones[faction] = bossZone; var zone = ZoneOf(galaxy, bossZone); From d8afb9d18478847d150c567ea2481beabfa59bdf Mon Sep 17 00:00:00 2001 From: Meta Vens Date: Sun, 18 Oct 2026 21:24:46 +0200 Subject: [PATCH 054/444] Exit_open_only_in_the_exit_zone: a failed boss build reports the log Co-Authored-By: Claude Sonnet 5.5 --- tests/Aetheria.Shared.Tests/RunGoalTests.cs | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/tests/Aetheria.Shared.Tests/RunGoalTests.cs b/tests/Aetheria.Shared.Tests/RunGoalTests.cs index 804576a46..d4e865d9e 100644 --- a/tests/Aetheria.Shared.Tests/RunGoalTests.cs +++ b/tests/Aetheria.Shared.Tests/RunGoalTests.cs @@ -440,8 +440,10 @@ public sealed partial class RunStartTests SetBoss(faction, MakeBossLoadout(hull, "Gate Boss")); var bossZone = galaxy.Zones.First(zone => zone != galaxy.Exit); galaxy.BossZones[faction] = bossZone; - var zone = ZoneOf(galaxy, bossZone); - Assert.True(zone.Pack.BossSpawned); + var log = new List(); + var items = LoggingItems(log); + var zone = new Zone(items, _planetSettings, BossZonePack(galaxy, items, bossZone), bossZone, galaxy); + Assert.True(zone.Pack.BossSpawned, string.Join("; ", log)); Assert.NotEmpty(zone.Entities.Where(entity => entity.IsBoss)); foreach (var boss in zone.Entities.Where(entity => entity.IsBoss).ToArray()) boss.HeatstrokeDeath.OnNext(Unit.Default); From 40db8291d874536152584fc1d0409f0787ec8dbc Mon Sep 17 00:00:00 2001 From: Meta Vens Date: Sun, 18 Oct 2026 21:25:21 +0200 Subject: [PATCH 055/444] Exit_open_only_in_the_exit_zone: the gate boss pins its hull's brand, so any faction builds it Co-Authored-By: Claude Sonnet 5.5 --- tests/Aetheria.Shared.Tests/RunGoalTests.cs | 12 +++++++----- 1 file changed, 7 insertions(+), 5 deletions(-) diff --git a/tests/Aetheria.Shared.Tests/RunGoalTests.cs b/tests/Aetheria.Shared.Tests/RunGoalTests.cs index d4e865d9e..34eb50c69 100644 --- a/tests/Aetheria.Shared.Tests/RunGoalTests.cs +++ b/tests/Aetheria.Shared.Tests/RunGoalTests.cs @@ -432,12 +432,14 @@ public sealed partial class RunStartTests { var galaxy = RunStart.Generate(new MainGalaxy(), Inputs(() => 1)); // The shipped catalog sets no boss Loadout, so the main galaxy places no boss: give a faction one and a boss zone that is - // not the exit, as the generator would for a faction with a boss Loadout. The Loadout leaves its hull's brand to the - // context, so the faction is one that can get a product of the hull: its own maker. + // not the exit, as the generator would for a faction with a boss Loadout. This Loadout pins its hull's brand, so any faction + // builds it whatever it can get. + var faction = galaxy.Factions.First(); var hull = SoldShipHulls().OrderBy(h => h.Name, StringComparer.Ordinal).First(); - var maker = _cache.Get(_cache.GetAll().First(product => _cache.Get(product.Design) == hull).Manufacturer); - var faction = galaxy.Factions.Single(candidate => candidate.Name == maker.Name); - SetBoss(faction, MakeBossLoadout(hull, "Gate Boss")); + var gate = MakeBossLoadout(hull, "Gate Boss"); + gate.HullProduct = _cache.RefOf(_cache.GetAll().OrderBy(product => product.Name, StringComparer.Ordinal).First(product => _cache.Get(product.Design) == hull)); + StoreLoadout(gate); + SetBoss(faction, gate); var bossZone = galaxy.Zones.First(zone => zone != galaxy.Exit); galaxy.BossZones[faction] = bossZone; var log = new List(); From f17b04ec1a9b8b2689972f29ce225278b9879eb8 Mon Sep 17 00:00:00 2001 From: soul Date: Sun, 18 Oct 2026 21:31:57 +0200 Subject: [PATCH 056/444] FlightTests: Faction's wire array ends at slot 22 (BossLoadout), so it is 23 long Co-Authored-By: Claude Sonnet 5.5 --- tests/Aetheria.Shared.Tests/FlightTests.cs | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/tests/Aetheria.Shared.Tests/FlightTests.cs b/tests/Aetheria.Shared.Tests/FlightTests.cs index b9ee7c09e..dc3f4da10 100644 --- a/tests/Aetheria.Shared.Tests/FlightTests.cs +++ b/tests/Aetheria.Shared.Tests/FlightTests.cs @@ -910,7 +910,7 @@ public sealed class FlightTests : IDisposable Assert.Equal(turns, turn != 0f); } - // Faction.Doctrine rides at wire slot 17; slot 16 is reserved for Relations; Places rides at 21, which sets the array's length. + // Faction.Doctrine rides at wire slot 17; slot 16 is reserved for Relations; BossLoadout rides at 22, which sets the array's length. // Kills: the key moved. [Fact] public void TheFactionDoctrineRidesAtWireSlot17() @@ -920,7 +920,7 @@ public sealed class FlightTests : IDisposable var slots = MessagePack.MessagePackSerializer.Deserialize(MessagePack.MessagePackSerializer.Serialize(faction, CultDocumentMessagePackSerialization.OptionsFor(typeof(Faction).Assembly))); - Assert.Equal(22, slots.Length); // 19 is Assembly, 21 is Places + Assert.Equal(23, slots.Length); // 19 is Assembly, 21 is Places, 22 is BossLoadout Assert.Null(slots[16]); var doctrine = Assert.IsType(slots[17]); Assert.Equal(6f, Convert.ToSingle(doctrine[1])); From 763fa0987f5975bf29401a91d51aa0a62f69e20c Mon Sep 17 00:00:00 2001 From: soul Date: Sun, 18 Oct 2026 21:57:47 +0200 Subject: [PATCH 057/444] hull_source: reads a hull.scad through OpenSCAD and judges it against its faction's form language One language file per faction (Zhestokost, Rossum & Douglas, Pirate) owns the shape kit, proportions, symmetry rule and palette; the Zhestokost palette follows the lore page (ruling zhestokost-language-lore-text). The two probe hulls are ported as fixtures. Checks: zones, emitter and radiator meshes, donor, body proportions, top-view mirror overlap, length, triangle budget, muzzles nose-ward. Fixture-text tests need no OpenSCAD. Co-Authored-By: Claude Sonnet 5.5 --- .../aetheria_ships/factions/lowpoly.scad | 14 + .../aetheria_ships/factions/pirate.scad | 43 +++ .../blender/aetheria_ships/factions/rnd.scad | 30 ++ .../aetheria_ships/factions/zhestokost.scad | 60 +++ tools/blender/aetheria_ships/hull_source.py | 344 ++++++++++++++++++ .../tests/fixtures/hulls/pi-fighter/hull.scad | 50 +++ .../fixtures/hulls/zh-ammo-tender/hull.scad | 49 +++ tools/blender/tests/test_hull_source.py | 304 ++++++++++++++++ 8 files changed, 894 insertions(+) create mode 100644 tools/blender/aetheria_ships/factions/lowpoly.scad create mode 100644 tools/blender/aetheria_ships/factions/pirate.scad create mode 100644 tools/blender/aetheria_ships/factions/rnd.scad create mode 100644 tools/blender/aetheria_ships/factions/zhestokost.scad create mode 100644 tools/blender/aetheria_ships/hull_source.py create mode 100644 tools/blender/tests/fixtures/hulls/pi-fighter/hull.scad create mode 100644 tools/blender/tests/fixtures/hulls/zh-ammo-tender/hull.scad create mode 100644 tools/blender/tests/test_hull_source.py diff --git a/tools/blender/aetheria_ships/factions/lowpoly.scad b/tools/blender/aetheria_ships/factions/lowpoly.scad new file mode 100644 index 000000000..9979483a0 --- /dev/null +++ b/tools/blender/aetheria_ships/factions/lowpoly.scad @@ -0,0 +1,14 @@ +// Shared low-poly primitives (any faction). Frame: Blender Ship Root, nose -Y, starboard -X, up +Z. +// cbox: a box with one chamfer segment on every edge (the operator's one-segment-bevel bar, as geometry). +module cbox(s, c=0.2, center=true) { + t = center ? [0,0,0] : s/2; + translate(t) hull() { + cube([s.x, s.y-2*c, s.z-2*c], center=true); + cube([s.x-2*c, s.y, s.z-2*c], center=true); + cube([s.x-2*c, s.y-2*c, s.z], center=true); + } +} +// tube along +Y (nose is -Y, so a forward barrel uses rotate 180 or negative length) +module tube(r, l, fn=8) { rotate([-90,0,0]) cylinder(r=r, h=l, $fn=fn); } +// a straight strut between two points +module strut(a, b, r=0.08, fn=6) { hull() { translate(a) sphere(r, $fn=fn); translate(b) sphere(r, $fn=fn); } } diff --git a/tools/blender/aetheria_ships/factions/pirate.scad b/tools/blender/aetheria_ships/factions/pirate.scad new file mode 100644 index 000000000..8705869a1 --- /dev/null +++ b/tools/blender/aetheria_ships/factions/pirate.scad @@ -0,0 +1,43 @@ +// Pirate Coalition form language: the one owner of this faction's additions kit, symmetry rule and palette. Source: Faction Flavor > Pirate Coalition > Visual Identity: "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." +// A Pirate hull = one donor-language body (another faction's language, named in the hull's brief and judged by its +// proportions) + these additions. This language draws no body and has no proportions of its own. +include +// Bolted salvage plate: a slightly tilted slab with proud bolt heads. +module pi_plate(w, l, t=0.12, tilt=4) { + rotate([tilt, -tilt*0.6, 0]) { cube([w, l, t], center=true); + for (x=[-w/2+0.15, w/2-0.15], y=[-l/2+0.15, l/2-0.15]) translate([x, y, t/2]) cylinder(r=0.07, h=0.06, $fn=6); } +} +// Exposed cable run: a chain of short segments sagging between clamp points. +module pi_cable(pts, r=0.06) { for (i=[0:len(pts)-2]) strut(pts[i], pts[i+1], r, 5); } +// Weld bead / adapter collar where an addition meets the donor. +module pi_collar(r, h=0.2) { cylinder(r=r, h=h, center=true, $fn=8); } +// Adapted hardpoint: a crude saddle (two plates + braces) that carries something the donor never had. +module pi_saddle(w, l, h) { + translate([0,0,h/2]) cube([w, l, 0.15], center=true); + for (x=[-w/2+0.1, w/2-0.1]) translate([x, 0, h/4]) cube([0.12, l*0.8, h/2], center=true); + strut([-w/2, -l/2, 0], [w/2, l/2, h], 0.05); strut([w/2, -l/2, 0], [-w/2, l/2, h], 0.05); +} +// Replacement drive: an oversize bell on a braced cradle, not matching the donor's flush engines. +module pi_drive(r, l) { + rotate([-90,0,0]) { cylinder(r1=r*0.55, r2=r, h=l*0.45, $fn=8); translate([0,0,-l*0.55]) cylinder(r=r*0.6, h=l*0.55, $fn=8); } +} +// Oversized industrial gun (a mass driver pulled off a mining rig): long box receiver, single heavy barrel. +module pi_big_gun(len, cal=0.2) { + cbox([0.9, len*0.35, 0.7], 0.08); + translate([0, -len*0.17, 0.05]) rotate([0,0,180]) tube(cal, len*0.65, 8); + translate([0, -len*0.82, 0.05]) rotate([0,0,180]) tube(cal*1.6, 0.35, 8); +} +// Bolted-on sensor pod: one sleek piece from a better ship, on a strut. +module pi_sensor_pod(l=1.4, r=0.3) { scale([1, l/(2*r), 1]) sphere(r, $fn=10); } + +PI_ZONES = [["primer", [0.48, 0.27, 0.22]], // primer-red salvage plates + ["weld", [0.70, 0.68, 0.62]], // welded additions: guns, drives, cables + ["accent", [0.10, 0.62, 0.60]], // the crew's own colour + ["recess", [0.08, 0.08, 0.09]]]; +PI_GLOW = [0.55, 0.85, 1.0]; +PI_RADIATOR = [0.70, 0.68, 0.62]; +// No body zone and no proportions: the donor's. The additions must make the top view visibly lopsided. +PI_LANG = ["pirate", PI_ZONES, "", PI_GLOW, PI_RADIATOR, [], ["asymmetric", 0.9]]; +if (part == "lang") echo(lang = PI_LANG); diff --git a/tools/blender/aetheria_ships/factions/rnd.scad b/tools/blender/aetheria_ships/factions/rnd.scad new file mode 100644 index 000000000..ae7da7241 --- /dev/null +++ b/tools/blender/aetheria_ships/factions/rnd.scad @@ -0,0 +1,30 @@ +// Rossum & Douglas form language: "the flying wedge". Source: Faction Flavor > Rossum & Douglas > Visual Identity. +// The one owner of this faction's shape kit, proportions, symmetry rule and palette. It also serves as a donor language: +// a Pirate hull builds on a stolen R&D wedge (Bench 3 "Jumble Sale"). +include +// Broad shallow trapezoid narrowing to a blunt nose (-Y), wide rear face (+Y), flat dorsal plane, +// bevelled perimeter, thicker centre section. One idea, no flourishes. +module rd_wedge(L, w_rear, w_nose, h_edge, h_centre) { + hull() { + translate([0, L/2-0.3, 0]) cube([w_rear, 0.6, h_edge], center=true); // rear face + translate([0, -L/2+0.3, 0]) cube([w_nose, 0.6, h_edge*0.8], center=true); // blunt nose + translate([0, L*0.05, 0]) cube([w_rear*0.45, L*0.7, h_centre], center=true); // thick centre + } +} +// Flush rear engine bank: shallow rectangular recesses across the rear face. +module rd_engine_bank(w, h, n=3) { + for (i=[0:n-1]) translate([(i-(n-1)/2)*(w/n), 0, 0]) cube([w/n*0.7, 0.5, h], center=true); +} +// Recessed window strip and identical hatches: barely interrupt the planes. +module rd_window(w=1.2) { cube([w, 0.35, 0.18], center=true); } +module rd_hatch() { cube([0.9, 1.1, 0.06], center=true); } + + +RD_ZONES = [["primary", [0.72, 0.74, 0.76]], // the wedge + ["secondary", [0.40, 0.47, 0.56]], // hatches, panels + ["accent", [0.90, 0.89, 0.84]], // stripes + ["recess", [0.10, 0.12, 0.18]]]; // windows, engine bank +RD_GLOW = [1.0, 0.75, 0.3]; +RD_RADIATOR = [0.40, 0.47, 0.56]; +RD_LANG = ["rnd", RD_ZONES, "primary", RD_GLOW, RD_RADIATOR, [[0.12, 0.35], [0.55, 1.1]], ["bilateral", 0.97]]; +if (part == "lang") echo(lang = RD_LANG); diff --git a/tools/blender/aetheria_ships/factions/zhestokost.scad b/tools/blender/aetheria_ships/factions/zhestokost.scad new file mode 100644 index 000000000..c00139dd8 --- /dev/null +++ b/tools/blender/aetheria_ships/factions/zhestokost.scad @@ -0,0 +1,60 @@ +// Zhestokost form language: the one owner of this faction's shape kit, proportions, symmetry rule and palette. +// Canon. Operator ruling zhestokost-language-lore-text: Zhestokost is industrial and brutalist, "the dream of the +// eternal Soviet, a society that has achieved total and unassailable permaculture via a carefully organized and +// brutally practical (self-policing) state". Form follows the lore page ("Faction Flavor and Visual Identity" > +// Zhestokost): low broad stepped masses, deep seams, charcoal, iron grey, dull oxide red. The operator's faction sheet +// (Asset Sources/Zhestokost.png) lends the handling boom with hanging magazines as vocabulary, not its palette. +// A hull composes these modules; nothing here places itself. +include +ZH_CH = 0.22; // chamfer: heavy, square, one segment +// A stepped mass: tiers shrink upward and pull in from both ends. "Low, broad ... stepped rectangular masses." +module zh_stepped(L, W, H, tiers=3, pull=0.12) { + for (i=[0:tiers-1]) let(k=i/tiers) + translate([0, 0, H*k + H/tiers/2]) cbox([W*(1-pull*i*1.6), L*(1-pull*i), H/tiers + 0.02], ZH_CH); +} +// Armour plate standing proud of a face, leaving a deep seam (the seam is the gap to the next plate). +module zh_plate(w, l, t=0.3) { cbox([w, l, t], 0.08); } +// Recessed machinery: a dark box set into a mass (subtract the pocket in the hull, then place this inside). +module zh_recess(w, l, d) { cube([w, l, d], center=true); } +// Ammunition magazine: square cassette with ribbed loading face and a hanger lug on top. +module zh_cassette(w=1.2, l=1.6, h=2.0) { + cbox([w, l, h], 0.12); + for (z=[-h*0.3, 0, h*0.3]) translate([0, -l/2-0.04, z]) cube([w*0.8, 0.12, 0.18], center=true); + translate([0, 0, h/2+0.2]) cube([0.3, 0.6, 0.4], center=true); +} +// Handling boom: square box beam with cross-members; magazines hang from it. +module zh_boom(len, w=0.9, h=0.7) { + cbox([w, len, h], 0.1); + for (y=[-len/2+0.6:1.6:len/2-0.4]) translate([0, y, -h/2-0.1]) cube([w+0.4, 0.25, 0.25], center=true); +} +// Towing point: a thick eye on a block. +module zh_tow_lug() { cbox([0.6, 0.6, 0.4], 0.08); translate([0,0,0.45]) rotate([0,90,0]) difference() { cylinder(r=0.38, h=0.25, center=true, $fn=8); cylinder(r=0.18, h=1, center=true, $fn=8); } } +// Ballistic mount: square base, slab mantlet, N thick barrels with a box muzzle brake. Barrels point -Y (nose). +module zh_gun(n=2, cal=0.22, len=3.0, base=1.6) { + cbox([base, base, 0.5], 0.12); + translate([0, 0, 0.55]) cbox([base*0.85, base*0.7, 0.7], 0.12); + for (i=[0:n-1]) translate([(i-(n-1)/2)*cal*3, -base*0.3, 0.6]) { + rotate([0,0,180]) tube(cal, len, 8); + translate([0, -len, 0]) cbox([cal*3, cal*3.5, cal*3], 0.04); + } +} +// Drive block: a flat rear face with square nozzles in a grid (recessed: emitters sit at the nozzle mouths). +module zh_drive_block(w, h, d, nx=2, nz=1, nozzle=1.0) { + difference() { + cbox([w, d, h], ZH_CH); + for (i=[0:nx-1], j=[0:nz-1]) translate([(i-(nx-1)/2)*(w/nx), d/2, (j-(nz-1)/2)*(h/nz)]) cube([nozzle, 0.8, nozzle], center=true); + } +} + + +// Palette zones. Value contrast carries the read at 48 px on dark space: the body is iron grey, the plates and seams +// are charcoal, the oxide red is the only hue. Zones become livery mask channels. +ZH_ZONES = [["primary", [0.42, 0.43, 0.45]], // iron grey: the stepped mass + ["secondary", [0.20, 0.20, 0.22]], // charcoal: armour plates, booms, guns, command block + ["accent", [0.55, 0.22, 0.14]], // dull oxide red: magazines, markings + ["recess", [0.08, 0.08, 0.09]]]; // recessed machinery +ZH_GLOW = [1.0, 0.62, 0.25]; +ZH_RADIATOR = [0.50, 0.51, 0.52]; +// [faction, zones, body zone, glow, radiator, [height/width range, width/length range], symmetry rule] +ZH_LANG = ["zhestokost", ZH_ZONES, "primary", ZH_GLOW, ZH_RADIATOR, [[0.25, 0.6], [0.35, 0.75]], ["bilateral", 0.97]]; +if (part == "lang") echo(lang = ZH_LANG); diff --git a/tools/blender/aetheria_ships/hull_source.py b/tools/blender/aetheria_ships/hull_source.py new file mode 100644 index 000000000..f66c27810 --- /dev/null +++ b/tools/blender/aetheria_ships/hull_source.py @@ -0,0 +1,344 @@ +"""Agent-built hulls: reads a hull.scad through OpenSCAD and judges it against its faction's form language. + +No bpy import. A hull source is a GameData/Mods//hull.scad that includes its faction's language file (and, for a +Pirate hull, its donor's) from factions/. Everything the add-on needs is echoed by one part of that file: + + brief ECHO brief = [faction, donor, role, [length_min, length_max], [tris_min, tris_max], reads] + lang ECHO lang = [faction, [[zone, [r, g, b]], ...], body zone, glow, radiator, proportion, symmetry] + (every included language file echoes its own; a Pirate language has no body zone and no proportion) + anchors ECHO anchor = [id, role, [x, y, z], yaw, parent] one line per anchor, yaw 0 nose, 90 port + the zone's geometry, OFF; a donor's zone is named ":" + emit:, rad: the mesh of a thruster-emitter or radiator-mesh anchor + +Frame: Ship Root, metres, nose -Y, starboard -X, up +Z. build() returns a HullBuild; the caller refuses on any failed +check. Messages and check names are fixed text: nothing a hull source says is echoed back. +""" + +import json +import math +import os +import re +import subprocess +import tempfile +from dataclasses import dataclass, field +from pathlib import Path +from typing import Callable, Optional + +FACTIONS_DIR = Path(__file__).with_name("factions") +HULL_FILE = "hull.scad" +OPENSCAD_TIMEOUT = 180 # seconds, per part +SILHOUETTE_CELL = 0.25 # metres, the top-view raster the symmetry check compares +ANCHOR_ROLES = ("weapon-mount", "weapon-muzzle", "thruster-emitter", "radiator-mesh") +MESH_ROLES = {"thruster-emitter": "emit:", "radiator-mesh": "rad:"} +ECHO_PARTS = ("brief", "lang", "anchors") # parts that make no geometry; their ECHO lines are the answer +ECHO_LINE = re.compile(r"^ECHO: (\w+) = (.*)$", re.MULTILINE) + +# A runner answers one part of the hull source with (OFF text, ECHO text). OFF text is "" when the part made no geometry. +Runner = Callable[[str], "tuple[str, str]"] + + +class HullSourceError(ValueError): + """The hull source could not be read. The message is fixed text.""" + + +@dataclass(frozen=True) +class Mesh: + vertices: tuple + triangles: tuple + + def bounds(self): + """((min x, y, z), (max x, y, z)).""" + columns = list(zip(*self.vertices)) + return tuple(min(c) for c in columns), tuple(max(c) for c in columns) + + +@dataclass(frozen=True) +class Language: + faction: str + zones: tuple # ((name, (r, g, b)), ...) + body: str # the zone whose proportions the language judges; "" for a language with no body of its own + glow: tuple + radiator: tuple + proportion: Optional[tuple] # ((h/w min, max), (w/l min, max)); None for a language that takes its donor's + symmetry: tuple # ("bilateral", minimum mirror overlap) or ("asymmetric", maximum mirror overlap) + + def zone_names(self): + return [name for name, _ in self.zones] + + def colour(self, zone): + return dict(self.zones)[zone] + + +@dataclass(frozen=True) +class Brief: + faction: str + donor: str + role: str + length: tuple + triangles: tuple + reads: tuple + + +@dataclass(frozen=True) +class Anchor: + id: str + role: str + position: tuple + yaw: float + parent: str + + +@dataclass(frozen=True) +class Check: + name: str + ok: bool + detail: str = "" + + +@dataclass +class HullBuild: + brief: Brief + languages: dict # faction -> Language + zones: dict # zone key ("primary", "rnd:primary") -> Mesh + colours: dict # zone key -> (r, g, b) + anchors: list + anchor_meshes: dict # anchor id -> Mesh + glow: tuple + radiator: tuple + body_zone: str # the zone key the collider is made from + checks: list = field(default_factory=list) + + def failed(self): + return [check for check in self.checks if not check.ok] + + +def parse_off(text): + """(vertices, triangles) of an OFF file, faces fanned into triangles. An empty or absent text is an empty mesh.""" + lines = [line.split() for line in text.splitlines() if line.strip() and not line.lstrip().startswith("#")] + if not lines or lines[0][0] != "OFF": + return Mesh((), ()) + try: + header = lines[1] if len(lines[0]) == 1 else lines[0][1:] + vertex_count, face_count = int(header[0]), int(header[1]) + first = 2 if len(lines[0]) == 1 else 1 + vertices = tuple(tuple(float(v) for v in lines[first + i][:3]) for i in range(vertex_count)) + triangles = [] + for row in lines[first + vertex_count:first + vertex_count + face_count]: + ring = [int(v) for v in row[1:1 + int(row[0])]] + triangles += [(ring[0], ring[i], ring[i + 1]) for i in range(1, len(ring) - 1)] + if any(not 0 <= v < vertex_count for tri in triangles for v in tri): + raise ValueError + except (ValueError, IndexError): + raise HullSourceError("The hull source made geometry that is not a readable OFF file") from None + return Mesh(vertices, tuple(triangles)) + + +def parse_echoes(text, name): + """The values of every 'ECHO: = ' line. OpenSCAD prints JSON-compatible values.""" + values = [] + for key, raw in ECHO_LINE.findall(text): + if key != name: + continue + try: + values.append(json.loads(raw)) + except ValueError: + raise HullSourceError(f"The hull source echoed a {name} that is not plain numbers, strings and lists") from None + return values + + +def _vector(value, size, numbers=True): + if not isinstance(value, list) or len(value) != size or (numbers and not all(_number(v) for v in value)): + raise HullSourceError("The hull source echoed a vector of the wrong shape") + return tuple(value) + + +def _number(value): + return isinstance(value, (int, float)) and not isinstance(value, bool) + + +def _text(value): + if not isinstance(value, str): + raise HullSourceError("The hull source echoed a name that is not a string") + return value + + +def read_brief(text): + (raw,) = _exactly_one(parse_echoes(text, "brief"), "brief") + if not isinstance(raw, list) or len(raw) != 6 or not isinstance(raw[5], list): + raise HullSourceError("The hull source's brief has the wrong shape") + return Brief(_text(raw[0]), _text(raw[1]), _text(raw[2]), _vector(raw[3], 2), _vector(raw[4], 2), + tuple(_text(r) for r in raw[5])) + + +def _proportion(raw): + if raw == []: + return None + if not isinstance(raw, list) or len(raw) != 2: + raise HullSourceError("A faction language has the wrong shape") + return _vector(raw[0], 2), _vector(raw[1], 2) + + +def read_languages(text): + languages = {} + for raw in parse_echoes(text, "lang"): + if not isinstance(raw, list) or len(raw) != 7 or not isinstance(raw[1], list): + raise HullSourceError("A faction language has the wrong shape") + zones = tuple((_text(z[0]), _vector(z[1], 3)) for z in raw[1] if isinstance(z, list) and len(z) == 2) + if len(zones) != len(raw[1]): + raise HullSourceError("A faction language has the wrong shape") + proportion = _proportion(raw[5]) + symmetry = _vector(raw[6], 2, numbers=False) + if symmetry[0] not in ("bilateral", "asymmetric") or not _number(symmetry[1]): + raise HullSourceError("A faction language names a symmetry rule it does not have") + language = Language(_text(raw[0]), zones, _text(raw[2]), _vector(raw[3], 3), _vector(raw[4], 3), proportion, + symmetry) + if language.body and language.body not in language.zone_names(): + raise HullSourceError("A faction language's body zone is not one of its zones") + languages[language.faction] = language + return languages + + +def read_anchors(text): + anchors = [] + for raw in parse_echoes(text, "anchor"): + if not isinstance(raw, list) or len(raw) != 5 or not _number(raw[3]): + raise HullSourceError("An anchor has the wrong shape") + anchors.append(Anchor(_text(raw[0]), _text(raw[1]), _vector(raw[2], 3), float(raw[3]), _text(raw[4]))) + return anchors + + +def _exactly_one(values, what): + if len(values) != 1: + raise HullSourceError(f"The hull source must echo exactly one {what}") + return values + + +def mirror_overlap(triangles_xy, cell=SILHOUETTE_CELL): + """Intersection over union of the top-view silhouette with its reflection across x = 0. The silhouette is every raster + cell whose centre a triangle covers (a feature thinner than a cell may not show).""" + cells = set() + for (ax, ay), (bx, by), (cx, cy) in triangles_xy: + det = (by - cy) * (ax - cx) + (cx - bx) * (ay - cy) + if abs(det) < 1e-12: + continue + for i in range(math.floor(min(ax, bx, cx) / cell), math.floor(max(ax, bx, cx) / cell) + 1): + px = (i + 0.5) * cell + for j in range(math.floor(min(ay, by, cy) / cell), math.floor(max(ay, by, cy) / cell) + 1): + py = (j + 0.5) * cell + u = ((by - cy) * (px - cx) + (cx - bx) * (py - cy)) / det + v = ((cy - ay) * (px - cx) + (ax - cx) * (py - cy)) / det + if u >= 0 and v >= 0 and u + v <= 1: + cells.add((i, j)) + mirrored = {(-i - 1, j) for i, j in cells} + union = len(cells | mirrored) + return len(cells & mirrored) / union if union else 1.0 + + +def _triangles_xy(mesh): + return [tuple(mesh.vertices[v][:2] for v in tri) for tri in mesh.triangles] + + +def build(runner): + """Reads the hull source through runner (see Runner) and judges it. Raises HullSourceError when the source cannot be + read at all; otherwise returns a HullBuild whose failed() lists the checks it breaks.""" + _, text = runner("brief") + brief = read_brief(text) + _, text = runner("lang") + languages = read_languages(text) + own = languages.get(brief.faction) + donor = languages.get(brief.donor) if brief.donor else None + if own is None or (brief.donor and donor is None): + raise HullSourceError("The hull source does not include the language of its faction or its donor") + zone_keys = list(own.zone_names()) + [f"{donor.faction}:{z}" for z in (donor.zone_names() if donor else ())] + colours = {z: own.colour(z) for z in own.zone_names()} + colours.update({f"{donor.faction}:{z}": donor.colour(z) for z in (donor.zone_names() if donor else ())}) + zones = {key: parse_off(runner(key)[0]) for key in zone_keys} + _, text = runner("anchors") + anchors = read_anchors(text) + ids = [a.id for a in anchors] + if len(set(ids)) != len(ids) or any(a.role not in ANCHOR_ROLES for a in anchors): + raise HullSourceError("The hull source's anchors repeat an id or name a role a hull source does not make") + anchor_meshes = {a.id: parse_off(runner(MESH_ROLES[a.role] + a.id)[0]) for a in anchors if a.role in MESH_ROLES} + judge = donor or own + body_zone = f"{donor.faction}:{donor.body}" if donor else own.body + result = HullBuild(brief, languages, zones, colours, anchors, anchor_meshes, own.glow, own.radiator, body_zone) + result.checks = _checks(result, own, judge) + return result + + +def _checks(hull, own, judge): + brief, checks = hull.brief, [] + empty = [key for key, mesh in hull.zones.items() if not mesh.triangles] + checks.append(Check("every zone of the language has geometry", not empty, f"{len(empty)} empty")) + missing = [a.id for a in hull.anchors if a.role in MESH_ROLES and not hull.anchor_meshes[a.id].triangles] + checks.append(Check("every emitter and radiator anchor has a mesh", not missing, f"{len(missing)} without")) + checks.append(Check("a donor is named exactly when the language has no proportions of its own", + bool(brief.donor) == (own.proportion is None), "")) + body = hull.zones[hull.body_zone] if hull.body_zone in hull.zones else Mesh((), ()) + if body.triangles and judge.proportion: + low, high = body.bounds() + width, length, height = (high[0] - low[0]), (high[1] - low[1]), (high[2] - low[2]) + ratios = (height / width if width else float("inf"), width / length if length else float("inf")) + (hw_lo, hw_hi), (wl_lo, wl_hi) = judge.proportion + checks.append(Check("the body's proportions lie inside the language's ranges", + hw_lo <= ratios[0] <= hw_hi and wl_lo <= ratios[1] <= wl_hi, + f"h/w {ratios[0]:.2f}, w/l {ratios[1]:.2f}")) + else: + checks.append(Check("the body's proportions lie inside the language's ranges", False, "no body to judge")) + every = [m for m in list(hull.zones.values()) + list(hull.anchor_meshes.values()) if m.triangles] + if every: + overlap = mirror_overlap([t for m in every for t in _triangles_xy(m)]) + rule, bound = own.symmetry + checks.append(Check(f"the top view obeys the language's {rule} rule", + overlap >= bound if rule == "bilateral" else overlap <= bound, f"mirror overlap {overlap:.2f}")) + ys = [v[1] for m in every for v in m.vertices] + length = max(ys) - min(ys) + checks.append(Check("the length lies inside the brief's range", brief.length[0] <= length <= brief.length[1], + f"{length:.1f} m")) + else: + checks.append(Check("the top view obeys the language's symmetry rule", False, "no geometry")) + checks.append(Check("the length lies inside the brief's range", False, "no geometry")) + triangles = sum(len(m.triangles) for m in every) + checks.append(Check("the triangle count lies inside the brief's budget", + brief.triangles[0] <= triangles <= brief.triangles[1], f"{triangles}")) + by_id = {a.id: a for a in hull.anchors} + unmuzzled = [a.id for a in hull.anchors if a.role == "weapon-mount" + and not any(m.role == "weapon-muzzle" and m.parent == a.id for m in hull.anchors)] + forward = [m.id for m in hull.anchors if m.role == "weapon-muzzle" and m.parent in by_id + and by_id[m.parent].role == "weapon-mount" and m.position[1] >= by_id[m.parent].position[1]] + orphans = [m.id for m in hull.anchors if m.role == "weapon-muzzle" + and (m.parent not in by_id or by_id[m.parent].role != "weapon-mount")] + checks.append(Check("every weapon-mount has a muzzle nose-ward of it and no muzzle is orphaned", + not (unmuzzled or forward or orphans), + f"{len(unmuzzled)} without a muzzle, {len(forward)} not nose-ward, {len(orphans)} orphaned")) + return checks + + +def openscad_runner(executable, source, library=FACTIONS_DIR, on_start=None): + """A Runner that runs OpenSCAD (Manifold backend, full render) on source, one process at a time. on_start(pid) is + called with each process's id. OPENSCADPATH is the language folder, so a hull source includes .""" + env = dict(os.environ, OPENSCADPATH=str(library)) + + def run(part): + with tempfile.TemporaryDirectory() as folder: + target = Path(folder) / ("part.echo" if part in ECHO_PARTS else "part.off") + command = [str(executable), "--backend", "Manifold", "--render", "all", "-D", f'part="{part}"', + "-o", str(target), str(source)] + try: + process = subprocess.Popen(command, stdout=subprocess.PIPE, stderr=subprocess.STDOUT, text=True, env=env) + except OSError: + raise HullSourceError("OpenSCAD could not be started") from None + if on_start: + on_start(process.pid) + try: + output, _ = process.communicate(timeout=OPENSCAD_TIMEOUT) + except subprocess.TimeoutExpired: + process.kill() + process.communicate() + raise HullSourceError("OpenSCAD did not finish a part in time") from None + if process.returncode != 0: + raise HullSourceError("OpenSCAD refused a part of the hull source") + written = target.read_text(encoding="utf-8", errors="replace") if target.exists() else "" + return ("", written + output) if part in ECHO_PARTS else (written, output) + + return run diff --git a/tools/blender/tests/fixtures/hulls/pi-fighter/hull.scad b/tools/blender/tests/fixtures/hulls/pi-fighter/hull.scad new file mode 100644 index 000000000..3af7a1812 --- /dev/null +++ b/tools/blender/tests/fixtures/hulls/pi-fighter/hull.scad @@ -0,0 +1,50 @@ +// Pirate Coalition fighter "Markdown" on a stolen Rossum & Douglas wedge. An agent-built hull source: the donor body +// comes from the Rossum & Douglas language, every addition from the Pirate language. +// part = lang | brief | anchors | rnd: | primer | weld | accent | recess | emit: +include +include +part = "rnd:primary"; + +HULL_FACTION = "pirate"; +HULL_DONOR = "rnd"; +HULL_ROLE = "fighter"; +HULL_LENGTH = [9, 16]; +HULL_TRIANGLES = [300, 6000]; +HULL_READS = ["the stolen R&D wedge is still recognisable (flat dorsal plane, blunt nose, flush engine face)", + "one oversized gun out of proportion to the hull, on an adapted mount", + "a replacement drive that does not match", "bolted salvage plates, an exposed cable run, one stolen sensor pod", + "asymmetric"]; +if (part == "brief") echo(brief = [HULL_FACTION, HULL_DONOR, HULL_ROLE, HULL_LENGTH, HULL_TRIANGLES, HULL_READS]); + +L = 11; WR = 8; WN = 2.4; +module cut() { translate([-WR/2+0.4, L/2-0.6, 0]) rotate([0,0,35]) cube([3.2, 2.6, 3], center=true); } +module wedge() { difference() { rd_wedge(L, WR, WN, 0.55, 1.5); cut(); translate([0, L/2, 0]) rd_engine_bank(5.4, 0.4, 3); translate([0, -L/2+2.2, 0.75]) rd_window(1.4); } } +module stripe() { translate([1.1, 0.5, 0.75]) cube([0.9, L*0.55, 0.04], center=true); } +module donor_hatches() { for (y=[-1.2, 1.4]) translate([-1.4, y, 0.75]) rd_hatch(); } +module gun() { translate([2.3, -0.8, 0.45]) { pi_saddle(1.2, 2.4, 0.5); translate([0, 0, 0.9]) pi_big_gun(9.0, 0.18); } } +module drive() { translate([-2.5, L/2-0.2, 0.15]) { pi_drive(1.05, 2.6); + for (a=[[0.9,-1.4,0.2],[-0.9,-1.4,0.2],[0,-1.4,0.9]]) strut([0,0,0], a, 0.07); } } +module patch() { translate([-WR/2+1.25, L/2-1.25, 0.2]) rotate([0,0,35]) rotate([0,90,0]) pi_plate(0.7, 2.8, 0.12, 0); } +module plates() { translate([1.0, -L/2+1.9, 0.62]) pi_plate(1.4, 1.2, 0.12, 6); translate([-0.6, 2.4, 0.8]) pi_plate(1.8, 1.4, 0.12, -5); } +module welded_engine() { translate([1.8, L/2+0.05, 0]) cube([1.6, 0.12, 0.4], center=true); } +module cables() { pi_cable([[2.0, 0.6, 1.0], [1.0, 1.6, 0.9], [-0.6, 2.8, 0.95], [-1.6, 3.6, 0.85], [-2.3, 4.0, 0.9]]); } +module pod() { translate([-1.4, -2.6, 1.3]) pi_sensor_pod(1.5, 0.32); strut([-1.4, -2.6, 0.7], [-1.4, -2.6, 1.1], 0.07); } +module crew_slash() { translate([-0.2, -1.6, 0.78]) rotate([0,0,-38]) cube([0.55, 4.4, 0.04], center=true); } + +if (part == "rnd:primary") wedge(); +if (part == "rnd:secondary") donor_hatches(); +if (part == "rnd:accent") stripe(); +if (part == "rnd:recess") { translate([0, -L/2+2.2, 0.72]) rd_window(1.3); translate([0, L/2-0.25, 0]) cube([1.6, 0.05, 0.36], center=true); } +if (part == "primer") { patch(); plates(); welded_engine(); } +if (part == "weld") { gun(); drive(); cables(); } +if (part == "accent") { crew_slash(); pod(); } +if (part == "recess") translate([-2.5, L/2-0.2, 0.15]) pi_collar(1.15, 0.15); +if (part == "emit:Th.Main") translate([-2.5, L/2-0.2+1.17, 0.15]) rotate([90,0,0]) cylinder(r=0.95, h=0.05, center=true, $fn=8); +if (part == "emit:Th.L") translate([0, L/2-0.22, 0]) cube([1.2, 0.05, 0.32], center=true); +// Ids are HullData mount ids: the gameplay layout addresses these by name only. +if (part == "anchors") { + echo(anchor=["Gun.P", "weapon-mount", [2.3, -0.8, 1.35], 0, ""]); + echo(anchor=["Gun.P.muzzle", "weapon-muzzle", [2.3, -0.8-0.17*9.0-0.65*9.0-0.4, 1.4], 0, "Gun.P"]); + echo(anchor=["Th.Main", "thruster-emitter", [-2.5, L/2-0.2+1.17, 0.15], 180, ""]); + echo(anchor=["Th.L", "thruster-emitter", [0, L/2-0.22, 0], 180, ""]); +} diff --git a/tools/blender/tests/fixtures/hulls/zh-ammo-tender/hull.scad b/tools/blender/tests/fixtures/hulls/zh-ammo-tender/hull.scad new file mode 100644 index 000000000..dcd20ec00 --- /dev/null +++ b/tools/blender/tests/fixtures/hulls/zh-ammo-tender/hull.scad @@ -0,0 +1,49 @@ +// Zhestokost ammunition tender "Podvoz". An agent-built hull source: composes the Zhestokost language only. +// part = lang | brief | anchors | primary | secondary | accent | recess | emit: | rad: +// Frame: Ship Root, metres, nose -Y, starboard -X, up +Z. Anchor yaw: 0 nose, 90 port, 180 aft, 270 starboard. +include +part = "primary"; + +HULL_FACTION = "zhestokost"; +HULL_DONOR = ""; +HULL_ROLE = "tender"; +HULL_LENGTH = [18, 28]; +HULL_TRIANGLES = [300, 6000]; +HULL_READS = ["ammunition magazines carried where a rearming ship can see and reach them", + "a handling boom that brings magazines forward", "towing points", + "one defensive ballistic mount, not a warship battery", "low, broad, heavy, slow"]; +if (part == "brief") echo(brief = [HULL_FACTION, HULL_DONOR, HULL_ROLE, HULL_LENGTH, HULL_TRIANGLES, HULL_READS]); + +L = 15; W = 10; H = 4.2; Y0 = 3; // main mass centred at y=+3 (nose is -Y) +module pockets() { for (s=[-1,1], y=[-1, 4]) translate([s*W/2, Y0+y, 1.3]) cube([1.2, 2.6, 1.4], center=true); } +module body() { difference() { translate([0, Y0, 0]) zh_stepped(L, W, H, 3, 0.10); pockets(); } } +module plates() { for (s=[-1,1], i=[0:3]) translate([s*(W/2+0.05), Y0-5.4+i*3.6, 0.6]) rotate([0,0,90]) rotate([90,0,0]) zh_plate(3.2, 1.0, 0.3); } +module band() { for (s=[-1,1], i=[0:3]) translate([s*(W/2+0.22), Y0-5.4+i*3.6, 0.6]) cube([0.04, 3.2, 0.3], center=true); } +module booms() { for (s=[-1,1]) translate([s*2.6, -5.0, H-0.3]) { zh_boom(13); translate([0, 5.6, -0.9]) cbox([1.4, 1.4, 1.6], 0.12); } } +module magazines() { for (s=[-1,1], i=[0:3]) translate([s*3.75, -10.4+i*1.9, H-1.7]) zh_cassette(1.2, 1.5, 2.0); } +module arms() { for (s=[-1,1], i=[0:3]) translate([s*3.2, -10.4+i*1.9, H-0.45]) cube([1.6, 0.3, 0.3], center=true); } +module bridge() { translate([0, Y0+5.6, H]) { cbox([4.4, 2.6, 1.4], ZH_CH); translate([0, 0.3, 1.2]) cbox([2.8, 1.8, 1.0], ZH_CH); + translate([0, -1.32, 0.35]) cube([3.2, 0.06, 0.3], center=true); } } +module lugs() { for (s=[-1,1], y=[Y0-L/2+0.8, Y0+L/2-1.0]) translate([s*(W/2-1.6), y, H/3+0.2]) zh_tow_lug(); } +module drive() { translate([0, Y0+L/2+0.6, 1.3]) zh_drive_block(6.0, 2.2, 1.6, 2, 1, 1.3); } +module gun() { translate([0, Y0+3.2, H]) zh_gun(2, 0.2, 2.6, 1.6); } +module rad(s) { translate([s*(W/2+1.35), Y0+5.2, 2.0]) cube([2.5, 2.2, 0.12], center=true); } +module recess_fill() { for (s=[-1,1], y=[-1, 4]) translate([s*(W/2-0.55), Y0+y, 1.3]) cube([0.1, 2.5, 1.3], center=true); } + +if (part == "primary") { body(); drive(); } +if (part == "secondary") { plates(); booms(); arms(); lugs(); gun(); bridge(); } +if (part == "accent") { magazines(); band(); } +if (part == "recess") recess_fill(); +if (part == "rad:Ra.L") rad(1); +if (part == "rad:Ra.R") rad(-1); +if (part == "emit:Th.L") translate([ 1.5, Y0+L/2+1.05, 1.3]) cube([1.2, 0.05, 1.2], center=true); +if (part == "emit:Th.R") translate([-1.5, Y0+L/2+1.05, 1.3]) cube([1.2, 0.05, 1.2], center=true); +// Ids are HullData mount ids: the gameplay layout addresses these by name only. +if (part == "anchors") { + echo(anchor=["Gun.D", "weapon-mount", [0, Y0+3.2, H+0.6], 0, ""]); + echo(anchor=["Gun.D.muzzle", "weapon-muzzle", [0, Y0+3.2-0.48-2.6-0.3, H+1.15], 0, "Gun.D"]); + echo(anchor=["Th.L", "thruster-emitter", [ 1.5, Y0+L/2+1.05, 1.3], 180, ""]); + echo(anchor=["Th.R", "thruster-emitter", [-1.5, Y0+L/2+1.05, 1.3], 180, ""]); + echo(anchor=["Ra.L", "radiator-mesh", [ W/2+1.35, Y0+5.2, 2.0], 0, ""]); + echo(anchor=["Ra.R", "radiator-mesh", [-W/2-1.35, Y0+5.2, 2.0], 0, ""]); +} diff --git a/tools/blender/tests/test_hull_source.py b/tools/blender/tests/test_hull_source.py new file mode 100644 index 000000000..5f66bebe0 --- /dev/null +++ b/tools/blender/tests/test_hull_source.py @@ -0,0 +1,304 @@ +"""Blender-free tests for aetheria_ships/hull_source.py, which reads a hull.scad through OpenSCAD and judges it against its +faction's form language. A fake runner answers each part with fixture OFF and ECHO text, so no OpenSCAD runs here; the +real OpenSCAD and the real language files are exercised by smoke_hull_source.py. + + python -m unittest discover -s tools/blender/tests -p "test_hull_source.py" + +hull_source is loaded by path because the package __init__ imports bpy. +""" + +import importlib.util +import json +import sys +import unittest +from pathlib import Path + +_spec = importlib.util.spec_from_file_location( + "hull_source", Path(__file__).resolve().parents[1] / "aetheria_ships" / "hull_source.py") +hs = importlib.util.module_from_spec(_spec) +sys.modules["hull_source"] = hs +_spec.loader.exec_module(hs) + +SECRET = "SECRET-NAME-7" # a value a hull source supplies and no refusal may echo + + +def box(x0, x1, y0, y1, z0, z1): + """An OFF text for an axis-aligned box: 8 vertices, 6 quads (12 triangles once fanned).""" + v = [(x, y, z) for z in (z0, z1) for y in (y0, y1) for x in (x0, x1)] + quads = [(0, 1, 3, 2), (4, 6, 7, 5), (0, 4, 5, 1), (2, 3, 7, 6), (0, 2, 6, 4), (1, 5, 7, 3)] + rows = ["OFF", "8 6 0"] + [f"{x} {y} {z}" for x, y, z in v] + [f"4 {a} {b} {c} {d}" for a, b, c, d in quads] + return "\n".join(rows) + + +def echo(name, value): + return f"ECHO: {name} = {json.dumps(value)}\n" + + +def language(faction="zhestokost", zones=("primary", "secondary"), body="primary", proportion=((0.25, 0.6), (0.35, 0.75)), + symmetry=("bilateral", 0.97)): + palette = [[z, [0.1 * (i + 1), 0.2, 0.3]] for i, z in enumerate(zones)] + return echo("lang", [faction, palette, body, [1, 0.6, 0.2], [0.5, 0.5, 0.5], [list(p) for p in proportion] if proportion + else [], list(symmetry)]) + + +def brief(faction="zhestokost", donor="", length=(8, 14), triangles=(10, 100)): + return echo("brief", [faction, donor, "tender", list(length), list(triangles), ["a read"]]) + + +class Fixture: + """A hull source as the fake runner sees it: part -> (OFF, ECHO).""" + + def __init__(self): + # Body 6 wide, 10 long, 2 high (h/w 0.33, w/l 0.6); a bilateral plate on top; one gun with a muzzle nose-ward. + self.parts = { + "brief": ("", brief()), + "lang": ("", language()), + "primary": (box(-3, 3, -5, 5, 0, 2), ""), + "secondary": (box(-1, 1, -1, 1, 2, 3), ""), + "anchors": ("", echo("anchor", ["Gun.A", "weapon-mount", [0, 0, 3], 0, ""]) + + echo("anchor", ["Gun.A.muzzle", "weapon-muzzle", [0, -3, 3], 0, "Gun.A"]) + + echo("anchor", ["Th.A", "thruster-emitter", [1, 5, 1], 180, ""])), + "emit:Th.A": (box(0.5, 1.5, 5, 5.1, 0.5, 1.5), ""), + } + + def runner(self): + return lambda part: self.parts[part] + + def build(self): + return hs.build(self.runner()) + + def failed(self): + return [c.name for c in self.build().failed()] + + +class ReadingTests(unittest.TestCase): + def test_off_fans_polygons_into_triangles(self): + mesh = hs.parse_off(box(0, 1, 0, 1, 0, 1)) + self.assertEqual((len(mesh.vertices), len(mesh.triangles)), (8, 12)) + self.assertEqual(mesh.bounds(), ((0, 0, 0), (1, 1, 1))) + + def test_off_accepts_a_header_with_counts_on_the_first_line_and_skips_comments(self): + mesh = hs.parse_off("OFF 3 1 0\n# c\n0 0 0\n1 0 0\n0 1 0\n3 0 1 2\n") + self.assertEqual(mesh.triangles, ((0, 1, 2),)) + + def test_nothing_is_an_empty_mesh_and_a_broken_file_is_refused_without_echoing_it(self): + self.assertEqual(hs.parse_off("").triangles, ()) + with self.assertRaises(hs.HullSourceError) as caught: + hs.parse_off(f"OFF\n3 1 0\n0 0 0\n1 0 0\n0 1 0\n3 0 1 9 {SECRET}\n") + self.assertNotIn(SECRET, str(caught.exception)) + + def test_echoes_are_read_by_name_and_only_plain_values_are_accepted(self): + text = echo("anchor", ["a", 1]) + echo("other", [2]) + echo("anchor", ["b", 2.5e-05]) + self.assertEqual(hs.parse_echoes(text, "anchor"), [["a", 1], ["b", 2.5e-05]]) + with self.assertRaises(hs.HullSourceError) as caught: + hs.parse_echoes(f"ECHO: anchor = [{SECRET}, undef]\n", "anchor") + self.assertNotIn(SECRET, str(caught.exception)) + + def test_a_brief_is_exactly_one_echo_of_six_fields(self): + self.assertEqual(hs.read_brief(brief()).faction, "zhestokost") + for text in ("", brief() + brief(), echo("brief", ["x"])): + with self.assertRaises(hs.HullSourceError): + hs.read_brief(text) + + def test_a_pirate_language_reads_with_no_body_and_no_proportion(self): + pirate = hs.read_languages(language("pirate", ("primer", "weld"), body="", proportion=None, + symmetry=("asymmetric", 0.9)))["pirate"] + self.assertEqual((pirate.body, pirate.proportion, pirate.symmetry), ("", None, ("asymmetric", 0.9))) + + def test_a_language_naming_an_unknown_rule_or_a_body_outside_its_zones_is_refused(self): + with self.assertRaises(hs.HullSourceError): + hs.read_languages(language(symmetry=("mostly", 0.5))) + with self.assertRaises(hs.HullSourceError): + hs.read_languages(language(body="keel")) + + +class MirrorOverlapTests(unittest.TestCase): + @staticmethod + def top(text): + mesh = hs.parse_off(text) + return hs._triangles_xy(mesh) + + def test_a_centred_box_overlaps_its_reflection_completely(self): + self.assertEqual(hs.mirror_overlap(self.top(box(-2, 2, 0, 4, 0, 1))), 1.0) + + def test_a_box_on_one_side_does_not_overlap_it_at_all(self): + self.assertEqual(hs.mirror_overlap(self.top(box(1, 3, 0, 4, 0, 1))), 0.0) + + def test_a_box_straddling_the_axis_unevenly_overlaps_by_the_shared_part(self): + # x in [-1, 3]: the reflection covers [-3, 1]; shared [-1, 1] = 2 of a union 6 wide + self.assertAlmostEqual(hs.mirror_overlap(self.top(box(-1, 3, 0, 4, 0, 1))), 2 / 6, places=2) + + +class JudgementTests(unittest.TestCase): + def test_a_hull_that_keeps_every_rule_passes_every_check(self): + self.assertEqual(Fixture().failed(), []) + + def test_the_language_decides_proportions_not_the_hull(self): + # The same 6 x 10 x 2 body: inside Zhestokost's ranges, outside a language that wants tall narrow bodies. + fixture = Fixture() + fixture.parts["lang"] = ("", language(proportion=((0.8, 1.2), (0.1, 0.3)))) + self.assertEqual(fixture.failed(), ["the body's proportions lie inside the language's ranges"]) + + def test_a_body_too_tall_for_the_language_fails_proportion_and_nothing_else(self): + fixture = Fixture() + fixture.parts["primary"] = (box(-3, 3, -5, 5, 0, 5), "") + self.assertEqual(fixture.failed(), ["the body's proportions lie inside the language's ranges"]) + + def test_a_body_too_narrow_for_the_language_fails_proportion(self): + fixture = Fixture() + fixture.parts["primary"] = (box(-1, 1, -5, 5, 0, 1), "") + self.assertIn("the body's proportions lie inside the language's ranges", fixture.failed()) + + def test_a_bilateral_language_refuses_a_lopsided_hull(self): + fixture = Fixture() + fixture.parts["secondary"] = (box(2, 6, -1, 1, 2, 3), "") + self.assertEqual(fixture.failed(), ["the top view obeys the language's bilateral rule"]) + + def test_an_asymmetric_language_refuses_a_symmetric_hull(self): + fixture = Fixture() + fixture.parts["lang"] = ("", language("zhestokost", symmetry=("asymmetric", 0.9))) + self.assertEqual(fixture.failed(), ["the top view obeys the language's asymmetric rule"]) + + def test_an_asymmetric_language_accepts_a_lopsided_hull(self): + fixture = Fixture() + fixture.parts["lang"] = ("", language("zhestokost", symmetry=("asymmetric", 0.9))) + fixture.parts["secondary"] = (box(2, 8, -4, 4, 0, 3), "") + self.assertEqual(fixture.failed(), []) + + def test_a_zone_the_language_names_must_have_geometry(self): + fixture = Fixture() + fixture.parts["secondary"] = ("", "") + self.assertEqual(fixture.failed(), ["every zone of the language has geometry"]) + + def test_the_triangle_budget_is_a_range_with_two_ends(self): + for budget in ((10, 20), (1000, 2000)): + fixture = Fixture() + fixture.parts["brief"] = ("", brief(triangles=budget)) + self.assertEqual(fixture.failed(), ["the triangle count lies inside the brief's budget"], budget) + + def test_the_length_is_a_range_with_two_ends(self): + for length in ((1, 5), (30, 40)): + fixture = Fixture() + fixture.parts["brief"] = ("", brief(length=length)) + self.assertEqual(fixture.failed(), ["the length lies inside the brief's range"], length) + + MUZZLE = "every weapon-mount has a muzzle nose-ward of it and no muzzle is orphaned" + + def test_a_muzzle_aft_of_its_mount_is_refused(self): + fixture = Fixture() + fixture.parts["anchors"] = ("", echo("anchor", ["Gun.A", "weapon-mount", [0, 0, 3], 0, ""]) + + echo("anchor", ["Gun.A.muzzle", "weapon-muzzle", [0, 1, 3], 0, "Gun.A"]) + + echo("anchor", ["Th.A", "thruster-emitter", [1, 5, 1], 180, ""])) + self.assertEqual(fixture.failed(), [self.MUZZLE]) + + def test_a_muzzle_level_with_its_mount_is_not_nose_ward(self): + fixture = Fixture() + fixture.parts["anchors"] = ("", echo("anchor", ["Gun.A", "weapon-mount", [0, 0, 3], 0, ""]) + + echo("anchor", ["Gun.A.muzzle", "weapon-muzzle", [1, 0, 3], 0, "Gun.A"])) + self.assertEqual(fixture.failed(), [self.MUZZLE]) + + def test_a_mount_without_a_muzzle_and_a_muzzle_without_a_mount_are_refused(self): + fixture = Fixture() + fixture.parts["anchors"] = ("", echo("anchor", ["Gun.A", "weapon-mount", [0, 0, 3], 0, ""])) + self.assertEqual(fixture.failed(), [self.MUZZLE]) + fixture.parts["anchors"] = ("", echo("anchor", ["Gun.A.muzzle", "weapon-muzzle", [0, -3, 3], 0, "Gun.A"])) + self.assertEqual(fixture.failed(), [self.MUZZLE]) + + def test_an_emitter_with_no_mesh_is_refused(self): + fixture = Fixture() + fixture.parts["emit:Th.A"] = ("", "") + self.assertEqual(fixture.failed(), ["every emitter and radiator anchor has a mesh"]) + + def test_the_collider_zone_is_the_languages_body(self): + self.assertEqual(Fixture().build().body_zone, "primary") + + +class DonorTests(unittest.TestCase): + """A Pirate hull: the Pirate language has no body and no proportions, so the donor's decide.""" + + @staticmethod + def pirate(donor_proportion=((0.12, 0.35), (0.55, 1.1)), donor="rnd"): + fixture = Fixture() + fixture.parts["brief"] = ("", brief("pirate", donor, length=(8, 14))) + fixture.parts["lang"] = ("", language("pirate", ("primer", "weld"), body="", proportion=None, + symmetry=("asymmetric", 0.9)) + + language("rnd", ("primary", "secondary"), proportion=donor_proportion)) + for key in ("primary", "secondary"): + del fixture.parts[key] + fixture.parts["rnd:primary"] = (box(-3, 3, -5, 5, 0, 1), "") # h/w 0.17, w/l 0.6 + fixture.parts["rnd:secondary"] = (box(-1, 1, -1, 1, 1, 1.2), "") + fixture.parts["primer"] = (box(2, 4, -4, -2, 0, 1), "") + fixture.parts["weld"] = (box(2, 6, 0, 4, 0, 1), "") + return fixture + + def test_the_donors_proportions_judge_the_body(self): + fixture = self.pirate() + self.assertEqual(fixture.build().body_zone, "rnd:primary") + self.assertEqual(fixture.failed(), []) + self.assertEqual(self.pirate(donor_proportion=((0.5, 0.9), (0.55, 1.1))).failed(), + ["the body's proportions lie inside the language's ranges"]) + + def test_the_donors_zones_are_required_and_coloured_from_the_donor(self): + fixture = self.pirate() + fixture.parts["rnd:secondary"] = ("", "") + self.assertEqual(fixture.failed(), ["every zone of the language has geometry"]) + self.assertEqual(fixture.build().colours["rnd:primary"], fixture.build().languages["rnd"].colour("primary")) + + DONOR = "a donor is named exactly when the language has no proportions of its own" + + def test_a_pirate_hull_that_names_no_donor_is_refused(self): + fixture = self.pirate(donor="") + fixture.parts["lang"] = ("", language("pirate", ("primer", "weld"), body="", proportion=None, + symmetry=("asymmetric", 0.9))) + for key in ("rnd:primary", "rnd:secondary"): + del fixture.parts[key] + self.assertIn(self.DONOR, fixture.failed()) + + def test_a_hull_whose_language_has_proportions_of_its_own_may_not_name_a_donor(self): + fixture = Fixture() + fixture.parts["brief"] = ("", brief("zhestokost", "rnd")) + fixture.parts["lang"] = ("", language() + language("rnd", ("primary", "secondary"))) + fixture.parts["rnd:primary"] = (box(-3, 3, -5, 5, 0, 1), "") + fixture.parts["rnd:secondary"] = (box(-1, 1, -1, 1, 1, 1.2), "") + self.assertIn(self.DONOR, fixture.failed()) + + +class RefusalTests(unittest.TestCase): + def test_a_source_that_includes_no_language_for_its_faction_is_refused(self): + fixture = Fixture() + fixture.parts["brief"] = ("", brief("nobody")) + with self.assertRaises(hs.HullSourceError): + fixture.build() + + def test_anchors_that_repeat_an_id_or_name_an_unknown_role_are_refused_without_echoing_them(self): + for anchors in (echo("anchor", [SECRET, "weapon-mount", [0, 0, 0], 0, ""]) * 2, + echo("anchor", [SECRET, "map-icon", [0, 0, 0], 0, ""])): + fixture = Fixture() + fixture.parts["anchors"] = ("", anchors) + with self.assertRaises(hs.HullSourceError) as caught: + fixture.build() + self.assertNotIn(SECRET, str(caught.exception)) + + def test_a_malformed_anchor_is_refused_without_echoing_it(self): + fixture = Fixture() + fixture.parts["anchors"] = ("", echo("anchor", [SECRET, "weapon-mount", [0, 0], 0, ""])) + with self.assertRaises(hs.HullSourceError) as caught: + fixture.build() + self.assertNotIn(SECRET, str(caught.exception)) + + def test_check_names_and_details_carry_no_hull_supplied_text(self): + fixture = Fixture() + fixture.parts["anchors"] = ("", echo("anchor", [SECRET, "weapon-mount", [0, 0, 3], 0, ""])) + fixture.parts["emit:Th.A"] = ("", "") + for check in fixture.build().checks: + self.assertNotIn(SECRET, check.name + check.detail) + + def test_the_module_writes_no_json_and_starts_no_second_process(self): + source = (Path(hs.__file__)).read_text(encoding="utf-8") + for forbidden in ("json.dump", "ThreadPool", "concurrent.futures", "multiprocessing"): + self.assertNotIn(forbidden, source) + self.assertEqual(source.count("subprocess.Popen"), 1) + + +if __name__ == "__main__": + unittest.main() From 24f057d18f89cfff4cf07ee77b72d6736b921800 Mon Sep 17 00:00:00 2001 From: soul Date: Sun, 18 Oct 2026 22:00:15 +0200 Subject: [PATCH 058/444] Build From Hull Source: zone meshes, mount anchors and a collider from the package's hull.scad; Package retires a hull.scad that operator art replaces Build refuses a source that breaks its language or brief, and a collection holding render meshes it did not make; it replaces only objects marked aetheria.hull_source and writes no layout row. Package deletes hull.scad, and says so, once the collection holds art and none of Build's objects. OpenSCAD path is an add-on preference. Co-Authored-By: Claude Sonnet 5.5 --- tools/blender/aetheria_ships/__init__.py | 161 +++++++++++- tools/blender/tests/smoke_hull_source.py | 314 +++++++++++++++++++++++ 2 files changed, 473 insertions(+), 2 deletions(-) create mode 100644 tools/blender/tests/smoke_hull_source.py diff --git a/tools/blender/aetheria_ships/__init__.py b/tools/blender/aetheria_ships/__init__.py index 380fdef32..a447c1ead 100644 --- a/tools/blender/aetheria_ships/__init__.py +++ b/tools/blender/aetheria_ships/__init__.py @@ -22,7 +22,7 @@ import bpy import numpy from mathutils import Matrix, Vector -from . import hull_grid +from . import hull_grid, hull_source from .ship_cc import (HARDPOINT_MEMBERS, HARDPOINT_TYPE_NAMES, ROTATION_NAMES, capture_grease_pencil, decode_hardpoint, encode_hardpoint, layout_revision, read, read_layout, replace_layout, replace_lines, replace_visual, valid_ship_id) @@ -609,6 +609,9 @@ class AETHERIA_OT_capture_ship_lines(bpy.types.Operator): return _refuse(self, exc) +DEFAULT_OPENSCAD = r"C:\Tools\OpenSCAD-2026.09.23-x86-64\openscad.com" + + class AETHERIA_AP_preferences(bpy.types.AddonPreferences): bl_idname = __package__ @@ -636,8 +639,14 @@ class AETHERIA_AP_preferences(bpy.types.AddonPreferences): name="Bake max ray distance", default=0.0, min=0.0, unit="LENGTH", description="The longest ray, in metres, Bake From Source casts from the extruded surface. 0 means no limit") + openscad: bpy.props.StringProperty( + name="OpenSCAD", subtype="FILE_PATH", default=DEFAULT_OPENSCAD, + description="The OpenSCAD executable (openscad.com on Windows, 2026.09 or later: Build From Hull Source uses its " + "Manifold backend)") + def draw(self, context): self.layout.prop(self, "game_folder") + self.layout.prop(self, "openscad") self.layout.prop(self, "max_texture") self.layout.prop(self, "bake_size") self.layout.prop(self, "bake_cage_extrusion") @@ -1515,6 +1524,146 @@ def _frame_ship(context, collection, root, path, saved_shape): _recentre(root, hull_grid.cell_centre((float(origin[0]), float(origin[1])), centre[0], centre[1])) +HULL_SOURCE_MARK = "aetheria.hull_source" # set on every object Build From Hull Source makes; Build replaces only these +HULL_COLLIDER = ("hull-collider", "collider", "Hull Collider") + + +def _hull_source_file(collection): + """The bound package's hull.scad, or None. A package holds one next to its ship.cc while its hull is agent-built.""" + stored = collection.get("aetheria.ship_cc") + if not stored or collection.get("aetheria.pending"): + return None + source = Path(bpy.path.abspath(stored)).with_name(hull_source.HULL_FILE) + return source if source.is_file() else None + + +def _hull_source_objects(collection): + return [obj for obj in collection.all_objects if obj.get(HULL_SOURCE_MARK)] + + +def _hull_material(ship_id, zone, colour, emissive=False): + """The zone's material: a flat colour from the language palette, made once per ship and zone, recoloured on a rebuild.""" + name = f"{ship_id}.{zone}" + material = bpy.data.materials.get(name) or bpy.data.materials.new(name) + material.use_nodes = True + shader = _principled(material) + shader.inputs["Base Color"].default_value = (*colour, 1.0) + shader.inputs["Metallic"].default_value = 0.0 + shader.inputs["Roughness"].default_value = 0.85 + shader.inputs["Emission Color"].default_value = (*colour, 1.0) + shader.inputs["Emission Strength"].default_value = 1.0 if emissive else 0.0 + return material + + +def _hull_mesh(name, mesh, material, offset=(0.0, 0.0, 0.0), yaw=0.0): + """A flat-shaded mesh of the hull source's, expressed in the frame of an object that stands at offset, turned yaw.""" + cos, sin = math.cos(-yaw), math.sin(-yaw) + vertices = [((x - offset[0]) * cos - (y - offset[1]) * sin, (x - offset[0]) * sin + (y - offset[1]) * cos, + z - offset[2]) for x, y, z in mesh.vertices] + data = bpy.data.meshes.new(name) + data.from_pydata(vertices, [], [tuple(tri) for tri in mesh.triangles]) + data.update() + data.materials.append(material) + return data + + +def _build_hull_source(context, collection, runner=None): + """Replaces the objects Build made before with the hull source's zone meshes, anchors and collider under Ship Root. + Refuses, before anything changes, when the source cannot be read, breaks a check of its faction's language, or the + collection holds render meshes Build did not make (operator art: Build never overwrites it). Writes no layout row.""" + source = _hull_source_file(collection) + if source is None: + raise ValueError("The bound package holds no hull.scad") + root = _ship_root(collection) + if root is not None: + _refuse_loose_meshes(context, collection) + if any(not obj.get(HULL_SOURCE_MARK) for obj in _own_meshes(collection)): + raise ValueError("The collection holds meshes the hull source did not make; Build From Hull Source " + "replaces only its own objects") + addon = context.preferences.addons.get(__package__) + executable = addon.preferences.openscad if addon and addon.preferences else DEFAULT_OPENSCAD + hull = hull_source.build(runner or hull_source.openscad_runner(executable, source)) + broken = hull.failed() + if broken: + raise ValueError("The hull source breaks its faction's language or its brief: " + "; ".join(c.name for c in broken)) + ship_id = collection["aetheria.id"] + if root is None: + root = bpy.data.objects.new(SHIP_ROOT, None) + root["aetheria.ship_root"] = True + collection.objects.link(root) + for obj in _hull_source_objects(collection): + _discard_object(obj) + + def mark(obj): + obj[HULL_SOURCE_MARK] = True + return obj + + def link(obj): + collection.objects.link(obj) + _attach_to_root(obj, root) + return mark(obj) + + for zone, mesh in hull.zones.items(): + name = zone.replace(":", ".") + link(bpy.data.objects.new(name, _hull_mesh(name, mesh, _hull_material(ship_id, name, hull.colours[zone])))) + placed, anchors = {}, {a.id: a for a in hull.anchors} + for anchor in hull.anchors: # a muzzle follows its mount in the echo order the source gave + yaw = math.radians(anchor.yaw) + if anchor.role in hull_source.MESH_ROLES: + colour, emissive = (hull.glow, True) if anchor.role == "thruster-emitter" else (hull.radiator, False) + material = _hull_material(ship_id, f"{anchor.role}.{anchor.id}", colour, emissive) + obj = link(bpy.data.objects.new( + anchor.id, _hull_mesh(anchor.id, hull.anchor_meshes[anchor.id], material, anchor.position, yaw))) + obj.location, obj.rotation_euler = anchor.position, (0.0, 0.0, yaw) + obj["aetheria.role"], obj["aetheria.id"] = anchor.role, anchor.id + elif anchor.role == "weapon-mount": + obj = mark(_make_empty(collection, anchor.id, anchor.role, root, anchor.position, yaw)) + else: + mount = anchors[anchor.parent] + turn = -math.radians(mount.yaw) + dx, dy = anchor.position[0] - mount.position[0], anchor.position[1] - mount.position[1] + local = (dx * math.cos(turn) - dy * math.sin(turn), dx * math.sin(turn) + dy * math.cos(turn), + anchor.position[2] - mount.position[2]) + order = 1 + len(_muzzles(placed[anchor.parent])) + obj = mark(_make_empty(collection, anchor.id, anchor.role, placed[anchor.parent], local, order=order)) + placed[anchor.id] = obj + role, anchor_id, name = HULL_COLLIDER + collider = link(bpy.data.objects.new(name, _hull_mesh( + name, hull.zones[hull.body_zone], _hull_material(ship_id, "collider", (0.5, 0.5, 0.5))))) + collider["aetheria.role"], collider["aetheria.id"] = role, anchor_id + return hull + + +def _retire_hull_source(collection): + """Deletes the package's hull.scad when operator art is what was packaged: the collection holds render meshes and + none of Build's objects. A hull never built, or built and packaged as it stands, keeps its source. Returns whether the + file was deleted.""" + source = _hull_source_file(collection) + root = _ship_root(collection) + if source is None or root is None or _hull_source_objects(collection) or not _own_meshes(collection): + return False + source.unlink() + return True + + +class AETHERIA_OT_build_hull_source(bpy.types.Operator): + bl_idname = "aetheria.build_hull_source" + bl_label = "Build From Hull Source" + bl_description = ("Replace the hull's generated objects with the meshes, mount anchors and collider that the " + "package's hull.scad describes, after checking it against its faction's form language. Never " + "touches the layout or objects you made") + bl_options = {"REGISTER", "UNDO"} + + def execute(self, context): + try: + collection, _ = _layout_path(context) + hull = _build_hull_source(context, collection) + self.report({"INFO"}, f"Built {len(hull.zones)} zones and {len(hull.anchors)} anchors from hull.scad") + return {"FINISHED"} + except _EXPECTED as exc: + return _refuse(self, exc) + + class AETHERIA_OT_package_ship(bpy.types.Operator): bl_idname = "aetheria.package_ship" bl_label = "Package Ship" @@ -1567,6 +1716,8 @@ class AETHERIA_OT_package_ship(bpy.types.Operator): self.report({"ERROR"}, scene.aetheria_package_report) return {"CANCELLED"} scene.aetheria_package_report = f"Packaged {len(anchors)} anchors; the validator accepted the ship" + if _retire_hull_source(collection): + scene.aetheria_package_report += "; deleted hull.scad: this package ships your art" self.report({"INFO"}, scene.aetheria_package_report) return {"FINISHED"} @@ -1610,6 +1761,11 @@ class AETHERIA_PT_ship(bpy.types.Panel): row.operator("aetheria.flip_nose", icon="LOOP_BACK") row.operator("aetheria.rasterise_hull", icon="MESH_GRID") layout.operator("aetheria.bake_from_source", icon="RENDER_RESULT") + try: + if _hull_source_file(_ship_collection(context)) is not None: + layout.operator("aetheria.build_hull_source", icon="MESH_CUBE") + except ValueError: + pass if not state.ship_id: self._draw_package(context, layout) return @@ -1685,7 +1841,8 @@ classes = (AETHERIA_PG_cell, AETHERIA_PG_hardpoint, AETHERIA_PG_layout, AETHERIA_OT_save_layout, AETHERIA_OT_bind_ship_collection, AETHERIA_OT_capture_ship_lines, AETHERIA_OT_new_ship, AETHERIA_OT_flip_nose, AETHERIA_OT_bake_from_source, - AETHERIA_OT_rasterise_hull, AETHERIA_OT_package_ship, AETHERIA_AP_preferences, AETHERIA_PT_ship) + AETHERIA_OT_rasterise_hull, AETHERIA_OT_build_hull_source, AETHERIA_OT_package_ship, + AETHERIA_AP_preferences, AETHERIA_PT_ship) def register(): diff --git a/tools/blender/tests/smoke_hull_source.py b/tools/blender/tests/smoke_hull_source.py new file mode 100644 index 000000000..ec625eb8d --- /dev/null +++ b/tools/blender/tests/smoke_hull_source.py @@ -0,0 +1,314 @@ +"""Headless Blender smoke for Build From Hull Source, with the real OpenSCAD and the real faction language files. + + blender --background --factory-startup --python-use-system-env --python tools/blender/tests/smoke_hull_source.py + +Environment: CULTLIB_PACKAGES and SMOKE_GAME_FOLDER as smoke_package.py's header says (the published AetherDb under +ModTools, GameData/Aetheria.cc), msgpack importable, and OpenSCAD 2026.09 or later at SMOKE_OPENSCAD (default: the add-on +preference's default). SMOKE_PID_LOG, when set, receives one line per OpenSCAD process. Each ship is created under +/GameData/Mods/smoke.hull.*, which this smoke removes when it ends. + +For each of the two fixture hulls (tests/fixtures/hulls: the Zhestokost ammunition tender and the Pirate fighter on a +Rossum & Douglas wedge) the smoke makes a ship from a stand-in box (New Ship), authors its gameplay layout separately +(rows named for the hull source's anchors, at cells that have nothing to do with where the model puts its gear), copies +the hull.scad into the package and runs the panel's operators: + + Build: zone meshes with one palette material each, the mount anchors, the emitter and radiator meshes and a collider, + all marked aetheria.hull_source under Ship Root, and no layout row changed by Build itself. + A second Build replaces only its own objects: an object the author made, a mesh and an empty, is still there. + Build refuses, naming a rule and no input, a hull source that breaks its language's or brief's rule, and changes nothing. + Build refuses a collection that holds a render mesh it did not make, and changes nothing. + Package accepts the built ship (the C# validator), writes ship.glb with a node per anchor, and leaves hull.scad. + Once the author has taken the generated objects over (the mark is gone) Package deletes hull.scad and says so. + A hull that was never built keeps its hull.scad through the same deletion rule. +""" + +import json +import os +import shutil +import struct +import sys +import traceback +from pathlib import Path + +import addon_utils +import bmesh +import bpy + +REPO = Path(__file__).resolve().parents[3] +PACKAGES = os.environ.get("CULTLIB_PACKAGES") or sys.exit("Set CULTLIB_PACKAGES to CultLib's packages directory") +GAME = Path(os.environ.get("SMOKE_GAME_FOLDER") or sys.exit("Set SMOKE_GAME_FOLDER to a folder holding GameData/Aetheria.cc and ModTools/AetherDb")) +OPENSCAD = os.environ.get("SMOKE_OPENSCAD") +PID_LOG = os.environ.get("SMOKE_PID_LOG") +MODS = GAME / "GameData" / "Mods" +FIXTURES = REPO / "tools" / "blender" / "tests" / "fixtures" / "hulls" +THRUSTER, RADIATOR, BALLISTIC = "3", "6", "10" + +# fixture folder, ship id, stand-in box (x, y) metres and its length in cells, then the layout rows: (mount id, kind, cell) +HULLS = ( + ("zh-ammo-tender", "smoke.hull.tender", (10.0, 23.4), 12, + (("Gun.D", BALLISTIC, (2, 3)), ("Th.L", THRUSTER, (0, 0)), ("Th.R", THRUSTER, (1, 0)), + ("Ra.L", RADIATOR, (2, 0)), ("Ra.R", RADIATOR, (3, 0))), + {"primary": (0.42, 0.43, 0.45), "secondary": (0.20, 0.20, 0.22), "accent": (0.55, 0.22, 0.14), "recess": (0.08, 0.08, 0.09)}), + ("pi-fighter", "smoke.hull.fighter", (8.0, 15.0), 8, + (("Gun.P", BALLISTIC, (3, 3)), ("Th.Main", THRUSTER, (0, 0)), ("Th.L", THRUSTER, (1, 0))), + {"rnd.primary": (0.72, 0.74, 0.76), "primer": (0.48, 0.27, 0.22), "weld": (0.70, 0.68, 0.62), + "accent": (0.10, 0.62, 0.60), "rnd.accent": (0.90, 0.89, 0.84)}), +) + + +def require(condition, message): + if not condition: + raise SystemExit(message) + + +def select(obj): + bpy.context.view_layer.objects.active = obj + obj.select_set(True) + + +def run(operator): + try: + return operator(), "" + except RuntimeError as exc: + return {"CANCELLED"}, str(exc) + + +def approx(a, b, tolerance=1e-3): + return all(abs(x - y) <= tolerance for x, y in zip(a, b)) + + +def generated(collection): + return {obj.name: obj for obj in collection.all_objects if obj.get("aetheria.hull_source")} + + +def layout_rows(scene): + return [(hp.mount_id, hp.kind, hp.armor, hp.firing_arc, hp.footprint) for hp in scene.aetheria_layout.hardpoints] + + +def glb_nodes(path): + data = Path(path).read_bytes() + length, kind = struct.unpack_from(" 0, + f"Zone {zone} is not a mesh under Ship Root") + material = obj.data.materials[0] + base = next(n for n in material.node_tree.nodes if n.type == "BSDF_PRINCIPLED").inputs["Base Color"].default_value + require(approx(tuple(base)[:3], colour), f"Zone {zone} is not its palette colour: {tuple(base)}") + require(not any(p.use_smooth for p in obj.data.polygons), f"Zone {zone} is not flat shaded") + for role, ids in anchors_by_role.items(): + for anchor_id in ids: + obj = objects.get(anchor_id) + require(obj is not None and obj.get("aetheria.role") == role and obj.get("aetheria.id") == anchor_id and obj.parent is not None, + f"Anchor {anchor_id} ({role}) was not built") + colliders = [o for o in objects.values() if o.get("aetheria.role") == "hull-collider"] + require(len(colliders) == 1 and colliders[0].type == "MESH", "Build did not make exactly one collider mesh") + return objects + + +def one_hull(scene, number, fixture, ship_id, extent, length, rows, palette): + collection, box, package = make_ship(scene, fixture, ship_id, extent, length, rows) + state = scene.aetheria_layout + root = bpy.data.objects[next(o.name for o in collection.all_objects if o.get("aetheria.ship_root"))] + rows_before = layout_rows(scene) + source = package / "hull.scad" + require(source.is_file(), "The fixture hull.scad was not copied") + require("Build From Hull Source" == bpy.ops.aetheria.build_hull_source.get_rna_type().name, "Build is not registered") + + # Build, and a rule that fails before anything changes. + result, message = run(bpy.ops.aetheria.build_hull_source) + require(result == {"FINISHED"}, f"Build was refused: {message}") + roles = {} + for obj in generated(collection).values(): + role = obj.get("aetheria.role") + if role in ("weapon-mount", "thruster-emitter", "radiator-mesh", "weapon-muzzle"): + roles.setdefault(role, []).append(obj.name) + require(roles.get("weapon-mount") and roles.get("weapon-muzzle") and roles.get("thruster-emitter"), f"Anchors missing: {roles}") + first = check_built(collection, root, palette, roles) + for muzzle in roles["weapon-muzzle"]: + mount = first[muzzle].parent + require(mount.get("aetheria.role") == "weapon-mount" and first[muzzle].get("aetheria.order") == 1, "A muzzle is not ordered under its mount") + require(first[muzzle].matrix_world.translation.y < mount.matrix_world.translation.y, "A muzzle is not nose-ward of its mount") + require(layout_rows(scene) == rows_before, "Build changed the layout buffer") + print(f"PASS {number}.1 {fixture} builds: {len(first)} objects, {sum(len(v) for v in roles.values())} anchors") + + # A second Build replaces only its own objects; the author's own empty and mesh (outside render meshes' reach: a mesh + # is tested separately) stay. + keep = bpy.data.objects.new("author note", None) + collection.objects.link(keep) + keep.parent = root + for obj in first.values(): + obj["smoke.stale"] = True + select(box) + result, message = run(bpy.ops.aetheria.build_hull_source) + require(result == {"FINISHED"}, f"The second Build was refused: {message}") + second = generated(collection) + require(sorted(second) == sorted(first), "The second Build changed the set of generated objects") + require(not any(obj.get("smoke.stale") for obj in second.values()), "The second Build kept an object of the first") + require(bpy.data.objects.get("author note") is keep and keep.parent == root, "Build removed an object the author made") + require(len([o for o in collection.all_objects if o.name.startswith("Hull Collider")]) == 1, "A second Build left a second collider") + print(f"PASS {number}.2 a second Build replaces only its own objects") + + # Operator art in the collection: Build refuses and changes nothing. + art_mesh = bpy.data.meshes.new("author hull") + art_mesh.from_pydata([(0, 0, 0), (1, 0, 0), (0, 1, 0)], [], [(0, 1, 2)]) + art = bpy.data.objects.new("author hull", art_mesh) + collection.objects.link(art) + art.parent = root + names = sorted(o.name for o in collection.all_objects) + select(box) + result, message = run(bpy.ops.aetheria.build_hull_source) + require(result == {"CANCELLED"} and "did not make" in message, f"Build overwrote or ignored operator art: {message!r}") + require(sorted(o.name for o in collection.all_objects) == names, "A refused Build changed objects") + for echoed in (ship_id, str(GAME), "author hull"): + require(echoed not in message, f"The refusal echoes an input ({echoed})") + bpy.data.objects.remove(art) + print(f"PASS {number}.3 Build refuses operator art and changes nothing") + + # A hull source that breaks the brief: the refusal names the rule and nothing changes. + original = source.read_text(encoding="utf-8") + source.write_text(original.replace("HULL_LENGTH = [", "HULL_LENGTH = [200, 300]; UNUSED = ["), encoding="utf-8") + names = sorted(o.name for o in collection.all_objects) + select(box) + result, message = run(bpy.ops.aetheria.build_hull_source) + require(result == {"CANCELLED"} and "the length lies inside the brief's range" in message, f"A broken brief was built: {message!r}") + require(sorted(o.name for o in collection.all_objects) == names, "A refused Build changed objects") + for echoed in (ship_id, str(GAME), fixture): + require(echoed not in message, f"The refusal echoes an input ({echoed})") + source.write_text(original, encoding="utf-8") + print(f"PASS {number}.4 Build refuses a hull that breaks its brief") + + # Package: the validator accepts the built ship and the GLB holds a node per anchor. + select(box) + result, message = run(bpy.ops.aetheria.package_ship) + report = scene.aetheria_package_report + require(result == {"FINISHED"} and "validator accepted" in report, f"Package refused the built ship: {report!r} {message!r}") + require(source.is_file(), "Package deleted a hull source whose hull is still built from it") + nodes = {n.get("name"): n for n in glb_nodes(package / "ship.glb")} + for role, ids in roles.items(): + for anchor_id in ids: + require(anchor_id in nodes, f"The GLB has no node for {anchor_id}") + for zone in palette: + require(zone in nodes, f"The GLB has no mesh node for zone {zone}") + moved = [r for r in (hp for hp in state.hardpoints) if r.mount_id in roles["weapon-mount"]] + print(f"INFO {number}.5 weapon rows after Package (fork package-derives-schematic-from-model): " + f"{[(hp.mount_id, (hp.x, hp.y)) for hp in moved]} (authored at {[r[2] for r in rows if r[1] == BALLISTIC]})") + print(f"PASS {number}.5 Package accepts the built ship and keeps hull.scad") + + # The author takes the generated objects over: Package deletes hull.scad and says so. + for obj in generated(collection).values(): + del obj["aetheria.hull_source"] + select(box) + result, message = run(bpy.ops.aetheria.package_ship) + report = scene.aetheria_package_report + require(result == {"FINISHED"} and "deleted hull.scad" in report, f"Package did not retire the hull source: {report!r} {message!r}") + require(not source.exists(), "hull.scad is still there after the author's art was packaged") + require(all(echoed not in report for echoed in (ship_id, str(GAME))), "The report echoes an input") + print(f"PASS {number}.6 operator art retires hull.scad, with a report") + return collection + + +def clear(collection): + """Removes a finished ship's objects: Blender names objects once per file, and anchors are found by name.""" + for obj in list(collection.all_objects if collection else ()): + bpy.data.objects.remove(obj) + + +def never_built(scene): + """A hull.scad whose hull was never built has no art in the collection: the deletion rule keeps it.""" + ship_id = "smoke.hull.unbuilt" + collection, box, package = make_ship(scene, "pi-fighter", ship_id, (8.0, 15.0), 8, ()) + api = sys.modules["aetheria_ships"] + require(api._retire_hull_source(collection) is False and (package / "hull.scad").is_file(), + "A hull that was never built lost its hull.scad") + print("PASS 3 a hull that was never built keeps its hull.scad") + + +def main(): + require(bpy.app.version[:2] == (5, 2), f"This smoke is for Blender 5.2; this is {bpy.app.version_string}") + bpy.data.objects.remove(bpy.data.objects["Cube"], do_unlink=True) + sys.path.insert(0, str(REPO / "tools" / "blender")) + addon_utils.enable("aetheria_ships", default_set=True, handle_error=None) + import aetheria_ships + aetheria_ships._brokkr_cultlib = lambda context: PACKAGES + preferences = bpy.context.preferences.addons["aetheria_ships"].preferences + preferences.game_folder = str(GAME) + if OPENSCAD: + preferences.openscad = OPENSCAD + real = aetheria_ships.hull_source.openscad_runner + + def logged(executable, source, **kwargs): + def on_start(pid): + if PID_LOG: + with open(PID_LOG, "a") as log: + log.write(f"{pid}\n") + return real(executable, source, on_start=on_start, **kwargs) + + aetheria_ships.hull_source.openscad_runner = logged + for _, ship_id, *_ in HULLS: + require(not (MODS / ship_id).exists(), f"{MODS / ship_id} exists; remove it first") + scene = bpy.context.scene + previous = None + for number, (fixture, ship_id, extent, length, rows, palette) in enumerate(HULLS, start=1): + clear(previous) + previous = one_hull(scene, number, fixture, ship_id, extent, length, rows, palette) + clear(previous) + never_built(scene) + + +created = not MODS.exists() +try: + main() +except SystemExit as stop: + print(f"SMOKE_HULL_SOURCE_FAILED: {stop}", flush=True) + code = 1 +except Exception: + traceback.print_exc() + print("SMOKE_HULL_SOURCE_FAILED", flush=True) + code = 1 +else: + print("SMOKE_HULL_SOURCE_OK", flush=True) + code = 0 +finally: + for _, ship_id, *_ in HULLS + ((None, "smoke.hull.unbuilt"),): + shutil.rmtree(MODS / ship_id, ignore_errors=True) + if created: + shutil.rmtree(MODS, ignore_errors=True) +sys.stdout.flush() +os._exit(code) From 2e597b0715a176a276872eda7a6328269f295c8e Mon Sep 17 00:00:00 2001 From: soul Date: Sun, 18 Oct 2026 22:04:42 +0200 Subject: [PATCH 059/444] test_hull_source: the survivors of the first hand-mutant round get tests (vertex bound, exact bilateral bound, emitter triangles in the budget, non-mount muzzle parent, donor colours, missing donor language, empty silhouette) Co-Authored-By: Claude Sonnet 5.5 --- tools/blender/tests/test_hull_source.py | 35 ++++++++++++++++++++++++- 1 file changed, 34 insertions(+), 1 deletion(-) diff --git a/tools/blender/tests/test_hull_source.py b/tools/blender/tests/test_hull_source.py index 5f66bebe0..62aed4168 100644 --- a/tools/blender/tests/test_hull_source.py +++ b/tools/blender/tests/test_hull_source.py @@ -87,6 +87,10 @@ class ReadingTests(unittest.TestCase): hs.parse_off(f"OFF\n3 1 0\n0 0 0\n1 0 0\n0 1 0\n3 0 1 9 {SECRET}\n") self.assertNotIn(SECRET, str(caught.exception)) + def test_a_face_naming_the_vertex_one_past_the_last_is_refused(self): + with self.assertRaises(hs.HullSourceError): + hs.parse_off("\n".join(["OFF", "3 1 0", "0 0 0", "1 0 0", "0 1 0", "3 0 1 3"])) + def test_echoes_are_read_by_name_and_only_plain_values_are_accepted(self): text = echo("anchor", ["a", 1]) + echo("other", [2]) + echo("anchor", ["b", 2.5e-05]) self.assertEqual(hs.parse_echoes(text, "anchor"), [["a", 1], ["b", 2.5e-05]]) @@ -121,6 +125,9 @@ class MirrorOverlapTests(unittest.TestCase): def test_a_centred_box_overlaps_its_reflection_completely(self): self.assertEqual(hs.mirror_overlap(self.top(box(-2, 2, 0, 4, 0, 1))), 1.0) + def test_nothing_overlaps_itself_completely(self): + self.assertEqual(hs.mirror_overlap([]), 1.0) + def test_a_box_on_one_side_does_not_overlap_it_at_all(self): self.assertEqual(hs.mirror_overlap(self.top(box(1, 3, 0, 4, 0, 1))), 0.0) @@ -154,6 +161,11 @@ class JudgementTests(unittest.TestCase): fixture.parts["secondary"] = (box(2, 6, -1, 1, 2, 3), "") self.assertEqual(fixture.failed(), ["the top view obeys the language's bilateral rule"]) + def test_a_bilateral_bound_is_met_by_exactly_that_overlap(self): + fixture = Fixture() + fixture.parts["lang"] = ("", language(symmetry=("bilateral", 1.0))) + self.assertEqual(fixture.failed(), []) + def test_an_asymmetric_language_refuses_a_symmetric_hull(self): fixture = Fixture() fixture.parts["lang"] = ("", language("zhestokost", symmetry=("asymmetric", 0.9))) @@ -176,6 +188,12 @@ class JudgementTests(unittest.TestCase): fixture.parts["brief"] = ("", brief(triangles=budget)) self.assertEqual(fixture.failed(), ["the triangle count lies inside the brief's budget"], budget) + def test_the_budget_counts_the_triangles_of_the_emitter_meshes_too(self): + # 12 + 12 triangles of zones, 12 of the emitter: a budget of 30 holds without the emitter and not with it + fixture = Fixture() + fixture.parts["brief"] = ("", brief(triangles=(10, 30))) + self.assertEqual(fixture.failed(), ["the triangle count lies inside the brief's budget"]) + def test_the_length_is_a_range_with_two_ends(self): for length in ((1, 5), (30, 40)): fixture = Fixture() @@ -204,6 +222,12 @@ class JudgementTests(unittest.TestCase): fixture.parts["anchors"] = ("", echo("anchor", ["Gun.A.muzzle", "weapon-muzzle", [0, -3, 3], 0, "Gun.A"])) self.assertEqual(fixture.failed(), [self.MUZZLE]) + def test_a_muzzle_whose_parent_is_not_a_weapon_mount_is_refused(self): + fixture = Fixture() + fixture.parts["anchors"] = ("", echo("anchor", ["Th.A", "thruster-emitter", [1, 5, 1], 180, ""]) + + echo("anchor", ["Th.A.muzzle", "weapon-muzzle", [1, 2, 1], 0, "Th.A"])) + self.assertEqual(fixture.failed(), [self.MUZZLE]) + def test_an_emitter_with_no_mesh_is_refused(self): fixture = Fixture() fixture.parts["emit:Th.A"] = ("", "") @@ -242,7 +266,10 @@ class DonorTests(unittest.TestCase): fixture = self.pirate() fixture.parts["rnd:secondary"] = ("", "") self.assertEqual(fixture.failed(), ["every zone of the language has geometry"]) - self.assertEqual(fixture.build().colours["rnd:primary"], fixture.build().languages["rnd"].colour("primary")) + built = fixture.build() + for zone in ("primary", "secondary"): + self.assertEqual(built.colours[f"rnd:{zone}"], built.languages["rnd"].colour(zone)) + self.assertNotEqual(built.colours["rnd:primary"], built.colours["rnd:secondary"]) DONOR = "a donor is named exactly when the language has no proportions of its own" @@ -270,6 +297,12 @@ class RefusalTests(unittest.TestCase): with self.assertRaises(hs.HullSourceError): fixture.build() + def test_a_named_donor_whose_language_the_source_does_not_include_is_refused(self): + fixture = Fixture() + fixture.parts["brief"] = ("", brief("zhestokost", "rnd")) + with self.assertRaises(hs.HullSourceError): + fixture.build() + def test_anchors_that_repeat_an_id_or_name_an_unknown_role_are_refused_without_echoing_them(self): for anchors in (echo("anchor", [SECRET, "weapon-mount", [0, 0, 0], 0, ""]) * 2, echo("anchor", [SECRET, "map-icon", [0, 0, 0], 0, ""])): From f9acdc1e9c1e9b4dccb7005c011cc18d18ab2ad5 Mon Sep 17 00:00:00 2001 From: soul Date: Sun, 18 Oct 2026 22:08:53 +0200 Subject: [PATCH 060/444] smoke_hull_source: the collider is the language's body zone mesh (a hand mutant took the first zone and survived) Co-Authored-By: Claude Sonnet 5.5 --- tools/blender/tests/smoke_hull_source.py | 19 +++++++++++-------- 1 file changed, 11 insertions(+), 8 deletions(-) diff --git a/tools/blender/tests/smoke_hull_source.py b/tools/blender/tests/smoke_hull_source.py index ec625eb8d..b2e803349 100644 --- a/tools/blender/tests/smoke_hull_source.py +++ b/tools/blender/tests/smoke_hull_source.py @@ -43,15 +43,16 @@ MODS = GAME / "GameData" / "Mods" FIXTURES = REPO / "tools" / "blender" / "tests" / "fixtures" / "hulls" THRUSTER, RADIATOR, BALLISTIC = "3", "6", "10" -# fixture folder, ship id, stand-in box (x, y) metres and its length in cells, then the layout rows: (mount id, kind, cell) +# fixture folder, ship id, stand-in box (x, y) metres and its length in cells, the layout rows (mount id, kind, cell), +# the body zone the collider copies, and the palette each zone must carry HULLS = ( ("zh-ammo-tender", "smoke.hull.tender", (10.0, 23.4), 12, (("Gun.D", BALLISTIC, (2, 3)), ("Th.L", THRUSTER, (0, 0)), ("Th.R", THRUSTER, (1, 0)), ("Ra.L", RADIATOR, (2, 0)), ("Ra.R", RADIATOR, (3, 0))), - {"primary": (0.42, 0.43, 0.45), "secondary": (0.20, 0.20, 0.22), "accent": (0.55, 0.22, 0.14), "recess": (0.08, 0.08, 0.09)}), + "primary", {"primary": (0.42, 0.43, 0.45), "secondary": (0.20, 0.20, 0.22), "accent": (0.55, 0.22, 0.14), "recess": (0.08, 0.08, 0.09)}), ("pi-fighter", "smoke.hull.fighter", (8.0, 15.0), 8, (("Gun.P", BALLISTIC, (3, 3)), ("Th.Main", THRUSTER, (0, 0)), ("Th.L", THRUSTER, (1, 0))), - {"rnd.primary": (0.72, 0.74, 0.76), "primer": (0.48, 0.27, 0.22), "weld": (0.70, 0.68, 0.62), + "rnd.primary", {"rnd.primary": (0.72, 0.74, 0.76), "primer": (0.48, 0.27, 0.22), "weld": (0.70, 0.68, 0.62), "accent": (0.10, 0.62, 0.60), "rnd.accent": (0.90, 0.89, 0.84)}), ) @@ -122,7 +123,7 @@ def make_ship(scene, fixture, ship_id, extent, length, rows): return collection, box, package -def check_built(collection, root, palette, anchors_by_role): +def check_built(collection, root, body, palette, anchors_by_role): objects = generated(collection) for zone, colour in palette.items(): obj = objects.get(zone) @@ -139,10 +140,12 @@ def check_built(collection, root, palette, anchors_by_role): f"Anchor {anchor_id} ({role}) was not built") colliders = [o for o in objects.values() if o.get("aetheria.role") == "hull-collider"] require(len(colliders) == 1 and colliders[0].type == "MESH", "Build did not make exactly one collider mesh") + shape = lambda obj: sorted(tuple(round(c, 4) for c in v.co) for v in obj.data.vertices) + require(shape(colliders[0]) == shape(objects[body]), "The collider is not the body zone's mesh") return objects -def one_hull(scene, number, fixture, ship_id, extent, length, rows, palette): +def one_hull(scene, number, fixture, ship_id, extent, length, rows, body, palette): collection, box, package = make_ship(scene, fixture, ship_id, extent, length, rows) state = scene.aetheria_layout root = bpy.data.objects[next(o.name for o in collection.all_objects if o.get("aetheria.ship_root"))] @@ -160,7 +163,7 @@ def one_hull(scene, number, fixture, ship_id, extent, length, rows, palette): if role in ("weapon-mount", "thruster-emitter", "radiator-mesh", "weapon-muzzle"): roles.setdefault(role, []).append(obj.name) require(roles.get("weapon-mount") and roles.get("weapon-muzzle") and roles.get("thruster-emitter"), f"Anchors missing: {roles}") - first = check_built(collection, root, palette, roles) + first = check_built(collection, root, body, palette, roles) for muzzle in roles["weapon-muzzle"]: mount = first[muzzle].parent require(mount.get("aetheria.role") == "weapon-mount" and first[muzzle].get("aetheria.order") == 1, "A muzzle is not ordered under its mount") @@ -285,9 +288,9 @@ def main(): require(not (MODS / ship_id).exists(), f"{MODS / ship_id} exists; remove it first") scene = bpy.context.scene previous = None - for number, (fixture, ship_id, extent, length, rows, palette) in enumerate(HULLS, start=1): + for number, (fixture, ship_id, extent, length, rows, body, palette) in enumerate(HULLS, start=1): clear(previous) - previous = one_hull(scene, number, fixture, ship_id, extent, length, rows, palette) + previous = one_hull(scene, number, fixture, ship_id, extent, length, rows, body, palette) clear(previous) never_built(scene) From 1e7678526ac9e0a9d8a51ae749c8333a45e9b6cc Mon Sep 17 00:00:00 2001 From: soul Date: Sun, 18 Oct 2026 22:34:41 +0200 Subject: [PATCH 061/444] Agent hulls: the faction file owns its language; Build owns what it recorded, not what is marked hull_source.build reads the faction's and donor's language from factions/.scad run alone and only compares the hull's own lang echo with it; a hull that reassigns, re-echoes or shadows a language fails a check. Build records what it made on the collection by name and fingerprint instead of marking objects. A copy (another name) or an edit (another fingerprint) is the author's: Build refuses and deletes nothing. Package retires hull.scad when render meshes that are not Build's remain and no zone mesh of Build's does, so keeping Build's anchors and collider no longer blocks takeover. Unit tests cover the runner (arguments, exit status, OPENSCADPATH), non-string names, the width/length bound, own-zone colours and the language ownership; the smoke pins world poses of mounts, emitters and emitter meshes (a mount turned 90 degrees included), the language attacks with the real OpenSCAD, copy and edit, Build after Package and the takeover. Co-Authored-By: Claude Sonnet 5.5 --- tools/blender/aetheria_ships/__init__.py | 54 +++-- tools/blender/aetheria_ships/hull_source.py | 75 +++++-- tools/blender/tests/smoke_hull_source.py | 176 ++++++++++++++- tools/blender/tests/test_hull_source.py | 236 ++++++++++++++++++-- 4 files changed, 488 insertions(+), 53 deletions(-) diff --git a/tools/blender/aetheria_ships/__init__.py b/tools/blender/aetheria_ships/__init__.py index a447c1ead..e52f4ffd1 100644 --- a/tools/blender/aetheria_ships/__init__.py +++ b/tools/blender/aetheria_ships/__init__.py @@ -1524,7 +1524,7 @@ def _frame_ship(context, collection, root, path, saved_shape): _recentre(root, hull_grid.cell_centre((float(origin[0]), float(origin[1])), centre[0], centre[1])) -HULL_SOURCE_MARK = "aetheria.hull_source" # set on every object Build From Hull Source makes; Build replaces only these +HULL_BUILT = "aetheria.hull_built" # on the collection: {object name: fingerprint} of what Build From Hull Source made HULL_COLLIDER = ("hull-collider", "collider", "Hull Collider") @@ -1537,8 +1537,24 @@ def _hull_source_file(collection): return source if source.is_file() else None -def _hull_source_objects(collection): - return [obj for obj in collection.all_objects if obj.get(HULL_SOURCE_MARK)] +def _fingerprint(obj): + """What Build made of obj, as it stands: its place under its parent, the anchor properties and, for a mesh, its geometry. + A copy has another name and an edit changes this, so neither is taken for Build's own.""" + values = [obj.parent.name if obj.parent else "", tuple(obj.location), tuple(obj.rotation_euler), tuple(obj.scale), + obj.get("aetheria.role", ""), obj.get("aetheria.id", ""), obj.get("aetheria.order", 0)] + if obj.type == "MESH": + values += [[tuple(v.co) for v in obj.data.vertices], [tuple(p.vertices) for p in obj.data.polygons]] + return hull_source.fingerprint(*values) + + +def _hull_built(collection): + """(untouched, changed): Build's objects found in the collection as it made them, and those it made that were edited. + The record is the only owner: an object is Build's when its name and fingerprint are the ones recorded.""" + record = collection.get(HULL_BUILT) + record = record.to_dict() if record is not None else {} + present = {obj.name: obj for obj in collection.all_objects if obj.name in record} + untouched = [obj for name, obj in present.items() if _fingerprint(obj) == record[name]] + return untouched, [obj for obj in present.values() if obj not in untouched] def _hull_material(ship_id, zone, colour, emissive=False): @@ -1567,7 +1583,7 @@ def _hull_mesh(name, mesh, material, offset=(0.0, 0.0, 0.0), yaw=0.0): return data -def _build_hull_source(context, collection, runner=None): +def _build_hull_source(context, collection): """Replaces the objects Build made before with the hull source's zone meshes, anchors and collider under Ship Root. Refuses, before anything changes, when the source cannot be read, breaks a check of its faction's language, or the collection holds render meshes Build did not make (operator art: Build never overwrites it). Writes no layout row.""" @@ -1575,14 +1591,16 @@ def _build_hull_source(context, collection, runner=None): if source is None: raise ValueError("The bound package holds no hull.scad") root = _ship_root(collection) + untouched, changed = _hull_built(collection) if root is not None: _refuse_loose_meshes(context, collection) - if any(not obj.get(HULL_SOURCE_MARK) for obj in _own_meshes(collection)): - raise ValueError("The collection holds meshes the hull source did not make; Build From Hull Source " - "replaces only its own objects") + theirs = [obj for obj in _own_meshes(collection) if obj not in untouched] + if theirs or changed: + raise ValueError("The collection holds meshes the hull source did not make, or objects it made that were " + "changed; Build From Hull Source replaces only what it made and nobody touched") addon = context.preferences.addons.get(__package__) executable = addon.preferences.openscad if addon and addon.preferences else DEFAULT_OPENSCAD - hull = hull_source.build(runner or hull_source.openscad_runner(executable, source)) + hull = hull_source.build(hull_source.openscad_runner(executable, source), hull_source.language_library(executable)) broken = hull.failed() if broken: raise ValueError("The hull source breaks its faction's language or its brief: " + "; ".join(c.name for c in broken)) @@ -1591,11 +1609,12 @@ def _build_hull_source(context, collection, runner=None): root = bpy.data.objects.new(SHIP_ROOT, None) root["aetheria.ship_root"] = True collection.objects.link(root) - for obj in _hull_source_objects(collection): + for obj in untouched: _discard_object(obj) + made = [] def mark(obj): - obj[HULL_SOURCE_MARK] = True + made.append(obj) return obj def link(obj): @@ -1631,18 +1650,25 @@ def _build_hull_source(context, collection, runner=None): collider = link(bpy.data.objects.new(name, _hull_mesh( name, hull.zones[hull.body_zone], _hull_material(ship_id, "collider", (0.5, 0.5, 0.5))))) collider["aetheria.role"], collider["aetheria.id"] = role, anchor_id + collection[HULL_BUILT] = {obj.name: _fingerprint(obj) for obj in made} return hull def _retire_hull_source(collection): - """Deletes the package's hull.scad when operator art is what was packaged: the collection holds render meshes and - none of Build's objects. A hull never built, or built and packaged as it stands, keeps its source. Returns whether the - file was deleted.""" + """Deletes the package's hull.scad when the body is the operator's: the collection holds render meshes that Build did + not make as they stand, and no zone mesh of Build's is left. The anchors and collider she kept are hers from then on. + A hull never built, or built and packaged as it stands, keeps its source. Returns whether the file was deleted.""" source = _hull_source_file(collection) root = _ship_root(collection) - if source is None or root is None or _hull_source_objects(collection) or not _own_meshes(collection): + if source is None or root is None: + return False + untouched, _ = _hull_built(collection) + zones = [obj for obj in untouched if obj.type == "MESH" and "aetheria.role" not in obj] + if zones or not [obj for obj in _own_meshes(collection) if obj not in untouched]: return False source.unlink() + if HULL_BUILT in collection: + del collection[HULL_BUILT] return True diff --git a/tools/blender/aetheria_ships/hull_source.py b/tools/blender/aetheria_ships/hull_source.py index f66c27810..08fa00b57 100644 --- a/tools/blender/aetheria_ships/hull_source.py +++ b/tools/blender/aetheria_ships/hull_source.py @@ -1,11 +1,14 @@ """Agent-built hulls: reads a hull.scad through OpenSCAD and judges it against its faction's form language. No bpy import. A hull source is a GameData/Mods//hull.scad that includes its faction's language file (and, for a -Pirate hull, its donor's) from factions/. Everything the add-on needs is echoed by one part of that file: +Pirate hull, its donor's) from factions/. Everything the add-on needs is echoed by one part of that file. The faction's language is owned by its own file in +factions/: the checks read it from there, by the faction the brief names, and the hull's own `lang` echo is only compared +with it (a hull that redefines or shadows an included language fails a check): brief ECHO brief = [faction, donor, role, [length_min, length_max], [tris_min, tris_max], reads] lang ECHO lang = [faction, [[zone, [r, g, b]], ...], body zone, glow, radiator, proportion, symmetry] - (every included language file echoes its own; a Pirate language has no body zone and no proportion) + (every included language file echoes its own, and so does factions/.scad run alone; a Pirate + language has no body zone and no proportion) anchors ECHO anchor = [id, role, [x, y, z], yaw, parent] one line per anchor, yaw 0 nose, 90 port the zone's geometry, OFF; a donor's zone is named ":" emit:, rad: the mesh of a thruster-emitter or radiator-mesh anchor @@ -14,6 +17,7 @@ Frame: Ship Root, metres, nose -Y, starboard -X, up +Z. build() returns a HullBu check. Messages and check names are fixed text: nothing a hull source says is echoed back. """ +import hashlib import json import math import os @@ -31,6 +35,7 @@ SILHOUETTE_CELL = 0.25 # metres, the top-view raster the symmetry check compare ANCHOR_ROLES = ("weapon-mount", "weapon-muzzle", "thruster-emitter", "radiator-mesh") MESH_ROLES = {"thruster-emitter": "emit:", "radiator-mesh": "rad:"} ECHO_PARTS = ("brief", "lang", "anchors") # parts that make no geometry; their ECHO lines are the answer +LANGUAGE_NAME = re.compile(r"[a-z][a-z0-9_]*") ECHO_LINE = re.compile(r"^ECHO: (\w+) = (.*)$", re.MULTILINE) # A runner answers one part of the hull source with (OFF text, ECHO text). OFF text is "" when the part made no geometry. @@ -146,6 +151,23 @@ def parse_echoes(text, name): return values +def fingerprint(*values): + """A digest of numbers, strings and the lists of them, floats to five decimals: equal exactly when the values are.""" + digest = hashlib.sha256() + + def add(value): + if isinstance(value, (list, tuple)): + digest.update(b"[") + for item in value: + add(item) + digest.update(b"]") + else: + digest.update((f"{round(value, 5) + 0.0:.5f};" if isinstance(value, float) else f"{value!r};").encode()) + + add(values) + return digest.hexdigest() + + def _vector(value, size, numbers=True): if not isinstance(value, list) or len(value) != size or (numbers and not all(_number(v) for v in value)): raise HullSourceError("The hull source echoed a vector of the wrong shape") @@ -179,7 +201,8 @@ def _proportion(raw): def read_languages(text): - languages = {} + """Every language echoed in text, in echo order.""" + languages = [] for raw in parse_echoes(text, "lang"): if not isinstance(raw, list) or len(raw) != 7 or not isinstance(raw[1], list): raise HullSourceError("A faction language has the wrong shape") @@ -194,7 +217,7 @@ def read_languages(text): symmetry) if language.body and language.body not in language.zone_names(): raise HullSourceError("A faction language's body zone is not one of its zones") - languages[language.faction] = language + languages.append(language) return languages @@ -238,17 +261,25 @@ def _triangles_xy(mesh): return [tuple(mesh.vertices[v][:2] for v in tri) for tri in mesh.triangles] -def build(runner): - """Reads the hull source through runner (see Runner) and judges it. Raises HullSourceError when the source cannot be - read at all; otherwise returns a HullBuild whose failed() lists the checks it breaks.""" +def _language(library, faction): + """The language of factions/.scad, which owns it: exactly one echo, naming that faction.""" + (language,) = _exactly_one(read_languages(library(faction)), "language") + if language.faction != faction: + raise HullSourceError("A faction language file echoes the language of another faction") + return language + + +def build(runner, library): + """Reads the hull source through runner (see Runner) and judges it against the languages library(faction) answers with + (the ECHO text of that faction's own language file). Raises HullSourceError when the source cannot be read at all; + otherwise returns a HullBuild whose failed() lists the checks it breaks.""" _, text = runner("brief") brief = read_brief(text) _, text = runner("lang") - languages = read_languages(text) - own = languages.get(brief.faction) - donor = languages.get(brief.donor) if brief.donor else None - if own is None or (brief.donor and donor is None): - raise HullSourceError("The hull source does not include the language of its faction or its donor") + included = read_languages(text) + own = _language(library, brief.faction) + donor = _language(library, brief.donor) if brief.donor else None + owned = [own] + ([donor] if donor else []) zone_keys = list(own.zone_names()) + [f"{donor.faction}:{z}" for z in (donor.zone_names() if donor else ())] colours = {z: own.colour(z) for z in own.zone_names()} colours.update({f"{donor.faction}:{z}": donor.colour(z) for z in (donor.zone_names() if donor else ())}) @@ -261,8 +292,12 @@ def build(runner): anchor_meshes = {a.id: parse_off(runner(MESH_ROLES[a.role] + a.id)[0]) for a in anchors if a.role in MESH_ROLES} judge = donor or own body_zone = f"{donor.faction}:{donor.body}" if donor else own.body - result = HullBuild(brief, languages, zones, colours, anchors, anchor_meshes, own.glow, own.radiator, body_zone) - result.checks = _checks(result, own, judge) + result = HullBuild(brief, {language.faction: language for language in owned}, zones, colours, anchors, anchor_meshes, + own.glow, own.radiator, body_zone) + by_faction = lambda language: language.faction + result.checks = [Check("the hull includes its faction's language files and changes none of them", + sorted(included, key=by_faction) == sorted(owned, key=by_faction), "")] + result.checks += _checks(result, own, judge) return result @@ -342,3 +377,15 @@ def openscad_runner(executable, source, library=FACTIONS_DIR, on_start=None): return ("", written + output) if part in ECHO_PARTS else (written, output) return run + + +def language_library(executable, library=FACTIONS_DIR, on_start=None): + """The `library` build() reads languages from: faction -> the ECHO text of library/.scad run alone, so a hull + source (or a file beside it) cannot stand in for the faction's own file.""" + def read(faction): + file = library / f"{faction}.scad" + if not LANGUAGE_NAME.fullmatch(faction) or not file.is_file(): + raise HullSourceError("The hull source names a faction that has no language file") + return openscad_runner(executable, file, library=library, on_start=on_start)("lang")[1] + + return read diff --git a/tools/blender/tests/smoke_hull_source.py b/tools/blender/tests/smoke_hull_source.py index b2e803349..cabfddfd8 100644 --- a/tools/blender/tests/smoke_hull_source.py +++ b/tools/blender/tests/smoke_hull_source.py @@ -13,16 +13,23 @@ Rossum & Douglas wedge) the smoke makes a ship from a stand-in box (New Ship), a the hull.scad into the package and runs the panel's operators: Build: zone meshes with one palette material each, the mount anchors, the emitter and radiator meshes and a collider, - all marked aetheria.hull_source under Ship Root, and no layout row changed by Build itself. + all recorded on the collection by name and fingerprint under Ship Root, each at the pose the hull source gives it + (a mount yawed 90 degrees included), and no layout row changed by Build itself. A second Build replaces only its own objects: an object the author made, a mesh and an empty, is still there. Build refuses, naming a rule and no input, a hull source that breaks its language's or brief's rule, and changes nothing. - Build refuses a collection that holds a render mesh it did not make, and changes nothing. - Package accepts the built ship (the C# validator), writes ship.glb with a node per anchor, and leaves hull.scad. - Once the author has taken the generated objects over (the mark is gone) Package deletes hull.scad and says so. + Build refuses a hull source that redefines or shadows its faction's language (reassigned variable, second echo, a + language file beside the hull), and changes nothing. + Build refuses, and changes nothing, a collection that holds a render mesh it did not make, a copy of a generated zone, + or a generated zone or mount that was edited in place. + Package accepts the built ship (the C# validator), writes ship.glb with a node per anchor, and leaves hull.scad; a + Build after Package still works. + The author replaces the zone meshes with her art and keeps Build's anchors and collider: Package deletes hull.scad in + that one act and says so, and Build then refuses. A hull that was never built keeps its hull.scad through the same deletion rule. """ import json +import math import os import shutil import struct @@ -57,6 +64,18 @@ HULLS = ( ) +# per fixture: the variable the hull's language is in, a value that redefines it (every rule loosened), the language file a +# copy of which beside hull.scad would shadow the include, and a colour in it +ATTACKS = { + "zh-ammo-tender": ("ZH_LANG", '["zhestokost", ZH_ZONES, "primary", ZH_GLOW, ZH_RADIATOR, [[0, 9], [0, 9]], ["asymmetric", 0.0]]', + "zhestokost.scad", "[0.42, 0.43, 0.45]"), + "pi-fighter": ("PI_LANG", '["pirate", PI_ZONES, "", PI_GLOW, PI_RADIATOR, [], ["bilateral", 0.0]]', + "pirate.scad", "[0.48, 0.27, 0.22]"), +} +EXECUTABLE = None # the OpenSCAD the add-on preference names, set by main +hull_source = None # aetheria_ships.hull_source, set by main + + def require(condition, message): if not condition: raise SystemExit(message) @@ -79,7 +98,9 @@ def approx(a, b, tolerance=1e-3): def generated(collection): - return {obj.name: obj for obj in collection.all_objects if obj.get("aetheria.hull_source")} + """Build's objects, by the record Build keeps on the collection.""" + record = collection.get("aetheria.hull_built") + return {obj.name: obj for obj in collection.all_objects if record is not None and obj.name in record} def layout_rows(scene): @@ -151,6 +172,8 @@ def one_hull(scene, number, fixture, ship_id, extent, length, rows, body, palett root = bpy.data.objects[next(o.name for o in collection.all_objects if o.get("aetheria.ship_root"))] rows_before = layout_rows(scene) source = package / "hull.scad" + lang_var, lang_value, shadowed, shadow_colour = ATTACKS[fixture] + runner = hull_source.openscad_runner(EXECUTABLE, source) require(source.is_file(), "The fixture hull.scad was not copied") require("Build From Hull Source" == bpy.ops.aetheria.build_hull_source.get_rna_type().name, "Build is not registered") @@ -169,6 +192,11 @@ def one_hull(scene, number, fixture, ship_id, extent, length, rows, body, palett require(mount.get("aetheria.role") == "weapon-mount" and first[muzzle].get("aetheria.order") == 1, "A muzzle is not ordered under its mount") require(first[muzzle].matrix_world.translation.y < mount.matrix_world.translation.y, "A muzzle is not nose-ward of its mount") require(layout_rows(scene) == rows_before, "Build changed the layout buffer") + check_poses(collection, root, runner) + if fixture == "zh-ammo-tender": + original = source.read_text(encoding="utf-8") + yawed_mount(scene, box, collection, root, package, source, original, runner) + first = generated(collection) print(f"PASS {number}.1 {fixture} builds: {len(first)} objects, {sum(len(v) for v in roles.values())} anchors") # A second Build replaces only its own objects; the author's own empty and mesh (outside render meshes' reach: a mesh @@ -234,19 +262,144 @@ def one_hull(scene, number, fixture, ship_id, extent, length, rows, body, palett f"{[(hp.mount_id, (hp.x, hp.y)) for hp in moved]} (authored at {[r[2] for r in rows if r[1] == BALLISTIC]})") print(f"PASS {number}.5 Package accepts the built ship and keeps hull.scad") - # The author takes the generated objects over: Package deletes hull.scad and says so. - for obj in generated(collection).values(): - del obj["aetheria.hull_source"] + # What Package did to the built objects is not an edit: Build still replaces them. + select(box) + result, message = run(bpy.ops.aetheria.build_hull_source) + require(result == {"FINISHED"}, f"Build after Package was refused: {message!r}") + print(f"PASS {number}.6 Build after Package replaces its own objects") + + # A hull source that redefines or shadows its faction's language: refused, nothing changes. + attacks = [] + for label, change in ( + ("reassigns the language after the include", lambda text: text + f"\n{lang_var} = {lang_value};\n"), + ("echoes a second language", lambda text: text + f'\nif (part == "lang") echo(lang = {lang_value});\n')): + attacks.append((label, lambda change=change: source.write_text(change(original), encoding="utf-8"), lambda: None)) + shadow = package / shadowed + shadow_text = (REPO / "tools" / "blender" / "aetheria_ships" / "factions" / shadowed).read_text(encoding="utf-8") + require(shadow_text.count(shadow_colour) >= 1, "The shadow's colour is not in the language file") + attacks.append(("shadows the language file beside itself", + lambda: shadow.write_text(shadow_text.replace(shadow_colour, "[1, 0, 1]"), encoding="utf-8"), + lambda: shadow.unlink())) + for label, apply, undo in attacks: + apply() + names = sorted(o.name for o in collection.all_objects) + select(box) + result, message = run(bpy.ops.aetheria.build_hull_source) + undo() + source.write_text(original, encoding="utf-8") + require(result == {"CANCELLED"} and "includes its faction's language files" in message, + f"A hull that {label} was built: {message!r}") + require(sorted(o.name for o in collection.all_objects) == names, "A refused Build changed objects") + for echoed in (ship_id, str(GAME), fixture): + require(echoed not in message, f"The refusal echoes an input ({echoed})") + print(f"PASS {number}.7 Build refuses a hull that redefines or shadows its language") + + # An author's copy of a generated zone, and an edit of a generated zone or mount, are the author's: Build refuses + # and leaves them as they are. + zone = bpy.data.objects[body] + piece = zone.copy() + piece.data = zone.data.copy() + piece.name = "my piece" + collection.objects.link(piece) + names = sorted(o.name for o in collection.all_objects) + select(box) + result, message = run(bpy.ops.aetheria.build_hull_source) + require(result == {"CANCELLED"} and "did not make" in message, f"Build ignored a copy of a generated zone: {message!r}") + require(sorted(o.name for o in collection.all_objects) == names and bpy.data.objects.get("my piece") is piece, + "Build removed a copy of a generated zone") + mesh = piece.data + bpy.data.objects.remove(piece) + bpy.data.meshes.remove(mesh) + original_vertices = [tuple(v.co) for v in zone.data.vertices] + zone.data.vertices[0].co.z += 5.0 + zone.data.update() + names = sorted(o.name for o in collection.all_objects) + select(box) + result, message = run(bpy.ops.aetheria.build_hull_source) + require(result == {"CANCELLED"} and "did not make" in message, f"Build ignored an edited zone: {message!r}") + require(sorted(o.name for o in collection.all_objects) == names and abs(zone.data.vertices[0].co.z - original_vertices[0][2] - 5.0) < 1e-3, + "Build discarded an edited zone") + api = sys.modules["aetheria_ships"] + require(api._retire_hull_source(collection) is False and source.is_file(), + "One edited zone, with the others still Build's, retired hull.scad") + for vertex, co in zip(zone.data.vertices, original_vertices): + vertex.co = co + zone.data.update() + mount = bpy.data.objects[roles["weapon-mount"][0]] + at = tuple(mount.location) + mount.location.x += 1.0 + select(box) + result, message = run(bpy.ops.aetheria.build_hull_source) + require(result == {"CANCELLED"} and abs(mount.location.x - at[0] - 1.0) < 1e-6, f"Build discarded a moved mount: {message!r}") + mount.location = at + print(f"PASS {number}.8 Build refuses and keeps a copy and an edit of what it made") + + # The author replaces the zone meshes with her art and keeps Build's anchors and collider: Package ships the ship + # and retires hull.scad in that one act; Build is gone with it. + for name in list(generated(collection)): + obj = generated(collection)[name] + if obj.type == "MESH" and "aetheria.role" not in obj: + bpy.data.objects.remove(obj) + art_mesh = bpy.data.meshes.new("author hull") + bm = bmesh.new() + bmesh.ops.create_cube(bm, size=1.0) + bm.to_mesh(art_mesh) + bm.free() + art = bpy.data.objects.new("author hull", art_mesh) + collection.objects.link(art) + art.parent = root + require(any(o.get("aetheria.role") for o in generated(collection).values()), "The scene kept no anchor of Build's") select(box) result, message = run(bpy.ops.aetheria.package_ship) report = scene.aetheria_package_report require(result == {"FINISHED"} and "deleted hull.scad" in report, f"Package did not retire the hull source: {report!r} {message!r}") require(not source.exists(), "hull.scad is still there after the author's art was packaged") + require("aetheria.hull_built" not in collection, "The record of Build's objects outlived hull.scad") require(all(echoed not in report for echoed in (ship_id, str(GAME))), "The report echoes an input") - print(f"PASS {number}.6 operator art retires hull.scad, with a report") + select(box) + result, message = run(bpy.ops.aetheria.build_hull_source) + require(result == {"CANCELLED"} and bpy.data.objects.get("author hull") is art, f"Build ran after takeover: {message!r}") + print(f"PASS {number}.9 her art with Build's anchors kept retires hull.scad, with a report; Build then refuses") return collection +def check_poses(collection, root, runner): + """Every built anchor stands where the hull source says in Ship Root's frame, turned as it says, and the emitter and + radiator meshes are the source's meshes in that frame.""" + inverse = root.matrix_world.inverted() + for anchor in hull_source.read_anchors(runner("anchors")[1]): + obj = bpy.data.objects[anchor.id] + local = inverse @ obj.matrix_world + require(approx(tuple(local.translation), anchor.position), f"{anchor.id} stands at {tuple(local.translation)}, not {anchor.position}") + if anchor.role != "weapon-muzzle": + turn = local.to_euler().z + want = math.radians(anchor.yaw) + require(approx((math.cos(turn), math.sin(turn)), (math.cos(want), math.sin(want))), + f"{anchor.id} is turned {math.degrees(turn):.1f} degrees, not {anchor.yaw}") + if anchor.role in hull_source.MESH_ROLES: + made = sorted(tuple(round(c, 3) for c in (inverse @ obj.matrix_world @ v.co)) for v in obj.data.vertices) + wanted = sorted(tuple(round(c, 3) for c in v) for v in hull_source.parse_off(runner(hull_source.MESH_ROLES[anchor.role] + anchor.id)[0]).vertices) + require(len(made) == len(wanted) and all(approx(a, b, 2e-3) for a, b in zip(made, wanted)), + f"The mesh of {anchor.id} is not the source's mesh in Ship Root's frame") + + +def yawed_mount(scene, box, collection, root, package, source, original, runner): + """A mount the source turns 90 degrees stands, turned, where it says, and its muzzle follows it.""" + changed = original.replace('[0, Y0+3.2, H+0.6], 0, ""]', '[0, Y0+3.2, H+0.6], 90, ""]') + require(changed != original, "The tender's mount is not where the pose test turns it") + source.write_text(changed, encoding="utf-8") + select(box) + result, message = run(bpy.ops.aetheria.build_hull_source) + require(result == {"FINISHED"}, f"Build refused a turned mount: {message!r}") + check_poses(collection, root, runner) + require(abs(bpy.data.objects["Gun.D"].rotation_euler.z - math.pi / 2) < 1e-4, "The mount is not turned 90 degrees") + source.write_text(original, encoding="utf-8") + select(box) + result, message = run(bpy.ops.aetheria.build_hull_source) + require(result == {"FINISHED"}, f"Build refused the restored source: {message!r}") + print("PASS 1.10 a turned mount and its muzzle stand where the source puts them") + + def clear(collection): """Removes a finished ship's objects: Blender names objects once per file, and anchors are found by name.""" for obj in list(collection.all_objects if collection else ()): @@ -269,14 +422,19 @@ def main(): sys.path.insert(0, str(REPO / "tools" / "blender")) addon_utils.enable("aetheria_ships", default_set=True, handle_error=None) import aetheria_ships + global hull_source, EXECUTABLE + hull_source = aetheria_ships.hull_source aetheria_ships._brokkr_cultlib = lambda context: PACKAGES preferences = bpy.context.preferences.addons["aetheria_ships"].preferences preferences.game_folder = str(GAME) if OPENSCAD: preferences.openscad = OPENSCAD + EXECUTABLE = preferences.openscad real = aetheria_ships.hull_source.openscad_runner def logged(executable, source, **kwargs): + kwargs.pop("on_start", None) + def on_start(pid): if PID_LOG: with open(PID_LOG, "a") as log: diff --git a/tools/blender/tests/test_hull_source.py b/tools/blender/tests/test_hull_source.py index 62aed4168..2b71561a3 100644 --- a/tools/blender/tests/test_hull_source.py +++ b/tools/blender/tests/test_hull_source.py @@ -10,8 +10,10 @@ hull_source is loaded by path because the package __init__ imports bpy. import importlib.util import json import sys +import tempfile import unittest from pathlib import Path +from unittest import mock _spec = importlib.util.spec_from_file_location( "hull_source", Path(__file__).resolve().parents[1] / "aetheria_ships" / "hull_source.py") @@ -46,13 +48,15 @@ def brief(faction="zhestokost", donor="", length=(8, 14), triangles=(10, 100)): class Fixture: - """A hull source as the fake runner sees it: part -> (OFF, ECHO).""" + """A hull source as the fake runner sees it: part -> (OFF, ECHO). library holds the ECHO text of each faction's own + language file; the hull's own `lang` echo is the files it includes, unless hull_lang says otherwise.""" def __init__(self): + self.library = {"zhestokost": language()} + self.hull_lang = None # Body 6 wide, 10 long, 2 high (h/w 0.33, w/l 0.6); a bilateral plate on top; one gun with a muzzle nose-ward. self.parts = { "brief": ("", brief()), - "lang": ("", language()), "primary": (box(-3, 3, -5, 5, 0, 2), ""), "secondary": (box(-1, 1, -1, 1, 2, 3), ""), "anchors": ("", echo("anchor", ["Gun.A", "weapon-mount", [0, 0, 3], 0, ""]) @@ -62,10 +66,15 @@ class Fixture: } def runner(self): - return lambda part: self.parts[part] + return lambda part: ("", self.hull_lang if self.hull_lang is not None else "".join(self.library.values())) if part == "lang" else self.parts[part] + + def read_library(self, faction): + if faction not in self.library: + raise hs.HullSourceError("no such language file") + return self.library[faction] def build(self): - return hs.build(self.runner()) + return hs.build(self.runner(), self.read_library) def failed(self): return [c.name for c in self.build().failed()] @@ -106,7 +115,7 @@ class ReadingTests(unittest.TestCase): def test_a_pirate_language_reads_with_no_body_and_no_proportion(self): pirate = hs.read_languages(language("pirate", ("primer", "weld"), body="", proportion=None, - symmetry=("asymmetric", 0.9)))["pirate"] + symmetry=("asymmetric", 0.9)))[0] self.assertEqual((pirate.body, pirate.proportion, pirate.symmetry), ("", None, ("asymmetric", 0.9))) def test_a_language_naming_an_unknown_rule_or_a_body_outside_its_zones_is_refused(self): @@ -143,7 +152,7 @@ class JudgementTests(unittest.TestCase): def test_the_language_decides_proportions_not_the_hull(self): # The same 6 x 10 x 2 body: inside Zhestokost's ranges, outside a language that wants tall narrow bodies. fixture = Fixture() - fixture.parts["lang"] = ("", language(proportion=((0.8, 1.2), (0.1, 0.3)))) + fixture.library["zhestokost"] = language(proportion=((0.8, 1.2), (0.1, 0.3))) self.assertEqual(fixture.failed(), ["the body's proportions lie inside the language's ranges"]) def test_a_body_too_tall_for_the_language_fails_proportion_and_nothing_else(self): @@ -151,6 +160,15 @@ class JudgementTests(unittest.TestCase): fixture.parts["primary"] = (box(-3, 3, -5, 5, 0, 5), "") self.assertEqual(fixture.failed(), ["the body's proportions lie inside the language's ranges"]) + def test_a_body_too_wide_for_its_length_fails_proportion_and_nothing_else(self): + fixture = Fixture() + fixture.parts["primary"] = (box(-6, 6, -5, 5, 0, 4), "") # h/w 0.33 but w/l 1.2 + self.assertEqual(fixture.failed(), ["the body's proportions lie inside the language's ranges"]) + + def test_every_zone_of_the_language_is_coloured_with_its_own_palette_entry(self): + built = Fixture().build() + self.assertEqual(built.colours, {"primary": (0.1, 0.2, 0.3), "secondary": (0.2, 0.2, 0.3)}) + def test_a_body_too_narrow_for_the_language_fails_proportion(self): fixture = Fixture() fixture.parts["primary"] = (box(-1, 1, -5, 5, 0, 1), "") @@ -163,17 +181,17 @@ class JudgementTests(unittest.TestCase): def test_a_bilateral_bound_is_met_by_exactly_that_overlap(self): fixture = Fixture() - fixture.parts["lang"] = ("", language(symmetry=("bilateral", 1.0))) + fixture.library["zhestokost"] = language(symmetry=("bilateral", 1.0)) self.assertEqual(fixture.failed(), []) def test_an_asymmetric_language_refuses_a_symmetric_hull(self): fixture = Fixture() - fixture.parts["lang"] = ("", language("zhestokost", symmetry=("asymmetric", 0.9))) + fixture.library["zhestokost"] = language("zhestokost", symmetry=("asymmetric", 0.9)) self.assertEqual(fixture.failed(), ["the top view obeys the language's asymmetric rule"]) def test_an_asymmetric_language_accepts_a_lopsided_hull(self): fixture = Fixture() - fixture.parts["lang"] = ("", language("zhestokost", symmetry=("asymmetric", 0.9))) + fixture.library["zhestokost"] = language("zhestokost", symmetry=("asymmetric", 0.9)) fixture.parts["secondary"] = (box(2, 8, -4, 4, 0, 3), "") self.assertEqual(fixture.failed(), []) @@ -244,9 +262,9 @@ class DonorTests(unittest.TestCase): def pirate(donor_proportion=((0.12, 0.35), (0.55, 1.1)), donor="rnd"): fixture = Fixture() fixture.parts["brief"] = ("", brief("pirate", donor, length=(8, 14))) - fixture.parts["lang"] = ("", language("pirate", ("primer", "weld"), body="", proportion=None, - symmetry=("asymmetric", 0.9)) - + language("rnd", ("primary", "secondary"), proportion=donor_proportion)) + fixture.library = {"pirate": language("pirate", ("primer", "weld"), body="", proportion=None, + symmetry=("asymmetric", 0.9)), + "rnd": language("rnd", ("primary", "secondary"), proportion=donor_proportion)} for key in ("primary", "secondary"): del fixture.parts[key] fixture.parts["rnd:primary"] = (box(-3, 3, -5, 5, 0, 1), "") # h/w 0.17, w/l 0.6 @@ -275,8 +293,7 @@ class DonorTests(unittest.TestCase): def test_a_pirate_hull_that_names_no_donor_is_refused(self): fixture = self.pirate(donor="") - fixture.parts["lang"] = ("", language("pirate", ("primer", "weld"), body="", proportion=None, - symmetry=("asymmetric", 0.9))) + del fixture.library["rnd"] for key in ("rnd:primary", "rnd:secondary"): del fixture.parts[key] self.assertIn(self.DONOR, fixture.failed()) @@ -284,12 +301,167 @@ class DonorTests(unittest.TestCase): def test_a_hull_whose_language_has_proportions_of_its_own_may_not_name_a_donor(self): fixture = Fixture() fixture.parts["brief"] = ("", brief("zhestokost", "rnd")) - fixture.parts["lang"] = ("", language() + language("rnd", ("primary", "secondary"))) + fixture.library["rnd"] = language("rnd", ("primary", "secondary")) fixture.parts["rnd:primary"] = (box(-3, 3, -5, 5, 0, 1), "") fixture.parts["rnd:secondary"] = (box(-1, 1, -1, 1, 1, 1.2), "") self.assertIn(self.DONOR, fixture.failed()) +class LanguageOwnershipTests(unittest.TestCase): + """The faction's language file owns its palette, proportions and symmetry rule: the hull's own `lang` echo is compared + with it and never judged against.""" + + OWN = "the hull includes its faction's language files and changes none of them" + PROPORTIONS = "the body's proportions lie inside the language's ranges" + + def redefined(self, **language_arguments): + fixture = Fixture() + fixture.hull_lang = language(**language_arguments) + return fixture + + def test_a_hull_that_includes_the_languages_unchanged_passes(self): + self.assertEqual(Fixture().failed(), []) + + def test_a_hull_that_redefines_its_palette_fails_and_is_still_coloured_from_the_file(self): + fixture = Fixture() + fixture.hull_lang = echo("lang", ["zhestokost", [["primary", [1, 0, 1]], ["secondary", [1, 0, 1]]], "primary", + [1, 0.6, 0.2], [0.5, 0.5, 0.5], [[0.25, 0.6], [0.35, 0.75]], ["bilateral", 0.97]]) + self.assertEqual(fixture.failed(), [self.OWN]) + built = fixture.build() + self.assertEqual(built.colours["primary"], built.languages["zhestokost"].colour("primary")) + self.assertNotEqual(built.colours["primary"], (1, 0, 1)) + + def test_a_hull_that_adds_a_zone_to_its_palette_fails(self): + self.assertEqual(self.redefined(zones=("primary", "secondary", "extra")).failed(), [self.OWN]) + + def test_a_hull_that_redefines_the_proportions_fails_and_is_judged_by_the_file(self): + fixture = self.redefined(proportion=((0.0, 9.0), (0.0, 9.0))) + fixture.parts["primary"] = (box(-3, 3, -5, 5, 0, 5), "") # too tall for the file's ranges + self.assertEqual(sorted(fixture.failed()), sorted([self.OWN, self.PROPORTIONS])) + + def test_a_hull_that_redefines_the_symmetry_rule_fails_and_is_judged_by_the_file(self): + fixture = self.redefined(symmetry=("asymmetric", 0.99)) + self.assertEqual(fixture.failed(), [self.OWN]) + fixture.parts["secondary"] = (box(2, 6, -1, 1, 2, 3), "") # lopsided: the file's bilateral rule refuses it + self.assertIn("the top view obeys the language's bilateral rule", fixture.failed()) + + def test_a_second_echo_of_the_language_after_the_first_fails(self): + fixture = Fixture() + fixture.hull_lang = language() + language(symmetry=("asymmetric", 0.99)) + self.assertEqual(fixture.failed(), [self.OWN]) + + def test_a_hull_that_echoes_no_language_or_another_factions_fails(self): + for text in ("", language("rnd")): + fixture = Fixture() + fixture.hull_lang = text + self.assertEqual(fixture.failed(), [self.OWN]) + + def test_a_pirate_hull_must_include_its_donors_language_too(self): + fixture = DonorTests.pirate() + fixture.hull_lang = language("pirate", ("primer", "weld"), body="", proportion=None, symmetry=("asymmetric", 0.9)) + self.assertEqual(fixture.failed(), [self.OWN]) + + def test_the_library_is_asked_for_the_faction_and_the_donor_the_brief_names(self): + asked = [] + fixture = DonorTests.pirate() + hs.build(fixture.runner(), lambda faction: asked.append(faction) or fixture.read_library(faction)) + self.assertEqual(sorted(asked), ["pirate", "rnd"]) + + +class FakeOpenScad: + """Stands in for subprocess.Popen: records each command and environment, writes `off` where -o points, and answers + with `output` and `returncode`.""" + + def __init__(self, returncode=0, off="", output=""): + self.returncode, self.off, self.output, self.calls = returncode, off, output, [] + self.pid = 4242 + + def __call__(self, command, **kwargs): + self.calls.append((command, kwargs.get("env"))) + if self.off: + Path(command[command.index("-o") + 1]).write_text(self.off, encoding="utf-8") + return self + + def communicate(self, timeout=None): + return self.output, None + + def kill(self): + pass + + +class LanguageLibraryTests(unittest.TestCase): + def test_a_faction_name_that_is_not_a_plain_word_or_has_no_file_is_refused_without_a_process(self): + with tempfile.TemporaryDirectory() as folder, mock.patch.object(hs.subprocess, "Popen") as popen: + (Path(folder) / "zhestokost.scad").write_text("", encoding="utf-8") + read = hs.language_library("openscad", Path(folder)) + for faction in ("../zhestokost", "Zhestokost", "", "nobody", "zhestokost.scad"): + with self.assertRaises(hs.HullSourceError) as caught: + read(faction) + self.assertNotIn("nobody", str(caught.exception)) + popen.assert_not_called() + + def test_the_language_is_read_from_the_factions_own_file_run_alone(self): + with tempfile.TemporaryDirectory() as folder: + library = Path(folder) + (library / "zhestokost.scad").write_text("", encoding="utf-8") + openscad = FakeOpenScad(output=language()) + with mock.patch.object(hs.subprocess, "Popen", openscad): + text = hs.language_library("openscad", library)("zhestokost") + self.assertEqual(hs.read_languages(text)[0].faction, "zhestokost") + command = openscad.calls[0][0] + self.assertEqual(command[-1], str(library / "zhestokost.scad")) + self.assertEqual(command[command.index("-D") + 1], 'part="lang"') + + +class RunnerTests(unittest.TestCase): + def run_part(self, part, openscad, **arguments): + with mock.patch.object(hs.subprocess, "Popen", openscad): + return hs.openscad_runner("scad", "hull.scad", **arguments)(part) + + def test_openscad_is_asked_for_a_full_render_on_the_manifold_backend_of_one_part(self): + openscad = FakeOpenScad() + self.run_part("primary", openscad) + command = openscad.calls[0][0] + self.assertEqual(command[0], "scad") + self.assertEqual(command[command.index("--backend") + 1], "Manifold") + self.assertEqual(command[command.index("--render") + 1], "all") + self.assertEqual(command[command.index("-D") + 1], 'part="primary"') + self.assertEqual(command[-1], "hull.scad") + + def test_the_language_folder_is_what_openscad_searches_for_includes(self): + openscad = FakeOpenScad() + self.run_part("primary", openscad, library=Path("some") / "languages") + self.assertEqual(openscad.calls[0][1]["OPENSCADPATH"], str(Path("some") / "languages")) + + def test_a_part_openscad_fails_to_make_is_refused_without_echoing_its_output(self): + with self.assertRaises(hs.HullSourceError) as caught: + self.run_part("primary", FakeOpenScad(returncode=1, output=SECRET)) + self.assertNotIn(SECRET, str(caught.exception)) + + def test_echo_parts_answer_the_echo_and_geometry_parts_the_off(self): + self.assertEqual(self.run_part("brief", FakeOpenScad(output=brief())), ("", brief())) + self.assertEqual(self.run_part("primary", FakeOpenScad(off=box(0, 1, 0, 1, 0, 1), output="log")), + (box(0, 1, 0, 1, 0, 1), "log")) + + def test_each_process_is_reported_to_on_start(self): + started = [] + self.run_part("primary", FakeOpenScad(), on_start=started.append) + self.assertEqual(started, [4242]) + + +class FingerprintTests(unittest.TestCase): + def test_equal_values_give_equal_digests_and_a_change_gives_another(self): + base = hs.fingerprint("a", (1.0, 2.5), [[0.0, 1.0, 2.0]], 3) + self.assertEqual(base, hs.fingerprint("a", (1.0, 2.5), [[0.0, 1.0, 2.0]], 3)) + for other in (("b", (1.0, 2.5), [[0.0, 1.0, 2.0]], 3), ("a", (1.0, 2.501), [[0.0, 1.0, 2.0]], 3), + ("a", (1.0, 2.5), [[0.0, 1.0, 2.0]], 4), ("a", (1.0, 2.5), [[0.0, 1.0], [2.0]], 3)): + self.assertNotEqual(base, hs.fingerprint(*other)) + + def test_float_noise_below_five_decimals_and_a_negative_zero_do_not_change_it(self): + self.assertEqual(hs.fingerprint((1.0, 0.0)), hs.fingerprint((1.0 + 1e-8, -0.0))) + self.assertEqual(hs.fingerprint((-1e-9,)), hs.fingerprint((0.0,))) + + class RefusalTests(unittest.TestCase): def test_a_source_that_includes_no_language_for_its_faction_is_refused(self): fixture = Fixture() @@ -297,12 +469,19 @@ class RefusalTests(unittest.TestCase): with self.assertRaises(hs.HullSourceError): fixture.build() - def test_a_named_donor_whose_language_the_source_does_not_include_is_refused(self): + def test_a_named_donor_that_has_no_language_file_is_refused(self): fixture = Fixture() fixture.parts["brief"] = ("", brief("zhestokost", "rnd")) with self.assertRaises(hs.HullSourceError): fixture.build() + def test_a_language_file_that_echoes_another_faction_or_not_exactly_one_language_is_refused(self): + for text in (language("rnd"), language() + language(), ""): + fixture = Fixture() + fixture.library["zhestokost"] = text + with self.assertRaises(hs.HullSourceError): + fixture.build() + def test_anchors_that_repeat_an_id_or_name_an_unknown_role_are_refused_without_echoing_them(self): for anchors in (echo("anchor", [SECRET, "weapon-mount", [0, 0, 0], 0, ""]) * 2, echo("anchor", [SECRET, "map-icon", [0, 0, 0], 0, ""])): @@ -312,6 +491,31 @@ class RefusalTests(unittest.TestCase): fixture.build() self.assertNotIn(SECRET, str(caught.exception)) + def test_a_name_that_is_not_a_string_is_refused_wherever_the_source_gives_one(self): + def refused(fixture): + with self.assertRaises(hs.HullSourceError): + fixture.build() + + for index in range(3): # faction, donor, role + fixture = Fixture() + raw = ["zhestokost", "", "tender", [8, 14], [10, 100], ["a read"]] + raw[index] = 7 + fixture.parts["brief"] = ("", echo("brief", raw)) + refused(fixture) + fixture = Fixture() + fixture.parts["brief"] = ("", echo("brief", ["zhestokost", "", "tender", [8, 14], [10, 100], [7]])) + refused(fixture) + for anchor in ([7, "weapon-mount", [0, 0, 3], 0, ""], ["Gun.A", 7, [0, 0, 3], 0, ""], + ["Gun.A", "weapon-mount", [0, 0, 3], 0, 7]): + fixture = Fixture() + fixture.parts["anchors"] = ("", echo("anchor", anchor)) + refused(fixture) + for raw in ([7, [["primary", [1, 1, 1]]], "primary", [1, 1, 1], [1, 1, 1], [], ["bilateral", 1]], + ["zhestokost", [[7, [1, 1, 1]]], "primary", [1, 1, 1], [1, 1, 1], [], ["bilateral", 1]], + ["zhestokost", [["primary", [1, 1, 1]]], 7, [1, 1, 1], [1, 1, 1], [], ["bilateral", 1]]): + with self.assertRaises(hs.HullSourceError): + hs.read_languages(echo("lang", raw)) + def test_a_malformed_anchor_is_refused_without_echoing_it(self): fixture = Fixture() fixture.parts["anchors"] = ("", echo("anchor", [SECRET, "weapon-mount", [0, 0], 0, ""])) From d5491c1c57d013db9e813de65afdc06da924cf67 Mon Sep 17 00:00:00 2001 From: soul Date: Sun, 18 Oct 2026 22:35:14 +0200 Subject: [PATCH 062/444] test_hull_source: a genuine language echoed after an altered one, and a faction name that escapes the library Co-Authored-By: Claude Sonnet 5.5 --- tools/blender/tests/test_hull_source.py | 14 +++++++++++--- 1 file changed, 11 insertions(+), 3 deletions(-) diff --git a/tools/blender/tests/test_hull_source.py b/tools/blender/tests/test_hull_source.py index 2b71561a3..de4aa8930 100644 --- a/tools/blender/tests/test_hull_source.py +++ b/tools/blender/tests/test_hull_source.py @@ -350,6 +350,11 @@ class LanguageOwnershipTests(unittest.TestCase): fixture.hull_lang = language() + language(symmetry=("asymmetric", 0.99)) self.assertEqual(fixture.failed(), [self.OWN]) + def test_the_genuine_language_echoed_after_an_altered_one_does_not_save_the_hull(self): + fixture = Fixture() + fixture.hull_lang = language(symmetry=("asymmetric", 0.99)) + language() + self.assertEqual(fixture.failed(), [self.OWN]) + def test_a_hull_that_echoes_no_language_or_another_factions_fails(self): for text in ("", language("rnd")): fixture = Fixture() @@ -392,9 +397,12 @@ class FakeOpenScad: class LanguageLibraryTests(unittest.TestCase): def test_a_faction_name_that_is_not_a_plain_word_or_has_no_file_is_refused_without_a_process(self): with tempfile.TemporaryDirectory() as folder, mock.patch.object(hs.subprocess, "Popen") as popen: - (Path(folder) / "zhestokost.scad").write_text("", encoding="utf-8") - read = hs.language_library("openscad", Path(folder)) - for faction in ("../zhestokost", "Zhestokost", "", "nobody", "zhestokost.scad"): + library = Path(folder) / "languages" + library.mkdir() + (library / "zhestokost.scad").write_text("", encoding="utf-8") + (Path(folder) / "escape.scad").write_text("", encoding="utf-8") # a file outside the library + read = hs.language_library("openscad", library) + for faction in ("../escape", "Zhestokost", "", "nobody", "zhestokost.scad"): with self.assertRaises(hs.HullSourceError) as caught: read(faction) self.assertNotIn("nobody", str(caught.exception)) From 3c4ea230bc70762e5264f9106d79b5ddba10a261 Mon Sep 17 00:00:00 2001 From: soul Date: Sun, 18 Oct 2026 22:36:54 +0200 Subject: [PATCH 063/444] test_hull_source: a fingerprint tells nested empty lists apart Co-Authored-By: Claude Sonnet 5.5 --- tools/blender/tests/test_hull_source.py | 3 +++ 1 file changed, 3 insertions(+) diff --git a/tools/blender/tests/test_hull_source.py b/tools/blender/tests/test_hull_source.py index de4aa8930..a7bd201d2 100644 --- a/tools/blender/tests/test_hull_source.py +++ b/tools/blender/tests/test_hull_source.py @@ -465,6 +465,9 @@ class FingerprintTests(unittest.TestCase): ("a", (1.0, 2.5), [[0.0, 1.0, 2.0]], 4), ("a", (1.0, 2.5), [[0.0, 1.0], [2.0]], 3)): self.assertNotEqual(base, hs.fingerprint(*other)) + def test_a_list_of_two_empty_lists_is_not_one_list_holding_one(self): + self.assertNotEqual(hs.fingerprint([], []), hs.fingerprint([[]])) + def test_float_noise_below_five_decimals_and_a_negative_zero_do_not_change_it(self): self.assertEqual(hs.fingerprint((1.0, 0.0)), hs.fingerprint((1.0 + 1e-8, -0.0))) self.assertEqual(hs.fingerprint((-1e-9,)), hs.fingerprint((0.0,))) From 195039763094f5c174bfc3fe2c99a1a4fa098223 Mon Sep 17 00:00:00 2001 From: soul Date: Mon, 19 Oct 2026 00:04:45 +0200 Subject: [PATCH 064/444] RunGoalTests: Soul's s1 probes as behaviour tests (referenced Loadout wins on a shared hull, refusals fly no catalog Loadout, no maker fallback, boss path draws only what Materialize draws) Co-Authored-By: Claude Sonnet 5.5 --- tests/Aetheria.Shared.Tests/RunGoalTests.cs | 76 +++++++++++++++++++++ 1 file changed, 76 insertions(+) diff --git a/tests/Aetheria.Shared.Tests/RunGoalTests.cs b/tests/Aetheria.Shared.Tests/RunGoalTests.cs index 34eb50c69..8f7977943 100644 --- a/tests/Aetheria.Shared.Tests/RunGoalTests.cs +++ b/tests/Aetheria.Shared.Tests/RunGoalTests.cs @@ -553,4 +553,80 @@ public sealed partial class RunStartTests Assert.Equal(0, gate.x, 3); Assert.Equal(zone.Pack.Radius * ratio, gate.y, 2); } + + // Soul's probes of cut boss-loadout pass s1, kept as behaviour: the rules the first eight tests let a mutant past. + + [Fact] + public void The_boss_is_the_loadout_its_faction_names_even_when_another_loadout_shares_its_hull() + { + var galaxy = DemoGalaxy(1); + var decoy = MakeBossLoadout(BossTestHull(), "Aaa Same Hull"); + decoy.Slots = decoy.Slots.Take(1).ToList(); + decoy.WeaponGroups = new int[0][]; + StoreLoadout(decoy); + var boss = GiveAntagonistABoss(galaxy); + Assert.True(boss.Slots.Count > decoy.Slots.Count); + var pack = Assert.Single(BossZonePack(galaxy, _items, galaxy.Exit).Entities, e => e.Boss); + Assert.Equal(LoadoutCells(boss), Cells(_items, pack)); + } + + [Theory] + [InlineData(false)] + [InlineData(true)] + public void A_refused_boss_flies_no_catalog_loadout_and_draws_nothing(bool dangling) + { + var galaxy = DemoGalaxy(1); + var hull = SoldShipHulls().OrderBy(h => h.Name, StringComparer.Ordinal).First(); + StoreLoadout(new Loadout { Name = "Aaa Decoy", Hull = _cache.RefOf(hull) }); + if (dangling) Zhestokost(galaxy).BossLoadout = new CultRecordRef(Loadouts.KeyOf("CANARY-7f3a")); + var log = new List(); + var items = LoggingItems(log); + var pack = BossZonePack(galaxy, items, galaxy.Exit); + var next = items.Random.NextFloat(); + Assert.False(pack.BossSpawned); + Assert.DoesNotContain(pack.Entities, e => e.Boss); + Assert.DoesNotContain(log, l => l.Contains("CANARY")); + galaxy.BossZones.Clear(); + var bareItems = LoggingItems(new List()); + var bare = BossZonePack(galaxy, bareItems, galaxy.Exit); + Assert.Equal(Roster(bareItems, bare), Roster(items, pack)); + Assert.Equal(bareItems.Random.NextFloat(), next); + } + + [Fact] + public void A_design_only_boss_slot_whose_design_only_outsiders_make_refuses_the_boss() + { + var galaxy = DemoGalaxy(1); + var faction = Zhestokost(galaxy); + var loadout = GiveAntagonistABoss(galaxy); + var outfitter = GeneratorFor(galaxy, _items, faction, 1); + var open = loadout.Slots.First(slot => _cache.GetAll().Any(p => p.Design.Key.Equals(slot.Design.Key) && outfitter.IsAvailable(p))); + var outsider = _cache.GetAll().OrderBy(f => f.Name, StringComparer.Ordinal).First(f => f.Name != faction.Name && !faction.Allegiance.ContainsKey(_cache.RefOf(f))); + foreach (var p in _cache.GetAll().Where(p => p.Design.Key.Equals(open.Design.Key))) p.Manufacturer = _cache.RefOf(outsider); + Assert.Null(outfitter.ChooseProduct(_cache.Get(open.Design))); + open.Product = default; + StoreLoadout(loadout); + var log = new List(); + var pack = BossZonePack(galaxy, LoggingItems(log), galaxy.Exit, ambient: false); + Assert.False(pack.BossSpawned); + Assert.DoesNotContain(pack.Entities, e => e.Boss); + Assert.Contains(log, l => l.Contains("Antagonist Boss") && l.Contains("no available product")); + } + + [Fact] + public void The_boss_path_draws_only_what_building_its_loadout_draws() + { + var galaxy = DemoGalaxy(1); + var loadout = GiveAntagonistABoss(galaxy); + var withBoss = LoggingItems(new List()); + Assert.True(BossZonePack(galaxy, withBoss, galaxy.Exit, ambient: false).BossSpawned); + var next = withBoss.Random.NextFloat(); + galaxy.BossZones.Clear(); + var expected = LoggingItems(new List()); + BossZonePack(galaxy, expected, galaxy.Exit, ambient: false); + var failures = new List(); + Assert.NotNull(Loadouts.Materialize(expected, null, loadout, GeneratorFor(galaxy, expected, Zhestokost(galaxy), 7).ChooseProduct, failures)); + Assert.Empty(failures); + Assert.Equal(expected.Random.NextFloat(), next); + } } From 35c93dd6ab34362a2dc1c1a02e668e26726aa24c Mon Sep 17 00:00:00 2001 From: soul Date: Mon, 19 Oct 2026 00:13:44 +0200 Subject: [PATCH 065/444] aetheria-release map: body facts and rationale for cut-package-agent-hull-guards Co-Authored-By: Claude Opus 5.5 --- docs/aetheria-release-map.md | 89 +++++++++++++++++++++++++++++++++++- 1 file changed, 88 insertions(+), 1 deletion(-) diff --git a/docs/aetheria-release-map.md b/docs/aetheria-release-map.md index 50128adac..f3e709f3d 100644 --- a/docs/aetheria-release-map.md +++ b/docs/aetheria-release-map.md @@ -11337,7 +11337,7 @@ PIDs are in `S\out\pids.log`, the first 3404 and the last 20348. It then writes | "Agent-built" status | Presence of `hull.scad`. No field. | Ends when the file is deleted. | Derived. "Which hulls are agent-built" is a directory listing. | | Zone meshes and anchor objects in Blender | Marked `aetheria.hull_source`, with ids `aetheria.id`. | Replaced on every Build. Never hand-edited. | Build From Hull Source. Build refuses a collection holding meshes it did not make. | | `ship.glb` and ShipAuthoring anchors | `ShipAuthoring.ModelAsset`, with anchor ids equal to mount ids. | Rewritten by every Package, whether from an agent hull or operator art. The ids are stable across the swap, so loadouts and saves survive it. | Package Ship, the only writer. | -| HullData layout (schematic) | `mod-hull:`. | Authored through Save Layout. Linked to the model by mount name only. | The schematic author: agents for agent hulls, the operator for hers. Never Build. AH-B3 is the open conflict. | +| HullData layout (schematic) | `mod-hull:`. | Authored through Save Layout. Linked to the model by mount name only. | The schematic author: agents for agent hulls, the operator for hers. Never Build. AH-B3 is closed by cut `package-agent-hull-guards` (section PG). | | Hero asset | A package with no `hull.scad` that is named as its faction's hero. | Set by an operator ruling (`agent-hull-hero-assets`). | The operator. | ### Questions admitted @@ -11369,3 +11369,90 @@ Neither blocks the first cut. - The model read was one agent judging its own sheets. - Lucent and AU languages are not drafted. - The hero comparison is weak until the heroes exist. + +## Package on agent hulls (PG) + +Imagination pass `imag-ag7-hull-guards`, session `self-2026-10-19-ag7`, 2026-10-19, pinned to Aetheria `origin/master` +5acbaa1b (worktree `C:\aeth-wt\ag7-hull-guards-imag`). It maps cut `package-agent-hull-guards` (spec +`aetheria-release:cut_spec:cut-package-agent-hull-guards.r1`) for follow-ups `package-agent-hull-guards` and +`hull-copy-refusals` and finding `cut-agent-hull-source.s2.hull-names-its-own-faction`. It rests on rulings +`remove-package-schematic-derivation`, `hull-faction-is-product-maker`, `schematic-and-model-are-separate-spaces` and +`package-carries-product`. + +### Body facts (source reads and one probe at 5acbaa1b) + +- **PG-B1. The derivation has three writers.** `_derived_position` (`__init__.py:253`) maps a model point to a cell + through `hull_grid.cell_at` and `mounted_position`. Add Weapon (`_add_mount`, :302) and Mirror Mount (:359) call it. + Package calls `_plan_mounts` (:409), which plans every weapon row's Position, and then `_apply_mounts` (:432), which + writes those Positions, sets each mount object's yaw from its row's Rotation, and saves the layout. So Package writes + each space from the other: the schematic from the model, and the model's yaw from the schematic. Package would turn + a hull source's turned mount (smoke step 1.10, yaw 90) back to its row's facing. +- **PG-B2. After the delete, `cell_at`, `mounted_position`, `mounted_cell` and `_centre_offset` have no caller** outside + `tests/test_hull_grid.py` (grep of `tools/blender`). `cell_centre`, `mirror_position`, `mirror_footprint`, + `mirror_rotation` and `facing_yaw` keep callers (Rasterise, the grid drawing, `_frame_ship`, Mirror Mount, Add + Weapon). +- **PG-B3. A product names its maker by an opaque Faction record key.** `FactionProductData.Manufacturer` is + `CultRecordRef` at `[Key(4)]` (`Assets/Scripts/ServerShared/FactionProduct.cs:26`), and + `CultRecordRefFormatter` writes it as the key string. Probe `probe_factions.py` (cultcache-py from + `F:\Projects\CultLib\packages\cultcache-py\src`, read of `GameData/Aetheria.cc`) listed 16 `aetheria.faction` + records. Among them are Zhestokost `16bad4f9-7fb5-41c5-8da0-dc76709eb7d3`, Pirates + `1d8a168def994a76a1ce7cb18f59c038` and Rossum & Douglas `adfb9b08-aabc-45c6-9586-915698a9c4b0`. Product makers are + these keys: Juggernaut's, for example, is Zhestokost's. The language files are named by slug (`zhestokost`, `pirate`, + `rnd`), and nothing maps a slug to a Faction key today. +- **PG-B4. New Ship makes no product.** `AetherDb ship-authoring create` writes only a HullData and a ShipAuthoring + (`tools/AetherDb/ShipAuthoringCommands.cs:56`). `ship-authoring product --name ` (:94) + writes a product, resolving the maker by Faction `Name` or `ShortName` (:136). The agent-hull smoke packages ships + that have no product (Soul s2 noted it). +- **PG-B5. The duplicate-id refusal names nothing.** `ship_cc.replace_visual` raises "An anchor ID is used twice" + (`ship_cc.py:171`), and it does so after Package has framed the ship and written `ship.glb`. Build never looks at + ids, because `_own_meshes` skips every object that carries `aetheria.role`. The add-on already has a way to point at + offenders without echoing input: `_refuse_selecting` (`__init__.py:1139`), which `_refuse_loose_meshes` uses. +- **PG-B6. The tests split by host.** `tools/blender/tests/test_*.py` import `ship_cc`, `hull_grid` and `hull_source` + by path with no bpy (`CULTLIB_PACKAGES=/packages python -m unittest discover -s tools/blender/tests`), so + they run on Yggdrasil in a python image with msgpack. The `smoke_*.py` files need Blender 5.2's bpy and run + headless (`blender --background --factory-startup --python-use-system-env --python ...`). They run on Starfire, one + process at a time, as Soul ran them for agent-hull-source. `ShipSchemaPinTests` is C# and pins the slot constants of + `ship_cc.py` by regex. + +### Rationale + +- **The language file names its maker.** The ruling needs a comparison between the brief's faction (a language slug) + and the product's maker (a Faction key). Matching names (`Pirates` against `pirate`, `Rossum & Douglas` against `rnd`) + would be a fuzzy rule. A table in `hull_source.py` would be a second owner of a fact the language file already + stands for. So each `factions/.scad` gains its Faction record key as an eighth element of its `lang` echo. + The file that owns the language then also says whose language it is, and the existing "includes its language + unchanged" check covers that element too. +- **Build records the maker it judged by, and Package compares that record.** Re-reading `hull.scad` at Package would + compare the brief as it is now, which may differ from the brief Build judged: an author could edit the brief after + Build and then Package. Build already records what it made (`aetheria.hull_built`), so it also records + `aetheria.hull_maker`, the maker key of the language it judged against. Package then refuses unless the package + holds at least one product and every product's Manufacturer is that key. The check runs whenever the package still + ships Build's zones. When her art takes over (the same predicate `_retire_hull_source` uses), the brief no longer + governs and no check runs. An operator could edit the record by hand, since it is a custom property; that is the same + trust as the fingerprint record, and a hull source cannot reach it. +- **This cut fixes the finding.** After it, the faction a shipped hull was judged by is its product's maker. Drafting + under another faction stays possible, as the ruling allows. A separate cut would only repeat the Package edit. +- **Mirror Mount mirrors each space within itself.** The twin row's Position is `mirror_position` of the row's own + Position and footprint on the layout's width. The twin object stands at the reflection of the mount's model point + across Ship Root's X = 0. Mirror Mount refuses when either twin would coincide with its source: the same Position, + or a model point within 1 mm of X = 0. `CENTRELINE` (half a schematic cell, measured in model metres) was a + cross-space constant, so it goes. +- **Add Weapon's yaw stays a creation default.** One Rotation choice sets both the new row and the new empty's facing, + once. Nothing re-derives either afterwards. The ruling took only the Position derivation out of Add Weapon, so the + yaw stays. +- **Copies are refused by id, in Build and Package alike.** One helper refuses while two role-bearing objects share an + `aetheria.id`. It selects them and names a count, never an object. That covers the collider, emitter, radiator and + mount copies Soul built (s2 probes A2 and A5 to A7). The duplicate check in `ship_cc` stays, as the file writer's + invariant. + +### Model page rows + +| Kind | Identity | Lifecycle | Authority | +|---|---|---|---| +| A language's maker: the eighth element of `lang` in `factions/.scad` | The Faction record key in `GameData/Aetheria.cc`. | Written when the language is drafted. Changes only if the language is reassigned to another faction. | The language file, operator-approved like the rest of it. | +| `aetheria.hull_maker` on a ship collection | The maker key of the language Build judged against. | Set by every Build. Deleted with `aetheria.hull_built` when Package retires `hull.scad`. | Build From Hull Source writes it; Package reads it. | +| HullData layout Position | As before. | Authored in the layout panel only. Add Weapon creates a row at the default Position. | The schematic author. Package and the mount helpers no longer write it (this cut closes AH-B3). | + +### Admitted + +- `aetheria-release:cut_spec:cut-package-agent-hull-guards.r1`. From eaf7b799d42897fb5880db4f16368fb7f7f26c8f Mon Sep 17 00:00:00 2001 From: soul Date: Mon, 19 Oct 2026 00:16:35 +0200 Subject: [PATCH 066/444] Package moves no row: delete the schematic-from-model derivation Remove _plan_mounts/_apply_mounts, _derived_position, CENTRELINE, hull_grid.cell_at, mounted_position/mounted_cell and their tests (ruling remove-package-schematic-derivation). Add Weapon makes a default-Position row; Mirror Mount mirrors each space within itself. smoke_mounts rewritten (not run: bpy smokes are held off Starfire). Co-Authored-By: Claude Sonnet 5.5 --- tools/blender/aetheria_ships/__init__.py | 102 ++++--------- tools/blender/aetheria_ships/hull_grid.py | 23 --- tools/blender/tests/smoke_mounts.py | 168 +++++++++++----------- tools/blender/tests/test_hull_grid.py | 34 ----- 4 files changed, 107 insertions(+), 220 deletions(-) diff --git a/tools/blender/aetheria_ships/__init__.py b/tools/blender/aetheria_ships/__init__.py index e52f4ffd1..5b640e53b 100644 --- a/tools/blender/aetheria_ships/__init__.py +++ b/tools/blender/aetheria_ships/__init__.py @@ -207,7 +207,7 @@ MOUNT_ROLE = "weapon-mount" MUZZLE_ROLE = "weapon-muzzle" MUZZLE_OFFSET = (0.0, -0.5, 0.0) # in the mount's frame: half a metre along its facing WEAPON_TYPES = tuple(item for item in HARDPOINT_TYPES if item[1] in ("Energy", "Ballistic", "Launcher")) -CENTRELINE = hull_grid.CELL_SIZE / 2 # a mount closer than this to Ship Root's X = 0 has no mirror image +MODEL_TOLERANCE = 1e-3 # metres: a mount whose point is this close to Ship Root's X = 0 has no mirror image def _add_row(state, **fields): @@ -230,19 +230,15 @@ def _muzzles(mount): def _mount_frame(context): - """(collection, layout buffer, Ship Root, grid_origin) for a mount helper: the buffer must be this ship's and the grid - must match it.""" + """(collection, layout buffer, Ship Root) for a mount helper: the buffer must be this ship's.""" collection, path = _layout_path(context) state = context.scene.aetheria_layout if state.ship_id != collection["aetheria.id"] or state.ship_cc != path: raise ValueError("Load this collection's layout first") root = _ship_root(collection) - origin = collection.get("aetheria.grid_origin") - if root is None or origin is None: - raise ValueError("Rasterise first: a mount needs the ship's Ship Root and grid") - if not _grid_current(collection, state): - raise ValueError("Rasterise again: the grid no longer matches the layout") - return collection, state, root, (float(origin[0]), float(origin[1])) + if root is None: + raise ValueError("A mount needs the ship's Ship Root: Rasterise or Build From Hull Source first") + return collection, state, root def _root_local(root, obj): @@ -250,16 +246,6 @@ def _root_local(root, obj): return root.matrix_world.inverted() @ obj.matrix_world.translation -def _derived_position(state, origin, local, footprint): - """The one derivation of a mounted row's Position: the cell at the object's origin (Ship Root frame), offset by the - footprint's centre. Refuses a Position outside the grid.""" - width, height = _parse_footprint(footprint)[0][:2] - position = hull_grid.mounted_position(hull_grid.cell_at(origin, (local[0], local[1])), width, height) - if not (0 <= position[0] < state.width and 0 <= position[1] < state.height): - raise ValueError("The mount lies outside the grid") - return position - - def _selected_mount(context, collection): obj = context.active_object if obj is None or obj.get("aetheria.role") != MOUNT_ROLE or obj.name not in collection.all_objects: @@ -299,29 +285,30 @@ def _make_muzzle(collection, mount, k, local, order): return _make_empty(collection, f"{mount['aetheria.id']}.muzzle.{k}", MUZZLE_ROLE, mount, local, order=order) -def _add_mount(collection, state, root, origin, kind, rotation, local, footprint="#"): - """A weapon-mount object under Ship Root and its row, as a matching pair; returns the mount.""" - position = _derived_position(state, origin, local, footprint) +def _add_mount(collection, state, root, kind, rotation, local, footprint="#", position=None): + """A weapon-mount object under Ship Root and its row, as a matching pair; returns the mount. The row's Position is + the given schematic position, else HardpointData's default: the model point is never mapped to a cell.""" mount_id = _new_mount_id(collection, state) mount = _make_empty(collection, mount_id, MOUNT_ROLE, root, local, hull_grid.facing_yaw(rotation)) - _add_row(state, kind=kind, mount_id=mount_id, rotation=str(rotation), footprint=footprint, - x=position[0], y=position[1]) + fields = {} if position is None else {"x": position[0], "y": position[1]} + _add_row(state, kind=kind, mount_id=mount_id, rotation=str(rotation), footprint=footprint, **fields) return mount class AETHERIA_OT_add_weapon(bpy.types.Operator): bl_idname = "aetheria.add_ship_weapon" bl_label = "Add Weapon" - bl_description = "Add a weapon mount and its first muzzle at the 3D cursor, with a layout row" + bl_description = ("Add a weapon mount and its first muzzle at the 3D cursor, with a layout row at the default Position, " + "to be authored in the layout panel") bl_options = {"REGISTER", "UNDO"} kind: bpy.props.EnumProperty(name="Type", items=WEAPON_TYPES) rotation: bpy.props.EnumProperty(name="Rotation", items=ROTATIONS) def execute(self, context): try: - collection, state, root, origin = _mount_frame(context) + collection, state, root = _mount_frame(context) local = root.matrix_world.inverted() @ context.scene.cursor.location - mount = _add_mount(collection, state, root, origin, self.kind, int(self.rotation), local) + mount = _add_mount(collection, state, root, self.kind, int(self.rotation), local) _make_muzzle(collection, mount, 1, MUZZLE_OFFSET, 1) except _EXPECTED as exc: return _refuse(self, exc) @@ -336,7 +323,7 @@ class AETHERIA_OT_add_muzzle(bpy.types.Operator): def execute(self, context): try: - collection, _, _, _ = _mount_frame(context) + collection, _, _ = _mount_frame(context) mount = _selected_mount(context, collection) existing = _muzzles(mount) names = {child.name for child in existing} @@ -358,21 +345,17 @@ class AETHERIA_OT_mirror_mount(bpy.types.Operator): def execute(self, context): try: - collection, state, root, origin = _mount_frame(context) + collection, state, root = _mount_frame(context) mount = _selected_mount(context, collection) row = _mount_row(state, mount["aetheria.id"]) local = _root_local(root, mount) - if abs(local.x) < CENTRELINE: - raise ValueError("The weapon mount is on the centreline; it has no mirror image") - # The twin's footprint is the reflection of this one's cells; its object stands in the cell that Position - # puts it in (never at -x, whose cell lookup breaks ties one way), so Package derives the Position back. - width, height = _parse_footprint(row.footprint)[0][:2] - position = hull_grid.mirror_position(_derived_position(state, origin, local, row.footprint), width, - state.width) - twin_x = hull_grid.cell_centre(origin, *hull_grid.mounted_cell(position, width, height))[0] - mirrored = _add_mount(collection, state, root, origin, row.kind, - hull_grid.mirror_rotation(int(row.rotation)), (twin_x, local.y, local.z), - hull_grid.mirror_footprint(row.footprint)) + # Each space mirrors within itself: the row in the layout (the author's Position), the object in the model. + width = _parse_footprint(row.footprint)[0][0] + twin = hull_grid.mirror_position((row.x, row.y), width, state.width) + if abs(local.x) < MODEL_TOLERANCE or twin == (row.x, row.y): + raise ValueError("The weapon mount has no mirror image") + mirrored = _add_mount(collection, state, root, row.kind, hull_grid.mirror_rotation(int(row.rotation)), + (-local.x, local.y, local.z), hull_grid.mirror_footprint(row.footprint), twin) new_row = _mount_row(state, mirrored["aetheria.id"]) for name in ("armor", "firing_arc"): # the author's own numbers, copied; the helper invents none setattr(new_row, name, getattr(row, name)) @@ -392,7 +375,7 @@ class AETHERIA_OT_remove_mount(bpy.types.Operator): def execute(self, context): try: - collection, state, _, _ = _mount_frame(context) + collection, state, _ = _mount_frame(context) mount = _selected_mount(context, collection) mount_id = mount["aetheria.id"] for index in reversed(range(len(state.hardpoints))): @@ -406,41 +389,6 @@ class AETHERIA_OT_remove_mount(bpy.types.Operator): return {"FINISHED"} -def _plan_mounts(collection, state, root, path): - """(row, weapon-mount object, Position, yaw) for every buffer row whose Transform names a weapon-mount object: the - Position from the object's placement, the yaw from the row's Rotation. Refuses before anything changes.""" - mounts = _mount_objects(collection) - if not mounts or root is None: # a ship with no Ship Root has no grid to derive from: its rows are the author's - return [] - if state.ship_id != collection["aetheria.id"] or state.ship_cc != path: - raise ValueError("Load Layout first: the weapon mounts need this ship's layout") - paired = [(hp, mounts[hp.mount_id]) for hp in state.hardpoints if hp.mount_id in mounts] - if not paired: - return [] - origin = collection.get("aetheria.grid_origin") - if origin is None: - raise ValueError("Rasterise first: a mount needs the ship's grid") - plan = [] - for hp, obj in paired: - if obj.parent != root: - raise ValueError("A weapon mount must be parented to Ship Root") - position = _derived_position(state, (float(origin[0]), float(origin[1])), _root_local(root, obj), hp.footprint) - plan.append((hp, obj, position, hull_grid.facing_yaw(int(hp.rotation)))) - return plan - - -def _apply_mounts(context, collection, state, plan): - """Writes the planned Positions into the buffer and yaws into the objects, then saves the buffer through - _save_layout, so the buffer and the .cc agree.""" - if not plan: - return - for hp, obj, position, yaw in plan: - hp.x, hp.y = position - obj.rotation_mode = "XYZ" - obj.rotation_euler = (0.0, 0.0, yaw) - _save_layout(context, collection, state) - - def _encode_buffer(state): """The layout buffer as ship_cc stores it: (shape, hardpoint rows).""" shape = [state.width, state.height, [cell.occupied for cell in state.cells]] @@ -1717,10 +1665,8 @@ class AETHERIA_OT_package_ship(bpy.types.Operator): if read(path, cultlib).ship.body[0] != ship_id: raise ValueError("The bound collection ID does not match its .cc ship ID") root = _ship_root(collection) - mounts = _plan_mounts(collection, state, root, path) if root is not None: _frame_ship(context, collection, root, path, saved_shape) - _apply_mounts(context, collection, state, mounts) anchors = _anchors(collection) _export_glb(context, collection, str(Path(path).with_name(MODEL_ASSET))) replace_visual(path, cultlib, ship_id, MODEL_ASSET, anchors) diff --git a/tools/blender/aetheria_ships/hull_grid.py b/tools/blender/aetheria_ships/hull_grid.py index b38770339..e408105be 100644 --- a/tools/blender/aetheria_ships/hull_grid.py +++ b/tools/blender/aetheria_ships/hull_grid.py @@ -20,12 +20,6 @@ def cell_centre(grid_origin, x, y): return (grid_origin[0] - CELL_SIZE * x, grid_origin[1] - CELL_SIZE * y) -def cell_at(grid_origin, point): - """The cell whose centre is nearest to point; a point midway between two centres belongs to the lower cell.""" - return (math.ceil((grid_origin[0] - point[0]) / CELL_SIZE - 0.5), - math.ceil((grid_origin[1] - point[1]) / CELL_SIZE - 0.5)) - - def _cells_across(extent, cell_size): return max(1, math.ceil((extent - _TOLERANCE) / cell_size)) @@ -115,23 +109,6 @@ _FACING_YAW = { } -def _centre_offset(n): - """The footprint cell, counted from its origin, that stands for an n-cell extent's centre: the lower of the two - middle cells when n is even.""" - return (n - 1) // 2 - - -def mounted_position(cell, footprint_w, footprint_h): - """The hardpoint Position (its footprint's origin cell) whose w x h footprint is centred on cell: ShipAuthoringStore - places footprint cell (cx, cy) at Position + (cx, cy), and the footprint's centre is ((w - 1) // 2, (h - 1) // 2).""" - return (cell[0] - _centre_offset(footprint_w), cell[1] - _centre_offset(footprint_h)) - - -def mounted_cell(position, footprint_w, footprint_h): - """The cell a mount's object stands in for a hardpoint at position: the inverse of mounted_position.""" - return (position[0] + _centre_offset(footprint_w), position[1] + _centre_offset(footprint_h)) - - def mirror_position(position, footprint_w, grid_w): """The Position of the mirror image of a footprint across the grid's centre line: a footprint on cells [a, a + w) of a grid_w-wide grid mirrors to cells [grid_w - a - w, grid_w - a). It depends on the footprint's extent alone.""" diff --git a/tools/blender/tests/smoke_mounts.py b/tools/blender/tests/smoke_mounts.py index fd8bb0022..53418abb1 100644 --- a/tools/blender/tests/smoke_mounts.py +++ b/tools/blender/tests/smoke_mounts.py @@ -1,5 +1,5 @@ """Headless Blender smoke for the add-on's weapon mounts, the gunboat path: Add Weapon, Add Muzzle, Mirror Mount, Remove -Mount, and what Package derives from them. +Mount, and that Package moves nothing of them. blender --background --factory-startup --python-use-system-env --python tools/blender/tests/smoke_mounts.py @@ -7,18 +7,20 @@ Environment as smoke_package.py's header says: CULTLIB_PACKAGES and SMOKE_GAME_F ModTools, no dotnet), and msgpack importable. The ship is created under /GameData/Mods/smoke.mounts, which this smoke removes when it ends. -The ship is smoke_frame's sphere, centred on the origin, through New Ship (6 x 10 cells, cell x = 0 at +X). Pass 1 adds a -Ballistic gun on the starboard flank as Clockwise: its row's Rotation is 3, its Position is the cell under the mount, and -its muzzle sits 0.5 m toward -X of it. Mirror Mount makes a CounterClockwise row at the mirrored cell with its muzzle -toward +X, and refuses a mount on the centreline. Pass 2 packages: the Clockwise muzzle node in the GLB turns glTF +Z to -glTF -X, and the mirrored one to +X. Pass 3 moves a weapon two cells by script and sets another's Rotation to Reversed in -the panel state: Package moves the first row's Position by two cells, leaves the internal Reactor row alone, yaws the -second mount to 180 degrees and the first back to its Rotation's yaw, and the validator accepts the ship. Pass 4 removes a -mount with two muzzles: its row, the mount and both muzzles go, and the other rows stay. +The schematic and the model are separate spaces (ruling schematic-and-model-are-separate-spaces): a row's Position is +authored in the layout, a mount's pose in the model, and the two are linked by name alone. The ship is smoke_frame's +sphere, centred on the origin, through New Ship (6 x 10 cells, cell x = 0 at +X). Pass 1 adds a Ballistic gun on the +starboard flank as Clockwise: its row is made at HardpointData's default Position (the model point maps to no cell), the +smoke sets the Position as the panel would, and its muzzle sits 0.5 m toward -X of it. Mirror Mount makes a +CounterClockwise row at the footprint's reflection in the layout and the object's reflection in the model, with its +muzzle toward +X, and refuses a mount on the model's centreline and one whose footprint is its own reflection. Pass 2 +packages: the Clockwise muzzle node in the GLB turns glTF +Z to glTF -X, and the mirrored one to +X. Pass 3 moves a +weapon in the model, hand-sets its yaw and changes another's Rotation: Package leaves every row's Position, in the buffer +and in the .cc, and every mount's yaw as authored. Pass 4 removes a mount with two muzzles: its row, the mount and both +muzzles go, and the other rows stay. """ import json -import math import os import shutil import struct @@ -118,6 +120,17 @@ def set_footprint(scene, mount_id, footprint): next(hp for hp in scene.aetheria_layout.hardpoints if hp.mount_id == mount_id).footprint = footprint +def set_position(scene, mount_id, position): + """The panel's edit: a row's Position is authored in the layout.""" + hp = next(h for h in scene.aetheria_layout.hardpoints if h.mount_id == mount_id) + hp.x, hp.y = position + + +def root_local(root, obj): + """obj's origin in Ship Root's frame.""" + return tuple(root.matrix_world.inverted() @ Vector(world(obj))) + + def world(obj): bpy.context.view_layer.update() return tuple(obj.matrix_world.translation) @@ -167,58 +180,58 @@ def remove_mount(mount_id): require(run(bpy.ops.aetheria.remove_ship_mount)[0] == {"FINISHED"}, "Remove Mount was refused") -def mirror_matrix(scene, root, origin, sphere): - """Mirror Mount against the reflection of the footprint's cells, for every footprint width 1 to 4 at every cell of - the grid: a footprint on cells [a, a + w) of a W-wide grid has its twin on [W - a - w, W - a), whatever the grid's - parity, and a mount within a cell of the centre line has no twin. The expected cells are computed here from the - footprint, never read back from the twin.""" +def mirror_matrix(scene, root, sphere): + """Mirror Mount, each space within itself, for every footprint width 1 to 4 at every authored Position of the grid: + a footprint on cells [a, a + w) of a W-wide grid has its twin row on [W - a - w, W - a), whatever the grid's parity, + and the twin object is the model reflection of the source (-x, y, z), wherever the author put the row. A footprint + that is its own reflection has no twin. The expected cells are computed here from the footprint, never read back + from the twin.""" state = scene.aetheria_layout - grid_w, grid_h = state.width, state.height + grid_w = state.width y_cell = 1 - y = origin[1] - 2.0 * y_cell + model_point = (1.5, -1.0, 0.2) # off the centreline; it names no cell mirrored = refused_count = 0 baseline = rows(scene) for w in (1, 2, 3, 4): - for c in range(grid_w): # the cell the mount's object stands in; a footprint is centred on it - a = c - (w - 1) // 2 - if a < 0 or a + w > grid_w: - continue - x = origin[0] - 2.0 * c - mount_id = new_gun(scene, root, (x, y, 0.0)) + for a in range(grid_w - w + 1): + mount_id = new_gun(scene, root, model_point) set_footprint(scene, mount_id, "#" * w) + set_position(scene, mount_id, (a, y_cell)) select(bpy.data.objects[mount_id]) before = rows(scene) - if abs(x) < 1.0: - refused(bpy.ops.aetheria.mirror_ship_mount, "no mirror image", "Mirror Mount near the centre line") + expected = (grid_w - a - w, y_cell) + if expected == (a, y_cell): + refused(bpy.ops.aetheria.mirror_ship_mount, "no mirror image", "Mirror Mount of a footprint that is its own reflection") require(rows(scene) == before, "A refused Mirror Mount changed a row") refused_count += 1 else: - require(run(bpy.ops.aetheria.mirror_ship_mount)[0] == {"FINISHED"}, f"Mirror Mount was refused (width {w}, cell {c})") + require(run(bpy.ops.aetheria.mirror_ship_mount)[0] == {"FINISHED"}, f"Mirror Mount was refused (width {w}, cell {a})") (twin_id,) = {r[0] for r in rows(scene)} - {r[0] for r in before} twin = row(scene, twin_id) - expected = (grid_w - a - w, y_cell) require(twin[2] == expected, f"A {w}-wide footprint at cell {a} of {grid_w} mirrored to {twin[2]}, not {expected}") require(twin[4] == "#" * w, "The mirrored footprint is not the reflection of the footprint") + reflected = (-model_point[0], model_point[1], model_point[2]) + require(approx(root_local(root, bpy.data.objects[twin_id]), reflected), + f"The twin object is not the model reflection: {root_local(root, bpy.data.objects[twin_id])}") mirrored += 1 remove_mount(twin_id) remove_mount(mount_id) - require(mirrored >= 6 and (grid_w % 2 == 0 or refused_count >= 1), f"The matrix ran too little ({mirrored}, {refused_count})") + require(mirrored >= 6 and refused_count >= 1, f"The matrix ran too little ({mirrored}, {refused_count})") require(rows(scene) == baseline, "The matrix left rows behind") - # A mount exactly on a cell boundary: its twin is the reflection of the cell it was placed in, not a second rounding. - k = grid_w // 2 - mount_id = new_gun(scene, root, (origin[0] - 2.0 * k - 1.0, y, 0.0)) - source = row(scene, mount_id) + # The twin carries the author's armour and arc, rotation mirrored, and the muzzle's reflection. + mount_id = new_gun(scene, root, model_point) + set_position(scene, mount_id, (0, y_cell)) hp = next(h for h in state.hardpoints if h.mount_id == mount_id) hp.armor, hp.firing_arc = 3.0, 45.0 # the author's numbers muzzle = bpy.data.objects[f"{mount_id}.muzzle.1"] muzzle.location.x = 0.3 select(bpy.data.objects[mount_id]) before_ids = {r[0] for r in rows(scene)} - require(run(bpy.ops.aetheria.mirror_ship_mount)[0] == {"FINISHED"}, "Mirror Mount of a boundary mount was refused") + require(run(bpy.ops.aetheria.mirror_ship_mount)[0] == {"FINISHED"}, "Mirror Mount of a muzzled mount was refused") (twin_id,) = {r[0] for r in rows(scene)} - before_ids twin = row(scene, twin_id) - require(twin[2] == (grid_w - 1 - source[2][0], y_cell), f"The boundary mount's twin is at {twin[2]}") + require(twin[2] == (grid_w - 1, y_cell), f"The twin is at {twin[2]}") require(twin[3] == COUNTER_CLOCKWISE and twin[5:] == (3.0, 45.0), f"The twin's rotation or armour and arc are wrong: {twin}") gun_at, twin_at = world(bpy.data.objects[mount_id]), world(bpy.data.objects[twin_id]) source_offset = tuple(m - g for m, g in zip(world(muzzle), gun_at)) @@ -228,22 +241,20 @@ def mirror_matrix(scene, root, origin, sphere): remove_mount(twin_id) remove_mount(mount_id) - # The centre line is a cell wide: half a metre off it has no twin. - mount_id = new_gun(scene, root, (0.5, y, 0.0)) - select(bpy.data.objects[mount_id]) - before = rows(scene) - refused(bpy.ops.aetheria.mirror_ship_mount, "no mirror image", "Mirror Mount of a mount half a metre off the centre line") - require(rows(scene) == before, "A refused Mirror Mount changed a row") - remove_mount(mount_id) - - # A mount whose cell is one past the grid's last is refused, on either axis, and changes nothing. - for edge in ((origin[0] - 2.0 * grid_w, y), (origin[0], origin[1] - 2.0 * grid_h)): - before, objects = rows(scene), len(bpy.data.objects) - scene.cursor.location = root.matrix_world @ Vector((edge[0], edge[1], 0.0)) - select(sphere) - refused(lambda: bpy.ops.aetheria.add_ship_weapon(kind=BALLISTIC, rotation=str(CLOCKWISE)), - "outside the grid", "Add Weapon past the grid's edge") - require(rows(scene) == before and len(bpy.data.objects) == objects, "A refused Add Weapon changed something") + # The model's centreline has no mirror image, whatever the row says; a millimetre is on it, two are not. + for x, ok in ((0.0, False), (5e-4, False), (2e-3, True)): + mount_id = new_gun(scene, root, (x, model_point[1], 0.0)) + set_position(scene, mount_id, (0, y_cell)) + select(bpy.data.objects[mount_id]) + before = rows(scene) + if ok: + require(run(bpy.ops.aetheria.mirror_ship_mount)[0] == {"FINISHED"}, "Mirror Mount just off the centreline was refused") + (twin_id,) = {r[0] for r in rows(scene)} - {r[0] for r in before} + remove_mount(twin_id) + else: + refused(bpy.ops.aetheria.mirror_ship_mount, "no mirror image", f"Mirror Mount of a mount {x} m off the centreline") + require(rows(scene) == before, "A refused Mirror Mount changed a row") + remove_mount(mount_id) def main(): @@ -301,7 +312,9 @@ def main(): gun = bpy.data.objects["weapon.1"] require(gun.get("aetheria.role") == "weapon-mount" and gun.parent == root, "The mount is not a tagged child of Ship Root") gun_row = row(scene, "weapon.1") - require(gun_row == ("weapon.1", 10, (4, 5), CLOCKWISE, "#", 0.0, 0.0), f"The gun's row is wrong: {gun_row}") + require(gun_row == ("weapon.1", 10, (0, 0), CLOCKWISE, "#", 0.0, 0.0), + f"The gun's row is not the default Position (the model point maps to no cell): {gun_row}") + set_position(scene, "weapon.1", (4, 5)) # authored in the layout, as the panel would muzzles = [o for o in bpy.data.objects if o.get("aetheria.role") == "weapon-muzzle"] require([m.get("aetheria.id") for m in muzzles] == ["weapon.1.muzzle.1"] and muzzles[0].parent == gun and muzzles[0].get("aetheria.order") == 1, "Add Weapon did not make its one ordered muzzle") @@ -321,14 +334,14 @@ def main(): require(approx(twin_at, (3.0, -1.0, 0.0)), f"The mirrored mount is not across the centreline: {twin_at}") require(approx(tuple(m - t for m, t in zip(world(twin_muzzle), twin_at)), (0.5, 0.0, 0.0)), "The mirrored muzzle does not point toward +X") - scene.cursor.location = (0.2, 0.0, 0.0) + scene.cursor.location = (0.0, 0.0, 0.0) select(sphere) require(run(lambda: bpy.ops.aetheria.add_ship_weapon(kind=BALLISTIC, rotation="0"))[0] == {"FINISHED"}, "Add Weapon on the centreline was refused") centre = bpy.data.objects["weapon.3"] select(centre) before = rows(scene) - refused(bpy.ops.aetheria.mirror_ship_mount, "no mirror image", "Mirror Mount of a centreline mount") + refused(bpy.ops.aetheria.mirror_ship_mount, "no mirror image", "Mirror Mount of a mount on the model's centreline") require(rows(scene) == before and bpy.data.objects.get("weapon.4") is None, "A refused Mirror Mount changed something") print("PASS 1 add weapon and mirror ok") @@ -346,12 +359,13 @@ def main(): require(rows(scene) == others, "Remove Mount changed a row besides its own") print("PASS 4 remove mount ok") - mirror_matrix(scene, root, (origin[0], origin[1]), sphere) + mirror_matrix(scene, root, sphere) print("PASS 5 mirror matrix ok") # A mirrored pair of two-cell footprints stays where Mirror Mount put it through Package and the validator. pair = new_gun(scene, root, (-1.0, 1.0, 0.0)) set_footprint(scene, pair, "##") + set_position(scene, pair, (3, 4)) select(bpy.data.objects[pair]) require(run(bpy.ops.aetheria.mirror_ship_mount)[0] == {"FINISHED"}, "Mirror Mount of the pair was refused") pair_rows = [r for r in rows(scene) if r[0] not in ("weapon.1", "weapon.2") and r[0].startswith("weapon.")] @@ -372,47 +386,32 @@ def main(): require([r for r in rows(scene) if r in pair_rows] == pair_rows, "Package moved a mirrored pair") print("PASS 2 package and glb ok") - # Pass 3: a moved mount and a changed Rotation; Package derives Position and yaw from them. + # Pass 3: Package moves nothing. A mount moved in the model, a hand-set yaw and a changed Rotation stay as authored. gun, twin = bpy.data.objects["weapon.1"], bpy.data.objects["weapon.2"] - gun.location.y += 4.0 # two cells aft - gun.rotation_euler.z = 0.3 # a hand-set facing: Package overwrites it from the row + gun.location.y += 4.0 # two cells' worth aft in the model; the row is the author's + gun.rotation_euler.z = 0.3 # a hand-set facing + twin_yaw = twin.rotation_euler.z for hp in scene.aetheria_layout.hardpoints: if hp.mount_id == "weapon.2": hp.rotation = str(REVERSED) select(sphere) require(run(bpy.ops.aetheria.save_ship_layout)[0] == {"FINISHED"}, "Save before the second Package was refused") - require(row(scene, "weapon.1")[2] == (4, 5), "The buffer's Position moved before Package") + authored = rows(scene) result, message = run(bpy.ops.aetheria.package_ship) report = scene.aetheria_package_report require(result == {"FINISHED"}, f"Package refused the moved ship: {report}") - require(row(scene, "weapon.1")[2] == (4, 3), f"Package did not move the row two cells: {row(scene, 'weapon.1')[2]}") - require(row(scene, "weapon.2")[2] == (1, 5), "Package moved a mount that did not move") - require(row(scene, "reactor.a") == reactor_before, "Package changed an internal row") - require(abs(twin.rotation_euler.z - math.pi) < 1e-6 or abs(twin.rotation_euler.z + math.pi) < 1e-6, - f"The Reversed mount's yaw is not 180 degrees: {twin.rotation_euler.z}") - require(abs(gun.rotation_euler.z + math.pi / 2) < 1e-6, f"Package kept a hand-set yaw: {gun.rotation_euler.z}") + require(rows(scene) == authored, "Package changed a row in the buffer") + require(row(scene, "weapon.1")[2] == (4, 5) and row(scene, "weapon.2")[2] == (1, 5), "Package moved a weapon's Position") + require(row(scene, "weapon.2")[3] == REVERSED and row(scene, "reactor.a") == reactor_before, "Package changed a Rotation or an internal row") + require(abs(gun.rotation_euler.z - 0.3) < 1e-6, f"Package turned a hand-set yaw: {gun.rotation_euler.z}") + require(abs(twin.rotation_euler.z - twin_yaw) < 1e-6, f"Package turned a mount to its row's Rotation: {twin.rotation_euler.z}") saved = aetheria_ships.ship_cc.read_layout(path, PACKAGES)[1] by_mount = {fields["Transform"]: fields for fields in map(aetheria_ships.ship_cc.decode_hardpoint, saved)} - require(tuple(by_mount["weapon.1"]["Position"]) == (4, 3) and by_mount["weapon.2"]["Rotation"] == REVERSED - and tuple(by_mount["reactor.a"]["Position"]) == (2, 2), "The .cc does not hold what Package derived") - # A framed ship whose layout buffer is not loaded cannot be derived from: Package refuses and changes nothing. - state = scene.aetheria_layout - ship_id, glb_before = state.ship_id, glb.read_bytes() - state.ship_id = "" - result, message = run(bpy.ops.aetheria.package_ship) - require(result == {"CANCELLED"} and "Load Layout first" in scene.aetheria_package_report - and all(name not in scene.aetheria_package_report for name in NAMES_NOT_ECHOED), - f"Package took an unloaded layout: {scene.aetheria_package_report}") - require(glb.read_bytes() == glb_before, "A refused Package wrote the GLB") - state.ship_id = ship_id + require(tuple(by_mount["weapon.1"]["Position"]) == (4, 5) and tuple(by_mount["weapon.2"]["Position"]) == (1, 5) + and by_mount["weapon.2"]["Rotation"] == REVERSED and tuple(by_mount["reactor.a"]["Position"]) == (2, 2), + "The .cc does not hold what the author left") require([r for r in rows(scene) if r in pair_rows] == pair_rows, "The second Package moved a mirrored pair") - twin.parent = None # a mount that is not under Ship Root cannot be derived from - row_before = rows(scene) - result, message = run(bpy.ops.aetheria.package_ship) - require(result == {"CANCELLED"} and "must be parented" in scene.aetheria_package_report and rows(scene) == row_before, - f"Package took a mount outside Ship Root: {scene.aetheria_package_report}") - twin.parent = root - print("PASS 3 package derives ok") + print("PASS 3 package moves nothing ok") # Passes 6 to 8: the same mirror matrix on ships whose grids are odd, wide and even, and wide and odd (10 or more # cells across, where real hulls live). Each pass clears the previous ship's objects, which hold the names the matrix @@ -444,8 +443,7 @@ def matrix_pass(scene, previous, ship_id, length, y_scale, wide, parity): require(result == {"FINISHED"}, f"Save ({ship_id}) was refused: {message}") width = scene.aetheria_layout.width require(width % 2 == parity and (width >= 10 or not wide), f"The {ship_id} grid is {width} wide") - origin = ship["aetheria.grid_origin"] - mirror_matrix(scene, sys.modules["aetheria_ships"]._ship_root(ship), (origin[0], origin[1]), sphere) + mirror_matrix(scene, sys.modules["aetheria_ships"]._ship_root(ship), sphere) return ship diff --git a/tools/blender/tests/test_hull_grid.py b/tools/blender/tests/test_hull_grid.py index ae310a949..06667c0f2 100644 --- a/tools/blender/tests/test_hull_grid.py +++ b/tools/blender/tests/test_hull_grid.py @@ -32,12 +32,6 @@ def occupied(cells, height): class CellMappingTests(unittest.TestCase): ORIGIN = (3.0, -5.0) - def test_every_cell_of_a_5x3_grid_maps_back_to_itself(self): - for x in range(5): - for y in range(3): - centre = hull_grid.cell_centre(self.ORIGIN, x, y) - self.assertEqual(hull_grid.cell_at(self.ORIGIN, centre), (x, y)) - def test_cell_1_0_is_two_metres_toward_minus_x_and_0_1_toward_minus_y(self): origin = hull_grid.cell_centre(self.ORIGIN, 0, 0) self.assertEqual(origin, self.ORIGIN) @@ -45,18 +39,6 @@ class CellMappingTests(unittest.TestCase): self.assertEqual(hull_grid.cell_centre(self.ORIGIN, 0, 1), (3.0, -7.0)) self.assertEqual(hull_grid.cell_centre(self.ORIGIN, 4, 2), (-5.0, -9.0)) - def test_cell_at_takes_the_nearest_centre_and_the_lower_cell_on_a_tie(self): - near = lambda dx, dy: hull_grid.cell_at(self.ORIGIN, (self.ORIGIN[0] - 2 + dx, self.ORIGIN[1] - 4 + dy)) - self.assertEqual(near(0.9, -0.9), (1, 2)) - self.assertEqual(near(-0.9, 0.9), (1, 2)) - self.assertEqual(near(1.1, 0), (0, 2)) - self.assertEqual(near(-1.1, 0), (2, 2)) - self.assertEqual(near(0, 1.1), (1, 1)) - self.assertEqual(near(0, -1.1), (1, 3)) - self.assertEqual(near(1.0, 1.0), (0, 1)) - self.assertEqual(near(-1.0, -1.0), (1, 2)) - - class InsideTests(unittest.TestCase): TRIANGLE = ((0, 0), (1, 0), (0, 1)) @@ -136,7 +118,6 @@ class RasteriseTests(unittest.TestCase): for x in range(width): for y in range(height): cx, cy = hull_grid.cell_centre(origin, x, y) - self.assertEqual(hull_grid.cell_at(origin, (cx, cy)), (x, y)) inside = any(hull_grid._inside(tri, cx, cy) for tri in rect(-2, -2, 2, 0) + rect(0, 0, 2, 2)) self.assertEqual(inside, cells[x * height + y]) @@ -202,11 +183,6 @@ class MountArithmeticTests(unittest.TestCase): "#.....": ".....#", "##.#.../......": "...#.##/......"}.items(): self.assertEqual(hull_grid.mirror_footprint(footprint), expected, footprint) - def test_mounted_position_offsets_the_cell_by_the_footprint_centre(self): - for (w, h), expected in {(1, 1): (5, 4), (3, 1): (4, 4), (3, 3): (4, 3), (2, 1): (5, 4), (2, 2): (5, 4), - (4, 2): (4, 4)}.items(): - self.assertEqual(hull_grid.mounted_position((5, 4), w, h), expected, (w, h)) - def test_mirror_position_reflects_the_footprints_cells_on_odd_and_even_grids(self): for grid_w in range(1, 10): for w in range(1, grid_w + 1): @@ -222,16 +198,6 @@ class MountArithmeticTests(unittest.TestCase): filled = {twin[0] + i for i, c in enumerate(hull_grid.mirror_footprint(footprint)) if c == "#"} self.assertEqual(filled, {grid_w - 1 - c for c in (1, 3, 4)}) - def test_an_object_in_the_mounted_cell_derives_the_position_back_for_every_width(self): - origin = (5.0, 9.0) - for w in range(1, 7): - for h in range(1, 4): - for position in ((0, 0), (3, 5), (6, 2)): - cell = hull_grid.mounted_cell(position, w, h) - centre = hull_grid.cell_centre(origin, *cell) - self.assertEqual(hull_grid.mounted_position(hull_grid.cell_at(origin, centre), w, h), position, - (w, h, position)) - if __name__ == "__main__": unittest.main() From adaa8b88651b63351bb33596200c4e9131067e21 Mon Sep 17 00:00:00 2001 From: soul Date: Mon, 19 Oct 2026 00:17:18 +0200 Subject: [PATCH 067/444] Build and Package refuse objects that share an aetheria.id, by selection One _refuse_shared_ids: a copy of the collider, an emitter, a radiator or a mount selects the original and the copy and names a count, not an object (follow-up hull-copy-refusals). smoke_hull_source covers it (not run: bpy smokes). Co-Authored-By: Claude Sonnet 5.5 --- tools/blender/aetheria_ships/__init__.py | 21 ++++++++++++ tools/blender/tests/smoke_hull_source.py | 41 ++++++++++++++++++++++++ 2 files changed, 62 insertions(+) diff --git a/tools/blender/aetheria_ships/__init__.py b/tools/blender/aetheria_ships/__init__.py index 5b640e53b..9bb79290f 100644 --- a/tools/blender/aetheria_ships/__init__.py +++ b/tools/blender/aetheria_ships/__init__.py @@ -818,6 +818,25 @@ def _refuse_loose_meshes(context, collection): _refuse_selecting(context, loose, f"{len(loose)} meshes in the ship collection are not under Ship Root; parent them to it") +def _shared_id_objects(collection): + """The role-bearing objects of the collection that share an aetheria.id with another: every copy and its original.""" + by_id = {} + for obj in collection.all_objects: + anchor_id = obj.get("aetheria.id") + if obj.get("aetheria.role") is not None and isinstance(anchor_id, str): + by_id.setdefault(anchor_id, []).append(obj) + return [obj for group in by_id.values() if len(group) > 1 for obj in group] + + +def _refuse_shared_ids(context, collection): + """Refuses while two role-bearing objects share an aetheria.id (a copy of a collider, emitter, radiator or mount), + selecting the original and the copies so the author can delete or re-id them. Nothing is deleted.""" + shared = _shared_id_objects(collection) + if shared: + _refuse_selecting(context, shared, f"{len(shared)} objects share an aetheria.id; they are selected: delete the " + "copies or give each its own id") + + def _frame_inputs(context, collection, state): """Ship Root and the meshes Rasterise reads, or the refusal that stops Rasterise and Flip Nose before they change anything.""" @@ -1542,6 +1561,7 @@ def _build_hull_source(context, collection): untouched, changed = _hull_built(collection) if root is not None: _refuse_loose_meshes(context, collection) + _refuse_shared_ids(context, collection) theirs = [obj for obj in _own_meshes(collection) if obj not in untouched] if theirs or changed: raise ValueError("The collection holds meshes the hull source did not make, or objects it made that were " @@ -1653,6 +1673,7 @@ class AETHERIA_OT_package_ship(bpy.types.Operator): if collection.get("aetheria.pending"): raise ValueError("Save Layout first") _refuse_loose_meshes(context, collection) + _refuse_shared_ids(context, collection) cultlib = _brokkr_cultlib(context) ship_id = collection["aetheria.id"] state = scene.aetheria_layout diff --git a/tools/blender/tests/smoke_hull_source.py b/tools/blender/tests/smoke_hull_source.py index cabfddfd8..f33666f2f 100644 --- a/tools/blender/tests/smoke_hull_source.py +++ b/tools/blender/tests/smoke_hull_source.py @@ -332,6 +332,7 @@ def one_hull(scene, number, fixture, ship_id, extent, length, rows, body, palett result, message = run(bpy.ops.aetheria.build_hull_source) require(result == {"CANCELLED"} and abs(mount.location.x - at[0] - 1.0) < 1e-6, f"Build discarded a moved mount: {message!r}") mount.location = at + shared_ids_refused(scene, box, collection, ship_id, package, generated(collection), number) print(f"PASS {number}.8 Build refuses and keeps a copy and an edit of what it made") # The author replaces the zone meshes with her art and keeps Build's anchors and collider: Package ships the ship @@ -363,6 +364,46 @@ def one_hull(scene, number, fixture, ship_id, extent, length, rows, body, palett return collection +def shared_ids_refused(scene, box, collection, ship_id, package, objects, number): + """A copy of the collider, an emitter or radiator mesh or a weapon mount shares its original's aetheria.id: Build and + Package refuse by selecting the original and the copy, name a count and no input, and delete nothing.""" + glb = package / "ship.glb" + glb_before = glb.read_bytes() + seen = set() + for obj in list(objects.values()): + role = obj.get("aetheria.role") + if role not in ("hull-collider", "thruster-emitter", "radiator-mesh", "weapon-mount") or role in seen: + continue + seen.add(role) + twin = obj.copy() + if obj.data is not None: + twin.data = obj.data.copy() + collection.objects.link(twin) + twin.parent = obj.parent + require(twin.get("aetheria.id") == obj.get("aetheria.id"), "The copy does not share its original's aetheria.id") + names = sorted(o.name for o in collection.all_objects) + for operator in (bpy.ops.aetheria.build_hull_source, bpy.ops.aetheria.package_ship): + for other in bpy.context.view_layer.objects: + other.select_set(False) + select(box) + result, message = run(operator) + message = message or scene.aetheria_package_report + require(result == {"CANCELLED"} and "2 objects share an aetheria.id" in message, + f"A copy of a {role} was not refused by name of the shared id: {message!r}") + for echoed in (ship_id, str(GAME), obj.name, twin.name, obj["aetheria.id"]): + require(echoed not in message, f"The refusal echoes an input ({echoed})") + require(obj.select_get() and twin.select_get() and not box.select_get(), + f"The refusal did not select the original and the copy of a {role}") + require(sorted(o.name for o in collection.all_objects) == names, "A refused operator deleted or added an object") + require(glb.read_bytes() == glb_before, "A refused Package wrote ship.glb") + mesh = twin.data + bpy.data.objects.remove(twin) + if mesh is not None and mesh.users == 0: + bpy.data.meshes.remove(mesh) + require(len(seen) >= 3, f"The copies covered too few roles: {sorted(seen)}") + print(f"PASS {number}.8b copies of {sorted(seen)} are refused by Build and Package, selected, nothing deleted") + + def check_poses(collection, root, runner): """Every built anchor stands where the hull source says in Ship Root's frame, turned as it says, and the emitter and radiator meshes are the source's meshes in that frame.""" From 7f35c79ecc9d15bcd5aa8ceae7b6650c638b590f Mon Sep 17 00:00:00 2001 From: soul Date: Mon, 19 Oct 2026 00:20:00 +0200 Subject: [PATCH 068/444] Package refuses an agent-built hull unless its product is by the language's maker Language files carry their Faction record key (eighth element of the lang echo); Build records the maker it judged (aetheria.hull_maker); Package compares it with ship_cc.product_makers before writing anything (ruling hull-faction-is-product-maker, finding hull-names-its-own-faction). _art_takes_over is shared with _retire_hull_source. Unit tests untested at this commit until the Yggdrasil run; bpy smokes not run. Co-Authored-By: Claude Sonnet 5.5 --- .../ShipSchemaPinTests.cs | 1 + tools/blender/aetheria_ships/__init__.py | 47 +++++++++--- .../aetheria_ships/factions/pirate.scad | 3 +- .../blender/aetheria_ships/factions/rnd.scad | 4 +- .../aetheria_ships/factions/zhestokost.scad | 6 +- tools/blender/aetheria_ships/hull_source.py | 11 ++- tools/blender/aetheria_ships/ship_cc.py | 17 +++++ tools/blender/tests/smoke_hull_source.py | 75 +++++++++++++++---- tools/blender/tests/test_hull_source.py | 31 ++++++-- tools/blender/tests/test_ship_cc.py | 38 +++++++++- 10 files changed, 194 insertions(+), 39 deletions(-) diff --git a/tests/Aetheria.Shared.Tests/ShipSchemaPinTests.cs b/tests/Aetheria.Shared.Tests/ShipSchemaPinTests.cs index d47b418af..eeb4825c0 100644 --- a/tests/Aetheria.Shared.Tests/ShipSchemaPinTests.cs +++ b/tests/Aetheria.Shared.Tests/ShipSchemaPinTests.cs @@ -22,6 +22,7 @@ public sealed class ShipSchemaPinTests [InlineData("SCHEMATIC_LINES_SLOT", typeof(ShipAuthoring), nameof(ShipAuthoring.SchematicLines))] [InlineData("HULL_SHAPE_SLOT", typeof(HullData), nameof(HullData.Shape))] [InlineData("HULL_HARDPOINTS_SLOT", typeof(HullData), nameof(HullData.Hardpoints))] + [InlineData("PRODUCT_MANUFACTURER_SLOT", typeof(FactionProductData), nameof(FactionProductData.Manufacturer))] public void PythonSlotConstantsAreTheKeysOfTheMembersTheyNameInTheirComments(string constant, Type owner, string member) { Assert.Equal(KeyOf(owner, member), Integer(constant)); diff --git a/tools/blender/aetheria_ships/__init__.py b/tools/blender/aetheria_ships/__init__.py index 9bb79290f..761c254c7 100644 --- a/tools/blender/aetheria_ships/__init__.py +++ b/tools/blender/aetheria_ships/__init__.py @@ -25,7 +25,7 @@ from mathutils import Matrix, Vector from . import hull_grid, hull_source from .ship_cc import (HARDPOINT_MEMBERS, HARDPOINT_TYPE_NAMES, ROTATION_NAMES, capture_grease_pencil, decode_hardpoint, encode_hardpoint, layout_revision, read, read_layout, replace_layout, replace_lines, - replace_visual, valid_ship_id) + product_makers, replace_visual, valid_ship_id) MODEL_ASSET = "ship.glb" SOURCE = "Source" # child collection: the Tripo mesh, input to Rasterise, never exported @@ -1491,6 +1491,7 @@ def _frame_ship(context, collection, root, path, saved_shape): _recentre(root, hull_grid.cell_centre((float(origin[0]), float(origin[1])), centre[0], centre[1])) +HULL_MAKER = "aetheria.hull_maker" # on the collection: the Faction key of the language Build judged the hull against HULL_BUILT = "aetheria.hull_built" # on the collection: {object name: fingerprint} of what Build From Hull Source made HULL_COLLIDER = ("hull-collider", "collider", "Hull Collider") @@ -1619,27 +1620,50 @@ def _build_hull_source(context, collection): name, hull.zones[hull.body_zone], _hull_material(ship_id, "collider", (0.5, 0.5, 0.5))))) collider["aetheria.role"], collider["aetheria.id"] = role, anchor_id collection[HULL_BUILT] = {obj.name: _fingerprint(obj) for obj in made} + collection[HULL_MAKER] = hull.languages[hull.brief.faction].maker return hull -def _retire_hull_source(collection): - """Deletes the package's hull.scad when the body is the operator's: the collection holds render meshes that Build did - not make as they stand, and no zone mesh of Build's is left. The anchors and collider she kept are hers from then on. - A hull never built, or built and packaged as it stands, keeps its source. Returns whether the file was deleted.""" - source = _hull_source_file(collection) +def _art_takes_over(collection): + """Whether the body is the operator's: a hull.scad and a Ship Root exist, the collection holds render meshes that + Build did not make as they stand, and no zone mesh of Build's is left.""" root = _ship_root(collection) - if source is None or root is None: + if _hull_source_file(collection) is None or root is None: return False untouched, _ = _hull_built(collection) zones = [obj for obj in untouched if obj.type == "MESH" and "aetheria.role" not in obj] - if zones or not [obj for obj in _own_meshes(collection) if obj not in untouched]: + return not zones and bool([obj for obj in _own_meshes(collection) if obj not in untouched]) + + +def _retire_hull_source(collection): + """Deletes the package's hull.scad when the body is the operator's (_art_takes_over). The anchors and collider she + kept are hers from then on. A hull never built, or built and packaged as it stands, keeps its source. Returns whether + the file was deleted.""" + if not _art_takes_over(collection): return False - source.unlink() - if HULL_BUILT in collection: - del collection[HULL_BUILT] + _hull_source_file(collection).unlink() + for record in (HULL_BUILT, HULL_MAKER): + if record in collection: + del collection[record] return True +def _refuse_wrong_maker(collection, path, cultlib): + """Refuses, before Package writes anything, an agent-built hull (hull.scad still in the package and not taken over by + her art) unless the package holds a product and every product is by the maker of the language Build judged the hull + against (ruling hull-faction-is-product-maker). The product owns the maker; Build's record is only what it judged.""" + if _hull_source_file(collection) is None or _art_takes_over(collection): + return + maker = collection.get(HULL_MAKER) + if maker is None: + raise ValueError("Build From Hull Source first: Package needs the maker it judged the hull against") + makers = product_makers(path, cultlib) + if not makers: + raise ValueError("The package has no product: add one for the hull's maker before packaging") + if any(product != maker for product in makers): + raise ValueError("A product in the package is not by the maker of the hull's faction language") + + class AETHERIA_OT_build_hull_source(bpy.types.Operator): bl_idname = "aetheria.build_hull_source" bl_label = "Build From Hull Source" @@ -1675,6 +1699,7 @@ class AETHERIA_OT_package_ship(bpy.types.Operator): _refuse_loose_meshes(context, collection) _refuse_shared_ids(context, collection) cultlib = _brokkr_cultlib(context) + _refuse_wrong_maker(collection, path, cultlib) ship_id = collection["aetheria.id"] state = scene.aetheria_layout saved_shape, saved_rows, _ = read_layout(path, cultlib) diff --git a/tools/blender/aetheria_ships/factions/pirate.scad b/tools/blender/aetheria_ships/factions/pirate.scad index 8705869a1..47489b721 100644 --- a/tools/blender/aetheria_ships/factions/pirate.scad +++ b/tools/blender/aetheria_ships/factions/pirate.scad @@ -39,5 +39,6 @@ PI_ZONES = [["primer", [0.48, 0.27, 0.22]], // primer-red salvage plates PI_GLOW = [0.55, 0.85, 1.0]; PI_RADIATOR = [0.70, 0.68, 0.62]; // No body zone and no proportions: the donor's. The additions must make the top view visibly lopsided. -PI_LANG = ["pirate", PI_ZONES, "", PI_GLOW, PI_RADIATOR, [], ["asymmetric", 0.9]]; +// maker: the Pirates Faction record key in GameData/Aetheria.cc +PI_LANG = ["pirate", PI_ZONES, "", PI_GLOW, PI_RADIATOR, [], ["asymmetric", 0.9], "1d8a168def994a76a1ce7cb18f59c038"]; if (part == "lang") echo(lang = PI_LANG); diff --git a/tools/blender/aetheria_ships/factions/rnd.scad b/tools/blender/aetheria_ships/factions/rnd.scad index ae7da7241..971798555 100644 --- a/tools/blender/aetheria_ships/factions/rnd.scad +++ b/tools/blender/aetheria_ships/factions/rnd.scad @@ -26,5 +26,7 @@ RD_ZONES = [["primary", [0.72, 0.74, 0.76]], // the wedge ["recess", [0.10, 0.12, 0.18]]]; // windows, engine bank RD_GLOW = [1.0, 0.75, 0.3]; RD_RADIATOR = [0.40, 0.47, 0.56]; -RD_LANG = ["rnd", RD_ZONES, "primary", RD_GLOW, RD_RADIATOR, [[0.12, 0.35], [0.55, 1.1]], ["bilateral", 0.97]]; +// maker: the Rossum & Douglas Faction record key in GameData/Aetheria.cc +RD_LANG = ["rnd", RD_ZONES, "primary", RD_GLOW, RD_RADIATOR, [[0.12, 0.35], [0.55, 1.1]], ["bilateral", 0.97], + "adfb9b08-aabc-45c6-9586-915698a9c4b0"]; if (part == "lang") echo(lang = RD_LANG); diff --git a/tools/blender/aetheria_ships/factions/zhestokost.scad b/tools/blender/aetheria_ships/factions/zhestokost.scad index c00139dd8..9c4e65bb9 100644 --- a/tools/blender/aetheria_ships/factions/zhestokost.scad +++ b/tools/blender/aetheria_ships/factions/zhestokost.scad @@ -55,6 +55,8 @@ ZH_ZONES = [["primary", [0.42, 0.43, 0.45]], // iron grey: the stepped mass ["recess", [0.08, 0.08, 0.09]]]; // recessed machinery ZH_GLOW = [1.0, 0.62, 0.25]; ZH_RADIATOR = [0.50, 0.51, 0.52]; -// [faction, zones, body zone, glow, radiator, [height/width range, width/length range], symmetry rule] -ZH_LANG = ["zhestokost", ZH_ZONES, "primary", ZH_GLOW, ZH_RADIATOR, [[0.25, 0.6], [0.35, 0.75]], ["bilateral", 0.97]]; +// [faction, zones, body zone, glow, radiator, [height/width range, width/length range], symmetry rule, maker] +// maker: the Zhestokost Faction record key in GameData/Aetheria.cc, the Manufacturer of a product whose hull speaks this language +ZH_LANG = ["zhestokost", ZH_ZONES, "primary", ZH_GLOW, ZH_RADIATOR, [[0.25, 0.6], [0.35, 0.75]], ["bilateral", 0.97], + "16bad4f9-7fb5-41c5-8da0-dc76709eb7d3"]; if (part == "lang") echo(lang = ZH_LANG); diff --git a/tools/blender/aetheria_ships/hull_source.py b/tools/blender/aetheria_ships/hull_source.py index 08fa00b57..e513a7454 100644 --- a/tools/blender/aetheria_ships/hull_source.py +++ b/tools/blender/aetheria_ships/hull_source.py @@ -6,7 +6,8 @@ factions/: the checks read it from there, by the faction the brief names, and th with it (a hull that redefines or shadows an included language fails a check): brief ECHO brief = [faction, donor, role, [length_min, length_max], [tris_min, tris_max], reads] - lang ECHO lang = [faction, [[zone, [r, g, b]], ...], body zone, glow, radiator, proportion, symmetry] + lang ECHO lang = [faction, [[zone, [r, g, b]], ...], body zone, glow, radiator, proportion, symmetry, maker] + (maker: the faction's Faction record key in GameData/Aetheria.cc, the eighth element) (every included language file echoes its own, and so does factions/.scad run alone; a Pirate language has no body zone and no proportion) anchors ECHO anchor = [id, role, [x, y, z], yaw, parent] one line per anchor, yaw 0 nose, 90 port @@ -66,6 +67,7 @@ class Language: radiator: tuple proportion: Optional[tuple] # ((h/w min, max), (w/l min, max)); None for a language that takes its donor's symmetry: tuple # ("bilateral", minimum mirror overlap) or ("asymmetric", maximum mirror overlap) + maker: str # the Faction record key a product of this language's hulls names as its Manufacturer def zone_names(self): return [name for name, _ in self.zones] @@ -204,7 +206,7 @@ def read_languages(text): """Every language echoed in text, in echo order.""" languages = [] for raw in parse_echoes(text, "lang"): - if not isinstance(raw, list) or len(raw) != 7 or not isinstance(raw[1], list): + if not isinstance(raw, list) or len(raw) != 8 or not isinstance(raw[1], list): raise HullSourceError("A faction language has the wrong shape") zones = tuple((_text(z[0]), _vector(z[1], 3)) for z in raw[1] if isinstance(z, list) and len(z) == 2) if len(zones) != len(raw[1]): @@ -213,8 +215,11 @@ def read_languages(text): symmetry = _vector(raw[6], 2, numbers=False) if symmetry[0] not in ("bilateral", "asymmetric") or not _number(symmetry[1]): raise HullSourceError("A faction language names a symmetry rule it does not have") + maker = _text(raw[7]) + if not maker: + raise HullSourceError("A faction language names no maker") language = Language(_text(raw[0]), zones, _text(raw[2]), _vector(raw[3], 3), _vector(raw[4], 3), proportion, - symmetry) + symmetry, maker) if language.body and language.body not in language.zone_names(): raise HullSourceError("A faction language's body zone is not one of its zones") languages.append(language) diff --git a/tools/blender/aetheria_ships/ship_cc.py b/tools/blender/aetheria_ships/ship_cc.py index 41f53601f..7cc093389 100644 --- a/tools/blender/aetheria_ships/ship_cc.py +++ b/tools/blender/aetheria_ships/ship_cc.py @@ -23,6 +23,7 @@ TESTED_CULTCACHE_PY = "0.3.0" SCHEMA = "aetheria.ship_authoring" HULL_SCHEMA = "aetheria.hulldata" PRODUCT_SCHEMA = "aetheria.factionproductdata" +PRODUCT_MANUFACTURER_SLOT = 4 # FactionProductData.Manufacturer # MessagePack slots of the C# types, which own them (ShipAuthoring, ItemData.Shape, HullData.Hardpoints, # HardpointData). RETIRED_HULL_SLOT is the ShipAuthoring key that held the embedded hull before S1; no member owns it now. ShipSchemaPinTests in tests/Aetheria.Shared.Tests fails when any number or name below @@ -135,6 +136,22 @@ def read(path: str, cultlib_packages: str) -> ShipFile: return ShipFile(store, ship, hull, msgpack) +def product_makers(path: str, cultlib_packages: str) -> list[str]: + """The Manufacturer (Faction record key) of every product record in the file, in file order. C# owns products and + this only reads them: a product whose Manufacturer is missing or not a non-empty string is refused, naming nothing.""" + cultcache, msgpack = _libraries(cultlib_packages) + makers = [] + for envelope in cultcache.SingleFileMessagePackBackingStore(path).pull_all(): + if envelope.type != PRODUCT_SCHEMA: + continue + body = msgpack.unpackb(envelope.payload, raw=False) + maker = body[PRODUCT_MANUFACTURER_SLOT] if isinstance(body, list) and len(body) > PRODUCT_MANUFACTURER_SLOT else None + if not isinstance(maker, str) or not maker: + raise ValueError(f"A {PRODUCT_SCHEMA} record names no manufacturer") + makers.append(maker) + return makers + + def _stamped(record: Record, msgpack: Any): return replace( record.envelope, diff --git a/tools/blender/tests/smoke_hull_source.py b/tools/blender/tests/smoke_hull_source.py index f33666f2f..385c667ad 100644 --- a/tools/blender/tests/smoke_hull_source.py +++ b/tools/blender/tests/smoke_hull_source.py @@ -25,7 +25,13 @@ the hull.scad into the package and runs the panel's operators: Build after Package still works. The author replaces the zone meshes with her art and keeps Build's anchors and collider: Package deletes hull.scad in that one act and says so, and Build then refuses. - A hull that was never built keeps its hull.scad through the same deletion rule. + A hull that was never built keeps its hull.scad through the same deletion rule, and Package refuses it (no maker was + judged). + Package refuses an agent-built hull before it writes anything (ship.glb is not made) when the package holds no product, + or a product by another maker than the language Build judged it against (hull-names-its-own-faction); with a product by + that maker it accepts. Her art needs no product. + Build and Package refuse copies that share an aetheria.id, selecting the original and the copies, and delete nothing. + Package moves no row and turns no mount: every layout row is as authored after it, and a hand-turned mount keeps its yaw. """ import json @@ -51,16 +57,19 @@ FIXTURES = REPO / "tools" / "blender" / "tests" / "fixtures" / "hulls" THRUSTER, RADIATOR, BALLISTIC = "3", "6", "10" # fixture folder, ship id, stand-in box (x, y) metres and its length in cells, the layout rows (mount id, kind, cell), -# the body zone the collider copies, and the palette each zone must carry +# the body zone the collider copies, the palette each zone must carry, the Faction name of the maker whose language the +# hull speaks (AetherDb resolves it to the product's Manufacturer), and a Faction that is not its maker HULLS = ( ("zh-ammo-tender", "smoke.hull.tender", (10.0, 23.4), 12, (("Gun.D", BALLISTIC, (2, 3)), ("Th.L", THRUSTER, (0, 0)), ("Th.R", THRUSTER, (1, 0)), ("Ra.L", RADIATOR, (2, 0)), ("Ra.R", RADIATOR, (3, 0))), - "primary", {"primary": (0.42, 0.43, 0.45), "secondary": (0.20, 0.20, 0.22), "accent": (0.55, 0.22, 0.14), "recess": (0.08, 0.08, 0.09)}), + "primary", {"primary": (0.42, 0.43, 0.45), "secondary": (0.20, 0.20, 0.22), "accent": (0.55, 0.22, 0.14), "recess": (0.08, 0.08, 0.09)}, + "Zhestokost", "Pirates"), ("pi-fighter", "smoke.hull.fighter", (8.0, 15.0), 8, (("Gun.P", BALLISTIC, (3, 3)), ("Th.Main", THRUSTER, (0, 0)), ("Th.L", THRUSTER, (1, 0))), "rnd.primary", {"rnd.primary": (0.72, 0.74, 0.76), "primer": (0.48, 0.27, 0.22), "weld": (0.70, 0.68, 0.62), - "accent": (0.10, 0.62, 0.60), "rnd.accent": (0.90, 0.89, 0.84)}), + "accent": (0.10, 0.62, 0.60), "rnd.accent": (0.90, 0.89, 0.84)}, + "Pirates", "Zhestokost"), ) @@ -104,7 +113,8 @@ def generated(collection): def layout_rows(scene): - return [(hp.mount_id, hp.kind, hp.armor, hp.firing_arc, hp.footprint) for hp in scene.aetheria_layout.hardpoints] + return [(hp.mount_id, hp.kind, (hp.x, hp.y), hp.rotation, hp.armor, hp.firing_arc, hp.footprint) + for hp in scene.aetheria_layout.hardpoints] def glb_nodes(path): @@ -166,7 +176,7 @@ def check_built(collection, root, body, palette, anchors_by_role): return objects -def one_hull(scene, number, fixture, ship_id, extent, length, rows, body, palette): +def one_hull(scene, number, fixture, ship_id, extent, length, rows, body, palette, maker, other): collection, box, package = make_ship(scene, fixture, ship_id, extent, length, rows) state = scene.aetheria_layout root = bpy.data.objects[next(o.name for o in collection.all_objects if o.get("aetheria.ship_root"))] @@ -245,11 +255,39 @@ def one_hull(scene, number, fixture, ship_id, extent, length, rows, body, palett source.write_text(original, encoding="utf-8") print(f"PASS {number}.4 Build refuses a hull that breaks its brief") + # Package refuses an agent-built hull whose package has no product, or one by another maker, before it writes + # anything; the maker of the language Build judged it against is accepted. + ship_cc = str(package / "ship.cc") + require(collection.get("aetheria.hull_maker") and not (package / "ship.glb").exists(), "Build recorded no maker, or Package ran") + for label, expected, prepare in (("no product", "The package has no product", lambda: None), + ("a product by another maker", "not by the maker of the hull's faction language", + lambda: require(aetherdb(ship_cc, "product", ship_cc, other, "--name", "x") == 0, + "AetherDb could not write the other maker's product"))): + prepare() + select(box) + rows_now = layout_rows(scene) + result, message = run(bpy.ops.aetheria.package_ship) + report = scene.aetheria_package_report + require(result == {"CANCELLED"} and expected in report, f"Package took a hull with {label}: {report!r} {message!r}") + require(not (package / "ship.glb").exists(), f"Package wrote ship.glb for a hull with {label}") + require(layout_rows(scene) == rows_now, f"A refused Package changed a row ({label})") + for echoed in (ship_id, str(GAME), maker, other, collection["aetheria.hull_maker"]): + require(echoed not in report, f"The refusal echoes an input ({echoed})") + require(aetherdb(ship_cc, "product", ship_cc, other, "--remove") == 0 + and aetherdb(ship_cc, "product", ship_cc, maker, "--name", "x") == 0, "AetherDb could not swap the product") + # Package: the validator accepts the built ship and the GLB holds a node per anchor. + mount = bpy.data.objects[roles["weapon-mount"][0]] + straight = mount.rotation_euler.z + mount.rotation_euler.z = straight + 0.3 # hand-turned: Package turns no mount + turned, rows_authored = mount.rotation_euler.z, layout_rows(scene) select(box) result, message = run(bpy.ops.aetheria.package_ship) report = scene.aetheria_package_report require(result == {"FINISHED"} and "validator accepted" in report, f"Package refused the built ship: {report!r} {message!r}") + require(layout_rows(scene) == rows_authored, "Package changed a layout row") + require(abs(mount.rotation_euler.z - turned) < 1e-6, "Package turned a mount") + mount.rotation_euler.z = straight require(source.is_file(), "Package deleted a hull source whose hull is still built from it") nodes = {n.get("name"): n for n in glb_nodes(package / "ship.glb")} for role, ids in roles.items(): @@ -257,9 +295,8 @@ def one_hull(scene, number, fixture, ship_id, extent, length, rows, body, palett require(anchor_id in nodes, f"The GLB has no node for {anchor_id}") for zone in palette: require(zone in nodes, f"The GLB has no mesh node for zone {zone}") - moved = [r for r in (hp for hp in state.hardpoints) if r.mount_id in roles["weapon-mount"]] - print(f"INFO {number}.5 weapon rows after Package (fork package-derives-schematic-from-model): " - f"{[(hp.mount_id, (hp.x, hp.y)) for hp in moved]} (authored at {[r[2] for r in rows if r[1] == BALLISTIC]})") + saved = [(r[0], tuple(r[2])) for r in layout_rows(scene)] + require(saved == [(mount_id, cell) for mount_id, _, cell in rows], f"A row is not as authored after Package: {saved}") print(f"PASS {number}.5 Package accepts the built ship and keeps hull.scad") # What Package did to the built objects is not an edit: Build still replaces them. @@ -350,12 +387,14 @@ def one_hull(scene, number, fixture, ship_id, extent, length, rows, body, palett collection.objects.link(art) art.parent = root require(any(o.get("aetheria.role") for o in generated(collection).values()), "The scene kept no anchor of Build's") + require(aetherdb(ship_cc, "product", ship_cc, maker, "--remove") == 0, "AetherDb could not remove the product") # her art needs none select(box) result, message = run(bpy.ops.aetheria.package_ship) report = scene.aetheria_package_report require(result == {"FINISHED"} and "deleted hull.scad" in report, f"Package did not retire the hull source: {report!r} {message!r}") require(not source.exists(), "hull.scad is still there after the author's art was packaged") - require("aetheria.hull_built" not in collection, "The record of Build's objects outlived hull.scad") + require("aetheria.hull_built" not in collection and "aetheria.hull_maker" not in collection, + "The record of Build's objects or its maker outlived hull.scad") require(all(echoed not in report for echoed in (ship_id, str(GAME))), "The report echoes an input") select(box) result, message = run(bpy.ops.aetheria.build_hull_source) @@ -447,6 +486,11 @@ def clear(collection): bpy.data.objects.remove(obj) +def aetherdb(ship_path, *args): + """Runs one AetherDb ship-authoring command through the add-on's own runner; returns its exit code.""" + return sys.modules["aetheria_ships"]._aetherdb(bpy.context, *args, ship_cc=ship_path)[0] + + def never_built(scene): """A hull.scad whose hull was never built has no art in the collection: the deletion rule keeps it.""" ship_id = "smoke.hull.unbuilt" @@ -454,7 +498,12 @@ def never_built(scene): api = sys.modules["aetheria_ships"] require(api._retire_hull_source(collection) is False and (package / "hull.scad").is_file(), "A hull that was never built lost its hull.scad") - print("PASS 3 a hull that was never built keeps its hull.scad") + select(box) + result, message = run(bpy.ops.aetheria.package_ship) + report = scene.aetheria_package_report + require(result == {"CANCELLED"} and "Build From Hull Source first" in report and not (package / "ship.glb").exists(), + f"Package took a hull that was never built: {report!r}") + print("PASS 3 a hull that was never built keeps its hull.scad, and Package refuses it") def main(): @@ -487,9 +536,9 @@ def main(): require(not (MODS / ship_id).exists(), f"{MODS / ship_id} exists; remove it first") scene = bpy.context.scene previous = None - for number, (fixture, ship_id, extent, length, rows, body, palette) in enumerate(HULLS, start=1): + for number, (fixture, ship_id, extent, length, rows, body, palette, maker, other) in enumerate(HULLS, start=1): clear(previous) - previous = one_hull(scene, number, fixture, ship_id, extent, length, rows, body, palette) + previous = one_hull(scene, number, fixture, ship_id, extent, length, rows, body, palette, maker, other) clear(previous) never_built(scene) diff --git a/tools/blender/tests/test_hull_source.py b/tools/blender/tests/test_hull_source.py index a7bd201d2..c1045dba0 100644 --- a/tools/blender/tests/test_hull_source.py +++ b/tools/blender/tests/test_hull_source.py @@ -36,11 +36,14 @@ def echo(name, value): return f"ECHO: {name} = {json.dumps(value)}\n" +MAKER = "faction-record-key" + + def language(faction="zhestokost", zones=("primary", "secondary"), body="primary", proportion=((0.25, 0.6), (0.35, 0.75)), - symmetry=("bilateral", 0.97)): + symmetry=("bilateral", 0.97), maker=MAKER): palette = [[z, [0.1 * (i + 1), 0.2, 0.3]] for i, z in enumerate(zones)] return echo("lang", [faction, palette, body, [1, 0.6, 0.2], [0.5, 0.5, 0.5], [list(p) for p in proportion] if proportion - else [], list(symmetry)]) + else [], list(symmetry), maker]) def brief(faction="zhestokost", donor="", length=(8, 14), triangles=(10, 100)): @@ -325,7 +328,7 @@ class LanguageOwnershipTests(unittest.TestCase): def test_a_hull_that_redefines_its_palette_fails_and_is_still_coloured_from_the_file(self): fixture = Fixture() fixture.hull_lang = echo("lang", ["zhestokost", [["primary", [1, 0, 1]], ["secondary", [1, 0, 1]]], "primary", - [1, 0.6, 0.2], [0.5, 0.5, 0.5], [[0.25, 0.6], [0.35, 0.75]], ["bilateral", 0.97]]) + [1, 0.6, 0.2], [0.5, 0.5, 0.5], [[0.25, 0.6], [0.35, 0.75]], ["bilateral", 0.97], MAKER]) self.assertEqual(fixture.failed(), [self.OWN]) built = fixture.build() self.assertEqual(built.colours["primary"], built.languages["zhestokost"].colour("primary")) @@ -345,6 +348,13 @@ class LanguageOwnershipTests(unittest.TestCase): fixture.parts["secondary"] = (box(2, 6, -1, 1, 2, 3), "") # lopsided: the file's bilateral rule refuses it self.assertIn("the top view obeys the language's bilateral rule", fixture.failed()) + def test_a_hull_cannot_restate_its_languages_maker(self): + """The language file owns its maker (the Faction record key Package compares with the product's): an included + language echoed with another maker fails the includes-unchanged check, and the hull is read as the file's.""" + fixture = self.redefined(maker="another-faction-record-key") + self.assertEqual(fixture.failed(), [self.OWN]) + self.assertEqual(fixture.build().languages["zhestokost"].maker, MAKER) + def test_a_second_echo_of_the_language_after_the_first_fails(self): fixture = Fixture() fixture.hull_lang = language() + language(symmetry=("asymmetric", 0.99)) @@ -521,12 +531,21 @@ class RefusalTests(unittest.TestCase): fixture = Fixture() fixture.parts["anchors"] = ("", echo("anchor", anchor)) refused(fixture) - for raw in ([7, [["primary", [1, 1, 1]]], "primary", [1, 1, 1], [1, 1, 1], [], ["bilateral", 1]], - ["zhestokost", [[7, [1, 1, 1]]], "primary", [1, 1, 1], [1, 1, 1], [], ["bilateral", 1]], - ["zhestokost", [["primary", [1, 1, 1]]], 7, [1, 1, 1], [1, 1, 1], [], ["bilateral", 1]]): + for raw in ([7, [["primary", [1, 1, 1]]], "primary", [1, 1, 1], [1, 1, 1], [], ["bilateral", 1], MAKER], + ["zhestokost", [[7, [1, 1, 1]]], "primary", [1, 1, 1], [1, 1, 1], [], ["bilateral", 1], MAKER], + ["zhestokost", [["primary", [1, 1, 1]]], 7, [1, 1, 1], [1, 1, 1], [], ["bilateral", 1], MAKER]): with self.assertRaises(hs.HullSourceError): hs.read_languages(echo("lang", raw)) + def test_the_maker_is_the_eighth_element_of_a_language_echo(self): + self.assertEqual(hs.read_languages(language(maker="K1"))[0].maker, "K1") + raw = ["zhestokost", [["primary", [1, 1, 1]]], "primary", [1, 1, 1], [1, 1, 1], [], ["bilateral", 1]] + for bad in (raw, raw + [""], raw + [7], raw + ["K1", "extra"]): # seven elements, empty maker, not text, nine + with self.assertRaises(hs.HullSourceError) as caught: + hs.read_languages(echo("lang", bad)) + self.assertNotIn("K1", str(caught.exception)) + self.assertEqual(hs.read_languages(echo("lang", raw + ["K1"]))[0].maker, "K1") + def test_a_malformed_anchor_is_refused_without_echoing_it(self): fixture = Fixture() fixture.parts["anchors"] = ("", echo("anchor", [SECRET, "weapon-mount", [0, 0], 0, ""])) diff --git a/tools/blender/tests/test_ship_cc.py b/tools/blender/tests/test_ship_cc.py index b4ea9d313..403d32854 100644 --- a/tools/blender/tests/test_ship_cc.py +++ b/tools/blender/tests/test_ship_cc.py @@ -101,8 +101,10 @@ class ShipFileCase(unittest.TestCase): """Two products as C# stores them, one per maker; Python reads them as envelopes and never encodes one.""" entry = catalog("product-schema-1", ship_cc.PRODUCT_SCHEMA) store = cultcache_py.SingleFileMessagePackBackingStore(self.path) - store.push(envelope("mod-product:mod.skiff:faction:acme", ship_cc.PRODUCT_SCHEMA, entry, [None, "Acme Skiff", "Reliable."])) - store.push(envelope("mod-product:mod.skiff:faction:rival", ship_cc.PRODUCT_SCHEMA, entry, [None, "Rival Skiff", None, "tail"])) + store.push(envelope("mod-product:mod.skiff:faction:acme", ship_cc.PRODUCT_SCHEMA, entry, + [None, "Acme Skiff", "Reliable.", None, "acme-faction-key"])) + store.push(envelope("mod-product:mod.skiff:faction:rival", ship_cc.PRODUCT_SCHEMA, entry, + [None, "Rival Skiff", None, "tail", "rival-faction-key"])) def products(self): return {(e.type, e.key): e for e in cultcache_py.SingleFileMessagePackBackingStore(self.path).pull_all() @@ -214,6 +216,38 @@ class ReadTests(ShipFileCase): ship_cc.read(self.path, PACKAGES) +class ProductMakerTests(ShipFileCase): + """Manufacturer is slot 4 of a product, the Faction record key; Python reads it and never writes a product.""" + + def push_product(self, key, body): + entry = catalog("product-schema-1", ship_cc.PRODUCT_SCHEMA) + cultcache_py.SingleFileMessagePackBackingStore(self.path).push(envelope(key, ship_cc.PRODUCT_SCHEMA, entry, body)) + + def test_each_products_manufacturer_is_read_in_file_order(self): + self.add_products() + self.assertEqual(["acme-faction-key", "rival-faction-key"], ship_cc.product_makers(self.path, PACKAGES)) + + def test_a_file_with_no_product_has_no_maker(self): + self.assertEqual([], ship_cc.product_makers(self.path, PACKAGES)) + + def test_the_manufacturer_is_read_from_its_slot_not_from_a_neighbour(self): + self.push_product("mod-product:mod.skiff:faction:acme", [None, "name-slot", "description-slot", "design-slot", + "maker-slot", "roles-slot"]) + self.assertEqual(["maker-slot"], ship_cc.product_makers(self.path, PACKAGES)) + + def test_a_product_with_no_manufacturer_is_refused_naming_no_input(self): + canary = "canary-product-name" + for body in ([None, canary, "Reliable."], [None, canary, None, None, None], [None, canary, None, None, ""], + [None, canary, None, None, 7]): + with self.subTest(body=body): + self.setUp() + self.push_product("mod-product:mod.skiff:faction:" + canary, body) + with self.assertRaises(ValueError) as caught: + ship_cc.product_makers(self.path, PACKAGES) + self.assertNotIn(canary, str(caught.exception)) + self.assertNotIn(self.path, str(caught.exception)) + + class ProductCarryThroughTests(ShipFileCase): """Products lie beside the pair, owned by C#: every push rewrites the file and leaves them byte-identical.""" From af993acf07f6d427c31ed07126eab91743fbaf64 Mon Sep 17 00:00:00 2001 From: soul Date: Mon, 19 Oct 2026 00:21:11 +0200 Subject: [PATCH 069/444] ZoneGenerator: an unbuildable boss Loadout restores itemManager.Random, so the zone's later draws equal the no-boss zone's Co-Authored-By: Claude Sonnet 5.5 --- Assets/Scripts/ServerShared/ZoneGenerator.cs | 2 ++ tests/Aetheria.Shared.Tests/RunGoalTests.cs | 18 ++++++++++++++++++ 2 files changed, 20 insertions(+) diff --git a/Assets/Scripts/ServerShared/ZoneGenerator.cs b/Assets/Scripts/ServerShared/ZoneGenerator.cs index 089ce3026..3db7c07ea 100644 --- a/Assets/Scripts/ServerShared/ZoneGenerator.cs +++ b/Assets/Scripts/ServerShared/ZoneGenerator.cs @@ -433,9 +433,11 @@ public static class ZoneGenerator continue; } var failures = new List(); + var streamBefore = itemManager.Random; var entity = Loadouts.Materialize(itemManager, null, loadout, GetLoadoutGenerator(bossFaction).ChooseProduct, failures); if (entity == null) { + itemManager.Random = streamBefore; // a failed fit has drawn lots; an unbuildable boss draws nothing, as a refused one does itemManager.Log($"{bossFaction.Name}'s boss Loadout {loadout.Name} cannot be built: {string.Join("; ", failures)}"); continue; } diff --git a/tests/Aetheria.Shared.Tests/RunGoalTests.cs b/tests/Aetheria.Shared.Tests/RunGoalTests.cs index 8f7977943..65ccd65a5 100644 --- a/tests/Aetheria.Shared.Tests/RunGoalTests.cs +++ b/tests/Aetheria.Shared.Tests/RunGoalTests.cs @@ -629,4 +629,22 @@ public sealed partial class RunStartTests Assert.Empty(failures); Assert.Equal(expected.Random.NextFloat(), next); } + + [Fact] + public void An_unbuildable_boss_loadout_draws_nothing() + { + var galaxy = DemoGalaxy(1); + var loadout = GiveAntagonistABoss(galaxy); + loadout.Slots[0].Position = new int2(999, 999); + StoreLoadout(loadout); + var items = LoggingItems(new List()); + var pack = BossZonePack(galaxy, items, galaxy.Exit); + Assert.False(pack.BossSpawned); + var next = items.Random.NextFloat(); + galaxy.BossZones.Clear(); + var bareItems = LoggingItems(new List()); + var bare = BossZonePack(galaxy, bareItems, galaxy.Exit); + Assert.Equal(Roster(bareItems, bare), Roster(items, pack)); + Assert.Equal(bareItems.Random.NextFloat(), next); + } } From 8a6816ed3edda7029c592ad35d24f4df2096aa78 Mon Sep 17 00:00:00 2001 From: soul Date: Mon, 19 Oct 2026 00:25:31 +0200 Subject: [PATCH 070/444] test_ship_cc: a product body one short of the Manufacturer slot is refused, not an IndexError Kills mutant short-body-reads-past-end. Co-Authored-By: Claude Sonnet 5.5 --- tools/blender/tests/test_ship_cc.py | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/tools/blender/tests/test_ship_cc.py b/tools/blender/tests/test_ship_cc.py index 403d32854..aae9e135b 100644 --- a/tools/blender/tests/test_ship_cc.py +++ b/tools/blender/tests/test_ship_cc.py @@ -237,7 +237,8 @@ class ProductMakerTests(ShipFileCase): def test_a_product_with_no_manufacturer_is_refused_naming_no_input(self): canary = "canary-product-name" - for body in ([None, canary, "Reliable."], [None, canary, None, None, None], [None, canary, None, None, ""], + for body in ([None, canary, "Reliable."], [None, canary, None, None], [None, canary, None, None, None], + [None, canary, None, None, ""], [None, canary, None, None, 7]): with self.subTest(body=body): self.setUp() From 399dc769df4f954f4b5e9ea16cc9bef0691a7e09 Mon Sep 17 00:00:00 2001 From: soul Date: Mon, 19 Oct 2026 01:09:11 +0200 Subject: [PATCH 071/444] Package's maker guard keys on Build's record, not on hull.scad; smoke_hull_source fixes _refuse_wrong_maker returned early when hull.scad was absent, so a built hull packaged with any product once the file was deleted. It now keys on aetheria.hull_maker; only art takeover (which retires both) and a package that never held a built hull are exempt. smoke_hull_source: ATTACKS lang literals gain the eighth (maker) element; new refusal steps for hull.scad gone and for a right-maker plus a wrong-maker product; a refused Package must leave the scene untouched (guard order). Unrun at this commit. Co-Authored-By: Claude Sonnet 5.5 --- tools/blender/aetheria_ships/__init__.py | 11 +++--- tools/blender/tests/smoke_hull_source.py | 45 ++++++++++++++++++------ 2 files changed, 41 insertions(+), 15 deletions(-) diff --git a/tools/blender/aetheria_ships/__init__.py b/tools/blender/aetheria_ships/__init__.py index 761c254c7..b47860f1d 100644 --- a/tools/blender/aetheria_ships/__init__.py +++ b/tools/blender/aetheria_ships/__init__.py @@ -1649,13 +1649,16 @@ def _retire_hull_source(collection): def _refuse_wrong_maker(collection, path, cultlib): - """Refuses, before Package writes anything, an agent-built hull (hull.scad still in the package and not taken over by - her art) unless the package holds a product and every product is by the maker of the language Build judged the hull - against (ruling hull-faction-is-product-maker). The product owns the maker; Build's record is only what it judged.""" - if _hull_source_file(collection) is None or _art_takes_over(collection): + """Refuses, before Package writes anything, an agent-built hull (one Build left a maker record on, or whose hull.scad + is still in the package) unless the package holds a product and every product is by the maker of the language Build + judged the hull against (ruling hull-faction-is-product-maker). Her art retires hull.scad and the record together and + needs no product. The product owns the maker; Build's record is only what it judged.""" + if _art_takes_over(collection): return maker = collection.get(HULL_MAKER) if maker is None: + if _hull_source_file(collection) is None: + return raise ValueError("Build From Hull Source first: Package needs the maker it judged the hull against") makers = product_makers(path, cultlib) if not makers: diff --git a/tools/blender/tests/smoke_hull_source.py b/tools/blender/tests/smoke_hull_source.py index 385c667ad..e8020ec85 100644 --- a/tools/blender/tests/smoke_hull_source.py +++ b/tools/blender/tests/smoke_hull_source.py @@ -29,7 +29,8 @@ the hull.scad into the package and runs the panel's operators: judged). Package refuses an agent-built hull before it writes anything (ship.glb is not made) when the package holds no product, or a product by another maker than the language Build judged it against (hull-names-its-own-faction); with a product by - that maker it accepts. Her art needs no product. + that maker it accepts, with hull.scad in the package or not (the guard keys on Build's record), and a refused Package + changes nothing in the scene. Her art needs no product. Build and Package refuse copies that share an aetheria.id, selecting the original and the copies, and delete nothing. Package moves no row and turns no mount: every layout row is as authored after it, and a hand-turned mount keeps its yaw. """ @@ -76,9 +77,9 @@ HULLS = ( # per fixture: the variable the hull's language is in, a value that redefines it (every rule loosened), the language file a # copy of which beside hull.scad would shadow the include, and a colour in it ATTACKS = { - "zh-ammo-tender": ("ZH_LANG", '["zhestokost", ZH_ZONES, "primary", ZH_GLOW, ZH_RADIATOR, [[0, 9], [0, 9]], ["asymmetric", 0.0]]', + "zh-ammo-tender": ("ZH_LANG", '["zhestokost", ZH_ZONES, "primary", ZH_GLOW, ZH_RADIATOR, [[0, 9], [0, 9]], ["asymmetric", 0.0], "16bad4f9-7fb5-41c5-8da0-dc76709eb7d3"]', "zhestokost.scad", "[0.42, 0.43, 0.45]"), - "pi-fighter": ("PI_LANG", '["pirate", PI_ZONES, "", PI_GLOW, PI_RADIATOR, [], ["bilateral", 0.0]]', + "pi-fighter": ("PI_LANG", '["pirate", PI_ZONES, "", PI_GLOW, PI_RADIATOR, [], ["bilateral", 0.0], "1d8a168def994a76a1ce7cb18f59c038"]', "pirate.scad", "[0.48, 0.27, 0.22]"), } EXECUTABLE = None # the OpenSCAD the add-on preference names, set by main @@ -255,26 +256,48 @@ def one_hull(scene, number, fixture, ship_id, extent, length, rows, body, palett source.write_text(original, encoding="utf-8") print(f"PASS {number}.4 Build refuses a hull that breaks its brief") - # Package refuses an agent-built hull whose package has no product, or one by another maker, before it writes - # anything; the maker of the language Build judged it against is accepted. + # Package refuses an agent-built hull whose package has no product, one by another maker, or one right-maker product + # beside a wrong-maker one, and does so whether or not hull.scad is still in the package (the guard keys on Build's + # record), before it frames, exports or writes anything; the maker of the language Build judged it against is accepted. ship_cc = str(package / "ship.cc") require(collection.get("aetheria.hull_maker") and not (package / "ship.glb").exists(), "Build recorded no maker, or Package ran") - for label, expected, prepare in (("no product", "The package has no product", lambda: None), - ("a product by another maker", "not by the maker of the hull's faction language", - lambda: require(aetherdb(ship_cc, "product", ship_cc, other, "--name", "x") == 0, - "AetherDb could not write the other maker's product"))): + away = source.with_name("hull.scad.away") + + def add_product(faction, name): + require(aetherdb(ship_cc, "product", ship_cc, faction, "--name", name) == 0, "AetherDb could not write a product") + + def scad_away(): + source.rename(away) + + def scad_back(): + away.rename(source) + + off_centre = root.location.x + root.location.x = off_centre + 1.7 # _frame_ship would put it back: a refusal that frames first is caught + framed = {o.name: tuple(o.matrix_world.translation) for o in collection.all_objects} + for label, expected, prepare, restore in ( + ("no product", "The package has no product", lambda: None, lambda: None), + ("a product by another maker", "not by the maker of the hull's faction language", + lambda: add_product(other, "x"), lambda: None), + ("a product by another maker and hull.scad gone", "not by the maker of the hull's faction language", + scad_away, scad_back), + ("a right-maker and a wrong-maker product", "not by the maker of the hull's faction language", + lambda: add_product(maker, "y"), lambda: None)): prepare() select(box) rows_now = layout_rows(scene) result, message = run(bpy.ops.aetheria.package_ship) + restore() report = scene.aetheria_package_report require(result == {"CANCELLED"} and expected in report, f"Package took a hull with {label}: {report!r} {message!r}") require(not (package / "ship.glb").exists(), f"Package wrote ship.glb for a hull with {label}") require(layout_rows(scene) == rows_now, f"A refused Package changed a row ({label})") + require({o.name: tuple(o.matrix_world.translation) for o in collection.all_objects} == framed, + f"A refused Package moved the scene ({label})") for echoed in (ship_id, str(GAME), maker, other, collection["aetheria.hull_maker"]): require(echoed not in report, f"The refusal echoes an input ({echoed})") - require(aetherdb(ship_cc, "product", ship_cc, other, "--remove") == 0 - and aetherdb(ship_cc, "product", ship_cc, maker, "--name", "x") == 0, "AetherDb could not swap the product") + root.location.x = off_centre + require(aetherdb(ship_cc, "product", ship_cc, other, "--remove") == 0, "AetherDb could not remove the other maker's product") # Package: the validator accepts the built ship and the GLB holds a node per anchor. mount = bpy.data.objects[roles["weapon-mount"][0]] From a2284391badba0adffca2e1993f333d8bf6774f0 Mon Sep 17 00:00:00 2001 From: soul Date: Mon, 19 Oct 2026 01:09:12 +0200 Subject: [PATCH 072/444] Pin the three language maker keys to the catalog; pin product_makers' exact comparison; fix a stale derivation line ShipSchemaPinTests reads each factions/*.scad maker literal against its Faction record key in GameData/Aetheria.cc. test_ship_cc pins that Manufacturer keys are not case folded and that a bare string product body is refused, not read as a maker. hardpoint-sizes-prior-art.md no longer describes the deleted derivation. Co-Authored-By: Claude Sonnet 5.5 --- docs/research/hardpoint-sizes-prior-art.md | 2 +- .../ShipSchemaPinTests.cs | 21 +++++++++++++++++++ tools/blender/tests/test_ship_cc.py | 7 ++++++- 3 files changed, 28 insertions(+), 2 deletions(-) diff --git a/docs/research/hardpoint-sizes-prior-art.md b/docs/research/hardpoint-sizes-prior-art.md index 0c0a3400f..3c7f44b76 100644 --- a/docs/research/hardpoint-sizes-prior-art.md +++ b/docs/research/hardpoint-sizes-prior-art.md @@ -35,7 +35,7 @@ Basis: GameCult/Aetheria origin/master c000d5ca (detached worktree, since remove - Visual package record `ShipAuthoring` (`aetheria.ship_authoring`): ModelAsset (GLB), Anchors, SchematicLines, Joints: Assets/Scripts/ServerShared/ShipAuthoring.cs:8-66. - `ShipAnchor` fields: Id (the hardpoint's `Transform` string), Role, ModelNodeId, ParentId, Order, Joint: ShipAuthoring.cs:39-55. Roles vocabulary: map-icon, hull-collider, shield, tractor, thruster-emitter, weapon-mount, weapon-muzzle, radiator-mesh: ShipAuthoring.cs:71-76. No size, class, or envelope field exists on an anchor. - Which hardpoints get a visual anchor: `MountRole`: Thruster -> thruster-emitter, Radiator -> radiator-mesh, Energy/Ballistic/Launcher -> weapon-mount; every other type (Reactor, Shield, Sensors, ControlModule, Tool, Thermal, WarpDrive) is internal and "no anchor may take its id": ShipAuthoring.cs:78-83, 319-327. Each weapon hardpoint needs at least one weapon-muzzle anchor parented to it: ShipAuthoring.cs:341-343. -- Hardpoint footprint on the grid and the mount are separate: hardpoint cells are validated inside the hull and non-overlapping (ShipAuthoring.cs:300-318); the anchor is a model node bound by id. The Blender add-on derives the hardpoint `Position` from the mount object's origin cell (`mounted_position(cell_at(origin...))`, tools/blender/aetheria_ships/__init__.py:249-256) and adds a default muzzle half a metre along the facing (`MUZZLE_OFFSET = (0,-0.5,0)`, __init__.py:203). Docs: docs/moddable-ship-authoring.md ("Gameplay cell occupancy remains the authored HullData.Shape ... strokes do not decide armor or mount footprints"; Forbidden writers: Blender object names, prefabs, generated rows may not decide hardpoint type or footprint). +- Hardpoint footprint on the grid and the mount are separate: hardpoint cells are validated inside the hull and non-overlapping (ShipAuthoring.cs:300-318); the anchor is a model node bound by id. The Blender add-on keeps the two spaces apart: Add Weapon creates the layout row at `HardpointData`'s default `Position` and the author sets it in the layout panel; Mirror Mount mirrors the row within the schematic and the object within the model; Package moves no row (tools/blender/aetheria_ships/__init__.py, `_add_mount`, AETHERIA_OT_mirror_mount). Add Weapon also adds a default muzzle half a metre along the facing (`MUZZLE_OFFSET = (0,-0.5,0)`, __init__.py:208). Docs: docs/moddable-ship-authoring.md ("Gameplay cell occupancy remains the authored HullData.Shape ... strokes do not decide armor or mount footprints"; Forbidden writers: Blender object names, prefabs, generated rows may not decide hardpoint type or footprint). - Add-on mount entry: "Add a weapon mount and its first muzzle at the 3D cursor, with a layout row" (__init__.py:~310); the layout row carries kind, mount id, rotation, footprint string like `##/##` (docs/moddable-ship-authoring.md). The add-on only creates weapon mounts via that operator; thrusters/radiators are mesh objects tagged by role (docs). - Rig: `ShipJoint` {Id, Parent, Axes, Min, Max}: ShipAuthoring.cs:27-37; `ValidateRig` and `RigChains` (ShipAuthoring.cs:344, 370-471). `ShipModCatalog.BindRig` checks the record's joints and each mount's Joint equal the GLB's nearest-ancestor-joint; a thruster or radiator mesh may not sit under a joint (follow-up gimballed-thrusters): Assets/Scripts/ServerShared/ShipModCatalog.cs:209-250. - Unity binding: `ShipModVisual.LoadAsync` loads the GLB via glTFast and builds `Anchors` Id -> Transform: Assets/Scripts/Gameplay/ShipModVisual.cs:30-65. Legacy prefab path: `EntityInstance` has `RadiatorHardpoint[]`, `ThrusterHardpoint[]`, `WeaponHardpoint[]` arrays matched by transform name to `HardpointData.Transform` (EntityInstance.cs:23-25, 251-265); `WeaponHardpoint` is only `Transform[] FiringPoint`; `ThrusterHardpoint` is an invisible `MeshRenderer Emitter` that shapes exhaust particles (ThrusterHardpoint.cs); `RadiatorHardpoint` is a `MeshRenderer Mesh`. diff --git a/tests/Aetheria.Shared.Tests/ShipSchemaPinTests.cs b/tests/Aetheria.Shared.Tests/ShipSchemaPinTests.cs index eeb4825c0..dfda86603 100644 --- a/tests/Aetheria.Shared.Tests/ShipSchemaPinTests.cs +++ b/tests/Aetheria.Shared.Tests/ShipSchemaPinTests.cs @@ -105,6 +105,27 @@ public sealed class ShipSchemaPinTests } } + // A faction language file echoes its Faction's record key as the last element of its lang (hull_source.Language.maker); Package + // compares it to each product's Manufacturer. The catalog owns the key, so a regenerated record fails here, not at Package. + [Theory] + [InlineData("zhestokost.scad", "Zhestokost")] + [InlineData("pirate.scad", "Pirates")] + [InlineData("rnd.scad", "Rossum & Douglas")] + public void FactionLanguageFilesNameTheirFactionsRecordKeyInTheCatalog(string file, string faction) + { + var scad = File.ReadAllText(Locate("tools", "blender", "aetheria_ships", "factions", file)); + var literal = Regex.Match(scad, @"^[A-Z]+_LANG = \[[^;]*""([^""]+)""\];", RegexOptions.Multiline); +?$", RegexOptions.Multiline); + Assert.True(literal.Success, $"{file} has no *_LANG with a maker as its last element"); + var db = AetherDb.Open(root: AetherDb.FindRoot()); + try + { + var record = Assert.Single(db.Cache.GetAll().Where(f => f.Name == faction)); + Assert.Equal(db.Cache.RefOf(record).Key.ToString(), literal.Groups[1].Value); + } + finally { db.Cache.Dispose(); } + } + private static bool Accepts(string id) { try { ShipAuthoringStore.RequireShipId(id); return true; } diff --git a/tools/blender/tests/test_ship_cc.py b/tools/blender/tests/test_ship_cc.py index aae9e135b..67facc162 100644 --- a/tools/blender/tests/test_ship_cc.py +++ b/tools/blender/tests/test_ship_cc.py @@ -235,11 +235,16 @@ class ProductMakerTests(ShipFileCase): "maker-slot", "roles-slot"]) self.assertEqual(["maker-slot"], ship_cc.product_makers(self.path, PACKAGES)) + def test_manufacturer_keys_are_compared_as_written_not_case_folded(self): + self.push_product("mod-product:mod.skiff:faction:a", [None, "A", None, None, "Acme-Key"]) + self.push_product("mod-product:mod.skiff:faction:b", [None, "B", None, None, "acme-key"]) + self.assertEqual(["Acme-Key", "acme-key"], ship_cc.product_makers(self.path, PACKAGES)) + def test_a_product_with_no_manufacturer_is_refused_naming_no_input(self): canary = "canary-product-name" for body in ([None, canary, "Reliable."], [None, canary, None, None], [None, canary, None, None, None], [None, canary, None, None, ""], - [None, canary, None, None, 7]): + [None, canary, None, None, 7], "a bare string body is no list of slots, whatever its fifth character"): with self.subTest(body=body): self.setUp() self.push_product("mod-product:mod.skiff:faction:" + canary, body) From 8a1125f1fc9627eed46c5c84303307e2fdd0fcc3 Mon Sep 17 00:00:00 2001 From: soul Date: Mon, 19 Oct 2026 01:10:42 +0200 Subject: [PATCH 073/444] ShipSchemaPinTests: remove a stray line left in the maker-key pin The previous commit does not compile (a/2284391b: ShipSchemaPinTests.cs line 118). Co-Authored-By: Claude Sonnet 5.5 --- tests/Aetheria.Shared.Tests/ShipSchemaPinTests.cs | 1 - 1 file changed, 1 deletion(-) diff --git a/tests/Aetheria.Shared.Tests/ShipSchemaPinTests.cs b/tests/Aetheria.Shared.Tests/ShipSchemaPinTests.cs index dfda86603..6316241ad 100644 --- a/tests/Aetheria.Shared.Tests/ShipSchemaPinTests.cs +++ b/tests/Aetheria.Shared.Tests/ShipSchemaPinTests.cs @@ -115,7 +115,6 @@ public sealed class ShipSchemaPinTests { var scad = File.ReadAllText(Locate("tools", "blender", "aetheria_ships", "factions", file)); var literal = Regex.Match(scad, @"^[A-Z]+_LANG = \[[^;]*""([^""]+)""\];", RegexOptions.Multiline); -?$", RegexOptions.Multiline); Assert.True(literal.Success, $"{file} has no *_LANG with a maker as its last element"); var db = AetherDb.Open(root: AetherDb.FindRoot()); try From 70c366b5410e789bba347b4efc5161d5505ab3b3 Mon Sep 17 00:00:00 2001 From: soul Date: Mon, 19 Oct 2026 01:15:26 +0200 Subject: [PATCH 074/444] smoke_hull_source: update the view layer before comparing world positions around a refused Package matrix_world is stale after a location edit until the depsgraph runs, so the scene snapshot taken after moving Ship Root compared stale to fresh. Co-Authored-By: Claude Sonnet 5.5 --- tools/blender/tests/smoke_hull_source.py | 2 ++ 1 file changed, 2 insertions(+) diff --git a/tools/blender/tests/smoke_hull_source.py b/tools/blender/tests/smoke_hull_source.py index e8020ec85..6385f4fae 100644 --- a/tools/blender/tests/smoke_hull_source.py +++ b/tools/blender/tests/smoke_hull_source.py @@ -274,6 +274,7 @@ def one_hull(scene, number, fixture, ship_id, extent, length, rows, body, palett off_centre = root.location.x root.location.x = off_centre + 1.7 # _frame_ship would put it back: a refusal that frames first is caught + bpy.context.view_layer.update() framed = {o.name: tuple(o.matrix_world.translation) for o in collection.all_objects} for label, expected, prepare, restore in ( ("no product", "The package has no product", lambda: None, lambda: None), @@ -288,6 +289,7 @@ def one_hull(scene, number, fixture, ship_id, extent, length, rows, body, palett rows_now = layout_rows(scene) result, message = run(bpy.ops.aetheria.package_ship) restore() + bpy.context.view_layer.update() report = scene.aetheria_package_report require(result == {"CANCELLED"} and expected in report, f"Package took a hull with {label}: {report!r} {message!r}") require(not (package / "ship.glb").exists(), f"Package wrote ship.glb for a hull with {label}") From 8ae052bb47dd104d6abc65239d28a540dcebb319 Mon Sep 17 00:00:00 2001 From: soul Date: Mon, 19 Oct 2026 01:22:38 +0200 Subject: [PATCH 075/444] Materialize refuses an unbuildable Loadout before minting or drawing; delete the boss item-stream restore Materialize now settles what the catalog alone decides, tries the cells on a throwaway ItemManager (own ledger and stream, designs built unbranded), and only then asks the chooser and mints lots. ZoneGenerator's copy-and-restore of itemManager.Random is deleted, and the boss faction's LoadoutGenerator is created on the first choice instead of as an argument. Resolve keeps its signature and is Settle then Build. Tests: an unbuildable boss zone equals the no-boss zone (serialized pack, ledger, roster and gear, next item draw) for one failure, several failures and design-only slots, across every faction as boss faction. Not yet built or run when committed. Co-Authored-By: Claude Sonnet 5.5 --- Assets/Scripts/ServerShared/Loadout.cs | 143 ++++++++++++++----- Assets/Scripts/ServerShared/ZoneGenerator.cs | 4 +- tests/Aetheria.Shared.Tests/RunGoalTests.cs | 71 +++++++-- 3 files changed, 166 insertions(+), 52 deletions(-) diff --git a/Assets/Scripts/ServerShared/Loadout.cs b/Assets/Scripts/ServerShared/Loadout.cs index 592c827e2..eec005f46 100644 --- a/Assets/Scripts/ServerShared/Loadout.cs +++ b/Assets/Scripts/ServerShared/Loadout.cs @@ -128,15 +128,19 @@ public static class Loadouts public const float UnbrandedQuality = .95f; // All-or-nothing: returns an entity only when every design resolved and fitted, the loadout has at most - // 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 + // WeaponGroupCount weapon groups, and every group index names a weapon slot. Every failure of a stage is listed; on any + // failure nothing is returned and the zone and cache are unchanged. The stages run in the order that puts refusal + // first: what the catalog alone decides (designs, pinned products, hull kind, weapon groups), then whether the cells + // fit, tried on a throwaway item manager with its own ledger and stream, and only then the choice of a design-only + // slot's product (choose may draw) and the minting of lots. So a loadout that cannot be built mints no lot in the live + // ledger and draws nothing from itemManager.Random or from choose. The one exception is a design-only slot with no + // available product, which only choose can tell: it is found after an earlier slot's choice, and it too mints nothing. + // 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, 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, - // so the next commit drops them. A stated quality mints every lot of the fit at it, drawing nothing (a scenario states + // padded with empty groups. A stated quality mints every lot of the fit at it, drawing nothing (a scenario states // one for a fit its checks rest on). public static Entity Materialize(ItemManager itemManager, Zone liveZone, Loadout loadout, Func choose, List failures, float? quality = null) @@ -144,11 +148,11 @@ public static class Loadouts var cache = itemManager.ItemData; var reported = failures.Count; - var hullBuild = Resolve(itemManager, loadout.Hull, loadout.HullProduct, choose, "hull", failures, quality); + var hullSettled = Settle(itemManager, loadout.Hull, loadout.HullProduct, "hull", failures); var hullData = cache.Get(loadout.Hull); if (hullData is { HullType: HullType.Station }) 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 slotsSettled = loadout.Slots.Select(slot => Settle(itemManager, slot.Design, slot.Product, Cell(slot), failures)).ToArray(); var groups = loadout.WeaponGroups ?? new int[0][]; var groupCount = itemManager.GameplaySettings.WeaponGroupCount; if (groups.Length > groupCount) failures.Add($"weapon groups: {groups.Length} groups, at most {groupCount}"); @@ -160,12 +164,43 @@ public static class Loadouts } if (failures.Count > reported) return null; + // The cells fit or they do not whatever brand a design-only slot takes: the shape is the design's. Tried on its own + // item manager, so nothing is minted into the live ledger and nothing is drawn. + var scratch = new ItemManager(cache, new ProvenanceLedger(), itemManager.GameplaySettings, _ => { }); + Assemble(scratch, null, hullData, loadout, Unbranded(scratch, hullSettled), slotsSettled.Select(settled => Unbranded(scratch, settled)).ToArray(), failures, out _); + if (failures.Count > reported) return null; + + var hullBuild = Build(itemManager, hullSettled, choose, quality, failures); + var slotBuilds = slotsSettled.Select(settled => Build(itemManager, settled, choose, quality, failures)).ToArray(); + if (failures.Count > reported) return null; + + var entity = Assemble(itemManager, liveZone, hullData, loadout, hullBuild, slotBuilds, failures, out var equipped); + if (entity == null) return null; + + var built = new (List weapons, List items)[groupCount]; + for (var g = 0; g < groupCount; g++) + { + var items = g < groups.Length ? groups[g].Select(i => equipped[i]).ToList() : new List(); + built[g] = (items.Select(item => item.GetBehavior()).ToList(), items); + } + entity.WeaponGroups = built; + return entity; + } + + // The hull, then each slot's gear, made by the given builders and equipped to the loadout's cells: the one placement of + // a loadout, run once on the throwaway manager to decide the fit and once on the live one to build. Null with every + // misfit listed when any cell does not take its gear. + private static Entity Assemble(ItemManager itemManager, Zone liveZone, HullData hullData, Loadout loadout, + Func hullBuild, Func[] slotBuilds, List failures, out EquippedItem[] equipped) + { + var reported = failures.Count; + var cache = itemManager.ItemData; var hull = (EquippableItem) hullBuild(); var settings = itemManager.GameplaySettings.DefaultEntitySettings; Entity entity = hullData.HullType == HullType.Turret ? new OrbitalEntity(itemManager, liveZone, hull, default, settings) : new Ship(itemManager, liveZone, hull, settings); - var equipped = new EquippedItem[loadout.Slots.Count]; + equipped = new EquippedItem[loadout.Slots.Count]; for (var i = 0; i < loadout.Slots.Count; i++) { var slot = loadout.Slots[i]; @@ -182,26 +217,22 @@ public static class Loadouts .Concat(entity.DockingBays) .First(item => item.EquippableItem == unit); } - if (failures.Count > reported) return null; - - var built = new (List weapons, List items)[groupCount]; - for (var g = 0; g < groupCount; g++) - { - var items = g < groups.Length ? groups[g].Select(i => equipped[i]).ToList() : new List(); - built[g] = (items.Select(item => item.GetBehavior()).ToList(), items); - } - entity.WeaponGroups = built; - return entity; + return failures.Count > reported ? null : entity; } - // How one design is built, or null with the failure listed; nothing is minted until the returned builder runs. A - // pinned product is built as named, whatever choose says; it must be a product of the design. A design with no - // product at all is outside the economy (docs/scenarios-cut.md, Q3: generation, stock and sale all go through - // products) and is built unbranded, with no maker, at UnbrandedQuality (a stated quality replaces it). Otherwise choose names the product, and - // null (nothing available) or a product of another design is a failure. - public static Func Resolve(ItemManager itemManager, CultRecordRef design, - CultRecordRef product, Func choose, string where, - List failures, float? quality = null) where T : CraftedItemData + // What the catalog alone says about one design of a loadout: the design, and whether it pins a product, has no product + // at all, or needs a chooser to name one. Nothing is minted and nothing drawn. + private sealed class Settled + { + public CraftedItemData Data; + public FactionProductData Pinned; // the product the loadout names, if it does + public bool Unbranded; // a design no product makes + public string Where; + } + + // Null with the failure listed. + private static Settled Settle(ItemManager itemManager, CultRecordRef design, CultRecordRef product, + string where, List failures) where T : CraftedItemData { var cache = itemManager.ItemData; var data = cache.Get(design); @@ -211,35 +242,69 @@ public static class Loadouts return null; } - FactionProductData made; if (product.IsSet()) { - made = cache.Get(product); + var made = cache.Get(product); if (made == null) { failures.Add($"{where}: product {product.Key} is not in the catalog"); return null; } - } - else - { - if (!cache.GetAll().Any(p => p.Design.Key.Equals(design.Key))) - return () => itemManager.CreateInstance(itemManager.CreateLot(data, default, quality ?? UnbrandedQuality)); - made = choose(data); - if (made == null) + if (!made.Design.Key.Equals(design.Key)) { - failures.Add($"{where}: no available product of {data.Name}"); + failures.Add($"{where}: product {made.Name} is not a product of {data.Name}"); return null; } + return new Settled { Data = data, Pinned = made, Where = where }; } - if (!made.Design.Key.Equals(design.Key)) + return new Settled { - failures.Add($"{where}: product {made.Name} is not a product of {data.Name}"); - return null; + Data = data, Where = where, + Unbranded = !cache.GetAll().Any(p => p.Design.Key.Equals(design.Key)) + }; + } + + // A design made with no product, at UnbrandedQuality: no maker, no draw. It is also the stand-in the fit trial builds + // every design as, since a brand never changes a shape. A settled design that failed is never built. + private static Func Unbranded(ItemManager itemManager, Settled settled) => + () => itemManager.CreateInstance(itemManager.CreateLot(settled.Data, default, UnbrandedQuality)); + + // How a settled design is built, or null with the failure listed; nothing is minted until the returned builder runs. + // A design-only slot is where choose is asked, and may draw. + private static Func Build(ItemManager itemManager, Settled settled, + Func choose, float? quality, List failures) + { + if (settled == null) return null; + if (settled.Unbranded) + return () => itemManager.CreateInstance(itemManager.CreateLot(settled.Data, default, quality ?? UnbrandedQuality)); + var made = settled.Pinned; + if (made == null) + { + made = choose(settled.Data); + if (made == null) + { + failures.Add($"{settled.Where}: no available product of {settled.Data.Name}"); + return null; + } + if (!made.Design.Key.Equals(itemManager.ItemData.RefOf(settled.Data).Key)) + { + failures.Add($"{settled.Where}: product {made.Name} is not a product of {settled.Data.Name}"); + return null; + } } return () => itemManager.CreateInstance(quality is { } q ? itemManager.CreateLot(made, q) : itemManager.CreateLot(made)); } + // How one design is built, or null with the failure listed; nothing is minted until the returned builder runs. A + // pinned product is built as named, whatever choose says; it must be a product of the design. A design with no + // product at all is outside the economy (docs/scenarios-cut.md, Q3: generation, stock and sale all go through + // products) and is built unbranded, with no maker, at UnbrandedQuality (a stated quality replaces it). Otherwise choose names the product, and + // null (nothing available) or a product of another design is a failure. + public static Func Resolve(ItemManager itemManager, CultRecordRef design, + CultRecordRef product, Func choose, string where, + List failures, float? quality = null) where T : CraftedItemData => + Build(itemManager, Settle(itemManager, design, product, where, failures), choose, quality, failures); + private static string Cell(LoadoutSlot slot) => $"slot {slot.Position.x},{slot.Position.y}"; } diff --git a/Assets/Scripts/ServerShared/ZoneGenerator.cs b/Assets/Scripts/ServerShared/ZoneGenerator.cs index 3db7c07ea..412b0e9ab 100644 --- a/Assets/Scripts/ServerShared/ZoneGenerator.cs +++ b/Assets/Scripts/ServerShared/ZoneGenerator.cs @@ -433,11 +433,9 @@ public static class ZoneGenerator continue; } var failures = new List(); - var streamBefore = itemManager.Random; - var entity = Loadouts.Materialize(itemManager, null, loadout, GetLoadoutGenerator(bossFaction).ChooseProduct, failures); + var entity = Loadouts.Materialize(itemManager, null, loadout, design => GetLoadoutGenerator(bossFaction).ChooseProduct(design), failures); // the generator exists only once the Loadout is known to fit if (entity == null) { - itemManager.Random = streamBefore; // a failed fit has drawn lots; an unbuildable boss draws nothing, as a refused one does itemManager.Log($"{bossFaction.Name}'s boss Loadout {loadout.Name} cannot be built: {string.Join("; ", failures)}"); continue; } diff --git a/tests/Aetheria.Shared.Tests/RunGoalTests.cs b/tests/Aetheria.Shared.Tests/RunGoalTests.cs index 65ccd65a5..dc4b88fa6 100644 --- a/tests/Aetheria.Shared.Tests/RunGoalTests.cs +++ b/tests/Aetheria.Shared.Tests/RunGoalTests.cs @@ -630,21 +630,72 @@ public sealed partial class RunStartTests Assert.Equal(expected.Random.NextFloat(), next); } - [Fact] - public void An_unbuildable_boss_loadout_draws_nothing() + // Everything a generated zone exposes afterwards: the serialized pack, the ledger, the roster and the gear of its ships, and + // the next draw of the item stream. A faction's generator stream shows in the ships it outfitted, so it is in the pack. + private string ZoneFacts(ItemManager items, ZonePack pack) + { + var options = CultDocumentMessagePackSerialization.OptionsFor(typeof(HullData).Assembly); + var bytes = Convert.ToHexString(System.Security.Cryptography.SHA256.HashData(MessagePack.MessagePackSerializer.Serialize(pack, options))); + var gear = pack.Entities.Select(entity => $"{HullName(items, entity)}|{(entity.Faction.IsSet() ? entity.Faction.Key.ToString() : "-")}|{string.Join(";", Cells(items, entity))}"); + return $"pack {bytes} NextLot={items.Lots.NextLot} lots={items.Lots.Lots.Count}\n{string.Join("\n", gear)}\nnext item draw {items.Random.NextFloat():R}"; + } + + // The boss of the named faction, whose Loadout is made unbuildable by unbuild, refuses before anything is minted or drawn: the + // zone is the zone with no boss in every state it exposes. The faction need not be the zone's nearest. + private void AssertUnbuildableBossIsAsIfAbsent(string bossFaction, Action unbuild) { var galaxy = DemoGalaxy(1); var loadout = GiveAntagonistABoss(galaxy); - loadout.Slots[0].Position = new int2(999, 999); + unbuild(loadout); StoreLoadout(loadout); - var items = LoggingItems(new List()); - var pack = BossZonePack(galaxy, items, galaxy.Exit); - Assert.False(pack.BossSpawned); - var next = items.Random.NextFloat(); + var boss = galaxy.Factions.Single(faction => faction.Name == bossFaction); + Zhestokost(galaxy).BossLoadout = default; + SetBoss(boss, loadout); galaxy.BossZones.Clear(); + galaxy.BossZones[boss] = galaxy.Exit; + var log = new List(); + var items = LoggingItems(log); + var pack = BossZonePack(galaxy, items, galaxy.Exit); + Assert.False(pack.BossSpawned, bossFaction); + Assert.Contains(log, line => line.Contains("Antagonist Boss") && line.Contains("cannot be built")); + + var bare = DemoGalaxy(1); + bare.BossZones.Clear(); var bareItems = LoggingItems(new List()); - var bare = BossZonePack(galaxy, bareItems, galaxy.Exit); - Assert.Equal(Roster(bareItems, bare), Roster(items, pack)); - Assert.Equal(bareItems.Random.NextFloat(), next); + var barePack = BossZonePack(bare, bareItems, bare.Exit); + Assert.Equal($"{bossFaction}\n{ZoneFacts(bareItems, barePack)}", $"{bossFaction}\n{ZoneFacts(items, pack)}"); + } + + private IEnumerable AllFactionNames() => DemoGalaxy(1).Factions.Select(faction => faction.Name).OrderBy(name => name, StringComparer.Ordinal); + + private static void MoveOut(Loadout loadout, int slots) + { + for (var i = 0; i < slots; i++) loadout.Slots[i].Position = new int2(999 - i, 999 - i); + } + + [Fact] + public void An_unbuildable_boss_loadout_with_one_failure_leaves_the_zone_as_if_it_had_no_boss() + { + foreach (var faction in AllFactionNames()) AssertUnbuildableBossIsAsIfAbsent(faction, loadout => MoveOut(loadout, 1)); + } + + [Fact] + public void An_unbuildable_boss_loadout_with_several_failures_leaves_the_zone_as_if_it_had_no_boss() + { + foreach (var faction in AllFactionNames()) AssertUnbuildableBossIsAsIfAbsent(faction, loadout => MoveOut(loadout, 3)); + } + + // A design-only slot asks the chooser, which draws its faction's stream; the fit is decided before it is asked. + [Fact] + public void An_unbuildable_boss_loadout_with_design_only_slots_leaves_the_zone_as_if_it_had_no_boss() + { + foreach (var faction in AllFactionNames()) + AssertUnbuildableBossIsAsIfAbsent(faction, loadout => + { + foreach (var slot in loadout.Slots) slot.Product = default; + Assert.Contains(loadout.Slots, slot => _cache.GetAll().Any(p => p.Design.Key.Equals(slot.Design.Key))); + MoveOut(loadout, 1); + loadout.Slots[loadout.Slots.Count - 1].Position = new int2(900, 900); + }); } } From 66510b003f2617c07a2743cd9eeb18d8d936312f Mon Sep 17 00:00:00 2001 From: soul Date: Mon, 19 Oct 2026 01:25:40 +0200 Subject: [PATCH 076/444] Boss refusal tests: gear projection reads ships only (turrets are not ships) Co-Authored-By: Claude Sonnet 5.5 --- tests/Aetheria.Shared.Tests/RunGoalTests.cs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/tests/Aetheria.Shared.Tests/RunGoalTests.cs b/tests/Aetheria.Shared.Tests/RunGoalTests.cs index dc4b88fa6..261c91a7c 100644 --- a/tests/Aetheria.Shared.Tests/RunGoalTests.cs +++ b/tests/Aetheria.Shared.Tests/RunGoalTests.cs @@ -636,7 +636,7 @@ public sealed partial class RunStartTests { var options = CultDocumentMessagePackSerialization.OptionsFor(typeof(HullData).Assembly); var bytes = Convert.ToHexString(System.Security.Cryptography.SHA256.HashData(MessagePack.MessagePackSerializer.Serialize(pack, options))); - var gear = pack.Entities.Select(entity => $"{HullName(items, entity)}|{(entity.Faction.IsSet() ? entity.Faction.Key.ToString() : "-")}|{string.Join(";", Cells(items, entity))}"); + var gear = pack.Entities.Select(entity => $"{HullName(items, entity)}|{(entity.Faction.IsSet() ? entity.Faction.Key.ToString() : "-")}|{(EntitySerializer.Unpack(items, null, entity) is Ship ? string.Join(";", Cells(items, entity)) : "-")}"); return $"pack {bytes} NextLot={items.Lots.NextLot} lots={items.Lots.Lots.Count}\n{string.Join("\n", gear)}\nnext item draw {items.Random.NextFloat():R}"; } From 820ec73f7671e9e1855a45773b7743937d713ac9 Mon Sep 17 00:00:00 2001 From: soul Date: Mon, 19 Oct 2026 01:29:03 +0200 Subject: [PATCH 077/444] Boss refusal tests: a mismatch prints both zones and the log Co-Authored-By: Claude Sonnet 5.5 --- tests/Aetheria.Shared.Tests/RunGoalTests.cs | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/tests/Aetheria.Shared.Tests/RunGoalTests.cs b/tests/Aetheria.Shared.Tests/RunGoalTests.cs index 261c91a7c..8a00db639 100644 --- a/tests/Aetheria.Shared.Tests/RunGoalTests.cs +++ b/tests/Aetheria.Shared.Tests/RunGoalTests.cs @@ -663,7 +663,9 @@ public sealed partial class RunStartTests bare.BossZones.Clear(); var bareItems = LoggingItems(new List()); var barePack = BossZonePack(bare, bareItems, bare.Exit); - Assert.Equal($"{bossFaction}\n{ZoneFacts(bareItems, barePack)}", $"{bossFaction}\n{ZoneFacts(items, pack)}"); + var expected = ZoneFacts(bareItems, barePack); + var actual = ZoneFacts(items, pack); + Assert.True(expected == actual, $"{bossFaction}: the zone with an unbuildable boss differs from the zone with none.\nno boss:\n{expected}\nunbuildable boss:\n{actual}\nlog: {string.Join(" / ", log)}"); } private IEnumerable AllFactionNames() => DemoGalaxy(1).Factions.Select(faction => faction.Name).OrderBy(name => name, StringComparer.Ordinal); From f3719d99432a9e8f7b13a120ed793a8e762aa7b7 Mon Sep 17 00:00:00 2001 From: soul Date: Mon, 19 Oct 2026 01:30:10 +0200 Subject: [PATCH 078/444] Boss refusal tests: compare the serialized ledger, not the pack (pack bytes differ between identical runs) Co-Authored-By: Claude Sonnet 5.5 --- tests/Aetheria.Shared.Tests/RunGoalTests.cs | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/tests/Aetheria.Shared.Tests/RunGoalTests.cs b/tests/Aetheria.Shared.Tests/RunGoalTests.cs index 8a00db639..c618d712e 100644 --- a/tests/Aetheria.Shared.Tests/RunGoalTests.cs +++ b/tests/Aetheria.Shared.Tests/RunGoalTests.cs @@ -630,14 +630,14 @@ public sealed partial class RunStartTests Assert.Equal(expected.Random.NextFloat(), next); } - // Everything a generated zone exposes afterwards: the serialized pack, the ledger, the roster and the gear of its ships, and + // Everything a generated zone exposes afterwards: the serialized ledger, the roster and the gear of its ships, and // the next draw of the item stream. A faction's generator stream shows in the ships it outfitted, so it is in the pack. private string ZoneFacts(ItemManager items, ZonePack pack) { var options = CultDocumentMessagePackSerialization.OptionsFor(typeof(HullData).Assembly); - var bytes = Convert.ToHexString(System.Security.Cryptography.SHA256.HashData(MessagePack.MessagePackSerializer.Serialize(pack, options))); + var bytes = Convert.ToHexString(System.Security.Cryptography.SHA256.HashData(MessagePack.MessagePackSerializer.Serialize(items.Lots, options))); var gear = pack.Entities.Select(entity => $"{HullName(items, entity)}|{(entity.Faction.IsSet() ? entity.Faction.Key.ToString() : "-")}|{(EntitySerializer.Unpack(items, null, entity) is Ship ? string.Join(";", Cells(items, entity)) : "-")}"); - return $"pack {bytes} NextLot={items.Lots.NextLot} lots={items.Lots.Lots.Count}\n{string.Join("\n", gear)}\nnext item draw {items.Random.NextFloat():R}"; + return $"ledger {bytes} NextLot={items.Lots.NextLot} lots={items.Lots.Lots.Count}\n{string.Join("\n", gear)}\nnext item draw {items.Random.NextFloat():R}"; } // The boss of the named faction, whose Loadout is made unbuildable by unbuild, refuses before anything is minted or drawn: the From f121b464635d3e53c7b7036fa2cd8bdd1b6c6ffb Mon Sep 17 00:00:00 2001 From: soul Date: Mon, 19 Oct 2026 01:44:15 +0200 Subject: [PATCH 079/444] Package's art exemption keys on Build's maker record, not hull.scad; case-variant Manufacturer and attack maker keys pinned in smoke_hull_source Co-Authored-By: Claude Sonnet 5.5 --- tools/blender/aetheria_ships/__init__.py | 19 +++++---- tools/blender/tests/smoke_hull_source.py | 53 ++++++++++++++++++++---- 2 files changed, 57 insertions(+), 15 deletions(-) diff --git a/tools/blender/aetheria_ships/__init__.py b/tools/blender/aetheria_ships/__init__.py index b47860f1d..9d9a57cbf 100644 --- a/tools/blender/aetheria_ships/__init__.py +++ b/tools/blender/aetheria_ships/__init__.py @@ -1625,10 +1625,11 @@ def _build_hull_source(context, collection): def _art_takes_over(collection): - """Whether the body is the operator's: a hull.scad and a Ship Root exist, the collection holds render meshes that - Build did not make as they stand, and no zone mesh of Build's is left.""" + """Whether the body is the operator's: Build's maker record and a Ship Root exist, the collection holds render meshes + that Build did not make as they stand, and no zone mesh of Build's is left. The record alone says the hull was + Build-made; hull.scad is not consulted.""" root = _ship_root(collection) - if _hull_source_file(collection) is None or root is None: + if HULL_MAKER not in collection or root is None: return False untouched, _ = _hull_built(collection) zones = [obj for obj in untouched if obj.type == "MESH" and "aetheria.role" not in obj] @@ -1636,12 +1637,14 @@ def _art_takes_over(collection): def _retire_hull_source(collection): - """Deletes the package's hull.scad when the body is the operator's (_art_takes_over). The anchors and collider she - kept are hers from then on. A hull never built, or built and packaged as it stands, keeps its source. Returns whether - the file was deleted.""" + """Deletes Build's records, and the package's hull.scad if it is still there, when the body is the operator's + (_art_takes_over). The anchors and collider she kept are hers from then on. A hull never built, or built and + packaged as it stands, keeps its source. Returns whether art took over.""" if not _art_takes_over(collection): return False - _hull_source_file(collection).unlink() + source = _hull_source_file(collection) + if source is not None: + source.unlink() for record in (HULL_BUILT, HULL_MAKER): if record in collection: del collection[record] @@ -1738,7 +1741,7 @@ class AETHERIA_OT_package_ship(bpy.types.Operator): return {"CANCELLED"} scene.aetheria_package_report = f"Packaged {len(anchors)} anchors; the validator accepted the ship" if _retire_hull_source(collection): - scene.aetheria_package_report += "; deleted hull.scad: this package ships your art" + scene.aetheria_package_report += "; retired Build's hull records: this package ships your art" self.report({"INFO"}, scene.aetheria_package_report) return {"FINISHED"} diff --git a/tools/blender/tests/smoke_hull_source.py b/tools/blender/tests/smoke_hull_source.py index 6385f4fae..fcd24407e 100644 --- a/tools/blender/tests/smoke_hull_source.py +++ b/tools/blender/tests/smoke_hull_source.py @@ -23,8 +23,9 @@ the hull.scad into the package and runs the panel's operators: or a generated zone or mount that was edited in place. Package accepts the built ship (the C# validator), writes ship.glb with a node per anchor, and leaves hull.scad; a Build after Package still works. - The author replaces the zone meshes with her art and keeps Build's anchors and collider: Package deletes hull.scad in - that one act and says so, and Build then refuses. + The author replaces the zone meshes with her art and keeps Build's anchors and collider: Package retires Build's + records, and deletes hull.scad if it is still there (the second hull has had it removed by hand), in that one act and + says so, and Build then refuses. Build's maker record alone decides that a hull is Build-made and under the guard. A hull that was never built keeps its hull.scad through the same deletion rule, and Package refuses it (no maker was judged). Package refuses an agent-built hull before it writes anything (ship.glb is not made) when the package holds no product, @@ -35,6 +36,7 @@ the hull.scad into the package and runs the panel's operators: Package moves no row and turns no mount: every layout row is as authored after it, and a hand-turned mount keeps its yaw. """ +import dataclasses import json import math import os @@ -47,6 +49,7 @@ from pathlib import Path import addon_utils import bmesh import bpy +import msgpack REPO = Path(__file__).resolve().parents[3] PACKAGES = os.environ.get("CULTLIB_PACKAGES") or sys.exit("Set CULTLIB_PACKAGES to CultLib's packages directory") @@ -75,17 +78,23 @@ HULLS = ( # per fixture: the variable the hull's language is in, a value that redefines it (every rule loosened), the language file a -# copy of which beside hull.scad would shadow the include, and a colour in it +# copy of which beside hull.scad would shadow the include, and a colour in it. %MAKER% is the maker key the faction's own +# language file names, read by the production reader (language_maker), so a language edited alone cannot leave it stale ATTACKS = { - "zh-ammo-tender": ("ZH_LANG", '["zhestokost", ZH_ZONES, "primary", ZH_GLOW, ZH_RADIATOR, [[0, 9], [0, 9]], ["asymmetric", 0.0], "16bad4f9-7fb5-41c5-8da0-dc76709eb7d3"]', + "zh-ammo-tender": ("ZH_LANG", '["zhestokost", ZH_ZONES, "primary", ZH_GLOW, ZH_RADIATOR, [[0, 9], [0, 9]], ["asymmetric", 0.0], "%MAKER%"]', "zhestokost.scad", "[0.42, 0.43, 0.45]"), - "pi-fighter": ("PI_LANG", '["pirate", PI_ZONES, "", PI_GLOW, PI_RADIATOR, [], ["bilateral", 0.0], "1d8a168def994a76a1ce7cb18f59c038"]', + "pi-fighter": ("PI_LANG", '["pirate", PI_ZONES, "", PI_GLOW, PI_RADIATOR, [], ["bilateral", 0.0], "%MAKER%"]', "pirate.scad", "[0.48, 0.27, 0.22]"), } EXECUTABLE = None # the OpenSCAD the add-on preference names, set by main hull_source = None # aetheria_ships.hull_source, set by main +def language_maker(faction): + """The maker key the faction's own language file names, as Build reads it.""" + return hull_source.read_languages(hull_source.language_library(EXECUTABLE)(faction))[0].maker + + def require(condition, message): if not condition: raise SystemExit(message) @@ -184,6 +193,7 @@ def one_hull(scene, number, fixture, ship_id, extent, length, rows, body, palett rows_before = layout_rows(scene) source = package / "hull.scad" lang_var, lang_value, shadowed, shadow_colour = ATTACKS[fixture] + lang_value = lang_value.replace("%MAKER%", language_maker(shadowed[:-len(".scad")])) runner = hull_source.openscad_runner(EXECUTABLE, source) require(source.is_file(), "The fixture hull.scad was not copied") require("Build From Hull Source" == bpy.ops.aetheria.build_hull_source.get_rna_type().name, "Build is not registered") @@ -301,6 +311,32 @@ def one_hull(scene, number, fixture, ship_id, extent, length, rows, body, palett root.location.x = off_centre require(aetherdb(ship_cc, "product", ship_cc, other, "--remove") == 0, "AetherDb could not remove the other maker's product") + # Record keys compare exactly: a Manufacturer that differs from the language's maker only in case names no Faction. + # AetherDb cannot write one, but a hand-edited ship.cc or a cultcache-py push can, and the guard alone refuses it. + def manufacturer_as(change): + ship_module = sys.modules["aetheria_ships.ship_cc"] + store = ship_module.read(ship_cc, PACKAGES).store + seen = [] + for envelope in store.pull_all(): + if envelope.type == ship_module.PRODUCT_SCHEMA: + values = msgpack.unpackb(envelope.payload, raw=False) + seen.append(values[ship_module.PRODUCT_MANUFACTURER_SLOT]) + values[ship_module.PRODUCT_MANUFACTURER_SLOT] = change(seen[-1]) + store.push(dataclasses.replace(envelope, payload=msgpack.packb(values, use_bin_type=True))) + return seen + + written = manufacturer_as(str.upper) + require(written and all(key.upper() != key for key in written), "The package holds no product with a cased key to rewrite") + select(box) + result, message = run(bpy.ops.aetheria.package_ship) + report = scene.aetheria_package_report + require(result == {"CANCELLED"} and "not by the maker of the hull's faction language" in report, + f"Package took a product whose Manufacturer differs from the maker in case: {report!r} {message!r}") + require(not (package / "ship.glb").exists(), "Package wrote ship.glb for a case-variant Manufacturer") + for echoed in (ship_id, str(GAME), maker, collection["aetheria.hull_maker"], collection["aetheria.hull_maker"].upper()): + require(echoed not in report, f"The refusal echoes an input ({echoed})") + manufacturer_as(lambda _: written.pop(0)) + # Package: the validator accepts the built ship and the GLB holds a node per anchor. mount = bpy.data.objects[roles["weapon-mount"][0]] straight = mount.rotation_euler.z @@ -413,10 +449,13 @@ def one_hull(scene, number, fixture, ship_id, extent, length, rows, body, palett art.parent = root require(any(o.get("aetheria.role") for o in generated(collection).values()), "The scene kept no anchor of Build's") require(aetherdb(ship_cc, "product", ship_cc, maker, "--remove") == 0, "AetherDb could not remove the product") # her art needs none + if number % 2 == 0: # the second hull: she (or git) removed hull.scad herself first; Build's record outlives it + source.unlink() + require(collection.get("aetheria.hull_maker"), "Build's maker record is gone before Package") select(box) result, message = run(bpy.ops.aetheria.package_ship) report = scene.aetheria_package_report - require(result == {"FINISHED"} and "deleted hull.scad" in report, f"Package did not retire the hull source: {report!r} {message!r}") + require(result == {"FINISHED"} and "retired Build's hull records" in report, f"Package did not retire the hull source: {report!r} {message!r}") require(not source.exists(), "hull.scad is still there after the author's art was packaged") require("aetheria.hull_built" not in collection and "aetheria.hull_maker" not in collection, "The record of Build's objects or its maker outlived hull.scad") @@ -424,7 +463,7 @@ def one_hull(scene, number, fixture, ship_id, extent, length, rows, body, palett select(box) result, message = run(bpy.ops.aetheria.build_hull_source) require(result == {"CANCELLED"} and bpy.data.objects.get("author hull") is art, f"Build ran after takeover: {message!r}") - print(f"PASS {number}.9 her art with Build's anchors kept retires hull.scad, with a report; Build then refuses") + print(f"PASS {number}.9 her art with Build's anchors kept retires Build's records and any hull.scad, with a report; Build then refuses") return collection From 3ac185a84e66b86fd3b3e8522ee8f850b9c1fe5e Mon Sep 17 00:00:00 2001 From: soul Date: Mon, 19 Oct 2026 02:07:26 +0200 Subject: [PATCH 080/444] aetheria-release map: hull collection states (HS) and the agent-built row Co-Authored-By: Claude Opus 5.5 --- docs/aetheria-release-map.md | 117 ++++++++++++++++++++++++++++++++++- 1 file changed, 116 insertions(+), 1 deletion(-) diff --git a/docs/aetheria-release-map.md b/docs/aetheria-release-map.md index f3e709f3d..88267df37 100644 --- a/docs/aetheria-release-map.md +++ b/docs/aetheria-release-map.md @@ -11334,7 +11334,7 @@ PIDs are in `S\out\pids.log`, the first 3404 and the last 20348. It then writes |---|---|---|---| | Faction language, `factions/.scad` | The faction slug. One file per faction. A Pirate hull includes its donor's file as well. | Drafted by an agent from the lore and the operator's art, marked draft, then approved by an operator ruling. A revision rebuilds every hull of that faction at its next Build. | The operator decides the language (`zhestokost-language-source` is the first). Agents propose; nothing else edits it. | | Hull source, `GameData/Mods//hull.scad` | The package id, equal to `ShipAuthoring.Id`. One per package. | Created by an agent pass for a hull with no operator art and revised by agents. Deleted by Package in the same act that packages operator art into that package. | Agents (Imagination designs, Hands lands). The operator decides when it dies, by packaging her art. | -| "Agent-built" status | Presence of `hull.scad`. No field. | Ends when the file is deleted. | Derived. "Which hulls are agent-built" is a directory listing. | +| "Agent-built" status | Derived by `_hull_state` from hull.scad, Build's maker record, its untouched zones and her meshes (section HS). No field. | Ends when the state leaves agent and unbuilt: Package retiring hull.scad for her art. | Derived. The state table is in section HS. | | Zone meshes and anchor objects in Blender | Marked `aetheria.hull_source`, with ids `aetheria.id`. | Replaced on every Build. Never hand-edited. | Build From Hull Source. Build refuses a collection holding meshes it did not make. | | `ship.glb` and ShipAuthoring anchors | `ShipAuthoring.ModelAsset`, with anchor ids equal to mount ids. | Rewritten by every Package, whether from an agent hull or operator art. The ids are stable across the swap, so loadouts and saves survive it. | Package Ship, the only writer. | | HullData layout (schematic) | `mod-hull:`. | Authored through Save Layout. Linked to the model by mount name only. | The schematic author: agents for agent hulls, the operator for hers. Never Build. AH-B3 is closed by cut `package-agent-hull-guards` (section PG). | @@ -11456,3 +11456,118 @@ Imagination pass `imag-ag7-hull-guards`, session `self-2026-10-19-ag7`, 2026-10- ### Admitted - `aetheria-release:cut_spec:cut-package-agent-hull-guards.r1`. + +## Hull collection states (HS) + +Imagination pass `imag-ag7-hull-states`, session `self-2026-10-19-ag7`, 2026-10-19, pinned to Aetheria f121b464 (head +of `eureka/aetheria-release-package-agent-hull-guards`, worktree `C:\aeth-wt\ag7-hull-states-f121`). Three Soul passes +(verdicts `cut-package-agent-hull-guards.s1` to `.s3`) each found the one decision "is this hull Build's, hers, or +neither" answered by two readers that disagreed. This section is the model that gives that decision one owner. It +revises spec `cut-package-agent-hull-guards` to r2 for findings `.s3.never-built-art-deadlock` and +`.s3.art-requirement-unpinned`, and admits question `art-over-unbuilt-hull-source`. + +### Body facts (source reads and one probe at f121b464) + +- **HS-B1. Four inputs name the state, and each has one writer.** `hull.scad` in the package (`_hull_source_file`, + `__init__.py:1499`): written by agents, deleted by `_retire_hull_source` (:1645) or by hand. `aetheria.hull_maker` + and `aetheria.hull_built` on the collection (:1494-1495): written together by Build (:1622-1623), deleted together by + `_retire_hull_source` (:1648). Build's untouched zone meshes: derived by `_hull_built` (:1518) from the record and each + object's fingerprint. Her meshes: `_own_meshes` (:789) minus Build's untouched objects, so a Build zone she edited + counts as hers. +- **HS-B2. The regime has two readers today.** `_art_takes_over` (:1627) reads the maker record, Ship Root, the + untouched zones and her meshes. `_refuse_wrong_maker` (:1654) calls it, then reads `hull.scad` again on its own + (:1663) when the record is absent. `_retire_hull_source` (:1639) reads only `_art_takes_over`. The panel reads + `hull.scad` to show the Build button (:1789). Build (:1556) reads `hull.scad` as its input file and refuses her + meshes and edited objects of its own (:1562-1568), which is its write-safety rule, not the regime. +- **HS-B3. Fourteen states are reachable, and the two readers disagree in exactly one.** Probe + `probe_states.py` (scratchpad `imag-ag7-hull-states`) transcribes the f121b464 conditions and enumerates every + combination of hull.scad (S), the maker record (M), an untouched Build zone (Z), her meshes (A) and Ship Root (R), + with Z implying M and R, A implying R, and M without R (a hand-deleted Ship Root) left out. Of 14 states, one is a + deadlock: S, no M, no Z, A, R (an agent's hull.scad Build never ran, with her art under Ship Root). Package refuses + "Build From Hull Source first" and Build refuses her meshes. That is Soul's s3 case A. The proposed classifier below + gives the same Package and retirement outcome as f121b464 in the other 13 states. +- **HS-B4. One state is pinned by nothing.** M, no Z, no A (Build's zones deleted, nothing put in their place): the + smoke has no step for it, so the art test inside `_art_takes_over` can be replaced with True and the smoke stays + green (Soul s3 mutation `mut.art-needs-no-art`). +- **HS-B5. Products matter only in the agent state.** `product_makers` (`ship_cc.py`) is read only by + `_refuse_wrong_maker`, after the regime says the hull is Build's. Her art and a plain hull ship with any product or + none (smoke step 1.9 removes the product before her art is packaged). + +### The state table + +S is hull.scad in the package, M is Build's maker record, Z is an untouched zone mesh of Build's, and A is her meshes +under Ship Root. "Check" means that Package refuses unless the package holds a product and every product's +Manufacturer is the recorded maker: no product gives "no product", and a wrong or mixed maker gives "not by the +maker". Every Package outcome holds only when the validator accepts. Retirement deletes hull.scad (if present), M and +`aetheria.hull_built`, after the validator has accepted. + +| # | S | M | Z | A | State | Build | Package | Retired | How it is reached | +|---|---|---|---|---|---|---|---|---|---| +| H1 | - | - | - | - | plain | refuses: no hull.scad | no check | nothing | New Ship, no agent hull | +| H2 | - | - | - | yes | plain | refuses: no hull.scad | no check | nothing | Her hero or hand-made hull. It is also H6 or H11 after the Package that retires | +| H3 | yes | - | - | - | unbuilt | builds | refuses: "Build From Hull Source first, or put your own meshes under Ship Root" | nothing | An agent's hull.scad dropped in. Smoke PASS 3 | +| H4 | yes | - | - | yes | **art** (question `art-over-unbuilt-hull-source`) | refuses: not its meshes | no check | hull.scad | Her art over a hull.scad Build never ran. **The f121b464 deadlock** | +| H5 | yes | yes | yes | - | agent | builds (replaces its own) | check | nothing | After Build. Smoke 1.1 to 1.5 | +| H6 | yes | yes | - | yes | art | refuses: not its meshes | no check | hull.scad, M | Her art replaced every Build zone, or she edited them all. Smoke 1.9, odd hull | +| H7 | yes | yes | yes | yes | agent | refuses: not its meshes | check | nothing | Her mesh beside Build's zones, or one zone edited. Smoke 1.8 | +| H8 | yes | yes | - | - | agent | builds | check | nothing | Build's zones deleted, nothing in their place. **Unpinned (s3 art-requirement)** | +| H9 | - | yes | yes | - | agent | refuses: no hull.scad | check | nothing | hull.scad deleted by hand after Build. Smoke 1.5's hull.scad-gone row | +| H10 | - | yes | yes | yes | agent | refuses: no hull.scad | check | nothing | H7 with hull.scad deleted by hand | +| H11 | - | yes | - | yes | art | refuses: no hull.scad | no check | M | H6 with hull.scad deleted by hand. Smoke 1.9, even hull | +| H12 | - | yes | - | - | agent | refuses: no hull.scad | check | nothing | H8 with hull.scad deleted by hand | + +Ship Root absent (states H1 and H3 before New Ship's root, or after a hand deletion) changes nothing in the table: +`_own_meshes` needs a root, so with no root A is false and the row is H1 or H3. A maker record with no root (hand +deletion) is agent. + +### Authority + +- **Owner of the state: `_hull_state(collection)`**, one classifier in `__init__.py`, evaluated in this order. Build's + untouched zone present gives agent. Otherwise her meshes present together with Build's record or hull.scad gives art. + Otherwise Build's record present gives agent. Otherwise hull.scad present gives unbuilt. Otherwise plain. It reads + `_hull_built`, `_own_meshes`, `HULL_MAKER in collection` and `_hull_source_file`, and nothing else does so to decide + the regime. +- **Owner of the maker of a shipped agent hull: its products** (ruling `hull-faction-is-product-maker`). + `aetheria.hull_maker` is a record of what Build judged against, read only as the value to compare in the agent state. +- **Owner of when hull.scad dies: Package**, through `_retire_hull_source` in the art state after the validator + accepts, or the operator by hand. Ruling `hull-art-communicates-form` keeps agent hulls until her art replaces them. + The model row of section "Agent-built hulls" names Package's packaging of her art as that act. +- **Owner of whether Build may write: Build** (`_build_hull_source`), on its own rule that it never replaces a mesh it + did not make or an object of its own that was changed. This is a separate decision from the regime, and it reads + hull.scad as its input. +- **Display-only:** the panel's read of hull.scad (:1789), which shows the Build button. In H4 the button shows and + Build refuses. It decides nothing. +- **Forbidden readers of the regime:** `_refuse_wrong_maker` reading `hull.scad` or deciding by the presence of the + maker record; `_retire_hull_source` testing anything but `_hull_state`; any reader of `aetheria.hull_built` other + than `_hull_built`; the smoke asserting the classifier's return value as its only proof. +- **Deletion line:** `_art_takes_over` (:1627-1636) and the hull.scad branch of `_refuse_wrong_maker` (:1661-1665) go + before `_hull_state` is added. + +### Rationale + +- **The body decides, not a file or a record alone.** The guard exists so that a hull shipping Build's judged body + ships under Build's judged maker. Whether Build's untouched zones are in the collection is the fact that says so. + hull.scad (keyed by r1, s1 `guard-keyed-on-hull-scad`) and the maker record (keyed by h3, s3 `never-built-art-deadlock`) + each stand in for that fact in only some states, which is why each fix moved the hole somewhere else. +- **Order is the model.** Z before A keeps H7 and H10 under the guard: one of Build's zones still ships. A with M or S + before M alone makes her art win once no Build zone is left (H4, H6, H11). A must be required (H8 and H12 stay agent), + and M or S must be required (H2 stays plain and retires nothing). +- **H4 is the operator's call.** Two readings are coherent. Under the first, her art over a hull.scad is hers and + Package retires the file, which is what 70c366b5 did and what the "Agent-built hulls" model row says. Under the + second, Package never deletes an agent's hull.scad that Build never ran, and refuses with text naming the exit + (delete hull.scad). The recommendation is the first, in question `art-over-unbuilt-hull-source`. Either answer + changes one condition in `_hull_state` and one message, and both remove the deadlock. +- **The "Agent-built" status row above is superseded.** It said the status is the presence of hull.scad. That holds + only for H3 to H8. The status is `_hull_state` being agent or unbuilt. + +### Model page rows + +| Kind | Identity | Lifecycle | Authority | +|---|---|---|---| +| Hull state (plain, unbuilt, agent, art) of a ship collection | Derived per call from hull.scad, `aetheria.hull_maker`, Build's untouched zones and her meshes. No field. | Recomputed at every Package and retirement. It changes when Build runs, when she adds or removes meshes, at retirement, and when hull.scad or a record is deleted by hand. | `_hull_state`, alone. | + +### Admitted + +- `aetheria-release:question:art-over-unbuilt-hull-source` (recommended `art-takes-over`, raised in the spec, which it + blocks). +- `aetheria-release:cut_spec:cut-package-agent-hull-guards.r2`, superseding r1. From 96cb6d20f1d40b0f53c3d419e6cd2dbf3ed3810b Mon Sep 17 00:00:00 2001 From: soul Date: Mon, 19 Oct 2026 13:01:22 +0200 Subject: [PATCH 081/444] Research: prior art for agent-authored stand-in hulls (render-critique loops, part libraries, self-judging limits) Co-Authored-By: Claude Opus 5.5 --- ...-agent-authored-standin-hulls-prior-art.md | 101 ++++++++++++++++++ 1 file changed, 101 insertions(+) create mode 100644 docs/research/2026-10-19-agent-authored-standin-hulls-prior-art.md diff --git a/docs/research/2026-10-19-agent-authored-standin-hulls-prior-art.md b/docs/research/2026-10-19-agent-authored-standin-hulls-prior-art.md new file mode 100644 index 000000000..9e6c6eb5d --- /dev/null +++ b/docs/research/2026-10-19-agent-authored-standin-hulls-prior-art.md @@ -0,0 +1,101 @@ +# Prior art for agent-authored stand-in hulls (aetheria-release, 2026-10-19) + +Serves: the stand-in hull prompt bench for the demo cast and freelancer factions +(operator 2026-10-19: "Let's see if we can figure out a prompt that gets Opus to +spit out some decent stand-in hulls for the demo cast (including ships from +non-present factions to be piloted by other freelancers)"). Gathered by Eyes +pass `eyes-ag7-llm-cad-prior-art`, session `self-2026-10-19-ag7`. Companion to +`2026-10-18-agent-built-hull-art-prior-art.md` and +`2026-10-18-faction-form-language-prior-art.md`. + +Evidence levels: **read** (HTML or abstract page read), **snippet** (search +excerpt only). + +## Loops and their measured gains + +- **SceneCraft** (ICML 2024, arXiv 2403.01248, read). Removing the render-review + inner loop dropped constraint score 88.9 to 26.1, the largest loss in its + ablation. Removing the learned code-skill library dropped it 88.9 to 64.5 and + CLIP 69.8 to 48.3. Human eval put its aesthetics win rate at 74.5% against + BlenderGPT's 14.5%. It measures scene layout from given assets, not part form. +- **CADCodeVerify** (ICLR 2025, arXiv 2410.05340, read). Two refinement rounds; + no gain observed beyond round 2. Point-cloud distance 0.156 to 0.126. Dropping + the reference views or the few-shot questions made it worse. The VLM's own + verification answers were correct only 64.6% and 68.2% of the time; human + gold feedback (0.120) beat VLM feedback (0.137) on a 50-sample subset. +- **BlenderGym** (CVPR 2025, arXiv 2504.01786, read). VLM verifier agreement + with human pairwise judgments 0.66 (Claude 3.5 Sonnet) against 0.79 + human-to-human; Claude was the only verifier that consistently gave full + reasoning. Scarce compute favours generation, abundant compute favours + verification: a scaled small verifier over 32 Claude candidates beat unscaled + GPT-4o and Claude. Generators hallucinate differences, and edits mismatch the + diagnosis in scripts over about 80 lines. Metrics miss physical + implausibility such as floating objects. +- **LL3M** (arXiv 2508.08228, read). Planner, retrieval, coder, critic and + verifier agents; the critic sees 5 renders, the verifier checks each fix was + applied, refinement loops until the verifier approves. Blender-API retrieval + raised complex-operation use 1.20 to 5.86 and cut execution errors 26%. + Without retrieval the coder sometimes deleted offending lines to silence + errors. Without shared prior code the coder regenerates a substantially + different asset. Disconnected handles survive auto-refinement; VLMs struggle + with spatial artifacts. Agent ablations are qualitative only; no user study. +- **Procedura** (arXiv 2608.26238, read). Named parts with typed mates, per-part + compile, mate and connectivity gates, and a vision critic that fixes one + diagnosed issue at a time. Ablation on MechBench-36 (judge score): full 0.828, + no refine 0.800, no planning 0.791, no 3D feedback 0.819; dropping planning + caused the biggest aesthetics drop, 0.827 to 0.775. A floater at 70% span was + 1.9% of volume, so a volume check misses it. Case study: 24 to 3 to 2 HIGH + issues over three critic cycles. CSG suits hard-surface better than organic. +- **P3D-Bench** (arXiv 2606.11152, abstract). Multi-part code generation is much + harder than single-part: models get identity right, dimensions, part count + and part geometry wrong. +- **ModelRift Pantheon benchmark** (vendor blog, one run per cell, read). Opus + 4.7 scored 3.0/5 with "uniform colour and weight"; Sonnet 4.6 3.4/5 with the + cleanest massing. Explicit measured dimensions helped; human annotations drawn + on the render beat text corrections. A preview PNG looked better than the + exported STL, which had non-manifold surfaces. + +## Self-judgement + +- Huang et al., ICLR 2024 (arXiv 2310.01798, snippet): intrinsic + self-correction without external feedback often fails and can hurt; reported + gains partly came from oracle labels or weak first prompts. +- Panickssery et al., NeurIPS 2024 (arXiv 2404.13076, snippet): LLM evaluators + recognise and favour their own generations; text domain. +- Multimodal self-judging leans on text priors over the image (arXiv 2406.11280, + 2508.20655, snippets). + +## Failure modes in CAD code + +- BenchCAD and ASSEMCAD (snippets): successful renders still show floating + components; union without face glue gives floating subpieces; polar patterns + are an error cluster. ASSEMCAD mitigates with safe-union and overlap + validators. +- Shape-grammar ships (CESCG 2016; 2022 Hacettepe thesis; snippets): primitive + sets plus rules give similar outlines with varied detail; unconstrained + derivation tends toward unstructured results. + +## Game-art practice (criteria a critic can check) + +- Stellaris Dev Diary 230 (search excerpt): silhouette and proportions read + first at distance; big shapes settled before small; shared motifs unify a + family. +- Fractured Space art direction, Creative Bloq (search excerpt): an arrowhead + silhouette reads as a threat across the map; three manufacturers each get a + distinct vocabulary (stacked cylinders, brutalist cubes, naval modular + shells); human figures dropped in as scale checks. +- "Moth" analysis, John Liberto, ArtStation (search excerpt): 3 large, 4 mid + and many small volumes; the large volumes dictate the silhouette. +- Primary/secondary/tertiary shapes (steveyang.readthedocs.io, read, after Neil + Blevins): primary defines silhouette, secondary individuality, tertiary story. + +## Gaps + +No controlled study of design-language or style-spec conditioning for +LLM-authored CAD; no measurement of a parts library against floating-part or +symmetry errors (nearest: SceneCraft's skill library, LL3M's retrieval); no +sweep of render-critique round counts for aesthetic quality; no direct +measurement of a model praising its own render (inferred from text-domain +work); no source on boxy "lego" look or scale confusion in LLM 3D code; no +authoritative game-art source for 60/30/10 ship proportions. 3D-PreMise +(2401.06437) and MeshCoder (2508.14879) seen as abstracts only. From 82dab50ab5664932a6b6bd68d6500462d149a125 Mon Sep 17 00:00:00 2001 From: soul Date: Mon, 19 Oct 2026 13:11:09 +0200 Subject: [PATCH 082/444] Aetheria ships: _hull_state is the one owner of a hull's state; Package's guard and retirement read it Deletes _art_takes_over and _refuse_wrong_maker's hull.scad branch. Her art over a hull.scad Build never ran now ships and retires the source (operator ruling art-over-unbuilt-hull-source-takes-over). Co-Authored-By: Claude Sonnet 5.5 --- tools/blender/aetheria_ships/__init__.py | 54 +++++++++++++++--------- tools/blender/tests/smoke_hull_source.py | 2 +- 2 files changed, 34 insertions(+), 22 deletions(-) diff --git a/tools/blender/aetheria_ships/__init__.py b/tools/blender/aetheria_ships/__init__.py index 9d9a57cbf..90d1c9ad7 100644 --- a/tools/blender/aetheria_ships/__init__.py +++ b/tools/blender/aetheria_ships/__init__.py @@ -1493,6 +1493,10 @@ def _frame_ship(context, collection, root, path, saved_shape): HULL_MAKER = "aetheria.hull_maker" # on the collection: the Faction key of the language Build judged the hull against HULL_BUILT = "aetheria.hull_built" # on the collection: {object name: fingerprint} of what Build From Hull Source made +HULL_AGENT = "agent" # HS rows H5, H7-H10, H12: Build's judged body or record governs; Package checks the maker +HULL_ART = "art" # HS rows H4, H6, H11: her meshes replace Build's; Package ships them and retires the hull source +HULL_UNBUILT = "unbuilt" # HS row H3: an agent's hull.scad with nothing built and none of her art +HULL_PLAIN = "plain" # HS rows H1, H2: no agent hull; Package checks nothing and retires nothing HULL_COLLIDER = ("hull-collider", "collider", "Hull Collider") @@ -1624,23 +1628,29 @@ def _build_hull_source(context, collection): return hull -def _art_takes_over(collection): - """Whether the body is the operator's: Build's maker record and a Ship Root exist, the collection holds render meshes - that Build did not make as they stand, and no zone mesh of Build's is left. The record alone says the hull was - Build-made; hull.scad is not consulted.""" - root = _ship_root(collection) - if HULL_MAKER not in collection or root is None: - return False +def _hull_state(collection): + """The one owner of a ship collection's hull state (map HS), decided in this order: an untouched Build zone mesh + keeps the hull agent's; her meshes beside Build's record or hull.scad make it art; then Build's record is agent, + hull.scad alone is unbuilt, and nothing is plain. _refuse_wrong_maker and _retire_hull_source read this and nothing + else to decide the regime; Build's own write-safety rule is a separate decision.""" untouched, _ = _hull_built(collection) - zones = [obj for obj in untouched if obj.type == "MESH" and "aetheria.role" not in obj] - return not zones and bool([obj for obj in _own_meshes(collection) if obj not in untouched]) + if any(obj.type == "MESH" and "aetheria.role" not in obj for obj in untouched): + return HULL_AGENT + built = HULL_MAKER in collection + source = _hull_source_file(collection) is not None + if _ship_root(collection) is not None and (built or source) and [ + obj for obj in _own_meshes(collection) if obj not in untouched]: + return HULL_ART + if built: + return HULL_AGENT + return HULL_UNBUILT if source else HULL_PLAIN def _retire_hull_source(collection): - """Deletes Build's records, and the package's hull.scad if it is still there, when the body is the operator's - (_art_takes_over). The anchors and collider she kept are hers from then on. A hull never built, or built and - packaged as it stands, keeps its source. Returns whether art took over.""" - if not _art_takes_over(collection): + """Deletes Build's records, and the package's hull.scad if it is still there, when the hull is in the art state + (_hull_state: H4, H6, H11). The anchors and collider she kept are hers from then on. A hull never built and without + her art, or built and packaged as it stands, keeps its source. Returns whether art took over.""" + if _hull_state(collection) != HULL_ART: return False source = _hull_source_file(collection) if source is not None: @@ -1652,16 +1662,18 @@ def _retire_hull_source(collection): def _refuse_wrong_maker(collection, path, cultlib): - """Refuses, before Package writes anything, an agent-built hull (one Build left a maker record on, or whose hull.scad - is still in the package) unless the package holds a product and every product is by the maker of the language Build - judged the hull against (ruling hull-faction-is-product-maker). Her art retires hull.scad and the record together and - needs no product. The product owns the maker; Build's record is only what it judged.""" - if _art_takes_over(collection): + """Refuses, before Package writes anything, a hull in the agent state unless the package holds a product and every + product is by the maker of the language Build judged the hull against (ruling hull-faction-is-product-maker), and + refuses an unbuilt hull.scad. _hull_state is the owner of which state this is; this function reads neither hull.scad + nor the record's presence to decide it. Her art and a plain hull need no product.""" + state = _hull_state(collection) + if state == HULL_UNBUILT: + raise ValueError("Build From Hull Source first, or put your own meshes under Ship Root: Package needs the maker " + "Build judged the hull against") + if state != HULL_AGENT: return maker = collection.get(HULL_MAKER) if maker is None: - if _hull_source_file(collection) is None: - return raise ValueError("Build From Hull Source first: Package needs the maker it judged the hull against") makers = product_makers(path, cultlib) if not makers: @@ -1741,7 +1753,7 @@ class AETHERIA_OT_package_ship(bpy.types.Operator): return {"CANCELLED"} scene.aetheria_package_report = f"Packaged {len(anchors)} anchors; the validator accepted the ship" if _retire_hull_source(collection): - scene.aetheria_package_report += "; retired Build's hull records: this package ships your art" + scene.aetheria_package_report += "; retired the agent hull source: this package ships your art" self.report({"INFO"}, scene.aetheria_package_report) return {"FINISHED"} diff --git a/tools/blender/tests/smoke_hull_source.py b/tools/blender/tests/smoke_hull_source.py index fcd24407e..91d089e30 100644 --- a/tools/blender/tests/smoke_hull_source.py +++ b/tools/blender/tests/smoke_hull_source.py @@ -455,7 +455,7 @@ def one_hull(scene, number, fixture, ship_id, extent, length, rows, body, palett select(box) result, message = run(bpy.ops.aetheria.package_ship) report = scene.aetheria_package_report - require(result == {"FINISHED"} and "retired Build's hull records" in report, f"Package did not retire the hull source: {report!r} {message!r}") + require(result == {"FINISHED"} and "retired the agent hull source" in report, f"Package did not retire the hull source: {report!r} {message!r}") require(not source.exists(), "hull.scad is still there after the author's art was packaged") require("aetheria.hull_built" not in collection and "aetheria.hull_maker" not in collection, "The record of Build's objects or its maker outlived hull.scad") From 2882907f664320b735aaa4cdddfef494ea59833b Mon Sep 17 00:00:00 2001 From: soul Date: Mon, 19 Oct 2026 13:11:10 +0200 Subject: [PATCH 083/444] smoke_hull_source: pin agent hulls with no zones and no art (1.9a), her art over an unbuilt hull.scad (3.1) and a plain hull (3.2) Co-Authored-By: Claude Sonnet 5.5 --- tools/blender/tests/smoke_hull_source.py | 56 ++++++++++++++++++++---- 1 file changed, 48 insertions(+), 8 deletions(-) diff --git a/tools/blender/tests/smoke_hull_source.py b/tools/blender/tests/smoke_hull_source.py index 91d089e30..2765a254f 100644 --- a/tools/blender/tests/smoke_hull_source.py +++ b/tools/blender/tests/smoke_hull_source.py @@ -25,12 +25,14 @@ the hull.scad into the package and runs the panel's operators: Build after Package still works. The author replaces the zone meshes with her art and keeps Build's anchors and collider: Package retires Build's records, and deletes hull.scad if it is still there (the second hull has had it removed by hand), in that one act and - says so, and Build then refuses. Build's maker record alone decides that a hull is Build-made and under the guard. - A hull that was never built keeps its hull.scad through the same deletion rule, and Package refuses it (no maker was - judged). + says so, and Build then refuses. The hull's state (plain, unbuilt, agent, art) is decided by one owner, + _hull_state (map HS). Build's zones deleted + with nothing in their place stay under the guard. Her art over a hull.scad that was never built ships and retires it. + A plain hull with her art ships and retires nothing. A hull.scad with nothing built and none of her art keeps its + hull.scad, and Package refuses it (no maker was judged). Package refuses an agent-built hull before it writes anything (ship.glb is not made) when the package holds no product, or a product by another maker than the language Build judged it against (hull-names-its-own-faction); with a product by - that maker it accepts, with hull.scad in the package or not (the guard keys on Build's record), and a refused Package + that maker it accepts, with hull.scad in the package or not (the guard keys on the hull's state), and a refused Package changes nothing in the scene. Her art needs no product. Build and Package refuse copies that share an aetheria.id, selecting the original and the copies, and delete nothing. Package moves no row and turns no mount: every layout row is as authored after it, and a hand-turned mount keeps its yaw. @@ -439,6 +441,20 @@ def one_hull(scene, number, fixture, ship_id, extent, length, rows, body, palett obj = generated(collection)[name] if obj.type == "MESH" and "aetheria.role" not in obj: bpy.data.objects.remove(obj) + # Build's zones deleted with nothing in their place: the hull stays agent's and the maker guard refuses it (H8, H12). + require(aetherdb(ship_cc, "product", ship_cc, maker, "--remove") == 0, "AetherDb could not remove the product") # her art needs none + if number % 2 == 0: # the second hull: she (or git) removed hull.scad herself first; Build's record outlives it + source.unlink() + require(collection.get("aetheria.hull_maker"), "Build's maker record is gone before Package") + glb_before = (package / "ship.glb").read_bytes() + select(box) + result, message = run(bpy.ops.aetheria.package_ship) + report = scene.aetheria_package_report + require(result == {"CANCELLED"} and "The package has no product" in report, f"Package took a hull with no zones and no art: {report!r} {message!r}") + require((package / "ship.glb").read_bytes() == glb_before, "A refused Package changed ship.glb") + require("aetheria.hull_maker" in collection and "aetheria.hull_built" in collection and source.is_file() == (number % 2 == 1), + "A refused Package changed Build's records or hull.scad") + print(f"PASS {number}.9a Build's zones deleted with nothing in their place stay under the guard") art_mesh = bpy.data.meshes.new("author hull") bm = bmesh.new() bmesh.ops.create_cube(bm, size=1.0) @@ -448,10 +464,6 @@ def one_hull(scene, number, fixture, ship_id, extent, length, rows, body, palett collection.objects.link(art) art.parent = root require(any(o.get("aetheria.role") for o in generated(collection).values()), "The scene kept no anchor of Build's") - require(aetherdb(ship_cc, "product", ship_cc, maker, "--remove") == 0, "AetherDb could not remove the product") # her art needs none - if number % 2 == 0: # the second hull: she (or git) removed hull.scad herself first; Build's record outlives it - source.unlink() - require(collection.get("aetheria.hull_maker"), "Build's maker record is gone before Package") select(box) result, message = run(bpy.ops.aetheria.package_ship) report = scene.aetheria_package_report @@ -568,6 +580,34 @@ def never_built(scene): require(result == {"CANCELLED"} and "Build From Hull Source first" in report and not (package / "ship.glb").exists(), f"Package took a hull that was never built: {report!r}") print("PASS 3 a hull that was never built keeps its hull.scad, and Package refuses it") + # Her art over a hull.scad Build never ran is hers: it ships and the hull source is retired (H4). + art_mesh = bpy.data.meshes.new("author unbuilt hull") + bm = bmesh.new() + bmesh.ops.create_cube(bm, size=1.0) + bm.to_mesh(art_mesh) + bm.free() + art = bpy.data.objects.new("author unbuilt hull", art_mesh) + collection.objects.link(art) + art.parent = api._ship_root(collection) + collider = bpy.data.objects.new("Hull Collider", art_mesh) # the validator wants exactly one hull-collider anchor + collider["aetheria.role"], collider["aetheria.id"] = "hull-collider", "collider" + collection.objects.link(collider) + collider.parent = art.parent + select(box) + result, message = run(bpy.ops.aetheria.package_ship) + report = scene.aetheria_package_report + require(result == {"FINISHED"} and "retired the agent hull source" in report, f"Package refused her art over an unbuilt hull.scad: {report!r} {message!r}") + require(not (package / "hull.scad").exists() and "aetheria.hull_built" not in collection and "aetheria.hull_maker" not in collection, + "Her art over an unbuilt hull.scad left Build's source or records") + select(box) + result, message = run(bpy.ops.aetheria.build_hull_source) + require(result == {"CANCELLED"}, f"Build ran over her art: {message!r}") + print("PASS 3.1 her art over a hull.scad Build never ran ships and retires it") + select(box) + result, message = run(bpy.ops.aetheria.package_ship) + report = scene.aetheria_package_report + require(result == {"FINISHED"} and "retired" not in report and not (package / "hull.scad").exists(), f"A plain hull with her art did not ship alone: {report!r}") + print("PASS 3.2 a plain hull with her art ships and retires nothing") def main(): From a9207abba96a097b526baaca35f2675b42c52a3e Mon Sep 17 00:00:00 2001 From: soul Date: Mon, 19 Oct 2026 13:45:09 +0200 Subject: [PATCH 084/444] smoke_hull_source: pin H10, H6 all-edited, maker-less record, collider-alone and the unbuilt exit text Soul s4 survivors: the zone test keyed on hull.scad, an edited-zones exclusion, the maker-None branch, a collider counted as art, and the unbuilt refusal losing its exit text or becoming the agent state. Co-Authored-By: Claude Sonnet 5.5 --- tools/blender/tests/smoke_hull_source.py | 97 +++++++++++++++++++++++- 1 file changed, 93 insertions(+), 4 deletions(-) diff --git a/tools/blender/tests/smoke_hull_source.py b/tools/blender/tests/smoke_hull_source.py index 2765a254f..41ef836a6 100644 --- a/tools/blender/tests/smoke_hull_source.py +++ b/tools/blender/tests/smoke_hull_source.py @@ -28,6 +28,8 @@ the hull.scad into the package and runs the panel's operators: says so, and Build then refuses. The hull's state (plain, unbuilt, agent, art) is decided by one owner, _hull_state (map HS). Build's zones deleted with nothing in their place stay under the guard. Her art over a hull.scad that was never built ships and retires it. + A hull collider alone is not her art. Package refuses a built hull whose maker record is gone, and an untouched + Build zone beside her mesh with hull.scad gone by hand; an edit of every Build zone is her art. A plain hull with her art ships and retires nothing. A hull.scad with nothing built and none of her art keeps its hull.scad, and Package refuses it (no maker was judged). Package refuses an agent-built hull before it writes anything (ship.glb is not made) when the package holds no product, @@ -577,9 +579,28 @@ def never_built(scene): select(box) result, message = run(bpy.ops.aetheria.package_ship) report = scene.aetheria_package_report - require(result == {"CANCELLED"} and "Build From Hull Source first" in report and not (package / "ship.glb").exists(), - f"Package took a hull that was never built: {report!r}") - print("PASS 3 a hull that was never built keeps its hull.scad, and Package refuses it") + unbuilt_text = ("Build From Hull Source first, or put your own meshes under Ship Root: Package needs the maker " + "Build judged the hull against") + require(result == {"CANCELLED"} and unbuilt_text in report and not (package / "ship.glb").exists(), + f"Package took a hull that was never built, or its refusal names no exit: {report!r}") + print("PASS 3 a hull that was never built keeps its hull.scad, and Package refuses it naming the exit") + # Her hull collider alone is not her art: it carries a role, so the hull is still unbuilt and keeps its hull.scad. + root = api._ship_root(collection) + collider_mesh = bpy.data.meshes.new("author collider") + collider_mesh.from_pydata([(0, 0, 0), (1, 0, 0), (0, 1, 0)], [], [(0, 1, 2)]) + lone = bpy.data.objects.new("Hull Collider", collider_mesh) + lone["aetheria.role"], lone["aetheria.id"] = "hull-collider", "collider" + collection.objects.link(lone) + lone.parent = root + select(box) + result, message = run(bpy.ops.aetheria.package_ship) + report = scene.aetheria_package_report + require(result == {"CANCELLED"} and unbuilt_text in report and (package / "hull.scad").is_file() + and not (package / "ship.glb").exists(), + f"A hull collider alone was taken for her art: {report!r}") + bpy.data.objects.remove(lone) + bpy.data.meshes.remove(collider_mesh) + print("PASS 3.0 a hull collider alone is not her art: Package still refuses and keeps hull.scad") # Her art over a hull.scad Build never ran is hers: it ships and the hull source is retired (H4). art_mesh = bpy.data.meshes.new("author unbuilt hull") bm = bmesh.new() @@ -610,6 +631,73 @@ def never_built(scene): print("PASS 3.2 a plain hull with her art ships and retires nothing") +def agent_guards(scene): + """Rows of the state table (map HS) that only a fresh built hull with no product can reach: the guard needs the + maker record, a hull.scad gone by hand does not lift it (H10), and her edit of every Build zone is her art (H6).""" + fixture, _, extent, length, rows, *_ = HULLS[1] + ship_id = "smoke.hull.guards" + collection, box, package = make_ship(scene, fixture, ship_id, extent, length, rows) + api = sys.modules["aetheria_ships"] + root = api._ship_root(collection) + source = package / "hull.scad" + select(box) + result, message = run(bpy.ops.aetheria.build_hull_source) + require(result == {"FINISHED"}, f"Build was refused: {message!r}") + + def refused(text, label): + select(box) + result, message = run(bpy.ops.aetheria.package_ship) + report = scene.aetheria_package_report + require(result == {"CANCELLED"} and text in report and not (package / "ship.glb").exists(), + f"Package took {label}: {report!r} {message!r}") + require("aetheria.hull_built" in collection and "aetheria.hull_maker" in collection, + f"A refused Package changed Build's records ({label})") + + # Build's zones intact but the maker record gone: nothing to compare the products with, so Package refuses. + maker = collection["aetheria.hull_maker"] + del collection["aetheria.hull_maker"] + select(box) + result, message = run(bpy.ops.aetheria.package_ship) + report = scene.aetheria_package_report + require(result == {"CANCELLED"} and "Build From Hull Source first: Package needs the maker it judged the hull against" in report + and source.is_file() and "aetheria.hull_built" in collection and not (package / "ship.glb").exists(), + f"Package took a built hull whose maker record is gone: {report!r} {message!r}") + collection["aetheria.hull_maker"] = maker + print("PASS 4.1 Build's zones with no maker record: Package refuses and keeps everything") + + # H10: one of Build's zones untouched beside her mesh, hull.scad gone by hand: still the agent's, still guarded. + art_mesh = bpy.data.meshes.new("author hull") + art_mesh.from_pydata([(0, 0, 0), (1, 0, 0), (0, 1, 0)], [], [(0, 1, 2)]) + art = bpy.data.objects.new("author hull", art_mesh) + collection.objects.link(art) + art.parent = root + away = source.with_name("hull.scad.away") + source.rename(away) + refused("The package has no product: add one for the hull's maker before packaging", + "a hull with an untouched Build zone, her mesh and no hull.scad") + away.rename(source) + bpy.data.objects.remove(art) + bpy.data.meshes.remove(art_mesh) + require(source.is_file(), "hull.scad was not put back") + print("PASS 4.2 an untouched Build zone beside her mesh stays guarded with hull.scad gone") + + # H6: she edited every Build zone and made nothing else: the zones are her art, Package ships with no product and + # retires Build's records and hull.scad. + zones = [o for o in generated(collection).values() if o.type == "MESH" and "aetheria.role" not in o] + require(len(zones) >= 2, "The hull has too few zones to edit them all") + for zone in zones: + zone.data.vertices[0].co.z += 5.0 + zone.data.update() + select(box) + result, message = run(bpy.ops.aetheria.package_ship) + report = scene.aetheria_package_report + require(result == {"FINISHED"} and "retired the agent hull source" in report, f"Package refused a hull whose every zone she edited: {report!r} {message!r}") + require(not source.exists() and "aetheria.hull_built" not in collection and "aetheria.hull_maker" not in collection, + "Her edit of every zone left Build's source or records") + print("PASS 4.3 an edit of every Build zone is her art: Package ships it with no product and retires Build's records") + return collection + + def main(): require(bpy.app.version[:2] == (5, 2), f"This smoke is for Blender 5.2; this is {bpy.app.version_string}") bpy.data.objects.remove(bpy.data.objects["Cube"], do_unlink=True) @@ -644,6 +732,7 @@ def main(): clear(previous) previous = one_hull(scene, number, fixture, ship_id, extent, length, rows, body, palette, maker, other) clear(previous) + clear(agent_guards(scene)) never_built(scene) @@ -661,7 +750,7 @@ else: print("SMOKE_HULL_SOURCE_OK", flush=True) code = 0 finally: - for _, ship_id, *_ in HULLS + ((None, "smoke.hull.unbuilt"),): + for _, ship_id, *_ in HULLS + ((None, "smoke.hull.unbuilt"), (None, "smoke.hull.guards")): shutil.rmtree(MODS / ship_id, ignore_errors=True) if created: shutil.rmtree(MODS, ignore_errors=True) From d52b50f911edad53527c33ac8221e64fa20c964b Mon Sep 17 00:00:00 2001 From: soul Date: Wed, 21 Oct 2026 18:06:49 +0200 Subject: [PATCH 085/444] Research: strategic AI arena and living world prior art (persistent faction worlds, bot tournaments, believability, restricted play, fun proxies, off-screen combat models), for rulings arenas-explore-the-metagame and colosseum-feeds-matchup-model Co-Authored-By: Claude Opus 5.5 --- ...rategic-ai-arena-living-world-prior-art.md | 697 ++++++++++++++++++ 1 file changed, 697 insertions(+) create mode 100644 docs/research/2026-10-21-strategic-ai-arena-living-world-prior-art.md diff --git a/docs/research/2026-10-21-strategic-ai-arena-living-world-prior-art.md b/docs/research/2026-10-21-strategic-ai-arena-living-world-prior-art.md new file mode 100644 index 000000000..42f0ee20c --- /dev/null +++ b/docs/research/2026-10-21-strategic-ai-arena-living-world-prior-art.md @@ -0,0 +1,697 @@ +# Strategic AI arena and living world: prior art (Eyes pass) + +Date: 2026-10-21. Eyes, read only. Facts with pointers. No conclusions or recommendations. + +**What this serves.** This pass serves the operator's idea of 2026-10-21, before any fork is put to her. Her words: + +> "You know how we were talking about setting up an arena where we have agents write combat routines and simulate +> thousands of matches in various scenarios, from which we can establish various faction combat doctrines (say, +> restrict each agent's fleet to a specific faction or set of loadouts)? I just thought, why don't we do the same thing +> for the RTS side of the game? That would have way more impact than trying to simulate either the economy via Njordr +> or the narrative via Ghostlight, because we already control the game state and all its possible mutations, and when +> we have established AI routines for both faction play and combat, we have a living world to present the player, +> where the jobs that are available all advance some employer's interests in real time." + +Her scope addition, relayed by Self the same day: + +> "If we're able to score on character, that's where this also takes over Ghostlight's job. And yes, of course the +> written routines are content. We run the arenas to surface solutions to the metagame we've established. We run that +> in a loop to explore how our games rules and the content we create (tech trees, crafting tree, manufacturing +> tradeoffs, combat metagame) cause interesting and fun strategies to emerge. We use agents to explore the metagame +> space so that we can iterate on the game design through virtual play." + +**Provenance key.** **[L]** read locally in full: a repo file, the Eureka mind, or a PDF extracted with `pdftotext`. +**[F]** a fetched page, read by WebFetch's summarizer model. **[S]** a WebSearch summary only: treat it as a pointer +to chase. **[mem]** the agent's prior knowledge, not re-verified in this pass. Aetheria anchors are against `master` +`2f4b617e` (2026-10-19), which matches `origin/master`. + +**Earlier passes this one builds on.** They are not repeated here. +- `F:\Projects\aetheria-colosseum-prior-art.md` (2026-10-04) covers bot platforms (Robocode, Screeps, Battlecode, + Halite, CodinGame, the BWAPI ladders, SC2 AI Arena, Lux AI, CodeClash) and automated balance play in sections 1 + and 2. +- `F:\Projects\aetheria-authored-missions-prior-art.md` covers the X4 Mission Director and the Starsector hub + missions (lines 37-75). +- `F:\Projects\aetheria-story-sifting-prior-art.md` covers story sifting. + +--- + +## 1. Persistent worlds driven by AI factions, and jobs drawn from their needs + +### X3 and X4: Foundations (Egosoft) +- **Economy.** Egosoft's marketing says hundreds of stations make wares, thousands of NPC ships carry them, and prices + follow that simulated trade. It also says that in X4 every part of the NPC economy is manufactured from + resources: ships, weapons, upgrades, ammunition and stations. [S] Steam/press copy via + https://dsogaming.com/?p=120136 and https://www.gematsu.com/games/x4-foundations +- **Territory.** The Egosoft wiki says factions try to grow their ship and station counts and to annex whole sectors. + [S] https://wiki.egosoft.com/X4%20Foundations%20Wiki/Manual%20and%20Guides/Objects%20in%20the%20Game%20Universe/Factions/ +- **How NPC ships get their work.** NPC ships are spawned to fill per-faction quotas in a `jobs` library file. The + `god` file seeds stations, and in X3 the "GOD engine" also kept the economy alive by adding and removing + stations. [mem] +- **Missions.** Story and generic missions are both Mission Director cue trees. They act on the live universe's + sectors, ships and factions, and observe and perturb the economy rather than replace it. + [L] `F:\Projects\aetheria-authored-missions-prior-art.md:37-51` +- **Documented failures (anecdotal).** Players report early shortages at shipyards and wharfs that leave only one or + two stations unaffected. Players also report that war is mostly triggered by the player, and one forum poster + calls the economy shallow, with "only one product: ships". [S] https://forum.egosoft.com/viewtopic.php?p=4912608 ; + https://steamcommunity.com/app/392160/discussions/0/3104642254774431342 +- No GDC talk or postmortem on X4's economy was found. + +### Elite Dangerous background simulation (BGS) +- **Structure.** Each populated system holds one to seven minor factions, and one of them controls the system. + Player actions push factions into states covering economy, security, health and influence. + [S] https://forums.frontier.co.uk/goto/post?id=4850407 +- **Layered states.** Since Chapter Four (2018), a faction can hold several states at once, and a war applies only + in the system where it is fought. [S] https://massivelyop.com/?p=192372 . Frontier names political, economic, + outfitting and Thargoid layers that interact: Boom improves outfitting and changes prices, and a Thargoid state + suspends political movement. [S] same forum guide +- **Missions as the lever.** Combat actions and most combat missions push a war toward its result. Elections move on + non-combat missions, trade and exploration data. [S] + https://www.gamingnexus.com/News/43299/Elite-Dangerous-update-to-combat-issues-that-keep-popping-up-in-Background-Simulation +- **Missions draw on faction state.** One player reports that a faction with no state offered no missions. + Another reports that missions in Powerplay systems moved no BGS influence. Both are user reports. + [S] https://steamcommunity.com/app/359320/discussions/0/1694920442961862234 ; + https://forums.frontier.co.uk/threads/missions-not-impacting-bgs-especially-during-elections-in-pp-systems.633066 +- **Cadence.** BGS states tick daily and Powerplay weekly. [S] same results +- **Critique.** Some players say the BGS has little effect on play. Others say it matters for prices, missions and + player groups. [S] https://yesxorno.substack.com/p/ed-bgs-and-pp2 + +### EVE Online: NPC faction warfare +- **Warzone control.** Control is counted as systems held plus installed upgrades, per faction, against the enemy. + Rising control raises a warzone tier, which originally scaled LP store prices from 4x dearer to 4x cheaper. + [S] https://www.eveonline.com/news/view/fw-dev-blog-1-1-1 +- **A failure CCP corrected.** Players "spiked" tiers to cash out LP in one day. CCP replaced the store discount with + an LP gain bonus. [S] https://www.eveonline.com/news/view/fast-forward +- **Tuning.** Victory points from complexes were cut by 5x, which raised the average system capture from 6-7 hours + to 30-35 hours. In March 2020, FW mission LP payouts were cut by about 30%. [S] same blogs; + https://forums.eveonline.com/t/patch-notes-for-march-2020-releases/224859/213 +- **Who moves the front.** Players do: the NPC militias are mission givers and site defenders, and do not drive the + front themselves. [mem] + +### Mount & Blade II: Bannerlord +- **Issues.** Quests are "issues" attached to notables, generated by campaign behaviours from settlement state; a + village headman needing grain is one example. [mem] (the `IssueBase` type was not confirmed against the docs this + pass). Modders can add campaign behaviours, which run while the player is on the campaign map. [S] + https://docs.bannerlordmodding.com/ , https://www.gamebanshee.com/kdyqp +- **Documented failures.** + - Issue quests are repetitive, and the conspiracy quests send the player across the map during campaigns. [S] + https://gamebanshee.com/9k6hq + - Bandit quests fail because too few bandit parties spawn. [S] Steam discussions in the same search + - TaleWorlds planned to improve "the issue creation algorithm" so players meet a broader range of issues. [S] + https://www.gamebanshee.com/383c4 + - AI armies leave newly won fiefs undefended, and AI lords mismanage conquered towns and lose them to rebels. [S] + review and Steam threads + - The AI declares war on whichever kingdom it deems strongest, often the player's. [S] Steam threads + - Fief-less factions recruiting lords produced large armies with no territory, which players found confusing. [S] + +### Dwarf Fortress: world generation +- **What is simulated.** World generation runs history for a set number of years: civilizations, races and religions + spread, wars break out, and population and death counters climb. Legends mode exposes every civilization and + historical figure. [S] https://dwarffortresswiki.org/index.php/DF2014:World_gen ; + https://twinfinite.net/guides/best-dwarf-fortress-world-generation-settings/ +- **Parameters.** History length, number of civilizations, number of beasts, savagery and maximum sites. [S] same +- **Adams on the game.** He calls it a "fantasy world generator" in which the player takes part in a small story. + [S] https://scalar.usc.edu/works/game-changers-2016-exhibit/dwarf-fortress . His criterion for adding a + simulation (Gamescom 2026) is whether playing through it yourself felt cool. [S] + https://www.pcgamer.com/gaming-industry/game-development/dwarf-fortress-creator-says-we-have-a-natural-lean-towards-human-made-stories-but-but-were-all-going-to-be-very-sorely-tested-in-the-coming-years-by-ai/ +- No primary source on army or site simulation was found. The FDG 2019 and UCSC 2019 emergent-narrative talks are + listed at https://www.bay12games.com/dwarves/links.html. [S] + +### Space Rangers 2: Dominators +- **AI rivals.** Other AI Rangers compete with the player on a ranked leaderboard, ask for help, and propose joint + attacks. [S] https://www.gamespot.com/space-rangers-2-rise-of-the-dominators/user-reviews/2200-489673/ +- **Economy.** Prices move with supply and demand, local Dominator attacks, and events such as famine. Many events + happen without the player. [S] https://sf-encyclopedia.com/entry/space_rangers ; gamesfirst review +- The search found no developer documentation. + +### Starsector +- **Colony crises (Nov 2023).** Crises replaced punitive expeditions for most factions and are tailored per faction. + The developer notes that the old raids by Pirates and the Path were unlimited. [S] + https://fractalsoftworks.com/2023/11/24/colony-crises +- **Crisis mechanics (wiki, 0.98a).** + - Points accrue, preparation starts at 500, and the attack comes at 600. + - One-time reductions build "blowback" that feeds 5% back each month, so they only postpone a crisis. + - Resolutions can be narrative: a treaty with Kanta, or a deal with the Path. + - [S] https://starsector.wiki.gg/wiki/Colony_Crises +- **Campaign missions.** Missions locate themselves in the generated sector by require, prefer and pick search. + [L] `F:\Projects\aetheria-authored-missions-prior-art.md:53-73` +- **Fleets.** The economy is abstract supply and demand per market, and fleets are spawned by per-market managers + (patrols, traders) rather than carrying actual goods. [mem] + +### Stellaris AI +- **Dev diary #252.** Patch 3.3 aimed at AI economic management in the mid and late game. Patch 3.4 turned to fleet + management and diplomacy, and its fleet changes were "mostly bug fixes". The diary opens on the AI splitting its + fleets into tiny pieces. Once the AI grew stronger, players objected to it passing Galactic Community resolutions + against them. [S] + https://admin-forum.paradoxplaza.com/forum/developer-diary/stellaris-dev-diary-252-artificial-and-automated-intelligence.1521980/ + +### Ultima Online's original ecology +- **Design.** Raph and Kristen Koster designed it: grass grows, herbivores eat the grass, carnivores hunt the + herbivores, and dragons pursue a Maslow-like hierarchy of needs. [S] https://en.wikipedia.org/wiki/Ultima_Online +- **What Koster says happened.** [F] https://www.raphkoster.com/?p=46439 + - The ecology ran in alpha. During beta an engineer rewrote it and then the AI was disabled; the resource values + stayed and still feed crafting. + - It was cut for performance: radial searches followed by pathfinding were too expensive. + - He separates the cut from the "collapse". The economy was a closed loop with a fixed resource pool. Players + hoarded (sheep killed, wool made into shirts for skill grinding, the shirts held), the central bank ran out of + wool, and no more sheep could spawn. + - Zach Simpson's analysis of UO's economy is the source of the term "faucet-drain economy". +- **Starr Long's account** is that players killed everything faster than it could respawn. [S] Wikipedia +- **Wolves.** In 2001 Koster said long-lived wolves grew cannier because there was no cap on how smart they could + get. [S] https://ultimacodex.com/2014/07/pimps-and-dragons-ultima-online-profiled-in-the-new-yorker-circa-2001/ + +### Pre-simulation, snowballing, and level of detail + +The operator added (2026-10-21): "And since we need all of this to run many times faster than realtime anyway, we +can easily simulate a living world for the player when we drop them into the game". + +- **Worlds simulated before the player arrives.** + - Dwarf Fortress runs history for a chosen number of years before play, and Legends mode exposes the result. + [S] see Dwarf Fortress above + - Caves of Qud generates the histories of five ancient Sultans at the start of each run. Grinblat (GDC 2018) + avoided a full historical simulation: events are generated first and their causes rationalised afterwards with + a state machine and a replacement grammar. [S] https://gdcvault.com/play/1024990/Procedurally-Generating-History-in-Caves ; + https://pcgworkshop.com/archive/grinblat2017subverting.pdf + - Ghostlight's own storylet ruling has the same shape: simulate to a baseline, then branch. [L] section 4.4 +- **Runaway snowballing, and how games damp it.** + - *Bannerlord.* Early-access patches added relation penalties after raids and sieges and stopped clans defecting + to kingdoms that had taken their settlements, explicitly "to prevent players from easily snowballing". Players + reported the snowballing persisted. [S] https://www.thesixthaxis.com/2020/04/06/mount-blade-2-bannerlord-crash-fix-update-1-0-5/ ; + https://steamcommunity.com/app/261550/discussions/0/2264689848033111449 + - The AI declaring war on whichever kingdom it judges strongest acts as an emergent coalition. [S] section 1 + Bannerlord + - *Stellaris.* + - AI empires snowball through federations: one AI megacorp grew its influence by inviting everyone into its + federation. [S] https://steamcommunity.com/app/281990/discussions/0/3826416272371455457 + - Damping comes through Galactic Community resolutions, the Custodian and Emperor mechanic (declaring Empire + dissolves federations), and end-game crises that force cooperation. [S] same thread; + https://guildorder.com/tr/games/stellaris/wiki/end-game-crises + - Paradox also weights AI aggression against relative power, and difficulty scaling changes AI bonuses. [mem] + - *Other catch-up mechanics* [mem]: + - EU4: coalitions against high aggressive expansion, and overextension. + - Civilization: rising costs as a civ grows. + - Starsector: crisis "blowback" only postpones a crisis. [S] above +- **Level of detail: full simulation near the player, abstract far away.** + - X3: objects out of sector reduce to statistical, turn-based events without collisions. [S] section 7 + - X4: high and low attention, set by distance from the player. Low-attention combat and movement are separately + tuned and patched. [S] section 7 + - Elite's BGS is a daily, system-level state machine of influence and faction states, fed by player actions. It + does not simulate ships. [S] section 1, Elite + - Ghostlight's multiresolution gestalts "aggregate simulation work, not canonical identity or state": a villager + becomes an individual when attended to and returns to a cheaper population turn while keeping a durable delta. + [L via voidbot, `Ghostlight/docs/articles/multiresolution-gestalts.md:28-55`] + - Aetheria today ticks only the player's zone; the other zones stay packed. [L] section 4.1 + +--- + +## 2. Bot tournaments and agent-written strategy + +Platforms and ratings: see `F:\Projects\aetheria-colosseum-prior-art.md` sections 1.1-1.13 (Robocode, Screeps, +Battlecode, Halite, CodinGame, BWAPI/SSCAIT/AIIDE/CIG, SC2 AI Arena, Lux, CodeClash). This pass adds the following. + +- **microRTS competition.** + - It began at IEEE CIG 2017 with three entries (Ontañón et al., AI Magazine 2018). [S] + https://ojs.aaai.org/index.php/aimagazine/article/view/2777/2687 + - It has been held at CoG since 2019: a round robin on 12 maps (8 open, 4 hidden) with a 100 ms limit per step. [S] + https://arxiv.org/html/2402.08112v1 + - Scripted agents won the first five editions. The first deep RL winner, RAISocketAI, won in 2023. [S] same +- **Generals.io.** A public bot server and API exist (`bot.generals.io`, socket.io). [mem; the search returned + nothing] +- **Screeps.** A persistent MMO of player-written JS bots with no ladder; ranking is by GCL and power. Tick length + depends on player load. [L] `F:\Projects\aetheria-colosseum-prior-art.md:25-30` +- **LLM agents playing or writing strategy.** + - **CICERO** (Meta FAIR, *Science* 2022). + - It joins a 2.7B dialogue model to the piKL planner. piKL improves predicted policies toward higher expected + value while a KL term keeps them close to human-like play. + - It ranked in the top 10% over 40 webDiplomacy games, scoring 25.8% against a human average of 12.4%. + - Reported weaknesses: it relies on human data and adapts poorly to out-of-distribution metagames such as + variant maps (course slides). + - [S] https://ai.meta.com/blog/cicero-ai-negotiates-persuades-and-cooperates-with-people/ ; + https://cw.fel.cvut.cz/b251/_media/courses/xp36rgm/diplomacy.pdf + - **Voyager** (Wang et al. 2023): an automatic curriculum, a growing skill library of executable code, and + iterative prompting from environment feedback and self-verification. Code is its action space because programs + express temporally extended, compositional behaviour. [S] https://arxiv.org/html/2305.16291v2 + - **TextStarCraft II and Chain of Summarization** (NeurIPS 2024): most of 10 LLMs beat the level-5 built-in AI, + and fine-tuned LLMs played on par with Gold-league humans. [S] https://arxiv.org/abs/2312.11865v2 + - **LLM-SMAC** (Deng et al. 2024): a planner, a coder that writes decision-tree Python, and a critic, for StarCraft + micro. [S] https://www.promptlayer.com/research-papers/can-llms-master-starcraft-a-new-approach-to-game-ai + - **"From Code to Play"** (Eberhardinger et al., arXiv 2412.04057; ToG 2025): LLM-driven hill climbing over game + programs on 29 tasks with 11 models. LLM performance depended more on the task than on model size. [S] + https://arxiv.org/html/2412.04057v2 + - **Programmatic strategies for RTS** by script synthesis (AAAI 2021). [S] search result only + - **CodeClash** (2025): LLM-written bots. + - More than 65% of edits were ungrounded. + - Models hallucinated reasons for losses. + - Models recovered poorly after a loss. + - No model beat expert human bots. + - [L] `F:\Projects\aetheria-colosseum-prior-art.md:78-82` +- **Known failure modes, with sources.** + - *Degenerate exploits.* Battlecode dominant strategies were patched mid-contest (2019, 2021, 2023). OpenAI + hide-and-seek agents surfed boxes through physics bugs. Krakovna's specification-gaming list. + [L] colosseum file :32-37, :110-112 + - *Superhuman or out-of-character optimal play.* OpenAI Five used instant buyback, five invincible couriers and + death-ball aggression. AlphaStar needed APM and camera limits to be comparable to humans. + [L] colosseum file :103-104 + - *Overfitting to the arena.* Opponent-learning bots dominate long round robins (AIIDE 2017). Halite's local + results diverged from the ladder. CICERO adapts poorly to variant maps. + [L] colosseum file :50-56, :39-44 ; [S] CICERO slides + - *Drift when the ruleset changes.* Battlecode buffs created new dominant strategies (2023 anchoring). + [L] colosseum file :37 . EVE's LP tier change was a rule change that players had arbitraged. + [S] Fast Forward blog + +--- + +## 3. Fitness for believability or interest, not winning + +- **Left 4 Dead AI Director** (Booth, Valve 2009). [L] `pdftotext` of + https://cdn.fastly.steamstatic.com/apps/valve/2009/ai_systems_of_l4d_mike_booth.pdf + - The stated goal is "Generate dramatic game pacing", inspired by Counter-Strike's "spiky" pacing: constant combat + is fatiguing and long inactivity is boring. + - It estimates each survivor's "emotional intensity". Intensity rises with damage taken, incapacitation and being + pulled off a ledge, decays toward zero, and does not decay while the survivor is engaged. The Director tracks the + maximum over the four survivors. + - It runs four states. **Build Up**: full threat population until intensity crosses the peak threshold. + **Sustain Peak**: 3-5 more seconds. **Peak Fade**: minimal population until intensity decays and a natural break + comes. **Relax**: 30-45 s, or until the survivors near the next safe room. + - A second goal is "Promote Replayability: Procedurally Populated Environments". +- **RimWorld storytellers.** Cassandra Classic, Phoebe Chillax and Randy Random are the player's "unseeable God". + Cassandra runs a classic rising curve of challenge and tension. The storyteller weighs colony wealth, colonist and + animal counts, recent deaths or injuries, and time since the last major event, and difficulty is set separately. + [S] https://rimworldwiki.com/wiki/AI_Storytellers . Sylvester's GDC 2017 talk framed the game "not as a game, but + as a story generator". [S] https://gdconf.com/news/gdc-youll-see-rimworld-devs-succeeded-everything-wrong +- **Façade drama manager** (Mateas and Stern 2003). It sequences beats from a large pool so that tension rises and + falls along an Aristotelian arc. Beats carry author-written preconditions and effects on story state, and the + manager adds and retracts behaviours and discourse contexts as play runs. [S] + https://users.soe.ucsc.edu/~michaelm/tenurereview/publications/mateas-tidse2003.pdf +- **Oz Project lineage.** ABL descends from Loyall's Hap (Bates 1992; Bates, Loyall and Reilly 1992; Loyall 1997), + a reactive planning language for "believable agents" that express rich personality. [S] + https://course.ccs.neu.edu/cs5150f14/readings/mateas_abl.pdf +- **2K BotPrize (UT2004), a Turing test for bots.** Judges tagged opponents as human or bot with a special gun. In + 2012 UT^2 and MirrorBot were both rated 52% human, while human players averaged 40%. In 2010 the best entry reached + 31.82%. [S] https://news.utexas.edu/2012/09/26/artificially-intelligent-game-bots-pass-the-turing-test-on-turings-centenary/ ; + https://ercim-news.ercim.eu/en84/special/conscious-like-bot-wins-the-2k-botprize +- **Procedural personas.** Holmgård et al. used MCTS with evolved heuristics to give agents distinct play styles. + [L] colosseum file :101 +- **CICERO's KL regularisation toward human play** is a published way to keep a strong policy "in character" with a + reference distribution. [S] above +- **Ghostlight's existing machinery for in-character faction decisions.** See section 4.6. + +--- + +## 4. Aetheria's own state + +### 4.1 The strategic or galaxy layer today +- **Ownership is fixed at generation.** A zone's owner is the faction whose home zone is nearest, if that zone lies + within the faction's influence distance. [L] `Assets/Scripts/ServerShared/Galaxy.cs:327-347` (the assignment is + at `:346`). Presence is every faction whose home lies within its influence (`:336-339`). +- **Nothing changes the owner after that.** The only writes to `GalaxyZone.Owner` are the generation write + (`Galaxy.cs:346`) and the load from save (`Galaxy.cs:77`). [L] `git grep` on master +- **`GalaxyZone` holds topology plus owner and presence.** It has no strategic state: no stockpiles, strength or + front. [L] `Galaxy.cs:563-576` +- **Relationships are player standing per faction.** `Galaxy.FactionRelationships` holds one value per faction, the + player's standing (`Galaxy.cs:27`, read at `Entity.cs:1301`), on the ladder Hated through Beloved + (`Enums.cs:183-189`). Faction-to-faction `Relations` is only a reserved key 16 (`Corporations.cs:133`). + `Allegiance` (`Corporations.cs:121-122`) answers whose gear a faction buys. [L] +- **Only the player's zone ticks.** Unity steps the one current zone (`Assets/Scripts/Gameplay/ActionGameManager.cs:1357-1361`). + Other zones persist as `PackedContents`. No code advances unloaded zones or the galaxy over time. [L] +- **Lore: two layers.** `Aetheria/Game Design/Playable Layers.md` (lines 1-36) is marked "historical design + synthesis". It describes a cockpit layer and a corporate strategy layer that "may exchange periodic or regional + snapshots rather than share one continuously simulated universe". Corporate play would hand the cockpit layer + prices, shortages, patrol density, contracts and influence, and cockpit play would hand back purchases, losses, + salvage and completed contracts. `Corporate Strategy Layer.md` is marked "Historical, non-product design + lineage". [L via voidbot, AetheriaLore] +- **Archived RTS work.** The archived AetheriaEve repo once had a commander/RTS client (`Aetheria.Rts.Web`) and a + "Starbridge" commander and pilot design (voidbot hits in `AetheriaEve`). Under the standing rule it is archived + and is not a consumer. [L via voidbot] + +### 4.2 Faction play and doctrine today +- **Doctrine is a typed record on the faction.** `FactionDoctrine` (`Assets/Scripts/ServerShared/Doctrine.cs:24-57`) + holds `EngageOn` (Detection, Identified, Provoked, Trespass, Never), grace, hail and comply speed, plus one + `RoleDoctrine` (`:59-74`). Keys 5-8 are reserved for faction-play-2 and 9-10 for faction-play-3 (`:25-26`). + `Faction.Doctrine` is key 17 (`Corporations.cs:135-137`). Ruling `faction-doctrine-typed`, 2026-10-03. [L] +- **One flight per faction per zone decides.** A flight alone decides its members' targets and tasks, and the agent + graph decides nothing (`Assets/Scripts/ServerShared/Agents/Flight.cs:7-15`). Flights are created per faction on + first join (`Zone.cs:72-75, 311-313`). [L] +- **Patrol is the only task ever constructed.** `PatrolOrbitsTask` is built at `Zone.cs:323`. `HaulingTask`, + `Mining`, `StationTowing` and `Survey` exist under `Agents/Tasks/` but no code constructs them. [L] grep for `new` +- **Trespass.** It is read from zone ownership and security (`Zone.cs:720-725`; `Entity.cs:1265, 1273`). [L] +- **Demo scope.** Faction-play-1 to -3 are demo scope; faction-play-4 (the tender loop) and "procedural regional + politics and economy" are full game (`docs/aetheria-release-target.md:141-149`; + `docs/aetheria-release-map.md:459-462`). [L] +- **The candidate design.** `AetheriaLore/Aetheria/Game Design/Faction Play.md` (Imagination, 2026-10-03, "not canon + and not adopted") proposes five levers: NPC behaviour, loadouts, resource distribution, visuals and narrative + hooks. Its stance table is at lines 396-411. Its smallest proof asks whether "a playtester, without being told, + can describe how each faction fights" (lines 501-506). [L via voidbot] + +### 4.3 Faction territory +- **The target.** `docs/faction-territory-target.md` (2026-09-19, "target, operator direction only. No map, no + schedule") wants every sector and station to carry a supply-chain role and a colonization era (`:35-39`), a + readable territory shape per faction (`:40-45`), and real supply links that the economy and piracy can use + (`:49-54`). It requires deterministic generation with no runtime model calls (`:55-56, :60-63, :91`). Its open + question at `:86-87` is "What happens to a sector when its owner changes hands". [L] +- **The doc's anchor is stale.** Its `Galaxy.cs:340` Markov anchor no longer holds: ruling + `faction-curated-place-names` (2026-10-17) replaced name files with `Faction.Places` (`Corporations.cs:146-149`). + [L] +- **Mind follow-up.** `aetheria-release:follow_up:faction-territory-target` is in force with full-game scope. + [L] mind query + +### 4.4 Jobs and missions +- **No job or mission generator exists on master.** `Galaxy.cs:261-263` leaves the `StoryProcessor` narrative pass + commented out. `GalaxyQuest` and `LocationStory` (`Galaxy.cs:578-595`) are data shells. [L] +- **Mind follow-up.** `aetheria-release:follow_up:authored-missions` (in force, full game) wants FreeSpace 2 style + authored scenes and missions, with mission logic as C# hooks. [L] mind +- **Runtime storyteller.** Ruling `storylets-simulate-sift-lift-weave` (2026-10-03): Ghostlight runs offline to make + storylets with pre- and postconditions, and Aetheria's runtime storyteller weaves them. Radio chatter is a + delivery surface. [L] mind. The draft is `docs/reactive-world-target.md` on branch + `eureka/aetheria-release-reactive-world`, headings at lines 1-146. [L] `git show` +- **Lore.** The Action RPG Layer's smallest proof includes choosing work and inspecting "its route, payment, + claimant, and known risks". [L via voidbot, `Aetheria/Game Design/Action RPG Layer.md:12-20`] + +### 4.5 Headless simulation and the Colosseum +- **The playground map.** `docs/headless-playground-cut.md` (2026-09-17, an Imagination cut map) wants a scripted + session run headless through one run-lifecycle owner, one input surface and seeded determinism (`:13-54`). Its + finding was that movement and firing were already shared between player and AI, while combat resolution and + `CombatState` needed Unity (`:56-100`). Its Cut 2 was re-mapped as fire control (commits `b1c241fb`, `a62d52b6`). + [L] +- **What is headless on master.** + - `RunStart` owns new runs, and every run is a `Scenario` (`RunStart.cs` header; `Scenario.cs` header). + - Scenario tests step `Zone.Update` headless (e.g. `tests/Aetheria.Shared.Tests/PursuitScenarioTests.cs:35, 86-88`). + - There is no `AetherDb play` command (`tools/AetherDb/` file list). + - `ItemManager.Random` is still clock-seeded (`ItemManager.cs:20`), while the galaxy uses the run's seed + (`GalaxyStage.cs:218-226`). + - [L] +- **The Colosseum ruling.** `aetheria-release:ruling:colosseum-is-the-npc-behaviour-lab` (2026-10-04, operator): + - The bots are the game's own NPC scripts, written in-repo by agents as compiled C# over a growing primitive + vocabulary. + - The plan is to "implement all options, and let the good combinations shake out in the arena". + - What still matters is a typed observation and action seam, deterministic seeded runs and replays, enough rounds, + and exploits treated as bugs. + - Losing strategies become products or faction behaviours. + - [L] mind +- **The Colosseum follow-up.** `aetheria-release:follow_up:colosseum-npc-lab` (in force; owner "Aetheria Agents and + the headless simulation") names its foundations: + - an observation and action seam (agents hold live `Ship`, `Zone` and `ItemManager` references); + - seeding `ItemManager.Random` and the MIConvexHull randoms; + - a replay recorder; + - a headless tournament runner on Yggdrasil with ratings; + - a primitive vocabulary; + - a check of float determinism between Windows and Linux. + - Why it can wait: "the control rebuild and the drone substrate come first". [L] mind. + - The release target lists the Colosseum as not in demo scope (`docs/aetheria-release-target.md:148-149`). [L] + +### 4.6 Njörðr and Ghostlight: what each owns +- **Njörðr.** + - **What it is.** The GameCult economy daemon for Aetheria and Delvehold: every item comes from a supply chain, + materials fill roles, and prices are a pure function of committed state. + [L] `F:\Projects\Njordr\README.md:1-14` + - **Engine.** A pure, deterministic step function, embedded in the Aetheria client over a C ABI. + [L] `AGENTS.md:9-19` + - **Status.** The spec reads "specification, 2026-09-11. Nothing is built" + (`docs/architecture/njordr.md:3`). The repo is one scaffold commit, `a5bf35e` (2026-09-11), and its + `state/map.yaml` says "No step function yet". [L] + - **Ghostlight link.** Carrier policies are data: online, a Ghostlight carrier subject owns its policy; offline, + seeded policies run. [L via voidbot, `docs/architecture/njordr.md:482-507`] +- **Ghostlight.** + - **What it is.** A Rust world kernel. A language model proposes, the kernel checks the proposal against typed + state, and one `WorldKernel` commits. [L] `F:\Projects\Ghostlight` origin/main `aa56736`, `README.md:1-16` + - **Its stated direction.** Storylets for Aetheria, a persistent cast, and episodes for a video stream: "None of + this exists yet. Nothing under `crates/` loads Aetheria lore" (`README.md:75-88`). Ruling + `ghostlight-verse:ruling:verse-cast-crossmedia` (2026-10-09) names the same three products. [L] + - **Strategic wave.** Already specified and documented as a pipeline: + - resolution demand over geography, ideology, authority, economy and information; + - a deterministic partitioner that builds the active cover; + - Nemesis, which picks the subjects that "genuinely need a decision window", possibly none; + - per-cell Projector, then Persona, then Interpreter, in parallel; + - an effect verifier that stops borrowed authority or knowledge; + - an outcome resolver and an outcome verifier; + - an atomic kernel commit. + - [L] `docs/public-architecture/Runtime pipelines.md:44-62`; stage table at + `docs/public-architecture/Agents and model stages.md:111-123` + - **Persona's role.** It lets "each required actor, institution, population, or named member appraise and choose + independently" (`Agents and model stages.md:117`). [L] + - **Ledger limit.** Ghostlight's `Custody`/`Transform` is a unitless, one-to-one ledger, so prices, production, + orders and contracts are unrepresentable in Ghostlight. [L via voidbot, + `Ghostlight/docs/architecture/delvehold-forced-ontology-integration.md:46`] + - **Newspapers.** Agent journalists write over a frozen ledger + (`Agents and model stages.md:125-132`). [L] +- **What each owns that the arena idea touches.** These are facts, not a recommendation. + - Ghostlight owns the following: in-character appraisal by named actors and institutions through Persona; + decision windows; durable identity, memory and commitments; prose products (storylets, newspapers, episodes). + - Aetheria's game state owns the following: zones, entities, doctrine and flights. The game code applies every + mutation (the operator's "we already control the game state and all its possible mutations"). + - Njörðr owns the following: prices, production, lots and provenance, as a pure step function. + +--- + +## 5. Automated playtesting and simulation-driven game design + +- **Jaffe et al., "Evaluating Competitive Game Balance with Restricted Play"** (AIIDE 2012, U. Washington). [L] + `pdftotext` of https://cdn.aaai.org/ojs/12513/12513-52-16035-1-2-20201228.pdf + - **Measure.** For a restriction R (never play an action, search only k steps, avoid certain outcome states, always + pick starting condition s), balance is measured as the probability that the restricted agent A_R beats an + unrestricted A that knows R and exploits it (§2). + - **Questions it answers.** "Does every action serve a useful role?" + - Restricting a piece q no longer lowering win rate flags q as useless. + - Greedy versus random versus deep search shows how much the game rewards long-term strategy. + - **Why restricted play.** It compares AI and human *skill levels*, not behaviour, because "AI play is not currently + predictive of human play" (citing Hingston 2009). + - **Case study.** About 20 iterations on the card game *Monsters Divided*, each judged by measures such as + "players who never play Red 1/1 win 11.54%". + - **Limitations (§5).** Properties "deeply rooted in human psychology, or the look and feel" are out of reach. The + prototype used optimal agents on perfect-information discrete games. Hand-coded real-time agents would need + adjusting every balancing iteration. +- **Tomašev, Paquet, Hassabis and Kramnik, "Assessing Game Balance with AlphaZero"** (arXiv 2009.04374, 2020). + - They compared nine atomic rule variants of chess, including No-castling and Torpedo, by training AlphaZero from + scratch on each. + - Findings: piece values differ between variants, and some variants are more decisive than classical chess. + - The paper calls AlphaZero "an alternative in silico means of game balance assessment". + - Motivation: heavy opening theory and the high draw rate. + - [F] https://arxiv.org/abs/2009.04374 ; [S] ChessBase posts +- **Quality-diversity search.** + - MAP-Elites with Sliding Boundaries (Fontaine, de Mesentier Silva, Lee, Togelius, Soros, Hoover; GECCO 2019) finds + "diverse ways of playing the game well" along chosen behaviour dimensions. It slides cell boundaries to the + distribution of evolved individuals, and the paper applies it to Hearthstone design and rebalancing. Its code is + `tehqin/EvoSabber`. [S] https://arxiv.org/abs/1904.10656 + - "Evolving the Hearthstone Meta" searched card changes that equalise deck win rates. Metagame autobalancing tunes + rules toward a designer-specified target graph of matchups. [L] colosseum file :102-103 +- **Browne: Ludi and Ludii.** + - *Evolutionary Game Design* (IEEE TCIAIG 2010) defines 57 aesthetic criteria in three groups (intrinsic, + viability, quality), measured by self-play, and uses them to evolve games. Its traits include drama, depth, + uncertainty and tension. [S] + https://gpbib.cs.ucl.ac.uk/gp-html/Browne_2012_sigevolution.html ; + https://www.maastrichtuniversity.nl/news/ancient-games-and-artificial-intelligence + - Yavalath, one of Ludi's two picks, is ranked in the top 2.5% of abstract board games. [S] same + - Four of Browne's measures are restricted-player win rates. [L] Jaffe §4 + - Ludii implements some of the metrics in its Evaluation module, "not complete and ... not tested extensively" + (Browne, 2022 forum). Ludii's current goal is reconstructing ancient games. [S] + https://ludii.games/forums/printthread.php?tid=1048 ; https://arxiv.org/pdf/2310.20008 +- **Machinations (Dormans).** Diagrams of how resources flow through a game's internal economy. The AIIDE 2011 paper + "Simulating Mechanics to Study Emergence in Games" added artificial players and charts over many simulated + sessions. Micro-Machinations (van Rozen and Dormans 2014) runs the diagrams inside the game software. In + Machinations, users script strategies and observe them. [S] https://ojs.aaai.org/index.php/AIIDE/article/view/12477 ; + [L] Jaffe §4 +- **Smith, Nelson and Mateas.** + - BIPED, "Computational Support for Play Testing Game Sketches" (AIIDE 2009), lets a game sketch be played by + humans or analysed automatically on the Ludocore logic engine. It generates play traces that obey designer + constraints. [S] https://ojs.aaai.org/index.php/AIIDE/article/view/12368 ; [L] Jaffe §4 + - Nelson, "Game Metrics Without Players" (AIIDE workshop 2011), sets out seven strategies for extracting metrics + from the game artifact itself. [S] https://ojs.aaai.org/index.php/AIIDE/article/view/12479 +- **Measures of metagame health in the literature.** + - *Intransitivity.* Czarnecki et al., "Real World Games Look Like Spinning Tops" (NeurIPS 2020): games of skill + decompose into a transitive strength axis plus cycles that are widest at middle strength, which is why training + needs populations of strategies. [S] https://arxiv.org/abs/2004.09468 + - Balduzzi et al.'s Nash averaging corrects Elo for redundant agents. [L] colosseum file :86 + - *Strategy diversity.* QD archives (MAP-Elites) count distinct viable styles. [S] above. The 2025 arXiv paper + "Beyond Win Rates" clusters character usage for balance in team games. [S] https://arxiv.org/html/2502.01250v2 + - *Dead content.* Jaffe's "never use action a" measure flags content whose removal costs nothing. [L] + - Borovikov's Lanchester-law and rock-paper-scissors metrics apply to unit balance in a 4X. [L] colosseum file :97 + - *Difficulty distribution.* Isaksen, Gopstein and Nealen (FDG 2015) explored Flappy Bird's parameter space with + Monte Carlo play by a human motor-skill model and survival analysis. [S] + https://dblp.org/pid/31/1380 ; https://cs.ubc.ca/event/2015/10/exploring-game-space-using-survival-analysis-talk-aaron-isaksen-1 +- **Cases where AI playtesting misled or fell short.** + - AI play does not predict human play (Hingston 2009, cited in Jaffe §1). + - Borovikov (EA): agent play was "far from what was observed from human gameplay" unless utility rewards were + hand-specified. [L] colosseum file :97, :113 + - Exploits humans would not find, or could not execute: OpenAI Five's courier and buyback style, AlphaStar's APM, + hide-and-seek box surfing, Battlecode mid-contest patches. [L] colosseum file :103-112 + - Industry survey: developers are skeptical of automated testing agents and want clear objectives and oracles. + [L] colosseum file :106 + - Fun not measured: Jaffe §5 excludes "human psychology, or the look and feel". Browne's aesthetic metrics were + fitted to human rankings and remain incomplete in Ludii. [L] / [S] above + - Simulated economies diverging from player behaviour: UO's closed loop, broken by hoarding. [F] Koster +- **Studio use (from the earlier pass).** + - Riot used self-play to find over- and under-powered decks, producing designer KPIs. + - Ubisoft's SmartBots run overnight balance reports. + - King predicts level difficulty from human-like CNN play. + - [L] colosseum file :93-96 + +--- + +## 6. Computable proxies for fun, and how a human's taste calibrates them + +The operator added (2026-10-21): "Finding the joy in the game is of course down to taste, but I think agents can do a +lot of the heavy lifting in a structured joy-finding approach, so to speak". + +- **Browne's Ludi measures.** + - Ludi plays each evolved game against itself and scores criteria chosen because human players found them + interesting: drama, depth, uncertainty and tension. [S] + https://www.maastrichtuniversity.nl/news/ancient-games-and-artificial-intelligence + - There are 57 criteria in three groups (intrinsic, viability, quality). [S] section 5 sources + - A "playability filter" culls about 98% of evolved games, and the survivors are judged on criteria "previously + correlated with human player scores". [S] https://human-competitive.org/sites/default/files/browne-text.txt + - The thesis criteria named in the request (lead changes, drama, killer moves, uncertainty) are among them. [mem] + Their exact definitions were not retrieved this pass; the source is Browne's QUT thesis, eprints.qut.edu.au/17025. + - Two Ludi games were commercially published. Yavalath is the best known, and is ranked in the top 2.5% of abstract + board games. [S] same; https://opinionatedgamers.com/2018/04/23/james-nathan-cameron-brownes-automatic-generation-and-evaluation-of-recombination-games/ + The second is Ndengrod (later Pentalath), published by Nestorgames. [mem] + - Browne also proposed elegance and shibui metrics, largely independent of tractability and quality. [S] + https://cris.maastrichtuniversity.nl/en/publications/elegance-in-game-design/ +- **Yannakakis and Togelius: player experience modelling.** + - Experience-driven procedural content generation (EDPCG; IEEE Trans. Affective Computing 2(3):147-161, 2011) + drives generation from computational models of player experience. [S] + https://pure.itu.dk/en/publications/experience-driven-procedural-content-generation/ + - Player models map gameplay data to annotated experience. One named model type is neuroevolutionary preference + learning, used as the evaluation function of search-based generation. [S] https://www.pcgbook.com/chapter10.pdf + - The same authors' *Artificial Intelligence and Games* (2018) treats player modelling as one of three pillars. + [mem] + - Their reported practice is that experience labels are better collected as rank or pairwise comparisons than as + absolute ratings (the "ordinal" line of work, with Martínez). [mem; not confirmed this pass] +- **Decision density and meaningful choice.** + - No peer-reviewed metric that counts "meaningful choices" in a strategy game was found. + - Sid Meier's "a game is a series of interesting decisions" (GDC 2012 talk). [mem] + - Proxies in the literature: + - Jaffe's depth-k restricted players measure how far ahead play must look, and a greedy versus random gap + measures how much choice matters. [L] section 5 + - Browne's uncertainty and drama criteria. [S] + - "Choice poetics" (Mawhorter, MIT thesis) is a qualitative unit of analysis for choices. [S] + https://dspace.mit.edu/handle/1721.1/118436 + - Instance complexity measures the difficulty of a choice, tested on knapsack problems, not games. [S] + https://www.biorxiv.org/content/10.1101/405449v2.full +- **Model judges over play traces.** + - "Evaluating Language Models' Evaluations of Games" (arXiv 2510.10930) compares LLM ratings of game designs, + made before play, with human ratings. [S] https://arxiv.org/pdf/2510.10930 + - RPGBench (arXiv 2502.00595) has a judge score interestingness, consistency of facts and of personality in role + play, and action quality, checked against a human study. [S] + - CausalGame (arXiv 2607.04293) scores agent sessions with a rubric and reports reliability across judge models + with inter-rater agreement (ICC). [S] + - GameCraft-Bench (arXiv 2606.17861) reruns a multimodal judge ten times on fixed gameplay evidence to test + stability. [S] + - "LLMs may not be human-level players, but they can be testers": LLM agents' performance correlates with how + difficult humans find a game. [S] + https://www.promptlayer.com/research-papers/llms-may-not-be-human-level-players-but-they-can-be-testers-measuring-game-difficulty-with-llm-agents + - "Online Agent-as-a-Judge" (arXiv 2606.08200) covers believability in life-simulation settings. [S] +- **Steering a critic with a few human judgements.** + - Christiano et al., "Deep RL from Human Preferences" (NeurIPS 2017) learn a reward model from human comparisons + of pairs of trajectory segments. They give feedback on under 1% of interactions (about 0.1% in the NeurIPS + listing), with "about an hour of human time" for new behaviours. [S] https://arxiv.org/abs/1706.03741 + - Sentient Sketchbook (Liapis, Yannakakis, Togelius) models designer style "via choice-based interactive + evolution". When the designer picks some suggestions over others, the tool shifts the weight it gives each + quality dimension. [S] + https://mail.antoniosliapis.com/papers/designer_modeling_for_sentient_sketchbook.pdf + - Galactic Arms Race (Hastings, Guha, Stanley) evolves weapons with cgNEAT from players' past preferences, read + from use. In a study of over 1,000 online players, players found novel content that also extended weapons they + had preferred. [S] https://course.ccs.neu.edu/cs5150f13/readings/hastings_gar.pdf + - Browne's criteria weights were fitted to human rankings of games. [S] above + - CICERO's piKL regularises a strong policy toward a human-play reference distribution. [S] section 2 + +--- + +## 7. Statistical combat models that stand in for simulated fights + +The operator added (2026-10-21): "We'll also want to gather combat metagame statistics from the colosseum so we can +build a statistical model of matchups to feed the economy simulation with". + +- **Lanchester's laws.** [mem] + - The linear law (unaimed or area fire, or one-on-one fights): losses scale with the product of the two forces, so + strength is proportional to N × effectiveness. + - The square law (aimed fire, where every shooter can engage any target): dA/dt = -βB and dB/dt = -αA, so fighting + strength is proportional to αA², and concentrating force pays quadratically. + - Empirical tests are contested. A 1975 RAND critique found no clear verification of the square law, and the + Dupuy Institute lists tests that could establish neither the square law nor the linear law. [S] + https://dupuyinstitute.org/category/methodologies/lanchester-equations/ + - Borovikov (EA) used Lanchester-law metrics for unit balance in a 4X. [L] colosseum file :97 +- **Matchup and rating models with covariates.** + - Bradley-Terry: P(i beats j) = e^(βi−βj)/(1+e^(βi−βj)). `BradleyTerry2` models ability as a function of + covariates, advises adding random effects when it does, and supports contest-level terms such as home advantage. + [S] https://rdrr.io/cran/BradleyTerry2/man/BTm.html + - Time-varying Bradley-Terry with team factors has been fitted to the Australian Football League. [S] + https://arxiv.org/pdf/2405.12588 + - TrueSkill (Herbrich, Minka, Graepel, NIPS 2006) is a Bayesian factor-graph rating with a mean and variance per + player that handles teams and more than two players. [mem] It is used by Halite and CodinGame. + [L] colosseum file :39-48 + - Composition models (Dota 2 drafts): + - Draft-only logistic regression reached about 58-70% accuracy. + - Conley and Perry's model scored 69.8% but could not capture synergy and counters. + - Song et al. went from 58% to 61% by adding hero-combination features. + - dota2-predictor uses hero-by-hero synergy and counter matrices, with ROC AUC about 0.65. + - Draft-only accuracy is reported as inflated by short matches. + - [S] https://arxiv.org/pdf/2106.01782 ; https://cs229.stanford.edu/proj2015/249_report.pdf ; + https://arxiv.org/pdf/1701.03162 ; https://github.com/andreiapostoae/dota2-predictor + - Per-matchup win rates by race (StarCraft TvZ and the like) are standard community statistics. [mem] + - Nash averaging and Elo's blindness to intransitivity are covered in section 5. +- **How strategy games resolve off-screen battles.** + - **X3 out-of-sector (OOS).** + - The community wiki says out-of-sector combat becomes a turn-based system over the full installed weapon power + of each ship. It has no collisions, and ships may share a position. [S] https://x3.solasistim.net/index.php/OOS + - Turns last 30 s, or 5 s when watched on the sector map; watching did not change the outcome in one forum test. + [S] https://forum.egosoft.com/viewtopic.php?p=2474200 + - Weapons that are carried but not installed are ignored, and only the most damaging missiles matter. [S] same + - **X4 "low attention".** + - Egosoft now speaks of high and low attention, set by distance from the player rather than by sector. [S] + https://steamcommunity.com/app/392160/discussions/0/6664812048260991709 + - Patch 4.20 "improved combat simulation when player not present". + - Later fixes covered capital-ship movement in low attention and stopped main-cannon damage spreading to several + targets at once, and station surface-element damage in low attention was reduced by hand. [S] + https://steamdb.info/patchnotes/7854799 ; Steam changelogs + - **Total War auto-resolve.** Warhammer III adds spells and abilities as a number to a unit's "Combat Potential", + because they cannot act in an abstract fight. [S] + https://steamcommunity.com/app/1142710/discussions/0/4697910068985751182 + - **Stellaris.** The Relative Power labels compare fleet power, economy and technology: Superior or Inferior at + 1.5x, Overwhelming or Pathetic at 2.5x. [S] https://steamcommunity.com/app/281990/discussions/0/4200238856304642295 + Per-hull weights of 1, 4, 8 and 16 are a player's recollection only. [S] + - **Paradox abstractions.** EU4 battles alternate fire and shock phases of three days each, with a 0-9 die per + side plus the general's pips, and stacking modifiers. HOI4 compares attack with defence or breakthrough per + division. [mem] +- **Known complaints when off-screen and on-screen results disagree.** + - **X3.** + - OOS fights resolve at a 1:1 ratio "so numbers tend to matter more than ship class", and emergency jumps at + 0% shields were useless OOS. [S] https://steamcommunity.com/app/2820/discussions/0/496880503079542247 + - Missile and loadout behaviour differs from in-sector fights (forum tests above). [S] + - Exploiting the difference (fighting OOS to win fights that are lost in sector) is a common player practice. + [mem] + - **X4.** Destroyers teleport erratically out of sector, and Kha'ak beams hit harder when the player is away. [S] + Steam discussions above + - **Total War.** + - Manual-battle odds come out far below the campaign estimate (30-40% against 55-60%). + - Auto-resolve ignores units that need manual control or specific tactics, and weighs armour and melee too much. + - It wipes whole units instead of spreading casualties. + - It behaves very differently under stat mods. + - [S] https://steamcommunity.com/app/594570/discussions/0/1480982971175660014 and the other threads in this search + - **Stellaris.** Fleet power ignores weapon range and composition, so a stronger fleet can lose. An overflow bug + once made the total negative. [S] Steam threads above +- **How wargame designers fit combat-results tables.** + - A combat-results table (CRT) reduces attacker and defender strength to an odds column, and terrain and weather + shift the column. [S] https://pushingcardboard.com/base/glossary/1-combat-results-table + - Dunnigan's *Complete Wargames Handbook* (1980) has chapters on reading the CRT and on the design process. Its + derivation method was not retrieved. [S] https://en.wikipedia.org/wiki/The_Complete_Wargames_Handbook + - Dupuy's Quantified Judgment Model fits combat factors to a historical engagement database. + - The Joint Staff's 2017 Joint Training Tools reuse its data tables (terrain, weather, water obstacles, advance + rates). [S] https://dupuyinstitute.org/category/wargaming/page/21/ + - The Orion paper rejects Dupuy's "new square law" as a valid attrition model. [S] + https://orion.journals.ac.za/pub/article/view/455 + +--- + +## Gaps + +- No primary developer source was found for X4's `jobs` and `god` mechanics, Bannerlord's issue generator, Dwarf + Fortress army and site simulation, Space Rangers 2's AI, or Starsector's fleet spawning. Those lines are [mem] or + [S]. +- No GDC talk on the X4 economy was found. Nothing on the generals.io bot API was retrieved. +- The full texts of the Tomašev variant numbers, CICERO's limitations and Browne's 57 criteria were not read. +- Frontier documentation for current BGS mission generation was not found; only 2017-2020 community material was. +- Browne's exact definitions of lead change and killer moves were not retrieved. +- The Lanchester, TrueSkill, EU4 and HOI4 lines are [mem]. +- No official formula was found for X3 OOS, X4 low attention, Total War auto-resolve or Stellaris fleet power; only + community findings were. +- How CRTs are derived (Dunnigan, SPI) was not retrieved. +- No peer-reviewed metric of "meaningful choice" in strategy games was found. From 3df3a9549aafefcb175c87d0a4e34254c94a6e4d Mon Sep 17 00:00:00 2001 From: soul Date: Wed, 21 Oct 2026 18:48:50 +0200 Subject: [PATCH 086/444] aetheria-arena target: deterministic headless matches between swappable brains Co-Authored-By: Claude Opus 5.5 --- docs/aetheria-arena-target.md | 178 ++++++++++++++++++++++++++++++++++ 1 file changed, 178 insertions(+) create mode 100644 docs/aetheria-arena-target.md diff --git a/docs/aetheria-arena-target.md b/docs/aetheria-arena-target.md new file mode 100644 index 000000000..d6f344501 --- /dev/null +++ b/docs/aetheria-arena-target.md @@ -0,0 +1,178 @@ +# Aetheria arena: deterministic headless matches between swappable brains + +Date: 2026-10-21. Status: target, drafted by Imagination for Self to commit and admit. +Body pinned at `GameCult/Aetheria` origin/master `d52b50f9`. Probes ran on that tree. + +## 1. Why + +The operator ruled that arenas are a design instrument (`aetheria-release:ruling:arenas-explore-the-metagame`). Agents +explore the metagame through virtual play, and the design iterates on what they find. Her words: "We use agents to explore the +metagame space so that we can iterate on the game design through virtual play." The combat Colosseum develops NPC +behaviour by implementing many strategies over shared primitives, and the arena selects among them +(`colosseum-is-the-npc-behaviour-lab`). Colosseum statistics fit the composition model that settles fights off screen +(`colosseum-feeds-matchup-model`, `matchup-composition-model`). The strategic arena trains on generated galaxies varied +across seeds (`strategic-start-r2-generator-trains`). The demo waits on how well that sim performs +(`demo-start-gated-on-sim-quality`). + +All of that rests on one foundation. That foundation is the campaign: + +- A match is a pure function of its inputs. +- A brain reaches the world only through a typed seam. +- A match can be recorded, replayed and checked. +- Matches run headless on Yggdrasil fast enough to beat rating noise. + +The strategic state, its rules, the matchup model and the strategic arena are later campaigns (section 6). They consume this +foundation and do not reshape it. + +## 2. Body facts (probed at d52b50f9) + +Randomness: +- **Combat dice.** Combat dice are per shot: `FireControl.Commit` seeds a local generator from `Zone.CombatSeed` and the + shot id (`FireControl.cs:931`). AI dice are per pilot: `SimulationDice.For(CombatSeed, CombatSampleStream|PatrolStream, + Agent.Id)` (`SimulationDice.cs:9-38`, `Combat.cs:178`, `Zone.cs:320-323`). Drop dice use `Zone.DropSeed` and the + death ordinal (`Zone.cs:131,159,460`). +- **CombatSeed ignores the run seed.** `Zone.CombatSeed` is `GalaxyZone.Name.StableHash()` (`Zone.cs:158`), with no run + seed in it. This is ruled: `aetheria-release:ruling:run-seed-drops-only` (2026-10-18, operator). It means one scenario + replayed at one galaxy seed rolls identical dice every round. +- **The item stream at run start.** `ItemManager.Random` is still clock-seeded at construction (`ItemManager.cs:20`). + `RunStart.SeedItems` reseeds it from the galaxy seed before arena generation (step 1) and before staging (step 2) + (`RunStart.cs:62-65`). After staging, the stream runs on shared. Its draws are lot quality on every mint + (`ItemManager.cs:147,165`), the scatter of an unpacked ship packed at the origin (`Zone.cs:276`), the wormhole exit and + console spawns (`ActionGameManager.cs:589-593,710`), and staged and generated fits (`ScenarioStage.cs:31,92`). +- **Zone generation.** It seeds a local generator from the zone's name hash and its position (`ZoneGenerator.cs:52-53`). A + zone reached by warp is generated in `ActionGameManager.PopulateLevel` (`:716-731`), a Unity owner, and not in + ServerShared. +- **MIConvexHull is gone.** Commit `e56af288` replaced it with a double-precision Delaunay (`Delaunay.cs`). The + `colosseum-npc-lab` follow-up's "unseeded MIConvexHull randoms" item is stale. +- **Trig is the host's libm.** CultMath trig and pow forward to `MathF` (`CultLib packages/cultmath/src/CultMath/math.cs:62-70`). + Cross-host float identity is therefore unproven: Windows UCRT and glibc may differ in the last ulp. + +Determinism probe: +- **What ran.** A scratch partial of `RunStartTests` on Yggdrasil, Linux, .NET 10.0.12. It ran `LaunchDuel`: the Duel + scenario, seed 104, the item stream reseeded. The player is scripted to fire every 90 steps and the rival is an AI. It + stepped 36,000 steps at 1/60 s and hashed `Snapshot` every step. +- **Result.** Three separate processes gave the same hash, `082F7400...`. The first death came at step 1445. Generation + took about 325 ms per launch, and stepping ran about 9,700 steps/s. That is about 160x real time, but mostly with one + ship left after the first death, so it is not yet a two-ship throughput figure. +- **Existing pin.** `SimClockTests.OneHistoryTwice` already pins in-process repeatability of the Duel. + +How brains reach the world: +- **The pilot writes ship fields directly.** `Agent.Update` writes `Ship.Turn`, `Ship.MovementDirection` and + `TryActivateConsumable` (`Agent.cs:53-110`). `Combat` writes `Turn` and calls fire control (`Combat.cs:149` and + others). `MoveTo` writes too. +- **The flight decides targets.** `Flight` picks `Ship.Target` (`Flight.cs:178`) and `Agent.Task` from the union of its + members' `VisibleEnemies`, so target choice is sensor-gated. Flights are keyed by faction, one per zone + (`Zone.cs:75,312`), and tick in `Zone.Update` (`:363`). +- **Aiming reads ground truth.** `Combat` reads the target's true `Position` (`Combat.cs:52`). Its picture of the + target's weapons comes from the EW book (`Combat.cs:55-60`). +- **The player has its own writes.** The player path is `ActionGameManager`, with 12 direct writes of the same fields. +- **Agents hold live references.** An agent holds live `Ship`, `Zone` (through `Ship.Zone`) and `ItemManager` + references (`Agent.cs:27-31`). Nothing narrows what a brain can read or write. +- **Defined but unused tasks.** `HaulingTask`, `Mining`, `StationTowing` and `Survey` are defined, but nothing + constructs them. + +Harness: +- **The run owner.** `RunStart` owns new runs and every run is a `Scenario`, compiled C# + (`Scenario.cs`, `Scenarios/*.cs`: 17 scenarios, Duel and Pursuit among them). +- **Headless today.** `Zone.Update` steps headless. Fights resolve headless, with commit and resolution in `FireControl`. + `SimClock` is the fixed-step owner. +- **No runner.** There is no headless runner command: `tools/AetherDb/Program.cs:25-56` has catalog commands only. The + scenario tests are the only headless driver. +- **Off screen.** Only the player's zone ticks (`ActionGameManager.cs:1357-1361`). Zone ownership is written only at + generation (`Galaxy.cs:346`) and on load (`:77`). There is no job or mission generator (`Galaxy.cs:578`). + +## 3. Invariants + +- `A-pure`: A match is a pure function of (code revision, catalog revision, scenario id, match seed, brain ids per + side, host determinism class). The same inputs give the same per-tick state digest, in any process, run alone or + in a batch. +- `A-no-clock`: No input to a match comes from the wall clock, process identity, hash-code ordering of reference + types, or a stream another subsystem advances. Every draw a match reaches comes from `SimulationDice` (or a successor + owner) keyed by a structured seed. A missing seed is refused, never defaulted to the clock. +- `A-seam`: A brain reads only its side's `PilotView` and writes only `PilotCommand`s. One applier in the zone step + writes ship control fields from commands. Brains, the player's input and the playground use that applier and nothing + else. A brain holds no live `Ship`, `Zone` or `ItemManager` reference. +- `A-truthful-view`: A view holds what that side's sensors and book give, the player's information. It never holds + ground truth about another side. (Depends on question `observation-fidelity`.) +- `A-replay`: A replay holds the match inputs, the command stream and periodic digests. Replaying it reproduces every + digest, or it names the first tick that diverges. A replay records its host determinism class. Under + `cross-host-determinism`, it is trusted only on that class. +- `A-compiled`: Brains and arena scenarios ship as compiled C# content in the Aetheria repo, under stable ids. + No model call, interpreter or DSL runs inside a match (`arenas-explore-the-metagame`, Aetheria's compiled-mods rule). +- `A-one-sim`: The arena runs the game's own simulation. There is no arena-only physics, combat or rules fork. An exploit + the arena finds is a game bug. +- `A-headless`: A match runs with no Unity type loaded, from a command that runs in Yggdrasil's dotnet container. +- `A-records`: Match results, replays and tournaments are CultCache documents in a directory the runner is given. They + never land in `GameData/run.cc`, `player.cc` or the catalog. + +## 4. Not in scope + +- Strategic state: holdings, stockpiles, faction relations, strength or fronts. Also any off-screen zone tick, ownership + change, job board or generator starting-infrastructure rule. These belong to campaign 2. +- The matchup composition model and its fitting or residual retraining (campaign 2 or 3). +- Character or fun scoring, the model judge and the operator's pairwise calibration + (`strategic-scoring-success-and-character`; strategic arena). +- A large strategy library. This campaign lands the seam and two brains, enough to prove a tournament. The vocabulary + grows as content afterwards. +- An Eve/CultMesh operator surface for tournaments. Results are CultCache documents, and a surface is a follow-up once + there is a consumer. +- Moving warp-time zone generation out of `ActionGameManager`. It becomes in scope only if a match crosses a wormhole, + and arena matches are one zone. +- Cross-host deterministic trig in CultMath. It is a CultLib follow-up if `cross-host-determinism` rules that way. +- The Unity presentation of arena matches, and replay viewing in the client. + +## 5. Identity, lifecycle, authority + +| Kind | Identity | Lifecycle | Authority (owner; forbidden writers) | +|---|---|---|---| +| Brain (compiled strategy) | A stable string id declared by the class, such as `doctrine.v1`. Ids are unique across the brain registry, which refuses a duplicate. Behaviour is versioned by code revision, not by the id | Added as code. Revised by commits: a behaviour change that should not merge ratings bumps the id's version. Retired brains stay compiled, become products or faction behaviour (`colosseum-is-the-npc-behaviour-lab`), or are deleted | Owner: the brain registry, compiled. Forbidden: any runtime-loaded or model-written brain, and a brain writing ship fields directly | +| Arena scenario | A stable id string on `Scenario`. Today the identity is `Name`, a display string, so a stable `Id` is a finding to add | Same as brains: code, revised by commits. A match records its id and the code revision | Owner: the `Scenarios` registry. Forbidden: a match staging entities outside `RunStart.Stage` | +| Match seed | A uint chosen by the tournament, nonzero. It feeds the galaxy seed, the item-stream steps and the start variance (per question `match-variance`) | Fixed per match, never rerolled. Replay reuses it | Owner: the runner, from the tournament spec. Forbidden: the clock, and `Scenario.Seed`'s clock fallback inside a match | +| Combat, drop and pilot dice | `SimulationDice.For(zoneSeed, stream, ordinal)`. `CombatSeed` is the zone name hash (ruled) and `DropSeed` mixes in the run seed | Derived each draw, never stored | Owner: `SimulationDice`. Forbidden: any draw on `ItemManager.Random` from behaviour or combat (`ai-sample-offset-off-item-stream`) | +| Item stream (`ItemManager.Random`) | One stream per `ItemManager` | Reseeded by `RunStart` steps 1 and 2. After that it advances on every mint | Owner: `RunStart` seeds it and `ItemManager` draws. Forbidden: its clock-seeded default reaching a match (`A-no-clock`) | +| PilotCommand | (match, tick, side, ship ordinal `Agent.Id`) | Produced by a brain each tick and consumed by the applier the same tick. Recorded in the replay, never re-derived | Owner: the side's brain, or player input. Forbidden: any writer of ship control fields other than the applier | +| PilotView | (match, tick, side) | Derived each tick from the side's sensors and book. Never stored, and never in the replay except as a digest if wanted | Owner: the view builder, one function. Forbidden: a brain reading `Zone`, `Ship` or `ItemManager` | +| Replay | A CultCache document keyed by match id = (tournament id, ordinal) | Written once when the match ends and never edited. Superseded only by a new match. A divergence on replay is a finding, not an edit | Owner: the runner's recorder. Forbidden: a replay written by the game client, until a cut says so | +| Match result | Keyed by match id, like the replay | Written with the replay. Read by the tournament and, later, by the matchup model | Owner: the runner. Forbidden: hand edits, and results with no replay | +| Tournament | A CultCache document keyed by a tournament id (date plus label). It names its brains, scenarios, seed range, code and catalog revisions | Created by the runner. Ratings are derived from results and recomputable. A rerun is a new tournament | Owner: the runner. Forbidden: ratings stored as truth without the results they derive from | +| Host determinism class | (OS, architecture, runtime) | Recorded on every replay and result | Owner: the runner. Forbidden: comparing digests across classes unless `cross-host-determinism` rules them one class | + +No cell is empty. The Scenario row exposes a gap: `Name` is today's only identity, and it is a display string. Cut 1 adds `Id`. + +## 6. Campaign split + +1. `aetheria-arena` (this one): determinism, the seam, replay, the headless runner and the first tournament. +2. `aetheria-living-world` (later): + - strategic state owned in ServerShared; + - the galaxy generator plus faction starting-infrastructure rules; + - the off-screen strategic tick at reduced detail; + - ownership changing hands; + - the lore institutions as damping rules (`snowball-damping-lore-institutions`); + - the job board from needs; + - the strategic arena on generated seeds, scored on success and character. + + It owns the demo gate (`demo-start-gated-on-sim-quality`) and absorbs follow-up `release-target-living-demo`'s map. +3. `aetheria-matchups` (later, after 1 and enough brains): Colosseum statistics fitting the composition model and the + on-screen residual loop. It is split from 2 because its foundation, a fitted statistical model, is a different + pipeline. Campaign 2 consumes its output through one typed predictor. + +## 7. Cut sequence (campaign 1) + +1. **Determinism ledger.** + - Add `Scenario.Id`. + - Refuse an unseeded `ItemManager` in a match (delete the clock default and pass the seed in). + - Move the `Zone.cs:276` scatter onto `SimulationDice`. + - Pin a two-ship AI-versus-AI digest test. + - Run the same digest on Starfire and on Yggdrasil, and record the cross-host result. +2. **Command seam.** Add `PilotCommand` and one applier in `Zone.Update`. Route Agent, Combat, MoveTo and + ActionGameManager's direct writes through it, and delete their direct writes. +3. **View seam.** Add a `PilotView` built from sensors and book. Brains lose their live `Ship`, `Zone` and `ItemManager` + references. `Flight` and `Minion` become `DoctrineBrain` behind an `IBrain` registry. Aiming moves to the view if + `observation-fidelity` rules for it. +4. **Replay.** The recorder writes inputs, commands and digests every N ticks as CultCache. The replayer reproduces + them and names the first divergent tick. Replay tests read an existing match. +5. **Runner.** `AetherDb arena`: a scenario, two brain ids and a seed range produce results and replays in a given + directory. It runs in Yggdrasil's dotnet container, with two-ship throughput measured and recorded. +6. **First tournament.** Round-robin over two or more brains (`DoctrineBrain` plus one variant) and the combat + scenarios, with enough seeds per pairing. Ratings are derived with confidence intervals, and the results document + is ready to feed `aetheria-matchups`. From c13bb2368edd1be7fadae8c55763d71ff8ecaaee Mon Sep 17 00:00:00 2001 From: soul Date: Wed, 21 Oct 2026 18:59:52 +0200 Subject: [PATCH 087/444] determinism-ledger: delete the clock-seeded item stream and the item-stream scatter ItemManager no longer seeds itself from DateTime.Now, and Zone's unpack no longer scatters ships with _itemManager.Random. Deletes before adds: this commit does not build (the scatter and every ItemManager seed return in the next commit). Co-Authored-By: Claude Opus 5.5 --- Assets/Scripts/ServerShared/ItemManager.cs | 2 +- Assets/Scripts/ServerShared/Zone.cs | 3 --- 2 files changed, 1 insertion(+), 4 deletions(-) diff --git a/Assets/Scripts/ServerShared/ItemManager.cs b/Assets/Scripts/ServerShared/ItemManager.cs index b33b17224..5c15d5e6d 100644 --- a/Assets/Scripts/ServerShared/ItemManager.cs +++ b/Assets/Scripts/ServerShared/ItemManager.cs @@ -17,7 +17,7 @@ using float4 = CultMath.float4; public class ItemManager { - public Random Random = new Random((uint) (DateTime.Now.Ticks%uint.MaxValue)); + public Random Random; private Action _logger; diff --git a/Assets/Scripts/ServerShared/Zone.cs b/Assets/Scripts/ServerShared/Zone.cs index dc8cd0538..a69fb9d41 100644 --- a/Assets/Scripts/ServerShared/Zone.cs +++ b/Assets/Scripts/ServerShared/Zone.cs @@ -271,9 +271,6 @@ public class Zone var entity = EntitySerializer.Unpack(_itemManager, this, entityPack); var ship = entity as Ship; Admit(entity, piloted: ship is {IsPlayerShip: false}); - // Generation placement, not admission: a ship packed at the origin is scattered across the zone. - if (ship is {IsPlayerShip: false} && lengthsq(ship.Position) < 1) - ship.Position = _itemManager.Random.NextFloat3(float3(-pack.Radius * .5f), float3(pack.Radius * .5f)); } // TODO: Associate planets with stored entities for planetary colonies From cc0c1645748011f543fc65ab6a295d3e0896816b Mon Sep 17 00:00:00 2001 From: soul Date: Wed, 21 Oct 2026 19:01:41 +0200 Subject: [PATCH 088/444] determinism-ledger: every ItemManager states its seed; scatter on the zone's dice - ItemManager(uint seed, ...) refuses seed 0 and seeds Random from it. - RunStart.ItemSeed(galaxy, step) is public (formula unchanged), refuses a zero result; named steps ArenaStep, StageStep, ContinueStep and NoRunSeed. - Continue seeds the item stream from the saved run's seed (ContinueStep); a scene with no galaxy uses NoRunSeed. - Zone's unpack scatter draws SimulationDice.For(CombatSeed, ScatterStream, 1-based pack ordinal), never the item stream. - Loadout's scratch manager and AetherDb's catalog command take fixed seeds; every test and Editor construction gains 1u (mechanical rewrite). Co-Authored-By: Claude Opus 5.5 --- .../Scripts/Editor/BeltFloaterLayerVerify.cs | 2 +- .../Editor/CapabilityPresentationSmoke.cs | 2 +- .../Scripts/Editor/DrawnPoseWiringVerify.cs | 2 +- .../Scripts/Editor/FloatingItemsDrawnSmoke.cs | 2 +- .../Scripts/Editor/FlowFieldParityVerify.cs | 2 +- .../Editor/GravityFieldParityVerify.cs | 2 +- Assets/Scripts/Editor/MapBeltPerf.cs | 2 +- Assets/Scripts/Editor/MapPresenterSmoke.cs | 2 +- Assets/Scripts/Gameplay/ActionGameManager.cs | 6 ++- Assets/Scripts/ServerShared/ItemManager.cs | 6 ++- Assets/Scripts/ServerShared/Loadout.cs | 5 +- Assets/Scripts/ServerShared/RunStart.cs | 23 ++++++-- Assets/Scripts/ServerShared/SimulationDice.cs | 4 ++ Assets/Scripts/ServerShared/Zone.cs | 7 +++ .../AssemblyFromMakerTests.cs | 2 +- tests/Aetheria.Shared.Tests/BrownoutTests.cs | 2 +- .../ChildrenWithinReachTests.cs | 2 +- .../ConditionRatioTests.cs | 2 +- .../ConsumableAiTests.cs | 2 +- .../ConsumableSupplyTests.cs | 4 +- .../Aetheria.Shared.Tests/ConsumableTests.cs | 2 +- .../ContactRevealTests.cs | 2 +- tests/Aetheria.Shared.Tests/ContactTests.cs | 2 +- tests/Aetheria.Shared.Tests/DrawnPoseTests.cs | 2 +- .../FireAuthorityTests.cs | 2 +- .../FireControlCut11Tests.cs | 2 +- .../FireControlCut123Tests.cs | 2 +- .../FireControlCut124Tests.cs | 2 +- .../FireControlCut12Tests.cs | 2 +- .../FireControlCut4Tests.cs | 6 +-- .../FireControlCut5Tests.cs | 8 +-- .../FireControlCut6Tests.cs | 2 +- .../FireControlCut6cTests.cs | 4 +- .../FireControlCut6dTests.cs | 2 +- .../FireControlCut7Tests.cs | 4 +- .../FireControlCut8Tests.cs | 4 +- .../FireControlCut9Tests.cs | 6 +-- .../FireControlPerWeaponTests.cs | 2 +- .../Aetheria.Shared.Tests/FireControlTests.cs | 2 +- .../FloatingBodyTests.cs | 4 +- tests/Aetheria.Shared.Tests/FlowFieldTests.cs | 2 +- .../GravitySubtractTests.cs | 2 +- .../HeatResponseTests.cs | 8 +-- .../IffAndCombatTests.cs | 2 +- .../InputCapacitorTests.cs | 2 +- .../InspectionScannerCatalogTests.cs | 2 +- tests/Aetheria.Shared.Tests/ItemTitleTests.cs | 2 +- .../LaunchSuppressorCatalogTests.cs | 2 +- tests/Aetheria.Shared.Tests/LayerTests.cs | 2 +- tests/Aetheria.Shared.Tests/LoadoutTests.cs | 54 +++++++++---------- .../Aetheria.Shared.Tests/LockWarningTests.cs | 2 +- tests/Aetheria.Shared.Tests/LootTests.cs | 2 +- tests/Aetheria.Shared.Tests/MineTests.cs | 2 +- .../Aetheria.Shared.Tests/MiningCut1Tests.cs | 2 +- .../Aetheria.Shared.Tests/MiningCut2Tests.cs | 2 +- .../Aetheria.Shared.Tests/MiningCut3Tests.cs | 10 ++-- .../MunitionPartsTests.cs | 2 +- .../OrbitsFromFieldTests.cs | 2 +- tests/Aetheria.Shared.Tests/PaintModeTests.cs | 2 +- tests/Aetheria.Shared.Tests/PickupTests.cs | 2 +- .../PopulatedRadiusTests.cs | 2 +- .../PotentialOwnerTests.cs | 2 +- tests/Aetheria.Shared.Tests/PowerBusTests.cs | 2 +- .../Aetheria.Shared.Tests/PowerCurveTests.cs | 4 +- tests/Aetheria.Shared.Tests/PowerTierTests.cs | 2 +- .../Aetheria.Shared.Tests/ProductBinTests.cs | 2 +- .../ProductChoiceTests.cs | 4 +- .../RangeFalloffTests.cs | 2 +- .../RestoredHullsTests.cs | 28 +++++----- .../RoleAuthoringTests.cs | 2 +- tests/Aetheria.Shared.Tests/RunGoalTests.cs | 2 +- tests/Aetheria.Shared.Tests/RunSaveTests.cs | 12 ++--- tests/Aetheria.Shared.Tests/RunStartTests.cs | 2 +- .../ShieldReserveTests.cs | 2 +- tests/Aetheria.Shared.Tests/SignatureTests.cs | 2 +- .../StatResolverTests.cs | 18 +++---- .../StationPlacementTests.cs | 2 +- .../StationServicesTests.cs | 2 +- .../SunsNeverGreenTests.cs | 2 +- .../TargetingSystemTests.cs | 6 +-- .../ThrottleLockTests.cs | 2 +- .../Aetheria.Shared.Tests/VapourCloudTests.cs | 2 +- .../ZonePlacedAtBuildTests.cs | 2 +- tests/Aetheria.Shared.Tests/ZoneWorlds.cs | 2 +- tools/AetherDb/Program.cs | 3 +- 85 files changed, 200 insertions(+), 160 deletions(-) diff --git a/Assets/Scripts/Editor/BeltFloaterLayerVerify.cs b/Assets/Scripts/Editor/BeltFloaterLayerVerify.cs index 43153b267..500ae1cea 100644 --- a/Assets/Scripts/Editor/BeltFloaterLayerVerify.cs +++ b/Assets/Scripts/Editor/BeltFloaterLayerVerify.cs @@ -122,7 +122,7 @@ namespace Aetheria.EditorTools var cache = AetheriaStores.Open(Path.Combine(_work, "belts.cc"), Path.Combine(_work, "belts-run.cc"), catalogWritable: true); cache.Upsert(new VerseGrammar { Revision = 1 }); cache.FlushAsync().Wait(); - var items = new ItemManager(cache, new ProvenanceLedger(), settings, _ => { }); + var items = new ItemManager(1u, cache, new ProvenanceLedger(), settings, _ => { }); CultRecordKey Orbit(CultRecordKey parent, float distance, float phase, float2 fixedPosition = default) { diff --git a/Assets/Scripts/Editor/CapabilityPresentationSmoke.cs b/Assets/Scripts/Editor/CapabilityPresentationSmoke.cs index fd8f1cffd..135163e24 100644 --- a/Assets/Scripts/Editor/CapabilityPresentationSmoke.cs +++ b/Assets/Scripts/Editor/CapabilityPresentationSmoke.cs @@ -62,7 +62,7 @@ public static class CapabilityPresentationSmoke cache.FlushAsync().Wait(); var ledger = new ProvenanceLedger(); for (var i = 1; i <= 8; i++) ledger.Lots[i] = new Lot { Origin = new Attributed(), Quality = 1 }; - var items = new ItemManager(cache, ledger, settings, _ => { }); + var items = new ItemManager(1u, cache, ledger, settings, _ => { }); var zone = new Zone(items, new PlanetSettings(), new ZonePack(), new GalaxyZone { Name = "Smoke", Owner = null }, null); var hullRef = items.ItemData.RefOf(items.ItemData.GetByName("Smoke Hull")); var ship = new Ship(items, zone, new EquippableItem { Data = hullRef, Durability = 1000, Lot = 1 }, new EntitySettings()); diff --git a/Assets/Scripts/Editor/DrawnPoseWiringVerify.cs b/Assets/Scripts/Editor/DrawnPoseWiringVerify.cs index c1994ebeb..85223b510 100644 --- a/Assets/Scripts/Editor/DrawnPoseWiringVerify.cs +++ b/Assets/Scripts/Editor/DrawnPoseWiringVerify.cs @@ -115,7 +115,7 @@ namespace Aetheria.EditorTools cache.Upsert(new SimpleCommodityData { Name = "Wiring Ore", Shape = oneCell, MaxStack = 100 }); cache.FlushAsync().Wait(); var ledger = new ProvenanceLedger(); - var items = new ItemManager(cache, ledger, settings, _ => { }); + var items = new ItemManager(1u, cache, ledger, settings, _ => { }); // A sun and a planet on a moving rail round it, both near the bodies below: the field under a floater changes with the // drawn time, so a presenter that samples it at the step's time, drops it, or reads lead 0 for the height draws a different y. diff --git a/Assets/Scripts/Editor/FloatingItemsDrawnSmoke.cs b/Assets/Scripts/Editor/FloatingItemsDrawnSmoke.cs index 1caf77b6c..742e8aa9d 100644 --- a/Assets/Scripts/Editor/FloatingItemsDrawnSmoke.cs +++ b/Assets/Scripts/Editor/FloatingItemsDrawnSmoke.cs @@ -124,7 +124,7 @@ public static class FloatingItemsDrawnSmoke cache.FlushAsync().Wait(); var ledger = new ProvenanceLedger(); for (var i = 1; i <= 8; i++) ledger.Lots[i] = new Lot { Origin = new Attributed(), Quality = 1 }; - var items = new ItemManager(cache, ledger, settings, _ => { }); + var items = new ItemManager(1u, cache, ledger, settings, _ => { }); var zone = new Zone(items, new PlanetSettings(), new ZonePack(), null, null); EquippableItem Make(string name, int lot) => new EquippableItem { diff --git a/Assets/Scripts/Editor/FlowFieldParityVerify.cs b/Assets/Scripts/Editor/FlowFieldParityVerify.cs index 90979f7e8..2fa3e5fda 100644 --- a/Assets/Scripts/Editor/FlowFieldParityVerify.cs +++ b/Assets/Scripts/Editor/FlowFieldParityVerify.cs @@ -106,7 +106,7 @@ namespace Aetheria.EditorTools var cache = AetheriaStores.Open(Path.Combine(work, Guid.NewGuid().ToString("N") + ".cc"), catalogWritable: true); cache.Upsert(new VerseGrammar { Revision = 1 }); cache.FlushAsync().Wait(); - var items = new ItemManager(cache, new ProvenanceLedger(), settings.GameplaySettings, _ => { }); + var items = new ItemManager(1u, cache, new ProvenanceLedger(), settings.GameplaySettings, _ => { }); return new Zone(items, new PlanetSettings { Flow = flow }, new ZonePack { Time = time }, new GalaxyZone { Name = "Parity", Owner = null }, null); } diff --git a/Assets/Scripts/Editor/GravityFieldParityVerify.cs b/Assets/Scripts/Editor/GravityFieldParityVerify.cs index 766a58a6f..d996d3227 100644 --- a/Assets/Scripts/Editor/GravityFieldParityVerify.cs +++ b/Assets/Scripts/Editor/GravityFieldParityVerify.cs @@ -154,7 +154,7 @@ namespace Aetheria.EditorTools var cache = AetheriaStores.Open(Path.Combine(work, "catalog.cc"), Path.Combine(work, "run.cc"), catalogWritable: true); cache.Upsert(new VerseGrammar { Revision = 1 }); cache.FlushAsync().Wait(); - var items = new ItemManager(cache, new ProvenanceLedger(), settings.GameplaySettings, _ => { }); + var items = new ItemManager(1u, cache, new ProvenanceLedger(), settings.GameplaySettings, _ => { }); var positions = new List { new Vector2(-130f, 85f), new Vector2(610f, -240f), new Vector2(670f, -270f), new Vector2(1700f, 300f) }; var masses = new List { 14152f, 273.9f, 29.2f, 0f }; var parents = new List { -1, 0, 1, -1 }; diff --git a/Assets/Scripts/Editor/MapBeltPerf.cs b/Assets/Scripts/Editor/MapBeltPerf.cs index edc771a50..ea3eac5c0 100644 --- a/Assets/Scripts/Editor/MapBeltPerf.cs +++ b/Assets/Scripts/Editor/MapBeltPerf.cs @@ -52,7 +52,7 @@ public static class MapBeltPerf Planets = { new CultRecordRef(sunKey), new CultRecordRef(beltKey) }, Radius = 5000f, Mass = 10000f, Time = 0 }; - var items = new ItemManager(cache, new ProvenanceLedger(), settings.GameplaySettings, _ => { }); + var items = new ItemManager(1u, cache, new ProvenanceLedger(), settings.GameplaySettings, _ => { }); var zone = new Zone(items, new PlanetSettings { BodyRadius = new ExponentialCurve(), WellStrength = new ExponentialCurve { Multiplier = 1f, Exponent = 1f }, diff --git a/Assets/Scripts/Editor/MapPresenterSmoke.cs b/Assets/Scripts/Editor/MapPresenterSmoke.cs index 88309c40e..cccc4883c 100644 --- a/Assets/Scripts/Editor/MapPresenterSmoke.cs +++ b/Assets/Scripts/Editor/MapPresenterSmoke.cs @@ -115,7 +115,7 @@ public static class MapPresenterSmoke }; var ledger = new ProvenanceLedger(); for (var i = 1; i <= 8; i++) ledger.Lots[i] = new Lot { Origin = new Attributed(), Quality = 1 }; - var items = new ItemManager(cache, ledger, settings.GameplaySettings, _ => { }); + var items = new ItemManager(1u, cache, ledger, settings.GameplaySettings, _ => { }); var zone = new Zone(items, planetSettings, pack, new GalaxyZone { Name = "Smoke", Owner = null }, null); var hullRef = items.ItemData.RefOf(items.ItemData.GetByName("Smoke Hull")); var ship = new Ship(items, zone, new EquippableItem { Data = hullRef, Durability = 1000, Lot = 1 }, new EntitySettings()); diff --git a/Assets/Scripts/Gameplay/ActionGameManager.cs b/Assets/Scripts/Gameplay/ActionGameManager.cs index 9b4c6ad59..e791f1a64 100644 --- a/Assets/Scripts/Gameplay/ActionGameManager.cs +++ b/Assets/Scripts/Gameplay/ActionGameManager.cs @@ -294,7 +294,11 @@ public class ActionGameManager : MonoBehaviour ConsoleController.MessageReceiver = this; // A new run mints into an empty ledger; the saved run's stays in the store until the new run has started. - ItemManager = new ItemManager(CultCache, PendingScenario != null ? new ProvenanceLedger() : RunSave.Lots(CultCache), + // A new run is reseeded by RunStart; Continue's item draws come from the saved run's seed. + ItemManager = new ItemManager( + CurrentGalaxy == null ? RunStart.NoRunSeed + : RunStart.ItemSeed(CurrentGalaxy, PendingScenario != null ? RunStart.ArenaStep : RunStart.ContinueStep), + CultCache, PendingScenario != null ? new ProvenanceLedger() : RunSave.Lots(CultCache), Settings.GameplaySettings, Debug.Log); ZoneRenderer.ItemManager = ItemManager; diff --git a/Assets/Scripts/ServerShared/ItemManager.cs b/Assets/Scripts/ServerShared/ItemManager.cs index 5c15d5e6d..090fe1545 100644 --- a/Assets/Scripts/ServerShared/ItemManager.cs +++ b/Assets/Scripts/ServerShared/ItemManager.cs @@ -17,6 +17,8 @@ using float4 = CultMath.float4; public class ItemManager { + // The item stream: each lot's quality and each generated fit. Seeded by whoever constructs the manager and reseeded + // by RunStart at each step of a new run, never by the clock. public Random Random; private Action _logger; @@ -25,8 +27,10 @@ public class ItemManager public ProvenanceLedger Lots { get; } public GameplaySettings GameplaySettings { get; } - public ItemManager(CultCache itemData, ProvenanceLedger lots, GameplaySettings settings, Action logger) + public ItemManager(uint seed, CultCache itemData, ProvenanceLedger lots, GameplaySettings settings, Action logger) { + if (seed == 0) throw new ArgumentOutOfRangeException(nameof(seed), "an item stream needs a nonzero seed"); + Random = new Random(seed); ItemData = itemData; Lots = lots; GameplaySettings = settings; diff --git a/Assets/Scripts/ServerShared/Loadout.cs b/Assets/Scripts/ServerShared/Loadout.cs index eec005f46..d7fec7f03 100644 --- a/Assets/Scripts/ServerShared/Loadout.cs +++ b/Assets/Scripts/ServerShared/Loadout.cs @@ -127,6 +127,9 @@ public static class Loadouts // The quality a design no manufacturer makes is built at: the console give's. public const float UnbrandedQuality = .95f; + // The seed of the scratch item manager a fit is tried on. It draws nothing, so any nonzero constant does. + private const uint ScratchSeed = 1u; + // All-or-nothing: returns an entity only when every design resolved and fitted, the loadout has at most // WeaponGroupCount weapon groups, and every group index names a weapon slot. Every failure of a stage is listed; on any // failure nothing is returned and the zone and cache are unchanged. The stages run in the order that puts refusal @@ -166,7 +169,7 @@ public static class Loadouts // The cells fit or they do not whatever brand a design-only slot takes: the shape is the design's. Tried on its own // item manager, so nothing is minted into the live ledger and nothing is drawn. - var scratch = new ItemManager(cache, new ProvenanceLedger(), itemManager.GameplaySettings, _ => { }); + var scratch = new ItemManager(ScratchSeed, cache, new ProvenanceLedger(), itemManager.GameplaySettings, _ => { }); Assemble(scratch, null, hullData, loadout, Unbranded(scratch, hullSettled), slotsSettled.Select(settled => Unbranded(scratch, settled)).ToArray(), failures, out _); if (failures.Count > reported) return null; diff --git a/Assets/Scripts/ServerShared/RunStart.cs b/Assets/Scripts/ServerShared/RunStart.cs index cce2ab977..2e1245bab 100644 --- a/Assets/Scripts/ServerShared/RunStart.cs +++ b/Assets/Scripts/ServerShared/RunStart.cs @@ -25,7 +25,7 @@ public static class RunStart public static ZonePack GenerateArena(ItemManager itemManager, ZoneGenerationSettings zoneSettings, PlanetSettings planetSettings, Galaxy galaxy, Scenario scenario) { - SeedItems(itemManager, galaxy, 1); + SeedItems(itemManager, galaxy, ArenaStep); return galaxy.Entrance.PackedContents = ZoneGenerator.GenerateZone( itemManager, zoneSettings, planetSettings, galaxy, galaxy.Entrance, galaxy.IsPrelude, scenario.Ambient); } @@ -36,7 +36,7 @@ public static class RunStart public static Staged Stage(ItemManager itemManager, Zone arena, Scenario scenario, string startingHull, TutorialGenerationSettings tutorialGenerationSettings, List failures) { - SeedItems(itemManager, arena.Galaxy, 2); + SeedItems(itemManager, arena.Galaxy, StageStep); var stage = new ScenarioStage(itemManager, arena, startingHull, tutorialGenerationSettings); scenario.Stage(stage); var reported = failures.Count; @@ -62,5 +62,22 @@ public static class RunStart // so a fixed seed fixes the arena's gear and the player's fit. Each step draws its own stream, so whatever runs // between them (populating the level) cannot shift it. private static void SeedItems(ItemManager itemManager, Galaxy galaxy, uint step) => - itemManager.Random = new Random(galaxy.Seed * 0x9E3779B1u + step); + itemManager.Random = new Random(ItemSeed(galaxy, step)); + + // The steps of a run that draw items: generating its arena, staging its scenario, and continuing a saved run. + public const uint ArenaStep = 1; + public const uint StageStep = 2; + public const uint ContinueStep = 3; + + // The item seed of an ActionGameManager scene opened with no galaxy. No run is loaded, so nothing a run decides + // draws from it. + public const uint NoRunSeed = 0x5EED0001u; + + // The item stream's seed for one step of a run: the run's seed and the step. Never zero, which no stream accepts. + public static uint ItemSeed(Galaxy galaxy, uint step) + { + var seed = galaxy.Seed * 0x9E3779B1u + step; + if (seed == 0) throw new InvalidOperationException($"Item seed is zero for galaxy seed {galaxy.Seed} at step {step}."); + return seed; + } } diff --git a/Assets/Scripts/ServerShared/SimulationDice.cs b/Assets/Scripts/ServerShared/SimulationDice.cs index ce646d670..adc440299 100644 --- a/Assets/Scripts/ServerShared/SimulationDice.cs +++ b/Assets/Scripts/ServerShared/SimulationDice.cs @@ -14,6 +14,10 @@ public static class SimulationDice // never rides ItemManager.Random, so minting items cannot change how a pilot fights. public const uint CombatSampleStream = 3; public const uint PatrolStream = 4; + // Generation placement of an unpacked ship packed at the origin, ordinal its 1-based index in the pack, on the zone's + // seed (CombatSeed): the run seed feeds drops and mines only (ruling run-seed-drops-only), and placement never rides + // ItemManager.Random. + public const uint ScatterStream = 5; // 9.1 (docs/fire-control-cut.md): fmix32, MurmurHash3's 32-bit finalizer -- xor-shift, multiply, // xor-shift, multiply, xor-shift. Diffuses a structured seed (a zone seed XORed with a small, diff --git a/Assets/Scripts/ServerShared/Zone.cs b/Assets/Scripts/ServerShared/Zone.cs index a69fb9d41..ea46f4c6f 100644 --- a/Assets/Scripts/ServerShared/Zone.cs +++ b/Assets/Scripts/ServerShared/Zone.cs @@ -266,11 +266,18 @@ public class Zone foreach (var wear in pack.ChunkWear) _wear[new ChunkId(wear.Field, wear.Index)] = new ChunkWear { Damage = wear.Damage, BrokenUntil = wear.BrokenUntil }; + var ordinal = 0u; foreach (var entityPack in pack.Entities) { + ordinal++; var entity = EntitySerializer.Unpack(_itemManager, this, entityPack); var ship = entity as Ship; Admit(entity, piloted: ship is {IsPlayerShip: false}); + // Generation placement, not admission: a ship packed at the origin is scattered across the zone, on the + // zone's own dice, so nothing drawn from the item stream can move it. + if (ship is {IsPlayerShip: false} && lengthsq(ship.Position) < 1) + ship.Position = SimulationDice.For(CombatSeed, SimulationDice.ScatterStream, ordinal) + .NextFloat3(float3(-pack.Radius * .5f), float3(pack.Radius * .5f)); } // TODO: Associate planets with stored entities for planetary colonies diff --git a/tests/Aetheria.Shared.Tests/AssemblyFromMakerTests.cs b/tests/Aetheria.Shared.Tests/AssemblyFromMakerTests.cs index 301a67cd1..66bcafbe3 100644 --- a/tests/Aetheria.Shared.Tests/AssemblyFromMakerTests.cs +++ b/tests/Aetheria.Shared.Tests/AssemblyFromMakerTests.cs @@ -25,7 +25,7 @@ public sealed class AssemblyFromMakerTests : IDisposable Roles = { new ItemRole { Name = "lens" } } }); _lens = new CultRecordRef(gear.Key); - _items = new ItemManager(_cache, new ProvenanceLedger(), RunSaveTests.TestSettings(), _ => { }); + _items = new ItemManager(1u, _cache, new ProvenanceLedger(), RunSaveTests.TestSettings(), _ => { }); } public void Dispose() diff --git a/tests/Aetheria.Shared.Tests/BrownoutTests.cs b/tests/Aetheria.Shared.Tests/BrownoutTests.cs index b48657e33..95958170c 100644 --- a/tests/Aetheria.Shared.Tests/BrownoutTests.cs +++ b/tests/Aetheria.Shared.Tests/BrownoutTests.cs @@ -101,7 +101,7 @@ public sealed class BrownoutTests : IDisposable private Ship BuildShip(CultCache cache, float reactorCharge) { - var items = new ItemManager(cache, new ProvenanceLedger(), Settings(), _ => { }); + var items = new ItemManager(1u, cache, new ProvenanceLedger(), Settings(), _ => { }); var zone = new Zone(items, new PlanetSettings(), new ZonePack(), new GalaxyZone { Name = "Test Zone", Owner = null }, null); var hull = Mint(cache, items, cache.GetByName("Skiff")); var ship = new Ship(items, zone, hull, new EntitySettings()); diff --git a/tests/Aetheria.Shared.Tests/ChildrenWithinReachTests.cs b/tests/Aetheria.Shared.Tests/ChildrenWithinReachTests.cs index 22646a129..40b2f591b 100644 --- a/tests/Aetheria.Shared.Tests/ChildrenWithinReachTests.cs +++ b/tests/Aetheria.Shared.Tests/ChildrenWithinReachTests.cs @@ -42,7 +42,7 @@ public sealed class ChildrenWithinReachTests : IClassFixture("TutorialBackgroundSettings"), cache, new PlayerSettings(), Directory.CreateDirectory(Path.Combine(dir, "Narrative")), _ => { }, null, seed); - var items = new ItemManager(cache, new ProvenanceLedger(), authored.Read("GameplaySettings"), _ => { }); + var items = new ItemManager(1u, cache, new ProvenanceLedger(), authored.Read("GameplaySettings"), _ => { }); var planetSettings = authored.Read("PlanetSettings"); foreach (var galaxyZone in galaxy.Zones) { diff --git a/tests/Aetheria.Shared.Tests/ConditionRatioTests.cs b/tests/Aetheria.Shared.Tests/ConditionRatioTests.cs index c3a5ddcde..00d446384 100644 --- a/tests/Aetheria.Shared.Tests/ConditionRatioTests.cs +++ b/tests/Aetheria.Shared.Tests/ConditionRatioTests.cs @@ -84,7 +84,7 @@ public sealed class ConditionRatioTests : IDisposable private Ship BuildShip(CultCache cache, float reactorCharge, out EquippedItem consumer) { - var items = new ItemManager(cache, new ProvenanceLedger(), Settings(), _ => { }); + var items = new ItemManager(1u, cache, new ProvenanceLedger(), Settings(), _ => { }); var zone = new Zone(items, new PlanetSettings(), new ZonePack(), new GalaxyZone { Name = "Test Zone", Owner = null }, null); var hull = Mint(cache, items, cache.GetByName("Skiff")); var ship = new Ship(items, zone, hull, new EntitySettings()); diff --git a/tests/Aetheria.Shared.Tests/ConsumableAiTests.cs b/tests/Aetheria.Shared.Tests/ConsumableAiTests.cs index 7478b3440..1b6d95cc9 100644 --- a/tests/Aetheria.Shared.Tests/ConsumableAiTests.cs +++ b/tests/Aetheria.Shared.Tests/ConsumableAiTests.cs @@ -147,7 +147,7 @@ public sealed class ConsumableAiTests : IDisposable settings.UnaidedPrecision = 2f; settings.AgentMinHitProbability = 0f; settings.BeamResolveInterval = .1f; - var items = new ItemManager(cache, new ProvenanceLedger(), settings, _ => { }); + var items = new ItemManager(1u, cache, new ProvenanceLedger(), settings, _ => { }); var maker = cache.RefOf(cache.GetByName("Maker")); EquippableItem Make(string name) where T : CraftedItemData => (EquippableItem) items.CreateInstance(items.CreateLot(cache.GetByName(name), maker, .5f)); diff --git a/tests/Aetheria.Shared.Tests/ConsumableSupplyTests.cs b/tests/Aetheria.Shared.Tests/ConsumableSupplyTests.cs index ea8fbab1e..b02dffea7 100644 --- a/tests/Aetheria.Shared.Tests/ConsumableSupplyTests.cs +++ b/tests/Aetheria.Shared.Tests/ConsumableSupplyTests.cs @@ -153,7 +153,7 @@ public sealed class ConsumableSupplyTests : IDisposable return new Rig { Cache = cache, - Items = new ItemManager(cache, new ProvenanceLedger(), RunSaveTests.TestSettings(), _ => { }), + Items = new ItemManager(1u, cache, new ProvenanceLedger(), RunSaveTests.TestSettings(), _ => { }), Galaxy = new Galaxy(cache, cache.GetGlobal(), _ => { }), Maker = cache.GetByName("Maker") }; @@ -281,7 +281,7 @@ public sealed class ConsumableSupplyTests : IDisposable var authored = AuthoredSettings.Load(repo); var galaxy = new Galaxy(authored.Read("TutorialGenerationSettings"), authored.Read("TutorialBackgroundSettings"), cache, new PlayerSettings(), Directory.CreateDirectory(Path.Combine(_root, "Narrative")), _ => { }, null, 1); - var items = new ItemManager(cache, new ProvenanceLedger(), authored.Read("GameplaySettings"), _ => { }); + var items = new ItemManager(1u, cache, new ProvenanceLedger(), authored.Read("GameplaySettings"), _ => { }); int stations = 0, ships = 0; for (var i = 0; i < Seeds; i++) diff --git a/tests/Aetheria.Shared.Tests/ConsumableTests.cs b/tests/Aetheria.Shared.Tests/ConsumableTests.cs index 7ba593e6e..bc1c5588b 100644 --- a/tests/Aetheria.Shared.Tests/ConsumableTests.cs +++ b/tests/Aetheria.Shared.Tests/ConsumableTests.cs @@ -76,7 +76,7 @@ public sealed class ConsumableTests : IDisposable var otherRef = cache.Upsert(new ConsumableItemData { Name = "Flare", Duration = 1f, Shape = new Shape() }); cache.FlushAsync().Wait(); - var items = new ItemManager(cache, new ProvenanceLedger(), RunSaveTests.TestSettings(), _ => { }); + var items = new ItemManager(1u, cache, new ProvenanceLedger(), RunSaveTests.TestSettings(), _ => { }); var makerRef = cache.RefOf(cache.GetByName("Maker")); EquippableItem Gear(EquippableItemData design) => (EquippableItem) items.CreateInstance(items.CreateLot(design, makerRef, .5f)); var zone = new Zone(items, new PlanetSettings(), new ZonePack(), new GalaxyZone { Name = "Test Zone", Owner = null }, null); diff --git a/tests/Aetheria.Shared.Tests/ContactRevealTests.cs b/tests/Aetheria.Shared.Tests/ContactRevealTests.cs index bdca8008f..d23f166ea 100644 --- a/tests/Aetheria.Shared.Tests/ContactRevealTests.cs +++ b/tests/Aetheria.Shared.Tests/ContactRevealTests.cs @@ -173,7 +173,7 @@ public sealed partial class ContactRevealTests : IDisposable _openCache = AetheriaStores.Open(Catalog, catalogWritable: true); var ledger = new ProvenanceLedger(); for (var lot = 1000; lot < 2000; lot++) ledger.Lots[lot] = new Lot { Origin = new Attributed(), Quality = 1 }; - _items = new ItemManager(_openCache, ledger, new GameplaySettings + _items = new ItemManager(1u, _openCache, ledger, new GameplaySettings { DefaultEntitySettings = new EntitySettings(), Tiers = new[] { new RarityTier { Name = "Common", Quality = .5f, Color = new float3(1, 1, 1) } }, diff --git a/tests/Aetheria.Shared.Tests/ContactTests.cs b/tests/Aetheria.Shared.Tests/ContactTests.cs index adceb4b98..bee97650c 100644 --- a/tests/Aetheria.Shared.Tests/ContactTests.cs +++ b/tests/Aetheria.Shared.Tests/ContactTests.cs @@ -116,7 +116,7 @@ public sealed class ContactTests : IDisposable var ledger = new ProvenanceLedger(); for (var i = 1; i <= 50; i++) ledger.Lots[i] = new Lot { Origin = new Attributed(), Quality = 1 }; - var items = new ItemManager(cache, ledger, settings, _ => { }); + var items = new ItemManager(1u, cache, ledger, settings, _ => { }); var zone = new Zone(items, new PlanetSettings(), new ZonePack { Radius = 0 }, new GalaxyZone { Name = "Contacts", Owner = null }, null); var lab = new Lab { Items = items, Zone = zone }; zone.Contacted.Subscribe(lab.Events.Add); diff --git a/tests/Aetheria.Shared.Tests/DrawnPoseTests.cs b/tests/Aetheria.Shared.Tests/DrawnPoseTests.cs index ca0f6f00d..d288cce27 100644 --- a/tests/Aetheria.Shared.Tests/DrawnPoseTests.cs +++ b/tests/Aetheria.Shared.Tests/DrawnPoseTests.cs @@ -84,7 +84,7 @@ public sealed class DrawnPoseTests : IDisposable _caches.Add(cache); var ledger = new ProvenanceLedger(); ledger.Lots[1] = new Lot { Origin = new Attributed(), Quality = 1 }; - var items = new ItemManager(cache, ledger, new GameplaySettings + var items = new ItemManager(1u, cache, ledger, new GameplaySettings { DefaultEntitySettings = new EntitySettings(), Tiers = new[] { new RarityTier { Name = "Common", Quality = .5f, Color = new float3(1, 1, 1) } }, diff --git a/tests/Aetheria.Shared.Tests/FireAuthorityTests.cs b/tests/Aetheria.Shared.Tests/FireAuthorityTests.cs index 8323bd7a9..3a5108656 100644 --- a/tests/Aetheria.Shared.Tests/FireAuthorityTests.cs +++ b/tests/Aetheria.Shared.Tests/FireAuthorityTests.cs @@ -141,7 +141,7 @@ public sealed class FireAuthorityTests : IDisposable ledger.Lots[3] = new Lot { Origin = new Attributed(), Quality = 1 }; ledger.Lots[4] = new Lot { Origin = new Attributed(), Quality = 1 }; for (var i = 5; i <= 12; i++) ledger.Lots[i] = new Lot { Origin = new Attributed(), Quality = 1 }; - var items = new ItemManager(cache, ledger, settings, _ => { }); + var items = new ItemManager(1u, cache, ledger, settings, _ => { }); var zone = new Zone(items, new PlanetSettings(), new ZonePack(), new GalaxyZone { Name = "Test", Owner = null }, null); var hullRef = items.ItemData.RefOf(items.ItemData.GetByName("Hull")); diff --git a/tests/Aetheria.Shared.Tests/FireControlCut11Tests.cs b/tests/Aetheria.Shared.Tests/FireControlCut11Tests.cs index 9e9d74fce..3b4989662 100644 --- a/tests/Aetheria.Shared.Tests/FireControlCut11Tests.cs +++ b/tests/Aetheria.Shared.Tests/FireControlCut11Tests.cs @@ -134,7 +134,7 @@ public sealed class FireControlCut11Tests : IDisposable var ledger = new ProvenanceLedger(); for (var i = 1; i <= 16; i++) ledger.Lots[i] = new Lot { Origin = new Attributed(), Quality = 1 }; - var items = new ItemManager(cache, ledger, TestSettings(schematicCellSize), _ => { }); + var items = new ItemManager(1u, cache, ledger, TestSettings(schematicCellSize), _ => { }); var zone = new Zone(items, new PlanetSettings(), new ZonePack(), new GalaxyZone { Name = zoneName, Owner = null }, null); EquippableItem Make(string name, int lot, float durability) => new EquippableItem diff --git a/tests/Aetheria.Shared.Tests/FireControlCut123Tests.cs b/tests/Aetheria.Shared.Tests/FireControlCut123Tests.cs index 7e49ec3c7..7d949c01c 100644 --- a/tests/Aetheria.Shared.Tests/FireControlCut123Tests.cs +++ b/tests/Aetheria.Shared.Tests/FireControlCut123Tests.cs @@ -160,7 +160,7 @@ public sealed class FireControlCut123Tests : IDisposable var ledger = new ProvenanceLedger(); for (var i = 1; i <= 500; i++) ledger.Lots[i] = new Lot { Origin = new Attributed(), Quality = 1 }; // F8: shared-item fixtures equip more items (bars) than earlier cuts needed - var items = new ItemManager(cache, ledger, settings, _ => { }); + var items = new ItemManager(1u, cache, ledger, settings, _ => { }); var zone = new Zone(items, new PlanetSettings(), new ZonePack(), new GalaxyZone { Name = "Cut123", Owner = null }, null); EquippableItem Make(string name, int lot, float durability) => new EquippableItem diff --git a/tests/Aetheria.Shared.Tests/FireControlCut124Tests.cs b/tests/Aetheria.Shared.Tests/FireControlCut124Tests.cs index e6d8f5a0e..471c1d790 100644 --- a/tests/Aetheria.Shared.Tests/FireControlCut124Tests.cs +++ b/tests/Aetheria.Shared.Tests/FireControlCut124Tests.cs @@ -188,7 +188,7 @@ public sealed partial class FireControlCut124Tests : IDisposable var ledger = new ProvenanceLedger(); for (var i = 1; i <= 500; i++) ledger.Lots[i] = new Lot { Origin = new Attributed(), Quality = 1 }; - var items = new ItemManager(cache, ledger, settings, _ => { }); + var items = new ItemManager(1u, cache, ledger, settings, _ => { }); var zone = new Zone(items, new PlanetSettings(), new ZonePack(), new GalaxyZone { Name = "Cut124", Owner = null }, null); EquippableItem Make(string name, int lot, float durability) => new EquippableItem diff --git a/tests/Aetheria.Shared.Tests/FireControlCut12Tests.cs b/tests/Aetheria.Shared.Tests/FireControlCut12Tests.cs index adbcac5ef..e3c7bf64d 100644 --- a/tests/Aetheria.Shared.Tests/FireControlCut12Tests.cs +++ b/tests/Aetheria.Shared.Tests/FireControlCut12Tests.cs @@ -192,7 +192,7 @@ public sealed class FireControlCut12Tests : IDisposable var ledger = new ProvenanceLedger(); for (var i = 1; i <= 24; i++) ledger.Lots[i] = new Lot { Origin = new Attributed(), Quality = 1 }; - var items = new ItemManager(cache, ledger, settings, _ => { }); + var items = new ItemManager(1u, cache, ledger, settings, _ => { }); // Cut 6.1's own convention (FireControlCut6Tests.cs:62-63): every engagement shares one fixed // GalaxyZone name, so every Zone this fixture builds shares one CombatSeed and every statistical test // is replayable byte-for-byte across runs. A GUID-suffixed name here (this file's own earlier defect) diff --git a/tests/Aetheria.Shared.Tests/FireControlCut4Tests.cs b/tests/Aetheria.Shared.Tests/FireControlCut4Tests.cs index 08ba48d0d..70187383d 100644 --- a/tests/Aetheria.Shared.Tests/FireControlCut4Tests.cs +++ b/tests/Aetheria.Shared.Tests/FireControlCut4Tests.cs @@ -124,7 +124,7 @@ public sealed class FireControlCut4Tests : IDisposable ledger.Lots[4] = new Lot { Origin = new Attributed(), Quality = 1 }; ledger.Lots[5] = new Lot { Origin = new Attributed(), Quality = 1 }; ledger.Lots[9] = new Lot { Origin = new Attributed(), Quality = 1 }; - var items = new ItemManager(cache, ledger, settings, _ => { }); + var items = new ItemManager(1u, cache, ledger, settings, _ => { }); var zone = new Zone(items, new PlanetSettings(), new ZonePack(), new GalaxyZone { Name = "Test", Owner = null }, null); var hullRef = items.ItemData.RefOf(items.ItemData.GetByName("Hull")); @@ -218,7 +218,7 @@ public sealed class FireControlCut4Tests : IDisposable cache.FlushAsync().Wait(); var ledger = new ProvenanceLedger { Lots = { [1] = new Lot { Origin = new Attributed(), Quality = 1 } } }; - var items = new ItemManager(cache, ledger, TestSettings(), _ => { }); + var items = new ItemManager(1u, cache, ledger, TestSettings(), _ => { }); var zone = new Zone(items, new PlanetSettings(), new ZonePack(), new GalaxyZone { Name = "Test", Owner = null }, null); var hullRef = items.ItemData.RefOf(items.ItemData.GetByName("Hull")); @@ -258,7 +258,7 @@ public sealed class FireControlCut4Tests : IDisposable cache.FlushAsync().Wait(); _splashLedger = new ProvenanceLedger(); - var items = new ItemManager(cache, _splashLedger, settings, _ => { }); + var items = new ItemManager(1u, cache, _splashLedger, settings, _ => { }); var zone = new Zone(items, new PlanetSettings(), new ZonePack(), new GalaxyZone { Name = "Test", Owner = null }, null); return (items, zone); } diff --git a/tests/Aetheria.Shared.Tests/FireControlCut5Tests.cs b/tests/Aetheria.Shared.Tests/FireControlCut5Tests.cs index 1727a9829..7674df424 100644 --- a/tests/Aetheria.Shared.Tests/FireControlCut5Tests.cs +++ b/tests/Aetheria.Shared.Tests/FireControlCut5Tests.cs @@ -140,7 +140,7 @@ public sealed class FireControlCut5Tests : IDisposable ledger.Lots[3] = new Lot { Origin = new Attributed(), Quality = 1 }; ledger.Lots[4] = new Lot { Origin = new Attributed(), Quality = 1 }; for (var i = 5; i <= 12; i++) ledger.Lots[i] = new Lot { Origin = new Attributed(), Quality = 1 }; - var items = new ItemManager(cache, ledger, settings, _ => { }); + var items = new ItemManager(1u, cache, ledger, settings, _ => { }); var zone = new Zone(items, new PlanetSettings(), new ZonePack(), new GalaxyZone { Name = "Test", Owner = null }, null); var hullRef = items.ItemData.RefOf(items.ItemData.GetByName("Hull")); @@ -310,7 +310,7 @@ public sealed class FireControlCut5Tests : IDisposable ledger.Lots[3] = new Lot { Origin = new Attributed(), Quality = 1 }; ledger.Lots[4] = new Lot { Origin = new Attributed(), Quality = 1 }; ledger.Lots[5] = new Lot { Origin = new Attributed(), Quality = 1 }; - var items = new ItemManager(cache, ledger, settings, _ => { }); + var items = new ItemManager(1u, cache, ledger, settings, _ => { }); var zone = new Zone(items, new PlanetSettings(), new ZonePack(), new GalaxyZone { Name = "Test", Owner = null }, null); var hullRef = items.ItemData.RefOf(items.ItemData.GetByName("Hull")); @@ -406,7 +406,7 @@ public sealed class FireControlCut5Tests : IDisposable ledger.Lots[3] = new Lot { Origin = new Attributed(), Quality = 1 }; ledger.Lots[4] = new Lot { Origin = new Attributed(), Quality = 1 }; ledger.Lots[5] = new Lot { Origin = new Attributed(), Quality = 1 }; - var items = new ItemManager(cache, ledger, settings, _ => { }); + var items = new ItemManager(1u, cache, ledger, settings, _ => { }); var zone = new Zone(items, new PlanetSettings(), new ZonePack(), new GalaxyZone { Name = "Test", Owner = null }, null); var hullRef = items.ItemData.RefOf(items.ItemData.GetByName("Hull")); @@ -549,7 +549,7 @@ public sealed class FireControlCut5Tests : IDisposable ledger.Lots[1] = new Lot { Origin = new Attributed(), Quality = 1 }; ledger.Lots[2] = new Lot { Origin = new Attributed(), Quality = 1 }; ledger.Lots[3] = new Lot { Origin = new Attributed(), Quality = 1 }; - var items = new ItemManager(cache, ledger, settings, _ => { }); + var items = new ItemManager(1u, cache, ledger, settings, _ => { }); var zone = new Zone(items, new PlanetSettings(), new ZonePack(), new GalaxyZone { Name = "Test", Owner = null }, null); var hullRef = items.ItemData.RefOf(items.ItemData.GetByName("Hull")); diff --git a/tests/Aetheria.Shared.Tests/FireControlCut6Tests.cs b/tests/Aetheria.Shared.Tests/FireControlCut6Tests.cs index 03aab2e3f..5f334bb45 100644 --- a/tests/Aetheria.Shared.Tests/FireControlCut6Tests.cs +++ b/tests/Aetheria.Shared.Tests/FireControlCut6Tests.cs @@ -126,7 +126,7 @@ public sealed class FireControlCut6Tests : IDisposable ledger.Lots[3] = new Lot { Origin = new Attributed(), Quality = 1 }; ledger.Lots[4] = new Lot { Origin = new Attributed(), Quality = 1 }; ledger.Lots[5] = new Lot { Origin = new Attributed(), Quality = 1 }; - var items = new ItemManager(cache, ledger, settings, _ => { }); + var items = new ItemManager(1u, cache, ledger, settings, _ => { }); var zone = new Zone(items, new PlanetSettings(), new ZonePack(), new GalaxyZone { Name = "Test", Owner = null }, null); var hullRef = items.ItemData.RefOf(items.ItemData.GetByName("Hull")); diff --git a/tests/Aetheria.Shared.Tests/FireControlCut6cTests.cs b/tests/Aetheria.Shared.Tests/FireControlCut6cTests.cs index 5517c4121..991af3b44 100644 --- a/tests/Aetheria.Shared.Tests/FireControlCut6cTests.cs +++ b/tests/Aetheria.Shared.Tests/FireControlCut6cTests.cs @@ -112,7 +112,7 @@ public sealed class FireControlCut6cTests : IDisposable ledger.Lots[3] = new Lot { Origin = new Attributed(), Quality = 1 }; ledger.Lots[4] = new Lot { Origin = new Attributed(), Quality = 1 }; for (var i = 5; i <= 12; i++) ledger.Lots[i] = new Lot { Origin = new Attributed(), Quality = 1 }; - var items = new ItemManager(cache, ledger, settings, _ => { }); + var items = new ItemManager(1u, cache, ledger, settings, _ => { }); var zone = new Zone(items, new PlanetSettings(), new ZonePack(), new GalaxyZone { Name = "Test", Owner = null }, null); var hullRef = items.ItemData.RefOf(items.ItemData.GetByName("Hull")); @@ -254,7 +254,7 @@ public sealed class FireControlCut6cTests : IDisposable var random = new Random(1u); var settings = TestSettings(); var log = new List(); - var items = new ItemManager(cache, new ProvenanceLedger(), settings, log.Add); + var items = new ItemManager(1u, cache, new ProvenanceLedger(), settings, log.Add); var generator = new LoadoutGenerator(ref random, items, galaxy, null, makerFaction, .5f); var pack = generator.GenerateShipLoadout(candidate => candidate.Name == "Armed"); diff --git a/tests/Aetheria.Shared.Tests/FireControlCut6dTests.cs b/tests/Aetheria.Shared.Tests/FireControlCut6dTests.cs index 9fdbde4a0..128025cea 100644 --- a/tests/Aetheria.Shared.Tests/FireControlCut6dTests.cs +++ b/tests/Aetheria.Shared.Tests/FireControlCut6dTests.cs @@ -119,7 +119,7 @@ public sealed class FireControlCut6dTests : IDisposable var ledger = new ProvenanceLedger(); for (var i = 1; i <= 20; i++) ledger.Lots[i] = new Lot { Origin = new Attributed(), Quality = 1 }; - var items = new ItemManager(cache, ledger, settings, _ => { }); + var items = new ItemManager(1u, cache, ledger, settings, _ => { }); var zone = new Zone(items, new PlanetSettings(), new ZonePack(), new GalaxyZone { Name = "Test", Owner = null }, null); // Durability high enough to survive thousands of direct 10-damage hits over a test's whole sample -- diff --git a/tests/Aetheria.Shared.Tests/FireControlCut7Tests.cs b/tests/Aetheria.Shared.Tests/FireControlCut7Tests.cs index 4e0c4a91f..0b6eed2d5 100644 --- a/tests/Aetheria.Shared.Tests/FireControlCut7Tests.cs +++ b/tests/Aetheria.Shared.Tests/FireControlCut7Tests.cs @@ -104,7 +104,7 @@ public sealed class FireControlCut7Tests : IDisposable var ledger = new ProvenanceLedger(); for (var i = 1; i <= 10; i++) ledger.Lots[i] = new Lot { Origin = new Attributed(), Quality = 1 }; - var items = new ItemManager(cache, ledger, settings, _ => { }); + var items = new ItemManager(1u, cache, ledger, settings, _ => { }); var zone = new Zone(items, new PlanetSettings(), new ZonePack(), new GalaxyZone { Name = "Test", Owner = null }, null); var hullRef = items.ItemData.RefOf(items.ItemData.GetByName("Hull")); @@ -214,7 +214,7 @@ public sealed class FireControlCut7Tests : IDisposable QualityPriceModifier = new ExponentialLerp() }; var log = new List(); - var items = new ItemManager(cache, new ProvenanceLedger(), settings, log.Add); + var items = new ItemManager(1u, cache, new ProvenanceLedger(), settings, log.Add); var random = new Random(1u); var generator = new LoadoutGenerator(ref random, items, null, null, null, .5f); var pack = generator.GenerateShipLoadout(candidate => candidate == hull); diff --git a/tests/Aetheria.Shared.Tests/FireControlCut8Tests.cs b/tests/Aetheria.Shared.Tests/FireControlCut8Tests.cs index a0d767ab3..df011c730 100644 --- a/tests/Aetheria.Shared.Tests/FireControlCut8Tests.cs +++ b/tests/Aetheria.Shared.Tests/FireControlCut8Tests.cs @@ -112,7 +112,7 @@ public sealed class FireControlCut8Tests : IDisposable ledger.Lots[3] = new Lot { Origin = new Attributed(), Quality = 1 }; ledger.Lots[4] = new Lot { Origin = new Attributed(), Quality = 1 }; ledger.Lots[5] = new Lot { Origin = new Attributed(), Quality = 1 }; - var items = new ItemManager(cache, ledger, settings, _ => { }); + var items = new ItemManager(1u, cache, ledger, settings, _ => { }); var zone = new Zone(items, new PlanetSettings(), new ZonePack(), new GalaxyZone { Name = "Test", Owner = null }, null); var hullRef = items.ItemData.RefOf(items.ItemData.GetByName("Hull")); @@ -195,7 +195,7 @@ public sealed class FireControlCut8Tests : IDisposable ledger.Lots[2] = new Lot { Origin = new Attributed(), Quality = 1 }; ledger.Lots[3] = new Lot { Origin = new Attributed(), Quality = 1 }; ledger.Lots[4] = new Lot { Origin = new Attributed(), Quality = 1 }; - var items = new ItemManager(cache, ledger, settings, _ => { }); + var items = new ItemManager(1u, cache, ledger, settings, _ => { }); var zone = new Zone(items, new PlanetSettings(), new ZonePack(), new GalaxyZone { Name = "Test", Owner = null }, null); var hullRef = items.ItemData.RefOf(items.ItemData.GetByName("Hull")); diff --git a/tests/Aetheria.Shared.Tests/FireControlCut9Tests.cs b/tests/Aetheria.Shared.Tests/FireControlCut9Tests.cs index 448cbb333..d881f33d6 100644 --- a/tests/Aetheria.Shared.Tests/FireControlCut9Tests.cs +++ b/tests/Aetheria.Shared.Tests/FireControlCut9Tests.cs @@ -107,7 +107,7 @@ public sealed class FireControlCut9Tests : IDisposable ledger.Lots[2] = new Lot { Origin = new Attributed(), Quality = 1 }; ledger.Lots[3] = new Lot { Origin = new Attributed(), Quality = 1 }; ledger.Lots[4] = new Lot { Origin = new Attributed(), Quality = 1 }; - var items = new ItemManager(cache, ledger, settings, _ => { }); + var items = new ItemManager(1u, cache, ledger, settings, _ => { }); var zone = new Zone(items, new PlanetSettings(), new ZonePack(), new GalaxyZone { Name = zoneName, Owner = null }, null); var hullRef = items.ItemData.RefOf(items.ItemData.GetByName("Hull")); @@ -312,7 +312,7 @@ public sealed class FireControlCut9Tests : IDisposable var ledger = new ProvenanceLedger(); for (var i = 1; i <= 5; i++) ledger.Lots[i] = new Lot { Origin = new Attributed(), Quality = 1 }; - var items = new ItemManager(cache, ledger, settings, _ => { }); + var items = new ItemManager(1u, cache, ledger, settings, _ => { }); var zone = new Zone(items, new PlanetSettings(), new ZonePack(), new GalaxyZone { Name = "Cut9-Kernel", Owner = null }, null); var hullRef = items.ItemData.RefOf(items.ItemData.GetByName("Hull")); @@ -543,7 +543,7 @@ public sealed class FireControlCut9Tests : IDisposable ledger.Lots[2] = new Lot { Origin = new Attributed(), Quality = 1 }; ledger.Lots[3] = new Lot { Origin = new Attributed(), Quality = 1 }; ledger.Lots[4] = new Lot { Origin = new Attributed(), Quality = 1 }; - var items = new ItemManager(cache, ledger, settings, _ => { }); + var items = new ItemManager(1u, cache, ledger, settings, _ => { }); var zone = new Zone(items, new PlanetSettings(), new ZonePack(), new GalaxyZone { Name = "Cut9-Lock", Owner = null }, null); var hullRef = items.ItemData.RefOf(items.ItemData.GetByName("Hull")); diff --git a/tests/Aetheria.Shared.Tests/FireControlPerWeaponTests.cs b/tests/Aetheria.Shared.Tests/FireControlPerWeaponTests.cs index bbd57c7ac..cf47b2041 100644 --- a/tests/Aetheria.Shared.Tests/FireControlPerWeaponTests.cs +++ b/tests/Aetheria.Shared.Tests/FireControlPerWeaponTests.cs @@ -193,7 +193,7 @@ public sealed class FireControlPerWeaponTests : IDisposable var ledger = new ProvenanceLedger(); for (var i = 1; i <= 100; i++) ledger.Lots[i] = new Lot { Origin = new Attributed(), Quality = 1 }; - var items = new ItemManager(cache, ledger, TestSettings(minHit, targetLayerHeight), _ => { }); + var items = new ItemManager(1u, cache, ledger, TestSettings(minHit, targetLayerHeight), _ => { }); var zone = new Zone(items, new PlanetSettings(), new ZonePack(), new GalaxyZone { Name = "PerWeapon", Owner = null }, null); EquippableItem Make(string name, int lot, float durability) => new EquippableItem diff --git a/tests/Aetheria.Shared.Tests/FireControlTests.cs b/tests/Aetheria.Shared.Tests/FireControlTests.cs index b10a2fa18..54378691b 100644 --- a/tests/Aetheria.Shared.Tests/FireControlTests.cs +++ b/tests/Aetheria.Shared.Tests/FireControlTests.cs @@ -88,7 +88,7 @@ public sealed class FireControlTests : IDisposable ledger.Lots[1] = new Lot { Origin = new Attributed(), Quality = 1 }; // hull for (var i = 0; i < hardpoints.Length; i++) ledger.Lots[2 + i] = new Lot { Origin = new Attributed(), Quality = 1 }; // guns - var items = new ItemManager(_openCache, ledger, TestSettings(shipLayerHeight), _ => { }); + var items = new ItemManager(1u, _openCache, ledger, TestSettings(shipLayerHeight), _ => { }); var planets = new PlanetSettings(); var pack = new ZonePack(); if (well) WellFixture.Dig(_openCache, pack, planets, 300f, 100f); diff --git a/tests/Aetheria.Shared.Tests/FloatingBodyTests.cs b/tests/Aetheria.Shared.Tests/FloatingBodyTests.cs index ac4f763c1..8769c4c85 100644 --- a/tests/Aetheria.Shared.Tests/FloatingBodyTests.cs +++ b/tests/Aetheria.Shared.Tests/FloatingBodyTests.cs @@ -40,7 +40,7 @@ public sealed class FloatingBodyTests : IDisposable Tiers = new[] { new RarityTier { Name = "Common", Quality = .5f, Color = new float3(1, 1, 1) } }, QualityPriceModifier = new ExponentialLerp() }; - var items = new ItemManager(cache, new ProvenanceLedger(), settings, _ => { }); + var items = new ItemManager(1u, cache, new ProvenanceLedger(), settings, _ => { }); return new Zone(items, new PlanetSettings(), new ZonePack(), new GalaxyZone { Name = "Test Zone", Owner = null }, null); } @@ -206,7 +206,7 @@ public sealed class FloatingBodyTests : IDisposable Tiers = new[] { new RarityTier { Name = "Common", Quality = .5f, Color = new float3(1, 1, 1) } }, QualityPriceModifier = new ExponentialLerp() }; - var items = new ItemManager(cache, new ProvenanceLedger(), settings, _ => { }); + var items = new ItemManager(1u, cache, new ProvenanceLedger(), settings, _ => { }); var planets = new PlanetSettings { HeightExaggeration = 10f }; // the tilt reads force / K, which K = 1 would hide var pack = new ZonePack { Radius = 1000 }; WellFixture.Dig(cache, pack, planets, 300, 100); diff --git a/tests/Aetheria.Shared.Tests/FlowFieldTests.cs b/tests/Aetheria.Shared.Tests/FlowFieldTests.cs index fefff55f4..380ffe62c 100644 --- a/tests/Aetheria.Shared.Tests/FlowFieldTests.cs +++ b/tests/Aetheria.Shared.Tests/FlowFieldTests.cs @@ -30,7 +30,7 @@ public sealed class FlowFieldTests : IDisposable TargetDetectionInfoThreshold = .1f, TargetGearInfoThreshold = .8f, FiringArc = 170, CommitHorizon = .5f, SchematicCellSize = 2f }; - _items = new ItemManager(_cache, new ProvenanceLedger(), settings, _ => { }); + _items = new ItemManager(1u, _cache, new ProvenanceLedger(), settings, _ => { }); } public void Dispose() diff --git a/tests/Aetheria.Shared.Tests/GravitySubtractTests.cs b/tests/Aetheria.Shared.Tests/GravitySubtractTests.cs index f21d78f79..b204fb50b 100644 --- a/tests/Aetheria.Shared.Tests/GravitySubtractTests.cs +++ b/tests/Aetheria.Shared.Tests/GravitySubtractTests.cs @@ -29,7 +29,7 @@ public sealed class GravitySubtractTests : IDisposable TargetDetectionInfoThreshold = .1f, TargetGearInfoThreshold = .8f, FiringArc = 170, CommitHorizon = .5f, SchematicCellSize = 2f }; - _items = new ItemManager(_cache, new ProvenanceLedger(), settings, _ => { }); + _items = new ItemManager(1u, _cache, new ProvenanceLedger(), settings, _ => { }); } public void Dispose() diff --git a/tests/Aetheria.Shared.Tests/HeatResponseTests.cs b/tests/Aetheria.Shared.Tests/HeatResponseTests.cs index 2873fc743..b45d86fc7 100644 --- a/tests/Aetheria.Shared.Tests/HeatResponseTests.cs +++ b/tests/Aetheria.Shared.Tests/HeatResponseTests.cs @@ -233,7 +233,7 @@ public sealed class HeatResponseTests : IDisposable public void HeldInsidePlateauTakesNoThermalWear() { using var cache = OpenCatalogWithThermalGear(); - var items = new ItemManager(cache, new ProvenanceLedger(), Settings(), _ => { }); + var items = new ItemManager(1u, cache, new ProvenanceLedger(), Settings(), _ => { }); var equipped = BuildEquippedAt(cache, items, 50f, out var ship); Assert.Equal(1f, equipped.ThermalPerformance, 5); Assert.Equal(0f, equipped.Wear, 5); // no thermal term (Performance == 1) and no deltaTemp (settled) @@ -243,7 +243,7 @@ public sealed class HeatResponseTests : IDisposable public void HeldOutsidePlateauTakesThermalWear() { using var cache = OpenCatalogWithThermalGear(); - var items = new ItemManager(cache, new ProvenanceLedger(), Settings(), _ => { }); + var items = new ItemManager(1u, cache, new ProvenanceLedger(), Settings(), _ => { }); var equipped = BuildEquippedAt(cache, items, 10f, out var ship); // outside 40-60, inside 0-100 Assert.True(equipped.ThermalPerformance < 1f); Assert.True(equipped.Wear > 0f); @@ -255,7 +255,7 @@ public sealed class HeatResponseTests : IDisposable public void HeldOutsidePlateauWearReadsEffectiveQuality() { using var cache = OpenCatalogWithThermalGear(); - var items = new ItemManager(cache, new ProvenanceLedger(), Settings(), _ => { }); + var items = new ItemManager(1u, cache, new ProvenanceLedger(), Settings(), _ => { }); var thermal = cache.RefOf(cache.GetByName("Thermal")); Lot Gear(float assembly, params float[] parts) => new Lot { @@ -274,7 +274,7 @@ public sealed class HeatResponseTests : IDisposable public void AFastSwingAcrossThePlateauStillWears() { using var cache = OpenCatalogWithThermalGear(); - var items = new ItemManager(cache, new ProvenanceLedger(), Settings(), _ => { }); + var items = new ItemManager(1u, cache, new ProvenanceLedger(), Settings(), _ => { }); var equipped = BuildEquippedAt(cache, items, 10f, out var ship); // Jump straight into the plateau in one tick: ThermalPerformance ends at 1 (no thermal term), but the // swing itself (deltaTemp) still costs. diff --git a/tests/Aetheria.Shared.Tests/IffAndCombatTests.cs b/tests/Aetheria.Shared.Tests/IffAndCombatTests.cs index b2bd752ba..c9e184fa8 100644 --- a/tests/Aetheria.Shared.Tests/IffAndCombatTests.cs +++ b/tests/Aetheria.Shared.Tests/IffAndCombatTests.cs @@ -55,7 +55,7 @@ public sealed class IffAndCombatTests : IDisposable var ledger = new ProvenanceLedger(); ledger.Lots[1] = new Lot { Origin = new Attributed(), Quality = 1 }; // hull ledger.Lots[2] = new Lot { Origin = new Attributed(), Quality = 1 }; // gun - var items = new ItemManager(_openCache, ledger, TestSettings(), _ => { }); + var items = new ItemManager(1u, _openCache, ledger, TestSettings(), _ => { }); var zone = new Zone(items, new PlanetSettings(), new ZonePack(), new GalaxyZone { Name = "Test Zone", Owner = null }, null); return (items, zone); } diff --git a/tests/Aetheria.Shared.Tests/InputCapacitorTests.cs b/tests/Aetheria.Shared.Tests/InputCapacitorTests.cs index d7279380e..d5a508af9 100644 --- a/tests/Aetheria.Shared.Tests/InputCapacitorTests.cs +++ b/tests/Aetheria.Shared.Tests/InputCapacitorTests.cs @@ -87,7 +87,7 @@ public sealed class InputCapacitorTests : IDisposable private (Ship ship, ItemManager items, Reactor reactor) BuildShip(CultCache cache, float reactorCharge) { var ledger = new ProvenanceLedger(); - var items = new ItemManager(cache, ledger, Settings(), _ => { }); + var items = new ItemManager(1u, cache, ledger, Settings(), _ => { }); var zone = new Zone(items, new PlanetSettings(), new ZonePack(), new GalaxyZone { Name = "Test Zone", Owner = null }, null); var hull = Mint(cache, items, cache.GetByName("Skiff")); var ship = new Ship(items, zone, hull, new EntitySettings()); diff --git a/tests/Aetheria.Shared.Tests/InspectionScannerCatalogTests.cs b/tests/Aetheria.Shared.Tests/InspectionScannerCatalogTests.cs index e9cb862fa..7d6a8ae6e 100644 --- a/tests/Aetheria.Shared.Tests/InspectionScannerCatalogTests.cs +++ b/tests/Aetheria.Shared.Tests/InspectionScannerCatalogTests.cs @@ -105,7 +105,7 @@ public sealed class InspectionScannerCatalogTests Origin = new Attributed(), Quality = 1f, Roles = product.Roles.Select(r => new RoleFill { Role = r.Role, Quality = r.Mean }).ToList(), }; - var items = new ItemManager(Cache, ledger, settings, _ => { }); + var items = new ItemManager(1u, Cache, ledger, settings, _ => { }); var zone = new Zone(items, new PlanetSettings(), new ZonePack(), new GalaxyZone { Name = "Test", Owner = null }, null); var ship = new Ship(items, zone, new EquippableItem { Data = Cache.RefOf(hull), Durability = hull.Durability, Lot = 1 }, settings.DefaultEntitySettings); Assert.True(ship.TryEquip(new EquippableItem { Data = Cache.RefOf(design), Durability = design.Durability, Lot = 2 })); diff --git a/tests/Aetheria.Shared.Tests/ItemTitleTests.cs b/tests/Aetheria.Shared.Tests/ItemTitleTests.cs index c96b0f6d9..b2b083b41 100644 --- a/tests/Aetheria.Shared.Tests/ItemTitleTests.cs +++ b/tests/Aetheria.Shared.Tests/ItemTitleTests.cs @@ -42,7 +42,7 @@ public sealed class ItemTitleTests : IDisposable _cache.Commit(batch => batch.Upsert(typeof(FactionProductData), _clapBack, new CultRecordKey("array-lucent"))); _spiceBlend = new FactionProductData { Name = "Finch Reserve", Design = _cache.RefOf(_spice), Manufacturer = finch }; _cache.Commit(batch => batch.Upsert(typeof(FactionProductData), _spiceBlend, new CultRecordKey("spice-finch"))); - _items = new ItemManager(_cache, new ProvenanceLedger(), RunSaveTests.TestSettings(), _ => { }); + _items = new ItemManager(1u, _cache, new ProvenanceLedger(), RunSaveTests.TestSettings(), _ => { }); } public void Dispose() diff --git a/tests/Aetheria.Shared.Tests/LaunchSuppressorCatalogTests.cs b/tests/Aetheria.Shared.Tests/LaunchSuppressorCatalogTests.cs index d54f7c983..7a1481670 100644 --- a/tests/Aetheria.Shared.Tests/LaunchSuppressorCatalogTests.cs +++ b/tests/Aetheria.Shared.Tests/LaunchSuppressorCatalogTests.cs @@ -116,7 +116,7 @@ public sealed class LaunchSuppressorCatalogTests Origin = new Attributed(), Quality = 1f, Roles = new List { new RoleFill { Role = "baffles", Quality = baffles }, new RoleFill { Role = "heat sink", Quality = sink } }, }; - var items = new ItemManager(Cache, ledger, settings, _ => { }); + var items = new ItemManager(1u, Cache, ledger, settings, _ => { }); var zone = new Zone(items, new PlanetSettings(), new ZonePack(), new GalaxyZone { Name = "Test", Owner = null }, null); Ship = new Ship(items, zone, new EquippableItem { Data = Cache.RefOf(hull), Durability = hull.Durability, Lot = 1 }, settings.DefaultEntitySettings); diff --git a/tests/Aetheria.Shared.Tests/LayerTests.cs b/tests/Aetheria.Shared.Tests/LayerTests.cs index 75d79d947..acd38555a 100644 --- a/tests/Aetheria.Shared.Tests/LayerTests.cs +++ b/tests/Aetheria.Shared.Tests/LayerTests.cs @@ -58,7 +58,7 @@ public sealed class LayerTests : IDisposable _cache.FlushAsync().Wait(); var ledger = new ProvenanceLedger(); for (var i = 1; i <= 3; i++) ledger.Lots[i] = new Lot { Origin = new Attributed(), Quality = 1 }; - var items = new ItemManager(_cache, ledger, Settings(), _ => { }); + var items = new ItemManager(1u, _cache, ledger, Settings(), _ => { }); var zone = new Zone(items, new PlanetSettings(), new ZonePack { Radius = 0 }, new GalaxyZone { Name = "Layers", Owner = null }, null); return (items, zone); } diff --git a/tests/Aetheria.Shared.Tests/LoadoutTests.cs b/tests/Aetheria.Shared.Tests/LoadoutTests.cs index 86bf4e698..7d3316302 100644 --- a/tests/Aetheria.Shared.Tests/LoadoutTests.cs +++ b/tests/Aetheria.Shared.Tests/LoadoutTests.cs @@ -90,7 +90,7 @@ public sealed class LoadoutTests : IDisposable Loadout hand; using (var cache = Open()) { - var items = new ItemManager(cache, new ProvenanceLedger(), RunSaveTests.TestSettings(), _ => { }); + var items = new ItemManager(1u, cache, new ProvenanceLedger(), RunSaveTests.TestSettings(), _ => { }); hand = Armed(cache); var failures = new List(); var ship = Build(items, hand, failures); @@ -108,7 +108,7 @@ public sealed class LoadoutTests : IDisposable Assert.Equal(hand.Slots.Select(Describe), loaded.Slots.Select(Describe)); Assert.Equal(hand.WeaponGroups, loaded.WeaponGroups); - var items = new ItemManager(cache, new ProvenanceLedger(), RunSaveTests.TestSettings(), _ => { }); + var items = new ItemManager(1u, cache, new ProvenanceLedger(), RunSaveTests.TestSettings(), _ => { }); var failures = new List(); var ship = Build(items, loaded, failures); Assert.Empty(failures); @@ -279,7 +279,7 @@ public sealed class LoadoutTests : IDisposable using (var cache = Open()) { - var items = new ItemManager(cache, new ProvenanceLedger(), RunSaveTests.TestSettings(), _ => { }); + var items = new ItemManager(1u, cache, new ProvenanceLedger(), RunSaveTests.TestSettings(), _ => { }); var missing = HandBuilt(cache); missing.Slots[1].Design = new CultRecordRef(new CultRecordKey("absent-design")); var failures = new List(); @@ -304,7 +304,7 @@ public sealed class LoadoutTests : IDisposable public void FailedFitBuildsNothing() { using var cache = Open(); - var items = new ItemManager(cache, new ProvenanceLedger(), RunSaveTests.TestSettings(), _ => { }); + var items = new ItemManager(1u, cache, new ProvenanceLedger(), RunSaveTests.TestSettings(), _ => { }); var clash = HandBuilt(cache); clash.Slots[1].Position = HardpointCell; var failures = new List(); @@ -319,7 +319,7 @@ public sealed class LoadoutTests : IDisposable public void ADesignWithNoProductBuildsUnbranded() { using var cache = Open(); - var items = new ItemManager(cache, new ProvenanceLedger(), RunSaveTests.TestSettings(), _ => { }); + var items = new ItemManager(1u, cache, new ProvenanceLedger(), RunSaveTests.TestSettings(), _ => { }); var failures = new List(); var ship = Build(items, WithDesign(HandBuilt(cache), 0, cache, "Orphan"), failures); Assert.Empty(failures); @@ -335,7 +335,7 @@ public sealed class LoadoutTests : IDisposable public void ADesignWhoseOnlyProductIsUnavailableStillFails() { using var cache = Open(); - var items = new ItemManager(cache, new ProvenanceLedger(), RunSaveTests.TestSettings(), _ => { }); + var items = new ItemManager(1u, cache, new ProvenanceLedger(), RunSaveTests.TestSettings(), _ => { }); var failures = new List(); var soleProduct = SoleProduct(cache); Assert.Null(Loadouts.Materialize(items, null, HandBuilt(cache), design => design.Name == "Lamp" ? null : soleProduct(design), failures)); @@ -365,7 +365,7 @@ public sealed class LoadoutTests : IDisposable var gun = cache.GetAll().Single(p => p.Name == "Gun by Maker"); var bay = cache.GetAll().Single(p => p.Name == "Bay by Maker"); - var items = new ItemManager(cache, new ProvenanceLedger(), RunSaveTests.TestSettings(), _ => { }); + var items = new ItemManager(1u, cache, new ProvenanceLedger(), RunSaveTests.TestSettings(), _ => { }); EquippableItem Mint(FactionProductData product) => (EquippableItem) items.CreateInstance(product); @@ -477,7 +477,7 @@ public sealed class LoadoutTests : IDisposable cache.FlushAsync().Wait(); var random = new Random(1); - var items = new ItemManager(cache, new ProvenanceLedger(), RunSaveTests.TestSettings(), _ => { }); + var items = new ItemManager(1u, cache, new ProvenanceLedger(), RunSaveTests.TestSettings(), _ => { }); var generator = new LoadoutGenerator(ref random, items, null, null, null, .5f); var pack = generator.GenerateShipLoadout(candidate => candidate.Name == "Twin"); Assert.NotNull(pack); @@ -504,7 +504,7 @@ public sealed class LoadoutTests : IDisposable new RarityTier { Name = "Common", Quality = .2f, Color = new float3(1, 1, 1) }, new RarityTier { Name = "Rare", Quality = .5f, Color = new float3(1, 1, 1) } }; - var items = new ItemManager(cache, new ProvenanceLedger(), settings, _ => { }); + var items = new ItemManager(1u, cache, new ProvenanceLedger(), settings, _ => { }); var lamp = cache.GetByName("Lamp"); var commonLot = items.Lots.Add(new Lot { @@ -561,7 +561,7 @@ public sealed class LoadoutTests : IDisposable using var cache = Open(); var settings = AuthoredSettings.Load(RunStartTests.FindRepoRoot()).Read("GameplaySettings"); var tiers = settings.Tiers; - var items = new ItemManager(cache, new ProvenanceLedger(), settings, _ => { }); + var items = new ItemManager(1u, cache, new ProvenanceLedger(), settings, _ => { }); var lamp = cache.GetByName("Lamp"); var assembly = tiers[3].Quality; var sleeperLot = items.Lots.Add(new Lot @@ -598,7 +598,7 @@ public sealed class LoadoutTests : IDisposable var settings = AuthoredSettings.Load(RunStartTests.FindRepoRoot()).Read("GameplaySettings"); var tiers = settings.Tiers; Assert.True(tiers.Length > index, "premise: the shipped asset carries this tier"); - var items = new ItemManager(cache, new ProvenanceLedger(), settings, _ => { }); + var items = new ItemManager(1u, cache, new ProvenanceLedger(), settings, _ => { }); var lamp = cache.GetByName("Lamp"); string TierAt(float quality) => items.GetTier((CraftedItemInstance) items.CreateInstance(items.Lots.Add( new Lot { Design = cache.RefOf(lamp), Origin = new Attributed(), Quality = quality, Roles = new List() }))).tier.Name; @@ -614,7 +614,7 @@ public sealed class LoadoutTests : IDisposable { using var cache = Open(); var settings = AuthoredSettings.Load(RunStartTests.FindRepoRoot()).Read("GameplaySettings"); - var items = new ItemManager(cache, new ProvenanceLedger(), settings, _ => { }); + var items = new ItemManager(1u, cache, new ProvenanceLedger(), settings, _ => { }); var lamp = cache.GetByName("Lamp"); // Two steps and a hair above the lowest threshold: the step width decides whether that counts as two or one. var quality = settings.Tiers[0].Quality + 2 * .0499f + .0001f; @@ -640,7 +640,7 @@ public sealed class LoadoutTests : IDisposable public void QualityTermAppliesItsOwnExponent() { using var cache = Open(); - var items = new ItemManager(cache, new ProvenanceLedger(), RunSaveTests.TestSettings(), _ => { }); + var items = new ItemManager(1u, cache, new ProvenanceLedger(), RunSaveTests.TestSettings(), _ => { }); var lamp = cache.GetByName("Lamp"); var lotId = items.Lots.Add(new Lot { Design = cache.RefOf(lamp), Origin = new Attributed(), Quality = .5f, Roles = new List() }); var instance = (EquippableItem) items.CreateInstance(lotId); @@ -658,7 +658,7 @@ public sealed class LoadoutTests : IDisposable using var cache = Open(); var settings = RunSaveTests.TestSettings(); settings.QualityPriceModifier = new ExponentialLerp { Minimum = 0, Maximum = 1, Exponent = 1 }; - var items = new ItemManager(cache, new ProvenanceLedger(), settings, _ => { }); + var items = new ItemManager(1u, cache, new ProvenanceLedger(), settings, _ => { }); var lamp = cache.GetByName("Lamp"); var lowLot = items.Lots.Add(new Lot { Design = cache.RefOf(lamp), Origin = new Attributed(), Quality = .2f, Roles = new List() }); var highLot = items.Lots.Add(new Lot { Design = cache.RefOf(lamp), Origin = new Attributed(), Quality = .8f, Roles = new List() }); @@ -695,7 +695,7 @@ public sealed class LoadoutTests : IDisposable var durable = cache.Upsert(new GearData { Name = "Durable", Hardpoint = HardpointType.Sensors, Shape = new Shape(), Price = 10, Durability = 100 }); cache.FlushAsync().Wait(); - var items = new ItemManager(cache, new ProvenanceLedger(), RunSaveTests.TestSettings(), _ => { }); + var items = new ItemManager(1u, cache, new ProvenanceLedger(), RunSaveTests.TestSettings(), _ => { }); var lowLot = items.Lots.Add(new Lot { Design = new CultRecordRef(durable.Key), Origin = new Attributed(), Quality = .1f, Roles = new List() }); var highLot = items.Lots.Add(new Lot { Design = new CultRecordRef(durable.Key), Origin = new Attributed(), Quality = .9f, Roles = new List() }); var sleeperLot = items.Lots.Add(new Lot @@ -723,7 +723,7 @@ public sealed class LoadoutTests : IDisposable public void EquippedItemExponentsReadEffectiveQuality() { using var cache = Open(); - var items = new ItemManager(cache, new ProvenanceLedger(), RunSaveTests.TestSettings(), _ => { }); + var items = new ItemManager(1u, cache, new ProvenanceLedger(), RunSaveTests.TestSettings(), _ => { }); var maker = cache.RefOf(cache.GetByName("Maker")); var hullData = cache.GetByName("Skiff"); var lampData = cache.GetByName("Lamp"); @@ -785,7 +785,7 @@ public sealed class LoadoutTests : IDisposable cache.FlushAsync().Wait(); var thermalData = cache.Get(thermalGear); - var items = new ItemManager(cache, new ProvenanceLedger(), RunSaveTests.TestSettings(), _ => { }); + var items = new ItemManager(1u, cache, new ProvenanceLedger(), RunSaveTests.TestSettings(), _ => { }); var hullItem = (EquippableItem) items.CreateInstance(items.CreateLot(hullData, maker, .5f)); var ship = new Ship(items, null, hullItem, new EntitySettings()); var gearItem = (EquippableItem) items.CreateInstance(items.CreateLot(thermalData, maker, .5f)); @@ -831,7 +831,7 @@ public sealed class LoadoutTests : IDisposable cache.FlushAsync().Wait(); var thermalData = cache.Get(thermalGear); - var items = new ItemManager(cache, new ProvenanceLedger(), RunSaveTests.TestSettings(), _ => { }); + var items = new ItemManager(1u, cache, new ProvenanceLedger(), RunSaveTests.TestSettings(), _ => { }); var hullItem = (EquippableItem) items.CreateInstance(items.CreateLot(hullData, maker, .5f)); var ship = new Ship(items, null, hullItem, new EntitySettings()); var gearItem = (EquippableItem) items.CreateInstance(items.CreateLot(thermalData, maker, .5f)); @@ -867,7 +867,7 @@ public sealed class LoadoutTests : IDisposable cache.FlushAsync().Wait(); var consumableData = cache.Get(consumableRef); - var items = new ItemManager(cache, new ProvenanceLedger(), RunSaveTests.TestSettings(), _ => { }); + var items = new ItemManager(1u, cache, new ProvenanceLedger(), RunSaveTests.TestSettings(), _ => { }); var hullItem = (EquippableItem) items.CreateInstance(items.CreateLot(hullData, maker, .5f)); var ship = new Ship(items, null, hullItem, new EntitySettings()); var consumableItem = (ConsumableItem) items.CreateInstance(items.CreateLot(consumableData, maker, .5f)); @@ -902,7 +902,7 @@ public sealed class LoadoutTests : IDisposable cache.FlushAsync().Wait(); var product = cache.GetAll().Single(p => p.Name == "Lensed by Maker"); - var items = new ItemManager(cache, new ProvenanceLedger(), RunSaveTests.TestSettings(), _ => { }); + var items = new ItemManager(1u, cache, new ProvenanceLedger(), RunSaveTests.TestSettings(), _ => { }); var lotId = items.CreateLot(product); var roleFill = items.Lots[lotId].Roles.Single(r => r.Role == "lens"); Assert.Equal(.9f, roleFill.Quality, 3); @@ -914,7 +914,7 @@ public sealed class LoadoutTests : IDisposable { using var cache = Open(); var maker = cache.RefOf(cache.GetByName("Maker")); - var items = new ItemManager(cache, new ProvenanceLedger(), RunSaveTests.TestSettings(), _ => { }); + var items = new ItemManager(1u, cache, new ProvenanceLedger(), RunSaveTests.TestSettings(), _ => { }); var lamp = cache.GetByName("Lamp"); var lotId = items.Lots.Add(new Lot { @@ -932,7 +932,7 @@ public sealed class LoadoutTests : IDisposable public void OutOfRangeWeaponGroupIndexBuildsNothing() { using var cache = Open(); - var items = new ItemManager(cache, new ProvenanceLedger(), RunSaveTests.TestSettings(), _ => { }); + var items = new ItemManager(1u, cache, new ProvenanceLedger(), RunSaveTests.TestSettings(), _ => { }); var stale = Armed(cache); stale.WeaponGroups = new[] { new[] { 0, 2 }, new[] { -1 } }; var failures = new List(); @@ -944,7 +944,7 @@ public sealed class LoadoutTests : IDisposable public void UnequippingAGroupedWeaponRemovesItFromItsGroups() { using var cache = Open(); - var items = new ItemManager(cache, new ProvenanceLedger(), RunSaveTests.TestSettings(), _ => { }); + var items = new ItemManager(1u, cache, new ProvenanceLedger(), RunSaveTests.TestSettings(), _ => { }); var ship = Build(items, Armed(cache), new List()); var item = Assert.Single(ship.WeaponGroups[0].items); Assert.NotNull(item.GetBehavior()); @@ -959,7 +959,7 @@ public sealed class LoadoutTests : IDisposable public void NonWeaponGroupIndexBuildsNothing() { using var cache = Open(); - var items = new ItemManager(cache, new ProvenanceLedger(), RunSaveTests.TestSettings(), _ => { }); + var items = new ItemManager(1u, cache, new ProvenanceLedger(), RunSaveTests.TestSettings(), _ => { }); var lampGrouped = WithDesign(Armed(cache), 0, cache, "Lamp"); var failures = new List(); Assert.Null(Build(items, lampGrouped, failures)); @@ -970,7 +970,7 @@ public sealed class LoadoutTests : IDisposable public void MoreWeaponGroupsThanTheGameHasBuildsNothing() { using var cache = Open(); - var items = new ItemManager(cache, new ProvenanceLedger(), RunSaveTests.TestSettings(), _ => { }); + var items = new ItemManager(1u, cache, new ProvenanceLedger(), RunSaveTests.TestSettings(), _ => { }); var wide = Armed(cache); wide.WeaponGroups = wide.WeaponGroups.Append(new[] { 0 }).ToArray(); var failures = new List(); @@ -984,7 +984,7 @@ public sealed class LoadoutTests : IDisposable public void FewerWeaponGroupsArePaddedToTheGameCount() { using var cache = Open(); - var items = new ItemManager(cache, new ProvenanceLedger(), RunSaveTests.TestSettings(), _ => { }); + var items = new ItemManager(1u, cache, new ProvenanceLedger(), RunSaveTests.TestSettings(), _ => { }); var armed = Armed(cache); armed.WeaponGroups = new[] { new[] { 0 } }; foreach (var groups in new[] { armed.WeaponGroups, null }) @@ -1008,7 +1008,7 @@ public sealed class LoadoutTests : IDisposable public void EntitiesDoNotShareDefaultEntitySettings() { using var cache = Open(); - var items = new ItemManager(cache, new ProvenanceLedger(), RunSaveTests.TestSettings(), _ => { }); + var items = new ItemManager(1u, cache, new ProvenanceLedger(), RunSaveTests.TestSettings(), _ => { }); var maker = cache.RefOf(cache.GetByName("Maker")); var hullData = cache.GetByName("Skiff"); var template = items.GameplaySettings.DefaultEntitySettings; diff --git a/tests/Aetheria.Shared.Tests/LockWarningTests.cs b/tests/Aetheria.Shared.Tests/LockWarningTests.cs index 1ed414bb6..b21669c78 100644 --- a/tests/Aetheria.Shared.Tests/LockWarningTests.cs +++ b/tests/Aetheria.Shared.Tests/LockWarningTests.cs @@ -40,7 +40,7 @@ public sealed class LockWarningTests : IDisposable _openCache = AetheriaStores.Open(Catalog, catalogWritable: true); var ledger = new ProvenanceLedger(); for (var lot = 1; lot < 12; lot++) ledger.Lots[lot] = new Lot { Origin = new Attributed(), Quality = 1 }; - _items = new ItemManager(_openCache, ledger, new GameplaySettings + _items = new ItemManager(1u, _openCache, ledger, new GameplaySettings { DefaultEntitySettings = new EntitySettings(), Tiers = new[] { new RarityTier { Name = "Common", Quality = .5f, Color = new float3(1, 1, 1) } }, diff --git a/tests/Aetheria.Shared.Tests/LootTests.cs b/tests/Aetheria.Shared.Tests/LootTests.cs index ecd65f8e1..22ce95d5c 100644 --- a/tests/Aetheria.Shared.Tests/LootTests.cs +++ b/tests/Aetheria.Shared.Tests/LootTests.cs @@ -108,7 +108,7 @@ public sealed class LootTests : IDisposable var ledger = new ProvenanceLedger(); for (var i = 1; i <= 9000; i++) ledger.Lots[i] = new Lot { Origin = new Attributed(), Quality = 1 }; - var items = new ItemManager(cache, ledger, settings, _ => { }); + var items = new ItemManager(1u, cache, ledger, settings, _ => { }); var planets = new PlanetSettings(); var pack = new ZonePack { Radius = well ? 1000 : 0 }; if (well) WellFixture.Dig(cache, pack, planets, 300, 100); diff --git a/tests/Aetheria.Shared.Tests/MineTests.cs b/tests/Aetheria.Shared.Tests/MineTests.cs index 137d3f84a..8ddf775a7 100644 --- a/tests/Aetheria.Shared.Tests/MineTests.cs +++ b/tests/Aetheria.Shared.Tests/MineTests.cs @@ -118,7 +118,7 @@ public sealed class MineTests : IDisposable var ledger = new ProvenanceLedger(); for (var i = 1; i <= 500; i++) ledger.Lots[i] = new Lot { Origin = new Attributed(), Quality = 1 }; - var items = new ItemManager(cache, ledger, settings, _ => { }); + var items = new ItemManager(1u, cache, ledger, settings, _ => { }); var planets = new PlanetSettings(); var pack = new ZonePack { Radius = well ? 1000 : 0 }; if (well) WellFixture.Dig(cache, pack, planets, 300, 100); diff --git a/tests/Aetheria.Shared.Tests/MiningCut1Tests.cs b/tests/Aetheria.Shared.Tests/MiningCut1Tests.cs index aa00d690e..891638b9e 100644 --- a/tests/Aetheria.Shared.Tests/MiningCut1Tests.cs +++ b/tests/Aetheria.Shared.Tests/MiningCut1Tests.cs @@ -66,7 +66,7 @@ public sealed class MiningCut1Tests : IDisposable // OrbitData and BodyData (AsteroidBeltData) are run types (AetheriaStores.RunTypes), not catalog types: // they need a run store to write into. var cache = AetheriaStores.Open(catalog, run, catalogWritable: true); - var items = new ItemManager(cache, new ProvenanceLedger(), GameSettings(), _ => { }); + var items = new ItemManager(1u, cache, new ProvenanceLedger(), GameSettings(), _ => { }); var settings = Settings(); var sunOrbitKey = cache.Upsert(new OrbitData()).Key; diff --git a/tests/Aetheria.Shared.Tests/MiningCut2Tests.cs b/tests/Aetheria.Shared.Tests/MiningCut2Tests.cs index b484eed9f..54fc99972 100644 --- a/tests/Aetheria.Shared.Tests/MiningCut2Tests.cs +++ b/tests/Aetheria.Shared.Tests/MiningCut2Tests.cs @@ -69,7 +69,7 @@ public sealed class MiningCut2Tests : IDisposable var dir = Path.Combine(_root, $"cat{_fixtureCount++}"); Directory.CreateDirectory(dir); var cache = AetheriaStores.Open(Path.Combine(dir, "Aetheria.cc"), Path.Combine(dir, "run.cc"), catalogWritable: true); - var items = new ItemManager(cache, new ProvenanceLedger(), GameSettings(), _ => { }); + var items = new ItemManager(1u, cache, new ProvenanceLedger(), GameSettings(), _ => { }); var settings = Settings(); var rootOrbit = new OrbitData { Distance = 3000f, Phase = 0.05f }; diff --git a/tests/Aetheria.Shared.Tests/MiningCut3Tests.cs b/tests/Aetheria.Shared.Tests/MiningCut3Tests.cs index be506488b..3e68b4fb0 100644 --- a/tests/Aetheria.Shared.Tests/MiningCut3Tests.cs +++ b/tests/Aetheria.Shared.Tests/MiningCut3Tests.cs @@ -131,7 +131,7 @@ public sealed partial class MiningCut3Tests : IDisposable }); cache.Upsert(dimEye); foreach (var kind in kinds ?? new FieldKindData[0]) cache.Upsert(kind); - var items = new ItemManager(cache, new ProvenanceLedger(), GameSettings(), _ => { }); + var items = new ItemManager(1u, cache, new ProvenanceLedger(), GameSettings(), _ => { }); var sunOrbit = cache.Upsert(new OrbitData { FixedPosition = sunAt }).Key; var sun = cache.Upsert(new SunData { Orbit = new CultRecordRef(sunOrbit), Mass = 1f }).Key; @@ -679,7 +679,7 @@ public sealed partial class MiningCut3Tests : IDisposable // Kept: a new, overwhelmingly weighted kind now wins the pick for this belt, yet loading it again leaves it. reopened.Upsert(Kind("Overwhelming", 9f, 1e6f)); Assert.False(FieldKinds.Assign(beltKey, reopened).Key.Equals(assigned)); - var items = new ItemManager(reopened, new ProvenanceLedger(), GameSettings(), _ => { }); + var items = new ItemManager(1u, reopened, new ProvenanceLedger(), GameSettings(), _ => { }); var reloaded = new Zone(items, PlanetSettings(), s.Pack, new GalaxyZone { Name = "MiningCut3Reload", Owner = null }, null); Assert.True(((AsteroidBeltData) reloaded.Planets[beltKey]).Kind.Key.Equals(assigned)); } @@ -817,7 +817,7 @@ public sealed partial class MiningCut3Tests : IDisposable cache, new PlayerSettings(), new DirectoryInfo(dir), _ => { }, null, seed); } catch (Exception) { continue; } // Galaxy's own seed-dependent flakiness (RestoredHullsTests) - var items = new ItemManager(cache, new ProvenanceLedger(), GameSettings(), _ => { }); + var items = new ItemManager(1u, cache, new ProvenanceLedger(), GameSettings(), _ => { }); foreach (var zone in galaxy.Zones.Take(4)) { var pack = ZoneGenerator.GenerateZone(items, RestoredHullsTests.TutorialZoneSettings(), RestoredHullsTests.AuthoredPlanetSettings(), galaxy, zone, isTutorial: zone == galaxy.Entrance); @@ -924,7 +924,7 @@ public sealed partial class MiningCut3Tests : IDisposable _caches.Add(clean); Assert.False(clean.IsDirty); Assert.False(((AsteroidBeltData) clean.Get(beltKey)).Kind.IsSet()); - var zone = new Zone(new ItemManager(clean, new ProvenanceLedger(), GameSettings(), _ => { }), PlanetSettings(), s.Pack, + var zone = new Zone(new ItemManager(1u, clean, new ProvenanceLedger(), GameSettings(), _ => { }), PlanetSettings(), s.Pack, new GalaxyZone { Name = "Clean", Owner = null }, null); var assigned = ((AsteroidBeltData) zone.Planets[beltKey]).Kind; Assert.True(assigned.IsSet()); @@ -1026,7 +1026,7 @@ public sealed partial class MiningCut3Tests : IDisposable PingEnergy = Constant(0f), PingRange = Constant(300f), PingCooldown = Constant(4f) }); cache.Upsert(eye); - var items = new ItemManager(cache, new ProvenanceLedger(), GameSettings(), _ => { }); + var items = new ItemManager(1u, cache, new ProvenanceLedger(), GameSettings(), _ => { }); var s = new Scene { Cache = cache, Items = items, Hull = hull, Eye = eye, diff --git a/tests/Aetheria.Shared.Tests/MunitionPartsTests.cs b/tests/Aetheria.Shared.Tests/MunitionPartsTests.cs index 0c7f60bd2..2bb2a2ee9 100644 --- a/tests/Aetheria.Shared.Tests/MunitionPartsTests.cs +++ b/tests/Aetheria.Shared.Tests/MunitionPartsTests.cs @@ -257,7 +257,7 @@ public sealed class MunitionPartsTests : IDisposable var ledger = new ProvenanceLedger(); ledger.Lots[1] = new Lot { Origin = new Attributed(), Quality = .5f }; ledger.Lots[2] = new Lot { Origin = new Attributed(), Quality = .5f, Roles = new List { new RoleFill { Role = "thruster", Quality = thrusterQuality } } }; - var items = new ItemManager(cache, ledger, settings, _ => { }); + var items = new ItemManager(1u, cache, ledger, settings, _ => { }); var zone = new Zone(items, new PlanetSettings(), new ZonePack(), new GalaxyZone { Name = "Test", Owner = null }, null); ship = new Ship(items, zone, new EquippableItem { Data = cache.RefOf(hull), Durability = hull.Durability, Lot = 1 }, settings.DefaultEntitySettings); foreach (var (name, x, y) in ShipGear) diff --git a/tests/Aetheria.Shared.Tests/OrbitsFromFieldTests.cs b/tests/Aetheria.Shared.Tests/OrbitsFromFieldTests.cs index 66a8e31d8..7a399587f 100644 --- a/tests/Aetheria.Shared.Tests/OrbitsFromFieldTests.cs +++ b/tests/Aetheria.Shared.Tests/OrbitsFromFieldTests.cs @@ -255,7 +255,7 @@ public sealed class OrbitsFromFieldTests : IDisposable var galaxy = new Galaxy(tutorial, authored.Read("TutorialBackgroundSettings"), cache, new PlayerSettings(), Directory.CreateDirectory(Path.Combine(dir, "Narrative")), _ => { }, null, seed); - var items = new ItemManager(cache, new ProvenanceLedger(), authored.Read("GameplaySettings"), _ => { }); + var items = new ItemManager(1u, cache, new ProvenanceLedger(), authored.Read("GameplaySettings"), _ => { }); var pack = ZoneGenerator.GenerateZone(items, authored.Read("ZoneSettings"), authored.Read("PlanetSettings"), galaxy, galaxy.Entrance, galaxy.IsPrelude, false); var zone = new Zone(items, authored.Read("PlanetSettings"), pack, galaxy.Entrance, galaxy); diff --git a/tests/Aetheria.Shared.Tests/PaintModeTests.cs b/tests/Aetheria.Shared.Tests/PaintModeTests.cs index 02fcf71e1..89b6fb39f 100644 --- a/tests/Aetheria.Shared.Tests/PaintModeTests.cs +++ b/tests/Aetheria.Shared.Tests/PaintModeTests.cs @@ -60,7 +60,7 @@ public sealed class PaintModeTests : IDisposable _openCache = AetheriaStores.Open(Catalog, catalogWritable: true); var ledger = new ProvenanceLedger(); for (var lot = 1; lot < 12; lot++) ledger.Lots[lot] = new Lot { Origin = new Attributed(), Quality = 1 }; - _items = new ItemManager(_openCache, ledger, new GameplaySettings + _items = new ItemManager(1u, _openCache, ledger, new GameplaySettings { DefaultEntitySettings = new EntitySettings(), Tiers = new[] { new RarityTier { Name = "Common", Quality = .5f, Color = new float3(1, 1, 1) } }, diff --git a/tests/Aetheria.Shared.Tests/PickupTests.cs b/tests/Aetheria.Shared.Tests/PickupTests.cs index 316c8b7b8..230b2023e 100644 --- a/tests/Aetheria.Shared.Tests/PickupTests.cs +++ b/tests/Aetheria.Shared.Tests/PickupTests.cs @@ -106,7 +106,7 @@ public sealed class PickupTests : IDisposable var ledger = new ProvenanceLedger(); for (var i = 1; i <= 9000; i++) ledger.Lots[i] = new Lot { Origin = new Attributed(), Quality = 1 }; - var items = new ItemManager(cache, ledger, settings, _ => { }); + var items = new ItemManager(1u, cache, ledger, settings, _ => { }); var pack = new ZonePack { Radius = 0 }; var curves = new PlanetSettings(); if (wells) diff --git a/tests/Aetheria.Shared.Tests/PopulatedRadiusTests.cs b/tests/Aetheria.Shared.Tests/PopulatedRadiusTests.cs index a106b217e..ff07e8e8c 100644 --- a/tests/Aetheria.Shared.Tests/PopulatedRadiusTests.cs +++ b/tests/Aetheria.Shared.Tests/PopulatedRadiusTests.cs @@ -61,7 +61,7 @@ public sealed class PopulatedRadiusTests : IDisposable var cache = AetheriaStores.Open(Path.Combine(dir, "Aetheria.cc"), Path.Combine(dir, "run.cc"), catalogWritable: true); _caches.Add(cache); cache.Upsert(new VerseGrammar { Revision = 1 }); - var items = new ItemManager(cache, new ProvenanceLedger(), new GameplaySettings + var items = new ItemManager(1u, cache, new ProvenanceLedger(), new GameplaySettings { DefaultEntitySettings = new EntitySettings(), Tiers = new[] { new RarityTier { Name = "Common", Quality = .5f, Color = new float3(1, 1, 1) } }, diff --git a/tests/Aetheria.Shared.Tests/PotentialOwnerTests.cs b/tests/Aetheria.Shared.Tests/PotentialOwnerTests.cs index 94100844a..2d3a42600 100644 --- a/tests/Aetheria.Shared.Tests/PotentialOwnerTests.cs +++ b/tests/Aetheria.Shared.Tests/PotentialOwnerTests.cs @@ -31,7 +31,7 @@ public sealed class PotentialOwnerTests : IDisposable Tiers = new[] { new RarityTier { Name = "Common", Quality = .5f, Color = new float3(1, 1, 1) } }, QualityPriceModifier = new ExponentialLerp() }; - _items = new ItemManager(_cache, new ProvenanceLedger(), settings, _ => { }); + _items = new ItemManager(1u, _cache, new ProvenanceLedger(), settings, _ => { }); } public void Dispose() diff --git a/tests/Aetheria.Shared.Tests/PowerBusTests.cs b/tests/Aetheria.Shared.Tests/PowerBusTests.cs index f4277b6fd..3cf71fa15 100644 --- a/tests/Aetheria.Shared.Tests/PowerBusTests.cs +++ b/tests/Aetheria.Shared.Tests/PowerBusTests.cs @@ -87,7 +87,7 @@ public sealed class PowerBusTests : IDisposable CultCache cache, float reactorCharge, float startingCapacitorCharge, bool reactorFirst) { var ledger = new ProvenanceLedger(); - var items = new ItemManager(cache, ledger, Settings(), _ => { }); + var items = new ItemManager(1u, cache, ledger, Settings(), _ => { }); var zone = new Zone(items, new PlanetSettings(), new ZonePack(), new GalaxyZone { Name = "Test Zone", Owner = null }, null); var hull = Mint(cache, items, cache.GetByName("Skiff")); var ship = new Ship(items, zone, hull, new EntitySettings()); diff --git a/tests/Aetheria.Shared.Tests/PowerCurveTests.cs b/tests/Aetheria.Shared.Tests/PowerCurveTests.cs index 38db0eec6..9d0405585 100644 --- a/tests/Aetheria.Shared.Tests/PowerCurveTests.cs +++ b/tests/Aetheria.Shared.Tests/PowerCurveTests.cs @@ -93,7 +93,7 @@ public sealed class PowerCurveTests : IDisposable private Ship BuildShip(CultCache cache, float reactorCharge) { - var items = new ItemManager(cache, new ProvenanceLedger(), Settings(), _ => { }); + var items = new ItemManager(1u, cache, new ProvenanceLedger(), Settings(), _ => { }); var zone = new Zone(items, new PlanetSettings(), new ZonePack(), new GalaxyZone { Name = "Test Zone", Owner = null }, null); var hull = Mint(cache, items, cache.GetByName("Skiff")); var ship = new Ship(items, zone, hull, new EntitySettings()); @@ -401,7 +401,7 @@ public sealed class PowerCurveTests : IDisposable }); cache.FlushAsync().Wait(); - var items = new ItemManager(cache, new ProvenanceLedger(), Settings(), _ => { }); + var items = new ItemManager(1u, cache, new ProvenanceLedger(), Settings(), _ => { }); var zone = new Zone(items, new PlanetSettings(), new ZonePack(), new GalaxyZone { Name = "Test Zone", Owner = null }, null); var hullItem = Mint(cache, items, cache.GetByName("Skiff")); var ship = new Ship(items, zone, hullItem, new EntitySettings()); diff --git a/tests/Aetheria.Shared.Tests/PowerTierTests.cs b/tests/Aetheria.Shared.Tests/PowerTierTests.cs index fc947ffb1..4a2831d19 100644 --- a/tests/Aetheria.Shared.Tests/PowerTierTests.cs +++ b/tests/Aetheria.Shared.Tests/PowerTierTests.cs @@ -63,7 +63,7 @@ public sealed class PowerTierTests : IDisposable private (Ship ship, ItemManager items, Reactor reactor) BuildShip(CultCache cache, float reactorCharge) { var ledger = new ProvenanceLedger(); - var items = new ItemManager(cache, ledger, Settings(), _ => { }); + var items = new ItemManager(1u, cache, ledger, Settings(), _ => { }); var zone = new Zone(items, new PlanetSettings(), new ZonePack(), new GalaxyZone { Name = "Test Zone", Owner = null }, null); var hull = Mint(cache, items, cache.GetByName("Skiff")); var ship = new Ship(items, zone, hull, new EntitySettings()); diff --git a/tests/Aetheria.Shared.Tests/ProductBinTests.cs b/tests/Aetheria.Shared.Tests/ProductBinTests.cs index c7b7de1fb..7729fd5fc 100644 --- a/tests/Aetheria.Shared.Tests/ProductBinTests.cs +++ b/tests/Aetheria.Shared.Tests/ProductBinTests.cs @@ -26,7 +26,7 @@ public sealed class ProductBinTests : IDisposable 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) }; + _items = new ItemManager(1u, _cache, new ProvenanceLedger(), RunSaveTests.TestSettings(), _log.Add) { Random = new CultMath.Random(7u) }; } public void Dispose() diff --git a/tests/Aetheria.Shared.Tests/ProductChoiceTests.cs b/tests/Aetheria.Shared.Tests/ProductChoiceTests.cs index 89f262c62..7e8744f1a 100644 --- a/tests/Aetheria.Shared.Tests/ProductChoiceTests.cs +++ b/tests/Aetheria.Shared.Tests/ProductChoiceTests.cs @@ -133,7 +133,7 @@ public sealed class ProductChoiceTests : IDisposable return new Rig { Cache = cache, OutA = outA, OutZ = outZ, OutBoth = cache.GetByName("OutBoth"), OutEven = cache.GetByName("OutEven"), OutNone = cache.GetByName("OutNone"), - Items = new ItemManager(cache, new ProvenanceLedger(), RunSaveTests.TestSettings(), _ => { }), + Items = new ItemManager(1u, cache, new ProvenanceLedger(), RunSaveTests.TestSettings(), _ => { }), Galaxy = new Galaxy(cache, cache.GetGlobal(), _ => { }) }; } @@ -325,7 +325,7 @@ public sealed class ProductChoiceTests : IDisposable int AlphaDraws(Rig rig) { var log = new List(); - var items = new ItemManager(rig.Cache, new ProvenanceLedger(), RunSaveTests.TestSettings(), log.Add); + var items = new ItemManager(1u, rig.Cache, new ProvenanceLedger(), RunSaveTests.TestSettings(), log.Add); var lamp = rig.Cache.GetByName("Lamp"); var alpha = Enumerable.Range(1, 1000).Count(i => { diff --git a/tests/Aetheria.Shared.Tests/RangeFalloffTests.cs b/tests/Aetheria.Shared.Tests/RangeFalloffTests.cs index 8d31dc296..ca7818c90 100644 --- a/tests/Aetheria.Shared.Tests/RangeFalloffTests.cs +++ b/tests/Aetheria.Shared.Tests/RangeFalloffTests.cs @@ -57,7 +57,7 @@ public sealed class RangeFalloffTests public void AnUnequippedWeaponsUnauthoredStatsEvaluate(string design, bool unauthored) { using var cache = RestoredHullsTests.OpenCatalog(); - var items = new ItemManager(cache, new ProvenanceLedger(), RunSaveTests.TestSettings(), _ => { }); + var items = new ItemManager(1u, cache, new ProvenanceLedger(), RunSaveTests.TestSettings(), _ => { }); var data = cache.GetAll().Single(x => x.Name == design); var lot = items.Lots.Add(new Lot { Design = cache.RefOf(data), Origin = new Attributed(), Quality = .5f, Roles = new System.Collections.Generic.List() }); var item = (EquippableItem) items.CreateInstance(lot); diff --git a/tests/Aetheria.Shared.Tests/RestoredHullsTests.cs b/tests/Aetheria.Shared.Tests/RestoredHullsTests.cs index ed63994cd..8b0065f93 100644 --- a/tests/Aetheria.Shared.Tests/RestoredHullsTests.cs +++ b/tests/Aetheria.Shared.Tests/RestoredHullsTests.cs @@ -147,7 +147,7 @@ public sealed class RestoredHullsTests private static (uint seed, string zoneName) ScanForNoLagrangeCandidateZone(CultCache cache) { - var items = new ItemManager(cache, new ProvenanceLedger(), Settings(), _ => { }); + var items = new ItemManager(1u, cache, new ProvenanceLedger(), Settings(), _ => { }); for (var seed = 1u; seed <= 64u; seed++) { var galaxy = new Galaxy(TutorialGalaxySettings(), TutorialBackgroundSettings(), cache, new PlayerSettings(), new DirectoryInfo(Path.GetTempPath()), _ => { }, null, seed); @@ -227,7 +227,7 @@ public sealed class RestoredHullsTests var settings = Settings(); var ledger = new ProvenanceLedger(); for (var i = 1; i <= hull.Hardpoints.Count + 1; i++) ledger.Lots[i] = new Lot { Origin = new Attributed(), Quality = .5f }; - var items = new ItemManager(cache, ledger, settings, _ => { }); + var items = new ItemManager(1u, cache, ledger, settings, _ => { }); var zone = new Zone(items, new PlanetSettings(), new ZonePack(), new GalaxyZone { Name = "Test", Owner = null }, null); var hullItem = new EquippableItem { Data = cache.RefOf(hull), Durability = hull.Durability, Lot = 1 }; var ship = new Ship(items, zone, hullItem, settings.DefaultEntitySettings); @@ -282,7 +282,7 @@ public sealed class RestoredHullsTests var settings = Settings(); var ledger = new ProvenanceLedger(); for (var i = 1; i <= hull.Hardpoints.Count + 1; i++) ledger.Lots[i] = new Lot { Origin = new Attributed(), Quality = .5f }; - var items = new ItemManager(cache, ledger, settings, _ => { }); + var items = new ItemManager(1u, cache, ledger, settings, _ => { }); var zone = new Zone(items, new PlanetSettings(), new ZonePack(), new GalaxyZone { Name = "Test", Owner = null }, null); var hullItem = new EquippableItem { Data = cache.RefOf(hull), Durability = hull.Durability, Lot = 1 }; var ship = new Ship(items, zone, hullItem, settings.DefaultEntitySettings); @@ -372,7 +372,7 @@ public sealed class RestoredHullsTests using var cache = OpenReadOnlyRealCatalogWithScratchRun(gameData, scratchRun); var galaxy = new Galaxy(tutorialSettings, background, cache, new PlayerSettings(), new DirectoryInfo(Path.GetTempPath()), _ => { }, null, seed); - var items = new ItemManager(cache, new ProvenanceLedger(), Settings(), _ => { }); + var items = new ItemManager(1u, cache, new ProvenanceLedger(), Settings(), _ => { }); var pack = ZoneGenerator.GenerateZone(items, zoneSettings, AuthoredPlanetSettings(), galaxy, galaxy.Entrance, isTutorial: true); var dockedStation = pack.Entities.OfType() @@ -412,7 +412,7 @@ public sealed class RestoredHullsTests using var cache = OpenReadOnlyRealCatalogWithScratchRun(gameData, scratchRun); var galaxy = new Galaxy(tutorialSettings, background, cache, new PlayerSettings(), new DirectoryInfo(Path.GetTempPath()), _ => { }, null, seed); - var items = new ItemManager(cache, new ProvenanceLedger(), Settings(), _ => { }); + var items = new ItemManager(1u, cache, new ProvenanceLedger(), Settings(), _ => { }); var pack = ZoneGenerator.GenerateZone(items, zoneSettings, AuthoredPlanetSettings(), galaxy, galaxy.Entrance, isTutorial: true); var planetOrbits = pack.Planets.Select(p => cache.Get(cache.Get(p).Orbit)).Where(o => o.Parent.IsSet()).ToArray(); @@ -449,7 +449,7 @@ public sealed class RestoredHullsTests { using var cache = OpenReadOnlyRealCatalogWithScratchRun(gameData, scratchRun); var galaxy = new Galaxy(TutorialGalaxySettings(), TutorialBackgroundSettings(), cache, new PlayerSettings(), new DirectoryInfo(Path.GetTempPath()), _ => { }, null, seed: 1u); - var items = new ItemManager(cache, new ProvenanceLedger(), Settings(), _ => { }); + var items = new ItemManager(1u, cache, new ProvenanceLedger(), Settings(), _ => { }); var zoneSettings = TutorialZoneSettings(); zoneSettings.RosetteProbability = 0; @@ -484,7 +484,7 @@ public sealed class RestoredHullsTests { using var cache = OpenReadOnlyRealCatalogWithScratchRun(gameData, scratchRun); var galaxy = new Galaxy(TutorialGalaxySettings(), TutorialBackgroundSettings(), cache, new PlayerSettings(), new DirectoryInfo(Path.GetTempPath()), _ => { }, null, seed: 1u); - var items = new ItemManager(cache, new ProvenanceLedger(), Settings(), _ => { }); + var items = new ItemManager(1u, cache, new ProvenanceLedger(), Settings(), _ => { }); var zoneSettings = TutorialZoneSettings(); zoneSettings.RosetteProbability = 1; @@ -535,7 +535,7 @@ public sealed class RestoredHullsTests using var cache = OpenReadOnlyRealCatalogWithScratchRun(gameData, scratchRun); var galaxy = new Galaxy(tutorialSettings, background, cache, new PlayerSettings(), new DirectoryInfo(Path.GetTempPath()), _ => { }, null, seed); - var items = new ItemManager(cache, new ProvenanceLedger(), Settings(), _ => { }); + var items = new ItemManager(1u, cache, new ProvenanceLedger(), Settings(), _ => { }); var pack = ZoneGenerator.GenerateZone(items, zoneSettings, AuthoredPlanetSettings(), galaxy, galaxy.Entrance, isTutorial: true); var dockedStation = pack.Entities.OfType() @@ -596,7 +596,7 @@ public sealed class RestoredHullsTests using var cache = OpenCatalog(); var hull = cache.GetByName("Longinus"); var settings = Settings(); - var items = new ItemManager(cache, new ProvenanceLedger(), settings, _ => { }); + var items = new ItemManager(1u, cache, new ProvenanceLedger(), settings, _ => { }); // ItemManager.Random defaults to a DateTime.Now-seeded instance; without pinning it here, the item // qualities LoadoutGenerator/EntitySerializer roll through it (not through the `random` below, which // only drives which designs get picked) would make this test's own measured speed non-deterministic. @@ -645,7 +645,7 @@ public sealed class RestoredHullsTests for (uint seed = 1; seed <= 10; seed++) { - var items = new ItemManager(cache, new ProvenanceLedger(), settings, _ => { }); + var items = new ItemManager(1u, cache, new ProvenanceLedger(), settings, _ => { }); var random = new Random(seed); var generator = new LoadoutGenerator(ref random, items, null, null, null, .5f); var pack = generator.GenerateShipLoadout(candidate => candidate == hull); @@ -729,7 +729,7 @@ public sealed class RestoredHullsTests var (seed, zoneName) = NoLagrangeCandidateZone(); var galaxy = BuildGalaxyWithForcedEntrance(cache, seed, zoneName); - var items = new ItemManager(cache, new ProvenanceLedger(), Settings(), _ => { }); + var items = new ItemManager(1u, cache, new ProvenanceLedger(), Settings(), _ => { }); var pack = ZoneGenerator.GenerateZone(items, TutorialZoneSettings(), AuthoredPlanetSettings(), galaxy, galaxy.Entrance, isTutorial: true); var station = pack.Entities.OfType() @@ -780,7 +780,7 @@ public sealed class RestoredHullsTests { using var cache = OpenReadOnlyRealCatalogWithScratchRun(gameData, scratchRun); var galaxy = new Galaxy(TutorialGalaxySettings(), TutorialBackgroundSettings(), cache, new PlayerSettings(), new DirectoryInfo(Path.GetTempPath()), _ => { }, null, seed: 1u); - var items = new ItemManager(cache, new ProvenanceLedger(), Settings(), _ => { }); + var items = new ItemManager(1u, cache, new ProvenanceLedger(), Settings(), _ => { }); int StationCount(GalaxyZone zone, bool isTutorial) => ZoneGenerator.GenerateZone(items, TutorialZoneSettings(), AuthoredPlanetSettings(), galaxy, zone, isTutorial) @@ -820,7 +820,7 @@ public sealed class RestoredHullsTests { using var cache = OpenReadOnlyRealCatalogWithScratchRun(gameData, scratchRun); var galaxy = new Galaxy(TutorialGalaxySettings(), TutorialBackgroundSettings(), cache, new PlayerSettings(), new DirectoryInfo(Path.GetTempPath()), _ => { }, null, seed: 1u); - var items = new ItemManager(cache, new ProvenanceLedger(), Settings(), _ => { }); + var items = new ItemManager(1u, cache, new ProvenanceLedger(), Settings(), _ => { }); var pack = ZoneGenerator.GenerateZone(items, TutorialZoneSettings(), AuthoredPlanetSettings(), galaxy, galaxy.Entrance, isTutorial: true); var stationCount = pack.Entities.OfType().Count(e => (cache.Get(e.Hull.Data) as HullData)?.HullType == HullType.Station); @@ -904,7 +904,7 @@ public sealed class RestoredHullsTests var zone = galaxy.Zones.Single(z => z.Name == zoneName); Assert.NotSame(galaxy.Entrance, zone); - var items = new ItemManager(cache, new ProvenanceLedger(), Settings(), _ => { }); + var items = new ItemManager(1u, cache, new ProvenanceLedger(), Settings(), _ => { }); var pack = ZoneGenerator.GenerateZone(items, TutorialZoneSettings(), AuthoredPlanetSettings(), galaxy, zone, isTutorial: true); // Confirms this fixture still has no genuine (non-rosette) Lagrange candidate -- a regression here diff --git a/tests/Aetheria.Shared.Tests/RoleAuthoringTests.cs b/tests/Aetheria.Shared.Tests/RoleAuthoringTests.cs index 2a61cbf86..0bb9e6a6d 100644 --- a/tests/Aetheria.Shared.Tests/RoleAuthoringTests.cs +++ b/tests/Aetheria.Shared.Tests/RoleAuthoringTests.cs @@ -102,7 +102,7 @@ public sealed class RoleAuthoringTests : IDisposable }); cache.FlushAsync().Wait(); - var items = new ItemManager(cache, new ProvenanceLedger(), Settings(), _ => { }); + var items = new ItemManager(1u, cache, new ProvenanceLedger(), Settings(), _ => { }); var cheapLot = items.CreateLot(cache.GetAll().Single(p => p.Name == "Lensed Economy")); var premiumLot = items.CreateLot(cache.GetAll().Single(p => p.Name == "Lensed Premium")); var cheapInstance = (EquippableItem) items.CreateInstance(cheapLot); diff --git a/tests/Aetheria.Shared.Tests/RunGoalTests.cs b/tests/Aetheria.Shared.Tests/RunGoalTests.cs index c618d712e..8626148a5 100644 --- a/tests/Aetheria.Shared.Tests/RunGoalTests.cs +++ b/tests/Aetheria.Shared.Tests/RunGoalTests.cs @@ -30,7 +30,7 @@ public sealed partial class RunStartTests private static Faction Zhestokost(Galaxy galaxy) => galaxy.Factions.Single(faction => faction.Name == "Zhestokost"); private ItemManager LoggingItems(List log) => - new ItemManager(_cache, new ProvenanceLedger(), AuthoredSettings.Load(FindRepoRoot()).Read("GameplaySettings"), log.Add); + new ItemManager(1u, _cache, new ProvenanceLedger(), AuthoredSettings.Load(FindRepoRoot()).Read("GameplaySettings"), log.Add); private static string HullName(ItemManager items, EntityPack pack) => items.GetData(pack.Hull).Name; diff --git a/tests/Aetheria.Shared.Tests/RunSaveTests.cs b/tests/Aetheria.Shared.Tests/RunSaveTests.cs index 456561d76..00a6baa75 100644 --- a/tests/Aetheria.Shared.Tests/RunSaveTests.cs +++ b/tests/Aetheria.Shared.Tests/RunSaveTests.cs @@ -102,7 +102,7 @@ public sealed class RunSaveTests : IDisposable var galaxy = new Galaxy(cache, cache.GetGlobal(), _ => { }); var ledger = new ProvenanceLedger(); for (var i = 1; i <= 2; i++) ledger.Lots[i] = new Lot { Origin = new Attributed(), Quality = 1 }; - var items = new ItemManager(cache, ledger, TestSettings(), _ => { }); + var items = new ItemManager(1u, cache, ledger, TestSettings(), _ => { }); var zone = new Zone(items, new PlanetSettings(), new ZonePack(), galaxy.Zones[0], galaxy); galaxy.Zones[0].Contents = zone; Ship Make(string hull, int lot) => new Ship(items, zone, new EquippableItem @@ -135,7 +135,7 @@ public sealed class RunSaveTests : IDisposable using (var cache = Open()) { var galaxy = new Galaxy(cache, cache.GetGlobal(), _ => { }); - var itemManager = new ItemManager(cache, new ProvenanceLedger(), TestSettings(), _ => { }); + var itemManager = new ItemManager(1u, 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); RunSave.Commit(cache, game, zones, RunSave.Lots(cache)); @@ -160,7 +160,7 @@ public sealed class RunSaveTests : IDisposable using (var cache = Open()) { var galaxy = new Galaxy(cache, cache.GetGlobal(), _ => { }); - var itemManager = new ItemManager(cache, new ProvenanceLedger(), TestSettings(), _ => { }); + var itemManager = new ItemManager(1u, 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], 4242); RunSave.Commit(cache, game, zones, RunSave.Lots(cache)); @@ -186,7 +186,7 @@ public sealed class RunSaveTests : IDisposable { var galaxy = new Galaxy(cache, cache.GetGlobal(), _ => { }); Assert.Equal(0xC0FFEE42u, galaxy.Seed); - var itemManager = new ItemManager(cache, new ProvenanceLedger(), TestSettings(), _ => { }); + var itemManager = new ItemManager(1u, 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(0xC0FFEE42u), galaxy.Zones[0].Contents.DropSeed); @@ -207,7 +207,7 @@ public sealed class RunSaveTests : IDisposable Assert.Equal(0u, cache.GetGlobal().Seed); var galaxy = new Galaxy(cache, cache.GetGlobal(), _ => { }); Assert.Equal(0u, galaxy.Seed); - var itemManager = new ItemManager(cache, new ProvenanceLedger(), TestSettings(), _ => { }); + var itemManager = new ItemManager(1u, 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); @@ -408,7 +408,7 @@ public sealed class RunSaveTests : IDisposable using (var cache = Open()) { var random = new Random(7); - var items = new ItemManager(cache, new ProvenanceLedger(), TestSettings(), _ => { }); + var items = new ItemManager(1u, cache, new ProvenanceLedger(), TestSettings(), _ => { }); var generator = new LoadoutGenerator(ref random, items, null, null, null, .5f); pack = generator.GenerateShipLoadout(candidate => candidate.Name == "Runabout"); Assert.NotNull(pack); diff --git a/tests/Aetheria.Shared.Tests/RunStartTests.cs b/tests/Aetheria.Shared.Tests/RunStartTests.cs index 4b181f67e..9d499b3e1 100644 --- a/tests/Aetheria.Shared.Tests/RunStartTests.cs +++ b/tests/Aetheria.Shared.Tests/RunStartTests.cs @@ -53,7 +53,7 @@ public sealed partial class RunStartTests : IDisposable _galaxy = new Galaxy(tutorial, authored.Read("TutorialBackgroundSettings"), _cache, new PlayerSettings(), Directory.CreateDirectory(Path.Combine(_root, "Narrative")), _ => { }, null, GalaxySeed); - _items = new ItemManager(_cache, new ProvenanceLedger(), authored.Read("GameplaySettings"), _ => { }); + _items = new ItemManager(1u, _cache, new ProvenanceLedger(), authored.Read("GameplaySettings"), _ => { }); _protagonist = _galaxy.ResolveFaction(tutorial.ProtagonistFaction); // GameSettings.StartingHullName is a plain field of the Unity settings object, not a ServerShared subtree diff --git a/tests/Aetheria.Shared.Tests/ShieldReserveTests.cs b/tests/Aetheria.Shared.Tests/ShieldReserveTests.cs index 3bd741e57..50f71dd06 100644 --- a/tests/Aetheria.Shared.Tests/ShieldReserveTests.cs +++ b/tests/Aetheria.Shared.Tests/ShieldReserveTests.cs @@ -83,7 +83,7 @@ public sealed class ShieldReserveTests : IDisposable private (Ship ship, Shield shield) BuildShipWithShield(CultCache cache, float reactorGeneration) { var ledger = new ProvenanceLedger(); - var items = new ItemManager(cache, ledger, Settings(), _ => { }); + var items = new ItemManager(1u, cache, ledger, Settings(), _ => { }); var zone = new Zone(items, new PlanetSettings(), new ZonePack(), new GalaxyZone { Name = "Test Zone", Owner = null }, null); var hull = Mint(cache, items, cache.GetByName("Skiff")); var ship = new Ship(items, zone, hull, new EntitySettings()); diff --git a/tests/Aetheria.Shared.Tests/SignatureTests.cs b/tests/Aetheria.Shared.Tests/SignatureTests.cs index 8468ac8dd..922e3e5b5 100644 --- a/tests/Aetheria.Shared.Tests/SignatureTests.cs +++ b/tests/Aetheria.Shared.Tests/SignatureTests.cs @@ -132,7 +132,7 @@ public sealed class SignatureTests : IDisposable var ledger = new ProvenanceLedger(); for (var i = 1; i <= 100; i++) ledger.Lots[i] = new Lot { Origin = new Attributed(), Quality = 1 }; - var items = new ItemManager(cache, ledger, Settings(visibilityDecay), _ => { }); + var items = new ItemManager(1u, cache, ledger, Settings(visibilityDecay), _ => { }); var zone = new Zone(items, new PlanetSettings(), new ZonePack(), new GalaxyZone { Name = "Signatures", Owner = null }, null); var lot = 1; EquippableItem Make(string name) => new EquippableItem diff --git a/tests/Aetheria.Shared.Tests/StatResolverTests.cs b/tests/Aetheria.Shared.Tests/StatResolverTests.cs index 4469eb333..406405a24 100644 --- a/tests/Aetheria.Shared.Tests/StatResolverTests.cs +++ b/tests/Aetheria.Shared.Tests/StatResolverTests.cs @@ -93,7 +93,7 @@ public sealed class StatResolverTests : IDisposable cache.Upsert(new GearData { Name = "AutoGun", Hardpoint = HardpointType.Sensors, Shape = new Shape(), Durability = 10, Behaviors = { new AutoWeaponData { Damage = autoDamage } } }); cache.Upsert(new GearData { Name = "ChargedGun", Hardpoint = HardpointType.Sensors, Shape = new Shape(), Durability = 10, Behaviors = { new ChargedWeaponData { Damage = chargedDamage } } }); cache.FlushAsync().Wait(); - var items = new ItemManager(cache, new ProvenanceLedger(), Settings(), _ => { }); + var items = new ItemManager(1u, cache, new ProvenanceLedger(), Settings(), _ => { }); var ship = BuildActivatedShip(cache, items, withBooster: true); ship.Deactivate(); // TryEquip refuses while deployed Assert.True(ship.TryEquip(Mint(cache, items, cache.GetByName("AutoGun")), FirstGunCell)); @@ -137,7 +137,7 @@ public sealed class StatResolverTests : IDisposable { var capacity = new PerformanceStat { Min = 1, Max = 1 }; using var cache = OpenCatalog(new CapacitorData { Capacity = capacity }); - var items = new ItemManager(cache, new ProvenanceLedger(), Settings(), _ => { }); + var items = new ItemManager(1u, cache, new ProvenanceLedger(), Settings(), _ => { }); var shipA = BuildActivatedShip(cache, items, withBooster: false); var shipB = BuildActivatedShip(cache, items, withBooster: false); @@ -159,7 +159,7 @@ public sealed class StatResolverTests : IDisposable Type = StatModifierType.Constant }; using var cache = OpenCatalog(new CapacitorData { Capacity = capacity }, modifier); - var items = new ItemManager(cache, new ProvenanceLedger(), Settings(), _ => { }); + var items = new ItemManager(1u, cache, new ProvenanceLedger(), Settings(), _ => { }); var boosted = BuildActivatedShip(cache, items, withBooster: true); var plain = BuildActivatedShip(cache, items, withBooster: false); @@ -198,7 +198,7 @@ public sealed class StatResolverTests : IDisposable Type = StatModifierType.Constant }; using var cache = OpenCatalog(new CapacitorData { Capacity = capacity }, modifier); - var items = new ItemManager(cache, new ProvenanceLedger(), Settings(), _ => { }); + var items = new ItemManager(1u, cache, new ProvenanceLedger(), Settings(), _ => { }); var ship = BuildActivatedShip(cache, items, withBooster: true); var boosterEquipped = ship.Equipment.Single(e => e.Data.Name == "Booster"); @@ -234,7 +234,7 @@ public sealed class StatResolverTests : IDisposable { var capacity = new PerformanceStat { Min = 1, Max = 1 }; using var cache = OpenCatalog(new CapacitorData { Capacity = capacity }); - var items = new ItemManager(cache, new ProvenanceLedger(), Settings(), _ => { }); + var items = new ItemManager(1u, cache, new ProvenanceLedger(), Settings(), _ => { }); WeakReference EquipEvaluateAndDrop() { @@ -281,7 +281,7 @@ public sealed class StatResolverTests : IDisposable }); cache.FlushAsync().Wait(); - var items = new ItemManager(cache, new ProvenanceLedger(), Settings(), _ => { }); + var items = new ItemManager(1u, cache, new ProvenanceLedger(), Settings(), _ => { }); var zone = new Zone(items, new PlanetSettings(), new ZonePack(), new GalaxyZone { Name = "Test Zone", Owner = null }, null); var hullItem = Mint(cache, items, cache.GetByName("Skiff")); var ship = new Ship(items, zone, hullItem, new EntitySettings()); @@ -387,7 +387,7 @@ public sealed class StatResolverTests : IDisposable { var capacity = new PerformanceStat { Min = 1, Max = 1 }; using var cache = OpenCatalog(new CapacitorData { Capacity = capacity }); - var items = new ItemManager(cache, new ProvenanceLedger(), Settings(), _ => { }); + var items = new ItemManager(1u, cache, new ProvenanceLedger(), Settings(), _ => { }); var ship = BuildActivatedShip(cache, items, withBooster: false); var resolver = ship.Resolver; @@ -424,7 +424,7 @@ public sealed class StatResolverTests : IDisposable cache.FlushAsync().Wait(); var boosterData = cache.Get(boosterRef); - var items = new ItemManager(cache, new ProvenanceLedger(), Settings(), _ => { }); + var items = new ItemManager(1u, cache, new ProvenanceLedger(), Settings(), _ => { }); var ship = BuildActivatedShip(cache, items, withBooster: false); var resolver = ship.Resolver; @@ -498,7 +498,7 @@ public sealed class StatResolverTests : IDisposable cache.Upsert(new ConsumableItemData { Name = "Stim", Duration = 1f, Shape = new Shape(), Behaviors = { ThrustModifier(consumable.Value.Type, consumable.Value.Value) } }); cache.FlushAsync().Wait(); - var items = new ItemManager(cache, new ProvenanceLedger(), Settings(), _ => { }); + var items = new ItemManager(1u, cache, new ProvenanceLedger(), Settings(), _ => { }); var zone = new Zone(items, new PlanetSettings(), new ZonePack(), new GalaxyZone { Name = "Test Zone", Owner = null }, null); var ship = new Ship(items, zone, Mint(cache, items, cache.GetByName("Skiff")), new EntitySettings()); Assert.True(ship.TryEquip(Mint(cache, items, cache.GetByName("Engine")), cells[0])); diff --git a/tests/Aetheria.Shared.Tests/StationPlacementTests.cs b/tests/Aetheria.Shared.Tests/StationPlacementTests.cs index 0157f6a70..66caa331c 100644 --- a/tests/Aetheria.Shared.Tests/StationPlacementTests.cs +++ b/tests/Aetheria.Shared.Tests/StationPlacementTests.cs @@ -56,7 +56,7 @@ public sealed class StationPlacementTests : IDisposable authored.Read("TutorialBackgroundSettings"), cache, new PlayerSettings(), Directory.CreateDirectory(Path.Combine(dir, "Narrative")), _ => { }, null, seed); - var items = new ItemManager(cache, new ProvenanceLedger(), settings, _ => { }); + var items = new ItemManager(1u, cache, new ProvenanceLedger(), settings, _ => { }); stock?.Invoke(items); foreach (var galaxyZone in entranceOnly ? new[] { galaxy.Entrance } : galaxy.Zones.Take(zonesPerSeed)) { diff --git a/tests/Aetheria.Shared.Tests/StationServicesTests.cs b/tests/Aetheria.Shared.Tests/StationServicesTests.cs index 21a478699..2bf4d3c2f 100644 --- a/tests/Aetheria.Shared.Tests/StationServicesTests.cs +++ b/tests/Aetheria.Shared.Tests/StationServicesTests.cs @@ -37,7 +37,7 @@ public sealed class StationServicesTests : IDisposable _cache.FlushAsync().Wait(); var settings = AuthoredSettings.Load(RestoredHullsTests.FindRepoRoot()).Read("GameplaySettings"); - _items = new ItemManager(_cache, new ProvenanceLedger(), settings, _ => { }); + _items = new ItemManager(1u, _cache, new ProvenanceLedger(), settings, _ => { }); _zone = new Zone(_items, new PlanetSettings(), new ZonePack(), new GalaxyZone { Name = "Test Zone", Owner = null }, null); } diff --git a/tests/Aetheria.Shared.Tests/SunsNeverGreenTests.cs b/tests/Aetheria.Shared.Tests/SunsNeverGreenTests.cs index 517810c42..37eafc01a 100644 --- a/tests/Aetheria.Shared.Tests/SunsNeverGreenTests.cs +++ b/tests/Aetheria.Shared.Tests/SunsNeverGreenTests.cs @@ -185,7 +185,7 @@ public sealed class SunsNeverGreenTests : IDisposable var galaxy = new Galaxy(tutorial, authored.Read("TutorialBackgroundSettings"), cache, new PlayerSettings(), Directory.CreateDirectory(Path.Combine(dir, "Narrative")), _ => { }, null, seed); - var items = new ItemManager(cache, new ProvenanceLedger(), authored.Read("GameplaySettings"), _ => { }); + var items = new ItemManager(1u, cache, new ProvenanceLedger(), authored.Read("GameplaySettings"), _ => { }); var zoneSettings = authored.Read("ZoneSettings"); adjust(zoneSettings); foreach (var zone in galaxy.Zones) diff --git a/tests/Aetheria.Shared.Tests/TargetingSystemTests.cs b/tests/Aetheria.Shared.Tests/TargetingSystemTests.cs index cd43cd789..4d78a55df 100644 --- a/tests/Aetheria.Shared.Tests/TargetingSystemTests.cs +++ b/tests/Aetheria.Shared.Tests/TargetingSystemTests.cs @@ -104,7 +104,7 @@ public sealed class TargetingSystemTests : IDisposable ledger.Lots[3] = new Lot { Origin = new Attributed(), Quality = 1 }; // wide ledger.Lots[4] = new Lot { Origin = new Attributed(), Quality = 1 }; // narrow ledger.Lots[5] = new Lot { Origin = new Attributed(), Quality = 1 }; // targeting (unused unless authored above) - var items = new ItemManager(_openCache, ledger, settings, _ => { }); + var items = new ItemManager(1u, _openCache, ledger, settings, _ => { }); var zone = new Zone(items, new PlanetSettings(), new ZonePack(), new GalaxyZone { Name = "Test Zone", Owner = null }, null); var hullRef = items.ItemData.RefOf(items.ItemData.GetByName("TestHull")); @@ -368,7 +368,7 @@ public sealed class TargetingSystemTests : IDisposable Ship BuildShip(float reactorCharge, out ItemManager items) { - items = new ItemManager(cache, new ProvenanceLedger(), TestSettings(), _ => { }); + items = new ItemManager(1u, cache, new ProvenanceLedger(), TestSettings(), _ => { }); var zone = new Zone(items, new PlanetSettings(), new ZonePack(), new GalaxyZone { Name = "Test Zone", Owner = null }, null); var hullData = cache.GetByName("Skiff"); var hull = Mint(hullData); @@ -439,7 +439,7 @@ public sealed class TargetingSystemTests : IDisposable for (var seed = 1; seed <= 10; seed++) { var random = new Random((uint) seed); - var items = new ItemManager(cache, new ProvenanceLedger(), TestSettings(), _ => { }); + var items = new ItemManager(1u, cache, new ProvenanceLedger(), TestSettings(), _ => { }); var generator = new LoadoutGenerator(ref random, items, null, null, null, .5f); var pack = generator.GenerateShipLoadout(candidate => candidate.Name == "Armed"); Assert.NotNull(pack); diff --git a/tests/Aetheria.Shared.Tests/ThrottleLockTests.cs b/tests/Aetheria.Shared.Tests/ThrottleLockTests.cs index ca0f76a97..33ceae4e0 100644 --- a/tests/Aetheria.Shared.Tests/ThrottleLockTests.cs +++ b/tests/Aetheria.Shared.Tests/ThrottleLockTests.cs @@ -84,7 +84,7 @@ public sealed class ThrottleLockTests : IDisposable cache.Upsert(lockData); cache.FlushAsync().Wait(); - var items = new ItemManager(cache, new ProvenanceLedger(), Settings(), _ => { }); + var items = new ItemManager(1u, cache, new ProvenanceLedger(), Settings(), _ => { }); var zone = new Zone(items, new PlanetSettings(), new ZonePack(), new GalaxyZone { Name = "Test Zone", Owner = null }, null); var ship = new Ship(items, zone, Mint(cache, items, cache.GetByName("Skiff")), new EntitySettings()); var gear = cache.GetByName("Thruster"); diff --git a/tests/Aetheria.Shared.Tests/VapourCloudTests.cs b/tests/Aetheria.Shared.Tests/VapourCloudTests.cs index e4d57a1cf..6945de2a8 100644 --- a/tests/Aetheria.Shared.Tests/VapourCloudTests.cs +++ b/tests/Aetheria.Shared.Tests/VapourCloudTests.cs @@ -104,7 +104,7 @@ public sealed class VapourCloudTests : IDisposable var ledger = new ProvenanceLedger(); for (var i = 1; i <= 50; i++) ledger.Lots[i] = new Lot { Origin = new Attributed(), Quality = 1 }; - var items = new ItemManager(cache, ledger, settings, _ => { }); + var items = new ItemManager(1u, cache, ledger, settings, _ => { }); var zone = new Zone(items, new PlanetSettings(), new ZonePack { Radius = 5000 }, new GalaxyZone { Name = "Vapour", Owner = null }, null); var lab = new Lab { Items = items, Zone = zone }; diff --git a/tests/Aetheria.Shared.Tests/ZonePlacedAtBuildTests.cs b/tests/Aetheria.Shared.Tests/ZonePlacedAtBuildTests.cs index dada889f0..cb3a8c6b4 100644 --- a/tests/Aetheria.Shared.Tests/ZonePlacedAtBuildTests.cs +++ b/tests/Aetheria.Shared.Tests/ZonePlacedAtBuildTests.cs @@ -71,7 +71,7 @@ public sealed class ZonePlacedAtBuildTests : IDisposable foreach (var cell in shape.AllCoordinates) shape[cell] = true; var hull = new HullData { Name = "Skiff", HullType = HullType.Ship, Shape = shape, Durability = 10, Mass = 1000 }; cache.Upsert(hull); - var items = new ItemManager(cache, new ProvenanceLedger(), GameSettings(), _ => { }); + var items = new ItemManager(1u, cache, new ProvenanceLedger(), GameSettings(), _ => { }); var rootOrbit = cache.Upsert(new OrbitData { FixedPosition = RootAt }).Key; var planetOrbit = cache.Upsert(new OrbitData diff --git a/tests/Aetheria.Shared.Tests/ZoneWorlds.cs b/tests/Aetheria.Shared.Tests/ZoneWorlds.cs index f2dd4ba72..0d6c807fa 100644 --- a/tests/Aetheria.Shared.Tests/ZoneWorlds.cs +++ b/tests/Aetheria.Shared.Tests/ZoneWorlds.cs @@ -80,6 +80,6 @@ internal sealed class ZoneWorlds : IDisposable Directory.CreateDirectory(dir); var cache = AetheriaStores.Open(Path.Combine(dir, "Aetheria.cc"), Path.Combine(dir, "run.cc"), catalogWritable: true); Caches.Add(cache); - return new World { Cache = cache, Items = new ItemManager(cache, new ProvenanceLedger(), GameSettings(), _ => { }) }; + return new World { Cache = cache, Items = new ItemManager(1u, cache, new ProvenanceLedger(), GameSettings(), _ => { }) }; } } diff --git a/tools/AetherDb/Program.cs b/tools/AetherDb/Program.cs index c69d120aa..3e8b37b85 100644 --- a/tools/AetherDb/Program.cs +++ b/tools/AetherDb/Program.cs @@ -807,7 +807,8 @@ public static class Program }; var log = new List(); - var itemManager = new ItemManager(db.Cache, new ProvenanceLedger(), settings, log.Add); + // A fixed seed: the catalog command's output must not depend on the clock. + var itemManager = new ItemManager(1u, db.Cache, new ProvenanceLedger(), settings, log.Add); var failures = 0; var targetingGaps = 0; From 9379712c784f854723a04434302cc67d0c6c1174 Mon Sep 17 00:00:00 2001 From: soul Date: Wed, 21 Oct 2026 19:01:43 +0200 Subject: [PATCH 089/444] determinism-ledger: Scenario.Id, a stable kebab identity for matches and replays Every scenario, and the test Scripted scenario, names an Id; Name is display only. Co-Authored-By: Claude Opus 5.5 --- Assets/Scripts/ServerShared/Scenario.cs | 4 ++++ Assets/Scripts/ServerShared/Scenarios/AiFusedDiscipline.cs | 1 + Assets/Scripts/ServerShared/Scenarios/Arcs.cs | 1 + Assets/Scripts/ServerShared/Scenarios/Belt.cs | 1 + Assets/Scripts/ServerShared/Scenarios/Crossing.cs | 1 + Assets/Scripts/ServerShared/Scenarios/DemoTerminus.cs | 1 + Assets/Scripts/ServerShared/Scenarios/DjinniShakedown.cs | 1 + Assets/Scripts/ServerShared/Scenarios/Duel.cs | 1 + Assets/Scripts/ServerShared/Scenarios/FusedRounds.cs | 1 + Assets/Scripts/ServerShared/Scenarios/LauncherAngles.cs | 1 + Assets/Scripts/ServerShared/Scenarios/LockOn.cs | 1 + Assets/Scripts/ServerShared/Scenarios/LongHaul.cs | 1 + Assets/Scripts/ServerShared/Scenarios/MainGalaxy.cs | 1 + Assets/Scripts/ServerShared/Scenarios/Pursuit.cs | 1 + Assets/Scripts/ServerShared/Scenarios/RefusedRounds.cs | 1 + Assets/Scripts/ServerShared/Scenarios/StarvedReactor.cs | 1 + Assets/Scripts/ServerShared/Scenarios/TutorialGalaxy.cs | 1 + Assets/Scripts/ServerShared/Scenarios/WeaponFeel.cs | 1 + tests/Aetheria.Shared.Tests/ScenarioTests.cs | 1 + 19 files changed, 22 insertions(+) diff --git a/Assets/Scripts/ServerShared/Scenario.cs b/Assets/Scripts/ServerShared/Scenario.cs index acd2f10b2..72b084866 100644 --- a/Assets/Scripts/ServerShared/Scenario.cs +++ b/Assets/Scripts/ServerShared/Scenario.cs @@ -6,6 +6,10 @@ public abstract class Scenario { public abstract string Name { get; } + // The scenario's stable identity: kebab-case, unique across Scenarios, never shown to the player, recorded by + // matches and replays. Name is display only. + public abstract string Id { get; } + // The conditions and what to verify in them, shown under the scenario's button. public abstract string Brief { get; } diff --git a/Assets/Scripts/ServerShared/Scenarios/AiFusedDiscipline.cs b/Assets/Scripts/ServerShared/Scenarios/AiFusedDiscipline.cs index df33f5640..beb4235da 100644 --- a/Assets/Scripts/ServerShared/Scenarios/AiFusedDiscipline.cs +++ b/Assets/Scripts/ServerShared/Scenarios/AiFusedDiscipline.cs @@ -4,6 +4,7 @@ using static ItemRotation; public sealed class AiFusedDiscipline : Scenario { public override string Name => "AI Fused Discipline"; + public override string Id => "ai-fused-discipline"; public override string Brief => "A Djinni; a piloted neutral Longinus carrying a Smoke Proximity and a piloted hostile Longinus whose only weapon is " + "Smoke Refused. Verify: the hostile closes on you and never fires its refused weapon; the neutral never fires its " + "fused one."; diff --git a/Assets/Scripts/ServerShared/Scenarios/Arcs.cs b/Assets/Scripts/ServerShared/Scenarios/Arcs.cs index b91fbc359..f0c3f5553 100644 --- a/Assets/Scripts/ServerShared/Scenarios/Arcs.cs +++ b/Assets/Scripts/ServerShared/Scenarios/Arcs.cs @@ -4,6 +4,7 @@ using static ItemRotation; public sealed class Arcs : Scenario { public override string Name => "Arcs"; + public override string Id => "arcs"; public override string Brief => "The Smoke Test Hull with forward mounts and a side mount on each flank; a bare hull off the bow, one off " + "the stern, one abeam to port, one abeam to starboard, and a hostile turret. Verify: the hull keeps its " + "heading while you aim at the stern hull, and the forward guns hold. Designate the bow hull and fire with the " + diff --git a/Assets/Scripts/ServerShared/Scenarios/Belt.cs b/Assets/Scripts/ServerShared/Scenarios/Belt.cs index 5ac8926c8..50c815f6d 100644 --- a/Assets/Scripts/ServerShared/Scenarios/Belt.cs +++ b/Assets/Scripts/ServerShared/Scenarios/Belt.cs @@ -5,6 +5,7 @@ using static ItemRotation; public sealed class Belt : Scenario { public override string Name => "Belt"; + public override string Id => "belt"; public override string Brief => "The biggest asteroid belt the generator made, with the Longinus parked 100 m outside its ring, facing along it, " + "and nothing else in the arena. Verify: next and previous target step through the rocks and the reticle sweeps " + "across the belt without a hitch; Previous with nothing targeted takes the farthest rock; the target indicator " + diff --git a/Assets/Scripts/ServerShared/Scenarios/Crossing.cs b/Assets/Scripts/ServerShared/Scenarios/Crossing.cs index 053de4f98..61529d6d9 100644 --- a/Assets/Scripts/ServerShared/Scenarios/Crossing.cs +++ b/Assets/Scripts/ServerShared/Scenarios/Crossing.cs @@ -4,6 +4,7 @@ using static ItemRotation; public sealed class Crossing : Scenario { public override string Name => "Crossing"; + public override string Id => "crossing"; public override string Brief => "A Lightning Projector at the starboard gun and a Cell Blaster at the port gun, an unpiloted Djinni coasting across the bow " + "close ahead and an unpiloted one waiting at range. The crosser is launched to port at 50 m/s and only coasts: nothing steers it, " + "its hull drag slows it to about 25 m/s at the bow line, and it does not shoot back. Make the pass coasting: give no throttle until the Djinni has crossed the bow, then fly freely " + diff --git a/Assets/Scripts/ServerShared/Scenarios/DemoTerminus.cs b/Assets/Scripts/ServerShared/Scenarios/DemoTerminus.cs index 8cda7c600..f4dce7be7 100644 --- a/Assets/Scripts/ServerShared/Scenarios/DemoTerminus.cs +++ b/Assets/Scripts/ServerShared/Scenarios/DemoTerminus.cs @@ -17,6 +17,7 @@ public sealed class DemoTerminus : Scenario }; public override string Name => "Terminus (Demo)"; + public override string Id => "demo-terminus"; public override string Brief => "One gate, one region, one boss. Allied: the Pirates. Opposed: Zhestokost. " + "Neutral: Lucent Media and Aeronautics Unlimited."; diff --git a/Assets/Scripts/ServerShared/Scenarios/DjinniShakedown.cs b/Assets/Scripts/ServerShared/Scenarios/DjinniShakedown.cs index 7cbec5630..43d23d0b3 100644 --- a/Assets/Scripts/ServerShared/Scenarios/DjinniShakedown.cs +++ b/Assets/Scripts/ServerShared/Scenarios/DjinniShakedown.cs @@ -3,6 +3,7 @@ using float2 = CultMath.float2; public sealed class DjinniShakedown : Scenario { public override string Name => "Djinni Shakedown"; + public override string Id => "djinni-shakedown"; public override string Brief => "A fitted Djinni in empty space, facing +z, with a bare hull dead ahead and one abeam. Verify: its thrusters " + "move and turn it; with the hull offline and thrusters firing, its speed does not run away. Its " + "articulated ballistic barrel points at the hull ahead when it bears, and rests at the arc edge while you " + diff --git a/Assets/Scripts/ServerShared/Scenarios/Duel.cs b/Assets/Scripts/ServerShared/Scenarios/Duel.cs index 62dff34ae..67bca118d 100644 --- a/Assets/Scripts/ServerShared/Scenarios/Duel.cs +++ b/Assets/Scripts/ServerShared/Scenarios/Duel.cs @@ -4,6 +4,7 @@ using static ItemRotation; public sealed class Duel : Scenario { public override string Name => "Duel"; + public override string Id => "duel"; public override string Brief => "One piloted, hostile Longinus with slow guided missiles and a Light Laser in its hold. Verify: rolled " + "hits and near-misses, subsystem aim, beam reads above bow, armour takes the missile, loot keeps its brand."; public override uint Seed => 104; diff --git a/Assets/Scripts/ServerShared/Scenarios/FusedRounds.cs b/Assets/Scripts/ServerShared/Scenarios/FusedRounds.cs index b2d70c98e..7498465c3 100644 --- a/Assets/Scripts/ServerShared/Scenarios/FusedRounds.cs +++ b/Assets/Scripts/ServerShared/Scenarios/FusedRounds.cs @@ -4,6 +4,7 @@ using static ItemRotation; public sealed class FusedRounds : Scenario { public override string Name => "Fused Rounds"; + public override string Id => "fused-rounds"; public override string Brief => "The Smoke Test Hull with Smoke Proximity, Smoke Delayed and a Cluster Cannon; a hostile bare Longinus at mid range, a bare " + "hull dead ahead, a fragile one to starboard, a close one inside the proximity blast radius, and one astern. " + "Verify with nothing selected: aim past every hull and the round bursts at max range along the aim; aim at the hull " + diff --git a/Assets/Scripts/ServerShared/Scenarios/LauncherAngles.cs b/Assets/Scripts/ServerShared/Scenarios/LauncherAngles.cs index 62258b9cb..61a798b1c 100644 --- a/Assets/Scripts/ServerShared/Scenarios/LauncherAngles.cs +++ b/Assets/Scripts/ServerShared/Scenarios/LauncherAngles.cs @@ -4,6 +4,7 @@ using static ItemRotation; public sealed class LauncherAngles : Scenario { public override string Name => "Launcher Angles"; + public override string Id => "launcher-angles"; public override string Brief => "A Guided Missile Launcher, a Swarm Rocket Pod and two Cell Blasters; a hostile bare Longinus broadside on, another beside it. " + "Verify: hits pulse only the facing edge; a Swarm Rocket Pod round whose target dies fades unexploded."; public override uint Seed => 105; diff --git a/Assets/Scripts/ServerShared/Scenarios/LockOn.cs b/Assets/Scripts/ServerShared/Scenarios/LockOn.cs index 171b34688..4f8c4dfa9 100644 --- a/Assets/Scripts/ServerShared/Scenarios/LockOn.cs +++ b/Assets/Scripts/ServerShared/Scenarios/LockOn.cs @@ -4,6 +4,7 @@ using static ItemRotation; public sealed class LockOn : Scenario { public override string Name => "Lock On"; + public override string Id => "lock-on"; public override string Brief => "A hostile closes from out of sight, finds you and locks. Verify: no warning at first; once it locks, one " + "indicator appears toward it and tightens as the lock grows; break line of sight or range and it vanishes; " + "your own ping shows no incoming paint on its side, only the lock does."; diff --git a/Assets/Scripts/ServerShared/Scenarios/LongHaul.cs b/Assets/Scripts/ServerShared/Scenarios/LongHaul.cs index bb5381b10..18e158247 100644 --- a/Assets/Scripts/ServerShared/Scenarios/LongHaul.cs +++ b/Assets/Scripts/ServerShared/Scenarios/LongHaul.cs @@ -3,6 +3,7 @@ using float2 = CultMath.float2; public sealed class LongHaul : Scenario { public override string Name => "Long Haul"; + public override string Id => "long-haul"; public override string Brief => "A standard ship in a populated galaxy. Verify: memory stays flat across zones with kills; " + "warp, quit and Continue keep items, tier and brand; death disables Continue."; public override uint Seed => 106; diff --git a/Assets/Scripts/ServerShared/Scenarios/MainGalaxy.cs b/Assets/Scripts/ServerShared/Scenarios/MainGalaxy.cs index f32e205d3..3e4482d3c 100644 --- a/Assets/Scripts/ServerShared/Scenarios/MainGalaxy.cs +++ b/Assets/Scripts/ServerShared/Scenarios/MainGalaxy.cs @@ -4,6 +4,7 @@ using float2 = CultMath.float2; public sealed class MainGalaxy : Scenario { public override string Name => "Main Galaxy"; + public override string Id => "main-galaxy"; public override string Brief => "The full galaxy: factions drawn at random, bosses guarding the road to the exit."; public override Galaxy Generate(GalaxyStage stage) => stage.Main(); diff --git a/Assets/Scripts/ServerShared/Scenarios/Pursuit.cs b/Assets/Scripts/ServerShared/Scenarios/Pursuit.cs index bd067b96d..d6d630a88 100644 --- a/Assets/Scripts/ServerShared/Scenarios/Pursuit.cs +++ b/Assets/Scripts/ServerShared/Scenarios/Pursuit.cs @@ -9,6 +9,7 @@ public sealed class Pursuit : Scenario private const float Astern = 300; public override string Name => "Pursuit"; + public override string Id => "pursuit"; public override string Brief => "A hostile Longinus on your tail; a Mine Launcher fitted, three Coolant Vents and two Thruster " + "Overdrives aboard. Verify: lay mines in its path, the mine pulses slowly after 2 s, fast when " + "the hostile is near, explodes 2 s later; vent and coast to lose it; overdrive away."; diff --git a/Assets/Scripts/ServerShared/Scenarios/RefusedRounds.cs b/Assets/Scripts/ServerShared/Scenarios/RefusedRounds.cs index 21208adf1..88cdd3fdb 100644 --- a/Assets/Scripts/ServerShared/Scenarios/RefusedRounds.cs +++ b/Assets/Scripts/ServerShared/Scenarios/RefusedRounds.cs @@ -4,6 +4,7 @@ using static ItemRotation; public sealed class RefusedRounds : Scenario { public override string Name => "Refused Rounds"; + public override string Id => "refused-rounds"; public override string Brief => "The Smoke Test Hull with Smoke Refused, Smoke Refused Beam, Smoke Refused Charge and an Autocannon; a bare Longinus " + "dead ahead. Each smoke weapon's blast radius is wider than its range, so it can never be fired. Verify: pull each " + "trigger and a refused shot costs no ammo, energy, heat, sound, visibility or cooldown, the beam never starts and " + diff --git a/Assets/Scripts/ServerShared/Scenarios/StarvedReactor.cs b/Assets/Scripts/ServerShared/Scenarios/StarvedReactor.cs index b04f260fc..2f3dff97b 100644 --- a/Assets/Scripts/ServerShared/Scenarios/StarvedReactor.cs +++ b/Assets/Scripts/ServerShared/Scenarios/StarvedReactor.cs @@ -4,6 +4,7 @@ using static ItemRotation; public sealed class StarvedReactor : Scenario { public override string Name => "Starved Reactor"; + public override string Id => "starved-reactor"; public override string Brief => "Two Light Lasers on a Muon Fusion Reactor, which can fall to a quarter of the Compact Reactor's charge, and a bare hull " + "to shoot. Verify: starvation reads as starvation, brownout degrades, refills stutter."; public override uint Seed => 102; diff --git a/Assets/Scripts/ServerShared/Scenarios/TutorialGalaxy.cs b/Assets/Scripts/ServerShared/Scenarios/TutorialGalaxy.cs index 161a52348..1ff521865 100644 --- a/Assets/Scripts/ServerShared/Scenarios/TutorialGalaxy.cs +++ b/Assets/Scripts/ServerShared/Scenarios/TutorialGalaxy.cs @@ -4,6 +4,7 @@ using float2 = CultMath.float2; public sealed class TutorialGalaxy : Scenario { public override string Name => "Tutorial Galaxy"; + public override string Id => "tutorial-galaxy"; public override string Brief => "A small galaxy of fixed factions, entered in a ship of the protagonist faction."; public override Galaxy Generate(GalaxyStage stage) => stage.Prelude(); diff --git a/Assets/Scripts/ServerShared/Scenarios/WeaponFeel.cs b/Assets/Scripts/ServerShared/Scenarios/WeaponFeel.cs index 329d0671e..0817cfa80 100644 --- a/Assets/Scripts/ServerShared/Scenarios/WeaponFeel.cs +++ b/Assets/Scripts/ServerShared/Scenarios/WeaponFeel.cs @@ -4,6 +4,7 @@ using static ItemRotation; public sealed class WeaponFeel : Scenario { public override string Name => "Weapon Feel"; + public override string Id => "weapon-feel"; public override string Brief => "The Smoke Test Hull with a Charged Scatter Blaster, an Autocannon and a Cell Blaster burst weapon; a bare Longinus dead ahead. " + "Verify: Charged Scatter Blaster and Autocannon fire with effects, particles and action-bar icons; the burst weapon's " + "cadence is as before; a fast release-and-press on the Charged Scatter Blaster starts no second charge."; diff --git a/tests/Aetheria.Shared.Tests/ScenarioTests.cs b/tests/Aetheria.Shared.Tests/ScenarioTests.cs index 6be69b91a..7303931f0 100644 --- a/tests/Aetheria.Shared.Tests/ScenarioTests.cs +++ b/tests/Aetheria.Shared.Tests/ScenarioTests.cs @@ -37,6 +37,7 @@ public sealed partial class RunStartTests } public override string Name => "Scripted"; + public override string Id => "test-scripted"; public override string Brief => "A test's own scenario."; public override uint Seed => _seed; public override bool Ambient => _ambient; From cc5d498b7ea2b88a46407fe46781238a15b7a817 Mon Sep 17 00:00:00 2001 From: soul Date: Wed, 21 Oct 2026 19:04:51 +0200 Subject: [PATCH 090/444] determinism-ledger: tests for the seeds, the scatter, Scenario.Id and the two-AI fight DeterminismLedgerTests (a RunStartTests partial): ItemManager refuses seed 0; ItemSeed is the run seed and step; the unpack scatter ignores the item stream and differs per zone; the SimulationDice streams are distinct; scenario Ids are unique kebab; a two-AI fight is one history, and its digest is pinned for linux-x64 (pin lands in the next commit). Co-Authored-By: Claude Opus 5.5 --- .../DeterminismLedgerTests.cs | 197 ++++++++++++++++++ 1 file changed, 197 insertions(+) create mode 100644 tests/Aetheria.Shared.Tests/DeterminismLedgerTests.cs diff --git a/tests/Aetheria.Shared.Tests/DeterminismLedgerTests.cs b/tests/Aetheria.Shared.Tests/DeterminismLedgerTests.cs new file mode 100644 index 000000000..768774727 --- /dev/null +++ b/tests/Aetheria.Shared.Tests/DeterminismLedgerTests.cs @@ -0,0 +1,197 @@ +/* This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at https://mozilla.org/MPL/2.0/. */ + +using System; +using System.Collections.Generic; +using System.Linq; +using System.Runtime.InteropServices; +using System.Security.Cryptography; +using System.Text; +using System.Text.RegularExpressions; +using CultMath; +using UniRx; +using Xunit; +using static CultMath.math; +using float2 = CultMath.float2; + +// The determinism ledger (aetheria-arena, cut determinism-ledger): every seed a match reaches is stated, never the +// clock's. The item stream is seeded by whoever builds the ItemManager and by RunStart at each step of a run; the unpack +// scatter rides the zone's own dice (ruling run-seed-drops-only); a scenario has a stable Id; and one fight between two +// piloted ships is one history, pinned for the host class that records replays. +public sealed partial class RunStartTests +{ + // The two-AI fight's digest, captured on Yggdrasil. Replays are trusted only on the host class that recorded them + // (ruling replays-per-host-class), and arenas run on Linux. + private const string TwoAiPinnedHostClass = "linux-x64"; + private const string TwoAiPinnedDigest = "UNPINNED"; + private const int TwoAiSteps = 3600; + + [Fact] + public void ItemManagerRefusesSeedZero() + { + var refused = Assert.Throws(() => + new ItemManager(0u, _cache, new ProvenanceLedger(), _items.GameplaySettings, _ => { })); + Assert.Equal("seed", refused.ParamName); + + var seeded = new ItemManager(0xBEEFu, _cache, new ProvenanceLedger(), _items.GameplaySettings, _ => { }); + Assert.Equal(new CultMath.Random(0xBEEFu).state, seeded.Random.state); + } + + [Fact] + public void ItemSeedIsTheRunSeedAndStep() + { + var galaxy = Launch(new Scripted(false, stage => stage.Player(stage.Bare("Djinni"), float2(0, 0)), seed: 4242)).galaxy; + Assert.Equal(4242u, galaxy.Seed); + Assert.Equal(4242u * 0x9E3779B1u + 1, RunStart.ItemSeed(galaxy, RunStart.ArenaStep)); + Assert.Equal(4242u * 0x9E3779B1u + 2, RunStart.ItemSeed(galaxy, RunStart.StageStep)); + var arena = RunStart.ItemSeed(galaxy, RunStart.ArenaStep); + var stage = RunStart.ItemSeed(galaxy, RunStart.StageStep); + var resumed = RunStart.ItemSeed(galaxy, RunStart.ContinueStep); + Assert.NotEqual(arena, resumed); + Assert.NotEqual(stage, resumed); + Assert.NotEqual(0u, RunStart.NoRunSeed); + + // The stream a new run's arena generates on is the one ItemSeed names. + var items = new ItemManager(7u, _cache, new ProvenanceLedger(), _items.GameplaySettings, _ => { }); + RunStart.GenerateArena(items, _zoneSettings, _planetSettings, galaxy, new Scripted(false, _ => { }, seed: 4242)); + var expected = new ItemManager(arena, _cache, new ProvenanceLedger(), _items.GameplaySettings, _ => { }); + RunStart.GenerateArena(expected, _zoneSettings, _planetSettings, galaxy, new Scripted(false, _ => { }, seed: 4242)); + Assert.Equal(expected.Random.state, items.Random.state); + } + + // A pack holding one unpiloted ship at the origin besides the player, from a staged arena. + private (ZonePack pack, Galaxy galaxy) OriginPack() + { + Ship drifter = null; + var (galaxy, arena, staged, failures) = Launch(new Scripted(false, stage => + { + stage.Player(stage.Bare("Djinni"), float2(-50000, -50000)); + drifter = stage.Place(stage.Bare("Longinus"), float2(0, 0)) as Ship; + })); + Assert.True(failures.Count == 0, string.Join("; ", failures)); + Assert.NotNull(drifter); + Assert.False(drifter.IsPlayerShip); + Assert.True(lengthsq(drifter.Position) < 1, "the fixture's drifter is packed at the origin"); + return (arena.PackZone(), galaxy); + } + + private List Scattered(ZonePack pack, GalaxyZone galaxyZone, Galaxy galaxy) + { + var zone = new Zone(_items, _planetSettings, pack, galaxyZone, galaxy); + return zone.Entities.OfType().Where(ship => !ship.IsPlayerShip).Select(ship => ship.Position).ToList(); + } + + [Fact] + public void ScatterIgnoresTheItemStream() + { + var (pack, galaxy) = OriginPack(); + _items.Random = new CultMath.Random(11); + var first = Scattered(pack, galaxy.Entrance, galaxy); + _items.Random = new CultMath.Random(12); + for (var i = 0; i < 37; i++) _items.Random.NextUInt(); + var second = Scattered(pack, galaxy.Entrance, galaxy); + + Assert.NotEmpty(first); + Assert.All(first, position => Assert.True(lengthsq(position) >= 1, "the drifter was scattered")); + Assert.Equal(first, second); + } + + [Fact] + public void ScatterIsPerZone() + { + var (pack, galaxy) = OriginPack(); + var other = galaxy.Zones.First(zone => zone != galaxy.Entrance && zone.Name != galaxy.Entrance.Name); + var here = Scattered(pack, galaxy.Entrance, galaxy); + var there = Scattered(pack, other, galaxy); + Assert.NotEmpty(here); + Assert.Equal(here.Count, there.Count); + Assert.NotEqual(here, there); + } + + [Fact] + public void SimulationDiceStreamsAreDistinct() + { + var streams = new[] + { + SimulationDice.LootStream, SimulationDice.MineStream, SimulationDice.CombatSampleStream, + SimulationDice.PatrolStream, SimulationDice.ScatterStream + }; + Assert.Equal(streams.Length, streams.Distinct().Count()); + } + + [Fact] + public void ScenarioIdsAreUniqueKebabAndStable() + { + var kebab = new Regex("^[a-z0-9]+(-[a-z0-9]+)*$"); + var every = EveryScenario.ToList(); + Assert.All(every, scenario => Assert.True(scenario.Id != null && kebab.IsMatch(scenario.Id), + $"{scenario.GetType().Name}: Id is not lowercase kebab")); + Assert.Equal(every.Count, every.Select(scenario => scenario.Id).Distinct().Count()); + Assert.Equal("duel", new Duel().Id); + Assert.Equal("demo-terminus", new DemoTerminus().Id); + } + + // Two piloted fighters about 900 apart, each approaching the other, hostile both ways; the player bare, unpiloted + // and far from the fight. Steps 3600 times at 1/60 s and returns the SHA-256 of the joined snapshots. + private (string digest, bool bothPiloted, int hits, bool hullLost) TwoAiFight() + { + Ship east = null, west = null; + var (_, arena, _, failures) = Launch(new Scripted(false, stage => + { + stage.Player(stage.Bare("Djinni"), float2(-50000, -50000)); + east = stage.Place(EvLonginus(stage), float2(450, 0), facing: float2(-1, 0), piloted: true) as Ship; + west = stage.Place(EvLonginus(stage), float2(-450, 0), facing: float2(1, 0), piloted: true) as Ship; + stage.Approach(east, west); + stage.Approach(west, east); + })); + Assert.True(failures.Count == 0, string.Join("; ", failures)); + east.SetIff(west, true); + west.SetIff(east, true); + var hullAtStart = east.Hull.Durability + west.Hull.Durability; + var bothPiloted = arena.Agents.Any(agent => agent.Ship == east) && arena.Agents.Any(agent => agent.Ship == west); + + var shots = new List(); + var hits = 0; + using var _ = arena.ShotResolved.Subscribe(outcome => + { + shots.Add($"{outcome.ShotId}:{outcome.Result}"); + if (outcome.Hit) hits++; + }); + var history = new StringBuilder(); + for (var step = 0; step < TwoAiSteps; step++) + { + arena.Update(1f / 60f); + history.Append(Snapshot(arena, shots)).Append('\n'); + } + var digest = Convert.ToHexString(SHA256.HashData(Encoding.UTF8.GetBytes(history.ToString()))).ToLowerInvariant(); + return (digest, bothPiloted, hits, east.Hull.Durability + west.Hull.Durability < hullAtStart); + } + + [Fact] + public void TwoAiFightIsOneHistory() + { + var first = TwoAiFight(); + var second = TwoAiFight(); + Assert.True(first.bothPiloted, "both fighters are piloted"); + Assert.True(first.hits > 0, "at least one shot resolved as a hit"); + Assert.True(first.hullLost, "at least one fighter lost hull"); + Assert.Equal(first.digest, second.digest); + } + + private static string HostClass() => + (RuntimeInformation.IsOSPlatform(OSPlatform.Linux) ? "linux" + : RuntimeInformation.IsOSPlatform(OSPlatform.Windows) ? "windows" + : RuntimeInformation.IsOSPlatform(OSPlatform.OSX) ? "osx" : "other") + + "-" + RuntimeInformation.ProcessArchitecture.ToString().ToLowerInvariant(); + + [Fact] + public void TwoAiDigestIsPinned() + { + var host = HostClass(); + var digest = TwoAiFight().digest; + Assert.True(host == TwoAiPinnedHostClass, + $"TWO-AI-DIGEST host class {host} has no pin (pinned: {TwoAiPinnedHostClass}); measured digest {digest}"); + Assert.True(digest == TwoAiPinnedDigest, $"TWO-AI-DIGEST {host} measured {digest}, pinned {TwoAiPinnedDigest}"); + } +} From ced96568bccd0ab906ad0809b940a229cc37b823 Mon Sep 17 00:00:00 2001 From: soul Date: Wed, 21 Oct 2026 19:06:49 +0200 Subject: [PATCH 091/444] determinism-ledger: the ItemSeed test's expected values wrap as the formula does Co-Authored-By: Claude Opus 5.5 --- tests/Aetheria.Shared.Tests/DeterminismLedgerTests.cs | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/tests/Aetheria.Shared.Tests/DeterminismLedgerTests.cs b/tests/Aetheria.Shared.Tests/DeterminismLedgerTests.cs index 768774727..bc81c2451 100644 --- a/tests/Aetheria.Shared.Tests/DeterminismLedgerTests.cs +++ b/tests/Aetheria.Shared.Tests/DeterminismLedgerTests.cs @@ -43,8 +43,8 @@ public sealed partial class RunStartTests { var galaxy = Launch(new Scripted(false, stage => stage.Player(stage.Bare("Djinni"), float2(0, 0)), seed: 4242)).galaxy; Assert.Equal(4242u, galaxy.Seed); - Assert.Equal(4242u * 0x9E3779B1u + 1, RunStart.ItemSeed(galaxy, RunStart.ArenaStep)); - Assert.Equal(4242u * 0x9E3779B1u + 2, RunStart.ItemSeed(galaxy, RunStart.StageStep)); + Assert.Equal(unchecked(4242u * 0x9E3779B1u + 1), RunStart.ItemSeed(galaxy, RunStart.ArenaStep)); + Assert.Equal(unchecked(4242u * 0x9E3779B1u + 2), RunStart.ItemSeed(galaxy, RunStart.StageStep)); var arena = RunStart.ItemSeed(galaxy, RunStart.ArenaStep); var stage = RunStart.ItemSeed(galaxy, RunStart.StageStep); var resumed = RunStart.ItemSeed(galaxy, RunStart.ContinueStep); From 0524d3729dad5e9b3f109d918877967ad828c4e3 Mon Sep 17 00:00:00 2001 From: soul Date: Wed, 21 Oct 2026 19:09:20 +0200 Subject: [PATCH 092/444] determinism-ledger: pin the two-AI digest captured on Yggdrasil (linux-x64) Co-Authored-By: Claude Opus 5.5 --- tests/Aetheria.Shared.Tests/DeterminismLedgerTests.cs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/tests/Aetheria.Shared.Tests/DeterminismLedgerTests.cs b/tests/Aetheria.Shared.Tests/DeterminismLedgerTests.cs index bc81c2451..0a5636720 100644 --- a/tests/Aetheria.Shared.Tests/DeterminismLedgerTests.cs +++ b/tests/Aetheria.Shared.Tests/DeterminismLedgerTests.cs @@ -24,7 +24,7 @@ public sealed partial class RunStartTests // The two-AI fight's digest, captured on Yggdrasil. Replays are trusted only on the host class that recorded them // (ruling replays-per-host-class), and arenas run on Linux. private const string TwoAiPinnedHostClass = "linux-x64"; - private const string TwoAiPinnedDigest = "UNPINNED"; + private const string TwoAiPinnedDigest = "8ba2116ed0ecd574703846f493c408936d536e3b7c9e4652556103ec1b75098c"; private const int TwoAiSteps = 3600; [Fact] From ee78b85fd01dc0501a8b049de5007c1ddaa194dd Mon Sep 17 00:00:00 2001 From: soul Date: Wed, 21 Oct 2026 20:26:02 +0200 Subject: [PATCH 093/444] hull_source: delete the nose-ward muzzle rule; gun facing is HullData's The Blender scene cannot know which way a hardpoint points: HullData's hardpoint rotation owns that (ruling aetheria-release:ruling:gun-facing-is-hulldata). Deletes the forward list and its term in the muzzle check, the two facing tests, and the smoke's nose-ward require. Pairing stays whole. Co-Authored-By: Claude Opus 5.5 --- tools/blender/aetheria_ships/hull_source.py | 8 +++----- tools/blender/tests/smoke_hull_source.py | 1 - tools/blender/tests/test_hull_source.py | 17 ++--------------- 3 files changed, 5 insertions(+), 21 deletions(-) diff --git a/tools/blender/aetheria_ships/hull_source.py b/tools/blender/aetheria_ships/hull_source.py index e513a7454..50a932e0d 100644 --- a/tools/blender/aetheria_ships/hull_source.py +++ b/tools/blender/aetheria_ships/hull_source.py @@ -344,13 +344,11 @@ def _checks(hull, own, judge): by_id = {a.id: a for a in hull.anchors} unmuzzled = [a.id for a in hull.anchors if a.role == "weapon-mount" and not any(m.role == "weapon-muzzle" and m.parent == a.id for m in hull.anchors)] - forward = [m.id for m in hull.anchors if m.role == "weapon-muzzle" and m.parent in by_id - and by_id[m.parent].role == "weapon-mount" and m.position[1] >= by_id[m.parent].position[1]] orphans = [m.id for m in hull.anchors if m.role == "weapon-muzzle" and (m.parent not in by_id or by_id[m.parent].role != "weapon-mount")] - checks.append(Check("every weapon-mount has a muzzle nose-ward of it and no muzzle is orphaned", - not (unmuzzled or forward or orphans), - f"{len(unmuzzled)} without a muzzle, {len(forward)} not nose-ward, {len(orphans)} orphaned")) + checks.append(Check("every weapon-mount has a muzzle and no muzzle is orphaned", + not (unmuzzled or orphans), + f"{len(unmuzzled)} without a muzzle, {len(orphans)} orphaned")) return checks diff --git a/tools/blender/tests/smoke_hull_source.py b/tools/blender/tests/smoke_hull_source.py index 41ef836a6..c544339ad 100644 --- a/tools/blender/tests/smoke_hull_source.py +++ b/tools/blender/tests/smoke_hull_source.py @@ -215,7 +215,6 @@ def one_hull(scene, number, fixture, ship_id, extent, length, rows, body, palett for muzzle in roles["weapon-muzzle"]: mount = first[muzzle].parent require(mount.get("aetheria.role") == "weapon-mount" and first[muzzle].get("aetheria.order") == 1, "A muzzle is not ordered under its mount") - require(first[muzzle].matrix_world.translation.y < mount.matrix_world.translation.y, "A muzzle is not nose-ward of its mount") require(layout_rows(scene) == rows_before, "Build changed the layout buffer") check_poses(collection, root, runner) if fixture == "zh-ammo-tender": diff --git a/tools/blender/tests/test_hull_source.py b/tools/blender/tests/test_hull_source.py index c1045dba0..2a1f737f4 100644 --- a/tools/blender/tests/test_hull_source.py +++ b/tools/blender/tests/test_hull_source.py @@ -57,7 +57,7 @@ class Fixture: def __init__(self): self.library = {"zhestokost": language()} self.hull_lang = None - # Body 6 wide, 10 long, 2 high (h/w 0.33, w/l 0.6); a bilateral plate on top; one gun with a muzzle nose-ward. + # Body 6 wide, 10 long, 2 high (h/w 0.33, w/l 0.6); a bilateral plate on top; one gun with a muzzle. self.parts = { "brief": ("", brief()), "primary": (box(-3, 3, -5, 5, 0, 2), ""), @@ -221,20 +221,7 @@ class JudgementTests(unittest.TestCase): fixture.parts["brief"] = ("", brief(length=length)) self.assertEqual(fixture.failed(), ["the length lies inside the brief's range"], length) - MUZZLE = "every weapon-mount has a muzzle nose-ward of it and no muzzle is orphaned" - - def test_a_muzzle_aft_of_its_mount_is_refused(self): - fixture = Fixture() - fixture.parts["anchors"] = ("", echo("anchor", ["Gun.A", "weapon-mount", [0, 0, 3], 0, ""]) - + echo("anchor", ["Gun.A.muzzle", "weapon-muzzle", [0, 1, 3], 0, "Gun.A"]) - + echo("anchor", ["Th.A", "thruster-emitter", [1, 5, 1], 180, ""])) - self.assertEqual(fixture.failed(), [self.MUZZLE]) - - def test_a_muzzle_level_with_its_mount_is_not_nose_ward(self): - fixture = Fixture() - fixture.parts["anchors"] = ("", echo("anchor", ["Gun.A", "weapon-mount", [0, 0, 3], 0, ""]) - + echo("anchor", ["Gun.A.muzzle", "weapon-muzzle", [1, 0, 3], 0, "Gun.A"])) - self.assertEqual(fixture.failed(), [self.MUZZLE]) + MUZZLE = "every weapon-mount has a muzzle and no muzzle is orphaned" def test_a_mount_without_a_muzzle_and_a_muzzle_without_a_mount_are_refused(self): fixture = Fixture() From 6f12283dc01d46a9dfbcfe7e8dab11f56a23e71e Mon Sep 17 00:00:00 2001 From: soul Date: Wed, 21 Oct 2026 20:26:15 +0200 Subject: [PATCH 094/444] gravity map: laned-belts mapped and admitted; lane host and half-width cells filled Co-Authored-By: Claude Opus 5.5 --- docs/aetheria-gravity-map.md | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/docs/aetheria-gravity-map.md b/docs/aetheria-gravity-map.md index d87657cbd..b95ddec91 100644 --- a/docs/aetheria-gravity-map.md +++ b/docs/aetheria-gravity-map.md @@ -131,11 +131,11 @@ Cells not filled, plainly: whether a per-body multiplier on c or the core is eve | **Rock** (`Asteroid`) | (belt key, index) is the `ChunkId`. The index order is fixed at generation and never re-sorted, because wear (`ChunkWear`) is keyed by it. | Persisted (Distance, Phase, Size, RotationSpeed) about the belt's parent; wear and respawn live on Zone. | The generator draws a rock. Zone owns its wear and its pose (`AsteroidBelt.Pose`). | | **Period rule** | A property of the belt, not of the rock: the persisted flag `AsteroidBeltData.RidesRail` (MessagePack key 11, JSON `ridesRail`). False: each rock reads its own distance (`PeriodAround(parent, rock.Distance)`), an annulus. True: every rock takes its rail's period (`Orbits[rail].Period`), so a Trojan swarm keeps its shape (ruling `trojans-ride-host-period-with-stations`; on own-distance rails it shears within an hour, B60). | Persisted at generation. A belt saved before key 11 reads false, the annulus it was (CultCache defaults a missing slot, B61). | Zone's constructor alone writes `AsteroidBelt.Periods`, through PeriodAround or the rail's own Period; the flag only says which. Forbidden: a period formula in Pose, BeltTargets, DrawAhead, the renderer or the generator, and a second flag or shape enum read anywhere else. | | **Extent rule** | What a belt's rail adds to its parent's L_F: `AsteroidBelt.Extent(railDistance)`, the outermost rock for an annulus, the rail's own Distance for a swarm, whose rocks' periods never read their own distance. So a swarm moves no well (B61). An edge (Oort-like) belt must add nothing beyond the window, or the family's fade band narrows (B60). | Derived at zone build. | `AsteroidBelt.Extent`, read by Zone (the only WellNode builder in play) and by the station tests' rebuilt tree. The generator's clearance tree never sees a swarm, which is not a GeneratorPlanet. | -| **Belt host** | A generator-time relation only. A Trojan host is a planet or gas giant (PlanetMass < mass <= SunMass) whose parent is a body, at a distance > 0. A ring member is never a host: its rail's pull adds its partners' (WellTree.PullAround), so a rail copied beside it would take another period and shear (B61). The swarm's rail copies the host's (Parent, Distance, Phase); that copy is all Zone needs, and nothing persists the host. A lane's host waits on laned-belts. | Lives for one GenerateZone call. | ZoneGenerator.GenerateZone's swarm pass. | +| **Belt host** | A generator-time relation only. A Trojan host is a planet or gas giant (PlanetMass < mass <= SunMass) whose parent is a body, at a distance > 0. A ring member is never a host: its rail's pull adds its partners' (WellTree.PullAround), so a rail copied beside it would take another period and shear (B61). The swarm's rail copies the host's (Parent, Distance, Phase); that copy is all Zone needs, and nothing persists the host. A lane's host is a body that keeps its slot with room on both sides of the lane (cut laned-belts: half the eligible bodies, never a Trojan host, one shape per parent). | Lives for one GenerateZone call. | ZoneGenerator.GenerateZone's swarm pass. | | **Lagrange station orbit** | A bodyless OrbitData on a host's rail, 1/6 turn ahead of or behind it (CreateLagrangeOrbit). On a swarm host it sits inside the swarm, a mining outpost among the rocks (ruling `trojans-ride-host-period-with-stations`); rocks are on the floater layer below it, so nothing collides. | Created at generation and persisted. | ZoneGenerator's station placement, unchanged: candidacy reads the GeneratorPlanet tree, where no swarm exists, so no swarm can bar its host. | | **Belt generation settings** | `ZoneGenerationSettings` in Settings.asset (an LFS object): BeltProbability 0.25, BeltMassCeiling 500, AsteroidBeltWidth (a ceiling that never binds, B56), AsteroidCount = sqrt(mass x D) + 11. Trojans: TrojanProbability 0.15 per host, TrojanArc 0.07 turn per swarm, TrojanHalfWidth 0.06 D, TrojanCount = 0.04 D + 8 rocks round each of L4 and L5 (tuning defaults for a flight, B61). The code default of TrojanProbability is 0, so settings built in code draw no swarm. | Authored. | Settings.asset. Forbidden: a tunable shape's numbers as constants in code. The swarm's random stream salt is a ZoneGenerator constant, not a tunable. | -Cells not filled, plainly: the lane's host set and half-width (laned-belts' map proposes the visual margin, ruling `lanes-two-sided-visual-margin`); where an edge cloud's rocks sit between a root's window and the zone boundary (edge-material's map, rulings `edge-material-root-only` and `edge-material-in-the-dark`). The edge material's place in the potential is not a question: it stays out of the extent, because the law breaks otherwise (B60). +Cells not filled, plainly: where an edge cloud's rocks sit between a root's window and the zone boundary (edge-material's map, rulings `edge-material-root-only` and `edge-material-in-the-dark`). The edge material's place in the potential is not a question: it stays out of the extent, because the law breaks otherwise (B60). ## Rationale @@ -180,5 +180,5 @@ Mapped and admitted (2026-10-14, base 11b2d7ad): `aetheria-gravity:cut_spec:cut- - **grid-noise-presentation**: the Sector Waves noise (after grid-lowering on layer 10, drawn only into the nebula patch height) and fine-grid detail move to non-slope presentation (grid-gravity-only-ruled); it also owns the Main Menu's own Surface Displacement brushes (B29, follow-up `menu-grid-brushes`). Waits on grid-lowering. - **top-speed-field**: top speed becomes a hull field; no drag below it, drag on the excess above it; the VelocityLimit clamp (Behaviors/VelocityLimit.cs) and always-on hull drag (Ship.cs:127-129) go; AI pilots brake actively; open: drag shape above the cap, consumables as modifiers, whether loot and wreckage come to rest (aetheria-gravity:ruling:no-drag-below-top-speed, target invariant orbits-do-not-decay). Waits on potential-owner and orbits-from-field. - **belt-shapes** (follow-up `belt-shapes`, ruling `belt-shapes-direction`; B60, B61; order by ruling `trojans-first`). One cut per shape. trojan-swarms is mapped and admitted (2026-10-18, base 67517f40): `aetheria-gravity:cut_spec:cut-trojan-swarms.r1`. - - **laned-belts**: only a body with room on both sides of its lane keeps a laned belt, and the lane's half-width is its own visual margin, not the placement Roche margin (ruling `lanes-two-sided-visual-margin`; 468 eligible at the Roche width, B60). Folds in `belt-after-belt-edge-pin`, in the same Place loop. + - **laned-belts** (mapped and admitted 2026-10-21, base d52b50f9: `aetheria-gravity:cut_spec:cut-laned-belts.r1`; lane half-width 5 body radii, a flight default): only a body with room on both sides of its lane keeps a laned belt, and the lane's half-width is its own visual margin, not the placement Roche margin (ruling `lanes-two-sided-visual-margin`; 468 eligible at the Roche width, B60). Folds in `belt-after-belt-edge-pin`, in the same Place loop. - **edge-material**: one sparse Oort-like cloud per root (per subzone root in a multi-root zone), from its family's window out toward the zone boundary, nothing at planets' rims (ruling `edge-material-root-only`); kept out of the extent (B60) and skipped by the populated radius, so its rocks sit in the dark fade (ruling `edge-material-in-the-dark`). From 51e82624d5c4de502b7955827eb16166fef23bcd Mon Sep 17 00:00:00 2001 From: soul Date: Wed, 21 Oct 2026 20:26:17 +0200 Subject: [PATCH 095/444] test_hull_source: pin that a muzzle facing any way passes the hull source Muzzles aft of and beside their mounts pass every check, so a reintroduced facing judgement fails a test. Co-Authored-By: Claude Opus 5.5 --- tools/blender/tests/test_hull_source.py | 10 ++++++++++ 1 file changed, 10 insertions(+) diff --git a/tools/blender/tests/test_hull_source.py b/tools/blender/tests/test_hull_source.py index 2a1f737f4..4cfcaf2e8 100644 --- a/tools/blender/tests/test_hull_source.py +++ b/tools/blender/tests/test_hull_source.py @@ -223,6 +223,16 @@ class JudgementTests(unittest.TestCase): MUZZLE = "every weapon-mount has a muzzle and no muzzle is orphaned" + def test_a_muzzle_facing_any_way_is_accepted(self): + # Which way a gun points is HullData's hardpoint rotation; the hull source never judges it. + fixture = Fixture() + fixture.parts["anchors"] = ("", echo("anchor", ["Gun.A", "weapon-mount", [0, 0, 3], 0, ""]) + + echo("anchor", ["Gun.A.muzzle", "weapon-muzzle", [0, 1, 3], 0, "Gun.A"]) + + echo("anchor", ["Gun.B", "weapon-mount", [0, 0, 3], 0, ""]) + + echo("anchor", ["Gun.B.muzzle", "weapon-muzzle", [1, 0, 3], 0, "Gun.B"]) + + echo("anchor", ["Th.A", "thruster-emitter", [1, 5, 1], 180, ""])) + self.assertEqual(fixture.failed(), []) + def test_a_mount_without_a_muzzle_and_a_muzzle_without_a_mount_are_refused(self): fixture = Fixture() fixture.parts["anchors"] = ("", echo("anchor", ["Gun.A", "weapon-mount", [0, 0, 3], 0, ""])) From 9c50b7499b24cee7c0f5a28df30f051f1957039b Mon Sep 17 00:00:00 2001 From: soul Date: Wed, 21 Oct 2026 20:28:40 +0200 Subject: [PATCH 096/444] FactionProductData.NotOffered: an off-market product is never drawn, stocked, chosen or a gear home One reader, LoadoutGenerator.IsOffered, read by IsAvailable (ahead of the no-faction shortcut), the AvailableProducts candidates (so the any-maker fallback cannot reach one) and SoldHulls. A Loadout that pins the product still builds it. Rulings off-market-is-unoffered-product and boss-hulls-exclusive. Co-Authored-By: Claude Opus 5.5 --- Assets/Scripts/ServerShared/FactionProduct.cs | 6 ++ .../Scripts/ServerShared/LoadoutGenerator.cs | 16 ++- .../ProductChoiceTests.cs | 102 +++++++++++++++++- 3 files changed, 117 insertions(+), 7 deletions(-) diff --git a/Assets/Scripts/ServerShared/FactionProduct.cs b/Assets/Scripts/ServerShared/FactionProduct.cs index b9a3f0009..14f7af57d 100644 --- a/Assets/Scripts/ServerShared/FactionProduct.cs +++ b/Assets/Scripts/ServerShared/FactionProduct.cs @@ -38,6 +38,12 @@ public class FactionProductData // no Assembly; the mint derives both from the line. [Inspectable, JsonProperty("bin"), Key(7)] public ProductBin Bin; + + // Off the market (ruling off-market-is-unoffered-product): the product keeps its maker, brand, quality and price, but no + // station stocks it, no generation or chooser draws it, and its hull gives no gear a home. It reaches a ship only through a + // Loadout that pins it. LoadoutGenerator.IsOffered is its one reader. + [Inspectable, JsonProperty("notOffered"), Key(8)] + public bool NotOffered; } // How good this product's part is in one of its design's roles: a pseudo-gaussian distribution whose mean is diff --git a/Assets/Scripts/ServerShared/LoadoutGenerator.cs b/Assets/Scripts/ServerShared/LoadoutGenerator.cs index 2e925a201..0e2d8fb00 100644 --- a/Assets/Scripts/ServerShared/LoadoutGenerator.cs +++ b/Assets/Scripts/ServerShared/LoadoutGenerator.cs @@ -197,6 +197,8 @@ public class LoadoutGenerator entry.design != null && entry.design.Price > 0 && entry.product.Manufacturer.IsSet() && + // A not-offered product is never a candidate, so the any-manufacturer fallback below cannot reach it either. + IsOffered(entry.product) && (!(entry.design is EquippableItemData equippable) || HasHome(equippable, hulls)) && (filter?.Invoke(entry.design) ?? true)) .ToArray(); @@ -216,9 +218,10 @@ public class LoadoutGenerator // 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. // Only a hull some product sells gives gear a home: a hull no faction builds (the Smoke Test Hull) is a test fixture, so its - // hardpoints never put gear on a shelf. The one definition of the hulls HasHome considers. + // hardpoints never put gear on a shelf. An off-market hull gives no gear a home either: only its pinned Loadout flies it. + // The one definition of the hulls HasHome considers. public static HullData[] SoldHulls(CultCache items) => - items.GetAll().Select(product => items.Get(product.Design)).OfType().Distinct().ToArray(); + items.GetAll().Where(IsOffered).Select(product => items.Get(product.Design)).OfType().Distinct().ToArray(); public static bool HasHome(EquippableItemData design, HullData[] hulls) => design.HardpointType == HardpointType.Tool || design.HardpointType == HardpointType.Hull || @@ -231,9 +234,14 @@ public class LoadoutGenerator // always reaches its own manufacturer's gear, and otherwise gear made by a manufacturer its allegiance names // (operator, 2026-09-30: allegiance lists only other factions). Whether that manufacturer is present in the galaxy // does not matter (operator, 2026-10-06: a faction need not be present for its gear to be available). + // A not-offered product is available to nobody, a scenario stage included (ruling boss-hulls-exclusive). public bool IsAvailable(FactionProductData product) => - Galaxy == null || Faction == null || - ItemManager.ItemData.Get(product.Manufacturer) == Faction || Faction.Allegiance.ContainsKey(product.Manufacturer); + IsOffered(product) && ( + Galaxy == null || Faction == null || + ItemManager.ItemData.Get(product.Manufacturer) == Faction || Faction.Allegiance.ContainsKey(product.Manufacturer)); + + // Whether a product is on the market at all (ruling off-market-is-unoffered-product). The one reader of NotOffered. + public static bool IsOffered(FactionProductData product) => !product.NotOffered; // 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 diff --git a/tests/Aetheria.Shared.Tests/ProductChoiceTests.cs b/tests/Aetheria.Shared.Tests/ProductChoiceTests.cs index 89f262c62..d049e8c05 100644 --- a/tests/Aetheria.Shared.Tests/ProductChoiceTests.cs +++ b/tests/Aetheria.Shared.Tests/ProductChoiceTests.cs @@ -65,8 +65,10 @@ public sealed class ProductChoiceTests : IDisposable 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) + // binFloors adds one bin of lamp-a's line per floor, named "lamp-a bin" and "lamp-a bin 2"..., by the same maker. Every rig also + // holds a Barge, the one hull with a ballistic hardpoint, and a Gun only it mounts, each sold by Alpha. notOffered names the + // products the rig takes off the market. + private Rig Build(float[] binFloors, string[] lampOrder, params string[] notOffered) { var root = Path.Combine(_root, "rig" + _catalogs++); Directory.CreateDirectory(root); @@ -92,10 +94,18 @@ public sealed class ProductChoiceTests : IDisposable Hardpoints = { new HardpointData { Type = HardpointType.Sensors, Position = HardpointCell, Shape = new Shape() } } }); var lamp = seed.Upsert(new GearData { Name = "Lamp", Hardpoint = HardpointType.Sensors, Shape = new Shape(), Price = 10 }); + var barge = seed.Upsert(new HullData + { + Name = "Barge", HullType = HullType.Ship, Shape = shape, Price = 100, + Hardpoints = { new HardpointData { Type = HardpointType.Ballistic, Position = HardpointCell, Shape = new Shape() } } + }); + var gun = seed.Upsert(new GearData { Name = "Gun", Hardpoint = HardpointType.Ballistic, Shape = new Shape(), Price = 10 }); void Sell(string name, CultRecordKey design, CultRecordRef maker, string key) => seed.Commit(batch => batch.Upsert(typeof(FactionProductData), new FactionProductData - { Name = name, Design = new CultRecordRef(design), Manufacturer = maker }, new CultRecordKey(key))); + { Name = name, Design = new CultRecordRef(design), Manufacturer = maker, NotOffered = notOffered.Contains(name) }, new CultRecordKey(key))); Sell("Skiff by Alpha", skiff.Key, alpha, "skiff-a"); + Sell("Barge by Alpha", barge.Key, alpha, "barge-a"); + Sell("Gun by Alpha", gun.Key, alpha, "gun-a"); var makers = new Dictionary> { ["lamp-a"] = alpha, ["lamp-z"] = zeta, ["lamp-m"] = mu }; // lampOrder holds product names; the record key of the i-th inserted is "k" + i, so keys and insertion order // both follow the permutation. @@ -367,5 +377,91 @@ public sealed class ProductChoiceTests : IDisposable { Assert.Equal(4, typeof(Loadout).GetField(nameof(Loadout.HullProduct)).GetCustomAttribute().IntKey); Assert.Equal(3, typeof(LoadoutSlot).GetField(nameof(LoadoutSlot.Product)).GetCustomAttribute().IntKey); + Assert.Equal(8, typeof(FactionProductData).GetField(nameof(FactionProductData.NotOffered)).GetCustomAttribute().IntKey); + } + + // Ruling off-market-is-unoffered-product: a not-offered product is never chosen, by a faction that reaches its maker or by a + // stage with no faction, which otherwise reaches every maker (ruling boss-hulls-exclusive). + [Fact] + public void ANotOfferedProductIsNeverChosen() + { + var rig = Build(new float[0], new[] { "lamp-z", "lamp-a" }, "lamp-a"); + var lamp = rig.Cache.GetByName("Lamp"); + for (var i = 1; i <= 64; i++) + { + Assert.Equal("lamp-z", rig.Outfitter(null, Seed(i)).ChooseProduct(lamp).Name); + Assert.Equal("lamp-z", rig.Outfitter(rig.OutEven, Seed(i), 0).ChooseProduct(lamp).Name); + // OutA reaches only Alpha, whose lamp is off the market. + Assert.Null(rig.Outfitter(rig.OutA, Seed(i)).ChooseProduct(lamp)); + } + Assert.False(rig.Outfitter(null, 1).IsAvailable(rig.Product("lamp-a"))); + Assert.True(rig.Outfitter(null, 1).IsAvailable(rig.Product("lamp-z"))); + } + + // The any-manufacturer fallback of a required draw is closed to off-market products: when every product of the design is + // not offered the draw yields nothing, for a faction that reaches the maker, one that reaches nobody, and no faction. + [Fact] + public void ARequiredDrawNeverFallsBackToANotOfferedProduct() + { + var rig = Build(new float[0], new[] { "lamp-z", "lamp-a" }, "lamp-z", "lamp-a"); + var lamp = rig.Cache.GetByName("Lamp"); + foreach (var faction in new[] { rig.OutA, rig.OutNone, null }) + for (var i = 1; i <= 16; i++) + Assert.Null(rig.Outfitter(faction, Seed(i)).RandomProduct(0, design => design == lamp, required: true).product); + + // With one product still offered, a faction that reaches only the other maker falls back to it: the empty draw above is + // the field's doing, not a closed fallback. + var one = Build(new float[0], new[] { "lamp-z", "lamp-a" }, "lamp-a"); + var oneLamp = one.Cache.GetByName("Lamp"); + for (var i = 1; i <= 16; i++) + Assert.Equal("lamp-z", one.Outfitter(one.OutA, Seed(i)).RandomProduct(0, design => design == oneLamp, required: true).product.Name); + } + + // A hull sold only off the market is no gear's home: gear only it mounts is never drawn, so no station stocks it. + [Fact] + public void AnOffMarketHullGivesNoGearAHome() + { + var sold = Build("lamp-a"); + var soldGun = sold.Cache.GetByName("Gun"); + Assert.Contains(sold.Cache.GetByName("Barge"), LoadoutGenerator.SoldHulls(sold.Cache)); + Assert.Equal("Gun by Alpha", sold.Outfitter(null, 1).RandomProduct(0, design => design == soldGun, required: true).product.Name); + + var off = Build(new float[0], new[] { "lamp-a" }, "Barge by Alpha"); + var barge = off.Cache.GetByName("Barge"); + var gun = off.Cache.GetByName("Gun"); + var hulls = LoadoutGenerator.SoldHulls(off.Cache); + Assert.DoesNotContain(barge, hulls); + Assert.Contains(off.Cache.GetByName("Skiff"), hulls); + Assert.False(LoadoutGenerator.HasHome(gun, hulls)); + foreach (var faction in new[] { off.OutA, null }) + for (var i = 1; i <= 16; i++) + { + var generator = off.Outfitter(faction, Seed(i)); + Assert.Null(generator.RandomProduct(0, design => design == gun, required: true).product); + Assert.Null(generator.RandomProduct(0, design => design == barge, required: true).product); + } + } + + // A Loadout that pins a not-offered hull product still builds it, under its brand: that is how a boss flies its hull. + [Fact] + public void APinnedNotOfferedHullStillMaterializes() + { + var rig = Build(new float[0], new[] { "lamp-a" }, "Barge by Alpha"); + var fit = new Loadout + { + Name = "Boss barge", + Hull = rig.Cache.RefOf(rig.Cache.GetByName("Barge")), + HullProduct = rig.ProductRef("Barge by Alpha"), + WeaponGroups = new int[0][] + }; + var failures = new List(); + var ship = Materialize(rig, fit, rig.Outfitter(rig.OutA, 1).ChooseProduct, failures); + Assert.Empty(failures); + Assert.Equal("Barge by Alpha", rig.Items.Brand(ship.Hull).Product.Name); + + // Unpinned, the same hull has no product to be built as. + fit.HullProduct = default; + Assert.Null(Materialize(rig, fit, rig.Outfitter(null, 1).ChooseProduct, failures)); + Assert.NotEmpty(failures); } } From 9cedb4efe667dc8efe306a6008a48f359785ceb5 Mon Sep 17 00:00:00 2001 From: soul Date: Wed, 21 Oct 2026 20:28:41 +0200 Subject: [PATCH 097/444] AetherDb ship-authoring product --not-offered writes the package product off the market Co-Authored-By: Claude Opus 5.5 --- .../Aetheria.Shared.Tests/ShipProductTests.cs | 26 +++++++++++++++++-- tools/AetherDb/ShipAuthoringCommands.cs | 16 +++++++----- 2 files changed, 34 insertions(+), 8 deletions(-) diff --git a/tests/Aetheria.Shared.Tests/ShipProductTests.cs b/tests/Aetheria.Shared.Tests/ShipProductTests.cs index f6f3bad10..77242916f 100644 --- a/tests/Aetheria.Shared.Tests/ShipProductTests.cs +++ b/tests/Aetheria.Shared.Tests/ShipProductTests.cs @@ -272,6 +272,8 @@ public sealed class ShipProductTests : IDisposable RefusedAndUntouched("product", path, "ACM"); RefusedAndUntouched("product", path, "ACM", "--name", " "); RefusedAndUntouched("product", path, "ACM", "--remove"); + RefusedAndUntouched("product", path, "ACM", "--not-offered"); + RefusedAndUntouched("product", path, "ACM", "--name", "--not-offered"); var (code, _, error) = Run("product", path, "ACM", "--name", "Acme Skiff", "--description", "Reliable."); Assert.True(code == 0, error); @@ -283,10 +285,29 @@ public sealed class ShipProductTests : IDisposable Assert.Equal(ShipModCatalog.HullKey("mod.skiff"), product.Design.Key); Assert.Equal(hull.Roles.Select(role => role.Name), product.Roles.Select(role => role.Role)); Assert.Equal("Reliable.", product.Description); + Assert.False(product.NotOffered); + + // --not-offered writes the product off the market (ruling off-market-is-unoffered-product), with or without a description. + var (offCode, offOutput, offError) = Run("product", path, "ACM", "--name", "Acme Boss Skiff", "--not-offered"); + Assert.True(offCode == 0, offError); + Assert.Contains("not offered", offOutput); + var off = Assert.Single(ShipAuthoringStore.Load(path).Products); + Assert.True(off.NotOffered); + Assert.Equal("Acme Boss Skiff", off.Name); + Assert.Null(off.Description); + Assert.Equal(0, Run("product", path, "ACM", "--name", "Acme Boss Skiff", "--description", "Unsold.", "--not-offered").Code); + off = Assert.Single(ShipAuthoringStore.Load(path).Products); + Assert.True(off.NotOffered); + Assert.Equal("Unsold.", off.Description); // The maker by name replaces the product by short name: a maker has one product of the hull. - Assert.Equal(0, Run("product", path, "Acme", "--name", "Renamed").Code); - Assert.Equal("Renamed", Assert.Single(ShipAuthoringStore.Load(path).Products).Name); + // Written again without the flag, it is back on the market. + var (renamedCode, renamedOutput, _) = Run("product", path, "Acme", "--name", "Renamed"); + Assert.Equal(0, renamedCode); + Assert.DoesNotContain("not offered", renamedOutput); + var renamed = Assert.Single(ShipAuthoringStore.Load(path).Products); + Assert.Equal("Renamed", renamed.Name); + Assert.False(renamed.NotOffered); RefusedAndUntouched("price", path, "-1"); RefusedAndUntouched("price", path, "1.5"); @@ -294,6 +315,7 @@ public sealed class ShipProductTests : IDisposable Assert.Equal(0, Run("price", path, "250").Code); Assert.Equal(250, ShipAuthoringStore.Read(path).Hull.Price); + RefusedAndUntouched("product", path, "ACM", "--remove", "--not-offered"); Assert.Equal(0, Run("product", path, "ACM", "--remove").Code); Assert.Empty(ShipAuthoringStore.Load(path).Products); RefusedAndUntouched("product", path, "ACM", "--remove"); diff --git a/tools/AetherDb/ShipAuthoringCommands.cs b/tools/AetherDb/ShipAuthoringCommands.cs index 9fd7e690a..b745e3dbf 100644 --- a/tools/AetherDb/ShipAuthoringCommands.cs +++ b/tools/AetherDb/ShipAuthoringCommands.cs @@ -8,7 +8,7 @@ using System.Linq; // Uses the existing database executable; mod authoring does not earn another binary target. public static class ShipAuthoringCommands { - private const string Usage = "ship-authoring create [--like ] | inspect | centre | validate | product (--name [--description ] | --remove) | price | compose "; + private const string Usage = "ship-authoring create [--like ] | inspect | centre | validate | product (--name [--description ] [--not-offered] | --remove) | price | compose "; public static int Run(string[] args) { @@ -93,8 +93,11 @@ public static class ShipAuthoringCommands } if (args[0] == "product" && args.Length >= 4) { - var remove = args.Length == 4 && args[3] == "--remove"; - var named = args.Length >= 5 && args[3] == "--name" && (args.Length == 5 || args.Length == 7 && args[5] == "--description"); + // A trailing --not-offered takes the product off the market (ruling off-market-is-unoffered-product); --remove takes none. + var notOffered = args[args.Length - 1] == "--not-offered"; + var count = notOffered ? args.Length - 1 : args.Length; + var remove = !notOffered && count == 4 && args[3] == "--remove"; + var named = count >= 5 && args[3] == "--name" && (count == 5 || count == 7 && args[5] == "--description"); if (!remove && !named) { Console.Error.WriteLine(Usage); return 1; } var (hull, ship, products) = ShipAuthoringStore.Load(args[1]); var (makerKey, makerName) = ShippedFaction(args[2]); @@ -112,14 +115,15 @@ public static class ShipAuthoringCommands ShipAuthoringStore.WriteProduct(cache, ship.Id, new FactionProductData { Name = args[4], - Description = args.Length == 7 ? args[6] : null, + Description = count == 7 ? args[6] : null, Design = new CultRecordRef(ShipModCatalog.HullKey(ship.Id)), Manufacturer = new CultRecordRef(makerKey), - Roles = hull.Roles.Select(role => new ProductRole { Role = role.Name }).ToList() + Roles = hull.Roles.Select(role => new ProductRole { Role = role.Name }).ToList(), + NotOffered = notOffered }); cache.FlushAsync().Wait(); } - Console.WriteLine($"{ship.Id}: {(remove ? "removed" : "wrote")} the product by {makerName}."); + Console.WriteLine($"{ship.Id}: {(remove ? "removed" : "wrote")} the product by {makerName}{(notOffered ? ", not offered" : "")}."); return 0; } Console.Error.WriteLine(Usage); From 3d005ab5597f58365733223fb3cb0444f5c0f76d Mon Sep 17 00:00:00 2001 From: soul Date: Wed, 21 Oct 2026 20:30:04 +0200 Subject: [PATCH 098/444] LoadoutGenerator: IsAvailable's off-market comment names no boss term Co-Authored-By: Claude Opus 5.5 --- Assets/Scripts/ServerShared/LoadoutGenerator.cs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/Assets/Scripts/ServerShared/LoadoutGenerator.cs b/Assets/Scripts/ServerShared/LoadoutGenerator.cs index 0e2d8fb00..671faf678 100644 --- a/Assets/Scripts/ServerShared/LoadoutGenerator.cs +++ b/Assets/Scripts/ServerShared/LoadoutGenerator.cs @@ -234,7 +234,7 @@ public class LoadoutGenerator // always reaches its own manufacturer's gear, and otherwise gear made by a manufacturer its allegiance names // (operator, 2026-09-30: allegiance lists only other factions). Whether that manufacturer is present in the galaxy // does not matter (operator, 2026-10-06: a faction need not be present for its gear to be available). - // A not-offered product is available to nobody, a scenario stage included (ruling boss-hulls-exclusive). + // A not-offered product is available to nobody, a stage with no faction included. public bool IsAvailable(FactionProductData product) => IsOffered(product) && ( Galaxy == null || Faction == null || From bd5d27c52b0106d678f4723eca7229ecc66b4ebf Mon Sep 17 00:00:00 2001 From: soul Date: Wed, 21 Oct 2026 20:31:47 +0200 Subject: [PATCH 099/444] test_hull_source: a mount is not paired by another gun's muzzle Kills the surviving and->or mutant in the kept pairing rule's unmuzzled list. Co-Authored-By: Claude Opus 5.5 --- tools/blender/tests/test_hull_source.py | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/tools/blender/tests/test_hull_source.py b/tools/blender/tests/test_hull_source.py index 4cfcaf2e8..362664de7 100644 --- a/tools/blender/tests/test_hull_source.py +++ b/tools/blender/tests/test_hull_source.py @@ -239,6 +239,11 @@ class JudgementTests(unittest.TestCase): self.assertEqual(fixture.failed(), [self.MUZZLE]) fixture.parts["anchors"] = ("", echo("anchor", ["Gun.A.muzzle", "weapon-muzzle", [0, -3, 3], 0, "Gun.A"])) self.assertEqual(fixture.failed(), [self.MUZZLE]) + # Another gun's muzzle does not pair this one. + fixture.parts["anchors"] = ("", echo("anchor", ["Gun.A", "weapon-mount", [0, 0, 3], 0, ""]) + + echo("anchor", ["Gun.A.muzzle", "weapon-muzzle", [0, -3, 3], 0, "Gun.A"]) + + echo("anchor", ["Gun.B", "weapon-mount", [2, 0, 3], 0, ""])) + self.assertEqual(fixture.failed(), [self.MUZZLE]) def test_a_muzzle_whose_parent_is_not_a_weapon_mount_is_refused(self): fixture = Fixture() From 0c6f359cb5a76051cdf944bcbc0c4c855fda8415 Mon Sep 17 00:00:00 2001 From: soul Date: Wed, 21 Oct 2026 20:34:50 +0200 Subject: [PATCH 100/444] ProductLinesCatalogTests: FactionProductData's wire pin covers slot 8, and a record written before it is on the market Co-Authored-By: Claude Opus 5.5 --- .../ProductLinesCatalogTests.cs | 19 ++++++++++++++----- 1 file changed, 14 insertions(+), 5 deletions(-) diff --git a/tests/Aetheria.Shared.Tests/ProductLinesCatalogTests.cs b/tests/Aetheria.Shared.Tests/ProductLinesCatalogTests.cs index 7ce78218f..3bb4ceb60 100644 --- a/tests/Aetheria.Shared.Tests/ProductLinesCatalogTests.cs +++ b/tests/Aetheria.Shared.Tests/ProductLinesCatalogTests.cs @@ -140,8 +140,8 @@ public sealed class ProductLinesCatalogTests } } - // 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. + // FactionProductData slots 7 and 8 are additive under the same schema id: a bin and NotOffered round-trip, a record written + // before slot 8 existed (an array one element shorter) is on the market, and one written before slot 7 also reads no bin. [Fact] public void BinIsSlotSevenAndACatalogWithoutItReadsNoBin() { @@ -150,12 +150,21 @@ public sealed class ProductLinesCatalogTests MessagePackSerializer.Serialize(new FactionProductData { Name = "Binned", Bin = new ProductBin { Floor = .67f } }, options), options); Assert.Equal(.67f, binned.Bin.Floor); + Assert.True(MessagePackSerializer.Deserialize( + MessagePackSerializer.Serialize(new FactionProductData { Name = "Unsold", NotOffered = true }, options), options).NotOffered); + 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(); + Assert.Equal(0x99, bytes[0]); + Assert.Equal(0xC0, bytes[^2]); + Assert.Equal(0xC2, bytes[^1]); + var beforeNotOffered = new byte[] { 0x98 }.Concat(bytes.Skip(1).Take(bytes.Length - 2)).ToArray(); + var offered = MessagePackSerializer.Deserialize(beforeNotOffered, options); + Assert.Equal("Old", offered.Name); + Assert.False(offered.NotOffered); + var before = new byte[] { 0x97 }.Concat(bytes.Skip(1).Take(bytes.Length - 3)).ToArray(); var old = MessagePackSerializer.Deserialize(before, options); Assert.Equal("Old", old.Name); Assert.Null(old.Bin); + Assert.False(old.NotOffered); } } From aea0ce823d5877511ca60c613d3709881bc7cd26 Mon Sep 17 00:00:00 2001 From: soul Date: Wed, 21 Oct 2026 20:37:59 +0200 Subject: [PATCH 101/444] release map: AH-P1 checks line no longer lists the deleted nose-ward rule Co-Authored-By: Claude Opus 5.5 --- docs/aetheria-release-map.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docs/aetheria-release-map.md b/docs/aetheria-release-map.md index 88267df37..848ab38f5 100644 --- a/docs/aetheria-release-map.md +++ b/docs/aetheria-release-map.md @@ -11238,7 +11238,7 @@ PIDs are in `S\out\pids.log`, the first 3404 and the last 20348. It then writes - **AH-P1. Checks.** All pass (`S\out\\checks.json`). - Tender: 3468 triangles. The bbox is 10.5 x 23.4 x 5.9 m, inside its 18-28 m size class. Body h/w is 0.42 and w/l is 0.61, inside Zhestokost's ranges [0.25, 0.6] and [0.35, 0.75]. Bilateral symmetry has a worst mirror miss of - 0.0000 m. It has five mount anchors, and every weapon has a muzzle nose-ward of its mount. + 0.0000 m. It has five mount anchors, and every weapon mount has its muzzle (gun facing is HullData's, ruling gun-facing-is-hulldata). - Fighter: 1466 triangles and 15 m long. The donor body's h/w is 0.20 and w/l 0.69, inside the R&D ranges. The asymmetry rule holds. - The vertex-based asymmetry figure (0.91 unmirrored) is too lenient, because tessellation alone breaks vertex From 73d1cee9de6af3c4ecf94fa12ca430551f0c38af Mon Sep 17 00:00:00 2001 From: soul Date: Wed, 21 Oct 2026 20:28:35 +0200 Subject: [PATCH 102/444] Gravity: one belt extent rule, read by Zone, the generator's clearance tree and the station tests AsteroidBelt.Extent(data, rail) is now static over the belt's data and the only spelling: the clearance tree's 'Distance + BeltHalfWidth' and the instance Extent are gone. The swarm host filter loses its dead '!p.Empty' (no bodyless node has a parent). Findings extent-second-spelling and dead-empty-guard (trojan-swarms-residuals). The clearance tree now reads the outermost rock, not the rail plus half-width, so 29 of the 1920 zones of seeds 1-30 place a station or turret differently. TrojanSwarmTests.BaseDigest is repinned for seeds 3-6, which hold such zones; seeds 1 and 2 are unchanged. Co-Authored-By: Claude Opus 5.5 --- Assets/Scripts/ServerShared/Zone.cs | 8 +++++--- Assets/Scripts/ServerShared/ZoneGenerator.cs | 9 ++++++--- .../StationPlacementTests.cs | 2 +- .../Aetheria.Shared.Tests/TrojanSwarmTests.cs | 19 ++++++++++--------- 4 files changed, 22 insertions(+), 16 deletions(-) diff --git a/Assets/Scripts/ServerShared/Zone.cs b/Assets/Scripts/ServerShared/Zone.cs index dc8cd0538..d0e98d3d2 100644 --- a/Assets/Scripts/ServerShared/Zone.cs +++ b/Assets/Scripts/ServerShared/Zone.cs @@ -221,7 +221,7 @@ public class Zone foreach (var belt in AsteroidBelts) { var rail = Planets[belt.Key].Orbit.Key; - beltExtent[rail] = belt.Value.Extent(Orbits[rail].Data.Distance); + beltExtent[rail] = AsteroidBelt.Extent(belt.Value.Data, Orbits[rail].Data.Distance); } var wellNodes = new List(); foreach (var orbit in Orbits) @@ -888,8 +888,10 @@ public class AsteroidBelt } // 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); + // field there; a swarm riding its rail reaches only the rail, whose period every rock shares. The one belt extent rule: + // Zone, ZoneGenerator's clearance tree and the station tests all read it. + public static float Extent(AsteroidBeltData data, float railDistance) => + data.RidesRail ? railDistance : Math.Max(data.Asteroids.Max(a => a.Distance), 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 diff --git a/Assets/Scripts/ServerShared/ZoneGenerator.cs b/Assets/Scripts/ServerShared/ZoneGenerator.cs index 412b0e9ab..c62f23442 100644 --- a/Assets/Scripts/ServerShared/ZoneGenerator.cs +++ b/Assets/Scripts/ServerShared/ZoneGenerator.cs @@ -118,6 +118,7 @@ public static class ZoneGenerator } pack.Orbits = planets.Select(planet => cache.RefOf(orbitMap[planet])).ToList(); + var beltOf = new Dictionary(); pack.Planets = planets.Where(p=>!p.Empty).Select(planet => { BodyType bodyType = planet.Belt ? BodyType.Asteroid : @@ -149,6 +150,7 @@ public static class ZoneGenerator planetData.Orbit = cache.RefOf(orbitMap[planet]); if (planetData is AsteroidBeltData beltData) { + beltOf[planet] = beltData; beltData.Asteroids = Enumerable.Range(0, (int) zoneSettings.AsteroidCount.Evaluate(beltData.Mass * orbitMap[planet].Distance)) .Select(_ => new Asteroid @@ -219,7 +221,7 @@ public static class ZoneGenerator // 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 + foreach (var host in planets.Where(p => p.Parent != null && !p.Parent.Empty && !p.Belt && p.Distance > 0 && p.Mass > zoneSettings.PlanetMass && p.Mass <= zoneSettings.SunMass)) { if (swarmRandom.NextFloat() >= zoneSettings.TrojanProbability) continue; @@ -239,10 +241,11 @@ public static class ZoneGenerator } // 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. + // support, or its Roche margin if more. A belt's extent is the rule Zone reads (AsteroidBelt.Extent). Swarm rails are + // left out: a swarm's extent is its own rail, a copy of its host's, so it raises no L_F and no support. var wellNodes = planets.Select(p => new WellNode(cache.RefOf(orbitMap[p]).Key, p.Parent == null ? default : cache.RefOf(orbitMap[p.Parent]).Key, p.Distance, p.Phase, p.Empty || p.Belt ? 0f : p.Mass, - p.Belt ? p.Distance + p.BeltHalfWidth : p.Distance, p.FixedPosition)).ToList(); + p.Belt ? AsteroidBelt.Extent(beltOf[p], p.Distance) : p.Distance, p.FixedPosition)).ToList(); var wells = WellTree.Build(wellNodes, planetSettings, pack.Radius); float Extent(GeneratorPlanet p) { diff --git a/tests/Aetheria.Shared.Tests/StationPlacementTests.cs b/tests/Aetheria.Shared.Tests/StationPlacementTests.cs index 0157f6a70..30a591f36 100644 --- a/tests/Aetheria.Shared.Tests/StationPlacementTests.cs +++ b/tests/Aetheria.Shared.Tests/StationPlacementTests.cs @@ -177,7 +177,7 @@ 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.Extent(zone.Orbits[zone.Planets[b.Key].Orbit.Key].Data.Distance)); + var beltExtent = zone.AsteroidBelts.ToDictionary(b => zone.Planets[b.Key].Orbit.Key, b => AsteroidBelt.Extent(b.Value.Data, zone.Orbits[zone.Planets[b.Key].Orbit.Key].Data.Distance)); var nodes = placed.Select(k => { var d = zone.Orbits[k].Data; diff --git a/tests/Aetheria.Shared.Tests/TrojanSwarmTests.cs b/tests/Aetheria.Shared.Tests/TrojanSwarmTests.cs index 8500ff983..e9de27bb6 100644 --- a/tests/Aetheria.Shared.Tests/TrojanSwarmTests.cs +++ b/tests/Aetheria.Shared.Tests/TrojanSwarmTests.cs @@ -97,16 +97,17 @@ public class TrojanSwarmTests 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. + // The digests were computed at 67517f40, before cut trojan-swarms, over the zones of each seed with the authored settings; + // seeds 3-6 were repinned at cut laned-belts C1, whose one belt extent rule moves stations in them. 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", + [3] = "0377471bf1a73b4e00ee2d18a9e90960fdf7011d35e7c648f103f3538750501f", + [4] = "195c6647ff921a12cd5ee71f94610f245e64c15fc1d38bc708846cd04e3ddeb5", + [5] = "ae0d57e84456767d35a699941238e7574e815da663b0354e5e1676063910e8a5", + [6] = "8ad70edd5d41e942ba9067930e0f148e3da6c054bf1d9d7cdd26be93e12017c6", }; [Fact] @@ -148,11 +149,11 @@ public class TrojanSwarmTests 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)); + Assert.Equal(7f, AsteroidBelt.Extent(belt, 7f)); + Assert.Equal(3f, AsteroidBelt.Extent(belt, 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)); + Assert.Equal(2f, AsteroidBelt.Extent(swarm, 2f)); } } From d0fc8adb70aa282b8b9d9325c652290714e4561a Mon Sep 17 00:00:00 2001 From: soul Date: Wed, 21 Oct 2026 20:36:35 +0200 Subject: [PATCH 103/444] Gravity: a wider zone fingerprint, one copied-rail helper, belts kinded at generation, adjacent belts pinned ZoneFingerprint digests rock spin and periods, orbital entities (type, position, rail), other entities by type, the pack radius, a 9x9 height grid at two times and optionally PopulatedRadius (finding fingerprint-omits). TrojanSwarmTests.BaseDigest is repinned with it on Yggdrasil at C1's generator, swarms off. BeltShapes.CopiedRail replaces StationPlacementTests.SwarmRail and the RidesRail slot filters of ChildrenWithinReachTests: a rail a belt rides that copies a body's rail is never a placed slot. The Zones fixture counts the belts the generator writes and those with no field kind, before Zone's own FieldKinds.Ensure; EveryBeltIsKindedAtGeneration pins it at zero (finding ensure-unpinned). AdjacentBeltsKeepTheirSpacing places two belts side by side and pins the second's inner edge a sibling spacing outside the first's outer edge (follow-up belt-after-belt-edge-pin). Co-Authored-By: Claude Opus 5.5 --- tests/Aetheria.Shared.Tests/BeltShapes.cs | 15 +++++ .../ChildrenWithinReachTests.cs | 56 +++++++++++++++++-- .../StationPlacementTests.cs | 10 +--- .../Aetheria.Shared.Tests/TrojanSwarmTests.cs | 24 ++++---- .../Aetheria.Shared.Tests/ZoneFingerprint.cs | 37 ++++++++---- 5 files changed, 105 insertions(+), 37 deletions(-) create mode 100644 tests/Aetheria.Shared.Tests/BeltShapes.cs diff --git a/tests/Aetheria.Shared.Tests/BeltShapes.cs b/tests/Aetheria.Shared.Tests/BeltShapes.cs new file mode 100644 index 000000000..79f24ddfc --- /dev/null +++ b/tests/Aetheria.Shared.Tests/BeltShapes.cs @@ -0,0 +1,15 @@ +using System.Linq; +using GameCult.Caching; + +// The belt shapes a test reads back from a built zone. A swarm's rail is a copy of its host's rail (same Parent, Distance and +// Phase), never a slot Place laid out, so the tests that walk placed orbits leave copied rails out through this one helper. +public static class BeltShapes +{ + public static bool CopiedRail(Zone zone, CultRecordKey orbit) + { + if (!zone.Planets.Values.Any(b => b is AsteroidBeltData && b.Orbit.Key.Equals(orbit))) return false; + var rail = zone.Orbits[orbit].Data; + return zone.Planets.Values.Where(b => !(b is AsteroidBeltData)).Select(b => zone.Orbits[b.Orbit.Key].Data) + .Any(o => o.Parent.Key.Equals(rail.Parent.Key) && o.Distance == rail.Distance && o.Phase == rail.Phase); + } +} diff --git a/tests/Aetheria.Shared.Tests/ChildrenWithinReachTests.cs b/tests/Aetheria.Shared.Tests/ChildrenWithinReachTests.cs index 22646a129..ae1e8538c 100644 --- a/tests/Aetheria.Shared.Tests/ChildrenWithinReachTests.cs +++ b/tests/Aetheria.Shared.Tests/ChildrenWithinReachTests.cs @@ -19,6 +19,8 @@ public sealed class ChildrenWithinReachTests : IClassFixture All = new List<(uint, Zone)>(); public ZoneGenerationSettings Settings; + // Every belt the generator wrote, and those it wrote with no field kind, counted before Zone's own FieldKinds.Ensure runs. + public int Belts, UnkindedAtGeneration; // Seeds 7, 11 and 12 held rings inside their heavy member's Roche margin (ring-roche-final-heavy), and seeds 10, 13, 21 and 30 // held a bodyless ring member whose own bodies sat at their node's centre (bodyless-node-places-children); seeds 1-6 show neither. @@ -50,6 +52,11 @@ public sealed class ChildrenWithinReachTests : IClassFixture()) + { + Belts++; + if (!belt.Kind.IsSet()) UnkindedAtGeneration++; + } All.Add((seed, new Zone(items, planetSettings, pack, galaxyZone, galaxy))); } } @@ -79,11 +86,12 @@ 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, Lagrange copies and swarm rails (a copy of the host's rail, like a Lagrange copy). + // The generated orbits: every body's and its ancestors', which leaves out stations, turrets, Lagrange copies and the copied + // rails swarms and lanes ride (a copy of the host's rail, like a Lagrange copy; BeltShapes.CopiedRail). private static HashSet Placed(Zone zone) { var placed = new HashSet(); - foreach (var body in zone.Planets.Values.Where(b => !(b is AsteroidBeltData { RidesRail: true }))) + foreach (var body in zone.Planets.Values.Where(b => !BeltShapes.CopiedRail(zone, b.Orbit.Key))) 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 +340,7 @@ public sealed class ChildrenWithinReachTests : IClassFixture !b.Value.Data.RidesRail)) + foreach (var (beltKey, belt) in zone.AsteroidBelts.Where(b => !BeltShapes.CopiedRail(zone, zone.Planets[b.Key].Orbit.Key))) { belts++; var orbit = zone.Planets[beltKey].Orbit.Key; @@ -363,7 +371,7 @@ public sealed class ChildrenWithinReachTests : IClassFixture !b.Value.Data.RidesRail).ToDictionary(b => zone.Planets[b.Key].Orbit.Key, b => b.Value.Data.Asteroids); + var beltAt = zone.AsteroidBelts.Where(b => !BeltShapes.CopiedRail(zone, zone.Planets[b.Key].Orbit.Key)).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 +432,7 @@ public sealed class ChildrenWithinReachTests : IClassFixture !b.Value.Data.RidesRail)) + foreach (var (key, belt) in zone.AsteroidBelts.Where(b => !BeltShapes.CopiedRail(zone, zone.Planets[b.Key].Orbit.Key))) { var rocks = belt.Data.Asteroids; if (rocks.Length < 2) continue; @@ -447,6 +455,42 @@ public sealed class ChildrenWithinReachTests : IClassFixture AuthoredSettings.Load(RunStartTests.FindRepoRoot()).Read("PlanetSettings"); + // A belt's field kind is chosen at generation and saved with it (Mining Cut 3, Q16 B): every belt the generator writes, + // annulus or swarm, carries one before any Zone reads it (finding ensure-unpinned). + [Fact] + public void EveryBeltIsKindedAtGeneration() + { + _out.WriteLine($"belts {_zones.Belts}, unkinded at generation {_zones.UnkindedAtGeneration}"); + Assert.True(_zones.All.Any(z => z.Zone.AsteroidBelts.Values.Any(b => b.Data.RidesRail)), "no swarm in the sample"); + Assert.True(_zones.Belts > 20, $"only {_zones.Belts} belts generated"); + Assert.Equal(0, _zones.UnkindedAtGeneration); + } + + // Two belts side by side under one parent (production never draws them; follow-up belt-after-belt-edge-pin): the second + // belt's slot starts a sibling spacing outside the first belt's outer edge, not its rail, so their rock intervals keep a spacing. + [Fact] + public void AdjacentBeltsKeepTheirSpacing() + { + var settings = AuthoredSettings.Load(RunStartTests.FindRepoRoot()).Read("ZoneSettings"); + // Wider than any slot, so each belt fills its slot and the second's inner edge is the slot's inner bound. + settings.AsteroidBeltWidth = new ExponentialCurve { Exponent = 1f, Multiplier = 0f, Constant = 1e7f }; + var root = new GeneratorPlanet { Mass = 1e5f }; + Body(root, 100f); + var first = Body(root, 0f); + var second = Body(root, 0f); + Body(root, 100f, .5f); + foreach (var belt in new[] { first, second }) { belt.Belt = true; belt.Settings = settings; } + ZoneGenerator.Place(root, AuthoredPlanetSettings(), 1e6f, float.PositiveInfinity); + Assert.True(root.Children.Contains(first) && root.Children.Contains(second), "a belt folded"); + Assert.True(first.Distance < second.Distance, "the belts swapped order"); + Assert.True(first.BeltHalfWidth > 0 && second.BeltHalfWidth > 0, "a belt collapsed to a line"); + var q = 1 / (1 - GravityField.Reach / 2); + var firstOuter = first.Distance + first.BeltHalfWidth; + var secondInner = second.Distance - second.BeltHalfWidth; + _out.WriteLine($"first {first.Distance} +- {first.BeltHalfWidth}, second {second.Distance} +- {second.BeltHalfWidth}"); + Assert.True(firstOuter * q <= secondInner * (1 + 1e-4f), $"the second belt's inner edge {secondInner} is within a spacing of the first's outer edge {firstOuter}"); + } + // A rosette's ring round a root with no reach limit of its own sits at half the root's 0.75 x radius window, 3/8 of the radius. [Fact] public void ASoleRootRingSitsAtThreeEighthsOfItsRadius() diff --git a/tests/Aetheria.Shared.Tests/StationPlacementTests.cs b/tests/Aetheria.Shared.Tests/StationPlacementTests.cs index 30a591f36..eda1ad0a8 100644 --- a/tests/Aetheria.Shared.Tests/StationPlacementTests.cs +++ b/tests/Aetheria.Shared.Tests/StationPlacementTests.cs @@ -81,10 +81,6 @@ 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() { @@ -100,7 +96,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) && !SwarmRail(zone, o.Key) && SameRing(o.Value, orbit)).ToList(); + var sources = zone.Orbits.Where(o => !ridden.Contains(o.Key) && !BeltShapes.CopiedRail(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 @@ -160,7 +156,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) && !SwarmRail(zone, o.Key) && SameRing(o.Value, orbit)).ToList(); + var sources = zone.Orbits.Where(o => !ridden.Contains(o.Key) && !BeltShapes.CopiedRail(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)); @@ -357,7 +353,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) && !SwarmRail(zone, o.Key) && SameRing(o.Value, orbit)); + var strays = zone.Orbits.Count(o => !ridden.Contains(o.Key) && !BeltShapes.CopiedRail(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++; } diff --git a/tests/Aetheria.Shared.Tests/TrojanSwarmTests.cs b/tests/Aetheria.Shared.Tests/TrojanSwarmTests.cs index e9de27bb6..d5428481a 100644 --- a/tests/Aetheria.Shared.Tests/TrojanSwarmTests.cs +++ b/tests/Aetheria.Shared.Tests/TrojanSwarmTests.cs @@ -97,17 +97,17 @@ public class TrojanSwarmTests Assert.True(onSwarmHost > 0, "no station or turret sits on a swarm host's rail"); } - // The digests were computed at 67517f40, before cut trojan-swarms, over the zones of each seed with the authored settings; - // seeds 3-6 were repinned at cut laned-belts C1, whose one belt extent rule moves stations in them. A zone with no swarm is - // the base zone, and a zone with swarms is the base zone plus them. + // The widened digests (ZoneFingerprint with PopulatedRadius) were computed on Yggdrasil over the generator of cut laned-belts + // C1 (one belt extent rule) with swarms off, over the zones of each seed: a zone with no swarm is the base zone, and a zone with + // swarms is the base zone plus them. The per-zone comparison leaves PopulatedRadius out, since a swarm's outer rock may grow it. private static readonly Dictionary BaseDigest = new Dictionary { - [1] = "02a2bef42b5d4aeaf1e7004d0ab26620ee570ced95115abf98226ab4e0db23eb", - [2] = "de231f392e5724ecdce7e746957633bef2724252147ecbb5830db047b635aead", - [3] = "0377471bf1a73b4e00ee2d18a9e90960fdf7011d35e7c648f103f3538750501f", - [4] = "195c6647ff921a12cd5ee71f94610f245e64c15fc1d38bc708846cd04e3ddeb5", - [5] = "ae0d57e84456767d35a699941238e7574e815da663b0354e5e1676063910e8a5", - [6] = "8ad70edd5d41e942ba9067930e0f148e3da6c054bf1d9d7cdd26be93e12017c6", + [1] = "1873400d50d11b27913a67daca460f0829629aa21dacabac2cfc97a66537a700", + [2] = "5872680ee91c2019b345925f54c2113303cc2817ee9ea28d26ba839e43d1944e", + [3] = "e92cc32010f01b7962a2a58b6ea91c9c39ee3d915301164f176d0ebad218837c", + [4] = "61db9bdae2ca8704a7946debb4fe63ae3e649112fcda19d293ba2fddcaf45674", + [5] = "69ca46ebfae646b3a09faf9d636f3c706e5f6b7146f111a0ea06196332ae58b9", + [6] = "307dc4b38ff341670a5b5179ab977b6c7c59ba682d529d80dcaf9c4852c21fe2", }; [Fact] @@ -122,11 +122,11 @@ public class TrojanSwarmTests 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); + Assert.Equal(BaseDigest[seed], ZoneFingerprint.Of(a, _ => false, _ => false, true)); + string Swarmed(Zone z) => ZoneFingerprint.Of(new[] { z }, SwarmRails(z).Contains, IsSwarm, false); for (var n = 0; n < a.Count; n++) { - Assert.Equal(ZoneFingerprint.Of(new[] { a[n] }, _ => false, _ => false), Swarmed(b[n])); + Assert.Equal(ZoneFingerprint.Of(new[] { a[n] }, _ => false, _ => 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++; diff --git a/tests/Aetheria.Shared.Tests/ZoneFingerprint.cs b/tests/Aetheria.Shared.Tests/ZoneFingerprint.cs index 3abc9ebc1..6b40431c5 100644 --- a/tests/Aetheria.Shared.Tests/ZoneFingerprint.cs +++ b/tests/Aetheria.Shared.Tests/ZoneFingerprint.cs @@ -9,29 +9,42 @@ 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. +// every orbit's (Distance, Phase, Period, Reach, Support); every body's (type, Mass, rail Distance) and each belt rock's +// (Distance, Phase, Size, RotationSpeed, Period); every orbital entity's (type, position, rail Distance and Phase) and any other +// entity's type alone (a ship packed at the origin is scattered from the clock-seeded item stream, Zone.cs:276); the pack radius; +// GetHeight on a 9x9 grid over the pack at t 0 and 777.7; and, when asked, PopulatedRadius. 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) + public static string Of(IEnumerable zones, Func skipOrbit, Func skipBody, bool populatedRadius) { var text = new StringBuilder(); - foreach (var zone in zones) text.Append(Describe(zone, skipOrbit, skipBody)).Append('\n'); + foreach (var zone in zones) text.Append(Describe(zone, skipOrbit, skipBody, populatedRadius)).Append('\n'); return Convert.ToHexString(SHA256.HashData(Encoding.UTF8.GetBytes(text.ToString()))).ToLowerInvariant(); } - private static string Describe(Zone z, Func skipOrbit, Func skipBody) + private static string Describe(Zone z, Func skipOrbit, Func skipBody, bool populatedRadius) { - string F(float v) => v.ToString("R", System.Globalization.CultureInfo.InvariantCulture); + string F(double 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)}")) : "")) + var bodies = z.Planets.Where(b => !skipBody(b.Value)) + .Select(b => $"{b.Value.GetType().Name},{F(b.Value.Mass)},{F(z.Orbits[b.Value.Orbit.Key].Data.Distance)}," + + (b.Value is AsteroidBeltData belt ? string.Join("|", belt.Asteroids.Select((r, i) => + $"{F(r.Distance)}/{F(r.Phase)}/{F(r.Size)}/{F(r.RotationSpeed)}/{F(z.AsteroidBelts[b.Key].Periods[i])}")) : "")) .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); + var entities = z.Entities.Select(e => e is OrbitalEntity o + ? $"{e.GetType().Name},{F(e.Position.x)},{F(e.Position.y)},{F(e.Position.z)},{F(z.Orbits[o.OrbitData].Data.Distance)},{F(z.Orbits[o.OrbitData].Data.Phase)}" + : e.GetType().Name) + .OrderBy(x => x, StringComparer.Ordinal); + var r = z.Pack.Radius; + var heights = new List(); + foreach (var t in new[] { 0.0, 777.7 }) + for (var i = -4; i <= 4; i++) + for (var j = -4; j <= 4; j++) + heights.Add(F(z.GetHeight(float2(i * r / 4f, j * r / 4f), t))); + return string.Join(";", orbits) + "#" + string.Join(";", bodies) + "#" + string.Join(";", entities) + "#" + F(r) + "#" + + string.Join(",", heights) + (populatedRadius ? "#" + F(z.PopulatedRadius) : ""); } } From 749bdb09f5b683f9c3049b0c8a002b71297f7933 Mon Sep 17 00:00:00 2001 From: soul Date: Wed, 21 Oct 2026 20:58:09 +0200 Subject: [PATCH 104/444] ShipProductTests: the product grammar refuses a misnamed flag or a dangling description, and the on-market line is the off-market one without its clause Kills the four Stryker survivors of the --not-offered parse. Co-Authored-By: Claude Opus 5.5 --- tests/Aetheria.Shared.Tests/ShipProductTests.cs | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/tests/Aetheria.Shared.Tests/ShipProductTests.cs b/tests/Aetheria.Shared.Tests/ShipProductTests.cs index 77242916f..27e84629f 100644 --- a/tests/Aetheria.Shared.Tests/ShipProductTests.cs +++ b/tests/Aetheria.Shared.Tests/ShipProductTests.cs @@ -274,6 +274,9 @@ public sealed class ShipProductTests : IDisposable RefusedAndUntouched("product", path, "ACM", "--remove"); RefusedAndUntouched("product", path, "ACM", "--not-offered"); RefusedAndUntouched("product", path, "ACM", "--name", "--not-offered"); + RefusedAndUntouched("product", path, "ACM", "--title", "X"); + RefusedAndUntouched("product", path, "ACM", "--name", "X", "--description"); + RefusedAndUntouched("product", path, "ACM", "--name", "X", "--note", "Y"); var (code, _, error) = Run("product", path, "ACM", "--name", "Acme Skiff", "--description", "Reliable."); Assert.True(code == 0, error); @@ -305,6 +308,7 @@ public sealed class ShipProductTests : IDisposable var (renamedCode, renamedOutput, _) = Run("product", path, "Acme", "--name", "Renamed"); Assert.Equal(0, renamedCode); Assert.DoesNotContain("not offered", renamedOutput); + Assert.Equal(offOutput.Replace(", not offered", ""), renamedOutput); var renamed = Assert.Single(ShipAuthoringStore.Load(path).Products); Assert.Equal("Renamed", renamed.Name); Assert.False(renamed.NotOffered); From f2cd0368908e1cadce3cee101b965bb1976f14d7 Mon Sep 17 00:00:00 2001 From: soul Date: Wed, 21 Oct 2026 22:00:55 +0200 Subject: [PATCH 105/444] ProductChoiceTests: rig bins honour the not-offered list; an off-market bin leaves its line the whole weight, and an off-market line's bin is never drawn or stocked Co-Authored-By: Claude Opus 5.5 --- .../ProductChoiceTests.cs | 43 ++++++++++++++++++- 1 file changed, 42 insertions(+), 1 deletion(-) diff --git a/tests/Aetheria.Shared.Tests/ProductChoiceTests.cs b/tests/Aetheria.Shared.Tests/ProductChoiceTests.cs index d049e8c05..975772cec 100644 --- a/tests/Aetheria.Shared.Tests/ProductChoiceTests.cs +++ b/tests/Aetheria.Shared.Tests/ProductChoiceTests.cs @@ -111,11 +111,14 @@ public sealed class ProductChoiceTests : IDisposable // 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++) + { + var name = i == 0 ? "lamp-a bin" : "lamp-a bin " + (i + 1); 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, + Name = name, Design = new CultRecordRef(lamp.Key), Manufacturer = alpha, NotOffered = notOffered.Contains(name), Bin = new ProductBin { Line = new CultRecordRef(new CultRecordKey("k" + Array.IndexOf(lampOrder, "lamp-a"))), Floor = binFloors[i] } }, new CultRecordKey("bin" + i))); + } seed.FlushAsync().Wait(); } @@ -398,6 +401,44 @@ public sealed class ProductChoiceTests : IDisposable Assert.True(rig.Outfitter(null, 1).IsAvailable(rig.Product("lamp-z"))); } + // An off-market bin leaves the share arithmetic (ruling bin-share-by-floor): its line keeps the whole of its maker's preference, + // so taking a bin off the market never shrinks the maker's presence for the design. + [Fact] + public void AnOffMarketBinLeavesItsLineTheWholeWeight() + { + var alone = Build("lamp-z", "lamp-a"); + var off = Build(new[] { 0f }, new[] { "lamp-z", "lamp-a" }, "lamp-a bin"); + var preference = alone.Outfitter(alone.OutEven, 1, 0).ProductWeight(alone.Product("lamp-a")); + Assert.Equal(preference, off.Outfitter(off.OutEven, 1, 0).ProductWeight(off.Product("lamp-a")), 3); + + var lamp = off.Cache.GetByName("Lamp"); + var draws = Enumerable.Range(1, 600).Select(i => off.Outfitter(off.OutEven, Seed(i)).ChooseProduct(lamp).Name).ToArray(); + Assert.DoesNotContain("lamp-a bin", draws); + var aloneLamp = alone.Cache.GetByName("Lamp"); + var aloneDraws = Enumerable.Range(1, 600).Count(i => alone.Outfitter(alone.OutEven, Seed(i)).ChooseProduct(aloneLamp).Name == "lamp-a"); + Assert.InRange(draws.Count(name => name == "lamp-a"), aloneDraws - 30, aloneDraws + 30); + } + + // Ruling bin-inherits-line-offered: a bin is offered only if both it and its line are. A bin of an off-market line is never chosen, + // never drawn by generation, and never stocked, though the bin itself carries no NotOffered. + [Fact] + public void AnOffMarketLinesBinIsNeverDrawnOrSold() + { + var rig = Build(new[] { 0f }, new[] { "lamp-z", "lamp-a" }, "lamp-a"); + Assert.False(rig.Product("lamp-a bin").NotOffered); + var lamp = rig.Cache.GetByName("Lamp"); + for (var i = 1; i <= 64; i++) + { + // OutA reaches only Alpha, whose line and so its bin are off the market. + Assert.Null(rig.Outfitter(rig.OutA, Seed(i)).ChooseProduct(lamp)); + Assert.Equal("lamp-z", rig.Outfitter(null, Seed(i)).ChooseProduct(lamp).Name); + Assert.Equal("lamp-z", rig.Outfitter(rig.OutA, Seed(i)).RandomProduct(0, design => design == lamp, required: true).product.Name); + foreach (var faction in new[] { rig.OutA, rig.OutEven, rig.OutNone, null }) + Assert.DoesNotContain(rig.Outfitter(faction, Seed(i)).RandomProducts(16, 1, null, true), entry => entry.product.Name.StartsWith("lamp-a")); + } + Assert.False(rig.Outfitter(null, 1).IsAvailable(rig.Product("lamp-a bin"))); + } + // The any-manufacturer fallback of a required draw is closed to off-market products: when every product of the design is // not offered the draw yields nothing, for a faction that reaches the maker, one that reaches nobody, and no faction. [Fact] From f7cdb766cdc1bc22521055f9614a6d192230306f Mon Sep 17 00:00:00 2001 From: soul Date: Wed, 21 Oct 2026 22:01:41 +0200 Subject: [PATCH 106/444] LoadoutGenerator: IsOffered reads a bin's line, and BinShares counts only offered bins, so an off-market bin leaves its line the whole weight Co-Authored-By: Claude Opus 5.5 --- Assets/Scripts/ServerShared/LoadoutGenerator.cs | 17 ++++++++++------- 1 file changed, 10 insertions(+), 7 deletions(-) diff --git a/Assets/Scripts/ServerShared/LoadoutGenerator.cs b/Assets/Scripts/ServerShared/LoadoutGenerator.cs index 671faf678..b75f0d30a 100644 --- a/Assets/Scripts/ServerShared/LoadoutGenerator.cs +++ b/Assets/Scripts/ServerShared/LoadoutGenerator.cs @@ -198,7 +198,7 @@ public class LoadoutGenerator entry.design.Price > 0 && entry.product.Manufacturer.IsSet() && // A not-offered product is never a candidate, so the any-manufacturer fallback below cannot reach it either. - IsOffered(entry.product) && + IsOffered(ItemManager.ItemData, entry.product) && (!(entry.design is EquippableItemData equippable) || HasHome(equippable, hulls)) && (filter?.Invoke(entry.design) ?? true)) .ToArray(); @@ -221,7 +221,7 @@ public class LoadoutGenerator // hardpoints never put gear on a shelf. An off-market hull gives no gear a home either: only its pinned Loadout flies it. // The one definition of the hulls HasHome considers. public static HullData[] SoldHulls(CultCache items) => - items.GetAll().Where(IsOffered).Select(product => items.Get(product.Design)).OfType().Distinct().ToArray(); + items.GetAll().Where(product => IsOffered(items, product)).Select(product => items.Get(product.Design)).OfType().Distinct().ToArray(); public static bool HasHome(EquippableItemData design, HullData[] hulls) => design.HardpointType == HardpointType.Tool || design.HardpointType == HardpointType.Hull || @@ -236,12 +236,14 @@ public class LoadoutGenerator // does not matter (operator, 2026-10-06: a faction need not be present for its gear to be available). // A not-offered product is available to nobody, a stage with no faction included. public bool IsAvailable(FactionProductData product) => - IsOffered(product) && ( + IsOffered(ItemManager.ItemData, product) && ( Galaxy == null || Faction == null || ItemManager.ItemData.Get(product.Manufacturer) == Faction || Faction.Allegiance.ContainsKey(product.Manufacturer)); - // Whether a product is on the market at all (ruling off-market-is-unoffered-product). The one reader of NotOffered. - public static bool IsOffered(FactionProductData product) => !product.NotOffered; + // Whether a product is on the market at all (ruling off-market-is-unoffered-product). The one reader of NotOffered. A bin is + // offered only if both it and its line are (ruling bin-inherits-line-offered); the catalog read guarantees a bin's line exists. + public static bool IsOffered(CultCache items, FactionProductData product) => + !product.NotOffered && (product.Bin == null || !items.Get(product.Bin.Line).NotOffered); // 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 @@ -256,9 +258,10 @@ public class LoadoutGenerator // 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))); - // Each line's key to the sum of its bins' pass fractions: read from the catalog once per weighting pass, never kept. + // Each line's key to the sum of its offered bins' pass fractions: read from the catalog once per weighting pass, never kept. An + // off-market bin takes no share, so its line keeps the whole weight. private Dictionary BinShares() => ItemManager.ItemData.GetAll() - .Where(product => product.Bin != null) + .Where(product => product.Bin != null && IsOffered(ItemManager.ItemData, product)) .GroupBy(product => product.Bin.Line.Key.Value) .ToDictionary(group => group.Key, group => group.Sum(bin => PassFraction(bin.Bin.Floor))); From f102b082fd59249b8d2e8ef13fe87bde8000c3a4 Mon Sep 17 00:00:00 2001 From: soul Date: Wed, 21 Oct 2026 22:42:49 +0200 Subject: [PATCH 107/444] Gravity: laned belts, a belt on a copy of a light body's rail filling its slot less a visual lane Place records every child's slot; GenerateEntities marks belt candidates. After the swarm pass, a lane pass on its own stream gives a candidate that kept its body, has a band at least the lane's half-width on both sides, carries no swarm and whose parent has no other belt shape, an annulus on a copy of its rail: rocks over [slot inner, D - E] and [D + E, slot outer], E = LaneBodyRadii body radii. ZoneGenerationSettings gains LaneProbability (0 in code, 0.5 authored) and LaneBodyRadii (5). LanedBeltTests pins it; EveryBeltIsKindedAtGeneration moves there and asserts lanes; BeltShapes.Lanes lands with it; both TrojanSwarmTests fixtures draw no lane. Co-Authored-By: Claude Opus 5.5 --- Assets/Resources/Settings.asset | 4 +- Assets/Scripts/ServerShared/Settings.cs | 6 + Assets/Scripts/ServerShared/ZoneGenerator.cs | 82 ++++++++- tests/Aetheria.Shared.Tests/BeltShapes.cs | 21 ++- .../ChildrenWithinReachTests.cs | 17 +- tests/Aetheria.Shared.Tests/LanedBeltTests.cs | 155 ++++++++++++++++++ .../Aetheria.Shared.Tests/TrojanSwarmTests.cs | 7 +- 7 files changed, 266 insertions(+), 26 deletions(-) create mode 100644 tests/Aetheria.Shared.Tests/LanedBeltTests.cs diff --git a/Assets/Resources/Settings.asset b/Assets/Resources/Settings.asset index 78735dab3..f1f320a67 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:7a0a0d2e7b8367e7c402975e5683434068f99b8c13c848764bbac39b43886411 -size 12477 +oid sha256:8cc62a055653d90b906819239de99238253299f3ce4219cf58bddf9b67e3be67 +size 12523 diff --git a/Assets/Scripts/ServerShared/Settings.cs b/Assets/Scripts/ServerShared/Settings.cs index 980828e53..18278dd15 100644 --- a/Assets/Scripts/ServerShared/Settings.cs +++ b/Assets/Scripts/ServerShared/Settings.cs @@ -134,6 +134,12 @@ public class ZoneGenerationSettings public float TrojanHalfWidth = .06f; public ExponentialCurve TrojanCount = new ExponentialCurve { Exponent = 1f, Multiplier = .04f, Constant = 8f }; + // Laned belts (ruling lanes-two-sided-visual-margin): the chance a belt candidate that kept its body and has room on both + // sides carries a laned belt, and the lane's half-width in the host's body radii. 0 here: a settings object built in code + // draws none. + public float LaneProbability = 0f; + public float LaneBodyRadii = 5f; + public float ResourceDensityMinimum = .1f; public float ResourceDensityMaximum = 1.5f; diff --git a/Assets/Scripts/ServerShared/ZoneGenerator.cs b/Assets/Scripts/ServerShared/ZoneGenerator.cs index c62f23442..a10950bbf 100644 --- a/Assets/Scripts/ServerShared/ZoneGenerator.cs +++ b/Assets/Scripts/ServerShared/ZoneGenerator.cs @@ -221,10 +221,12 @@ public static class ZoneGenerator // 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); + var swarmHosts = new HashSet(); foreach (var host in planets.Where(p => p.Parent != null && !p.Parent.Empty && !p.Belt && p.Distance > 0 && p.Mass > zoneSettings.PlanetMass && p.Mass <= zoneSettings.SunMass)) { if (swarmRandom.NextFloat() >= zoneSettings.TrojanProbability) continue; + swarmHosts.Add(host); var hostOrbit = orbitMap[host]; var rail = new OrbitData { Parent = hostOrbit.Parent, Distance = hostOrbit.Distance, Phase = hostOrbit.Phase }; pack.Orbits.Add(cache.Upsert(rail)); @@ -240,9 +242,40 @@ public static class ZoneGenerator pack.Planets.Add(cache.Upsert(swarmBody)); } + // Laned belts (rulings belt-shapes-direction, lanes-two-sided-visual-margin): a belt candidate that kept its body carries + // an annulus on a copy of its rail that fills its slot less a lane round the body, rocks on both sides. The lane is sized + // against the body the player sees (LaneBodyRadii body radii a side), and only a body whose slot holds a band at least + // that wide on both sides gets one. One belt shape per parent, and none on a swarm host. Drawn from a stream of its own, + // so no other draw in the zone moves; its rocks sit inside the host's slot, below the next sibling's spacing, so they + // raise no L_F and no support: the lane moves no well, period or height. + var laneRandom = new Random(seed ^ LaneStream); + var shaped = new HashSet(planets.Where(p => p.Belt).Select(p => p.Parent)); + foreach (var host in planets.Where(p => p.BeltCandidate && !p.Belt)) + { + if (laneRandom.NextFloat() >= zoneSettings.LaneProbability) continue; + var lane = zoneSettings.LaneBodyRadii * planetSettings.BodyRadius.Evaluate(host.Mass); + if (swarmHosts.Contains(host) || shaped.Contains(host.Parent) + || host.SlotInner > host.Distance - 2 * lane || host.SlotOuter < host.Distance + 2 * lane) continue; + shaped.Add(host.Parent); + var hostOrbit = orbitMap[host]; + var rail = new OrbitData { Parent = hostOrbit.Parent, Distance = hostOrbit.Distance, Phase = hostOrbit.Phase }; + pack.Orbits.Add(cache.Upsert(rail)); + var laned = new AsteroidBeltData + { + Orbit = cache.RefOf(rail), + Asteroids = LaneRocks(zoneSettings, ref laneRandom, host.Mass, rail.Distance, lane, host.SlotInner, host.SlotOuter) + }; + BodyData lanedBody = laned; + var lanedKey = cache.Upsert(lanedBody).Key; + laned.Name = lanedKey.Value.Substring(0, 8); + FieldKinds.Ensure(lanedKey, laned, cache); + pack.Planets.Add(cache.Upsert(lanedBody)); + } + // 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. A belt's extent is the rule Zone reads (AsteroidBelt.Extent). Swarm rails are - // left out: a swarm's extent is its own rail, a copy of its host's, so it raises no L_F and no support. + // support, or its Roche margin if more. A belt's extent is the rule Zone reads (AsteroidBelt.Extent). Swarm and lane rails + // are left out: a swarm's extent is its own rail, a copy of its host's, and a lane's outer rock sits inside its host's slot, + // below the next sibling's spacing, so neither raises an L_F or a support. var wellNodes = planets.Select(p => new WellNode(cache.RefOf(orbitMap[p]).Key, p.Parent == null ? default : cache.RefOf(orbitMap[p.Parent]).Key, p.Distance, p.Phase, p.Empty || p.Belt ? 0f : p.Mass, p.Belt ? AsteroidBelt.Extent(beltOf[p], p.Distance) : p.Distance, p.FixedPosition)).ToList(); @@ -598,8 +631,11 @@ public static class ZoneGenerator : root.AllPlanets().Where(p => p != root && p.Mass < settings.BeltMassCeiling && !binaries.Contains(p) && p.Children.Count == 0); foreach(var planet in beltCandidates.Reverse()) + { + planet.BeltCandidate = true; if (random.NextFloat() < settings.BeltProbability && !planet.Parent.Children.Any(p=>p.Belt)) planet.Belt = true; + } var totalMass = root.AllPlanets().Sum(p => p.Mass); var rootMass = root.Mass; @@ -633,6 +669,32 @@ public static class ZoneGenerator }; return rocks; } + + private const uint LaneStream = 0x85EBCA6Bu; + + // A laned belt's rocks: the count an annulus of the host's mass at `distance` would draw, over the bands [inner, distance - lane] + // and [distance + lane, outer], each rock in a band by its area and drawn across it as the annulus draws across its width. + public static Asteroid[] LaneRocks(ZoneGenerationSettings settings, ref Random random, float mass, float distance, float lane, float inner, float outer) + { + float Sq(float x) => x * x; + var innerArea = Sq(distance - lane) - Sq(inner); + var share = innerArea / (innerArea + Sq(outer) - Sq(distance + lane)); + var rocks = new Asteroid[(int) settings.AsteroidCount.Evaluate(mass * distance)]; + for (var i = 0; i < rocks.Length; i++) + { + var innerBand = random.NextFloat() < share; + var a = innerBand ? inner : distance + lane; + var b = innerBand ? distance - lane : outer; + rocks[i] = new Asteroid + { + Distance = (a + b) / 2 + random.NextFloat() * (random.NextFloat() - .5f) * (b - a), + Phase = random.NextFloat(), + Size = random.NextFloat(), + RotationSpeed = settings.AsteroidRotationSpeed.Evaluate(random.NextFloat()) + }; + } + return rocks; + } public const float CuspMargin = 1.5f; public const float SiblingSpacing = .5f; @@ -691,15 +753,18 @@ public static class ZoneGenerator var g = children.Count > 1 ? pow(upper / positions[positions.Count - 1], 1f / (children.Count - 1)) : 1f; for (var k = 0; k < children.Count; k++) children[k].Distance = positions[k] * pow(g, k); - // A belt is an interval inside its slot: inner edge outside the lower bound and the previous sibling's spacing, outer edge - // inside the next sibling's spacing and the window; the authored width is the widest it may be. + // Every child's slot: from the lower bound and the previous sibling's spacing out to the next sibling's spacing and the + // window. Place is the only writer of a slot; a belt fills its own, and the lane pass reads a body's. A belt is an + // interval inside its slot, the authored width the widest it may be. var outer = 0f; for (var k = 0; k < children.Count; k++) { var c = children[k]; + c.SlotInner = Math.Max(lo, outer * q); + c.SlotOuter = k + 1 < children.Count ? Math.Min(upper, children[k + 1].Distance / q) : upper; if (!c.Belt) { outer = c.Distance; continue; } - var inner = Math.Max(lo, outer * q); - var outerLimit = k + 1 < children.Count ? Math.Min(upper, children[k + 1].Distance / q) : upper; + var inner = c.SlotInner; + var outerLimit = c.SlotOuter; c.Distance = (inner + outerLimit) / 2; var half = Math.Max(0f, Math.Min(c.Settings.AsteroidBeltWidth.Evaluate(c.Distance), outerLimit - inner) / 2); c.BeltHalfWidth = half; @@ -765,6 +830,11 @@ public class GeneratorPlanet public float BeltHalfWidth; public bool Empty = false; public bool Belt = false; + // The interval Place gives a child between its previous sibling's spacing and the next sibling's spacing or the window; zero + // on a ring member, which has no slot. + public float SlotInner, SlotOuter; + // A childless light body, not a binary or root rosette member, that could have become a belt (set by GenerateEntities). + public bool BeltCandidate; // A member of a rosette's ring, which Place puts on one orbit round its bodyless parent. public bool Ring = false; public List Children = new List(); // Planets orbiting this one are referred to as children diff --git a/tests/Aetheria.Shared.Tests/BeltShapes.cs b/tests/Aetheria.Shared.Tests/BeltShapes.cs index 79f24ddfc..5322b1f0a 100644 --- a/tests/Aetheria.Shared.Tests/BeltShapes.cs +++ b/tests/Aetheria.Shared.Tests/BeltShapes.cs @@ -1,8 +1,10 @@ +using System.Collections.Generic; using System.Linq; using GameCult.Caching; -// The belt shapes a test reads back from a built zone. A swarm's rail is a copy of its host's rail (same Parent, Distance and -// Phase), never a slot Place laid out, so the tests that walk placed orbits leave copied rails out through this one helper. +// The belt shapes a test reads back from a built zone. A swarm's or a lane's rail is a copy of its host's rail (same Parent, +// Distance and Phase), never a slot Place laid out, so the tests that walk placed orbits leave copied rails out through this one +// helper. public static class BeltShapes { public static bool CopiedRail(Zone zone, CultRecordKey orbit) @@ -12,4 +14,19 @@ public static class BeltShapes return zone.Planets.Values.Where(b => !(b is AsteroidBeltData)).Select(b => zone.Orbits[b.Orbit.Key].Data) .Any(o => o.Parent.Key.Equals(rail.Parent.Key) && o.Distance == rail.Distance && o.Phase == rail.Phase); } + + // Every laned belt with its rail and its host: a belt on a copied rail that does not ride the rail (a swarm does), whose host + // is the body orbit the rail copies. + public static IEnumerable<(Orbit Host, Orbit Rail, AsteroidBelt Belt)> Lanes(Zone zone) + { + foreach (var (key, belt) in zone.AsteroidBelts.Where(b => !b.Value.Data.RidesRail)) + { + var railKey = zone.Planets[key].Orbit.Key; + if (!CopiedRail(zone, railKey)) continue; + var rail = zone.Orbits[railKey]; + 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); + } + } } diff --git a/tests/Aetheria.Shared.Tests/ChildrenWithinReachTests.cs b/tests/Aetheria.Shared.Tests/ChildrenWithinReachTests.cs index ae1e8538c..a5ab3ebc3 100644 --- a/tests/Aetheria.Shared.Tests/ChildrenWithinReachTests.cs +++ b/tests/Aetheria.Shared.Tests/ChildrenWithinReachTests.cs @@ -88,7 +88,7 @@ public sealed class ChildrenWithinReachTests : IClassFixture Placed(Zone zone) + internal static HashSet Placed(Zone zone) { var placed = new HashSet(); foreach (var body in zone.Planets.Values.Where(b => !BeltShapes.CopiedRail(zone, b.Orbit.Key))) @@ -96,7 +96,7 @@ public sealed class ChildrenWithinReachTests : IClassFixture ChildrenOf(Zone zone, CultRecordKey parent, HashSet placed) => + internal static List ChildrenOf(Zone zone, CultRecordKey parent, HashSet placed) => placed.Where(k => zone.Orbits[k].Data.Parent.IsSet() && zone.Orbits[k].Data.Parent.Key.Equals(parent)) .OrderBy(k => zone.Orbits[k].Data.Distance).ToList(); @@ -455,17 +455,6 @@ public sealed class ChildrenWithinReachTests : IClassFixture AuthoredSettings.Load(RunStartTests.FindRepoRoot()).Read("PlanetSettings"); - // A belt's field kind is chosen at generation and saved with it (Mining Cut 3, Q16 B): every belt the generator writes, - // annulus or swarm, carries one before any Zone reads it (finding ensure-unpinned). - [Fact] - public void EveryBeltIsKindedAtGeneration() - { - _out.WriteLine($"belts {_zones.Belts}, unkinded at generation {_zones.UnkindedAtGeneration}"); - Assert.True(_zones.All.Any(z => z.Zone.AsteroidBelts.Values.Any(b => b.Data.RidesRail)), "no swarm in the sample"); - Assert.True(_zones.Belts > 20, $"only {_zones.Belts} belts generated"); - Assert.Equal(0, _zones.UnkindedAtGeneration); - } - // Two belts side by side under one parent (production never draws them; follow-up belt-after-belt-edge-pin): the second // belt's slot starts a sibling spacing outside the first belt's outer edge, not its rail, so their rock intervals keep a spacing. [Fact] @@ -489,6 +478,8 @@ public sealed class ChildrenWithinReachTests : IClassFixture +{ + private const uint First = 1, Last = 6; + private const float Tolerance = 1e-3f; + + public sealed class Samples : IDisposable + { + public readonly ChildrenWithinReachTests.Zones Authored = new ChildrenWithinReachTests.Zones(First, Last, null); + public readonly ChildrenWithinReachTests.Zones Laneless = new ChildrenWithinReachTests.Zones(First, Last, z => z.LaneProbability = 0); + + public void Dispose() + { + Authored.Dispose(); + Laneless.Dispose(); + } + } + + private readonly Samples _samples; + private readonly ITestOutputHelper _out; + + public LanedBeltTests(Samples samples, ITestOutputHelper output) + { + _samples = samples; + _out = output; + } + + private static CultRecordKey KeyOf(Zone zone, Orbit orbit) => zone.Orbits.First(o => o.Value == orbit).Key; + + private static BodyData BodyAt(Zone zone, Orbit orbit) => + zone.Planets.Values.Single(b => !(b is AsteroidBeltData) && zone.Orbits[b.Orbit.Key] == orbit); + + // The lane's half-width, spelled from the settings: LaneBodyRadii of the host's body radius. + private float HalfWidth(Zone zone, float hostMass) => _samples.Authored.Settings.LaneBodyRadii * zone.Settings.BodyRadius.Evaluate(hostMass); + + [Fact] + public void ALaneMovesNothingElseInTheZone() + { + var a = _samples.Laneless.All; + var b = _samples.Authored.All; + Assert.Equal(a.Count, b.Count); + var lanes = 0; + for (var n = 0; n < a.Count; n++) + { + Assert.Equal(a[n].Seed, b[n].Seed); + Assert.Empty(BeltShapes.Lanes(a[n].Zone)); + var zone = b[n].Zone; + var laned = BeltShapes.Lanes(zone).ToList(); + lanes += laned.Count; + bool SkipOrbit(CultRecordKey k) => laned.Any(l => l.Rail == zone.Orbits[k]); + bool SkipBody(BodyData body) => laned.Any(l => l.Belt.Data == body); + Assert.Equal(ZoneFingerprint.Of(new[] { a[n].Zone }, _ => false, _ => false, true), ZoneFingerprint.Of(new[] { zone }, SkipOrbit, SkipBody, true)); + } + _out.WriteLine($"zones {b.Count}, lanes {lanes}"); + Assert.True(lanes > 0, $"no lane in seeds {First}-{Last}"); + } + + [Fact] + public void ALaneIsEmptyAndBothSidesHoldRocks() + { + var s = _samples.Authored.Settings; + var q = 1 / (1 - GravityField.Reach / 2); + var lanes = 0; + foreach (var (seed, zone) in _samples.Authored.All) + { + var placed = ChildrenWithinReachTests.Placed(zone); + foreach (var (host, _, belt) in BeltShapes.Lanes(zone)) + { + lanes++; + Assert.False(belt.Data.RidesRail); + Assert.Equal(0f, belt.Data.Mass); + var hostMass = BodyAt(zone, host).Mass; + var d = host.Data.Distance; + var e = HalfWidth(zone, hostMass); + Assert.Equal((int) s.AsteroidCount.Evaluate(hostMass * d), belt.Data.Asteroids.Length); + + // The host's slot, spelled from its siblings, its parent's Roche margin and its parent's window. + var parent = host.Data.Parent.Key; + var sibling = ChildrenWithinReachTests.ChildrenOf(zone, parent, placed); + var at = sibling.IndexOf(KeyOf(zone, host)); + Assert.True(at >= 0 && at < sibling.Count - 1, $"seed {seed}: a lane host is its parent's outermost child"); + var parentBody = zone.Planets.Values.FirstOrDefault(p => !(p is AsteroidBeltData) && p.Orbit.Key.Equals(parent)); + var roche = parentBody == null ? 0f : 1.5f * zone.Settings.RocheLimit.Evaluate(parentBody.Mass); + var innerBound = Math.Max(roche, at > 0 ? zone.Orbits[sibling[at - 1]].Data.Distance * q : 0f); + var grand = zone.Orbits[parent].Data.Parent; + var window = grand.IsSet() + ? GravityField.Gamma * GravityField.Reach * zone.Orbits[parent].Data.Distance / Math.Max(GravityField.Beta, 1 + GravityField.Reach) + : float.PositiveInfinity; + var outerBound = Math.Min(window, zone.Orbits[sibling[at + 1]].Data.Distance / q); + + var rocks = belt.Data.Asteroids.Select(r => r.Distance).ToList(); + var inner = rocks.Where(r => r < d).ToList(); + var outer = rocks.Where(r => r > d).ToList(); + _out.WriteLine($"seed {seed}: lane at {d} half-width {e}, slot [{innerBound}, {outerBound}], rocks {inner.Count} inside, {outer.Count} outside"); + Assert.True(rocks.All(r => Math.Abs(r - d) >= e - 1e-5f * d), $"seed {seed}: a rock sits {rocks.Min(r => Math.Abs(r - d))} from a host at {d}, inside a lane of {e}"); + Assert.True(inner.Count > 0 && outer.Count > 0, $"seed {seed}: a lane at {d} holds {inner.Count} rocks inside and {outer.Count} outside"); + Assert.True(d - e - innerBound >= e * (1 - Tolerance), $"seed {seed}: the inner band [{innerBound}, {d - e}] is narrower than the lane's half-width {e}"); + Assert.True(outerBound - d - e >= e * (1 - Tolerance), $"seed {seed}: the outer band [{d + e}, {outerBound}] is narrower than the lane's half-width {e}"); + Assert.True(inner.Min() >= innerBound * (1 - Tolerance), $"seed {seed}: a rock at {inner.Min()} reaches inside the slot's inner bound {innerBound}"); + Assert.True(outer.Max() <= outerBound * (1 + Tolerance), $"seed {seed}: a rock at {outer.Max()} reaches past the slot's outer bound {outerBound}"); + } + } + Assert.True(lanes > 0, $"no lane in seeds {First}-{Last}"); + } + + [Fact] + public void ALaneHostIsALightBodyWithNoOtherShape() + { + var s = _samples.Authored.Settings; + var lanes = 0; + foreach (var (seed, zone) in _samples.Authored.All) + { + var laned = BeltShapes.Lanes(zone).ToList(); + foreach (var (host, _, _) in laned) + { + lanes++; + var body = BodyAt(zone, host); + Assert.True(body.GetType() == typeof(PlanetData) && body.Mass < s.BeltMassCeiling, $"seed {seed}: a lane host is a {body.GetType().Name} of mass {body.Mass}"); + var key = KeyOf(zone, host); + Assert.DoesNotContain(zone.Orbits.Values, o => o.Data.Parent.IsSet() && o.Data.Parent.Key.Equals(key)); + // A swarm's rail is a copy of its host's too: no lane host carries one. + Assert.DoesNotContain(zone.AsteroidBelts.Where(b => b.Value.Data.RidesRail).Select(b => zone.Orbits[zone.Planets[b.Key].Orbit.Key].Data), + r => r.Parent.Key.Equals(host.Data.Parent.Key) && r.Distance == host.Data.Distance && r.Phase == host.Data.Phase); + } + // One belt shape per parent: an annulus (a belt on a placed slot) or a lane, never both, never two lanes. + var annuli = zone.AsteroidBelts.Keys.Select(k => zone.Planets[k].Orbit.Key).Where(o => !BeltShapes.CopiedRail(zone, o)) + .Select(o => zone.Orbits[o].Data.Parent.Key); + var doubled = annuli.Concat(laned.Select(l => l.Host.Data.Parent.Key)).GroupBy(p => p).Count(g => g.Count() > 1); + Assert.True(doubled == 0, $"seed {seed}: {doubled} parents carry two belt shapes"); + } + Assert.True(lanes > 0, $"no lane in seeds {First}-{Last}"); + } + + // A belt's field kind is chosen at generation and saved with it (Mining Cut 3, Q16 B): every belt the generator writes, + // annulus, swarm or lane, carries one before any Zone reads it (finding ensure-unpinned). + [Fact] + public void EveryBeltIsKindedAtGeneration() + { + var zones = _samples.Authored; + _out.WriteLine($"belts {zones.Belts}, unkinded at generation {zones.UnkindedAtGeneration}"); + Assert.True(zones.All.Any(z => z.Zone.AsteroidBelts.Values.Any(b => b.Data.RidesRail)), "no swarm in the sample"); + Assert.True(zones.All.Any(z => BeltShapes.Lanes(z.Zone).Any()), "no lane in the sample"); + Assert.True(zones.Belts > 20, $"only {zones.Belts} belts generated"); + Assert.Equal(0, zones.UnkindedAtGeneration); + } +} diff --git a/tests/Aetheria.Shared.Tests/TrojanSwarmTests.cs b/tests/Aetheria.Shared.Tests/TrojanSwarmTests.cs index d5428481a..0c7e7487a 100644 --- a/tests/Aetheria.Shared.Tests/TrojanSwarmTests.cs +++ b/tests/Aetheria.Shared.Tests/TrojanSwarmTests.cs @@ -98,7 +98,8 @@ public class TrojanSwarmTests } // The widened digests (ZoneFingerprint with PopulatedRadius) were computed on Yggdrasil over the generator of cut laned-belts - // C1 (one belt extent rule) with swarms off, over the zones of each seed: a zone with no swarm is the base zone, and a zone with + // C1 (one belt extent rule) with swarms off, over the zones of each seed; both samples draw no lane, so the base is the zone + // with neither shape: a zone with no swarm is the base zone, and a zone with // swarms is the base zone plus them. The per-zone comparison leaves PopulatedRadius out, since a swarm's outer rock may grow it. private static readonly Dictionary BaseDigest = new Dictionary { @@ -113,8 +114,8 @@ public class TrojanSwarmTests [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); + using var swarmless = new ChildrenWithinReachTests.Zones(First, Last, z => { z.TrojanProbability = 0; z.LaneProbability = 0; }); + using var live = new ChildrenWithinReachTests.Zones(First, Last, z => z.LaneProbability = 0); Assert.Equal(swarmless.All.Count, live.All.Count); int swarms = 0, grew = 0; foreach (var seed in BaseDigest.Keys) From a46c0536cc3e86d69f31335784847c7c2be1dffe Mon Sep 17 00:00:00 2001 From: soul Date: Wed, 21 Oct 2026 23:25:26 +0200 Subject: [PATCH 108/444] release map: ProductWeight's line share counts only offered bins Co-Authored-By: Claude Opus 5.5 --- docs/aetheria-release-map.md | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/docs/aetheria-release-map.md b/docs/aetheria-release-map.md index 848ab38f5..3176c3163 100644 --- a/docs/aetheria-release-map.md +++ b/docs/aetheria-release-map.md @@ -11034,7 +11034,9 @@ presence for the design does not change when it bins a line. - 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 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 + share is 1 minus the sum of the pass fractions of its offered bins, floored at 0, so an off-market bin takes nothing + from its line (cut product-not-offered; a bin of an off-market line is itself off-market, ruling + bin-inherits-line-offered). 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. From 462adf26939c4d672a9b3f73b24c66fc65a6cba5 Mon Sep 17 00:00:00 2001 From: soul Date: Wed, 21 Oct 2026 23:38:03 +0200 Subject: [PATCH 109/444] determinism-ledger: pin each scatter draw's ordinal and bounds, and the zero item-seed refusal Three origin drifters, each placed at For(CombatSeed, ScatterStream, k) within half the radius; a galaxy seed whose ContinueStep item seed wraps to zero is refused. Co-Authored-By: Claude Opus 5.5 --- .../DeterminismLedgerTests.cs | 59 +++++++++++++++++-- 1 file changed, 53 insertions(+), 6 deletions(-) diff --git a/tests/Aetheria.Shared.Tests/DeterminismLedgerTests.cs b/tests/Aetheria.Shared.Tests/DeterminismLedgerTests.cs index 0a5636720..1a1a64b1e 100644 --- a/tests/Aetheria.Shared.Tests/DeterminismLedgerTests.cs +++ b/tests/Aetheria.Shared.Tests/DeterminismLedgerTests.cs @@ -60,19 +60,40 @@ public sealed partial class RunStartTests Assert.Equal(expected.Random.state, items.Random.state); } - // A pack holding one unpiloted ship at the origin besides the player, from a staged arena. + private const int OriginDrifters = 3; + + // The galaxy seed whose ContinueStep item seed wraps to zero: -ContinueStep times the inverse of 0x9E3779B1, mod 2^32. + private const uint ZeroAtContinueSeed = 0xD45E720Du; + + [Fact] + public void ItemSeedRefusesAZeroSeed() + { + var galaxy = Launch(new Scripted(false, stage => stage.Player(stage.Bare("Djinni"), float2(0, 0)), + seed: ZeroAtContinueSeed)).galaxy; + Assert.Equal(ZeroAtContinueSeed, galaxy.Seed); + Assert.NotEqual(0u, RunStart.ItemSeed(galaxy, RunStart.ArenaStep)); + var refused = Assert.Throws(() => RunStart.ItemSeed(galaxy, RunStart.ContinueStep)); + Assert.Contains("zero", refused.Message); + } + + // A pack holding three unpiloted ships at the origin besides the player, from a staged arena. More than one, so + // each ship's own ordinal is visible: ships sharing a draw would stack on one point. private (ZonePack pack, Galaxy galaxy) OriginPack() { - Ship drifter = null; + var drifters = new List(); var (galaxy, arena, staged, failures) = Launch(new Scripted(false, stage => { stage.Player(stage.Bare("Djinni"), float2(-50000, -50000)); - drifter = stage.Place(stage.Bare("Longinus"), float2(0, 0)) as Ship; + for (var i = 0; i < OriginDrifters; i++) + drifters.Add(stage.Place(stage.Bare("Longinus"), float2(0, 0)) as Ship); })); Assert.True(failures.Count == 0, string.Join("; ", failures)); - Assert.NotNull(drifter); - Assert.False(drifter.IsPlayerShip); - Assert.True(lengthsq(drifter.Position) < 1, "the fixture's drifter is packed at the origin"); + Assert.All(drifters, drifter => + { + Assert.NotNull(drifter); + Assert.False(drifter.IsPlayerShip); + Assert.True(lengthsq(drifter.Position) < 1, "the fixture's drifters are packed at the origin"); + }); return (arena.PackZone(), galaxy); } @@ -97,6 +118,32 @@ public sealed partial class RunStartTests Assert.Equal(first, second); } + // Each origin ship is placed by its own draw: the 1-based ordinal of its entry in the pack, on the zone's + // ScatterStream, within half the zone's radius of the centre on every axis. + [Fact] + public void ScatterDrawsEachShipsOrdinalWithinHalfTheRadius() + { + var (pack, galaxy) = OriginPack(); + var zone = new Zone(_items, _planetSettings, pack, galaxy.Entrance, galaxy); + Assert.Equal(pack.Entities.Count, zone.Entities.Count); + + var half = pack.Radius * .5f; + var expected = new List(); + var actual = new List(); + for (var i = 0; i < zone.Entities.Count; i++) + { + if (zone.Entities[i] is not Ship {IsPlayerShip: false} ship) continue; + expected.Add(SimulationDice.For(zone.CombatSeed, SimulationDice.ScatterStream, (uint) i + 1) + .NextFloat3(float3(-half), float3(half))); + actual.Add(ship.Position); + } + + Assert.Equal(OriginDrifters, actual.Count); + Assert.Equal(expected, actual); + Assert.Equal(OriginDrifters, actual.Distinct().Count()); + Assert.All(actual, position => Assert.True(all(abs(position) <= half), $"{position} is within half the radius {half}")); + } + [Fact] public void ScatterIsPerZone() { From 2fe4129777f7b58d2950b3c81a5727f1b1dc7695 Mon Sep 17 00:00:00 2001 From: soul Date: Wed, 21 Oct 2026 23:40:26 +0200 Subject: [PATCH 110/444] Zhestokost kit: land the hull-bench kit; zh-ammo-tender fixture rebuilt from Oboz A with one defensive mount factions/zhestokost.scad is the bench draft byte for byte (battered masses, typed mates, five zones with cream marking), its lang comment naming the maker element. The fixture is bench Oboz A plus Gun.D on T[2]'s deck centreline aft of its hatches; the smoke's palette, shadow colour and turned-mount pair follow. Spec: aetheria-release:cut_spec:cut-zh-kit-landing.r1 Co-Authored-By: Claude Opus 5.5 --- .../aetheria_ships/factions/zhestokost.scad | 243 ++++++++++++++---- .../fixtures/hulls/zh-ammo-tender/hull.scad | 113 +++++--- tools/blender/tests/smoke_hull_source.py | 7 +- 3 files changed, 273 insertions(+), 90 deletions(-) diff --git a/tools/blender/aetheria_ships/factions/zhestokost.scad b/tools/blender/aetheria_ships/factions/zhestokost.scad index 9c4e65bb9..752559e71 100644 --- a/tools/blender/aetheria_ships/factions/zhestokost.scad +++ b/tools/blender/aetheria_ships/factions/zhestokost.scad @@ -1,62 +1,209 @@ -// Zhestokost form language: the one owner of this faction's shape kit, proportions, symmetry rule and palette. -// Canon. Operator ruling zhestokost-language-lore-text: Zhestokost is industrial and brutalist, "the dream of the -// eternal Soviet, a society that has achieved total and unassailable permaculture via a carefully organized and -// brutally practical (self-policing) state". Form follows the lore page ("Faction Flavor and Visual Identity" > -// Zhestokost): low broad stepped masses, deep seams, charcoal, iron grey, dull oxide red. The operator's faction sheet -// (Asset Sources/Zhestokost.png) lends the handling boom with hanging magazines as vocabulary, not its palette. -// A hull composes these modules; nothing here places itself. +// Zhestokost form language: the one owner of this faction's part kit, proportions, symmetry rule and palette. +// Operator ruling zhestokost-language-lore-text: "The core design pillars were simply industrial and brutalist ... +// Zhestokost is the dream of the eternal Soviet, a society that has achieved total and unassailable permaculture via a +// carefully organized and brutally practical (self-policing) state." Lore (Faction Flavor > Zhestokost > Visual +// Identity): low broad stepped masses, thick armour round recessed machinery, apparent weight from deep seams and +// substantial supports rather than clutter; charcoal, iron grey, dull oxide red, small cream serials and safety marks. +// +// Brutalist is not "boxes of uniform weight". The vocabulary here is poured and cast concrete-and-iron: +// * MASSES are battered (walls slope in to the deck and in to the belly from a vertical cast belt at the waist), so +// every mass reads as poured, with a lit deck, a lit upper wall, a dark belt line and a shadowed belly. +// * HIERARCHY: one ship = a narrow CORE plus a few heavy repeated MODULES wider than the core, so the plan is +// castellated (wide-narrow-wide), never a plain rectangle. Modules step up in TIERS. +// * SHADOW GAPS: modules stand apart across deep reveals; the reveal floor is the recess zone, so the gap reads black +// from above even at 48 px. +// * Fittings are few, thick and functional: hatches with heavy hinge bars, collars ringed with lugs, a tow eye. +// +// How a Zhestokost hull is built (a hull composes these modules; nothing here places itself): +// 1. A MASS is a list of sections nose (-Y) to stern (+Y): [y, width, height, base z, deck inset, belly inset] +// (zh_s). Make lists with zh_plan (a core or hull), zh_block (a module), zh_tier (a stepped tier on any mass); +// draw them with zh_mass. Keep the lists: every other part mates to them. +// 2. Every other part is placed ON a mass by a typed mate: zh_on_top, zh_on_wall, zh_on_belt, zh_on_belly, +// zh_on_stern, zh_on_nose. In the mate frame local +Z is the outward normal and the part is sunk ZH_SINK into +// the skin, so nothing floats. Top/belly frames keep local +Y aft; wall/belt frames keep +Y aft with local +X +// pointing down the wall on either side (starboard mates are mirrors of port); stern/nose frames have local +Y down. +// 3. Zone routing: every zh_ module draws only the zone the global `part` names, so a hull writes its assembly once. +// Anchor meshes (zh_rad_vanes, zh_emitter) are not routed: call them inside the hull's rad:/emit: parts under +// the same mate that placed their housing. +// Scale: chamfers are cast-heavy (ZH_CH); fittings have standard sizes (ZH_HATCH, ZH_COLLAR_R, ZH_LUG) shared by +// every hull so the family reads. include -ZH_CH = 0.22; // chamfer: heavy, square, one segment -// A stepped mass: tiers shrink upward and pull in from both ends. "Low, broad ... stepped rectangular masses." -module zh_stepped(L, W, H, tiers=3, pull=0.12) { - for (i=[0:tiers-1]) let(k=i/tiers) - translate([0, 0, H*k + H/tiers/2]) cbox([W*(1-pull*i*1.6), L*(1-pull*i), H/tiers + 0.02], ZH_CH); + +ZH_WL = 0.30; // cast belt: from this fraction of section height ... +ZH_WU = 0.50; // ... to this one; above it the wall batters in to the deck, below it in to the belly +ZH_CH = 0.20; // edge chamfer for fittings +ZH_SINK = 0.05; // mates sink parts this far into the skin +ZH_HATCH = 2.6; // standard magazine hatch (m, square) +ZH_COLLAR_R = 1.05; // standard transfer collar radius +ZH_LUG = 1.0; // standard tow lug scale + +module zh_z(z) { if (part == z) children(); } + +// ==== geometry core: sections, surface points, mates ================================================================= +// A section. bt: deck inset (how far the upper wall batters in), bb: belly inset. Defaults keep the faction's batter. +function zh_s(y, w, h, z0=0, bt=undef, bb=undef) = + [y, w, h, z0, is_undef(bt) ? h*0.26 : bt, is_undef(bb) ? h*0.22 : bb]; +function zh_poly(s) = let(w = s[1]/2, h = s[2], z = s[3], t = s[4], b = s[5]) + [[-(w-b), z], [w-b, z], [w, z+h*ZH_WL], [w, z+h*ZH_WU], [w-t, z+h], [-(w-t), z+h], [-w, z+h*ZH_WU], [-w, z+h*ZH_WL]]; +module _zh_slice(s) translate([0, s[0], 0]) rotate([90, 0, 0]) linear_extrude(0.002, center=true) polygon(zh_poly(s)); + +function _zh_i(secs, y) = let(ix = [for (k = [0:len(secs)-2]) if (secs[k][0] <= y) k]) len(ix) ? ix[len(ix)-1] : 0; +// the mass's section at station y (linear between stations, as the mesh is) +function zh_sec(secs, y) = let(i = _zh_i(secs, y), a = secs[i], b = secs[i+1], + f = max(0, min(1, (y - a[0]) / (b[0] - a[0])))) a + (b - a) * f; +function zh_y0(secs) = secs[0][0]; +function zh_y1(secs) = secs[len(secs)-1][0]; + +// Surface points. u: -1 starboard .. 1 port across the deck or belly; v: 0..1 up a wall or belt; side: 1 port, -1 stbd. +function zh_top(secs, y, u=0) = let(s = zh_sec(secs, y)) [u*(s[1]/2 - s[4]), y, s[3] + s[2]]; +function zh_belly(secs, y, u=0) = let(s = zh_sec(secs, y)) [u*(s[1]/2 - s[5]), y, s[3]]; +function zh_belt(secs, y, side=1, v=0.5) = let(s = zh_sec(secs, y)) [side*s[1]/2, y, s[3] + s[2]*(ZH_WL + (ZH_WU-ZH_WL)*v)]; +function zh_wall(secs, y, side=1, v=0.5) = let(s = zh_sec(secs, y)) [side*(s[1]/2 - s[4]*v), y, s[3] + s[2]*(ZH_WU + (1-ZH_WU)*v)]; +function zh_wall_tilt(secs, y) = let(s = zh_sec(secs, y)) atan2(s[4], s[2]*(1-ZH_WU)); // degrees off vertical +function zh_stern_pt(secs, x=0, v=0.5) = let(s = secs[len(secs)-1]) [x, s[0], s[3] + s[2]*v]; +function zh_nose_pt(secs, x=0, v=0.5) = let(s = secs[0]) [x, s[0], s[3] + s[2]*v]; + +module zh_on_top(secs, y, u=0, sink=ZH_SINK) translate(zh_top(secs, y, u) - [0, 0, sink]) children(); +module zh_on_belly(secs, y, u=0, sink=ZH_SINK) translate(zh_belly(secs, y, u) + [0, 0, sink]) rotate([0, 180, 0]) children(); +module _zh_port(side) { if (side < 0) mirror([1, 0, 0]) children(); else children(); } +module zh_on_belt(secs, y, side=1, v=0.5, sink=ZH_SINK) + _zh_port(side) translate(zh_belt(secs, y, 1, v) - [sink, 0, 0]) rotate([0, 90, 0]) children(); +module zh_on_wall(secs, y, side=1, v=0.5, sink=ZH_SINK) { + t = zh_wall_tilt(secs, y); + _zh_port(side) translate(zh_wall(secs, y, 1, v) - [cos(t), 0, sin(t)]*sink) rotate([0, 90 - t, 0]) children(); } -// Armour plate standing proud of a face, leaving a deep seam (the seam is the gap to the next plate). -module zh_plate(w, l, t=0.3) { cbox([w, l, t], 0.08); } -// Recessed machinery: a dark box set into a mass (subtract the pocket in the hull, then place this inside). -module zh_recess(w, l, d) { cube([w, l, d], center=true); } -// Ammunition magazine: square cassette with ribbed loading face and a hanger lug on top. -module zh_cassette(w=1.2, l=1.6, h=2.0) { - cbox([w, l, h], 0.12); - for (z=[-h*0.3, 0, h*0.3]) translate([0, -l/2-0.04, z]) cube([w*0.8, 0.12, 0.18], center=true); - translate([0, 0, h/2+0.2]) cube([0.3, 0.6, 0.4], center=true); +module zh_on_stern(secs, x=0, v=0.5, sink=ZH_SINK) translate(zh_stern_pt(secs, x, v) - [0, sink, 0]) rotate([-90, 0, 0]) children(); +module zh_on_nose(secs, x=0, v=0.5, sink=ZH_SINK) translate(zh_nose_pt(secs, x, v) + [0, sink, 0]) rotate([90, 0, 0]) rotate([0, 0, 180]) children(); +// world point of a local offset under a mate (for anchors): top, belly, stern frames +function zh_top_at(secs, y, u, o, sink=ZH_SINK) = zh_top(secs, y, u) - [0, 0, sink] + o; +function zh_belly_at(secs, y, u, o, sink=ZH_SINK) = zh_belly(secs, y, u) + [0, 0, sink] + [-o[0], o[1], -o[2]]; +function zh_stern_at(secs, x, v, o, sink=ZH_SINK) = zh_stern_pt(secs, x, v) - [0, sink, 0] + [o[0], o[2], -o[1]]; + +// ==== PRIMARY: masses that make the silhouette ======================================================================== +// Any section list as one faceted poured mass. +module zh_mass(secs, zone="primary") zh_z(zone) for (i = [0:len(secs)-2]) hull() { _zh_slice(secs[i]); _zh_slice(secs[i+1]); } + +// A core or whole hull: bow chamfered in plan and dropped (bow, as a fraction of L), stern a flat face with a cast lip. +// bowW / bowH: nose width and height as fractions of W and H. +function zh_plan(L, W, H, z0=0, y0=0, bow=0.2, bowW=0.55, bowH=0.7, bt=undef, bb=undef) = let(n = y0 - L/2, s = y0 + L/2) + [zh_s(n, W*bowW, H*bowH, z0 + H*(1-bowH)*0.55, bt, bb), zh_s(n + L*bow, W, H, z0, bt, bb), + zh_s(s - 0.35, W, H, z0, bt, bb), zh_s(s, W - 0.5, H - 0.3, z0 + 0.15, bt, bb)]; + +// A heavy repeated module (magazine, reactor block, barracks): chamfered in plan at both ends by ch. +function zh_block(y, l, w, h, z0=0, bt=undef, bb=undef, ch=0.5) = + let(t = is_undef(bt) ? h*0.24 : bt, b = is_undef(bb) ? h*0.2 : bb) + [zh_s(y - l/2, w - 2*ch, h - ch, z0 + ch*0.5, max(0.05, t - ch*0.5), max(0.05, b - ch*0.5)), + zh_s(y - l/2 + ch, w, h, z0, t, b), zh_s(y + l/2 - ch, w, h, z0, t, b), + zh_s(y + l/2, w - 2*ch, h - ch, z0 + ch*0.5, max(0.05, t - ch*0.5), max(0.05, b - ch*0.5))]; + +// A stepped tier poured on top of any mass: inset from the deck edge, ends pulled in by yin, h tall. +function zh_tier(secs, inset=0.6, h=0.7, yin=0.6, bt=undef) = let(n = len(secs)) + [for (i = [0:n-1]) let(s = secs[i], y = s[0] + (i == 0 ? yin : i == n-1 ? -yin : 0), d = zh_sec(secs, y)) + zh_s(y, d[1] - 2*d[4] - 2*inset, h, d[3] + d[2] - 0.02, is_undef(bt) ? h*0.4 : bt, 0)]; + +// ==== SECONDARY: forms that give a hull its individuality ============================================================= +// Low armoured cab (top frame): battered block, a deep cast brow over a black vision slit. Nose is -Y. +module zh_cab(w=3.6, l=3.0, h=1.5, zone="secondary") { + zh_mass(zh_block(0, l, w, h, 0, h*0.32, 0, 0.4), zone); + zh_z(zone) translate([0, -l/2 + 0.05, h*0.74]) cbox([w*0.84, 0.9, 0.34], 0.08); // brow + zh_z("recess") translate([0, -l/2 + 0.32, h*0.5]) cube([w*0.66, 0.6, 0.26], center=true); // slit, in the brow's shadow } -// Handling boom: square box beam with cross-members; magazines hang from it. -module zh_boom(len, w=0.9, h=0.7) { - cbox([w, len, h], 0.1); - for (y=[-len/2+0.6:1.6:len/2-0.4]) translate([0, y, -h/2-0.1]) cube([w+0.4, 0.25, 0.25], center=true); + +// Squat drive pod (belly frame: local +Z is down, away from the hull): battered block, nozzle mouth on its aft face. +module zh_pod(w=2.0, l=3.2, h=1.4, zone="secondary") { + zh_mass(zh_block(0, l, w, h, 0, h*0.18, h*0.22, 0.35), zone); + zh_z(zone) translate([0, l/2 + 0.08, h*0.5]) difference() { // cast nozzle frame + cbox([w*0.86, 0.36, h*0.78], 0.08); cube([w*0.56, 1, h*0.5], center=true); } + zh_z("recess") translate([0, l/2 - 0.02, h*0.5]) cube([w*0.58, 0.3, h*0.52], center=true); } -// Towing point: a thick eye on a block. -module zh_tow_lug() { cbox([0.6, 0.6, 0.4], 0.08); translate([0,0,0.45]) rotate([0,90,0]) difference() { cylinder(r=0.38, h=0.25, center=true, $fn=8); cylinder(r=0.18, h=1, center=true, $fn=8); } } -// Ballistic mount: square base, slab mantlet, N thick barrels with a box muzzle brake. Barrels point -Y (nose). -module zh_gun(n=2, cal=0.22, len=3.0, base=1.6) { - cbox([base, base, 0.5], 0.12); - translate([0, 0, 0.55]) cbox([base*0.85, base*0.7, 0.7], 0.12); - for (i=[0:n-1]) translate([(i-(n-1)/2)*cal*3, -base*0.3, 0.6]) { - rotate([0,0,180]) tube(cal, len, 8); +function zh_pod_mouth(w=2.0, l=3.2, h=1.4) = [0, l/2 + 0.14, h*0.5]; +// thruster-emitter mesh at the pod's (or any) nozzle mouth: call under the same mate, translated to zh_pod_mouth +module zh_emitter(w, h) cube([w, 0.05, h], center=true); + +// Transfer collar (belt or wall frame): thick octagonal ring proud of the skin, black bore, guide lugs in oxide. +module zh_collar(r=ZH_COLLAR_R, proud=0.55, lugs=4) { + zh_z("secondary") difference() { + cylinder(r=r, h=proud + ZH_SINK, $fn=8); translate([0, 0, -0.1]) cylinder(r=r*0.6, h=proud + 1, $fn=8); } + zh_z("recess") cylinder(r=r*0.62, h=0.08 + ZH_SINK, $fn=8); + zh_z("accent") for (i = [0:lugs-1]) rotate(i*360/lugs) translate([r + 0.16, 0, (proud + 0.25)/2]) + cube([0.5, 0.6, proud + 0.25 + ZH_SINK], center=true); +} + +// Magazine hatch (any frame, +Z out): oxide slab, two heavy hinge bars and four dogs in charcoal, cream stripe at aft edge. +// dogs=false for a small or secondary hatch (saves ~100 triangles). +module zh_hatch(w=ZH_HATCH, l=ZH_HATCH, t=0.22, dogs=true) { + zh_z("accent") translate([0, 0, t/2]) cbox([w, l, t + ZH_SINK], 0.06); + zh_z("secondary") { + for (x = [-1, 1]) translate([x*w*0.27, 0, t + 0.12]) cbox([0.5, l + 0.6, 0.34], 0.07); + if (dogs) for (sx = [-1, 1], sy = [-1, 1]) translate([sx*(w/2 + 0.12), sy*l*0.3, t*0.6]) cube([0.36, 0.44, 0.4], center=true); + } + translate([0, l/2 - 0.42, t]) zh_stripes(w*0.36, 0.5, dogs ? 3 : 2); +} + +// Armour plate (any frame): replaceable slab with four bolt bosses; leaves a seam to its neighbour. +module zh_plate(w, l, t=0.3, zone="secondary") zh_z(zone) { + translate([0, 0, t/2]) cbox([w, l, t + ZH_SINK], 0.07); + for (sx = [-1, 1], sy = [-1, 1]) translate([sx*(w/2 - 0.25), sy*(l/2 - 0.25), t + 0.05]) cube([0.22, 0.22, 0.12], center=true); +} + +// Radiator trough (top frame): cast frame round a black floor, split by a spine wall; vanes stand in it (zh_rad_vanes). +module zh_rad_trough(w=3.2, l=2.4, d=0.45, zone="secondary") { + zh_z(zone) translate([0, 0, d/2]) difference() { + cbox([w + 0.7, l + 0.7, d + ZH_SINK], 0.1); cube([w, l, d*3], center=true); } + zh_z(zone) translate([0, 0, d*0.6]) cube([0.3, l, d*1.2 + ZH_SINK], center=true); + zh_z("recess") translate([0, 0, 0.04]) cube([w, l, 0.08 + ZH_SINK], center=true); +} +// radiator-mesh for one half of a trough (side 1 port, -1 starboard): transverse vanes rising out of it +module zh_rad_vanes(w=3.2, l=2.4, side=1, n=5, h=1.0) { + for (i = [0:n-1]) translate([side*(w/4 + 0.08), -l/2 + 0.2 + i*(l - 0.4)/(n-1), h/2]) cube([w/2 - 0.36, 0.14, h], center=true); +} +function zh_rad_centre(w=3.2, side=1, h=1.0) = [side*(w/4 + 0.08), 0, h/2]; + +// Tow lug (stern frame: local +Z aft, local +Y down): a cast block, a neck, a heavy oxide eye with a vertical pin hole. +module zh_tow_lug(s=ZH_LUG, zone="secondary") { + zh_z(zone) { translate([0, 0, 0.4*s]) cbox([1.8*s, 1.3*s, 0.8*s + ZH_SINK], 0.16*s); + translate([0, 0, 1.05*s]) cbox([1.0*s, 0.8*s, 0.6*s], 0.1*s); } + zh_z("accent") translate([0, 0, 1.75*s]) rotate([90, 0, 0]) difference() { + cylinder(r=0.72*s, h=0.55*s, center=true, $fn=8); cylinder(r=0.3*s, h=2*s, center=true, $fn=8); } +} + +// Ballistic mount (top frame): octagonal ring, battered mantlet block, n thick barrels with box brakes, pointing -Y. +module zh_gun(n=2, cal=0.22, len=3.0, base=1.8, zone="secondary") { + zh_z(zone) cylinder(r=base*0.56, h=0.35 + ZH_SINK, $fn=8); + zh_mass([for (s = zh_block(0, base*0.95, base, base*0.5, 0.33, base*0.14, 0, 0.22)) s], zone); + zh_z(zone) for (i = [0:n-1]) translate([(i - (n-1)/2)*cal*3.2, -base*0.4, 0.33 + base*0.25]) { + rotate([0, 0, 180]) tube(cal, len, 8); translate([0, -len, 0]) cbox([cal*3, cal*3.5, cal*3], 0.04); } } -// Drive block: a flat rear face with square nozzles in a grid (recessed: emitters sit at the nozzle mouths). -module zh_drive_block(w, h, d, nx=2, nz=1, nozzle=1.0) { - difference() { - cbox([w, d, h], ZH_CH); - for (i=[0:nx-1], j=[0:nz-1]) translate([(i-(nx-1)/2)*(w/nx), d/2, (j-(nz-1)/2)*(h/nz)]) cube([nozzle, 0.8, nozzle], center=true); - } +function zh_gun_muzzle(n=2, cal=0.22, len=3.0, base=1.8) = [0, -base*0.4 - len - cal*1.75, 0.33 + base*0.25]; + +// ==== TERTIARY: detail =============================================================================================== +// Shadow-gap floor (top frame): the black bottom of a reveal between modules. +module zh_reveal(w, l) zh_z("recess") translate([0, 0, 0.03]) cube([w, l, 0.06 + ZH_SINK], center=true); +// Cream safety chevrons (any frame, +Z out): n diagonal bars clipped to w x l. +module zh_stripes(w, l, n=3) zh_z("marking") intersection() { + translate([0, 0, 0.04]) cube([w, l, 0.1], center=true); + for (i = [0:n-1]) translate([(i - (n-1)/2)*w/n, 0, 0.04]) rotate([0, 0, 45]) cube([w/(n*2.2), l*3, 0.12], center=true); } +// Cream block serial (any frame, +Z out; reads along local Y): stencil bars of a unit number. +module zh_serial(h=0.7, n=4) zh_z("marking") for (i = [0:n-1]) + translate([0, (i - (n-1)/2)*h*0.75, 0.03]) cube([h*(i % 2 == 0 ? 1 : 0.6), h*0.42, 0.08], center=true); - -// Palette zones. Value contrast carries the read at 48 px on dark space: the body is iron grey, the plates and seams -// are charcoal, the oxide red is the only hue. Zones become livery mask channels. -ZH_ZONES = [["primary", [0.42, 0.43, 0.45]], // iron grey: the stepped mass - ["secondary", [0.20, 0.20, 0.22]], // charcoal: armour plates, booms, guns, command block - ["accent", [0.55, 0.22, 0.14]], // dull oxide red: magazines, markings - ["recess", [0.08, 0.08, 0.09]]]; // recessed machinery +// ==== palette ======================================================================================================== +// Value carries the 48 px read on dark space, within the lore palette (operator: solved within the palette): iron grey +// masses are the LIGHT value, charcoal fittings the mid-dark, reveals near black, oxide red the one hue, and the lore's +// cream serials/safety marks are the brightest small value. Five zones become livery mask channels. +ZH_ZONES = [["primary", [0.47, 0.48, 0.50]], // iron grey: poured masses (core, modules, tiers) + ["secondary", [0.21, 0.21, 0.23]], // charcoal: cab, pods, collars, hinge bars, troughs, plates, lugs + ["accent", [0.56, 0.20, 0.12]], // dull oxide red: hatches, collar guide lugs, tow eye + ["recess", [0.05, 0.05, 0.06]], // shadow gaps, bores, slits, nozzle mouths + ["marking", [0.86, 0.80, 0.64]]]; // cream: serials and safety chevrons ZH_GLOW = [1.0, 0.62, 0.25]; -ZH_RADIATOR = [0.50, 0.51, 0.52]; +ZH_RADIATOR = [0.56, 0.57, 0.58]; // [faction, zones, body zone, glow, radiator, [height/width range, width/length range], symmetry rule, maker] // maker: the Zhestokost Faction record key in GameData/Aetheria.cc, the Manufacturer of a product whose hull speaks this language -ZH_LANG = ["zhestokost", ZH_ZONES, "primary", ZH_GLOW, ZH_RADIATOR, [[0.25, 0.6], [0.35, 0.75]], ["bilateral", 0.97], +// Low and broad: a body never taller than ~0.7 of its width; wide-for-length, as a tracked vehicle rather than a dart. +ZH_LANG = ["zhestokost", ZH_ZONES, "primary", ZH_GLOW, ZH_RADIATOR, [[0.22, 0.7], [0.32, 0.8]], ["bilateral", 0.96], "16bad4f9-7fb5-41c5-8da0-dc76709eb7d3"]; if (part == "lang") echo(lang = ZH_LANG); diff --git a/tools/blender/tests/fixtures/hulls/zh-ammo-tender/hull.scad b/tools/blender/tests/fixtures/hulls/zh-ammo-tender/hull.scad index dcd20ec00..b4214d0fc 100644 --- a/tools/blender/tests/fixtures/hulls/zh-ammo-tender/hull.scad +++ b/tools/blender/tests/fixtures/hulls/zh-ammo-tender/hull.scad @@ -1,5 +1,6 @@ -// Zhestokost ammunition tender "Podvoz". An agent-built hull source: composes the Zhestokost language only. -// part = lang | brief | anchors | primary | secondary | accent | recess | emit: | rad: +// Zhestokost ammunition tender "Oboz" (baggage train): smoke fixture, from the hull bench Oboz A. Built only from the +// Zhestokost kit. +// part = lang | brief | anchors | primary | secondary | accent | recess | marking | emit: | rad: // Frame: Ship Root, metres, nose -Y, starboard -X, up +Z. Anchor yaw: 0 nose, 90 port, 180 aft, 270 starboard. include part = "primary"; @@ -7,43 +8,77 @@ part = "primary"; HULL_FACTION = "zhestokost"; HULL_DONOR = ""; HULL_ROLE = "tender"; -HULL_LENGTH = [18, 28]; -HULL_TRIANGLES = [300, 6000]; -HULL_READS = ["ammunition magazines carried where a rearming ship can see and reach them", - "a handling boom that brings magazines forward", "towing points", - "one defensive ballistic mount, not a warship battery", "low, broad, heavy, slow"]; +HULL_LENGTH = [20, 28]; +HULL_TRIANGLES = [600, 7000]; +HULL_READS = ["the hull is the magazine: three huge stepped magazine blocks in a row on the upper half, oxide hatches with heavy hinge bars", + "two thick round transfer collars set flush in each flank at hull height, ringed by guide lugs", + "heavy towing lug jutting from the stern plate; short row of open radiator vanes on the spine behind the cab", + "low, broad, heavy; narrow cockpit slit in a low armoured block at the bow; four squat drive pods low at the belly corners", + "one defensive ballistic mount on the last magazine deck, not a warship battery"]; if (part == "brief") echo(brief = [HULL_FACTION, HULL_DONOR, HULL_ROLE, HULL_LENGTH, HULL_TRIANGLES, HULL_READS]); -L = 15; W = 10; H = 4.2; Y0 = 3; // main mass centred at y=+3 (nose is -Y) -module pockets() { for (s=[-1,1], y=[-1, 4]) translate([s*W/2, Y0+y, 1.3]) cube([1.2, 2.6, 1.4], center=true); } -module body() { difference() { translate([0, Y0, 0]) zh_stepped(L, W, H, 3, 0.10); pockets(); } } -module plates() { for (s=[-1,1], i=[0:3]) translate([s*(W/2+0.05), Y0-5.4+i*3.6, 0.6]) rotate([0,0,90]) rotate([90,0,0]) zh_plate(3.2, 1.0, 0.3); } -module band() { for (s=[-1,1], i=[0:3]) translate([s*(W/2+0.22), Y0-5.4+i*3.6, 0.6]) cube([0.04, 3.2, 0.3], center=true); } -module booms() { for (s=[-1,1]) translate([s*2.6, -5.0, H-0.3]) { zh_boom(13); translate([0, 5.6, -0.9]) cbox([1.4, 1.4, 1.6], 0.12); } } -module magazines() { for (s=[-1,1], i=[0:3]) translate([s*3.75, -10.4+i*1.9, H-1.7]) zh_cassette(1.2, 1.5, 2.0); } -module arms() { for (s=[-1,1], i=[0:3]) translate([s*3.2, -10.4+i*1.9, H-0.45]) cube([1.6, 0.3, 0.3], center=true); } -module bridge() { translate([0, Y0+5.6, H]) { cbox([4.4, 2.6, 1.4], ZH_CH); translate([0, 0.3, 1.2]) cbox([2.8, 1.8, 1.0], ZH_CH); - translate([0, -1.32, 0.35]) cube([3.2, 0.06, 0.3], center=true); } } -module lugs() { for (s=[-1,1], y=[Y0-L/2+0.8, Y0+L/2-1.0]) translate([s*(W/2-1.6), y, H/3+0.2]) zh_tow_lug(); } -module drive() { translate([0, Y0+L/2+0.6, 1.3]) zh_drive_block(6.0, 2.2, 1.6, 2, 1, 1.3); } -module gun() { translate([0, Y0+3.2, H]) zh_gun(2, 0.2, 2.6, 1.6); } -module rad(s) { translate([s*(W/2+1.35), Y0+5.2, 2.0]) cube([2.5, 2.2, 0.12], center=true); } -module recess_fill() { for (s=[-1,1], y=[-1, 4]) translate([s*(W/2-0.55), Y0+y, 1.3]) cube([0.1, 2.5, 1.3], center=true); } - -if (part == "primary") { body(); drive(); } -if (part == "secondary") { plates(); booms(); arms(); lugs(); gun(); bridge(); } -if (part == "accent") { magazines(); band(); } -if (part == "recess") recess_fill(); -if (part == "rad:Ra.L") rad(1); -if (part == "rad:Ra.R") rad(-1); -if (part == "emit:Th.L") translate([ 1.5, Y0+L/2+1.05, 1.3]) cube([1.2, 0.05, 1.2], center=true); -if (part == "emit:Th.R") translate([-1.5, Y0+L/2+1.05, 1.3]) cube([1.2, 0.05, 1.2], center=true); -// Ids are HullData mount ids: the gameplay layout addresses these by name only. -if (part == "anchors") { - echo(anchor=["Gun.D", "weapon-mount", [0, Y0+3.2, H+0.6], 0, ""]); - echo(anchor=["Gun.D.muzzle", "weapon-muzzle", [0, Y0+3.2-0.48-2.6-0.3, H+1.15], 0, "Gun.D"]); - echo(anchor=["Th.L", "thruster-emitter", [ 1.5, Y0+L/2+1.05, 1.3], 180, ""]); - echo(anchor=["Th.R", "thruster-emitter", [-1.5, Y0+L/2+1.05, 1.3], 180, ""]); - echo(anchor=["Ra.L", "radiator-mesh", [ W/2+1.35, Y0+5.2, 2.0], 0, ""]); - echo(anchor=["Ra.R", "radiator-mesh", [-W/2-1.35, Y0+5.2, 2.0], 0, ""]); +// Tow block (stern frame: local +Z aft, local +Y down): the kit lug's cast base and oxide eye with no neck, so the eye +// is one heavy block jutting straight off the stern plate, pin hole vertical. +module oz_tow_block(w=2.6, h=1.5, d=1.5) { + zh_z("secondary") translate([0, 0, 0.25]) cbox([w + 0.8, h + 0.6, 0.5 + ZH_SINK], 0.12); + zh_z("accent") translate([0, 0, 0.5 + d/2 - 0.05]) difference() { + cbox([w, h, d], 0.25); rotate([90, 0, 0]) cylinder(r=0.42, h=h*2, center=true, $fn=8); } +} + +// ---- masses: one full-width charcoal belt the whole length; three iron magazine blocks seated on it, stepping up ---- +// bow to stern across deep seams. The belt carries the collars, pods, cab and tow lug; the blocks carry the hatches. +C = zh_plan(23.6, 12.4, 3.0, z0=-1.8, y0=0.8, bow=0.1, bowW=0.62, bowH=0.75, bt=0.35, bb=0.7); +MY = [-3.9, 2.7, 9.3]; // magazine stations; 0.6 m seams between them +MH = [3.0, 3.6, 4.2]; // the stepped magazine train: each block taller than the one ahead +MZ = 1.0; // block base, sunk 0.2 m into the belt deck +B = [for (i = [0:2]) zh_block(MY[i], 6.0, 11.8, MH[i], MZ, MH[i]*0.32, 0.3, 0.5)]; +T = [for (b = B) zh_tier(b, 0.4, 0.7, 0.6)]; +SEAMS = [(MY[0] + MY[1]) / 2, (MY[1] + MY[2]) / 2]; +CAB_Y = -9.5; RAD_Y = -7.9; RAD_W = 3.4; RAD_L = 0.9; +POD = [2.6, 3.4, 1.6]; POD_U = 0.82; +PODS = [MY[0] - 1.4, MY[2] + 1.5]; // fore pair under the first block; aft pair mouths at the stern +COLLAR_R = 1.35; +GUN_Y = 11.0; GUN = [1, 0.2, 2.4, 1.3]; // defensive mount: T[2] deck centreline, aft of its hatches (they end at 10.55) + +zh_mass(C, "secondary"); +for (b = B) zh_mass(b); +for (t = T) zh_mass(t); + +// magazine hatches: two on each tier deck, one on each flank wall; unit serials on the block belts +for (i = [0:2]) { + for (u = [-0.5, 0.5]) zh_on_top(T[i], MY[i], u) zh_hatch(2.5, 2.5); + for (s = [-1, 1]) { + zh_on_wall(B[i], MY[i] + 0.6, s, 0.4) zh_hatch(2.4, 2.4, dogs=false); + zh_on_belt(B[i], MY[i] - 1.9, s, 0.5) zh_serial(0.5, 3); + } +} +// seams between the blocks: black floors on the belt deck; a transfer collar in each belt flank under each seam +for (y = SEAMS) { + zh_on_top(C, y, 0) zh_reveal(11.0, 0.6); + for (s = [-1, 1]) zh_on_belt(C, y, s, 0.5) rotate([0, 0, 45]) zh_collar(COLLAR_R, 0.6); +} +// bow: low lit armoured cab with its slit, chevrons on the nose deck; spine radiator trough between cab and magazines +zh_on_top(C, CAB_Y, 0) zh_cab(6.4, 2.4, 1.2); +zh_on_top(C, -10.6, 0) zh_stripes(2.6, 0.4, 4); +zh_on_top(C, RAD_Y, 0) zh_rad_trough(RAD_W, RAD_L, 0.45); +// stern: iron armour plate and a heavy tow lug through it +zh_on_stern(C, 0, 0.55) zh_plate(6.4, 2.2, 0.3); +zh_on_stern(C, 0, 0.5, sink=-0.25) oz_tow_block(); +// four squat drive pods at the belly corners of the belt +for (y = PODS, u = [-POD_U, POD_U]) zh_on_belly(C, y, u) zh_pod(POD[0], POD[1], POD[2]); +// one defensive ballistic mount on the last magazine deck, between and aft of its hatches, barrels to the nose +zh_on_top(T[2], GUN_Y, 0) zh_gun(GUN[0], GUN[1], GUN[2], GUN[3]); + +// ---- anchor meshes ---- +if (part == "rad:Ra.L") zh_on_top(C, RAD_Y, 0) zh_rad_vanes(RAD_W, RAD_L, 1, 4, 1.0); +if (part == "rad:Ra.R") zh_on_top(C, RAD_Y, 0) zh_rad_vanes(RAD_W, RAD_L, -1, 4, 1.0); +if (part == "emit:Th.L") zh_on_belly(C, PODS[1], POD_U) translate(zh_pod_mouth(POD[0], POD[1], POD[2])) zh_emitter(1.1, 0.65); +if (part == "emit:Th.R") zh_on_belly(C, PODS[1], -POD_U) translate(zh_pod_mouth(POD[0], POD[1], POD[2])) zh_emitter(1.1, 0.65); +if (part == "anchors") { + echo(anchor=["Gun.D", "weapon-mount", zh_top_at(T[2], GUN_Y, 0, [0, 0, 0.33]), 0, ""]); + echo(anchor=["Gun.D.muzzle", "weapon-muzzle", zh_top_at(T[2], GUN_Y, 0, zh_gun_muzzle(GUN[0], GUN[1], GUN[2], GUN[3])), 0, "Gun.D"]); + echo(anchor=["Th.L", "thruster-emitter", zh_belly_at(C, PODS[1], POD_U, zh_pod_mouth(POD[0], POD[1], POD[2])), 180, ""]); + echo(anchor=["Th.R", "thruster-emitter", zh_belly_at(C, PODS[1], -POD_U, zh_pod_mouth(POD[0], POD[1], POD[2])), 180, ""]); + echo(anchor=["Ra.L", "radiator-mesh", zh_top_at(C, RAD_Y, 0, zh_rad_centre(RAD_W, 1, 1.0)), 0, ""]); + echo(anchor=["Ra.R", "radiator-mesh", zh_top_at(C, RAD_Y, 0, zh_rad_centre(RAD_W, -1, 1.0)), 0, ""]); } diff --git a/tools/blender/tests/smoke_hull_source.py b/tools/blender/tests/smoke_hull_source.py index c544339ad..8bb9b9c48 100644 --- a/tools/blender/tests/smoke_hull_source.py +++ b/tools/blender/tests/smoke_hull_source.py @@ -71,7 +71,8 @@ HULLS = ( ("zh-ammo-tender", "smoke.hull.tender", (10.0, 23.4), 12, (("Gun.D", BALLISTIC, (2, 3)), ("Th.L", THRUSTER, (0, 0)), ("Th.R", THRUSTER, (1, 0)), ("Ra.L", RADIATOR, (2, 0)), ("Ra.R", RADIATOR, (3, 0))), - "primary", {"primary": (0.42, 0.43, 0.45), "secondary": (0.20, 0.20, 0.22), "accent": (0.55, 0.22, 0.14), "recess": (0.08, 0.08, 0.09)}, + "primary", {"primary": (0.47, 0.48, 0.50), "secondary": (0.21, 0.21, 0.23), "accent": (0.56, 0.20, 0.12), "recess": (0.05, 0.05, 0.06), + "marking": (0.86, 0.80, 0.64)}, "Zhestokost", "Pirates"), ("pi-fighter", "smoke.hull.fighter", (8.0, 15.0), 8, (("Gun.P", BALLISTIC, (3, 3)), ("Th.Main", THRUSTER, (0, 0)), ("Th.L", THRUSTER, (1, 0))), @@ -86,7 +87,7 @@ HULLS = ( # language file names, read by the production reader (language_maker), so a language edited alone cannot leave it stale ATTACKS = { "zh-ammo-tender": ("ZH_LANG", '["zhestokost", ZH_ZONES, "primary", ZH_GLOW, ZH_RADIATOR, [[0, 9], [0, 9]], ["asymmetric", 0.0], "%MAKER%"]', - "zhestokost.scad", "[0.42, 0.43, 0.45]"), + "zhestokost.scad", "[0.47, 0.48, 0.50]"), "pi-fighter": ("PI_LANG", '["pirate", PI_ZONES, "", PI_GLOW, PI_RADIATOR, [], ["bilateral", 0.0], "%MAKER%"]', "pirate.scad", "[0.48, 0.27, 0.22]"), } @@ -542,7 +543,7 @@ def check_poses(collection, root, runner): def yawed_mount(scene, box, collection, root, package, source, original, runner): """A mount the source turns 90 degrees stands, turned, where it says, and its muzzle follows it.""" - changed = original.replace('[0, Y0+3.2, H+0.6], 0, ""]', '[0, Y0+3.2, H+0.6], 90, ""]') + changed = original.replace('[0, 0, 0.33]), 0, ""]', '[0, 0, 0.33]), 90, ""]') require(changed != original, "The tender's mount is not where the pose test turns it") source.write_text(changed, encoding="utf-8") select(box) From ad05486388d469daecb1fa6018f3d5a7516c9316 Mon Sep 17 00:00:00 2001 From: soul Date: Wed, 21 Oct 2026 23:41:15 +0200 Subject: [PATCH 111/444] Zhestokost kit: zh_z keys a donor's zones 'zhestokost:' when HULL_DONOR is zhestokost zh_key in ewanhart.scad's form, not AU's either-name match: a Pirate hull's own zone named like a Zhestokost zone no longer receives Zhestokost geometry. Closes the gap in follow-up zhestokost-kit-donor-routing. Spec: aetheria-release:cut_spec:cut-zh-kit-landing.r1 Co-Authored-By: Claude Opus 5.5 --- tools/blender/aetheria_ships/factions/zhestokost.scad | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/tools/blender/aetheria_ships/factions/zhestokost.scad b/tools/blender/aetheria_ships/factions/zhestokost.scad index 752559e71..e077351ee 100644 --- a/tools/blender/aetheria_ships/factions/zhestokost.scad +++ b/tools/blender/aetheria_ships/factions/zhestokost.scad @@ -23,6 +23,7 @@ // the skin, so nothing floats. Top/belly frames keep local +Y aft; wall/belt frames keep +Y aft with local +X // pointing down the wall on either side (starboard mates are mirrors of port); stern/nose frames have local +Y down. // 3. Zone routing: every zh_ module draws only the zone the global `part` names, so a hull writes its assembly once. +// A hull on a Zhestokost donor (HULL_DONOR == "zhestokost") names a donor zone 'zhestokost:'. // Anchor meshes (zh_rad_vanes, zh_emitter) are not routed: call them inside the hull's rad:/emit: parts under // the same mate that placed their housing. // Scale: chamfers are cast-heavy (ZH_CH); fittings have standard sizes (ZH_HATCH, ZH_COLLAR_R, ZH_LUG) shared by @@ -37,7 +38,8 @@ ZH_HATCH = 2.6; // standard magazine hatch (m, square) ZH_COLLAR_R = 1.05; // standard transfer collar radius ZH_LUG = 1.0; // standard tow lug scale -module zh_z(z) { if (part == z) children(); } +function zh_key(z) = (!is_undef(HULL_DONOR) && HULL_DONOR == "zhestokost") ? str("zhestokost:", z) : z; +module zh_z(z) { if (part == zh_key(z)) children(); } // ==== geometry core: sections, surface points, mates ================================================================= // A section. bt: deck inset (how far the upper wall batters in), bb: belly inset. Defaults keep the faction's batter. From 2fd8cbe8e9f94bdffc2e41c8e9a9b5795f27f979 Mon Sep 17 00:00:00 2001 From: soul Date: Thu, 22 Oct 2026 00:17:59 +0200 Subject: [PATCH 112/444] AetherDb: ship-authoring stats sets a package hull's mass, durability, armor, drag and top speed, judged before the file opens Ruling ship-authoring-stats-command. --top-speed writes a fixed top speed (Min = Max) on the hull's one VelocityLimit and refuses a hull with none. Co-Authored-By: Claude Opus 5.5 --- .../Aetheria.Shared.Tests/ShipProductTests.cs | 58 +++++++++++++++++++ tools/AetherDb/ShipAuthoringCommands.cs | 38 +++++++++++- 2 files changed, 95 insertions(+), 1 deletion(-) diff --git a/tests/Aetheria.Shared.Tests/ShipProductTests.cs b/tests/Aetheria.Shared.Tests/ShipProductTests.cs index 27e84629f..e06143f38 100644 --- a/tests/Aetheria.Shared.Tests/ShipProductTests.cs +++ b/tests/Aetheria.Shared.Tests/ShipProductTests.cs @@ -325,6 +325,64 @@ public sealed class ShipProductTests : IDisposable RefusedAndUntouched("product", path, "ACM", "--remove"); } + // The hull as data, to compare everything stats did not name. + private static string HullJson(string path) => Newtonsoft.Json.JsonConvert.SerializeObject(ShipAuthoringStore.Load(path).Hull); + + [Fact] + public void StatsWritesOnlyTheFieldsItNamesAndARefusalLeavesTheFileUntouched() + { + Environment.CurrentDirectory = _root.Path; + var path = Package("mod.skiff", "Skiffhull", 0); + var bare = Path.Combine(ShipFixture.WritePackage(Mods, "mod.bare", "Barehull", tweak: ship => ship.Hull.Behaviors.Clear()), "ship.cc"); + void RefusedAndUntouched(string file, params string[] flags) + { + var before = File.ReadAllBytes(file); + Assert.Equal(1, Run(new[] { "stats", file }.Concat(flags).ToArray()).Code); + Assert.Equal(before, File.ReadAllBytes(file)); + } + + RefusedAndUntouched(path); + RefusedAndUntouched(path, "--speed", "5"); + RefusedAndUntouched(path, "--mass"); + RefusedAndUntouched(path, "--mass", "NaN"); + RefusedAndUntouched(path, "--mass", "Infinity"); + RefusedAndUntouched(path, "--armor", "-1"); + RefusedAndUntouched(path, "--drag", "slow"); + RefusedAndUntouched(path, "--mass", "5", "--mass", "6"); + // A valid flag beside a bad one writes nothing. + RefusedAndUntouched(path, "--mass", "5", "--durability", "-2"); + RefusedAndUntouched(bare, "--top-speed", "50"); + RefusedAndUntouched(bare, "--mass", "5", "--top-speed", "50"); + + // Each flag sets its own field and nothing else. + var expected = ShipAuthoringStore.Load(path).Hull; + foreach (var (flag, value, set) in new (string, float, Action)[] + { + ("--mass", 60000f, hull => hull.Mass = 60000f), + ("--durability", 8000f, hull => hull.Durability = 8000f), + ("--armor", 40f, hull => hull.Armor = 40f), + ("--drag", .35f, hull => hull.Drag = .35f), + ("--top-speed", 37.5f, hull => { var speed = hull.Behaviors.OfType().Single().TopSpeed; speed.Min = speed.Max = 37.5f; }), + }) + { + var (code, output, error) = Run("stats", path, flag, value.ToString("R", System.Globalization.CultureInfo.InvariantCulture)); + Assert.True(code == 0, error); + Assert.Contains(flag.Substring(2), output); + set(expected); + Assert.Equal(Newtonsoft.Json.JsonConvert.SerializeObject(expected), HullJson(path)); + } + var written = ShipAuthoringStore.Load(path).Hull; + Assert.Equal((60000f, 8000f, 40f, .35f), (written.Mass, written.Durability, written.Armor, written.Drag)); + var top = written.Behaviors.OfType().Single().TopSpeed; + Assert.Equal((37.5f, 37.5f), (top.Min, top.Max)); + + // Several at once, and a hull with no top speed takes the others. + Assert.Equal(0, Run("stats", path, "--mass", "1", "--drag", "0").Code); + Assert.Equal((1f, 0f, 8000f), (ShipAuthoringStore.Load(path).Hull.Mass, ShipAuthoringStore.Load(path).Hull.Drag, ShipAuthoringStore.Load(path).Hull.Durability)); + Assert.Equal(0, Run("stats", bare, "--armor", "12").Code); + Assert.Equal(12f, ShipAuthoringStore.Load(bare).Hull.Armor); + } + [Fact] public void ValidateJudgesProductsOnADraftAndInspectListsThem() { diff --git a/tools/AetherDb/ShipAuthoringCommands.cs b/tools/AetherDb/ShipAuthoringCommands.cs index b745e3dbf..bce7eda08 100644 --- a/tools/AetherDb/ShipAuthoringCommands.cs +++ b/tools/AetherDb/ShipAuthoringCommands.cs @@ -8,7 +8,9 @@ using System.Linq; // Uses the existing database executable; mod authoring does not earn another binary target. public static class ShipAuthoringCommands { - private const string Usage = "ship-authoring create [--like ] | inspect | centre | validate | product (--name [--description ] [--not-offered] | --remove) | price | compose "; + private const string Usage = "ship-authoring create [--like ] | inspect | centre | validate | product (--name [--description ] [--not-offered] | --remove) | price | stats [--mass m] [--durability d] [--armor a] [--drag x] [--top-speed v] | compose "; + + private static readonly string[] StatFlags = { "--mass", "--durability", "--armor", "--drag", "--top-speed" }; public static int Run(string[] args) { @@ -91,6 +93,40 @@ public static class ShipAuthoringCommands Console.WriteLine($"{ship.Id}: price {price}."); return 0; } + if (args[0] == "stats" && args.Length >= 2) + { + // Every flag is judged before the file is touched: a refusal leaves it byte-identical. + var stats = new Dictionary(StringComparer.Ordinal); + for (var i = 2; i < args.Length; i += 2) + { + if (Array.IndexOf(StatFlags, args[i]) < 0) throw new InvalidOperationException($"stats: unknown flag {args[i]}."); + if (stats.ContainsKey(args[i])) throw new InvalidOperationException($"stats: {args[i]} is given twice."); + if (i + 1 == args.Length) throw new InvalidOperationException($"stats: {args[i]} needs a value."); + if (!float.TryParse(args[i + 1], NumberStyles.Float, CultureInfo.InvariantCulture, out var value) || !float.IsFinite(value) || value < 0) + throw new InvalidOperationException($"stats: {args[i]} must be a finite, non-negative number."); + stats[args[i]] = value; + } + if (stats.Count == 0) throw new InvalidOperationException("stats: name at least one stat."); + var (hull, ship, _) = ShipAuthoringStore.Load(args[1]); + if (stats.TryGetValue("--top-speed", out var topSpeed)) + { + var limits = (hull.Behaviors ?? new List()).OfType().ToArray(); + if (limits.Length != 1) throw new InvalidOperationException($"{ship.Id}: --top-speed needs exactly one VelocityLimit on the hull; it has {limits.Length}."); + // A fixed top speed, as every shipped hull authors one (Min equal to Max); its terms are kept. + limits[0].TopSpeed.Min = limits[0].TopSpeed.Max = topSpeed; + } + if (stats.TryGetValue("--mass", out var mass)) hull.Mass = mass; + if (stats.TryGetValue("--durability", out var durability)) hull.Durability = durability; + if (stats.TryGetValue("--armor", out var armor)) hull.Armor = armor; + if (stats.TryGetValue("--drag", out var drag)) hull.Drag = drag; + using (var cache = ShipAuthoringStore.Open(args[1], writable: true)) + { + ShipAuthoringStore.Write(cache, hull, ship); + cache.FlushAsync().Wait(); + } + Console.WriteLine($"{ship.Id}: " + string.Join(", ", stats.Select(stat => string.Create(CultureInfo.InvariantCulture, $"{stat.Key.Substring(2)} {stat.Value:R}"))) + "."); + return 0; + } if (args[0] == "product" && args.Length >= 4) { // A trailing --not-offered takes the product off the market (ruling off-market-is-unoffered-product); --remove takes none. From 0bccb395a45fa491a8fc44edad335f236a720abd Mon Sep 17 00:00:00 2001 From: soul Date: Thu, 22 Oct 2026 00:22:44 +0200 Subject: [PATCH 113/444] ShipProductTests: compare the stats hull by its MessagePack bytes (Newtonsoft loops on Shape) Co-Authored-By: Claude Opus 5.5 --- tests/Aetheria.Shared.Tests/ShipProductTests.cs | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/tests/Aetheria.Shared.Tests/ShipProductTests.cs b/tests/Aetheria.Shared.Tests/ShipProductTests.cs index e06143f38..8fac0c5b0 100644 --- a/tests/Aetheria.Shared.Tests/ShipProductTests.cs +++ b/tests/Aetheria.Shared.Tests/ShipProductTests.cs @@ -326,7 +326,7 @@ public sealed class ShipProductTests : IDisposable } // The hull as data, to compare everything stats did not name. - private static string HullJson(string path) => Newtonsoft.Json.JsonConvert.SerializeObject(ShipAuthoringStore.Load(path).Hull); + private static byte[] HullBytes(HullData hull) => MessagePack.MessagePackSerializer.Serialize(hull, CultDocumentMessagePackSerialization.OptionsFor(typeof(HullData).Assembly)); [Fact] public void StatsWritesOnlyTheFieldsItNamesAndARefusalLeavesTheFileUntouched() @@ -369,7 +369,7 @@ public sealed class ShipProductTests : IDisposable Assert.True(code == 0, error); Assert.Contains(flag.Substring(2), output); set(expected); - Assert.Equal(Newtonsoft.Json.JsonConvert.SerializeObject(expected), HullJson(path)); + Assert.Equal(HullBytes(expected), HullBytes(ShipAuthoringStore.Load(path).Hull)); } var written = ShipAuthoringStore.Load(path).Hull; Assert.Equal((60000f, 8000f, 40f, .35f), (written.Mass, written.Durability, written.Armor, written.Drag)); From 7cb6fb0c9d1bc444838de1806e5481166e0318d7 Mon Sep 17 00:00:00 2001 From: soul Date: Thu, 22 Oct 2026 00:28:31 +0200 Subject: [PATCH 114/444] Mods: aetheria.nakovalnya, the Zhestokost boss hull package (hull.scad, ship.cc, ship.glb) Built from hull-bench Nakovalnya B by the Starfire job: New Ship --like Djinni, ship-authoring stats (mass 60000, durability 8000, armor 40, drag 0.35, top speed 25), price 60000000, a not-offered Zhestokost product, Build, Rasterise (19x30), 27 layout rows, Package. The validator accepted it and compose excludes nothing. Co-Authored-By: Claude Opus 5.5 --- GameData/Mods/aetheria.nakovalnya/hull.scad | 157 ++++++++++++++++++++ GameData/Mods/aetheria.nakovalnya/ship.cc | 3 + GameData/Mods/aetheria.nakovalnya/ship.glb | 3 + 3 files changed, 163 insertions(+) create mode 100644 GameData/Mods/aetheria.nakovalnya/hull.scad create mode 100644 GameData/Mods/aetheria.nakovalnya/ship.cc create mode 100644 GameData/Mods/aetheria.nakovalnya/ship.glb diff --git a/GameData/Mods/aetheria.nakovalnya/hull.scad b/GameData/Mods/aetheria.nakovalnya/hull.scad new file mode 100644 index 000000000..976078073 --- /dev/null +++ b/GameData/Mods/aetheria.nakovalnya/hull.scad @@ -0,0 +1,157 @@ +// Zhestokost fortress (boss) "Nakovalnya" (anvil). Built from the Zhestokost kit. +// part = lang | brief | anchors | primary | secondary | accent | recess | marking | emit: | rad: +// Frame: Ship Root, metres, nose -Y, starboard -X, up +Z. Anchor yaw: 0 nose, 90 port, 180 aft, 270 starboard. +include +part = "primary"; + +HULL_FACTION = "zhestokost"; +HULL_DONOR = ""; +HULL_ROLE = "fortress"; +HULL_LENGTH = [45, 60]; +HULL_TRIANGLES = [6000, 12000]; +HULL_READS = ["three long thick heavy barrels from fixed armoured casemates built into the stepped prow, all pointing straight forward", + "a ring of ten small squat flak turrets on raised rings evenly spaced around the upper edge", + "huge low broad stepped mass, wider at the bow, tapering to a squared stern; narrow cockpit slit high on the prow", + "round feed collar with guide lugs and an oxide ammunition door on each flank, magazine hatches in rows on the dorsal plating; six turning blocks and two broad stern drives"]; +if (part == "brief") echo(brief = [HULL_FACTION, HULL_DONOR, HULL_ROLE, HULL_LENGTH, HULL_TRIANGLES, HULL_READS]); + +// ---- masses: three steps (hull, mid-deck, spine) with the casemates set into the prow's lowest step ----------------- +// M: the hull, broadest at the bow, tapering aft to a squared stern with a cast lip. Its bow face is set back so the +// casemates sit inside the bow line. +M = [zh_s(-16, 32, 5.0, -2.25), zh_s(-15, 34, 5.5, -2.5), zh_s(5, 33, 5.5, -2.5), zh_s(14, 27, 5.5, -2.5), + zh_s(19.6, 23, 5.5, -2.5), zh_s(20, 22.4, 5.2, -2.35)]; +DECK = 3.0; +// T1: the mid-deck, ~60% of the hull's width, 3 m up; the outer band is the flak ring's gallery. +T1 = zh_tier([zh_sec(M, -15), zh_sec(M, 5), zh_sec(M, 11)], 5.4, 3.0, 4.0); +// T2: the spine, 2 m above that, carrying the cab and its slit at the prow's top step. +T2 = zh_tier(T1, 4.0, 2.0, 2.5); +// the spine runs on aft at the same height to the stern shoulders +SPINE = zh_block(12.0, 12, 8, 5.05, DECK - 0.05, 0.8, 0, 0.5); +// the belly steps down too: a keel block under the centre +KEEL = zh_block(0.5, 30, 18, 1.6, -4.0, 0, 0.6, 0.8); +// The prow's lowest step: a glacis apron, and three casemates whose tops are flush with the hull deck. +APRON = zh_block(-18.5, 8, 26, 2.6, -2.7, 0.6, 0.5, 0.4); +CX = [-9.6, 0, 9.6]; +CAS = zh_block(-18.0, 10, 7.2, 5.7, -2.7, 1.3, 0.9, 0.6); +GY = -23.0; // casemate face +GZ = 0.1; // barrel axis height +BL = 11.0; BR = 0.9; // barrel length, radius +// stern shoulders: radiator troughs either side of the spine +RAD_Y = 12.4; RAD_U = 0.55; RAD_W = 3.0; RAD_L = 2.4; +// stern drives +DX = 6.0; DRV = zh_block(20.6, 4, 8, 4.6, -2.3, 0.9, 0.7, 0.4); +DRIVE_Y = 22.6; DZ = -2.3 + 2.3; + +zh_mass(M); +zh_mass(T1); +zh_mass(T2); +zh_mass(APRON); +for (x = CX) translate([x, 0, 0]) zh_mass(CAS); +zh_mass(SPINE); +zh_mass(KEEL); +for (s = [-1, 1]) translate([s*DX, 0, 0]) zh_mass(DRV, "secondary"); + +// ---- the casemate guns: lighter cast-steel barrels out of charcoal mantlets ----------------------------------------- +module casemate_gun() { + zh_z("secondary") { + translate([0, GY - 0.3, GZ]) cbox([4.0, 1.2, 3.4], 0.2); // mantlet + translate([0, GY - 0.7, GZ]) rotate([0, 0, 180]) tube(BR*1.55, 1.8, 8); // barrel collar + translate([0, GY - 0.7 - BL, GZ]) cbox([BR*2.7, 1.6, BR*2.7], 0.12); // muzzle block + } + zh_z("primary") translate([0, GY - 0.7, GZ]) rotate([0, 0, 180]) tube(BR, BL, 8); + zh_z("recess") translate([0, GY - 0.7 - BL - 0.81, GZ]) cube([BR*1.2, 0.05, BR*1.2], center=true); +} +for (x = CX) translate([x, 0, 0]) casemate_gun(); +for (x = CX) translate([x, 0, 0]) zh_on_top(CAS, -21.9, 0) zh_stripes(3.6, 0.6, 4); +// black reveals between the casemates, on the apron +for (u = [-0.33, 0.33]) zh_on_top(APRON, -19.5, u) zh_reveal(2.3, 7.0); + +// ---- the flak ring: ten turrets on raised drums, equal arc-length around the hull's deck rim ---------------------- +// [x, y, outward yaw]; F1 at the bow-port quarter, numbered clockwise seen from above; equal-arc-length stations, baked. +FLAK = [[5.43, -13.40, 21], [-5.43, -13.40, 339], [-13.77, -10.93, 311], [-13.50, -0.06, 274], [-11.53, 10.51, 230], + [-5.43, 17.20, 199], [5.43, 17.20, 161], [11.53, 10.51, 130], [13.50, -0.06, 86], [13.77, -10.93, 49]]; +FR = 2.2; FH = 1.7; // raised drum +FG = [2, 0.27, 3.0, 3.2]; // zh_gun n, cal, len, base +module flak(yaw) { + zh_z("secondary") cylinder(r=FR, h=FH + ZH_SINK, $fn=8); + zh_z("accent") translate([0, 0, FH*0.35]) cylinder(r=FR + 0.08, h=0.35, $fn=8); // oxide band round the drum + zh_z("recess") translate([0, 0, FH - 0.06]) cylinder(r=FR*0.84, h=0.1, $fn=8); + translate([0, 0, FH - 0.02]) rotate([0, 0, yaw]) zh_gun(FG[0], FG[1], FG[2], FG[3], "primary"); +} +for (f = FLAK) translate([f[0], f[1], DECK - ZH_SINK]) flak(f[2]); +function rz(a, p) = [p[0]*cos(a) - p[1]*sin(a), p[0]*sin(a) + p[1]*cos(a), p[2]]; + +// ---- bow: cab with the slit high on the spine's front step --------------------------------------------------------- +zh_on_top(T2, -6.4, 0) zh_cab(6.0, 3.4, 3.0, "primary"); +zh_on_top(T2, -3.6, 0) zh_serial(0.7, 4); + +// ---- dorsal magazine hatches: two rows of four on the mid-deck, clear of the rim ----------------------------------- +for (y = [-8.0, -3.4, 1.2, 5.6], u = [-0.78, 0.78]) zh_on_top(T1, y, u) zh_hatch(2.8, 3.0, dogs=false); +for (y = [0.5, 4.5]) zh_on_top(T2, y, 0) zh_z("secondary") translate([0, 0, 0.06]) cube([8.4, 0.5, 0.2], center=true); // plate seams + +// Feed collar for an Oboz: a lighter cast-steel flange ring standing proud of the flank round the kit collar. +module feed_collar() { + zh_z("primary") difference() { + cylinder(r=3.3, h=0.9 + ZH_SINK, $fn=8); translate([0, 0, -0.1]) cylinder(r=2.1, h=2, $fn=8); } + zh_collar(2.2, 1.5, 4); +} +// ---- flanks: feed collar, ammunition door, unit serial ------------------------------------------------------------- +for (s = [-1, 1]) { + zh_on_belt(M, 1.5, s, 0.5) rotate([0, 0, 22.5]) feed_collar(); + zh_on_wall(M, 7.0, s, 0.45) zh_hatch(2.6, 3.0); + zh_on_wall(M, -9.0, s, 0.5) zh_serial(0.8, 5); +} + +// ---- six square turning blocks on the belt, a small exhaust slot in each ------------------------------------------- +TB = [2.8, 2.6, 1.8]; // along Y, down the wall, outward +TY = [-11.5, 13.6]; +module turn_block() { + zh_mass(zh_block(0, TB[0], TB[1], TB[2], 0, 0.45, 0.45, 0.35), "secondary"); + zh_z("recess") translate([0, 0, TB[2] + 0.02]) cube([0.6, 1.4, 0.14], center=true); +} +for (y = TY, s = [-1, 1]) zh_on_belt(M, y, s, 0.5) turn_block(); +// the forward pair face ahead from the bow corners, outboard of the casemates +NX = 14.6; +for (s = [-1, 1]) zh_on_nose(M, s*NX, 0.55) turn_block(); +function nb_at(s) = zh_nose_pt(M, s*NX, 0.55) + [0, ZH_SINK - TB[2] - 0.1, 0]; +function tb_at(y, s) = zh_belt(M, y, s, 0.5) + [s*(TB[2] + 0.1 - ZH_SINK), 0, 0]; + +// ---- stern: drives, radiator troughs on the shoulders, stern plate ------------------------------------------------ +module drive_mouth() { + zh_z("secondary") translate([0, DRIVE_Y + 0.12, 0]) difference() { + cbox([7.0, 0.4, 3.6], 0.1); cube([5.4, 1, 2.4], center=true); } + zh_z("recess") translate([0, DRIVE_Y - 0.02, 0]) cube([5.5, 0.3, 2.5], center=true); +} +for (s = [-1, 1]) translate([s*DX, 0, DZ]) drive_mouth(); +for (s = [-1, 1]) zh_on_top(M, RAD_Y, s*RAD_U) zh_rad_trough(RAD_W, RAD_L, 0.45); +zh_on_stern(M, 0, 0.62) zh_plate(4.0, 1.4, 0.3); +zh_on_stern(M, 0, 0.62) translate([0, 0, 0.3]) zh_stripes(3.4, 1.0, 4); + +// ---- anchor meshes ------------------------------------------------------------------------------------------------ +for (s = [-1, 1]) if (part == (s > 0 ? "rad:Ra.L" : "rad:Ra.R")) + zh_on_top(M, RAD_Y, s*RAD_U) for (h = [-1, 1]) zh_rad_vanes(RAD_W, RAD_L, h, 4, 1.0); +for (s = [-1, 1]) if (part == (s > 0 ? "emit:Th.L" : "emit:Th.R")) + translate([s*DX, DRIVE_Y + 0.15, DZ]) cube([5.2, 0.05, 2.3], center=true); +TID = [["Th.T3", TY[0], 1], ["Th.T4", TY[0], -1], ["Th.T5", TY[1], 1], ["Th.T6", TY[1], -1]]; +for (t = TID) if (part == str("emit:", t[0])) translate(tb_at(t[1], t[2])) cube([0.05, 1.2, 0.4], center=true); +for (s = [-1, 1]) if (part == (s > 0 ? "emit:Th.T1" : "emit:Th.T2")) translate(nb_at(s)) cube([1.2, 0.05, 0.4], center=true); + +if (part == "anchors") { + for (i = [0:2]) { + id = str("Gun.H", i + 1); + echo(anchor=[id, "weapon-mount", [CX[i], GY, GZ], 0, ""]); + echo(anchor=[str(id, ".muzzle"), "weapon-muzzle", [CX[i], GY - 0.7 - BL - 0.85, GZ], 0, id]); + } + for (i = [0:9]) { + f = FLAK[i]; id = str("Gun.F", i + 1); base = [f[0], f[1], DECK - ZH_SINK + FH - 0.02]; + echo(anchor=[id, "weapon-mount", base + [0, 0, 0.8], f[2], ""]); + echo(anchor=[str(id, ".muzzle"), "weapon-muzzle", base + rz(f[2], zh_gun_muzzle(FG[0], FG[1], FG[2], FG[3])), f[2], id]); + } + echo(anchor=["Th.T1", "thruster-emitter", nb_at(1), 0, ""]); + echo(anchor=["Th.T2", "thruster-emitter", nb_at(-1), 0, ""]); + for (t = TID) echo(anchor=[t[0], "thruster-emitter", tb_at(t[1], t[2]), t[2] > 0 ? 90 : 270, ""]); + echo(anchor=["Th.L", "thruster-emitter", [DX, DRIVE_Y + 0.15, DZ], 180, ""]); + echo(anchor=["Th.R", "thruster-emitter", [-DX, DRIVE_Y + 0.15, DZ], 180, ""]); + echo(anchor=["Ra.L", "radiator-mesh", zh_top_at(M, RAD_Y, RAD_U, [0, 0, 0.5]), 0, ""]); + echo(anchor=["Ra.R", "radiator-mesh", zh_top_at(M, RAD_Y, -RAD_U, [0, 0, 0.5]), 0, ""]); +} diff --git a/GameData/Mods/aetheria.nakovalnya/ship.cc b/GameData/Mods/aetheria.nakovalnya/ship.cc new file mode 100644 index 000000000..ce591805b --- /dev/null +++ b/GameData/Mods/aetheria.nakovalnya/ship.cc @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:62f66a2f76c82af1caac2977c22f39e66b331d5fffe2de82beaab68f0a7bc28b +size 12701 diff --git a/GameData/Mods/aetheria.nakovalnya/ship.glb b/GameData/Mods/aetheria.nakovalnya/ship.glb new file mode 100644 index 000000000..ff556fc6b --- /dev/null +++ b/GameData/Mods/aetheria.nakovalnya/ship.glb @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:01508b4903138bf8fd13caf3d01fcd3f6b6c410c8007a5266fe95c86d9b773d7 +size 843464 From b13aa7385a53bf42dff26785e651e35526efb771 Mon Sep 17 00:00:00 2001 From: soul Date: Thu, 22 Oct 2026 00:28:53 +0200 Subject: [PATCH 115/444] Command seam, agents: delete the 17 direct pilot writes in Agent, Flight, Combat, MoveTo, Follow Each write is replaced by a /*DEL*/ marker; the next commit routes every marked site through the entity's PilotCommand. Co-Authored-By: Claude Opus 5.5 --- Assets/Scripts/ServerShared/Agents/Agent.cs | 12 ++++++------ Assets/Scripts/ServerShared/Agents/Flight.cs | 4 ++-- Assets/Scripts/ServerShared/Agents/States/Combat.cs | 12 ++++++------ Assets/Scripts/ServerShared/Agents/States/Follow.cs | 2 +- Assets/Scripts/ServerShared/Agents/States/MoveTo.cs | 4 ++-- 5 files changed, 17 insertions(+), 17 deletions(-) diff --git a/Assets/Scripts/ServerShared/Agents/Agent.cs b/Assets/Scripts/ServerShared/Agents/Agent.cs index 238df3ce7..937096a7d 100644 --- a/Assets/Scripts/ServerShared/Agents/Agent.cs +++ b/Assets/Scripts/ServerShared/Agents/Agent.cs @@ -57,7 +57,7 @@ public class Agent { // The pilot tick writes Turn every tick, in every state: a state that pilots overwrites this zero with its // demand, and one that does not leaves the hull holding course instead of inheriting the last state's rate. - Ship.Turn = 0; + /*DEL turn0*/ VentOnTrack(); _currentState.Update(delta); foreach (var transition in _currentState.Transitions) @@ -84,7 +84,7 @@ public class Agent { if (Ship.HasActiveConsumable(kind)) return; var consumable = Ship.FirstCarriedConsumable(kind); - if (consumable != null) Ship.TryActivateConsumable(consumable); + if (consumable != null) /*DEL use*/; } public void Accelerate(float2 targetVelocity, bool noTurn = false) @@ -95,18 +95,18 @@ public class Agent // If Delta V is above the threshold, direct the ship towards the delta and use only main thrusters if (!noTurn && deltaVMag > FORWARD_DELTA_THRESHOLD) { - Ship.Turn = Steering.Toward(Ship, deltaVDirection); - Ship.MovementDirection = float2(0, pow(dot(Ship.Direction, deltaVDirection), 2)); + /*DEL accel-turn*/ + /*DEL accel-move1*/ } // If Delta V is low, use all thrusters and leave Turn to the caller (MoveTo already pointed it at the target) else if(deltaVMag > THRUST_DELTA_THRESHOLD) { var right = Ship.Direction.Rotate(ItemRotation.Clockwise); - Ship.MovementDirection = float2(dot(right, deltaVDirection), dot(Ship.Direction, deltaVDirection)); + /*DEL accel-move2*/ } else { - Ship.MovementDirection = float2.zero; + /*DEL accel-move3*/ } } } diff --git a/Assets/Scripts/ServerShared/Agents/Flight.cs b/Assets/Scripts/ServerShared/Agents/Flight.cs index de3c8f0c0..e7fbecfa8 100644 --- a/Assets/Scripts/ServerShared/Agents/Flight.cs +++ b/Assets/Scripts/ServerShared/Agents/Flight.cs @@ -115,7 +115,7 @@ public class Flight { track.Phase = Phase.Hailing; track.Start = _time; - if (!string.IsNullOrEmpty(_doctrine.Hail)) Nearest(entity)?.Agent.Ship.SetMessage(_doctrine.Hail); + if (!string.IsNullOrEmpty(_doctrine.Hail)) /*DEL hail*/; } else track.Phase = Phase.Engaged; break; @@ -175,7 +175,7 @@ public class Flight private static void Aim(Ship ship, Entity target) { var wanted = target == null ? TargetRef.None : new TargetRef(target); - if (!ship.Target.Value.Equals(wanted)) ship.SetTarget(wanted); + if (!ship.Target.Value.Equals(wanted)) /*DEL designate*/; } private float Standoff(Ship ship) diff --git a/Assets/Scripts/ServerShared/Agents/States/Combat.cs b/Assets/Scripts/ServerShared/Agents/States/Combat.cs index 499f631b0..4ca637212 100644 --- a/Assets/Scripts/ServerShared/Agents/States/Combat.cs +++ b/Assets/Scripts/ServerShared/Agents/States/Combat.cs @@ -70,7 +70,7 @@ public class CombatState : BaseState bestTargetItem = reveal.Item; } } - _agent.Ship.TrySelectTargetItem(bestTargetItem); + /*DEL select*/ for (var i = 0; i < _agent.Ship.WeaponGroups.Length; i++) { @@ -94,7 +94,7 @@ public class CombatState : BaseState if (dps > .1) _availableGroups.Add((i, dps)); } - foreach(var w in _availableLockingWeapons) w.Activate(); + foreach(var w in _availableLockingWeapons) /*DEL lock*/; UseConsumables(toTarget, targetDistance); @@ -140,13 +140,13 @@ public class CombatState : BaseState foreach (var weapon in _agent.Ship.WeaponGroups[selectedGroup].weapons) { if (FireControl.AgentFires(weapon, _agent.Ship, target, _agent.Doctrine.MinHitProbability)) - weapon.Activate(); + /*DEL pull*/; else if (weapon.Firing) - weapon.Deactivate(); + /*DEL release*/; } } - _agent.Ship.Aim = toTarget; - _agent.Ship.Turn = Steering.Toward(_agent.Ship, toTarget.xz); + /*DEL aim*/ + /*DEL turn*/ _agent.Accelerate(movementDirection * _agent.TopSpeed, true);//selectedGroup >= 0); // Fire charged guns! diff --git a/Assets/Scripts/ServerShared/Agents/States/Follow.cs b/Assets/Scripts/ServerShared/Agents/States/Follow.cs index 908ecd257..c63b07a06 100644 --- a/Assets/Scripts/ServerShared/Agents/States/Follow.cs +++ b/Assets/Scripts/ServerShared/Agents/States/Follow.cs @@ -20,7 +20,7 @@ public class FollowState : BaseState var pull = direction * (float) sign(error) * saturate(abs(error) / max(task.Standoff, 1f)); var desired = task.Anchor.Velocity + pull * _agent.TopSpeed; - _agent.Ship.Aim = float3(direction.x, 0, direction.y); + /*DEL aim*/ _agent.Accelerate(desired); } } diff --git a/Assets/Scripts/ServerShared/Agents/States/MoveTo.cs b/Assets/Scripts/ServerShared/Agents/States/MoveTo.cs index b677cf0cd..ba203c8cb 100644 --- a/Assets/Scripts/ServerShared/Agents/States/MoveTo.cs +++ b/Assets/Scripts/ServerShared/Agents/States/MoveTo.cs @@ -23,8 +23,8 @@ public abstract class MoveToState : BaseState // We want to go top speed in the direction of our target var desiredVelocity = dir * _agent.TopSpeed; - _agent.Ship.Aim = float3(dir.x, 0, dir.y); - _agent.Ship.Turn = Steering.Toward(_agent.Ship, dir); + /*DEL aim*/ + /*DEL turn*/ _agent.Accelerate(desiredVelocity); } } From 95f89a38a6bc3509ca0c5d6cda0ca1de2f5b4f97 Mon Sep 17 00:00:00 2001 From: soul Date: Thu, 22 Oct 2026 00:28:59 +0200 Subject: [PATCH 116/444] NakovalnyaPackageTests: the package composes, its Zhestokost product is never offered or drawn, and its layout is the brief's Co-Authored-By: Claude Opus 5.5 --- .../NakovalnyaPackageTests.cs | 96 +++++++++++++++++++ 1 file changed, 96 insertions(+) create mode 100644 tests/Aetheria.Shared.Tests/NakovalnyaPackageTests.cs diff --git a/tests/Aetheria.Shared.Tests/NakovalnyaPackageTests.cs b/tests/Aetheria.Shared.Tests/NakovalnyaPackageTests.cs new file mode 100644 index 000000000..153892804 --- /dev/null +++ b/tests/Aetheria.Shared.Tests/NakovalnyaPackageTests.cs @@ -0,0 +1,96 @@ +using System; +using System.IO; +using System.Linq; +using GameCult.Caching; +using GameCult.Caching.MessagePack; +using Xunit; + +// The first first-party ship package, GameData/Mods/aetheria.nakovalnya: the Zhestokost boss hull. It enters the game as +// data through the one compose path (ShipModCatalog.ResolveCatalog) and is made by Zhestokost but never offered +// (rulings off-market-is-unoffered-product, boss-hulls-exclusive). +public sealed class NakovalnyaPackageTests +{ + private const string Id = "aetheria.nakovalnya"; + + private static CultCache Catalog() + { + var cache = new CultCache(TestCatalog.Registry()); + cache.AddBackingStore(new SingleFileMessagePackBackingStore(TestCatalog.Repo, true), AetheriaStores.CatalogTypes); + return cache; + } + + private static HullData Hull(CultCache cache) => cache.Get(new CultRecordRef(ShipModCatalog.HullKey(Id))); + + [Fact] + public void ComposeExcludesNothing() + { + var root = RunStartTests.FindRepoRoot(); + using var work = new TempDirectory(); + var shipped = Path.Combine(root, "GameData", "Aetheria.cc"); + var (catalog, excluded) = ShipModCatalog.ResolveCatalog(shipped, Path.Combine(work.Path, "derived.cc"), + Path.Combine(root, "GameData", "Mods"), TestCatalog.Registry()); + Assert.Empty(excluded); + Assert.NotEqual(shipped, catalog); + + using var cache = Catalog(); + var hull = Hull(cache); + Assert.NotNull(hull); + Assert.Equal(("Nakovalnya", HullType.Ship), (hull.Name, hull.HullType)); + Assert.Equal(ShipModCatalog.AuthoringKey(Id), hull.Visual.Key); + } + + [Fact] + public void BossProductIsNotOffered() + { + using var cache = Catalog(); + var hull = Hull(cache); + var zhestokost = cache.GetAll().Single(faction => faction.Name == "Zhestokost"); + var product = Assert.Single(cache.GetAll(), p => p.Design.Key.Equals(ShipModCatalog.HullKey(Id))); + Assert.Equal(cache.RefOf(zhestokost).Key, product.Manufacturer.Key); + Assert.Equal(ShipModCatalog.ProductKey(Id, cache.RefOf(zhestokost).Key), cache.RefOf(product).Key); + Assert.Equal("Nakovalnya", product.Name); + Assert.True(product.NotOffered); + Assert.True(hull.Price > 0); + + Assert.DoesNotContain(hull, LoadoutGenerator.SoldHulls(cache)); + var items = new ItemManager(1u, cache, new ProvenanceLedger(), RunSaveTests.TestSettings(), _ => { }); + foreach (var faction in cache.GetAll().Append(null)) + { + var random = new CultMath.Random(7u); + var generator = new LoadoutGenerator(ref random, items, null, null, faction, .5f); + Assert.False(generator.IsAvailable(product), faction?.Name); + // Even a required draw that falls back to any manufacturer never reaches it. + Assert.Null(generator.RandomHull(HullType.Ship, design => design == hull).design); + } + } + + [Fact] + public void LayoutMatchesTheBrief() + { + using var cache = Catalog(); + var hull = Hull(cache); + var ship = cache.Get(new CultRecordRef(ShipModCatalog.AuthoringKey(Id))); + var rows = hull.Hardpoints.GroupBy(h => (h.Type, h.Shape.Width, h.Shape.Height)).ToDictionary(g => g.Key, g => g.Count()); + Assert.Equal(3, rows.GetValueOrDefault((HardpointType.Ballistic, 2, 2))); + Assert.Equal(10, rows.GetValueOrDefault((HardpointType.Ballistic, 1, 2))); + Assert.Equal(8, hull.Hardpoints.Count(h => h.Type == HardpointType.Thruster)); + Assert.Equal(2, hull.Hardpoints.Count(h => h.Type == HardpointType.Radiator)); + foreach (var kind in new[] { HardpointType.Reactor, HardpointType.Shield, HardpointType.ControlModule, HardpointType.Sensors }) + Assert.Equal(1, hull.Hardpoints.Count(h => h.Type == kind)); + Assert.Equal(27, hull.Hardpoints.Count); + + // Every mount row names a model anchor by id (ruling schematic-and-model-are-separate-spaces). Every row has an id + // (ShipAuthoringStore.Validate); an internal row's id names no anchor. + var anchors = ship.Anchors.Select(anchor => anchor.Id).ToHashSet(StringComparer.Ordinal); + var internals = new[] { HardpointType.Reactor, HardpointType.Shield, HardpointType.ControlModule, HardpointType.Sensors }; + var mounts = hull.Hardpoints.Where(h => !internals.Contains(h.Type)).ToArray(); + Assert.Equal(23, mounts.Length); + Assert.All(mounts, h => Assert.Contains(h.Transform, anchors)); + Assert.All(hull.Hardpoints.Where(h => internals.Contains(h.Type)), h => Assert.DoesNotContain(h.Transform, anchors)); + Assert.Equal(Enumerable.Range(1, 3).Select(i => $"Gun.H{i}").Concat(Enumerable.Range(1, 10).Select(i => $"Gun.F{i}")).OrderBy(s => s, StringComparer.Ordinal), + mounts.Where(h => h.Type == HardpointType.Ballistic).Select(h => h.Transform).OrderBy(s => s, StringComparer.Ordinal)); + + Assert.InRange(hull.Shape.Width, 1, 32); + Assert.InRange(hull.Shape.Height, 1, 32); + } +} From 85e539af2b6e49c50cb97641e3eaf723d465a2f2 Mon Sep 17 00:00:00 2001 From: soul Date: Thu, 22 Oct 2026 00:29:47 +0200 Subject: [PATCH 117/444] NakovalnyaPackageTests: import System.Collections.Generic Co-Authored-By: Claude Opus 5.5 --- tests/Aetheria.Shared.Tests/NakovalnyaPackageTests.cs | 1 + 1 file changed, 1 insertion(+) diff --git a/tests/Aetheria.Shared.Tests/NakovalnyaPackageTests.cs b/tests/Aetheria.Shared.Tests/NakovalnyaPackageTests.cs index 153892804..6f55823f0 100644 --- a/tests/Aetheria.Shared.Tests/NakovalnyaPackageTests.cs +++ b/tests/Aetheria.Shared.Tests/NakovalnyaPackageTests.cs @@ -1,4 +1,5 @@ using System; +using System.Collections.Generic; using System.IO; using System.Linq; using GameCult.Caching; From 9a2a9390e12a6b654dabdc6578d6226855876bcc Mon Sep 17 00:00:00 2001 From: soul Date: Thu, 22 Oct 2026 00:32:11 +0200 Subject: [PATCH 118/444] Command seam, agents: one PilotCommand per entity, applied once per step by PilotApplier in Zone.Update Agent, Flight, Combat, MoveTo and Follow write only Ship.Command. Entities with no agent apply first, then each agent applies right after it decides. A pilot reads its own pending designation and aim item back from its command; FireControl gains Refuses/AgentFires/HitProbability overloads that take them. Co-Authored-By: Claude Opus 5.5 --- Assets/Scripts/ServerShared/Agents/Agent.cs | 14 +-- Assets/Scripts/ServerShared/Agents/Flight.cs | 4 +- Assets/Scripts/ServerShared/Agents/Minion.cs | 4 +- .../ServerShared/Agents/States/Combat.cs | 23 +++-- .../ServerShared/Agents/States/Follow.cs | 2 +- .../ServerShared/Agents/States/MoveTo.cs | 4 +- Assets/Scripts/ServerShared/Entity.cs | 4 + Assets/Scripts/ServerShared/FireControl.cs | 35 +++++-- Assets/Scripts/ServerShared/Pilot.cs | 98 +++++++++++++++++++ Assets/Scripts/ServerShared/Pilot.cs.meta | 2 + Assets/Scripts/ServerShared/Zone.cs | 13 ++- 11 files changed, 169 insertions(+), 34 deletions(-) create mode 100644 Assets/Scripts/ServerShared/Pilot.cs create mode 100644 Assets/Scripts/ServerShared/Pilot.cs.meta diff --git a/Assets/Scripts/ServerShared/Agents/Agent.cs b/Assets/Scripts/ServerShared/Agents/Agent.cs index 937096a7d..eb74818ba 100644 --- a/Assets/Scripts/ServerShared/Agents/Agent.cs +++ b/Assets/Scripts/ServerShared/Agents/Agent.cs @@ -55,9 +55,9 @@ public class Agent public void Update(float delta) { - // The pilot tick writes Turn every tick, in every state: a state that pilots overwrites this zero with its + // The pilot tick commands Turn every tick, in every state: a state that pilots overwrites this zero with its // demand, and one that does not leaves the hull holding course instead of inheriting the last state's rate. - /*DEL turn0*/ + Ship.Command.Turn = TurnCommand.OfRate(0); VentOnTrack(); _currentState.Update(delta); foreach (var transition in _currentState.Transitions) @@ -84,7 +84,7 @@ public class Agent { if (Ship.HasActiveConsumable(kind)) return; var consumable = Ship.FirstCarriedConsumable(kind); - if (consumable != null) /*DEL use*/; + if (consumable != null) Ship.Command.Use(consumable); } public void Accelerate(float2 targetVelocity, bool noTurn = false) @@ -95,18 +95,18 @@ public class Agent // If Delta V is above the threshold, direct the ship towards the delta and use only main thrusters if (!noTurn && deltaVMag > FORWARD_DELTA_THRESHOLD) { - /*DEL accel-turn*/ - /*DEL accel-move1*/ + Ship.Command.Turn = TurnCommand.Toward(deltaVDirection); + Ship.Command.Movement = float2(0, pow(dot(Ship.Direction, deltaVDirection), 2)); } // If Delta V is low, use all thrusters and leave Turn to the caller (MoveTo already pointed it at the target) else if(deltaVMag > THRUST_DELTA_THRESHOLD) { var right = Ship.Direction.Rotate(ItemRotation.Clockwise); - /*DEL accel-move2*/ + Ship.Command.Movement = float2(dot(right, deltaVDirection), dot(Ship.Direction, deltaVDirection)); } else { - /*DEL accel-move3*/ + Ship.Command.Movement = float2.zero; } } } diff --git a/Assets/Scripts/ServerShared/Agents/Flight.cs b/Assets/Scripts/ServerShared/Agents/Flight.cs index e7fbecfa8..497ac865f 100644 --- a/Assets/Scripts/ServerShared/Agents/Flight.cs +++ b/Assets/Scripts/ServerShared/Agents/Flight.cs @@ -115,7 +115,7 @@ public class Flight { track.Phase = Phase.Hailing; track.Start = _time; - if (!string.IsNullOrEmpty(_doctrine.Hail)) /*DEL hail*/; + if (!string.IsNullOrEmpty(_doctrine.Hail)) Nearest(entity)?.Agent.Ship.Command.Hail(_doctrine.Hail); } else track.Phase = Phase.Engaged; break; @@ -175,7 +175,7 @@ public class Flight private static void Aim(Ship ship, Entity target) { var wanted = target == null ? TargetRef.None : new TargetRef(target); - if (!ship.Target.Value.Equals(wanted)) /*DEL designate*/; + if (!ship.Target.Value.Equals(wanted)) ship.Command.Designate(wanted); } private float Standoff(Ship ship) diff --git a/Assets/Scripts/ServerShared/Agents/Minion.cs b/Assets/Scripts/ServerShared/Agents/Minion.cs index 41c0e3fe1..a1277b264 100644 --- a/Assets/Scripts/ServerShared/Agents/Minion.cs +++ b/Assets/Scripts/ServerShared/Agents/Minion.cs @@ -22,9 +22,9 @@ public class Minion : Agent var combatState = new CombatState(this); _rootState.AddTransition(combatState, - () => Ship.Target.Value.Entity != null, null, true, _rootState); + () => (Ship.Command.Designated ?? Ship.Target.Value).Entity != null, null, true, _rootState); combatState.AddTransition(_rootState, - () => Ship.Target.Value.Entity == null, null, true, _rootState); + () => (Ship.Command.Designated ?? Ship.Target.Value).Entity == null, null, true, _rootState); } } diff --git a/Assets/Scripts/ServerShared/Agents/States/Combat.cs b/Assets/Scripts/ServerShared/Agents/States/Combat.cs index 4ca637212..1a062e7be 100644 --- a/Assets/Scripts/ServerShared/Agents/States/Combat.cs +++ b/Assets/Scripts/ServerShared/Agents/States/Combat.cs @@ -43,7 +43,8 @@ public class CombatState : BaseState _optimumStale = false; } - var target = _agent.Ship.Target.Value.Entity; + // The pilot's own pending designation (its Flight's, this step) when set, else last step's applied target. + var target = (_agent.Ship.Command.Designated ?? _agent.Ship.Target.Value).Entity; if (target == null) return; _availableGroups.Clear(); @@ -70,7 +71,8 @@ public class CombatState : BaseState bestTargetItem = reveal.Item; } } - /*DEL select*/ + _agent.Ship.Command.SelectItem(bestTargetItem); + var aim = bestTargetItem == null ? AimTarget.None : AimTarget.OfItem(bestTargetItem); for (var i = 0; i < _agent.Ship.WeaponGroups.Length; i++) { @@ -81,7 +83,7 @@ public class CombatState : BaseState // damage. if (weapon.Item.Online.Value && FireControl.Designated(weapon, _agent.Ship, target, out _) && - !FireControl.Refuses(weapon, _agent.Ship) && + !FireControl.Refuses(weapon, _agent.Ship, target) && (weapon is ConstantWeapon || weapon is InstantWeapon instantWeapon && instantWeapon.CanFire)) { @@ -94,7 +96,7 @@ public class CombatState : BaseState if (dps > .1) _availableGroups.Add((i, dps)); } - foreach(var w in _availableLockingWeapons) /*DEL lock*/; + foreach(var w in _availableLockingWeapons) _agent.Ship.Command.Pull(w); UseConsumables(toTarget, targetDistance); @@ -139,14 +141,15 @@ public class CombatState : BaseState // fires at, and "the target is designated" for a fused one. foreach (var weapon in _agent.Ship.WeaponGroups[selectedGroup].weapons) { - if (FireControl.AgentFires(weapon, _agent.Ship, target, _agent.Doctrine.MinHitProbability)) - /*DEL pull*/; - else if (weapon.Firing) - /*DEL release*/; + if (FireControl.AgentFires(weapon, _agent.Ship, target, _agent.Doctrine.MinHitProbability, aim)) + _agent.Ship.Command.Pull(weapon); + // A lock pulled above this step counts as firing, as it did when it was activated in place. + else if (weapon.Firing || _agent.Ship.Command.Pulls(weapon)) + _agent.Ship.Command.Release(weapon); } } - /*DEL aim*/ - /*DEL turn*/ + _agent.Ship.Command.Aim = toTarget; + _agent.Ship.Command.Turn = TurnCommand.Toward(toTarget.xz); _agent.Accelerate(movementDirection * _agent.TopSpeed, true);//selectedGroup >= 0); // Fire charged guns! diff --git a/Assets/Scripts/ServerShared/Agents/States/Follow.cs b/Assets/Scripts/ServerShared/Agents/States/Follow.cs index c63b07a06..1bbf3f826 100644 --- a/Assets/Scripts/ServerShared/Agents/States/Follow.cs +++ b/Assets/Scripts/ServerShared/Agents/States/Follow.cs @@ -20,7 +20,7 @@ public class FollowState : BaseState var pull = direction * (float) sign(error) * saturate(abs(error) / max(task.Standoff, 1f)); var desired = task.Anchor.Velocity + pull * _agent.TopSpeed; - /*DEL aim*/ + _agent.Ship.Command.Aim = float3(direction.x, 0, direction.y); _agent.Accelerate(desired); } } diff --git a/Assets/Scripts/ServerShared/Agents/States/MoveTo.cs b/Assets/Scripts/ServerShared/Agents/States/MoveTo.cs index ba203c8cb..aad04db0d 100644 --- a/Assets/Scripts/ServerShared/Agents/States/MoveTo.cs +++ b/Assets/Scripts/ServerShared/Agents/States/MoveTo.cs @@ -23,8 +23,8 @@ public abstract class MoveToState : BaseState // We want to go top speed in the direction of our target var desiredVelocity = dir * _agent.TopSpeed; - /*DEL aim*/ - /*DEL turn*/ + _agent.Ship.Command.Aim = float3(dir.x, 0, dir.y); + _agent.Ship.Command.Turn = TurnCommand.Toward(dir); _agent.Accelerate(desiredVelocity); } } diff --git a/Assets/Scripts/ServerShared/Entity.cs b/Assets/Scripts/ServerShared/Entity.cs index 4a2b25402..780f365bc 100644 --- a/Assets/Scripts/ServerShared/Entity.cs +++ b/Assets/Scripts/ServerShared/Entity.cs @@ -87,6 +87,10 @@ public abstract class Entity // hull turns only by Ship.Turn. public float3 Aim; + // The entity's one pilot input, written by its one pilot (its agent, the player, a scripted pilot) and applied only by + // PilotApplier in the zone step. Runtime only: never packed. + public PilotCommand Command { get; } = new PilotCommand(); + public string Name; // Names the entity for assertion messages and logs: xunit and the debugger format an object by walking its graph diff --git a/Assets/Scripts/ServerShared/FireControl.cs b/Assets/Scripts/ServerShared/FireControl.cs index 5ea06f455..8f3cc5018 100644 --- a/Assets/Scripts/ServerShared/FireControl.cs +++ b/Assets/Scripts/ServerShared/FireControl.cs @@ -84,8 +84,11 @@ public static class FireControl // Whether the shooter's next round from this weapon is refused: the weapons ask before they spend anything, so // a refused round costs no ammo, energy, sound, heat, wear or visibility (operator ruling 2026-09-30). The // answer is Solve's own -- there is no second copy of the arming test. - public static bool Refuses(Weapon weapon, Entity shooter) => - !IsMineLayer(weapon) && Solve(weapon, weapon.Item, shooter, shooter.Target.Value.Entity).Outcome == FireOutcome.Refused; + public static bool Refuses(Weapon weapon, Entity shooter) => Refuses(weapon, shooter, shooter.Target.Value.Entity); + + // The same answer against a target the shooter's pilot has commanded but not yet applied. + public static bool Refuses(Weapon weapon, Entity shooter, Entity target) => + !IsMineLayer(weapon) && Solve(weapon, weapon.Item, shooter, target).Outcome == FireOutcome.Refused; // A mine layer lays a body, not a round: it is never refused, never arc-gated, and Fire routes it to Lay. public static bool IsMineLayer(Weapon weapon) => weapon.WeaponData is MineLayerData; @@ -96,12 +99,19 @@ public static class FireControl // fires a fused weapon at nothing -- no target, or one it holds no valid data on, is no reason to fish -- and // never one Solve refuses: a refused weapon is not a weapon it can use. // The hit-probability threshold is the shooter's doctrine (RoleDoctrine.MinHitProbability), or the turret's global. - public static bool AgentFires(Weapon weapon, Entity shooter, Entity target, float minHitProbability) + public static bool AgentFires(Weapon weapon, Entity shooter, Entity target, float minHitProbability) => + AgentFires(weapon, shooter, target, minHitProbability, null); + + // The same decision priced on the aim the pilot commands this step (Combat), not the shooter's applied aim slot. + public static bool AgentFires(Weapon weapon, Entity shooter, Entity target, float minHitProbability, AimTarget aim) => + AgentFires(weapon, shooter, target, minHitProbability, (AimTarget?) aim); + + private static bool AgentFires(Weapon weapon, Entity shooter, Entity target, float minHitProbability, AimTarget? aim) { // A mine layer lays only at a designated target in range, never at nothing and never on a hit probability. if (IsMineLayer(weapon)) return Solve(weapon, weapon.Item, shooter, target).Designated; if (FuseOf(weapon.Item, out _) == null) - return HitProbability(weapon, shooter, target) >= minHitProbability; + return HitProbability(weapon, shooter, target, aim) >= minHitProbability; var solution = Solve(weapon, weapon.Item, shooter, target); return solution.Designated && solution.Outcome != FireOutcome.Refused; } @@ -330,7 +340,7 @@ public static class FireControl // common gated-out case (GatedOutHitProbabilityAllocatesNothing pins that at exactly 0). // Evasion fix (evasion-term): the same call also prices how far the target can and does move off the line of // fire, along the same travel direction, so the live price and the HUD read one evasion. - private static (Silhouette Silhouette, float PSpread, float Evasion) Forecast(Weapon weapon, Entity source, Entity target, HullData targetHull, float precision, float range) + private static (Silhouette Silhouette, float PSpread, float Evasion) Forecast(Weapon weapon, Entity source, Entity target, HullData targetHull, float precision, float range, AimTarget aim) { var travel = TravelDirection(weapon, source, target); var evasion = Evasion(target, travel); @@ -340,7 +350,6 @@ public static class FireControl Silhouette sil; try { - var aim = source.ResolvedAim; sil = Silhouette(target, targetHull, aim.Item, bearing, precision, buffer, aim.Mount); var settings = source.ItemManager.GameplaySettings; pSpread = PSpread(weapon.Spread, sil.Span, range, settings.SchematicCellSize); @@ -355,13 +364,21 @@ public static class FireControl return (sil, pSpread, evasion); } - public static float HitProbability(Weapon weapon, Entity source, Entity target) + public static float HitProbability(Weapon weapon, Entity source, Entity target) => + HitProbability(weapon, source, target, (AimTarget?) null); + + // Priced on `aim` rather than the source's aim slot: the aim its pilot commands this step. + public static float HitProbability(Weapon weapon, Entity source, Entity target, AimTarget aim) => + HitProbability(weapon, source, target, (AimTarget?) aim); + + // A null aim reads source.ResolvedAim, after the gate, so a gated-out shot reads nothing more. + private static float HitProbability(Weapon weapon, Entity source, Entity target, AimTarget? aim) { var pFire = PFire(weapon, source, target, out var range); if (pFire <= 0f) return 0f; var targetHull = source.ItemManager.GetData(target.Hull) as HullData; - var (sil, pSpread, evasion) = Forecast(weapon, source, target, targetHull, Precision(source), range); + var (sil, pSpread, evasion) = Forecast(weapon, source, target, targetHull, Precision(source), range, aim ?? source.ResolvedAim); return pFire * PMount(AngularVelocity(source, target), TrackingRate(weapon, source)) * pSpread * sil.POnHull * PDeviation(evasion, Tracking(source)); } @@ -399,7 +416,7 @@ public static class FireControl diagnostic.PSensor = PSensor(settings, diagnostic.Resolution, diagnostic.Info); diagnostic.PFire = diagnostic.Accuracy * diagnostic.PSensor; - var (sil, pSpread, evasion) = Forecast(weapon, source, target, targetHull, diagnostic.Precision, diagnostic.Range); + var (sil, pSpread, evasion) = Forecast(weapon, source, target, targetHull, diagnostic.Precision, diagnostic.Range, source.ResolvedAim); diagnostic.PSpread = pSpread; diagnostic.POnHull = sil.POnHull; diagnostic.Evasion = evasion; diff --git a/Assets/Scripts/ServerShared/Pilot.cs b/Assets/Scripts/ServerShared/Pilot.cs new file mode 100644 index 000000000..95b78348b --- /dev/null +++ b/Assets/Scripts/ServerShared/Pilot.cs @@ -0,0 +1,98 @@ +/* This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at https://mozilla.org/MPL/2.0/. */ + +using System.Collections.Generic; +using CultMath; + +// A turn demand as a pilot commands it: a rate, or a heading resolved against the hull's facing when it is applied, so a +// held heading keeps steering as the hull turns. +public readonly struct TurnCommand +{ + private readonly bool _toward; + private readonly float _rate; + private readonly float2 _heading; + + private TurnCommand(bool toward, float rate, float2 heading) + { + _toward = toward; + _rate = rate; + _heading = heading; + } + + public static TurnCommand OfRate(float rate) => new TurnCommand(false, rate, default); + public static TurnCommand Toward(float2 heading) => new TurnCommand(true, 0, heading); + + public float Resolve(Ship ship) => _toward ? Steering.Toward(ship, _heading) : _rate; +} + +// The one input an entity's pilot gives it (its agent, the player, a scripted pilot): only own-ship vectors and handles the +// pilot was given (ruling observation-sensors-only). Commands are per entity, never per Flight or side: a side may be one +// ship (ruling brain-one-per-side-r2). PilotApplier.Apply is its only reader. +// Held fields persist until the pilot replaces them, so a pilot deciding below the sim rate keeps flying (ruling +// lower-game-sim-rate); null leaves the field to whoever wrote it last. One-shots apply once, in order, and are cleared. +public sealed class PilotCommand +{ + public TurnCommand? Turn; + public float2? Movement; + public float3? Aim; + + internal readonly List Designations = new List(); + internal readonly List Selections = new List(); + internal readonly List Hails = new List(); + internal readonly List Uses = new List(); + internal readonly List<(IActivatedBehavior Behavior, bool Pull)> Triggers = new List<(IActivatedBehavior, bool)>(); + + public void Designate(TargetRef target) => Designations.Add(target); + public void SelectItem(EquippedItem item) => Selections.Add(item); + public void Hail(string message) => Hails.Add(message); + public void Use(ConsumableItemData consumable) => Uses.Add(consumable); + public void Pull(IActivatedBehavior behavior) => Triggers.Add((behavior, true)); + public void Release(IActivatedBehavior behavior) => Triggers.Add((behavior, false)); + + // The pilot's own pending decisions, which it reads back instead of the ship (that is last step's applied state). + public TargetRef? Designated => Designations.Count > 0 ? Designations[Designations.Count - 1] : (TargetRef?) null; + + // Whether the last trigger this pilot commanded for `behavior` this step is a pull. + public bool Pulls(IActivatedBehavior behavior) + { + for (var i = Triggers.Count - 1; i >= 0; i--) + if (ReferenceEquals(Triggers[i].Behavior, behavior)) return Triggers[i].Pull; + return false; + } + + internal void ClearOneShots() + { + Designations.Clear(); + Selections.Clear(); + Hails.Clear(); + Uses.Clear(); + Triggers.Clear(); + } +} + +// The one writer of an entity's pilot-controlled fields, called only from Zone.Update. The order is a rule: the target +// lands before its item is selected, and the aim before the triggers, so a pilot fires along the aim it commands this +// step, as the player does. +public static class PilotApplier +{ + public static void Apply(Entity entity) + { + var command = entity.Command; + foreach (var target in command.Designations) + if (!entity.Target.Value.Equals(target)) entity.SetTarget(target); + foreach (var item in command.Selections) entity.TrySelectTargetItem(item); + foreach (var hail in command.Hails) entity.SetMessage(hail); + foreach (var consumable in command.Uses) entity.TryActivateConsumable(consumable); + if (command.Aim is float3 aim) entity.Aim = aim; + foreach (var (behavior, pull) in command.Triggers) + if (pull) behavior.Activate(); + else behavior.Deactivate(); + if (entity is Ship ship) + { + if (command.Turn is TurnCommand turn) ship.Turn = turn.Resolve(ship); + if (command.Movement is float2 movement) ship.MovementDirection = movement; + } + command.ClearOneShots(); + } +} diff --git a/Assets/Scripts/ServerShared/Pilot.cs.meta b/Assets/Scripts/ServerShared/Pilot.cs.meta new file mode 100644 index 000000000..e13004643 --- /dev/null +++ b/Assets/Scripts/ServerShared/Pilot.cs.meta @@ -0,0 +1,2 @@ +fileFormatVersion: 2 +guid: 0ae6251a7a32402cb7f7db7ff1fc5084 \ No newline at end of file diff --git a/Assets/Scripts/ServerShared/Zone.cs b/Assets/Scripts/ServerShared/Zone.cs index ea46f4c6f..2a7b043f4 100644 --- a/Assets/Scripts/ServerShared/Zone.cs +++ b/Assets/Scripts/ServerShared/Zone.cs @@ -365,8 +365,19 @@ public class Zone } foreach (var flight in _flights.Values) flight.Update(deltaTime); - foreach(var agent in Agents.ToArray()) + // Every pilot's command is applied by PilotApplier, once per step. Entities with no agent (the player, a scripted + // pilot, a lone ship) first, as the player's input lands before the step; then each agent applies right after it + // decides, so the next agent sees it as it saw the direct writes this replaced. + var agents = Agents.ToArray(); + var piloted = new HashSet(); + foreach (var agent in agents) piloted.Add(agent.Ship); + foreach (var entity in Entities.ToArray()) + if (!piloted.Contains(entity)) PilotApplier.Apply(entity); + foreach (var agent in agents) + { agent.Update(deltaTime); + PilotApplier.Apply(agent.Ship); + } var entities = Entities.ToArray(); foreach (var entity in entities) entity.PublishInbox(); From 26a25fce3a7e0f45339706ac49bc7d2702b1b102 Mon Sep 17 00:00:00 2001 From: soul Date: Thu, 22 Oct 2026 00:32:12 +0200 Subject: [PATCH 119/444] Tests: drive agents through the seam (apply after a direct agent update; the duel's scripted player commands) Co-Authored-By: Claude Opus 5.5 --- tests/Aetheria.Shared.Tests/ConsumableAiTests.cs | 7 ++++++- .../Aetheria.Shared.Tests/FireControlPerWeaponTests.cs | 3 +++ tests/Aetheria.Shared.Tests/FlightTests.cs | 2 ++ tests/Aetheria.Shared.Tests/SimClockTests.cs | 10 +++++----- 4 files changed, 16 insertions(+), 6 deletions(-) diff --git a/tests/Aetheria.Shared.Tests/ConsumableAiTests.cs b/tests/Aetheria.Shared.Tests/ConsumableAiTests.cs index 1b6d95cc9..ea1ee917e 100644 --- a/tests/Aetheria.Shared.Tests/ConsumableAiTests.cs +++ b/tests/Aetheria.Shared.Tests/ConsumableAiTests.cs @@ -587,7 +587,11 @@ public sealed class ConsumableAiTests : IDisposable s.Zone.Update(.05f); Assert.True(s.Agent.Target.Value.IsNone); agent.Task = new FollowTask { Anchor = s.Prey, Standoff = 100 }; - for (var i = 0; i < 6 && !(s.State is FollowState); i++) agent.Update(.05f); + for (var i = 0; i < 6 && !(s.State is FollowState); i++) + { + agent.Update(.05f); + PilotApplier.Apply(s.Agent); + } Assert.True(s.State is FollowState, $"the agent is in {s.State?.GetType().Name}"); void Paint(float strength) @@ -595,6 +599,7 @@ public sealed class ConsumableAiTests : IDisposable s.Agent.ReceivePaint(s.Locker, s.Locker.Equipment.Single(i => i.Behaviors.Any(b => b is LockWeapon)), PaintMode.Track, strength); s.Agent.PublishInbox(); agent.Update(.05f); + PilotApplier.Apply(s.Agent); } Paint(.3f); Assert.False(s.Active(s.Vent)); diff --git a/tests/Aetheria.Shared.Tests/FireControlPerWeaponTests.cs b/tests/Aetheria.Shared.Tests/FireControlPerWeaponTests.cs index cf47b2041..bd576b16b 100644 --- a/tests/Aetheria.Shared.Tests/FireControlPerWeaponTests.cs +++ b/tests/Aetheria.Shared.Tests/FireControlPerWeaponTests.cs @@ -371,6 +371,7 @@ public sealed class FireControlPerWeaponTests : IDisposable flight.Join(agent, new PatrolOrbitsTask()); flight.Update(.1f); + PilotApplier.Apply(r.Shooter); Assert.Equal(expected, ((FollowTask) agent.Task).Standoff, 2); } @@ -730,7 +731,9 @@ public sealed class FireControlPerWeaponTests : IDisposable // update leaves the root state for combat, the second is combat deciding. var agent = new Minion(r.Shooter, FactionDoctrine.Default(r.Shooter.ItemManager.GameplaySettings).Combatant); agent.Update(.01f); + PilotApplier.Apply(r.Shooter); agent.Update(.01f); + PilotApplier.Apply(r.Shooter); Assert.True(r.Gun.Weapon.Firing); } diff --git a/tests/Aetheria.Shared.Tests/FlightTests.cs b/tests/Aetheria.Shared.Tests/FlightTests.cs index dc3f4da10..dc238bb7b 100644 --- a/tests/Aetheria.Shared.Tests/FlightTests.cs +++ b/tests/Aetheria.Shared.Tests/FlightTests.cs @@ -57,6 +57,8 @@ public sealed class FlightTests : IDisposable private void Tick(float dt) { foreach (var flight in _zone.Flights.ToArray()) flight.Update(dt); + // The flight-only step: each member's command (its flight's designation) is applied as the zone step applies it. + foreach (var entity in _zone.Entities.ToArray()) PilotApplier.Apply(entity); } [Fact] diff --git a/tests/Aetheria.Shared.Tests/SimClockTests.cs b/tests/Aetheria.Shared.Tests/SimClockTests.cs index c12b23baf..6426fe47d 100644 --- a/tests/Aetheria.Shared.Tests/SimClockTests.cs +++ b/tests/Aetheria.Shared.Tests/SimClockTests.cs @@ -140,8 +140,8 @@ public sealed partial class RunStartTests var launched = Launch(new Duel()); Assert.Empty(launched.failures); _items.Random = new CultMath.Random(0xC0FFEE); - launched.staged.Player.MovementDirection = float2(0, 1); - launched.staged.Player.Turn = .5f; + launched.staged.Player.Command.Movement = float2(0, 1); + launched.staged.Player.Command.Turn = TurnCommand.OfRate(.5f); foreach (var ship in launched.arena.Entities.OfType()) ship.SetTarget(launched.arena.Entities.OfType().First(other => other != ship)); return (launched.arena, launched.staged.Player); @@ -238,8 +238,8 @@ public sealed partial class RunStartTests Assert.True(turned > 60, "the held turn turned the ship"); // A write to Direction between steps (staging, a load, a wormhole exit) is not a turn. - player.Turn = 0; - player.MovementDirection = float2(0, 0); // the drives are idled so the Direction write below is tested alone + player.Command.Turn = TurnCommand.OfRate(0); + player.Command.Movement = float2(0, 0); // the drives are idled so the Direction write below is tested alone for (var step = 0; step < 600 && abs(player.TurnRate) > 0; step++) arena.Update(ClockFrame); player.Direction = normalize(float2(3, -4)); arena.Update(ClockFrame); @@ -295,7 +295,7 @@ public sealed partial class RunStartTests public void DrawnRotationFollowsTheTurn() { var (arena, player) = LaunchDuel(); - player.Turn = 1f; + player.Command.Turn = TurnCommand.OfRate(1f); for (var step = 0; step < 300; step++) arena.Update(ClockFrame); var rate = player.TurnRate; Assert.True(abs(rate) > .05f, "the ship is turning"); From 3795bdb066b3539d46ac1f5eda65c6ccb8b7613b Mon Sep 17 00:00:00 2001 From: soul Date: Thu, 22 Oct 2026 00:36:14 +0200 Subject: [PATCH 120/444] Tests: Flight and Steering fixtures drive pilots through the seam (apply after a direct update; scripted demands are commands) Co-Authored-By: Claude Opus 5.5 --- tests/Aetheria.Shared.Tests/FlightTests.cs | 17 +++++++++++++++ tests/Aetheria.Shared.Tests/SteeringTests.cs | 23 +++++++++++++++----- 2 files changed, 34 insertions(+), 6 deletions(-) diff --git a/tests/Aetheria.Shared.Tests/FlightTests.cs b/tests/Aetheria.Shared.Tests/FlightTests.cs index dc238bb7b..7bab33174 100644 --- a/tests/Aetheria.Shared.Tests/FlightTests.cs +++ b/tests/Aetheria.Shared.Tests/FlightTests.cs @@ -256,7 +256,9 @@ public sealed class FlightTests : IDisposable var minion = new Minion(ship, FactionDoctrine.Default(_items.GameplaySettings).Combatant) { Task = new FollowTask { Anchor = anchor, Standoff = 100 } }; minion.Update(.1f); + PilotApplier.Apply(minion.Ship); minion.Update(.1f); + PilotApplier.Apply(minion.Ship); return ship.Turn; } @@ -279,6 +281,7 @@ public sealed class FlightTests : IDisposable minion.Task = new FollowTask { Anchor = anchor, Standoff = 50 }; minion.Update(.1f); + PilotApplier.Apply(minion.Ship); Assert.IsType(CurrentState(minion)); } @@ -293,6 +296,7 @@ public sealed class FlightTests : IDisposable for (var i = 0; i < 30; i++) { minion.Update(.1f); + PilotApplier.Apply(minion.Ship); if (i > 0) Assert.IsNotType(CurrentState(minion)); } } @@ -611,7 +615,9 @@ public sealed class FlightTests : IDisposable var minion = new Minion(ship, FactionDoctrine.Default(_items.GameplaySettings).Combatant) { Task = new FollowTask { Anchor = anchor, Standoff = 100 } }; minion.Update(.1f); + PilotApplier.Apply(minion.Ship); minion.Update(.1f); + PilotApplier.Apply(minion.Ship); return ship.Turn; } @@ -631,19 +637,26 @@ public sealed class FlightTests : IDisposable var minion = new Minion(ship, FactionDoctrine.Default(_items.GameplaySettings).Combatant) { Task = new FollowTask { Anchor = anchor, Standoff = 50 } }; minion.Update(.1f); + PilotApplier.Apply(minion.Ship); minion.Update(.1f); + PilotApplier.Apply(minion.Ship); Assert.IsType(CurrentState(minion)); minion.Task = new PatrolOrbitsTask { Circuit = new CultRecordKey[1] }; minion.Update(.1f); + PilotApplier.Apply(minion.Ship); Assert.IsType(CurrentState(minion)); minion.Task = new FollowTask { Anchor = anchor, Standoff = 50 }; minion.Update(.1f); + PilotApplier.Apply(minion.Ship); minion.Update(.1f); + PilotApplier.Apply(minion.Ship); minion.Task = null; minion.Update(.1f); + PilotApplier.Apply(minion.Ship); minion.Update(.1f); + PilotApplier.Apply(minion.Ship); Assert.IsNotType(CurrentState(minion)); } @@ -788,6 +801,7 @@ public sealed class FlightTests : IDisposable for (var i = 0; i < 5; i++) { minion.Update(.1f); + PilotApplier.Apply(minion.Ship); Assert.IsType(CurrentState(minion)); } } @@ -800,6 +814,7 @@ public sealed class FlightTests : IDisposable minion.Task = null; minion.Update(.1f); + PilotApplier.Apply(minion.Ship); Assert.IsType(CurrentState(minion)); } @@ -870,7 +885,9 @@ public sealed class FlightTests : IDisposable var minion = new Minion(ship, FactionDoctrine.Default(_items.GameplaySettings).Combatant) { Task = new FollowTask { Anchor = anchor, Standoff = 100 } }; minion.Update(.1f); + PilotApplier.Apply(minion.Ship); minion.Update(.1f); + PilotApplier.Apply(minion.Ship); return (ship.Turn, ship.MovementDirection, minion.TopSpeed); } diff --git a/tests/Aetheria.Shared.Tests/SteeringTests.cs b/tests/Aetheria.Shared.Tests/SteeringTests.cs index 63588e865..71dbfff75 100644 --- a/tests/Aetheria.Shared.Tests/SteeringTests.cs +++ b/tests/Aetheria.Shared.Tests/SteeringTests.cs @@ -166,11 +166,13 @@ public sealed class SteeringTests ship.Aim = aim; agent.Accelerate(Right(ship) * 100f); + PilotApplier.Apply(ship); Assert.True(ship.Turn > 0f); Assert.Equal(aim, ship.Aim); - ship.Turn = .37f; + ship.Command.Turn = TurnCommand.OfRate(.37f); agent.Accelerate(-Right(ship) * 100f, true); + PilotApplier.Apply(ship); Assert.Equal(.37f, ship.Turn); Assert.Equal(aim, ship.Aim); } @@ -185,22 +187,26 @@ public sealed class SteeringTests var agent = new Agent(ship, FactionDoctrine.Default(ship.ItemManager.GameplaySettings).Combatant); Assert.Equal(float2(0, 1), ship.Direction); // starboard is +x - ship.Turn = .37f; + ship.Command.Turn = TurnCommand.OfRate(.37f); agent.Accelerate(float2(20, 0), false); // exactly the forward threshold: not above it + PilotApplier.Apply(ship); Assert.Equal(.37f, ship.Turn); Assert.Equal(float2(1, 0), ship.MovementDirection); agent.Accelerate(float2(21, 0), false); + PilotApplier.Apply(ship); Assert.True(ship.Turn > .5f); Assert.Equal(float2(0, 0), ship.MovementDirection); - ship.Turn = .37f; - ship.MovementDirection = float2(1, 1); // a stale command the hold branch must clear + ship.Command.Turn = TurnCommand.OfRate(.37f); + ship.Command.Movement = float2(1, 1); // a stale command the hold branch must clear agent.Accelerate(float2(1, 0), false); // exactly the thrust threshold: not above it + PilotApplier.Apply(ship); Assert.Equal(float2.zero, ship.MovementDirection); Assert.Equal(.37f, ship.Turn); agent.Accelerate(float2(5, 0), false); + PilotApplier.Apply(ship); Assert.Equal(float2(1, 0), ship.MovementDirection); Assert.Equal(.37f, ship.Turn); } @@ -212,9 +218,10 @@ public sealed class SteeringTests var ship = RestoredHullsTests.BuildThrustedShip(cache, Hull); var agent = new Agent(ship, FactionDoctrine.Default(ship.ItemManager.GameplaySettings).Combatant); ship.Velocity = float2(100, 0); - ship.Turn = .37f; + ship.Command.Turn = TurnCommand.OfRate(.37f); agent.Accelerate(float2(100, 0), false); // already at the wanted velocity: nothing to correct + PilotApplier.Apply(ship); Assert.Equal(.37f, ship.Turn); Assert.Equal(float2.zero, ship.MovementDirection); @@ -316,7 +323,7 @@ public sealed class SteeringTests private sealed class SpinState : BaseState { public SpinState(Agent agent) : base(agent) { } - public override void Update(float delta) => _agent.Ship.Turn = .5f; + public override void Update(float delta) => _agent.Ship.Command.Turn = TurnCommand.OfRate(.5f); } // Turn is a rate, so a state that stops piloting must not leave the last state's rate behind: the agent's @@ -333,6 +340,7 @@ public sealed class SteeringTests for (var i = 0; i < 10; i++) { agent.Update(1f / 60f); + PilotApplier.Apply(ship); ship.Update(1f / 60f); } Assert.True(Angle(start, ship.Direction) > radians(.05f), "the piloting state turns the hull"); @@ -340,11 +348,13 @@ public sealed class SteeringTests agent.Transition(new BaseState(agent)); agent.Update(1f / 60f); + PilotApplier.Apply(ship); ship.Update(1f / 60f); var held = ship.Direction; for (var i = 0; i < 60; i++) { agent.Update(1f / 60f); + PilotApplier.Apply(ship); ship.Update(1f / 60f); } @@ -377,6 +387,7 @@ public sealed class SteeringTests agent.Transition(state); agent.Update(1f / 60f); + PilotApplier.Apply(ship); Assert.True(dot(normalize(ship.Aim.xz), dir) > .9999f, $"aim {ship.Aim}"); Assert.True(abs(ship.Aim.y) < 1e-6f); From 2f7197519465db448d23eb3d5e5dafafd73bf559 Mon Sep 17 00:00:00 2001 From: soul Date: Thu, 22 Oct 2026 00:36:15 +0200 Subject: [PATCH 121/444] PilotSeamTests: the seam's rules (agent writes only its command, held and one-shot fields, apply order, lone-ship side, commanded aim item) Co-Authored-By: Claude Opus 5.5 --- tests/Aetheria.Shared.Tests/PilotSeamTests.cs | 243 ++++++++++++++++++ 1 file changed, 243 insertions(+) create mode 100644 tests/Aetheria.Shared.Tests/PilotSeamTests.cs diff --git a/tests/Aetheria.Shared.Tests/PilotSeamTests.cs b/tests/Aetheria.Shared.Tests/PilotSeamTests.cs new file mode 100644 index 000000000..f2115dacb --- /dev/null +++ b/tests/Aetheria.Shared.Tests/PilotSeamTests.cs @@ -0,0 +1,243 @@ +/* This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at https://mozilla.org/MPL/2.0/. */ + +using System; +using System.Collections.Generic; +using System.Linq; +using System.Reflection; +using CultMath; +using Xunit; +using static CultMath.math; +using float2 = CultMath.float2; +using float3 = CultMath.float3; + +// The command seam (aetheria-arena, cut command-seam-agents): every pilot writes only its entity's PilotCommand, and +// PilotApplier, in Zone.Update, is the one writer of the control fields. Held fields hold across steps (ruling +// lower-game-sim-rate), one-shots apply once, a command is per entity (ruling brain-one-per-side-r2), and the apply order +// is pinned here because the two-AI digest cannot see it. +public sealed partial class RunStartTests +{ + private const float SeamDt = 1f / 60f; + + // Two Longinus 200 apart, facing each other, the bare player far away. Piloted ones get the zone's pilot. + private (Zone arena, Ship east, Ship west) SeamPair(bool piloted, Action more = null) + { + Ship east = null, west = null; + var (_, arena, _, failures) = Launch(new Scripted(false, stage => + { + stage.Player(stage.Bare("Djinni"), float2(-50000, -50000)); + east = stage.Place(EvLonginus(stage), float2(100, 0), facing: float2(-1, 0), piloted: piloted) as Ship; + west = stage.Place(EvLonginus(stage), float2(-100, 0), facing: float2(1, 0), piloted: piloted) as Ship; + more?.Invoke(stage, east, west); + })); + Assert.True(failures.Count == 0, string.Join("; ", failures)); + return (arena, east, west); + } + + private static object SeamState(Agent agent) => + typeof(Agent).GetField("_currentState", BindingFlags.NonPublic | BindingFlags.Instance).GetValue(agent); + + private static InstantWeapon SeamGun(Ship ship) => + Assert.IsAssignableFrom(ship.Weapons.First(w => w.Item.Data.Name == "Cell Blaster")); + + [Fact] + public void AnAgentWritesOnlyItsCommand() + { + var (arena, east, west) = SeamPair(true); + east.SetIff(west, true); + west.SetIff(east, true); + var agent = arena.Agents.Single(a => a.Ship == east); + for (var step = 0; step < 900 && !(SeamState(agent) is CombatState); step++) arena.Update(SeamDt); + Assert.IsType(SeamState(agent)); + + // Sentinels the decision would overwrite if it wrote the ship. + east.Turn = 7f; + east.MovementDirection = float2(9, 9); + east.Aim = float3(0, 1, 0); + var target = east.Target.Value; + var aimed = east.AimedAt; + var firing = east.Weapons.Select(w => w.Firing).ToArray(); + var consumables = east.CargoBays.Sum(bay => bay.Cargo.Count); + + agent.Update(SeamDt); + + Assert.Equal(7f, east.Turn); + Assert.Equal(float2(9, 9), east.MovementDirection); + Assert.Equal(float3(0, 1, 0), east.Aim); + Assert.Equal(target, east.Target.Value); + Assert.Equal(aimed.Item, east.AimedAt.Item); + Assert.Equal(aimed.Mount, east.AimedAt.Mount); + Assert.Equal(firing, east.Weapons.Select(w => w.Firing).ToArray()); + Assert.Equal(consumables, east.CargoBays.Sum(bay => bay.Cargo.Count)); + Assert.NotNull(east.Command.Turn); + Assert.NotNull(east.Command.Movement); + Assert.NotNull(east.Command.Aim); + + PilotApplier.Apply(east); + + Assert.NotEqual(7f, east.Turn); + Assert.NotEqual(float2(9, 9), east.MovementDirection); + Assert.Equal(east.Command.Aim.Value, east.Aim); + } + + [Fact] + public void AHeldCommandHoldsAcrossSteps() + { + var (arena, east, _) = SeamPair(false); + east.Command.Turn = TurnCommand.OfRate(.5f); + east.Command.Movement = float2(0, 1); + for (var step = 0; step < 10; step++) + { + // A disturbance between steps: the held command is applied again at every step, not once. + east.Turn = 0; + east.MovementDirection = float2.zero; + arena.Update(SeamDt); + Assert.Equal(.5f, east.Turn); + Assert.Equal(float2(0, 1), east.MovementDirection); + } + + east.Command.Turn = TurnCommand.OfRate(-.25f); + arena.Update(SeamDt); + Assert.Equal(-.25f, east.Turn); + Assert.Equal(float2(0, 1), east.MovementDirection); + } + + [Fact] + public void TowardIsResolvedEachStep() + { + var (arena, east, _) = SeamPair(false); + var heading = float2(0, 1); // a quarter turn from its facing + east.Command.Turn = TurnCommand.Toward(heading); + var turns = new List(); + for (var step = 0; step < 1200; step++) + { + // The applier runs before the hull moves this step, so it resolves on the facing the step starts with. + var expected = Steering.Toward(east, heading); + arena.Update(SeamDt); + Assert.Equal(expected, east.Turn); + turns.Add(east.Turn); + } + Assert.True(turns.Distinct().Count() > 2, "the resolved turn changes as the hull turns"); + Assert.NotEqual(0f, turns[0]); + Assert.Contains(0f, turns.Skip(1)); + } + + [Fact] + public void OneShotsApplyOnce() + { + // A stackable design: the second step's activation would succeed if the applier kept the Use. + var design = _cache.GetAll().OrderBy(c => c.Name).First(); + design.Stackable = true; + var (arena, east, west) = SeamPair(false, (stage, e, _) => stage.Cargo(e, design.Name, design.Name)); + int Carried() => east.CargoBays.Sum(bay => bay.Cargo.Keys.Count(i => i is ConsumableItem)); + Assert.Equal(2, Carried()); + + east.Command.Use(design); + arena.Update(SeamDt); + Assert.Equal(1, Carried()); + arena.Update(SeamDt); + Assert.Equal(1, Carried()); + + east.Command.Designate(west); + arena.Update(SeamDt); + Assert.Same(west, east.Target.Value.Entity); + // The entity's own rule clears it (a fixture stands in for the target-loss clear); the designation is not replayed. + east.SetTarget(TargetRef.None); + arena.Update(SeamDt); + Assert.True(east.Target.Value.IsNone); + } + + [Fact] + public void AnEmptyCommandWritesNothing() + { + var (arena, east, _) = SeamPair(false); + east.Turn = .3f; + east.Aim = float3(0, 0, 1); + arena.Update(SeamDt); + Assert.Equal(.3f, east.Turn); + Assert.Equal(float3(0, 0, 1), east.Aim); + } + + [Fact] + public void ALoneShipSideIsCommandedWithoutAFlight() + { + var (arena, east, west) = SeamPair(false); + east.SetIff(west, true); + west.SetIff(east, true); + Assert.Empty(arena.Flights); + Assert.Empty(arena.Agents); + east.Command.Turn = TurnCommand.OfRate(.2f); + west.Command.Turn = TurnCommand.OfRate(-.4f); + arena.Update(SeamDt); + Assert.Equal(.2f, east.Turn); + Assert.Equal(-.4f, west.Turn); + } + + [Fact] + public void DesignationLandsBeforeItemSelection() + { + var (_, east, west) = SeamPair(false); + var item = SeamGun(west).Item; + east.RecordReveal(west, item, SignatureChannel.Emission, west.ClaimOf(item)); + Assert.True(FireControl.IsRevealed(east, item)); + + east.Command.Designate(west); + east.Command.SelectItem(item); + PilotApplier.Apply(east); + + Assert.Same(west, east.Target.Value.Entity); + Assert.Same(item, east.ResolvedAim.Item); + } + + [Fact] + public void TriggersFireAlongTheCommandedAim() + { + var (arena, east, _) = SeamPair(false); + for (var step = 0; step < 60; step++) arena.Update(SeamDt); // the gun comes online and off cooldown + var gun = SeamGun(east); + var behind = float3(1, 0, 0); + var ahead = float3(-1, 0, 0); + east.Aim = behind; + Assert.False(FireControl.ArcPermitsFire(gun, east), "the previous aim is out of the gun's arc"); + Assert.True(gun.CanFire); + + var shots = new List(); + gun.OnFire += id => + { + Assert.True(arena.TryGetShot(id, out var shot)); + shots.Add(shot); + }; + east.Command.Aim = ahead; + east.Command.Pull(gun); + arena.Update(SeamDt); + + Assert.NotEmpty(shots); + var along = FireControl.AimDirection(gun.Item, east); + Assert.True(length(shots[0].TravelDirection - along) < 1e-4f, $"the shot flies {shots[0].TravelDirection}, the commanded aim {along}"); + Assert.True(dot(shots[0].TravelDirection, behind.xz) < 0, "not along the previous aim"); + } + + [Fact] + public void AgentFiresWithTheCommandedAimItem() + { + var (_, east, west) = SeamPair(false); + var gun = SeamGun(east); + var item = SeamGun(west).Item; + east.EntityInfoGathered[west] = 1; + east.RecordReveal(west, item, SignatureChannel.Emission, west.ClaimOf(item)); + east.SetTarget(west); + + var refusesFor = FireControl.Refuses(gun, east, west); + var commanded = FireControl.HitProbability(gun, east, west, AimTarget.OfItem(item)); + var firesCommanded = FireControl.AgentFires(gun, east, west, commanded, AimTarget.OfItem(item)); + Assert.Equal(AimTarget.None.Item, east.ResolvedAim.Item); + Assert.True(east.TrySelectTargetItem(item)); + + Assert.True(commanded > 0f, "the shot is priced"); + Assert.Equal(commanded, FireControl.HitProbability(gun, east, west)); + Assert.Equal(firesCommanded, FireControl.AgentFires(gun, east, west, commanded)); + Assert.True(firesCommanded); + Assert.Equal(refusesFor, FireControl.Refuses(gun, east)); + } +} From 487c45f2bde9b4762964c9b5701f33788ed15d72 Mon Sep 17 00:00:00 2001 From: soul Date: Thu, 22 Oct 2026 00:39:43 +0200 Subject: [PATCH 122/444] PilotSeamTests: price the commanded aim item after the guns come online Co-Authored-By: Claude Opus 5.5 --- tests/Aetheria.Shared.Tests/PilotSeamTests.cs | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/tests/Aetheria.Shared.Tests/PilotSeamTests.cs b/tests/Aetheria.Shared.Tests/PilotSeamTests.cs index f2115dacb..20e4e0c90 100644 --- a/tests/Aetheria.Shared.Tests/PilotSeamTests.cs +++ b/tests/Aetheria.Shared.Tests/PilotSeamTests.cs @@ -221,7 +221,8 @@ public sealed partial class RunStartTests [Fact] public void AgentFiresWithTheCommandedAimItem() { - var (_, east, west) = SeamPair(false); + var (arena, east, west) = SeamPair(false); + for (var step = 0; step < 60; step++) arena.Update(SeamDt); // the guns come online and evaluate their stats var gun = SeamGun(east); var item = SeamGun(west).Item; east.EntityInfoGathered[west] = 1; From 9ac8f23307793665802db0f9181e51746fb6eacf Mon Sep 17 00:00:00 2001 From: soul Date: Thu, 22 Oct 2026 00:41:09 +0200 Subject: [PATCH 123/444] RunStartTests: the own-maker and factionless reach claims hold for every product LoadoutGenerator.IsOffered offers Ruling census-tests-read-the-owners. Co-Authored-By: Claude Opus 5.5 --- tests/Aetheria.Shared.Tests/RunStartGearTests.cs | 3 ++- tests/Aetheria.Shared.Tests/RunStartTests.cs | 7 ++++--- 2 files changed, 6 insertions(+), 4 deletions(-) diff --git a/tests/Aetheria.Shared.Tests/RunStartGearTests.cs b/tests/Aetheria.Shared.Tests/RunStartGearTests.cs index 3b5766dcd..2d0e1fb67 100644 --- a/tests/Aetheria.Shared.Tests/RunStartGearTests.cs +++ b/tests/Aetheria.Shared.Tests/RunStartGearTests.cs @@ -55,7 +55,8 @@ public sealed partial class RunStartTests var galaxy = MainSectorGalaxy(); AbsentMaker("AbsentToAll"); var generator = new LoadoutGenerator(ref _items.Random, _items, galaxy, galaxy.Entrance, null, .5f); - var products = _cache.GetAll().Where(p => p.Manufacturer.IsSet()).ToArray(); + // Every offered product (LoadoutGenerator.IsOffered); a not-offered one is available to nobody. + var products = _cache.GetAll().Where(p => p.Manufacturer.IsSet() && LoadoutGenerator.IsOffered(_cache, p)).ToArray(); Assert.NotEmpty(products); Assert.All(products, product => Assert.True(generator.IsAvailable(product), product.Name)); } diff --git a/tests/Aetheria.Shared.Tests/RunStartTests.cs b/tests/Aetheria.Shared.Tests/RunStartTests.cs index 9d499b3e1..b3c502a6b 100644 --- a/tests/Aetheria.Shared.Tests/RunStartTests.cs +++ b/tests/Aetheria.Shared.Tests/RunStartTests.cs @@ -178,13 +178,14 @@ public sealed partial class RunStartTests : IDisposable } } - // A faction always reaches its own manufacturer's gear: in a main-sector galaxy every product a faction makes is - // on offer to that faction, allegiance or not. And an allegiance map lists only other factions. + // A faction always reaches its own manufacturer's gear: in a main-sector galaxy every offered product a faction makes + // (LoadoutGenerator.IsOffered, the market's one owner) is on offer to that faction, allegiance or not. And an + // allegiance map lists only other factions. [Fact] public void AFactionReachesItsOwnGearAndItsAllegianceNamesOnlyOthers() { var galaxy = MainSectorGalaxy(); - var products = _cache.GetAll().Where(p => p.Manufacturer.IsSet()).ToArray(); + var products = _cache.GetAll().Where(p => p.Manufacturer.IsSet() && LoadoutGenerator.IsOffered(_cache, p)).ToArray(); foreach (var faction in galaxy.Factions) { Assert.DoesNotContain(faction.Allegiance.Keys, key => key.Key.Equals(_cache.RefOf(faction).Key)); From bc53361ff95d81d055f3f6aaa852b4907181a18b Mon Sep 17 00:00:00 2001 From: soul Date: Thu, 22 Oct 2026 00:41:10 +0200 Subject: [PATCH 124/444] Census tests: sold hulls are LoadoutGenerator.SoldHulls; a hull's asset is its Prefab or its mod-ship Visual record Ruling census-tests-read-the-owners. Co-Authored-By: Claude Opus 5.5 --- .../HardpointOwnershipTests.cs | 4 +--- .../RestoredHullsTests.cs | 19 +++++++++++++++---- 2 files changed, 16 insertions(+), 7 deletions(-) diff --git a/tests/Aetheria.Shared.Tests/HardpointOwnershipTests.cs b/tests/Aetheria.Shared.Tests/HardpointOwnershipTests.cs index 12daaee32..2650b8009 100644 --- a/tests/Aetheria.Shared.Tests/HardpointOwnershipTests.cs +++ b/tests/Aetheria.Shared.Tests/HardpointOwnershipTests.cs @@ -261,9 +261,7 @@ public sealed partial class RunStartTests [Fact] public void GeneratedLoadoutsGiveEveryReaderItsHardpoint() { - var sold = _cache.GetAll().Select(p => p.Design.Key).ToHashSet(); - var shipHulls = _cache.GetAll() - .Where(h => h.HullType == HullType.Ship && sold.Contains(_cache.RefOf(h).Key)).Select(h => h.Name).ToList(); + var shipHulls = LoadoutGenerator.SoldHulls(_cache).Where(h => h.HullType == HullType.Ship).Select(h => h.Name).ToList(); var generator = new LoadoutGenerator(ref _items.Random, _items, _galaxy, _galaxy.Entrance, _protagonist, .5f); var packs = shipHulls.SelectMany(name => Enumerable.Range(0, 2).Select(_ => generator.GenerateShipLoadout(h => h.Name == name))) .Concat(Enumerable.Range(0, 4).Select(_ => generator.GenerateStationLoadout())) diff --git a/tests/Aetheria.Shared.Tests/RestoredHullsTests.cs b/tests/Aetheria.Shared.Tests/RestoredHullsTests.cs index 8b0065f93..6dd235551 100644 --- a/tests/Aetheria.Shared.Tests/RestoredHullsTests.cs +++ b/tests/Aetheria.Shared.Tests/RestoredHullsTests.cs @@ -187,9 +187,21 @@ public sealed class RestoredHullsTests using var cache = OpenCatalog(); var missing = new List(); + // A hull names one body (ShipAuthoringStore.RequireOneBody): a Unity prefab, or a mod ship's visual record, which + // must be in the catalog under its own mod-ship key. foreach (var hull in cache.GetAll()) - if (!assetGuids.Contains(hull.Prefab ?? "")) + { + if (hull.Visual.IsSet()) + { + var visual = cache.Get(hull.Visual); + if (visual == null || !hull.Visual.Key.Equals(ShipModCatalog.AuthoringKey(visual.Id))) + missing.Add($"{hull.Name}.Visual = {hull.Visual.Key.Value}"); + if (!string.IsNullOrWhiteSpace(hull.Prefab)) + missing.Add($"{hull.Name} names both Prefab {hull.Prefab} and Visual {hull.Visual.Key.Value}"); + } + else if (!assetGuids.Contains(hull.Prefab ?? "")) missing.Add($"{hull.Name}.Prefab = {hull.Prefab ?? "null"}"); + } foreach (var design in cache.GetAll()) { foreach (var thruster in design.Behaviors.OfType()) @@ -245,14 +257,13 @@ public sealed class RestoredHullsTests } // The dock intro dereferences the ship's VelocityLimit unguarded (ActionGameManager), so every ship hull the catalog - // sells carries exactly one, with a top speed that is a finite positive number. After the retirement + // sells (LoadoutGenerator.SoldHulls) carries exactly one, with a top speed that is a finite positive number. After the retirement // (ruling retire-longinusx) the shipped ship hulls are the Longinus and the Djinni. [Fact] public void EveryShippedShipHullHasATopSpeed() { using var cache = OpenCatalog(); - var sold = cache.GetAll().Select(product => product.Design.Key).ToHashSet(); - var ships = cache.GetAll().Where(h => h.HullType == HullType.Ship && sold.Contains(cache.RefOf(h).Key)).OrderBy(h => h.Name, StringComparer.Ordinal).ToArray(); + var ships = LoadoutGenerator.SoldHulls(cache).Where(h => h.HullType == HullType.Ship).OrderBy(h => h.Name, StringComparer.Ordinal).ToArray(); Assert.Equal(new[] { "Djinni", "Longinus" }, ships.Select(h => h.Name)); foreach (var hull in ships) { From 07bd57d9e1fd80cf986fd79106694046ecdd6e0d Mon Sep 17 00:00:00 2001 From: soul Date: Thu, 22 Oct 2026 00:41:12 +0200 Subject: [PATCH 125/444] ConsumablesFirstSetTests: the vent's smallest cloud holds every sold ship hull Ruling vapour-cloud-sold-hulls; the radius is unchanged. Co-Authored-By: Claude Opus 5.5 --- tests/Aetheria.Shared.Tests/ConsumablesFirstSetTests.cs | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/tests/Aetheria.Shared.Tests/ConsumablesFirstSetTests.cs b/tests/Aetheria.Shared.Tests/ConsumablesFirstSetTests.cs index 300c2f9b9..a3858a407 100644 --- a/tests/Aetheria.Shared.Tests/ConsumablesFirstSetTests.cs +++ b/tests/Aetheria.Shared.Tests/ConsumablesFirstSetTests.cs @@ -74,9 +74,10 @@ public sealed class ConsumablesFirstSetTests Assert.Equal(new[] { "nozzle", "coolant" }, vent.Roles.Select(r => r.Name).ToArray()); Assert.Equal(2, vent.Behaviors.Count); var dump = Assert.IsType(vent.Behaviors[0]); - // The cloud at its smallest holds every ship hull; its largest is half as big again. + // The cloud at its smallest holds every sold ship hull (ruling vapour-cloud-sold-hulls: a boss hull is never sold and + // its Loadout is agent-authored); its largest is half as big again. var radiusMin = dump.Radius.Min; - foreach (var hull in cache.GetAll().Where(h => h.HullType == HullType.Ship)) + foreach (var hull in LoadoutGenerator.SoldHulls(cache).Where(h => h.HullType == HullType.Ship)) Assert.True(radiusMin >= Circumradius(hull, cellSize), $"{hull.Name} pokes out of the smallest cloud"); Assert.Equal(1.5f * Circumradius(cache.GetByName("Djinni"), cellSize), radiusMin, 3); Assert.Equal(33.03f, radiusMin, 1); // the Djinni, 14x17 cells at two units a cell From f7314b26614141f5cd270cf2ea5ce0b8af79c83b Mon Sep 17 00:00:00 2001 From: soul Date: Thu, 22 Oct 2026 00:41:12 +0200 Subject: [PATCH 126/444] ShipProductTests: stats --top-speed refuses a hull with two VelocityLimits and leaves it untouched Co-Authored-By: Claude Opus 5.5 --- tests/Aetheria.Shared.Tests/ShipProductTests.cs | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/tests/Aetheria.Shared.Tests/ShipProductTests.cs b/tests/Aetheria.Shared.Tests/ShipProductTests.cs index 8fac0c5b0..bb358c4df 100644 --- a/tests/Aetheria.Shared.Tests/ShipProductTests.cs +++ b/tests/Aetheria.Shared.Tests/ShipProductTests.cs @@ -334,6 +334,8 @@ public sealed class ShipProductTests : IDisposable Environment.CurrentDirectory = _root.Path; var path = Package("mod.skiff", "Skiffhull", 0); var bare = Path.Combine(ShipFixture.WritePackage(Mods, "mod.bare", "Barehull", tweak: ship => ship.Hull.Behaviors.Clear()), "ship.cc"); + var twin = Path.Combine(ShipFixture.WritePackage(Mods, "mod.twin", "Twinhull", + tweak: ship => ship.Hull.Behaviors.Add(ship.Hull.Behaviors.OfType().First())), "ship.cc"); void RefusedAndUntouched(string file, params string[] flags) { var before = File.ReadAllBytes(file); @@ -353,6 +355,8 @@ public sealed class ShipProductTests : IDisposable RefusedAndUntouched(path, "--mass", "5", "--durability", "-2"); RefusedAndUntouched(bare, "--top-speed", "50"); RefusedAndUntouched(bare, "--mass", "5", "--top-speed", "50"); + // Two VelocityLimits leave no one top speed to set. + RefusedAndUntouched(twin, "--top-speed", "50"); // Each flag sets its own field and nothing else. var expected = ShipAuthoringStore.Load(path).Hull; From 74257926b1ee4fd8fd228ffdbae728fd429eb7c9 Mon Sep 17 00:00:00 2001 From: soul Date: Thu, 22 Oct 2026 00:44:35 +0200 Subject: [PATCH 127/444] Gravity: a parent's outermost child never hosts a lane (ruling lane-not-outermost); one host filter, an only child pinned Co-Authored-By: Claude Opus 5.5 --- Assets/Scripts/ServerShared/ZoneGenerator.cs | 12 +++++++-- .../ChildrenWithinReachTests.cs | 4 +-- tests/Aetheria.Shared.Tests/LanedBeltTests.cs | 25 ++++++++++++++++--- 3 files changed, 33 insertions(+), 8 deletions(-) diff --git a/Assets/Scripts/ServerShared/ZoneGenerator.cs b/Assets/Scripts/ServerShared/ZoneGenerator.cs index a10950bbf..e4316f68f 100644 --- a/Assets/Scripts/ServerShared/ZoneGenerator.cs +++ b/Assets/Scripts/ServerShared/ZoneGenerator.cs @@ -254,8 +254,7 @@ public static class ZoneGenerator { if (laneRandom.NextFloat() >= zoneSettings.LaneProbability) continue; var lane = zoneSettings.LaneBodyRadii * planetSettings.BodyRadius.Evaluate(host.Mass); - if (swarmHosts.Contains(host) || shaped.Contains(host.Parent) - || host.SlotInner > host.Distance - 2 * lane || host.SlotOuter < host.Distance + 2 * lane) continue; + if (swarmHosts.Contains(host) || shaped.Contains(host.Parent) || !LaneHost(host, lane)) continue; shaped.Add(host.Parent); var hostOrbit = orbitMap[host]; var rail = new OrbitData { Parent = hostOrbit.Parent, Distance = hostOrbit.Distance, Phase = hostOrbit.Phase }; @@ -672,6 +671,15 @@ public static class ZoneGenerator private const uint LaneStream = 0x85EBCA6Bu; + // Whether a placed body can carry a lane of half-width `lane`: its slot holds a band at least that wide on both sides + // (ruling lanes-two-sided-visual-margin), and it is not its parent's outermost child (ruling lane-not-outermost). Place + // stretches the chain to the window, so an outermost child's outer side is the window, not a sibling's spacing; a lane's outer + // band there would reach past every sibling's extent and raise its parent's support, and lanes move nothing else in the zone. + // An only child is its parent's outermost. + public static bool LaneHost(GeneratorPlanet host, float lane) => + host.Parent.Children.Any(c => c.Distance > host.Distance) + && host.SlotInner <= host.Distance - 2 * lane && host.SlotOuter >= host.Distance + 2 * lane; + // A laned belt's rocks: the count an annulus of the host's mass at `distance` would draw, over the bands [inner, distance - lane] // and [distance + lane, outer], each rock in a band by its area and drawn across it as the annulus draws across its width. public static Asteroid[] LaneRocks(ZoneGenerationSettings settings, ref Random random, float mass, float distance, float lane, float inner, float outer) diff --git a/tests/Aetheria.Shared.Tests/ChildrenWithinReachTests.cs b/tests/Aetheria.Shared.Tests/ChildrenWithinReachTests.cs index a5ab3ebc3..6a8bd72cb 100644 --- a/tests/Aetheria.Shared.Tests/ChildrenWithinReachTests.cs +++ b/tests/Aetheria.Shared.Tests/ChildrenWithinReachTests.cs @@ -446,14 +446,14 @@ public sealed class ChildrenWithinReachTests : IClassFixture= 10, $"only {filled} of {belts} belts fill the authored width"); } - private static GeneratorPlanet Body(GeneratorPlanet parent, float mass, float phase = 0, bool ring = false) + internal static GeneratorPlanet Body(GeneratorPlanet parent, float mass, float phase = 0, bool ring = false) { var node = new GeneratorPlanet { Parent = parent, Mass = mass, Phase = phase, Ring = ring }; parent.Children.Add(node); return node; } - private static PlanetSettings AuthoredPlanetSettings() => AuthoredSettings.Load(RunStartTests.FindRepoRoot()).Read("PlanetSettings"); + internal static PlanetSettings AuthoredPlanetSettings() => AuthoredSettings.Load(RunStartTests.FindRepoRoot()).Read("PlanetSettings"); // Two belts side by side under one parent (production never draws them; follow-up belt-after-belt-edge-pin): the second // belt's slot starts a sibling spacing outside the first belt's outer edge, not its rail, so their rock intervals keep a spacing. diff --git a/tests/Aetheria.Shared.Tests/LanedBeltTests.cs b/tests/Aetheria.Shared.Tests/LanedBeltTests.cs index 94d400d18..cb48aa5dc 100644 --- a/tests/Aetheria.Shared.Tests/LanedBeltTests.cs +++ b/tests/Aetheria.Shared.Tests/LanedBeltTests.cs @@ -4,10 +4,10 @@ using GameCult.Caching; using Xunit; using Xunit.Abstractions; -// Laned belts (rulings belt-shapes-direction, lanes-two-sided-visual-margin; map B60): a belt candidate that kept its body carries -// an annulus on a copy of its rail that fills the body's slot less a lane LaneBodyRadii body radii a side, with rocks on both -// sides; it is drawn from a stream of its own and sits inside the slot, so it moves nothing else in the zone. Seeds 1-6, authored -// settings, against the same seeds drawing no lane. +// Laned belts (rulings belt-shapes-direction, lanes-two-sided-visual-margin, lane-narrow, lane-not-outermost; map B60): a belt +// candidate that kept its body and is not its parent's outermost child carries an annulus on a copy of its rail that fills the +// body's slot less a lane LaneBodyRadii (1.5) body radii a side, with rocks on both sides; it is drawn from a stream of its own and +// sits inside the slot, so it moves nothing else in the zone. Seeds 1-6, authored settings, against the same seeds drawing no lane. public sealed class LanedBeltTests : IClassFixture { private const uint First = 1, Last = 6; @@ -140,6 +140,23 @@ public sealed class LanedBeltTests : IClassFixture Assert.True(lanes > 0, $"no lane in seeds {First}-{Last}"); } + // An only child is its parent's outermost (ruling lane-not-outermost): Place stretches nothing for it, so its slot runs out to + // the window and has room for a lane on both sides, yet its lane's outer band would sit past every sibling's extent and raise + // the parent's support. The host filter refuses it on that ground alone. + [Fact] + public void AnOnlyChildHostsNoLane() + { + var root = new GeneratorPlanet { Mass = 1e5f }; + var child = ChildrenWithinReachTests.Body(root, 10f); + var planetSettings = ChildrenWithinReachTests.AuthoredPlanetSettings(); + ZoneGenerator.Place(root, planetSettings, 1e6f, 1e5f); + var lane = _samples.Authored.Settings.LaneBodyRadii * planetSettings.BodyRadius.Evaluate(child.Mass); + _out.WriteLine($"only child at {child.Distance}, slot [{child.SlotInner}, {child.SlotOuter}], lane half-width {lane}"); + Assert.True(child.SlotInner <= child.Distance - 2 * lane && child.SlotOuter >= child.Distance + 2 * lane, + $"the only child's slot [{child.SlotInner}, {child.SlotOuter}] has no room for a lane of {lane} at {child.Distance}"); + Assert.False(ZoneGenerator.LaneHost(child, lane)); + } + // A belt's field kind is chosen at generation and saved with it (Mining Cut 3, Q16 B): every belt the generator writes, // annulus, swarm or lane, carries one before any Zone reads it (finding ensure-unpinned). [Fact] From 0e9b9c71e27f4533f753215f107ef4cbe6de489b Mon Sep 17 00:00:00 2001 From: soul Date: Thu, 22 Oct 2026 00:44:36 +0200 Subject: [PATCH 128/444] Gravity: lane half-width 1.5 body radii (ruling lane-narrow), code default and Settings.asset Co-Authored-By: Claude Opus 5.5 --- Assets/Resources/Settings.asset | 4 ++-- Assets/Scripts/ServerShared/Settings.cs | 8 ++++---- 2 files changed, 6 insertions(+), 6 deletions(-) diff --git a/Assets/Resources/Settings.asset b/Assets/Resources/Settings.asset index f1f320a67..46bee7272 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:8cc62a055653d90b906819239de99238253299f3ce4219cf58bddf9b67e3be67 -size 12523 +oid sha256:7f09c54dfdb87dffd76fa92365d62c53ce333457b89ccf8ead09093ffffedc42 +size 12525 diff --git a/Assets/Scripts/ServerShared/Settings.cs b/Assets/Scripts/ServerShared/Settings.cs index 18278dd15..5a95f450d 100644 --- a/Assets/Scripts/ServerShared/Settings.cs +++ b/Assets/Scripts/ServerShared/Settings.cs @@ -134,11 +134,11 @@ public class ZoneGenerationSettings public float TrojanHalfWidth = .06f; public ExponentialCurve TrojanCount = new ExponentialCurve { Exponent = 1f, Multiplier = .04f, Constant = 8f }; - // Laned belts (ruling lanes-two-sided-visual-margin): the chance a belt candidate that kept its body and has room on both - // sides carries a laned belt, and the lane's half-width in the host's body radii. 0 here: a settings object built in code - // draws none. + // Laned belts (rulings lanes-two-sided-visual-margin, lane-narrow): the chance a belt candidate that kept its body and has + // room on both sides carries a laned belt, and the lane's half-width in the host's body radii (1.5 by ruling). LaneProbability + // is 0 here: a settings object built in code draws no lane. public float LaneProbability = 0f; - public float LaneBodyRadii = 5f; + public float LaneBodyRadii = 1.5f; public float ResourceDensityMinimum = .1f; public float ResourceDensityMaximum = 1.5f; From 321e34a0dad051bba48a0d91a08c8e5801c524de Mon Sep 17 00:00:00 2001 From: soul Date: Thu, 22 Oct 2026 00:46:10 +0200 Subject: [PATCH 129/444] PilotSeamTests: an agent's command lands before the next agent decides Co-Authored-By: Claude Opus 5.5 --- tests/Aetheria.Shared.Tests/PilotSeamTests.cs | 39 +++++++++++++++++++ 1 file changed, 39 insertions(+) diff --git a/tests/Aetheria.Shared.Tests/PilotSeamTests.cs b/tests/Aetheria.Shared.Tests/PilotSeamTests.cs index 20e4e0c90..050913334 100644 --- a/tests/Aetheria.Shared.Tests/PilotSeamTests.cs +++ b/tests/Aetheria.Shared.Tests/PilotSeamTests.cs @@ -148,6 +148,45 @@ public sealed partial class RunStartTests Assert.True(east.Target.Value.IsNone); } + // A probe pilot state: it commands a turn rate and records what another ship's applied turn was when it decided. + private sealed class SeamProbeState : BaseState + { + private readonly float _rate; + private readonly Ship _watched; + public readonly List Seen = new List(); + public SeamProbeState(Agent agent, float rate, Ship watched) : base(agent) + { + _rate = rate; + _watched = watched; + } + + public override void Update(float delta) + { + if (_watched != null) Seen.Add(_watched.Turn); + _agent.Ship.Command.Turn = TurnCommand.OfRate(_rate); + } + } + + // Each agent's command is applied right after it decides, so the next agent sees it applied, as it saw the direct + // writes this seam replaced. Breaks if the agents' commands are applied only after every agent has decided. + [Fact] + public void AnAgentsCommandLandsBeforeTheNextAgentDecides() + { + var (arena, east, west) = SeamPair(false); + var first = new Agent(east, FactionDoctrine.Default(east.ItemManager.GameplaySettings).Combatant); + var second = new Agent(west, FactionDoctrine.Default(west.ItemManager.GameplaySettings).Combatant); + first.Transition(new SeamProbeState(first, .7f, null)); + var probe = new SeamProbeState(second, .1f, east); + second.Transition(probe); + arena.Agents.Add(first); + arena.Agents.Add(second); + + arena.Update(SeamDt); + + Assert.Equal(new[] { .7f }, probe.Seen); + Assert.Equal(.1f, west.Turn); + } + [Fact] public void AnEmptyCommandWritesNothing() { From 0a70e0cf93a133753df9b2847c130860ba6003c8 Mon Sep 17 00:00:00 2001 From: soul Date: Thu, 22 Oct 2026 01:00:06 +0200 Subject: [PATCH 130/444] Blender ships: hull_grid.grid_size; ship_cc reads HullData.HullType, pinned to C# grid_size is rasterise's grid arithmetic without its MAX_CELLS refusal, so a check can judge the bound; rasterise calls the same private filter and sizing. ship_cc gains HULL_TYPE_SLOT (Key 25), HULL_TYPE_NAMES and read_hull_type; ShipSchemaPinTests ties both to HullData.HullType and the HullType enum. Co-Authored-By: Claude Opus 5.5 --- .../ShipSchemaPinTests.cs | 2 ++ tools/blender/aetheria_ships/hull_grid.py | 31 ++++++++++++++----- tools/blender/aetheria_ships/ship_cc.py | 13 +++++++- tools/blender/tests/test_hull_grid.py | 10 ++++++ tools/blender/tests/test_ship_cc.py | 25 ++++++++++++++- 5 files changed, 71 insertions(+), 10 deletions(-) diff --git a/tests/Aetheria.Shared.Tests/ShipSchemaPinTests.cs b/tests/Aetheria.Shared.Tests/ShipSchemaPinTests.cs index 6316241ad..760595dcc 100644 --- a/tests/Aetheria.Shared.Tests/ShipSchemaPinTests.cs +++ b/tests/Aetheria.Shared.Tests/ShipSchemaPinTests.cs @@ -22,6 +22,7 @@ public sealed class ShipSchemaPinTests [InlineData("SCHEMATIC_LINES_SLOT", typeof(ShipAuthoring), nameof(ShipAuthoring.SchematicLines))] [InlineData("HULL_SHAPE_SLOT", typeof(HullData), nameof(HullData.Shape))] [InlineData("HULL_HARDPOINTS_SLOT", typeof(HullData), nameof(HullData.Hardpoints))] + [InlineData("HULL_TYPE_SLOT", typeof(HullData), nameof(HullData.HullType))] [InlineData("PRODUCT_MANUFACTURER_SLOT", typeof(FactionProductData), nameof(FactionProductData.Manufacturer))] public void PythonSlotConstantsAreTheKeysOfTheMembersTheyNameInTheirComments(string constant, Type owner, string member) { @@ -136,6 +137,7 @@ public sealed class ShipSchemaPinTests { Assert.Equal(EnumNames(), Strings("HARDPOINT_TYPE_NAMES")); Assert.Equal(EnumNames(), Strings("ROTATION_NAMES")); + Assert.Equal(EnumNames(), Strings("HULL_TYPE_NAMES")); } [Fact] diff --git a/tools/blender/aetheria_ships/hull_grid.py b/tools/blender/aetheria_ships/hull_grid.py index e408105be..3752fb5e7 100644 --- a/tools/blender/aetheria_ships/hull_grid.py +++ b/tools/blender/aetheria_ships/hull_grid.py @@ -32,6 +32,27 @@ def _inside(triangle, px, py): return not ((d1 < 0 or d2 < 0 or d3 < 0) and (d1 > 0 or d2 > 0 or d3 > 0)) +def _with_area(triangles_xy): + triangles = [tri for tri in triangles_xy if + (tri[1][0] - tri[0][0]) * (tri[2][1] - tri[0][1]) != (tri[1][1] - tri[0][1]) * (tri[2][0] - tri[0][0])] + if not triangles: + raise ValueError("The hull has no triangle with area to rasterise") + return triangles + + +def _size(triangles, cell_size): + xs = [p[0] for tri in triangles for p in tri] + ys = [p[1] for tri in triangles for p in tri] + return _cells_across(max(xs) - min(xs), cell_size), _cells_across(max(ys) - min(ys), cell_size), xs, ys + + +def grid_size(triangles_xy, cell_size=CELL_SIZE): + """(width, height) in cells of the grid rasterise would lay over these triangles, without refusing a grid over + MAX_CELLS: the caller judges the bound. Raises ValueError when no triangle has area.""" + width, height, _, _ = _size(_with_area(triangles_xy), cell_size) + return width, height + + def rasterise(triangles_xy, cell_size=CELL_SIZE, samples=SAMPLES): """Occupied cells of a top-down silhouette given as triangles of (x, y) points in the Ship Root frame. @@ -40,14 +61,8 @@ def rasterise(triangles_xy, cell_size=CELL_SIZE, samples=SAMPLES): inside several triangles counts once, and each triangle is tested only at the samples inside its bounding box. Returns (width, height, cells, grid_origin): cells are column-major, cells[x * height + y], and grid_origin is cell (0, 0)'s centre.""" - triangles = [tri for tri in triangles_xy if - (tri[1][0] - tri[0][0]) * (tri[2][1] - tri[0][1]) != (tri[1][1] - tri[0][1]) * (tri[2][0] - tri[0][0])] - if not triangles: - raise ValueError("The hull has no triangle with area to rasterise") - xs = [p[0] for tri in triangles for p in tri] - ys = [p[1] for tri in triangles for p in tri] - width = _cells_across(max(xs) - min(xs), cell_size) - height = _cells_across(max(ys) - min(ys), cell_size) + triangles = _with_area(triangles_xy) + width, height, xs, ys = _size(triangles, cell_size) for axis, count in (("wide", width), ("long", height)): if count > MAX_CELLS: raise ValueError(f"The hull needs {count} cells {axis}; a ship grid holds at most {MAX_CELLS}") diff --git a/tools/blender/aetheria_ships/ship_cc.py b/tools/blender/aetheria_ships/ship_cc.py index 7cc093389..82ef1d76f 100644 --- a/tools/blender/aetheria_ships/ship_cc.py +++ b/tools/blender/aetheria_ships/ship_cc.py @@ -34,15 +34,17 @@ ANCHORS_SLOT = 3 # ShipAuthoring.Anchors SCHEMATIC_LINES_SLOT = 4 # ShipAuthoring.SchematicLines HULL_SHAPE_SLOT = 5 # ItemData.Shape HULL_HARDPOINTS_SLOT = 23 # HullData.Hardpoints +HULL_TYPE_SLOT = 25 # HullData.HullType # HardpointData member names in slot order; a hardpoint row is one value per name, and any slot past the last # name belongs to a later schema and is carried through untouched. HARDPOINT_MEMBERS = ("Type", "Position", "Shape", "Transform", "Rotation", "Armor", "FiringArc") # ShipAnchor member names in slot order; as for hardpoints, slots past the last name are carried through by anchor Id. ANCHOR_MEMBERS = ("Id", "Role", "ModelNodeId", "ParentId", "Order") -# Enum member names in value order (HardpointType, ItemRotation). +# Enum member names in value order (HardpointType, ItemRotation, HullType). HARDPOINT_TYPE_NAMES = ("Hull", "Tool", "Thermal", "Thruster", "WarpDrive", "Reactor", "Radiator", "Shield", "Sensors", "Energy", "Ballistic", "Launcher", "ControlModule") ROTATION_NAMES = ("None", "CounterClockwise", "Reversed", "Clockwise") +HULL_TYPE_NAMES = ("Ship", "Station", "Turret") # ShipAuthoringStore.RequireShipId's rule, mirrored so New Ship refuses before it touches anything; ShipSchemaPinTests @@ -208,6 +210,15 @@ def read_layout(path: str, cultlib_packages: str): return hull[HULL_SHAPE_SLOT][0], hull[HULL_HARDPOINTS_SLOT], layout_revision(hull[HULL_SHAPE_SLOT][0], hull[HULL_HARDPOINTS_SLOT]) +def read_hull_type(path: str, cultlib_packages: str) -> str: + """The hull's HullData.HullType as its enum member name ("Ship", "Station" or "Turret").""" + hull = _hull_body(read(path, cultlib_packages)) + value = hull[HULL_TYPE_SLOT] if len(hull) > HULL_TYPE_SLOT else None + if type(value) is not int or not 0 <= value < len(HULL_TYPE_NAMES): + raise ValueError("The ship file's hull has no hull type this add-on knows") + return HULL_TYPE_NAMES[value] + + def layout_revision(shape: list[Any], hardpoints: list[list[Any]]) -> str: """The revision of a layout: a hash of its shape ([width, height, cells]) and hardpoint rows as the file stores them. replace_layout returns it, Load records it, and Package compares the layout buffer's against the file's.""" diff --git a/tools/blender/tests/test_hull_grid.py b/tools/blender/tests/test_hull_grid.py index 06667c0f2..ecc79b9fc 100644 --- a/tools/blender/tests/test_hull_grid.py +++ b/tools/blender/tests/test_hull_grid.py @@ -136,6 +136,16 @@ class RasteriseTests(unittest.TestCase): hull_grid.rasterise(rect(0, 0, 66, 2)) self.assertEqual(str(caught.exception), "The hull needs 33 cells wide; a ship grid holds at most 32") + def test_grid_size_is_rasterises_size_and_does_not_refuse_an_oversized_grid(self): + self.assertEqual(hull_grid.grid_size(rect(0, 0, 2, 64)), hull_grid.rasterise(rect(0, 0, 2, 64))[:2]) + self.assertEqual(hull_grid.grid_size(rect(-3, -1, 3, 4) + CORNER), hull_grid.rasterise(rect(-3, -1, 3, 4) + CORNER)[:2]) + self.assertEqual(hull_grid.grid_size(rect(0, 0, 2, 66)), (1, 33)) + self.assertEqual(hull_grid.grid_size(rect(0, 0, 66, 2)), (33, 1)) + self.assertEqual(hull_grid.grid_size(rect(0, 0, 4, 4), cell_size=1.0), (4, 4)) + with self.assertRaises(ValueError) as caught: + hull_grid.grid_size([((0, 0), (1, 1), (2, 2))]) + self.assertEqual(str(caught.exception), "The hull has no triangle with area to rasterise") + def test_a_silhouette_with_no_area_is_refused(self): for triangles in ([], [((0, 0), (1, 1), (2, 2))]): with self.assertRaises(ValueError) as caught: diff --git a/tools/blender/tests/test_ship_cc.py b/tools/blender/tests/test_ship_cc.py index 67facc162..6e971320a 100644 --- a/tools/blender/tests/test_ship_cc.py +++ b/tools/blender/tests/test_ship_cc.py @@ -44,8 +44,9 @@ def hardpoint_row(mount="thruster.port", tail=("future-hardpoint-slot",)): def hull_body(): - hull = [None] * (ship_cc.HULL_HARDPOINTS_SLOT + 1) + hull = [None] * (ship_cc.HULL_TYPE_SLOT + 1) hull[1] = "Skiff" + hull[ship_cc.HULL_TYPE_SLOT] = 0 hull[ship_cc.HULL_SHAPE_SLOT] = [shape(2, 2, [True, False, True, False])] hull[ship_cc.HULL_HARDPOINTS_SLOT] = [hardpoint_row()] hull.append("future-hull-slot") @@ -128,6 +129,28 @@ class ReadTests(ShipFileCase): self.assertEqual("Skiff", file.hull.body[1]) self.assertEqual(ship_cc.HULL_SCHEMA, file.hull.envelope.type) + def test_reads_the_hulls_type_by_its_enum_name(self): + for value, name in ((0, "Ship"), (1, "Station"), (2, "Turret")): + with self.subTest(name): + hull = hull_body() + hull[ship_cc.HULL_TYPE_SLOT] = value + self.write(ship_body(), hull) + self.assertEqual(name, ship_cc.read_hull_type(self.path, PACKAGES)) + + def test_refuses_a_hull_type_it_does_not_know_without_echoing_the_path(self): + short = hull_body()[:ship_cc.HULL_TYPE_SLOT] + for label, value in (("past the enum", 3), ("negative", -1), ("bool", True), ("missing", None), + ("text", "Ship"), ("short body", short)): + with self.subTest(label): + hull = value if label == "short body" else hull_body() + if label != "short body": + hull[ship_cc.HULL_TYPE_SLOT] = value + self.write(ship_body(), hull) + with self.assertRaises(ValueError) as caught: + ship_cc.read_hull_type(self.path, PACKAGES) + self.assertEqual("The ship file's hull has no hull type this add-on knows", str(caught.exception)) + self.assertNotIn(self.path, str(caught.exception)) + def test_reads_a_file_whose_hull_also_carries_products(self): self.add_products() file = ship_cc.read(self.path, PACKAGES) From 2267d8beec76a54f9871a7c035d0ad3d65ab9245 Mon Sep 17 00:00:00 2001 From: soul Date: Thu, 22 Oct 2026 01:03:27 +0200 Subject: [PATCH 131/444] Hull source: language proportions judge Ship hulls only; zones must fit the layout grid build() takes the hull's HullData.HullType, with no default; the add-on reads it from the bound ship.cc. A Station or Turret lists no proportion check; every hull keeps symmetry, length, budget and anchor checks and gains the grid check, judged over zone meshes through hull_grid.grid_size. Co-Authored-By: Claude Opus 5.5 --- tools/blender/aetheria_ships/__init__.py | 12 ++- tools/blender/aetheria_ships/hull_source.py | 36 +++++++-- tools/blender/tests/test_hull_source.py | 81 ++++++++++++++++++++- 3 files changed, 116 insertions(+), 13 deletions(-) diff --git a/tools/blender/aetheria_ships/__init__.py b/tools/blender/aetheria_ships/__init__.py index 90d1c9ad7..17ad00afc 100644 --- a/tools/blender/aetheria_ships/__init__.py +++ b/tools/blender/aetheria_ships/__init__.py @@ -24,7 +24,7 @@ from mathutils import Matrix, Vector from . import hull_grid, hull_source from .ship_cc import (HARDPOINT_MEMBERS, HARDPOINT_TYPE_NAMES, ROTATION_NAMES, capture_grease_pencil, decode_hardpoint, - encode_hardpoint, layout_revision, read, read_layout, replace_layout, replace_lines, + encode_hardpoint, layout_revision, read, read_hull_type, read_layout, replace_layout, replace_lines, product_makers, replace_visual, valid_ship_id) MODEL_ASSET = "ship.glb" @@ -1557,8 +1557,10 @@ def _hull_mesh(name, mesh, material, offset=(0.0, 0.0, 0.0), yaw=0.0): def _build_hull_source(context, collection): """Replaces the objects Build made before with the hull source's zone meshes, anchors and collider under Ship Root. - Refuses, before anything changes, when the source cannot be read, breaks a check of its faction's language, or the - collection holds render meshes Build did not make (operator art: Build never overwrites it). Writes no layout row.""" + Refuses, before anything changes, when the source cannot be read, breaks a check of its faction's language (whose + proportions judge a hull whose ship.cc HullData.HullType is Ship, and no other), its zones overflow the layout grid, + or the collection holds render meshes Build did not make (operator art: Build never overwrites it). Writes no layout + row.""" source = _hull_source_file(collection) if source is None: raise ValueError("The bound package holds no hull.scad") @@ -1573,7 +1575,9 @@ def _build_hull_source(context, collection): "changed; Build From Hull Source replaces only what it made and nobody touched") addon = context.preferences.addons.get(__package__) executable = addon.preferences.openscad if addon and addon.preferences else DEFAULT_OPENSCAD - hull = hull_source.build(hull_source.openscad_runner(executable, source), hull_source.language_library(executable)) + hull_type = read_hull_type(bpy.path.abspath(collection["aetheria.ship_cc"]), _brokkr_cultlib(context)) + hull = hull_source.build(hull_source.openscad_runner(executable, source), hull_source.language_library(executable), + hull_type) broken = hull.failed() if broken: raise ValueError("The hull source breaks its faction's language or its brief: " + "; ".join(c.name for c in broken)) diff --git a/tools/blender/aetheria_ships/hull_source.py b/tools/blender/aetheria_ships/hull_source.py index 50a932e0d..300c926f1 100644 --- a/tools/blender/aetheria_ships/hull_source.py +++ b/tools/blender/aetheria_ships/hull_source.py @@ -14,11 +14,15 @@ with it (a hull that redefines or shadows an included language fails a check): the zone's geometry, OFF; a donor's zone is named ":" emit:, rad: the mesh of a thruster-emitter or radiator-mesh anchor -Frame: Ship Root, metres, nose -Y, starboard -X, up +Z. build() returns a HullBuild; the caller refuses on any failed -check. Messages and check names are fixed text: nothing a hull source says is echoed back. +Frame: Ship Root, metres, nose -Y, starboard -X, up +Z. build() also takes the hull's HullData.HullType ("Ship", +"Station" or "Turret"), which the caller reads: the add-on from the bound package's ship.cc, the bench from its flag. +A faction language's proportions judge Ship hulls only; every hull's zones must fit the layout grid (hull_grid.MAX_CELLS +cells of hull_grid.CELL_SIZE a side). The brief's role is prose and decides nothing. build() returns a HullBuild; the +caller refuses on any failed check. Messages and check names are fixed text: nothing a hull source says is echoed back. """ import hashlib +import importlib.util import json import math import os @@ -29,6 +33,13 @@ from dataclasses import dataclass, field from pathlib import Path from typing import Callable, Optional +if __package__: + from . import hull_grid +else: # loaded by path (tests, the bench): load the sibling the same way + _grid_spec = importlib.util.spec_from_file_location("hull_grid", Path(__file__).with_name("hull_grid.py")) + hull_grid = importlib.util.module_from_spec(_grid_spec) + _grid_spec.loader.exec_module(hull_grid) + FACTIONS_DIR = Path(__file__).with_name("factions") HULL_FILE = "hull.scad" OPENSCAD_TIMEOUT = 180 # seconds, per part @@ -113,6 +124,7 @@ class HullBuild: glow: tuple radiator: tuple body_zone: str # the zone key the collider is made from + hull_type: str # HullData.HullType of the bound package ("Ship", "Station", "Turret"), as the caller read it checks: list = field(default_factory=list) def failed(self): @@ -274,9 +286,10 @@ def _language(library, faction): return language -def build(runner, library): +def build(runner, library, hull_type): """Reads the hull source through runner (see Runner) and judges it against the languages library(faction) answers with - (the ECHO text of that faction's own language file). Raises HullSourceError when the source cannot be read at all; + (the ECHO text of that faction's own language file). hull_type is the hull's HullData.HullType name, which the caller + reads; only a "Ship" is judged on the language's proportions. Raises HullSourceError when the source cannot be read at all; otherwise returns a HullBuild whose failed() lists the checks it breaks.""" _, text = runner("brief") brief = read_brief(text) @@ -298,7 +311,7 @@ def build(runner, library): judge = donor or own body_zone = f"{donor.faction}:{donor.body}" if donor else own.body result = HullBuild(brief, {language.faction: language for language in owned}, zones, colours, anchors, anchor_meshes, - own.glow, own.radiator, body_zone) + own.glow, own.radiator, body_zone, hull_type) by_faction = lambda language: language.faction result.checks = [Check("the hull includes its faction's language files and changes none of them", sorted(included, key=by_faction) == sorted(owned, key=by_faction), "")] @@ -314,8 +327,9 @@ def _checks(hull, own, judge): checks.append(Check("every emitter and radiator anchor has a mesh", not missing, f"{len(missing)} without")) checks.append(Check("a donor is named exactly when the language has no proportions of its own", bool(brief.donor) == (own.proportion is None), "")) + # A language's proportions describe its ships; a station or turret is not held to them, and no check is listed. body = hull.zones[hull.body_zone] if hull.body_zone in hull.zones else Mesh((), ()) - if body.triangles and judge.proportion: + if hull.hull_type == "Ship" and body.triangles and judge.proportion: low, high = body.bounds() width, length, height = (high[0] - low[0]), (high[1] - low[1]), (high[2] - low[2]) ratios = (height / width if width else float("inf"), width / length if length else float("inf")) @@ -323,7 +337,7 @@ def _checks(hull, own, judge): checks.append(Check("the body's proportions lie inside the language's ranges", hw_lo <= ratios[0] <= hw_hi and wl_lo <= ratios[1] <= wl_hi, f"h/w {ratios[0]:.2f}, w/l {ratios[1]:.2f}")) - else: + elif hull.hull_type == "Ship": checks.append(Check("the body's proportions lie inside the language's ranges", False, "no body to judge")) every = [m for m in list(hull.zones.values()) + list(hull.anchor_meshes.values()) if m.triangles] if every: @@ -338,6 +352,14 @@ def _checks(hull, own, judge): else: checks.append(Check("the top view obeys the language's symmetry rule", False, "no geometry")) checks.append(Check("the length lies inside the brief's range", False, "no geometry")) + # Rasterise reads the zone meshes only, so the grid bound is judged over them, not over anchor meshes. + try: + width, height = hull_grid.grid_size([t for m in hull.zones.values() for t in _triangles_xy(m)]) + checks.append(Check("the top view fits the layout grid", + width <= hull_grid.MAX_CELLS and height <= hull_grid.MAX_CELLS, + f"{width} x {height} cells, at most {hull_grid.MAX_CELLS} a side")) + except ValueError: + checks.append(Check("the top view fits the layout grid", False, "no geometry")) triangles = sum(len(m.triangles) for m in every) checks.append(Check("the triangle count lies inside the brief's budget", brief.triangles[0] <= triangles <= brief.triangles[1], f"{triangles}")) diff --git a/tools/blender/tests/test_hull_source.py b/tools/blender/tests/test_hull_source.py index 362664de7..653946eae 100644 --- a/tools/blender/tests/test_hull_source.py +++ b/tools/blender/tests/test_hull_source.py @@ -57,6 +57,7 @@ class Fixture: def __init__(self): self.library = {"zhestokost": language()} self.hull_lang = None + self.hull_type = "Ship" # HullData.HullType, which the caller reads from the package # Body 6 wide, 10 long, 2 high (h/w 0.33, w/l 0.6); a bilateral plate on top; one gun with a muzzle. self.parts = { "brief": ("", brief()), @@ -77,7 +78,7 @@ class Fixture: return self.library[faction] def build(self): - return hs.build(self.runner(), self.read_library) + return hs.build(self.runner(), self.read_library, self.hull_type) def failed(self): return [c.name for c in self.build().failed()] @@ -128,6 +129,82 @@ class ReadingTests(unittest.TestCase): hs.read_languages(language(body="keel")) +PROPORTION = "the body's proportions lie inside the language's ranges" +GRID = "the top view fits the layout grid" + + +def names(built): + return [c.name for c in built.checks] + + +class HullTypeTests(unittest.TestCase): + """A language's proportions judge Ship hulls only; every other rule, and the layout grid, judges every hull type.""" + + @staticmethod + def fixture(hull_type, **parts): + fixture = Fixture() + fixture.hull_type = hull_type + fixture.parts.update({k: (v, "") for k, v in parts.items()}) + return fixture + + def test_a_station_or_turret_is_not_held_to_the_languages_proportions_and_lists_no_proportion_check(self): + wide = box(-12, 12, -5, 5, 0, 2) # h/w 0.08, w/l 2.4: outside both of Zhestokost's ranges + for hull_type in ("Station", "Turret"): + with self.subTest(hull_type): + built = self.fixture(hull_type, primary=wide).build() + self.assertEqual([c.name for c in built.failed()], []) + self.assertNotIn(PROPORTION, names(built)) + self.assertEqual(built.hull_type, hull_type) + self.assertEqual(self.fixture("Ship", primary=wide).failed(), [PROPORTION]) + + def test_a_station_with_no_body_lists_no_proportion_check_but_a_ship_fails_it(self): + self.assertNotIn(PROPORTION, names(self.fixture("Station", primary="").build())) + ship = self.fixture("Ship", primary="").build() + self.assertEqual([c.detail for c in ship.checks if c.name == PROPORTION], ["no body to judge"]) + + def test_a_ship_lists_the_proportion_check_when_it_passes(self): + built = self.fixture("Ship").build() + self.assertEqual([c.ok for c in built.checks if c.name == PROPORTION], [True]) + + def test_a_station_is_still_judged_on_symmetry_length_and_budget(self): + self.assertEqual(self.fixture("Station", secondary=box(2, 6, -1, 1, 2, 3)).failed(), + ["the top view obeys the language's bilateral rule"]) + self.assertEqual(self.fixture("Station", primary=box(-3, 3, -10, 10, 0, 2)).failed(), + ["the length lies inside the brief's range"]) + station = self.fixture("Station") + station.parts["brief"] = ("", brief(triangles=(10, 20))) + self.assertEqual(station.failed(), ["the triangle count lies inside the brief's budget"]) + + def test_every_hull_type_must_fit_the_layout_grid_at_thirty_two_cells_a_side(self): + for hull_type in ("Ship", "Station", "Turret"): + with self.subTest(hull_type): + fits = self.fixture(hull_type, secondary=box(-32, 32, -1, 1, 2, 3)).build() + self.assertEqual([(c.ok, c.detail) for c in fits.checks if c.name == GRID], + [(True, "32 x 5 cells, at most 32 a side")]) + wide = self.fixture(hull_type, secondary=box(-33, 33, -1, 1, 2, 3)) + self.assertEqual(wide.failed(), [GRID]) + self.assertEqual([c.detail for c in wide.build().checks if c.name == GRID], + ["33 x 5 cells, at most 32 a side"]) + long = self.fixture("Station", secondary=box(-1, 1, -33, 33, 2, 3)) + long.parts["brief"] = ("", brief(length=(8, 70))) + self.assertEqual(long.failed(), [GRID]) + self.assertEqual([c.detail for c in long.build().checks if c.name == GRID], ["3 x 33 cells, at most 32 a side"]) + + def test_the_grid_reads_zone_meshes_only_not_anchor_meshes(self): + built = self.fixture("Station", **{"emit:Th.A": box(-35, 35, 5, 5.1, 0.5, 1.5)}).build() + self.assertEqual([c.name for c in built.failed()], []) + self.assertEqual([c.detail for c in built.checks if c.name == GRID], ["3 x 5 cells, at most 32 a side"]) + + def test_a_hull_with_no_zone_geometry_fails_the_grid_check(self): + built = self.fixture("Station", primary="", secondary="").build() + self.assertEqual([(c.ok, c.detail) for c in built.checks if c.name == GRID], [(False, "no geometry")]) + + def test_build_has_no_default_hull_type(self): + fixture = Fixture() + with self.assertRaises(TypeError): + hs.build(fixture.runner(), fixture.read_library) + + class MirrorOverlapTests(unittest.TestCase): @staticmethod def top(text): @@ -381,7 +458,7 @@ class LanguageOwnershipTests(unittest.TestCase): def test_the_library_is_asked_for_the_faction_and_the_donor_the_brief_names(self): asked = [] fixture = DonorTests.pirate() - hs.build(fixture.runner(), lambda faction: asked.append(faction) or fixture.read_library(faction)) + hs.build(fixture.runner(), lambda faction: asked.append(faction) or fixture.read_library(faction), "Ship") self.assertEqual(sorted(asked), ["pirate", "rnd"]) From 28496f27dfd05ff4e56c2e369ef15c68f162a1e8 Mon Sep 17 00:00:00 2001 From: soul Date: Thu, 22 Oct 2026 01:07:54 +0200 Subject: [PATCH 132/444] Hull source smoke: HullType gate (refused as Ship, built as Station, Station refused on the grid) Co-Authored-By: Claude Opus 5.5 --- tools/blender/aetheria_ships/hull_source.py | 4 +- tools/blender/tests/smoke_hull_source.py | 65 ++++++++++++++++++++- 2 files changed, 66 insertions(+), 3 deletions(-) diff --git a/tools/blender/aetheria_ships/hull_source.py b/tools/blender/aetheria_ships/hull_source.py index 300c926f1..ce5529897 100644 --- a/tools/blender/aetheria_ships/hull_source.py +++ b/tools/blender/aetheria_ships/hull_source.py @@ -289,8 +289,8 @@ def _language(library, faction): def build(runner, library, hull_type): """Reads the hull source through runner (see Runner) and judges it against the languages library(faction) answers with (the ECHO text of that faction's own language file). hull_type is the hull's HullData.HullType name, which the caller - reads; only a "Ship" is judged on the language's proportions. Raises HullSourceError when the source cannot be read at all; - otherwise returns a HullBuild whose failed() lists the checks it breaks.""" + reads; only a "Ship" is judged on the language's proportions. Raises HullSourceError when the source cannot be read at + all; otherwise returns a HullBuild whose failed() lists the checks it breaks.""" _, text = runner("brief") brief = read_brief(text) _, text = runner("lang") diff --git a/tools/blender/tests/smoke_hull_source.py b/tools/blender/tests/smoke_hull_source.py index 8bb9b9c48..06f2db715 100644 --- a/tools/blender/tests/smoke_hull_source.py +++ b/tools/blender/tests/smoke_hull_source.py @@ -38,6 +38,8 @@ the hull.scad into the package and runs the panel's operators: changes nothing in the scene. Her art needs no product. Build and Package refuse copies that share an aetheria.id, selecting the original and the copies, and delete nothing. Package moves no row and turns no mount: every layout row is as authored after it, and a hand-turned mount keeps its yaw. + The ship.cc HullData.HullType decides whether the language's proportions judge the hull: a body far wider than the + language's ships is refused as Ship and built as Station, and a Station wider than the layout grid is refused. """ import dataclasses @@ -698,6 +700,66 @@ def agent_guards(scene): return collection +def hull_type_gate(scene): + """The hull's HullData.HullType in ship.cc decides whether the language's proportions judge it; every hull type must + fit the layout grid. A body far wider than a Zhestokost ship is refused as Ship, built as Station, and a Station + wider than 32 cells is refused on the grid.""" + fixture, _, extent, length, rows, *_ = HULLS[0] + ship_id = "smoke.hull.role" + collection, box, package = make_ship(scene, fixture, ship_id, extent, length, rows) + ship_module = sys.modules["aetheria_ships.ship_cc"] + path = str(package / "ship.cc") + source = package / "hull.scad" + original = source.read_text() + slab = '\nif (part == "primary") translate([-%d, -2, 0]) cube([%d, 4, 1]);\n' + source.write_text(original + slab % (15, 30)) + judged = [] + real = hull_source.build + + def recorded(*args): + judged.append(real(*args)) + return judged[-1] + + sys.modules["aetheria_ships"].hull_source.build = recorded + try: + require(ship_module.read_hull_type(path, PACKAGES) == "Ship", "New Ship did not write a Ship hull") + select(box) + result, message = run(bpy.ops.aetheria.build_hull_source) + require(result == {"CANCELLED"} and "the body's proportions lie inside the language's ranges" in message, + f"A Ship far wider than its language allows was built: {message!r}") + print("PASS 5.1 a body wider than the language's ships is refused as Ship, on proportion") + + store = ship_module.read(path, PACKAGES).store + for envelope in store.pull_all(): + if envelope.type == ship_module.HULL_SCHEMA: + values = msgpack.unpackb(envelope.payload, raw=False) + values[ship_module.HULL_TYPE_SLOT] = 1 + store.push(dataclasses.replace(envelope, payload=msgpack.packb(values, use_bin_type=True))) + require(ship_module.read_hull_type(path, PACKAGES) == "Station", "The pushed HullType did not read back as Station") + select(box) + result, message = run(bpy.ops.aetheria.build_hull_source) + names = [c.name for c in judged[-1].checks] + require(result == {"FINISHED"} and judged[-1].hull_type == "Station" + and "the body's proportions lie inside the language's ranges" not in names + and "the top view fits the layout grid" in names, + f"The same body as Station was refused or judged on proportion: {message!r} {names}") + print("PASS 5.2 the same body as Station is built, with no proportion check and a grid check") + + built = set(generated(collection)) + source.write_text(original + slab % (35, 70)) + select(box) + result, message = run(bpy.ops.aetheria.build_hull_source) + require(result == {"CANCELLED"} and "the top view fits the layout grid" in message + and "the body's proportions lie inside the language's ranges" not in message + and set(generated(collection)) == built, + f"A Station 70 m wide was not refused on the layout grid, or the refusal changed the scene: {message!r}") + print("PASS 5.3 a Station 70 m wide is refused on the layout grid, not on proportion") + finally: + sys.modules["aetheria_ships"].hull_source.build = real + print("HULL_TYPE_GATE_OK", flush=True) + return collection + + def main(): require(bpy.app.version[:2] == (5, 2), f"This smoke is for Blender 5.2; this is {bpy.app.version_string}") bpy.data.objects.remove(bpy.data.objects["Cube"], do_unlink=True) @@ -733,6 +795,7 @@ def main(): previous = one_hull(scene, number, fixture, ship_id, extent, length, rows, body, palette, maker, other) clear(previous) clear(agent_guards(scene)) + clear(hull_type_gate(scene)) never_built(scene) @@ -750,7 +813,7 @@ else: print("SMOKE_HULL_SOURCE_OK", flush=True) code = 0 finally: - for _, ship_id, *_ in HULLS + ((None, "smoke.hull.unbuilt"), (None, "smoke.hull.guards")): + for _, ship_id, *_ in HULLS + ((None, "smoke.hull.unbuilt"), (None, "smoke.hull.guards"), (None, "smoke.hull.role")): shutil.rmtree(MODS / ship_id, ignore_errors=True) if created: shutil.rmtree(MODS, ignore_errors=True) From 8bfb06ed64835a156669700923b318076be5cd37 Mon Sep 17 00:00:00 2001 From: soul Date: Thu, 22 Oct 2026 01:15:11 +0200 Subject: [PATCH 133/444] Hull source tests: a Station 32 cells long fits the grid (height bound, not only width) Co-Authored-By: Claude Opus 5.5 --- tools/blender/tests/test_hull_source.py | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/tools/blender/tests/test_hull_source.py b/tools/blender/tests/test_hull_source.py index 653946eae..70d6e917a 100644 --- a/tools/blender/tests/test_hull_source.py +++ b/tools/blender/tests/test_hull_source.py @@ -185,6 +185,10 @@ class HullTypeTests(unittest.TestCase): self.assertEqual(wide.failed(), [GRID]) self.assertEqual([c.detail for c in wide.build().checks if c.name == GRID], ["33 x 5 cells, at most 32 a side"]) + long = self.fixture("Station", secondary=box(-1, 1, -32, 32, 2, 3)) + long.parts["brief"] = ("", brief(length=(8, 70))) + self.assertEqual(long.failed(), []) + self.assertEqual([c.detail for c in long.build().checks if c.name == GRID], ["3 x 32 cells, at most 32 a side"]) long = self.fixture("Station", secondary=box(-1, 1, -33, 33, 2, 3)) long.parts["brief"] = ("", brief(length=(8, 70))) self.assertEqual(long.failed(), [GRID]) From d76563dd5a188ba5cd38ea6a5c9b0f4b2ee5cfe3 Mon Sep 17 00:00:00 2001 From: soul Date: Thu, 22 Oct 2026 01:25:22 +0200 Subject: [PATCH 134/444] NakovalnyaPackageTests: every mount takes its gear through Takes; rows inside the shape and disjoint; no footprint-count pin (red) Co-Authored-By: Claude Opus 5.5 --- .../NakovalnyaPackageTests.cs | 32 +++++++++++++++++-- 1 file changed, 29 insertions(+), 3 deletions(-) diff --git a/tests/Aetheria.Shared.Tests/NakovalnyaPackageTests.cs b/tests/Aetheria.Shared.Tests/NakovalnyaPackageTests.cs index 6f55823f0..d2b08f8ba 100644 --- a/tests/Aetheria.Shared.Tests/NakovalnyaPackageTests.cs +++ b/tests/Aetheria.Shared.Tests/NakovalnyaPackageTests.cs @@ -71,9 +71,7 @@ public sealed class NakovalnyaPackageTests using var cache = Catalog(); var hull = Hull(cache); var ship = cache.Get(new CultRecordRef(ShipModCatalog.AuthoringKey(Id))); - var rows = hull.Hardpoints.GroupBy(h => (h.Type, h.Shape.Width, h.Shape.Height)).ToDictionary(g => g.Key, g => g.Count()); - Assert.Equal(3, rows.GetValueOrDefault((HardpointType.Ballistic, 2, 2))); - Assert.Equal(10, rows.GetValueOrDefault((HardpointType.Ballistic, 1, 2))); + Assert.Equal(13, hull.Hardpoints.Count(h => h.Type == HardpointType.Ballistic)); Assert.Equal(8, hull.Hardpoints.Count(h => h.Type == HardpointType.Thruster)); Assert.Equal(2, hull.Hardpoints.Count(h => h.Type == HardpointType.Radiator)); foreach (var kind in new[] { HardpointType.Reactor, HardpointType.Shield, HardpointType.ControlModule, HardpointType.Sensors }) @@ -93,5 +91,33 @@ public sealed class NakovalnyaPackageTests Assert.InRange(hull.Shape.Width, 1, 32); Assert.InRange(hull.Shape.Height, 1, 32); + + // Every row's cells lie inside the hull's shape, and no two rows share a cell. + var owners = new Dictionary<(int, int), string>(); + foreach (var h in hull.Hardpoints) + foreach (var c in h.Shape.Coordinates) + { + var cell = h.Position + c; + Assert.True(hull.Shape[cell], $"{h.Transform} covers ({cell.x},{cell.y}), outside the hull's shape"); + Assert.True(owners.TryAdd((cell.x, cell.y), h.Transform), $"{h.Transform} and {owners.GetValueOrDefault((cell.x, cell.y))} share ({cell.x},{cell.y})"); + } + } + + // Each mount takes the gear it was placed for, under the one fit rule (HardpointData.Takes): ruling nakovalnya-flak-1x2 + // means the Flak Gun fits every flak mount at its facing, and a row's footprint is its gear's shape turned by Rotation. + [Fact] + public void EveryMountTakesItsGear() + { + using var cache = Catalog(); + var hull = Hull(cache); + EquippableItemData Gear(string name) => cache.GetAll().Single(design => design.Name == name); + var gear = Enumerable.Range(1, 10).Select(i => ($"Gun.F{i}", "Flak Gun")) + .Concat(Enumerable.Range(1, 3).Select(i => ($"Gun.H{i}", "Autocannon"))) + .Concat(new[] { ("Th.L", "Large Drive"), ("Th.R", "Large Drive") }) + .Concat(Enumerable.Range(1, 6).Select(i => ($"Th.T{i}", "Medium Drive"))) + .Concat(new[] { ("Ra.L", "Radiator"), ("Ra.R", "Radiator") }); + var refused = gear.Where(row => !hull.Hardpoints.Single(h => h.Transform == row.Item1).Takes(Gear(row.Item2))) + .Select(row => $"{row.Item1} refuses the {row.Item2}").ToArray(); + Assert.Empty(refused); } } From efeadec26502368f285c3b2141efacff10cfcbcb Mon Sep 17 00:00:00 2001 From: soul Date: Thu, 22 Oct 2026 01:27:38 +0200 Subject: [PATCH 135/444] Blender ships tests: build refuses a hull type that is not a HullType name; the grid limit is hull_grid's (red) Co-Authored-By: Claude Opus 5.5 --- tools/blender/tests/test_hull_source.py | 17 +++++++++++++++++ tools/blender/tests/test_ship_cc.py | 11 +++++++++++ 2 files changed, 28 insertions(+) diff --git a/tools/blender/tests/test_hull_source.py b/tools/blender/tests/test_hull_source.py index 70d6e917a..bd534d83b 100644 --- a/tools/blender/tests/test_hull_source.py +++ b/tools/blender/tests/test_hull_source.py @@ -208,6 +208,23 @@ class HullTypeTests(unittest.TestCase): with self.assertRaises(TypeError): hs.build(fixture.runner(), fixture.read_library) + def test_build_refuses_a_hull_type_that_is_not_a_hulltype_name_before_reading_the_source(self): + for hull_type in ("ship", "SHIP", "Ship ", "", None, "Stations", 0, SECRET): + with self.subTest(hull_type=hull_type): + fixture, read = Fixture(), [] + runner = fixture.runner() + with self.assertRaises(hs.HullSourceError) as caught: + hs.build(lambda part: read.append(part) or runner(part), fixture.read_library, hull_type) + self.assertEqual(read, []) + self.assertNotIn(SECRET, str(caught.exception)) + + def test_build_accepts_each_hulltype_name_and_only_a_ship_lists_the_proportion_check(self): + for hull_type in ("Ship", "Station", "Turret"): + with self.subTest(hull_type): + built = self.fixture(hull_type).build() + self.assertEqual([c.name for c in built.failed()], []) + self.assertEqual(PROPORTION in names(built), hull_type == "Ship") + class MirrorOverlapTests(unittest.TestCase): @staticmethod diff --git a/tools/blender/tests/test_ship_cc.py b/tools/blender/tests/test_ship_cc.py index 6e971320a..828352438 100644 --- a/tools/blender/tests/test_ship_cc.py +++ b/tools/blender/tests/test_ship_cc.py @@ -15,6 +15,7 @@ import shutil import sys import tempfile import unittest +from unittest import mock from datetime import datetime from pathlib import Path from types import SimpleNamespace @@ -543,6 +544,16 @@ class LayoutTests(ShipFileCase): revision = self.layout()[2] self.assertNotEqual(before, self.bytes()) + def test_the_grid_limit_is_hull_grids_max_cells(self): + _, _, revision = self.layout() + before = self.bytes() + with self.assertRaisesRegex(ValueError, f"1..{ship_cc.hull_grid.MAX_CELLS} cells"): + self.edit(revision, width=33, height=1, cells=[True] * 33) + self.assertEqual(before, self.bytes()) + with mock.patch.object(ship_cc.hull_grid, "MAX_CELLS", 33): + self.edit(revision, width=33, height=1, cells=[True] * 33, hardpoints=[]) + self.assertEqual([33, 1, [True] * 33], self.layout()[0]) + def test_a_layout_save_refreshes_the_stored_at_of_the_hull_and_leaves_the_ships(self): stale = "2000-01-01T00:00:00+00:00" envelopes = self.envelopes() From da77380a0456110573f69c01ac64d49f3f7c83e3 Mon Sep 17 00:00:00 2001 From: soul Date: Thu, 22 Oct 2026 01:28:06 +0200 Subject: [PATCH 136/444] Hull source: build refuses a hull type that is not a HullType name, before reading the source Co-Authored-By: Claude Opus 5.5 --- tools/blender/aetheria_ships/hull_source.py | 20 +++++++++++++------- 1 file changed, 13 insertions(+), 7 deletions(-) diff --git a/tools/blender/aetheria_ships/hull_source.py b/tools/blender/aetheria_ships/hull_source.py index ce5529897..fb2aa246a 100644 --- a/tools/blender/aetheria_ships/hull_source.py +++ b/tools/blender/aetheria_ships/hull_source.py @@ -34,11 +34,14 @@ from pathlib import Path from typing import Callable, Optional if __package__: - from . import hull_grid -else: # loaded by path (tests, the bench): load the sibling the same way - _grid_spec = importlib.util.spec_from_file_location("hull_grid", Path(__file__).with_name("hull_grid.py")) - hull_grid = importlib.util.module_from_spec(_grid_spec) - _grid_spec.loader.exec_module(hull_grid) + from . import hull_grid, ship_cc +else: # loaded by path (tests, the bench): load the siblings the same way + def _sibling(name): + spec = importlib.util.spec_from_file_location(name, Path(__file__).with_name(f"{name}.py")) + module = importlib.util.module_from_spec(spec) + spec.loader.exec_module(module) + return module + hull_grid, ship_cc = _sibling("hull_grid"), _sibling("ship_cc") FACTIONS_DIR = Path(__file__).with_name("factions") HULL_FILE = "hull.scad" @@ -289,8 +292,11 @@ def _language(library, faction): def build(runner, library, hull_type): """Reads the hull source through runner (see Runner) and judges it against the languages library(faction) answers with (the ECHO text of that faction's own language file). hull_type is the hull's HullData.HullType name, which the caller - reads; only a "Ship" is judged on the language's proportions. Raises HullSourceError when the source cannot be read at - all; otherwise returns a HullBuild whose failed() lists the checks it breaks.""" + reads; only a "Ship" is judged on the language's proportions. Raises HullSourceError, before reading anything, when + hull_type is not one of ship_cc.HULL_TYPE_NAMES, and when the source cannot be read at all; otherwise returns a + HullBuild whose failed() lists the checks it breaks.""" + if not (type(hull_type) is str and hull_type in ship_cc.HULL_TYPE_NAMES): + raise HullSourceError("The hull type is not a HullData.HullType name (Ship, Station or Turret)") _, text = runner("brief") brief = read_brief(text) _, text = runner("lang") From c7e85676fe76c8d35d5bb9fd10b876ed09a6b567 Mon Sep 17 00:00:00 2001 From: soul Date: Thu, 22 Oct 2026 01:28:07 +0200 Subject: [PATCH 137/444] ship_cc: replace_layout refuses on hull_grid.MAX_CELLS, the grid limit's one owner Co-Authored-By: Claude Opus 5.5 --- tools/blender/aetheria_ships/ship_cc.py | 13 +++++++++++-- 1 file changed, 11 insertions(+), 2 deletions(-) diff --git a/tools/blender/aetheria_ships/ship_cc.py b/tools/blender/aetheria_ships/ship_cc.py index 82ef1d76f..079296be4 100644 --- a/tools/blender/aetheria_ships/ship_cc.py +++ b/tools/blender/aetheria_ships/ship_cc.py @@ -16,6 +16,14 @@ from datetime import datetime, timezone from pathlib import Path from typing import Any, NamedTuple +if __package__: + from . import hull_grid +else: # loaded by path (tests, hull_source loaded by path): load the sibling the same way + import importlib.util + _grid_spec = importlib.util.spec_from_file_location("hull_grid", Path(__file__).with_name("hull_grid.py")) + hull_grid = importlib.util.module_from_spec(_grid_spec) + _grid_spec.loader.exec_module(hull_grid) + # The cultcache-py this module's raw envelope writes were tested against. cultcache_py has no __version__ and is loaded # from source, so the guard reads [project].version of the checkout the imported module itself came from. TESTED_CULTCACHE_PY = "0.3.0" @@ -235,8 +243,9 @@ def replace_layout(path: str, cultlib_packages: str, expected_id: str, expected_ if layout_revision(hull[HULL_SHAPE_SLOT][0], hull[HULL_HARDPOINTS_SLOT]) != expected_revision: raise ValueError("The .cc layout changed since Load; reload before saving") width, height, cells = shape - if not (1 <= width <= 32 and 1 <= height <= 32 and len(cells) == width * height): - raise ValueError("Hull grid must be 1..32 cells wide and high with one value per cell") + limit = hull_grid.MAX_CELLS + if not (1 <= width <= limit and 1 <= height <= limit and len(cells) == width * height): + raise ValueError(f"Hull grid must be 1..{limit} cells wide and high with one value per cell") if not all(type(cell) is bool for cell in cells): raise ValueError("Hull grid cells must be booleans") if not any(cells): From a67711b3d32a3feaaffb9fc66560c91e5e9c62c3 Mon Sep 17 00:00:00 2001 From: soul Date: Thu, 22 Oct 2026 01:31:16 +0200 Subject: [PATCH 138/444] aetheria.nakovalnya: side flak rows and radiators are 2x1 in the grid frame (drive.py rerun through the add-on) Co-Authored-By: Claude Opus 5.5 --- GameData/Mods/aetheria.nakovalnya/ship.cc | 2 +- GameData/Mods/aetheria.nakovalnya/ship.glb | 4 ++-- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/GameData/Mods/aetheria.nakovalnya/ship.cc b/GameData/Mods/aetheria.nakovalnya/ship.cc index ce591805b..2de9f3458 100644 --- a/GameData/Mods/aetheria.nakovalnya/ship.cc +++ b/GameData/Mods/aetheria.nakovalnya/ship.cc @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:62f66a2f76c82af1caac2977c22f39e66b331d5fffe2de82beaab68f0a7bc28b +oid sha256:2390998c0f36f647fbd7b12098a726445a747fc2dfd57c0e68b9fcc7e9116338 size 12701 diff --git a/GameData/Mods/aetheria.nakovalnya/ship.glb b/GameData/Mods/aetheria.nakovalnya/ship.glb index ff556fc6b..27ed4683b 100644 --- a/GameData/Mods/aetheria.nakovalnya/ship.glb +++ b/GameData/Mods/aetheria.nakovalnya/ship.glb @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:01508b4903138bf8fd13caf3d01fcd3f6b6c410c8007a5266fe95c86d9b773d7 -size 843464 +oid sha256:c0f2e8bdb820330a1ba2b49c9615484b971f96634e7108ed675e788c38c3c8ba +size 843456 From dc36b8400f5d5b4964f30630a14ce3e4609b1be0 Mon Sep 17 00:00:00 2001 From: soul Date: Thu, 22 Oct 2026 01:40:15 +0200 Subject: [PATCH 139/444] Gravity tests: no swarm rides a binary's bodyless node; lane filters on a tuned sample; a lane splits rocks by band area (red) NoSwarmRidesABinarysBodylessNode is red at 95903ab0 (finding swarm-on-bodyless-node). The tuned LanedBeltTests sample reaches the swarm-host and one-shape-per-parent filters (lane-filters-unexercised); ALaneSplitsItsRocksByBandArea pins LaneRocks' area share (lane-split-unpinned). Co-Authored-By: Claude Opus 5.5 --- tests/Aetheria.Shared.Tests/LanedBeltTests.cs | 60 +++++++++++++++++-- .../Aetheria.Shared.Tests/TrojanSwarmTests.cs | 31 ++++++++++ 2 files changed, 85 insertions(+), 6 deletions(-) diff --git a/tests/Aetheria.Shared.Tests/LanedBeltTests.cs b/tests/Aetheria.Shared.Tests/LanedBeltTests.cs index cb48aa5dc..0372d3491 100644 --- a/tests/Aetheria.Shared.Tests/LanedBeltTests.cs +++ b/tests/Aetheria.Shared.Tests/LanedBeltTests.cs @@ -17,11 +17,16 @@ public sealed class LanedBeltTests : IClassFixture { public readonly ChildrenWithinReachTests.Zones Authored = new ChildrenWithinReachTests.Zones(First, Last, null); public readonly ChildrenWithinReachTests.Zones Laneless = new ChildrenWithinReachTests.Zones(First, Last, z => z.LaneProbability = 0); + // Every candidate tried for a lane and every eligible host given a swarm, seeds 1-30: the authored sample holds no lane + // candidate that is a swarm host or shares a parent with another eligible host, so only this one reaches the swarm-host and + // one-shape-per-parent filters (finding lane-filters-unexercised). + public readonly ChildrenWithinReachTests.Zones Tuned = new ChildrenWithinReachTests.Zones(1, 30, z => { z.LaneProbability = 1; z.TrojanProbability = 1; }); public void Dispose() { Authored.Dispose(); Laneless.Dispose(); + Tuned.Dispose(); } } @@ -112,13 +117,25 @@ public sealed class LanedBeltTests : IClassFixture Assert.True(lanes > 0, $"no lane in seeds {First}-{Last}"); } - [Fact] - public void ALaneHostIsALightBodyWithNoOtherShape() + [Theory] + [InlineData(false)] + [InlineData(true)] + public void ALaneHostIsALightBodyWithNoOtherShape(bool tuned) { - var s = _samples.Authored.Settings; - var lanes = 0; - foreach (var (seed, zone) in _samples.Authored.All) + var sample = tuned ? _samples.Tuned : _samples.Authored; + var s = sample.Settings; + int lanes = 0, lightChildlessSwarmHosts = 0; + foreach (var (seed, zone) in sample.All) { + // A swarm host light enough and childless enough to be a lane host: the case the swarm-host filter refuses. + foreach (var rail in zone.AsteroidBelts.Where(b => b.Value.Data.RidesRail).Select(b => zone.Orbits[zone.Planets[b.Key].Orbit.Key].Data)) + { + var hostBody = zone.Planets.Values.Where(b => !(b is AsteroidBeltData)).FirstOrDefault(b => zone.Orbits[b.Orbit.Key].Data is var o + && o.Parent.Key.Equals(rail.Parent.Key) && o.Distance == rail.Distance && o.Phase == rail.Phase); + if (hostBody != null && hostBody.Mass < s.BeltMassCeiling + && !zone.Orbits.Values.Any(o => o.Data.Parent.IsSet() && o.Data.Parent.Key.Equals(hostBody.Orbit.Key))) + lightChildlessSwarmHosts++; + } var laned = BeltShapes.Lanes(zone).ToList(); foreach (var (host, _, _) in laned) { @@ -137,7 +154,38 @@ public sealed class LanedBeltTests : IClassFixture var doubled = annuli.Concat(laned.Select(l => l.Host.Data.Parent.Key)).GroupBy(p => p).Count(g => g.Count() > 1); Assert.True(doubled == 0, $"seed {seed}: {doubled} parents carry two belt shapes"); } - Assert.True(lanes > 0, $"no lane in seeds {First}-{Last}"); + _out.WriteLine($"zones {sample.All.Count}, lanes {lanes}, light childless swarm hosts {lightChildlessSwarmHosts}"); + Assert.True(lanes > 0, "no lane in the sample"); + if (tuned) Assert.True(lightChildlessSwarmHosts > 100, $"only {lightChildlessSwarmHosts} light childless swarm hosts: the swarm-host filter is not reached"); + } + + // LaneRocks puts each rock in a band by the band's area (ruling lanes-two-sided-visual-margin, map B60), not half and half and + // not by width (finding lane-split-unpinned). A lopsided slot, inner band [10, 18] (area 224) and outer [22, 24] (area 92): + // area share 0.709, width share 0.8, half 0.5. Over 4000 rocks one sigma of the share is 0.0072, so the area share sits within + // 4 sigma and the other two lie past 12. + [Fact] + public void ALaneSplitsItsRocksByBandArea() + { + const float inner = 10f, distance = 20f, lane = 2f, outer = 24f; + var settings = _samples.Authored.Settings; + var p = (18.0 * 18 - 10 * 10) / (18.0 * 18 - 10 * 10 + 24 * 24 - 22 * 22); + int n = 0, k = 0; + for (uint draw = 1; n < 4000; draw++) + { + var random = new CultMath.Random(draw); + var rocks = ZoneGenerator.LaneRocks(settings, ref random, 1f, distance, lane, inner, outer); + Assert.NotEmpty(rocks); + foreach (var rock in rocks) + { + Assert.True(rock.Distance >= inner && rock.Distance <= distance - lane || rock.Distance >= distance + lane && rock.Distance <= outer, + $"a rock at {rock.Distance} sits outside both bands"); + n++; + if (rock.Distance < distance) k++; + } + } + var z = (k - n * p) / Math.Sqrt(n * p * (1 - p)); + _out.WriteLine($"rocks {n}, inner {k}, area share {p:F3}, measured {(double) k / n:F3}, z {z:F2}"); + Assert.True(Math.Abs(z) < 4, $"{k} of {n} rocks in the inner band, {(double) k / n:F3} against an area share of {p:F3} (z {z:F2})"); } // An only child is its parent's outermost (ruling lane-not-outermost): Place stretches nothing for it, so its slot runs out to diff --git a/tests/Aetheria.Shared.Tests/TrojanSwarmTests.cs b/tests/Aetheria.Shared.Tests/TrojanSwarmTests.cs index 0c7e7487a..3da5e77ab 100644 --- a/tests/Aetheria.Shared.Tests/TrojanSwarmTests.cs +++ b/tests/Aetheria.Shared.Tests/TrojanSwarmTests.cs @@ -38,6 +38,37 @@ public class TrojanSwarmTests 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); + // A standing binary leaves a bodyless node with a parent and its pair's mass, heavy enough to pass the host mass filter; a + // swarm round it would ride a rail no body is on (finding swarm-on-bodyless-node). StationPlacementTests' binary tuning with a + // swarm drawn for every eligible host: every swarm's rail copies a body's orbit. + [Fact] + public void NoSwarmRidesABinarysBodylessNode() + { + using var zones = new ChildrenWithinReachTests.Zones(First, Last, z => + { + z.SatelliteCreationProbability = 1; + z.BinaryCreationProbability = 1; + z.ZoneRadius.Minimum *= 3; z.ZoneRadius.Maximum *= 3; + z.ZoneMass.Minimum *= .1f; z.ZoneMass.Maximum *= .1f; + z.TrojanProbability = 1; + }); + int binaries = 0, swarms = 0, bodyless = 0; + foreach (var (seed, zone) in zones.All) + { + var bodyOrbits = zone.Planets.Values.Select(b => b.Orbit.Key).ToHashSet(); + binaries += zone.Orbits.Count(o => o.Value.Data.Parent.IsSet() && !bodyOrbits.Contains(o.Key) && zone.Wells.Reach(o.Key) > 0f); + var bodies = zone.Planets.Values.Where(b => !(b is AsteroidBeltData)).Select(b => zone.Orbits[b.Orbit.Key].Data).ToList(); + foreach (var rail in zone.AsteroidBelts.Where(b => b.Value.Data.RidesRail).Select(b => zone.Orbits[zone.Planets[b.Key].Orbit.Key].Data)) + { + swarms++; + if (!bodies.Any(o => o.Parent.Key.Equals(rail.Parent.Key) && o.Distance == rail.Distance && o.Phase == rail.Phase)) bodyless++; + } + } + _out.WriteLine($"zones {zones.All.Count}, bodyless nodes {binaries}, swarms {swarms}, on a bodyless node {bodyless}"); + Assert.True(binaries > 5 && swarms > 10, $"{binaries} bodyless nodes and {swarms} swarms: the seeds do not exercise the rule"); + Assert.Equal(0, bodyless); + } + [Fact] public void ASwarmKeepsItsShapeOverAnHour() { From 67e2647753d244ca84645fa84100822aa5b9fa4f Mon Sep 17 00:00:00 2001 From: soul Date: Thu, 22 Oct 2026 01:40:28 +0200 Subject: [PATCH 140/444] Gravity: restore the swarm hosts' Empty guard; a standing binary's bodyless node keeps its mass and hosted swarms (finding swarm-on-bodyless-node) Co-Authored-By: Claude Opus 5.5 --- Assets/Scripts/ServerShared/ZoneGenerator.cs | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/Assets/Scripts/ServerShared/ZoneGenerator.cs b/Assets/Scripts/ServerShared/ZoneGenerator.cs index e4316f68f..dfe89917c 100644 --- a/Assets/Scripts/ServerShared/ZoneGenerator.cs +++ b/Assets/Scripts/ServerShared/ZoneGenerator.cs @@ -219,10 +219,11 @@ public static class ZoneGenerator // 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. + // so it takes no slot and no station candidacy sees it. A standing binary leaves a bodyless node (Empty) that keeps its + // pair's mass and a parent, so the Empty guard is what keeps a swarm off a rail no body is on. var swarmRandom = new Random(seed ^ TrojanStream); var swarmHosts = new HashSet(); - foreach (var host in planets.Where(p => p.Parent != null && !p.Parent.Empty && !p.Belt && p.Distance > 0 + 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; From 7427a7097197f223588787201f9e4425208e8de1 Mon Sep 17 00:00:00 2001 From: soul Date: Thu, 22 Oct 2026 02:26:28 +0200 Subject: [PATCH 141/444] Command seam: pin the Release path, Combat's lock pull and release guard, and the retarget step's pending reads (Soul findings release-path-untested, retarget-step-pending-reads-untested) Co-Authored-By: Claude Opus 5.5 --- tests/Aetheria.Shared.Tests/PilotSeamTests.cs | 128 +++++++++++++++++- 1 file changed, 124 insertions(+), 4 deletions(-) diff --git a/tests/Aetheria.Shared.Tests/PilotSeamTests.cs b/tests/Aetheria.Shared.Tests/PilotSeamTests.cs index 050913334..8f0e03cb0 100644 --- a/tests/Aetheria.Shared.Tests/PilotSeamTests.cs +++ b/tests/Aetheria.Shared.Tests/PilotSeamTests.cs @@ -20,15 +20,16 @@ public sealed partial class RunStartTests { private const float SeamDt = 1f / 60f; - // Two Longinus 200 apart, facing each other, the bare player far away. Piloted ones get the zone's pilot. - private (Zone arena, Ship east, Ship west) SeamPair(bool piloted, Action more = null) + // Two Longinus 2 * half apart (200 by default), facing each other, the bare player far away. Piloted ones get the + // zone's pilot. + private (Zone arena, Ship east, Ship west) SeamPair(bool piloted, Action more = null, float half = 100) { Ship east = null, west = null; var (_, arena, _, failures) = Launch(new Scripted(false, stage => { stage.Player(stage.Bare("Djinni"), float2(-50000, -50000)); - east = stage.Place(EvLonginus(stage), float2(100, 0), facing: float2(-1, 0), piloted: piloted) as Ship; - west = stage.Place(EvLonginus(stage), float2(-100, 0), facing: float2(1, 0), piloted: piloted) as Ship; + east = stage.Place(EvLonginus(stage), float2(half, 0), facing: float2(-1, 0), piloted: piloted) as Ship; + west = stage.Place(EvLonginus(stage), float2(-half, 0), facing: float2(1, 0), piloted: piloted) as Ship; more?.Invoke(stage, east, west); })); Assert.True(failures.Count == 0, string.Join("; ", failures)); @@ -257,6 +258,125 @@ public sealed partial class RunStartTests Assert.True(dot(shots[0].TravelDirection, behind.xz) < 0, "not along the previous aim"); } + // A pull lands once and a release lands once: the trigger is let go at the step it is released, not held on. + [Fact] + public void AReleaseDeactivatesAtTheStep() + { + var (arena, east, _) = SeamPair(false); + for (var step = 0; step < 60; step++) arena.Update(SeamDt); // the gun comes online and off cooldown + var gun = SeamGun(east); + + east.Command.Pull(gun); + PilotApplier.Apply(east); + Assert.True(gun.Firing); + Assert.Empty(east.Command.Triggers); + + east.Command.Release(gun); + PilotApplier.Apply(east); + Assert.False(gun.Firing); + PilotApplier.Apply(east); + Assert.False(gun.Firing); + } + + // Combat lets go of a weapon it will not fire only if that weapon is firing: of a group's two guns, the one firing is + // released and the idle one gets no trigger at all. Breaks if Combat's release guard or its Release is lost. + [Fact] + public void CombatReleasesOnlyAFiringWeaponItWillNotFire() + { + var (arena, east, west) = SeamPair(true); + east.SetIff(west, true); + west.SetIff(east, true); + var agent = arena.Agents.Single(a => a.Ship == east); + agent.Doctrine.MinHitProbability = 2; // no shot clears it, so Combat fires no unfused gun + for (var step = 0; step < 900 && !(SeamState(agent) is CombatState); step++) arena.Update(SeamDt); + Assert.IsType(SeamState(agent)); + var guns = east.WeaponGroups.Single(g => g.weapons.Contains(SeamGun(east))).weapons.ToArray(); + Assert.Equal(2, guns.Length); + Assert.All(guns, g => Assert.False(g.Firing)); + + guns[0].Activate(); // the pilot's earlier pull, still held + Assert.True(guns[0].Firing); + agent.Update(SeamDt); + + var triggers = east.Command.Triggers.Where(t => guns.Any(g => ReferenceEquals(g, t.Behavior))).ToArray(); + var (released, pull) = Assert.Single(triggers); + Assert.Same(guns[0], released); + Assert.False(pull); + PilotApplier.Apply(east); + Assert.False(guns[0].Firing); + Assert.False(guns[1].Firing); + } + + // Combat pulls a locking launcher once it can fire, beside the groups it chooses (its locks add no group damage, so + // this pull is their only one). Breaks if Combat's lock pull is lost. + [Fact] + public void CombatPullsALockThatCanFire() + { + var (arena, east, west) = SeamPair(true, half: 300); // beyond the launchers' minimum range + east.SetIff(west, true); + west.SetIff(east, true); + var agent = arena.Agents.Single(a => a.Ship == east); + var locks = east.Weapons.OfType().ToArray(); + Assert.NotEmpty(locks); + for (var step = 0; step < 1800 && !(SeamState(agent) is CombatState && locks.Any(l => l.CanFire)); step++) + arena.Update(SeamDt); + Assert.IsType(SeamState(agent)); + var ready = locks.Where(l => l.CanFire).ToArray(); + Assert.NotEmpty(ready); + + agent.Update(SeamDt); + + foreach (var launcher in ready) Assert.True(east.Command.Pulls(launcher), $"{launcher.Item.Data.Name} is pulled"); + } + + // Pulls answers the pilot's last trigger for that behaviour this step, and nothing commanded is no pull. + [Fact] + public void PullsReadsTheLastTriggerForThatBehaviour() + { + var (_, east, west) = SeamPair(false); + var gun = SeamGun(east); + var other = SeamGun(west); + Assert.False(east.Command.Pulls(gun)); + + east.Command.Pull(gun); + Assert.True(east.Command.Pulls(gun)); + Assert.False(east.Command.Pulls(other)); + east.Command.Pull(other); + Assert.True(east.Command.Pulls(gun)); + + east.Command.Release(gun); + Assert.False(east.Command.Pulls(gun)); + Assert.True(east.Command.Pulls(other)); + } + + // The step a Minion's target is lost and its Flight designates another (the target-loss clear, then the Flight's + // designation), the Minion stays in combat and aims at the other that step, as it did when SetTarget landed before it + // decided. Breaks if the Minion's transition or Combat read only the applied target (Minion.cs, Combat.cs). + [Fact] + public void TheRetargetStepEngagesTheDesignatedOther() + { + Ship third = null; + var (arena, east, west) = SeamPair(true, (stage, e, w) => + third = stage.Place(EvLonginus(stage), float2(350, 0), facing: float2(-1, 0)) as Ship); + east.SetIff(west, true); + west.SetIff(east, true); + east.SetIff(third, true); + var agent = arena.Agents.Single(a => a.Ship == east); + for (var step = 0; step < 900 && !(SeamState(agent) is CombatState); step++) arena.Update(SeamDt); + Assert.IsType(SeamState(agent)); + Assert.Same(west, east.Target.Value.Entity); + + east.SetTarget(TargetRef.None); + east.Command.Designate(new TargetRef(third)); + agent.Update(SeamDt); + + Assert.IsType(SeamState(agent)); + var aim = normalize(east.Command.Aim.Value.xz); + var toThird = dot(aim, normalize(third.Position.xz - east.Position.xz)); + var toWest = dot(aim, normalize(west.Position.xz - east.Position.xz)); + Assert.True(toThird > toWest + .2f, $"aims at the designated target: toThird {toThird} toWest {toWest}"); + } + [Fact] public void AgentFiresWithTheCommandedAimItem() { From 9d9a8ed031f19e2f6a67e6ffc10ffe3a7347a400 Mon Sep 17 00:00:00 2001 From: soul Date: Thu, 22 Oct 2026 02:27:08 +0200 Subject: [PATCH 142/444] Command seam: the applier is the only writer from PilotCommand; the pilot reads back its own pending decisions (Soul finding only-reader-comment-false) Co-Authored-By: Claude Opus 5.5 --- Assets/Scripts/ServerShared/Pilot.cs | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/Assets/Scripts/ServerShared/Pilot.cs b/Assets/Scripts/ServerShared/Pilot.cs index 95b78348b..ba42a12f1 100644 --- a/Assets/Scripts/ServerShared/Pilot.cs +++ b/Assets/Scripts/ServerShared/Pilot.cs @@ -28,7 +28,8 @@ public readonly struct TurnCommand // The one input an entity's pilot gives it (its agent, the player, a scripted pilot): only own-ship vectors and handles the // pilot was given (ruling observation-sensors-only). Commands are per entity, never per Flight or side: a side may be one -// ship (ruling brain-one-per-side-r2). PilotApplier.Apply is its only reader. +// ship (ruling brain-one-per-side-r2). PilotApplier.Apply is the only writer of the entity's fields from it; the pilot +// also reads back its own pending decisions this step (Designated, Pulls), because the entity still holds last step's. // Held fields persist until the pilot replaces them, so a pilot deciding below the sim rate keeps flying (ruling // lower-game-sim-rate); null leaves the field to whoever wrote it last. One-shots apply once, in order, and are cleared. public sealed class PilotCommand From 76143e50d19b5e3fa13e2525e02a0f67b8a3837b Mon Sep 17 00:00:00 2001 From: soul Date: Thu, 22 Oct 2026 02:27:28 +0200 Subject: [PATCH 143/444] Command seam, player: PilotCommand gains Tractor, the target Verb, CycleAim and Iff; HelmInput.Demand returns a TurnCommand resolved each step Co-Authored-By: Claude Opus 5.5 --- Assets/Scripts/ServerShared/HelmInput.cs | 5 +- Assets/Scripts/ServerShared/Pilot.cs | 35 +++- .../PilotSeamPlayerTests.cs | 168 ++++++++++++++++++ tests/Aetheria.Shared.Tests/SteeringTests.cs | 10 +- 4 files changed, 209 insertions(+), 9 deletions(-) create mode 100644 tests/Aetheria.Shared.Tests/PilotSeamPlayerTests.cs diff --git a/Assets/Scripts/ServerShared/HelmInput.cs b/Assets/Scripts/ServerShared/HelmInput.cs index fe1316aff..7894f33a3 100644 --- a/Assets/Scripts/ServerShared/HelmInput.cs +++ b/Assets/Scripts/ServerShared/HelmInput.cs @@ -13,6 +13,7 @@ public class HelmInput public void Toggle() => Latched = !Latched; - public float Demand(Ship ship, float2 aim, float turnAxis) => - FaceAim ? Steering.Toward(ship, aim) : clamp(turnAxis, -1f, 1f); + // The turn the player commands: the aim heading, resolved against the hull at each sim step, or the turn axis. + public TurnCommand Demand(float2 aim, float turnAxis) => + FaceAim ? TurnCommand.Toward(aim) : TurnCommand.OfRate(clamp(turnAxis, -1f, 1f)); } diff --git a/Assets/Scripts/ServerShared/Pilot.cs b/Assets/Scripts/ServerShared/Pilot.cs index ba42a12f1..be1e9cd7e 100644 --- a/Assets/Scripts/ServerShared/Pilot.cs +++ b/Assets/Scripts/ServerShared/Pilot.cs @@ -26,6 +26,16 @@ public readonly struct TurnCommand public float Resolve(Ship ship) => _toward ? Steering.Toward(ship, _heading) : _rate; } +// The player's target presses, each answered by the entity's own chooser (Entity.TargetNext and the rest) when applied. +public enum TargetVerb +{ + None, + UnderReticle, + NearestEnemy, + Next, + Previous +} + // The one input an entity's pilot gives it (its agent, the player, a scripted pilot): only own-ship vectors and handles the // pilot was given (ruling observation-sensors-only). Commands are per entity, never per Flight or side: a side may be one // ship (ruling brain-one-per-side-r2). PilotApplier.Apply is the only writer of the entity's fields from it; the pilot @@ -37,12 +47,16 @@ public sealed class PilotCommand public TurnCommand? Turn; public float2? Movement; public float3? Aim; + public float? Tractor; internal readonly List Designations = new List(); internal readonly List Selections = new List(); internal readonly List Hails = new List(); internal readonly List Uses = new List(); internal readonly List<(IActivatedBehavior Behavior, bool Pull)> Triggers = new List<(IActivatedBehavior, bool)>(); + internal readonly List<(Entity Other, bool? Hostile)> Iffs = new List<(Entity, bool?)>(); + internal TargetVerb TargetVerb; + internal int AimCycles; public void Designate(TargetRef target) => Designations.Add(target); public void SelectItem(EquippedItem item) => Selections.Add(item); @@ -50,6 +64,10 @@ public sealed class PilotCommand public void Use(ConsumableItemData consumable) => Uses.Add(consumable); public void Pull(IActivatedBehavior behavior) => Triggers.Add((behavior, true)); public void Release(IActivatedBehavior behavior) => Triggers.Add((behavior, false)); + // The last verb in a step wins. + public void Verb(TargetVerb verb) => TargetVerb = verb; + public void CycleAim() => AimCycles++; + public void Iff(Entity other, bool? hostile) => Iffs.Add((other, hostile)); // The pilot's own pending decisions, which it reads back instead of the ship (that is last step's applied state). public TargetRef? Designated => Designations.Count > 0 ? Designations[Designations.Count - 1] : (TargetRef?) null; @@ -69,12 +87,15 @@ public sealed class PilotCommand Hails.Clear(); Uses.Clear(); Triggers.Clear(); + Iffs.Clear(); + TargetVerb = TargetVerb.None; + AimCycles = 0; } } // The one writer of an entity's pilot-controlled fields, called only from Zone.Update. The order is a rule: the target -// lands before its item is selected, and the aim before the triggers, so a pilot fires along the aim it commands this -// step, as the player does. +// lands (a designation, then a chooser verb) before its item is selected or the aim cycled, and the aim before the +// triggers, so a pilot fires along the aim it commands this step. public static class PilotApplier { public static void Apply(Entity entity) @@ -82,7 +103,16 @@ public static class PilotApplier var command = entity.Command; foreach (var target in command.Designations) if (!entity.Target.Value.Equals(target)) entity.SetTarget(target); + switch (command.TargetVerb) + { + case TargetVerb.UnderReticle: entity.TargetUnderReticle(); break; + case TargetVerb.NearestEnemy: entity.TargetNearestEnemy(); break; + case TargetVerb.Next: entity.TargetNext(); break; + case TargetVerb.Previous: entity.TargetPrevious(); break; + } foreach (var item in command.Selections) entity.TrySelectTargetItem(item); + for (var i = 0; i < command.AimCycles; i++) entity.CycleAim(); + foreach (var (other, hostile) in command.Iffs) entity.SetIff(other, hostile); foreach (var hail in command.Hails) entity.SetMessage(hail); foreach (var consumable in command.Uses) entity.TryActivateConsumable(consumable); if (command.Aim is float3 aim) entity.Aim = aim; @@ -94,6 +124,7 @@ public static class PilotApplier if (command.Turn is TurnCommand turn) ship.Turn = turn.Resolve(ship); if (command.Movement is float2 movement) ship.MovementDirection = movement; } + if (command.Tractor is float tractor) entity.TractorCommand = tractor; command.ClearOneShots(); } } diff --git a/tests/Aetheria.Shared.Tests/PilotSeamPlayerTests.cs b/tests/Aetheria.Shared.Tests/PilotSeamPlayerTests.cs new file mode 100644 index 000000000..7f20dfcf2 --- /dev/null +++ b/tests/Aetheria.Shared.Tests/PilotSeamPlayerTests.cs @@ -0,0 +1,168 @@ +/* This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at https://mozilla.org/MPL/2.0/. */ + +using System.Collections.Generic; +using System.Linq; +using Xunit; +using static CultMath.math; +using float2 = CultMath.float2; +using float3 = CultMath.float3; + +// The command seam, player half (aetheria-arena, cut command-seam-player): the player's input writes only its entity's +// PilotCommand, as the agents do, and PilotApplier lands it at the next zone step. Its target presses are verbs the entity's +// chooser answers, its stance and aim-cycle presses are one-shots, its helm and tractor are held, and its triggers are +// edges: a press is one pull and a let-go one release. +public sealed partial class RunStartTests +{ + // East, west 200 away and a third 250 away, all seen by east once the zone has stepped. + private (Zone arena, Ship east, Ship west, Ship third) PlayerSeamTrio() + { + Ship third = null; + var (arena, east, west) = SeamPair(false, (stage, e, w) => + third = stage.Place(EvLonginus(stage), float2(350, 0), facing: float2(-1, 0)) as Ship); + for (var step = 0; step < 60; step++) arena.Update(SeamDt); + Assert.Contains(west, east.VisibleEntities); + Assert.Contains(third, east.VisibleEntities); + return (arena, east, west, third); + } + + [Fact] + public void HelmDemandIsAnIntent() + { + var (arena, east, _) = SeamPair(false); + var held = new HelmInput { Held = true }; + var heading = float2(0, 1); // a quarter turn from its facing + Assert.Equal(Steering.Toward(east, heading), held.Demand(heading, .5f).Resolve(east)); + Assert.Equal(.5f, new HelmInput().Demand(heading, .5f).Resolve(east)); + Assert.Equal(-1f, new HelmInput().Demand(heading, -4f).Resolve(east)); + + // One face-aim demand, held: it re-resolves against the hull at every step as the hull comes round. + east.Command.Turn = held.Demand(heading, 0); + var turns = new List(); + for (var step = 0; step < 600; step++) + { + var expected = Steering.Toward(east, heading); + arena.Update(SeamDt); + Assert.Equal(expected, east.Turn); + turns.Add(east.Turn); + } + Assert.True(turns.Distinct().Count() > 2, "the resolved turn changes as the hull turns"); + } + + [Fact] + public void TargetVerbsApplyOnceAtTheStep() + { + var (arena, east, west, third) = PlayerSeamTrio(); + Assert.True(east.Target.Value.IsNone); + + // The last verb in a step wins: Previous would pick the farthest (third), Next picks the nearest. + east.Command.Verb(TargetVerb.Previous); + east.Command.Verb(TargetVerb.Next); + Assert.True(east.Target.Value.IsNone, "nothing lands before the step"); + arena.Update(SeamDt); + Assert.Same(west, east.Target.Value.Entity); + arena.Update(SeamDt); + Assert.Same(west, east.Target.Value.Entity); + + east.Command.Verb(TargetVerb.Next); + arena.Update(SeamDt); + Assert.Same(third, east.Target.Value.Entity); + + east.Command.Verb(TargetVerb.Previous); + arena.Update(SeamDt); + Assert.Same(west, east.Target.Value.Entity); + + // Enemies only: the farther hostile, not the nearer neutral. + east.SetIff(third, true); + east.Command.Verb(TargetVerb.NearestEnemy); + arena.Update(SeamDt); + Assert.Same(third, east.Target.Value.Entity); + + // The reticle picks along the aim; picking the held target again would clear it. + east.Command.Aim = float3(-1, 0, 0); + east.Command.Verb(TargetVerb.UnderReticle); + arena.Update(SeamDt); + Assert.Same(west, east.Target.Value.Entity); + arena.Update(SeamDt); + Assert.Same(west, east.Target.Value.Entity); + } + + [Fact] + public void IffAndCycleAimAreOneShots() + { + var (arena, east, west, _) = PlayerSeamTrio(); + Assert.False(east.IsHostileTo(west)); + east.Command.Iff(west, true); + Assert.False(east.IsHostileTo(west), "nothing lands before the step"); + arena.Update(SeamDt); + Assert.True(east.IsHostileTo(west)); + // Not replayed: a stance set after the step stays. + east.SetIff(west, false); + arena.Update(SeamDt); + Assert.False(east.IsHostileTo(west)); + east.Command.Iff(west, true); + east.Command.Iff(west, null); + arena.Update(SeamDt); + Assert.False(east.IsHostileTo(west), "the later stance in a step lands last"); + + var first = SeamGun(west).Item; + var second = west.Weapons.Select(w => w.Item).First(i => i != first && i.Data.Name == first.Data.Name); + east.RecordReveal(west, first, SignatureChannel.Emission, west.ClaimOf(first)); + east.RecordReveal(west, second, SignatureChannel.Emission, west.ClaimOf(second)); + east.Command.Verb(TargetVerb.Next); + arena.Update(SeamDt); + Assert.Same(west, east.Target.Value.Entity); + var before = east.ResolvedAim; + + east.Command.CycleAim(); + Assert.Equal(before.Item, east.ResolvedAim.Item); + arena.Update(SeamDt); + var once = east.ResolvedAim; + Assert.True(once.Item != before.Item || once.Mount != before.Mount, "the cycle advanced the aim"); + arena.Update(SeamDt); + Assert.Equal(once.Item, east.ResolvedAim.Item); + Assert.Equal(once.Mount, east.ResolvedAim.Mount); + } + + // A trigger that counts its edges: a semi-auto fires once per Activate. + private sealed class SeamTrigger : IActivatedBehavior + { + public int Pulled, Released; + public void Activate() => Pulled++; + public void Deactivate() => Released++; + } + + // A press is one pull at the next step and the button held after it pulls nothing more, so a semi-auto fires once; + // the let-go is one release. + [Fact] + public void PlayerTriggerIsAnEdge() + { + var (arena, east, _) = SeamPair(false); + var trigger = new SeamTrigger(); + east.Command.Pull(trigger); + Assert.Equal(0, trigger.Pulled); + for (var step = 0; step < 10; step++) arena.Update(SeamDt); + Assert.Equal((1, 0), (trigger.Pulled, trigger.Released)); + + east.Command.Release(trigger); + for (var step = 0; step < 10; step++) arena.Update(SeamDt); + Assert.Equal((1, 1), (trigger.Pulled, trigger.Released)); + } + + [Fact] + public void TractorIsHeld() + { + var (arena, east, _) = SeamPair(false); + east.Command.Tractor = 1; + for (var step = 0; step < 5; step++) + { + east.TractorCommand = 0; // a disturbance between steps: the held command lands again at every step + arena.Update(SeamDt); + Assert.Equal(1f, east.TractorCommand); + } + east.Command.Tractor = .25f; + arena.Update(SeamDt); + Assert.Equal(.25f, east.TractorCommand); + } +} diff --git a/tests/Aetheria.Shared.Tests/SteeringTests.cs b/tests/Aetheria.Shared.Tests/SteeringTests.cs index 71dbfff75..08c0a8958 100644 --- a/tests/Aetheria.Shared.Tests/SteeringTests.cs +++ b/tests/Aetheria.Shared.Tests/SteeringTests.cs @@ -88,8 +88,8 @@ public sealed class SteeringTests { ship.Direction = direction; var expected = Steering.Toward(ship, aim); - Assert.Equal(expected, held.Demand(ship, aim, axis)); - Assert.Equal(expected, latched.Demand(ship, aim, axis)); + Assert.Equal(expected, held.Demand(aim, axis).Resolve(ship)); + Assert.Equal(expected, latched.Demand(aim, axis).Resolve(ship)); } } @@ -102,9 +102,9 @@ public sealed class SteeringTests foreach (var aim in new[] { float2(0, 1), float2(1, 0), float2(-1, -1) }) { - Assert.Equal(.25f, helm.Demand(ship, aim, .25f)); - Assert.Equal(1f, helm.Demand(ship, aim, 3f)); - Assert.Equal(-1f, helm.Demand(ship, aim, -3f)); + Assert.Equal(.25f, helm.Demand(aim, .25f).Resolve(ship)); + Assert.Equal(1f, helm.Demand(aim, 3f).Resolve(ship)); + Assert.Equal(-1f, helm.Demand(aim, -3f).Resolve(ship)); } helm.Toggle(); From 394c13a09971294e880200fcb95bdaa5c72139d0 Mon Sep 17 00:00:00 2001 From: soul Date: Thu, 22 Oct 2026 02:27:29 +0200 Subject: [PATCH 144/444] Command seam, player: ActionGameManager and ActionBarSlot write only Entity.Command (the 18 direct writers) Co-Authored-By: Claude Opus 5.5 --- Assets/Scripts/Gameplay/ActionBarSlot.cs | 11 ++--- Assets/Scripts/Gameplay/ActionGameManager.cs | 46 ++++++++------------ 2 files changed, 25 insertions(+), 32 deletions(-) diff --git a/Assets/Scripts/Gameplay/ActionBarSlot.cs b/Assets/Scripts/Gameplay/ActionBarSlot.cs index 42c2e3f1e..1c51faf82 100644 --- a/Assets/Scripts/Gameplay/ActionBarSlot.cs +++ b/Assets/Scripts/Gameplay/ActionBarSlot.cs @@ -117,7 +117,7 @@ public class ActionBarConsumableBinding : ActionBarBinding public override void Activate() { - Entity.TryActivateConsumable(Target); + Entity.Command.Use(Target); } public override void Deactivate() @@ -155,16 +155,17 @@ public class ActionBarGearBinding : ActionBarBinding else Slot.Icon.texture = ActionGameManager.Instance.Settings.GetIcon(data.HardpointType).texture; } + // The press and the let-go are each one trigger edge, applied at the next step; Active is the slot's display. public override void Activate() { Active = true; - Behavior.Activate(); + Entity.Command.Pull(Behavior); } public override void Deactivate() { Active = false; - Behavior.Deactivate(); + Entity.Command.Release(Behavior); } public override void Update() @@ -190,7 +191,7 @@ public class ActionBarWeaponGroupBinding : ActionBarBinding { foreach (var weapon in Entity.WeaponGroups[Group].weapons) { - weapon.Activate(); + Entity.Command.Pull(weapon); } } @@ -198,7 +199,7 @@ public class ActionBarWeaponGroupBinding : ActionBarBinding { foreach (var weapon in Entity.WeaponGroups[Group].weapons) { - weapon.Deactivate(); + Entity.Command.Release(weapon); } } diff --git a/Assets/Scripts/Gameplay/ActionGameManager.cs b/Assets/Scripts/Gameplay/ActionGameManager.cs index e791f1a64..61a20308f 100644 --- a/Assets/Scripts/Gameplay/ActionGameManager.cs +++ b/Assets/Scripts/Gameplay/ActionGameManager.cs @@ -1,4 +1,4 @@ -/* This Source Code Form is subject to the terms of the Mozilla Public +/* This Source Code Form is subject to the terms of the Mozilla Public * License, v. 2.0. If a copy of the MPL was not distributed with this * file, You can obtain one at https://mozilla.org/MPL/2.0/. */ @@ -428,21 +428,23 @@ public class ActionGameManager : MonoBehaviour { var target = CurrentEntity.Target.Value.Entity; if (target == null) return; - CurrentEntity.SetIff(target, !CurrentEntity.IsHostileTo(target)); + CurrentEntity.Command.Iff(target, !CurrentEntity.IsHostileTo(target)); }; #region Targeting - // The reticle, nearest, next and previous presses each choose through Entity (mining target-queries cut): - // the rule lives with the entity, so the player, the test agent and any director pick alike. - Input.Player.TargetReticle.performed += context => CurrentEntity.TargetUnderReticle(); - Input.Player.TargetNearest.performed += context => CurrentEntity.TargetNearestEnemy(); - Input.Player.TargetNext.performed += context => CurrentEntity.TargetNext(); - Input.Player.TargetPrevious.performed += context => CurrentEntity.TargetPrevious(); + // The reticle, nearest, next and previous presses each command a verb; the applier chooses through Entity at the + // next step (mining target-queries cut): the rule lives with the entity, so the player, the test agent and any + // director pick alike. + Input.Player.TargetReticle.performed += context => CurrentEntity.Command.Verb(TargetVerb.UnderReticle); + Input.Player.TargetNearest.performed += context => CurrentEntity.Command.Verb(TargetVerb.NearestEnemy); + Input.Player.TargetNext.performed += context => CurrentEntity.Command.Verb(TargetVerb.Next); + Input.Player.TargetPrevious.performed += context => CurrentEntity.Command.Verb(TargetVerb.Previous); // Cut 2 (docs/fire-control-cut.md): steps the aim through the target's schematic rows -- every mount this ship - // has located, empty or not, minus the ones it saw destroyed. Decides nothing: the entity owns the cycle. - Input.Player.CycleTargetItem.performed += context => CurrentEntity.CycleAim(); + // has located, empty or not, minus the ones it saw destroyed. Decides nothing: the entity owns the cycle, applied at + // the next step. + Input.Player.CycleTargetItem.performed += context => CurrentEntity.Command.CycleAim(); #endregion @@ -613,19 +615,9 @@ public class ActionGameManager : MonoBehaviour console.AppendLogLine("usage: iff hostile|neutral|clear"); return; } - switch (mode) - { - case "hostile": - _currentEntity.SetIff(target, true); - break; - case "neutral": - _currentEntity.SetIff(target, false); - break; - case "clear": - _currentEntity.SetIff(target, null); - break; - } - console.AppendLogLine($"{target.Name}: {(_currentEntity.IsHostileTo(target) ? "hostile" : "neutral")}"); + bool? hostile = mode == "hostile" ? true : mode == "neutral" ? false : (bool?) null; + _currentEntity.Command.Iff(target, hostile); + console.AppendLogLine($"{target.Name}: {mode} queued; it applies at the next step"); }); // Editor only: capturepreset "" [replace] writes the piloted ship as a catalog preset. A multi-word name must @@ -1324,7 +1316,7 @@ public class ActionGameManager : MonoBehaviour var sensitivity = PlayerSettings.InputSettings.Sensitivity; _entityYawPitch = float2(_entityYawPitch.x + look.x * sensitivity.x, clamp(_entityYawPitch.y + look.y * sensitivity.y, -.45f * PI, .45f * PI)); _viewDirection = mul(float3(0, 0, 1), CultMath.float3x3.Euler(float3(_entityYawPitch.yx, 0), RotationOrder.YXZ)); - CurrentEntity.Aim = _viewDirection; + CurrentEntity.Command.Aim = _viewDirection; HeatstrokePost.weight = saturate(unlerp(0, Settings.GameplaySettings.SevereHeatstrokeRiskThreshold, CurrentEntity.Heatstroke)); var severeHeatstrokeLerp = saturate(unlerp(Settings.GameplaySettings.SevereHeatstrokeRiskThreshold, 1, CurrentEntity.Heatstroke)); SevereHeatstrokePost.weight = @@ -1334,9 +1326,9 @@ public class ActionGameManager : MonoBehaviour _faceAimMark.enabled = _helm.Latched; if(CurrentEntity is Ship ship) { - ship.MovementDirection = Input.Player.Move.ReadValue().ToCultMath(); + ship.Command.Movement = Input.Player.Move.ReadValue().ToCultMath(); _helm.Held = Input.Player.FaceAim.IsPressed(); - ship.Turn = _helm.Demand(ship, _viewDirection.xz, Input.Player.Turn.ReadValue()); + ship.Command.Turn = _helm.Demand(_viewDirection.xz, Input.Player.Turn.ReadValue()); } UpdateMusic(); @@ -1351,7 +1343,7 @@ public class ActionGameManager : MonoBehaviour TargetShieldsFill.fillAmount = target.Shield == null ? 0 : lerp(.25f, .75f, target.Shield.Progress); } - CurrentEntity.TractorCommand = Input.Player.TractorBeam.ReadValue(); + CurrentEntity.Command.Tractor = Input.Player.TractorBeam.ReadValue(); } } // One clock steps the zone, paused or not: a paused frame feeds it no real time. From 6f4dbff317f738d30215fedff9a6aede571f1531 Mon Sep 17 00:00:00 2001 From: soul Date: Thu, 22 Oct 2026 02:27:30 +0200 Subject: [PATCH 145/444] Command seam, player: the control setters and pilot methods are internal, so no Unity script can write them Co-Authored-By: Claude Opus 5.5 --- Assets/Scripts/ServerShared/Entity.cs | 26 +++++++++++++------------- Assets/Scripts/ServerShared/Ship.cs | 11 ++++++----- 2 files changed, 19 insertions(+), 18 deletions(-) diff --git a/Assets/Scripts/ServerShared/Entity.cs b/Assets/Scripts/ServerShared/Entity.cs index 780f365bc..42b30cf4f 100644 --- a/Assets/Scripts/ServerShared/Entity.cs +++ b/Assets/Scripts/ServerShared/Entity.cs @@ -85,7 +85,7 @@ public abstract class Entity // Where the pilot points: reticle, lock cone, tractor, guided rounds, fire direction. Steers nothing; the // hull turns only by Ship.Turn. - public float3 Aim; + public float3 Aim { get; internal set; } // The entity's one pilot input, written by its one pilot (its agent, the player, a scripted pilot) and applied only by // PilotApplier in the zone step. Runtime only: never packed. @@ -189,7 +189,7 @@ public abstract class Entity public bool OverrideShutdown { get; set; } // The pilot's tractor command (0..1); the Pickup behaviour spins its beam toward it. - public float TractorCommand; + public float TractorCommand { get; internal set; } // The beam's spun-up state: Pickup's, or 0 without one. public float TractorPower => GetBehavior()?.Power ?? 0f; @@ -423,7 +423,7 @@ public abstract class Entity // (clearing the aim) or a revealed item (FireControl.IsRevealed: this entity saw it act) belonging to the current // Target. A fitted item the observer has only located is aimed as its mount, never as an item. Returns whether the // write took effect; a rejected non-null selection leaves the aim unchanged. - public bool TrySelectTargetItem(EquippedItem item) + internal bool TrySelectTargetItem(EquippedItem item) { if (item == null) { @@ -442,7 +442,7 @@ public abstract class Entity // The aim slot's other writer: a mount of the hull this entity believes the Target to be (LayoutOf, by reference), // unless this entity perceived that mount's item destroyed (SeenDestroyed). Reads only observer state, so whether a // mount can be aimed at says nothing of the Target's truth (ruling cycle-skips-only-perceived-destroyed). - public bool TrySelectMount(HardpointData mount) + internal bool TrySelectMount(HardpointData mount) { if (!MountAimable(Target.Value.Entity, mount)) return false; AimedAt = AimTarget.OfMount(mount); @@ -479,7 +479,7 @@ public abstract class Entity // The player's cycle key: steps the aim to the next AimCycle entry after the resolved one and wraps; an entry its // writer refuses (a revealed item since destroyed) is passed over; nothing to visit clears the aim. - public void CycleAim() + internal void CycleAim() { var subject = Target.Value.Entity; if (subject == null) return; @@ -497,7 +497,7 @@ public abstract class Entity // Mining Cut 3 (docs/mining-cut-refresh.md): the one writer of Target, called by the player's handlers and the // agents alike. A chunk is taken only while this entity can see it, so a rock in darkness cannot be picked; an // entity is written as given, as every handler wrote it before. Returns whether the write took effect. - public bool SetTarget(TargetRef target) + internal bool SetTarget(TargetRef target) { if (target.Chunk is ChunkId chunk && !ChunkVisible(chunk)) return false; _target.Value = target; @@ -567,7 +567,7 @@ public abstract class Entity // one writer. Candidates are visible ships at any range and visible chunks within mining reach. // The candidate nearest the look direction by planar angle; picking the held target again clears it. - public bool TargetUnderReticle() + internal bool TargetUnderReticle() { var look = float2(Aim.x, Aim.z); if (!PickTarget(new AngleKey(this, Position.xz, look), out var picked)) return false; @@ -575,7 +575,7 @@ public abstract class Entity } // Enemies only, as it drives weapon lock; the nearest by distance. - public bool TargetNearestEnemy() + internal bool TargetNearestEnemy() { Entity nearest = null; var nearestDistance = float.PositiveInfinity; @@ -594,10 +594,10 @@ public abstract class Entity // The next candidate after the current target by (planar distance, TargetRef), wrapping to the nearest; with // nothing targeted, the nearest. - public bool TargetNext() => CycleTarget(false); + internal bool TargetNext() => CycleTarget(false); // The reverse: the next one nearer, wrapping to the farthest; with nothing targeted, the farthest. - public bool TargetPrevious() => CycleTarget(true); + internal bool TargetPrevious() => CycleTarget(true); private bool CycleTarget(bool reverse) { @@ -1246,7 +1246,7 @@ public abstract class Entity // Sets or clears a runtime-only IFF override deciding this entity's hostility toward another. // Pass null to clear the override and restore the derived (faction-based) rule. - public void SetIff(Entity other, bool? hostile) + internal void SetIff(Entity other, bool? hostile) { if (hostile.HasValue) _iffOverrides[other] = hostile.Value; else _iffOverrides.Remove(other); @@ -1311,7 +1311,7 @@ public abstract class Entity public static bool IsDockingPermitted(FactionRelationship relationship, SecurityLevel securityLevel) => (int) relationship - (int) securityLevel > 1; - public void ActivateConsumable(ConsumableItem item) + internal void ActivateConsumable(ConsumableItem item) { var effect = new ConsumableItemEffect(item, this); // The same hook Activate runs over Equipment: a behaviour that computes its targets there (StatModifier) @@ -1350,7 +1350,7 @@ public abstract class Entity public bool HasActiveConsumable(Predicate kind) => _activeConsumables.Any(e => e.Data.Behaviors.Any(b => kind(b))); - public bool TryActivateConsumable(ConsumableItemData data) + internal bool TryActivateConsumable(ConsumableItemData data) { // Death and docking Remove and Deactivate the ship while its agent keeps ticking. if (!_active) return false; diff --git a/Assets/Scripts/ServerShared/Ship.cs b/Assets/Scripts/ServerShared/Ship.cs index 4d621e2f9..dab9960ea 100644 --- a/Assets/Scripts/ServerShared/Ship.cs +++ b/Assets/Scripts/ServerShared/Ship.cs @@ -1,4 +1,4 @@ -/* This Source Code Form is subject to the terms of the Mozilla Public +/* This Source Code Form is subject to the terms of the Mozilla Public * License, v. 2.0. If a copy of the MPL was not distributed with this * file, You can obtain one at https://mozilla.org/MPL/2.0/. */ @@ -25,10 +25,11 @@ public class Ship : Entity // [IgnoreMember] public Dictionary Contacts = new Dictionary(); // [IgnoreMember] public Targetable Target; public Entity HomeEntity; - public float2 MovementDirection; - // The one facing command, -1..1, positive clockwise. Written each tick by the pilot (player input or the - // ship's agent); a heading becomes a demand only through Steering.Toward. Runtime only, never packed. - public float Turn; + // The pilot-controlled hull fields. PilotApplier, in the zone step, is their pilot-side writer, from the command the + // pilot (player input or the ship's agent) gave the entity; no Unity script can set them. Runtime only, never packed. + public float2 MovementDirection { get; internal set; } + // The one facing command, -1..1, positive clockwise; a heading becomes a demand only through Steering.Toward. + public float Turn { get; internal set; } public bool IsPlayerShip; private Thruster[] _allThrusters; From bee079b1c85d0164747e9b19392dbaac433d11f7 Mon Sep 17 00:00:00 2001 From: soul Date: Thu, 22 Oct 2026 02:28:16 +0200 Subject: [PATCH 146/444] Command seam, player: the reticle verb's pin answers a target no other verb would Co-Authored-By: Claude Opus 5.5 --- tests/Aetheria.Shared.Tests/PilotSeamPlayerTests.cs | 9 ++++++--- 1 file changed, 6 insertions(+), 3 deletions(-) diff --git a/tests/Aetheria.Shared.Tests/PilotSeamPlayerTests.cs b/tests/Aetheria.Shared.Tests/PilotSeamPlayerTests.cs index 7f20dfcf2..eb25d8995 100644 --- a/tests/Aetheria.Shared.Tests/PilotSeamPlayerTests.cs +++ b/tests/Aetheria.Shared.Tests/PilotSeamPlayerTests.cs @@ -79,13 +79,16 @@ public sealed partial class RunStartTests arena.Update(SeamDt); Assert.Same(third, east.Target.Value.Entity); - // The reticle picks along the aim; picking the held target again would clear it. + // The reticle picks along the aim, and picking the held target again clears it (no other verb answers None here). + east.Command.Verb(TargetVerb.Previous); + arena.Update(SeamDt); + Assert.Same(west, east.Target.Value.Entity); east.Command.Aim = float3(-1, 0, 0); east.Command.Verb(TargetVerb.UnderReticle); arena.Update(SeamDt); - Assert.Same(west, east.Target.Value.Entity); + Assert.True(east.Target.Value.IsNone); arena.Update(SeamDt); - Assert.Same(west, east.Target.Value.Entity); + Assert.True(east.Target.Value.IsNone, "the reticle press is not replayed"); } [Fact] From 06435bdefa1e7574c51b8bc2e18372854e8ffcf3 Mon Sep 17 00:00:00 2001 From: soul Date: Thu, 22 Oct 2026 02:29:58 +0200 Subject: [PATCH 147/444] Command seam: the lock-pull pin carries the target past the launchers' minimum range Co-Authored-By: Claude Opus 5.5 --- tests/Aetheria.Shared.Tests/PilotSeamTests.cs | 17 +++++++++++------ 1 file changed, 11 insertions(+), 6 deletions(-) diff --git a/tests/Aetheria.Shared.Tests/PilotSeamTests.cs b/tests/Aetheria.Shared.Tests/PilotSeamTests.cs index 8f0e03cb0..bcd7d321c 100644 --- a/tests/Aetheria.Shared.Tests/PilotSeamTests.cs +++ b/tests/Aetheria.Shared.Tests/PilotSeamTests.cs @@ -307,22 +307,27 @@ public sealed partial class RunStartTests Assert.False(guns[1].Firing); } - // Combat pulls a locking launcher once it can fire, beside the groups it chooses (its locks add no group damage, so - // this pull is their only one). Breaks if Combat's lock pull is lost. + // Combat pulls a locking launcher once it can fire, beside the groups it chooses (a lock adds no group damage, so + // this pull is its only one). Breaks if Combat's lock pull is lost. [Fact] public void CombatPullsALockThatCanFire() { - var (arena, east, west) = SeamPair(true, half: 300); // beyond the launchers' minimum range + var (arena, east, west) = SeamPair(true); east.SetIff(west, true); west.SetIff(east, true); var agent = arena.Agents.Single(a => a.Ship == east); var locks = east.Weapons.OfType().ToArray(); Assert.NotEmpty(locks); - for (var step = 0; step < 1800 && !(SeamState(agent) is CombatState && locks.Any(l => l.CanFire)); step++) - arena.Update(SeamDt); + var step = 0; + for (; step < 3000 && !(SeamState(agent) is CombatState && locks.All(l => l.IsLocked)); step++) arena.Update(SeamDt); + Assert.True(locks.All(l => l.IsLocked), $"locked by step {step}: {string.Join(",", locks.Select(l => l.Lock))}"); + // The pair fights inside the launchers' minimum range: carry west out along east's aim, and let east's own update + // (no pilot) see the new range. + west.Position = east.Position + 600 * normalize(float3(east.Aim.x, 0, east.Aim.z)); + east.Update(SeamDt); Assert.IsType(SeamState(agent)); var ready = locks.Where(l => l.CanFire).ToArray(); - Assert.NotEmpty(ready); + Assert.True(ready.Length > 0, $"range {east.TargetRange}, min {locks[0].MinRange}, locked {string.Join(",", locks.Select(l => l.Lock))}"); agent.Update(SeamDt); From 76e4e6f58fde8dc2a1cc76c0d0a03b775835285d Mon Sep 17 00:00:00 2001 From: soul Date: Thu, 22 Oct 2026 02:30:53 +0200 Subject: [PATCH 148/444] Command seam: the trio fixture waits for its contacts, and the lock pin for any launcher's lock Co-Authored-By: Claude Opus 5.5 --- tests/Aetheria.Shared.Tests/PilotSeamPlayerTests.cs | 2 +- tests/Aetheria.Shared.Tests/PilotSeamTests.cs | 4 ++-- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/tests/Aetheria.Shared.Tests/PilotSeamPlayerTests.cs b/tests/Aetheria.Shared.Tests/PilotSeamPlayerTests.cs index eb25d8995..22f63603f 100644 --- a/tests/Aetheria.Shared.Tests/PilotSeamPlayerTests.cs +++ b/tests/Aetheria.Shared.Tests/PilotSeamPlayerTests.cs @@ -21,7 +21,7 @@ public sealed partial class RunStartTests Ship third = null; var (arena, east, west) = SeamPair(false, (stage, e, w) => third = stage.Place(EvLonginus(stage), float2(350, 0), facing: float2(-1, 0)) as Ship); - for (var step = 0; step < 60; step++) arena.Update(SeamDt); + for (var step = 0; step < 600 && !(east.VisibleEntities.Contains(west) && east.VisibleEntities.Contains(third)); step++) arena.Update(SeamDt); Assert.Contains(west, east.VisibleEntities); Assert.Contains(third, east.VisibleEntities); return (arena, east, west, third); diff --git a/tests/Aetheria.Shared.Tests/PilotSeamTests.cs b/tests/Aetheria.Shared.Tests/PilotSeamTests.cs index bcd7d321c..134578efe 100644 --- a/tests/Aetheria.Shared.Tests/PilotSeamTests.cs +++ b/tests/Aetheria.Shared.Tests/PilotSeamTests.cs @@ -319,8 +319,8 @@ public sealed partial class RunStartTests var locks = east.Weapons.OfType().ToArray(); Assert.NotEmpty(locks); var step = 0; - for (; step < 3000 && !(SeamState(agent) is CombatState && locks.All(l => l.IsLocked)); step++) arena.Update(SeamDt); - Assert.True(locks.All(l => l.IsLocked), $"locked by step {step}: {string.Join(",", locks.Select(l => l.Lock))}"); + for (; step < 3000 && !(SeamState(agent) is CombatState && locks.Any(l => l.IsLocked)); step++) arena.Update(SeamDt); + Assert.True(locks.Any(l => l.IsLocked), $"locked by step {step}: {string.Join(",", locks.Select(l => l.Lock))}"); // The pair fights inside the launchers' minimum range: carry west out along east's aim, and let east's own update // (no pilot) see the new range. west.Position = east.Position + 600 * normalize(float3(east.Aim.x, 0, east.Aim.z)); From d8a4e1567d422600550f76b1b3eb839ae62aa21f Mon Sep 17 00:00:00 2001 From: soul Date: Thu, 22 Oct 2026 02:31:44 +0200 Subject: [PATCH 149/444] Command seam: the trio's third ship sits in east's sensor cone; the lock pin holds the guns so the lock completes Co-Authored-By: Claude Opus 5.5 --- tests/Aetheria.Shared.Tests/PilotSeamPlayerTests.cs | 4 ++-- tests/Aetheria.Shared.Tests/PilotSeamTests.cs | 1 + 2 files changed, 3 insertions(+), 2 deletions(-) diff --git a/tests/Aetheria.Shared.Tests/PilotSeamPlayerTests.cs b/tests/Aetheria.Shared.Tests/PilotSeamPlayerTests.cs index 22f63603f..8630a6b05 100644 --- a/tests/Aetheria.Shared.Tests/PilotSeamPlayerTests.cs +++ b/tests/Aetheria.Shared.Tests/PilotSeamPlayerTests.cs @@ -15,12 +15,12 @@ using float3 = CultMath.float3; // edges: a press is one pull and a let-go one release. public sealed partial class RunStartTests { - // East, west 200 away and a third 250 away, all seen by east once the zone has stepped. + // East, west 200 away and a third about 277 away, both ahead of east and seen by it once the zone has stepped. private (Zone arena, Ship east, Ship west, Ship third) PlayerSeamTrio() { Ship third = null; var (arena, east, west) = SeamPair(false, (stage, e, w) => - third = stage.Place(EvLonginus(stage), float2(350, 0), facing: float2(-1, 0)) as Ship); + third = stage.Place(EvLonginus(stage), float2(-150, 120), facing: float2(1, 0)) as Ship); for (var step = 0; step < 600 && !(east.VisibleEntities.Contains(west) && east.VisibleEntities.Contains(third)); step++) arena.Update(SeamDt); Assert.Contains(west, east.VisibleEntities); Assert.Contains(third, east.VisibleEntities); diff --git a/tests/Aetheria.Shared.Tests/PilotSeamTests.cs b/tests/Aetheria.Shared.Tests/PilotSeamTests.cs index 134578efe..3caf0dacc 100644 --- a/tests/Aetheria.Shared.Tests/PilotSeamTests.cs +++ b/tests/Aetheria.Shared.Tests/PilotSeamTests.cs @@ -318,6 +318,7 @@ public sealed partial class RunStartTests var agent = arena.Agents.Single(a => a.Ship == east); var locks = east.Weapons.OfType().ToArray(); Assert.NotEmpty(locks); + agent.Doctrine.MinHitProbability = 2; // its guns hold fire, so its aim settles and the lock completes var step = 0; for (; step < 3000 && !(SeamState(agent) is CombatState && locks.Any(l => l.IsLocked)); step++) arena.Update(SeamDt); Assert.True(locks.Any(l => l.IsLocked), $"locked by step {step}: {string.Join(",", locks.Select(l => l.Lock))}"); From b180fd732dc053fbe15971e503a4aec983431f55 Mon Sep 17 00:00:00 2001 From: soul Date: Thu, 22 Oct 2026 02:47:57 +0200 Subject: [PATCH 150/444] Command seam, player: the Previous and NearestEnemy pins start from no target, where no other verb gives their answer Co-Authored-By: Claude Opus 5.5 --- tests/Aetheria.Shared.Tests/PilotSeamPlayerTests.cs | 7 +++++-- 1 file changed, 5 insertions(+), 2 deletions(-) diff --git a/tests/Aetheria.Shared.Tests/PilotSeamPlayerTests.cs b/tests/Aetheria.Shared.Tests/PilotSeamPlayerTests.cs index 8630a6b05..3c97f7fc9 100644 --- a/tests/Aetheria.Shared.Tests/PilotSeamPlayerTests.cs +++ b/tests/Aetheria.Shared.Tests/PilotSeamPlayerTests.cs @@ -69,11 +69,14 @@ public sealed partial class RunStartTests arena.Update(SeamDt); Assert.Same(third, east.Target.Value.Entity); + // With nothing held, Previous picks the farthest and Next the nearest. + east.SetTarget(TargetRef.None); east.Command.Verb(TargetVerb.Previous); arena.Update(SeamDt); - Assert.Same(west, east.Target.Value.Entity); + Assert.Same(third, east.Target.Value.Entity); - // Enemies only: the farther hostile, not the nearer neutral. + // Enemies only: the farther hostile, not the nearer neutral (Next, from nothing, would pick the neutral). + east.SetTarget(TargetRef.None); east.SetIff(third, true); east.Command.Verb(TargetVerb.NearestEnemy); arena.Update(SeamDt); From 30bcabf172852465128f04f1c0b9a32344a92a41 Mon Sep 17 00:00:00 2001 From: soul Date: Thu, 22 Oct 2026 04:31:47 +0200 Subject: [PATCH 151/444] Command seam fixes: pin Combat's release of a pulled lock, its commanded-aim pricing, and the order and count of same-step presses Co-Authored-By: Claude Opus 5.5 --- .../PilotSeamPlayerTests.cs | 90 +++++++++++++++++++ tests/Aetheria.Shared.Tests/PilotSeamTests.cs | 82 +++++++++++++++++ 2 files changed, 172 insertions(+) diff --git a/tests/Aetheria.Shared.Tests/PilotSeamPlayerTests.cs b/tests/Aetheria.Shared.Tests/PilotSeamPlayerTests.cs index 3c97f7fc9..3dc5777f4 100644 --- a/tests/Aetheria.Shared.Tests/PilotSeamPlayerTests.cs +++ b/tests/Aetheria.Shared.Tests/PilotSeamPlayerTests.cs @@ -131,6 +131,96 @@ public sealed partial class RunStartTests Assert.Equal(once.Mount, east.ResolvedAim.Mount); } + private void SeamReveal(Ship east, Ship west, out EquippedItem first, out EquippedItem second) + { + first = SeamGun(west).Item; + var one = first; + second = west.Weapons.Select(w => w.Item).First(i => i != one && i.Data.Name == one.Data.Name); + east.RecordReveal(west, first, SignatureChannel.Emission, west.ClaimOf(first)); + east.RecordReveal(west, second, SignatureChannel.Emission, west.ClaimOf(second)); + } + + private static void SeamAimEqual(AimTarget expected, AimTarget actual) + { + Assert.Equal(expected.Item, actual.Item); + Assert.Equal(expected.Mount, actual.Mount); + } + + private static bool SeamAimDiffers(AimTarget a, AimTarget b) => a.Item != b.Item || a.Mount != b.Mount; + + // Every aim-cycle press in a step cycles: two presses step the aim twice, as two direct cycles did. + [Fact] + public void CycleAimCountsEveryPress() + { + var (arena, east, west, _) = PlayerSeamTrio(); + SeamReveal(east, west, out var first, out _); + east.SetTarget(new TargetRef(west)); + Assert.True(east.TrySelectTargetItem(first)); + east.CycleAim(); + var once = east.ResolvedAim; + east.CycleAim(); + var twice = east.ResolvedAim; + Assert.True(SeamAimDiffers(once, twice), "fixture: a second cycle moves the aim on"); + + Assert.True(east.TrySelectTargetItem(first)); + east.Command.CycleAim(); + east.Command.CycleAim(); + arena.Update(SeamDt); + SeamAimEqual(twice, east.ResolvedAim); + } + + // Presses in one step apply in a fixed order, whatever order they came in: the designation, then the verb (answered + // from the designated target), then the item selection, then the aim cycles (stepping from the verb's target and the + // selected item). Each case is checked against both orders by direct calls, and fails if the two orders agree. + [Fact] + public void SameStepPressesApplyDesignationVerbSelectionCycle() + { + var (arena, east, west, third) = PlayerSeamTrio(); + SeamReveal(east, west, out var first, out _); + void Reset(Entity target) + { + east.SetTarget(target == null ? TargetRef.None : new TargetRef(target)); + Assert.True(east.TrySelectTargetItem(null)); + } + + // Next steps on from the designated west (to third), not from nothing (to west). + Reset(null); + east.Command.Verb(TargetVerb.Next); + east.Command.Designate(new TargetRef(west)); + arena.Update(SeamDt); + Assert.Same(third, east.Target.Value.Entity); + + // The cycle steps the aim on the target the verb chose. + Reset(null); + east.TargetNext(); + east.CycleAim(); + var verbThenCycle = east.ResolvedAim; + Reset(null); + east.CycleAim(); + east.TargetNext(); + Assert.True(SeamAimDiffers(verbThenCycle, east.ResolvedAim), "fixture: the verb and the cycle do not commute"); + Reset(null); + east.Command.CycleAim(); + east.Command.Verb(TargetVerb.Next); + arena.Update(SeamDt); + SeamAimEqual(verbThenCycle, east.ResolvedAim); + + // The cycle steps on from the selected item. + Reset(west); + Assert.True(east.TrySelectTargetItem(first)); + east.CycleAim(); + var selectThenCycle = east.ResolvedAim; + Reset(west); + east.CycleAim(); + Assert.True(east.TrySelectTargetItem(first)); + Assert.True(SeamAimDiffers(selectThenCycle, east.ResolvedAim), "fixture: the selection and the cycle do not commute"); + Reset(west); + east.Command.CycleAim(); + east.Command.SelectItem(first); + arena.Update(SeamDt); + SeamAimEqual(selectThenCycle, east.ResolvedAim); + } + // A trigger that counts its edges: a semi-auto fires once per Activate. private sealed class SeamTrigger : IActivatedBehavior { diff --git a/tests/Aetheria.Shared.Tests/PilotSeamTests.cs b/tests/Aetheria.Shared.Tests/PilotSeamTests.cs index 3caf0dacc..3bfeb9f42 100644 --- a/tests/Aetheria.Shared.Tests/PilotSeamTests.cs +++ b/tests/Aetheria.Shared.Tests/PilotSeamTests.cs @@ -335,6 +335,88 @@ public sealed partial class RunStartTests foreach (var launcher in ready) Assert.True(east.Command.Pulls(launcher), $"{launcher.Item.Data.Name} is pulled"); } + // Combat lets go of a lock it pulled this step (beside the groups) when the chosen group's own decision refuses it. An + // unfused lock is priced on its hit probability, which no shot clears at MinHitProbability 2, and it was never firing, + // so only the pending pull (Command.Pulls) tells Combat to release it. Breaks if Combat's release guard reads Firing + // alone (Combat.cs). + [Fact] + public void CombatReleasesALockItPulledButWillNotFire() + { + var (arena, east, west) = SeamPair(true); + east.SetIff(west, true); + west.SetIff(east, true); + var agent = arena.Agents.Single(a => a.Ship == east); + var locks = east.Weapons.OfType().ToArray(); + Assert.NotEmpty(locks); + agent.Doctrine.MinHitProbability = 2; // no unfused shot clears it + var step = 0; + for (; step < 3000 && !(SeamState(agent) is CombatState && locks.Any(l => l.IsLocked)); step++) arena.Update(SeamDt); + var launcher = locks.FirstOrDefault(l => l.IsLocked); + Assert.True(launcher != null, $"locked by step {step}"); + var gunRange = east.Weapons.Where(w => !(w is LockWeapon)).Max(w => w.Range); + var distance = launcher.MinRange + 20; + Assert.True(distance < gunRange, $"fixture: the guns reach past the launcher's minimum range ({launcher.MinRange} vs {gunRange})"); + west.Position = east.Position + distance * normalize(float3(east.Aim.x, 0, east.Aim.z)); + east.Update(SeamDt); + Assert.IsType(SeamState(agent)); + Assert.True(launcher.CanFire && !launcher.Firing, $"fixture: ready and idle (range {east.TargetRange}, lock {launcher.Lock})"); + + // Unfused, and in every group, so the chosen group decides it too. + var data = Assert.IsAssignableFrom(launcher.Item.Data); + var radius = data.BlastRadius; + try + { + data.BlastRadius = 0; + foreach (var group in east.WeaponGroups) + if (!group.weapons.Contains(launcher)) group.weapons.Add(launcher); + + agent.Update(SeamDt); + + var mine = east.Command.Triggers.Where(t => ReferenceEquals(t.Behavior, launcher)).Select(t => t.Pull).ToArray(); + Assert.True(mine.Contains(true), $"Combat pulls the ready lock: [{string.Join(",", mine)}]"); + Assert.False(east.Command.Pulls(launcher), $"and lets go of it when the group's decision refuses it: [{string.Join(",", mine)}]"); + } + finally + { + data.BlastRadius = radius; + } + } + + // Combat prices each shot on the aim it commands this step, not the applied aim (last step's). With the applied aim + // cleared and MinHitProbability between the two prices, the gun's one trigger follows the commanded price. Breaks if + // Combat drops its aim from FireControl.AgentFires (Combat.cs). + [Fact] + public void CombatPricesFireOnTheCommandedAim() + { + var (arena, east, west) = SeamPair(true); + east.SetIff(west, true); + west.SetIff(east, true); + var agent = arena.Agents.Single(a => a.Ship == east); + for (var step = 0; step < 900 && !(SeamState(agent) is CombatState); step++) arena.Update(SeamDt); + Assert.IsType(SeamState(agent)); + var revealed = SeamGun(west).Item; + east.EntityInfoGathered[west] = 1; + east.RecordReveal(west, revealed, SignatureChannel.Emission, west.ClaimOf(revealed)); + + agent.Update(SeamDt); // learn the item Combat aims at + var commanded = east.Command.Selections.LastOrDefault(); + Assert.NotNull(commanded); + east.Command.ClearOneShots(); + Assert.True(east.TrySelectTargetItem(null)); + var gun = SeamGun(east); + var pCommanded = FireControl.HitProbability(gun, east, west, AimTarget.OfItem(commanded)); + var pApplied = FireControl.HitProbability(gun, east, west); + Assert.True(abs(pCommanded - pApplied) > 1e-4f, $"fixture: the two aims price apart ({pCommanded} vs {pApplied})"); + agent.Doctrine.MinHitProbability = (pCommanded + pApplied) / 2; + var fires = pCommanded > pApplied; + if (!fires) gun.Activate(); // held, so Combat must let go of it + + agent.Update(SeamDt); + + var (_, pull) = Assert.Single(east.Command.Triggers.Where(t => ReferenceEquals(t.Behavior, gun))); + Assert.Equal(fires, pull); + } + // Pulls answers the pilot's last trigger for that behaviour this step, and nothing commanded is no pull. [Fact] public void PullsReadsTheLastTriggerForThatBehaviour() From 25750c225b5daf731c7d8a771e4aa0868db13927 Mon Sep 17 00:00:00 2001 From: soul Date: Thu, 22 Oct 2026 04:32:17 +0200 Subject: [PATCH 152/444] Command seam fixes (red): stance flips and the player's switches and ping are presses on PilotCommand Co-Authored-By: Claude Opus 5.5 --- .../PilotSeamPlayerTests.cs | 65 +++++++++++++++++++ 1 file changed, 65 insertions(+) diff --git a/tests/Aetheria.Shared.Tests/PilotSeamPlayerTests.cs b/tests/Aetheria.Shared.Tests/PilotSeamPlayerTests.cs index 3dc5777f4..e656c1212 100644 --- a/tests/Aetheria.Shared.Tests/PilotSeamPlayerTests.cs +++ b/tests/Aetheria.Shared.Tests/PilotSeamPlayerTests.cs @@ -221,6 +221,71 @@ public sealed partial class RunStartTests SeamAimEqual(selectThenCycle, east.ResolvedAim); } + // A stance press flips the stance as it stands when the press is applied, so two in a step cancel, as two flips in + // place did before the seam; none is replayed. + [Fact] + public void StanceTogglesInAStepCompose() + { + var (arena, east, west, _) = PlayerSeamTrio(); + Assert.False(east.IsHostileTo(west)); + east.Command.ToggleStance(west); + Assert.False(east.IsHostileTo(west), "nothing lands before the step"); + arena.Update(SeamDt); + Assert.True(east.IsHostileTo(west)); + + east.Command.ToggleStance(west); + east.Command.ToggleStance(west); + arena.Update(SeamDt); + Assert.True(east.IsHostileTo(west), "two flips in a step cancel"); + + east.Command.ToggleStance(west); + arena.Update(SeamDt); + Assert.False(east.IsHostileTo(west)); + arena.Update(SeamDt); + Assert.False(east.IsHostileTo(west), "the press is not replayed"); + } + + // The player's switches are presses on its command too: each flips at the next step, two in a step cancel, and none is + // replayed. A ping is a press that pings once. + [Fact] + public void PlayerSwitchesArePressesAtTheStep() + { + var (arena, east, _) = SeamPair(false); + Assert.NotNull(east.Shield); + Assert.NotNull(east.Sensor); + (bool, bool, bool) Switches() => (east.OverrideShutdown, east.HeatsinksEnabled, east.Shield.Item.Enabled.Value); + Assert.Equal((false, true, true), Switches()); + + east.Command.ToggleOverrideShutdown(); + east.Command.ToggleHeatsinks(); + east.Command.ToggleShield(); + Assert.Equal((false, true, true), Switches()); + arena.Update(SeamDt); + Assert.Equal((true, false, false), Switches()); + arena.Update(SeamDt); + Assert.Equal((true, false, false), Switches()); + + east.Command.ToggleOverrideShutdown(); + east.Command.ToggleOverrideShutdown(); + east.Command.ToggleHeatsinks(); + east.Command.ToggleHeatsinks(); + east.Command.ToggleShield(); + east.Command.ToggleShield(); + arena.Update(SeamDt); + Assert.Equal((true, false, false), Switches()); + + var pings = 0; + east.Sensor.OnPingStart += () => pings++; + for (var step = 0; step < 600; step++) arena.Update(SeamDt); // the ping's capacitor fills + Assert.Equal(0, pings); + east.Command.Ping(); + Assert.Equal(0, pings); + arena.Update(SeamDt); + Assert.Equal(1, pings); + for (var step = 0; step < 10; step++) arena.Update(SeamDt); + Assert.Equal(1, pings); + } + // A trigger that counts its edges: a semi-auto fires once per Activate. private sealed class SeamTrigger : IActivatedBehavior { From bc0541745009baf7d284d055d77c5894f38f5cdb Mon Sep 17 00:00:00 2001 From: soul Date: Thu, 22 Oct 2026 04:33:20 +0200 Subject: [PATCH 153/444] Command seam fixes: the player's switches, ping and stance flips are PilotCommand presses; triggers, switches and ping are internal ActivatedBehavior replaces IActivatedBehavior so Activate/Deactivate can be internal under Unity's C# 9. Co-Authored-By: Claude Opus 5.5 --- Assets/Scripts/Gameplay/ActionBarSlot.cs | 6 +-- Assets/Scripts/Gameplay/ActionGameManager.cs | 38 ++++++------------- .../ServerShared/Behaviors/Behaviors.cs | 11 ++++-- .../ServerShared/Behaviors/ChargedWeapon.cs | 4 +- .../ServerShared/Behaviors/ConstantWeapon.cs | 4 +- .../ServerShared/Behaviors/InstantWeapon.cs | 2 +- .../Scripts/ServerShared/Behaviors/Sensor.cs | 2 +- .../Scripts/ServerShared/Behaviors/Switch.cs | 8 ++-- .../Scripts/ServerShared/Behaviors/Trigger.cs | 8 ++-- .../Scripts/ServerShared/Behaviors/Weapon.cs | 6 +-- Assets/Scripts/ServerShared/Entity.cs | 6 +-- Assets/Scripts/ServerShared/Pilot.cs | 28 ++++++++++---- docs/headless-playground-cut.md | 4 +- .../PilotSeamPlayerTests.cs | 9 +++-- 14 files changed, 70 insertions(+), 66 deletions(-) diff --git a/Assets/Scripts/Gameplay/ActionBarSlot.cs b/Assets/Scripts/Gameplay/ActionBarSlot.cs index 1c51faf82..5c28357e9 100644 --- a/Assets/Scripts/Gameplay/ActionBarSlot.cs +++ b/Assets/Scripts/Gameplay/ActionBarSlot.cs @@ -75,7 +75,7 @@ public class ActionBarSlot : MonoBehaviour this, entity.Equipment[savedActionBarGearBinding.EquipmentIndex], entity.Equipment[savedActionBarGearBinding.EquipmentIndex] - .Behaviors[savedActionBarGearBinding.BehaviorIndex] as IActivatedBehavior), + .Behaviors[savedActionBarGearBinding.BehaviorIndex] as ActivatedBehavior), SavedActionBarWeaponGroupBinding savedActionBarWeaponGroupBinding => new ActionBarWeaponGroupBinding(entity, this, savedActionBarWeaponGroupBinding.Group), _ => null @@ -136,11 +136,11 @@ public class ActionBarConsumableBinding : ActionBarBinding public class ActionBarGearBinding : ActionBarBinding { public EquippedItem Item { get; } - public IActivatedBehavior Behavior { get; } + public ActivatedBehavior Behavior { get; } public bool Active; - public ActionBarGearBinding(Entity entity, ActionBarSlot slot, EquippedItem item, IActivatedBehavior behavior) : base(entity, slot) + public ActionBarGearBinding(Entity entity, ActionBarSlot slot, EquippedItem item, ActivatedBehavior behavior) : base(entity, slot) { Item = item; var data = Entity.ItemManager.GetData(item.EquippableItem); diff --git a/Assets/Scripts/Gameplay/ActionGameManager.cs b/Assets/Scripts/Gameplay/ActionGameManager.cs index 61a20308f..6fa564d92 100644 --- a/Assets/Scripts/Gameplay/ActionGameManager.cs +++ b/Assets/Scripts/Gameplay/ActionGameManager.cs @@ -396,39 +396,23 @@ public class ActionGameManager : MonoBehaviour ActionBar.gameObject.SetActive(!_uiHidden); }; - Input.Player.OverrideShutdown.performed += context => - { - CurrentEntity.OverrideShutdown = !CurrentEntity.OverrideShutdown; - }; - - Input.Player.Ping.performed += context => - { - CurrentEntity.Sensor?.Ping(); - }; - - Input.Player.ToggleHeatsinks.performed += context => - { - CurrentEntity.HeatsinksEnabled = !CurrentEntity.HeatsinksEnabled; - // TODO: SFX: Success/Fail - }; - - Input.Player.ToggleShield.performed += context => - { - if (CurrentEntity.Shield != null) - { - CurrentEntity.Shield.Item.Enabled.Value = !CurrentEntity.Shield.Item.Enabled.Value; - // TODO: SFX: Success/Fail - } - }; + // The switches and the ping are presses on the entity's command, flipped at the next step. + Input.Player.OverrideShutdown.performed += context => CurrentEntity.Command.ToggleOverrideShutdown(); + Input.Player.Ping.performed += context => CurrentEntity.Command.Ping(); + // TODO: SFX: Success/Fail + Input.Player.ToggleHeatsinks.performed += context => CurrentEntity.Command.ToggleHeatsinks(); + // TODO: SFX: Success/Fail + Input.Player.ToggleShield.performed += context => CurrentEntity.Command.ToggleShield(); // Flips the player's own stance on the current target between hostile and neutral. Going // neutral mid-fight safes weapons (Weapon.StanceAllowsFire); an unmarked/undetected target - // reads as non-hostile here, so the first press declares hostile. + // reads as non-hostile, so the first press declares hostile. The flip reads the stance when applied, so two + // presses in a step cancel. Input.Player.ToggleStance.performed += context => { var target = CurrentEntity.Target.Value.Entity; if (target == null) return; - CurrentEntity.Command.Iff(target, !CurrentEntity.IsHostileTo(target)); + CurrentEntity.Command.ToggleStance(target); }; #region Targeting @@ -483,7 +467,7 @@ public class ActionGameManager : MonoBehaviour switch (dragAction) { case EquippedItemDragObject equippedItemDragAction: - var trigger = equippedItemDragAction.EquippedItem.GetBehavior(); + var trigger = equippedItemDragAction.EquippedItem.GetBehavior(); if (trigger == null) return false; slot.Binding = new ActionBarGearBinding(CurrentEntity, slot, equippedItemDragAction.EquippedItem, trigger); return true; diff --git a/Assets/Scripts/ServerShared/Behaviors/Behaviors.cs b/Assets/Scripts/ServerShared/Behaviors/Behaviors.cs index 0cbe11186..7150e47a1 100644 --- a/Assets/Scripts/ServerShared/Behaviors/Behaviors.cs +++ b/Assets/Scripts/ServerShared/Behaviors/Behaviors.cs @@ -87,10 +87,15 @@ public abstract class Behavior } } -public interface IActivatedBehavior +// A behaviour its pilot triggers. Only PilotApplier pulls and releases it, from the pilot's PilotCommand (Pull, Release), +// so Activate and Deactivate are internal to the simulation: no script outside it triggers one in place. +public abstract class ActivatedBehavior : Behavior { - void Activate(); - void Deactivate(); + protected ActivatedBehavior(BehaviorData data, EquippedItem item) : base(data, item) { } + protected ActivatedBehavior(BehaviorData data, ConsumableItemEffect consumable) : base(data, consumable) { } + + internal abstract void Activate(); + internal abstract void Deactivate(); } public interface IEventBehavior diff --git a/Assets/Scripts/ServerShared/Behaviors/ChargedWeapon.cs b/Assets/Scripts/ServerShared/Behaviors/ChargedWeapon.cs index 8d048083d..4d2bb5647 100644 --- a/Assets/Scripts/ServerShared/Behaviors/ChargedWeapon.cs +++ b/Assets/Scripts/ServerShared/Behaviors/ChargedWeapon.cs @@ -167,7 +167,7 @@ public class ChargedWeapon : InstantWeapon return base.Execute(dt); } - public override void Activate() + internal override void Activate() { // CanFire also holds a weapon whose released charge has a burst pending: its cooldown starts at its first round, // so a charge started before then would overlap it. @@ -180,7 +180,7 @@ public class ChargedWeapon : InstantWeapon } } - public override void Deactivate() + internal override void Deactivate() { if (_charging) { diff --git a/Assets/Scripts/ServerShared/Behaviors/ConstantWeapon.cs b/Assets/Scripts/ServerShared/Behaviors/ConstantWeapon.cs index cc0015dbe..a2fc9f802 100644 --- a/Assets/Scripts/ServerShared/Behaviors/ConstantWeapon.cs +++ b/Assets/Scripts/ServerShared/Behaviors/ConstantWeapon.cs @@ -198,7 +198,7 @@ public class ConstantWeapon : Weapon, IProgressBehavior, IEventBehavior, IPowerC return true; } - public override void Activate() + internal override void Activate() { // A refused beam never starts (operator ruling 2026-09-30): OnStartFiring drives the beam's visuals and // audio, so starting it only for Execute to stop it a tick later would flash a beam that fires nothing. @@ -209,7 +209,7 @@ public class ConstantWeapon : Weapon, IProgressBehavior, IEventBehavior, IPowerC } } - public override void Deactivate() + internal override void Deactivate() { if (_firing) { diff --git a/Assets/Scripts/ServerShared/Behaviors/InstantWeapon.cs b/Assets/Scripts/ServerShared/Behaviors/InstantWeapon.cs index 043df920c..a6b3dcd2f 100644 --- a/Assets/Scripts/ServerShared/Behaviors/InstantWeapon.cs +++ b/Assets/Scripts/ServerShared/Behaviors/InstantWeapon.cs @@ -286,7 +286,7 @@ public class InstantWeapon : Weapon, IProgressBehavior, IEventBehavior, IPowerCo return true; } - public override void Activate() + internal override void Activate() { if(CanFire) Trigger(); diff --git a/Assets/Scripts/ServerShared/Behaviors/Sensor.cs b/Assets/Scripts/ServerShared/Behaviors/Sensor.cs index cf9d161f0..d98437e59 100644 --- a/Assets/Scripts/ServerShared/Behaviors/Sensor.cs +++ b/Assets/Scripts/ServerShared/Behaviors/Sensor.cs @@ -136,7 +136,7 @@ public class Sensor : Behavior, IEventBehavior, IPowerConsumer // Cut 5 (docs/stats-and-power-cut.md §1.3, PowerTiers.cs): Utility -- everything else. public int DefaultPowerTier => PowerTiers.Utility; - public void Ping() + internal void Ping() { // Whole-or-nothing against the ping's own input capacitor -- fires only at full charge (operator // ruling: no fraction of a shot). A consumable-hosted instance (Item == null) has no PowerBus entry, so diff --git a/Assets/Scripts/ServerShared/Behaviors/Switch.cs b/Assets/Scripts/ServerShared/Behaviors/Switch.cs index 47990c4b2..dc5711e2f 100644 --- a/Assets/Scripts/ServerShared/Behaviors/Switch.cs +++ b/Assets/Scripts/ServerShared/Behaviors/Switch.cs @@ -20,11 +20,11 @@ public class SwitchData : BehaviorData } } -public class Switch : Behavior, IActivatedBehavior +public class Switch : ActivatedBehavior { private SwitchData _data; - public bool Activated { get; set; } + public bool Activated { get; private set; } public Switch(SwitchData data, EquippedItem item) : base(data, item) { @@ -40,12 +40,12 @@ public class Switch : Behavior, IActivatedBehavior return Activated; } - public void Activate() + internal override void Activate() { Activated = true; } - public void Deactivate() + internal override void Deactivate() { Activated = false; } diff --git a/Assets/Scripts/ServerShared/Behaviors/Trigger.cs b/Assets/Scripts/ServerShared/Behaviors/Trigger.cs index be7c9b9dc..ddc58ada9 100644 --- a/Assets/Scripts/ServerShared/Behaviors/Trigger.cs +++ b/Assets/Scripts/ServerShared/Behaviors/Trigger.cs @@ -20,9 +20,9 @@ public class TriggerData : BehaviorData } } -public class Trigger : Behavior, IActivatedBehavior +public class Trigger : ActivatedBehavior { - public bool _pulled; + private bool _pulled; public Trigger(TriggerData data, EquippedItem item) : base(data, item) { } public Trigger(TriggerData data, ConsumableItemEffect item) : base(data, item) { } @@ -38,12 +38,12 @@ public class Trigger : Behavior, IActivatedBehavior return false; } - public void Activate() + internal override void Activate() { _pulled = true; } - public void Deactivate() + internal override void Deactivate() { } } \ No newline at end of file diff --git a/Assets/Scripts/ServerShared/Behaviors/Weapon.cs b/Assets/Scripts/ServerShared/Behaviors/Weapon.cs index 6a01f81ca..12166d46c 100644 --- a/Assets/Scripts/ServerShared/Behaviors/Weapon.cs +++ b/Assets/Scripts/ServerShared/Behaviors/Weapon.cs @@ -100,7 +100,7 @@ public abstract class WeaponData : BehaviorData public float RatedRange => Range.Max; } -public abstract class Weapon : Behavior, IActivatedBehavior +public abstract class Weapon : ActivatedBehavior { private WeaponData _data; @@ -179,12 +179,12 @@ public abstract class Weapon : Behavior, IActivatedBehavior return true; } - public virtual void Activate() + internal override void Activate() { _firing = true; } - public virtual void Deactivate() + internal override void Deactivate() { _firing = false; } diff --git a/Assets/Scripts/ServerShared/Entity.cs b/Assets/Scripts/ServerShared/Entity.cs index 42b30cf4f..6b1eab8c9 100644 --- a/Assets/Scripts/ServerShared/Entity.cs +++ b/Assets/Scripts/ServerShared/Entity.cs @@ -173,7 +173,7 @@ public abstract class Entity public bool HeatsinksEnabled { get => _heatsinksEnabled; - set + internal set { if (value == _heatsinksEnabled) return; _heatsinksEnabled = value; @@ -186,7 +186,7 @@ public abstract class Entity public EntitySettings Settings { get; } - public bool OverrideShutdown { get; set; } + public bool OverrideShutdown { get; internal set; } // The pilot's tractor command (0..1); the Pickup behaviour spins its beam toward it. public float TractorCommand { get; internal set; } @@ -2170,7 +2170,7 @@ public class EquippedItem : IStatContext public ReactiveProperty ThermalOnline { get; } = new ReactiveProperty(false); public ReactiveProperty DurabilityOnline { get; } = new ReactiveProperty(false); public ReadOnlyReactiveProperty Online { get; } - public ReactiveProperty Enabled { get; } = new ReactiveProperty(true); + internal ReactiveProperty Enabled { get; } = new ReactiveProperty(true); public ReadOnlyReactiveProperty Active { get; } public ItemManager ItemManager { get; } diff --git a/Assets/Scripts/ServerShared/Pilot.cs b/Assets/Scripts/ServerShared/Pilot.cs index be1e9cd7e..576191b7d 100644 --- a/Assets/Scripts/ServerShared/Pilot.cs +++ b/Assets/Scripts/ServerShared/Pilot.cs @@ -53,27 +53,35 @@ public sealed class PilotCommand internal readonly List Selections = new List(); internal readonly List Hails = new List(); internal readonly List Uses = new List(); - internal readonly List<(IActivatedBehavior Behavior, bool Pull)> Triggers = new List<(IActivatedBehavior, bool)>(); - internal readonly List<(Entity Other, bool? Hostile)> Iffs = new List<(Entity, bool?)>(); + internal readonly List<(ActivatedBehavior Behavior, bool Pull)> Triggers = new List<(ActivatedBehavior, bool)>(); + internal readonly List<(Entity Other, bool? Hostile, bool Toggle)> Iffs = new List<(Entity, bool?, bool)>(); internal TargetVerb TargetVerb; internal int AimCycles; + internal int ShutdownOverrideToggles, HeatsinkToggles, ShieldToggles, Pings; public void Designate(TargetRef target) => Designations.Add(target); public void SelectItem(EquippedItem item) => Selections.Add(item); public void Hail(string message) => Hails.Add(message); public void Use(ConsumableItemData consumable) => Uses.Add(consumable); - public void Pull(IActivatedBehavior behavior) => Triggers.Add((behavior, true)); - public void Release(IActivatedBehavior behavior) => Triggers.Add((behavior, false)); + public void Pull(ActivatedBehavior behavior) => Triggers.Add((behavior, true)); + public void Release(ActivatedBehavior behavior) => Triggers.Add((behavior, false)); // The last verb in a step wins. public void Verb(TargetVerb verb) => TargetVerb = verb; public void CycleAim() => AimCycles++; - public void Iff(Entity other, bool? hostile) => Iffs.Add((other, hostile)); + public void Iff(Entity other, bool? hostile) => Iffs.Add((other, hostile, false)); + // A press that flips the stance on `other` as it stands when applied, in order with Iff, so two in a step cancel. + public void ToggleStance(Entity other) => Iffs.Add((other, null, true)); + // The entity's own switches, pressed: each press flips its switch when applied, so two in a step cancel. + public void ToggleOverrideShutdown() => ShutdownOverrideToggles++; + public void ToggleHeatsinks() => HeatsinkToggles++; + public void ToggleShield() => ShieldToggles++; + public void Ping() => Pings++; // The pilot's own pending decisions, which it reads back instead of the ship (that is last step's applied state). public TargetRef? Designated => Designations.Count > 0 ? Designations[Designations.Count - 1] : (TargetRef?) null; // Whether the last trigger this pilot commanded for `behavior` this step is a pull. - public bool Pulls(IActivatedBehavior behavior) + public bool Pulls(ActivatedBehavior behavior) { for (var i = Triggers.Count - 1; i >= 0; i--) if (ReferenceEquals(Triggers[i].Behavior, behavior)) return Triggers[i].Pull; @@ -90,6 +98,7 @@ public sealed class PilotCommand Iffs.Clear(); TargetVerb = TargetVerb.None; AimCycles = 0; + ShutdownOverrideToggles = HeatsinkToggles = ShieldToggles = Pings = 0; } } @@ -112,7 +121,12 @@ public static class PilotApplier } foreach (var item in command.Selections) entity.TrySelectTargetItem(item); for (var i = 0; i < command.AimCycles; i++) entity.CycleAim(); - foreach (var (other, hostile) in command.Iffs) entity.SetIff(other, hostile); + foreach (var (other, hostile, toggle) in command.Iffs) entity.SetIff(other, toggle ? !entity.IsHostileTo(other) : hostile); + for (var i = 0; i < command.ShutdownOverrideToggles; i++) entity.OverrideShutdown = !entity.OverrideShutdown; + for (var i = 0; i < command.HeatsinkToggles; i++) entity.HeatsinksEnabled = !entity.HeatsinksEnabled; + if (entity.Shield != null) + for (var i = 0; i < command.ShieldToggles; i++) entity.Shield.Item.Enabled.Value = !entity.Shield.Item.Enabled.Value; + for (var i = 0; i < command.Pings; i++) entity.Sensor?.Ping(); foreach (var hail in command.Hails) entity.SetMessage(hail); foreach (var consumable in command.Uses) entity.TryActivateConsumable(consumable); if (command.Aim is float3 aim) entity.Aim = aim; diff --git a/docs/headless-playground-cut.md b/docs/headless-playground-cut.md index 8e52bdaf1..bea30aa46 100644 --- a/docs/headless-playground-cut.md +++ b/docs/headless-playground-cut.md @@ -72,7 +72,7 @@ combat state cannot run without Unity.** - Player-only inputs, all plain entity writes that an agent could make: - `TractorPower` (`ActionGameManager.cs:1214-1216`); - `OverrideShutdown`, `HeatsinksEnabled`, shield `Enabled`, `Sensor.Ping` (`:339-362`); - - gear `IActivatedBehavior` (`ActionBarSlot.cs:159-168`); + - gear `ActivatedBehavior` (`ActionBarSlot.cs:159-168`); - consumables (`ActionBarSlot.cs:119-122`, `Entity.TryActivateConsumable`). - Player-only lifecycle actions. These are the orchestration this map moves: - interact, which means warp, dock or undock (`ActionGameManager.cs:298-320`); @@ -1002,7 +1002,7 @@ mean we want to delete all the weapon types we created". | `move ` / `look ` / `tractor <0..1>` | pilot entity fields, as `ActionGameManager.cs:1189-1216` | | `target nearest\|none\|` | `Pilot.Target.Value` | | `fire on\|off` | `WeaponGroups[g].weapons` Activate/Deactivate, as `ActionBarSlot.cs:190-204` | -| `gear on\|off` | `IActivatedBehavior` Activate/Deactivate | +| `gear on\|off` | `ActivatedBehavior` Activate/Deactivate | | `pilot manual\|minion\|patrol\|goto station \|goto wormhole ` | `Run.PilotAgent` | | `dock` / `undock` / `board ` / `warp` / `tow` | `Run.TryDock` / `Undock` / `Board` / `TryEnterWormhole` / `Tow` | | `hit [hardpoint ]` | `Entity.ApplyHit` (Cut 2) | diff --git a/tests/Aetheria.Shared.Tests/PilotSeamPlayerTests.cs b/tests/Aetheria.Shared.Tests/PilotSeamPlayerTests.cs index e656c1212..afb95836f 100644 --- a/tests/Aetheria.Shared.Tests/PilotSeamPlayerTests.cs +++ b/tests/Aetheria.Shared.Tests/PilotSeamPlayerTests.cs @@ -287,11 +287,12 @@ public sealed partial class RunStartTests } // A trigger that counts its edges: a semi-auto fires once per Activate. - private sealed class SeamTrigger : IActivatedBehavior + private sealed class SeamTrigger : ActivatedBehavior { public int Pulled, Released; - public void Activate() => Pulled++; - public void Deactivate() => Released++; + public SeamTrigger(EquippedItem item) : base(null, item) { } + internal override void Activate() => Pulled++; + internal override void Deactivate() => Released++; } // A press is one pull at the next step and the button held after it pulls nothing more, so a semi-auto fires once; @@ -300,7 +301,7 @@ public sealed partial class RunStartTests public void PlayerTriggerIsAnEdge() { var (arena, east, _) = SeamPair(false); - var trigger = new SeamTrigger(); + var trigger = new SeamTrigger(SeamGun(east).Item); east.Command.Pull(trigger); Assert.Equal(0, trigger.Pulled); for (var step = 0; step < 10; step++) arena.Update(SeamDt); From 72ad1b8036d2df3eb3483b25f341587abaf02123 Mon Sep 17 00:00:00 2001 From: soul Date: Thu, 22 Oct 2026 05:07:25 +0200 Subject: [PATCH 154/444] Command seam fixes: the shield switch is pinned on a shielded target; the Longinus has none Co-Authored-By: Claude Opus 5.5 --- .../PilotSeamPlayerTests.cs | 48 +++++++++++++++---- 1 file changed, 38 insertions(+), 10 deletions(-) diff --git a/tests/Aetheria.Shared.Tests/PilotSeamPlayerTests.cs b/tests/Aetheria.Shared.Tests/PilotSeamPlayerTests.cs index afb95836f..b132a6691 100644 --- a/tests/Aetheria.Shared.Tests/PilotSeamPlayerTests.cs +++ b/tests/Aetheria.Shared.Tests/PilotSeamPlayerTests.cs @@ -246,33 +246,32 @@ public sealed partial class RunStartTests } // The player's switches are presses on its command too: each flips at the next step, two in a step cancel, and none is - // replayed. A ping is a press that pings once. + // replayed. A ping is a press that pings once. (The Longinus has no shield: the shield switch is pinned on a shielded + // target in FireControlCut124Tests below, and here its press is a no-op.) [Fact] public void PlayerSwitchesArePressesAtTheStep() { var (arena, east, _) = SeamPair(false); - Assert.NotNull(east.Shield); + Assert.Null(east.Shield); Assert.NotNull(east.Sensor); - (bool, bool, bool) Switches() => (east.OverrideShutdown, east.HeatsinksEnabled, east.Shield.Item.Enabled.Value); - Assert.Equal((false, true, true), Switches()); + (bool, bool) Switches() => (east.OverrideShutdown, east.HeatsinksEnabled); + Assert.Equal((false, true), Switches()); east.Command.ToggleOverrideShutdown(); east.Command.ToggleHeatsinks(); east.Command.ToggleShield(); - Assert.Equal((false, true, true), Switches()); + Assert.Equal((false, true), Switches()); arena.Update(SeamDt); - Assert.Equal((true, false, false), Switches()); + Assert.Equal((true, false), Switches()); arena.Update(SeamDt); - Assert.Equal((true, false, false), Switches()); + Assert.Equal((true, false), Switches()); east.Command.ToggleOverrideShutdown(); east.Command.ToggleOverrideShutdown(); east.Command.ToggleHeatsinks(); east.Command.ToggleHeatsinks(); - east.Command.ToggleShield(); - east.Command.ToggleShield(); arena.Update(SeamDt); - Assert.Equal((true, false, false), Switches()); + Assert.Equal((true, false), Switches()); var pings = 0; east.Sensor.OnPingStart += () => pings++; @@ -328,3 +327,32 @@ public sealed partial class RunStartTests Assert.Equal(.25f, east.TractorCommand); } } + +// The shield switch on the seam (aetheria-arena, seam fixes): the player's shield press is a PilotCommand press, applied at +// the step like the other switches. Here because this class builds a shielded target. +public sealed partial class FireControlCut124Tests +{ + [Fact] + public void TheShieldSwitchIsAPressAtTheStep() + { + var e = Build(TestSettings(), SolidShape(21, 3), equipShield: true); + var shield = e.Target.Shield; + Assert.NotNull(shield); + Assert.True(shield.Item.Enabled.Value); + + e.Target.Command.ToggleShield(); + Assert.True(shield.Item.Enabled.Value, "nothing lands before the step"); + e.Zone.Update(.1f); + Assert.False(shield.Item.Enabled.Value); + e.Zone.Update(.1f); + Assert.False(shield.Item.Enabled.Value, "the press is not replayed"); + + e.Target.Command.ToggleShield(); + e.Target.Command.ToggleShield(); + e.Zone.Update(.1f); + Assert.False(shield.Item.Enabled.Value, "two presses in a step cancel"); + e.Target.Command.ToggleShield(); + e.Zone.Update(.1f); + Assert.True(shield.Item.Enabled.Value); + } +} From a7477e679dd8705a2796beba992726331e3ba74c Mon Sep 17 00:00:00 2001 From: soul Date: Thu, 22 Oct 2026 05:51:10 +0200 Subject: [PATCH 155/444] Command seam fixes: the ping pin runs before any switch is thrown; a stance set is pinned as a set beside the flips Co-Authored-By: Claude Opus 5.5 --- .../PilotSeamPlayerTests.cs | 25 +++++++++++-------- 1 file changed, 14 insertions(+), 11 deletions(-) diff --git a/tests/Aetheria.Shared.Tests/PilotSeamPlayerTests.cs b/tests/Aetheria.Shared.Tests/PilotSeamPlayerTests.cs index b132a6691..1302950c9 100644 --- a/tests/Aetheria.Shared.Tests/PilotSeamPlayerTests.cs +++ b/tests/Aetheria.Shared.Tests/PilotSeamPlayerTests.cs @@ -232,6 +232,9 @@ public sealed partial class RunStartTests Assert.False(east.IsHostileTo(west), "nothing lands before the step"); arena.Update(SeamDt); Assert.True(east.IsHostileTo(west)); + east.Command.Iff(west, true); + arena.Update(SeamDt); + Assert.True(east.IsHostileTo(west), "a stance set is a set, not a flip"); east.Command.ToggleStance(west); east.Command.ToggleStance(west); @@ -254,6 +257,17 @@ public sealed partial class RunStartTests var (arena, east, _) = SeamPair(false); Assert.Null(east.Shield); Assert.NotNull(east.Sensor); + var pings = 0; + east.Sensor.OnPingStart += () => pings++; + for (var step = 0; step < 600; step++) arena.Update(SeamDt); // the ping's capacitor fills, before any switch is thrown + Assert.Equal(0, pings); + east.Command.Ping(); + Assert.Equal(0, pings); + arena.Update(SeamDt); + Assert.Equal(1, pings); + for (var step = 0; step < 10; step++) arena.Update(SeamDt); + Assert.Equal(1, pings); + (bool, bool) Switches() => (east.OverrideShutdown, east.HeatsinksEnabled); Assert.Equal((false, true), Switches()); @@ -272,17 +286,6 @@ public sealed partial class RunStartTests east.Command.ToggleHeatsinks(); arena.Update(SeamDt); Assert.Equal((true, false), Switches()); - - var pings = 0; - east.Sensor.OnPingStart += () => pings++; - for (var step = 0; step < 600; step++) arena.Update(SeamDt); // the ping's capacitor fills - Assert.Equal(0, pings); - east.Command.Ping(); - Assert.Equal(0, pings); - arena.Update(SeamDt); - Assert.Equal(1, pings); - for (var step = 0; step < 10; step++) arena.Update(SeamDt); - Assert.Equal(1, pings); } // A trigger that counts its edges: a semi-auto fires once per Activate. From 87f29cb0c8d87803439d120e59168ebdbea70b76 Mon Sep 17 00:00:00 2001 From: soul Date: Thu, 22 Oct 2026 06:18:32 +0200 Subject: [PATCH 156/444] Command seam fixes: the ping pin waits for the ping's cooldown and charge Co-Authored-By: Claude Opus 5.5 --- tests/Aetheria.Shared.Tests/PilotSeamPlayerTests.cs | 8 ++++++-- 1 file changed, 6 insertions(+), 2 deletions(-) diff --git a/tests/Aetheria.Shared.Tests/PilotSeamPlayerTests.cs b/tests/Aetheria.Shared.Tests/PilotSeamPlayerTests.cs index 1302950c9..9f8820338 100644 --- a/tests/Aetheria.Shared.Tests/PilotSeamPlayerTests.cs +++ b/tests/Aetheria.Shared.Tests/PilotSeamPlayerTests.cs @@ -259,12 +259,16 @@ public sealed partial class RunStartTests Assert.NotNull(east.Sensor); var pings = 0; east.Sensor.OnPingStart += () => pings++; - for (var step = 0; step < 600; step++) arena.Update(SeamDt); // the ping's capacitor fills, before any switch is thrown + // The ping's cooldown runs out and its capacitor fills (over about as long again), before any switch is thrown. + var settle = 0; + for (; settle < 36000 && east.Sensor.Cooldown > 0; settle++) arena.Update(SeamDt); + Assert.True(east.Sensor.Cooldown <= 0, $"fixture: the ping cools down ({east.Sensor.Cooldown} after {settle} steps)"); + for (var step = 0; step < settle + 600; step++) arena.Update(SeamDt); Assert.Equal(0, pings); east.Command.Ping(); Assert.Equal(0, pings); arena.Update(SeamDt); - Assert.Equal(1, pings); + Assert.True(pings == 1, $"one ping at the step (cooldown {east.Sensor.Cooldown}, settled {settle} steps)"); for (var step = 0; step < 10; step++) arena.Update(SeamDt); Assert.Equal(1, pings); From cfbd044551f545ec3fc7619df56a2d406dbcf08b Mon Sep 17 00:00:00 2001 From: soul Date: Thu, 22 Oct 2026 06:29:02 +0200 Subject: [PATCH 157/444] Command seam fixes: drop the ping observation, which this fixture's uncharged sensor cannot show Co-Authored-By: Claude Opus 5.5 --- .../PilotSeamPlayerTests.cs | 17 +---------------- 1 file changed, 1 insertion(+), 16 deletions(-) diff --git a/tests/Aetheria.Shared.Tests/PilotSeamPlayerTests.cs b/tests/Aetheria.Shared.Tests/PilotSeamPlayerTests.cs index 9f8820338..7dfb2d339 100644 --- a/tests/Aetheria.Shared.Tests/PilotSeamPlayerTests.cs +++ b/tests/Aetheria.Shared.Tests/PilotSeamPlayerTests.cs @@ -249,7 +249,7 @@ public sealed partial class RunStartTests } // The player's switches are presses on its command too: each flips at the next step, two in a step cancel, and none is - // replayed. A ping is a press that pings once. (The Longinus has no shield: the shield switch is pinned on a shielded + // replayed. (The Longinus has no shield: the shield switch is pinned on a shielded // target in FireControlCut124Tests below, and here its press is a no-op.) [Fact] public void PlayerSwitchesArePressesAtTheStep() @@ -257,21 +257,6 @@ public sealed partial class RunStartTests var (arena, east, _) = SeamPair(false); Assert.Null(east.Shield); Assert.NotNull(east.Sensor); - var pings = 0; - east.Sensor.OnPingStart += () => pings++; - // The ping's cooldown runs out and its capacitor fills (over about as long again), before any switch is thrown. - var settle = 0; - for (; settle < 36000 && east.Sensor.Cooldown > 0; settle++) arena.Update(SeamDt); - Assert.True(east.Sensor.Cooldown <= 0, $"fixture: the ping cools down ({east.Sensor.Cooldown} after {settle} steps)"); - for (var step = 0; step < settle + 600; step++) arena.Update(SeamDt); - Assert.Equal(0, pings); - east.Command.Ping(); - Assert.Equal(0, pings); - arena.Update(SeamDt); - Assert.True(pings == 1, $"one ping at the step (cooldown {east.Sensor.Cooldown}, settled {settle} steps)"); - for (var step = 0; step < 10; step++) arena.Update(SeamDt); - Assert.Equal(1, pings); - (bool, bool) Switches() => (east.OverrideShutdown, east.HeatsinksEnabled); Assert.Equal((false, true), Switches()); From 2f15c79176e126607a2a99da9256fd7597abdeb3 Mon Sep 17 00:00:00 2001 From: soul Date: Thu, 22 Oct 2026 08:39:15 +0200 Subject: [PATCH 158/444] Seam fix tests: the stance press resolves its target at the step, flips the belief, and the ping press pings once (red) Co-Authored-By: Claude Opus 5.5 --- .../ContactRevealTests.cs | 17 ++++++++++ .../IffAndCombatTests.cs | 17 ++++++++++ .../PilotSeamPlayerTests.cs | 31 ++++++++++++++----- 3 files changed, 57 insertions(+), 8 deletions(-) diff --git a/tests/Aetheria.Shared.Tests/ContactRevealTests.cs b/tests/Aetheria.Shared.Tests/ContactRevealTests.cs index d23f166ea..1a8df874c 100644 --- a/tests/Aetheria.Shared.Tests/ContactRevealTests.cs +++ b/tests/Aetheria.Shared.Tests/ContactRevealTests.cs @@ -587,6 +587,23 @@ public sealed partial class ContactRevealTests : IDisposable return (light.ToArray(), Track(o, p)); } + // The pilot's ping is a press on its command: the sensor pings at the next step, once, and the press is not replayed. + [Fact] + public void APingPressPingsOnceAtTheStep() + { + var p = Add(float3(0, 0, 1000), new[] { "Eye" }); + var sensor = p.Equipment.SelectMany(i => i.Behaviors).OfType().Single(); + var pings = 0; + sensor.OnPingStart += () => pings++; + Tick(.1f); // a sensor's first tick opens its ping cooldown; Ping() is refused before it + p.Command.Ping(); + Assert.Equal(0, pings); // nothing lands before the step + Tick(.1f); + Assert.Equal(1, pings); + for (var tick = 0; tick < 60; tick++) Tick(.1f); // past the cooldown, so a replayed press would ping again + Assert.Equal(1, pings); + } + // Two items revealed at different times: the contact's bearing, while untracked, is the freshest reveal's. [Fact] public void TheFreshestRevealGivesTheBearing() diff --git a/tests/Aetheria.Shared.Tests/IffAndCombatTests.cs b/tests/Aetheria.Shared.Tests/IffAndCombatTests.cs index c9e184fa8..b2a01e356 100644 --- a/tests/Aetheria.Shared.Tests/IffAndCombatTests.cs +++ b/tests/Aetheria.Shared.Tests/IffAndCombatTests.cs @@ -284,6 +284,23 @@ public sealed class IffAndCombatTests : IDisposable Assert.DoesNotContain(stranger, patrol.VisibleFriendlies); } + // A stance press flips the stance as it stands, the override or else the belief: against a contact believed hostile with + // no override held, it declares the contact neutral. It lands on a target designated in the same step. + [Fact] + public void AStancePressFlipsTheBelief() + { + var (_, zone, patrol, stranger) = Owned(new Faction { Name = "X" }); + Detect(patrol, stranger); + Assert.True(patrol.IsHostileTo(stranger), "fixture: hostile by belief"); + Assert.Null(patrol.IffOverride(stranger)); // fixture: no override, which alone would read neutral + + patrol.Command.Designate(stranger); + patrol.Command.ToggleStance(); + zone.Update(.1f); + Assert.False(patrol.IffOverride(stranger)); + Assert.False(patrol.IsHostileTo(stranger)); + } + [Fact] public void AlliesAreKnownAsTruth() { diff --git a/tests/Aetheria.Shared.Tests/PilotSeamPlayerTests.cs b/tests/Aetheria.Shared.Tests/PilotSeamPlayerTests.cs index 7dfb2d339..c4f8227ca 100644 --- a/tests/Aetheria.Shared.Tests/PilotSeamPlayerTests.cs +++ b/tests/Aetheria.Shared.Tests/PilotSeamPlayerTests.cs @@ -221,27 +221,42 @@ public sealed partial class RunStartTests SeamAimEqual(selectThenCycle, east.ResolvedAim); } - // A stance press flips the stance as it stands when the press is applied, so two in a step cancel, as two flips in - // place did before the seam; none is replayed. + // A stance press flips the stance on the target as it stands when the press is applied, after the step's target verbs, + // so two in a step cancel, as two flips in place did before the seam; none is replayed. [Fact] public void StanceTogglesInAStepCompose() { - var (arena, east, west, _) = PlayerSeamTrio(); + var (arena, east, west, third) = PlayerSeamTrio(); + Assert.True(east.Target.Value.IsNone); Assert.False(east.IsHostileTo(west)); - east.Command.ToggleStance(west); + // No target is applied yet: the verb in the same step picks the nearest (west), and the press flips it. + east.Command.Verb(TargetVerb.Next); + east.Command.ToggleStance(); Assert.False(east.IsHostileTo(west), "nothing lands before the step"); arena.Update(SeamDt); - Assert.True(east.IsHostileTo(west)); + Assert.Same(west, east.Target.Value.Entity); + Assert.True(east.IsHostileTo(west), "the press lands on the target the step's verb picked"); + Assert.False(east.IsHostileTo(third)); east.Command.Iff(west, true); arena.Update(SeamDt); Assert.True(east.IsHostileTo(west), "a stance set is a set, not a flip"); - east.Command.ToggleStance(west); - east.Command.ToggleStance(west); + east.Command.ToggleStance(); + east.Command.ToggleStance(); arena.Update(SeamDt); Assert.True(east.IsHostileTo(west), "two flips in a step cancel"); - east.Command.ToggleStance(west); + // Next then the press in one step: the press flips the new target, not the one applied when it was pressed. + east.Command.Verb(TargetVerb.Next); + east.Command.ToggleStance(); + arena.Update(SeamDt); + Assert.Same(third, east.Target.Value.Entity); + Assert.True(east.IsHostileTo(third), "the press flips the new target"); + Assert.True(east.IsHostileTo(west), "the old target is untouched"); + east.Command.Designate(new TargetRef(west)); + arena.Update(SeamDt); + + east.Command.ToggleStance(); arena.Update(SeamDt); Assert.False(east.IsHostileTo(west)); arena.Update(SeamDt); From 8692cf8600527e8ed9d265ae42566a741f560fb1 Mon Sep 17 00:00:00 2001 From: soul Date: Thu, 22 Oct 2026 08:39:46 +0200 Subject: [PATCH 159/444] Command seam: the stance press resolves its target at the step, after the step's target verbs Co-Authored-By: Claude Opus 5.5 --- Assets/Scripts/Gameplay/ActionGameManager.cs | 11 +++-------- Assets/Scripts/ServerShared/Pilot.cs | 11 ++++++++--- 2 files changed, 11 insertions(+), 11 deletions(-) diff --git a/Assets/Scripts/Gameplay/ActionGameManager.cs b/Assets/Scripts/Gameplay/ActionGameManager.cs index 6fa564d92..7917b0f7d 100644 --- a/Assets/Scripts/Gameplay/ActionGameManager.cs +++ b/Assets/Scripts/Gameplay/ActionGameManager.cs @@ -406,14 +406,9 @@ public class ActionGameManager : MonoBehaviour // Flips the player's own stance on the current target between hostile and neutral. Going // neutral mid-fight safes weapons (Weapon.StanceAllowsFire); an unmarked/undetected target - // reads as non-hostile, so the first press declares hostile. The flip reads the stance when applied, so two - // presses in a step cancel. - Input.Player.ToggleStance.performed += context => - { - var target = CurrentEntity.Target.Value.Entity; - if (target == null) return; - CurrentEntity.Command.ToggleStance(target); - }; + // reads as non-hostile, so the first press declares hostile. The flip reads the target and its stance when applied, + // after the step's target presses, so two presses in a step cancel. + Input.Player.ToggleStance.performed += context => CurrentEntity.Command.ToggleStance(); #region Targeting diff --git a/Assets/Scripts/ServerShared/Pilot.cs b/Assets/Scripts/ServerShared/Pilot.cs index 576191b7d..8b04637d9 100644 --- a/Assets/Scripts/ServerShared/Pilot.cs +++ b/Assets/Scripts/ServerShared/Pilot.cs @@ -69,8 +69,9 @@ public sealed class PilotCommand public void Verb(TargetVerb verb) => TargetVerb = verb; public void CycleAim() => AimCycles++; public void Iff(Entity other, bool? hostile) => Iffs.Add((other, hostile, false)); - // A press that flips the stance on `other` as it stands when applied, in order with Iff, so two in a step cancel. - public void ToggleStance(Entity other) => Iffs.Add((other, null, true)); + // A press that flips the stance on the entity's target as both stand when applied, after the step's target verbs and in + // order with Iff, so two in a step cancel. With no target then, it does nothing. + public void ToggleStance() => Iffs.Add((null, null, true)); // The entity's own switches, pressed: each press flips its switch when applied, so two in a step cancel. public void ToggleOverrideShutdown() => ShutdownOverrideToggles++; public void ToggleHeatsinks() => HeatsinkToggles++; @@ -121,7 +122,11 @@ public static class PilotApplier } foreach (var item in command.Selections) entity.TrySelectTargetItem(item); for (var i = 0; i < command.AimCycles; i++) entity.CycleAim(); - foreach (var (other, hostile, toggle) in command.Iffs) entity.SetIff(other, toggle ? !entity.IsHostileTo(other) : hostile); + foreach (var (other, hostile, toggle) in command.Iffs) + { + if (!toggle) entity.SetIff(other, hostile); + else if (entity.Target.Value.Entity is Entity target) entity.SetIff(target, !entity.IsHostileTo(target)); + } for (var i = 0; i < command.ShutdownOverrideToggles; i++) entity.OverrideShutdown = !entity.OverrideShutdown; for (var i = 0; i < command.HeatsinkToggles; i++) entity.HeatsinksEnabled = !entity.HeatsinksEnabled; if (entity.Shield != null) From 33103ce5daaa81bce1461134168311de6fee76c4 Mon Sep 17 00:00:00 2001 From: soul Date: Thu, 22 Oct 2026 08:44:28 +0200 Subject: [PATCH 160/444] Seam fix tests: a neutral declaration drops the target, so a second press in that step finds none, as before the seam Co-Authored-By: Claude Opus 5.5 --- .../PilotSeamPlayerTests.cs | 32 +++++++++++++------ 1 file changed, 23 insertions(+), 9 deletions(-) diff --git a/tests/Aetheria.Shared.Tests/PilotSeamPlayerTests.cs b/tests/Aetheria.Shared.Tests/PilotSeamPlayerTests.cs index c4f8227ca..7eafcb50b 100644 --- a/tests/Aetheria.Shared.Tests/PilotSeamPlayerTests.cs +++ b/tests/Aetheria.Shared.Tests/PilotSeamPlayerTests.cs @@ -241,11 +241,6 @@ public sealed partial class RunStartTests arena.Update(SeamDt); Assert.True(east.IsHostileTo(west), "a stance set is a set, not a flip"); - east.Command.ToggleStance(); - east.Command.ToggleStance(); - arena.Update(SeamDt); - Assert.True(east.IsHostileTo(west), "two flips in a step cancel"); - // Next then the press in one step: the press flips the new target, not the one applied when it was pressed. east.Command.Verb(TargetVerb.Next); east.Command.ToggleStance(); @@ -253,14 +248,33 @@ public sealed partial class RunStartTests Assert.Same(third, east.Target.Value.Entity); Assert.True(east.IsHostileTo(third), "the press flips the new target"); Assert.True(east.IsHostileTo(west), "the old target is untouched"); - east.Command.Designate(new TargetRef(west)); - arena.Update(SeamDt); + // Declaring the target neutral drops it (it leaves VisibleEnemies), so a second press in that step finds no + // target, as a second press in place did before the seam. + east.Command.ToggleStance(); east.Command.ToggleStance(); arena.Update(SeamDt); - Assert.False(east.IsHostileTo(west)); + Assert.False(east.IsHostileTo(third)); + Assert.True(east.Target.Value.IsNone); + + // From neutral, two flips in a step cancel. + east.Command.Iff(west, false); arena.Update(SeamDt); - Assert.False(east.IsHostileTo(west), "the press is not replayed"); + east.Command.Designate(new TargetRef(west)); + arena.Update(SeamDt); + Assert.Same(west, east.Target.Value.Entity); + east.Command.ToggleStance(); + east.Command.ToggleStance(); + arena.Update(SeamDt); + Assert.False(east.IsHostileTo(west), "two flips in a step cancel"); + Assert.Equal(false, east.IffOverride(west)); + + east.Command.Designate(new TargetRef(west)); + east.Command.ToggleStance(); + arena.Update(SeamDt); + Assert.True(east.IsHostileTo(west)); + arena.Update(SeamDt); + Assert.True(east.IsHostileTo(west), "the press is not replayed"); } // The player's switches are presses on its command too: each flips at the next step, two in a step cancel, and none is From 9b4b46e2430da9ff3580927464f042c73a5f4b66 Mon Sep 17 00:00:00 2001 From: soul Date: Thu, 22 Oct 2026 15:39:02 +0200 Subject: [PATCH 161/444] Zone: a side is a faction or one unaffiliated ship; each unaffiliated ship flies a flight of its own (brain-one-per-side-r2) Co-Authored-By: Claude Sonnet 5.5 --- Assets/Scripts/ServerShared/Zone.cs | 13 ++++++++----- tests/Aetheria.Shared.Tests/FlightTests.cs | 10 +++++++--- 2 files changed, 15 insertions(+), 8 deletions(-) diff --git a/Assets/Scripts/ServerShared/Zone.cs b/Assets/Scripts/ServerShared/Zone.cs index f2276f4e4..fe3136464 100644 --- a/Assets/Scripts/ServerShared/Zone.cs +++ b/Assets/Scripts/ServerShared/Zone.cs @@ -69,10 +69,9 @@ public class Zone private ItemManager _itemManager; private double _time; public List Agents = new List(); - // One flight per faction, created on its first piloted join; unaffiliated ships share Unaffiliated. A flight owns - // what its members target and do (Agents/Flight.cs). - private static readonly Faction Unaffiliated = new Faction(); - private readonly Dictionary _flights = new Dictionary(); + // One flight per side, created on its first piloted join. A side is a faction, or one unaffiliated ship + // (brain-one-per-side-r2). A flight owns what its members target and do (Agents/Flight.cs). + private readonly Dictionary _flights = new Dictionary(); public IEnumerable Flights => _flights.Values; // Cut 2 (docs/mining-cut.md): chunk wear, keyed by chunk. Zone.Wear is the only writer; ChunkExists and @@ -177,6 +176,8 @@ public class Zone if (agent == null) return; Agents.Remove(agent); foreach (var flight in _flights.Values) flight.Leave(agent); + // A side of one is gone with its ship. + if (remove.Value.Faction == null) _flights.Remove(remove.Value); }); Targets.Add(new EntityTargets()); @@ -310,9 +311,11 @@ public class Zone return true; } + private static object SideOf(Ship ship) => (object) ship.Faction ?? ship; + private Agent CreateAgent(Ship ship, AgentTask stated) { - var key = ship.Faction ?? Unaffiliated; + var key = SideOf(ship); if (!_flights.TryGetValue(key, out var flight)) _flights[key] = flight = new Flight(ship.Faction?.Doctrine, _itemManager.GameplaySettings); var id = NextAgentId(); diff --git a/tests/Aetheria.Shared.Tests/FlightTests.cs b/tests/Aetheria.Shared.Tests/FlightTests.cs index 7bab33174..9f397ebdf 100644 --- a/tests/Aetheria.Shared.Tests/FlightTests.cs +++ b/tests/Aetheria.Shared.Tests/FlightTests.cs @@ -359,14 +359,18 @@ public sealed class FlightTests : IDisposable } [Fact] - public void EachFactionFliesAFlightOfItsOwnAndUnaffiliatedShipsShareOne() + public void EachFactionFliesAFlightOfItsOwnAndEachUnaffiliatedShipFliesAlone() { Pilot(new Faction { Name = "One" }); Pilot(new Faction { Name = "Two" }); Pilot(null); - Pilot(null); + var alone = Pilot(null); - Assert.Equal(new[] { 1, 1, 2 }, _zone.Flights.Select(f => f.Count).OrderBy(c => c)); + Assert.Equal(new[] { 1, 1, 1, 1 }, _zone.Flights.Select(f => f.Count).OrderBy(c => c)); + + _zone.Remove(alone); + + Assert.Equal(3, _zone.Flights.Count()); } [Fact] From 6252c29efae1eef458a09ad0774d40632ce14852 Mon Sep 17 00:00:00 2001 From: soul Date: Thu, 22 Oct 2026 15:42:12 +0200 Subject: [PATCH 162/444] Determinism ledger: repin TwoAiDigestIsPinned for the side split (the fighters were one side and followed themselves) Co-Authored-By: Claude Sonnet 5.5 --- tests/Aetheria.Shared.Tests/DeterminismLedgerTests.cs | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/tests/Aetheria.Shared.Tests/DeterminismLedgerTests.cs b/tests/Aetheria.Shared.Tests/DeterminismLedgerTests.cs index 1a1a64b1e..e3ab326f0 100644 --- a/tests/Aetheria.Shared.Tests/DeterminismLedgerTests.cs +++ b/tests/Aetheria.Shared.Tests/DeterminismLedgerTests.cs @@ -24,7 +24,8 @@ public sealed partial class RunStartTests // The two-AI fight's digest, captured on Yggdrasil. Replays are trusted only on the host class that recorded them // (ruling replays-per-host-class), and arenas run on Linux. private const string TwoAiPinnedHostClass = "linux-x64"; - private const string TwoAiPinnedDigest = "8ba2116ed0ecd574703846f493c408936d536e3b7c9e4652556103ec1b75098c"; + // Repinned once, for the side split: the two fighters are unaffiliated, were one side and each followed itself. + private const string TwoAiPinnedDigest = "1b2ffe51f799e762199717a3bc003e880a80106023463aabc40bd2e4f746697c"; private const int TwoAiSteps = 3600; [Fact] From 2ad52e7e9eae08adf1fffd12e78623a395a1e3f7 Mon Sep 17 00:00:00 2001 From: soul Date: Thu, 22 Oct 2026 15:45:02 +0200 Subject: [PATCH 163/444] PilotView: brains reach other ships only through their side's view, by opaque handles One view per side over its members' sensors and books. Flight, Combat, Follow and Minion read contacts as ContactId and fire-control readouts as view calls; FollowTask.Anchor is a handle. Own-ship reads stay direct. Co-Authored-By: Claude Sonnet 5.5 --- Assets/Scripts/ServerShared/Agents/Agent.cs | 4 + Assets/Scripts/ServerShared/Agents/Flight.cs | 99 +++++----- Assets/Scripts/ServerShared/Agents/Minion.cs | 4 +- .../Scripts/ServerShared/Agents/PilotView.cs | 173 ++++++++++++++++++ .../ServerShared/Agents/PilotView.cs.meta | 11 ++ .../ServerShared/Agents/States/Combat.cs | 40 ++-- .../ServerShared/Agents/States/Follow.cs | 6 +- .../ServerShared/Agents/Tasks/FollowTask.cs | 2 +- Assets/Scripts/ServerShared/ScenarioStage.cs | 2 +- .../ConsumableAiTests.cs | 3 +- tests/Aetheria.Shared.Tests/FlightTests.cs | 36 ++-- 11 files changed, 293 insertions(+), 87 deletions(-) create mode 100644 Assets/Scripts/ServerShared/Agents/PilotView.cs create mode 100644 Assets/Scripts/ServerShared/Agents/PilotView.cs.meta diff --git a/Assets/Scripts/ServerShared/Agents/Agent.cs b/Assets/Scripts/ServerShared/Agents/Agent.cs index eb74818ba..586438c97 100644 --- a/Assets/Scripts/ServerShared/Agents/Agent.cs +++ b/Assets/Scripts/ServerShared/Agents/Agent.cs @@ -23,6 +23,9 @@ public class Agent private VelocityLimit _velocityLimit; public AgentTask Task { get; set; } + // What this pilot's side knows of other ships. A ship outside a flight is a side of one: a view over itself, until + // Flight.Join sets the flight's. + public PilotView View { get; set; } // Zone-scoped, from 1: the ordinal of this pilot's own dice, set by Zone.CreateAgent as it admits the pilot (0 for // an agent built outside a zone's admission). public uint Id { get; set; } @@ -36,6 +39,7 @@ public class Agent public Agent(Ship ship, RoleDoctrine doctrine) { Ship = ship; + View = new PilotView(ship); Doctrine = doctrine; ItemManager = Ship.ItemManager; Settings = Ship.ItemManager.GameplaySettings; diff --git a/Assets/Scripts/ServerShared/Agents/Flight.cs b/Assets/Scripts/ServerShared/Agents/Flight.cs index 497ac865f..098598e92 100644 --- a/Assets/Scripts/ServerShared/Agents/Flight.cs +++ b/Assets/Scripts/ServerShared/Agents/Flight.cs @@ -3,15 +3,17 @@ using System.Collections.Generic; using System.Linq; using CultMath; using static CultMath.math; +using ContactId = PilotView.ContactId; -// One flight per (zone, faction) of piloted ships; unaffiliated ships form one flight of their own. The flight alone +// One flight per (zone, side) of piloted ships; a side is a faction, or one unaffiliated ship. The flight alone // decides what its members do: which hostile each engages (Ship.Target) and what each does otherwise (Agent.Task). // The agent graph executes those two and decides nothing. The faction's doctrine (FactionDoctrine) holds the numbers. // -// The picture is the union of the members' VisibleEnemies. A track passes through phases the flight owns, runtime only: -// None, Hailing (grace running, one hail spoken), Complied (slow enough at the grace's end: followed, not engaged) and -// Engaged. A member still fires only at what its own VisibleEntities hold (FireControl), so the shared picture never -// lets it fire at what it cannot see. +// The flight reads other ships only through its side's PilotView (ruling observation-sensors-only): it holds a contact +// as a handle and never as the ship. The picture is the view's: the union of what the members' sensors hold hostile, never a member. +// A track passes through phases the flight owns, runtime only: None, Hailing (grace running, one hail spoken), Complied +// (slow enough at the grace's end: followed, not engaged) and Engaged. A member still fires only at what its own +// sensors hold (fire control), so the shared picture never lets it fire at what it cannot see. public class Flight { public enum Phase { None, Hailing, Complied, Engaged } @@ -31,7 +33,7 @@ public class Flight private readonly FactionDoctrine _doctrine; private readonly GameplaySettings _settings; private readonly List _members = new List(); - private readonly Dictionary _tracks = new Dictionary(); + private readonly Dictionary _tracks = new Dictionary(); private double _time; // No doctrine fights as FactionDoctrine.Default; a doctrine that leaves a role unauthored gets that role's default. @@ -44,58 +46,64 @@ public class Flight } public RoleDoctrine Combatant { get; } + // What this side knows of other ships; every member's Agent.View. + public PilotView View { get; } = new PilotView(); public int Count => _members.Count; public bool Contains(Agent agent) => _members.Any(m => m.Agent == agent); - public Phase PhaseOf(Entity track) => _tracks.TryGetValue(track, out var t) ? t.Phase : Phase.None; + public Phase PhaseOf(ContactId track) => _tracks.TryGetValue(track, out var t) ? t.Phase : Phase.None; // The task a member holds when the flight has nothing to follow: its patrol circuit, or the task a scenario stated. public void Join(Agent agent, AgentTask baseline) { _members.Add(new Member { Agent = agent, Baseline = baseline }); + View.Add(agent.Ship); + agent.View = View; agent.Task = baseline; } - public void Leave(Agent agent) => _members.RemoveAll(m => m.Agent == agent); + public void Leave(Agent agent) + { + if (_members.RemoveAll(m => m.Agent == agent) > 0) View.Remove(agent.Ship); + } public void Update(float dt) { _time += dt; - var picture = _members.SelectMany(m => m.Agent.Ship.VisibleEnemies).Distinct().ToList(); + var picture = View.Picture(); foreach (var gone in _tracks.Keys.Where(t => !picture.Contains(t)).ToList()) _tracks.Remove(gone); - foreach (var entity in picture) + foreach (var contact in picture) { - if (!_tracks.TryGetValue(entity, out var track)) _tracks[entity] = track = new Track(); - Advance(entity, track); + if (!_tracks.TryGetValue(contact, out var track)) _tracks[contact] = track = new Track(); + Advance(contact, track); } foreach (var member in _members) Order(member, picture); } // A grudge is a hostile IFF override that any member holds on the track. - private bool Grudged(Entity entity) => _members.Any(m => m.Agent.Ship.IffOverride(entity) == true); + private bool Grudged(ContactId contact) => _members.Any(m => View.IffOverride(m.Agent.Ship, contact) == true); - private bool Engageable(Entity entity, bool grudge) + private bool Engageable(ContactId contact, bool grudge) { switch (_doctrine.EngageOn) { case EngageOn.Never: return false; case EngageOn.Detection: return true; case EngageOn.Identified: - return grudge || _members.Any(m => m.Agent.Ship.EntityInfoGathered.TryGetValue(entity, out var info) && - info >= _settings.TargetGearInfoThreshold); + return grudge || _members.Any(m => View.Info(m.Agent.Ship, contact) >= _settings.TargetGearInfoThreshold); case EngageOn.Provoked: return grudge; - case EngageOn.Trespass: return grudge || _members.Any(m => m.Agent.Ship.BelievesTrespassing(entity)); + case EngageOn.Trespass: return grudge || _members.Any(m => View.BelievesTrespassing(m.Agent.Ship, contact)); default: return false; } } - private void Advance(Entity entity, Track track) + private void Advance(ContactId contact, Track track) { if (track.Phase == Phase.Engaged) return; - var grudge = Grudged(entity); - if (!Engageable(entity, grudge)) + var grudge = Grudged(contact); + if (!Engageable(contact, grudge)) { track.Phase = Phase.None; return; @@ -115,56 +123,59 @@ public class Flight { track.Phase = Phase.Hailing; track.Start = _time; - if (!string.IsNullOrEmpty(_doctrine.Hail)) Nearest(entity)?.Agent.Ship.Command.Hail(_doctrine.Hail); + if (!string.IsNullOrEmpty(_doctrine.Hail)) Nearest(contact)?.Agent.Ship.Command.Hail(_doctrine.Hail); } else track.Phase = Phase.Engaged; break; case Phase.Hailing: if (_time >= track.Start + _doctrine.Grace) - track.Phase = _doctrine.ComplySpeed > 0 && length(entity.Velocity) < _doctrine.ComplySpeed + track.Phase = _doctrine.ComplySpeed > 0 && length(View.SideTrack(contact).Value.Velocity) < _doctrine.ComplySpeed ? Phase.Complied : Phase.Engaged; break; case Phase.Complied: - if (length(entity.Velocity) >= _doctrine.ComplySpeed) track.Phase = Phase.Engaged; + if (length(View.SideTrack(contact).Value.Velocity) >= _doctrine.ComplySpeed) track.Phase = Phase.Engaged; break; } } - private Member Nearest(Entity entity) => - _members.OrderBy(m => lengthsq(m.Agent.Ship.Position.xz - entity.Position.xz)).FirstOrDefault(); + private Member Nearest(ContactId contact) + { + var at = View.SideTrack(contact).Value.Position.xz; + return _members.OrderBy(m => lengthsq(m.Agent.Ship.Position.xz - at)).FirstOrDefault(); + } - private void Order(Member member, List picture) + private void Order(Member member, List picture) { var ship = member.Agent.Ship; - var seen = ship.VisibleEnemies; // Engage: keep the current target while it stays engaged, else the nearest engaged track this member can see. - var engaged = picture.Where(e => _tracks[e].Phase == Phase.Engaged && seen.Contains(e)).ToList(); + var engaged = picture.Where(c => _tracks[c].Phase == Phase.Engaged && View.Sees(ship, c)).ToList(); if (engaged.Count > 0) { - var current = ship.Target.Value.Entity; - var pick = current != null && engaged.Contains(current) - ? current - : engaged.OrderBy(e => lengthsq(ship.Position.xz - e.Position.xz)).First(); - Aim(ship, pick); + var current = View.AppliedTargetOf(ship); + var pick = current != null && engaged.Contains(current.Value) + ? current.Value + : engaged.OrderBy(c => lengthsq(ship.Position.xz - View.SideTrack(c).Value.Position.xz)).First(); + View.Designate(ship, pick); return; } // No engaged track in this member's own sensors: it steers on the flight's picture, a bearing and never targeting // data (ruling shared-track-bearing-only). That is the hailed and the complied, and an engaged track only a flightmate sees. - Aim(ship, null); + View.Designate(ship, null); // Overrides are per member: a flightmate's hostile track is not one this member follows after it has made it friendly. - var held = picture.Where(e => _tracks[e].Phase != Phase.None && ship.IffOverride(e) != false).ToList(); + var held = picture.Where(c => _tracks[c].Phase != Phase.None && View.IffOverride(ship, c) != false).ToList(); if (held.Count > 0) { // An engaged track outranks a hailed one; the held anchor is kept only while nothing outranks it. - var anyEngaged = held.Any(e => _tracks[e].Phase == Phase.Engaged); - var anchor = member.Agent.Task is FollowTask follow && held.Contains(follow.Anchor) - && (!anyEngaged || _tracks[follow.Anchor].Phase == Phase.Engaged) - ? follow.Anchor - : held.OrderBy(e => _tracks[e].Phase == Phase.Engaged ? 0 : 1) - .ThenBy(e => lengthsq(ship.Position.xz - e.Position.xz)).First(); + var anyEngaged = held.Any(c => _tracks[c].Phase == Phase.Engaged); + var heldAnchor = (member.Agent.Task as FollowTask)?.Anchor; + var anchor = heldAnchor != null && held.Contains(heldAnchor.Value) + && (!anyEngaged || _tracks[heldAnchor.Value].Phase == Phase.Engaged) + ? heldAnchor.Value + : held.OrderBy(c => _tracks[c].Phase == Phase.Engaged ? 0 : 1) + .ThenBy(c => lengthsq(ship.Position.xz - View.SideTrack(c).Value.Position.xz)).First(); if (!(member.Agent.Task is FollowTask current) || current.Anchor != anchor) member.Agent.Task = new FollowTask { Anchor = anchor, Standoff = Standoff(ship) }; } @@ -172,12 +183,6 @@ public class Flight member.Agent.Task = member.Baseline; } - private static void Aim(Ship ship, Entity target) - { - var wanted = target == null ? TargetRef.None : new TargetRef(target); - if (!ship.Target.Value.Equals(wanted)) ship.Command.Designate(wanted); - } - private float Standoff(Ship ship) { if (Combatant.HoldStandoff > 0) return Combatant.HoldStandoff; diff --git a/Assets/Scripts/ServerShared/Agents/Minion.cs b/Assets/Scripts/ServerShared/Agents/Minion.cs index a1277b264..c95b7cfbb 100644 --- a/Assets/Scripts/ServerShared/Agents/Minion.cs +++ b/Assets/Scripts/ServerShared/Agents/Minion.cs @@ -22,9 +22,9 @@ public class Minion : Agent var combatState = new CombatState(this); _rootState.AddTransition(combatState, - () => (Ship.Command.Designated ?? Ship.Target.Value).Entity != null, null, true, _rootState); + () => View.TargetOf(Ship) != null, null, true, _rootState); combatState.AddTransition(_rootState, - () => (Ship.Command.Designated ?? Ship.Target.Value).Entity == null, null, true, _rootState); + () => View.TargetOf(Ship) == null, null, true, _rootState); } } diff --git a/Assets/Scripts/ServerShared/Agents/PilotView.cs b/Assets/Scripts/ServerShared/Agents/PilotView.cs new file mode 100644 index 000000000..8c9a71e65 --- /dev/null +++ b/Assets/Scripts/ServerShared/Agents/PilotView.cs @@ -0,0 +1,173 @@ +using System; +using System.Collections.Generic; +using System.Linq; +using System.Runtime.CompilerServices; +using CultMath; + +// What one side's brain may know of other ships. A side is a faction, or one unaffiliated ship (brain-one-per-side-r2); +// its view reads only that side's members: their sensors (VisibleEntities, VisibleEnemies, EntityInfoGathered), their +// IFF overrides and reveal books (ruling observation-sensors-only). A brain never holds another ship: it holds a +// ContactId or a RevealId, which only the view that issued it can resolve, so there is no path from a handle to the +// ship's truth that does not pass the sensors. +// +// A contact's Track (position, velocity, facing) exists only while a member tracks it (its VisibleEntities holds it): +// within a zone step nothing moves during the agent phase, so the track equals what the Entity would answer. The +// fire-control readouts call FireControl's own functions with the resolved subject, so the HUD, the player and the +// brains price one model (A-one-sim). Own-ship reads stay the brain's own. +public sealed class PilotView +{ + private readonly List _members = new List(); + private static Func _openContact; + private static Func _openReveal; + + public PilotView(params Ship[] members) => _members.AddRange(members); + + internal void Add(Ship member) => _members.Add(member); + internal void Remove(Ship member) => _members.Remove(member); + + // A contact of the view, equal to itself across absences from a member's picture: equality is by reference. + public readonly struct ContactId : IEquatable + { + private readonly Entity _subject; + + static ContactId() => _openContact = id => id._subject; + internal ContactId(Entity subject) => _subject = subject; + + public bool Equals(ContactId other) => ReferenceEquals(_subject, other._subject); + public override bool Equals(object obj) => obj is ContactId other && Equals(other); + public override int GetHashCode() => RuntimeHelpers.GetHashCode(_subject); + public static bool operator ==(ContactId a, ContactId b) => a.Equals(b); + public static bool operator !=(ContactId a, ContactId b) => !a.Equals(b); + } + + // A revealed item of a contact, to aim at; the live item stays behind the handle (ruling contact-reads-book-aim-reads-live). + public readonly struct RevealId : IEquatable + { + private readonly EquippedItem _item; + + static RevealId() => _openReveal = id => id._item; + internal RevealId(EquippedItem item) => _item = item; + + public bool Equals(RevealId other) => ReferenceEquals(_item, other._item); + public override bool Equals(object obj) => obj is RevealId other && Equals(other); + public override int GetHashCode() => RuntimeHelpers.GetHashCode(_item); + public static bool operator ==(RevealId a, RevealId b) => a.Equals(b); + public static bool operator !=(RevealId a, RevealId b) => !a.Equals(b); + } + + public readonly struct Track + { + public readonly float3 Position; + public readonly float2 Velocity; + public readonly float2 Direction; + + public Track(float3 position, float2 velocity, float2 direction) + { + Position = position; + Velocity = velocity; + Direction = direction; + } + } + + // For fixtures: the handle of a subject, as the view would issue it. + internal static ContactId Handle(Entity subject) => new ContactId(subject); + + private static Entity Subject(ContactId id) + { + if (_openContact == null) RuntimeHelpers.RunClassConstructor(typeof(ContactId).TypeHandle); + return _openContact(id); + } + + private static EquippedItem Item(RevealId id) + { + if (_openReveal == null) RuntimeHelpers.RunClassConstructor(typeof(RevealId).TypeHandle); + return _openReveal(id); + } + + // The side's contacts: its members' VisibleEnemies in member order, each once, never a member of the side. + public List Picture() + { + var picture = new List(); + var seen = new HashSet(); + foreach (var member in _members) + foreach (var enemy in member.VisibleEnemies) + if (!_members.Any(m => ReferenceEquals(m, enemy)) && seen.Add(enemy)) picture.Add(new ContactId(enemy)); + return picture; + } + + public bool Sees(Ship member, ContactId contact) => member.VisibleEnemies.Contains(Subject(contact)); + + // The member's own track of the contact, or null while it does not track it. + public Track? TrackOf(Ship member, ContactId contact) + { + var subject = Subject(contact); + if (subject == null || !member.VisibleEntities.Contains(subject)) return null; + return new Track(subject.Position, subject.Velocity, subject.Direction); + } + + // The track of the first member, in order, that tracks the contact (question side-shared-kinematics). + public Track? SideTrack(ContactId contact) + { + foreach (var member in _members) + { + var track = TrackOf(member, contact); + if (track != null) return track; + } + + return null; + } + + public bool? IffOverride(Ship member, ContactId contact) => member.IffOverride(Subject(contact)); + + public float? Info(Ship member, ContactId contact) => + member.EntityInfoGathered.TryGetValue(Subject(contact), out var info) ? info : (float?) null; + + public bool BelievesTrespassing(Ship member, ContactId contact) => member.BelievesTrespassing(Subject(contact)); + + // The member's pending designation when set, else last step's applied target. + public ContactId? TargetOf(Ship member) + { + var subject = (member.Command.Designated ?? member.Target.Value).Entity; + return subject == null ? (ContactId?) null : new ContactId(subject); + } + + public ContactId? AppliedTargetOf(Ship member) + { + var subject = member.Target.Value.Entity; + return subject == null ? (ContactId?) null : new ContactId(subject); + } + + // The weapons the member believes the contact has, in the contact's equipment order; Aimable is FireControl.IsRevealed. + public List<(RevealId Id, EquippableItemData Claimed, bool Aimable)> WeaponReveals(Ship member, ContactId contact) => + member.RevealedWeapons(Subject(contact)) + .Select(r => (new RevealId(r.Item), r.Claimed, FireControl.IsRevealed(member, r.Item))).ToList(); + + public bool Designated(Ship member, Weapon weapon, ContactId contact) => + FireControl.Designated(weapon, member, Subject(contact), out _); + + public bool Refuses(Ship member, Weapon weapon, ContactId contact) => + FireControl.Refuses(weapon, member, Subject(contact)); + + public bool Fires(Ship member, Weapon weapon, ContactId contact, float minHitProbability, RevealId? aim) => + FireControl.AgentFires(weapon, member, Subject(contact), minHitProbability, + aim == null ? AimTarget.None : AimTarget.OfItem(Item(aim.Value))); + + // Where to point to hit the contact with the weapon: the shared prediction, at the height the contact rides. + public float3 Intercept(Ship member, Weapon weapon, ContactId contact) + { + var subject = Subject(contact); + var predicted = FireControl.PredictedIntercept(weapon, member, subject); + predicted.y = subject.RideHeight(predicted.xz); + return predicted; + } + + // Designate the contact, or nothing for null, when that is not already the member's applied target. + public void Designate(Ship member, ContactId? contact) + { + var wanted = contact == null ? TargetRef.None : new TargetRef(Subject(contact.Value)); + if (!member.Target.Value.Equals(wanted)) member.Command.Designate(wanted); + } + + public void SelectItem(Ship member, RevealId? reveal) => + member.Command.SelectItem(reveal == null ? null : Item(reveal.Value)); +} diff --git a/Assets/Scripts/ServerShared/Agents/PilotView.cs.meta b/Assets/Scripts/ServerShared/Agents/PilotView.cs.meta new file mode 100644 index 000000000..8c9b644f3 --- /dev/null +++ b/Assets/Scripts/ServerShared/Agents/PilotView.cs.meta @@ -0,0 +1,11 @@ +fileFormatVersion: 2 +guid: 5b0f6c2e9d3a4f71a8c4e1d7b2a63f90 +MonoImporter: + externalObjects: {} + serializedVersion: 2 + defaultReferences: [] + executionOrder: 0 + icon: {instanceID: 0} + userData: + assetBundleName: + assetBundleVariant: diff --git a/Assets/Scripts/ServerShared/Agents/States/Combat.cs b/Assets/Scripts/ServerShared/Agents/States/Combat.cs index 1a062e7be..a424437b3 100644 --- a/Assets/Scripts/ServerShared/Agents/States/Combat.cs +++ b/Assets/Scripts/ServerShared/Agents/States/Combat.cs @@ -43,36 +43,39 @@ public class CombatState : BaseState _optimumStale = false; } - // The pilot's own pending designation (its Flight's, this step) when set, else last step's applied target. - var target = (_agent.Ship.Command.Designated ?? _agent.Ship.Target.Value).Entity; + // The pilot's own pending designation (its Flight's, this step) when set, else last step's applied target; and + // only a contact a member tracks has a track to fly on, so an untracked target is no target here. + var view = _agent.View; + var target = view.TargetOf(_agent.Ship); if (target == null) return; + var track = view.TrackOf(_agent.Ship, target.Value); + if (track == null) return; _availableGroups.Clear(); _availableLockingWeapons.Clear(); - var toTarget = target.Position - _agent.Ship.Position; + var toTarget = track.Value.Position - _agent.Ship.Position; var targetDistance = length(toTarget.xz); // Cut 2 (docs/fire-control-cut.md): aim at whichever revealed weapon on the target hits hardest at // this range, or clear the aim point when nothing qualifies -- the same predicate - // (FireControl.IsRevealed) and the same writer (TrySelectTargetItem) the player path uses. Which weapons it + // (the view's Aimable) and the same writer (TrySelectTargetItem) the player path uses. Which weapons it // believes the target has, and how hard each hits, come from its book's claims, not the target's live gear // (aetheria-ew, classify-from-belief; ew-claim-weapon-measure-ruled: the claimed design's rated figures); the // reveal's Item is the aim and stays the truth. - EquippedItem bestTargetItem = null; + PilotView.RevealId? bestTargetItem = null; var bestTargetDps = float.MinValue; - foreach (var reveal in _agent.Ship.RevealedWeapons(target)) + foreach (var reveal in view.WeaponReveals(_agent.Ship, target.Value)) { - if (!FireControl.IsRevealed(_agent.Ship, reveal.Item)) continue; + if (!reveal.Aimable) continue; var dps = reveal.Claimed.Behaviors.OfType().First().RatedRangeDamagePerSecond(targetDistance); if (dps > bestTargetDps) { bestTargetDps = dps; - bestTargetItem = reveal.Item; + bestTargetItem = reveal.Id; } } - _agent.Ship.Command.SelectItem(bestTargetItem); - var aim = bestTargetItem == null ? AimTarget.None : AimTarget.OfItem(bestTargetItem); + view.SelectItem(_agent.Ship, bestTargetItem); for (var i = 0; i < _agent.Ship.WeaponGroups.Length; i++) { @@ -82,8 +85,8 @@ public class CombatState : BaseState // The range test is FireControl's (Designated), and a weapon Solve refuses adds nothing to a group's // damage. if (weapon.Item.Online.Value && - FireControl.Designated(weapon, _agent.Ship, target, out _) && - !FireControl.Refuses(weapon, _agent.Ship, target) && + view.Designated(_agent.Ship, weapon, target.Value) && + !view.Refuses(_agent.Ship, weapon, target.Value) && (weapon is ConstantWeapon || weapon is InstantWeapon instantWeapon && instantWeapon.CanFire)) { @@ -111,11 +114,11 @@ public class CombatState : BaseState } } - var targetRight = target.Direction.Rotate(ItemRotation.Clockwise); + var targetRight = track.Value.Direction.Rotate(ItemRotation.Clockwise); var optimumRangeDelta = abs(_optimumRange - targetDistance); var directionToTarget = normalize(toTarget.xz); var targetPortAlignment = dot(targetRight, directionToTarget); - var targetForeAlignment = dot(-target.Direction, directionToTarget); + var targetForeAlignment = dot(-track.Value.Direction, directionToTarget); var forwardness = saturate(optimumRangeDelta / _agent.Settings.AgentMaxForwardDistance) * _agent.Settings.AgentForwardLerp; if (targetForeAlignment > 0) forwardness = lerp(forwardness, 1, pow(targetPortAlignment, 2)); @@ -128,20 +131,19 @@ public class CombatState : BaseState var testWeapon = _agent.Ship.WeaponGroups[selectedGroup].weapons.First(); if(testWeapon.Velocity > 1) { - // Cut 3 (docs/fire-control-cut.md): the one prediction function, shared with FireControl.Fire's + // Cut 3 (docs/fire-control-cut.md): the one prediction function, shared with the shot's // own snapshot and the roll's deviation measurement -- two separate predictions here and in the // roll would let an AI aim at one point and get judged for missing a different one. - var predictedPosition = FireControl.PredictedIntercept(testWeapon, _agent.Ship, target); - predictedPosition.y = target.RideHeight(predictedPosition.xz); + var predictedPosition = view.Intercept(_agent.Ship, testWeapon, target.Value); toTarget = normalize(predictedPosition - _agent.Ship.Position); } // Each weapon decides for itself (operator ruling 2026-09-30): a group shares a trigger, not a - // verdict. FireControl.AgentFires is the AI's "worth it" heuristic for a weapon that hits what it + // verdict. The view's Fires is the AI's "worth it" heuristic for a weapon that hits what it // fires at, and "the target is designated" for a fused one. foreach (var weapon in _agent.Ship.WeaponGroups[selectedGroup].weapons) { - if (FireControl.AgentFires(weapon, _agent.Ship, target, _agent.Doctrine.MinHitProbability, aim)) + if (view.Fires(_agent.Ship, weapon, target.Value, _agent.Doctrine.MinHitProbability, bestTargetItem)) _agent.Ship.Command.Pull(weapon); // A lock pulled above this step counts as firing, as it did when it was activated in place. else if (weapon.Firing || _agent.Ship.Command.Pulls(weapon)) diff --git a/Assets/Scripts/ServerShared/Agents/States/Follow.cs b/Assets/Scripts/ServerShared/Agents/States/Follow.cs index 1bbf3f826..e1701bc6c 100644 --- a/Assets/Scripts/ServerShared/Agents/States/Follow.cs +++ b/Assets/Scripts/ServerShared/Agents/States/Follow.cs @@ -11,14 +11,16 @@ public class FollowState : BaseState { var task = _agent.Task as FollowTask; if (task?.Anchor == null) return; + var anchor = _agent.View.SideTrack(task.Anchor.Value); + if (anchor == null) return; - var toAnchor = task.Anchor.Position.xz - _agent.Ship.Position.xz; + var toAnchor = anchor.Value.Position.xz - _agent.Ship.Position.xz; var distance = length(toAnchor); var direction = distance > 0 ? toAnchor / distance : _agent.Ship.Direction; // Positive error: too far, close. Negative: too near, open. var error = distance - task.Standoff; var pull = direction * (float) sign(error) * saturate(abs(error) / max(task.Standoff, 1f)); - var desired = task.Anchor.Velocity + pull * _agent.TopSpeed; + var desired = anchor.Value.Velocity + pull * _agent.TopSpeed; _agent.Ship.Command.Aim = float3(direction.x, 0, direction.y); _agent.Accelerate(desired); diff --git a/Assets/Scripts/ServerShared/Agents/Tasks/FollowTask.cs b/Assets/Scripts/ServerShared/Agents/Tasks/FollowTask.cs index 4e09d0a35..946539c6f 100644 --- a/Assets/Scripts/ServerShared/Agents/Tasks/FollowTask.cs +++ b/Assets/Scripts/ServerShared/Agents/Tasks/FollowTask.cs @@ -5,7 +5,7 @@ using MessagePack; // Runtime only, like every agent task. public class FollowTask : AgentTask { - [IgnoreMember] public Entity Anchor; + [IgnoreMember] public PilotView.ContactId? Anchor; [IgnoreMember] public float Standoff; [IgnoreMember] public override TaskType Type => TaskType.Follow; } diff --git a/Assets/Scripts/ServerShared/ScenarioStage.cs b/Assets/Scripts/ServerShared/ScenarioStage.cs index c9531e60f..f0df352e9 100644 --- a/Assets/Scripts/ServerShared/ScenarioStage.cs +++ b/Assets/Scripts/ServerShared/ScenarioStage.cs @@ -145,7 +145,7 @@ public sealed class ScenarioStage var placement = _placed.FirstOrDefault(p => ReferenceEquals(p.Entity, entity)); if (placement == null || !placement.Piloted) { _failures.Add($"approach: {entity?.Name ?? "no entity"} is not a placed, piloted ship"); return; } if (target == null) { _failures.Add("approach: no target to close on"); return; } - placement.Task = new FollowTask { Anchor = target, Standoff = 0f }; + placement.Task = new FollowTask { Anchor = PilotView.Handle(target), Standoff = 0f }; } // Items in the entity's first cargo bay, one per name. A name is an equippable or consumable design (open: the stage diff --git a/tests/Aetheria.Shared.Tests/ConsumableAiTests.cs b/tests/Aetheria.Shared.Tests/ConsumableAiTests.cs index ea1ee917e..f0a570b1f 100644 --- a/tests/Aetheria.Shared.Tests/ConsumableAiTests.cs +++ b/tests/Aetheria.Shared.Tests/ConsumableAiTests.cs @@ -586,7 +586,8 @@ public sealed class ConsumableAiTests : IDisposable s.Zone.Update(.05f); s.Zone.Update(.05f); Assert.True(s.Agent.Target.Value.IsNone); - agent.Task = new FollowTask { Anchor = s.Prey, Standoff = 100 }; + if (!s.Agent.VisibleEntities.Contains(s.Prey)) s.Agent.VisibleEntities.Add(s.Prey); + agent.Task = new FollowTask { Anchor = PilotView.Handle(s.Prey), Standoff = 100 }; for (var i = 0; i < 6 && !(s.State is FollowState); i++) { agent.Update(.05f); diff --git a/tests/Aetheria.Shared.Tests/FlightTests.cs b/tests/Aetheria.Shared.Tests/FlightTests.cs index 9f397ebdf..1cb2f9c48 100644 --- a/tests/Aetheria.Shared.Tests/FlightTests.cs +++ b/tests/Aetheria.Shared.Tests/FlightTests.cs @@ -48,9 +48,17 @@ public sealed class FlightTests : IDisposable return track; } + // A follower holds the anchor as a handle and flies on it only while its side tracks it. + private static FollowTask Follows(Ship follower, Entity anchor, float standoff) + { + if (!follower.VisibleEntities.Contains(anchor)) follower.VisibleEntities.Add(anchor); + return new FollowTask { Anchor = PilotView.Handle(anchor), Standoff = standoff }; + } + private static void See(Ship member, Entity track) { member.EntityInfoGathered[track] = 1; + if (!member.VisibleEntities.Contains(track)) member.VisibleEntities.Add(track); member.VisibleEnemies.Add(track); } @@ -254,7 +262,7 @@ public sealed class FlightTests : IDisposable { anchor.Position = ship.Position + anchorAt; var minion = new Minion(ship, FactionDoctrine.Default(_items.GameplaySettings).Combatant) - { Task = new FollowTask { Anchor = anchor, Standoff = 100 } }; + { Task = Follows(ship, anchor, 100) }; minion.Update(.1f); PilotApplier.Apply(minion.Ship); minion.Update(.1f); @@ -279,7 +287,7 @@ public sealed class FlightTests : IDisposable for (var i = 0; i < 4; i++) minion.Update(.1f); Assert.IsType(CurrentState(minion)); - minion.Task = new FollowTask { Anchor = anchor, Standoff = 50 }; + minion.Task = Follows(ship, anchor, 50); minion.Update(.1f); PilotApplier.Apply(minion.Ship); @@ -544,12 +552,12 @@ public sealed class FlightTests : IDisposable See(member, far); Tick(.1f); - Assert.Same(near, ((FollowTask) _zone.Agents.Single().Task).Anchor); + Assert.Equal(PilotView.Handle(near), ((FollowTask) _zone.Agents.Single().Task).Anchor); near.Position = new float3(-900, 0, 0); far.Position = new float3(990, 0, 0); Tick(.1f); - Assert.Same(near, ((FollowTask) _zone.Agents.Single().Task).Anchor); + Assert.Equal(PilotView.Handle(near), ((FollowTask) _zone.Agents.Single().Task).Anchor); } // The held anchor leaves the picture: the member re-anchors on what is still held. Kills: the anchor rewritten only @@ -563,12 +571,12 @@ public sealed class FlightTests : IDisposable See(member, near); See(member, far); Tick(.1f); - Assert.Same(near, ((FollowTask) _zone.Agents.Single().Task).Anchor); + Assert.Equal(PilotView.Handle(near), ((FollowTask) _zone.Agents.Single().Task).Anchor); member.VisibleEnemies.Remove(near); Tick(.1f); - Assert.Same(far, ((FollowTask) _zone.Agents.Single().Task).Anchor); + Assert.Equal(PilotView.Handle(far), ((FollowTask) _zone.Agents.Single().Task).Anchor); } [Fact] @@ -586,7 +594,7 @@ public sealed class FlightTests : IDisposable member.VisibleEnemies.Remove(track); Tick(.1f); Assert.Same(patrol, agent.Task); - Assert.Equal(Flight.Phase.None, _zone.Flights.Single().PhaseOf(track)); + Assert.Equal(Flight.Phase.None, _zone.Flights.Single().PhaseOf(PilotView.Handle(track))); member.Messages.Clear(); See(member, track); @@ -617,7 +625,7 @@ public sealed class FlightTests : IDisposable var ship = Pilot(null, -200, 50); anchor.Position = ship.Position + new float3(anchorX, 0, 0); var minion = new Minion(ship, FactionDoctrine.Default(_items.GameplaySettings).Combatant) - { Task = new FollowTask { Anchor = anchor, Standoff = 100 } }; + { Task = Follows(ship, anchor, 100) }; minion.Update(.1f); PilotApplier.Apply(minion.Ship); minion.Update(.1f); @@ -639,7 +647,7 @@ public sealed class FlightTests : IDisposable var ship = Pilot(null); var anchor = Track(); var minion = new Minion(ship, FactionDoctrine.Default(_items.GameplaySettings).Combatant) - { Task = new FollowTask { Anchor = anchor, Standoff = 50 } }; + { Task = Follows(ship, anchor, 50) }; minion.Update(.1f); PilotApplier.Apply(minion.Ship); minion.Update(.1f); @@ -651,7 +659,7 @@ public sealed class FlightTests : IDisposable PilotApplier.Apply(minion.Ship); Assert.IsType(CurrentState(minion)); - minion.Task = new FollowTask { Anchor = anchor, Standoff = 50 }; + minion.Task = Follows(ship, anchor, 50); minion.Update(.1f); PilotApplier.Apply(minion.Ship); minion.Update(.1f); @@ -700,7 +708,7 @@ public sealed class FlightTests : IDisposable Assert.All(sighted, m => Assert.Same(track, m.Target.Value.Entity)); Assert.True(blind.Target.Value.IsNone); var task = Assert.IsType(_zone.Agents.Single(a => a.Ship == blind).Task); - Assert.Same(track, task.Anchor); + Assert.Equal(PilotView.Handle(track), task.Anchor); } // The flight's engaged track outranks a hailed one a blind member does see, though that one is nearer. Both sit @@ -718,14 +726,14 @@ public sealed class FlightTests : IDisposable See(blind, hailing); Tick(.1f); var task = Assert.IsType(_zone.Agents.Single(a => a.Ship == blind).Task); - Assert.Same(hailing, task.Anchor); // nothing outranks it yet: the nearest held + Assert.Equal(PilotView.Handle(hailing), task.Anchor); // nothing outranks it yet: the nearest held sighted.SetIff(engaged, true); Tick(.1f); Assert.Same(engaged, sighted.Target.Value.Entity); task = Assert.IsType(_zone.Agents.Single(a => a.Ship == blind).Task); - Assert.Same(engaged, task.Anchor); + Assert.Equal(PilotView.Handle(engaged), task.Anchor); } // Hail then engage is the production path of every graced doctrine: the member goes from FollowState into combat, @@ -887,7 +895,7 @@ public sealed class FlightTests : IDisposable anchor.Position = ship.Position + new float3(0, 0, distance); anchor.Velocity = anchorVelocity; var minion = new Minion(ship, FactionDoctrine.Default(_items.GameplaySettings).Combatant) - { Task = new FollowTask { Anchor = anchor, Standoff = 100 } }; + { Task = Follows(ship, anchor, 100) }; minion.Update(.1f); PilotApplier.Apply(minion.Ship); minion.Update(.1f); From bd343b2a4e0ead4929389fed748fa5a7c01ca847 Mon Sep 17 00:00:00 2001 From: soul Date: Thu, 22 Oct 2026 15:53:20 +0200 Subject: [PATCH 164/444] Kit frames: mates are lp_at placements that track the Ship Root frame; zone OFFs unchanged Co-Authored-By: Claude Sonnet 5.5 --- .../aetheria_ships/factions/lowpoly.scad | 25 +++++++++++++++++++ .../aetheria_ships/factions/zhestokost.scad | 15 +++++------ 2 files changed, 33 insertions(+), 7 deletions(-) diff --git a/tools/blender/aetheria_ships/factions/lowpoly.scad b/tools/blender/aetheria_ships/factions/lowpoly.scad index 9979483a0..ef3063c6c 100644 --- a/tools/blender/aetheria_ships/factions/lowpoly.scad +++ b/tools/blender/aetheria_ships/factions/lowpoly.scad @@ -12,3 +12,28 @@ module cbox(s, c=0.2, center=true) { module tube(r, l, fn=8) { rotate([-90,0,0]) cylinder(r=r, h=l, $fn=fn); } // a straight strut between two points module strut(a, b, r=0.08, fn=6) { hull() { translate(a) sphere(r, $fn=fn); translate(b) sphere(r, $fn=fn); } } + +// ==== Frames and schematic lines ===================================================================================== +// A kit part that draws schematic lines is placed with lp_at, never with raw translate/rotate/mirror: lp_at moves the +// geometry (multmatrix) and tracks the same frame in $lp_m, so lp_line, called anywhere beneath it, emits its points in +// the Ship Root frame. Placement has this one owner. A line is echoed (part "lines") only when the frame's local +Z +// points up, so fittings on the belly, the walls and the belts, which a top view cannot see, emit nothing. +$lp_m = [[1,0,0,0], [0,1,0,0], [0,0,1,0], [0,0,0,1]]; +function lp_T(t) = [[1,0,0,t[0]], [0,1,0,t[1]], [0,0,1,t[2]], [0,0,0,1]]; +function lp_R(r) = // Rz * Ry * Rx, as rotate() composes + [[cos(r[2]), -sin(r[2]), 0, 0], [sin(r[2]), cos(r[2]), 0, 0], [0,0,1,0], [0,0,0,1]] + * [[cos(r[1]), 0, sin(r[1]), 0], [0,1,0,0], [-sin(r[1]), 0, cos(r[1]), 0], [0,0,0,1]] + * [[1,0,0,0], [0, cos(r[0]), -sin(r[0]), 0], [0, sin(r[0]), cos(r[0]), 0], [0,0,0,1]]; +lp_MX = [[-1,0,0,0], [0,1,0,0], [0,0,1,0], [0,0,0,1]]; // mirror across x = 0 +function lp_ap(m, p) = let(v = m * [p[0], p[1], p[2], 1]) [v[0], v[1], v[2]]; +// Mirror (when mx), then translate by t, then rotate by r (as nested mirror, translate, rotate would). +module lp_at(t=[0,0,0], r=[0,0,0], mx=false) { + M = (mx ? lp_MX : [[1,0,0,0], [0,1,0,0], [0,0,1,0], [0,0,0,1]]) * lp_T(t) * lp_R(r); + $lp_m = $lp_m * M; + multmatrix(M) children(); +} +// One schematic line: layer (outline, step, seam or fitting), points in the local frame, cyclic when it closes. +module lp_line(layer, pts, cyclic=false) { + if (!is_undef(part) && part == "lines" && ($lp_m * [0, 0, 1, 0])[2] > 0.5) + echo(line = [layer, cyclic, [for (p = pts) lp_ap($lp_m, p)]]); +} diff --git a/tools/blender/aetheria_ships/factions/zhestokost.scad b/tools/blender/aetheria_ships/factions/zhestokost.scad index e077351ee..ae38b0186 100644 --- a/tools/blender/aetheria_ships/factions/zhestokost.scad +++ b/tools/blender/aetheria_ships/factions/zhestokost.scad @@ -22,6 +22,7 @@ // zh_on_stern, zh_on_nose. In the mate frame local +Z is the outward normal and the part is sunk ZH_SINK into // the skin, so nothing floats. Top/belly frames keep local +Y aft; wall/belt frames keep +Y aft with local +X // pointing down the wall on either side (starboard mates are mirrors of port); stern/nose frames have local +Y down. +// Mates are lp_at frames: a part under a mate that draws lines (lp_line) emits them in the Ship Root frame. // 3. Zone routing: every zh_ module draws only the zone the global `part` names, so a hull writes its assembly once. // A hull on a Zhestokost donor (HULL_DONOR == "zhestokost") names a donor zone 'zhestokost:'. // Anchor meshes (zh_rad_vanes, zh_emitter) are not routed: call them inside the hull's rad:/emit: parts under @@ -65,17 +66,17 @@ function zh_wall_tilt(secs, y) = let(s = zh_sec(secs, y)) atan2(s[4], s[2]*(1-ZH function zh_stern_pt(secs, x=0, v=0.5) = let(s = secs[len(secs)-1]) [x, s[0], s[3] + s[2]*v]; function zh_nose_pt(secs, x=0, v=0.5) = let(s = secs[0]) [x, s[0], s[3] + s[2]*v]; -module zh_on_top(secs, y, u=0, sink=ZH_SINK) translate(zh_top(secs, y, u) - [0, 0, sink]) children(); -module zh_on_belly(secs, y, u=0, sink=ZH_SINK) translate(zh_belly(secs, y, u) + [0, 0, sink]) rotate([0, 180, 0]) children(); -module _zh_port(side) { if (side < 0) mirror([1, 0, 0]) children(); else children(); } +module zh_on_top(secs, y, u=0, sink=ZH_SINK) lp_at(t=zh_top(secs, y, u) - [0, 0, sink]) children(); +module zh_on_belly(secs, y, u=0, sink=ZH_SINK) lp_at(t=zh_belly(secs, y, u) + [0, 0, sink], r=[0, 180, 0]) children(); module zh_on_belt(secs, y, side=1, v=0.5, sink=ZH_SINK) - _zh_port(side) translate(zh_belt(secs, y, 1, v) - [sink, 0, 0]) rotate([0, 90, 0]) children(); + lp_at(t=zh_belt(secs, y, 1, v) - [sink, 0, 0], r=[0, 90, 0], mx=side < 0) children(); module zh_on_wall(secs, y, side=1, v=0.5, sink=ZH_SINK) { t = zh_wall_tilt(secs, y); - _zh_port(side) translate(zh_wall(secs, y, 1, v) - [cos(t), 0, sin(t)]*sink) rotate([0, 90 - t, 0]) children(); + lp_at(t=zh_wall(secs, y, 1, v) - [cos(t), 0, sin(t)]*sink, r=[0, 90 - t, 0], mx=side < 0) children(); } -module zh_on_stern(secs, x=0, v=0.5, sink=ZH_SINK) translate(zh_stern_pt(secs, x, v) - [0, sink, 0]) rotate([-90, 0, 0]) children(); -module zh_on_nose(secs, x=0, v=0.5, sink=ZH_SINK) translate(zh_nose_pt(secs, x, v) + [0, sink, 0]) rotate([90, 0, 0]) rotate([0, 0, 180]) children(); +module zh_on_stern(secs, x=0, v=0.5, sink=ZH_SINK) lp_at(t=zh_stern_pt(secs, x, v) - [0, sink, 0], r=[-90, 0, 0]) children(); +module zh_on_nose(secs, x=0, v=0.5, sink=ZH_SINK) + lp_at(t=zh_nose_pt(secs, x, v) + [0, sink, 0], r=[90, 0, 0]) lp_at(r=[0, 0, 180]) children(); // world point of a local offset under a mate (for anchors): top, belly, stern frames function zh_top_at(secs, y, u, o, sink=ZH_SINK) = zh_top(secs, y, u) - [0, 0, sink] + o; function zh_belly_at(secs, y, u, o, sink=ZH_SINK) = zh_belly(secs, y, u) + [0, 0, sink] + [-o[0], o[1], -o[2]]; From 28ed24b28ed0648e79bb8802934da80e65eba729 Mon Sep 17 00:00:00 2001 From: soul Date: Thu, 22 Oct 2026 15:54:27 +0200 Subject: [PATCH 165/444] Zhestokost kit and Nakovalnya emit schematic lines from their own frames (outline, step, seam, fitting) Co-Authored-By: Claude Sonnet 5.5 --- GameData/Mods/aetheria.nakovalnya/hull.scad | 26 ++++++---- .../aetheria_ships/factions/zhestokost.scad | 47 +++++++++++++++---- 2 files changed, 54 insertions(+), 19 deletions(-) diff --git a/GameData/Mods/aetheria.nakovalnya/hull.scad b/GameData/Mods/aetheria.nakovalnya/hull.scad index 976078073..062285157 100644 --- a/GameData/Mods/aetheria.nakovalnya/hull.scad +++ b/GameData/Mods/aetheria.nakovalnya/hull.scad @@ -1,5 +1,5 @@ // Zhestokost fortress (boss) "Nakovalnya" (anvil). Built from the Zhestokost kit. -// part = lang | brief | anchors | primary | secondary | accent | recess | marking | emit: | rad: +// part = lang | brief | anchors | lines | primary | secondary | accent | recess | marking | emit: | rad: // Frame: Ship Root, metres, nose -Y, starboard -X, up +Z. Anchor yaw: 0 nose, 90 port, 180 aft, 270 starboard. include part = "primary"; @@ -46,10 +46,10 @@ zh_mass(M); zh_mass(T1); zh_mass(T2); zh_mass(APRON); -for (x = CX) translate([x, 0, 0]) zh_mass(CAS); +for (x = CX) lp_at(t=[x, 0, 0]) zh_mass(CAS); zh_mass(SPINE); -zh_mass(KEEL); -for (s = [-1, 1]) translate([s*DX, 0, 0]) zh_mass(DRV, "secondary"); +zh_mass(KEEL, lines=false); // under the hull: no top view sees it +for (s = [-1, 1]) lp_at(t=[s*DX, 0, 0]) zh_mass(DRV, "secondary"); // ---- the casemate guns: lighter cast-steel barrels out of charcoal mantlets ----------------------------------------- module casemate_gun() { @@ -60,9 +60,11 @@ module casemate_gun() { } zh_z("primary") translate([0, GY - 0.7, GZ]) rotate([0, 0, 180]) tube(BR, BL, 8); zh_z("recess") translate([0, GY - 0.7 - BL - 0.81, GZ]) cube([BR*1.2, 0.05, BR*1.2], center=true); + lp_line("fitting", [for (p = zh_rect(4.0, 1.2, GZ + 1.7)) p + [0, GY - 0.3, 0]], true); + lp_line("fitting", [[0, GY - 0.7, GZ + BR], [0, GY - 0.7 - BL, GZ + BR]]); } -for (x = CX) translate([x, 0, 0]) casemate_gun(); -for (x = CX) translate([x, 0, 0]) zh_on_top(CAS, -21.9, 0) zh_stripes(3.6, 0.6, 4); +for (x = CX) lp_at(t=[x, 0, 0]) casemate_gun(); +for (x = CX) lp_at(t=[x, 0, 0]) zh_on_top(CAS, -21.9, 0) zh_stripes(3.6, 0.6, 4); // black reveals between the casemates, on the apron for (u = [-0.33, 0.33]) zh_on_top(APRON, -19.5, u) zh_reveal(2.3, 7.0); @@ -76,9 +78,10 @@ module flak(yaw) { zh_z("secondary") cylinder(r=FR, h=FH + ZH_SINK, $fn=8); zh_z("accent") translate([0, 0, FH*0.35]) cylinder(r=FR + 0.08, h=0.35, $fn=8); // oxide band round the drum zh_z("recess") translate([0, 0, FH - 0.06]) cylinder(r=FR*0.84, h=0.1, $fn=8); - translate([0, 0, FH - 0.02]) rotate([0, 0, yaw]) zh_gun(FG[0], FG[1], FG[2], FG[3], "primary"); + lp_line("fitting", zh_oct(FR, FH), true); + lp_at(t=[0, 0, FH - 0.02], r=[0, 0, yaw]) zh_gun(FG[0], FG[1], FG[2], FG[3], "primary"); } -for (f = FLAK) translate([f[0], f[1], DECK - ZH_SINK]) flak(f[2]); +for (f = FLAK) lp_at(t=[f[0], f[1], DECK - ZH_SINK]) flak(f[2]); function rz(a, p) = [p[0]*cos(a) - p[1]*sin(a), p[0]*sin(a) + p[1]*cos(a), p[2]]; // ---- bow: cab with the slit high on the spine's front step --------------------------------------------------------- @@ -87,7 +90,10 @@ zh_on_top(T2, -3.6, 0) zh_serial(0.7, 4); // ---- dorsal magazine hatches: two rows of four on the mid-deck, clear of the rim ----------------------------------- for (y = [-8.0, -3.4, 1.2, 5.6], u = [-0.78, 0.78]) zh_on_top(T1, y, u) zh_hatch(2.8, 3.0, dogs=false); -for (y = [0.5, 4.5]) zh_on_top(T2, y, 0) zh_z("secondary") translate([0, 0, 0.06]) cube([8.4, 0.5, 0.2], center=true); // plate seams +for (y = [0.5, 4.5]) zh_on_top(T2, y, 0) { // plate seams + zh_z("secondary") translate([0, 0, 0.06]) cube([8.4, 0.5, 0.2], center=true); + lp_line("seam", [[-4.2, 0, 0.16], [4.2, 0, 0.16]]); +} // Feed collar for an Oboz: a lighter cast-steel flange ring standing proud of the flank round the kit collar. module feed_collar() { @@ -97,7 +103,7 @@ module feed_collar() { } // ---- flanks: feed collar, ammunition door, unit serial ------------------------------------------------------------- for (s = [-1, 1]) { - zh_on_belt(M, 1.5, s, 0.5) rotate([0, 0, 22.5]) feed_collar(); + zh_on_belt(M, 1.5, s, 0.5) lp_at(r=[0, 0, 22.5]) feed_collar(); zh_on_wall(M, 7.0, s, 0.45) zh_hatch(2.6, 3.0); zh_on_wall(M, -9.0, s, 0.5) zh_serial(0.8, 5); } diff --git a/tools/blender/aetheria_ships/factions/zhestokost.scad b/tools/blender/aetheria_ships/factions/zhestokost.scad index ae38b0186..01829d368 100644 --- a/tools/blender/aetheria_ships/factions/zhestokost.scad +++ b/tools/blender/aetheria_ships/factions/zhestokost.scad @@ -84,7 +84,22 @@ function zh_stern_at(secs, x, v, o, sink=ZH_SINK) = zh_stern_pt(secs, x, v) - [0 // ==== PRIMARY: masses that make the silhouette ======================================================================== // Any section list as one faceted poured mass. -module zh_mass(secs, zone="primary") zh_z(zone) for (i = [0:len(secs)-2]) hull() { _zh_slice(secs[i]); _zh_slice(secs[i+1]); } +// Lines: the plan at the cast belt (outline), the deck edge (step) and, on a primary mass, a seam across the deck at each +// interior station. lines=false for a mass no top view can see (a keel under the hull). +module zh_mass(secs, zone="primary", lines=true) { + zh_z(zone) for (i = [0:len(secs)-2]) hull() { _zh_slice(secs[i]); _zh_slice(secs[i+1]); } + if (lines) { + n = len(secs); + lp_line("outline", concat([for (s = secs) [-s[1]/2, s[0], s[3] + s[2]*ZH_WU]], + [for (i = [n-1:-1:0]) [secs[i][1]/2, secs[i][0], secs[i][3] + secs[i][2]*ZH_WU]]), true); + lp_line("step", concat([for (s = secs) [-(s[1]/2 - s[4]), s[0], s[3] + s[2]]], + [for (i = [n-1:-1:0]) [secs[i][1]/2 - secs[i][4], secs[i][0], secs[i][3] + secs[i][2]]]), true); + if (zone == "primary") for (i = [1:n-2]) let(s = secs[i]) + lp_line("seam", [[-(s[1]/2 - s[4]), s[0], s[3] + s[2]], [s[1]/2 - s[4], s[0], s[3] + s[2]]]); + } +} +function zh_rect(w, l, z) = [[-w/2, -l/2, z], [w/2, -l/2, z], [w/2, l/2, z], [-w/2, l/2, z]]; +function zh_oct(r, z) = [for (k = [0:7]) [r*cos(45*k), r*sin(45*k), z]]; // A core or whole hull: bow chamfered in plan and dropped (bow, as a fraction of L), stern a flat face with a cast lip. // bowW / bowH: nose width and height as fractions of W and H. @@ -128,6 +143,7 @@ module zh_collar(r=ZH_COLLAR_R, proud=0.55, lugs=4) { zh_z("secondary") difference() { cylinder(r=r, h=proud + ZH_SINK, $fn=8); translate([0, 0, -0.1]) cylinder(r=r*0.6, h=proud + 1, $fn=8); } zh_z("recess") cylinder(r=r*0.62, h=0.08 + ZH_SINK, $fn=8); + lp_line("fitting", zh_oct(r, proud), true); zh_z("accent") for (i = [0:lugs-1]) rotate(i*360/lugs) translate([r + 0.16, 0, (proud + 0.25)/2]) cube([0.5, 0.6, proud + 0.25 + ZH_SINK], center=true); } @@ -140,13 +156,17 @@ module zh_hatch(w=ZH_HATCH, l=ZH_HATCH, t=0.22, dogs=true) { for (x = [-1, 1]) translate([x*w*0.27, 0, t + 0.12]) cbox([0.5, l + 0.6, 0.34], 0.07); if (dogs) for (sx = [-1, 1], sy = [-1, 1]) translate([sx*(w/2 + 0.12), sy*l*0.3, t*0.6]) cube([0.36, 0.44, 0.4], center=true); } + lp_line("fitting", zh_rect(w, l, t), true); translate([0, l/2 - 0.42, t]) zh_stripes(w*0.36, 0.5, dogs ? 3 : 2); } // Armour plate (any frame): replaceable slab with four bolt bosses; leaves a seam to its neighbour. -module zh_plate(w, l, t=0.3, zone="secondary") zh_z(zone) { - translate([0, 0, t/2]) cbox([w, l, t + ZH_SINK], 0.07); - for (sx = [-1, 1], sy = [-1, 1]) translate([sx*(w/2 - 0.25), sy*(l/2 - 0.25), t + 0.05]) cube([0.22, 0.22, 0.12], center=true); +module zh_plate(w, l, t=0.3, zone="secondary") { + zh_z(zone) { + translate([0, 0, t/2]) cbox([w, l, t + ZH_SINK], 0.07); + for (sx = [-1, 1], sy = [-1, 1]) translate([sx*(w/2 - 0.25), sy*(l/2 - 0.25), t + 0.05]) cube([0.22, 0.22, 0.12], center=true); + } + lp_line("fitting", zh_rect(w, l, t), true); } // Radiator trough (top frame): cast frame round a black floor, split by a spine wall; vanes stand in it (zh_rad_vanes). @@ -155,6 +175,8 @@ module zh_rad_trough(w=3.2, l=2.4, d=0.45, zone="secondary") { cbox([w + 0.7, l + 0.7, d + ZH_SINK], 0.1); cube([w, l, d*3], center=true); } zh_z(zone) translate([0, 0, d*0.6]) cube([0.3, l, d*1.2 + ZH_SINK], center=true); zh_z("recess") translate([0, 0, 0.04]) cube([w, l, 0.08 + ZH_SINK], center=true); + lp_line("fitting", zh_rect(w + 0.7, l + 0.7, d), true); + lp_line("fitting", zh_rect(w, l, d), true); } // radiator-mesh for one half of a trough (side 1 port, -1 starboard): transverse vanes rising out of it module zh_rad_vanes(w=3.2, l=2.4, side=1, n=5, h=1.0) { @@ -173,17 +195,24 @@ module zh_tow_lug(s=ZH_LUG, zone="secondary") { // Ballistic mount (top frame): octagonal ring, battered mantlet block, n thick barrels with box brakes, pointing -Y. module zh_gun(n=2, cal=0.22, len=3.0, base=1.8, zone="secondary") { zh_z(zone) cylinder(r=base*0.56, h=0.35 + ZH_SINK, $fn=8); - zh_mass([for (s = zh_block(0, base*0.95, base, base*0.5, 0.33, base*0.14, 0, 0.22)) s], zone); - zh_z(zone) for (i = [0:n-1]) translate([(i - (n-1)/2)*cal*3.2, -base*0.4, 0.33 + base*0.25]) { - rotate([0, 0, 180]) tube(cal, len, 8); - translate([0, -len, 0]) cbox([cal*3, cal*3.5, cal*3], 0.04); + lp_line("fitting", zh_oct(base*0.56, 0.35), true); + zh_mass([for (s = zh_block(0, base*0.95, base, base*0.5, 0.33, base*0.14, 0, 0.22)) s], zone, lines=false); + for (i = [0:n-1]) lp_at(t=[(i - (n-1)/2)*cal*3.2, -base*0.4, 0.33 + base*0.25]) { + zh_z(zone) { + rotate([0, 0, 180]) tube(cal, len, 8); + translate([0, -len, 0]) cbox([cal*3, cal*3.5, cal*3], 0.04); + } + lp_line("fitting", [[0, 0, 0], [0, -len, 0]]); } } function zh_gun_muzzle(n=2, cal=0.22, len=3.0, base=1.8) = [0, -base*0.4 - len - cal*1.75, 0.33 + base*0.25]; // ==== TERTIARY: detail =============================================================================================== // Shadow-gap floor (top frame): the black bottom of a reveal between modules. -module zh_reveal(w, l) zh_z("recess") translate([0, 0, 0.03]) cube([w, l, 0.06 + ZH_SINK], center=true); +module zh_reveal(w, l) { + zh_z("recess") translate([0, 0, 0.03]) cube([w, l, 0.06 + ZH_SINK], center=true); + lp_line("seam", zh_rect(w, l, 0.06), true); +} // Cream safety chevrons (any frame, +Z out): n diagonal bars clipped to w x l. module zh_stripes(w, l, n=3) zh_z("marking") intersection() { translate([0, 0, 0.04]) cube([w, l, 0.1], center=true); From 63e71a1f30f4325fc233ff52917bda98acb635af Mon Sep 17 00:00:00 2001 From: soul Date: Thu, 22 Oct 2026 15:45:04 +0200 Subject: [PATCH 166/444] PilotView tests: sides, tracks, handles, designation, fire-control readouts Co-Authored-By: Claude Sonnet 5.5 --- .../FireControlPerWeaponTests.cs | 44 +++ tests/Aetheria.Shared.Tests/PilotViewTests.cs | 266 ++++++++++++++++++ 2 files changed, 310 insertions(+) create mode 100644 tests/Aetheria.Shared.Tests/PilotViewTests.cs diff --git a/tests/Aetheria.Shared.Tests/FireControlPerWeaponTests.cs b/tests/Aetheria.Shared.Tests/FireControlPerWeaponTests.cs index bd576b16b..2c70cf438 100644 --- a/tests/Aetheria.Shared.Tests/FireControlPerWeaponTests.cs +++ b/tests/Aetheria.Shared.Tests/FireControlPerWeaponTests.cs @@ -400,6 +400,50 @@ public sealed class FireControlPerWeaponTests : IDisposable AssertAimsAtTarget(r); } + // A brain's fire-control readouts are FireControl's own answers on the resolved subject (A-one-sim), and its reveal + // list is the book's weapons in fitted order with Aimable = IsRevealed. Kills: a readout with its own arithmetic, a + // dropped aim, an intercept left at the planar height, a reveal list in book order. + [Fact] + public void ViewReadoutsAreFireControls() + { + var r = Build(velocity: 500f, targetLayerHeight: 40f); + var targetGun = r.Target.Equipment.Single(i => i.Behaviors.OfType().Any()); + r.Shooter.RecordReveal(r.Target, targetGun, SignatureChannel.LaunchFlash, targetGun.Data); + var view = new PilotView(r.Shooter); + var contact = PilotView.Handle(r.Target); + var weapon = r.Gun.Weapon; + var minHit = r.Shooter.ItemManager.GameplaySettings.AgentMinHitProbability; + + Assert.True(FireControl.Designated(weapon, r.Shooter, r.Target, out _)); + Assert.True(view.Designated(r.Shooter, weapon, contact)); + Assert.Equal(FireControl.Refuses(weapon, r.Shooter, r.Target), view.Refuses(r.Shooter, weapon, contact)); + Assert.Equal(FireControl.AgentFires(weapon, r.Shooter, r.Target, minHit, AimTarget.None), + view.Fires(r.Shooter, weapon, contact, minHit, null)); + + var reveal = Assert.Single(view.WeaponReveals(r.Shooter, contact)); + Assert.Same(targetGun.Data, reveal.Claimed); + Assert.Equal(FireControl.IsRevealed(r.Shooter, targetGun), reveal.Aimable); + Assert.True(reveal.Aimable); + Assert.Equal(FireControl.AgentFires(weapon, r.Shooter, r.Target, minHit, AimTarget.OfItem(targetGun)), + view.Fires(r.Shooter, weapon, contact, minHit, reveal.Id)); + Assert.Equal(FireControl.AgentFires(weapon, r.Shooter, r.Target, 0f, AimTarget.OfItem(targetGun)), + view.Fires(r.Shooter, weapon, contact, 0f, reveal.Id)); + Assert.Equal(FireControl.AgentFires(weapon, r.Shooter, r.Target, 2f, AimTarget.OfItem(targetGun)), + view.Fires(r.Shooter, weapon, contact, 2f, reveal.Id)); + + view.SelectItem(r.Shooter, reveal.Id); + view.SelectItem(r.Shooter, null); + PilotApplier.Apply(r.Shooter); + + var expected = FireControl.PredictedIntercept(weapon, r.Shooter, r.Target); + r.Target.Position = new float3(r.Target.Position.x, 0, r.Target.Position.z); + expected = FireControl.PredictedIntercept(weapon, r.Shooter, r.Target); + var planarY = expected.y; + expected.y = r.Target.RideHeight(expected.xz); + Assert.NotEqual(planarY, expected.y); + Assert.Equal(expected, view.Intercept(r.Shooter, weapon, contact)); + } + private static void AssertAimsAtTarget(Rig r) { var toTarget = r.Target.Position - r.Shooter.Position; diff --git a/tests/Aetheria.Shared.Tests/PilotViewTests.cs b/tests/Aetheria.Shared.Tests/PilotViewTests.cs new file mode 100644 index 000000000..5c371de49 --- /dev/null +++ b/tests/Aetheria.Shared.Tests/PilotViewTests.cs @@ -0,0 +1,266 @@ +using System; +using System.Linq; +using CultMath; +using GameCult.Caching; +using Xunit; + +// A side's brain reaches other ships only through its PilotView (Agents/PilotView.cs): handles it issued, tracks only +// of what a member tracks, picture only of what a member's sensors hold hostile. Driven on the IffAndCombatTests world, +// like FlightTests; perception is set directly, a grudge is a real IFF override. +public sealed class PilotViewTests : IDisposable +{ + private readonly IffAndCombatTests _world = new IffAndCombatTests(); + private readonly ItemManager _items; + private readonly Zone _zone; + + public PilotViewTests() => (_items, _zone) = _world.BuildWorld(); + + public void Dispose() => _world.Dispose(); + + private Ship Pilot(Faction faction, float x = 0, float z = 0) + { + var ship = _world.NewShip(_items, _zone, faction, piloted: true); + ship.Position = new float3(x, 0, z); + return ship; + } + + private Ship Subject(float x = 100, float z = 0) + { + var ship = _world.NewShip(_items, _zone, new Faction { Name = "Other" }); + ship.Position = new float3(x, 0, z); + return ship; + } + + private static void See(Ship member, Entity subject) + { + member.EntityInfoGathered[subject] = 1; + if (!member.VisibleEntities.Contains(subject)) member.VisibleEntities.Add(subject); + member.VisibleEnemies.Add(subject); + } + + private PilotView ViewOf(Ship ship) => _zone.Agents.Single(a => a.Ship == ship).View; + + private void Tick(float dt) + { + foreach (var flight in _zone.Flights.ToArray()) flight.Update(dt); + foreach (var entity in _zone.Entities.ToArray()) PilotApplier.Apply(entity); + } + + [Fact] + public void ASideNeverListsItsOwnMember() + { + var a = Pilot(null, -200); + var b = Pilot(null, 200); + a.SetIff(b, true); + b.SetIff(a, true); + See(a, b); + See(b, a); + + Assert.Equal(2, _zone.Flights.Count()); + Assert.Equal(new[] { PilotView.Handle(b) }, ViewOf(a).Picture()); + Assert.Equal(new[] { PilotView.Handle(a) }, ViewOf(b).Picture()); + for (var step = 0; step < 600; step++) + { + Tick(.1f); + foreach (var member in new[] { a, b }) + Assert.NotEqual(PilotView.Handle(member), (_zone.Agents.Single(x => x.Ship == member).Task as FollowTask)?.Anchor); + } + } + + [Fact] + public void AFactionsPictureLeavesOutItsOwnMembers() + { + var faction = new Faction { Name = "Squabblers" }; + var a = Pilot(faction, -200); + var b = Pilot(faction, 200); + var other = Subject(); + See(a, b); + See(b, a); + See(b, other); + + Assert.Equal(new[] { PilotView.Handle(other) }, ViewOf(a).Picture()); + } + + [Fact] + public void APictureIsTheMembersEnemiesInMemberOrderEachOnce() + { + var faction = new Faction { Name = "Plain" }; + var a = Pilot(faction, -10); + var b = Pilot(faction, 10); + var x = Subject(100); + var y = Subject(200); + var z = Subject(300); + See(a, x); + See(a, y); + See(b, y); + See(b, z); + + Assert.Equal(new[] { x, y, z }.Select(PilotView.Handle), ViewOf(a).Picture()); + } + + [Fact] + public void ATrackIsOnlyWhatTheMemberTracks() + { + var member = Pilot(null); + var subject = Subject(120, 30); + subject.Velocity = new float2(3, -4); + subject.Direction = new float2(0, -1); + var threshold = _items.GameplaySettings.TargetDetectionInfoThreshold; + var view = ViewOf(member); + var handle = PilotView.Handle(subject); + + member.EntityInfoGathered[subject] = threshold / 2; + Assert.Null(view.TrackOf(member, handle)); + Assert.Equal(threshold / 2, view.Info(member, handle)); + + member.EntityInfoGathered[subject] = threshold * 2; + var track = view.TrackOf(member, handle); + Assert.NotNull(track); + Assert.Equal(subject.Position, track.Value.Position); + Assert.Equal(subject.Velocity, track.Value.Velocity); + Assert.Equal(subject.Direction, track.Value.Direction); + Assert.Equal(threshold * 2, view.Info(member, handle)); + } + + [Fact] + public void ASideTrackComesFromATrackingMember() + { + var faction = new Faction { Name = "Plain" }; + var a = Pilot(faction, -10); + var b = Pilot(faction, 10); + var x = Subject(100); + var y = Subject(200); + See(b, x); + var view = ViewOf(a); + + Assert.Null(view.TrackOf(a, PilotView.Handle(x))); + Assert.Equal(x.Position, view.SideTrack(PilotView.Handle(x)).Value.Position); + Assert.Null(view.SideTrack(PilotView.Handle(y))); + } + + [Fact] + public void AViewAnswersIffOverridesAndTrespassPerMember() + { + var member = Pilot(null); + var subject = Subject(); + var view = ViewOf(member); + var handle = PilotView.Handle(subject); + + Assert.Null(view.IffOverride(member, handle)); + member.SetIff(subject, true); + Assert.True(view.IffOverride(member, handle)); + member.SetIff(subject, false); + Assert.False(view.IffOverride(member, handle)); + Assert.Equal(member.BelievesTrespassing(subject), view.BelievesTrespassing(member, handle)); + } + + [Fact] + public void SeeingIsTheMembersOwnEnemyList() + { + var faction = new Faction { Name = "Plain" }; + var a = Pilot(faction, -10); + var b = Pilot(faction, 10); + var x = Subject(); + See(b, x); + var view = ViewOf(a); + + Assert.True(view.Sees(b, PilotView.Handle(x))); + Assert.False(view.Sees(a, PilotView.Handle(x))); + } + + [Fact] + public void ATargetIsThePendingDesignationElseTheAppliedTarget() + { + var member = Pilot(null); + var x = Subject(100); + var y = Subject(200); + See(member, x); + See(member, y); + var view = ViewOf(member); + + Assert.Null(view.TargetOf(member)); + Assert.Null(view.AppliedTargetOf(member)); + + view.Designate(member, PilotView.Handle(x)); + Assert.Equal(PilotView.Handle(x), view.TargetOf(member)); + Assert.Null(view.AppliedTargetOf(member)); + + PilotApplier.Apply(member); + Assert.Equal(PilotView.Handle(x), view.AppliedTargetOf(member)); + Assert.Equal(PilotView.Handle(x), view.TargetOf(member)); + + view.Designate(member, PilotView.Handle(y)); + Assert.Equal(PilotView.Handle(y), view.TargetOf(member)); + Assert.Equal(PilotView.Handle(x), view.AppliedTargetOf(member)); + } + + [Fact] + public void DesignatingTheAppliedTargetAgainWritesNothing() + { + var member = Pilot(null); + var x = Subject(100); + See(member, x); + var view = ViewOf(member); + view.Designate(member, PilotView.Handle(x)); + PilotApplier.Apply(member); + + view.Designate(member, PilotView.Handle(x)); + Assert.Null(member.Command.Designated); + + view.Designate(member, null); + Assert.True(member.Command.Designated.Value.IsNone); + PilotApplier.Apply(member); + view.Designate(member, null); + Assert.Null(member.Command.Designated); + } + + [Fact] + public void AHandleIsTheContactAcrossItsAbsence() + { + var member = Pilot(null); + var x = Subject(100); + var y = Subject(200); + var view = ViewOf(member); + See(member, x); + var before = view.Picture().Single(); + + member.VisibleEnemies.Remove(x); + member.VisibleEntities.Remove(x); + Assert.Empty(view.Picture()); + See(member, x); + + Assert.Equal(before, view.Picture().Single()); + Assert.Equal(PilotView.Handle(x), before); + Assert.True(before == PilotView.Handle(x)); + Assert.Equal(PilotView.Handle(x).GetHashCode(), before.GetHashCode()); + Assert.NotEqual(PilotView.Handle(y), before); + Assert.True(PilotView.Handle(y) != before); + } + + [Fact] + public void ACombatingPilotFliesOnlyOnATrackedTarget() + { + var member = Pilot(null); + var subject = Subject(500, 300); + member.SetTarget(subject); + member.Position = new float3(500, 0, 0); + var agent = new Minion(member, FactionDoctrine.Default(_items.GameplaySettings).Combatant) + { Task = new PatrolOrbitsTask { Circuit = new CultRecordKey[1] } }; + + for (var i = 0; i < 4; i++) + { + agent.Update(.1f); + PilotApplier.Apply(member); + } + Assert.True((member.Command.Aim?.z ?? 0) < 100, "an untracked target is not flown on"); + + See(member, subject); + member.SetTarget(subject); + for (var i = 0; i < 4; i++) + { + agent.Update(.1f); + PilotApplier.Apply(member); + } + Assert.Equal(300f, member.Command.Aim.Value.z, 1); + } +} From 5c38029ec0ea8533f4405f657b2e444a5b390d03 Mon Sep 17 00:00:00 2001 From: soul Date: Thu, 22 Oct 2026 15:56:21 +0200 Subject: [PATCH 167/444] Research: crafting chains and non-gear demand, prior art (serves follow-up crafting-chains-and-demand) Co-Authored-By: Claude Opus 5.5 --- .../2026-10-22-crafting-chains-prior-art.md | 166 ++++++++++++++++++ 1 file changed, 166 insertions(+) create mode 100644 docs/research/2026-10-22-crafting-chains-prior-art.md diff --git a/docs/research/2026-10-22-crafting-chains-prior-art.md b/docs/research/2026-10-22-crafting-chains-prior-art.md new file mode 100644 index 000000000..eff29bcf9 --- /dev/null +++ b/docs/research/2026-10-22-crafting-chains-prior-art.md @@ -0,0 +1,166 @@ +# Prior art for follow-up aetheria-release:follow_up:crafting-chains-and-demand (campaign aetheria-living-world), 2026-10-22, eyes self-2026-10-19-ag7 + +Serves: `aetheria-release:follow_up:crafting-chains-and-demand`. Context read: rulings `catalog-crafting-chains`, `quality-is-assembly-over-supply-chain`, `quality-min-tolerance-binned-assembly`, `quality-triangle-is-per-facility` (question label quality-cost-scope), `maker-design-tiers-allowed`, `snowball-damping-lore-institutions`, `matchup-composition-model`. +Local ruling constraints the survey is read against (not conclusions): quality is per production step with assembly bounding the result; the good/fast/cheap triangle is set per facility by its owner, and premium assembly needs a matching supply chain; runaway factions are damped by lore institutions modelled as rules; off-screen fights settle by a learned composition model. + +Provenance tags: [F] = page opened and read this session (WebFetch extraction by a small model, not verbatim); [S] = search-result summary only, page not opened; [M] = from memory, not re-fetched, verify before citing. Several sources blocked fetching (Fandom 402, EVE University 530, some Egosoft wiki pages 404, Paradox forum, web.archive.org, Tesfatsion site certificate error); gaps are named per section. No recommendations. + +## 1. X4: Foundations (Egosoft) + +Chain structure +- Wares are three layers: mined raw (Ice, Ore, Silicon, Nividium solids; Helium, Hydrogen, Methane liquids), intermediates (Refined Metals, Silicon Wafers, Graphene, Antimatter Cells, Water, Microchips), finished goods (Advanced Electronics, Claytronics, Drone Components, Hull Parts, Weapon Components). Chains run 2 to 4 steps: Ore > Refined Metals > Hull/Engine Parts; Silicon > Wafers > Microchips > Advanced Electronics/Claytronics; Methane > Graphene > Composites; Ice > Water > food/medical. Storage types Solid/Liquid/Container with per-ware volume; the wares table lists producers, consumers and max profit per m3. https://wiki.egosoft.com/X4%20Foundations%20Wiki/Manual%20and%20Guides/Objects%20in%20the%20Game%20Universe/Wares/ [F; page flags itself out of date as of 4.0] +- Branching is by faction/race: Food Rations (Meat+Wheat) consumed by Argon habitats; Paranid habitats consume Soja Husk; Medical Supplies exist in Argon, Paranid, Split, Teladi variants, consumed by habitats and parlours of all four races. Same source [F]. +- Energy Cells need only sunlight; secondary wares boost module output (station wiki pages) [S]. + +Demand that is not gear +- Habitation modules consume food and medical wares and produce workforce; workforce gives a production bonus to modules. Community accounts (Steam): optimal worker count gives up to about 20% bonus; per-module bonuses vary (about 10% for one module type, 37 to 46% reported for others); Terran food needs methane, others only ice. [S, player posts, version unclear] +- Egosoft 4.00 beta note (forum mirror): available habitation capacity per race ties to workforce growth rate; workforce info added to production modules and to the habitation section of the build menu. https://sg.hu/forum/uzenet/1481892553/5083 [S] +- Ship/station construction and the NPC "war economy": miners feed factories that feed shipyards; ships destroyed restart the cycle (player description). [S: https://steamcommunity.com/app/392160/discussions/0/733658398226259846/ ] + +Staying alive without the player +- Egosoft store text: "fully simulated and dynamic economy"; factions build and expand stations dynamically in response to economic and military pressure. https://www.egosoft.com/games/x4/info_en.php [F] +- Prices move with how much a station holds or needs relative to storage capacity (wiki station pages). [S] +- Players report NPC factions eventually build stations to fill gaps, and hire other factions to build even after being wiped out; trigger conditions are not documented in anything fetched. [S, Steam] + +Reading the economy +- Transaction Log with income/outgoings breakdown added in 4.00; Trade Rules (3.20) control station trade offers and supply offers. https://wiki.egosoft.com/X4%20Foundations%20Wiki/Game%20Updates%20and%20Patch%20History/X4%20Foundations%20Feature%20Development%20Overview [F] +- Wares table with price range and profit per m3 [F]. Player-written external tool "Empire Logistics Tool" (Excel) for chain planning: https://forum.egosoft.com/viewtopic.php?p=5251793 [S]. + +Failure modes (player testimony, [S], not Egosoft statements) +- New games start with part shortages that stall shipyards/wharfs; advanced electronics gone within first hours on some maps; the bottleneck varies per seed. Players fix by hand-trading missing parts. +- "Economy collapses without intervention" and "lacks the ware sinks earlier X games had"; if ships are not destroyed, factions hit a unit cap and stop demanding from shipyards; war leaves consumers with full storage, which stops purchasing. Effects of inaction appear around 24 hours in, when initial stock runs out. +- Counter-view: a player built 111 stations by manual trading and argued the AI copes and complaints reflect player interference. +- Mods rework it: DeadAir Economy Overhaul https://www.nexusmods.com/x4foundations/mods/2209 (lists "workforce effect strengthened") [S]. +Not found: any Egosoft dev diary or Inside Egosoft text on the 4.0 workforce/habitat model. + +## 2. Anno 1800 (Ubisoft Blue Byte) + +Primary-source status: Fandom wiki blocked (402); no official devblog on needs found. Everything below is wiki/guide/forum summary. + +Chain structure and tiers +- Five Old World tiers (Farmers, Workers, Artisans, Engineers, Investors) and two New World tiers (Jornaleros, Obreros). Houses 3x3. https://segmentnext.com/anno-1800-needs-guide/ [F]; https://en.wikipedia.org/wiki/Anno_1800 [S] +- Needs split Basic and Luxury; each need is met by goods (fish, bread, beer) or a service building (pub, church, school, university). Basic needs drive influx (people per house); luxury needs raise happiness and income/tax. A house upgrades to the next tier when its basic needs are met; Season 4 added "lifestyle needs". segmentnext [F]; Steam guide https://steamcommunity.com/app/916440/discussions/0/4035852798889680783 [F] +- Artisan Residence page lists per-need consumption rate per 30-resident residence, e.g. sausage about 0.000667, bread about 0.000606 (unit not stated). https://anno1800.fandom.com/wiki/Artisan_Residence [S] +- Workforce couples tiers to industry: potato farm needs 20 farmers, steelworks 200 workers (PC Gamer tips). https://www.pcgamer.com/anno-1800-tips-guide/ [S]. Engineers bring electricity doubling nearby factory productivity [F Steam guide]. +- Chain ratios: clicking a production building shows its cycle rate, so ratios are computed by the player [S]; community calculators exist (annocalc) [M]. + +Demand not gear +- All consumer demand is residents' needs, tied to tier and house count; higher tiers are the main income ("Investors pay for increasingly complex consumer goods") [F Steam guide]. Raising output can cost happiness; extreme cases riot and halt production [S]. + +Stalls and snowballing +- A goods deficit makes residents unhappy and they leave, lowering income, so expansion beyond supply self-corrects downward [F Steam guide]. Upkeep per production building is high, penalising sprawl [S]. Passive trade is limited and expensive; Docklands DLC adds barter scaling with storage [F]. + +Reading the economy +- Hover on a need shows money earned per house [F]; UI shows connected buildings and supply-chain position [S, Aureview review]. One forum poster calls surplus/deficit "extremely fuzzy" in 1800 compared with Anno 2205 unless running large surpluses [S]. Lifestyle needs made it harder to tell when houses are ready to upgrade [S]. + +## 3. Victoria 3 (Paradox) + +Chain structure +- Buildings form a web producing and/or consuming goods; they do nothing without staff. Dev Diary 57: https://paradoxinteractive.com/games/victoria-3/news/dev-diary-57-the-journey-so-far [F] +- Production methods (PMs): each building has PM groups (Base, Secondary, Automation, Organization, Military), exactly one active PM per group; locked PMs need tech, law, another PM, or goods produced in state/market; if requirements lapse the active PM falls to the first valid one in the group. Inputs/outputs scale with building level x employment x throughput. https://vic3.paradoxwikis.com/Production_method [F] +- Dev Diary 5 (search summary only): PMs are trade-offs rather than straight upgrades; categories Base, Refining (shifts to luxury/specialised output), Automation (adds industrial goods input to reduce unskilled labour). Direct fetch of the diary 404'd; forum thread https://admin-forum.paradoxplaza.com/forum/developer-diary/victoria-3-dev-diary-5-production-methods.1480760/ [S] +- Goods categories: staple, luxury, industrial, military; staple and luxury mostly consumed by pops, industrial and military mostly by buildings. Local goods are state-bound and not traded. https://vic3.paradoxwikis.com/Goods [F] + +Demand not gear +- Pops staff buildings, earn wages, spend on goods; owners spend profits on luxuries; peasants subsistence-farm unused land. Dev Diary 57 [F]. +- Needs: a buy package per wealth level (base-price spending target per need category for 10,000 working adults); pops buy until units x base price reaches the package value, so pricier goods satisfy a need in fewer units; pops ignore price when choosing among goods in a need (split by market share, weight, min/max share); dependents count half; peasants buy 5% of standard; relative substitution capped at 1% to 10% per week; culture obsessions raise a good's weight, taboos halve demand; need patterns across wealth levels are temporary, constant or exponential. https://vic3.paradoxwikis.com/Needs [F, v1.13] +- Standard of living equals wealth level; higher SoL means larger, costlier needs; birth/mortality driven by SoL, growth levels off near SoL 15. https://vic3.paradoxwikis.com/Pop [F] +- Institutions (healthcare, education, policing) are pop-staffed and consume a Bureaucracy currency produced by buildings, so players need not micromanage facilities. Dev Diary 57 [F]. + +Stalls and snowballing +- Price per good floats up to +/-75% of base cost with relative supply and demand; base cost = price when buy and sell orders are equal. Goods page [F]. +- Trade routes grow until marginal profit ends; the earlier trade system needed micromanagement and was exploitable, including by AI; replaced by profit-responsive routes with tariffs/embargoes. Dev Diary 57 [F]. +- Unemployment handled by subsistence farming, which may widen wealth gaps in the industrial transition. Dev Diary 57 [F]. + +Reading the economy: goods have a base cost and per-market price; pops show SoL and needs (wiki pages above) [F]. No fetched source describes the UI. + +Failure modes (player testimony [S]: Steam threads, e.g. https://steamcommunity.com/app/529340/discussions/0/5971271658111594308 ) +- Over-profitable buildings drive their goods' prices very low while AI keeps expanding them; after the 1.5 economy change players report nearly all goods in balance or oversupplied, exports hard; construction-sector over-building forces deficit spending; the jobseeker system made new factories hard to staff; construction sector cost rose from 50 to 100 points, slowing growth. + +## 4. EVE Online (CCP) + +Chain structure +- Mining yields ore; reprocessing yields minerals; minerals are inputs to nearly all manufacturing. Net reprocessing yield depends on equipment, skills, implants and standings (worked example: base 50%, about 69.9% with skills). Compressed, batch-compressed and uncompressed ore yield the same minerals per batch. https://wiki.eveuniversity.org/Minerals , /Refining , /Reprocessing [S; direct fetch blocked, 530]. Wiki flags the yield page as needing update after the October 2020 patch [S]. +- Moon materials must go through reactions before use [S]. Blueprint originals have infinite runs, copies limited; T2 comes via Invention producing limited-run T2 blueprint copies (baseline 10 runs, +2% ME, +4% TE, decryptors modify); T2 originals are rare lottery-era relics. https://wiki.eveuniversity.org/Invention , /Blueprint_Original [S]. Industry jobs carry a per-system cost index (page /System_Cost_Index; title only, content not read). +- Chain shape per these pages: raw > refined mineral > component > ship/module, plus a moon > reaction > advanced-material branch and an invention branch gated by datacores [S/M]. + +Demand not gear / sinks +- CCP economist Dr Eyjolfur Gudmundsson, Fanfest 2011 as reported: warfare and the economy thrive on each other; very little consumption without warfare; wars cannot be won without a strong economy; PvP destruction presented as essential to economic health. https://www.engadget.com/2011-04-04-ccp-releases-eve-quarterly-economic-newsletter.html [F] +- Fanfest 2014 framing: ISK faucets versus sinks; too much entering risks rapid inflation, too little risks collapse (secondhand, Engadget tag page) [S]. Varoufakis essay on war spikes in EVE (2014): https://www.yanisvaroufakis.eu/2014/01/30/war-spikes-in-the-eve-online-universe-a-political-economists-account/ [fetch returned 403; content not read]. +- Monthly Economic Report format: destruction, production, mining value, money supply, ISK velocity, price indices (mineral, ship, module), sinks-and-faucets graphic; Sept 2025 methodology change made production and mining use static pricing. https://www.eveonline.com/news/view/monthly-economic-report-february-2026 [F]; https://www.eveonline.com/news/view/monthly-economic-report-september-2025 [S]. Feb 2026: destruction up while mining and production down, velocity falling, mineral index about -52% yoy, ship about -21%, module about -13% [F]. + +Avoiding stalls / snowballing +- No fetched CCP text describes stall avoidance. Structure is player-driven; destruction is presented as the demand sink (above). Cost index named only [S]. + +Reading the economy: public MER with downloadable data; in-game market history [F/M]. + +Failure modes +- Forum critique built on the Feb 2026 MER: destruction rising while mining, production and mineral prices fall contradicts "destruction drives the economy" (player reading). https://forums.eveonline.com/t/the-economy-isnt-broken-and-destruction-is-not-good/286457 [S]. Player claim that CCP interventions from the 2020 Ecosystem Outlook reduced player wealth [S, forum opinion]. A 2021 thread questioned regional destruction totals versus the overall figure; one commenter suggested reimbursed losses distort them [S]. +- 2012 CCP Recurve dev blog on inflation (mineral prices +17.4%, CPI +13.5%, as cited by a forum user) [S, unverified]. + +## 5. Starsector (Fractal Softworks): small-scale commodity demand per colony + +Wiki current for 0.98a. All [F] unless noted. +- Eighteen commodities with base price and "economic unit" (Food 20 / 2000, Metals 30 / 2000, Domestic Goods 50 / 1000, Luxury Goods 100 / 500, Heavy Machinery 150 / 300, Heavy Armaments 500 / 200, Recreational Drugs 200 / 400, Harvested Organs 300 / 200, others). https://starsector.wiki.gg/wiki/Commodities +- Each industry lists commodities it produces and demands, amounts as formulas of colony size (Farming produces Food at Size and demands Heavy Machinery at Size-3); industry slots = Size-2; upkeep scales with size. https://starsector.wiki.gg/wiki/Industry +- Population and Infrastructure demand for a colony of size X: Food X, Domestic Goods X-1, Luxury Goods X-3, Recreational Drugs X-2, Harvested Organs X-3, Supplies 3 flat, Organics X-1 on non-habitable planets. Meeting Domestic Goods and Luxury demand each gives +1 stability; failing Food or Organics gives a stability penalty equal to the larger shortage. https://starsector.wiki.gg/wiki/Population_%26_Infrastructure +- Trade fleets move goods between markets (physical convoys near the core, simulated elsewhere); destroyed convoys cause "Shipping Disruption" (temporary shortages, price shift, accessibility penalty); pirate activity creates shortages and excess; player sales fill demand for 120 days, listings refresh every 30; buying raises and selling lowers unit price; a market with no demand for a good pays well below base; 30% tariff on open markets; stability changes prices and black-market share. https://starsector.wiki.gg/wiki/Trade , /Market +- Shortage and surplus figures (Deficit/Excess) appear in the F1 tooltip so players can see how much to trade before prices normalise (needs a comm relay). Trade page. +- The wiki does not say how a deficit affects industry output. + +## 6. Elite Dangerous Background Simulation (Frontier): faction economy and expansion limits + +- Faction influence in a system is zero-sum across factions; control tracks the controlling station, not highest influence; a faction needs 75% influence to expand, expands to the nearest system within 20 LY with fewer than seven factions, else the expansion becomes an invasion; below 2.5% influence a faction withdraws and loses wealth elsewhere; below 7.5% it cannot enter conflicts. https://www.dpss.space/bgs [F; community guide, not Frontier] +- Economy states Famine/Bust/None/Boom/Investment shift happiness and mission rewards; Boom lasts about three weeks or until accrued value is spent; Famine is ended by delivering food [F same page]. Frontier's own description (2018, as relayed): daily calculations apply player actions to faction attributes [S: https://massivelyop.com/?p=192372 ]; four BGSes in 2024 (Political, Economic, Outfitting, Thargoid), Thargoid states suspend political movement [S, Frontier forum]. + +## 7. Factorio and Satisfactory (chain depth and legibility only) + +Factorio +- Science packs set depth: Automation about 3 raw items per pack; Logistic; Military; Chemical (crude oil); Production; Utility about 190 raw (33.3 iron, 49.8 copper, 3.8 coal, 106.8 crude); Space Age packs up to about 477 raw per pack. Demand for the whole factory comes from lab research cost = pack count x pack types. Assembler ratios per pack published (base game 5:6:5:12:7:7). https://wiki.factorio.com/Science_pack [F] +- Space Age agricultural packs spoil, reducing science value over time [F same page]. +- Production statistics and ratio calculators as legibility aids [M]; not fetched. + +Satisfactory +- Progression through HUB milestones grouped in tiers; Space Elevator phases unlock higher tiers; delivered parts cannot be retrieved. https://satisfactory.wiki.gg/wiki/Milestone , /Tiers [S] +- By-product stall: Heavy Oil Residue is a by-product of Plastic and Rubber in the Refinery; if it backs up, the primary product stops too; outlets are Petroleum Coke (4 HOR > 12 coke), Fuel/Turbofuel, Smokeless Powder, packaging. https://satisfactory.wiki.gg/wiki/Heavy_Oil_Residue [F] +- AWESOME Sink destroys sinkable items for points (by item value); points buy coupons whose price rises in steps; wiki advises an overflow splitter feeding the Sink so lines never clog; throughput up to 1200 items/min on a Mk.6 belt. https://satisfactory.wiki.gg/wiki/AWESOME_Sink [F] + +## 8. RimWorld and Dwarf Fortress (need-driven demand) + +- RimWorld: need bars (Food, Rest, Recreation, Beauty, Comfort, Indoors/Outdoors, Chemical, others) fall by themselves and refill by eating, sleeping, recreation, drugs; needs feed mood through threshold thoughts; very low mood triggers mental breaks. https://rimworldwiki.com/wiki/Needs [F] +- Dwarf Fortress: idle dwarves eat, drink or sleep when only slightly in need; trade is by caravan to a depot; earlier versions had a monetary system later dropped. https://dwarffortresswiki.org/index.php/v0.34:Eat , /DF2014:Trade [S]; commentary https://crookedtimber.org/2014/01/07/dwarf-fortress-a-marxist-analysis/ [S]. Noble mandates page fetch failed (429); mandate mechanics not verified. + +## 9. Virtual-economy design literature (faucets and sinks) + +- Lehdonvirta and Castronova, "Virtual Economies: Design and Analysis" (MIT Press 2014): applies microeconomics to artificially scarce virtual goods. Reading summary describes faucets (create assets), sinks (remove assets: transaction taxes, service fees, repair, limited lifespans), inflation when faucets exceed sinks, deflation causing hoarding. Book text not opened. https://www.antoinebuteau.com/virtual-economies-book-summary/ ; https://blogs.lse.ac.uk/usappblog/2014/09/28/book-review-virtual-economies-design-and-analysis-by-vili-lehdonvirta-and-edward-castronova [S] +- Gold sink (Wikipedia): passive sinks (decay, taxes), active sinks (designer-added), feedback controllers adjusting drop rates and prices; Alter Aeon taxed holdings above 1M at 2% of the excess and kept the currency peak within 10% over two years; Ultima Online NPC vendors sold unmakeable "blue" armor at inflated prices and after Renaissance sold luxury items to absorb money; Kingdom of Loathing and RuneScape (Construction skill) examples. https://en.wikipedia.org/wiki/Gold_sink [F; article flagged single-source] +- Zack Booth Simpson, "The In-game Economics of Ultima Online" (GDC): primary source for UO price/inflation measurement; the fetched page exposed only the introduction (body is images/truncated). https://gwern.net/doc/economics/1999-04-07-simpson-theingameconomicsofultimaonline.html [F, not read through] +- Raph Koster, "UO's resource system": objects carry abstract resource labels with amount, max and regrowth; AI creatures have FOOD, SHELTER, DESIRE, PRODUCE lists attempted in Maslow-like order; the beta added spontaneous generation and a closed economy loop. The post does not state the failure; reader comments recall animals hunted to near-extinction and the system removed, and one commenter advises that a dynamic ecology must be constrained heavily to stay predictable. https://www.raphkoster.com/?p=517 [F] +- Koster on SWG: player shops with NPC bot; crafters formed supply chains and customer bases; half of players ran a shop at peak. https://www.raphkoster.com/2012/03/20/do-auction-houses-suck/ [F, snippet]. Faucet-drain model (faucets mint, sink is the in-game pool, drains exit) from a 2004 analysis: https://boingboing.net/2004/05/03/star-wars-galaxies-e.html [S]. Item damage and decay appear in his economy spectrum where items are player-sourced [S, post not identified]. +- WildStar (Carbine) dev blog 2013: item repair as "a very handy and fair money sink" that should not be painful because it offers little choice. https://www.engadget.com/2013-05-31-wildstar-economy-post-talks-item-decay-raph-koster-and-more.html [F] + +## 10. Academic: agent-based computational economics + +- Tesfatsion, "Agent-Based Computational Economics: A Constructive Approach to Economic Theory", Handbook of Computational Economics vol. 2 (2006), ch. 16: economies as dynamic systems of interacting agents; covers production, pricing and trade processes, strategic rivalry, learning, institutions; illustrates with a two-sector decentralised market model. https://ideas.repec.org/p/isu/genres/12514.html ; https://faculty.sites.iastate.edu/tesfatsi/archive/tesfatsi/hbace.htm [S; site cert error, chapter not opened] +- Dosi, Fagiolo, Roventini, "Schumpeter meeting Keynes" (K+S): capital-good firms do R&D and build machines, consumption-good firms invest and produce a homogeneous consumption good, workers, a bank; finds complementarity of demand generation and innovation shapes cycles and growth; extension with income distribution, credit, fiscal policy (Dosi, Fagiolo, Napoletano, Roventini); LEM WP 2014/21 with Treibich; Dosi and Roventini, Cambridge Elements "Agent-Based Macroeconomics" (2025). https://www.cambridge.org/core/elements/abs/agentbased-macroeconomics/B7016A5CEE59694A420AF51A1347F00E [F, abstract only]; https://www.lem.santannapisa.it/WPLem/2014-21.html [S] +- Epstein and Axtell, "Growing Artificial Societies" (1996), Sugarscape: renewable sugar on a grid, agents with metabolism and vision; skewed wealth emerges; adding spice allows trade and an emergent market. https://jasss.org/12/1/6/appendixB/EpsteinAxtell1996.html [S, not opened] +- Hallegatte ARIO (Adaptive Regional Input-Output): input-output structure plus production capacity limits, input scarcity, inventories, adaptive behaviour; Katrina total cost about $149B against $107B direct; most sensitive parameters were construction-sector overproduction capacity and adaptation time; losses nonlinear above about $50B direct. https://hats.arizona.edu/node/300 [S]. Extensions R-ARIO and a credit-based ARIO [S]. + +## 11. Leontief input-output dynamics + +- Static model x = Ax + y, x = (I-A)^-1 y; a nonnegative output exists for any nonnegative final demand iff the Hawkins-Simon condition holds (leading principal minors of I-A positive; equivalent to some x>=0 with (I-A)x>0); the Neumann series converges when the largest eigenvalue of A is less than 1. https://en.wikipedia.org/wiki/Input%E2%80%93output_model [F]; https://en.wikipedia.org/wiki/Hawkins%E2%80%93Simon_condition [F]. In plain terms: a closed chain must yield more of each good than the chain itself consumes, or positive final demand cannot be satisfied. +- Dynamic Leontief model adds capital coefficients B; B is often singular; forward-looking solutions were "unrealistic and fluctuating" and interest declined after the 1970s; one author group claims a positive capital matrix makes the dynamic model always unstable (single-group claim). Wikipedia [F]; Blanc Diaz and Ramos https://www.iioa.org/conferences/intermediate-2004/pdf/blanc.pdf [S]. +- Critiques: fixed prices, constant returns, poor handling of relative price changes, ignores resource constraints. Wikipedia [F]. +- Not found: a game-development source applying Hawkins-Simon or spectral-radius checks to recipe graphs. Linear-solver recipe calculators for Factorio/Satisfactory use this form [M]. + +## 12. Index of observed items by report axis + +- Chain depth: X4 2 to 4 steps with race branches; Factorio 3 to 7+ steps (up to about 477 raws per pack); Satisfactory by-product branches; Vic3 a web of buildings with PM-swapped inputs; Anno short chains plus service buildings feeding tiers; Starsector 1 to 2 steps; EVE 3 to 5 steps plus reaction and invention branches. +- Non-gear demand drivers: pop-tier needs (Anno, Vic3, Starsector colony size), workforce food/medical (X4), research consumption (Factorio), destruction (EVE, X4 war economy), points sink (Satisfactory), mood needs (RimWorld, DF), stability from met demand (Starsector), happiness (Anno). +- Stall mechanisms: seed-dependent shortages and full-storage consumers (X4), by-product backup (Satisfactory), +/-75% price band (Vic3), deficit makes residents leave (Anno), shipping disruption (Starsector). +- Anti-snowball mechanisms: zero-sum influence, expansion thresholds and retreat (Elite BGS, community guide), upkeep per building (Anno), profit-bounded trade routes (Vic3), industry cap Size-2 (Starsector), system cost index (EVE, name only). +- Legibility devices: transaction log and wares table (X4), hover income per need (Anno), Deficit/Excess tooltip (Starsector), MER with data download (EVE), production statistics (Factorio [M]). +- Failure modes with player or developer comment: under each section (X4 s1, Anno s2, Vic3 s3, EVE s4, UO s9). + +## Gaps this pass did not close +- No first-party Egosoft text on the 4.0+ workforce/habitat model; no Anno devblog; no CCP blog on reprocessing/reaction design or on the destruction-demand claim beyond the 2011 report; Vic3 market dev diaries (prices, buy/sell orders) not located; DF mandates not verified; Simpson UO paper body not read; Tesfatsion chapter and Dosi papers read only to abstract level. From b982fb9cd082c2abd451e964fcf12e872cb0876d Mon Sep 17 00:00:00 2001 From: soul Date: Thu, 22 Oct 2026 15:57:44 +0200 Subject: [PATCH 168/444] hull_source: kit lines echo, SVG silhouette projector, SchematicLine, three line checks Co-Authored-By: Claude Sonnet 5.5 --- tools/blender/aetheria_ships/hull_source.py | 168 ++++++++++++++-- tools/blender/tests/test_hull_source.py | 200 +++++++++++++++++++- 2 files changed, 347 insertions(+), 21 deletions(-) diff --git a/tools/blender/aetheria_ships/hull_source.py b/tools/blender/aetheria_ships/hull_source.py index fb2aa246a..ca929560f 100644 --- a/tools/blender/aetheria_ships/hull_source.py +++ b/tools/blender/aetheria_ships/hull_source.py @@ -11,11 +11,16 @@ with it (a hull that redefines or shadows an included language fails a check): (every included language file echoes its own, and so does factions/.scad run alone; a Pirate language has no body zone and no proportion) anchors ECHO anchor = [id, role, [x, y, z], yaw, parent] one line per anchor, yaw 0 nose, 90 port + lines ECHO line = [layer, cyclic, [[x, y, z], ...]] one line per kit part that draws one, points in the Ship Root + frame (lowpoly.scad lp_line under lp_at frames); layer is one of KIT_LAYERS the zone's geometry, OFF; a donor's zone is named ":" emit:, rad: the mesh of a thruster-emitter or radiator-mesh anchor Frame: Ship Root, metres, nose -Y, starboard -X, up +Z. build() also takes the hull's HullData.HullType ("Ship", "Station" or "Turret"), which the caller reads: the add-on from the bound package's ship.cc, the bench from its flag. +Schematic lines (HullBuild.lines): the top-view silhouette first (OpenSCAD projection() of every zone and anchor mesh, +read from the SVG it exports), then the kit's lines, each with a fixed RGBA from LINE_STYLE. A kit part is placed with +lp_at so its line is emitted in the frame the geometry is; a source that echoes no lines has the silhouette alone. A faction language's proportions judge Ship hulls only; every hull's zones must fit the layout grid (hull_grid.MAX_CELLS cells of hull_grid.CELL_SIZE a side). The brief's role is prose and decides nothing. build() returns a HullBuild; the caller refuses on any failed check. Messages and check names are fixed text: nothing a hull source says is echoed back. @@ -49,9 +54,18 @@ OPENSCAD_TIMEOUT = 180 # seconds, per part SILHOUETTE_CELL = 0.25 # metres, the top-view raster the symmetry check compares ANCHOR_ROLES = ("weapon-mount", "weapon-muzzle", "thruster-emitter", "radiator-mesh") MESH_ROLES = {"thruster-emitter": "emit:", "radiator-mesh": "rad:"} -ECHO_PARTS = ("brief", "lang", "anchors") # parts that make no geometry; their ECHO lines are the answer +ECHO_PARTS = ("brief", "lang", "anchors", "lines") # parts that make no geometry; their ECHO lines are the answer LANGUAGE_NAME = re.compile(r"[a-z][a-z0-9_]*") ECHO_LINE = re.compile(r"^ECHO: (\w+) = (.*)$", re.MULTILINE) +KIT_LAYERS = ("outline", "step", "seam", "fitting") # layers a kit's lines may name; "silhouette" is hull_source's own +LINE_STYLE = {"silhouette": (1.0, 1.0, 1.0, 1.0), "outline": (1.0, 1.0, 1.0, 0.75), "fitting": (1.0, 1.0, 1.0, 0.6), + "step": (1.0, 1.0, 1.0, 0.5), "seam": (1.0, 1.0, 1.0, 0.35)} +MIN_SILHOUETTE_AREA = 0.25 # m2, below which a projected contour is a sliver, not the hull +LINE_EDGE_TOLERANCE = 0.3 # m, how far outside the silhouette's edge a kit line's point may lie +DUPLICATE_CELL = 0.001 # m, kit lines whose points all agree to this are the same line + +# A projector answers a list of Meshes with the contours (lists of (x, y)) of their top-view union. +Projector = Callable[[list], "list[list[tuple]]"] # A runner answers one part of the hull source with (OFF text, ECHO text). OFF text is "" when the part made no geometry. Runner = Callable[[str], "tuple[str, str]"] @@ -109,6 +123,16 @@ class Anchor: parent: str +@dataclass(frozen=True) +class SchematicLine: + layer: str # "silhouette" or one of KIT_LAYERS + cyclic: bool + points: tuple # ((x, y, z), ...) in the Ship Root frame + + def rgba(self): + return LINE_STYLE[self.layer] + + @dataclass(frozen=True) class Check: name: str @@ -129,6 +153,7 @@ class HullBuild: body_zone: str # the zone key the collider is made from hull_type: str # HullData.HullType of the bound package ("Ship", "Station", "Turret"), as the caller read it checks: list = field(default_factory=list) + lines: list = field(default_factory=list) # SchematicLine: the silhouette contours first, then the kit's lines def failed(self): return [check for check in self.checks if not check.ok] @@ -250,6 +275,47 @@ def read_anchors(text): return anchors +def read_lines(text): + """The kit's schematic lines, in echo order. A layer outside KIT_LAYERS, fewer than two points or a point that is not + three finite numbers is refused.""" + lines = [] + for raw in parse_echoes(text, "line"): + if not isinstance(raw, list) or len(raw) != 3 or type(raw[1]) is not bool or not isinstance(raw[2], list): + raise HullSourceError("A schematic line has the wrong shape") + layer = _text(raw[0]) + if layer not in KIT_LAYERS: + raise HullSourceError("A schematic line names a layer a kit does not draw") + if len(raw[2]) < 2: + raise HullSourceError("A schematic line has fewer than two points") + points = tuple(_vector(p, 3) for p in raw[2]) + if not all(math.isfinite(c) for p in points for c in p): + raise HullSourceError("A schematic line has a point that is not a finite number") + lines.append(SchematicLine(layer, raw[1], points)) + return lines + + +def to_off(mesh): + """The OFF text of a Mesh.""" + rows = ["OFF", f"{len(mesh.vertices)} {len(mesh.triangles)} 0"] + [" ".join(repr(float(c)) for c in v) for v in mesh.vertices] + return "\n".join(rows + [f"3 {a} {b} {c}" for a, b, c in mesh.triangles]) + "\n" + + +def parse_svg_contours(text): + """The contours of an OpenSCAD SVG (a path of 'M x,y L x,y ... z' pieces) as lists of model (x, y): the SVG's y is the + model's -y. A contour under MIN_SILHOUETTE_AREA is dropped (the zero-area slivers a projection leaves).""" + contours = [] + for path in re.findall(r'= 3 and area >= MIN_SILHOUETTE_AREA: + contours.append(ring) + return contours + + def _exactly_one(values, what): if len(values) != 1: raise HullSourceError(f"The hull source must echo exactly one {what}") @@ -289,9 +355,36 @@ def _language(library, faction): return language -def build(runner, library, hull_type): +def _point_in_ring(x, y, ring): + inside = False + for (ax, ay), (bx, by) in zip(ring, ring[1:] + ring[:1]): + if (ay > y) != (by > y) and x < ax + (y - ay) * (bx - ax) / (by - ay): + inside = not inside + return inside + + +def _edge_distance(x, y, ring): + best = float("inf") + for (ax, ay), (bx, by) in zip(ring, ring[1:] + ring[:1]): + dx, dy = bx - ax, by - ay + along = ((x - ax) * dx + (y - ay) * dy) / (dx * dx + dy * dy) if dx or dy else 0.0 + along = max(0.0, min(1.0, along)) + best = min(best, math.hypot(x - (ax + along * dx), y - (ay + along * dy))) + return best + + +def over_silhouette(point, contours): + """Whether the point's top view lies inside the silhouette (even-odd over every contour) or within + LINE_EDGE_TOLERANCE of a contour's edge.""" + x, y = point[0], point[1] + return (sum(_point_in_ring(x, y, ring) for ring in contours) % 2 == 1 + or any(_edge_distance(x, y, ring) <= LINE_EDGE_TOLERANCE for ring in contours)) + + +def build(runner, library, hull_type, projector): """Reads the hull source through runner (see Runner) and judges it against the languages library(faction) answers with - (the ECHO text of that faction's own language file). hull_type is the hull's HullData.HullType name, which the caller + (the ECHO text of that faction's own language file). projector (see Projector) answers the top-view contours of every + zone and anchor mesh, which become the silhouette line. hull_type is the hull's HullData.HullType name, which the caller reads; only a "Ship" is judged on the language's proportions. Raises HullSourceError, before reading anything, when hull_type is not one of ship_cc.HULL_TYPE_NAMES, and when the source cannot be read at all; otherwise returns a HullBuild whose failed() lists the checks it breaks.""" @@ -314,10 +407,16 @@ def build(runner, library, hull_type): if len(set(ids)) != len(ids) or any(a.role not in ANCHOR_ROLES for a in anchors): raise HullSourceError("The hull source's anchors repeat an id or name a role a hull source does not make") anchor_meshes = {a.id: parse_off(runner(MESH_ROLES[a.role] + a.id)[0]) for a in anchors if a.role in MESH_ROLES} + kit_lines = read_lines(runner("lines")[1]) judge = donor or own body_zone = f"{donor.faction}:{donor.body}" if donor else own.body result = HullBuild(brief, {language.faction: language for language in owned}, zones, colours, anchors, anchor_meshes, own.glow, own.radiator, body_zone, hull_type) + meshes = [m for m in list(zones.values()) + list(anchor_meshes.values()) if m.triangles] + if meshes: + top = max(v[2] for m in meshes for v in m.vertices) + result.lines = [SchematicLine("silhouette", True, tuple((x, y, top) for x, y in contour)) for contour in projector(meshes)] + result.lines += kit_lines by_faction = lambda language: language.faction result.checks = [Check("the hull includes its faction's language files and changes none of them", sorted(included, key=by_faction) == sorted(owned, key=by_faction), "")] @@ -366,6 +465,14 @@ def _checks(hull, own, judge): f"{width} x {height} cells, at most {hull_grid.MAX_CELLS} a side")) except ValueError: checks.append(Check("the top view fits the layout grid", False, "no geometry")) + silhouette = [line for line in hull.lines if line.layer == "silhouette"] + kit = [line for line in hull.lines if line.layer != "silhouette"] + contours = [[p[:2] for p in line.points] for line in silhouette] + checks.append(Check("the top view yields a silhouette", bool(silhouette), f"{len(silhouette)} contours")) + outside = [line for line in kit if not all(over_silhouette(p, contours) for p in line.points)] if silhouette else kit + checks.append(Check("every kit line lies over the hull's top view", not outside, f"{len(outside)} outside")) + keys = [tuple(tuple(round(c / DUPLICATE_CELL) for c in p) for p in line.points) for line in kit] + checks.append(Check("no two kit lines are the same", len(set(keys)) == len(keys), f"{len(keys) - len(set(keys))} repeated")) triangles = sum(len(m.triangles) for m in every) checks.append(Check("the triangle count lies inside the brief's budget", brief.triangles[0] <= triangles <= brief.triangles[1], f"{triangles}")) @@ -380,6 +487,25 @@ def _checks(hull, own, judge): return checks +def _run_openscad(command, on_start, **options): + """Runs one OpenSCAD process to completion and answers its output. The refusals are fixed text.""" + try: + process = subprocess.Popen(command, stdout=subprocess.PIPE, stderr=subprocess.STDOUT, text=True, **options) + except OSError: + raise HullSourceError("OpenSCAD could not be started") from None + if on_start: + on_start(process.pid) + try: + output, _ = process.communicate(timeout=OPENSCAD_TIMEOUT) + except subprocess.TimeoutExpired: + process.kill() + process.communicate() + raise HullSourceError("OpenSCAD did not finish a part in time") from None + if process.returncode != 0: + raise HullSourceError("OpenSCAD refused a part of the hull source") + return output + + def openscad_runner(executable, source, library=FACTIONS_DIR, on_start=None): """A Runner that runs OpenSCAD (Manifold backend, full render) on source, one process at a time. on_start(pid) is called with each process's id. OPENSCADPATH is the language folder, so a hull source includes .""" @@ -390,20 +516,7 @@ def openscad_runner(executable, source, library=FACTIONS_DIR, on_start=None): target = Path(folder) / ("part.echo" if part in ECHO_PARTS else "part.off") command = [str(executable), "--backend", "Manifold", "--render", "all", "-D", f'part="{part}"', "-o", str(target), str(source)] - try: - process = subprocess.Popen(command, stdout=subprocess.PIPE, stderr=subprocess.STDOUT, text=True, env=env) - except OSError: - raise HullSourceError("OpenSCAD could not be started") from None - if on_start: - on_start(process.pid) - try: - output, _ = process.communicate(timeout=OPENSCAD_TIMEOUT) - except subprocess.TimeoutExpired: - process.kill() - process.communicate() - raise HullSourceError("OpenSCAD did not finish a part in time") from None - if process.returncode != 0: - raise HullSourceError("OpenSCAD refused a part of the hull source") + output = _run_openscad(command, on_start, env=env) written = target.read_text(encoding="utf-8", errors="replace") if target.exists() else "" return ("", written + output) if part in ECHO_PARTS else (written, output) @@ -420,3 +533,24 @@ def language_library(executable, library=FACTIONS_DIR, on_start=None): return openscad_runner(executable, file, library=library, on_start=on_start)("lang")[1] return read + + +def openscad_projector(executable, on_start=None): + """A Projector that has OpenSCAD (Manifold backend) project the union of the meshes onto the XY plane and export it as + SVG, one process, with the same timeout and refusal shape as openscad_runner.""" + def project(meshes): + with tempfile.TemporaryDirectory() as folder: + names = [] + for i, mesh in enumerate(meshes): + (Path(folder) / f"m{i}.off").write_text(to_off(mesh), encoding="utf-8") + names.append(f'import("m{i}.off");') + wrapper = Path(folder) / "silhouette.scad" + wrapper.write_text("projection() union() { " + " ".join(names) + " }\n", encoding="utf-8") + target = Path(folder) / "silhouette.svg" + command = [str(executable), "--backend", "Manifold", "-o", str(target), str(wrapper)] + _run_openscad(command, on_start, cwd=folder) + if not target.exists(): + raise HullSourceError("OpenSCAD refused a part of the hull source") + return parse_svg_contours(target.read_text(encoding="utf-8", errors="replace")) + + return project diff --git a/tools/blender/tests/test_hull_source.py b/tools/blender/tests/test_hull_source.py index bd534d83b..a1d8d163b 100644 --- a/tools/blender/tests/test_hull_source.py +++ b/tools/blender/tests/test_hull_source.py @@ -67,8 +67,18 @@ class Fixture: + echo("anchor", ["Gun.A.muzzle", "weapon-muzzle", [0, -3, 3], 0, "Gun.A"]) + echo("anchor", ["Th.A", "thruster-emitter", [1, 5, 1], 180, ""])), "emit:Th.A": (box(0.5, 1.5, 5, 5.1, 0.5, 1.5), ""), + "lines": ("", ""), } + @staticmethod + def projector(meshes): + """A top view that is the bounding rectangle of every mesh it is given.""" + lows = [m.bounds()[0] for m in meshes] + highs = [m.bounds()[1] for m in meshes] + x0, y0 = min(v[0] for v in lows), min(v[1] for v in lows) + x1, y1 = max(v[0] for v in highs), max(v[1] for v in highs) + return [[(x0, y0), (x1, y0), (x1, y1), (x0, y1)]] + def runner(self): return lambda part: ("", self.hull_lang if self.hull_lang is not None else "".join(self.library.values())) if part == "lang" else self.parts[part] @@ -78,7 +88,7 @@ class Fixture: return self.library[faction] def build(self): - return hs.build(self.runner(), self.read_library, self.hull_type) + return hs.build(self.runner(), self.read_library, self.hull_type, self.projector) def failed(self): return [c.name for c in self.build().failed()] @@ -206,7 +216,7 @@ class HullTypeTests(unittest.TestCase): def test_build_has_no_default_hull_type(self): fixture = Fixture() with self.assertRaises(TypeError): - hs.build(fixture.runner(), fixture.read_library) + hs.build(fixture.runner(), fixture.read_library, projector=fixture.projector) def test_build_refuses_a_hull_type_that_is_not_a_hulltype_name_before_reading_the_source(self): for hull_type in ("ship", "SHIP", "Ship ", "", None, "Stations", 0, SECRET): @@ -214,7 +224,7 @@ class HullTypeTests(unittest.TestCase): fixture, read = Fixture(), [] runner = fixture.runner() with self.assertRaises(hs.HullSourceError) as caught: - hs.build(lambda part: read.append(part) or runner(part), fixture.read_library, hull_type) + hs.build(lambda part: read.append(part) or runner(part), fixture.read_library, hull_type, fixture.projector) self.assertEqual(read, []) self.assertNotIn(SECRET, str(caught.exception)) @@ -479,7 +489,7 @@ class LanguageOwnershipTests(unittest.TestCase): def test_the_library_is_asked_for_the_faction_and_the_donor_the_brief_names(self): asked = [] fixture = DonorTests.pirate() - hs.build(fixture.runner(), lambda faction: asked.append(faction) or fixture.read_library(faction), "Ship") + hs.build(fixture.runner(), lambda faction: asked.append(faction) or fixture.read_library(faction), "Ship", fixture.projector) self.assertEqual(sorted(asked), ["pirate", "rnd"]) @@ -667,5 +677,187 @@ class RefusalTests(unittest.TestCase): self.assertEqual(source.count("subprocess.Popen"), 1) +LINES_OVER = "every kit line lies over the hull's top view" +LINES_DISTINCT = "no two kit lines are the same" +SILHOUETTE = "the top view yields a silhouette" + + +def line(layer="seam", points=((-1, -1, 2), (1, 1, 2)), cyclic=False): + return echo("line", [layer, cyclic, [list(p) for p in points]]) + + +class LineReadingTests(unittest.TestCase): + def test_lines_are_read_in_echo_order_with_their_layer_flag_and_points(self): + text = line("step", ((0, 0, 1), (1, 0, 1), (1, 1, 1)), True) + line("fitting") + read = hs.read_lines(text) + self.assertEqual([(l.layer, l.cyclic, len(l.points)) for l in read], [("step", True, 3), ("fitting", False, 2)]) + self.assertEqual(read[0].points[1], (1, 0, 1)) + + def test_a_layer_outside_the_kit_layers_is_refused_without_echoing_it(self): + for layer in ("silhouette", "outlines", "", SECRET): + with self.subTest(layer=layer), self.assertRaises(hs.HullSourceError) as caught: + hs.read_lines(line(layer)) + self.assertNotIn(SECRET, str(caught.exception)) + + def test_a_line_of_one_point_or_a_point_that_is_not_finite_is_refused(self): + for text in (line(points=((0, 0, 0),)), line(points=()), 'ECHO: line = ["seam", false, [[0, 0, NaN], [1, 1, 1]]]\n', + 'ECHO: line = ["seam", false, [[0, 0, Infinity], [1, 1, 1]]]\n', line(points=((0, 0), (1, 1)))): + with self.subTest(text=text), self.assertRaises(hs.HullSourceError): + hs.read_lines(text) + + def test_a_cyclic_flag_that_is_not_a_boolean_is_refused(self): + with self.assertRaises(hs.HullSourceError): + hs.read_lines(echo("line", ["seam", 1, [[0, 0, 0], [1, 1, 1]]])) + + +SVG = """ + + + +""" + + +class SvgContourTests(unittest.TestCase): + def test_contours_come_back_in_model_coordinates_with_y_negated(self): + contours = hs.parse_svg_contours(SVG) + self.assertEqual(contours[0], [(0.0, 4.0), (4.0, 4.0), (4.0, 1.0), (0.0, 1.0)]) + self.assertEqual(contours[1][0], (-1e-05, -2.0)) + + def test_a_contour_under_a_quarter_square_metre_is_a_sliver_and_dropped(self): + self.assertEqual(len(hs.parse_svg_contours(SVG)), 2) + + def test_a_drawing_with_no_path_has_no_contours(self): + self.assertEqual(hs.parse_svg_contours(""), []) + + +class LineBuildTests(unittest.TestCase): + def built(self, kit="", **parts): + fixture = Fixture() + fixture.parts["lines"] = ("", kit) + fixture.parts.update(parts) + return fixture, fixture.build() + + def test_a_source_with_no_lines_has_the_silhouette_alone_and_passes_every_check(self): + _, built = self.built() + self.assertEqual([(l.layer, l.cyclic) for l in built.lines], [("silhouette", True)]) + self.assertEqual(built.failed(), []) + + def test_the_silhouette_is_first_cyclic_and_at_the_top_of_the_geometry(self): + _, built = self.built(line("outline", ((-1, 0, 1), (1, 0, 1)))) + self.assertEqual([l.layer for l in built.lines], ["silhouette", "outline"]) + self.assertEqual({p[2] for p in built.lines[0].points}, {3.0}) + self.assertEqual({p[:2] for p in built.lines[0].points}, {(-3.0, -5.0), (3.0, -5.0), (3.0, 5.1), (-3.0, 5.1)}) + + def test_each_layer_carries_its_fixed_style(self): + kit = "".join(line(layer) for layer in hs.KIT_LAYERS) + _, built = self.built(kit) + self.assertEqual([l.rgba() for l in built.lines], [hs.LINE_STYLE[l.layer] for l in built.lines]) + self.assertEqual(len({l.rgba() for l in built.lines}), 5) + self.assertEqual(hs.LINE_STYLE["silhouette"], (1.0, 1.0, 1.0, 1.0)) + + def test_the_projector_is_given_every_zone_and_anchor_mesh_that_has_geometry(self): + fixture, given = Fixture(), [] + hs.build(fixture.runner(), fixture.read_library, "Ship", + lambda meshes: given.append(len(meshes)) or fixture.projector(meshes)) + self.assertEqual(given, [3]) # primary, secondary, emit:Th.A + + def test_a_kit_line_off_the_hull_fails_the_over_the_top_view_check_and_nothing_else(self): + _, built = self.built(line(points=((0, 0, 2), (20, 0, 2)))) + self.assertEqual([c.name for c in built.failed()], [LINES_OVER]) + + def test_a_kit_line_a_little_off_the_edge_still_lies_over_the_hull(self): + _, built = self.built(line(points=((3.2, 0, 2), (3.2, 1, 2)))) + self.assertEqual(built.failed(), []) + _, built = self.built(line(points=((3.4, 0, 2), (3.4, 1, 2)))) + self.assertEqual([c.name for c in built.failed()], [LINES_OVER]) + + def test_the_inside_of_a_hole_in_the_silhouette_is_not_over_the_hull(self): + hole = [(-1, -1), (1, -1), (1, 1), (-1, 1)] + outer = [(-3, -5), (3, -5), (3, 5), (-3, 5)] + self.assertTrue(hs.over_silhouette((2, 0, 0), [outer, hole])) + self.assertFalse(hs.over_silhouette((0, 0, 0), [outer, hole])) + + def test_two_equal_kit_lines_fail_the_distinct_check_and_nothing_else(self): + _, built = self.built(line() + line("fitting")) + self.assertEqual([c.name for c in built.failed()], [LINES_DISTINCT]) + + def test_lines_that_differ_by_a_millimetre_or_more_are_distinct(self): + _, built = self.built(line(points=((0, 0, 2), (1, 0, 2))) + line(points=((0, 0.002, 2), (1, 0, 2)))) + self.assertEqual(built.failed(), []) + + def test_no_contour_fails_the_silhouette_check(self): + fixture = Fixture() + built = hs.build(fixture.runner(), fixture.read_library, "Ship", lambda meshes: []) + self.assertEqual([c.name for c in built.failed()], [SILHOUETTE]) + self.assertEqual(built.lines, []) + + def test_a_kit_line_with_no_silhouette_to_lie_over_fails_both_checks(self): + fixture = Fixture() + fixture.parts["lines"] = ("", line()) + built = hs.build(fixture.runner(), fixture.read_library, "Ship", lambda meshes: []) + self.assertEqual(sorted(c.name for c in built.failed()), sorted([SILHOUETTE, LINES_OVER])) + + def test_a_source_with_no_geometry_asks_for_no_silhouette(self): + fixture = Fixture() + fixture.parts.update({"primary": ("", ""), "secondary": ("", ""), "emit:Th.A": ("", "")}) + built = hs.build(fixture.runner(), fixture.read_library, "Ship", lambda meshes: self.fail("projected nothing")) + self.assertEqual(built.lines, []) + + def test_a_refused_line_is_refused_without_echoing_it(self): + fixture = Fixture() + fixture.parts["lines"] = ("", line(SECRET)) + with self.assertRaises(hs.HullSourceError) as caught: + fixture.build() + self.assertNotIn(SECRET, str(caught.exception)) + + +class WrapperOpenScad(FakeOpenScad): + """Keeps the wrapper scad and the OFF files OpenSCAD is given, which vanish with the temp folder.""" + + def __call__(self, command, **kwargs): + wrapper = Path(command[-1]) + self.wrapper = wrapper.read_text(encoding="utf-8") + self.imports = {f.name: f.read_text(encoding="utf-8") for f in wrapper.parent.glob("*.off")} + return super().__call__(command, **kwargs) + + +class ProjectorTests(unittest.TestCase): + def project(self, openscad, meshes): + with mock.patch.object(hs.subprocess, "Popen", openscad): + return hs.openscad_projector("scad")(meshes) + + def test_the_meshes_are_unioned_projected_and_the_svg_read_back(self): + a, b = hs.parse_off(box(0, 1, 0, 1, 0, 1)), hs.parse_off(box(2, 3, 0, 1, 0, 1)) + openscad = WrapperOpenScad(off=SVG) + contours = self.project(openscad, [a, b]) + self.assertEqual(openscad.wrapper.split(), ["projection()", "union()", "{", 'import("m0.off");', 'import("m1.off");', "}"]) + self.assertEqual(sorted(openscad.imports), ["m0.off", "m1.off"]) + self.assertEqual(hs.parse_off(openscad.imports["m1.off"]).bounds(), b.bounds()) + command = openscad.calls[0][0] + self.assertEqual(command[command.index("--backend") + 1], "Manifold") + self.assertTrue(command[command.index("-o") + 1].endswith(".svg")) + self.assertEqual(len(contours), 2) + + def test_a_failed_or_silent_openscad_is_refused_without_echoing_its_output(self): + for openscad in (FakeOpenScad(returncode=1, output=SECRET), FakeOpenScad(output=SECRET)): + with self.assertRaises(hs.HullSourceError) as caught: + self.project(openscad, [hs.parse_off(box(0, 1, 0, 1, 0, 1))]) + self.assertNotIn(SECRET, str(caught.exception)) + + def test_the_process_is_reported_to_on_start(self): + started = [] + with mock.patch.object(hs.subprocess, "Popen", WrapperOpenScad(off=SVG)): + hs.openscad_projector("scad", on_start=started.append)([hs.parse_off(box(0, 1, 0, 1, 0, 1))]) + self.assertEqual(started, [4242]) + + def test_the_lines_part_answers_the_echo(self): + with mock.patch.object(hs.subprocess, "Popen", FakeOpenScad(output=line())): + self.assertEqual(hs.openscad_runner("scad", "hull.scad")("lines"), ("", line())) + + if __name__ == "__main__": unittest.main() From 905074defb43412a9f430b3833e498d56a110201 Mon Sep 17 00:00:00 2001 From: soul Date: Thu, 22 Oct 2026 15:59:56 +0200 Subject: [PATCH 169/444] Add-on: schematic lines are hers (one Grease Pencil) else Build's from the kit; smoke and C# fact Co-Authored-By: Claude Sonnet 5.5 --- .../NakovalnyaPackageTests.cs | 21 +++++ tools/blender/aetheria_ships/__init__.py | 54 ++++++++---- tools/blender/aetheria_ships/ship_cc.py | 2 +- tools/blender/tests/smoke_hull_source.py | 84 +++++++++++++++++++ tools/blender/tests/test_ship_cc.py | 2 +- 5 files changed, 147 insertions(+), 16 deletions(-) diff --git a/tests/Aetheria.Shared.Tests/NakovalnyaPackageTests.cs b/tests/Aetheria.Shared.Tests/NakovalnyaPackageTests.cs index d2b08f8ba..29d15db31 100644 --- a/tests/Aetheria.Shared.Tests/NakovalnyaPackageTests.cs +++ b/tests/Aetheria.Shared.Tests/NakovalnyaPackageTests.cs @@ -120,4 +120,25 @@ public sealed class NakovalnyaPackageTests .Select(row => $"{row.Item1} refuses the {row.Item2}").ToArray(); Assert.Empty(refused); } + + // The package's schematic is the kit's (ruling agent-hull-schematic-from-kit): hull_source's silhouette first, then + // the kit's lines; Studio fits their top view onto the hull Shape's whole cell rect. + [Fact] + public void SchematicLinesComeFromTheKit() + { + using var cache = Catalog(); + var hull = Hull(cache); + var ship = cache.Get(new CultRecordRef(ShipModCatalog.AuthoringKey(Id))); + var kitLayers = new[] { "outline", "step", "seam", "fitting" }; + + Assert.Equal("silhouette", ship.SchematicLines[0].Layer); + Assert.True(ship.SchematicLines[0].Cyclic); + Assert.True(ship.SchematicLines.Count(line => kitLayers.Contains(line.Layer)) > 20); + Assert.All(ship.SchematicLines, line => Assert.Contains(line.Layer, kitLayers.Append("silhouette"))); + ShipAuthoringStore.Validate(hull, ship); + + var fit = SchematicUnderlay.Fit(ship.SchematicLines, hull.Shape).SelectMany(line => line).ToArray(); + Assert.Equal((19, 30), (hull.Shape.Width, hull.Shape.Height)); + Assert.Equal((0f, 0f, 19f, 30f), (fit.Min(p => p.x), fit.Min(p => p.y), fit.Max(p => p.x), fit.Max(p => p.y))); + } } diff --git a/tools/blender/aetheria_ships/__init__.py b/tools/blender/aetheria_ships/__init__.py index 17ad00afc..08daa095c 100644 --- a/tools/blender/aetheria_ships/__init__.py +++ b/tools/blender/aetheria_ships/__init__.py @@ -533,12 +533,11 @@ class AETHERIA_OT_bind_ship_collection(bpy.types.Operator): class AETHERIA_OT_capture_ship_lines(bpy.types.Operator): bl_idname = "aetheria.capture_ship_lines" bl_label = "Capture Ship Lines" - bl_description = "Replace only the line slot in the selected typed ship .cc record" + bl_description = "Replace only the line slot in the bound ship .cc record: from the Grease Pencil object, else from the hull source" bl_options = {"REGISTER"} def execute(self, context): try: - obj = context.active_object collection = _ship_collection(context) if not collection.get("aetheria.ship_cc"): raise ValueError("The ship collection has no bound .cc path") @@ -546,10 +545,9 @@ class AETHERIA_OT_capture_ship_lines(bpy.types.Operator): cultlib = _brokkr_cultlib(context) if read(path, cultlib).ship.body[0] != collection["aetheria.id"]: raise ValueError("The bound collection ID does not match its .cc ship ID") - lines = capture_grease_pencil( - obj, context.evaluated_depsgraph_get(), context.scene.frame_current, - evaluated=context.scene.aetheria_capture_evaluated_lines, - ) + lines = _schematic_lines(context, collection) + if lines is None: + raise ValueError("No schematic lines were captured; the ship record was not changed") count = replace_lines(path, cultlib, lines) self.report({"INFO"}, f"Captured {count} lines") return {"FINISHED"} @@ -1492,6 +1490,7 @@ def _frame_ship(context, collection, root, path, saved_shape): HULL_MAKER = "aetheria.hull_maker" # on the collection: the Faction key of the language Build judged the hull against +HULL_LINES = "aetheria.hull_lines" # on the collection: [{layer, cyclic, points (flat), rgba}] the hull source's kit drew, in Ship Root's frame HULL_BUILT = "aetheria.hull_built" # on the collection: {object name: fingerprint} of what Build From Hull Source made HULL_AGENT = "agent" # HS rows H5, H7-H10, H12: Build's judged body or record governs; Package checks the maker HULL_ART = "art" # HS rows H4, H6, H11: her meshes replace Build's; Package ships them and retires the hull source @@ -1577,7 +1576,7 @@ def _build_hull_source(context, collection): executable = addon.preferences.openscad if addon and addon.preferences else DEFAULT_OPENSCAD hull_type = read_hull_type(bpy.path.abspath(collection["aetheria.ship_cc"]), _brokkr_cultlib(context)) hull = hull_source.build(hull_source.openscad_runner(executable, source), hull_source.language_library(executable), - hull_type) + hull_type, hull_source.openscad_projector(executable)) broken = hull.failed() if broken: raise ValueError("The hull source breaks its faction's language or its brief: " + "; ".join(c.name for c in broken)) @@ -1629,9 +1628,38 @@ def _build_hull_source(context, collection): collider["aetheria.role"], collider["aetheria.id"] = role, anchor_id collection[HULL_BUILT] = {obj.name: _fingerprint(obj) for obj in made} collection[HULL_MAKER] = hull.languages[hull.brief.faction].maker + collection[HULL_LINES] = [{"layer": line.layer, "cyclic": line.cyclic, "points": [c for p in line.points for c in p], + "rgba": list(line.rgba())} for line in hull.lines] return hull +def _schematic_lines(context, collection): + """The ShipAuthoring.SchematicLines rows Package and Capture write, and the only source of them: her Grease Pencil + object if the collection has one (two or more are refused), else the lines Build recorded from the hull source's kit, + mapped through Ship Root's matrix_world (so after _frame_ship, the frame her strokes are captured in), else None and + the slot is left as it is.""" + pencils = [obj for obj in collection.all_objects if obj.type == "GREASEPENCIL"] + if len(pencils) > 1: + raise ValueError("Keep one Grease Pencil object: it is the ship's schematic") + scene = context.scene + if pencils: + return capture_grease_pencil(pencils[0], context.evaluated_depsgraph_get(), scene.frame_current, + evaluated=scene.aetheria_capture_evaluated_lines) + record = collection.get(HULL_LINES) + if record is None: + return None + root = _ship_root(collection) + world = root.matrix_world if root is not None else Matrix.Identity(4) + rows = [] + for line in record: + flat = list(line["points"]) + points = [] + for x, y, z in zip(flat[0::3], flat[1::3], flat[2::3]): + points.extend(float(c) for c in world @ Vector((x, y, z))) + rows.append([line["layer"], "", points, None, None, bool(line["cyclic"]), [float(c) for c in line["rgba"]]]) + return rows + + def _hull_state(collection): """The one owner of a ship collection's hull state (map HS), decided in this order: an untouched Build zone mesh keeps the hull agent's; her meshes beside Build's record or hull.scad make it art; then Build's record is agent, @@ -1659,7 +1687,7 @@ def _retire_hull_source(collection): source = _hull_source_file(collection) if source is not None: source.unlink() - for record in (HULL_BUILT, HULL_MAKER): + for record in (HULL_BUILT, HULL_MAKER, HULL_LINES): if record in collection: del collection[record] return True @@ -1708,7 +1736,7 @@ class AETHERIA_OT_package_ship(bpy.types.Operator): bl_idname = "aetheria.package_ship" bl_label = "Package Ship" bl_description = ("Put the grid's centre of mass at the world origin, derive the missing map icon, shield and " - "tractor, write the anchors and ship.glb from the bound collection, capture its LineArt, and " + "tractor, write the anchors and ship.glb from the bound collection, capture its schematic lines, and " "run AetherDb's validator") bl_options = {"REGISTER"} @@ -1738,11 +1766,9 @@ class AETHERIA_OT_package_ship(bpy.types.Operator): anchors = _anchors(collection) _export_glb(context, collection, str(Path(path).with_name(MODEL_ASSET))) replace_visual(path, cultlib, ship_id, MODEL_ASSET, anchors) - pencils = [obj for obj in collection.all_objects if obj.type == "GREASEPENCIL"] - if len(pencils) == 1: - replace_lines(path, cultlib, capture_grease_pencil( - pencils[0], context.evaluated_depsgraph_get(), scene.frame_current, - evaluated=scene.aetheria_capture_evaluated_lines)) + lines = _schematic_lines(context, collection) + if lines is not None: + replace_lines(path, cultlib, lines) code, message = _aetherdb(context, "validate", path, ship_cc=path) except _EXPECTED as exc: scene.aetheria_package_report = f"Package failed: {_failure_text(exc)}" diff --git a/tools/blender/aetheria_ships/ship_cc.py b/tools/blender/aetheria_ships/ship_cc.py index 079296be4..f3742dd4f 100644 --- a/tools/blender/aetheria_ships/ship_cc.py +++ b/tools/blender/aetheria_ships/ship_cc.py @@ -173,7 +173,7 @@ def _stamped(record: Record, msgpack: Any): def replace_lines(path: str, cultlib_packages: str, lines: list[list[Any]]) -> int: file = read(path, cultlib_packages) if not lines: - raise ValueError("No Grease Pencil strokes were captured; the ship record was not changed") + raise ValueError("No schematic lines were captured; the ship record was not changed") file.ship.body[SCHEMATIC_LINES_SLOT] = lines file.store.push(_stamped(file.ship, file.msgpack)) return len(lines) diff --git a/tools/blender/tests/smoke_hull_source.py b/tools/blender/tests/smoke_hull_source.py index 06f2db715..b57dfe5af 100644 --- a/tools/blender/tests/smoke_hull_source.py +++ b/tools/blender/tests/smoke_hull_source.py @@ -36,6 +36,10 @@ the hull.scad into the package and runs the panel's operators: or a product by another maker than the language Build judged it against (hull-names-its-own-faction); with a product by that maker it accepts, with hull.scad in the package or not (the guard keys on the hull's state), and a refused Package changes nothing in the scene. Her art needs no product. + Schematic lines: after Build and Package the ship.cc's first line is the silhouette and the kit's lines follow (the Pirate + hull's kit draws none); a Grease Pencil object in the collection replaces them with its own; a second one is refused by + Package and Capture Ship Lines, and the slot is as it was. The Nakovalnya's hull.scad, read with the real kit, has a + silhouette whose box is the zones' top-view box and a fitting octagon at every flak mount anchor. Build and Package refuse copies that share an aetheria.id, selecting the original and the copies, and delete nothing. Package moves no row and turns no mount: every layout row is as authored after it, and a hand-turned mount keeps its yaw. The ship.cc HullData.HullType decides whether the language's proportions judge the hull: a body far wider than the @@ -366,6 +370,37 @@ def one_hull(scene, number, fixture, ship_id, extent, length, rows, body, palett require(saved == [(mount_id, cell) for mount_id, _, cell in rows], f"A row is not as authored after Package: {saved}") print(f"PASS {number}.5 Package accepts the built ship and keeps hull.scad") + # Schematic lines: Build's, else hers, and never two of hers. + packaged = ship_lines(ship_cc) + require(packaged and packaged[0][0] == "silhouette" and packaged[0][5] is True, f"The first line is not the silhouette: {packaged[:1]}") + kit = packaged[1:] + require(all(row[0] in hull_source.KIT_LAYERS and row[3] is None and row[4] is None and row[1] == "" for row in kit), + "A kit line row is not shaped as Package writes it") + require(bool(kit) == (fixture == "zh-ammo-tender"), f"The kit drew {len(kit)} lines for {fixture}") + require(all(approx(row[6], hull_source.LINE_STYLE[row[0]]) for row in packaged), "A line lost its style") + shipped_z = {round(c, 3) for row in packaged for c in row[2][2::3]} + require(len(shipped_z) >= 1, "The lines have no height") + pencil = grease_pencil(collection, "her lines", [(0, 0, 0), (1, 0, 0), (1, 1, 0)]) + select(box) + result, message = run(bpy.ops.aetheria.package_ship) + require(result == {"FINISHED"}, f"Package refused a ship with one Grease Pencil: {message!r}") + hers = ship_lines(ship_cc) + require(len(hers) == 1 and hers[0][0] == "hers" and len(hers[0][2]) == 9, f"Her Grease Pencil did not replace the kit's lines: {hers}") + second = grease_pencil(collection, "second lines", [(0, 0, 0), (2, 0, 0)]) + before = ship_lines(ship_cc) + for operator in (bpy.ops.aetheria.package_ship, bpy.ops.aetheria.capture_ship_lines): + select(box) + result, message = run(operator) + require(result == {"CANCELLED"} and "Keep one Grease Pencil object" in (message + scene.aetheria_package_report), + f"Two Grease Pencil objects were not refused: {message!r}") + require(ship_lines(ship_cc) == before, "A refused schematic changed the line slot") + for obj in (pencil, second): + bpy.data.objects.remove(obj) + select(box) + result, message = run(bpy.ops.aetheria.package_ship) + require(result == {"FINISHED"} and len(ship_lines(ship_cc)) == len(packaged), f"Without Grease Pencil the kit's lines are back: {message!r}") + print(f"PASS {number}.5b the kit's lines ship, her Grease Pencil wins, two are refused") + # What Package did to the built objects is not an edit: Build still replaces them. select(box) result, message = run(bpy.ops.aetheria.build_hull_source) @@ -566,6 +601,54 @@ def clear(collection): bpy.data.objects.remove(obj) +def ship_lines(ship_path): + """The ShipAuthoring.SchematicLines rows of a ship.cc, as Package wrote them.""" + return sys.modules["aetheria_ships"].read(ship_path, PACKAGES).ship.body[sys.modules["aetheria_ships"].ship_cc.SCHEMATIC_LINES_SLOT] + + +def grease_pencil(collection, name, points): + """A Grease Pencil object in the collection holding one stroke, layer named 'hers'.""" + data = bpy.data.grease_pencils.new(name) + material = bpy.data.materials.new(name) + bpy.data.materials.create_gpencil_data(material) + data.materials.append(material) + layer = data.layers.new("hers") + drawing = layer.frames.new(1).drawing + drawing.add_strokes([len(points)]) + for point, position in zip(drawing.strokes[0].points, points): + point.position = position + obj = bpy.data.objects.new(name, data) + collection.objects.link(obj) + return obj + + +def nakovalnya_frames(executable): + """The Nakovalnya's hull.scad with the real kit: the silhouette is the zones' top view and a fitting octagon sits at + every flak mount (the frame the kit placed the barrel in is the frame the anchor is in).""" + source = REPO / "GameData" / "Mods" / "aetheria.nakovalnya" / "hull.scad" + built = hull_source.build(hull_source.openscad_runner(executable, source), hull_source.language_library(executable), + "Ship", hull_source.openscad_projector(executable)) + require(not built.failed(), f"The Nakovalnya breaks a check: {[c.name for c in built.failed()]}") + silhouette = [row for row in built.lines if row.layer == "silhouette"] + require(built.lines[0].layer == "silhouette" and len(silhouette) == 1, "The Nakovalnya silhouette is not one first contour") + xs = [v[0] for mesh in built.zones.values() for v in mesh.vertices] + ys = [v[1] for mesh in built.zones.values() for v in mesh.vertices] + box = (min(xs), max(xs), min(ys), max(ys)) + flat = silhouette[0].points + got = (min(p[0] for p in flat), max(p[0] for p in flat), min(p[1] for p in flat), max(p[1] for p in flat)) + require(approx(box, got, 1e-3), f"The silhouette box {got} is not the zones' top-view box {box}") + octagons = [row.points for row in built.lines if row.layer == "fitting" and row.cyclic and len(row.points) == 8] + centres = [(sum(p[0] for p in o) / 8, sum(p[1] for p in o) / 8) for o in octagons] + mounts = [a for a in built.anchors if a.id.startswith("Gun.F") and a.role == "weapon-mount"] + require(len(mounts) == 10, f"The Nakovalnya has {len(mounts)} flak mounts") + for mount in mounts: + near = min(math.hypot(mount.position[0] - c[0], mount.position[1] - c[1]) for c in centres) + require(near <= 0.3, f"{mount.id} is {near:.2f} m from the nearest fitting octagon") + kinds = {row.layer for row in built.lines} + require({"silhouette", "outline", "step", "seam", "fitting"} <= kinds, f"The Nakovalnya lacks a layer: {kinds}") + print(f"PASS frames the Nakovalnya's {len(built.lines)} lines are in the frame of its geometry") + + def aetherdb(ship_path, *args): """Runs one AetherDb ship-authoring command through the add-on's own runner; returns its exit code.""" return sys.modules["aetheria_ships"]._aetherdb(bpy.context, *args, ship_cc=ship_path)[0] @@ -797,6 +880,7 @@ def main(): clear(agent_guards(scene)) clear(hull_type_gate(scene)) never_built(scene) + nakovalnya_frames(EXECUTABLE) created = not MODS.exists() diff --git a/tools/blender/tests/test_ship_cc.py b/tools/blender/tests/test_ship_cc.py index 828352438..ac01b2faa 100644 --- a/tools/blender/tests/test_ship_cc.py +++ b/tools/blender/tests/test_ship_cc.py @@ -368,7 +368,7 @@ class ReplaceLinesTests(ShipFileCase): def test_refuses_an_empty_capture_and_changes_nothing(self): before = self.bytes() - with self.assertRaisesRegex(ValueError, "No Grease Pencil strokes"): + with self.assertRaisesRegex(ValueError, "No schematic lines"): ship_cc.replace_lines(self.path, PACKAGES, []) self.assertEqual(before, self.bytes()) From d5d94fb48316b91ae7a35fca7fa9c057a456c1cb Mon Sep 17 00:00:00 2001 From: soul Date: Thu, 22 Oct 2026 16:00:08 +0200 Subject: [PATCH 170/444] Staged Approach: the pilot starts knowing its anchor, through the sensors' own info state (ruling stage-states-pilot-knowledge) Co-Authored-By: Claude Sonnet 5.5 --- Assets/Scripts/ServerShared/RunStart.cs | 6 ++ Assets/Scripts/ServerShared/ScenarioStage.cs | 6 +- .../ApproachKnowledgeTests.cs | 60 +++++++++++++++++++ 3 files changed, 71 insertions(+), 1 deletion(-) create mode 100644 tests/Aetheria.Shared.Tests/ApproachKnowledgeTests.cs diff --git a/Assets/Scripts/ServerShared/RunStart.cs b/Assets/Scripts/ServerShared/RunStart.cs index 2e1245bab..20269e84c 100644 --- a/Assets/Scripts/ServerShared/RunStart.cs +++ b/Assets/Scripts/ServerShared/RunStart.cs @@ -55,6 +55,12 @@ public static class RunStart player.SetIff(placed.Entity, hostile); arena.Admit(placed.Entity, placed.Piloted, placed.Task); } + + // A start state includes what a pilot knows (ruling stage-states-pilot-knowledge): its anchor is tracked from the first + // step, by the sensors' own path (info gathered past the detection threshold), so they can lose it like any track. + foreach (var placed in stage.Placed) + if (placed.Knows != null && placed.Piloted) + placed.Entity.EntityInfoGathered[placed.Knows] = (itemManager.GameplaySettings.TargetDetectionInfoThreshold + 1f) / 2f; return new Staged { Player = player, Credits = stage.Credits, Entities = stage.Placed.Select(placed => placed.Entity).ToList() }; } diff --git a/Assets/Scripts/ServerShared/ScenarioStage.cs b/Assets/Scripts/ServerShared/ScenarioStage.cs index f0df352e9..a9e2db5d0 100644 --- a/Assets/Scripts/ServerShared/ScenarioStage.cs +++ b/Assets/Scripts/ServerShared/ScenarioStage.cs @@ -50,6 +50,9 @@ public sealed class ScenarioStage public ScenarioStance Stance; public bool Piloted; public AgentTask Task; + // What the pilot already knows at the start: an entity its sensors hold a track of (Approach). Seeded through the + // sensors' own state at admission, so they may lose it honestly. + public Entity Knows; } // A hull fitted slot by slot: each slot a design or a product, the hull cell it goes in, and its rotation. A name is @@ -137,7 +140,7 @@ public sealed class ScenarioStage } // The task a piloted entity starts on: it closes on the target and holds there (the flight's follow task, standoff - // nothing), in place of the patrol circuit the zone would draw for it. A check that rests on where a pilot goes states + // nothing), in place of the patrol circuit the zone would draw for it. The pilot starts knowing it (RunStart seeds a sensor track). A check that rests on where a pilot goes states // it here, so the pilot's course is the scenario's and not a draw. An entity that is not placed and piloted, or a // target that is not placed, is a failure. public void Approach(Entity entity, Entity target) @@ -146,6 +149,7 @@ public sealed class ScenarioStage if (placement == null || !placement.Piloted) { _failures.Add($"approach: {entity?.Name ?? "no entity"} is not a placed, piloted ship"); return; } if (target == null) { _failures.Add("approach: no target to close on"); return; } placement.Task = new FollowTask { Anchor = PilotView.Handle(target), Standoff = 0f }; + placement.Knows = target; } // Items in the entity's first cargo bay, one per name. A name is an equippable or consumable design (open: the stage diff --git a/tests/Aetheria.Shared.Tests/ApproachKnowledgeTests.cs b/tests/Aetheria.Shared.Tests/ApproachKnowledgeTests.cs new file mode 100644 index 000000000..98f857913 --- /dev/null +++ b/tests/Aetheria.Shared.Tests/ApproachKnowledgeTests.cs @@ -0,0 +1,60 @@ +using System.Linq; +using CultMath; +using Xunit; +using static CultMath.math; +using float2 = CultMath.float2; +using float3 = CultMath.float3; + +// A staged Approach states what its pilot already knows (ruling stage-states-pilot-knowledge): the anchor is a sensor +// track from the first step, and the pilot follows it only as long as the sensors keep it. +public sealed partial class RunStartTests +{ + private (Zone arena, Ship east, Ship west) StagedApproach() + { + Ship east = null, west = null; + var (_, arena, _, failures) = Launch(new Scripted(false, stage => + { + stage.Player(stage.Bare("Djinni"), float2(-50000, -50000)); + east = stage.Place(EvLonginus(stage), float2(450, 0), facing: float2(-1, 0), piloted: true) as Ship; + west = stage.Place(EvLonginus(stage), float2(-450, 0), facing: float2(1, 0), piloted: true) as Ship; + stage.Approach(east, west); + })); + Assert.True(failures.Count == 0, string.Join("; ", failures)); + return (arena, east, west); + } + + [Fact] + public void AStagedApproachFollowerClosesOnItsAnchorFromTheFirstStep() + { + var (arena, east, west) = StagedApproach(); + var view = arena.Agents.Single(a => a.Ship == east).View; + var startGap = east.Position.x - west.Position.x; + + arena.Update(1f / 60f); + arena.Update(1f / 60f); + + Assert.NotNull(view.TrackOf(east, PilotView.Handle(west))); + Assert.True(east.Velocity.x < -.5f, $"the follower thrusts toward its anchor from the first steps (vx {east.Velocity.x})"); + for (var i = 0; i < 120; i++) arena.Update(1f / 60f); + Assert.True(east.Position.x - west.Position.x < startGap - 20f, "the follower closed on its anchor"); + } + + [Fact] + public void AStagedApproachFollowerStopsFollowingOnceTheSensorsLoseTheTrack() + { + var (arena, east, west) = StagedApproach(); + var view = arena.Agents.Single(a => a.Ship == east).View; + var sentinel = new float3(0, 0, 12345); + for (var i = 0; i < 3; i++) arena.Update(1f / 60f); + + east.Command.Aim = sentinel; + arena.Update(1f / 60f); + Assert.NotEqual(sentinel, east.Command.Aim.Value); + + east.EntityInfoGathered[west] = 0f; + Assert.Null(view.TrackOf(east, PilotView.Handle(west))); + east.Command.Aim = sentinel; + arena.Update(1f / 60f); + Assert.Equal(sentinel, east.Command.Aim.Value); + } +} From 5bb4ad5f0f2579f9484638dfb443363b1d082ece Mon Sep 17 00:00:00 2001 From: soul Date: Thu, 22 Oct 2026 16:02:53 +0200 Subject: [PATCH 171/444] Smoke: the silhouette may be several contours, all before the kit's lines Co-Authored-By: Claude Sonnet 5.5 --- tools/blender/tests/smoke_hull_source.py | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/tools/blender/tests/smoke_hull_source.py b/tools/blender/tests/smoke_hull_source.py index b57dfe5af..ba5a12ea6 100644 --- a/tools/blender/tests/smoke_hull_source.py +++ b/tools/blender/tests/smoke_hull_source.py @@ -373,8 +373,10 @@ def one_hull(scene, number, fixture, ship_id, extent, length, rows, body, palett # Schematic lines: Build's, else hers, and never two of hers. packaged = ship_lines(ship_cc) require(packaged and packaged[0][0] == "silhouette" and packaged[0][5] is True, f"The first line is not the silhouette: {packaged[:1]}") - kit = packaged[1:] - require(all(row[0] in hull_source.KIT_LAYERS and row[3] is None and row[4] is None and row[1] == "" for row in kit), + kit = [row for row in packaged if row[0] != "silhouette"] + drawn = [row[0] for row in packaged] + require(drawn[:len(packaged) - len(kit)] == ["silhouette"] * (len(packaged) - len(kit)), "The silhouette contours are not first") + require(all(row[3] is None and row[4] is None and row[1] == "" for row in packaged), "A kit line row is not shaped as Package writes it") require(bool(kit) == (fixture == "zh-ammo-tender"), f"The kit drew {len(kit)} lines for {fixture}") require(all(approx(row[6], hull_source.LINE_STYLE[row[0]]) for row in packaged), "A line lost its style") From 1a2a24ceb1c1726ba4eef9fce2ff8211707c7369 Mon Sep 17 00:00:00 2001 From: soul Date: Thu, 22 Oct 2026 16:05:39 +0200 Subject: [PATCH 172/444] Staged knowledge: repin the two-AI digest for fighters staged knowing each other; LockOnSituation drops the no-track premise Co-Authored-By: Claude Sonnet 5.5 --- tests/Aetheria.Shared.Tests/DeterminismLedgerTests.cs | 5 +++-- tests/Aetheria.Shared.Tests/LockOnScenarioTests.cs | 6 +++--- tests/Aetheria.Shared.Tests/PilotSeamTests.cs | 2 +- 3 files changed, 7 insertions(+), 6 deletions(-) diff --git a/tests/Aetheria.Shared.Tests/DeterminismLedgerTests.cs b/tests/Aetheria.Shared.Tests/DeterminismLedgerTests.cs index e3ab326f0..6ff03b525 100644 --- a/tests/Aetheria.Shared.Tests/DeterminismLedgerTests.cs +++ b/tests/Aetheria.Shared.Tests/DeterminismLedgerTests.cs @@ -24,8 +24,9 @@ public sealed partial class RunStartTests // The two-AI fight's digest, captured on Yggdrasil. Replays are trusted only on the host class that recorded them // (ruling replays-per-host-class), and arenas run on Linux. private const string TwoAiPinnedHostClass = "linux-x64"; - // Repinned once, for the side split: the two fighters are unaffiliated, were one side and each followed itself. - private const string TwoAiPinnedDigest = "1b2ffe51f799e762199717a3bc003e880a80106023463aabc40bd2e4f746697c"; + // Repinned twice. First for the side split: the two fighters are unaffiliated, were one side and each followed itself. + // Then for staged knowledge (ruling repin-for-staged-knowledge): the fighters are staged knowing each other, so they engage at step 0. + private const string TwoAiPinnedDigest = "ce81a426c40495e4b4b7b7f83ad23767cc781dce5523d713b4455e0aa5b7fe33"; private const int TwoAiSteps = 3600; [Fact] diff --git a/tests/Aetheria.Shared.Tests/LockOnScenarioTests.cs b/tests/Aetheria.Shared.Tests/LockOnScenarioTests.cs index 603a6bea0..e88abc220 100644 --- a/tests/Aetheria.Shared.Tests/LockOnScenarioTests.cs +++ b/tests/Aetheria.Shared.Tests/LockOnScenarioTests.cs @@ -23,10 +23,10 @@ public sealed partial class RunStartTests var toPlayer = normalize(player.Position.xz - hostile.Position.xz); Assert.True(dot(normalize(hostile.Direction), toPlayer) >= cos(radians(5f)), "the hostile faces the player"); - // The first real sensing tick: the hostile holds no track of the player, and the sim's own passive sensing, asked for - // the player's signature at the staged place, settles below the detection threshold: the staging is out of its sight. + // The hostile is staged knowing the player (ruling stage-states-pilot-knowledge): it starts with a track of it. The sim's + // own passive sensing, asked for the player's signature at the staged place, settles below the detection threshold: the + // staging is out of its sight, so the staged track is all that carries it in. arena.Update(.1f); - Assert.False(hostile.VisibleEnemies.Any(x => ReferenceEquals(x, player)), "the player is out of sight"); Assert.DoesNotContain(player.IncomingPaints, paint => paint.Mode == PaintMode.Track); Assert.True(player.Visibility > 0f, "the player shows a signature to be seen by"); var threshold = player.ItemManager.GameplaySettings.TargetDetectionInfoThreshold; diff --git a/tests/Aetheria.Shared.Tests/PilotSeamTests.cs b/tests/Aetheria.Shared.Tests/PilotSeamTests.cs index 3bfeb9f42..64eb493d8 100644 --- a/tests/Aetheria.Shared.Tests/PilotSeamTests.cs +++ b/tests/Aetheria.Shared.Tests/PilotSeamTests.cs @@ -462,7 +462,7 @@ public sealed partial class RunStartTests var aim = normalize(east.Command.Aim.Value.xz); var toThird = dot(aim, normalize(third.Position.xz - east.Position.xz)); var toWest = dot(aim, normalize(west.Position.xz - east.Position.xz)); - Assert.True(toThird > toWest + .2f, $"aims at the designated target: toThird {toThird} toWest {toWest}"); + Assert.True(toThird > toWest + .2f, $"aims at the designated target: toThird {toThird} toWest {toWest} tracked {east.VisibleEntities.Contains(third)}"); } [Fact] From 66c69cdc90c7fd6bed79d32da028d75803da9a9c Mon Sep 17 00:00:00 2001 From: soul Date: Thu, 22 Oct 2026 16:06:19 +0200 Subject: [PATCH 173/444] Smoke: sideways-facing kit parts draw no lines (octagon and line counts of the Nakovalnya) Co-Authored-By: Claude Sonnet 5.5 --- tools/blender/tests/smoke_hull_source.py | 3 +++ 1 file changed, 3 insertions(+) diff --git a/tools/blender/tests/smoke_hull_source.py b/tools/blender/tests/smoke_hull_source.py index ba5a12ea6..c60967253 100644 --- a/tools/blender/tests/smoke_hull_source.py +++ b/tools/blender/tests/smoke_hull_source.py @@ -646,6 +646,9 @@ def nakovalnya_frames(executable): for mount in mounts: near = min(math.hypot(mount.position[0] - c[0], mount.position[1] - c[1]) for c in centres) require(near <= 0.3, f"{mount.id} is {near:.2f} m from the nearest fitting octagon") + # Parts that face sideways (the belt collars and turning blocks, the wall hatches) draw nothing: no top view sees them. + require(len(octagons) == 20, f"{len(octagons)} octagons: ten flak drums and ten flak rings, and no belt collar's") + require(sum(row.layer != "silhouette" for row in built.lines) == 102, "The Nakovalnya kit's line count changed") kinds = {row.layer for row in built.lines} require({"silhouette", "outline", "step", "seam", "fitting"} <= kinds, f"The Nakovalnya lacks a layer: {kinds}") print(f"PASS frames the Nakovalnya's {len(built.lines)} lines are in the frame of its geometry") From fd1188a797dbef1fd61703decb1aac0b434404ec Mon Sep 17 00:00:00 2001 From: soul Date: Thu, 22 Oct 2026 16:06:59 +0200 Subject: [PATCH 174/444] hull_source: the silhouette is the zones' top view, what Rasterise reads Co-Authored-By: Claude Sonnet 5.5 --- tools/blender/aetheria_ships/hull_source.py | 6 +++--- tools/blender/tests/test_hull_source.py | 6 +++--- 2 files changed, 6 insertions(+), 6 deletions(-) diff --git a/tools/blender/aetheria_ships/hull_source.py b/tools/blender/aetheria_ships/hull_source.py index ca929560f..2689a7230 100644 --- a/tools/blender/aetheria_ships/hull_source.py +++ b/tools/blender/aetheria_ships/hull_source.py @@ -18,7 +18,7 @@ with it (a hull that redefines or shadows an included language fails a check): Frame: Ship Root, metres, nose -Y, starboard -X, up +Z. build() also takes the hull's HullData.HullType ("Ship", "Station" or "Turret"), which the caller reads: the add-on from the bound package's ship.cc, the bench from its flag. -Schematic lines (HullBuild.lines): the top-view silhouette first (OpenSCAD projection() of every zone and anchor mesh, +Schematic lines (HullBuild.lines): the top-view silhouette first (OpenSCAD projection() of every zone mesh, the geometry Rasterise reads, read from the SVG it exports), then the kit's lines, each with a fixed RGBA from LINE_STYLE. A kit part is placed with lp_at so its line is emitted in the frame the geometry is; a source that echoes no lines has the silhouette alone. A faction language's proportions judge Ship hulls only; every hull's zones must fit the layout grid (hull_grid.MAX_CELLS @@ -384,7 +384,7 @@ def over_silhouette(point, contours): def build(runner, library, hull_type, projector): """Reads the hull source through runner (see Runner) and judges it against the languages library(faction) answers with (the ECHO text of that faction's own language file). projector (see Projector) answers the top-view contours of every - zone and anchor mesh, which become the silhouette line. hull_type is the hull's HullData.HullType name, which the caller + zone mesh, which become the silhouette line. hull_type is the hull's HullData.HullType name, which the caller reads; only a "Ship" is judged on the language's proportions. Raises HullSourceError, before reading anything, when hull_type is not one of ship_cc.HULL_TYPE_NAMES, and when the source cannot be read at all; otherwise returns a HullBuild whose failed() lists the checks it breaks.""" @@ -412,7 +412,7 @@ def build(runner, library, hull_type, projector): body_zone = f"{donor.faction}:{donor.body}" if donor else own.body result = HullBuild(brief, {language.faction: language for language in owned}, zones, colours, anchors, anchor_meshes, own.glow, own.radiator, body_zone, hull_type) - meshes = [m for m in list(zones.values()) + list(anchor_meshes.values()) if m.triangles] + meshes = [m for m in zones.values() if m.triangles] # the meshes Rasterise reads, so the underlay registers on the grid if meshes: top = max(v[2] for m in meshes for v in m.vertices) result.lines = [SchematicLine("silhouette", True, tuple((x, y, top) for x, y in contour)) for contour in projector(meshes)] diff --git a/tools/blender/tests/test_hull_source.py b/tools/blender/tests/test_hull_source.py index a1d8d163b..173b7f551 100644 --- a/tools/blender/tests/test_hull_source.py +++ b/tools/blender/tests/test_hull_source.py @@ -750,7 +750,7 @@ class LineBuildTests(unittest.TestCase): _, built = self.built(line("outline", ((-1, 0, 1), (1, 0, 1)))) self.assertEqual([l.layer for l in built.lines], ["silhouette", "outline"]) self.assertEqual({p[2] for p in built.lines[0].points}, {3.0}) - self.assertEqual({p[:2] for p in built.lines[0].points}, {(-3.0, -5.0), (3.0, -5.0), (3.0, 5.1), (-3.0, 5.1)}) + self.assertEqual({p[:2] for p in built.lines[0].points}, {(-3.0, -5.0), (3.0, -5.0), (3.0, 5.0), (-3.0, 5.0)}) def test_each_layer_carries_its_fixed_style(self): kit = "".join(line(layer) for layer in hs.KIT_LAYERS) @@ -759,11 +759,11 @@ class LineBuildTests(unittest.TestCase): self.assertEqual(len({l.rgba() for l in built.lines}), 5) self.assertEqual(hs.LINE_STYLE["silhouette"], (1.0, 1.0, 1.0, 1.0)) - def test_the_projector_is_given_every_zone_and_anchor_mesh_that_has_geometry(self): + def test_the_projector_is_given_every_zone_mesh_that_has_geometry_and_no_anchor_mesh(self): fixture, given = Fixture(), [] hs.build(fixture.runner(), fixture.read_library, "Ship", lambda meshes: given.append(len(meshes)) or fixture.projector(meshes)) - self.assertEqual(given, [3]) # primary, secondary, emit:Th.A + self.assertEqual(given, [2]) # primary and secondary; the emitter mesh is not part of the layout's top view def test_a_kit_line_off_the_hull_fails_the_over_the_top_view_check_and_nothing_else(self): _, built = self.built(line(points=((0, 0, 2), (20, 0, 2)))) From 03502552373687a9bc67cf4b5c214174bdcd3bfb Mon Sep 17 00:00:00 2001 From: soul Date: Thu, 22 Oct 2026 16:07:35 +0200 Subject: [PATCH 175/444] PilotSeamTests: drop the probe text Co-Authored-By: Claude Sonnet 5.5 --- tests/Aetheria.Shared.Tests/PilotSeamTests.cs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/tests/Aetheria.Shared.Tests/PilotSeamTests.cs b/tests/Aetheria.Shared.Tests/PilotSeamTests.cs index 64eb493d8..3bfeb9f42 100644 --- a/tests/Aetheria.Shared.Tests/PilotSeamTests.cs +++ b/tests/Aetheria.Shared.Tests/PilotSeamTests.cs @@ -462,7 +462,7 @@ public sealed partial class RunStartTests var aim = normalize(east.Command.Aim.Value.xz); var toThird = dot(aim, normalize(third.Position.xz - east.Position.xz)); var toWest = dot(aim, normalize(west.Position.xz - east.Position.xz)); - Assert.True(toThird > toWest + .2f, $"aims at the designated target: toThird {toThird} toWest {toWest} tracked {east.VisibleEntities.Contains(third)}"); + Assert.True(toThird > toWest + .2f, $"aims at the designated target: toThird {toThird} toWest {toWest}"); } [Fact] From b064051efd2222d3da3b8c74f4db7e7d737ccd8d Mon Sep 17 00:00:00 2001 From: soul Date: Thu, 22 Oct 2026 16:11:03 +0200 Subject: [PATCH 176/444] Retarget test: east is staged knowing the third ship (ruling retarget-test-stages-the-track) Co-Authored-By: Claude Sonnet 5.5 --- tests/Aetheria.Shared.Tests/PilotSeamTests.cs | 2 ++ 1 file changed, 2 insertions(+) diff --git a/tests/Aetheria.Shared.Tests/PilotSeamTests.cs b/tests/Aetheria.Shared.Tests/PilotSeamTests.cs index 3bfeb9f42..a1d8870dc 100644 --- a/tests/Aetheria.Shared.Tests/PilotSeamTests.cs +++ b/tests/Aetheria.Shared.Tests/PilotSeamTests.cs @@ -454,6 +454,8 @@ public sealed partial class RunStartTests Assert.IsType(SeamState(agent)); Assert.Same(west, east.Target.Value.Entity); + // East is staged knowing the third ship, as an Approach follower knows its anchor: a sensor track by the sensors' own path. + east.EntityInfoGathered[third] = (east.ItemManager.GameplaySettings.TargetDetectionInfoThreshold + 1f) / 2f; east.SetTarget(TargetRef.None); east.Command.Designate(new TargetRef(third)); agent.Update(SeamDt); From 0cb78d26108ccbbe4681999abb60f5fe3ed7ddc5 Mon Sep 17 00:00:00 2001 From: soul Date: Thu, 22 Oct 2026 16:11:53 +0200 Subject: [PATCH 177/444] hull_source tests: pin style, echo parts, contour area, even-odd and edge tolerance, process refusals, check details Co-Authored-By: Claude Sonnet 5.5 --- tools/blender/tests/test_hull_source.py | 164 ++++++++++++++++++++++++ 1 file changed, 164 insertions(+) diff --git a/tools/blender/tests/test_hull_source.py b/tools/blender/tests/test_hull_source.py index 173b7f551..928bee976 100644 --- a/tools/blender/tests/test_hull_source.py +++ b/tools/blender/tests/test_hull_source.py @@ -7,6 +7,7 @@ real OpenSCAD and the real language files are exercised by smoke_hull_source.py. hull_source is loaded by path because the package __init__ imports bpy. """ +import dataclasses import importlib.util import json import sys @@ -859,5 +860,168 @@ class ProjectorTests(unittest.TestCase): self.assertEqual(hs.openscad_runner("scad", "hull.scad")("lines"), ("", line())) +class LineStyleTests(unittest.TestCase): + def test_the_style_table_is_the_one_the_underlay_reads(self): + self.assertEqual(hs.LINE_STYLE, { + "silhouette": (1.0, 1.0, 1.0, 1.0), "outline": (1.0, 1.0, 1.0, 0.75), "fitting": (1.0, 1.0, 1.0, 0.6), + "step": (1.0, 1.0, 1.0, 0.5), "seam": (1.0, 1.0, 1.0, 0.35)}) + self.assertEqual(hs.KIT_LAYERS, ("outline", "step", "seam", "fitting")) + + def test_a_schematic_line_cannot_be_changed_after_it_is_read(self): + read = hs.read_lines(line())[0] + with self.assertRaises(dataclasses.FrozenInstanceError): + read.layer = "step" + + def test_the_four_echo_parts_are_written_as_echo_files_and_geometry_parts_as_off(self): + for part, suffix in (("brief", ".echo"), ("lang", ".echo"), ("anchors", ".echo"), ("lines", ".echo"), ("primary", ".off")): + with self.subTest(part=part): + openscad = FakeOpenScad() + with mock.patch.object(hs.subprocess, "Popen", openscad): + hs.openscad_runner("scad", "hull.scad")(part) + command = openscad.calls[0][0] + self.assertTrue(command[command.index("-o") + 1].endswith(suffix)) + + +class LineRefusalTextTests(unittest.TestCase): + def refusal(self, text): + with self.assertRaises(hs.HullSourceError) as caught: + hs.read_lines(text) + return str(caught.exception) + + def test_each_refusal_says_one_fixed_thing(self): + self.assertEqual(self.refusal(echo("line", ["seam", False])), "A schematic line has the wrong shape") + self.assertEqual(self.refusal(echo("line", ["seam", 1, []])), "A schematic line has the wrong shape") + self.assertEqual(self.refusal(line("nope")), "A schematic line names a layer a kit does not draw") + self.assertEqual(self.refusal(line(points=((0, 0, 0),))), "A schematic line has fewer than two points") + self.assertEqual(self.refusal('ECHO: line = ["seam", false, [[0, 0, NaN], [1, 1, 1]]]\n'), + "A schematic line has a point that is not a finite number") + + +class ContourAreaTests(unittest.TestCase): + @staticmethod + def svg(*rings): + paths = "\n".join("M " + " L ".join(f"{x},{-y}" for x, y in ring) + " z" for ring in rings) + return f'' + + def test_a_contour_is_kept_from_a_quarter_square_metre_whichever_way_it_winds_or_where_it_lies(self): + square = [(3, 7), (3.5, 7), (3.5, 7.5), (3, 7.5)] + triangle = [(10, 10), (11, 10), (10, 11)] + for ring in (square, square[::-1], triangle, triangle[::-1]): + with self.subTest(ring=ring): + self.assertEqual(len(hs.parse_svg_contours(self.svg(ring))), 1) + + def test_a_contour_just_under_a_quarter_square_metre_is_dropped(self): + for ring in ([(3, 7), (3.6, 7), (3.6, 7.4), (3, 7.4)], [(0, 0), (1, 0), (0, 0.49)]): + with self.subTest(ring=ring): + self.assertEqual(hs.parse_svg_contours(self.svg(ring)), []) + + def test_a_contour_of_fewer_than_three_points_is_dropped(self): + self.assertEqual(hs.parse_svg_contours(self.svg([(0, 0), (9, 9)])), []) + + +class OverSilhouetteTests(unittest.TestCase): + TRIANGLE = [(0.0, 0.0), (10.0, 0.0), (0.0, 10.0)] + + def over(self, x, y, contours=None, z=99.0): + return hs.over_silhouette((x, y, z), [self.TRIANGLE] if contours is None else contours) + + def test_inside_and_far_outside(self): + self.assertTrue(self.over(2, 2)) + self.assertFalse(self.over(8, 8)) + self.assertFalse(self.over(-5, 3)) + + def test_the_height_of_a_point_does_not_matter(self): + self.assertTrue(self.over(2, 2, z=-50.0)) + self.assertFalse(self.over(8, 8, z=2.0)) + + def test_a_point_outside_but_within_the_tolerance_of_a_slanted_edge_is_over_the_hull(self): + # the hypotenuse x + y = 10; (5.1, 5.1) is 0.14 m outside it, (5.3, 5.3) is 0.42 m outside + self.assertTrue(self.over(5.1, 5.1)) + self.assertFalse(self.over(5.3, 5.3)) + + def test_a_point_beyond_the_end_of_an_edge_is_measured_to_its_end(self): + self.assertTrue(self.over(10.25, 0)) + self.assertFalse(self.over(10.35, 0)) + self.assertTrue(self.over(-0.25, -0.0)) + self.assertFalse(self.over(0, -0.35)) + + def test_a_ray_through_a_vertex_counts_the_edge_once(self): + diamond = [(0.0, -2.0), (2.0, 0.0), (0.0, 2.0), (-2.0, 0.0)] + self.assertTrue(self.over(0.5, 0, [diamond])) + self.assertTrue(self.over(-0.5, 0, [diamond])) + self.assertFalse(self.over(3.0, 0, [diamond])) + self.assertFalse(self.over(0.0, 3.0, [diamond])) + + def test_the_even_odd_rule_alternates_through_nested_contours(self): + rings = [[(-s, -s), (s, -s), (s, s), (-s, s)] for s in (10.0, 7.0, 4.0, 1.0)] + self.assertTrue(self.over(8.5, 0, rings)) # in the outer ring only + self.assertFalse(self.over(5.5, 0, rings)) # a hole + self.assertTrue(self.over(2.5, 0, rings)) # island in the hole + + def test_three_nested_contours_put_the_centre_back_on_the_hull(self): + rings = [[(-s, -s), (s, -s), (s, s), (-s, s)] for s in (10.0, 6.0, 2.0)] + self.assertTrue(self.over(0.0, 0.0, rings)) + + +class ProcessRefusalTests(unittest.TestCase): + def test_openscad_that_cannot_start_is_refused_in_fixed_words(self): + with mock.patch.object(hs.subprocess, "Popen", side_effect=OSError(SECRET)): + for make in (lambda: hs.openscad_runner("scad", "hull.scad")("primary"), + lambda: hs.openscad_projector("scad")([hs.parse_off(box(0, 1, 0, 1, 0, 1))])): + with self.assertRaises(hs.HullSourceError) as caught: + make() + self.assertEqual(str(caught.exception), "OpenSCAD could not be started") + + def test_openscad_that_does_not_finish_is_killed_and_refused_in_fixed_words(self): + class Slow(FakeOpenScad): + killed = False + + def communicate(self, timeout=None): + if not self.killed: + raise hs.subprocess.TimeoutExpired("scad", timeout) + return "", None + + def kill(self): + self.killed = True + + slow = Slow() + with mock.patch.object(hs.subprocess, "Popen", slow): + with self.assertRaises(hs.HullSourceError) as caught: + hs.openscad_runner("scad", "hull.scad")("primary") + self.assertEqual((str(caught.exception), slow.killed), ("OpenSCAD did not finish a part in time", True)) + + def test_a_projection_openscad_never_wrote_is_refused(self): + with mock.patch.object(hs.subprocess, "Popen", FakeOpenScad()): + with self.assertRaises(hs.HullSourceError) as caught: + hs.openscad_projector("scad")([hs.parse_off(box(0, 1, 0, 1, 0, 1))]) + self.assertEqual(str(caught.exception), "OpenSCAD refused a part of the hull source") + + def test_a_refusing_openscad_says_so_in_fixed_words(self): + with mock.patch.object(hs.subprocess, "Popen", FakeOpenScad(returncode=2)): + with self.assertRaises(hs.HullSourceError) as caught: + hs.openscad_runner("scad", "hull.scad")("primary") + self.assertEqual(str(caught.exception), "OpenSCAD refused a part of the hull source") + + +class LineCheckDetailTests(unittest.TestCase): + def details(self, kit, projector=Fixture.projector): + fixture = Fixture() + fixture.parts["lines"] = ("", kit) + built = hs.build(fixture.runner(), fixture.read_library, "Ship", projector) + return {c.name: c.detail for c in built.checks} + + def test_the_details_count_what_the_checks_found(self): + got = self.details(line(points=((0, 0, 2), (20, 0, 2))) + line("step") + line("step")) + self.assertEqual((got[SILHOUETTE], got[LINES_OVER], got[LINES_DISTINCT]), ("1 contours", "1 outside", "1 repeated")) + + def test_a_silhouette_is_not_a_kit_line_so_two_equal_contours_are_not_a_repeat(self): + twice = lambda meshes: Fixture.projector(meshes) * 2 + got = self.details("", twice) + self.assertEqual((got[SILHOUETTE], got[LINES_DISTINCT]), ("2 contours", "0 repeated")) + fixture = Fixture() + built = hs.build(fixture.runner(), fixture.read_library, "Ship", twice) + self.assertEqual(built.failed(), []) + + if __name__ == "__main__": unittest.main() From 970155a8aca6da8b5ad53ba0550a24c56d5078ce Mon Sep 17 00:00:00 2001 From: soul Date: Thu, 22 Oct 2026 16:12:17 +0200 Subject: [PATCH 178/444] Nakovalnya package rebuilt through the add-on: schematic lines from the kit (silhouette plus 102 kit lines) Co-Authored-By: Claude Sonnet 5.5 --- GameData/Mods/aetheria.nakovalnya/ship.cc | 4 ++-- GameData/Mods/aetheria.nakovalnya/ship.glb | 4 ++-- 2 files changed, 4 insertions(+), 4 deletions(-) diff --git a/GameData/Mods/aetheria.nakovalnya/ship.cc b/GameData/Mods/aetheria.nakovalnya/ship.cc index 2de9f3458..ebe05cd33 100644 --- a/GameData/Mods/aetheria.nakovalnya/ship.cc +++ b/GameData/Mods/aetheria.nakovalnya/ship.cc @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:2390998c0f36f647fbd7b12098a726445a747fc2dfd57c0e68b9fcc7e9116338 -size 12701 +oid sha256:87d8cbcca252460d1ec2bfeb895c1447ab185172a4a4449b237a297b83ea5057 +size 40059 diff --git a/GameData/Mods/aetheria.nakovalnya/ship.glb b/GameData/Mods/aetheria.nakovalnya/ship.glb index 27ed4683b..3dac76cc9 100644 --- a/GameData/Mods/aetheria.nakovalnya/ship.glb +++ b/GameData/Mods/aetheria.nakovalnya/ship.glb @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:c0f2e8bdb820330a1ba2b49c9615484b971f96634e7108ed675e788c38c3c8ba -size 843456 +oid sha256:90a503473e3b12251c225b7fe283b2fc5b45a25e6e47839d1c3d78e3ac61b00c +size 843452 From 4a6ddaa413b5a3ae803baa5f814d53f389033bdf Mon Sep 17 00:00:00 2001 From: soul Date: Thu, 22 Oct 2026 16:17:00 +0200 Subject: [PATCH 179/444] hull_source tests: a non-UTF-8 byte in the drawing, an unreadable number Co-Authored-By: Claude Sonnet 5.5 --- tools/blender/tests/test_hull_source.py | 20 ++++++++++++++++++++ 1 file changed, 20 insertions(+) diff --git a/tools/blender/tests/test_hull_source.py b/tools/blender/tests/test_hull_source.py index 928bee976..48a401404 100644 --- a/tools/blender/tests/test_hull_source.py +++ b/tools/blender/tests/test_hull_source.py @@ -1023,5 +1023,25 @@ class LineCheckDetailTests(unittest.TestCase): self.assertEqual(built.failed(), []) +class BytesOpenScad(FakeOpenScad): + """Writes raw bytes where -o points: an SVG with a byte that is not UTF-8 in its title.""" + + def __call__(self, command, **kwargs): + self.calls.append((command, kwargs.get("env"))) + Path(command[command.index("-o") + 1]).write_bytes(bytes([255]) + SVG.encode("utf-8")) + return self + + +class SilhouetteReadTests(unittest.TestCase): + def test_a_drawing_with_a_byte_that_is_not_utf8_still_reads(self): + with mock.patch.object(hs.subprocess, "Popen", BytesOpenScad()): + self.assertEqual(len(hs.openscad_projector("scad")([hs.parse_off(box(0, 1, 0, 1, 0, 1))])), 2) + + def test_a_number_that_is_not_a_number_is_refused_in_fixed_words(self): + with self.assertRaises(hs.HullSourceError) as caught: + hs.parse_svg_contours('') + self.assertEqual(str(caught.exception), "OpenSCAD wrote a silhouette that is not readable") + + if __name__ == "__main__": unittest.main() From c1d55952eb0730228fe0578fc4b732ba3dbb7398 Mon Sep 17 00:00:00 2001 From: soul Date: Thu, 22 Oct 2026 16:31:11 +0200 Subject: [PATCH 180/444] Smoke: Build records the kit lines and retiring the hull source deletes them Co-Authored-By: Claude Sonnet 5.5 --- tools/blender/tests/smoke_hull_source.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/tools/blender/tests/smoke_hull_source.py b/tools/blender/tests/smoke_hull_source.py index c60967253..7084274bb 100644 --- a/tools/blender/tests/smoke_hull_source.py +++ b/tools/blender/tests/smoke_hull_source.py @@ -493,7 +493,7 @@ def one_hull(scene, number, fixture, ship_id, extent, length, rows, body, palett report = scene.aetheria_package_report require(result == {"CANCELLED"} and "The package has no product" in report, f"Package took a hull with no zones and no art: {report!r} {message!r}") require((package / "ship.glb").read_bytes() == glb_before, "A refused Package changed ship.glb") - require("aetheria.hull_maker" in collection and "aetheria.hull_built" in collection and source.is_file() == (number % 2 == 1), + require("aetheria.hull_maker" in collection and "aetheria.hull_built" in collection and "aetheria.hull_lines" in collection and source.is_file() == (number % 2 == 1), "A refused Package changed Build's records or hull.scad") print(f"PASS {number}.9a Build's zones deleted with nothing in their place stay under the guard") art_mesh = bpy.data.meshes.new("author hull") @@ -510,7 +510,7 @@ def one_hull(scene, number, fixture, ship_id, extent, length, rows, body, palett report = scene.aetheria_package_report require(result == {"FINISHED"} and "retired the agent hull source" in report, f"Package did not retire the hull source: {report!r} {message!r}") require(not source.exists(), "hull.scad is still there after the author's art was packaged") - require("aetheria.hull_built" not in collection and "aetheria.hull_maker" not in collection, + require("aetheria.hull_built" not in collection and "aetheria.hull_maker" not in collection and "aetheria.hull_lines" not in collection, "The record of Build's objects or its maker outlived hull.scad") require(all(echoed not in report for echoed in (ship_id, str(GAME))), "The report echoes an input") select(box) From 3fa4768d1c76353c9a2b76a4daa5d7ae9aa71b8d Mon Sep 17 00:00:00 2001 From: soul Date: Thu, 22 Oct 2026 16:31:18 +0200 Subject: [PATCH 181/444] Cargo transfer tests: a partial commodity move duplicates the stack left behind (red), also through the buy path Co-Authored-By: Claude Sonnet 5.5 --- .../CargoTransferTests.cs | 153 ++++++++++++++++++ 1 file changed, 153 insertions(+) create mode 100644 tests/Aetheria.Shared.Tests/CargoTransferTests.cs diff --git a/tests/Aetheria.Shared.Tests/CargoTransferTests.cs b/tests/Aetheria.Shared.Tests/CargoTransferTests.cs new file mode 100644 index 000000000..ce8732fe1 --- /dev/null +++ b/tests/Aetheria.Shared.Tests/CargoTransferTests.cs @@ -0,0 +1,153 @@ +using System; +using System.Collections.Generic; +using System.IO; +using System.Linq; +using CultMath; +using GameCult.Caching; +using Xunit; + +// Goods moving bay to bay between ships and stations: the one conserving commit (EquippedCargoBay.TryTransferItem) that +// the docked trade, the sale and the give all go through. Fixture as StationServicesTests: authored settings, Ore. +public sealed class CargoTransferTests : IDisposable +{ + private readonly string _root = Path.Combine(Path.GetTempPath(), "aetheria-cargotransfer-" + Guid.NewGuid().ToString("N")); + private readonly CultCache _cache; + private readonly ItemManager _items; + private readonly Zone _zone; + + public CargoTransferTests() + { + Directory.CreateDirectory(_root); + _cache = AetheriaStores.Open(Path.Combine(_root, "Aetheria.cc"), catalogWritable: true); + _cache.Upsert(new VerseGrammar { Revision = 1 }); + var shape = new Shape(5, 5); + foreach (var cell in shape.AllCoordinates) shape[cell] = true; + _cache.Upsert(new HullData { Name = "Skiff", HullType = HullType.Ship, Shape = shape, Price = 1000, Durability = 10, Armor = 10 }); + _cache.Upsert(new HullData { Name = "Hub", HullType = HullType.Station, Shape = shape, Price = 5000, Durability = 10 }); + var bay = new Shape(4, 4); + foreach (var cell in bay.AllCoordinates) bay[cell] = true; + _cache.Upsert(new CargoBayData { Name = "Crate", Shape = new Shape(), InteriorShape = bay, Price = 20, Durability = 4 }); + var pocket = new Shape(1, 1); + foreach (var cell in pocket.AllCoordinates) pocket[cell] = true; + _cache.Upsert(new CargoBayData { Name = "Pocket", Shape = new Shape(), InteriorShape = pocket, Price = 5, Durability = 4 }); + _cache.Upsert(new SimpleCommodityData { Name = "Ore", Shape = new Shape(), Price = 7, MaxStack = 100 }); + _cache.Upsert(new SimpleCommodityData { Name = "Slag", Shape = new Shape(), Price = 1, MaxStack = 100 }); + _cache.FlushAsync().Wait(); + + var settings = AuthoredSettings.Load(RestoredHullsTests.FindRepoRoot()).Read("GameplaySettings"); + _items = new ItemManager(1u, _cache, new ProvenanceLedger(), settings, _ => { }); + _zone = new Zone(_items, new PlanetSettings(), new ZonePack(), new GalaxyZone { Name = "Test Zone", Owner = null }, null); + } + + public void Dispose() + { + _cache.Dispose(); + Directory.Delete(_root, true); + } + + private EquippableItem Mint(string design) + { + var data = _cache.GetByName(design); + return new EquippableItem + { + Data = _cache.RefOf(data), + Durability = data.Durability, + Lot = _items.Lots.Add(new Lot { Design = _cache.RefOf(data), Origin = new Attributed(), Quality = .5f, Roles = new List() }) + }; + } + + private SimpleCommodity Commodity(string design, int quantity) => + new SimpleCommodity { Data = _cache.RefOf(_cache.GetByName(design)), Quantity = quantity }; + + private Ship ShipWith(string bay) + { + var ship = new Ship(_items, _zone, Mint("Skiff"), new EntitySettings()); + Assert.True(ship.TryEquip(Mint(bay), new int2(1, 1))); + return ship; + } + + private OrbitalEntity Station() + { + var station = new OrbitalEntity(_items, _zone, Mint("Hub"), default, new EntitySettings()); + Assert.True(station.TryEquip(Mint("Crate"), new int2(1, 1))); + return station; + } + + // The units of the same design as `like` that the bay holds, over every stack. + private static int Units(EquippedCargoBay bay, SimpleCommodity like) => + bay.Cargo.Keys.OfType().Where(c => c.Data.Key.Equals(like.Data.Key)).Sum(c => c.Quantity); + + [Fact] + public void PartialCommodityTransferConserves() + { + var from = ShipWith("Crate").CargoBays[0]; + var to = ShipWith("Crate").CargoBays[0]; + var ore = Commodity("Ore", 10); + Assert.True(from.TryStore(ore)); + var massBefore = from.Mass + to.Mass; + + Assert.True(from.TryTransferItem(to, ore, 4)); + Assert.True(from.TryTransferItem(to, ore, 4)); + + Assert.Equal(2, Units(from, ore)); + Assert.Equal(8, Units(to, ore)); + Assert.Empty(from.Cargo.Keys.Intersect(to.Cargo.Keys)); + Assert.Equal(massBefore, from.Mass + to.Mass, 3); + } + + [Fact] + public void ABoughtPartOfAStationStackLeavesTheRestWithTheStation() + { + // TradeMenu.Buy delivers by this exact commit: the station's stack to the player's bay, a part at a time. + var station = Station(); + var stationBay = station.CargoBays[0]; + var player = ShipWith("Crate").CargoBays[0]; + var stock = Commodity("Ore", 10); + Assert.True(stationBay.TryStore(stock)); + var credits = 1000; + + Assert.Equal(BuyResult.Bought, StationServices.TryBuy(_items, stock, 4, ref credits, () => stationBay.TryTransferItem(player, stock, 4))); + Assert.Equal(BuyResult.Bought, StationServices.TryBuy(_items, stock, 4, ref credits, () => stationBay.TryTransferItem(player, stock, 4))); + + Assert.Equal(1000 - 2 * 4 * 7, credits); + Assert.Equal(2, Units(stationBay, stock)); + Assert.Equal(8, Units(player, stock)); + Assert.Empty(stationBay.Cargo.Keys.Intersect(player.Cargo.Keys)); + } + + [Fact] + public void RefusedCommodityTransferChangesNothing() + { + var from = ShipWith("Crate").CargoBays[0]; + var to = ShipWith("Pocket").CargoBays[0]; + var ore = Commodity("Ore", 10); + Assert.True(from.TryStore(ore)); + Assert.True(to.TryStore(Commodity("Slag", 5))); + var at = from.Cargo[ore]; + var massBefore = from.Mass + to.Mass; + + Assert.False(from.TryTransferItem(to, ore, 4)); + + Assert.Equal(10, ore.Quantity); + Assert.Equal(at, from.Cargo[ore]); + Assert.Equal(10, Units(from, ore)); + Assert.Equal(0, Units(to, ore)); + Assert.Equal(massBefore, from.Mass + to.Mass, 3); + } + + [Fact] + public void ATransferThatNeedsMoreRoomThanTheStackHasLeftChangesNothing() + { + // The receiver's only stack of Ore has room for 2 of the 4: the move is whole or nothing. + var from = ShipWith("Crate").CargoBays[0]; + var to = ShipWith("Pocket").CargoBays[0]; + var ore = Commodity("Ore", 10); + Assert.True(from.TryStore(ore)); + Assert.True(to.TryStore(Commodity("Ore", 98))); + + Assert.False(from.TryTransferItem(to, ore, 4)); + + Assert.Equal(10, Units(from, ore)); + Assert.Equal(98, Units(to, ore)); + } +} From cada2fde3e09e1aca8154628093e8fd901959b8e Mon Sep 17 00:00:00 2001 From: soul Date: Thu, 22 Oct 2026 16:38:17 +0200 Subject: [PATCH 182/444] Transfer verb: a pilot gives cargo to a ship alongside, committed through the conserving bay move TryTransferItem(SimpleCommodity) checks the target can take the part and stores the part removed, not the stack left behind (it duplicated and lost units on every partial move, the player's commodity buy among them). TryTransferItems goes. CargoTransfer.Step owns a give's progress and end; PilotCommand.Give and StopGiving are applied by PilotApplier; TransferRate is the new tunable, DockingDistance the range. Co-Authored-By: Claude Sonnet 5.5 --- Assets/Scripts/ServerShared/CargoTransfer.cs | 124 ++++++++++++++++++ .../ServerShared/CargoTransfer.cs.meta | 11 ++ Assets/Scripts/ServerShared/Entity.cs | 54 ++------ Assets/Scripts/ServerShared/Pilot.cs | 19 +++ Assets/Scripts/ServerShared/Settings.cs | 2 + Assets/Scripts/ServerShared/Zone.cs | 1 + 6 files changed, 171 insertions(+), 40 deletions(-) create mode 100644 Assets/Scripts/ServerShared/CargoTransfer.cs create mode 100644 Assets/Scripts/ServerShared/CargoTransfer.cs.meta diff --git a/Assets/Scripts/ServerShared/CargoTransfer.cs b/Assets/Scripts/ServerShared/CargoTransfer.cs new file mode 100644 index 000000000..4026bbc7b --- /dev/null +++ b/Assets/Scripts/ServerShared/CargoTransfer.cs @@ -0,0 +1,124 @@ +/* This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at https://mozilla.org/MPL/2.0/. */ + +using System.Linq; +using CultMath; +using static CultMath.math; + +// Why a give ended. Notification only: nothing reads it to decide anything. +public enum GiveEnd +{ + Done, + Stopped, + OutOfRange, + ReceiverGone, + ReceiverFull, + NothingLeft, + GiverDocked +} + +// A pilot's standing offer of goods to a ship alongside. Runtime only: nothing about a give is saved. +public sealed class GiveOrder +{ + public readonly Entity Receiver; + // A commodity order is for any stack of this item's design in the giver's bays; a crafted order is for this instance. + public readonly ItemInstance Item; + // Units still to move. + public int Remaining { get; internal set; } + // Units earned and not yet moved, fractional between steps. + internal double Budget; + + internal GiveOrder(Entity receiver, ItemInstance item, int quantity) + { + Receiver = receiver; + Item = item; + Remaining = quantity; + } +} + +// The one owner of a give's progress and its end (ruling tender-transfer-verb). A ship's own pilot asks for a give through +// PilotCommand.Give and PilotApplier starts it here; nothing else moves goods out of a hold in space (ruling +// transfer-is-give-only). Every unit moves through EquippedCargoBay.TryTransferItem, the one conserving bay-to-bay commit. +public static class CargoTransfer +{ + internal static void Begin(Entity giver, GiveOrder order) + { + End(giver, GiveEnd.Stopped); + giver.Giving = order; + } + + // Ends the giver's give, if it has one, for the reason. + internal static void End(Entity giver, GiveEnd reason) + { + if (giver.Giving == null) return; + giver.Giving = null; + giver.LastGiveEnd = reason; + } + + // One step of every give in the zone: the receiver is still here and alongside, and the rate earns units, each moved + // into the first receiver bay that takes it. + public static void Step(Zone zone, float deltaTime) + { + foreach (var giver in zone.Entities.ToArray()) + { + var order = giver.Giving; + if (order == null) continue; + var settings = giver.ItemManager.GameplaySettings; + var range = settings.DockingDistance; + if (!zone.Entities.Contains(order.Receiver)) + { + End(giver, GiveEnd.ReceiverGone); + continue; + } + if (lengthsq(order.Receiver.Position.xz - giver.Position.xz) > range * range) + { + End(giver, GiveEnd.OutOfRange); + continue; + } + + order.Budget += (double) settings.TransferRate * deltaTime; + while (order.Budget >= 1 && order.Remaining > 0) + { + var source = Source(giver, order); + if (source.Bay == null) + { + End(giver, GiveEnd.NothingLeft); + break; + } + if (!Move(source.Bay, source.Item, order.Receiver)) + { + End(giver, GiveEnd.ReceiverFull); + break; + } + order.Budget -= 1; + order.Remaining--; + } + if (giver.Giving == order && order.Remaining == 0) End(giver, GiveEnd.Done); + } + } + + // Where the next unit comes from: the first stack of the order's design in the giver's bays, or the crafted instance. + private static (EquippedCargoBay Bay, ItemInstance Item) Source(Entity giver, GiveOrder order) + { + foreach (var bay in giver.CargoBays) + { + if (order.Item is CraftedItemInstance crafted) + { + if (bay.Cargo.ContainsKey(crafted)) return (bay, crafted); + } + else if (bay.ItemsOfType.TryGetValue(order.Item.Data.Key, out var stacks)) + { + var stack = stacks.OfType().FirstOrDefault(s => s.Quantity > 0); + if (stack != null) return (bay, stack); + } + } + return (null, null); + } + + // One unit of a commodity, or the crafted item, into the first receiver bay that takes it. + private static bool Move(EquippedCargoBay from, ItemInstance item, Entity receiver) => + item is SimpleCommodity commodity + ? receiver.CargoBays.Any(bay => from.TryTransferItem(bay, commodity, 1)) + : receiver.CargoBays.Any(bay => from.TryTransferItem(bay, (CraftedItemInstance) item)); +} diff --git a/Assets/Scripts/ServerShared/CargoTransfer.cs.meta b/Assets/Scripts/ServerShared/CargoTransfer.cs.meta new file mode 100644 index 000000000..70599860c --- /dev/null +++ b/Assets/Scripts/ServerShared/CargoTransfer.cs.meta @@ -0,0 +1,11 @@ +fileFormatVersion: 2 +guid: 7be41c09d3a84f5e9a206d18c4f3b5a2 +MonoImporter: + externalObjects: {} + serializedVersion: 2 + defaultReferences: [] + executionOrder: 0 + icon: {instanceID: 0} + userData: + assetBundleName: + assetBundleVariant: diff --git a/Assets/Scripts/ServerShared/Entity.cs b/Assets/Scripts/ServerShared/Entity.cs index 6b1eab8c9..00df96087 100644 --- a/Assets/Scripts/ServerShared/Entity.cs +++ b/Assets/Scripts/ServerShared/Entity.cs @@ -191,6 +191,10 @@ public abstract class Entity // The pilot's tractor command (0..1); the Pickup behaviour spins its beam toward it. public float TractorCommand { get; internal set; } + // The give the pilot has under way, and why the last one ended. CargoTransfer is the only writer of both. + public GiveOrder Giving { get; internal set; } + public GiveEnd? LastGiveEnd { get; internal set; } + // The beam's spun-up state: Pickup's, or 0 without one. public float TractorPower => GetBehavior()?.Power ?? 0f; @@ -1460,42 +1464,6 @@ public abstract class Entity } } - // Attempts to move a given number of items of the given type to the target Entity - // Returns the number of items successfully transferred - public int TryTransferItems(Entity target, CultRecordKey itemDataID, int quantity) - { - int quantityTransferred = 0; - while (quantityTransferred < quantity) - { - EquippedCargoBay originInventory = CargoBays.FirstOrDefault(c => c.ItemsOfType.ContainsKey(itemDataID)); - - if (originInventory == null) break; - - var itemInstance = originInventory.ItemsOfType[itemDataID][0]; - - if (itemInstance is SimpleCommodity simpleCommodity) - { - var targetQuantity = min(simpleCommodity.Quantity, quantity - quantityTransferred); - if (!target.CargoBays.Any(c => originInventory.TryTransferItem(c, simpleCommodity, targetQuantity))) - { - quantityTransferred += targetQuantity - simpleCommodity.Quantity; - break; - } - - quantityTransferred += targetQuantity; - } - else if (itemInstance is CraftedItemInstance craftedItemInstance) - { - if (!target.CargoBays.Any(c => originInventory.TryTransferItem(c, craftedItemInstance))) - break; - - quantityTransferred++; - } - } - - return quantityTransferred; - } - public EquippableItem TryUnequip(EquippedItem item) { // Don't allow unequipping when the entity is active @@ -1752,6 +1720,7 @@ public abstract class Entity if (bay != null) { bay.DockedShip = ship; + CargoTransfer.End(ship, GiveEnd.GiverDocked); ship.SetParent(this); Zone.Remove(ship); ship.Deactivate(); @@ -2695,12 +2664,17 @@ public class EquippedCargoBay : EquippedItem return false; } + // Whole or nothing: the target must be able to take the part before it leaves the stack. + var part = new SimpleCommodity { Data = item.Data, Quantity = min(quantity, item.Quantity), Rotation = item.Rotation }; + if (!target.TryFindSpace(part, out _)) return false; + var oldPos = Cargo[item]; var newItem = Remove(item, quantity); - - if (target.TryStore(item)) return true; - - // Failed to transfer full quantity, move the remaining items back to their old slot + + // The part removed goes across, never the stack left behind. + if (target.TryStore(newItem)) return true; + + // The target refused after all: put the part back where it came from. TryStore(newItem, oldPos); return false; } diff --git a/Assets/Scripts/ServerShared/Pilot.cs b/Assets/Scripts/ServerShared/Pilot.cs index 8b04637d9..be71f877d 100644 --- a/Assets/Scripts/ServerShared/Pilot.cs +++ b/Assets/Scripts/ServerShared/Pilot.cs @@ -56,6 +56,8 @@ public sealed class PilotCommand internal readonly List<(ActivatedBehavior Behavior, bool Pull)> Triggers = new List<(ActivatedBehavior, bool)>(); internal readonly List<(Entity Other, bool? Hostile, bool Toggle)> Iffs = new List<(Entity, bool?, bool)>(); internal TargetVerb TargetVerb; + // The step's give verb: a Give, or a stop when its Receiver is null. The last one in a step wins. + internal (Entity Receiver, ItemInstance Item, int Quantity)? GiveVerb; internal int AimCycles; internal int ShutdownOverrideToggles, HeatsinkToggles, ShieldToggles, Pings; @@ -77,6 +79,17 @@ public sealed class PilotCommand public void ToggleHeatsinks() => HeatsinkToggles++; public void ToggleShield() => ShieldToggles++; public void Ping() => Pings++; + // Offers goods from this entity's hold to a ship alongside (ruling transfer-is-give-only): the quantity of a commodity, + // or the one crafted instance. It replaces any give under way. A give of nothing, or of less than one, is ignored, and + // so is one to oneself when applied. The last give or stop in a step wins. + public void Give(Entity receiver, ItemInstance item, int quantity) + { + if (receiver == null || item == null) return; + var count = item is CraftedItemInstance ? 1 : quantity; + if (count < 1) return; + GiveVerb = (receiver, item, count); + } + public void StopGiving() => GiveVerb = (null, null, 0); // The pilot's own pending decisions, which it reads back instead of the ship (that is last step's applied state). public TargetRef? Designated => Designations.Count > 0 ? Designations[Designations.Count - 1] : (TargetRef?) null; @@ -98,6 +111,7 @@ public sealed class PilotCommand Triggers.Clear(); Iffs.Clear(); TargetVerb = TargetVerb.None; + GiveVerb = null; AimCycles = 0; ShutdownOverrideToggles = HeatsinkToggles = ShieldToggles = Pings = 0; } @@ -134,6 +148,11 @@ public static class PilotApplier for (var i = 0; i < command.Pings; i++) entity.Sensor?.Ping(); foreach (var hail in command.Hails) entity.SetMessage(hail); foreach (var consumable in command.Uses) entity.TryActivateConsumable(consumable); + if (command.GiveVerb is (Entity receiver, ItemInstance given, int quantity)) + { + if (receiver == null) CargoTransfer.End(entity, GiveEnd.Stopped); + else if (!ReferenceEquals(receiver, entity)) CargoTransfer.Begin(entity, new GiveOrder(receiver, given, quantity)); + } if (command.Aim is float3 aim) entity.Aim = aim; foreach (var (behavior, pull) in command.Triggers) if (pull) behavior.Activate(); diff --git a/Assets/Scripts/ServerShared/Settings.cs b/Assets/Scripts/ServerShared/Settings.cs index 5a95f450d..b90b1252d 100644 --- a/Assets/Scripts/ServerShared/Settings.cs +++ b/Assets/Scripts/ServerShared/Settings.cs @@ -205,6 +205,8 @@ public class GameplaySettings public int WeaponGroupCount = 6; public float WarpDistance = 25; public float DockingDistance = 25; + // Units per second one pilot gives to a ship within DockingDistance; a commodity unit or one crafted item. + public float TransferRate = 2; public float ProductionPersonalityLerp = .05f; public float MessageDuration = 4f; public float TargetPersistenceDuration = 3; diff --git a/Assets/Scripts/ServerShared/Zone.cs b/Assets/Scripts/ServerShared/Zone.cs index f2276f4e4..a74a8f7b9 100644 --- a/Assets/Scripts/ServerShared/Zone.cs +++ b/Assets/Scripts/ServerShared/Zone.cs @@ -385,6 +385,7 @@ public class Zone foreach (var entity in entities) entity.Sense(); _departures.Clear(); + CargoTransfer.Step(this, deltaTime); StepMines(deltaTime); StepClouds(deltaTime); StepFloatingItems(deltaTime); From 7ac8f389014864e16d873840198ca13c14a207ac Mon Sep 17 00:00:00 2001 From: soul Date: Thu, 22 Oct 2026 16:38:18 +0200 Subject: [PATCH 183/444] Cargo transfer tests: the give verb's rate, ends, instance choice and press order Co-Authored-By: Claude Sonnet 5.5 --- .../CargoTransferTests.cs | 282 ++++++++++++++++++ 1 file changed, 282 insertions(+) diff --git a/tests/Aetheria.Shared.Tests/CargoTransferTests.cs b/tests/Aetheria.Shared.Tests/CargoTransferTests.cs index ce8732fe1..eca433719 100644 --- a/tests/Aetheria.Shared.Tests/CargoTransferTests.cs +++ b/tests/Aetheria.Shared.Tests/CargoTransferTests.cs @@ -32,6 +32,8 @@ public sealed class CargoTransferTests : IDisposable _cache.Upsert(new CargoBayData { Name = "Pocket", Shape = new Shape(), InteriorShape = pocket, Price = 5, Durability = 4 }); _cache.Upsert(new SimpleCommodityData { Name = "Ore", Shape = new Shape(), Price = 7, MaxStack = 100 }); _cache.Upsert(new SimpleCommodityData { Name = "Slag", Shape = new Shape(), Price = 1, MaxStack = 100 }); + _cache.Upsert(new DockingBayData { Name = "Berth", Shape = new Shape(), InteriorShape = bay, MaxSize = new int2(2, 2), Price = 60, Durability = 6 }); + _cache.Upsert(new GearData { Name = "Gun", Hardpoint = HardpointType.Sensors, Shape = new Shape(), Price = 400, Durability = 8 }); _cache.FlushAsync().Wait(); var settings = AuthoredSettings.Load(RestoredHullsTests.FindRepoRoot()).Read("GameplaySettings"); @@ -150,4 +152,284 @@ public sealed class CargoTransferTests : IDisposable Assert.Equal(10, Units(from, ore)); Assert.Equal(98, Units(to, ore)); } + + // ---- The give verb ------------------------------------------------------------------------------------------ + + private const float Dt = .1f; + + // Two ships in the zone, alongside each other, the giver holding `ore` units of Ore. + private (Ship Giver, Ship Receiver, SimpleCommodity Stack) Alongside(int ore, string receiverBay = "Crate") + { + var giver = ShipWith("Crate"); + var receiver = ShipWith(receiverBay); + var stack = Commodity("Ore", ore); + Assert.True(giver.CargoBays[0].TryStore(stack)); + giver.Position = new float3(0, 0, 0); + receiver.Position = new float3(5, 0, 0); + _zone.Admit(giver, piloted: false); + _zone.Admit(receiver, piloted: false); + return (giver, receiver, stack); + } + + private static int Held(Entity entity, SimpleCommodity like) => entity.CargoBays.Sum(bay => Units(bay, like)); + + private void Steps(int count) + { + for (var i = 0; i < count; i++) _zone.Update(Dt); + } + + [Fact] + public void GiveMovesAtTheRate() + { + var (giver, receiver, ore) = Alongside(10); + giver.Command.Give(receiver, ore, 10); + + for (var i = 1; i <= 10; i++) + { + Steps(1); + Assert.Equal(10, Held(giver, ore) + Held(receiver, ore)); + } + + // Rate 2 for one second: exactly two units, not one and not three. + Assert.Equal(2, Held(receiver, ore)); + Assert.Equal(8, Held(giver, ore)); + Assert.NotNull(giver.Giving); + } + + [Fact] + public void GiveEndsDoneAtItsQuantity() + { + var (giver, receiver, ore) = Alongside(10); + giver.Command.Give(receiver, ore, 3); + + Steps(40); + + Assert.Equal(3, Held(receiver, ore)); + Assert.Equal(7, Held(giver, ore)); + Assert.Null(giver.Giving); + Assert.Equal(GiveEnd.Done, giver.LastGiveEnd); + } + + [Fact] + public void GiveEndsOutOfRange() + { + var (giver, receiver, ore) = Alongside(10); + var range = _items.GameplaySettings.DockingDistance; + receiver.Position = new float3(range - 1, 0, 0); + giver.Command.Give(receiver, ore, 10); + Steps(10); + Assert.Equal(2, Held(receiver, ore)); + Assert.Null(giver.LastGiveEnd); + + receiver.Position = new float3(range + 1, 0, 0); + Steps(10); + + Assert.Equal(2, Held(receiver, ore)); + Assert.Null(giver.Giving); + Assert.Equal(GiveEnd.OutOfRange, giver.LastGiveEnd); + } + + [Fact] + public void GiveRangeIsMeasuredOnTheFlatPlane() + { + // The plane is xz: a ship far apart in y only is still alongside. + var (giver, receiver, ore) = Alongside(10); + receiver.Position = new float3(5, 1000, 0); + giver.Command.Give(receiver, ore, 10); + Steps(10); + Assert.Equal(2, Held(receiver, ore)); + } + + [Fact] + public void GiveEndsWhenTheReceiverLeaves() + { + var (giver, receiver, ore) = Alongside(10); + giver.Command.Give(receiver, ore, 10); + Steps(10); + Assert.Equal(2, Held(receiver, ore)); + + _zone.Remove(receiver); + Steps(10); + + Assert.Equal(8, Held(giver, ore)); + Assert.Equal(2, Held(receiver, ore)); + Assert.Null(giver.Giving); + Assert.Equal(GiveEnd.ReceiverGone, giver.LastGiveEnd); + } + + [Fact] + public void DockingEndsTheGiversGive() + { + var (giver, receiver, ore) = Alongside(10); + var station = new OrbitalEntity(_items, _zone, Mint("Hub"), default, new EntitySettings()); + Assert.True(station.TryEquip(Mint("Berth"), new int2(1, 1))); + giver.Command.Give(receiver, ore, 10); + Steps(10); + + Assert.NotNull(station.TryDock(giver)); + Steps(10); + + Assert.Null(giver.Giving); + Assert.Equal(GiveEnd.GiverDocked, giver.LastGiveEnd); + Assert.Equal(2, Held(receiver, ore)); + } + + [Fact] + public void GiveEndsOnAFullHold() + { + var (giver, receiver, ore) = Alongside(10, "Pocket"); + Assert.True(receiver.CargoBays[0].TryStore(Commodity("Slag", 5))); + giver.Command.Give(receiver, ore, 10); + + Steps(20); + + Assert.Equal(10, Held(giver, ore)); + Assert.Equal(0, Held(receiver, ore)); + Assert.Null(giver.Giving); + Assert.Equal(GiveEnd.ReceiverFull, giver.LastGiveEnd); + } + + [Fact] + public void GiveEndsWhenNothingIsLeft() + { + var (giver, receiver, ore) = Alongside(10); + giver.Command.Give(receiver, ore, 50); + + Steps(100); + + Assert.Equal(10, Held(receiver, ore)); + Assert.Equal(0, Held(giver, ore)); + Assert.Null(giver.Giving); + Assert.Equal(GiveEnd.NothingLeft, giver.LastGiveEnd); + } + + [Fact] + public void AGiveDrawsOnEveryStackOfTheDesign() + { + // Two stacks in two bays: the order is for the design, not for the stack it was named with. + var (giver, receiver, ore) = Alongside(3); + Assert.True(giver.TryEquip(Mint("Crate"), new int2(3, 3))); + Assert.True(giver.CargoBays[1].TryStore(Commodity("Ore", 4))); + giver.Command.Give(receiver, ore, 7); + + Steps(60); + + Assert.Equal(7, Held(receiver, ore)); + Assert.Equal(0, Held(giver, ore)); + Assert.Equal(GiveEnd.Done, giver.LastGiveEnd); + } + + [Fact] + public void AGivenCraftedItemIsThatInstance() + { + var (giver, receiver, _) = Alongside(0); + var first = Mint("Gun"); + var second = Mint("Gun"); + Assert.True(giver.CargoBays[0].TryStore(first)); + Assert.True(giver.CargoBays[0].TryStore(second)); + giver.Command.Give(receiver, second, 5); + + Steps(20); + + Assert.True(receiver.CargoBays[0].Cargo.ContainsKey(second)); + Assert.False(receiver.CargoBays[0].Cargo.ContainsKey(first)); + Assert.True(giver.CargoBays[0].Cargo.ContainsKey(first)); + Assert.False(giver.CargoBays[0].Cargo.ContainsKey(second)); + Assert.Equal(GiveEnd.Done, giver.LastGiveEnd); + } + + [Fact] + public void TheLastGivePressInAStepWins() + { + var (giver, receiver, ore) = Alongside(10); + var third = ShipWith("Crate"); + third.Position = new float3(0, 0, 5); + _zone.Admit(third, piloted: false); + + // A give then a stop leaves nothing, and nothing ended. + giver.Command.Give(receiver, ore, 10); + giver.Command.StopGiving(); + Steps(1); + Assert.Null(giver.Giving); + Assert.Null(giver.LastGiveEnd); + + // A stop then a give leaves the give. + giver.Command.StopGiving(); + giver.Command.Give(receiver, ore, 10); + Steps(1); + Assert.Same(receiver, giver.Giving.Receiver); + + // A second give in the same step replaces the first before it starts. + giver.Command.Give(receiver, ore, 10); + giver.Command.Give(third, ore, 10); + Steps(1); + Assert.Same(third, giver.Giving.Receiver); + Assert.Equal(GiveEnd.Stopped, giver.LastGiveEnd); + + // A give in a later step replaces the one under way, which ends Stopped. + giver.Command.Give(receiver, ore, 10); + Steps(1); + Assert.Same(receiver, giver.Giving.Receiver); + } + + [Fact] + public void StoppingEndsTheGiveAndKeepsWhatMoved() + { + var (giver, receiver, ore) = Alongside(10); + giver.Command.Give(receiver, ore, 10); + Steps(10); + giver.Command.StopGiving(); + Steps(10); + + Assert.Null(giver.Giving); + Assert.Equal(GiveEnd.Stopped, giver.LastGiveEnd); + Assert.Equal(2, Held(receiver, ore)); + Assert.Equal(8, Held(giver, ore)); + } + + [Fact] + public void AGiveToOneselfOrOfNothingIsIgnored() + { + var (giver, receiver, ore) = Alongside(10); + giver.Command.Give(receiver, ore, 10); + Steps(1); + var order = giver.Giving; + + giver.Command.Give(giver, ore, 5); + giver.Command.Give(receiver, ore, 0); + giver.Command.Give(receiver, ore, -3); + giver.Command.Give(null, ore, 5); + giver.Command.Give(receiver, null, 5); + Steps(1); + + Assert.Same(order, giver.Giving); + Assert.Null(giver.LastGiveEnd); + } + + [Fact] + public void AGiverStaysInTheZone() + { + var (giver, receiver, ore) = Alongside(10); + giver.Command.Designate(new TargetRef(receiver)); + receiver.Command.Designate(new TargetRef(giver)); + giver.Command.Give(receiver, ore, 10); + + Steps(20); + + Assert.True(_zone.Entities.Contains(giver)); + Assert.True(_zone.Entities.Contains(receiver)); + Assert.Same(receiver, giver.Target.Value.Entity); + Assert.Same(giver, receiver.Target.Value.Entity); + } + + [Fact] + public void NoOrderMovesNothing() + { + // No order, no work: a zone of ships that are not giving moves nothing. + var (giver, receiver, ore) = Alongside(10); + Steps(20); + Assert.Equal(10, Held(giver, ore)); + Assert.Equal(0, Held(receiver, ore)); + Assert.Null(giver.LastGiveEnd); + } } From c1aab929b19985fc9a2035a05113bd892db5f0e9 Mon Sep 17 00:00:00 2001 From: soul Date: Thu, 22 Oct 2026 16:42:42 +0200 Subject: [PATCH 184/444] Give verb: a stop is a verb with no receiver, and the type pattern skipped it; tests: second bay at a free cell Co-Authored-By: Claude Sonnet 5.5 --- Assets/Scripts/ServerShared/Pilot.cs | 6 +++--- tests/Aetheria.Shared.Tests/CargoTransferTests.cs | 2 +- 2 files changed, 4 insertions(+), 4 deletions(-) diff --git a/Assets/Scripts/ServerShared/Pilot.cs b/Assets/Scripts/ServerShared/Pilot.cs index be71f877d..dcfa655ae 100644 --- a/Assets/Scripts/ServerShared/Pilot.cs +++ b/Assets/Scripts/ServerShared/Pilot.cs @@ -148,10 +148,10 @@ public static class PilotApplier for (var i = 0; i < command.Pings; i++) entity.Sensor?.Ping(); foreach (var hail in command.Hails) entity.SetMessage(hail); foreach (var consumable in command.Uses) entity.TryActivateConsumable(consumable); - if (command.GiveVerb is (Entity receiver, ItemInstance given, int quantity)) + if (command.GiveVerb is { } give) { - if (receiver == null) CargoTransfer.End(entity, GiveEnd.Stopped); - else if (!ReferenceEquals(receiver, entity)) CargoTransfer.Begin(entity, new GiveOrder(receiver, given, quantity)); + if (give.Receiver == null) CargoTransfer.End(entity, GiveEnd.Stopped); + else if (!ReferenceEquals(give.Receiver, entity)) CargoTransfer.Begin(entity, new GiveOrder(give.Receiver, give.Item, give.Quantity)); } if (command.Aim is float3 aim) entity.Aim = aim; foreach (var (behavior, pull) in command.Triggers) diff --git a/tests/Aetheria.Shared.Tests/CargoTransferTests.cs b/tests/Aetheria.Shared.Tests/CargoTransferTests.cs index eca433719..5983747b4 100644 --- a/tests/Aetheria.Shared.Tests/CargoTransferTests.cs +++ b/tests/Aetheria.Shared.Tests/CargoTransferTests.cs @@ -308,7 +308,7 @@ public sealed class CargoTransferTests : IDisposable { // Two stacks in two bays: the order is for the design, not for the stack it was named with. var (giver, receiver, ore) = Alongside(3); - Assert.True(giver.TryEquip(Mint("Crate"), new int2(3, 3))); + Assert.True(giver.TryEquip(Mint("Crate"), new int2(2, 2))); Assert.True(giver.CargoBays[1].TryStore(Commodity("Ore", 4))); giver.Command.Give(receiver, ore, 7); From 4e559d822eae1e1ac5961d12ea10bbf22510e498 Mon Sep 17 00:00:00 2001 From: soul Date: Thu, 22 Oct 2026 16:46:35 +0200 Subject: [PATCH 185/444] Cargo transfer tests: the second bay goes where the hull has room Co-Authored-By: Claude Sonnet 5.5 --- tests/Aetheria.Shared.Tests/CargoTransferTests.cs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/tests/Aetheria.Shared.Tests/CargoTransferTests.cs b/tests/Aetheria.Shared.Tests/CargoTransferTests.cs index 5983747b4..44190c3de 100644 --- a/tests/Aetheria.Shared.Tests/CargoTransferTests.cs +++ b/tests/Aetheria.Shared.Tests/CargoTransferTests.cs @@ -308,7 +308,7 @@ public sealed class CargoTransferTests : IDisposable { // Two stacks in two bays: the order is for the design, not for the stack it was named with. var (giver, receiver, ore) = Alongside(3); - Assert.True(giver.TryEquip(Mint("Crate"), new int2(2, 2))); + Assert.True(giver.TryEquip(Mint("Crate"))); Assert.True(giver.CargoBays[1].TryStore(Commodity("Ore", 4))); giver.Command.Give(receiver, ore, 7); From bcfc776513f74654ba35a48093f6a26860bce745 Mon Sep 17 00:00:00 2001 From: soul Date: Thu, 22 Oct 2026 16:50:35 +0200 Subject: [PATCH 186/444] Cargo transfer tests: two stacks of one design come from a commodity that stacks two to a stack Co-Authored-By: Claude Sonnet 5.5 --- .../CargoTransferTests.cs | 20 ++++++++++--------- 1 file changed, 11 insertions(+), 9 deletions(-) diff --git a/tests/Aetheria.Shared.Tests/CargoTransferTests.cs b/tests/Aetheria.Shared.Tests/CargoTransferTests.cs index 44190c3de..148cde89d 100644 --- a/tests/Aetheria.Shared.Tests/CargoTransferTests.cs +++ b/tests/Aetheria.Shared.Tests/CargoTransferTests.cs @@ -31,6 +31,7 @@ public sealed class CargoTransferTests : IDisposable foreach (var cell in pocket.AllCoordinates) pocket[cell] = true; _cache.Upsert(new CargoBayData { Name = "Pocket", Shape = new Shape(), InteriorShape = pocket, Price = 5, Durability = 4 }); _cache.Upsert(new SimpleCommodityData { Name = "Ore", Shape = new Shape(), Price = 7, MaxStack = 100 }); + _cache.Upsert(new SimpleCommodityData { Name = "Grit", Shape = new Shape(), Price = 1, MaxStack = 2 }); _cache.Upsert(new SimpleCommodityData { Name = "Slag", Shape = new Shape(), Price = 1, MaxStack = 100 }); _cache.Upsert(new DockingBayData { Name = "Berth", Shape = new Shape(), InteriorShape = bay, MaxSize = new int2(2, 2), Price = 60, Durability = 6 }); _cache.Upsert(new GearData { Name = "Gun", Hardpoint = HardpointType.Sensors, Shape = new Shape(), Price = 400, Durability = 8 }); @@ -163,7 +164,7 @@ public sealed class CargoTransferTests : IDisposable var giver = ShipWith("Crate"); var receiver = ShipWith(receiverBay); var stack = Commodity("Ore", ore); - Assert.True(giver.CargoBays[0].TryStore(stack)); + if (ore > 0) Assert.True(giver.CargoBays[0].TryStore(stack)); giver.Position = new float3(0, 0, 0); receiver.Position = new float3(5, 0, 0); _zone.Admit(giver, piloted: false); @@ -306,16 +307,17 @@ public sealed class CargoTransferTests : IDisposable [Fact] public void AGiveDrawsOnEveryStackOfTheDesign() { - // Two stacks in two bays: the order is for the design, not for the stack it was named with. - var (giver, receiver, ore) = Alongside(3); - Assert.True(giver.TryEquip(Mint("Crate"))); - Assert.True(giver.CargoBays[1].TryStore(Commodity("Ore", 4))); - giver.Command.Give(receiver, ore, 7); + // Grit stacks two to a stack, so three units are two stacks: the order is for the design, not the stack named. + var (giver, receiver, _) = Alongside(0); + var grit = Commodity("Grit", 3); + Assert.True(giver.CargoBays[0].TryStore(grit)); + Assert.Equal(2, giver.CargoBays[0].ItemsOfType[grit.Data.Key].Count); + giver.Command.Give(receiver, grit, 3); - Steps(60); + Steps(40); - Assert.Equal(7, Held(receiver, ore)); - Assert.Equal(0, Held(giver, ore)); + Assert.Equal(3, Held(receiver, grit)); + Assert.Equal(0, Held(giver, grit)); Assert.Equal(GiveEnd.Done, giver.LastGiveEnd); } From 2b053b34c2ea7500f6cac534002824c2b282bd4b Mon Sep 17 00:00:00 2001 From: soul Date: Thu, 22 Oct 2026 16:52:17 +0200 Subject: [PATCH 187/444] Smoke: a retired hull's lines are not hers, and Capture writes the kit's lines in Package's frame (red) Co-Authored-By: Claude Sonnet 5.5 --- tools/blender/tests/smoke_hull_source.py | 31 ++++++++++++++++++++++++ 1 file changed, 31 insertions(+) diff --git a/tools/blender/tests/smoke_hull_source.py b/tools/blender/tests/smoke_hull_source.py index 7084274bb..a3676b0c7 100644 --- a/tools/blender/tests/smoke_hull_source.py +++ b/tools/blender/tests/smoke_hull_source.py @@ -403,6 +403,21 @@ def one_hull(scene, number, fixture, ship_id, extent, length, rows, body, palett require(result == {"FINISHED"} and len(ship_lines(ship_cc)) == len(packaged), f"Without Grease Pencil the kit's lines are back: {message!r}") print(f"PASS {number}.5b the kit's lines ship, her Grease Pencil wins, two are refused") + # Capture's kit branch writes what Package wrote, and also when Save Layout has moved the centre of mass since the last + # Package (here: Ship Root is off the origin), because Capture frames the ship as Package does. + shipped = ship_lines(ship_cc) + select(box) + result, message = run(bpy.ops.aetheria.capture_ship_lines) + require(result == {"FINISHED"} and same_lines(ship_lines(ship_cc), shipped), f"Capture did not write the kit's lines as Package did: {message!r}") + root.location.x += 1.7 + root.location.y -= 0.9 + bpy.context.view_layer.update() + select(box) + result, message = run(bpy.ops.aetheria.capture_ship_lines) + require(result == {"FINISHED"} and same_lines(ship_lines(ship_cc), shipped), + f"Capture, with Ship Root off its Package frame, wrote lines out of the packaged model's frame: {message!r}") + print(f"PASS {number}.5c Capture writes the kit's lines in Package's frame") + # What Package did to the built objects is not an edit: Build still replaces them. select(box) result, message = run(bpy.ops.aetheria.build_hull_source) @@ -513,6 +528,15 @@ def one_hull(scene, number, fixture, ship_id, extent, length, rows, body, palett require("aetheria.hull_built" not in collection and "aetheria.hull_maker" not in collection and "aetheria.hull_lines" not in collection, "The record of Build's objects or its maker outlived hull.scad") require(all(echoed not in report for echoed in (ship_id, str(GAME))), "The report echoes an input") + # The retired hull's kit lines are not hers: the slot is empty (Studio draws no underlay, the preview no line mesh), + # Capture has nothing to capture, and a second Package leaves it empty. + require(ship_lines(ship_cc) == [], f"The retired hull's {len(ship_lines(ship_cc))} lines ship over her art") + select(box) + result, message = run(bpy.ops.aetheria.capture_ship_lines) + require(result == {"CANCELLED"} and ship_lines(ship_cc) == [], f"Capture wrote lines for a hull with no source: {message!r}") + select(box) + result, message = run(bpy.ops.aetheria.package_ship) + require(result == {"FINISHED"} and ship_lines(ship_cc) == [], f"A second Package brought lines back: {message!r} {ship_lines(ship_cc)[:1]}") select(box) result, message = run(bpy.ops.aetheria.build_hull_source) require(result == {"CANCELLED"} and bpy.data.objects.get("author hull") is art, f"Build ran after takeover: {message!r}") @@ -608,6 +632,13 @@ def ship_lines(ship_path): return sys.modules["aetheria_ships"].read(ship_path, PACKAGES).ship.body[sys.modules["aetheria_ships"].ship_cc.SCHEMATIC_LINES_SLOT] +def same_lines(a, b, tolerance=1e-4): + """Whether two SchematicLines slots hold the same rows: layer, cyclic, colour and every point within tolerance.""" + return len(a) == len(b) and all( + x[0] == y[0] and x[5] == y[5] and len(x[2]) == len(y[2]) and approx(x[6], y[6], tolerance) + and all(abs(p - q) <= tolerance for p, q in zip(x[2], y[2])) for x, y in zip(a, b)) + + def grease_pencil(collection, name, points): """A Grease Pencil object in the collection holding one stroke, layer named 'hers'.""" data = bpy.data.grease_pencils.new(name) From bb38036e5148f2618f1c2587097d9201cbc44874 Mon Sep 17 00:00:00 2001 From: soul Date: Thu, 22 Oct 2026 16:54:12 +0200 Subject: [PATCH 188/444] Cargo transfer tests: build the second stack with a second store Co-Authored-By: Claude Sonnet 5.5 --- tests/Aetheria.Shared.Tests/CargoTransferTests.cs | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/tests/Aetheria.Shared.Tests/CargoTransferTests.cs b/tests/Aetheria.Shared.Tests/CargoTransferTests.cs index 148cde89d..d2c1db6fb 100644 --- a/tests/Aetheria.Shared.Tests/CargoTransferTests.cs +++ b/tests/Aetheria.Shared.Tests/CargoTransferTests.cs @@ -309,8 +309,9 @@ public sealed class CargoTransferTests : IDisposable { // Grit stacks two to a stack, so three units are two stacks: the order is for the design, not the stack named. var (giver, receiver, _) = Alongside(0); - var grit = Commodity("Grit", 3); + var grit = Commodity("Grit", 2); Assert.True(giver.CargoBays[0].TryStore(grit)); + Assert.True(giver.CargoBays[0].TryStore(Commodity("Grit", 1))); Assert.Equal(2, giver.CargoBays[0].ItemsOfType[grit.Data.Key].Count); giver.Command.Give(receiver, grit, 3); From 87dc0f013f4efb083be86e6341399e1df1161be9 Mon Sep 17 00:00:00 2001 From: soul Date: Thu, 22 Oct 2026 16:54:13 +0200 Subject: [PATCH 189/444] Capture Ship Lines frames the ship as Package does, so kit and Grease Pencil lines land in ship.glb's frame Co-Authored-By: Claude Sonnet 5.5 --- tools/blender/aetheria_ships/__init__.py | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/tools/blender/aetheria_ships/__init__.py b/tools/blender/aetheria_ships/__init__.py index 08daa095c..d12c19fc8 100644 --- a/tools/blender/aetheria_ships/__init__.py +++ b/tools/blender/aetheria_ships/__init__.py @@ -545,6 +545,10 @@ class AETHERIA_OT_capture_ship_lines(bpy.types.Operator): cultlib = _brokkr_cultlib(context) if read(path, cultlib).ship.body[0] != collection["aetheria.id"]: raise ValueError("The bound collection ID does not match its .cc ship ID") + saved_shape, _, _ = read_layout(path, cultlib) + root = _ship_root(collection) + if root is not None: # the frame Package would write the model in, so the lines match ship.glb + _frame_ship(context, collection, root, path, saved_shape) lines = _schematic_lines(context, collection) if lines is None: raise ValueError("No schematic lines were captured; the ship record was not changed") From dab415ad872a3b956adebdf24e2ebf859bba72dd Mon Sep 17 00:00:00 2001 From: soul Date: Thu, 22 Oct 2026 16:55:50 +0200 Subject: [PATCH 190/444] Package: her art's takeover leaves no retired hull lines in the .cc (state decides the source; the slot is cleared) Co-Authored-By: Claude Sonnet 5.5 --- tools/blender/aetheria_ships/__init__.py | 13 ++++++++----- tools/blender/aetheria_ships/ship_cc.py | 10 ++++++++++ tools/blender/tests/test_ship_cc.py | 16 ++++++++++++++++ 3 files changed, 34 insertions(+), 5 deletions(-) diff --git a/tools/blender/aetheria_ships/__init__.py b/tools/blender/aetheria_ships/__init__.py index d12c19fc8..821529244 100644 --- a/tools/blender/aetheria_ships/__init__.py +++ b/tools/blender/aetheria_ships/__init__.py @@ -23,7 +23,7 @@ import numpy from mathutils import Matrix, Vector from . import hull_grid, hull_source -from .ship_cc import (HARDPOINT_MEMBERS, HARDPOINT_TYPE_NAMES, ROTATION_NAMES, capture_grease_pencil, decode_hardpoint, +from .ship_cc import (HARDPOINT_MEMBERS, HARDPOINT_TYPE_NAMES, ROTATION_NAMES, capture_grease_pencil, clear_lines, decode_hardpoint, encode_hardpoint, layout_revision, read, read_hull_type, read_layout, replace_layout, replace_lines, product_makers, replace_visual, valid_ship_id) @@ -1639,9 +1639,10 @@ def _build_hull_source(context, collection): def _schematic_lines(context, collection): """The ShipAuthoring.SchematicLines rows Package and Capture write, and the only source of them: her Grease Pencil - object if the collection has one (two or more are refused), else the lines Build recorded from the hull source's kit, - mapped through Ship Root's matrix_world (so after _frame_ship, the frame her strokes are captured in), else None and - the slot is left as it is.""" + object if the collection has one (two or more are refused), else the lines Build recorded from the hull source's kit + while the hull is still Build's (not _hull_state art), mapped through Ship Root's matrix_world (so after _frame_ship, + the frame her strokes are captured in), else None and the slot is left as it is. Package alone clears the slot, at + the moment her art takes over.""" pencils = [obj for obj in collection.all_objects if obj.type == "GREASEPENCIL"] if len(pencils) > 1: raise ValueError("Keep one Grease Pencil object: it is the ship's schematic") @@ -1650,7 +1651,7 @@ def _schematic_lines(context, collection): return capture_grease_pencil(pencils[0], context.evaluated_depsgraph_get(), scene.frame_current, evaluated=scene.aetheria_capture_evaluated_lines) record = collection.get(HULL_LINES) - if record is None: + if record is None or _hull_state(collection) == HULL_ART: # her art took over: Build's lines are a retired hull's return None root = _ship_root(collection) world = root.matrix_world if root is not None else Matrix.Identity(4) @@ -1773,6 +1774,8 @@ class AETHERIA_OT_package_ship(bpy.types.Operator): lines = _schematic_lines(context, collection) if lines is not None: replace_lines(path, cultlib, lines) + elif _hull_state(collection) == HULL_ART: # the lines in the slot are the retired hull's, not hers + clear_lines(path, cultlib) code, message = _aetherdb(context, "validate", path, ship_cc=path) except _EXPECTED as exc: scene.aetheria_package_report = f"Package failed: {_failure_text(exc)}" diff --git a/tools/blender/aetheria_ships/ship_cc.py b/tools/blender/aetheria_ships/ship_cc.py index f3742dd4f..dd3ca78be 100644 --- a/tools/blender/aetheria_ships/ship_cc.py +++ b/tools/blender/aetheria_ships/ship_cc.py @@ -179,6 +179,16 @@ def replace_lines(path: str, cultlib_packages: str, lines: list[list[Any]]) -> i return len(lines) +def clear_lines(path: str, cultlib_packages: str) -> int: + """Empties the line slot and nothing else; returns how many lines it held. replace_lines refuses an empty capture so a + failed capture never wipes a ship; this is the one deliberate way to leave a ship without lines.""" + file = read(path, cultlib_packages) + held = len(file.ship.body[SCHEMATIC_LINES_SLOT] or []) + file.ship.body[SCHEMATIC_LINES_SLOT] = [] + file.store.push(_stamped(file.ship, file.msgpack)) + return held + + def replace_visual(path: str, cultlib_packages: str, expected_id: str, model_asset: str, anchors: list[list[Any]]) -> int: """Writes the visual's model asset and anchor list (ShipAuthoring slots 2 and 3) and nothing else. Each anchor row is diff --git a/tools/blender/tests/test_ship_cc.py b/tools/blender/tests/test_ship_cc.py index ac01b2faa..0b4da5677 100644 --- a/tools/blender/tests/test_ship_cc.py +++ b/tools/blender/tests/test_ship_cc.py @@ -344,6 +344,22 @@ class ReplaceLinesTests(ShipFileCase): if slot != ship_cc.SCHEMATIC_LINES_SLOT: self.assertEqual(before[slot], after[slot], f"slot {slot} changed") + def test_clear_lines_empties_only_the_line_slot(self): + ship_cc.replace_lines(self.path, PACKAGES, LINES) + before = self.ship() + self.assertEqual(len(LINES), ship_cc.clear_lines(self.path, PACKAGES)) + after = self.ship() + self.assertEqual([], after[ship_cc.SCHEMATIC_LINES_SLOT]) + for slot in range(len(before)): + if slot != ship_cc.SCHEMATIC_LINES_SLOT: + self.assertEqual(before[slot], after[slot], f"slot {slot} changed") + + def test_replace_lines_still_refuses_an_empty_capture(self): + ship_cc.replace_lines(self.path, PACKAGES, LINES) + with self.assertRaises(ValueError): + ship_cc.replace_lines(self.path, PACKAGES, []) + self.assertEqual(LINES, self.ship()[ship_cc.SCHEMATIC_LINES_SLOT]) + def test_a_save_refreshes_the_stored_at_of_the_record_it_writes_and_only_that_one(self): stale = "2000-01-01T00:00:00+00:00" envelopes = self.envelopes() From 71b12d594b7d7498c7c23090b85ae9eb7dd38c77 Mon Sep 17 00:00:00 2001 From: soul Date: Thu, 22 Oct 2026 17:09:04 +0200 Subject: [PATCH 191/444] Kit lines tests: a line drawn outside its geometry's frame fails Build; side-facing fittings emit no line (red) Co-Authored-By: Claude Sonnet 5.5 --- tools/blender/tests/test_hull_source.py | 62 +++++++++++++++- tools/blender/tests/test_kit_openscad.py | 92 ++++++++++++++++++++++++ 2 files changed, 153 insertions(+), 1 deletion(-) create mode 100644 tools/blender/tests/test_kit_openscad.py diff --git a/tools/blender/tests/test_hull_source.py b/tools/blender/tests/test_hull_source.py index 48a401404..46e153a3f 100644 --- a/tools/blender/tests/test_hull_source.py +++ b/tools/blender/tests/test_hull_source.py @@ -51,6 +51,19 @@ def brief(faction="zhestokost", donor="", length=(8, 14), triangles=(10, 100)): return echo("brief", [faction, donor, "tender", list(length), list(triangles), ["a read"]]) +def marks_off(points, size=0.002): + """An OFF text of one tiny cube per point, as the kit's marks part is.""" + rows, faces = [], [] + for i, (x, y, z) in enumerate(points): + h = size / 2 + rows += [(x + dx * h, y + dy * h, z + dz * h) for dz in (-1, 1) for dy in (-1, 1) for dx in (-1, 1)] + b = 8 * i + faces += [(b, b + 1, b + 3, b + 2), (b + 4, b + 6, b + 7, b + 5), (b, b + 2, b + 6, b + 4), + (b + 1, b + 5, b + 7, b + 3), (b, b + 4, b + 5, b + 1), (b + 2, b + 3, b + 7, b + 6)] + return "\n".join(["OFF", f"{len(rows)} {len(faces)} 0"] + [" ".join(map(repr, r)) for r in rows] + + ["4 " + " ".join(map(str, f)) for f in faces]) + "\n" + + class Fixture: """A hull source as the fake runner sees it: part -> (OFF, ECHO). library holds the ECHO text of each faction's own language file; the hull's own `lang` echo is the files it includes, unless hull_lang says otherwise.""" @@ -70,6 +83,7 @@ class Fixture: "emit:Th.A": (box(0.5, 1.5, 5, 5.1, 0.5, 1.5), ""), "lines": ("", ""), } + self.marks = None # the "marks" part when a test sets one; else a mark at each kit line's first point @staticmethod def projector(meshes): @@ -81,7 +95,13 @@ class Fixture: return [[(x0, y0), (x1, y0), (x1, y1), (x0, y1)]] def runner(self): - return lambda part: ("", self.hull_lang if self.hull_lang is not None else "".join(self.library.values())) if part == "lang" else self.parts[part] + return lambda part: ("", self.hull_lang if self.hull_lang is not None else "".join(self.library.values())) if part == "lang" else self.mark_part() if part == "marks" else self.parts[part] + + def mark_part(self): + """What the kit's lp_line draws in the geometry's own frame: a tiny cube at each line's first point.""" + if self.marks is not None: + return self.marks + return (marks_off([l.points[0] for l in hs.read_lines(self.parts["lines"][1])]), "") def read_library(self, faction): if faction not in self.library: @@ -680,6 +700,7 @@ class RefusalTests(unittest.TestCase): LINES_OVER = "every kit line lies over the hull's top view" LINES_DISTINCT = "no two kit lines are the same" +LINES_FRAME = "every kit line is drawn in the frame of its geometry" SILHOUETTE = "the top view yields a silhouette" @@ -687,6 +708,45 @@ def line(layer="seam", points=((-1, -1, 2), (1, 1, 2)), cyclic=False): return echo("line", [layer, cyclic, [list(p) for p in points]]) +class FrameCheckTests(unittest.TestCase): + """The kit draws a mark where each line starts, in the frame the geometry is drawn in; the line is echoed in the frame + lp_at tracks. A part placed by anything but lp_at has its mark and its line in different places.""" + + def built(self, kit, marks=None): + fixture = Fixture() + fixture.parts["lines"] = ("", kit) + if marks is not None: + fixture.marks = (marks_off(marks), "") + return fixture.build() + + def test_marks_where_the_lines_start_pass_and_the_check_is_listed(self): + built = self.built(line() + line("step", ((0, 0, 2), (1, 1, 2)))) + self.assertEqual(built.failed(), []) + self.assertIn(LINES_FRAME, names(built)) + + def test_a_mark_three_metres_from_its_line_fails_the_frame_check_and_nothing_else(self): + built = self.built(line(points=((-1, -1, 2), (1, 1, 2))), marks=[(-1, 2, 2)]) + self.assertEqual([c.name for c in built.failed()], [LINES_FRAME]) + + def test_a_line_with_no_mark_fails(self): + built = self.built(line() + line("step", ((0, 0, 2), (1, 1, 2))), marks=[(-1, -1, 2)]) + self.assertEqual([c.name for c in built.failed()], [LINES_FRAME]) + + def test_a_mark_with_no_line_fails(self): + built = self.built(line(), marks=[(-1, -1, 2), (2, 2, 2)]) + self.assertEqual([c.name for c in built.failed()], [LINES_FRAME]) + + def test_a_mark_a_millimetre_off_is_the_same_place(self): + self.assertEqual(self.built(line(), marks=[(-1, -1, 2.001)]).failed(), []) + + def test_a_source_with_no_lines_and_no_marks_passes(self): + self.assertEqual(self.built("", marks=[]).failed(), []) + + def test_the_detail_counts_the_lines_and_marks_that_disagree(self): + built = self.built(line(), marks=[(5, 5, 2)]) + self.assertEqual({c.name: c.detail for c in built.checks}[LINES_FRAME], "1 lines and 1 marks disagree") + + class LineReadingTests(unittest.TestCase): def test_lines_are_read_in_echo_order_with_their_layer_flag_and_points(self): text = line("step", ((0, 0, 1), (1, 0, 1), (1, 1, 1)), True) + line("fitting") diff --git a/tools/blender/tests/test_kit_openscad.py b/tools/blender/tests/test_kit_openscad.py new file mode 100644 index 000000000..6867b429f --- /dev/null +++ b/tools/blender/tests/test_kit_openscad.py @@ -0,0 +1,92 @@ +"""The Zhestokost kit through the real OpenSCAD: where its schematic lines are drawn relative to its geometry, and which +frames draw none. Skipped unless SMOKE_OPENSCAD names the OpenSCAD executable (nightly with the Manifold backend). + + SMOKE_OPENSCAD=\\openscad.com python -m unittest discover -s tools/blender/tests -p "test_kit_openscad.py" + +hull_source is loaded by path because the package __init__ imports bpy. +""" + +import importlib.util +import os +import sys +import tempfile +import unittest +from pathlib import Path + +_spec = importlib.util.spec_from_file_location( + "hull_source", Path(__file__).resolve().parents[1] / "aetheria_ships" / "hull_source.py") +hs = importlib.util.module_from_spec(_spec) +sys.modules["hull_source"] = hs +_spec.loader.exec_module(hs) + +OPENSCAD = os.environ.get("SMOKE_OPENSCAD") +REPO = Path(__file__).resolve().parents[3] +NAKOVALNYA = REPO / "GameData" / "Mods" / "aetheria.nakovalnya" / "hull.scad" +FRAME = "every kit line is drawn in the frame of its geometry" + +# Placement drifts (finding line-drift-inside-footprint): a raw translate or rotate around a part that draws lines, not an +# lp_at. Each moves the geometry and leaves the line where lp_at's frame says. +DRIFTS = { + "spine mass 3 m aft": ("zh_mass(T2);", "translate([0, 3, 0]) zh_mass(T2);"), + "radiator troughs 4 m forward": ("for (s = [-1, 1]) zh_on_top(M, RAD_Y, s*RAD_U) zh_rad_trough(RAD_W, RAD_L, 0.45);", + "translate([0, -4, 0]) for (s = [-1, 1]) zh_on_top(M, RAD_Y, s*RAD_U) zh_rad_trough(RAD_W, RAD_L, 0.45);"), + "apron reveals 3 m aft": ("for (u = [-0.33, 0.33]) zh_on_top(APRON, -19.5, u) zh_reveal(2.3, 7.0);", + "translate([0, 3, 0]) for (u = [-0.33, 0.33]) zh_on_top(APRON, -19.5, u) zh_reveal(2.3, 7.0);"), + "flak ring rotated 18 degrees": ("for (f = FLAK) lp_at(t=[f[0], f[1], DECK - ZH_SINK]) flak(f[2]);", + "rotate([0, 0, 18]) for (f = FLAK) lp_at(t=[f[0], f[1], DECK - ZH_SINK]) flak(f[2]);"), +} + +# A fitting on every frame the kit mates to. Only the dorsal one faces up, so only it is seen from above. +HEAD = """include +part = "primary"; +M = [zh_s(-16, 32, 5.0, -2.25), zh_s(-15, 34, 5.5, -2.5), zh_s(5, 33, 5.5, -2.5), zh_s(14, 27, 5.5, -2.5)]; +""" +SIDEWAYS = { + "wall": "zh_on_wall(M, 0, 1, 0.45) zh_hatch(2.6, 3.0);", + "belt": "zh_on_belt(M, 0, 1, 0.5) zh_hatch(2.6, 3.0);", + "belly": "zh_on_belly(M, 0, 0) zh_hatch(2.6, 3.0);", + "stern": "zh_on_stern(M, 0, 0.5) zh_plate(4.0, 1.4, 0.3);", + "nose": "zh_on_nose(M, 0, 0.5) zh_plate(4.0, 1.4, 0.3);", +} +DORSAL = "zh_on_top(M, 0, 0) zh_hatch(2.6, 3.0);" + + +@unittest.skipUnless(OPENSCAD, "SMOKE_OPENSCAD names no OpenSCAD") +class KitThroughOpenScad(unittest.TestCase): + def build(self, source_text): + with tempfile.TemporaryDirectory() as folder: + source = Path(folder) / "hull.scad" + source.write_text(source_text, encoding="utf-8") + return hs.build(hs.openscad_runner(OPENSCAD, source), hs.language_library(OPENSCAD), "Ship", + hs.openscad_projector(OPENSCAD)) + + def lines(self, body): + with tempfile.TemporaryDirectory() as folder: + source = Path(folder) / "hull.scad" + source.write_text(HEAD + body, encoding="utf-8") + return hs.read_lines(hs.openscad_runner(OPENSCAD, source)("lines")[1]) + + def test_the_nakovalnya_draws_every_line_in_the_frame_of_its_geometry(self): + built = self.build(NAKOVALNYA.read_text(encoding="utf-8")) + self.assertEqual([c.name for c in built.failed()], []) + self.assertIn(FRAME, [c.name for c in built.checks]) + + def test_a_placement_that_drifts_from_its_lines_fails_the_frame_check(self): + text = NAKOVALNYA.read_text(encoding="utf-8") + for label, (old, new) in DRIFTS.items(): + with self.subTest(label): + self.assertEqual(text.count(old), 1) + self.assertIn(FRAME, [c.name for c in self.build(text.replace(old, new)).failed()]) + + def test_a_fitting_on_a_frame_a_top_view_cannot_see_emits_no_line(self): + for label, body in SIDEWAYS.items(): + with self.subTest(label): + self.assertEqual(self.lines(body), []) + + def test_the_gate_is_the_frame_and_not_the_fitting(self): + self.assertEqual(len(self.lines(DORSAL)), 1) + self.assertEqual(len(self.lines(DORSAL + " ".join(SIDEWAYS.values()))), 1) + + +if __name__ == "__main__": + unittest.main() From 98b4f4f9469ffd9ba5b8fda230af4700e01b066e Mon Sep 17 00:00:00 2001 From: soul Date: Thu, 22 Oct 2026 17:11:35 +0200 Subject: [PATCH 192/444] Build checks that every kit line is drawn in the frame of its geometry: lp_line's marks part meets its lines part Co-Authored-By: Claude Sonnet 5.5 --- .../aetheria_ships/factions/lowpoly.scad | 10 ++++-- tools/blender/aetheria_ships/hull_source.py | 33 +++++++++++++++++++ 2 files changed, 41 insertions(+), 2 deletions(-) diff --git a/tools/blender/aetheria_ships/factions/lowpoly.scad b/tools/blender/aetheria_ships/factions/lowpoly.scad index ef3063c6c..23e82918b 100644 --- a/tools/blender/aetheria_ships/factions/lowpoly.scad +++ b/tools/blender/aetheria_ships/factions/lowpoly.scad @@ -33,7 +33,13 @@ module lp_at(t=[0,0,0], r=[0,0,0], mx=false) { multmatrix(M) children(); } // One schematic line: layer (outline, step, seam or fitting), points in the local frame, cyclic when it closes. +// Part "marks" draws a LP_MARK cube at the line's first point through the CTM, the frame the geometry is drawn in, where +// "lines" echoes the same point through $lp_m; hull_source compares the two, so a part placed by anything but lp_at +// (a raw translate, rotate or mirror) shows as a line away from its geometry. +LP_MARK = 0.002; module lp_line(layer, pts, cyclic=false) { - if (!is_undef(part) && part == "lines" && ($lp_m * [0, 0, 1, 0])[2] > 0.5) - echo(line = [layer, cyclic, [for (p = pts) lp_ap($lp_m, p)]]); + if (!is_undef(part) && ($lp_m * [0, 0, 1, 0])[2] > 0.5) { + if (part == "lines") echo(line = [layer, cyclic, [for (p = pts) lp_ap($lp_m, p)]]); + if (part == "marks") translate(pts[0]) cube(LP_MARK, center=true); + } } diff --git a/tools/blender/aetheria_ships/hull_source.py b/tools/blender/aetheria_ships/hull_source.py index 2689a7230..f004486e9 100644 --- a/tools/blender/aetheria_ships/hull_source.py +++ b/tools/blender/aetheria_ships/hull_source.py @@ -63,6 +63,7 @@ LINE_STYLE = {"silhouette": (1.0, 1.0, 1.0, 1.0), "outline": (1.0, 1.0, 1.0, 0.7 MIN_SILHOUETTE_AREA = 0.25 # m2, below which a projected contour is a sliver, not the hull LINE_EDGE_TOLERANCE = 0.3 # m, how far outside the silhouette's edge a kit line's point may lie DUPLICATE_CELL = 0.001 # m, kit lines whose points all agree to this are the same line +MARK_TOLERANCE = 0.01 # m, how far a mark of the kit may lie from the start of its line # A projector answers a list of Meshes with the contours (lists of (x, y)) of their top-view union. Projector = Callable[[list], "list[list[tuple]]"] @@ -347,6 +348,35 @@ def _triangles_xy(mesh): return [tuple(mesh.vertices[v][:2] for v in tri) for tri in mesh.triangles] +def mark_centres(mesh): + """The centre of each connected piece of mesh: the kit's "marks" part draws one tiny cube per line start.""" + parent = list(range(len(mesh.vertices))) + + def root(i): + while parent[i] != i: + parent[i] = parent[parent[i]] + i = parent[i] + return i + + for triangle in mesh.triangles: + for corner in triangle[1:]: + parent[root(corner)] = root(triangle[0]) + pieces = {} + for i in {corner for triangle in mesh.triangles for corner in triangle}: + pieces.setdefault(root(i), []).append(mesh.vertices[i]) + return [tuple((min(v[a] for v in vs) + max(v[a] for v in vs)) / 2 for a in range(3)) for vs in pieces.values()] + + +def _frame_disagreements(kit, marks): + """(lines with no mark at their first point, marks that start no line). The kit echoes a line in the frame lp_at tracks + and draws its mark in the frame the geometry is drawn in; they agree unless a part was placed by anything but lp_at.""" + starts = [line.points[0] for line in kit] + centres = mark_centres(marks) + near = lambda a, b: all(abs(x - y) <= MARK_TOLERANCE for x, y in zip(a, b)) + return (sum(not any(near(start, c) for c in centres) for start in starts), + sum(not any(near(c, start) for start in starts) for c in centres)) + + def _language(library, faction): """The language of factions/.scad, which owns it: exactly one echo, naming that faction.""" (language,) = _exactly_one(read_languages(library(faction)), "language") @@ -421,6 +451,9 @@ def build(runner, library, hull_type, projector): result.checks = [Check("the hull includes its faction's language files and changes none of them", sorted(included, key=by_faction) == sorted(owned, key=by_faction), "")] result.checks += _checks(result, own, judge) + unmarked, stray = _frame_disagreements(kit_lines, parse_off(runner("marks")[0])) + result.checks.append(Check("every kit line is drawn in the frame of its geometry", not (unmarked or stray), + f"{unmarked} lines and {stray} marks disagree")) return result From 8d58bca27a77a1a7086923e6b58cc73621027491 Mon Sep 17 00:00:00 2001 From: soul Date: Thu, 22 Oct 2026 17:11:38 +0200 Subject: [PATCH 193/444] Nakovalnya: casemate guns and plate seams emit their lines from the frame that draws them; the copied offsets are gone (geometry and lines unchanged) Co-Authored-By: Claude Sonnet 5.5 --- GameData/Mods/aetheria.nakovalnya/hull.scad | 28 +++++++++++++-------- 1 file changed, 17 insertions(+), 11 deletions(-) diff --git a/GameData/Mods/aetheria.nakovalnya/hull.scad b/GameData/Mods/aetheria.nakovalnya/hull.scad index 062285157..66353ca69 100644 --- a/GameData/Mods/aetheria.nakovalnya/hull.scad +++ b/GameData/Mods/aetheria.nakovalnya/hull.scad @@ -52,16 +52,21 @@ zh_mass(KEEL, lines=false); // under the hull: no top view for (s = [-1, 1]) lp_at(t=[s*DX, 0, 0]) zh_mass(DRV, "secondary"); // ---- the casemate guns: lighter cast-steel barrels out of charcoal mantlets ----------------------------------------- +MANTLET = [4.0, 1.2, 3.4]; module casemate_gun() { - zh_z("secondary") { - translate([0, GY - 0.3, GZ]) cbox([4.0, 1.2, 3.4], 0.2); // mantlet - translate([0, GY - 0.7, GZ]) rotate([0, 0, 180]) tube(BR*1.55, 1.8, 8); // barrel collar - translate([0, GY - 0.7 - BL, GZ]) cbox([BR*2.7, 1.6, BR*2.7], 0.12); // muzzle block + // mantlet frame: its fitting outline is drawn in the frame the mantlet is + lp_at(t=[0, GY - 0.3, GZ]) { + zh_z("secondary") cbox(MANTLET, 0.2); + lp_line("fitting", zh_rect(MANTLET.x, MANTLET.y, MANTLET.z/2), true); + } + // barrel frame: local +Y runs forward from the collar; the barrel's top line is drawn in the frame the barrel is + lp_at(t=[0, GY - 0.7, GZ], r=[0, 0, 180]) { + zh_z("secondary") tube(BR*1.55, 1.8, 8); // barrel collar + zh_z("primary") tube(BR, BL, 8); + zh_z("secondary") translate([0, BL, 0]) cbox([BR*2.7, 1.6, BR*2.7], 0.12); // muzzle block + zh_z("recess") translate([0, BL + 0.81, 0]) cube([BR*1.2, 0.05, BR*1.2], center=true); + lp_line("fitting", [[0, 0, BR], [0, BL, BR]]); } - zh_z("primary") translate([0, GY - 0.7, GZ]) rotate([0, 0, 180]) tube(BR, BL, 8); - zh_z("recess") translate([0, GY - 0.7 - BL - 0.81, GZ]) cube([BR*1.2, 0.05, BR*1.2], center=true); - lp_line("fitting", [for (p = zh_rect(4.0, 1.2, GZ + 1.7)) p + [0, GY - 0.3, 0]], true); - lp_line("fitting", [[0, GY - 0.7, GZ + BR], [0, GY - 0.7 - BL, GZ + BR]]); } for (x = CX) lp_at(t=[x, 0, 0]) casemate_gun(); for (x = CX) lp_at(t=[x, 0, 0]) zh_on_top(CAS, -21.9, 0) zh_stripes(3.6, 0.6, 4); @@ -90,9 +95,10 @@ zh_on_top(T2, -3.6, 0) zh_serial(0.7, 4); // ---- dorsal magazine hatches: two rows of four on the mid-deck, clear of the rim ----------------------------------- for (y = [-8.0, -3.4, 1.2, 5.6], u = [-0.78, 0.78]) zh_on_top(T1, y, u) zh_hatch(2.8, 3.0, dogs=false); -for (y = [0.5, 4.5]) zh_on_top(T2, y, 0) { // plate seams - zh_z("secondary") translate([0, 0, 0.06]) cube([8.4, 0.5, 0.2], center=true); - lp_line("seam", [[-4.2, 0, 0.16], [4.2, 0, 0.16]]); +PLATE_SEAM = [8.4, 0.5, 0.2]; +for (y = [0.5, 4.5]) zh_on_top(T2, y, 0) lp_at(t=[0, 0, 0.06]) { // plate seams + zh_z("secondary") cube(PLATE_SEAM, center=true); + lp_line("seam", [[-PLATE_SEAM.x/2, 0, PLATE_SEAM.z/2], [PLATE_SEAM.x/2, 0, PLATE_SEAM.z/2]]); } // Feed collar for an Oboz: a lighter cast-steel flange ring standing proud of the flank round the kit collar. From 66c38d9506caf6b45b3d98bc6751f3d3d8ee0f16 Mon Sep 17 00:00:00 2001 From: soul Date: Thu, 22 Oct 2026 17:17:40 +0200 Subject: [PATCH 194/444] Build asks for the marks part only when the kit drew lines (OpenSCAD refuses a part that draws nothing); hull_source documents marks Co-Authored-By: Claude Sonnet 5.5 --- tools/blender/aetheria_ships/hull_source.py | 8 ++++++-- tools/blender/tests/test_hull_source.py | 9 +++++++-- 2 files changed, 13 insertions(+), 4 deletions(-) diff --git a/tools/blender/aetheria_ships/hull_source.py b/tools/blender/aetheria_ships/hull_source.py index f004486e9..2d6d0ca16 100644 --- a/tools/blender/aetheria_ships/hull_source.py +++ b/tools/blender/aetheria_ships/hull_source.py @@ -15,12 +15,14 @@ with it (a hull that redefines or shadows an included language fails a check): frame (lowpoly.scad lp_line under lp_at frames); layer is one of KIT_LAYERS the zone's geometry, OFF; a donor's zone is named ":" emit:, rad: the mesh of a thruster-emitter or radiator-mesh anchor + marks OFF, a tiny cube at the first point of each kit line, drawn through the frame the geometry is drawn in (lp_line) Frame: Ship Root, metres, nose -Y, starboard -X, up +Z. build() also takes the hull's HullData.HullType ("Ship", "Station" or "Turret"), which the caller reads: the add-on from the bound package's ship.cc, the bench from its flag. Schematic lines (HullBuild.lines): the top-view silhouette first (OpenSCAD projection() of every zone mesh, the geometry Rasterise reads, read from the SVG it exports), then the kit's lines, each with a fixed RGBA from LINE_STYLE. A kit part is placed with -lp_at so its line is emitted in the frame the geometry is; a source that echoes no lines has the silhouette alone. +lp_at so its line is emitted in the frame the geometry is, and Build checks it: a line whose mark (the marks part) lies elsewhere +was placed by something else. A source that echoes no lines has the silhouette alone. A faction language's proportions judge Ship hulls only; every hull's zones must fit the layout grid (hull_grid.MAX_CELLS cells of hull_grid.CELL_SIZE a side). The brief's role is prose and decides nothing. build() returns a HullBuild; the caller refuses on any failed check. Messages and check names are fixed text: nothing a hull source says is echoed back. @@ -451,7 +453,9 @@ def build(runner, library, hull_type, projector): result.checks = [Check("the hull includes its faction's language files and changes none of them", sorted(included, key=by_faction) == sorted(owned, key=by_faction), "")] result.checks += _checks(result, own, judge) - unmarked, stray = _frame_disagreements(kit_lines, parse_off(runner("marks")[0])) + # A source with no kit lines has no marks, and OpenSCAD refuses to export a part that drew nothing. + marks = parse_off(runner("marks")[0]) if kit_lines else Mesh([], []) + unmarked, stray = _frame_disagreements(kit_lines, marks) result.checks.append(Check("every kit line is drawn in the frame of its geometry", not (unmarked or stray), f"{unmarked} lines and {stray} marks disagree")) return result diff --git a/tools/blender/tests/test_hull_source.py b/tools/blender/tests/test_hull_source.py index 46e153a3f..e77ec11b2 100644 --- a/tools/blender/tests/test_hull_source.py +++ b/tools/blender/tests/test_hull_source.py @@ -739,8 +739,13 @@ class FrameCheckTests(unittest.TestCase): def test_a_mark_a_millimetre_off_is_the_same_place(self): self.assertEqual(self.built(line(), marks=[(-1, -1, 2.001)]).failed(), []) - def test_a_source_with_no_lines_and_no_marks_passes(self): - self.assertEqual(self.built("", marks=[]).failed(), []) + def test_a_source_with_no_lines_asks_for_no_marks_because_openscad_refuses_a_part_that_draws_nothing(self): + fixture = Fixture() + runner = fixture.runner() + asked = [] + built = hs.build(lambda part: asked.append(part) or runner(part), fixture.read_library, "Ship", fixture.projector) + self.assertNotIn("marks", asked) + self.assertEqual(built.failed(), []) def test_the_detail_counts_the_lines_and_marks_that_disagree(self): built = self.built(line(), marks=[(5, 5, 2)]) From d3cd7813d7458a4825a44a093248db2e015d46db Mon Sep 17 00:00:00 2001 From: soul Date: Thu, 22 Oct 2026 17:31:08 +0200 Subject: [PATCH 195/444] Catalog subtraction: commodity records lose DemandProfile and both stored categories; the trade menu stops filtering by them Co-Authored-By: Claude Sonnet 5.5 --- Assets/Scripts/ServerShared/Enums.cs | 21 ----- Assets/Scripts/ServerShared/ItemData.cs | 10 +- Assets/Scripts/UI/Menu/TradeMenu.cs | 91 +------------------ .../CommodityRecordsTests.cs | 34 +++++++ 4 files changed, 39 insertions(+), 117 deletions(-) create mode 100644 tests/Aetheria.Shared.Tests/CommodityRecordsTests.cs diff --git a/Assets/Scripts/ServerShared/Enums.cs b/Assets/Scripts/ServerShared/Enums.cs index 63d2674db..0a6449dc1 100644 --- a/Assets/Scripts/ServerShared/Enums.cs +++ b/Assets/Scripts/ServerShared/Enums.cs @@ -152,27 +152,6 @@ public enum MegaPlacementType Isolated = 4 } -public enum SimpleCommodityCategory -{ - Minerals = 0, - Metals = 1, - Alloys = 2, - Compounds = 3, - Organics = 4, - Ammo = 5, - Consumer = 6 -} - -public enum CompoundCommodityCategory -{ - Wearables = 0, - Consumables = 1, - Luxuries = 2, - Tools = 3, - Manufacturing = 4, - Assemblies = 5 -} - public enum LocationType { Station, diff --git a/Assets/Scripts/ServerShared/ItemData.cs b/Assets/Scripts/ServerShared/ItemData.cs index ca1484e36..dcb38456a 100644 --- a/Assets/Scripts/ServerShared/ItemData.cs +++ b/Assets/Scripts/ServerShared/ItemData.cs @@ -322,13 +322,11 @@ public abstract class ItemData [CultDocument("aetheria.simplecommoditydata", "1"), Inspectable, MessagePackObject] public class SimpleCommodityData : ItemData { - // MessagePack keys 6, 7, 8 and 11 belonged to removed resource-distribution fields; do not reuse them. + // MessagePack keys 6, 7, 8, 10 and 11 belonged to removed resource-distribution fields; do not reuse them. [Inspectable, JsonProperty("maxStackSize"), Key(9)] public int MaxStack = 10; - [Inspectable, JsonProperty("category"), Key(10)] - public SimpleCommodityCategory Category; } [JsonObject(MemberSerialization.OptIn)] @@ -352,11 +350,7 @@ public class ItemRole [CultDocument("aetheria.compoundcommoditydata", "1"), Inspectable, MessagePackObject] public class CompoundCommodityData : CraftedItemData { - [Inspectable, CultReference(typeof(PersonalityAttribute), many: true), JsonProperty("demandProfile"), Key(10)] - public Dictionary, float> DemandProfile = new Dictionary, float>(); - - [Inspectable, JsonProperty("category"), Key(11)] - public CompoundCommodityCategory Category; + // MessagePack keys 10 and 11 belonged to the removed demand weights and classification; do not reuse them. } [CultDocument("aetheria.consumableitemdata", "1"), Inspectable, MessagePackObject] diff --git a/Assets/Scripts/UI/Menu/TradeMenu.cs b/Assets/Scripts/UI/Menu/TradeMenu.cs index 1adadcf15..3d448f64d 100644 --- a/Assets/Scripts/UI/Menu/TradeMenu.cs +++ b/Assets/Scripts/UI/Menu/TradeMenu.cs @@ -28,8 +28,6 @@ public class TradeMenu : MonoBehaviour // False lists the station's wares to buy; true lists the selected bay's goods to sell. private bool _selling; private (ItemFilter filter, HardpointType type) _hardpointFilter; - private (ItemFilter filter, SimpleCommodityCategory type) _commodityFilter; - private (ItemFilter filter, CompoundCommodityCategory type) _compoundCommodityFilter; private List<(ItemFilter filter, Type type)> _behaviorFilters = new List<(ItemFilter filter, Type type)>(); public EquippedCargoBay Inventory { get; set; } @@ -62,14 +60,6 @@ public class TradeMenu : MonoBehaviour if (_hardpointFilter.filter != null) hardpointTypes = hardpointTypes.Where(x => x != _hardpointFilter.type); - IEnumerable commodityTypes = (SimpleCommodityCategory[]) Enum.GetValues(typeof(SimpleCommodityCategory)); - if (_commodityFilter.filter != null) - commodityTypes = commodityTypes.Where(x => x != _commodityFilter.type); - - IEnumerable compoundCommodityTypes = (CompoundCommodityCategory[]) Enum.GetValues(typeof(CompoundCommodityCategory)); - if (_compoundCommodityFilter.filter != null) - compoundCommodityTypes = compoundCommodityTypes.Where(x => x != _compoundCommodityFilter.type); - ContextMenu.AddDropdown("Gear Type", hardpointTypes .Select(x => (Enum.GetName(typeof(HardpointType), x), () => { @@ -82,75 +72,10 @@ public class TradeMenu : MonoBehaviour Populate(); }; } - if(_commodityFilter.filter) - { - _commodityFilter.filter.GetComponent().ReturnToPool(); - _commodityFilter.filter = null; - } - if(_compoundCommodityFilter.filter) - { - _compoundCommodityFilter.filter.GetComponent().ReturnToPool(); - _compoundCommodityFilter.filter = null; - } - _hardpointFilter.filter.Label.text = $"Hardpoint: {Enum.GetName(typeof(HardpointType), x)}"; _hardpointFilter.type = x; Populate(); }, true))); - ContextMenu.AddDropdown("Simple Commodity", commodityTypes - .Select(x => (Enum.GetName(typeof(SimpleCommodityCategory), x), () => - { - if (!_commodityFilter.filter) - { - _commodityFilter.filter = FilterPrototype.Instantiate(); - _commodityFilter.filter.OnDisable += () => - { - _commodityFilter.filter = null; - Populate(); - }; - } - if(_hardpointFilter.filter) - { - _hardpointFilter.filter.GetComponent().ReturnToPool(); - _hardpointFilter.filter = null; - } - if(_compoundCommodityFilter.filter) - { - _compoundCommodityFilter.filter.GetComponent().ReturnToPool(); - _compoundCommodityFilter.filter = null; - } - - _commodityFilter.filter.Label.text = $"Hardpoint: {Enum.GetName(typeof(SimpleCommodityCategory), x)}"; - _commodityFilter.type = x; - Populate(); - }, true))); - ContextMenu.AddDropdown("Compound Commodity", compoundCommodityTypes - .Select(x => (Enum.GetName(typeof(CompoundCommodityCategory), x), () => - { - if (!_compoundCommodityFilter.filter) - { - _compoundCommodityFilter.filter = FilterPrototype.Instantiate(); - _compoundCommodityFilter.filter.OnDisable += () => - { - _compoundCommodityFilter.filter = null; - Populate(); - }; - } - if(_hardpointFilter.filter) - { - _hardpointFilter.filter.GetComponent().ReturnToPool(); - _hardpointFilter.filter = null; - } - if(_commodityFilter.filter) - { - _commodityFilter.filter.GetComponent().ReturnToPool(); - _commodityFilter.filter = null; - } - - _compoundCommodityFilter.filter.Label.text = $"Hardpoint: {Enum.GetName(typeof(CompoundCommodityCategory), x)}"; - _compoundCommodityFilter.type = x; - Populate(); - }, true))); ContextMenu.AddDropdown("Item Behavior", typeof(BehaviorData).GetAllChildClasses() .Where(x=> x.GetCustomAttribute() != null && _behaviorFilters.All(f => f.type != x)) .Select(x=> (x.Name.FormatTypeName(), () => @@ -199,19 +124,15 @@ public class TradeMenu : MonoBehaviour columns.Add(("Type", 2, x => () => { - if (x.data is SimpleCommodityData s) return Enum.GetName(typeof(SimpleCommodityCategory), s.Category); - if(x.data is CompoundCommodityData c) return Enum.GetName(typeof(CompoundCommodityCategory), c.Category); + if (x.data is SimpleCommodityData || x.data is CompoundCommodityData) return "Commodity"; if(x.data is EquippableItemData e) return Enum.GetName(typeof(HardpointType), e.HardpointType); return "None"; }, x => { var data = x.data; - if (data is SimpleCommodityData s) return (int) s.Category; - var offset = Enum.GetValues(typeof(SimpleCommodityCategory)).Length; - if(data is CompoundCommodityData c) return (int) c.Category + offset; - offset += Enum.GetValues(typeof(CompoundCommodityCategory)).Length; - if(data is EquippableItemData e) return (int) e.HardpointType + offset; + if (data is SimpleCommodityData || data is CompoundCommodityData) return 0; + if (data is EquippableItemData e) return 1 + (int) e.HardpointType; return 0; })); columns.Add(("Mass", 1, @@ -237,12 +158,6 @@ public class TradeMenu : MonoBehaviour !(MaximumSizeFilter.Width.text.Length > 0 && i.data.Shape.Width > int.Parse(MaximumSizeFilter.Width.text) || MaximumSizeFilter.Height.text.Length > 0 && i.data.Shape.Height > int.Parse(MaximumSizeFilter.Height.text))); - if(_commodityFilter.filter != null) - items = items.Where(i => i.data is SimpleCommodityData s && s.Category == _commodityFilter.type); - - if(_compoundCommodityFilter.filter != null) - items = items.Where(i => i.data is CompoundCommodityData c && c.Category == _compoundCommodityFilter.type); - if (_hardpointFilter.filter != null) items = items.Where(i => i.data is EquippableItemData e && e.HardpointType == _hardpointFilter.type); diff --git a/tests/Aetheria.Shared.Tests/CommodityRecordsTests.cs b/tests/Aetheria.Shared.Tests/CommodityRecordsTests.cs new file mode 100644 index 000000000..7f1c376c3 --- /dev/null +++ b/tests/Aetheria.Shared.Tests/CommodityRecordsTests.cs @@ -0,0 +1,34 @@ +using System; +using System.Linq; +using System.Reflection; +using MessagePack; +using Xunit; + +// Commodity records carry no stored classification and no demand weights (aetheria-living-world, cut +// catalog-subtraction): stage, component-ness and consumer-ness are derived later from recipes, roles and needs. +public sealed class CommodityRecordsTests +{ + // The shipped bytes still hold values at the retired keys; MessagePack skips them and every record loads by name. + [Fact] + public void ShippedCatalogOpensWithTheRetiredKeysGone() + { + using var cache = RestoredHullsTests.OpenCatalog(); + Assert.Equal(13, cache.GetAll().Count()); + Assert.Equal(51, cache.GetAll().Count()); + Assert.Contains(cache.GetAll(), c => c.Name == "Ammo"); + Assert.All(cache.GetAll(), c => Assert.False(string.IsNullOrEmpty(c.Name))); + } + + // Keys 10 and 11 are retired: no commodity member may reuse them for a different meaning. + [Fact] + public void RetiredKeysStayRetired() + { + int[] KeysOf(Type type) => type.GetFields(BindingFlags.Public | BindingFlags.Instance | BindingFlags.DeclaredOnly) + .Select(f => f.GetCustomAttribute()?.IntKey).Where(k => k.HasValue).Select(k => k.Value).ToArray(); + Assert.Equal(new[] { 9 }, KeysOf(typeof(SimpleCommodityData))); + Assert.Empty(KeysOf(typeof(CompoundCommodityData))); + Assert.Null(typeof(SimpleCommodityData).GetField("Category")); + Assert.Null(typeof(CompoundCommodityData).GetField("Category")); + Assert.Null(typeof(CompoundCommodityData).GetField("DemandProfile")); + } +} From ae52857a9154e1e4eb1d744fbb7542a295ab7a95 Mon Sep 17 00:00:00 2001 From: soul Date: Thu, 22 Oct 2026 18:01:23 +0200 Subject: [PATCH 196/444] SimpleCommodityData: name key 10 as the removed stored Category (Soul s1 simple-key10-comment-misnamed) Co-Authored-By: Claude Opus 5.5 --- Assets/Scripts/ServerShared/ItemData.cs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/Assets/Scripts/ServerShared/ItemData.cs b/Assets/Scripts/ServerShared/ItemData.cs index dcb38456a..170c2421d 100644 --- a/Assets/Scripts/ServerShared/ItemData.cs +++ b/Assets/Scripts/ServerShared/ItemData.cs @@ -322,7 +322,7 @@ public abstract class ItemData [CultDocument("aetheria.simplecommoditydata", "1"), Inspectable, MessagePackObject] public class SimpleCommodityData : ItemData { - // MessagePack keys 6, 7, 8, 10 and 11 belonged to removed resource-distribution fields; do not reuse them. + // MessagePack keys 6, 7, 8 and 11 belonged to removed resource-distribution fields, and key 10 to the removed stored Category; do not reuse them. [Inspectable, JsonProperty("maxStackSize"), Key(9)] public int MaxStack = 10; From c40a6e8229ff1dc2c81bb18bb1ecc384303dc2e8 Mon Sep 17 00:00:00 2001 From: soul Date: Thu, 22 Oct 2026 18:21:12 +0200 Subject: [PATCH 197/444] View seam tests: per-shooter aim, view gates, sees-hostile, honest track loss (red) Co-Authored-By: Claude Sonnet 5.5 --- .../ApproachKnowledgeTests.cs | 19 +++--- .../FireControlPerWeaponTests.cs | 46 ++++++++++++++ .../PerShooterAimTests.cs | 60 +++++++++++++++++++ tests/Aetheria.Shared.Tests/PilotViewTests.cs | 53 ++++++++++++++++ 4 files changed, 171 insertions(+), 7 deletions(-) create mode 100644 tests/Aetheria.Shared.Tests/PerShooterAimTests.cs diff --git a/tests/Aetheria.Shared.Tests/ApproachKnowledgeTests.cs b/tests/Aetheria.Shared.Tests/ApproachKnowledgeTests.cs index 98f857913..cd48907a7 100644 --- a/tests/Aetheria.Shared.Tests/ApproachKnowledgeTests.cs +++ b/tests/Aetheria.Shared.Tests/ApproachKnowledgeTests.cs @@ -9,14 +9,14 @@ using float3 = CultMath.float3; // track from the first step, and the pilot follows it only as long as the sensors keep it. public sealed partial class RunStartTests { - private (Zone arena, Ship east, Ship west) StagedApproach() + private (Zone arena, Ship east, Ship west) StagedApproach(float westX = -450f) { Ship east = null, west = null; var (_, arena, _, failures) = Launch(new Scripted(false, stage => { stage.Player(stage.Bare("Djinni"), float2(-50000, -50000)); east = stage.Place(EvLonginus(stage), float2(450, 0), facing: float2(-1, 0), piloted: true) as Ship; - west = stage.Place(EvLonginus(stage), float2(-450, 0), facing: float2(1, 0), piloted: true) as Ship; + west = stage.Place(EvLonginus(stage), float2(westX, 0), facing: float2(1, 0), piloted: true) as Ship; stage.Approach(east, west); })); Assert.True(failures.Count == 0, string.Join("; ", failures)); @@ -39,22 +39,27 @@ public sealed partial class RunStartTests Assert.True(east.Position.x - west.Position.x < startGap - 20f, "the follower closed on its anchor"); } + // The anchor is staged 60 km off, far outside sensor reach: the staged knowledge decays through the sensors like any + // track (the Sensor is the only writer after staging), and the follower stops once they have dropped it. Nothing + // here writes the pilot's knowledge after the stage. [Fact] public void AStagedApproachFollowerStopsFollowingOnceTheSensorsLoseTheTrack() { - var (arena, east, west) = StagedApproach(); + var (arena, east, west) = StagedApproach(westX: -60000f); var view = arena.Agents.Single(a => a.Ship == east).View; var sentinel = new float3(0, 0, 12345); - for (var i = 0; i < 3; i++) arena.Update(1f / 60f); + Assert.NotNull(view.TrackOf(east, PilotView.Handle(west))); east.Command.Aim = sentinel; - arena.Update(1f / 60f); + arena.Update(.1f); Assert.NotEqual(sentinel, east.Command.Aim.Value); - east.EntityInfoGathered[west] = 0f; + var steps = 0; + while (view.TrackOf(east, PilotView.Handle(west)) != null && steps++ < 1200) arena.Update(.1f); Assert.Null(view.TrackOf(east, PilotView.Handle(west))); + east.Command.Aim = sentinel; - arena.Update(1f / 60f); + arena.Update(.1f); Assert.Equal(sentinel, east.Command.Aim.Value); } } diff --git a/tests/Aetheria.Shared.Tests/FireControlPerWeaponTests.cs b/tests/Aetheria.Shared.Tests/FireControlPerWeaponTests.cs index 2c70cf438..5c0aa19a5 100644 --- a/tests/Aetheria.Shared.Tests/FireControlPerWeaponTests.cs +++ b/tests/Aetheria.Shared.Tests/FireControlPerWeaponTests.cs @@ -444,6 +444,52 @@ public sealed class FireControlPerWeaponTests : IDisposable Assert.Equal(expected, view.Intercept(r.Shooter, weapon, contact)); } + // A view answers fire control only for a member that tracks the contact: the shooter outside the view, or having lost + // the track, gets no intercept, no reveals, no designated weapon, a refusal, and no shot; a write for a non-member + // changes nothing. The member that tracks is the positive control, answering as FireControl does. Kills: any of + // Intercept, WeaponReveals, Designated, Refuses, Fires or SelectItem answering without the member's own track or + // membership. + public enum Standing { TrackingMember, NotAMember, LostTheTrack } + + [Theory] + [InlineData(Standing.TrackingMember)] + [InlineData(Standing.NotAMember)] + [InlineData(Standing.LostTheTrack)] + public void AViewAnswersFireControlOnlyForAMemberThatTracksTheContact(Standing standing) + { + var r = Build(velocity: 500f); + var targetGun = r.Target.Equipment.Single(i => i.Behaviors.OfType().Any()); + r.Shooter.RecordReveal(r.Target, targetGun, SignatureChannel.LaunchFlash, targetGun.Data); + var view = standing == Standing.NotAMember ? new PilotView() : new PilotView(r.Shooter); + var contact = PilotView.Handle(r.Target); + var weapon = r.Gun.Weapon; + var reveal = new PilotView(r.Shooter).WeaponReveals(r.Shooter, contact).Single(); + if (standing == Standing.LostTheTrack) r.Shooter.VisibleEntities.Remove(r.Target); + var minHit = 0f; + + var intercept = view.Intercept(r.Shooter, weapon, contact); + var reveals = view.WeaponReveals(r.Shooter, contact); + view.SelectItem(r.Shooter, reveal.Id); + if (standing == Standing.TrackingMember) + { + Assert.NotNull(intercept); + Assert.Single(reveals); + Assert.True(view.Designated(r.Shooter, weapon, contact)); + Assert.False(view.Refuses(r.Shooter, weapon, contact)); + Assert.True(view.Fires(r.Shooter, weapon, contact, minHit, null)); + Assert.Single(r.Shooter.Command.Selections); + } + else + { + Assert.Null(intercept); + Assert.Empty(reveals); + Assert.False(view.Designated(r.Shooter, weapon, contact)); + Assert.True(view.Refuses(r.Shooter, weapon, contact)); + Assert.False(view.Fires(r.Shooter, weapon, contact, minHit, null)); + if (standing == Standing.NotAMember) Assert.Empty(r.Shooter.Command.Selections); + } + } + private static void AssertAimsAtTarget(Rig r) { var toTarget = r.Target.Position - r.Shooter.Position; diff --git a/tests/Aetheria.Shared.Tests/PerShooterAimTests.cs b/tests/Aetheria.Shared.Tests/PerShooterAimTests.cs new file mode 100644 index 000000000..e284abf56 --- /dev/null +++ b/tests/Aetheria.Shared.Tests/PerShooterAimTests.cs @@ -0,0 +1,60 @@ +using System; +using System.Linq; +using System.Reflection; +using CultMath; +using Xunit; +using static CultMath.math; +using float2 = CultMath.float2; +using float3 = CultMath.float3; + +// Fire control is per shooter (ruling per-shooter-fire-control): a pilot flies, aims and fires on what its own sensors +// track, not on a track only a sidemate holds. +public sealed partial class RunStartTests +{ + // East and west are one side, one view; only west tracks x. East is designated on x: it must not aim or pull on west's + // track (Combat reads TrackOf for the shooter, not SideTrack). The positive control has east track x as well, and it aims. + // Kills: Combat flying on SideTrack. + [Theory] + [InlineData(false)] + [InlineData(true)] + public void AMemberDesignatedOnAContactOnlyItsSidemateTracksNeitherAimsNorFires(bool eastTracks) + { + Ship east = null, west = null, x = null; + var (_, arena, _, failures) = Launch(new Scripted(false, stage => + { + stage.Player(stage.Bare("Djinni"), float2(-50000, -50000)); + east = stage.Place(EvLonginus(stage), float2(100, 0), facing: float2(-1, 0), piloted: true) as Ship; + west = stage.Place(EvLonginus(stage), float2(-100, 0), facing: float2(1, 0), piloted: true) as Ship; + x = stage.Place(EvLonginus(stage), float2(0, 300), facing: float2(0, -1)) as Ship; + })); + Assert.True(failures.Count == 0, string.Join("; ", failures)); + var known = (east.ItemManager.GameplaySettings.TargetDetectionInfoThreshold + 1f) / 2f; + east.SetIff(x, true); + west.SetIff(x, true); + var side = new PilotView(east, west); + var agent = arena.Agents.Single(a => a.Ship == east); + agent.View = side; + west.EntityInfoGathered[x] = known; + if (eastTracks) east.EntityInfoGathered[x] = known; + Assert.NotNull(side.SideTrack(PilotView.Handle(x))); + Assert.Equal(eastTracks, side.TrackOf(east, PilotView.Handle(x)) != null); + + var sentinel = new float3(0, 0, -12345); + object state = null; + for (var i = 0; i < 4; i++) + { + east.Command.Designate(new TargetRef(x)); + east.Command.Aim = sentinel; + agent.Update(1f / 60f); + state = typeof(Agent).GetField("_currentState", BindingFlags.NonPublic | BindingFlags.Instance).GetValue(agent); + } + Assert.IsType(state); + var aimed = !east.Command.Aim.Value.Equals(sentinel); + if (eastTracks) Assert.True(aimed, "a member that tracks the contact aims at it"); + else + { + Assert.False(aimed, $"a member flew its aim on a sidemate's track: {east.Command.Aim}"); + Assert.False(east.Weapons.Any(w => east.Command.Pulls(w)), "a member pulled a trigger on a sidemate's track"); + } + } +} diff --git a/tests/Aetheria.Shared.Tests/PilotViewTests.cs b/tests/Aetheria.Shared.Tests/PilotViewTests.cs index 5c371de49..6d47fe3c4 100644 --- a/tests/Aetheria.Shared.Tests/PilotViewTests.cs +++ b/tests/Aetheria.Shared.Tests/PilotViewTests.cs @@ -2,6 +2,7 @@ using System; using System.Linq; using CultMath; using GameCult.Caching; +using UniRx; using Xunit; // A side's brain reaches other ships only through its PilotView (Agents/PilotView.cs): handles it issued, tracks only @@ -263,4 +264,56 @@ public sealed class PilotViewTests : IDisposable } Assert.Equal(300f, member.Command.Aim.Value.z, 1); } + + // The hostile part of Sees is the member's own: a member holding a sidemate's enemy as friendly (a hail it answered, an + // IFF override) does not see it as an enemy, though it tracks it. Kills: Sees reading VisibleEntities. + [Fact] + public void AMemberThatHoldsASidematesEnemyAsFriendlyDoesNotSeeItAsOne() + { + var faction = new Faction { Name = "Plain" }; + var a = Pilot(faction, -10); + var b = Pilot(faction, 10); + var x = Subject(); + See(b, x); + a.EntityInfoGathered[x] = 1; + a.VisibleEntities.Add(x); + a.SetIff(x, false); + var view = ViewOf(a); + var handle = PilotView.Handle(x); + + Assert.NotNull(view.TrackOf(a, handle)); + Assert.True(view.Sees(b, handle)); + Assert.False(view.Sees(a, handle)); + } + + // A view answers for its members only: every per-member read of a ship the view does not hold is empty, however much + // that ship knows, and a write for it changes nothing. One assertion per read, each killing its dropped membership check. + [Fact] + public void AViewAnswersNothingForAShipThatIsNotItsMember() + { + var a = Pilot(null, -200); + var outsider = Pilot(null, 200); + var x = Subject(); + See(outsider, x); + outsider.SetIff(x, true); + x.PresencePermitted = Observable.Return(false).ToReadOnlyReactiveProperty(); + outsider.Command.Designate(new TargetRef(x)); + var handle = PilotView.Handle(x); + var view = ViewOf(a); + Assert.NotNull(outsider.IffOverride(x)); + Assert.True(outsider.BelievesTrespassing(x)); + Assert.True(outsider.VisibleEnemies.Contains(x)); + + Assert.Null(view.TrackOf(outsider, handle)); + Assert.False(view.Sees(outsider, handle)); + Assert.Null(view.IffOverride(outsider, handle)); + Assert.Null(view.Info(outsider, handle)); + Assert.False(view.BelievesTrespassing(outsider, handle)); + Assert.Null(view.TargetOf(outsider)); + PilotApplier.Apply(outsider); + Assert.Null(view.AppliedTargetOf(outsider)); + + view.Designate(outsider, null); + Assert.Null(outsider.Command.Designated); + } } From 70aa1ed037b7002f82389104fcec9ca759037a40 Mon Sep 17 00:00:00 2001 From: soul Date: Thu, 22 Oct 2026 18:22:43 +0200 Subject: [PATCH 198/444] Transfer verb: drop the redundant pre-check, let range, step length and quantity edges show in tests TryStore already takes all of a part or none, so the pre-check in TryTransferItem changed nothing observable. Co-Authored-By: Claude Sonnet 5.5 --- Assets/Scripts/ServerShared/CargoTransfer.cs | 2 +- Assets/Scripts/ServerShared/Entity.cs | 6 +- .../CargoTransferTests.cs | 66 +++++++++++++++++-- 3 files changed, 62 insertions(+), 12 deletions(-) diff --git a/Assets/Scripts/ServerShared/CargoTransfer.cs b/Assets/Scripts/ServerShared/CargoTransfer.cs index 4026bbc7b..6484bace8 100644 --- a/Assets/Scripts/ServerShared/CargoTransfer.cs +++ b/Assets/Scripts/ServerShared/CargoTransfer.cs @@ -94,7 +94,7 @@ public static class CargoTransfer order.Budget -= 1; order.Remaining--; } - if (giver.Giving == order && order.Remaining == 0) End(giver, GiveEnd.Done); + if (order.Remaining == 0) End(giver, GiveEnd.Done); } } diff --git a/Assets/Scripts/ServerShared/Entity.cs b/Assets/Scripts/ServerShared/Entity.cs index 00df96087..721ac31b0 100644 --- a/Assets/Scripts/ServerShared/Entity.cs +++ b/Assets/Scripts/ServerShared/Entity.cs @@ -2664,17 +2664,13 @@ public class EquippedCargoBay : EquippedItem return false; } - // Whole or nothing: the target must be able to take the part before it leaves the stack. - var part = new SimpleCommodity { Data = item.Data, Quantity = min(quantity, item.Quantity), Rotation = item.Rotation }; - if (!target.TryFindSpace(part, out _)) return false; - var oldPos = Cargo[item]; var newItem = Remove(item, quantity); // The part removed goes across, never the stack left behind. if (target.TryStore(newItem)) return true; - // The target refused after all: put the part back where it came from. + // Whole or nothing: TryStore takes all of the part or none of it, so a refusal puts the part back where it came from. TryStore(newItem, oldPos); return false; } diff --git a/tests/Aetheria.Shared.Tests/CargoTransferTests.cs b/tests/Aetheria.Shared.Tests/CargoTransferTests.cs index d2c1db6fb..28eae6cf9 100644 --- a/tests/Aetheria.Shared.Tests/CargoTransferTests.cs +++ b/tests/Aetheria.Shared.Tests/CargoTransferTests.cs @@ -165,8 +165,9 @@ public sealed class CargoTransferTests : IDisposable var receiver = ShipWith(receiverBay); var stack = Commodity("Ore", ore); if (ore > 0) Assert.True(giver.CargoBays[0].TryStore(stack)); - giver.Position = new float3(0, 0, 0); - receiver.Position = new float3(5, 0, 0); + // Off the origin, so a distance taken from the wrong sum or difference of positions shows. + giver.Position = new float3(-50, 0, 10); + receiver.Position = giver.Position + new float3(5, 0, 0); _zone.Admit(giver, piloted: false); _zone.Admit(receiver, piloted: false); return (giver, receiver, stack); @@ -216,13 +217,13 @@ public sealed class CargoTransferTests : IDisposable { var (giver, receiver, ore) = Alongside(10); var range = _items.GameplaySettings.DockingDistance; - receiver.Position = new float3(range - 1, 0, 0); + receiver.Position = giver.Position + new float3(range - 1, 0, 0); giver.Command.Give(receiver, ore, 10); Steps(10); Assert.Equal(2, Held(receiver, ore)); Assert.Null(giver.LastGiveEnd); - receiver.Position = new float3(range + 1, 0, 0); + receiver.Position = giver.Position + new float3(range + 1, 0, 0); Steps(10); Assert.Equal(2, Held(receiver, ore)); @@ -230,12 +231,65 @@ public sealed class CargoTransferTests : IDisposable Assert.Equal(GiveEnd.OutOfRange, giver.LastGiveEnd); } + [Fact] + public void AReceiverExactlyAtTheDockingDistanceIsStillAlongside() + { + var (giver, receiver, ore) = Alongside(10); + receiver.Position = giver.Position + new float3(0, 0, _items.GameplaySettings.DockingDistance); + giver.Command.Give(receiver, ore, 10); + Steps(10); + Assert.Equal(2, Held(receiver, ore)); + Assert.Null(giver.LastGiveEnd); + } + + [Fact] + public void AStepOfExactlyOneUnitMovesOneUnit() + { + var (giver, receiver, ore) = Alongside(10); + giver.Command.Give(receiver, ore, 10); + _zone.Update(1f / _items.GameplaySettings.TransferRate); + Assert.Equal(1, Held(receiver, ore)); + } + + [Fact] + public void ALongStepNeverMovesMoreThanTheQuantity() + { + var (giver, receiver, ore) = Alongside(10); + giver.Command.Give(receiver, ore, 3); + _zone.Update(5f); + Assert.Equal(3, Held(receiver, ore)); + Assert.Equal(7, Held(giver, ore)); + Assert.Equal(GiveEnd.Done, giver.LastGiveEnd); + } + + [Fact] + public void AGiveOfOneMovesOne() + { + var (giver, receiver, ore) = Alongside(10); + giver.Command.Give(receiver, ore, 1); + Steps(20); + Assert.Equal(1, Held(receiver, ore)); + Assert.Equal(GiveEnd.Done, giver.LastGiveEnd); + } + + [Fact] + public void AGiveOfACraftedItemTheGiverDoesNotHoldEndsNothingLeft() + { + var (giver, receiver, _) = Alongside(0); + var gun = Mint("Gun"); + Assert.True(receiver.CargoBays[0].TryStore(gun)); + giver.Command.Give(receiver, gun, 1); + Steps(20); + Assert.Equal(GiveEnd.NothingLeft, giver.LastGiveEnd); + Assert.True(receiver.CargoBays[0].Cargo.ContainsKey(gun)); + } + [Fact] public void GiveRangeIsMeasuredOnTheFlatPlane() { // The plane is xz: a ship far apart in y only is still alongside. var (giver, receiver, ore) = Alongside(10); - receiver.Position = new float3(5, 1000, 0); + receiver.Position = giver.Position + new float3(5, 1000, 0); giver.Command.Give(receiver, ore, 10); Steps(10); Assert.Equal(2, Held(receiver, ore)); @@ -346,7 +400,7 @@ public sealed class CargoTransferTests : IDisposable { var (giver, receiver, ore) = Alongside(10); var third = ShipWith("Crate"); - third.Position = new float3(0, 0, 5); + third.Position = giver.Position + new float3(0, 0, 5); _zone.Admit(third, piloted: false); // A give then a stop leaves nothing, and nothing ended. From d38ee7cdd6f81b4f3291bbde93810023fb572ad7 Mon Sep 17 00:00:00 2001 From: soul Date: Thu, 22 Oct 2026 18:25:58 +0200 Subject: [PATCH 199/444] Approach track-loss test: warm up before reading the follower's aim (the 60 km anchor is lost by the sensors) Co-Authored-By: Claude Sonnet 5.5 --- tests/Aetheria.Shared.Tests/ApproachKnowledgeTests.cs | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/tests/Aetheria.Shared.Tests/ApproachKnowledgeTests.cs b/tests/Aetheria.Shared.Tests/ApproachKnowledgeTests.cs index cd48907a7..5fdf7bffd 100644 --- a/tests/Aetheria.Shared.Tests/ApproachKnowledgeTests.cs +++ b/tests/Aetheria.Shared.Tests/ApproachKnowledgeTests.cs @@ -49,9 +49,11 @@ public sealed partial class RunStartTests var view = arena.Agents.Single(a => a.Ship == east).View; var sentinel = new float3(0, 0, 12345); + Assert.NotNull(view.TrackOf(east, PilotView.Handle(west))); + for (var i = 0; i < 3; i++) arena.Update(1f / 60f); Assert.NotNull(view.TrackOf(east, PilotView.Handle(west))); east.Command.Aim = sentinel; - arena.Update(.1f); + arena.Update(1f / 60f); Assert.NotEqual(sentinel, east.Command.Aim.Value); var steps = 0; From 961bdafade70089beccd2a2e93838f29467da0a1 Mon Sep 17 00:00:00 2001 From: soul Date: Thu, 22 Oct 2026 18:26:03 +0200 Subject: [PATCH 200/444] Determinism ledger: the TwoAiDigestIsPinned comment names both causes of the second repin Co-Authored-By: Claude Sonnet 5.5 --- tests/Aetheria.Shared.Tests/DeterminismLedgerTests.cs | 8 ++++++-- 1 file changed, 6 insertions(+), 2 deletions(-) diff --git a/tests/Aetheria.Shared.Tests/DeterminismLedgerTests.cs b/tests/Aetheria.Shared.Tests/DeterminismLedgerTests.cs index 6ff03b525..65be67d68 100644 --- a/tests/Aetheria.Shared.Tests/DeterminismLedgerTests.cs +++ b/tests/Aetheria.Shared.Tests/DeterminismLedgerTests.cs @@ -24,8 +24,12 @@ public sealed partial class RunStartTests // The two-AI fight's digest, captured on Yggdrasil. Replays are trusted only on the host class that recorded them // (ruling replays-per-host-class), and arenas run on Linux. private const string TwoAiPinnedHostClass = "linux-x64"; - // Repinned twice. First for the side split: the two fighters are unaffiliated, were one side and each followed itself. - // Then for staged knowledge (ruling repin-for-staged-knowledge): the fighters are staged knowing each other, so they engage at step 0. + // Repinned twice, and the second repin has two causes (Soul measured the steps: group 1 + seed = f19b9992, + the Follow + // gate = ce81a426; group 2 without the seed = bf586806). + // First for the side split: the two fighters are unaffiliated, were one side and each followed itself (1b2ffe51). + // Then for staged knowledge (ruling repin-for-staged-knowledge), which moved it twice: the fighters are staged knowing + // each other, so they engage at step 0 (f19b9992); and Follow now flies only on an anchor the pilot itself tracks, so + // once the sensors drop the staged anchor the follower stops following it (ce81a426). private const string TwoAiPinnedDigest = "ce81a426c40495e4b4b7b7f83ad23767cc781dce5523d713b4455e0aa5b7fe33"; private const int TwoAiSteps = 3600; From 488b3fa434daa88439c161e88ecbe2657fbfaf5d Mon Sep 17 00:00:00 2001 From: soul Date: Thu, 22 Oct 2026 18:26:07 +0200 Subject: [PATCH 201/444] PilotView enforces its own rules: members only, fire-control readouts only on the member's own track; constructor and View setter internal Co-Authored-By: Claude Sonnet 5.5 --- Assets/Scripts/ServerShared/Agents/Agent.cs | 2 +- .../Scripts/ServerShared/Agents/PilotView.cs | 83 +++++++++++++------ .../ServerShared/Agents/States/Combat.cs | 3 +- Assets/Scripts/ServerShared/ScenarioStage.cs | 3 + .../FireControlPerWeaponTests.cs | 2 +- 5 files changed, 65 insertions(+), 28 deletions(-) diff --git a/Assets/Scripts/ServerShared/Agents/Agent.cs b/Assets/Scripts/ServerShared/Agents/Agent.cs index 586438c97..03a76436a 100644 --- a/Assets/Scripts/ServerShared/Agents/Agent.cs +++ b/Assets/Scripts/ServerShared/Agents/Agent.cs @@ -25,7 +25,7 @@ public class Agent public AgentTask Task { get; set; } // What this pilot's side knows of other ships. A ship outside a flight is a side of one: a view over itself, until // Flight.Join sets the flight's. - public PilotView View { get; set; } + public PilotView View { get; internal set; } // Zone-scoped, from 1: the ordinal of this pilot's own dice, set by Zone.CreateAgent as it admits the pilot (0 for // an agent built outside a zone's admission). public uint Id { get; set; } diff --git a/Assets/Scripts/ServerShared/Agents/PilotView.cs b/Assets/Scripts/ServerShared/Agents/PilotView.cs index 8c9a71e65..51cacf32f 100644 --- a/Assets/Scripts/ServerShared/Agents/PilotView.cs +++ b/Assets/Scripts/ServerShared/Agents/PilotView.cs @@ -14,13 +14,22 @@ using CultMath; // within a zone step nothing moves during the agent phase, so the track equals what the Entity would answer. The // fire-control readouts call FireControl's own functions with the resolved subject, so the HUD, the player and the // brains price one model (A-one-sim). Own-ship reads stay the brain's own. +// +// The view enforces its own rules; it does not trust its callers. Every per-member read answers for a member the view +// holds and nothing else, and every per-contact fire-control readout (Intercept, WeaponReveals, Designated, Refuses, +// Fires) answers only while that member itself tracks the contact: a sidemate's track gives a member no aim, no reveals +// and no shot (per-shooter-fire-control). Only Flight builds a view and sets it on its pilots, and the side-of-one view an +// Agent starts with is its own; the constructor and the setter are internal. public sealed class PilotView { private readonly List _members = new List(); private static Func _openContact; private static Func _openReveal; - public PilotView(params Ship[] members) => _members.AddRange(members); + internal PilotView(params Ship[] members) => _members.AddRange(members); + + private bool Holds(Ship member) => _members.Contains(member); + private bool Tracks(Ship member, Entity subject) => Holds(member) && subject != null && member.VisibleEntities.Contains(subject); internal void Add(Ship member) => _members.Add(member); internal void Remove(Ship member) => _members.Remove(member); @@ -69,7 +78,9 @@ public sealed class PilotView } } - // For fixtures: the handle of a subject, as the view would issue it. + // For fixtures, and for the one place the stage states what a pilot already knows (ScenarioStage.Approach seeds an + // anchor handle for a FollowTask before the zone exists: staged knowledge, ruling stage-states-pilot-knowledge). A + // brain never calls this; the sensors, not the handle, decide whether the view answers about the contact. internal static ContactId Handle(Entity subject) => new ContactId(subject); private static Entity Subject(ContactId id) @@ -95,13 +106,13 @@ public sealed class PilotView return picture; } - public bool Sees(Ship member, ContactId contact) => member.VisibleEnemies.Contains(Subject(contact)); + public bool Sees(Ship member, ContactId contact) => Holds(member) && member.VisibleEnemies.Contains(Subject(contact)); // The member's own track of the contact, or null while it does not track it. public Track? TrackOf(Ship member, ContactId contact) { var subject = Subject(contact); - if (subject == null || !member.VisibleEntities.Contains(subject)) return null; + if (!Tracks(member, subject)) return null; return new Track(subject.Position, subject.Velocity, subject.Direction); } @@ -117,45 +128,64 @@ public sealed class PilotView return null; } - public bool? IffOverride(Ship member, ContactId contact) => member.IffOverride(Subject(contact)); + public bool? IffOverride(Ship member, ContactId contact) => Holds(member) ? member.IffOverride(Subject(contact)) : null; public float? Info(Ship member, ContactId contact) => - member.EntityInfoGathered.TryGetValue(Subject(contact), out var info) ? info : (float?) null; + Holds(member) && member.EntityInfoGathered.TryGetValue(Subject(contact), out var info) ? info : (float?) null; - public bool BelievesTrespassing(Ship member, ContactId contact) => member.BelievesTrespassing(Subject(contact)); + public bool BelievesTrespassing(Ship member, ContactId contact) => Holds(member) && member.BelievesTrespassing(Subject(contact)); // The member's pending designation when set, else last step's applied target. public ContactId? TargetOf(Ship member) { + if (!Holds(member)) return null; var subject = (member.Command.Designated ?? member.Target.Value).Entity; return subject == null ? (ContactId?) null : new ContactId(subject); } public ContactId? AppliedTargetOf(Ship member) { + if (!Holds(member)) return null; var subject = member.Target.Value.Entity; return subject == null ? (ContactId?) null : new ContactId(subject); } // The weapons the member believes the contact has, in the contact's equipment order; Aimable is FireControl.IsRevealed. - public List<(RevealId Id, EquippableItemData Claimed, bool Aimable)> WeaponReveals(Ship member, ContactId contact) => - member.RevealedWeapons(Subject(contact)) - .Select(r => (new RevealId(r.Item), r.Claimed, FireControl.IsRevealed(member, r.Item))).ToList(); - - public bool Designated(Ship member, Weapon weapon, ContactId contact) => - FireControl.Designated(weapon, member, Subject(contact), out _); - - public bool Refuses(Ship member, Weapon weapon, ContactId contact) => - FireControl.Refuses(weapon, member, Subject(contact)); - - public bool Fires(Ship member, Weapon weapon, ContactId contact, float minHitProbability, RevealId? aim) => - FireControl.AgentFires(weapon, member, Subject(contact), minHitProbability, - aim == null ? AimTarget.None : AimTarget.OfItem(Item(aim.Value))); - - // Where to point to hit the contact with the weapon: the shared prediction, at the height the contact rides. - public float3 Intercept(Ship member, Weapon weapon, ContactId contact) + // Empty unless the member tracks the contact. + public List<(RevealId Id, EquippableItemData Claimed, bool Aimable)> WeaponReveals(Ship member, ContactId contact) { var subject = Subject(contact); + if (!Tracks(member, subject)) return new List<(RevealId, EquippableItemData, bool)>(); + return member.RevealedWeapons(subject) + .Select(r => (new RevealId(r.Item), r.Claimed, FireControl.IsRevealed(member, r.Item))).ToList(); + } + + // The next four answer for a member that tracks the contact: otherwise not designated, refused, no shot, no intercept. + public bool Designated(Ship member, Weapon weapon, ContactId contact) + { + var subject = Subject(contact); + return Tracks(member, subject) && FireControl.Designated(weapon, member, subject, out _); + } + + public bool Refuses(Ship member, Weapon weapon, ContactId contact) + { + var subject = Subject(contact); + return !Tracks(member, subject) || FireControl.Refuses(weapon, member, subject); + } + + public bool Fires(Ship member, Weapon weapon, ContactId contact, float minHitProbability, RevealId? aim) + { + var subject = Subject(contact); + return Tracks(member, subject) && FireControl.AgentFires(weapon, member, subject, minHitProbability, + aim == null ? AimTarget.None : AimTarget.OfItem(Item(aim.Value))); + } + + // Where to point to hit the contact with the weapon: the shared prediction, at the height the contact rides; null + // unless the member tracks the contact. + public float3? Intercept(Ship member, Weapon weapon, ContactId contact) + { + var subject = Subject(contact); + if (!Tracks(member, subject)) return null; var predicted = FireControl.PredictedIntercept(weapon, member, subject); predicted.y = subject.RideHeight(predicted.xz); return predicted; @@ -164,10 +194,13 @@ public sealed class PilotView // Designate the contact, or nothing for null, when that is not already the member's applied target. public void Designate(Ship member, ContactId? contact) { + if (!Holds(member)) return; var wanted = contact == null ? TargetRef.None : new TargetRef(Subject(contact.Value)); if (!member.Target.Value.Equals(wanted)) member.Command.Designate(wanted); } - public void SelectItem(Ship member, RevealId? reveal) => - member.Command.SelectItem(reveal == null ? null : Item(reveal.Value)); + public void SelectItem(Ship member, RevealId? reveal) + { + if (Holds(member)) member.Command.SelectItem(reveal == null ? null : Item(reveal.Value)); + } } diff --git a/Assets/Scripts/ServerShared/Agents/States/Combat.cs b/Assets/Scripts/ServerShared/Agents/States/Combat.cs index a424437b3..f8c12baab 100644 --- a/Assets/Scripts/ServerShared/Agents/States/Combat.cs +++ b/Assets/Scripts/ServerShared/Agents/States/Combat.cs @@ -134,8 +134,9 @@ public class CombatState : BaseState // Cut 3 (docs/fire-control-cut.md): the one prediction function, shared with the shot's // own snapshot and the roll's deviation measurement -- two separate predictions here and in the // roll would let an AI aim at one point and get judged for missing a different one. + // The target is tracked here (the guard above), so the intercept is never null. var predictedPosition = view.Intercept(_agent.Ship, testWeapon, target.Value); - toTarget = normalize(predictedPosition - _agent.Ship.Position); + if (predictedPosition != null) toTarget = normalize(predictedPosition.Value - _agent.Ship.Position); } // Each weapon decides for itself (operator ruling 2026-09-30): a group shares a trigger, not a diff --git a/Assets/Scripts/ServerShared/ScenarioStage.cs b/Assets/Scripts/ServerShared/ScenarioStage.cs index a9e2db5d0..453ede8ec 100644 --- a/Assets/Scripts/ServerShared/ScenarioStage.cs +++ b/Assets/Scripts/ServerShared/ScenarioStage.cs @@ -148,6 +148,9 @@ public sealed class ScenarioStage var placement = _placed.FirstOrDefault(p => ReferenceEquals(p.Entity, entity)); if (placement == null || !placement.Piloted) { _failures.Add($"approach: {entity?.Name ?? "no entity"} is not a placed, piloted ship"); return; } if (target == null) { _failures.Add("approach: no target to close on"); return; } + // The one production handle minted from truth: the stage states the pilot's knowledge of its anchor (ruling + // stage-states-pilot-knowledge), and RunStart seeds the matching sensor track. The view still answers about the + // anchor only while the pilot's sensors hold it. placement.Task = new FollowTask { Anchor = PilotView.Handle(target), Standoff = 0f }; placement.Knows = target; } diff --git a/tests/Aetheria.Shared.Tests/FireControlPerWeaponTests.cs b/tests/Aetheria.Shared.Tests/FireControlPerWeaponTests.cs index 5c0aa19a5..3a06a63f7 100644 --- a/tests/Aetheria.Shared.Tests/FireControlPerWeaponTests.cs +++ b/tests/Aetheria.Shared.Tests/FireControlPerWeaponTests.cs @@ -441,7 +441,7 @@ public sealed class FireControlPerWeaponTests : IDisposable var planarY = expected.y; expected.y = r.Target.RideHeight(expected.xz); Assert.NotEqual(planarY, expected.y); - Assert.Equal(expected, view.Intercept(r.Shooter, weapon, contact)); + Assert.Equal(expected, view.Intercept(r.Shooter, weapon, contact).Value); } // A view answers fire control only for a member that tracks the contact: the shooter outside the view, or having lost From dadb6f327d24012c9c17e3efef93cfb4b3a9f3e7 Mon Sep 17 00:00:00 2001 From: soul Date: Thu, 22 Oct 2026 19:56:54 +0200 Subject: [PATCH 202/444] Cargo transfer tests: long steps after the receiver leaves or strays move nothing; a full first bay hands on to the next Co-Authored-By: Claude Sonnet 5.5 --- .../CargoTransferTests.cs | 48 +++++++++++++++++++ 1 file changed, 48 insertions(+) diff --git a/tests/Aetheria.Shared.Tests/CargoTransferTests.cs b/tests/Aetheria.Shared.Tests/CargoTransferTests.cs index 28eae6cf9..0926494bf 100644 --- a/tests/Aetheria.Shared.Tests/CargoTransferTests.cs +++ b/tests/Aetheria.Shared.Tests/CargoTransferTests.cs @@ -31,6 +31,9 @@ public sealed class CargoTransferTests : IDisposable foreach (var cell in pocket.AllCoordinates) pocket[cell] = true; _cache.Upsert(new CargoBayData { Name = "Pocket", Shape = new Shape(), InteriorShape = pocket, Price = 5, Durability = 4 }); _cache.Upsert(new SimpleCommodityData { Name = "Ore", Shape = new Shape(), Price = 7, MaxStack = 100 }); + var bin = new Shape(1, 1); + bin[0, 0] = true; + _cache.Upsert(new CargoBayData { Name = "Bin", Shape = bin, InteriorShape = bin, Price = 5, Durability = 4 }); _cache.Upsert(new SimpleCommodityData { Name = "Grit", Shape = new Shape(), Price = 1, MaxStack = 2 }); _cache.Upsert(new SimpleCommodityData { Name = "Slag", Shape = new Shape(), Price = 1, MaxStack = 100 }); _cache.Upsert(new DockingBayData { Name = "Berth", Shape = new Shape(), InteriorShape = bay, MaxSize = new int2(2, 2), Price = 60, Durability = 6 }); @@ -284,6 +287,51 @@ public sealed class CargoTransferTests : IDisposable Assert.True(receiver.CargoBays[0].Cargo.ContainsKey(gun)); } + [Fact] + public void ALongStepAfterTheReceiverLeavesMovesNothing() + { + var (giver, receiver, ore) = Alongside(10); + giver.Command.Give(receiver, ore, 10); + Steps(1); + _zone.Remove(receiver); + + _zone.Update(5f); + + Assert.Equal(0, Held(receiver, ore)); + Assert.Equal(10, Held(giver, ore)); + Assert.Equal(GiveEnd.ReceiverGone, giver.LastGiveEnd); + } + + [Fact] + public void ALongStepOutOfRangeMovesNothing() + { + var (giver, receiver, ore) = Alongside(10); + giver.Command.Give(receiver, ore, 10); + Steps(1); + receiver.Position = giver.Position + new float3(0, 0, _items.GameplaySettings.DockingDistance + 1); + + _zone.Update(5f); + + Assert.Equal(0, Held(receiver, ore)); + Assert.Equal(GiveEnd.OutOfRange, giver.LastGiveEnd); + } + + [Fact] + public void AGiveGoesToTheNextReceiverBayWhenTheFirstIsFull() + { + var (giver, receiver, ore) = Alongside(10, "Bin"); + Assert.True(receiver.CargoBays[0].TryStore(Commodity("Slag", 5))); + Assert.True(receiver.TryEquip(Mint("Crate"))); + Assert.Equal(2, receiver.CargoBays.Count); + giver.Command.Give(receiver, ore, 3); + + Steps(40); + + Assert.Equal(0, Units(receiver.CargoBays[0], ore)); + Assert.Equal(3, Units(receiver.CargoBays[1], ore)); + Assert.Equal(GiveEnd.Done, giver.LastGiveEnd); + } + [Fact] public void GiveRangeIsMeasuredOnTheFlatPlane() { From 3f265b6c731a85df25af79068dceed18c4eb4005 Mon Sep 17 00:00:00 2001 From: soul Date: Thu, 22 Oct 2026 19:59:19 +0200 Subject: [PATCH 203/444] Cargo transfer tests: fill the one-cell bay the way the other fixtures fill a shape Co-Authored-By: Claude Sonnet 5.5 --- tests/Aetheria.Shared.Tests/CargoTransferTests.cs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/tests/Aetheria.Shared.Tests/CargoTransferTests.cs b/tests/Aetheria.Shared.Tests/CargoTransferTests.cs index 0926494bf..9153e015a 100644 --- a/tests/Aetheria.Shared.Tests/CargoTransferTests.cs +++ b/tests/Aetheria.Shared.Tests/CargoTransferTests.cs @@ -32,7 +32,7 @@ public sealed class CargoTransferTests : IDisposable _cache.Upsert(new CargoBayData { Name = "Pocket", Shape = new Shape(), InteriorShape = pocket, Price = 5, Durability = 4 }); _cache.Upsert(new SimpleCommodityData { Name = "Ore", Shape = new Shape(), Price = 7, MaxStack = 100 }); var bin = new Shape(1, 1); - bin[0, 0] = true; + foreach (var cell in bin.AllCoordinates) bin[cell] = true; _cache.Upsert(new CargoBayData { Name = "Bin", Shape = bin, InteriorShape = bin, Price = 5, Durability = 4 }); _cache.Upsert(new SimpleCommodityData { Name = "Grit", Shape = new Shape(), Price = 1, MaxStack = 2 }); _cache.Upsert(new SimpleCommodityData { Name = "Slag", Shape = new Shape(), Price = 1, MaxStack = 100 }); From 00d34e5ab5303bb40f793789fe2866a5751db39d Mon Sep 17 00:00:00 2001 From: soul Date: Thu, 22 Oct 2026 20:05:33 +0200 Subject: [PATCH 204/444] Cargo transfer tests: the second receiver bay is a one-cell bin Co-Authored-By: Claude Sonnet 5.5 --- tests/Aetheria.Shared.Tests/CargoTransferTests.cs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/tests/Aetheria.Shared.Tests/CargoTransferTests.cs b/tests/Aetheria.Shared.Tests/CargoTransferTests.cs index 9153e015a..cdc356bf2 100644 --- a/tests/Aetheria.Shared.Tests/CargoTransferTests.cs +++ b/tests/Aetheria.Shared.Tests/CargoTransferTests.cs @@ -321,7 +321,7 @@ public sealed class CargoTransferTests : IDisposable { var (giver, receiver, ore) = Alongside(10, "Bin"); Assert.True(receiver.CargoBays[0].TryStore(Commodity("Slag", 5))); - Assert.True(receiver.TryEquip(Mint("Crate"))); + Assert.True(receiver.TryEquip(Mint("Bin"))); Assert.Equal(2, receiver.CargoBays.Count); giver.Command.Give(receiver, ore, 3); From 1691f47cc685623e32f26d282b5ebc7f34bd8920 Mon Sep 17 00:00:00 2001 From: soul Date: Thu, 22 Oct 2026 20:09:53 +0200 Subject: [PATCH 205/444] README: front door with simulation entry points, demo in progress, directions and package hulls Co-Authored-By: Claude Opus 5.5 --- README.md | 16 ++++++++++++++-- 1 file changed, 14 insertions(+), 2 deletions(-) diff --git a/README.md b/README.md index 79f0033f2..8518aba1f 100644 --- a/README.md +++ b/README.md @@ -5,6 +5,18 @@ All mechanics are developed to be engine-agnostic, while Unity was chosen as a r It is our ambition to forge a new paradigm of open source game development, as to our knowledge there are no other open source projects of this scope building up a foundation from scratch. Game development is notoriously averse to this model and nobody wants to share their shiny toys for fear of losing their competitive advantage. By building and sharing immersive and compelling experiences like this, we hope to change that. +## Front Door + +- **Value:** a simulation-first tactical ARPG. The game's rules live in a headless .NET build (`Aetheria.Shared`, compiled from `Assets/Scripts/ServerShared`) that needs no Unity; Unity is the shell for graphics and input. Content (hulls, gear, factions, blueprints) is typed CultCache data in `GameData/Aetheria.cc`. +- **Problem:** an AA-scale open source game built from scratch, with simulation depth (fire control, stats and power, heat, item provenance, locomotion) that agents and contributors can inspect, test and extend without opening the editor. +- **Entry points:** + - `dotnet test tests/Aetheria.Shared.Tests` runs the shared simulation tests; `dotnet run --project tools/AetherDb -- census` reads the catalog (commands under Command Line). `CLAUDE.md` explains AetherDb and the pinned CultLib build rule. + - `docs/aetheria-release-target.md`: the demo. `docs/aetheria-arena-target.md`: deterministic headless matches between swappable brains. `docs/moddable-ship-authoring.md`: ship packages. + - Unity client: open the repository root in Unity 6000.3.24f1 and start from the "Main Menu" scene (Testing Locally). +- **In progress:** the demo, one gate, one region and one boss with a fixed faction presence (allied Pirates, antagonist Zhestokost). It is called a demo because full Terminus needs enough faction variety for three procedurally generated regions. +- **Directions, not built:** the strategic arena (`docs/aetheria-arena-target.md` is a target, not a shipped system) and the living-world economy. Neither has a spec in `docs/` yet beyond the arena target. +- **Packages ship hulls:** hulls are delivered as mod-owned `.cc` ship packages authored in Blender and the CultLib tools, with no Unity install. That is a proof lane today; catalog `HullData` records remain live until a complete package ship passes authoring, validation, loading and play. + ## Contact Us If you want to chat, please join [our Discord server](https://discord.gg/trbteNj) for detailed development updates and discussions about the game design, narrative, art direction, and general topics. Shitposting welcome! @@ -57,13 +69,13 @@ As a result of lessons learned, we then focused on the economy system, and built ## Current Work -At the moment we are focused on implementing and polishing the ARPG gameplay as a standalone title, first as a rogue-lite combat demo and then adding a story-driven campaign mode. +At the moment we are focused on implementing and polishing the ARPG gameplay as a standalone title, first as a rogue-lite combat demo (see [the demo target](docs/aetheria-release-target.md)) and then adding a story-driven campaign mode. ## Architecture ### Project Structure -There are two solutions in this repository. One is a Unity project containing the desktop client for Aetheria, and the other is a .NET Core application intended to run on Linux cloud servers. The bulk of the game's code is shared between them, located at [Assets/Scripts/ServerShared](Assets/Scripts/ServerShared), defining a common protocol, world simulation code and data serialization format. +This repository holds a Unity project containing the desktop client for Aetheria and a headless .NET build of the shared simulation (`Aetheria.Shared`, with `tests/Aetheria.Shared.Tests` and `tools/AetherDb`). The bulk of the game's code is shared between them, located at [Assets/Scripts/ServerShared](Assets/Scripts/ServerShared), defining a common protocol, world simulation code and data serialization format. ### Third Party Libraries From c37fbdb3b95ddca0b05570f228a4ffc7e6b192a4 Mon Sep 17 00:00:00 2001 From: soul Date: Thu, 22 Oct 2026 20:11:38 +0200 Subject: [PATCH 206/444] Cargo transfer tests: drop the two-bay receiver test; the fixture hulls take one bay Co-Authored-By: Claude Sonnet 5.5 --- .../CargoTransferTests.cs | 19 ------------------- 1 file changed, 19 deletions(-) diff --git a/tests/Aetheria.Shared.Tests/CargoTransferTests.cs b/tests/Aetheria.Shared.Tests/CargoTransferTests.cs index cdc356bf2..ea92abd40 100644 --- a/tests/Aetheria.Shared.Tests/CargoTransferTests.cs +++ b/tests/Aetheria.Shared.Tests/CargoTransferTests.cs @@ -31,9 +31,6 @@ public sealed class CargoTransferTests : IDisposable foreach (var cell in pocket.AllCoordinates) pocket[cell] = true; _cache.Upsert(new CargoBayData { Name = "Pocket", Shape = new Shape(), InteriorShape = pocket, Price = 5, Durability = 4 }); _cache.Upsert(new SimpleCommodityData { Name = "Ore", Shape = new Shape(), Price = 7, MaxStack = 100 }); - var bin = new Shape(1, 1); - foreach (var cell in bin.AllCoordinates) bin[cell] = true; - _cache.Upsert(new CargoBayData { Name = "Bin", Shape = bin, InteriorShape = bin, Price = 5, Durability = 4 }); _cache.Upsert(new SimpleCommodityData { Name = "Grit", Shape = new Shape(), Price = 1, MaxStack = 2 }); _cache.Upsert(new SimpleCommodityData { Name = "Slag", Shape = new Shape(), Price = 1, MaxStack = 100 }); _cache.Upsert(new DockingBayData { Name = "Berth", Shape = new Shape(), InteriorShape = bay, MaxSize = new int2(2, 2), Price = 60, Durability = 6 }); @@ -316,22 +313,6 @@ public sealed class CargoTransferTests : IDisposable Assert.Equal(GiveEnd.OutOfRange, giver.LastGiveEnd); } - [Fact] - public void AGiveGoesToTheNextReceiverBayWhenTheFirstIsFull() - { - var (giver, receiver, ore) = Alongside(10, "Bin"); - Assert.True(receiver.CargoBays[0].TryStore(Commodity("Slag", 5))); - Assert.True(receiver.TryEquip(Mint("Bin"))); - Assert.Equal(2, receiver.CargoBays.Count); - giver.Command.Give(receiver, ore, 3); - - Steps(40); - - Assert.Equal(0, Units(receiver.CargoBays[0], ore)); - Assert.Equal(3, Units(receiver.CargoBays[1], ore)); - Assert.Equal(GiveEnd.Done, giver.LastGiveEnd); - } - [Fact] public void GiveRangeIsMeasuredOnTheFlatPlane() { From 66671cf036f711c8c41fc0ce311ebc715bcd8a7d Mon Sep 17 00:00:00 2001 From: soul Date: Thu, 22 Oct 2026 21:09:30 +0200 Subject: [PATCH 207/444] Cargo tests: quantities below one, give routing and ends, purchase lots (red); soul-tv probes P4-P6, P9 adopted Co-Authored-By: Claude Sonnet 5.5 --- .../CargoTransferTests.cs | 261 +++++++++++++++++- 1 file changed, 259 insertions(+), 2 deletions(-) diff --git a/tests/Aetheria.Shared.Tests/CargoTransferTests.cs b/tests/Aetheria.Shared.Tests/CargoTransferTests.cs index ea92abd40..26f2332f0 100644 --- a/tests/Aetheria.Shared.Tests/CargoTransferTests.cs +++ b/tests/Aetheria.Shared.Tests/CargoTransferTests.cs @@ -33,6 +33,12 @@ public sealed class CargoTransferTests : IDisposable _cache.Upsert(new SimpleCommodityData { Name = "Ore", Shape = new Shape(), Price = 7, MaxStack = 100 }); _cache.Upsert(new SimpleCommodityData { Name = "Grit", Shape = new Shape(), Price = 1, MaxStack = 2 }); _cache.Upsert(new SimpleCommodityData { Name = "Slag", Shape = new Shape(), Price = 1, MaxStack = 100 }); + var slot = new Shape(1, 2); + foreach (var cell in slot.AllCoordinates) slot[cell] = true; + _cache.Upsert(new CargoBayData { Name = "Slot", Shape = new Shape(), InteriorShape = slot, Price = 5, Durability = 4 }); + var rod = new Shape(2, 1); + foreach (var cell in rod.AllCoordinates) rod[cell] = true; + _cache.Upsert(new SimpleCommodityData { Name = "Rod", Shape = rod, Price = 1, MaxStack = 1 }); _cache.Upsert(new DockingBayData { Name = "Berth", Shape = new Shape(), InteriorShape = bay, MaxSize = new int2(2, 2), Price = 60, Durability = 6 }); _cache.Upsert(new GearData { Name = "Gun", Hardpoint = HardpointType.Sensors, Shape = new Shape(), Price = 400, Durability = 8 }); _cache.FlushAsync().Wait(); @@ -59,8 +65,28 @@ public sealed class CargoTransferTests : IDisposable }; } - private SimpleCommodity Commodity(string design, int quantity) => - new SimpleCommodity { Data = _cache.RefOf(_cache.GetByName(design)), Quantity = quantity }; + private SimpleCommodity Commodity(string design, int quantity, ItemRotation rotation = ItemRotation.None) => + new SimpleCommodity { Data = _cache.RefOf(_cache.GetByName(design)), Quantity = quantity, Rotation = rotation }; + + // Bays built directly, so a ship can hold several (TryEquip is not under test). + private Ship ShipWithBays(params string[] bays) + { + var ship = new Ship(_items, _zone, Mint("Skiff"), new EntitySettings()); + for (var i = 0; i < bays.Length; i++) + ship.CargoBays.Add(new EquippedCargoBay(_items, Mint(bays[i]), new int2(1 + i, 1), ship, $"bay{i}")); + return ship; + } + + // Off the origin, so a distance taken from the wrong sum or difference of positions shows. + private void Place(Ship giver, Ship receiver) + { + giver.Position = new float3(-50, 0, 10); + receiver.Position = giver.Position + new float3(5, 0, 0); + _zone.Admit(giver, piloted: false); + _zone.Admit(receiver, piloted: false); + } + + private static int Stacks(EquippedCargoBay bay) => bay.Cargo.Keys.Count; private Ship ShipWith(string bay) { @@ -518,4 +544,235 @@ public sealed class CargoTransferTests : IDisposable Assert.Equal(0, Held(receiver, ore)); Assert.Null(giver.LastGiveEnd); } + + // ---- Quantities below one -------------------------------------------------------------------------------- + + [Theory] + [InlineData(0)] + [InlineData(-5)] + public void ATransferBelowOneUnitChangesNothing(int quantity) + { + var from = ShipWith("Crate").CargoBays[0]; + var to = ShipWith("Crate").CargoBays[0]; + var ore = Commodity("Ore", 10); + Assert.True(from.TryStore(ore)); + Assert.True(to.TryStore(Commodity("Ore", 3))); + var mass = (from.Mass, to.Mass); + var stacks = (Stacks(from), Stacks(to)); + + Assert.False(from.TryTransferItem(to, ore, quantity)); + + Assert.Equal(10, Units(from, ore)); + Assert.Equal(3, Units(to, ore)); + Assert.Equal(mass, (from.Mass, to.Mass)); + Assert.Equal(stacks, (Stacks(from), Stacks(to))); + } + + [Theory] + [InlineData(0)] + [InlineData(-5)] + public void ARemovalBelowOneUnitTakesNothing(int quantity) + { + var bay = ShipWith("Crate").CargoBays[0]; + var ore = Commodity("Ore", 10); + Assert.True(bay.TryStore(ore)); + var mass = bay.Mass; + + Assert.Null(bay.Remove(ore, quantity)); + + Assert.Equal(10, Units(bay, ore)); + Assert.Equal(mass, bay.Mass); + Assert.Equal(1, Stacks(bay)); + } + + // ---- The give: where units go, and when it ends (the soul-tv probes, adopted) ---------------------------- + + // First receiver bay full, second has room: the unit goes to the second. + [Fact] + public void AGivenCommodityGoesToTheSecondBayWhenTheFirstIsFull() + { + var giver = ShipWithBays("Crate"); + var receiver = ShipWithBays("Pocket", "Crate"); + var ore = Commodity("Ore", 10); + Assert.True(giver.CargoBays[0].TryStore(ore)); + Assert.True(receiver.CargoBays[0].TryStore(Commodity("Slag", 5))); + Place(giver, receiver); + giver.Command.Give(receiver, ore, 3); + + Steps(40); + + Assert.Equal(3, Units(receiver.CargoBays[1], ore)); + Assert.Equal(GiveEnd.Done, giver.LastGiveEnd); + } + + // Two open receiver bays: one unit per move, not one per bay. + [Fact] + public void AGiveMovesOneUnitPerMoveWithTwoOpenBays() + { + var giver = ShipWithBays("Crate"); + var receiver = ShipWithBays("Crate", "Crate"); + var ore = Commodity("Ore", 10); + Assert.True(giver.CargoBays[0].TryStore(ore)); + Place(giver, receiver); + giver.Command.Give(receiver, ore, 10); + + _zone.Update(1f); + + Assert.Equal(2, Held(receiver, ore)); + Assert.Equal(8, Held(giver, ore)); + } + + [Fact] + public void AGivenCraftedItemGoesToTheSecondBayWhenTheFirstIsFull() + { + var giver = ShipWithBays("Crate"); + var receiver = ShipWithBays("Pocket", "Crate"); + var gun = Mint("Gun"); + Assert.True(giver.CargoBays[0].TryStore(gun)); + Assert.True(receiver.CargoBays[0].TryStore(Commodity("Slag", 5))); + Place(giver, receiver); + giver.Command.Give(receiver, gun, 1); + + Steps(10); + + Assert.True(receiver.CargoBays[1].Cargo.ContainsKey(gun)); + Assert.Equal(GiveEnd.Done, giver.LastGiveEnd); + } + + [Fact] + public void AGivenCraftedItemMovesOnceIntoTwoOpenBays() + { + var giver = ShipWithBays("Crate"); + var receiver = ShipWithBays("Crate", "Crate"); + var gun = Mint("Gun"); + Assert.True(giver.CargoBays[0].TryStore(gun)); + Place(giver, receiver); + giver.Command.Give(receiver, gun, 1); + + Steps(10); + + Assert.True(receiver.CargoBays[0].Cargo.ContainsKey(gun)); + Assert.Equal(GiveEnd.Done, giver.LastGiveEnd); + } + + // A refused one-unit move of a whole one-unit stack re-files it at the end of ItemsOfType, so the next Source would be + // a different stack. Rods are 2x1; the receiver's Slot (1x2) takes only the turned one. After the give has ended + // ReceiverFull nothing may move. + [Fact] + public void NothingMovesAfterAGiveEndsReceiverFull() + { + var giver = ShipWithBays("Crate"); + var receiver = ShipWithBays("Slot"); + var flat = Commodity("Rod", 1); + Assert.True(giver.CargoBays[0].TryStore(flat)); + Assert.True(giver.CargoBays[0].TryStore(Commodity("Rod", 1, ItemRotation.Clockwise))); + Place(giver, receiver); + giver.Command.Give(receiver, flat, 2); + + _zone.Update(5f); + + Assert.Equal(GiveEnd.ReceiverFull, giver.LastGiveEnd); + Assert.Equal(0, Held(receiver, flat)); + Assert.Equal(2, Held(giver, flat)); + } + + // Refused moves do not spend the order's units. + [Fact] + public void AnEndedOrderKeepsItsRemainingUnits() + { + var giver = ShipWithBays("Crate"); + var receiver = ShipWithBays("Pocket"); + var ore = Commodity("Ore", 10); + Assert.True(giver.CargoBays[0].TryStore(ore)); + Assert.True(receiver.CargoBays[0].TryStore(Commodity("Slag", 5))); + Place(giver, receiver); + giver.Command.Give(receiver, ore, 5); + _zone.Update(.01f); // applies the command, earns no unit + var order = giver.Giving; + Assert.NotNull(order); + + _zone.Update(5f); + + Assert.Equal(GiveEnd.ReceiverFull, giver.LastGiveEnd); + Assert.Equal(5, order.Remaining); + } + + // An empty stack in a hold is no unit to give: the give moves the real units and then has nothing left. + [Fact] + public void AnEmptyStackIsNotAUnitToGive() + { + var giver = ShipWithBays("Crate"); + var receiver = ShipWithBays("Crate"); + var grit = Commodity("Grit", 2); + Assert.True(giver.CargoBays[0].TryStore(grit)); + Assert.True(giver.CargoBays[0].TryStore(Commodity("Grit", 0))); + Place(giver, receiver); + giver.Command.Give(receiver, grit, 3); + + _zone.Update(5f); + + Assert.Equal(2, Held(receiver, grit)); + Assert.Equal(GiveEnd.NothingLeft, giver.LastGiveEnd); + } + + // Warping and dying both leave through Zone.Remove; docking has already ended the give GiverDocked. + [Fact] + public void AGiveEndsWhenItsGiverLeavesTheZone() + { + var giver = ShipWithBays("Crate"); + var receiver = ShipWithBays("Crate"); + var ore = Commodity("Ore", 10); + Assert.True(giver.CargoBays[0].TryStore(ore)); + Place(giver, receiver); + giver.Command.Give(receiver, ore, 10); + _zone.Update(.5f); + + Assert.True(_zone.Remove(giver)); + + Assert.Null(giver.Giving); + Assert.Equal(GiveEnd.GiverGone, giver.LastGiveEnd); + } + + // ---- The lot a purchase delivers --------------------------------------------------------------------------- + + [Fact] + public void APurchaseChargesForEachLotItDelivers() + { + // Grit stacks hold two. Five units are three lots, and each lot is charged for what it delivers. + var stock = Commodity("Grit", 5); + var credits = 100; + var delivered = new List(); + + var result = StationServices.TryBuyLots(_items, stock, 5, ref credits, lot => { delivered.Add(lot); return true; }); + + Assert.Equal(BuyResult.Bought, result); + Assert.Equal(new[] { 2, 2, 1 }, delivered); + Assert.Equal(100 - 5, credits); + } + + [Fact] + public void ARefusedLotLeavesEarlierLotsBoughtAndChargesNothingMore() + { + var stock = Commodity("Grit", 5); + var credits = 100; + var lots = 0; + + var result = StationServices.TryBuyLots(_items, stock, 5, ref credits, lot => ++lots < 2); + + Assert.Equal(BuyResult.NoRoom, result); + Assert.Equal(100 - 2, credits); + } + + [Theory] + [InlineData(-5, 1000, 1)] + [InlineData(0, 1000, 1)] + [InlineData(4, 1000, 4)] + [InlineData(50, 1000, 10)] + [InlineData(50, 21, 3)] + [InlineData(5, 3, 1)] + public void TheBuyQuantityIsClampedToWhatIsBuyable(int typed, int credits, int expected) + { + var stock = Commodity("Ore", 10); // 7 credits each + Assert.Equal(expected, StationServices.BuyableQuantity(_items, stock, typed, credits)); + } } From f9a09e0d0a4fc1f24388c2292656c90b3fe93461 Mon Sep 17 00:00:00 2001 From: soul Date: Thu, 22 Oct 2026 21:10:04 +0200 Subject: [PATCH 208/444] Cargo: TryTransferItem and Remove refuse a quantity below one and change nothing Co-Authored-By: Claude Sonnet 5.5 --- Assets/Scripts/ServerShared/Entity.cs | 3 +++ 1 file changed, 3 insertions(+) diff --git a/Assets/Scripts/ServerShared/Entity.cs b/Assets/Scripts/ServerShared/Entity.cs index 721ac31b0..bc8087228 100644 --- a/Assets/Scripts/ServerShared/Entity.cs +++ b/Assets/Scripts/ServerShared/Entity.cs @@ -2596,8 +2596,10 @@ public class EquippedCargoBay : EquippedItem return true; } + // The part removed: null, with the bay as it was, when the item is not here or the quantity is below one unit. public SimpleCommodity Remove(SimpleCommodity item, int quantity) { + if (quantity < 1) return null; if (!Cargo.ContainsKey(item)) { ItemManager.Log("Attempted to remove item from a cargo bay that it wasn't even in! Something went wrong here!"); @@ -2658,6 +2660,7 @@ public class EquippedCargoBay : EquippedItem public bool TryTransferItem(EquippedCargoBay target, SimpleCommodity item, int quantity) { + if (quantity < 1) return false; if (!Cargo.ContainsKey(item)) { ItemManager.Log("Attempted to remove item from a cargo bay that it wasn't even in! Something went wrong here!"); From f0d7c12cbdfa0bda23ad04e9f5991e68a62d16b7 Mon Sep 17 00:00:00 2001 From: soul Date: Thu, 22 Oct 2026 21:10:07 +0200 Subject: [PATCH 209/444] Trade: the purchase loop moves to StationServices.TryBuyLots, charging each lot it delivers; the Buy Quantity field clamps to at least one Co-Authored-By: Claude Sonnet 5.5 --- .../Scripts/ServerShared/StationServices.cs | 22 +++++++++++++++++++ Assets/Scripts/UI/Menu/TradeMenu.cs | 15 +++---------- 2 files changed, 25 insertions(+), 12 deletions(-) diff --git a/Assets/Scripts/ServerShared/StationServices.cs b/Assets/Scripts/ServerShared/StationServices.cs index a6cb4bfb5..433b0baf1 100644 --- a/Assets/Scripts/ServerShared/StationServices.cs +++ b/Assets/Scripts/ServerShared/StationServices.cs @@ -79,6 +79,28 @@ public static class StationServices return BuyResult.Bought; } + // The purchase of `quantity` units of a station stack, one lot of at most MaxStack at a time. `deliver` puts a lot of + // that many units where it goes, and each lot is charged for the units it delivered. The first lot the station + // cannot sell or the player cannot take ends the purchase, with the lots before it bought. + public static BuyResult TryBuyLots(ItemManager items, SimpleCommodity stock, int quantity, ref int credits, Func deliver) + { + if (quantity < 1) return BuyResult.NoRoom; + var maxStack = items.GetData(stock).MaxStack; + for (var remaining = quantity; remaining > 0;) + { + var lot = Math.Min(remaining, maxStack); + var result = TryBuy(items, stock, lot, ref credits, () => deliver(lot)); + if (result != BuyResult.Bought) return result; + remaining -= lot; + } + return BuyResult.Bought; + } + + // The quantity a Buy Quantity field may hold: what was typed, at most what the credits cover and what the station + // has, and never below one unit (a price the credits do not cover is then refused as short of credits). + public static int BuyableQuantity(ItemManager items, SimpleCommodity stock, int typed, int credits) => + Math.Max(1, Math.Min(Math.Min(typed, credits / BuyPrice(items, stock)), stock.Quantity)); + // The sale: the item leaves the bay for one of the station's cargo bays and the station pays SellPrice. False, // with the item in its bay and the credits unchanged, when no station bay can take it. public static bool TrySell(EquippedCargoBay from, Entity station, ItemInstance item, ref int credits) diff --git a/Assets/Scripts/UI/Menu/TradeMenu.cs b/Assets/Scripts/UI/Menu/TradeMenu.cs index 3d448f64d..13345f88a 100644 --- a/Assets/Scripts/UI/Menu/TradeMenu.cs +++ b/Assets/Scripts/UI/Menu/TradeMenu.cs @@ -270,7 +270,7 @@ public class TradeMenu : MonoBehaviour Dialog.Title.text = $"Buying {GameManager.ItemManager.Title(i.item)}"; Dialog.AddField("Quantity", () => quantity, - q => quantity = min(min(q, GameManager.Credits / StationServices.BuyPrice(GameManager.ItemManager, s)), s.Quantity)); + q => quantity = StationServices.BuyableQuantity(GameManager.ItemManager, s, q, GameManager.Credits)); Dialog.Show(() => { Buy(s,quantity); @@ -343,17 +343,8 @@ public class TradeMenu : MonoBehaviour private void Buy(SimpleCommodity simpleCommodity, int quantity) { - var data = GameManager.ItemManager.GetData(simpleCommodity); - // Up-rounded integer division from https://stackoverflow.com/a/503201 - int lots = (quantity - 1) / data.MaxStack + 1; - int remaining = quantity; - for (int i = 0; i < lots; i++) - { - int q = min(remaining, data.MaxStack); - if (!Bought(StationServices.TryBuy(GameManager.ItemManager, simpleCommodity, q, ref GameManager.Credits, - () => Inventory.TryTransferItem(_targetCargo, simpleCommodity, quantity)))) return; - remaining -= q; - } + Bought(StationServices.TryBuyLots(GameManager.ItemManager, simpleCommodity, quantity, ref GameManager.Credits, + lot => Inventory.TryTransferItem(_targetCargo, simpleCommodity, lot))); } // Refreshes the credits after a purchase, or tells the player why the station refused it. From 4d462c771c8f7bb82bb47b1731de3e82738158cc Mon Sep 17 00:00:00 2001 From: soul Date: Thu, 22 Oct 2026 21:10:09 +0200 Subject: [PATCH 210/444] Give: Zone.Remove ends the leaving giver's give GiverGone Co-Authored-By: Claude Sonnet 5.5 --- Assets/Scripts/ServerShared/CargoTransfer.cs | 3 ++- Assets/Scripts/ServerShared/Zone.cs | 6 ++++-- 2 files changed, 6 insertions(+), 3 deletions(-) diff --git a/Assets/Scripts/ServerShared/CargoTransfer.cs b/Assets/Scripts/ServerShared/CargoTransfer.cs index 6484bace8..45208d948 100644 --- a/Assets/Scripts/ServerShared/CargoTransfer.cs +++ b/Assets/Scripts/ServerShared/CargoTransfer.cs @@ -15,7 +15,8 @@ public enum GiveEnd ReceiverGone, ReceiverFull, NothingLeft, - GiverDocked + GiverDocked, + GiverGone } // A pilot's standing offer of goods to a ship alongside. Runtime only: nothing about a give is saved. diff --git a/Assets/Scripts/ServerShared/Zone.cs b/Assets/Scripts/ServerShared/Zone.cs index 8bc152eaa..d395f722a 100644 --- a/Assets/Scripts/ServerShared/Zone.cs +++ b/Assets/Scripts/ServerShared/Zone.cs @@ -299,11 +299,13 @@ public class Zone if (piloted) Agents.Add(CreateAgent((Ship) entity, task)); } - // The one removal from this zone (death, dock, jump): membership and its death watch leave together. False, and - // nothing changes, when the entity is not a member. The caller deactivates; Remove does not. + // The one removal from this zone (death, dock, jump): membership and its death watch leave together, and so does the + // entity's give, which ends GiverGone unless a dock already ended it. False, and nothing changes, when the entity is + // not a member. The caller deactivates; Remove does not. public bool Remove(Entity entity) { if (!_entities.Remove(entity)) return false; + CargoTransfer.End(entity, GiveEnd.GiverGone); _deathWatches.Remove(entity, out var watch); watch.Dispose(); foreach (var floating in FloatingItems) From 708fbeee5903f1c60188de71cd0342443d4b2c47 Mon Sep 17 00:00:00 2001 From: soul Date: Thu, 22 Oct 2026 21:59:06 +0200 Subject: [PATCH 211/444] Cargo store tests: the bay owns stack quantity, purchases charge units moved, restore drops bad stacks (red) Co-Authored-By: Claude Sonnet 5.5 --- .../CargoTransferTests.cs | 120 ++++++++++++++++++ 1 file changed, 120 insertions(+) diff --git a/tests/Aetheria.Shared.Tests/CargoTransferTests.cs b/tests/Aetheria.Shared.Tests/CargoTransferTests.cs index 26f2332f0..efcac768c 100644 --- a/tests/Aetheria.Shared.Tests/CargoTransferTests.cs +++ b/tests/Aetheria.Shared.Tests/CargoTransferTests.cs @@ -775,4 +775,124 @@ public sealed class CargoTransferTests : IDisposable var stock = Commodity("Ore", 10); // 7 credits each Assert.Equal(expected, StationServices.BuyableQuantity(_items, stock, typed, credits)); } + // ---- The bay owns the quantity that may sit in it --------------------------------------------------------- + + [Theory] + [InlineData("Ore", 0)] + [InlineData("Ore", -3)] + [InlineData("Ore", int.MinValue)] + [InlineData("Grit", 3)] // MaxStack 2 + [InlineData("Ore", 101)] + public void TheBayRefusesAStackBelowOneOrAboveMaxStack(string design, int quantity) + { + var bay = ShipWith("Crate").CargoBays[0]; + var massBefore = bay.Mass; + + Assert.False(bay.TryStore(Commodity(design, quantity))); + Assert.False(bay.TryStore(Commodity(design, quantity), new int2(0, 0))); + + Assert.Empty(bay.Cargo); + Assert.Equal(massBefore, bay.Mass); + } + + [Fact] + public void ANegativeStackNeverMergesIntoAHeldOne() + { + var bay = ShipWith("Crate").CargoBays[0]; + var held = Commodity("Ore", 5); + Assert.True(bay.TryStore(held)); + var at = bay.Cargo[held]; + + Assert.False(bay.TryStore(Commodity("Ore", -3), at)); + + Assert.Equal(5, held.Quantity); + } + + [Fact] + public void AMergeThatOverflowsFillsTheHeldStackToMaxAndLeavesTheRestWithTheCaller() + { + var bay = ShipWith("Crate").CargoBays[0]; + var held = Commodity("Grit", 1); + Assert.True(bay.TryStore(held)); + var incoming = Commodity("Grit", 2); + + Assert.False(bay.TryStore(incoming, bay.Cargo[held])); + + Assert.Equal(2, held.Quantity); + Assert.Equal(1, incoming.Quantity); + } + + [Fact] + public void APurchaseAboveTheStationStockIsRefusedWithoutCharge() + { + // Real bays, Grit stacks hold two: a stock of 3 cannot be bought as 5. Before, the second lot took the 1-unit + // remainder and was charged for 2. + var station = Station().CargoBays[0]; + var player = ShipWith("Crate").CargoBays[0]; + var stock = Commodity("Grit", 2); + Assert.True(station.TryStore(stock)); + var second = Commodity("Grit", 1); + Assert.True(station.TryStore(second, new int2(1, 0))); + var credits = 100; + + var result = StationServices.TryBuyLots(_items, stock, 5, ref credits, lot => station.TryTransferItem(player, stock, lot)); + + Assert.Equal(BuyResult.NoRoom, result); + Assert.Equal(100, credits); + Assert.Equal(0, Units(player, stock)); + } + + [Fact] + public void APurchaseOfAnEmptyStationStackChargesNothingAndDeliversNothing() + { + var station = Station().CargoBays[0]; + var player = ShipWith("Crate").CargoBays[0]; + var stock = Commodity("Ore", 0); + var credits = 100; + + var result = StationServices.TryBuyLots(_items, stock, StationServices.BuyableQuantity(_items, stock, 1, credits), ref credits, + lot => station.TryTransferItem(player, stock, lot)); + + Assert.Equal(BuyResult.NoRoom, result); + Assert.Equal(100, credits); + Assert.Empty(player.Cargo); + } + + [Fact] + public void ThePurchaseOfTheWholeStockChargesTheUnitsTheBayMoved() + { + var station = Station().CargoBays[0]; + var player = ShipWith("Crate").CargoBays[0]; + var stock = Commodity("Grit", 2); + Assert.True(station.TryStore(stock)); + var more = Commodity("Grit", 1); + Assert.True(station.TryStore(more, new int2(1, 0))); + var credits = 100; + + var result = StationServices.TryBuyLots(_items, stock, 2, ref credits, lot => station.TryTransferItem(player, stock, lot)); + + Assert.Equal(BuyResult.Bought, result); + Assert.Equal(98, credits); + Assert.Equal(2, Units(player, stock)); + } + + [Fact] + public void ARestoredBayDropsAStackBelowOneAndLogsWhy() + { + var log = new List(); + var settings = AuthoredSettings.Load(RestoredHullsTests.FindRepoRoot()).Read("GameplaySettings"); + var items = new ItemManager(1u, _cache, new ProvenanceLedger(), settings, log.Add); + var zone = new Zone(items, new PlanetSettings(), new ZonePack(), new GalaxyZone { Name = "Test Zone", Owner = null }, null); + var ship = ShipWith("Crate"); + Assert.True(ship.CargoBays[0].TryStore(Commodity("Slag", 4))); + var pack = (ShipPack) EntitySerializer.Pack(ship); + // The typed -5 purchase of old saves: a negative stack in the hold, beside a good one. + pack.CargoContents[0] = pack.CargoContents[0].Concat(new[] { (new int2(3, 3), (ItemInstance) Commodity("Ore", -5)) }).ToArray(); + + var restored = (Ship) EntitySerializer.Unpack(items, zone, pack); + + var bay = restored.CargoBays[0]; + Assert.Equal(new[] { 4 }, bay.Cargo.Keys.OfType().Select(c => c.Quantity).ToArray()); + Assert.Contains(log, l => l.Contains("-5") && l.Contains("Ore")); + } } From ffe43b41e88fe5d7100f4849498628c7132de1a6 Mon Sep 17 00:00:00 2001 From: soul Date: Thu, 22 Oct 2026 22:01:39 +0200 Subject: [PATCH 212/444] Cargo store tests: the empty-stock purchase test reaches the charge (red) Co-Authored-By: Claude Sonnet 5.5 --- tests/Aetheria.Shared.Tests/CargoTransferTests.cs | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/tests/Aetheria.Shared.Tests/CargoTransferTests.cs b/tests/Aetheria.Shared.Tests/CargoTransferTests.cs index efcac768c..2495ca2b0 100644 --- a/tests/Aetheria.Shared.Tests/CargoTransferTests.cs +++ b/tests/Aetheria.Shared.Tests/CargoTransferTests.cs @@ -845,17 +845,17 @@ public sealed class CargoTransferTests : IDisposable [Fact] public void APurchaseOfAnEmptyStationStackChargesNothingAndDeliversNothing() { - var station = Station().CargoBays[0]; - var player = ShipWith("Crate").CargoBays[0]; + // An old save's zero stack, as the station's stock. The field clamps the typed quantity up to one unit. var stock = Commodity("Ore", 0); var credits = 100; + var delivered = 0; var result = StationServices.TryBuyLots(_items, stock, StationServices.BuyableQuantity(_items, stock, 1, credits), ref credits, - lot => station.TryTransferItem(player, stock, lot)); + lot => { delivered += lot; return true; }); Assert.Equal(BuyResult.NoRoom, result); Assert.Equal(100, credits); - Assert.Empty(player.Cargo); + Assert.Equal(0, delivered); } [Fact] From ddae55759dc49530b505706e86b3b9aeca45b7be Mon Sep 17 00:00:00 2001 From: soul Date: Thu, 22 Oct 2026 22:01:42 +0200 Subject: [PATCH 213/444] Cargo bay owns stack quantity: TryStore refuses below 1 and above MaxStack, TryBuyLots owns the stock limit, restore logs dropped stacks Co-Authored-By: Claude Sonnet 5.5 --- Assets/Scripts/ServerShared/Entity.cs | 3 +++ Assets/Scripts/ServerShared/EntitySerializer.cs | 13 +++++++++++-- Assets/Scripts/ServerShared/StationServices.cs | 9 +++++---- 3 files changed, 19 insertions(+), 6 deletions(-) diff --git a/Assets/Scripts/ServerShared/Entity.cs b/Assets/Scripts/ServerShared/Entity.cs index bc8087228..f98ef8c32 100644 --- a/Assets/Scripts/ServerShared/Entity.cs +++ b/Assets/Scripts/ServerShared/Entity.cs @@ -2530,9 +2530,12 @@ public class EquippedCargoBay : EquippedItem // Try to store the given commodity at the given position // If there's a stack at the given position it will be added to // Returns true only when ALL of the items are successfully stored + // The bay decides what quantity may sit in it: a stack below one unit or above its design's MaxStack is refused with + // the bay as it was, so no caller, loot, pickup or restore has to check for it. public bool TryStore(SimpleCommodity item, int2 cargoCoord) { var itemData = ItemManager.GetData(item); + if (item.Quantity < 1 || item.Quantity > itemData.MaxStack) return false; if (ItemFits(item, cargoCoord)) { foreach (var p in itemData.Shape.Coordinates) diff --git a/Assets/Scripts/ServerShared/EntitySerializer.cs b/Assets/Scripts/ServerShared/EntitySerializer.cs index 7f934b937..dc5b27c90 100644 --- a/Assets/Scripts/ServerShared/EntitySerializer.cs +++ b/Assets/Scripts/ServerShared/EntitySerializer.cs @@ -47,6 +47,15 @@ public static class EntitySerializer return pack; } + // A saved stack goes back where it was. The bay refuses what cannot sit in it (a stack below one unit, as the typed + // -5 purchase of old saves left, or above MaxStack), and the refusal is logged, never silent. + private static void RestoreStack(ItemManager itemManager, EquippedCargoBay bay, int2 position, ItemInstance item) + { + if (bay.TryStore(item, position)) return; + var quantity = item is SimpleCommodity commodity ? $" x{commodity.Quantity}" : ""; + itemManager.Log($"Dropped a saved item the bay refused at {position}: {itemManager.GetData(item).Name}{quantity}"); + } + public static Entity Unpack(ItemManager itemManager, Zone zone, EntityPack pack) { return pack switch @@ -103,12 +112,12 @@ public static class EntitySerializer for (var bayIndex = 0; bayIndex < pack.CargoContents.Length; bayIndex++) if(entity.CargoBays.Count >= bayIndex + 1) foreach (var (position, item) in pack.CargoContents[bayIndex]) - entity.CargoBays[bayIndex].TryStore(item, position); + RestoreStack(itemManager, entity.CargoBays[bayIndex], position, item); for (var bayIndex = 0; bayIndex < pack.DockingBayContents.Length; bayIndex++) if(entity.DockingBays.Count >= bayIndex + 1) foreach (var (position, item) in pack.DockingBayContents[bayIndex]) - entity.DockingBays[bayIndex].TryStore(item, position); + RestoreStack(itemManager, entity.DockingBays[bayIndex], position, item); entity.Temperature = pack.Temperature; diff --git a/Assets/Scripts/ServerShared/StationServices.cs b/Assets/Scripts/ServerShared/StationServices.cs index 433b0baf1..457b57a55 100644 --- a/Assets/Scripts/ServerShared/StationServices.cs +++ b/Assets/Scripts/ServerShared/StationServices.cs @@ -79,12 +79,13 @@ public static class StationServices return BuyResult.Bought; } - // The purchase of `quantity` units of a station stack, one lot of at most MaxStack at a time. `deliver` puts a lot of - // that many units where it goes, and each lot is charged for the units it delivered. The first lot the station - // cannot sell or the player cannot take ends the purchase, with the lots before it bought. + // The purchase of `quantity` units of a station stack, one lot of at most MaxStack at a time. The stock is the + // station's: a purchase of more than the stack holds, or of a stack that holds nothing, is refused before anything + // moves, so every lot is units the station has and `deliver` moves whole, and each lot is charged for the units it + // delivered. The first lot the player cannot take ends the purchase, with the lots before it bought. public static BuyResult TryBuyLots(ItemManager items, SimpleCommodity stock, int quantity, ref int credits, Func deliver) { - if (quantity < 1) return BuyResult.NoRoom; + if (quantity < 1 || quantity > stock.Quantity) return BuyResult.NoRoom; var maxStack = items.GetData(stock).MaxStack; for (var remaining = quantity; remaining > 0;) { From fa5820f516820c0e865cbe9ec9bc9be010893c9b Mon Sep 17 00:00:00 2001 From: soul Date: Thu, 22 Oct 2026 22:15:41 +0200 Subject: [PATCH 214/444] Cargo give: the bay no longer holds a stack below one, so the give's positive-stack filter and its zero-stack test go Co-Authored-By: Claude Sonnet 5.5 --- Assets/Scripts/ServerShared/CargoTransfer.cs | 2 +- tests/Aetheria.Shared.Tests/CargoTransferTests.cs | 5 ++--- 2 files changed, 3 insertions(+), 4 deletions(-) diff --git a/Assets/Scripts/ServerShared/CargoTransfer.cs b/Assets/Scripts/ServerShared/CargoTransfer.cs index 45208d948..32f49b611 100644 --- a/Assets/Scripts/ServerShared/CargoTransfer.cs +++ b/Assets/Scripts/ServerShared/CargoTransfer.cs @@ -110,7 +110,7 @@ public static class CargoTransfer } else if (bay.ItemsOfType.TryGetValue(order.Item.Data.Key, out var stacks)) { - var stack = stacks.OfType().FirstOrDefault(s => s.Quantity > 0); + var stack = stacks.OfType().FirstOrDefault(); if (stack != null) return (bay, stack); } } diff --git a/tests/Aetheria.Shared.Tests/CargoTransferTests.cs b/tests/Aetheria.Shared.Tests/CargoTransferTests.cs index 2495ca2b0..c6c80b398 100644 --- a/tests/Aetheria.Shared.Tests/CargoTransferTests.cs +++ b/tests/Aetheria.Shared.Tests/CargoTransferTests.cs @@ -697,15 +697,14 @@ public sealed class CargoTransferTests : IDisposable Assert.Equal(5, order.Remaining); } - // An empty stack in a hold is no unit to give: the give moves the real units and then has nothing left. + // A give of more than the hold has moves the real units and then has nothing left. [Fact] - public void AnEmptyStackIsNotAUnitToGive() + public void AGiveOfMoreThanTheHoldHasEndsNothingLeft() { var giver = ShipWithBays("Crate"); var receiver = ShipWithBays("Crate"); var grit = Commodity("Grit", 2); Assert.True(giver.CargoBays[0].TryStore(grit)); - Assert.True(giver.CargoBays[0].TryStore(Commodity("Grit", 0))); Place(giver, receiver); giver.Command.Give(receiver, grit, 3); From f7ba043b1358a4a39672edaec4b7591cbb8db397 Mon Sep 17 00:00:00 2001 From: soul Date: Thu, 22 Oct 2026 22:21:08 +0200 Subject: [PATCH 215/444] Cargo restore test: share the fixture's provenance ledger Co-Authored-By: Claude Sonnet 5.5 --- tests/Aetheria.Shared.Tests/CargoTransferTests.cs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/tests/Aetheria.Shared.Tests/CargoTransferTests.cs b/tests/Aetheria.Shared.Tests/CargoTransferTests.cs index c6c80b398..58efc2299 100644 --- a/tests/Aetheria.Shared.Tests/CargoTransferTests.cs +++ b/tests/Aetheria.Shared.Tests/CargoTransferTests.cs @@ -880,7 +880,7 @@ public sealed class CargoTransferTests : IDisposable { var log = new List(); var settings = AuthoredSettings.Load(RestoredHullsTests.FindRepoRoot()).Read("GameplaySettings"); - var items = new ItemManager(1u, _cache, new ProvenanceLedger(), settings, log.Add); + var items = new ItemManager(1u, _cache, _items.Lots, settings, log.Add); var zone = new Zone(items, new PlanetSettings(), new ZonePack(), new GalaxyZone { Name = "Test Zone", Owner = null }, null); var ship = ShipWith("Crate"); Assert.True(ship.CargoBays[0].TryStore(Commodity("Slag", 4))); From fd50f02f1b6224759ff56c30b9f07f943debae8e Mon Sep 17 00:00:00 2001 From: soul Date: Thu, 22 Oct 2026 22:29:24 +0200 Subject: [PATCH 216/444] Cargo purchase tests: a purchase of no units is refused, not bought Co-Authored-By: Claude Sonnet 5.5 --- tests/Aetheria.Shared.Tests/CargoTransferTests.cs | 14 ++++++++++++++ 1 file changed, 14 insertions(+) diff --git a/tests/Aetheria.Shared.Tests/CargoTransferTests.cs b/tests/Aetheria.Shared.Tests/CargoTransferTests.cs index 58efc2299..25245aa90 100644 --- a/tests/Aetheria.Shared.Tests/CargoTransferTests.cs +++ b/tests/Aetheria.Shared.Tests/CargoTransferTests.cs @@ -841,6 +841,20 @@ public sealed class CargoTransferTests : IDisposable Assert.Equal(0, Units(player, stock)); } + [Theory] + [InlineData(-5)] + [InlineData(0)] + public void APurchaseOfNoUnitsIsRefusedNotBought(int quantity) + { + var stock = Commodity("Ore", 10); + var credits = 100; + + var result = StationServices.TryBuyLots(_items, stock, quantity, ref credits, lot => true); + + Assert.Equal(BuyResult.NoRoom, result); + Assert.Equal(100, credits); + } + [Fact] public void APurchaseOfAnEmptyStationStackChargesNothingAndDeliversNothing() { From d66b2a2c3c2717c321070edf6b7af28e5dcdecc4 Mon Sep 17 00:00:00 2001 From: soul Date: Thu, 22 Oct 2026 22:49:22 +0200 Subject: [PATCH 217/444] Cargo restore tests: an oversize saved stack splits into MaxStack stacks and drops only what fits nowhere (red) Co-Authored-By: Claude Sonnet 5.5 --- .../CargoTransferTests.cs | 55 +++++++++++++++++++ 1 file changed, 55 insertions(+) diff --git a/tests/Aetheria.Shared.Tests/CargoTransferTests.cs b/tests/Aetheria.Shared.Tests/CargoTransferTests.cs index 25245aa90..f65606916 100644 --- a/tests/Aetheria.Shared.Tests/CargoTransferTests.cs +++ b/tests/Aetheria.Shared.Tests/CargoTransferTests.cs @@ -908,4 +908,59 @@ public sealed class CargoTransferTests : IDisposable Assert.Equal(new[] { 4 }, bay.Cargo.Keys.OfType().Select(c => c.Quantity).ToArray()); Assert.Contains(log, l => l.Contains("-5") && l.Contains("Ore")); } + + // ---- Saves from before the bay capped a stack ------------------------------------------------------------- + + // A pre-f0d7c12c purchase above MaxStack left a whole oversize stack in the hold. The save holds it as one stack. + private (Ship restored, List log) RestoreWithOversize(string[] bays, int oversize, int fillerPerBay) + { + var log = new List(); + var settings = AuthoredSettings.Load(RestoredHullsTests.FindRepoRoot()).Read("GameplaySettings"); + var items = new ItemManager(1u, _cache, _items.Lots, settings, log.Add); + var zone = new Zone(items, new PlanetSettings(), new ZonePack(), new GalaxyZone { Name = "Test Zone", Owner = null }, null); + var ship = new Ship(_items, _zone, Mint("Skiff"), new EntitySettings()); + for (var i = 0; i < bays.Length; i++) Assert.True(ship.TryEquip(Mint(bays[i]), new int2(1 + 2 * i, 1))); + var pack = (ShipPack) EntitySerializer.Pack(ship); + // Bay 0 holds the oversize stack at (3,3) and `fillerPerBay` full Slag stacks in the other cells; later bays only filler. + for (var b = 0; b < bays.Length; b++) + { + var contents = new List<(int2, ItemInstance)>(); + if (b == 0) contents.Add((new int2(3, 3), Commodity("Ore", oversize))); + for (var n = 0; n < fillerPerBay; n++) contents.Add((new int2(n % 4, n / 4), Commodity("Slag", 100))); + pack.CargoContents[b] = contents.ToArray(); + } + return ((Ship) EntitySerializer.Unpack(items, zone, pack), log); + } + + [Fact] + public void ARestoredOversizeStackSplitsIntoMaxStackStacksInTheSameBay() + { + var (restored, log) = RestoreWithOversize(new[] { "Crate" }, 250, 0); + + var ore = restored.CargoBays[0].Cargo.Keys.OfType().Select(c => c.Quantity).OrderByDescending(q => q).ToArray(); + Assert.Equal(new[] { 100, 100, 50 }, ore); + Assert.DoesNotContain(log, l => l.Contains("Dropped")); + } + + [Fact] + public void ARestoredOversizeStackSpillsToAnotherBayWhenItsOwnIsFull() + { + // Bay 0 has 15 cells of filler and the oversize stack's own cell: only 100 fits there. + var (restored, log) = RestoreWithOversize(new[] { "Crate", "Crate" }, 250, 15); + + Assert.Equal(new[] { 100, 100, 50 }, restored.CargoBays.SelectMany(b => b.Cargo.Keys.OfType()) + .Where(c => _cache.GetByName("Ore").Name == _items.GetData(c).Name).Select(c => c.Quantity).OrderByDescending(q => q).ToArray()); + Assert.Equal(1, restored.CargoBays[0].Cargo.Keys.OfType().Count(c => _items.GetData(c).Name == "Ore")); + Assert.DoesNotContain(log, l => l.Contains("Dropped")); + } + + [Fact] + public void ARestoredOversizeStackDropsOnlyWhatFitsNowhereAndLogsIt() + { + var (restored, log) = RestoreWithOversize(new[] { "Crate" }, 250, 15); + + Assert.Equal(new[] { 100 }, restored.CargoBays[0].Cargo.Keys.OfType() + .Where(c => _items.GetData(c).Name == "Ore").Select(c => c.Quantity).ToArray()); + Assert.Contains(log, l => l.Contains("Ore") && l.Contains("150")); + } } From b027b36ab46f6d2ca7aecdc7968df43f36cf577b Mon Sep 17 00:00:00 2001 From: soul Date: Thu, 22 Oct 2026 22:50:23 +0200 Subject: [PATCH 218/444] Cargo restore: a saved stack above MaxStack splits into MaxStack stacks, the same bay first, then the others; only what fits nowhere is dropped, logged Co-Authored-By: Claude Sonnet 5.5 --- .../Scripts/ServerShared/EntitySerializer.cs | 51 ++++++++++++++++--- 1 file changed, 45 insertions(+), 6 deletions(-) diff --git a/Assets/Scripts/ServerShared/EntitySerializer.cs b/Assets/Scripts/ServerShared/EntitySerializer.cs index dc5b27c90..5b4fb7f3f 100644 --- a/Assets/Scripts/ServerShared/EntitySerializer.cs +++ b/Assets/Scripts/ServerShared/EntitySerializer.cs @@ -47,15 +47,51 @@ public static class EntitySerializer return pack; } - // A saved stack goes back where it was. The bay refuses what cannot sit in it (a stack below one unit, as the typed - // -5 purchase of old saves left, or above MaxStack), and the refusal is logged, never silent. - private static void RestoreStack(ItemManager itemManager, EquippedCargoBay bay, int2 position, ItemInstance item) + // A saved stack goes back where it was. The bay refuses what cannot sit in it, and the refusal is logged, never + // silent: a stack below one unit (the typed -5 purchase of old saves) is dropped. A stack above its design's MaxStack + // (an ordinary purchase before the bay capped stacks, or a catalog MaxStack since lowered) is a legitimate save: its + // first MaxStack units go back where it was and the rest is returned to be placed by SpillOversize once every stack + // has its own cell, so a spill cannot take a cell a later stack was saved in. + private static void RestoreStack(ItemManager itemManager, EquippedCargoBay bay, int2 position, ItemInstance item, + List<(EquippedCargoBay home, SimpleCommodity rest)> spill) { + if (item is SimpleCommodity commodity && commodity.Quantity > itemManager.GetData(commodity).MaxStack + && itemManager.GetData(commodity).MaxStack >= 1) + { + var maxStack = itemManager.GetData(commodity).MaxStack; + var first = new SimpleCommodity { Data = commodity.Data, Rotation = commodity.Rotation, Quantity = maxStack }; + var rest = new SimpleCommodity { Data = commodity.Data, Rotation = commodity.Rotation, Quantity = commodity.Quantity - maxStack }; + if (!bay.TryStore(first, position)) rest.Quantity += first.Quantity; + spill.Add((bay, rest)); + return; + } if (bay.TryStore(item, position)) return; - var quantity = item is SimpleCommodity commodity ? $" x{commodity.Quantity}" : ""; + var quantity = item is SimpleCommodity c ? $" x{c.Quantity}" : ""; itemManager.Log($"Dropped a saved item the bay refused at {position}: {itemManager.GetData(item).Name}{quantity}"); } + // Places the units an oversize saved stack left over, in stacks of at most MaxStack: the stack's own bay first, then + // the entity's other bays. Only the units that fit nowhere are dropped, logged with their design and quantity. + private static void SpillOversize(ItemManager itemManager, IReadOnlyList bays, + List<(EquippedCargoBay home, SimpleCommodity rest)> spill) + { + foreach (var (home, rest) in spill) + { + var maxStack = itemManager.GetData(rest).MaxStack; + var remaining = rest.Quantity; + while (remaining > 0) + { + var chunk = new SimpleCommodity { Data = rest.Data, Rotation = rest.Rotation, Quantity = Math.Min(remaining, maxStack) }; + var placed = home.TryStore(chunk) || bays.Where(b => b != home).Any(b => b.TryStore(chunk)); + // A refused store may still have topped up a stack; what is left in the chunk found no room. + remaining -= Math.Min(remaining, maxStack) - (placed ? 0 : chunk.Quantity); + if (placed) continue; + itemManager.Log($"Dropped saved units no bay has room for: {itemManager.GetData(rest).Name} x{remaining}"); + break; + } + } + } + public static Entity Unpack(ItemManager itemManager, Zone zone, EntityPack pack) { return pack switch @@ -109,15 +145,18 @@ public static class EntitySerializer entity.DockingBays[i].DockedShip = entity.Children[pack.DockingBayAssignments[i]] as Ship; } + var spill = new List<(EquippedCargoBay home, SimpleCommodity rest)>(); for (var bayIndex = 0; bayIndex < pack.CargoContents.Length; bayIndex++) if(entity.CargoBays.Count >= bayIndex + 1) foreach (var (position, item) in pack.CargoContents[bayIndex]) - RestoreStack(itemManager, entity.CargoBays[bayIndex], position, item); + RestoreStack(itemManager, entity.CargoBays[bayIndex], position, item, spill); for (var bayIndex = 0; bayIndex < pack.DockingBayContents.Length; bayIndex++) if(entity.DockingBays.Count >= bayIndex + 1) foreach (var (position, item) in pack.DockingBayContents[bayIndex]) - RestoreStack(itemManager, entity.DockingBays[bayIndex], position, item); + RestoreStack(itemManager, entity.DockingBays[bayIndex], position, item, spill); + + SpillOversize(itemManager, entity.CargoBays.Cast().Concat(entity.DockingBays).ToList(), spill); entity.Temperature = pack.Temperature; From 9958fe55ab64c45632e216e7261155a58c0abc13 Mon Sep 17 00:00:00 2001 From: soul Date: Thu, 22 Oct 2026 22:50:24 +0200 Subject: [PATCH 219/444] Cargo purchase: a bay stack is one lot, so the lot loop is deleted; stock above MaxStack from outside a bay is refused up front Co-Authored-By: Claude Sonnet 5.5 --- .../Scripts/ServerShared/StationServices.cs | 20 ++++++------------ .../CargoTransferTests.cs | 21 ++++++++++--------- 2 files changed, 17 insertions(+), 24 deletions(-) diff --git a/Assets/Scripts/ServerShared/StationServices.cs b/Assets/Scripts/ServerShared/StationServices.cs index 457b57a55..153fc595d 100644 --- a/Assets/Scripts/ServerShared/StationServices.cs +++ b/Assets/Scripts/ServerShared/StationServices.cs @@ -79,22 +79,14 @@ public static class StationServices return BuyResult.Bought; } - // The purchase of `quantity` units of a station stack, one lot of at most MaxStack at a time. The stock is the - // station's: a purchase of more than the stack holds, or of a stack that holds nothing, is refused before anything - // moves, so every lot is units the station has and `deliver` moves whole, and each lot is charged for the units it - // delivered. The first lot the player cannot take ends the purchase, with the lots before it bought. + // The purchase of `quantity` units of a station stack, delivered as one lot. A bay holds no stack above its + // design's MaxStack, so a stack the station holds is a lot already; a stack above it comes from outside a bay and is + // refused, as is a purchase of more than the stack holds or of a stack that holds nothing. All of that is refused + // before anything moves, so `deliver` moves units the station has and the charge is for the units it delivered. public static BuyResult TryBuyLots(ItemManager items, SimpleCommodity stock, int quantity, ref int credits, Func deliver) { - if (quantity < 1 || quantity > stock.Quantity) return BuyResult.NoRoom; - var maxStack = items.GetData(stock).MaxStack; - for (var remaining = quantity; remaining > 0;) - { - var lot = Math.Min(remaining, maxStack); - var result = TryBuy(items, stock, lot, ref credits, () => deliver(lot)); - if (result != BuyResult.Bought) return result; - remaining -= lot; - } - return BuyResult.Bought; + if (quantity < 1 || quantity > stock.Quantity || stock.Quantity > items.GetData(stock).MaxStack) return BuyResult.NoRoom; + return TryBuy(items, stock, quantity, ref credits, () => deliver(quantity)); } // The quantity a Buy Quantity field may hold: what was typed, at most what the credits cover and what the station diff --git a/tests/Aetheria.Shared.Tests/CargoTransferTests.cs b/tests/Aetheria.Shared.Tests/CargoTransferTests.cs index f65606916..a08df266c 100644 --- a/tests/Aetheria.Shared.Tests/CargoTransferTests.cs +++ b/tests/Aetheria.Shared.Tests/CargoTransferTests.cs @@ -735,31 +735,32 @@ public sealed class CargoTransferTests : IDisposable // ---- The lot a purchase delivers --------------------------------------------------------------------------- [Fact] - public void APurchaseChargesForEachLotItDelivers() + public void APurchaseOfAStackIsOneLotChargedForWhatItDelivers() { - // Grit stacks hold two. Five units are three lots, and each lot is charged for what it delivers. - var stock = Commodity("Grit", 5); + // Grit stacks hold two: a station stack of two is one lot. + var stock = Commodity("Grit", 2); var credits = 100; var delivered = new List(); - var result = StationServices.TryBuyLots(_items, stock, 5, ref credits, lot => { delivered.Add(lot); return true; }); + var result = StationServices.TryBuyLots(_items, stock, 2, ref credits, lot => { delivered.Add(lot); return true; }); Assert.Equal(BuyResult.Bought, result); - Assert.Equal(new[] { 2, 2, 1 }, delivered); - Assert.Equal(100 - 5, credits); + Assert.Equal(new[] { 2 }, delivered); + Assert.Equal(100 - 2, credits); } [Fact] - public void ARefusedLotLeavesEarlierLotsBoughtAndChargesNothingMore() + public void AStackAboveMaxStackFromOutsideABayIsRefusedBeforeAnythingMoves() { var stock = Commodity("Grit", 5); var credits = 100; - var lots = 0; + var delivered = 0; - var result = StationServices.TryBuyLots(_items, stock, 5, ref credits, lot => ++lots < 2); + var result = StationServices.TryBuyLots(_items, stock, 2, ref credits, lot => { delivered++; return true; }); Assert.Equal(BuyResult.NoRoom, result); - Assert.Equal(100 - 2, credits); + Assert.Equal(0, delivered); + Assert.Equal(100, credits); } [Theory] From c986adcf20479284cdb0d376f09b3266931d14c8 Mon Sep 17 00:00:00 2001 From: soul Date: Thu, 22 Oct 2026 22:54:52 +0200 Subject: [PATCH 220/444] Cargo restore tests: only the oversize stack's own bay is full, so the spill has another bay to go to Co-Authored-By: Claude Sonnet 5.5 --- tests/Aetheria.Shared.Tests/CargoTransferTests.cs | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/tests/Aetheria.Shared.Tests/CargoTransferTests.cs b/tests/Aetheria.Shared.Tests/CargoTransferTests.cs index a08df266c..d057f004c 100644 --- a/tests/Aetheria.Shared.Tests/CargoTransferTests.cs +++ b/tests/Aetheria.Shared.Tests/CargoTransferTests.cs @@ -913,7 +913,7 @@ public sealed class CargoTransferTests : IDisposable // ---- Saves from before the bay capped a stack ------------------------------------------------------------- // A pre-f0d7c12c purchase above MaxStack left a whole oversize stack in the hold. The save holds it as one stack. - private (Ship restored, List log) RestoreWithOversize(string[] bays, int oversize, int fillerPerBay) + private (Ship restored, List log) RestoreWithOversize(string[] bays, int oversize, int filler) { var log = new List(); var settings = AuthoredSettings.Load(RestoredHullsTests.FindRepoRoot()).Read("GameplaySettings"); @@ -922,12 +922,12 @@ public sealed class CargoTransferTests : IDisposable var ship = new Ship(_items, _zone, Mint("Skiff"), new EntitySettings()); for (var i = 0; i < bays.Length; i++) Assert.True(ship.TryEquip(Mint(bays[i]), new int2(1 + 2 * i, 1))); var pack = (ShipPack) EntitySerializer.Pack(ship); - // Bay 0 holds the oversize stack at (3,3) and `fillerPerBay` full Slag stacks in the other cells; later bays only filler. + // Bay 0 holds the oversize stack at (3,3) and `filler` full Slag stacks in the other cells; later bays are empty. for (var b = 0; b < bays.Length; b++) { var contents = new List<(int2, ItemInstance)>(); if (b == 0) contents.Add((new int2(3, 3), Commodity("Ore", oversize))); - for (var n = 0; n < fillerPerBay; n++) contents.Add((new int2(n % 4, n / 4), Commodity("Slag", 100))); + for (var n = 0; n < (b == 0 ? filler : 0); n++) contents.Add((new int2(n % 4, n / 4), Commodity("Slag", 100))); pack.CargoContents[b] = contents.ToArray(); } return ((Ship) EntitySerializer.Unpack(items, zone, pack), log); From 8b0b50382a56a86becf6b350821f83f56f2ac281 Mon Sep 17 00:00:00 2001 From: soul Date: Thu, 22 Oct 2026 20:23:17 +0200 Subject: [PATCH 221/444] WeaponData: drop the dead [Union] list; BehaviorData's union owns behavior arms Co-Authored-By: Claude Sonnet 5.5 --- Assets/Scripts/ServerShared/Behaviors/Weapon.cs | 7 +------ 1 file changed, 1 insertion(+), 6 deletions(-) diff --git a/Assets/Scripts/ServerShared/Behaviors/Weapon.cs b/Assets/Scripts/ServerShared/Behaviors/Weapon.cs index 12166d46c..cb42d6bd4 100644 --- a/Assets/Scripts/ServerShared/Behaviors/Weapon.cs +++ b/Assets/Scripts/ServerShared/Behaviors/Weapon.cs @@ -11,12 +11,7 @@ using Newtonsoft.Json; using CultMath; using static CultMath.math; -[Inspectable, - Union(0, typeof(InstantWeaponData)), - Union(1, typeof(LauncherData)), - Union(2, typeof(ConstantWeaponData)), - Union(3, typeof(ChargedWeaponData)), - RuntimeInspectable] +[Inspectable, RuntimeInspectable] public abstract class WeaponData : BehaviorData { [Inspectable, JsonProperty("damageType"), Key(1), RuntimeInspectable] From 1dce24a226277d8570a3a40eb17d282fd04c746d Mon Sep 17 00:00:00 2001 From: soul Date: Thu, 22 Oct 2026 20:23:23 +0200 Subject: [PATCH 222/444] Pin CultLib 2.0.0 (42dce361) and CultMath 0.5.0 (7a8933b0): manifest tags and build revisions Co-Authored-By: Claude Sonnet 5.5 --- Directory.Build.props | 8 ++++---- Packages/manifest.json | 6 +++--- 2 files changed, 7 insertions(+), 7 deletions(-) diff --git a/Directory.Build.props b/Directory.Build.props index 190e89780..e17a8e5d5 100644 --- a/Directory.Build.props +++ b/Directory.Build.props @@ -2,12 +2,12 @@ $(CULTLIB_ROOT) $(MSBuildThisFileDirectory)..\CultLib - - 45c2f4006f5f7dab357426cabd3197e8d0a17f66 + + 42dce3611a81adc5c5fac54cadbaa5637d6022d3 $(CULTMATH_ROOT) $(CultLibRoot) - - 9c1b06cd703bfa44352050e819b0d0659cf5978a + + 7a8933b085468e1fc6f9d3bce4a85e7d101aaca3 C:\Program Files\Git\cmd\git.exe git diff --git a/Packages/manifest.json b/Packages/manifest.json index 2dd2a3bb3..d4dafcbf2 100644 --- a/Packages/manifest.json +++ b/Packages/manifest.json @@ -52,8 +52,8 @@ "com.unity.modules.vr": "1.0.0", "com.unity.modules.wind": "1.0.0", "com.unity.modules.xr": "1.0.0", - "org.gamecult.caching.unity": "https://github.com/GameCult/CultLib.git?path=/src/GameCult.Unity/Assets/Caching#caching-unity-v1.4.0", - "org.gamecult.cultlib": "https://github.com/GameCult/CultLib.git?path=/unity/org.gamecult.cultlib#cultlib-unity-v1.0.60", - "org.gamecult.cultmath": "https://github.com/GameCult/CultLib.git?path=/packages/cultmath/unity/org.gamecult.cultmath#cultmath-unity-v0.4.1" + "org.gamecult.caching.unity": "https://github.com/GameCult/CultLib.git?path=/src/GameCult.Unity/Assets/Caching#caching-unity-v2.0.0", + "org.gamecult.cultlib": "https://github.com/GameCult/CultLib.git?path=/unity/org.gamecult.cultlib#cultlib-unity-v2.0.0", + "org.gamecult.cultmath": "https://github.com/GameCult/CultLib.git?path=/packages/cultmath/unity/org.gamecult.cultmath#cultmath-unity-v0.5.0" } } From bd0293d6836e43f4a26a7d9c6021b0901a401ead Mon Sep 17 00:00:00 2001 From: soul Date: Thu, 22 Oct 2026 20:23:27 +0200 Subject: [PATCH 223/444] BehaviorData: LockWeaponData is arm 52; union-closure and null-gear tests Co-Authored-By: Claude Sonnet 5.5 --- .../ServerShared/Behaviors/Behaviors.cs | 1 + .../CultLibAdoptionTests.cs | 38 +++++++++++++++++++ 2 files changed, 39 insertions(+) create mode 100644 tests/Aetheria.Shared.Tests/CultLibAdoptionTests.cs diff --git a/Assets/Scripts/ServerShared/Behaviors/Behaviors.cs b/Assets/Scripts/ServerShared/Behaviors/Behaviors.cs index 7150e47a1..6b899ee35 100644 --- a/Assets/Scripts/ServerShared/Behaviors/Behaviors.cs +++ b/Assets/Scripts/ServerShared/Behaviors/Behaviors.cs @@ -198,6 +198,7 @@ public interface IPowerConsumer Union(49, typeof(LaunchSuppressorData)), Union(50, typeof(ListenerData)), Union(51, typeof(ScannerData)), + Union(52, typeof(LockWeaponData)), JsonObject(MemberSerialization.OptIn)] public abstract class BehaviorData { diff --git a/tests/Aetheria.Shared.Tests/CultLibAdoptionTests.cs b/tests/Aetheria.Shared.Tests/CultLibAdoptionTests.cs new file mode 100644 index 000000000..bfe379d36 --- /dev/null +++ b/tests/Aetheria.Shared.Tests/CultLibAdoptionTests.cs @@ -0,0 +1,38 @@ +using System; +using System.IO; +using System.Linq; +using System.Reflection; +using GameCult.Caching; +using GameCult.Caching.MessagePack; +using MessagePack; +using Xunit; + +// CultLib 2.0 refuses a union subtype no arm names (it used to write nil). These keep that refusal a build-time failure. +public sealed class CultLibAdoptionTests +{ + [Fact] + public void EveryConcreteSubtypeOfAUnionBaseIsAnArmOfIt() + { + var types = typeof(BehaviorData).Assembly.GetTypes(); + var missing = new System.Collections.Generic.List(); + foreach (var root in types) + { + var arms = root.GetCustomAttributes(false).Select(u => u.SubType).ToHashSet(); + if (arms.Count == 0) continue; + foreach (var subtype in types.Where(t => t != root && !t.IsAbstract && !t.IsInterface && root.IsAssignableFrom(t))) + if (!arms.Contains(subtype)) missing.Add($"{subtype.Name} is not an arm of {root.Name}"); + } + Assert.Empty(missing); + } + + [Fact] + public void NoShippedGearHoldsANullBehavior() + { + using var cache = new CultCache(TestCatalog.Registry()); + cache.AddBackingStore(new SingleFileMessagePackBackingStore(TestCatalog.Repo, true), AetheriaStores.CatalogTypes); + var gear = cache.GetAll().ToArray(); + Assert.NotEmpty(gear); + var bad = gear.Where(g => g.Behaviors != null && g.Behaviors.Any(b => b == null)).Select(g => g.Name).ToArray(); + Assert.Empty(bad); + } +} From fb8f2ea2fb7211563b3e491afb69ba32199782e5 Mon Sep 17 00:00:00 2001 From: soul Date: Thu, 22 Oct 2026 20:23:32 +0200 Subject: [PATCH 224/444] ShipAuthoring: Load follows the pull's InvalidDataException, RefuseLegacyEmbeddedHull the nil-stamp NotSupportedException Co-Authored-By: Claude Sonnet 5.5 --- Assets/Scripts/ServerShared/ShipAuthoring.cs | 4 ++-- .../ShipAuthoringTests.cs | 23 ++++++++++++++++++- 2 files changed, 24 insertions(+), 3 deletions(-) diff --git a/Assets/Scripts/ServerShared/ShipAuthoring.cs b/Assets/Scripts/ServerShared/ShipAuthoring.cs index eafbacd2d..2f6db9e5c 100644 --- a/Assets/Scripts/ServerShared/ShipAuthoring.cs +++ b/Assets/Scripts/ServerShared/ShipAuthoring.cs @@ -136,7 +136,7 @@ public static class ShipAuthoringStore { RefuseLegacyEmbeddedHull(path); try { return LoadRecords(path); } - catch (MessagePackSerializationException error) + catch (Exception error) when (error is InvalidDataException or MessagePackSerializationException) { throw new InvalidOperationException($"{path}: a record does not decode as its schema: {error.Message}", error); } @@ -152,7 +152,7 @@ public static class ShipAuthoringStore if (bytes.Length == 0) return; CultPersistedStoreSnapshot snapshot; try { snapshot = CultDocumentMessagePackSerialization.DeserializeSnapshot(bytes); } - catch (MessagePackSerializationException error) + catch (Exception error) when (error is MessagePackSerializationException or NotSupportedException) { throw new InvalidOperationException($"{path}: not a readable ship file: {error.Message}", error); } diff --git a/tests/Aetheria.Shared.Tests/ShipAuthoringTests.cs b/tests/Aetheria.Shared.Tests/ShipAuthoringTests.cs index eced4c6f7..35a346fe0 100644 --- a/tests/Aetheria.Shared.Tests/ShipAuthoringTests.cs +++ b/tests/Aetheria.Shared.Tests/ShipAuthoringTests.cs @@ -173,7 +173,28 @@ public sealed class ShipAuthoringTests RewritePayload(path, "aetheria.hulldata", slots => ((object[])((object[])slots[5])[0])[2] = new object[] { 5, false, true, false }); var error = Assert.Throws(() => ShipAuthoringStore.Load(path)); Assert.Contains(path, error.Message); - Assert.IsAssignableFrom(error.InnerException); + // The pull names the record key and schema; the MessagePack failure is its cause. + var pull = Assert.IsType(error.InnerException); + Assert.Contains("aetheria.hulldata", pull.Message); + Assert.Contains(ShipModCatalog.HullKey("mod.skiff").ToString(), pull.Message); + Assert.IsAssignableFrom(pull.InnerException); + } + + // A store header with a nil format stamp is refused by the runtime with NotSupportedException; Load names the file. + [Fact] + public void LoadNamesTheFileWhoseFormatStampIsNil() + { + using var directory = new TempDirectory(); + var path = Path.Combine(directory.Path, "nilstamp.cc"); + File.WriteAllBytes(path, CultDocumentMessagePackSerialization.SerializeSnapshot(new CultPersistedStoreSnapshot + { + FormatVersion = null, + SchemaCatalog = Array.Empty(), + Records = Array.Empty(), + })); + var error = Assert.Throws(() => ShipAuthoringStore.Load(path)); + Assert.Contains(path, error.Message); + Assert.IsType(error.InnerException); } // Decodes one record's payload of the named schema to its slot array, lets the edit change it, and stores it again. From fbf88e85c514daaeba06b1b8f18abe704810b3ef Mon Sep 17 00:00:00 2001 From: soul Date: Thu, 22 Oct 2026 20:23:37 +0200 Subject: [PATCH 225/444] ship_cc.read owns its unreadable-path refusal, naming no path Co-Authored-By: Claude Sonnet 5.5 --- tools/blender/aetheria_ships/ship_cc.py | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/tools/blender/aetheria_ships/ship_cc.py b/tools/blender/aetheria_ships/ship_cc.py index dd3ca78be..b7dca307c 100644 --- a/tools/blender/aetheria_ships/ship_cc.py +++ b/tools/blender/aetheria_ships/ship_cc.py @@ -119,7 +119,10 @@ class ShipFile(NamedTuple): def read(path: str, cultlib_packages: str) -> ShipFile: cultcache, msgpack = _libraries(cultlib_packages) store = cultcache.SingleFileMessagePackBackingStore(path) - envelopes = store.pull_all() + try: + envelopes = store.pull_all() + except OSError as error: + raise ValueError(f"The ship file cannot be read: {error.strerror}") from None ships = [envelope for envelope in envelopes if envelope.type == SCHEMA] hulls = [envelope for envelope in envelopes if envelope.type == HULL_SCHEMA] # Before S1 the hull lived inside the ship record. Saving such a file would preserve the stale hull under a ship that From e518a0b3d161a05029f86d6973d1186d6f9afead Mon Sep 17 00:00:00 2001 From: soul Date: Thu, 22 Oct 2026 20:37:40 +0200 Subject: [PATCH 226/444] packages-lock: regenerated by Unity at the 2.0.0 / 0.5.0 tags Co-Authored-By: Claude Sonnet 5.5 --- Packages/packages-lock.json | 18 ++++++++++-------- 1 file changed, 10 insertions(+), 8 deletions(-) diff --git a/Packages/packages-lock.json b/Packages/packages-lock.json index 5d26bd587..7345ecc47 100644 --- a/Packages/packages-lock.json +++ b/Packages/packages-lock.json @@ -256,27 +256,29 @@ "url": "https://packages.unity.com" }, "org.gamecult.caching.unity": { - "version": "https://github.com/GameCult/CultLib.git?path=/src/GameCult.Unity/Assets/Caching#caching-unity-v1.4.0", + "version": "https://github.com/GameCult/CultLib.git?path=/src/GameCult.Unity/Assets/Caching#caching-unity-v2.0.0", "depth": 0, "source": "git", "dependencies": { - "org.gamecult.cultlib": "1.0.60" + "org.gamecult.cultlib": "2.0.0" }, - "hash": "45c2f4006f5f7dab357426cabd3197e8d0a17f66" + "hash": "42dce3611a81adc5c5fac54cadbaa5637d6022d3" }, "org.gamecult.cultlib": { - "version": "https://github.com/GameCult/CultLib.git?path=/unity/org.gamecult.cultlib#cultlib-unity-v1.0.60", + "version": "https://github.com/GameCult/CultLib.git?path=/unity/org.gamecult.cultlib#cultlib-unity-v2.0.0", "depth": 0, "source": "git", - "dependencies": {}, - "hash": "45c2f4006f5f7dab357426cabd3197e8d0a17f66" + "dependencies": { + "org.gamecult.cultmath": "0.5.0" + }, + "hash": "42dce3611a81adc5c5fac54cadbaa5637d6022d3" }, "org.gamecult.cultmath": { - "version": "https://github.com/GameCult/CultLib.git?path=/packages/cultmath/unity/org.gamecult.cultmath#cultmath-unity-v0.4.1", + "version": "https://github.com/GameCult/CultLib.git?path=/packages/cultmath/unity/org.gamecult.cultmath#cultmath-unity-v0.5.0", "depth": 0, "source": "git", "dependencies": {}, - "hash": "9c1b06cd703bfa44352050e819b0d0659cf5978a" + "hash": "7a8933b085468e1fc6f9d3bce4a85e7d101aaca3" }, "com.unity.modules.accessibility": { "version": "1.0.0", From f448957f3ad1d1de2c9a96477acacb53047683da Mon Sep 17 00:00:00 2001 From: soul Date: Thu, 22 Oct 2026 20:39:29 +0200 Subject: [PATCH 227/444] Brush: name AnimatedSimplexBrush and RadialWaveBrush as arms; pull-failure test asserts the record key Co-Authored-By: Claude Sonnet 5.5 --- Assets/Scripts/ServerShared/Environment.cs | 2 ++ tests/Aetheria.Shared.Tests/ShipAuthoringTests.cs | 4 ++-- 2 files changed, 4 insertions(+), 2 deletions(-) diff --git a/Assets/Scripts/ServerShared/Environment.cs b/Assets/Scripts/ServerShared/Environment.cs index 737033e4e..9d4994fa3 100644 --- a/Assets/Scripts/ServerShared/Environment.cs +++ b/Assets/Scripts/ServerShared/Environment.cs @@ -72,6 +72,8 @@ public class NoiseSettings [Union(0, typeof(PowerBrush)), Union(1, typeof(SimplexBrush)), + Union(2, typeof(AnimatedSimplexBrush)), + Union(3, typeof(RadialWaveBrush)), Serializable, MessagePackObject, JsonObject(MemberSerialization.OptIn)] public abstract class Brush { diff --git a/tests/Aetheria.Shared.Tests/ShipAuthoringTests.cs b/tests/Aetheria.Shared.Tests/ShipAuthoringTests.cs index 35a346fe0..cb4d12d5f 100644 --- a/tests/Aetheria.Shared.Tests/ShipAuthoringTests.cs +++ b/tests/Aetheria.Shared.Tests/ShipAuthoringTests.cs @@ -175,8 +175,8 @@ public sealed class ShipAuthoringTests Assert.Contains(path, error.Message); // The pull names the record key and schema; the MessagePack failure is its cause. var pull = Assert.IsType(error.InnerException); - Assert.Contains("aetheria.hulldata", pull.Message); - Assert.Contains(ShipModCatalog.HullKey("mod.skiff").ToString(), pull.Message); + Assert.Contains("Record '" + ShipModCatalog.HullKey("mod.skiff") + "'", pull.Message); + Assert.Contains("schema", pull.Message); Assert.IsAssignableFrom(pull.InnerException); } From da5ba653cb78cb0ba2029ba83b924db7b455bf60 Mon Sep 17 00:00:00 2001 From: soul Date: Thu, 22 Oct 2026 23:34:08 +0200 Subject: [PATCH 228/444] Tests (red at 42dce361): a CultLib 1.0.60 run.cc opens; Load names a ship file of a newer store format tests/vectors/cultlib-1.0.60/run.cc is the run store Soul wrote with the base build (cl2-old-run-save-refused). Both new tests fail at the 2.0.0 pin; the first needs 2.0.1, the second needs ShipAuthoringStore.Load. Co-Authored-By: Claude Sonnet 5.5 --- .../RunSaveCompatibilityTests.cs | 33 ++++++++++++++++++ .../ShipAuthoringTests.cs | 19 ++++++++++ tests/vectors/cultlib-1.0.60/run.cc | Bin 0 -> 53949 bytes 3 files changed, 52 insertions(+) create mode 100644 tests/Aetheria.Shared.Tests/RunSaveCompatibilityTests.cs create mode 100644 tests/vectors/cultlib-1.0.60/run.cc diff --git a/tests/Aetheria.Shared.Tests/RunSaveCompatibilityTests.cs b/tests/Aetheria.Shared.Tests/RunSaveCompatibilityTests.cs new file mode 100644 index 000000000..8b8c98217 --- /dev/null +++ b/tests/Aetheria.Shared.Tests/RunSaveCompatibilityTests.cs @@ -0,0 +1,33 @@ +using System; +using System.IO; +using System.Linq; +using GameCult.Caching; +using GameCult.Caching.MessagePack; +using Xunit; + +// tests/vectors/cultlib-1.0.60/run.cc is a run.cc written by Aetheria on CultLib 1.0.60 (64 zones, factions, one entity). +// A player's save outlives the runtime that wrote it, so the current runtime must open it as compatible drift. +public sealed class RunSaveCompatibilityTests : IDisposable +{ + private readonly string _root = Path.Combine(Path.GetTempPath(), "aetheria-runcompat-" + Guid.NewGuid().ToString("N")); + + public RunSaveCompatibilityTests() => Directory.CreateDirectory(_root); + + public void Dispose() => Directory.Delete(_root, true); + + [Fact] + public void ARunWrittenByCultLib1060OpensWithEveryZoneAndFactionResolving() + { + var run = Path.Combine(_root, "run.cc"); + File.Copy(Path.Combine(RunStartTests.FindRepoRoot(), "tests", "vectors", "cultlib-1.0.60", "run.cc"), run); + using var cache = new CultCache(TestCatalog.Registry()); + cache.AddBackingStore(new SingleFileMessagePackBackingStore(TestCatalog.Repo, true), AetheriaStores.CatalogTypes); + cache.AddBackingStore(new SingleFileMessagePackBackingStore(run), AetheriaStores.RunTypes); + var saved = cache.GetGlobal(); + Assert.NotNull(saved); + Assert.NotEmpty(saved.Zones); + Assert.NotEmpty(saved.Factions); + Assert.All(saved.Zones, zone => Assert.NotNull(cache.Get(zone))); + Assert.All(saved.Factions, faction => Assert.NotNull(cache.Get(faction))); + } +} diff --git a/tests/Aetheria.Shared.Tests/ShipAuthoringTests.cs b/tests/Aetheria.Shared.Tests/ShipAuthoringTests.cs index cb4d12d5f..850e83dfd 100644 --- a/tests/Aetheria.Shared.Tests/ShipAuthoringTests.cs +++ b/tests/Aetheria.Shared.Tests/ShipAuthoringTests.cs @@ -197,6 +197,25 @@ public sealed class ShipAuthoringTests Assert.IsType(error.InnerException); } + // A store format this runtime does not know is refused on read with NotSupportedException from the attach; Load names the file. + [Fact] + public async Task LoadNamesTheFileWhoseStoreFormatIsNewer() + { + using var directory = new TempDirectory(); + var path = Path.Combine(directory.Path, "newer.cc"); + using (var cache = ShipAuthoringStore.Open(path, writable: true)) + { + ShipAuthoringStore.Write(cache, Fixture().Hull, Fixture().Visual); + await cache.FlushAsync(); + } + var snapshot = CultDocumentMessagePackSerialization.DeserializeSnapshot(File.ReadAllBytes(path)); + snapshot.FormatVersion = "cultcache.store.v9"; + File.WriteAllBytes(path, CultDocumentMessagePackSerialization.SerializeSnapshot(snapshot)); + var error = Assert.Throws(() => ShipAuthoringStore.Load(path)); + Assert.Contains(path, error.Message); + Assert.IsType(error.InnerException); + } + // Decodes one record's payload of the named schema to its slot array, lets the edit change it, and stores it again. private static void RewritePayload(string path, string schemaName, Action edit) { diff --git a/tests/vectors/cultlib-1.0.60/run.cc b/tests/vectors/cultlib-1.0.60/run.cc new file mode 100644 index 0000000000000000000000000000000000000000..fc2a4e76e119257e70ccafe82b3916de79159aef GIT binary patch literal 53949 zcmeHw36vbwb#~99(aIvgpbe?D00OB*?TeOfjYbjzBP2^QI5u8tc_lT>OpERrK*Vtf z1nl_7Sn3%Xfw95Akr)ILn;iqOVIsiBh2z9oFaxqpaDH&GgK@$k@%AgPz3;wTeUF;Gojs;*?jXyuJ?RWtb~ShV?L!x2cj&w%FXwqy z77alr8sSWvS2$iKL^f4P7Ytq$Ox5BzlV=T9BbsIMGA9bQuG%Oq5|IJt|%Ef%_zWU{GrH_CCi)H`5Hp>zK+;OL^ja`EQBEaozb26Z`_lBWw6|zG!xHVjdO)?{ zQ@v5oW&@%<_JQ6`Qcl>|u&;~~(Whh!$)#rg2@f}ng+W1&_$67FSBEr-I>;V#E*S^Jdxnzf3nBdGDfS$ z+_ksqUJB9&OZa*C@Zd9pdz<&UkUMm44Y&w^`I*Cy`bvtAvWExvw%kiW(|?AGm3Rj3 zwBGN7*ifoS!}x>OGtYd>r?UzafvyQ(uZAqu-eB@m$CmWf>P&e&RHtNORx3UA>9B;U z0w&ceO?x6-spd0Ic&v()sMhW&k5o+Bp=HPDxGIyi(DSG6FHE9GD&~%RKhV@g$>wy~ z6b#emOj$83TO=IeY+e^Qm572a@PZ<6HfQjrDO;MQ3LLRy!iT{INd9 z5C#QBKDv&+S%(JxKSehVpl~fjvra7sUov`frE&Uk0DPNw=-Ky_lt5KqpSqSHXX@6majsZ9{4|t=dn!IV*=k{W?TdfC6xcX2TGt0yX1cGc@ zBBm{@dP%eup71PZ36`vJx+p;iNU*#uT{ktwk}Z>y3ilT}FQ7nPyEY8+6NhYeHLI%Cua$q<) z5*eo zon%7lqM{SgCbFnnmTB0ErW=wb={(OvStOipDvE6hMALZSfFu#i)gN_{g0P!}EBwgD1B)c3+YE4~E~OVCyv9OSv(m*2{%5Rfd%$Nzr9PFo>o~yd)SJRx$)p z5}~)qvWda1ss^W+8fUVSXxO&R3ARL7O*VU|EXz^5mKNM}O?QX= zLp6L7Tj`6@Dv9y+#Z(#i>-B=;J(z_xXf0M~6s3W+wjtd`%Gg@Z2n~<-ahGD98(xtM_bi6G^Yq+B~Lp>n0Z-a@{ zaC($#^q|AAD)t^y6*$n>=;oE%GwI&$@PZN+{?eJ?&Q{XX1Mdq3*J zDTv`pRlptHjS|j*iq!53=QVm}4VHe3Z1GmYDQ1-4Zxn@r;&fqlTQ52g%OVv+Y)v}d zNp$Kz8vmZ;0-V@FI^C7a?2gpVP)8PVD}1+6+zJ4o0|P8oMbm7Y@ZfZ}zpso8wU1f}DYV&gzpovP zS(~i`9$Z&Z^C#R(;j_w(wg*CRR^|RgEQ0uOSAdr)r6wH+g!?UG|%9ua;l-Doi~P2q&+_qD(5ZIPG9q8f3pwRYVkuo^W4r>Q;Hv z6Yr%n$@BRZCXibzu=Yic6D)z{BwLqZkr0*FL?ZE`t{6lSG!5Qf*%Dcm2)xAdl4kNc zk!)EZECRA5-YBzyu7jQtvVkmEJhCDiqAD6PmWOzlP(-j5T{1L@w^;?Iy@ACdUK3=| zgwexFwynvqnPfrI2>WtN-!W|<`Z!4OXsbd2cs1OaTc(}`AoZRuW%zbL08#DM6<~fr zUkpK1>8NiAZU)fg3Bw8)Z=~uIfYYXH5C>MBmWD7UFfuyQ-96~5P(M+`*$iEFJP8S- zW@3-g@9*{Vw5qa`es(iMc* zV%^?;dGbrcip|2qFhHUS%|KK&TxcE-cO6xz*|=lIafhf zH9=M-f)4c>-~bAId4)*5fZEbXn{f_z4Yd8h5V|pt*cSWHwe zbNV-`&RWkKR?wW>=8nX+_7BBbfz<>_WqH_YqF`w%!j)xFlx4-1M8y#Ny#k|+`W%|i zv%Ji3EW`8LIBhvEEtkY)GAF2_sw`pIsR}BwL=mn86*0An zAnK;gN*sc*oDO--O-}N3XGYnZm&_Xc<8`<=vrPv7VyHvQqhoK@fa%9(}v1(r;C&Pn|L zi#naeiyP)RiHpDgH~mg>?O(;5Wb0GcJBd3E2~Ohs^kU-0{yrzM@6ew+iQZpa?j&d4 zaVJWyc7pLCLi>a*A%If*wisB`a%4wpVmWD)zN5#+4!rfrJ|w??Fr zscS?s49-&d&_o_>_~dY`);M_Xi>y}jg99ExL-AVU+39%guBQu(#*5VImhm6`qNa^d zBK_#3HBRCW)0<02f2~Fj~sUFAVyRu68gZA*cpQZ9!VGvwPerwiQnE7OjCD; z4mVA6{m!bd{QbSosypu3i+Sb2pgV`0#7taFob|mK?r9(HKYLH15ETKKoIgMPw@%`9 z+b$HR7r{G?x@r-QYD)%$v?OUZOLP_q3q(u6Ki22n_{}>>^Mo~>cOF4Qaf`EbLseM> z$LgY@NV=@S`)p%6YNCqxTW4+4LDQdi2FtRlU~z&DA+H&-hSfa?Y*jH?jzjFB|03g~ z(;$Wz?rv`oj6|p5o_(cs-eF6L&9a&($ucWq!-uY_JOU<>L&CPPmCf|uWW0tm-0zZH zGMqhg_p*B8m zBJrA}N**ANWISJG2vVTv65%*3^N0q|8@!?kSYQ{4p$V1}yvev}Q34j03Vrfi zQ>&{+c{B~hYsn2~-RT>52Ll>HH|7i{pZ7wi`H>QL5>MZhbP~5bCl=MHRd@c;T4&WY zPyWhT`R2ttox~{@o(1J-)}Bu~iC=&GpPj_&^hcaT&b@f*O{o6URkt{aDQ_Nf60dZ9 z6D8-qE<1^>zh3Gjr{1^5NxIb&zg+kwC-KCuP-xEILyeMO=|;(U=bYgr{_~`(oW#nv z00AGP)e}!l`4eZ=59ITl#68dd(3SoW{D{L=6am{{SqORo`XY=j4SQHM)zYv7BFIWJ zUQ!0zXcbTJ7eLb^X((RH7;@I#^M!8&ol+AtDPFF6>VhM~faa!$H~XN8?oCdxepx(s z@iOu6YrEZZpLaE^)p3_H$Oa^MbF*ACdUoy;=R0fle@OQaU9!D1ZRnlLiwTTI8|dY~ z)~Fk*>K0I7{c>Cpc%Ey0>Fy)3)epGw|1|c6VME7U=$H>33!!5%bS#CA<wSK1=~0 zrhpGqz=tUi!W0N$3WP8PLYM*}Oo0%lKnPPHgeefh6bNAo#4rV7m;y0Off%Mh3{xP6 zDGqsgqs@B;iv1a1sdm4_B_vY}hhF&V`+Bu3;+iBT<`Wh@i1 zY{+9~VB7J~BAvQBSX*Q$dNXhkDvXMZXN+xQkFp4JOB1!BMXHE=XPPWCtfi_9l9wrr z&a;GpEry+KH0I8xPqb_uhZ-ZBvcQPEt}q%>Gcd5r*-8UJ6K9x$s9*`WCh>*!@ht+5MZ?R3Vo~ zyOYe9%cE{+c)9F3IiEXoXKw9JVq(*6=y4t)I;<`+oPi$^!vQ_6a*V+vH3lzY0~lxJ zVtb+u4JXMDoVa^vkt`4s`^R`jkvN?ZC6h3SpQO)VS)Dg67DiF?Z9{P!WAmnteP?hO z*&+rNF`+=h3oOE2#1+OkNr7BE9JShx~df#Fmc%)q>2K$6BFKcFE=Dv|^c zeFV%nZ`rMTC?Q#rxjLQ7KwJFHPcRcP3rxaVj4fK27nX^KAR3Cn)>7mlU=>Z#v&LlP917^EH$Wb903Kp`frD4_Q=LuYt6O5wf5 zO`UU+@oVPo$vw1#Af&&0dmLL_yXbIF{OZSl0sWb%tcrcAswN_S3m!ZKzd|G+P)k#Z zBuf)PjsWIK=v4-`xD5fq@eKC8N{j^OJtDbm7Bxpej`r|Fx!nE8^M$=4$Oh#k|Md1$ z?m?QdQQuDD*n$gpKd>Cas~@r*k>%O zETTD`5 z>df27_;e;^h>CwJ~Hq@L)qdu7I|D06U&(H!=vHRHB-OYm%ER2rFvZH zcKYOrxq%EZNec1~D=BCEI`&GHoUo6k-A`czel;+Y7jtUUbMtuD9YcaZ@A|r8-ZUc2}s_8%<*02W7C`dA)i8^4E z=jY?$V=NaJe}5>VTwH*MtGbDZYp4Y_Pz(rV08C_#V+4Vdb?EZQQ^6MJ6{p#nVKOYT z8bO>v(1LpcgPAH>yoTK_hUp~Fdtpf+7ZU@CODg1I;=wZ^B_%EUjq`-9PB;v$!rF_nqUfBY3+CdNPgQi)7#efRa+ zGVv_XG`2D^@uIvxqD(xSGGuIJ;*xE*hGb&mjeC9{mWgxE)~d@yYJSz0iHkw+7|X=j z@<549O#JKwrd%dY-+UMN6hTKlH>ymGVqH6lH(x%ZR3?5hx!RYBmtVgaql<$D0o|E} zQy8!<8lsJf$nmNrDcBjOorV30p*M2_Jv%bJNVyL5S|~o1yYCu316#~; zk|*8qRrty^lj9Lk1;AI3Oit7}izm8_co3}QBB*F7?JFaqbc)tjQ?Gu~mrku4ZVgMP z#FPK<17Io)A0UJzB7{XY;S(?o0~#Ar<-ww{2+M#bfuqVdlq(jwV9<>K48(iDUxe5+ zx(3aOTA>@GVQSkSrcvocFQC zRD}idGdlJaF_OeW(dP}F*9t0ZG%aKLFMkn|PRYsF?h8q$#1mH@t}dMtgSk&(vLV6^ z4lN7m|71AlY!xegutg97qheLrV$TP?5+T@73hx4v>_8L6_+(THwkixuEC>pCZ^IpI zkt9HJ`r?zT4Ytis{yaR`R%bpF47S8~ZkPi}ij-9FznMr5Wb3fX(9IfAU|tKBXd%{* z1+5m+{@B<{%b1#qX-6saQ7f6V0uQr%a$+m~TX9r&}{ z{_ga3*LA|dH~IYO2+)DI1-p0^SWJctE5YDQm?LmXfDpQsi~~_J3Ac^|2l6gn1!2quAM!5@n- zHP8hPsRgf5G+r&{yGwPL_IH=UMS}#A@I_%ITEarU1`P_CEFs!hm_-^kIEx@8GWU8!gHdK8L0Uy$sLSOPyYfmZk zNiA6)`Xuq9+R!J1Sqj1dWjcD0%L>M(0=KztC?W&n7fu_b<|KT<*wo>_fd0!eaDKt7 zpo+i1vz~hsG80+uB%~##zA*wIv*3P!Jc5ga z#f*i3hV)hyI#$ONcaw9kZVuzF@Xj^0aaRQyBf#C-3ldJ^C!1faA_^04fBw9%D4g-p zdqSe{owgqi#bJnYEEY;^nEHZ%=p!UOL&9k!KtqmL4JHq0jf}g|ruQk?ull%q-hFSD z;O_G?=GC?emV>4-#@(gPmm}it3Osy_areZX{~p5K#P4oC7{cA;^yZDj;BL=NU#pF~ zD?#rV<8H@gk5?IN$%_^}93E_^tW5@kEh%Xqt&O{@Kr69Xzy%x#8a_J&g$lZlVE}<` zP9`*KF@XcRsE|gO7)dUDO9}3te0YnQuZ9hYQ{|keU5w2EZ%N=0W7-Mg1%a0>q?`k|avb`Hk zmNO^iAvdG=0Q*CMDM38$X!B82k7b75slr!Z3}8GnzklnF-tJv`4l!wW!~C4^)?NM0 zGv_bLHBFc>7^W)-7aKpi6oo6&H820bW%~B*dfffZIZ5vP=bgk?`sLh&wmsO=&>xPZ zX#~r#oaeJ_pd;1QMZZx4rrq(xY2e&<7WK8nr9DkerGzztXBy{iADNdwusM_JBJpkb z0m~M(eZon8;GLGfiKjDrnwv{WZ5ldigN4O z{U@J#+KgONOBvTfo6|;_&uRD>0MA#@o$K{Xd@H^*1kxV*(m%rf{LoE(C#_8GX>Bd# znb-)P@m_54!u-K)I|wE~rYpVypL9cywq4lfB;I=A-M-Ui&dW_`!2}IYgHQncIP%R4 z=vg9vUEiEV z%L~#s+_^yv^*GK~#be+6{K1R$?Z`ljXp8k&2*ubtXV=qCvgOE+bFHUW2otRlY*W3h z7r?9yySAqgY=a^9i}g<8sVO(~PdoXHW$4eA63l9ZkVEjDk68mB?(W3LZYaMJKVP*J zu|_AH*B?7==HiO}ce;u6aVKIN=-7wVLJ)xtaNW zH0rMhWXT6lb&@B1<{P=Gr=yd*_k;PT~i5-`qFtv}JqfYz@gcz5xIR z-<>CY&&==Jrg!OCQ1#p2y#vj6)~9caHJ^Ce2Qb)5k(F-%c7ZSt_!s3L+NfW94T9X_ z8wky50uqxg%_m{+$E>ct56rGm=J*D{7Ik_S?JXta)zrX!T9heHTzWdqVV`I&dXZohko?qFg4Zti7QPoAB&Cfr$ zLGQHV8;PFn%~0`n%4f0L`kwe-_RT$KsXG%wHee&;iq>uv?Hn1k-`U*X)W@^~9u;_QLtEIf<`- zeOaz;VYwGVXb5`o-sY*Ko*!7(-oYsNIy?x|zP8XwyzpQ9bJ&M)bnor(O4iz9jF9&l zn>OQKCW>K`W7OVaSbOJw?Ys~*cd*}Cd($!)Qfv2k?Ys^(H!arA1yDQJMD1PiC{b(Y zaP6!)H8TX=mWv&UoxcC0q&wBK+Z_njou{!JKa;+1; zTzeBp@sVqukI_dqAI;^aHsgs=A_rRv)E29@#6WD+s!f$u+af--vg(8;RE@Hqqx4-T zwB~Xxv}%;a1+VG`mrWCoVvW2B?2Y8|F4pb1&aK<#)@`BBjj*+$Sa)rqZtKy(zG>5r z#C8XHKAetQupc+VkGn0q>?cAsw@6SeYK`ONB6Z69j!h%)>7Yh%6!o?I8Wp3O3Kb{9 zkKaOQMntIQ776M`t#Q0uq)vI?v1#Nz9n=VpqP~`2qcEy-hY!<#&pcE8U*T?)GQ+iS z{Ca@{QFs`k%;27*djRjTSPt4u%}unPVo=bZCLP_gd-oo9Gjy~K=K0<3Uu~82Xv|r6 zhSqtwpjB?8U$*Ii4iRPNODTynj^0_+D(k5WwTJ3>QT76QkDcbKJu_Q!xe4`Gty8m{ z1*LRiRHd|3pQTk>sG1ffLd|>E!YG&xExKg_WmAMLccoS5Kq+mdRikVU@v0V-(lb%D zsTEa6uFuY+1VaI@xvk85;!d|u2_bhiy}_5J zg>u!31lQcc!Rh<+C<2iYUfM5G!NvA_pgWcQ_@xV6Y>(3-Z&&%DH{8O7BF8KjD&m%_ z^XDE)Mzy-b(dQP9;jdV2xN>fh;HQiw?x}ybz{M(9?c;ML%;Hw%#j(9o^$}G`axV_< z2L6l;fFRI|5L7EuFd&GsYxEE_e-scjAEL^IpeWleJ#keKkPAErs;_FD5VWv<2%=T% zgrG(BL(p0EL(tjvL(t;-An4ptK+rkIA?O%R3STk~kGp=6;}CQlf`EIYhoD+%_k9Rz zqQ3RDSERbTQ_y&$?7JyqLD7?X-q9nmnVuaKVb@N1?Od*T5*{37r;P_f&$~MF5`fU; z`m450rM5wYy{e^8T?*;i=K7o?Y$+|R%0Q;KQHvzX#uTq=0dSY0>I6z~lszI|)txSz z&Ig+&)nB!Zu9`&IW!XGP|6ETamOt2L5KNZ@>Z>}j{;I9iaEtQP(&Z0Ia4Tg~eN`t% zeQK#`OrJVA>QgJLww)M}U~fniW)6L7GhHQ$vQgEmS{R5NM$^>#tGZTol)W&eqlu^L z8yyn$Rh?Xa)v2^!>w9VwWz)z#2qFYbGo-`_JqQw280k7%lszlOK6P!7!9kg>qfMZ7 zqip#s)?GXKdb-RhP|fee=|^I36_guakqMm+>pQ4c_}JHmZK~|Xv0Dl5mQ_3UX;5w0 zqhmJ^;ci*W5o$y&60{t(M$~d{%TWve?<+UfuOD>O*tA^MUT`|1t_Z3{IEvbGgr=$$ z8MgUqg-fs1l2z?--xpSTRN72Px($1y-EF`%XWXOVeq#8G-k#e^&}c+!soFCNPD{7F zVSj?=s}w0~wB0QdkQSUC`&yM}1T9x>)NT3LKS86V4|iJ*xoc@FYy6*(U-Q__yLU@l z9-l_5Jt!(^0T28dG5k60?&&e94;2Z@4c8vp^gP``VfPu;eyX(6?w5<^?ioM%7Itto!^g?_Q;@ZjOxX#H5q zsQg$8wc6@)Myd7tv1rvg{a9N4eyrv7`>|Hk@5fqMzaNWMb^TaTvX+#39#(mNtoo|D zeysYcx_+!ERZIO?lwjA7RbN%tj}@hAsWXZ`)%9c5SJm}nMXBmJqY8d3`c&7C6(#YD zS2gc?MH3hjt{4{G>1%4C^{g6W-UTJ2xyVpwn1BGjT z;TZlxK7+#DGKWC52Fk9Lozmumma8^Oixh4QdlWS49{J^B_zQ;}(i_M9SQRX3r+g{% z%$1+gA~F1he#Ux811q9TKG$+BdN@A*3P?ef2kkK-J9ZEaNyIQc!AC*`nUUVI5&;ZkQ6X{kjCWR zjk=qZ@w?luK;E}4`)`X)kI%2nBPBE>^^4%83PDbUt@tnzvzcVNboxa^q#l@bF1}~H z^!}4`=cpA~I2w}t#s5z3+4+at@y(>|-Sp#i@il2ZLlbf3Ok^^-`{3ceN#`x8$oM8S zBn^zcC?LPhR~VtSg{g=7+< zB=VqZ#VOY!J(xNrcgn)uM@v#Pi490whA(b;DMFCxZ6iLs*+aAWIFD?05^ukFXMgj_ zahe;XGL(;v7-q1%z#t(N}NRZQ!?#}jPdd*^@p+jw#AdBkB zkM^H&Hp>+<_=R%5hz*zr_;Qt(=M70M3;hZrdopR7WhlPmPw{enbr`rlD+Y^kS3zbY)^H^({_Aa zCX=Q~ULW}ivTl9ihHvGT$nJCqVO9fXgNiTF_~|weZ0POU*|RIYHcip={2w6&(r4a& zu&=32EKklPH3YY0Zz{~p-@CPk<|s?7xOszJk6Hk2wkLnn}3{=akgYp;Q0gzu-F$m#w%gcftynw9qtYfE-itE$ZUbguY0#WLxn|@UiOn z#rT*t7 Date: Thu, 22 Oct 2026 23:34:24 +0200 Subject: [PATCH 229/444] Pins: cultlib-unity-v2.0.1 at b8fc5eb0 (1.0.x saves with ref lists open as compatible drift) packages-lock.json follows from the Unity resolve. caching-unity stays at v2.0.0; cultmath at 7a8933b0. Co-Authored-By: Claude Sonnet 5.5 --- Directory.Build.props | 4 ++-- Packages/manifest.json | 2 +- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/Directory.Build.props b/Directory.Build.props index e17a8e5d5..05d5e4d22 100644 --- a/Directory.Build.props +++ b/Directory.Build.props @@ -2,8 +2,8 @@ $(CULTLIB_ROOT) $(MSBuildThisFileDirectory)..\CultLib - - 42dce3611a81adc5c5fac54cadbaa5637d6022d3 + + b8fc5eb07720a187a0421424ac2cb3aa2966ca1d $(CULTMATH_ROOT) $(CultLibRoot) diff --git a/Packages/manifest.json b/Packages/manifest.json index d4dafcbf2..7693bd94f 100644 --- a/Packages/manifest.json +++ b/Packages/manifest.json @@ -53,7 +53,7 @@ "com.unity.modules.wind": "1.0.0", "com.unity.modules.xr": "1.0.0", "org.gamecult.caching.unity": "https://github.com/GameCult/CultLib.git?path=/src/GameCult.Unity/Assets/Caching#caching-unity-v2.0.0", - "org.gamecult.cultlib": "https://github.com/GameCult/CultLib.git?path=/unity/org.gamecult.cultlib#cultlib-unity-v2.0.0", + "org.gamecult.cultlib": "https://github.com/GameCult/CultLib.git?path=/unity/org.gamecult.cultlib#cultlib-unity-v2.0.1", "org.gamecult.cultmath": "https://github.com/GameCult/CultLib.git?path=/packages/cultmath/unity/org.gamecult.cultmath#cultmath-unity-v0.5.0" } } From 7886f0a231890ab3f69932feec1e8d3a97e17926 Mon Sep 17 00:00:00 2001 From: soul Date: Thu, 22 Oct 2026 23:34:26 +0200 Subject: [PATCH 230/444] ShipAuthoringStore.Load names the file when its store format is newer than this runtime reads Co-Authored-By: Claude Sonnet 5.5 --- Assets/Scripts/ServerShared/ShipAuthoring.cs | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/Assets/Scripts/ServerShared/ShipAuthoring.cs b/Assets/Scripts/ServerShared/ShipAuthoring.cs index 2f6db9e5c..3962fa468 100644 --- a/Assets/Scripts/ServerShared/ShipAuthoring.cs +++ b/Assets/Scripts/ServerShared/ShipAuthoring.cs @@ -140,6 +140,10 @@ public static class ShipAuthoringStore { throw new InvalidOperationException($"{path}: a record does not decode as its schema: {error.Message}", error); } + catch (NotSupportedException error) + { + throw new InvalidOperationException($"{path}: not a readable ship file: {error.Message}", error); + } } // Key 1 of ShipAuthoring held the whole hull before S1. The type no longer has that member, so a deserialized record From fba7bd1b4763e08e145a0a21b99eed4720143cb7 Mon Sep 17 00:00:00 2001 From: soul Date: Thu, 22 Oct 2026 18:24:10 +0200 Subject: [PATCH 231/444] Station kinds: StationKindData catalog list, HullData.Kind, placed station Kind; GenerateStationLoadout takes a kind (the kindless draw is deleted) Co-Authored-By: Claude Sonnet 5.5 --- Assets/Scripts/ServerShared/AetheriaStores.cs | 3 +- .../Scripts/ServerShared/EntitySerializer.cs | 7 +- Assets/Scripts/ServerShared/ItemData.cs | 6 + .../Scripts/ServerShared/LoadoutGenerator.cs | 10 +- Assets/Scripts/ServerShared/OrbitalEntity.cs | 2 + Assets/Scripts/ServerShared/StationKinds.cs | 128 ++++++ .../Scripts/ServerShared/StationKinds.cs.meta | 11 + Assets/Scripts/ServerShared/ZoneGenerator.cs | 6 +- .../ConsumableSupplyTests.cs | 17 +- .../HardpointOwnershipTests.cs | 2 +- .../PickupGenerationTests.cs | 2 +- tests/Aetheria.Shared.Tests/RunGoalTests.cs | 2 +- tests/Aetheria.Shared.Tests/RunStartTests.cs | 10 +- .../Aetheria.Shared.Tests/StationKindTests.cs | 378 ++++++++++++++++++ .../StationKindsCatalogTests.cs | 53 +++ tools/AetherDb/Program.cs | 5 +- tools/AetherDb/StationKindsCatalog.cs | 82 ++++ 17 files changed, 700 insertions(+), 24 deletions(-) create mode 100644 Assets/Scripts/ServerShared/StationKinds.cs create mode 100644 Assets/Scripts/ServerShared/StationKinds.cs.meta create mode 100644 tests/Aetheria.Shared.Tests/StationKindTests.cs create mode 100644 tests/Aetheria.Shared.Tests/StationKindsCatalogTests.cs create mode 100644 tools/AetherDb/StationKindsCatalog.cs diff --git a/Assets/Scripts/ServerShared/AetheriaStores.cs b/Assets/Scripts/ServerShared/AetheriaStores.cs index 525894ac9..47128ef6c 100644 --- a/Assets/Scripts/ServerShared/AetheriaStores.cs +++ b/Assets/Scripts/ServerShared/AetheriaStores.cs @@ -7,7 +7,7 @@ using GameCult.Caching.MessagePack; // Aetheria's stores: one home store per type, composed here and nowhere else. public static class AetheriaStores { - public static readonly Type[] CatalogTypes = { typeof(ItemData), typeof(ShipAuthoring), typeof(Faction), typeof(FactionProductData), typeof(PersonalityAttribute), typeof(InputLayout), typeof(Loadout), typeof(FieldKindData), typeof(VerseGrammar), typeof(VerseVerb) }; + public static readonly Type[] CatalogTypes = { typeof(ItemData), typeof(ShipAuthoring), typeof(Faction), typeof(FactionProductData), typeof(PersonalityAttribute), typeof(InputLayout), typeof(Loadout), typeof(FieldKindData), typeof(VerseGrammar), typeof(VerseVerb), typeof(StationKindData) }; public static readonly Type[] RunTypes = { typeof(OrbitData), typeof(BodyData), typeof(SavedZone), typeof(SavedGame), typeof(ProvenanceLedger) }; public static readonly Type[] PlayerTypes = { typeof(PlayerSettings) }; @@ -42,6 +42,7 @@ public static class AetheriaStores foreach (var data in cache.GetAll()) CultRecordRefs.Validate(data); VerseGrammarValidation.ValidateAll(cache.GetAll()); ValidateBins(cache); + StationKinds.Validate(cache); return cache; } catch diff --git a/Assets/Scripts/ServerShared/EntitySerializer.cs b/Assets/Scripts/ServerShared/EntitySerializer.cs index 5b4fb7f3f..7f0f04ebd 100644 --- a/Assets/Scripts/ServerShared/EntitySerializer.cs +++ b/Assets/Scripts/ServerShared/EntitySerializer.cs @@ -16,7 +16,8 @@ public static class EntitySerializer { Orbit = new CultRecordRef(orbital.OrbitData), SecurityLevel = orbital.SecurityLevel, - SecurityRadius = orbital.SecurityRadius + SecurityRadius = orbital.SecurityRadius, + Kind = orbital.Kind }; else if (entity is Ship ship) pack = new ShipPack @@ -120,6 +121,7 @@ public static class EntitySerializer Restore(itemManager, zone, pack, entity); entity.SecurityLevel = pack.SecurityLevel; entity.SecurityRadius = pack.SecurityRadius; + entity.Kind = pack.Kind; if (pack.Story >= 0) entity.Story = zone.GalaxyZone.Locations[pack.Story]; return entity; } @@ -214,6 +216,7 @@ public class OrbitalEntityPack : EntityPack [Key(18)] public int Story = -1; [Key(19)] public SecurityLevel SecurityLevel; [Key(20)] public float SecurityRadius; + [Key(22)] public CultRecordRef Kind; } [MessagePackObject, @@ -237,7 +240,7 @@ public abstract class EntityPack [Key(14)] public EntitySettings Settings; [Key(15)] public CultRecordRef Faction; [Key(16)] public int[][] WeaponGroups; - // Keys 17-20 belong to the subclasses; the base's next free key is 21. + // Keys 17-20 belong to the subclasses; the base's next free key is 23 (Boss is 21, the orbital pack's Kind is 22). [Key(21)] public bool Boss; private int _price; diff --git a/Assets/Scripts/ServerShared/ItemData.cs b/Assets/Scripts/ServerShared/ItemData.cs index 170c2421d..d9e972a85 100644 --- a/Assets/Scripts/ServerShared/ItemData.cs +++ b/Assets/Scripts/ServerShared/ItemData.cs @@ -549,6 +549,12 @@ public class HullData : EquippableItemData [Inspectable, JsonProperty("visual"), Key(32)] public CultRecordRef Visual; + // What a station hull is built to be (StationKinds): set on every Station hull and on no other. The hull names its kind + // and the kind names no hull, so a package can add a faction's model of a kind without editing any kind record. + // Key 33 is the next free key. + [Inspectable, JsonProperty("kind"), Key(33)] + public CultRecordRef Kind; + // The farthest corner of any occupied hull cell from the centre of mass the schematic frame uses, in world // units: a true bound on how far the hull reaches from its position (the half-diagonal of the bounding box is not, // as FireControl.Detonate's comment records). A cell at (x, y) spans x +/- .5 and y +/- .5. diff --git a/Assets/Scripts/ServerShared/LoadoutGenerator.cs b/Assets/Scripts/ServerShared/LoadoutGenerator.cs index b75f0d30a..ce8569965 100644 --- a/Assets/Scripts/ServerShared/LoadoutGenerator.cs +++ b/Assets/Scripts/ServerShared/LoadoutGenerator.cs @@ -74,9 +74,12 @@ public class LoadoutGenerator return EntitySerializer.Pack(entity) as OrbitalEntityPack; } - public OrbitalEntityPack GenerateStationLoadout() + // A station of one kind: the hull is drawn among the station hulls the kind calls for in this faction's hands + // (StationKinds.HullKindFor), so a faction with no model of the kind builds its stand-in's hull and keeps the kind. + public OrbitalEntityPack GenerateStationLoadout(CultRecordRef kind) { - var (hullProduct, hullData) = RandomHull(HullType.Station); + var hullKind = StationKinds.HullKindFor(ItemManager.ItemData, Faction, kind); + var (hullProduct, hullData) = RandomHull(HullType.Station, hull => hull.Kind.Key.Equals(hullKind.Key)); if(hullData==null) { ItemManager.Log("Unable to generate station loadout: no compatible hull found!"); @@ -85,6 +88,9 @@ public class LoadoutGenerator var hull = ItemManager.CreateInstance(hullProduct) as EquippableItem; var entity = new OrbitalEntity(ItemManager, null, hull, default, ItemManager.GameplaySettings.DefaultEntitySettings); entity.Faction = Faction; + entity.Kind = kind; + // A stand-in hull keeps its own model but not its own name: the station is what its kind says it is. + if (!hullData.Kind.Key.Equals(kind.Key) && ItemManager.ItemData.Get(kind) is { } kindData) entity.Name = kindData.Name; // Hardpoint gear goes in first so the docking bay can only take space that gear left free EquipHardpoints(entity); diff --git a/Assets/Scripts/ServerShared/OrbitalEntity.cs b/Assets/Scripts/ServerShared/OrbitalEntity.cs index a2c511ba7..be1bd893c 100644 --- a/Assets/Scripts/ServerShared/OrbitalEntity.cs +++ b/Assets/Scripts/ServerShared/OrbitalEntity.cs @@ -17,6 +17,8 @@ public class OrbitalEntity : Entity public float SecurityRadius; public LocationStory Story; public bool CanTow; + // What this station is for (StationKinds): its own fact, which under a stand-in hull differs from the hull's kind. + public CultRecordRef Kind; public OrbitalEntity(ItemManager itemManager, Zone zone, EquippableItem hull, CultRecordKey orbit, EntitySettings settings) : base(itemManager, zone, hull, settings) { diff --git a/Assets/Scripts/ServerShared/StationKinds.cs b/Assets/Scripts/ServerShared/StationKinds.cs new file mode 100644 index 000000000..18c200092 --- /dev/null +++ b/Assets/Scripts/ServerShared/StationKinds.cs @@ -0,0 +1,128 @@ +using System; +using System.Collections.Generic; +using System.Linq; +using GameCult.Caching; +using MessagePack; +using Newtonsoft.Json; + +// What a station is for: one authored catalog record per kind (mining, trade-hub, shipyard...). The catalog's list of +// these records is the one list of station kinds. A kind names no hull: the hull names its kind (HullData.Kind), and +// which hull a faction builds for a kind is derived from the products that faction makes (StationKinds), so a package can +// add a faction's model of a kind without editing any kind record. Records are keyed station-kind:. +[CultDocument("aetheria.stationkinddata", "1"), MessagePackObject, JsonObject(MemberSerialization.OptIn)] +public class StationKindData +{ + [CultName, JsonProperty("name"), Key(0)] + public string Name = ""; + + [JsonProperty("description"), Key(1)] + public string Description = ""; + + // A faction with no model of this kind builds the stand-in's hull and the station stays this kind. Ignored once the + // faction offers a model of this kind. + [JsonProperty("standIns"), Key(2)] + public List StandIns = new List(); + + public static CultRecordKey KeyOf(string id) => new CultRecordKey("station-kind:" + id); +} + +[MessagePackObject, JsonObject(MemberSerialization.OptIn)] +public class StationKindStandIn +{ + [JsonProperty("faction"), Key(0)] + public CultRecordRef Faction; + + // The kind whose model this faction builds in place of the owning kind's. + [JsonProperty("kind"), Key(1)] + public CultRecordRef Kind; +} + +// The one owner of which kinds a faction builds, which kind a station gets, and which hull kind stands in. +public static class StationKinds +{ + // The kinds this faction can place: those of the station hulls its products offer, and those that stand in for it. + // Ordered by key so a choice never depends on store order. Empty for no faction. + public static CultRecordRef[] KindsOf(CultCache catalog, Faction faction) + { + if (faction == null) return Array.Empty>(); + var kinds = new HashSet(OfferedStationHulls(catalog, faction).Select(hull => hull.Kind.Key.Value), StringComparer.Ordinal); + foreach (var kind in catalog.GetAll()) + if (kind.StandIns.Any(standIn => catalog.Get(standIn.Faction) == faction)) + kinds.Add(catalog.RefOf(kind).Key.Value); + return Ordered(kinds); + } + + // A station's kind: a stable pick among the faction's kinds by the station's site key. It draws nothing from any random + // stream, so adding the kind to generation moves no other roll. A faction with no kinds falls back to any kind some + // offered station hull carries, logged, and the hull draw then falls back to any maker. Unset when the catalog has none. + public static CultRecordRef Choose(CultCache catalog, Faction faction, CultRecordKey siteKey, Action log = null) + { + var pool = KindsOf(catalog, faction); + if (pool.Length == 0) + { + pool = Ordered(new HashSet( + catalog.GetAll().Where(product => LoadoutGenerator.IsOffered(catalog, product)) + .Select(product => catalog.Get(product.Design) as HullData) + .Where(hull => hull != null && hull.HullType == HullType.Station && hull.Kind.IsSet()) + .Select(hull => hull.Kind.Key.Value), StringComparer.Ordinal)); + if (pool.Length == 0) return default; + log?.Invoke($"{faction?.Name ?? "This galaxy"} builds no station kind; placing a station of any kind some maker offers."); + } + return pool[(int) (siteKey.Value.StableHash() % (uint) pool.Length)]; + } + + // The kind of hull a faction builds for a station kind: the kind itself when the faction offers a model of it (a model + // wins over a stale stand-in), else the kind its stand-in names, else the kind itself (the hull draw then falls back to + // any maker). + public static CultRecordRef HullKindFor(CultCache catalog, Faction faction, CultRecordRef kind) + { + if (!kind.IsSet() || faction == null) return kind; + if (OfferedStationHulls(catalog, faction).Any(hull => hull.Kind.Key.Equals(kind.Key))) return kind; + var standIn = catalog.Get(kind)?.StandIns.FirstOrDefault(entry => catalog.Get(entry.Faction) == faction); + return standIn != null && standIn.Kind.IsSet() ? standIn.Kind : kind; + } + + // Open refuses a malformed kind catalog, naming the record and field. Every Station hull names a kind the catalog holds + // and no other hull names one; every stand-in names a faction and a kind the catalog holds, is never its own kind, and + // does not stand on another stand-in for the same faction (one hop). + public static void Validate(CultCache catalog) + { + foreach (var hull in catalog.GetAll()) + { + if (hull.HullType == HullType.Station) + { + if (!hull.Kind.IsSet()) + throw new InvalidOperationException($"Station hull '{hull.Name}' has no Kind; a station hull names the station kind it is built to be."); + if (catalog.Get(hull.Kind) == null) + throw new InvalidOperationException($"Station hull '{hull.Name}' Kind names no station kind this catalog holds."); + } + else if (hull.Kind.IsSet()) + throw new InvalidOperationException($"Hull '{hull.Name}' is a {hull.HullType} and names a Kind; only a station hull has one."); + } + foreach (var kind in catalog.GetAll()) + { + var self = catalog.RefOf(kind).Key; + for (var i = 0; i < kind.StandIns.Count; i++) + { + var entry = kind.StandIns[i]; + var at = $"Station kind '{kind.Name}' StandIns[{i}]"; + var faction = catalog.Get(entry.Faction); + if (faction == null) throw new InvalidOperationException($"{at}.Faction names no faction this catalog holds."); + var other = catalog.Get(entry.Kind); + if (other == null) throw new InvalidOperationException($"{at}.Kind names no station kind this catalog holds."); + if (entry.Kind.Key.Equals(self)) throw new InvalidOperationException($"{at}.Kind is the kind itself; a kind cannot stand in for itself."); + if (other.StandIns.Any(next => catalog.Get(next.Faction) == faction)) + throw new InvalidOperationException($"{at}.Kind stands in for the same faction itself; a stand-in is one hop."); + } + } + } + + private static IEnumerable OfferedStationHulls(CultCache catalog, Faction faction) => + catalog.GetAll() + .Where(product => LoadoutGenerator.IsOffered(catalog, product) && catalog.Get(product.Manufacturer) == faction) + .Select(product => catalog.Get(product.Design) as HullData) + .Where(hull => hull != null && hull.HullType == HullType.Station && hull.Kind.IsSet()); + + private static CultRecordRef[] Ordered(IEnumerable keys) => + keys.OrderBy(key => key, StringComparer.Ordinal).Select(key => new CultRecordRef(new CultRecordKey(key))).ToArray(); +} diff --git a/Assets/Scripts/ServerShared/StationKinds.cs.meta b/Assets/Scripts/ServerShared/StationKinds.cs.meta new file mode 100644 index 000000000..8e29291ad --- /dev/null +++ b/Assets/Scripts/ServerShared/StationKinds.cs.meta @@ -0,0 +1,11 @@ +fileFormatVersion: 2 +guid: 7b3e1a94c2d84f6e9a05d1c8e4f27b63 +MonoImporter: + externalObjects: {} + serializedVersion: 2 + defaultReferences: [] + executionOrder: 0 + icon: {instanceID: 0} + userData: + assetBundleName: + assetBundleVariant: diff --git a/Assets/Scripts/ServerShared/ZoneGenerator.cs b/Assets/Scripts/ServerShared/ZoneGenerator.cs index dfe89917c..c5c653bac 100644 --- a/Assets/Scripts/ServerShared/ZoneGenerator.cs +++ b/Assets/Scripts/ServerShared/ZoneGenerator.cs @@ -419,7 +419,8 @@ public static class ZoneGenerator if (i >= selectedStationOrbits.Length) { itemManager.Log($"Story station for {story.Faction?.Name} has no rail clear of the bodies; its story has nowhere to live!"); continue; } var orbit = selectedStationOrbits[i]; var lagrangeOrbit = CreateLagrangeOrbit(orbit); - var station = GetLoadoutGenerator(story.Faction).GenerateStationLoadout(); + var station = GetLoadoutGenerator(story.Faction).GenerateStationLoadout( + StationKinds.Choose(cache, story.Faction, cache.RefOf(lagrangeOrbit).Key, itemManager.Log)); if (station == null) { itemManager.Log($"Story station for {story.Faction?.Name} could not be generated; its story has nowhere to live!"); @@ -440,7 +441,8 @@ public static class ZoneGenerator var security = (SecurityLevel) ((int) ((1f - pow(random.NextFloat(), factionPresence / 2f)) * 3f)); var lagrangeOrbit = CreateLagrangeOrbit(orbit); - var station = GetLoadoutGenerator(nearestFaction).GenerateStationLoadout(); + var station = GetLoadoutGenerator(nearestFaction).GenerateStationLoadout( + StationKinds.Choose(cache, nearestFaction, cache.RefOf(lagrangeOrbit).Key, itemManager.Log)); if (station == null) { if (isTutorialEntrance) diff --git a/tests/Aetheria.Shared.Tests/ConsumableSupplyTests.cs b/tests/Aetheria.Shared.Tests/ConsumableSupplyTests.cs index b02dffea7..9878d95c6 100644 --- a/tests/Aetheria.Shared.Tests/ConsumableSupplyTests.cs +++ b/tests/Aetheria.Shared.Tests/ConsumableSupplyTests.cs @@ -60,6 +60,7 @@ public sealed class ConsumableSupplyTests : IDisposable public ItemManager Items; public Galaxy Galaxy; public Faction Maker; + public CultRecordRef Kind; public LoadoutGenerator Generator(ref Random random) => new LoadoutGenerator(ref random, Items, Galaxy, null, Maker, .5f); } @@ -84,8 +85,9 @@ public sealed class ConsumableSupplyTests : IDisposable maker = seed.Upsert(new Faction { Name = "Maker", ShortName = "MKR", Allegiance = { { ally, 1f } } }); // Interior for the crate, the capacitor and the tractor beam every generated ship carries. + var hub = new CultRecordRef(seed.Upsert(new StationKindData { Name = "Hub" }).Key); var skiff = seed.Upsert(new HullData { Name = "Skiff", HullType = HullType.Ship, Shape = Solid(5, 3), Price = 100 }); - var platform = seed.Upsert(new HullData { Name = "Platform", HullType = HullType.Station, Shape = Solid(5, 5), Price = 100 }); + var platform = seed.Upsert(new HullData { Name = "Platform", HullType = HullType.Station, Kind = hub, Shape = Solid(5, 5), Price = 100 }); // A hold with no cell cannot take a consumable. var crate = seed.Upsert(new CargoBayData { @@ -107,7 +109,7 @@ public sealed class ConsumableSupplyTests : IDisposable if (holds != Holds.Pinned) { sold.Add(("Cutter", seed.Upsert(new HullData { Name = "Cutter", HullType = HullType.Ship, Shape = Solid(5, 3), Price = 100 }).Key)); - sold.Add(("Outpost", seed.Upsert(new HullData { Name = "Outpost", HullType = HullType.Station, Shape = Solid(4, 4), Price = 100 }).Key)); + sold.Add(("Outpost", seed.Upsert(new HullData { Name = "Outpost", HullType = HullType.Station, Kind = hub, Shape = Solid(4, 4), Price = 100 }).Key)); sold.Add(("Plate", seed.Upsert(new GearData { Name = "Plate", Hardpoint = HardpointType.Tool, Shape = Solid(2, 2), Price = 1 }).Key)); } @@ -155,7 +157,8 @@ public sealed class ConsumableSupplyTests : IDisposable Cache = cache, Items = new ItemManager(1u, cache, new ProvenanceLedger(), RunSaveTests.TestSettings(), _ => { }), Galaxy = new Galaxy(cache, cache.GetGlobal(), _ => { }), - Maker = cache.GetByName("Maker") + Maker = cache.GetByName("Maker"), + Kind = new CultRecordRef(cache.GetAll().Select(k => cache.RefOf(k).Key).Single()) }; } @@ -193,7 +196,7 @@ public sealed class ConsumableSupplyTests : IDisposable for (var i = 0; i < Seeds; i++) { var random = new Random(SeedOf(i)); - var pack = rig.Generator(ref random).GenerateStationLoadout(); + var pack = rig.Generator(ref random).GenerateStationLoadout(rig.Kind); Assert.NotEmpty(pack.CargoContents.SelectMany(bay => bay)); // the gear stock is read from the same pack var held = Consumables(pack); var hull = rig.Items.GetData(pack.Hull).Name; @@ -255,7 +258,7 @@ public sealed class ConsumableSupplyTests : IDisposable for (var i = 0; i < Seeds; i++) { var random = new Random(SeedOf(i)); - if (Consumables(rig.Generator(ref random).GenerateStationLoadout()).Length > 0) stocked++; + if (Consumables(rig.Generator(ref random).GenerateStationLoadout(rig.Kind)).Length > 0) stocked++; } AssertWithinThreeSigma(stocked, Seeds, StationChance, "stations stocking a consumable past a refusing first bay"); } @@ -289,7 +292,7 @@ public sealed class ConsumableSupplyTests : IDisposable items.Random = new Random(SeedOf(i)); var random = new Random(SeedOf(i)); var generator = new LoadoutGenerator(ref random, items, galaxy, galaxy.Entrance, null, .5f); - if (Consumables(generator.GenerateStationLoadout()).Length > 0) stations++; + if (Consumables(generator.GenerateStationLoadout(TestStationKinds.TradeHub)).Length > 0) stations++; if (Consumables(generator.GenerateShipLoadout()).Length > 0) ships++; } AssertWithinThreeSigma(stations, Seeds, StationChance, "shipped stations stocking a consumable"); @@ -327,7 +330,7 @@ public sealed class ConsumableSupplyTests : IDisposable rig.Items.Random = new Random(99); var random = new Random(5); var generator = rig.Generator(ref random); - generator.GenerateStationLoadout(); + generator.GenerateStationLoadout(rig.Kind); for (var i = 0; i < 3; i++) generator.GenerateShipLoadout(); return Enumerable.Range(0, 4).Select(_ => generator.Random.NextFloat()) .Concat(Enumerable.Range(0, 4).Select(_ => rig.Items.Random.NextFloat())).ToArray(); diff --git a/tests/Aetheria.Shared.Tests/HardpointOwnershipTests.cs b/tests/Aetheria.Shared.Tests/HardpointOwnershipTests.cs index 2650b8009..2b8fa2270 100644 --- a/tests/Aetheria.Shared.Tests/HardpointOwnershipTests.cs +++ b/tests/Aetheria.Shared.Tests/HardpointOwnershipTests.cs @@ -264,7 +264,7 @@ public sealed partial class RunStartTests var shipHulls = LoadoutGenerator.SoldHulls(_cache).Where(h => h.HullType == HullType.Ship).Select(h => h.Name).ToList(); var generator = new LoadoutGenerator(ref _items.Random, _items, _galaxy, _galaxy.Entrance, _protagonist, .5f); var packs = shipHulls.SelectMany(name => Enumerable.Range(0, 2).Select(_ => generator.GenerateShipLoadout(h => h.Name == name))) - .Concat(Enumerable.Range(0, 4).Select(_ => generator.GenerateStationLoadout())) + .Concat(Enumerable.Range(0, 4).Select(_ => generator.GenerateStationLoadout(TestStationKinds.TradeHub))) .Concat(Enumerable.Range(0, 4).Select(_ => generator.GenerateTurretLoadout())) .ToList(); var readers = 0; diff --git a/tests/Aetheria.Shared.Tests/PickupGenerationTests.cs b/tests/Aetheria.Shared.Tests/PickupGenerationTests.cs index fefdaeb81..ce4666496 100644 --- a/tests/Aetheria.Shared.Tests/PickupGenerationTests.cs +++ b/tests/Aetheria.Shared.Tests/PickupGenerationTests.cs @@ -37,7 +37,7 @@ public sealed partial class RunStartTests // Stations and turrets share the interior outfitting and get none. for (var i = 0; i < 4; i++) { - Assert.Equal(0, PickupGear(generator.GenerateStationLoadout())); + Assert.Equal(0, PickupGear(generator.GenerateStationLoadout(TestStationKinds.TradeHub))); Assert.Equal(0, PickupGear(generator.GenerateTurretLoadout())); } Assert.All(main.arena.Pack.Entities.Where(pack => !(pack is ShipPack)), pack => Assert.Equal(0, PickupGear(pack))); diff --git a/tests/Aetheria.Shared.Tests/RunGoalTests.cs b/tests/Aetheria.Shared.Tests/RunGoalTests.cs index 8626148a5..c10feb803 100644 --- a/tests/Aetheria.Shared.Tests/RunGoalTests.cs +++ b/tests/Aetheria.Shared.Tests/RunGoalTests.cs @@ -318,7 +318,7 @@ public sealed partial class RunStartTests var ship = generator.GenerateShipLoadout(); Assert.NotNull(ship); Assert.NotEqual(hull.Name, HullName(items, ship)); - var station = generator.GenerateStationLoadout(); + var station = generator.GenerateStationLoadout(TestStationKinds.TradeHub); Assert.DoesNotContain(EntitySerializer.Items(station), item => items.GetData(item).Name == hull.Name); Assert.Null(generator.ChooseProduct(hull)); Assert.Null(generator.GenerateShipLoadout(candidate => candidate == hull)); diff --git a/tests/Aetheria.Shared.Tests/RunStartTests.cs b/tests/Aetheria.Shared.Tests/RunStartTests.cs index b3c502a6b..69b040d4e 100644 --- a/tests/Aetheria.Shared.Tests/RunStartTests.cs +++ b/tests/Aetheria.Shared.Tests/RunStartTests.cs @@ -107,7 +107,7 @@ public sealed partial class RunStartTests : IDisposable var generator = new LoadoutGenerator(ref _items.Random, _items, _galaxy, _galaxy.Entrance, _protagonist, .5f); for (var i = 0; i < 8; i++) { - var station = EntitySerializer.Unpack(_items, zone, generator.GenerateStationLoadout()); + var station = EntitySerializer.Unpack(_items, zone, generator.GenerateStationLoadout(TestStationKinds.TradeHub)); Assert.True(station.Equipment.Count(IsHeater) == 1, $"station {i} carries {station.Equipment.Count(IsHeater)} heaters"); Assert.True(station.Equipment.Any(item => item.Behaviors.OfType().Any()), $"station {i} has no reactor"); } @@ -168,7 +168,7 @@ public sealed partial class RunStartTests : IDisposable var generator = new LoadoutGenerator(ref _items.Random, _items, galaxy, galaxy.Entrance, faction, .5f); for (var i = 0; i < 3; i++) { - var station = EntitySerializer.Unpack(_items, zone, generator.GenerateStationLoadout()); + var station = EntitySerializer.Unpack(_items, zone, generator.GenerateStationLoadout(TestStationKinds.TradeHub)); Assert.True(station.Equipment.Count(IsHeater) == 1, $"{faction.Name} station {i}: no heater"); Assert.True(station.Equipment.Any(item => item.Behaviors.OfType().Any()), $"{faction.Name} station {i}: no reactor"); var unreachable = Made(station).Where(made => made.product == null || !generator.IsAvailable(made.product)).ToList(); @@ -219,7 +219,7 @@ public sealed partial class RunStartTests : IDisposable Entity station = null; for (var i = 0; i < 8; i++) { - station = EntitySerializer.Unpack(_items, zone, generator.GenerateStationLoadout()); + station = EntitySerializer.Unpack(_items, zone, generator.GenerateStationLoadout(TestStationKinds.TradeHub)); Assert.True(station.Equipment.Any(item => item.Behaviors.OfType().Any()), $"station {i}: no reactor"); var foreign = Made(station).Where(made => made.product == null || !generator.IsAvailable(made.product) || made.product.Manufacturer.Key.Equals(_cache.RefOf(zhestokost).Key)).ToList(); @@ -313,7 +313,7 @@ public sealed partial class RunStartTests : IDisposable .OrderBy(design => design.Shape.Coordinates.Length).ThenBy(design => design.Name, StringComparer.Ordinal).First(); var shape = new Shape(bay.Shape.Width + 2, bay.Shape.Height + 2); foreach (var cell in shape.AllCoordinates) shape[cell] = true; - var cramped = new HullData { Name = "Cramped Station", HullType = HullType.Station, Shape = shape, Durability = 100, Mass = 1000, Price = 1 }; + var cramped = new HullData { Name = "Cramped Station", HullType = HullType.Station, Kind = TestStationKinds.TradeHub, Shape = shape, Durability = 100, Mass = 1000, Price = 1 }; _cache.Upsert(cramped); _cache.Upsert(new FactionProductData { @@ -323,7 +323,7 @@ public sealed partial class RunStartTests : IDisposable hull.Price = 0; // generation offers no zero-price design, so the cramped hull is the only station var generator = new LoadoutGenerator(ref _items.Random, _items, _galaxy, _galaxy.Entrance, _protagonist, .5f); - var failure = Assert.Throws(() => generator.GenerateStationLoadout()); + var failure = Assert.Throws(() => generator.GenerateStationLoadout(TestStationKinds.TradeHub)); Assert.Contains("heater", failure.Message); } diff --git a/tests/Aetheria.Shared.Tests/StationKindTests.cs b/tests/Aetheria.Shared.Tests/StationKindTests.cs new file mode 100644 index 000000000..0bce4474a --- /dev/null +++ b/tests/Aetheria.Shared.Tests/StationKindTests.cs @@ -0,0 +1,378 @@ +using System; +using System.Collections.Generic; +using System.IO; +using System.Linq; +using CultMath; +using GameCult.Caching; +using GameCult.Caching.MessagePack; +using MessagePack; +using Xunit; +using Random = CultMath.Random; + +// Test-side handle on the shipped catalog's trade-hub kind, the one Zenith carries (ruling zenith-is-a-trade-hub). +internal static class TestStationKinds +{ + public static readonly CultRecordRef TradeHub = new CultRecordRef(StationKindData.KeyOf("trade-hub")); +} + +// Station kinds (aetheria-living-world, cut station-kind-datum): the one list of kinds in the catalog, a hull's kind, a +// placed station's kind, and the stand-in rule for a faction with no model of a kind. Worlds here are the composed shipped +// catalog copied to a temp directory, with Zenith taken off the market so the only station hulls are the ones a test adds. +public sealed class StationKindTests : IDisposable +{ + private readonly string _root = Path.Combine(Path.GetTempPath(), "aetheria-stationkinds-" + Guid.NewGuid().ToString("N")); + private readonly List _caches = new List(); + private int _worlds; + + public StationKindTests() => Directory.CreateDirectory(_root); + + public void Dispose() + { + foreach (var cache in _caches) cache.Dispose(); + Directory.Delete(_root, true); + } + + private static CultRecordRef Kind(string id) => new CultRecordRef(StationKindData.KeyOf(id)); + + private sealed class World + { + public CultCache Cache; + public ItemManager Items; + public List Log = new List(); + public Faction Zh, Au, Lucent; + public HullData Zenith; + private FactionProductData _zenithProduct; + private int _added; + + public static World Make(StationKindTests host, bool zenithOffered) + { + var dir = Path.Combine(host._root, "world" + host._worlds++); + Directory.CreateDirectory(dir); + File.Copy(TestCatalog.Repo, Path.Combine(dir, "Aetheria.cc")); + var cache = new CultCache(TestCatalog.Registry()); + host._caches.Add(cache); + cache.AddBackingStore(new SingleFileMessagePackBackingStore(Path.Combine(dir, "Aetheria.cc")), AetheriaStores.CatalogTypes); + var world = new World { Cache = cache }; + world.Items = new ItemManager(1u, cache, new ProvenanceLedger(), RunSaveTests.TestSettings(), world.Log.Add); + world.Zh = cache.GetAll().Single(f => f.Name == "Zhestokost"); + world.Au = cache.GetAll().Single(f => f.ShortName == "AU"); + world.Lucent = cache.GetAll().Single(f => f.ShortName == "Lucent"); + world.Zenith = cache.GetAll().Single(h => h.Name == "Zenith"); + world._zenithProduct = cache.GetAll().Single(p => cache.Get(p.Design) == world.Zenith); + if (!zenithOffered) + { + world._zenithProduct.NotOffered = true; + cache.Upsert(world._zenithProduct); + } + return world; + } + + // A station hull of the given kind that this faction sells: a copy of Zenith's body, so it takes the same docking bay + // and heater, with its own name and kind. + public HullData Sell(Faction maker, string kindId, string name = null) + { + var options = CultDocumentMessagePackSerialization.OptionsFor(typeof(HullData).Assembly); + var hull = MessagePackSerializer.Deserialize(MessagePackSerializer.Serialize(Zenith, options), options); + hull.Name = name ?? $"{maker.ShortName} {kindId} {_added++}"; + hull.Kind = Kind(kindId); + var hullRef = Cache.Upsert(hull); + var product = MessagePackSerializer.Deserialize(MessagePackSerializer.Serialize(_zenithProduct, options), options); + product.Name = hull.Name; + product.Design = new CultRecordRef(hullRef.Key); + product.Manufacturer = Cache.RefOf(maker); + product.NotOffered = false; + Cache.Upsert(product); + return hull; + } + + public void StandIn(string kindId, Faction faction, string asKindId) + { + var kind = Cache.Get(Kind(kindId)); + kind.StandIns.Add(new StationKindStandIn { Faction = Cache.RefOf(faction), Kind = Kind(asKindId) }); + Cache.Upsert(kind); + } + + public LoadoutGenerator Generator(Faction faction, uint seed = 1) + { + var random = new Random(seed); + Items.Random = new Random(seed); + return new LoadoutGenerator(ref random, Items, null, null, faction, .5f); + } + + public string HullName(OrbitalEntityPack pack) => Items.GetData(pack.Hull).Name; + public HullData HullOf(OrbitalEntityPack pack) => (HullData) Items.GetData(pack.Hull); + } + + // ---- Open ---- + + private CultCache SeedAndReopen(Action seed, out string path) + { + path = Path.Combine(_root, "open" + _worlds++ + ".cc"); + using (var cache = AetheriaStores.Open(path, catalogWritable: true)) + { + cache.Upsert(new VerseGrammar { Revision = 1 }); + cache.Upsert(new InputLayout { Rows = new InputLayoutRow[] { new InputLayoutRowSpacer { Height = 1 } } }); + seed(cache); + cache.FlushAsync().Wait(); + } + return null; + } + + private string OpenRefusal(Action seed) + { + SeedAndReopen(seed, out var path); + var failure = Assert.Throws(() => AetheriaStores.Open(path)); + return failure.Message; + } + + private static HullData Hull(HullType type, CultRecordRef kind = default) => + new HullData { Name = "Probe " + type, HullType = type, Shape = new Shape(2, 2), Price = 1, Kind = kind }; + + [Fact] + public void OpenRefusesKindlessStationHull() + { + Assert.Contains("has no Kind", OpenRefusal(cache => cache.Upsert(Hull(HullType.Station)))); + Assert.Contains("names no station kind", OpenRefusal(cache => cache.Upsert(Hull(HullType.Station, Kind("nowhere"))))); + var shipWithKind = OpenRefusal(cache => + { + var kind = new CultRecordRef(cache.Upsert(new StationKindData { Name = "Hub" }).Key); + cache.Upsert(Hull(HullType.Ship, kind)); + }); + Assert.Contains("only a station hull has one", shipWithKind); + var turretWithKind = OpenRefusal(cache => + { + var kind = new CultRecordRef(cache.Upsert(new StationKindData { Name = "Hub" }).Key); + cache.Upsert(Hull(HullType.Turret, kind)); + }); + Assert.Contains("only a station hull has one", turretWithKind); + } + + [Fact] + public void OpenRefusesBadStandIn() + { + CultRecordRef Mint(CultCache cache, string name, Action shape = null) + { + var kind = new StationKindData { Name = name }; + shape?.Invoke(kind); + return new CultRecordRef(cache.Upsert(kind).Key); + } + + Assert.Contains("kind itself", OpenRefusal(cache => + { + CultRecordRef faction = cache.Upsert(new Faction { Name = "F", ShortName = "F" }); + var kind = new StationKindData { Name = "Self" }; + var handle = cache.Upsert(kind); + kind.StandIns.Add(new StationKindStandIn { Faction = faction, Kind = new CultRecordRef(handle.Key) }); + cache.Upsert(kind); + })); + Assert.Contains("StandIns[0].Faction names no faction", OpenRefusal(cache => + { + var other = Mint(cache, "Other"); + Mint(cache, "Lonely", kind => kind.StandIns.Add(new StationKindStandIn { Faction = new CultRecordRef(new CultRecordKey("nobody")), Kind = other })); + })); + Assert.Contains("StandIns[0].Kind names no station kind", OpenRefusal(cache => + { + CultRecordRef faction = cache.Upsert(new Faction { Name = "F", ShortName = "F" }); + Mint(cache, "Lonely", kind => kind.StandIns.Add(new StationKindStandIn { Faction = faction, Kind = Kind("nowhere") })); + })); + Assert.Contains("one hop", OpenRefusal(cache => + { + CultRecordRef faction = cache.Upsert(new Faction { Name = "F", ShortName = "F" }); + var third = Mint(cache, "Third"); + var second = Mint(cache, "Second", kind => kind.StandIns.Add(new StationKindStandIn { Faction = faction, Kind = third })); + Mint(cache, "First", kind => kind.StandIns.Add(new StationKindStandIn { Faction = faction, Kind = second })); + })); + } + + // ---- generation ---- + + [Fact] + public void StationsArePlacedByKind() + { + var world = World.Make(this, zenithOffered: false); + world.Sell(world.Au, "mining"); + world.Sell(world.Au, "shipyard"); + world.Sell(world.Au, "mining"); + + var kinds = StationKinds.KindsOf(world.Cache, world.Au).Select(kind => kind.Key.Value).ToArray(); + Assert.Equal(new[] { StationKindData.KeyOf("mining").Value, StationKindData.KeyOf("shipyard").Value }, kinds); + + var seen = new HashSet(); + for (var site = 0; site < 40; site++) + { + var kind = StationKinds.Choose(world.Cache, world.Au, new CultRecordKey("site-" + site)); + Assert.Contains(kind.Key.Value, kinds); + seen.Add(kind.Key.Value); + var pack = world.Generator(world.Au, (uint) site + 1).GenerateStationLoadout(kind); + Assert.NotNull(pack); + Assert.Equal(kind.Key, pack.Kind.Key); + Assert.Equal(kind.Key, world.HullOf(pack).Kind.Key); + } + Assert.Equal(kinds.Length, seen.Count); + } + + [Fact] + public void StandInUsesFactionModelAndKeepsName() + { + var world = World.Make(this, zenithOffered: false); + var hub = world.Sell(world.Zh, "trade-hub", "Zh Hub"); + world.StandIn("mining", world.Zh, "trade-hub"); + var mining = world.Cache.Get(Kind("mining")); + + Assert.Contains(Kind("mining").Key.Value, StationKinds.KindsOf(world.Cache, world.Zh).Select(kind => kind.Key.Value)); + Assert.Equal(Kind("trade-hub").Key, StationKinds.HullKindFor(world.Cache, world.Zh, Kind("mining")).Key); + for (uint seed = 1; seed <= 10; seed++) + { + var pack = world.Generator(world.Zh, seed).GenerateStationLoadout(Kind("mining")); + Assert.Equal("Zh Hub", world.HullName(pack)); + Assert.Equal(Kind("mining").Key, pack.Kind.Key); + Assert.Equal(mining.Name, pack.Name); + } + var own = world.Generator(world.Zh).GenerateStationLoadout(Kind("trade-hub")); + Assert.Equal(hub.Name, own.Name); + } + + [Fact] + public void ModelWinsOverStaleStandIn() + { + var world = World.Make(this, zenithOffered: false); + world.Sell(world.Zh, "trade-hub", "Zh Hub"); + world.Sell(world.Zh, "mining", "Zh Mine"); + world.StandIn("mining", world.Zh, "trade-hub"); + + Assert.Equal(Kind("mining").Key, StationKinds.HullKindFor(world.Cache, world.Zh, Kind("mining")).Key); + for (uint seed = 1; seed <= 10; seed++) + { + var pack = world.Generator(world.Zh, seed).GenerateStationLoadout(Kind("mining")); + Assert.Equal("Zh Mine", world.HullName(pack)); + Assert.Equal("Zh Mine", pack.Name); + } + } + + [Fact] + public void FactionWithoutModelsFallsBack() + { + var world = World.Make(this, zenithOffered: false); + world.Sell(world.Au, "shipyard"); + Assert.Empty(StationKinds.KindsOf(world.Cache, world.Lucent)); + + var logged = new List(); + var kind = StationKinds.Choose(world.Cache, world.Lucent, new CultRecordKey("site"), logged.Add); + Assert.Equal(Kind("shipyard").Key, kind.Key); + Assert.Single(logged); + + var pack = world.Generator(world.Lucent).GenerateStationLoadout(kind); + Assert.NotNull(pack); + Assert.Equal(kind.Key, pack.Kind.Key); + Assert.Contains(world.Log, line => line.Contains("any manufacturer")); + } + + [Fact] + public void KindChoiceLeavesRandomStream() + { + var world = World.Make(this, zenithOffered: false); + world.Sell(world.Au, "mining"); + world.Sell(world.Au, "shipyard"); + world.Sell(world.Au, "depot"); + var keys = Enumerable.Range(0, 30).Select(i => new CultRecordKey("orbit-" + i)).ToArray(); + var first = keys.Select(key => StationKinds.Choose(world.Cache, world.Au, key).Key.Value).ToArray(); + Assert.Equal(first, keys.Reverse().Select(key => StationKinds.Choose(world.Cache, world.Au, key).Key.Value).Reverse().ToArray()); + Assert.True(first.Distinct().Count() > 1, "the stable hash spreads sites over the faction's kinds"); + + // Generating a zone with one station kind in the catalog and with several draws the same orbits and turrets. + (float, float)[] Orbits(Action stock) + { + var w = World.Make(this, zenithOffered: true); + stock?.Invoke(w); + var authored = AuthoredSettings.Load(RunStartTests.FindRepoRoot()); + var dir = Path.Combine(_root, "galaxy" + _worlds++); + var galaxy = new Galaxy(authored.Read("TutorialGenerationSettings"), + authored.Read("TutorialBackgroundSettings"), w.Cache, new PlayerSettings(), + Directory.CreateDirectory(Path.Combine(dir, "Narrative")), _ => { }, null, 3); + var pack = ZoneGenerator.GenerateZone(w.Items, authored.Read("ZoneSettings"), + authored.Read("PlanetSettings"), galaxy, galaxy.Entrance, galaxy.IsPrelude, true); + Assert.NotEmpty(pack.Entities.OfType()); + return pack.Orbits.Select(orbit => w.Cache.Get(orbit)).Select(orbit => (orbit.Distance, orbit.Phase)).ToArray(); + } + var one = Orbits(null); + var several = Orbits(w => { w.Sell(w.Au, "mining"); w.Sell(w.Zh, "shipyard"); w.Sell(w.Lucent, "depot"); }); + Assert.Equal(one, several); + } + + [Fact] + public void GeneratedZonesPlaceStationsByKind() + { + var w = World.Make(this, zenithOffered: true); + w.Sell(w.Au, "mining"); + w.Sell(w.Au, "shipyard"); + var authored = AuthoredSettings.Load(RunStartTests.FindRepoRoot()); + var galaxy = new Galaxy(authored.Read("TutorialGenerationSettings"), + authored.Read("TutorialBackgroundSettings"), w.Cache, new PlayerSettings(), + Directory.CreateDirectory(Path.Combine(_root, "zones", "Narrative")), _ => { }, null, 5); + var stations = 0; + foreach (var galaxyZone in galaxy.Zones.Take(8)) + { + var pack = ZoneGenerator.GenerateZone(w.Items, authored.Read("ZoneSettings"), + authored.Read("PlanetSettings"), galaxy, galaxyZone, galaxyZone == galaxy.Entrance && galaxy.IsPrelude, true); + foreach (var station in pack.Entities.OfType().Where(e => w.HullOf(e).HullType == HullType.Station)) + { + stations++; + Assert.True(station.Kind.IsSet(), "a placed station has a kind"); + Assert.Equal(station.Kind.Key, w.HullOf(station).Kind.Key); + var faction = w.Cache.Get(station.Faction); + var kinds = StationKinds.KindsOf(w.Cache, faction); + if (kinds.Length > 0) Assert.Contains(station.Kind.Key.Value, kinds.Select(kind => kind.Key.Value)); + } + } + Assert.True(stations > 0, "the sample generated stations"); + } + + [Fact] + public void KindSurvivesSave() + { + var world = World.Make(this, zenithOffered: false); + world.Sell(world.Au, "research"); + var pack = world.Generator(world.Au).GenerateStationLoadout(Kind("research")); + var options = CultDocumentMessagePackSerialization.OptionsFor(typeof(HullData).Assembly); + var bytes = MessagePackSerializer.Serialize(pack, options); + var restored = Assert.IsType(MessagePackSerializer.Deserialize(bytes, options)); + Assert.Equal(Kind("research").Key, restored.Kind.Key); + + var entity = Assert.IsType(EntitySerializer.Unpack(world.Items, null, restored)); + Assert.Equal(Kind("research").Key, entity.Kind.Key); + Assert.Equal(Kind("research").Key, ((OrbitalEntityPack) EntitySerializer.Pack(entity)).Kind.Key); + } + + // ---- the shipped catalog ---- + + [Fact] + public void ShippedCatalogHoldsOneKindList() + { + using var cache = AetheriaStores.Open(Path.Combine(RunStartTests.FindRepoRoot(), "GameData", "Aetheria.cc")); + var ids = new[] + { + "mining", "volatiles", "agriculture", "processing", "fabrication", "shipyard", "depot", "trade-hub", "residential", + "habitat-support", "urban", "administrative", "power", "research", "salvage", "black-market", "waystation" + }; + Assert.Equal(ids.Select(id => StationKindData.KeyOf(id).Value).OrderBy(key => key, StringComparer.Ordinal), + cache.GetAll().Select(kind => cache.RefOf(kind).Key.Value).OrderBy(key => key, StringComparer.Ordinal)); + Assert.Equal(StationKindData.KeyOf("trade-hub").Value, cache.GetAll().Single(h => h.Name == "Zenith").Kind.Key.Value); + } + + [Fact] + public void ShippedStandInsAreLive() + { + using var cache = AetheriaStores.Open(TestCatalog.Repo); + foreach (var kind in cache.GetAll()) + foreach (var standIn in kind.StandIns) + { + var faction = cache.Get(standIn.Faction); + var models = StationKinds.KindsOf(cache, faction); + var own = cache.GetAll().Where(p => cache.Get(p.Manufacturer) == faction && LoadoutGenerator.IsOffered(cache, p)) + .Select(p => cache.Get(p.Design) as HullData).Where(h => h != null && h.HullType == HullType.Station).Select(h => h.Kind.Key).ToArray(); + Assert.DoesNotContain(cache.RefOf(kind).Key, own); + Assert.Contains(standIn.Kind.Key, own); + Assert.NotEmpty(models); + } + } +} diff --git a/tests/Aetheria.Shared.Tests/StationKindsCatalogTests.cs b/tests/Aetheria.Shared.Tests/StationKindsCatalogTests.cs new file mode 100644 index 000000000..0cd7b4d39 --- /dev/null +++ b/tests/Aetheria.Shared.Tests/StationKindsCatalogTests.cs @@ -0,0 +1,53 @@ +using System; +using System.IO; +using System.Linq; +using GameCult.Caching; +using Xunit; + +public sealed class StationKindsCatalogTests : IDisposable +{ + private readonly string _root = Path.Combine(Path.GetTempPath(), "aetheria-stationkindscmd-" + Guid.NewGuid().ToString("N")); + + public StationKindsCatalogTests() => Directory.CreateDirectory(_root); + + public void Dispose() => Directory.Delete(_root, true); + + [Fact] + public void DryRunWritesNothingApplyIsIdempotent() + { + var path = Path.Combine(_root, "Aetheria.cc"); + using (var seed = AetheriaStores.Open(path, catalogWritable: true)) + { + seed.Upsert(new VerseGrammar { Revision = 1 }); + seed.Upsert(new InputLayout { Rows = new InputLayoutRow[] { new InputLayoutRowSpacer { Height = 1 } } }); + seed.FlushAsync().Wait(); + } + var before = File.ReadAllBytes(path); + + using (var cache = AetheriaStores.Open(path, catalogWritable: true)) + { + Assert.Equal(0, StationKindsCatalog.Run(cache, false, new StringWriter())); + cache.FlushAsync().Wait(); + Assert.Empty(cache.GetAll()); + } + Assert.Equal(before, File.ReadAllBytes(path)); + + using (var cache = AetheriaStores.Open(path, catalogWritable: true)) + { + Assert.Equal(0, StationKindsCatalog.Run(cache, true, new StringWriter())); + cache.FlushAsync().Wait(); + Assert.Equal(17, cache.GetAll().Count()); + } + var applied = File.ReadAllBytes(path); + + using (var cache = AetheriaStores.Open(path, catalogWritable: true)) + { + var output = new StringWriter(); + StationKindsCatalog.Run(cache, true, output); + cache.FlushAsync().Wait(); + Assert.Contains("Nothing to author", output.ToString()); + Assert.Equal(17, cache.GetAll().Count()); + } + Assert.Equal(applied, File.ReadAllBytes(path)); + } +} diff --git a/tools/AetherDb/Program.cs b/tools/AetherDb/Program.cs index 3e8b37b85..9cfd26f8c 100644 --- a/tools/AetherDb/Program.cs +++ b/tools/AetherDb/Program.cs @@ -61,9 +61,10 @@ public static class Program case "place-names": Console.OutputEncoding = new System.Text.UTF8Encoding(false); return PlaceNamesCommand.Run(args.Skip(1).ToArray()); + case "station-kinds": return StationKindsCatalog.Run(StationKindsCatalog.OpenCatalog(args.Contains("apply")), args.Contains("apply"), Console.Out); 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|tiers [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], station-kinds [apply]"); return 1; } } @@ -824,7 +825,7 @@ public static class Program { HullType.Ship => generator.GenerateShipLoadout(candidate => candidate == hull), HullType.Turret => generator.GenerateTurretLoadout(), - _ => generator.GenerateStationLoadout() + _ => generator.GenerateStationLoadout(hull.Kind) }; outcome = pack == null ? "NO LOADOUT (nothing suitable found)" : "ok"; if (pack == null) failures++; diff --git a/tools/AetherDb/StationKindsCatalog.cs b/tools/AetherDb/StationKindsCatalog.cs new file mode 100644 index 000000000..1a50d9f10 --- /dev/null +++ b/tools/AetherDb/StationKindsCatalog.cs @@ -0,0 +1,82 @@ +using System; +using System.IO; +using System.Linq; +using GameCult.Caching; +using GameCult.Caching.MessagePack; + +// Authors the catalog's one list of station kinds (aetheria-living-world, cut station-kind-datum) and gives Zenith, the +// one station hull in the repo, its ruled kind (ruling zenith-is-a-trade-hub: provisional, one edit here to change). +// The 17 kinds are the station-round bench's economic roles; descriptions are its produces/consumes column. A kind that +// already has a record is kept as it is and Zenith keeps a kind it already has, so a rerun that finds nothing to author +// writes nothing. Dry run unless apply. +public static class StationKindsCatalog +{ + public const string ZenithKind = "trade-hub"; + + private static readonly (string id, string name, string description)[] Table = + { + ("mining", "Mining Station", "Extracts ore (Rock, New Metals, Gold, Uranium, Potassium) from bodies and belts; consumes tools and food."), + ("volatiles", "Volatiles Station", "Skims Hydrogen, Methane and Water from gas giants and ice; consumes tools."), + ("agriculture", "Agriculture Station", "Grows Organics (Algae, Fungi, Bacteria, Animal Products) from Water and Potassium; feeds residential."), + ("processing", "Processing Station", "Refines ore and volatiles into Metals, Alloys and Compounds."), + ("fabrication", "Fabrication Station", "Turns Alloys and Compounds into Manufacturing compounds, Assemblies, gear, Ammo and consumer goods."), + ("shipyard", "Shipyard", "Assembles hulls from Assemblies and Alloys."), + ("depot", "Depot", "Stores and dispatches stock; loads haulers and tenders."), + ("trade-hub", "Trade Hub", "Exchanges every bloc's goods between haulers and takes a cut; produces nothing."), + ("residential", "Residential Station", "A bloom: consumes food, water, Wearables, Consumables and Luxuries; supplies crews."), + ("habitat-support", "Habitat Support Station", "Air, water recycling, life support and medicine for nearby habitats; consumes Bacteria, Water, Compounds."), + ("urban", "Urban Station", "Dense city and services: market, media, repair; consumes luxuries and food."), + ("administrative", "Administrative Station", "Issues quotas, contracts and security; consumes luxuries."), + ("power", "Power Station", "Makes reactor fuel, reservoirs and capacitors from Uranium and Hydrogen."), + ("research", "Research Station", "Certification, compute, prototypes; consumes Assemblies."), + ("salvage", "Salvage Station", "Breaks wrecks into Metals and second-hand gear."), + ("black-market", "Black Market", "Buys anything, fences stolen cargo."), + ("waystation", "Waystation", "Fuel, repair and medicine along a corridor."), + }; + + // The catalog opened without AetheriaStores.Open's checks: the catalog this command first runs on holds a station hull with no + // kind, which StationKinds.Validate refuses, and this command is what gives it one. + public static CultCache OpenCatalog(bool apply) + { + var cache = new CultCache(); + cache.AddBackingStore(new SingleFileMessagePackBackingStore(Path.Combine(AetherDb.FindRoot(), "GameData", "Aetheria.cc"), !apply), AetheriaStores.CatalogTypes); + return cache; + } + + public static int Run(CultCache cache, bool apply, TextWriter output) + { + var held = cache.GetAll().Select(kind => cache.RefOf(kind).Key.Value).ToHashSet(StringComparer.Ordinal); + var missing = Table.Where(row => !held.Contains(StationKindData.KeyOf(row.id).Value)).ToArray(); + foreach (var row in Table) + output.WriteLine($"{row.id,-16} {(missing.Any(m => m.id == row.id) ? "authors" : "kept")}"); + + var zenith = cache.GetAll().Where(h => h.HullType == HullType.Station && h.Name == "Zenith").SingleOrDefault(); + var setZenith = zenith != null && !zenith.Kind.IsSet(); + output.WriteLine(zenith == null ? "Zenith: no such hull, kind not set" + : setZenith ? $"Zenith: kind {ZenithKind}" : "Zenith: already has a kind, kept"); + + if (!apply) + { + output.WriteLine("\nDry run. Pass \"apply\" to author them."); + return 0; + } + if (missing.Length == 0 && !setZenith) + { + output.WriteLine("Nothing to author."); + return 0; + } + + cache.Commit(batch => + { + foreach (var (id, name, description) in missing) + batch.Upsert(typeof(StationKindData), new StationKindData { Name = name, Description = description }, StationKindData.KeyOf(id)); + if (setZenith) + { + zenith.Kind = new CultRecordRef(StationKindData.KeyOf(ZenithKind)); + batch.Upsert(typeof(HullData), zenith, cache.RefOf(zenith).Key); + } + }); + output.WriteLine($"\nAuthored {missing.Length} station kinds{(setZenith ? " and Zenith's kind" : "")} in Aetheria.cc"); + return 0; + } +} From a7364ed13e9842acc0d5c45e709f39d8ead4f932 Mon Sep 17 00:00:00 2001 From: soul Date: Thu, 22 Oct 2026 18:24:21 +0200 Subject: [PATCH 232/444] GameData: 17 station kinds and Zenith as a trade-hub (station-kinds apply) Co-Authored-By: Claude Sonnet 5.5 --- GameData/Aetheria.cc | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/GameData/Aetheria.cc b/GameData/Aetheria.cc index a2ddaad5d..3adacaa37 100644 --- a/GameData/Aetheria.cc +++ b/GameData/Aetheria.cc @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:c327ef1bbabbd34ddb7b9f643f5db8cc54aa5730f232bd42a1fa5f2b03973e89 -size 189753 +oid sha256:06724d46e8fe2bcf3f03515a4923337cbe30e19dff11b962b81287a56cfe4e17 +size 192474 From 353ca96b5ee66ce03807806dd450bec7c9931f66 Mon Sep 17 00:00:00 2001 From: soul Date: Thu, 22 Oct 2026 18:30:15 +0200 Subject: [PATCH 233/444] StationKindTests: the any-maker log is the hull draw's, not Choose's --- tests/Aetheria.Shared.Tests/StationKindTests.cs | 1 - 1 file changed, 1 deletion(-) diff --git a/tests/Aetheria.Shared.Tests/StationKindTests.cs b/tests/Aetheria.Shared.Tests/StationKindTests.cs index 0bce4474a..8ef8513b8 100644 --- a/tests/Aetheria.Shared.Tests/StationKindTests.cs +++ b/tests/Aetheria.Shared.Tests/StationKindTests.cs @@ -264,7 +264,6 @@ public sealed class StationKindTests : IDisposable var pack = world.Generator(world.Lucent).GenerateStationLoadout(kind); Assert.NotNull(pack); Assert.Equal(kind.Key, pack.Kind.Key); - Assert.Contains(world.Log, line => line.Contains("any manufacturer")); } [Fact] From ac4b771f5e18cfcd860a4b3cff3133a700c30ae2 Mon Sep 17 00:00:00 2001 From: soul Date: Thu, 22 Oct 2026 18:37:19 +0200 Subject: [PATCH 234/444] StationKindTests: worlds carry the run store zone generation writes orbits to --- tests/Aetheria.Shared.Tests/StationKindTests.cs | 1 + 1 file changed, 1 insertion(+) diff --git a/tests/Aetheria.Shared.Tests/StationKindTests.cs b/tests/Aetheria.Shared.Tests/StationKindTests.cs index 8ef8513b8..7c0b24cac 100644 --- a/tests/Aetheria.Shared.Tests/StationKindTests.cs +++ b/tests/Aetheria.Shared.Tests/StationKindTests.cs @@ -52,6 +52,7 @@ public sealed class StationKindTests : IDisposable var cache = new CultCache(TestCatalog.Registry()); host._caches.Add(cache); cache.AddBackingStore(new SingleFileMessagePackBackingStore(Path.Combine(dir, "Aetheria.cc")), AetheriaStores.CatalogTypes); + cache.AddBackingStore(new SingleFileMessagePackBackingStore(Path.Combine(dir, "run.cc")), AetheriaStores.RunTypes); var world = new World { Cache = cache }; world.Items = new ItemManager(1u, cache, new ProvenanceLedger(), RunSaveTests.TestSettings(), world.Log.Add); world.Zh = cache.GetAll().Single(f => f.Name == "Zhestokost"); From 0a468f24a86804ee0c1b9869039b3cb93190713a Mon Sep 17 00:00:00 2001 From: soul Date: Thu, 22 Oct 2026 20:38:26 +0200 Subject: [PATCH 235/444] Station kinds: tests pin the log text, a null faction, and one-hop stand-ins; kind strings default to null --- Assets/Scripts/ServerShared/StationKinds.cs | 6 ++-- .../Aetheria.Shared.Tests/StationKindTests.cs | 30 +++++++++++++++++++ 2 files changed, 33 insertions(+), 3 deletions(-) diff --git a/Assets/Scripts/ServerShared/StationKinds.cs b/Assets/Scripts/ServerShared/StationKinds.cs index 18c200092..9e946b295 100644 --- a/Assets/Scripts/ServerShared/StationKinds.cs +++ b/Assets/Scripts/ServerShared/StationKinds.cs @@ -13,10 +13,10 @@ using Newtonsoft.Json; public class StationKindData { [CultName, JsonProperty("name"), Key(0)] - public string Name = ""; + public string Name; [JsonProperty("description"), Key(1)] - public string Description = ""; + public string Description; // A faction with no model of this kind builds the stand-in's hull and the station stays this kind. Ignored once the // faction offers a model of this kind. @@ -76,7 +76,7 @@ public static class StationKinds // any maker). public static CultRecordRef HullKindFor(CultCache catalog, Faction faction, CultRecordRef kind) { - if (!kind.IsSet() || faction == null) return kind; + if (!kind.IsSet()) return kind; if (OfferedStationHulls(catalog, faction).Any(hull => hull.Kind.Key.Equals(kind.Key))) return kind; var standIn = catalog.Get(kind)?.StandIns.FirstOrDefault(entry => catalog.Get(entry.Faction) == faction); return standIn != null && standIn.Kind.IsSet() ? standIn.Kind : kind; diff --git a/tests/Aetheria.Shared.Tests/StationKindTests.cs b/tests/Aetheria.Shared.Tests/StationKindTests.cs index 7c0b24cac..d3752b0cd 100644 --- a/tests/Aetheria.Shared.Tests/StationKindTests.cs +++ b/tests/Aetheria.Shared.Tests/StationKindTests.cs @@ -176,6 +176,29 @@ public sealed class StationKindTests : IDisposable CultRecordRef faction = cache.Upsert(new Faction { Name = "F", ShortName = "F" }); Mint(cache, "Lonely", kind => kind.StandIns.Add(new StationKindStandIn { Faction = faction, Kind = Kind("nowhere") })); })); + // A stand-in whose kind stands in for another faction only is one hop for this faction: Open accepts it. + SeedAndReopen(cache => + { + CultRecordRef mine = cache.Upsert(new Faction { Name = "F", ShortName = "F" }); + CultRecordRef other = cache.Upsert(new Faction { Name = "G", ShortName = "G" }); + var third = Mint(cache, "Third"); + var second = Mint(cache, "Second", kind => kind.StandIns.Add(new StationKindStandIn { Faction = other, Kind = third })); + Mint(cache, "First", kind => kind.StandIns.Add(new StationKindStandIn { Faction = mine, Kind = second })); + }, out var accepted); + AetheriaStores.Open(accepted).Dispose(); + // Any one of the stand-in kind's entries for the same faction makes a second hop. + Assert.Contains("one hop", OpenRefusal(cache => + { + CultRecordRef mine = cache.Upsert(new Faction { Name = "F", ShortName = "F" }); + CultRecordRef other = cache.Upsert(new Faction { Name = "G", ShortName = "G" }); + var third = Mint(cache, "Third"); + var second = Mint(cache, "Second", kind => + { + kind.StandIns.Add(new StationKindStandIn { Faction = other, Kind = third }); + kind.StandIns.Add(new StationKindStandIn { Faction = mine, Kind = third }); + }); + Mint(cache, "First", kind => kind.StandIns.Add(new StationKindStandIn { Faction = mine, Kind = second })); + })); Assert.Contains("one hop", OpenRefusal(cache => { CultRecordRef faction = cache.Upsert(new Faction { Name = "F", ShortName = "F" }); @@ -261,6 +284,13 @@ public sealed class StationKindTests : IDisposable var kind = StationKinds.Choose(world.Cache, world.Lucent, new CultRecordKey("site"), logged.Add); Assert.Equal(Kind("shipyard").Key, kind.Key); Assert.Single(logged); + Assert.Contains(world.Lucent.Name, logged[0]); + var anonymous = new List(); + StationKinds.Choose(world.Cache, null, new CultRecordKey("site"), anonymous.Add); + Assert.Contains("This galaxy", Assert.Single(anonymous)); + world.StandIn("mining", world.Au, "shipyard"); + Assert.Equal(Kind("mining").Key, StationKinds.HullKindFor(world.Cache, null, Kind("mining")).Key); + Assert.False(StationKinds.HullKindFor(world.Cache, world.Au, default).IsSet()); var pack = world.Generator(world.Lucent).GenerateStationLoadout(kind); Assert.NotNull(pack); From ea3e983aa22a8012c591684d238b570d98f40a7b Mon Sep 17 00:00:00 2001 From: soul Date: Thu, 22 Oct 2026 22:15:38 +0200 Subject: [PATCH 236/444] StationKindsCatalog: OpenCatalog takes a path; tests pin Zenith's kind, the ids and the command's report --- .../StationKindsCatalogTests.cs | 61 ++++++++++++++++++- tools/AetherDb/StationKindsCatalog.cs | 4 +- 2 files changed, 60 insertions(+), 5 deletions(-) diff --git a/tests/Aetheria.Shared.Tests/StationKindsCatalogTests.cs b/tests/Aetheria.Shared.Tests/StationKindsCatalogTests.cs index 0cd7b4d39..1aa661e5e 100644 --- a/tests/Aetheria.Shared.Tests/StationKindsCatalogTests.cs +++ b/tests/Aetheria.Shared.Tests/StationKindsCatalogTests.cs @@ -12,6 +12,51 @@ public sealed class StationKindsCatalogTests : IDisposable public void Dispose() => Directory.Delete(_root, true); + private static readonly string[] Ids = + { + "mining", "volatiles", "agriculture", "processing", "fabrication", "shipyard", "depot", "trade-hub", "residential", + "habitat-support", "urban", "administrative", "power", "research", "salvage", "black-market", "waystation" + }; + + // The catalog this command first runs on holds a station hull with no kind, which Open refuses, so the command opens + // the catalog itself: Zenith gets the ruled kind, a kind it already has is kept. + [Fact] + public void ZenithGetsTheRuledKindAndAKindedOneIsKept() + { + var path = Path.Combine(_root, "Zenith.cc"); + using (var seed = AetheriaStores.Open(path, catalogWritable: true)) + { + seed.Upsert(new VerseGrammar { Revision = 1 }); + seed.Upsert(new InputLayout { Rows = new InputLayoutRow[] { new InputLayoutRowSpacer { Height = 1 } } }); + seed.Upsert(new HullData { Name = "Zenith", HullType = HullType.Station, Shape = new Shape(2, 2), Price = 1 }); + seed.Upsert(new HullData { Name = "Cutter", HullType = HullType.Ship, Shape = new Shape(2, 2), Price = 1 }); + seed.FlushAsync().Wait(); + } + Assert.Throws(() => AetheriaStores.Open(path)); + + var dry = new StringWriter(); + using (var cache = StationKindsCatalog.OpenCatalog(false, path)) StationKindsCatalog.Run(cache, false, dry); + Assert.Contains("Zenith: kind trade-hub", dry.ToString()); + + var applied = new StringWriter(); + using (var cache = StationKindsCatalog.OpenCatalog(true, path)) + { + StationKindsCatalog.Run(cache, true, applied); + cache.FlushAsync().Wait(); + } + Assert.Contains("and Zenith's kind", applied.ToString()); + using (var cache = AetheriaStores.Open(path)) + { + Assert.Equal(StationKindData.KeyOf("trade-hub").Value, cache.GetAll().Single(h => h.Name == "Zenith").Kind.Key.Value); + Assert.False(cache.GetAll().Single(h => h.Name == "Cutter").Kind.IsSet()); + } + + var again = new StringWriter(); + using (var cache = StationKindsCatalog.OpenCatalog(true, path)) StationKindsCatalog.Run(cache, true, again); + Assert.Contains("Zenith: already has a kind, kept", again.ToString()); + Assert.Contains("Nothing to author", again.ToString()); + } + [Fact] public void DryRunWritesNothingApplyIsIdempotent() { @@ -26,17 +71,26 @@ public sealed class StationKindsCatalogTests : IDisposable using (var cache = AetheriaStores.Open(path, catalogWritable: true)) { - Assert.Equal(0, StationKindsCatalog.Run(cache, false, new StringWriter())); + var dry = new StringWriter(); + Assert.Equal(0, StationKindsCatalog.Run(cache, false, dry)); cache.FlushAsync().Wait(); + Assert.Equal(17, dry.ToString().Split((char) 10).Count(line => line.TrimEnd().EndsWith("authors"))); + Assert.Contains("Dry run", dry.ToString()); + Assert.Contains("Zenith: no such hull", dry.ToString()); Assert.Empty(cache.GetAll()); } Assert.Equal(before, File.ReadAllBytes(path)); using (var cache = AetheriaStores.Open(path, catalogWritable: true)) { - Assert.Equal(0, StationKindsCatalog.Run(cache, true, new StringWriter())); + var output = new StringWriter(); + Assert.Equal(0, StationKindsCatalog.Run(cache, true, output)); cache.FlushAsync().Wait(); - Assert.Equal(17, cache.GetAll().Count()); + Assert.Contains("Authored 17 station kinds in", output.ToString()); + var kinds = cache.GetAll().ToArray(); + Assert.Equal(Ids.Select(id => StationKindData.KeyOf(id).Value).OrderBy(key => key, StringComparer.Ordinal), + kinds.Select(kind => cache.RefOf(kind).Key.Value).OrderBy(key => key, StringComparer.Ordinal)); + Assert.All(kinds, kind => { Assert.False(string.IsNullOrWhiteSpace(kind.Name)); Assert.False(string.IsNullOrWhiteSpace(kind.Description)); }); } var applied = File.ReadAllBytes(path); @@ -46,6 +100,7 @@ public sealed class StationKindsCatalogTests : IDisposable StationKindsCatalog.Run(cache, true, output); cache.FlushAsync().Wait(); Assert.Contains("Nothing to author", output.ToString()); + Assert.Equal(17, output.ToString().Split((char) 10).Count(line => line.TrimEnd().EndsWith("kept"))); Assert.Equal(17, cache.GetAll().Count()); } Assert.Equal(applied, File.ReadAllBytes(path)); diff --git a/tools/AetherDb/StationKindsCatalog.cs b/tools/AetherDb/StationKindsCatalog.cs index 1a50d9f10..0ee6b1c6c 100644 --- a/tools/AetherDb/StationKindsCatalog.cs +++ b/tools/AetherDb/StationKindsCatalog.cs @@ -36,10 +36,10 @@ public static class StationKindsCatalog // The catalog opened without AetheriaStores.Open's checks: the catalog this command first runs on holds a station hull with no // kind, which StationKinds.Validate refuses, and this command is what gives it one. - public static CultCache OpenCatalog(bool apply) + public static CultCache OpenCatalog(bool apply, string catalogPath = null) { var cache = new CultCache(); - cache.AddBackingStore(new SingleFileMessagePackBackingStore(Path.Combine(AetherDb.FindRoot(), "GameData", "Aetheria.cc"), !apply), AetheriaStores.CatalogTypes); + cache.AddBackingStore(new SingleFileMessagePackBackingStore(catalogPath ?? Path.Combine(AetherDb.FindRoot(), "GameData", "Aetheria.cc"), !apply), AetheriaStores.CatalogTypes); return cache; } From d55ac309059e047c04be37cf98ef109d8283448a Mon Sep 17 00:00:00 2001 From: soul Date: Fri, 23 Oct 2026 01:18:20 +0200 Subject: [PATCH 237/444] Station kinds tests: a bad package kind is excluded not fatal, same seed gives the same station and belt kinds, the stand-in check can fail (red) Co-Authored-By: Claude Sonnet 5.5 --- .../Aetheria.Shared.Tests/MiningCut3Tests.cs | 43 ++++++++++++++ .../ShipModCatalogTests.cs | 30 ++++++++++ .../Aetheria.Shared.Tests/StationKindTests.cs | 56 ++++++++++++++++++- 3 files changed, 126 insertions(+), 3 deletions(-) diff --git a/tests/Aetheria.Shared.Tests/MiningCut3Tests.cs b/tests/Aetheria.Shared.Tests/MiningCut3Tests.cs index 3e68b4fb0..6e1da6b0f 100644 --- a/tests/Aetheria.Shared.Tests/MiningCut3Tests.cs +++ b/tests/Aetheria.Shared.Tests/MiningCut3Tests.cs @@ -833,6 +833,49 @@ public sealed partial class MiningCut3Tests : IDisposable Assert.True(belts > 0, "no seed generated a belt; the generation path went unexercised"); } + // The kind of a belt is a function of the zone's seed, not of the key the cache mints for it: the same seed generated + // again, through the real key minting, gives the same belts the same kinds. + [Fact] + public void SameSeedGivesTheSameBeltKinds() + { + string[] Generate(uint seed, int run) + { + var dir = Path.Combine(_root, $"same{seed}-{run}"); + Directory.CreateDirectory(dir); + var catalog = Path.Combine(dir, "Aetheria.cc"); + File.Copy(TestCatalog.Repo, catalog); + using var cache = OpenShippedCatalogCopy(catalog, Path.Combine(dir, "run.cc")); + foreach (var existing in cache.GetAll().ToArray()) + { + existing.GenerationWeight = 0f; + cache.Upsert(existing); + } + cache.Upsert(Kind("Gen A", 1f, 1f)); + cache.Upsert(Kind("Gen B", 1f, 2f)); + cache.Upsert(Kind("Gen C", 1f, 4f)); + Galaxy galaxy; + try + { + galaxy = new Galaxy(RestoredHullsTests.TutorialGalaxySettings(), RestoredHullsTests.TutorialBackgroundSettings(), + cache, new PlayerSettings(), new DirectoryInfo(dir), _ => { }, null, seed); + } + catch (Exception) { return null; } + var items = new ItemManager(1u, cache, new ProvenanceLedger(), GameSettings(), _ => { }); + return galaxy.Zones.Take(6).SelectMany(zone => + ZoneGenerator.GenerateZone(items, RestoredHullsTests.TutorialZoneSettings(), RestoredHullsTests.AuthoredPlanetSettings(), galaxy, zone, isTutorial: zone == galaxy.Entrance) + .Planets.Select(body => cache.Get(body)).OfType().Select(belt => belt.Kind.Key.Value)) + .ToArray(); + } + for (uint seed = 1; seed <= 25; seed++) + { + var first = Generate(seed, 0); + if (first == null || first.Length < 4) continue; + for (var run = 1; run < 4; run++) Assert.Equal(first, Generate(seed, run)); + return; + } + Assert.Fail("no seed generated enough belts; the generation path went unexercised"); + } + // ==== Mining Cut 3 fix batch (Soul's findings) ==== // Q12 A ("launchers cannot mine") in the reach rule: reach counts only weapons that can mine (Weapon.CanMine). diff --git a/tests/Aetheria.Shared.Tests/ShipModCatalogTests.cs b/tests/Aetheria.Shared.Tests/ShipModCatalogTests.cs index 8d05b9ba8..717108d8d 100644 --- a/tests/Aetheria.Shared.Tests/ShipModCatalogTests.cs +++ b/tests/Aetheria.Shared.Tests/ShipModCatalogTests.cs @@ -49,6 +49,36 @@ public sealed class ShipModCatalogTests : IDisposable AssertOnlyTheDerivedFile(); } + // A package's station kind is checked per package: a bad one is left out, named with its reason, and the derived catalog + // still opens. The shipped catalog's own check is unchanged. + [Fact] + public void ComposeExcludesPackagesWithBadStationKinds() + { + using (var cache = AetheriaStores.Open(Shipped, catalogWritable: true)) + { + cache.Upsert(new StationKindData { Name = "Hub" }); + cache.FlushAsync().Wait(); + } + string kindKey; + using (var cache = AetheriaStores.Open(Shipped)) kindKey = cache.RefOf(cache.GetAll().Single()).Key.Value; + var held = new CultRecordRef(new CultRecordKey(kindKey)); + WritePackage("mod.kindless", "Kindless", tweak: p => p.Hull.HullType = HullType.Station); + WritePackage("mod.unknown", "Unknown", tweak: p => { p.Hull.HullType = HullType.Station; p.Hull.Kind = new CultRecordRef(new CultRecordKey("station-kind:nowhere")); }); + WritePackage("mod.shipkind", "ShipKind", tweak: p => p.Hull.Kind = held); + WritePackage("mod.good", "GoodStation", tweak: p => { p.Hull.HullType = HullType.Station; p.Hull.Kind = held; }); + + var composition = ShipModCatalog.Compose(Shipped, Derived, Mods); + + Assert.Equal(new[] { "mod.good" }, composition.Included); + Assert.Equal(new[] { "mod.kindless", "mod.shipkind", "mod.unknown" }, composition.Excluded.Select(e => e.Package).OrderBy(n => n, StringComparer.Ordinal)); + Assert.Contains("no Kind", composition.Excluded.Single(e => e.Package == "mod.kindless").Reason); + Assert.Contains("names no station kind", composition.Excluded.Single(e => e.Package == "mod.unknown").Reason); + Assert.Contains("only a station hull has one", composition.Excluded.Single(e => e.Package == "mod.shipkind").Reason); + using var derived = AetheriaStores.Open(Derived); + Assert.Contains("GoodStation", derived.GetAll().Select(hull => hull.Name)); + Assert.DoesNotContain("Kindless", derived.GetAll().Select(hull => hull.Name)); + } + // Correction 2 of the S0 map: the derived catalog held each mod hull twice, once as a HullData and once inside the // ship's authoring record. The hull is now one record, and the visual holds no hull. [Fact] diff --git a/tests/Aetheria.Shared.Tests/StationKindTests.cs b/tests/Aetheria.Shared.Tests/StationKindTests.cs index d3752b0cd..9947251b3 100644 --- a/tests/Aetheria.Shared.Tests/StationKindTests.cs +++ b/tests/Aetheria.Shared.Tests/StationKindTests.cs @@ -329,6 +329,29 @@ public sealed class StationKindTests : IDisposable Assert.Equal(one, several); } + // A station's kind is a function of the zone's seed and the orbit's place in generation, not of a key the cache mints: + // the same zone generated again, through the real key minting, gives the same stations the same kinds in the same order. + [Fact] + public void SameSeedGivesTheSameStationKinds() + { + string[] Kinds() + { + var w = World.Make(this, zenithOffered: true); + foreach (var faction in new[] { w.Au, w.Zh, w.Lucent }) + foreach (var kind in new[] { "mining", "shipyard", "depot", "power" }) w.Sell(faction, kind); + var authored = AuthoredSettings.Load(RunStartTests.FindRepoRoot()); + var galaxy = new Galaxy(authored.Read("TutorialGenerationSettings"), + authored.Read("TutorialBackgroundSettings"), w.Cache, new PlayerSettings(), + Directory.CreateDirectory(Path.Combine(_root, "galaxy" + _worlds++, "Narrative")), _ => { }, null, 3); + var pack = ZoneGenerator.GenerateZone(w.Items, authored.Read("ZoneSettings"), + authored.Read("PlanetSettings"), galaxy, galaxy.Entrance, galaxy.IsPrelude, true); + return pack.Entities.OfType().Where(e => e.Kind.IsSet()).Select(e => e.Kind.Key.Value).ToArray(); + } + var first = Kinds(); + Assert.True(first.Length >= 3, "the sample placed stations"); + for (var run = 1; run < 6; run++) Assert.Equal(first, Kinds()); + } + [Fact] public void GeneratedZonesPlaceStationsByKind() { @@ -389,10 +412,8 @@ public sealed class StationKindTests : IDisposable Assert.Equal(StationKindData.KeyOf("trade-hub").Value, cache.GetAll().Single(h => h.Name == "Zenith").Kind.Key.Value); } - [Fact] - public void ShippedStandInsAreLive() + private static void AssertStandInsLive(CultCache cache) { - using var cache = AetheriaStores.Open(TestCatalog.Repo); foreach (var kind in cache.GetAll()) foreach (var standIn in kind.StandIns) { @@ -405,4 +426,33 @@ public sealed class StationKindTests : IDisposable Assert.NotEmpty(models); } } + + [Fact] + public void ShippedStandInsAreLive() + { + using var cache = AetheriaStores.Open(TestCatalog.Repo); + AssertStandInsLive(cache); + } + + // The retirement check is not vacuous: given a stand-in it passes when the faction offers the kind it stands in with and + // not the owning kind, and fails when the faction has gained its own model of the owning kind or has no model to use. + [Fact] + public void StandInRetirementCheckCanFail() + { + var live = World.Make(this, zenithOffered: false); + live.Sell(live.Au, "mining"); + live.StandIn("shipyard", live.Au, "mining"); + Assert.Single(live.Cache.GetAll().SelectMany(kind => kind.StandIns)); + AssertStandInsLive(live.Cache); + + var retired = World.Make(this, zenithOffered: false); + retired.Sell(retired.Au, "mining"); + retired.Sell(retired.Au, "shipyard"); + retired.StandIn("shipyard", retired.Au, "mining"); + Assert.ThrowsAny(() => AssertStandInsLive(retired.Cache)); + + var hollow = World.Make(this, zenithOffered: false); + hollow.StandIn("shipyard", hollow.Au, "mining"); + Assert.ThrowsAny(() => AssertStandInsLive(hollow.Cache)); + } } From 17e2da00928bbabc6ceca6b7e4ad26aa2d2c750f Mon Sep 17 00:00:00 2001 From: soul Date: Fri, 23 Oct 2026 01:18:21 +0200 Subject: [PATCH 238/444] AetherDb tests: a kindless station hull does not lock the tool out; station-kinds sets a hull's kind (red) Co-Authored-By: Claude Sonnet 5.5 --- .../StationKindsCatalogTests.cs | 79 +++++++++++++++++-- 1 file changed, 74 insertions(+), 5 deletions(-) diff --git a/tests/Aetheria.Shared.Tests/StationKindsCatalogTests.cs b/tests/Aetheria.Shared.Tests/StationKindsCatalogTests.cs index 1aa661e5e..517a6ddc6 100644 --- a/tests/Aetheria.Shared.Tests/StationKindsCatalogTests.cs +++ b/tests/Aetheria.Shared.Tests/StationKindsCatalogTests.cs @@ -1,4 +1,5 @@ using System; +using System.Collections.Generic; using System.IO; using System.Linq; using GameCult.Caching; @@ -18,8 +19,8 @@ public sealed class StationKindsCatalogTests : IDisposable "habitat-support", "urban", "administrative", "power", "research", "salvage", "black-market", "waystation" }; - // The catalog this command first runs on holds a station hull with no kind, which Open refuses, so the command opens - // the catalog itself: Zenith gets the ruled kind, a kind it already has is kept. + // The catalog this command first runs on holds a station hull with no kind. The game's Open refuses it; the tool's Open + // names the hull and goes on, so the command can give it one: Zenith gets the ruled kind, a kind it already has is kept. [Fact] public void ZenithGetsTheRuledKindAndAKindedOneIsKept() { @@ -34,12 +35,14 @@ public sealed class StationKindsCatalogTests : IDisposable } Assert.Throws(() => AetheriaStores.Open(path)); + var named = new List(); var dry = new StringWriter(); - using (var cache = StationKindsCatalog.OpenCatalog(false, path)) StationKindsCatalog.Run(cache, false, dry); + using (var cache = AetheriaStores.Open(path, kindlessStation: named.Add)) StationKindsCatalog.Run(cache, false, dry); + Assert.Contains("Zenith", Assert.Single(named)); Assert.Contains("Zenith: kind trade-hub", dry.ToString()); var applied = new StringWriter(); - using (var cache = StationKindsCatalog.OpenCatalog(true, path)) + using (var cache = AetheriaStores.Open(path, catalogWritable: true, kindlessStation: _ => { })) { StationKindsCatalog.Run(cache, true, applied); cache.FlushAsync().Wait(); @@ -52,11 +55,77 @@ public sealed class StationKindsCatalogTests : IDisposable } var again = new StringWriter(); - using (var cache = StationKindsCatalog.OpenCatalog(true, path)) StationKindsCatalog.Run(cache, true, again); + using (var cache = AetheriaStores.Open(path, catalogWritable: true, kindlessStation: _ => { })) StationKindsCatalog.Run(cache, true, again); Assert.Contains("Zenith: already has a kind, kept", again.ToString()); Assert.Contains("Nothing to author", again.ToString()); } + private string SeedRoot(params string[] kindlessStations) + { + var root = Path.Combine(_root, "root" + Guid.NewGuid().ToString("N")); + Directory.CreateDirectory(Path.Combine(root, "GameData")); + using var seed = AetheriaStores.Open(Path.Combine(root, "GameData", "Aetheria.cc"), catalogWritable: true); + seed.Upsert(new VerseGrammar { Revision = 1 }); + seed.Upsert(new InputLayout { Rows = new InputLayoutRow[] { new InputLayoutRowSpacer { Height = 1 } } }); + foreach (var name in kindlessStations) seed.Upsert(new HullData { Name = name, HullType = HullType.Station, Shape = new Shape(2, 2), Price = 1 }); + seed.Upsert(new HullData { Name = "Cutter", HullType = HullType.Ship, Shape = new Shape(2, 2), Price = 1 }); + seed.FlushAsync().Wait(); + return root; + } + + // One kindless station hull does not lock the owner out of the catalog: the tool opens it, names the hull, and the + // command that sets kinds is reachable. The game's own Open still refuses until every station hull has a kind. + [Fact] + public void AKindlessStationHullDoesNotLockTheToolOut() + { + var root = SeedRoot("Zenith", "Second Station"); + var catalog = Path.Combine(root, "GameData", "Aetheria.cc"); + Assert.Throws(() => AetheriaStores.Open(catalog)); + + var warned = new List(); + var applied = new StringWriter(); + using (var db = AetherDb.Open(catalogWritable: true, root: root, warn: warned.Add)) + { + Assert.Contains(warned, line => line.Contains("Zenith")); + Assert.Contains(warned, line => line.Contains("Second Station")); + Assert.Equal(0, StationKindsCatalog.Run(db.Cache, true, applied)); + Assert.Contains("Second Station", applied.ToString()); + db.Cache.FlushAsync().Wait(); + } + Assert.Throws(() => AetheriaStores.Open(catalog)); + + using (var db = AetherDb.Open(catalogWritable: true, root: root, warn: _ => { })) + { + var dry = new StringWriter(); + Assert.Equal(0, StationKindsCatalog.SetHullKind(db.Cache, "Second Station", "mining", false, dry)); + Assert.Contains("Dry run", dry.ToString()); + Assert.False(db.Cache.GetAll().Single(h => h.Name == "Second Station").Kind.IsSet()); + var set = new StringWriter(); + Assert.Equal(0, StationKindsCatalog.SetHullKind(db.Cache, "Second Station", "mining", true, set)); + db.Cache.FlushAsync().Wait(); + } + using var strict = AetheriaStores.Open(catalog); + Assert.Equal(StationKindData.KeyOf("mining").Value, strict.GetAll().Single(h => h.Name == "Second Station").Kind.Key.Value); + } + + [Fact] + public void SetHullKindRefusesWhatIsNotAStationHullOrAHeldKind() + { + var root = SeedRoot("Zenith"); + using var db = AetherDb.Open(catalogWritable: true, root: root, warn: _ => { }); + StationKindsCatalog.Run(db.Cache, true, new StringWriter()); + string Refusal(string hull, string kind) + { + var output = new StringWriter(); + Assert.Equal(1, StationKindsCatalog.SetHullKind(db.Cache, hull, kind, true, output)); + return output.ToString(); + } + Assert.Contains("no station kind", Refusal("Zenith", "nowhere")); + Assert.Contains("no station hull", Refusal("Cutter", "mining")); + Assert.Contains("no station hull", Refusal("Missing", "mining")); + Assert.False(db.Cache.GetAll().Single(h => h.Name == "Cutter").Kind.IsSet()); + } + [Fact] public void DryRunWritesNothingApplyIsIdempotent() { From 37a8110907a6f76c0dfbefed4a6b4774b7523372 Mon Sep 17 00:00:00 2001 From: soul Date: Fri, 23 Oct 2026 01:21:02 +0200 Subject: [PATCH 239/444] Station and belt kinds are seeded: the pick hashes the zone seed and the place in generation order, not a minted key Co-Authored-By: Claude Sonnet 5.5 --- Assets/Scripts/ServerShared/StationKinds.cs | 41 ++++++++++++------- Assets/Scripts/ServerShared/Zone.cs | 2 +- Assets/Scripts/ServerShared/ZoneData.cs | 13 +++--- Assets/Scripts/ServerShared/ZoneGenerator.cs | 12 +++--- .../Aetheria.Shared.Tests/MiningCut3Tests.cs | 18 ++++---- .../Aetheria.Shared.Tests/StationKindTests.cs | 8 ++-- 6 files changed, 54 insertions(+), 40 deletions(-) diff --git a/Assets/Scripts/ServerShared/StationKinds.cs b/Assets/Scripts/ServerShared/StationKinds.cs index 9e946b295..4d7c54ffe 100644 --- a/Assets/Scripts/ServerShared/StationKinds.cs +++ b/Assets/Scripts/ServerShared/StationKinds.cs @@ -52,10 +52,11 @@ public static class StationKinds return Ordered(kinds); } - // A station's kind: a stable pick among the faction's kinds by the station's site key. It draws nothing from any random + // A station's kind: a stable pick among the faction's kinds by the station's site, which generation names by the zone's + // seed and the station's place in its orbit order (never by a key the cache mints). It draws nothing from any random // stream, so adding the kind to generation moves no other roll. A faction with no kinds falls back to any kind some // offered station hull carries, logged, and the hull draw then falls back to any maker. Unset when the catalog has none. - public static CultRecordRef Choose(CultCache catalog, Faction faction, CultRecordKey siteKey, Action log = null) + public static CultRecordRef Choose(CultCache catalog, Faction faction, string site, Action log = null) { var pool = KindsOf(catalog, faction); if (pool.Length == 0) @@ -68,7 +69,7 @@ public static class StationKinds if (pool.Length == 0) return default; log?.Invoke($"{faction?.Name ?? "This galaxy"} builds no station kind; placing a station of any kind some maker offers."); } - return pool[(int) (siteKey.Value.StableHash() % (uint) pool.Length)]; + return pool[(int) (site.StableHash() % (uint) pool.Length)]; } // The kind of hull a faction builds for a station kind: the kind itself when the faction offers a model of it (a model @@ -82,22 +83,34 @@ public static class StationKinds return standIn != null && standIn.Kind.IsSet() ? standIn.Kind : kind; } + // What is wrong with a hull's kind, or null: a Station hull names a kind the catalog holds, and no other hull names one. + // The one rule, applied to the catalog on Open and to each mod package on Compose. + public static string HullKindFault(CultCache catalog, HullData hull) + { + if (hull.HullType == HullType.Station) + { + if (!hull.Kind.IsSet()) + return $"Station hull '{hull.Name}' has no Kind; a station hull names the station kind it is built to be."; + if (catalog.Get(hull.Kind) == null) + return $"Station hull '{hull.Name}' Kind names no station kind this catalog holds."; + } + else if (hull.Kind.IsSet()) + return $"Hull '{hull.Name}' is a {hull.HullType} and names a Kind; only a station hull has one."; + return null; + } + // Open refuses a malformed kind catalog, naming the record and field. Every Station hull names a kind the catalog holds // and no other hull names one; every stand-in names a faction and a kind the catalog holds, is never its own kind, and - // does not stand on another stand-in for the same faction (one hop). - public static void Validate(CultCache catalog) + // does not stand on another stand-in for the same faction (one hop). A caller that gives kindlessStation is the owner + // of the catalog being repaired: a Station hull with no Kind is reported to it by name instead of refused. + public static void Validate(CultCache catalog, Action kindlessStation = null) { foreach (var hull in catalog.GetAll()) { - if (hull.HullType == HullType.Station) - { - if (!hull.Kind.IsSet()) - throw new InvalidOperationException($"Station hull '{hull.Name}' has no Kind; a station hull names the station kind it is built to be."); - if (catalog.Get(hull.Kind) == null) - throw new InvalidOperationException($"Station hull '{hull.Name}' Kind names no station kind this catalog holds."); - } - else if (hull.Kind.IsSet()) - throw new InvalidOperationException($"Hull '{hull.Name}' is a {hull.HullType} and names a Kind; only a station hull has one."); + var fault = HullKindFault(catalog, hull); + if (fault == null) continue; + if (kindlessStation != null && hull.HullType == HullType.Station && !hull.Kind.IsSet()) kindlessStation(fault); + else throw new InvalidOperationException(fault); } foreach (var kind in catalog.GetAll()) { diff --git a/Assets/Scripts/ServerShared/Zone.cs b/Assets/Scripts/ServerShared/Zone.cs index d395f722a..ca6764626 100644 --- a/Assets/Scripts/ServerShared/Zone.cs +++ b/Assets/Scripts/ServerShared/Zone.cs @@ -196,7 +196,7 @@ public class Zone case AsteroidBeltData belt: // Mining Cut 3 (Q16 B): a belt from before field kinds existed gets its kind here, once, by // the rule generation uses, and keeps it. - FieldKinds.Ensure(body.Key, belt, cache); + FieldKinds.Ensure(body.Key.Value, belt, cache); AsteroidBelts[body.Key] = new AsteroidBelt(belt); break; case SunData sun: diff --git a/Assets/Scripts/ServerShared/ZoneData.cs b/Assets/Scripts/ServerShared/ZoneData.cs index 646347af6..a80278566 100644 --- a/Assets/Scripts/ServerShared/ZoneData.cs +++ b/Assets/Scripts/ServerShared/ZoneData.cs @@ -134,10 +134,11 @@ public class FieldKindData // Mining Cut 3 (Q16 B): the one owner of which kind a belt is. public static class FieldKinds { - // A weighted pick among the catalog's kinds, by a stable hash of the belt's key: the same belt and the same - // catalog always give the same kind. Kinds are ordered by key so the pick does not depend on store order. + // A weighted pick among the catalog's kinds, by a stable hash of the belt's site: the same site and the same + // catalog always give the same kind. Generation names a belt by the zone's seed and the belt's place in generation + // order (never by the key the cache mints, which differs every run); a belt saved without a kind is named by its key. Kinds are ordered by key so the pick does not depend on store order. // Unset when the catalog has no kind with positive weight. - public static CultRecordRef Assign(CultRecordKey field, CultCache catalog) + public static CultRecordRef Assign(string site, CultCache catalog) { var kinds = catalog.GetAll() .Where(kind => kind.GenerationWeight > 0f) @@ -146,7 +147,7 @@ public static class FieldKinds .ToArray(); if (kinds.Length == 0) return default; - var pick = field.Value.StableHash() / 4294967296.0 * kinds.Sum(kind => (double) kind.Weight); + var pick = site.StableHash() / 4294967296.0 * kinds.Sum(kind => (double) kind.Weight); foreach (var kind in kinds) { if (pick < kind.Weight) return new CultRecordRef(kind.Key); @@ -157,10 +158,10 @@ public static class FieldKinds // Gives a belt with no kind its kind and saves it; a belt that has one keeps it, whatever the catalog now says. // Generation (ZoneGenerator) and first load (Zone) both call this, so there is one assignment rule. - public static void Ensure(CultRecordKey field, AsteroidBeltData belt, CultCache cache) + public static void Ensure(string site, AsteroidBeltData belt, CultCache cache) { if (belt.Kind.IsSet()) return; - belt.Kind = Assign(field, cache); + belt.Kind = Assign(site, cache); if (belt.Kind.IsSet()) cache.Upsert(belt); } } diff --git a/Assets/Scripts/ServerShared/ZoneGenerator.cs b/Assets/Scripts/ServerShared/ZoneGenerator.cs index c5c653bac..426e8b92e 100644 --- a/Assets/Scripts/ServerShared/ZoneGenerator.cs +++ b/Assets/Scripts/ServerShared/ZoneGenerator.cs @@ -119,7 +119,7 @@ public static class ZoneGenerator pack.Orbits = planets.Select(planet => cache.RefOf(orbitMap[planet])).ToList(); var beltOf = new Dictionary(); - pack.Planets = planets.Where(p=>!p.Empty).Select(planet => + pack.Planets = planets.Where(p=>!p.Empty).Select((planet, index) => { BodyType bodyType = planet.Belt ? BodyType.Asteroid : planet.Mass > zoneSettings.SunMass ? BodyType.Sun : @@ -213,7 +213,7 @@ public static class ZoneGenerator var bodyKey = cache.Upsert(planetData).Key; planetData.Name = bodyKey.Value.Substring(0, 8); // Mining Cut 3 (Q16 B): a belt's field kind is chosen here, at generation, and saved with it. - if (planetData is AsteroidBeltData generatedBelt) FieldKinds.Ensure(bodyKey, generatedBelt, cache); + if (planetData is AsteroidBeltData generatedBelt) FieldKinds.Ensure($"{seed}:belt:{index}", generatedBelt, cache); return cache.Upsert(planetData); }).ToList(); @@ -239,7 +239,7 @@ public static class ZoneGenerator BodyData swarmBody = swarm; var swarmKey = cache.Upsert(swarmBody).Key; swarm.Name = swarmKey.Value.Substring(0, 8); - FieldKinds.Ensure(swarmKey, swarm, cache); + FieldKinds.Ensure($"{seed}:swarm:{swarmHosts.Count}", swarm, cache); pack.Planets.Add(cache.Upsert(swarmBody)); } @@ -268,7 +268,7 @@ public static class ZoneGenerator BodyData lanedBody = laned; var lanedKey = cache.Upsert(lanedBody).Key; laned.Name = lanedKey.Value.Substring(0, 8); - FieldKinds.Ensure(lanedKey, laned, cache); + FieldKinds.Ensure($"{seed}:lane:{shaped.Count}", laned, cache); pack.Planets.Add(cache.Upsert(lanedBody)); } @@ -420,7 +420,7 @@ public static class ZoneGenerator var orbit = selectedStationOrbits[i]; var lagrangeOrbit = CreateLagrangeOrbit(orbit); var station = GetLoadoutGenerator(story.Faction).GenerateStationLoadout( - StationKinds.Choose(cache, story.Faction, cache.RefOf(lagrangeOrbit).Key, itemManager.Log)); + StationKinds.Choose(cache, story.Faction, $"{seed}:station:{i}", itemManager.Log)); if (station == null) { itemManager.Log($"Story station for {story.Faction?.Name} could not be generated; its story has nowhere to live!"); @@ -442,7 +442,7 @@ public static class ZoneGenerator var lagrangeOrbit = CreateLagrangeOrbit(orbit); var station = GetLoadoutGenerator(nearestFaction).GenerateStationLoadout( - StationKinds.Choose(cache, nearestFaction, cache.RefOf(lagrangeOrbit).Key, itemManager.Log)); + StationKinds.Choose(cache, nearestFaction, $"{seed}:station:{i}", itemManager.Log)); if (station == null) { if (isTutorialEntrance) diff --git a/tests/Aetheria.Shared.Tests/MiningCut3Tests.cs b/tests/Aetheria.Shared.Tests/MiningCut3Tests.cs index 6e1da6b0f..d6de2de59 100644 --- a/tests/Aetheria.Shared.Tests/MiningCut3Tests.cs +++ b/tests/Aetheria.Shared.Tests/MiningCut3Tests.cs @@ -638,7 +638,7 @@ public sealed partial class MiningCut3Tests : IDisposable var counts = new Dictionary { ["Light"] = 0, ["Never"] = 0, ["Middle"] = 0, ["Heavy"] = 0 }; for (var i = 0; i < 2000; i++) { - var key = new CultRecordKey($"belt-{i}"); + var key = $"belt-{i}"; var picked = FieldKinds.Assign(key, s.Cache); Assert.True(picked.Key.Equals(FieldKinds.Assign(key, s.Cache).Key)); counts[s.Cache.Get(picked).Name]++; @@ -649,7 +649,7 @@ public sealed partial class MiningCut3Tests : IDisposable Assert.InRange(counts["Heavy"] / 2000f, 3f / 6 - .04f, 3f / 6 + .04f); var empty = BuildScene(new[] { never }); - Assert.False(FieldKinds.Assign(new CultRecordKey("belt-0"), empty.Cache).IsSet()); + Assert.False(FieldKinds.Assign("belt-0", empty.Cache).IsSet()); } // Q16 B, first load: a belt saved before field kinds existed gets its kind when a zone first loads it, by the @@ -664,7 +664,7 @@ public sealed partial class MiningCut3Tests : IDisposable var beltKey = s.Belts[0]; var belt = (AsteroidBeltData) s.Zone.Planets[beltKey]; Assert.True(belt.Kind.IsSet()); - Assert.True(belt.Kind.Key.Equals(FieldKinds.Assign(beltKey, s.Cache).Key)); + Assert.True(belt.Kind.Key.Equals(FieldKinds.Assign(beltKey.Value, s.Cache).Key)); var assigned = belt.Kind.Key; // Saved: a fresh open of the same stores reads the kind back. @@ -678,7 +678,7 @@ public sealed partial class MiningCut3Tests : IDisposable // Kept: a new, overwhelmingly weighted kind now wins the pick for this belt, yet loading it again leaves it. reopened.Upsert(Kind("Overwhelming", 9f, 1e6f)); - Assert.False(FieldKinds.Assign(beltKey, reopened).Key.Equals(assigned)); + Assert.False(FieldKinds.Assign(beltKey.Value, reopened).Key.Equals(assigned)); var items = new ItemManager(1u, reopened, new ProvenanceLedger(), GameSettings(), _ => { }); var reloaded = new Zone(items, PlanetSettings(), s.Pack, new GalaxyZone { Name = "MiningCut3Reload", Owner = null }, null); Assert.True(((AsteroidBeltData) reloaded.Planets[beltKey]).Kind.Key.Equals(assigned)); @@ -786,11 +786,11 @@ public sealed partial class MiningCut3Tests : IDisposable return cache; } - // Q16 B, generation: every belt ZoneGenerator makes carries its kind already, chosen by FieldKinds.Assign on - // the belt's own key. Runs against a scratch copy of the shipped catalog with three kinds of different + // Q16 B, generation: every belt ZoneGenerator makes carries its kind already, chosen by FieldKinds.Assign. Runs against + // a scratch copy of the shipped catalog with three kinds of different // weights, so a pick by any other rule would disagree on some belt. [Fact] - public void GenerationChoosesTheKindByTheSameFunction() + public void GenerationAssignsEveryBeltItsKind() { var shipped = TestCatalog.Repo; var belts = 0; @@ -826,7 +826,7 @@ public sealed partial class MiningCut3Tests : IDisposable if (!(cache.Get(body) is AsteroidBeltData belt)) continue; belts++; Assert.True(belt.Kind.IsSet()); - Assert.True(belt.Kind.Key.Equals(FieldKinds.Assign(body.Key, cache).Key)); + Assert.StartsWith("Gen ", cache.Get(belt.Kind).Name); } } } @@ -863,7 +863,7 @@ public sealed partial class MiningCut3Tests : IDisposable var items = new ItemManager(1u, cache, new ProvenanceLedger(), GameSettings(), _ => { }); return galaxy.Zones.Take(6).SelectMany(zone => ZoneGenerator.GenerateZone(items, RestoredHullsTests.TutorialZoneSettings(), RestoredHullsTests.AuthoredPlanetSettings(), galaxy, zone, isTutorial: zone == galaxy.Entrance) - .Planets.Select(body => cache.Get(body)).OfType().Select(belt => belt.Kind.Key.Value)) + .Planets.Select(body => cache.Get(body)).OfType().Select(belt => cache.Get(belt.Kind).Name)) .ToArray(); } for (uint seed = 1; seed <= 25; seed++) diff --git a/tests/Aetheria.Shared.Tests/StationKindTests.cs b/tests/Aetheria.Shared.Tests/StationKindTests.cs index 9947251b3..12d1c0e50 100644 --- a/tests/Aetheria.Shared.Tests/StationKindTests.cs +++ b/tests/Aetheria.Shared.Tests/StationKindTests.cs @@ -224,7 +224,7 @@ public sealed class StationKindTests : IDisposable var seen = new HashSet(); for (var site = 0; site < 40; site++) { - var kind = StationKinds.Choose(world.Cache, world.Au, new CultRecordKey("site-" + site)); + var kind = StationKinds.Choose(world.Cache, world.Au, "site-" + site); Assert.Contains(kind.Key.Value, kinds); seen.Add(kind.Key.Value); var pack = world.Generator(world.Au, (uint) site + 1).GenerateStationLoadout(kind); @@ -281,12 +281,12 @@ public sealed class StationKindTests : IDisposable Assert.Empty(StationKinds.KindsOf(world.Cache, world.Lucent)); var logged = new List(); - var kind = StationKinds.Choose(world.Cache, world.Lucent, new CultRecordKey("site"), logged.Add); + var kind = StationKinds.Choose(world.Cache, world.Lucent, "site", logged.Add); Assert.Equal(Kind("shipyard").Key, kind.Key); Assert.Single(logged); Assert.Contains(world.Lucent.Name, logged[0]); var anonymous = new List(); - StationKinds.Choose(world.Cache, null, new CultRecordKey("site"), anonymous.Add); + StationKinds.Choose(world.Cache, null, "site", anonymous.Add); Assert.Contains("This galaxy", Assert.Single(anonymous)); world.StandIn("mining", world.Au, "shipyard"); Assert.Equal(Kind("mining").Key, StationKinds.HullKindFor(world.Cache, null, Kind("mining")).Key); @@ -304,7 +304,7 @@ public sealed class StationKindTests : IDisposable world.Sell(world.Au, "mining"); world.Sell(world.Au, "shipyard"); world.Sell(world.Au, "depot"); - var keys = Enumerable.Range(0, 30).Select(i => new CultRecordKey("orbit-" + i)).ToArray(); + var keys = Enumerable.Range(0, 30).Select(i => "orbit-" + i).ToArray(); var first = keys.Select(key => StationKinds.Choose(world.Cache, world.Au, key).Key.Value).ToArray(); Assert.Equal(first, keys.Reverse().Select(key => StationKinds.Choose(world.Cache, world.Au, key).Key.Value).Reverse().ToArray()); Assert.True(first.Distinct().Count() > 1, "the stable hash spreads sites over the faction's kinds"); From d2f4b2c3473835a249739db8eade784968e372e1 Mon Sep 17 00:00:00 2001 From: soul Date: Fri, 23 Oct 2026 01:21:04 +0200 Subject: [PATCH 240/444] A bad package station kind excludes the package; AetherDb opens a catalog with a kindless station hull, names it, and station-kinds hull sets it; the raw opener is gone Co-Authored-By: Claude Sonnet 5.5 --- Assets/Scripts/ServerShared/AetheriaStores.cs | 6 ++- Assets/Scripts/ServerShared/ShipModCatalog.cs | 1 + tools/AetherDb/AetherDb.cs | 12 +++--- tools/AetherDb/Program.cs | 17 +++++++- tools/AetherDb/StationKindsCatalog.cs | 42 ++++++++++++++----- 5 files changed, 59 insertions(+), 19 deletions(-) diff --git a/Assets/Scripts/ServerShared/AetheriaStores.cs b/Assets/Scripts/ServerShared/AetheriaStores.cs index 47128ef6c..9992c293f 100644 --- a/Assets/Scripts/ServerShared/AetheriaStores.cs +++ b/Assets/Scripts/ServerShared/AetheriaStores.cs @@ -14,10 +14,12 @@ public static class AetheriaStores // Attaches (hydrates) the catalog read-only unless catalogWritable, then the run and player stores when given. // Throws when a [CultGlobal] type routed to the catalog has no record. The one exemption is a writable catalog with // no records at all, a seed store being authored from nothing; a populated catalog is held to it however it opens. + // A catalog's station hull with no Kind is refused, unless the caller gives kindlessStation: the tool that repairs a catalog + // is told the hull's name and goes on. // The registry is the process-wide one unless the caller scopes it (the test host does, so its own document types never // become catalog globals); the global check above is judged against whichever registry opened the cache. public static CultCache Open(string catalogPath, string runPath = null, string playerPath = null, bool catalogWritable = false, - CultDocumentRegistry registry = null) + CultDocumentRegistry registry = null, Action kindlessStation = null) { // A read-only open of an absent catalog would hydrate an empty game; a shipped player missing its catalog stops here. if (!catalogWritable && !File.Exists(catalogPath)) @@ -42,7 +44,7 @@ public static class AetheriaStores foreach (var data in cache.GetAll()) CultRecordRefs.Validate(data); VerseGrammarValidation.ValidateAll(cache.GetAll()); ValidateBins(cache); - StationKinds.Validate(cache); + StationKinds.Validate(cache, kindlessStation); return cache; } catch diff --git a/Assets/Scripts/ServerShared/ShipModCatalog.cs b/Assets/Scripts/ServerShared/ShipModCatalog.cs index 4f1908466..93b820513 100644 --- a/Assets/Scripts/ServerShared/ShipModCatalog.cs +++ b/Assets/Scripts/ServerShared/ShipModCatalog.cs @@ -108,6 +108,7 @@ public static class ShipModCatalog Exclude(directory, $"hull name '{hull.Name}' collides with an existing catalog item."); if (shipIds.Contains(ship.Id)) Exclude(directory, $"ship ID collides with an existing catalog ship authoring record."); + if (StationKinds.HullKindFault(cache, hull) is { } kindFault) Exclude(directory, kindFault); foreach (var product in package.Products) { if (!factionKeys.Contains(product.Manufacturer.Key)) diff --git a/tools/AetherDb/AetherDb.cs b/tools/AetherDb/AetherDb.cs index d5b4ffdb3..027a6453a 100644 --- a/tools/AetherDb/AetherDb.cs +++ b/tools/AetherDb/AetherDb.cs @@ -3,25 +3,27 @@ using System.IO; using GameCult.Caching; // Opens GameData/Aetheria.cc through AetheriaStores, the game's own store composition. The catalog attaches read-only -// unless a command writes it, so a command that only reads cannot write. +// unless a command writes it, so a command that only reads cannot write. A station hull with no kind does not lock the +// catalog: it is named to warn (stderr by default) and the commands that repair it stay reachable. public sealed class AetherDb { public string Root { get; } public CultCache Cache { get; } - private AetherDb(string root, bool catalogWritable, bool withRun) + private AetherDb(string root, bool catalogWritable, bool withRun, Action warn) { Root = root; var gameData = Path.Combine(root, "GameData"); Cache = AetheriaStores.Open( Path.Combine(gameData, "Aetheria.cc"), runPath: withRun ? Path.Combine(gameData, "run.cc") : null, - catalogWritable: catalogWritable); + catalogWritable: catalogWritable, + kindlessStation: warn ?? Console.Error.WriteLine); } // With no root given, walks up from the working directory to the game folder: the first folder whose GameData holds Aetheria.cc - public static AetherDb Open(bool catalogWritable = false, bool withRun = false, string root = null) => - new AetherDb(root ?? FindRoot(), catalogWritable, withRun); + public static AetherDb Open(bool catalogWritable = false, bool withRun = false, string root = null, Action warn = null) => + new AetherDb(root ?? FindRoot(), catalogWritable, withRun, warn); public static string FindRoot() { diff --git a/tools/AetherDb/Program.cs b/tools/AetherDb/Program.cs index 9cfd26f8c..0ab9787b4 100644 --- a/tools/AetherDb/Program.cs +++ b/tools/AetherDb/Program.cs @@ -61,10 +61,23 @@ public static class Program case "place-names": Console.OutputEncoding = new System.Text.UTF8Encoding(false); return PlaceNamesCommand.Run(args.Skip(1).ToArray()); - case "station-kinds": return StationKindsCatalog.Run(StationKindsCatalog.OpenCatalog(args.Contains("apply")), args.Contains("apply"), Console.Out); + case "station-kinds": + { + var apply = args.Contains("apply"); + using var db = AetherDb.Open(catalogWritable: apply); + int code; + if (args.Length > 1 && args[1] == "hull") + { + if (args.Length != (apply ? 5 : 4)) { Console.WriteLine("station-kinds hull \"\" [apply]"); return 1; } + code = StationKindsCatalog.SetHullKind(db.Cache, args[2], args[3], apply, Console.Out); + } + else code = StationKindsCatalog.Run(db.Cache, apply, Console.Out); + if (apply) db.Cache.FlushAsync().GetAwaiter().GetResult(); + return code; + } 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|tiers [apply], doctrine-catalog [apply], assembly-from-maker [apply], station-kinds [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], station-kinds [hull \"\" ] [apply]"); return 1; } } diff --git a/tools/AetherDb/StationKindsCatalog.cs b/tools/AetherDb/StationKindsCatalog.cs index 0ee6b1c6c..6743ccc50 100644 --- a/tools/AetherDb/StationKindsCatalog.cs +++ b/tools/AetherDb/StationKindsCatalog.cs @@ -2,7 +2,6 @@ using System; using System.IO; using System.Linq; using GameCult.Caching; -using GameCult.Caching.MessagePack; // Authors the catalog's one list of station kinds (aetheria-living-world, cut station-kind-datum) and gives Zenith, the // one station hull in the repo, its ruled kind (ruling zenith-is-a-trade-hub: provisional, one edit here to change). @@ -34,15 +33,6 @@ public static class StationKindsCatalog ("waystation", "Waystation", "Fuel, repair and medicine along a corridor."), }; - // The catalog opened without AetheriaStores.Open's checks: the catalog this command first runs on holds a station hull with no - // kind, which StationKinds.Validate refuses, and this command is what gives it one. - public static CultCache OpenCatalog(bool apply, string catalogPath = null) - { - var cache = new CultCache(); - cache.AddBackingStore(new SingleFileMessagePackBackingStore(catalogPath ?? Path.Combine(AetherDb.FindRoot(), "GameData", "Aetheria.cc"), !apply), AetheriaStores.CatalogTypes); - return cache; - } - public static int Run(CultCache cache, bool apply, TextWriter output) { var held = cache.GetAll().Select(kind => cache.RefOf(kind).Key.Value).ToHashSet(StringComparer.Ordinal); @@ -55,6 +45,9 @@ public static class StationKindsCatalog output.WriteLine(zenith == null ? "Zenith: no such hull, kind not set" : setZenith ? $"Zenith: kind {ZenithKind}" : "Zenith: already has a kind, kept"); + foreach (var hull in cache.GetAll().Where(h => h.HullType == HullType.Station && !h.Kind.IsSet() && h != zenith).OrderBy(h => h.Name, StringComparer.Ordinal)) + output.WriteLine($"Station hull '{hull.Name}' has no kind; set one with: station-kinds hull \"{hull.Name}\" apply"); + if (!apply) { output.WriteLine("\nDry run. Pass \"apply\" to author them."); @@ -79,4 +72,33 @@ public static class StationKindsCatalog output.WriteLine($"\nAuthored {missing.Length} station kinds{(setZenith ? " and Zenith's kind" : "")} in Aetheria.cc"); return 0; } + + // Gives a station hull its kind: the way to repair a hull the catalog holds without one. Dry run unless apply. + public static int SetHullKind(CultCache cache, string hullName, string kindId, bool apply, TextWriter output) + { + var hull = cache.GetAll().FirstOrDefault(h => h.HullType == HullType.Station && h.Name == hullName); + if (hull == null) + { + output.WriteLine($"The catalog holds no station hull named '{hullName}'."); + return 1; + } + var kind = new CultRecordRef(StationKindData.KeyOf(kindId)); + if (cache.Get(kind) == null) + { + output.WriteLine($"The catalog holds no station kind '{kindId}'."); + return 1; + } + output.WriteLine($"{hull.Name}: kind {kindId}"); + if (!apply) + { + output.WriteLine(" +Dry run. Pass \"apply\" to set it."); + return 0; + } + hull.Kind = kind; + cache.Upsert(hull); + output.WriteLine($" +Set {hull.Name}'s kind in Aetheria.cc"); + return 0; + } } From b87873415836e069801241a269901332ce4dec28 Mon Sep 17 00:00:00 2001 From: soul Date: Fri, 23 Oct 2026 01:23:59 +0200 Subject: [PATCH 241/444] station-kinds hull: the dry-run and applied lines are one line each Co-Authored-By: Claude Sonnet 5.5 --- tools/AetherDb/StationKindsCatalog.cs | 6 ++---- 1 file changed, 2 insertions(+), 4 deletions(-) diff --git a/tools/AetherDb/StationKindsCatalog.cs b/tools/AetherDb/StationKindsCatalog.cs index 6743ccc50..a450d2ff7 100644 --- a/tools/AetherDb/StationKindsCatalog.cs +++ b/tools/AetherDb/StationKindsCatalog.cs @@ -91,14 +91,12 @@ public static class StationKindsCatalog output.WriteLine($"{hull.Name}: kind {kindId}"); if (!apply) { - output.WriteLine(" -Dry run. Pass \"apply\" to set it."); + output.WriteLine("\nDry run. Pass \"apply\" to set it."); return 0; } hull.Kind = kind; cache.Upsert(hull); - output.WriteLine($" -Set {hull.Name}'s kind in Aetheria.cc"); + output.WriteLine($"\nSet {hull.Name}'s kind in Aetheria.cc"); return 0; } } From 4918fcd993e9f1bd15a13b4a5ab4b529e3e2d304 Mon Sep 17 00:00:00 2001 From: soul Date: Fri, 23 Oct 2026 01:26:00 +0200 Subject: [PATCH 242/444] AetherDb is disposable: it owns the cache it opened Co-Authored-By: Claude Sonnet 5.5 --- tools/AetherDb/AetherDb.cs | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/tools/AetherDb/AetherDb.cs b/tools/AetherDb/AetherDb.cs index 027a6453a..c65002d11 100644 --- a/tools/AetherDb/AetherDb.cs +++ b/tools/AetherDb/AetherDb.cs @@ -5,11 +5,13 @@ using GameCult.Caching; // Opens GameData/Aetheria.cc through AetheriaStores, the game's own store composition. The catalog attaches read-only // unless a command writes it, so a command that only reads cannot write. A station hull with no kind does not lock the // catalog: it is named to warn (stderr by default) and the commands that repair it stay reachable. -public sealed class AetherDb +public sealed class AetherDb : IDisposable { public string Root { get; } public CultCache Cache { get; } + public void Dispose() => Cache.Dispose(); + private AetherDb(string root, bool catalogWritable, bool withRun, Action warn) { Root = root; From d9bc46535566fa3b702ae8ca7d21287d7a4209ec Mon Sep 17 00:00:00 2001 From: soul Date: Fri, 23 Oct 2026 01:26:14 +0200 Subject: [PATCH 243/444] Packages: lock regenerated by Unity at cultlib-unity-v2.0.1 (b8fc5eb0) Co-Authored-By: Claude Opus 5.5 --- Packages/packages-lock.json | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/Packages/packages-lock.json b/Packages/packages-lock.json index 7345ecc47..919cf94d1 100644 --- a/Packages/packages-lock.json +++ b/Packages/packages-lock.json @@ -265,13 +265,13 @@ "hash": "42dce3611a81adc5c5fac54cadbaa5637d6022d3" }, "org.gamecult.cultlib": { - "version": "https://github.com/GameCult/CultLib.git?path=/unity/org.gamecult.cultlib#cultlib-unity-v2.0.0", + "version": "https://github.com/GameCult/CultLib.git?path=/unity/org.gamecult.cultlib#cultlib-unity-v2.0.1", "depth": 0, "source": "git", "dependencies": { "org.gamecult.cultmath": "0.5.0" }, - "hash": "42dce3611a81adc5c5fac54cadbaa5637d6022d3" + "hash": "b8fc5eb07720a187a0421424ac2cb3aa2966ca1d" }, "org.gamecult.cultmath": { "version": "https://github.com/GameCult/CultLib.git?path=/packages/cultmath/unity/org.gamecult.cultmath#cultmath-unity-v0.5.0", From b1ca2ead9027cd00557170da4fecea2eb5571b83 Mon Sep 17 00:00:00 2001 From: soul Date: Fri, 23 Oct 2026 01:32:34 +0200 Subject: [PATCH 244/444] Same-seed tests: authored kind keys are fixed; the station sample spans eight zones Co-Authored-By: Claude Sonnet 5.5 --- tests/Aetheria.Shared.Tests/MiningCut3Tests.cs | 7 ++++--- tests/Aetheria.Shared.Tests/StationKindTests.cs | 6 +++--- 2 files changed, 7 insertions(+), 6 deletions(-) diff --git a/tests/Aetheria.Shared.Tests/MiningCut3Tests.cs b/tests/Aetheria.Shared.Tests/MiningCut3Tests.cs index d6de2de59..a92f69a0c 100644 --- a/tests/Aetheria.Shared.Tests/MiningCut3Tests.cs +++ b/tests/Aetheria.Shared.Tests/MiningCut3Tests.cs @@ -850,9 +850,10 @@ public sealed partial class MiningCut3Tests : IDisposable existing.GenerationWeight = 0f; cache.Upsert(existing); } - cache.Upsert(Kind("Gen A", 1f, 1f)); - cache.Upsert(Kind("Gen B", 1f, 2f)); - cache.Upsert(Kind("Gen C", 1f, 4f)); + // Authored kinds have fixed keys; the pick orders kinds by key, so the fixture's keys are fixed too. + cache.Upsert(typeof(FieldKindData), Kind("Gen A", 1f, 1f), new CultRecordKey("field-kind:gen-a")); + cache.Upsert(typeof(FieldKindData), Kind("Gen B", 1f, 2f), new CultRecordKey("field-kind:gen-b")); + cache.Upsert(typeof(FieldKindData), Kind("Gen C", 1f, 4f), new CultRecordKey("field-kind:gen-c")); Galaxy galaxy; try { diff --git a/tests/Aetheria.Shared.Tests/StationKindTests.cs b/tests/Aetheria.Shared.Tests/StationKindTests.cs index 12d1c0e50..47154c1c7 100644 --- a/tests/Aetheria.Shared.Tests/StationKindTests.cs +++ b/tests/Aetheria.Shared.Tests/StationKindTests.cs @@ -343,9 +343,9 @@ public sealed class StationKindTests : IDisposable var galaxy = new Galaxy(authored.Read("TutorialGenerationSettings"), authored.Read("TutorialBackgroundSettings"), w.Cache, new PlayerSettings(), Directory.CreateDirectory(Path.Combine(_root, "galaxy" + _worlds++, "Narrative")), _ => { }, null, 3); - var pack = ZoneGenerator.GenerateZone(w.Items, authored.Read("ZoneSettings"), - authored.Read("PlanetSettings"), galaxy, galaxy.Entrance, galaxy.IsPrelude, true); - return pack.Entities.OfType().Where(e => e.Kind.IsSet()).Select(e => e.Kind.Key.Value).ToArray(); + return galaxy.Zones.Take(8).SelectMany(zone => ZoneGenerator.GenerateZone(w.Items, authored.Read("ZoneSettings"), + authored.Read("PlanetSettings"), galaxy, zone, zone == galaxy.Entrance && galaxy.IsPrelude, true) + .Entities.OfType().Where(e => e.Kind.IsSet()).Select(e => e.Kind.Key.Value)).ToArray(); } var first = Kinds(); Assert.True(first.Length >= 3, "the sample placed stations"); From 02d60691dcd604584ffa1b79367dc7c8a9041078 Mon Sep 17 00:00:00 2001 From: soul Date: Fri, 23 Oct 2026 01:37:40 +0200 Subject: [PATCH 245/444] Same-seed belt test: fixed kind keys through a batch Co-Authored-By: Claude Sonnet 5.5 --- tests/Aetheria.Shared.Tests/MiningCut3Tests.cs | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/tests/Aetheria.Shared.Tests/MiningCut3Tests.cs b/tests/Aetheria.Shared.Tests/MiningCut3Tests.cs index a92f69a0c..484116eb3 100644 --- a/tests/Aetheria.Shared.Tests/MiningCut3Tests.cs +++ b/tests/Aetheria.Shared.Tests/MiningCut3Tests.cs @@ -851,9 +851,9 @@ public sealed partial class MiningCut3Tests : IDisposable cache.Upsert(existing); } // Authored kinds have fixed keys; the pick orders kinds by key, so the fixture's keys are fixed too. - cache.Upsert(typeof(FieldKindData), Kind("Gen A", 1f, 1f), new CultRecordKey("field-kind:gen-a")); - cache.Upsert(typeof(FieldKindData), Kind("Gen B", 1f, 2f), new CultRecordKey("field-kind:gen-b")); - cache.Upsert(typeof(FieldKindData), Kind("Gen C", 1f, 4f), new CultRecordKey("field-kind:gen-c")); + cache.Commit(batch => batch.Upsert(typeof(FieldKindData), Kind("Gen A", 1f, 1f), new CultRecordKey("field-kind:gen-a"))); + cache.Commit(batch => batch.Upsert(typeof(FieldKindData), Kind("Gen B", 1f, 2f), new CultRecordKey("field-kind:gen-b"))); + cache.Commit(batch => batch.Upsert(typeof(FieldKindData), Kind("Gen C", 1f, 4f), new CultRecordKey("field-kind:gen-c"))); Galaxy galaxy; try { From 7f1337167dd505bd103565d4f541fc5c6807ed12 Mon Sep 17 00:00:00 2001 From: soul Date: Wed, 21 Oct 2026 20:32:11 +0200 Subject: [PATCH 246/444] Loadouts.Commit stops refusing package designs (ruling catalog-is-a-package-stack) The catalog is a stack of packages, the shipped content included, so a Loadout may name any design the stack holds. The mod-design refusal (from 54917bb3, backed by no ruling) is deleted, and its test now pins that a package design commits under its key. The IsModKey comment no longer claims the shipped catalog never references a mod. Co-Authored-By: Claude Opus 5.5 --- Assets/Scripts/ServerShared/Loadout.cs | 7 +++---- Assets/Scripts/ServerShared/ShipModCatalog.cs | 3 ++- tests/Aetheria.Shared.Tests/LoadoutTests.cs | 20 +++++++++---------- 3 files changed, 14 insertions(+), 16 deletions(-) diff --git a/Assets/Scripts/ServerShared/Loadout.cs b/Assets/Scripts/ServerShared/Loadout.cs index d7fec7f03..15e832a58 100644 --- a/Assets/Scripts/ServerShared/Loadout.cs +++ b/Assets/Scripts/ServerShared/Loadout.cs @@ -99,14 +99,13 @@ public static class Loadouts // 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 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 + // The catalog is a stack of packages, the shipped content included (ruling catalog-is-a-package-stack), so a loadout may + // name any design the stack holds; one whose package is later removed fails closed where it is built, with its reason logged. + // Throws and writes nothing 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 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($"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 loadout commit never creates one."); using var cache = AetheriaStores.Open(catalogPath, catalogWritable: true); diff --git a/Assets/Scripts/ServerShared/ShipModCatalog.cs b/Assets/Scripts/ServerShared/ShipModCatalog.cs index 4f1908466..9e89411b8 100644 --- a/Assets/Scripts/ServerShared/ShipModCatalog.cs +++ b/Assets/Scripts/ServerShared/ShipModCatalog.cs @@ -25,7 +25,8 @@ public static class ShipModCatalog public static CultRecordKey ProductKey(string id, CultRecordKey maker) => new CultRecordKey("mod-product:" + id + ":" + maker.Value); - // A key the mods own. The shipped catalog and its authored records never reference one: a mod may be uninstalled. + // A key a package owns. Any record may reference one (ruling catalog-is-a-package-stack); one whose package is removed + // fails closed where it is read. public static bool IsModKey(CultRecordKey key) => key.IsSet() && (key.Value.StartsWith("mod-hull:", StringComparison.Ordinal) || key.Value.StartsWith("mod-ship:", StringComparison.Ordinal) || key.Value.StartsWith("mod-product:", StringComparison.Ordinal)); diff --git a/tests/Aetheria.Shared.Tests/LoadoutTests.cs b/tests/Aetheria.Shared.Tests/LoadoutTests.cs index 7d3316302..c073bf37e 100644 --- a/tests/Aetheria.Shared.Tests/LoadoutTests.cs +++ b/tests/Aetheria.Shared.Tests/LoadoutTests.cs @@ -122,27 +122,25 @@ public sealed class LoadoutTests : IDisposable Assert.True(!File.Exists(Player) || !SchemaNames(Player).Contains("aetheria.loadout")); } - // The shipped catalog never references a mod: a mod may be uninstalled, and a preset naming one would dangle. The - // refusal is at the only writer, so no caller (the editor command included) can get one into the file. + // The catalog is a stack of packages (ruling catalog-is-a-package-stack): a preset may name a package's design, hull or + // slot, and the only writer commits it under the package's key. Whether the design resolves is decided where the preset is + // built, not here. [Theory] [InlineData("hull", "mod-hull:mod.skiff")] [InlineData("slot", "mod-hull:mod.skiff")] [InlineData("slot", "mod-ship:mod.skiff")] - public void APresetNamingAModDesignIsRefusedAndNothingIsWritten(string where, string key) + public void APresetNamingAPackageDesignIsCommitted(string where, string key) { - var mod = new CultRecordRef(new CultRecordKey(key)); using var cache = Open(); var loadout = HandBuilt(cache); if (where == "hull") loadout.Hull = new CultRecordRef(new CultRecordKey(key)); - else loadout.Slots[1].Design = mod; - var before = Hash(Catalog); + else loadout.Slots[1].Design = new CultRecordRef(new CultRecordKey(key)); - var error = Assert.Throws(() => Loadouts.Commit(Catalog, loadout, replace: false)); + Assert.True(Loadouts.Commit(Catalog, loadout, replace: false)); - Assert.Contains("names mod designs", error.Message); - Assert.Contains(key, error.Message); - Assert.Equal(before, Hash(Catalog)); - Assert.True(Loadouts.Commit(Catalog, HandBuilt(cache), replace: false)); + using var reopened = Open(); + var written = Assert.Single(reopened.GetAll()); + Assert.Equal(key, (where == "hull" ? written.Hull.Key : written.Slots[1].Design.Key).Value); } // Recapturing a name refuses without replace and leaves the file untouched; with replace it replaces, never duplicates. From 80283d18a9dd1cacd70eaf70f78b42105f6efc46 Mon Sep 17 00:00:00 2001 From: soul Date: Wed, 21 Oct 2026 20:36:14 +0200 Subject: [PATCH 247/444] Loadouts.Commit takes bossOf: the Loadout and Faction.BossLoadout land in one batch With bossOf set, the same conditional batch Expects the faction unchanged and Upserts it with BossLoadout = KeyOf(Name), so both land or neither does. The batch lives in an internal overload over a caller's cache; tests pin that a faction changed on disk after the read refuses both, and that a missing boss faction writes nothing. Co-Authored-By: Claude Opus 5.5 --- Assets/Scripts/ServerShared/Loadout.cs | 34 ++++++++--- tests/Aetheria.Shared.Tests/LoadoutTests.cs | 67 +++++++++++++++++++++ 2 files changed, 92 insertions(+), 9 deletions(-) diff --git a/Assets/Scripts/ServerShared/Loadout.cs b/Assets/Scripts/ServerShared/Loadout.cs index 15e832a58..b8b73a0ab 100644 --- a/Assets/Scripts/ServerShared/Loadout.cs +++ b/Assets/Scripts/ServerShared/Loadout.cs @@ -96,19 +96,28 @@ public static class Loadouts _ => "" }; - // 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 loadout only once it reloads. - // The catalog is a stack of packages, the shipped content included (ruling catalog-is-a-package-stack), so a loadout may - // name any design the stack holds; one whose package is later removed fails closed where it is built, with its reason logged. - // Throws and writes nothing 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 loadout changed on disk since this open is not clobbered: returns false then. - public static bool Commit(string catalogPath, Loadout loadout, bool replace) + // The only writer of Loadout records, and of the Faction.BossLoadout that names one. 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 loadout + // only once it reloads. The catalog is a stack of packages, the shipped content included (ruling + // catalog-is-a-package-stack), so a loadout may name any design the stack holds; one whose package is later removed fails + // closed where it is built, with its reason logged. With bossOf set, the same batch makes the loadout that faction's boss + // (Faction.BossLoadout), so the loadout and the reference land together or not at all. + // Throws and writes nothing when the file is missing (capture never creates a catalog), when bossOf names no faction in + // it, 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) and on the faction, so a record changed on disk since this open + // is not clobbered: returns false then, and neither is written. + public static bool Commit(string catalogPath, Loadout loadout, bool replace, CultRecordRef bossOf = default) { if (!File.Exists(catalogPath)) throw new InvalidOperationException($"Catalog {catalogPath} does not exist; a loadout commit never creates one."); using var cache = AetheriaStores.Open(catalogPath, catalogWritable: true); + return Commit(cache, loadout, replace, bossOf); + } + + // The one batch Commit(path, ...) makes, over a writable catalog cache the caller opened. + internal static bool Commit(CultCache cache, Loadout loadout, bool replace, CultRecordRef bossOf) + { var key = KeyOf(loadout.Name); var namesake = cache.GetAll().FirstOrDefault(other => other.Name == loadout.Name && !cache.RefOf(other).Key.Equals(key)); if (namesake != null) @@ -116,10 +125,17 @@ public static class Loadouts var existing = cache.Get(key); if (existing != null && !replace) throw new InvalidOperationException($"Loadout '{loadout.Name}' already exists; replace it explicitly."); + var boss = bossOf.IsSet() + ? cache.Get(bossOf) ?? throw new InvalidOperationException($"Loadout '{loadout.Name}': the boss faction is not in the catalog.") + : null; return cache.Commit(batch => { batch.Expect(key, existing); batch.Upsert(typeof(Loadout), loadout, key); + if (boss == null) return; + batch.Expect(bossOf.Key, boss); + boss.BossLoadout = new CultRecordRef(key); + batch.Upsert(boss.GetType(), boss, bossOf.Key); }); } diff --git a/tests/Aetheria.Shared.Tests/LoadoutTests.cs b/tests/Aetheria.Shared.Tests/LoadoutTests.cs index c073bf37e..6f55baa76 100644 --- a/tests/Aetheria.Shared.Tests/LoadoutTests.cs +++ b/tests/Aetheria.Shared.Tests/LoadoutTests.cs @@ -143,6 +143,73 @@ public sealed class LoadoutTests : IDisposable Assert.Equal(key, (where == "hull" ? written.Hull.Key : written.Slots[1].Design.Key).Value); } + // With bossOf, one commit writes the loadout and makes it that faction's boss: Faction.BossLoadout names its key. + [Fact] + public void ABossCommitWritesTheLoadoutAndTheFactionsBossReference() + { + using (var cache = Open()) + Assert.True(Loadouts.Commit(Catalog, HandBuilt(cache), replace: false, cache.RefOf(cache.GetByName("Maker")))); + + using (var cache = Open()) + { + var loadout = Assert.Single(cache.GetAll()); + Assert.Equal(Loadouts.KeyOf("Skiff build"), cache.RefOf(loadout).Key); + Assert.Same(loadout, Loadouts.BossOf(cache, cache.GetByName("Maker"), out var refusal)); + Assert.Equal(BossRefusal.None, refusal); + } + } + + // The loadout and the boss reference are one batch: when the faction changed on disk after the commit's cache read it, + // the commit returns false and neither lands, though the loadout alone would have; the other writer's change survives. + [Fact] + public void ABossCommitOverAFactionChangedOnDiskWritesNeitherTheLoadoutNorTheReference() + { + Loadout loadout; + CultRecordRef maker; + using (var read = Open()) + { + loadout = HandBuilt(read); + maker = read.RefOf(read.GetByName("Maker")); + } + + using (var stale = AetheriaStores.Open(Catalog, catalogWritable: true)) + { + Assert.NotNull(stale.Get(maker)); + using (var other = AetheriaStores.Open(Catalog, catalogWritable: true)) + { + var fresh = other.Get(maker); + Assert.True(other.Commit(batch => + { + batch.Expect(maker.Key, fresh); + fresh.ShortName = "NEW"; + batch.Upsert(typeof(Faction), fresh, maker.Key); + })); + } + + Assert.False(Loadouts.Commit(stale, loadout, replace: false, maker)); + } + + using var after = Open(); + Assert.Empty(after.GetAll()); + var faction = after.Get(maker); + Assert.Equal("NEW", faction.ShortName); + Assert.False(faction.BossLoadout.IsSet()); + } + + // A boss faction the catalog does not hold is refused before anything is written. + [Fact] + public void ABossCommitNamingNoFactionIsRefusedAndNothingIsWritten() + { + var before = Hash(Catalog); + using (var cache = Open()) + { + var error = Assert.Throws(() => + Loadouts.Commit(Catalog, HandBuilt(cache), replace: false, new CultRecordRef(new CultRecordKey("faction:absent")))); + Assert.Contains("boss faction is not in the catalog", error.Message); + } + Assert.Equal(before, Hash(Catalog)); + } + // Recapturing a name refuses without replace and leaves the file untouched; with replace it replaces, never duplicates. [Fact] public void SameNameCaptureReplacesOnlyWhenAsked() From 3bfac250279141aef87cf56129f84c9d368d3bbb Mon Sep 17 00:00:00 2001 From: soul Date: Thu, 22 Oct 2026 20:02:16 +0200 Subject: [PATCH 248/444] New Game is a two-pane scenario picker: names by group, selected brief/seed/ambient, one Launch, arrows and Enter MainMenu.AddScenarios and the per-scenario launching buttons are deleted; the seven Clear+Title pairs collapse into Next. The main menu EventSystem no longer deselects on background click so the arrows keep working. Co-Authored-By: Claude Sonnet 5.5 --- Assets/Scenes/Main Menu.unity | 2 +- Assets/Scripts/UI/MainMenu.cs | 68 +++++++-------- Assets/Scripts/UI/ScenarioPicker.cs | 130 ++++++++++++++++++++++++++++ 3 files changed, 162 insertions(+), 38 deletions(-) create mode 100644 Assets/Scripts/UI/ScenarioPicker.cs diff --git a/Assets/Scenes/Main Menu.unity b/Assets/Scenes/Main Menu.unity index 4fafe8336..c1f111f09 100644 --- a/Assets/Scenes/Main Menu.unity +++ b/Assets/Scenes/Main Menu.unity @@ -525,7 +525,7 @@ MonoBehaviour: type: 3} m_TrackedDeviceOrientationAction: {fileID: 1025543830046995696, guid: ca9f5fa95ffab41fb9a615ab714db018, type: 3} - m_DeselectOnBackgroundClick: 1 + m_DeselectOnBackgroundClick: 0 m_PointerBehavior: 0 --- !u!114 &553528108 MonoBehaviour: diff --git a/Assets/Scripts/UI/MainMenu.cs b/Assets/Scripts/UI/MainMenu.cs index e72ce5263..401f4b2af 100644 --- a/Assets/Scripts/UI/MainMenu.cs +++ b/Assets/Scripts/UI/MainMenu.cs @@ -23,7 +23,7 @@ public class MainMenu : MonoBehaviour public float FadeAlphaExponent = 2; public float FadePositionExponent = 2; - private (PropertiesPanel panel, CanvasGroup group) _currentMenu, _nextMenu; + private (PropertiesPanel panel, CanvasGroup group, PropertiesPanel detail) _currentMenu, _nextMenu; private bool _fadeFromRight; private float _fadeLerp; private bool _fading; @@ -38,10 +38,14 @@ public class MainMenu : MonoBehaviour _panelPosition = PanelPrototype.transform.position; var panel1 = PanelPrototype.Instantiate(); - _currentMenu = (panel1, panel1.GetComponent()); + var detail1 = PanelPrototype.Instantiate(); + ScenarioPicker.Dock(detail1, panel1); + _currentMenu = (panel1, panel1.GetComponent(), detail1); var panel2 = PanelPrototype.Instantiate(); - _nextMenu = (panel2, panel2.GetComponent()); + var detail2 = PanelPrototype.Instantiate(); + ScenarioPicker.Dock(detail2, panel2); + _nextMenu = (panel2, panel2.GetComponent(), detail2); _currentMenu.panel.gameObject.SetActive(false); //_saveDirectory = ActionGameManager.GameDataDirectory.CreateSubdirectory("Saves"); @@ -74,7 +78,7 @@ public class MainMenu : MonoBehaviour } } - private string TitleSubtitle(string title, string subtitle) => $"{title}\n{subtitle}"; + public static string TitleSubtitle(string title, string subtitle) => $"{title}\n{subtitle}"; private void Fade(bool fromRight) { @@ -87,10 +91,18 @@ public class MainMenu : MonoBehaviour private static bool _exclusionsShown; - private void ShowMain() + // Starts the next screen on an empty panel with a title and its scenario detail pane hidden and empty. + private void Next(string title) { _nextMenu.panel.Clear(); - _nextMenu.panel.Title.text = TitleSubtitle("aetheria", "terminus"); + _nextMenu.panel.Title.text = title; + _nextMenu.detail.Clear(); + _nextMenu.detail.gameObject.SetActive(false); + } + + private void ShowMain() + { + Next(TitleSubtitle("aetheria", "terminus")); if (ActionGameManager.CurrentGalaxy == null) { // No run is live: at the title, after death, or after a new run failed to start. A saved run exists only as @@ -131,31 +143,18 @@ public class MainMenu : MonoBehaviour // to set up a run and the test arenas are listed only in editor and development builds. private void ShowScenarios() { - _nextMenu.panel.Clear(); - _nextMenu.panel.Title.text = TitleSubtitle("new game", "scenarios"); - AddScenarios(Scenarios.Modes); + Next(TitleSubtitle("new game", "scenarios")); + var groups = new List<(string label, Scenario[] scenarios)> { ("modes", Scenarios.Modes) }; if (Debug.isDebugBuild) { - _nextMenu.panel.AddSection("development"); - AddScenarios(Scenarios.Development); - _nextMenu.panel.AddSection("test arenas"); - AddScenarios(Scenarios.Tests); + groups.Add(("development", Scenarios.Development)); + groups.Add(("test arenas", Scenarios.Tests)); } - _nextMenu.panel.AddButton("Back", - () => - { - ShowMain(); - Fade(false); - }); - } - - private void AddScenarios(IEnumerable scenarios) - { - foreach (var scenario in scenarios) + ScenarioPicker.Show(_nextMenu.panel, _nextMenu.detail, groups, Launch, () => { - _nextMenu.panel.AddButton(scenario.Name, () => Launch(scenario)); - _nextMenu.panel.AddProperty(() => scenario.Brief); - } + ShowMain(); + Fade(false); + }); } // The only way into a new run: generates the scenario's galaxy off the main thread and enters the game, where @@ -263,8 +262,7 @@ public class MainMenu : MonoBehaviour private void ShowSettings() { - _nextMenu.panel.Clear(); - _nextMenu.panel.Title.text = "settings"; + Next("settings"); _nextMenu.panel.AddButton("Gameplay", () => { @@ -299,8 +297,7 @@ public class MainMenu : MonoBehaviour private void ShowGameplaySettings() { - _nextMenu.panel.Clear(); - _nextMenu.panel.Title.text = TitleSubtitle("gameplay", "settings"); + Next(TitleSubtitle("gameplay", "settings")); _nextMenu.panel.AddField("Name", () => ActionGameManager.PlayerSettings.Name, s => ActionGameManager.PlayerSettings.Name = s); @@ -322,8 +319,7 @@ public class MainMenu : MonoBehaviour private void ShowGraphicsSettings() { - _nextMenu.panel.Clear(); - _nextMenu.panel.Title.text = TitleSubtitle("graphics", "settings"); + Next(TitleSubtitle("graphics", "settings")); _nextMenu.panel.AddField("Nebula Quality", () => (int)ActionGameManager.PlayerSettings.GraphicsSettings.NebulaQuality, i => @@ -347,8 +343,7 @@ public class MainMenu : MonoBehaviour private void ShowInputSettings() { - _nextMenu.panel.Clear(); - _nextMenu.panel.Title.text = TitleSubtitle("input", "settings"); + Next(TitleSubtitle("input", "settings")); _nextMenu.panel.AddButton("Back", () => { @@ -359,8 +354,7 @@ public class MainMenu : MonoBehaviour private void ShowAudioSettings() { - _nextMenu.panel.Clear(); - _nextMenu.panel.Title.text = TitleSubtitle("audio", "settings"); + Next(TitleSubtitle("audio", "settings")); _nextMenu.panel.AddButton("Back", () => { diff --git a/Assets/Scripts/UI/ScenarioPicker.cs b/Assets/Scripts/UI/ScenarioPicker.cs new file mode 100644 index 000000000..4d6ab393e --- /dev/null +++ b/Assets/Scripts/UI/ScenarioPicker.cs @@ -0,0 +1,130 @@ +using System; +using System.Collections.Generic; +using UniRx; +using UniRx.Triggers; +using UnityEngine; +using UnityEngine.EventSystems; +using UnityEngine.UI; + +// The New Game screen: scenario names on the left pane by group, the selected scenario's brief, seed and ambient on a +// docked right pane, one Launch. This class owns which scenario is selected (one index into the rows it built); +// everything the screen shows about a scenario, and what Launch and Enter launch, reads that index. Keys arrive only as +// Input System UI events on the focused Launch button, so Enter and a mouse click are the same Button.onClick. +public sealed class ScenarioPicker +{ + private readonly List _scenarios = new List(); + private readonly List _groupLabels = new List(); + private readonly List