From 87793044999446b618d7f35738171708e4aed221 Mon Sep 17 00:00:00 2001 From: Meta Vens Date: Sun, 18 Oct 2026 17:28:36 +0200 Subject: [PATCH 01/31] 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 02/31] 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 03/31] 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 04/31] 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 05/31] 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 49853d3166bdeaaf1f784d447501eeff62efcf15 Mon Sep 17 00:00:00 2001 From: Meta Vens Date: Sun, 18 Oct 2026 17:58:51 +0200 Subject: [PATCH 06/31] 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 07/31] 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 08/31] 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 09/31] 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 10/31] 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 11/31] 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 12/31] 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 beb733f4b50f556012983d7bb60d43760721c891 Mon Sep 17 00:00:00 2001 From: Meta Vens Date: Sun, 18 Oct 2026 18:46:05 +0200 Subject: [PATCH 13/31] Census role gaps become RoleGaps, so a bin skipped by the census has a test; a bin authored on another line is refused Co-Authored-By: Claude Sonnet 5.5 --- .../ProductTiersCommandTests.cs | 7 ++++ .../RolesBackfillCommandTests.cs | 30 ++++++++++++++++ tools/AetherDb/Program.cs | 34 ++++++++++++------- 3 files changed, 58 insertions(+), 13 deletions(-) diff --git a/tests/Aetheria.Shared.Tests/ProductTiersCommandTests.cs b/tests/Aetheria.Shared.Tests/ProductTiersCommandTests.cs index 856cca7dc..2899737e0 100644 --- a/tests/Aetheria.Shared.Tests/ProductTiersCommandTests.cs +++ b/tests/Aetheria.Shared.Tests/ProductTiersCommandTests.cs @@ -126,6 +126,13 @@ public sealed class ProductTiersCommandTests : IDisposable Refused(new ProductLinesCommand.TierRow("Sparrow", "Goldcrest", .5f, "x"), "already belongs"); } + [Fact] + public void A_bin_already_authored_on_another_line_is_refused() + { + Assert.Equal(0, ProductLinesCommand.Run(Apply, _root)); + Refused(new ProductLinesCommand.TierRow("Spotlight", "Goldcrest", .67f, "x"), "already belongs"); + } + [Fact] public void A_line_the_catalog_does_not_hold_is_refused() { diff --git a/tests/Aetheria.Shared.Tests/RolesBackfillCommandTests.cs b/tests/Aetheria.Shared.Tests/RolesBackfillCommandTests.cs index 1f0f974f5..31152b63c 100644 --- a/tests/Aetheria.Shared.Tests/RolesBackfillCommandTests.cs +++ b/tests/Aetheria.Shared.Tests/RolesBackfillCommandTests.cs @@ -239,6 +239,36 @@ public sealed class RolesBackfillCommandTests : IDisposable finally { db.Cache.Dispose(); } } + // The census reports a seller that authors no spread for a role of its design as a gap, and never a bin: a bin reads its line's. + [Fact] + public void The_census_reports_a_roleless_seller_and_never_a_bin() + { + var db = AetherDb.Open(catalogWritable: true, root: _root); + var autocannon = db.Cache.GetAll().Single(weapon => weapon.Name == "Autocannon"); + var seller = db.Cache.GetAll().OrderBy(p => p.Name, StringComparer.Ordinal).First(p => p.Design.Key.Equals(db.Cache.RefOf(autocannon).Key)); + var sellerName = seller.Name; + seller.Roles = new List(); + db.Cache.Commit(batch => + { + batch.Upsert(typeof(FactionProductData), seller, db.Cache.RefOf(seller).Key); + batch.Upsert(typeof(FactionProductData), new FactionProductData + { + Name = "Binned " + sellerName, Design = seller.Design, Manufacturer = seller.Manufacturer, + Bin = new ProductBin { Line = db.Cache.RefOf(seller), Floor = 0f } + }); + }); + db.Cache.Dispose(); + + db = AetherDb.Open(root: _root); + try + { + var gaps = Program.RoleGaps(db.Cache, db.Cache.GetAll().ToArray(), db.Cache.GetAll().ToArray()).ToArray(); + Assert.Contains(gaps, gap => gap.Product.Name == sellerName && gap.Missing.Length == 2); + Assert.DoesNotContain(gaps, gap => gap.Product.Name == "Binned " + sellerName); + } + finally { db.Cache.Dispose(); } + } + // roles-migrate authors a role on every product that sells a design lacking one; a bin sells none of its own. [Fact] public void A_binned_seller_gains_no_roles_from_roles_migrate() diff --git a/tools/AetherDb/Program.cs b/tools/AetherDb/Program.cs index d88718817..7a28b1aa0 100644 --- a/tools/AetherDb/Program.cs +++ b/tools/AetherDb/Program.cs @@ -129,25 +129,33 @@ public static class Program // or a product added after roles landed), so every sold, role-bearing design is checked against every // product that actually sells it, not only the ones this run's roles-migrate happened to touch. var roleGaps = 0; - foreach (var item in items.Where(i => i.Roles != null && i.Roles.Count > 0)) + foreach (var (product, item, missing) in RoleGaps(db.Cache, items, products)) { - var design = db.Cache.RefOf(item).Key; - // A bin authors no spread: it reads its line's (ProductBin). - var sellers = products.Where(p => p.Bin == null && p.Design.Key.Equals(design)).ToArray(); - foreach (var product in sellers) - { - var covered = new HashSet((product.Roles ?? new List()).Select(r => r.Role)); - var missing = item.Roles.Select(r => r.Name).Where(r => !covered.Contains(r)).ToArray(); - if (missing.Length == 0) continue; - roleGaps++; - Console.WriteLine($" GAP: {product.Name} sells {item.Name} but authors no spread for: {string.Join(", ", missing)}" + - " -- a newly minted lot rolls the ProductRole default (mean .5) for these, not this maker's own quality"); - } + roleGaps++; + Console.WriteLine($" GAP: {product.Name} sells {item.Name} but authors no spread for: {string.Join(", ", missing)}" + + " -- a newly minted lot rolls the ProductRole default (mean .5) for these, not this maker's own quality"); } Console.WriteLine($"\n{roleGaps} (product, role) pairs where a sold, role-bearing design's product authors no matching spread"); return 0; } + // Every seller of a role-bearing design that authors no spread for one of its roles. A bin authors no spread: it reads its + // line's (ProductBin), so it is never a gap. + internal static IEnumerable<(FactionProductData Product, EquippableItemData Design, string[] Missing)> RoleGaps( + GameCult.Caching.CultCache cache, IEnumerable items, IEnumerable products) + { + foreach (var item in items.Where(i => i.Roles != null && i.Roles.Count > 0)) + { + var design = cache.RefOf(item).Key; + foreach (var product in products.Where(p => p.Bin == null && p.Design.Key.Equals(design))) + { + var covered = new HashSet((product.Roles ?? new List()).Select(r => r.Role)); + var missing = item.Roles.Select(r => r.Name).Where(r => !covered.Contains(r)).ToArray(); + if (missing.Length > 0) yield return (product, item, missing); + } + } + } + // Cut 7 (docs/stats-and-power-cut.md): "products author quality, designs author roles." Zero designs // declared a role and zero products authored role quality before this command; every field->role table below // is the one place that content is decided, so the operator reviews a legible table instead of hand-picking From 9457e4c8f93e90fb5e6b6857667f7bd9d2f097f3 Mon Sep 17 00:00:00 2001 From: Meta Vens Date: Sun, 18 Oct 2026 19:51:40 +0200 Subject: [PATCH 14/31] 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 15/31] Pin the run seed's edges: AI dice stay on CombatSeed, full-width seed, refusal names its field, refusal test cannot hang - ConsumableAiTests.ThePilotsDiceDoNotMoveWithTheRunSeed: sample offset and patrol circuit equal at different run seeds. - RunSaveTests.TheRunSeedRidesTheSave uses a seed with the top bit set. - RunStartTests.AGeneratedGalaxyRefusesSeedZero builds from authored settings (small ZoneCount) so a constructor that stops refusing fails by assertion, and pins ParamName and no ActualValue. Co-Authored-By: Claude Sonnet 5.5 --- .../ConsumableAiTests.cs | 31 +++++++++++++++++-- tests/Aetheria.Shared.Tests/RunSaveTests.cs | 10 +++--- tests/Aetheria.Shared.Tests/RunStartTests.cs | 22 ++++++++++--- 3 files changed, 50 insertions(+), 13 deletions(-) diff --git a/tests/Aetheria.Shared.Tests/ConsumableAiTests.cs b/tests/Aetheria.Shared.Tests/ConsumableAiTests.cs index 6ad889fda..2f5863958 100644 --- a/tests/Aetheria.Shared.Tests/ConsumableAiTests.cs +++ b/tests/Aetheria.Shared.Tests/ConsumableAiTests.cs @@ -50,6 +50,7 @@ public sealed class ConsumableAiTests : IDisposable public GearData HeavyGun; public CultRecordRef Maker; public float Optimum; + public CultRecordKey[] Circuit; public void Store(ConsumableItemData design) => Assert.True(Hold.TryStore((ItemInstance) Items.CreateInstance(Items.CreateLot(design, Maker, .5f)))); @@ -97,7 +98,7 @@ public sealed class ConsumableAiTests : IDisposable } }; - private Scene Build(bool carryVent = false, bool carryOverdrive = false, bool carryDecoy = false, int stackableVents = 0, float effective = 500f) + private Scene Build(bool carryVent = false, bool carryOverdrive = false, bool carryDecoy = false, int stackableVents = 0, float effective = 500f, uint? runSeed = null, int orbits = 1) { var cache = AetheriaStores.Open(Path.Combine(_root, Guid.NewGuid().ToString("N") + ".cc"), catalogWritable: true); _caches.Add(cache); @@ -150,7 +151,10 @@ public sealed class ConsumableAiTests : IDisposable var maker = cache.RefOf(cache.GetByName("Maker")); EquippableItem Make(string name) where T : CraftedItemData => (EquippableItem) items.CreateInstance(items.CreateLot(cache.GetByName(name), maker, .5f)); - var zone = new Zone(items, new PlanetSettings(), new ZonePack(), new GalaxyZone { Name = "Test Zone", Owner = null }, null); + var galaxy = runSeed.HasValue ? RunSeedFixture.Continue(cache, "Test Zone", runSeed.Value) : null; + var zone = galaxy != null + ? new Zone(items, new PlanetSettings(), new ZonePack(), galaxy.Zones[0], galaxy) + : new Zone(items, new PlanetSettings(), new ZonePack(), new GalaxyZone { Name = "Test Zone", Owner = null }, null); Ship NewShip(string gear) { var ship = new Ship(items, zone, Make("Skiff"), new EntitySettings()) @@ -174,8 +178,10 @@ public sealed class ConsumableAiTests : IDisposable // The agent joins as the zone admits a piloted ship (its Minion and CombatState come from production); the // locker is a gun ship nobody pilots. // One fixed orbit far from the fight, before the agent joins: a pilot that is not engaged patrols it, as production's would. - zone.Orbits.Add(new CultRecordKey("test-orbit"), new Orbit(new OrbitData { FixedPosition = float2(0, 5000) })); + for (var i = 0; i < orbits; i++) + zone.Orbits.Add(new CultRecordKey(orbits == 1 ? "test-orbit" : "test-orbit-" + i), new Orbit(new OrbitData { FixedPosition = float2(0, 5000) })); zone.Admit(scene.Agent, piloted: true); + scene.Circuit = ((PatrolOrbitsTask) zone.Agents.Single().Task).Circuit; zone.Admit(scene.Prey, piloted: false); zone.Admit(scene.Locker, piloted: false); // The flight is the only writer of the agent's Target: the agent senses the prey and holds a grudge on it, so the @@ -356,6 +362,25 @@ public sealed class ConsumableAiTests : IDisposable // The production path for the optimum: a ship the zone admits as piloted derives it from its gun's live stats // before it needs it, and re-derives it when its loadout changes. The gun's effective range is 500 and the // heavy gun's is 800, so the optimum moves with the weapons it has now. + // The run seed feeds drops and mines only (ruling run-seed-drops-only): the same pilot in the same zone samples its + // range with the same offset and draws the same patrol circuit at every run seed. Kills: the combat-sample dice or + // the patrol dice read DropSeed. + [Theory] + [InlineData(1u, 2u)] + [InlineData(0xC0FFEE42u, 0x2468ACE1u)] + public void ThePilotsDiceDoNotMoveWithTheRunSeed(uint first, uint second) + { + var a = Build(runSeed: first, orbits: 8); + var b = Build(runSeed: second, orbits: 8); + a.AssertInCombat(); + b.AssertInCombat(); + Assert.NotEqual(a.Zone.DropSeed, b.Zone.DropSeed); + Assert.Equal(a.Zone.CombatSeed, b.Zone.CombatSeed); + Assert.Equal(a.Optimum, b.Optimum); + Assert.Equal(4, a.Circuit.Length); + Assert.Equal(a.Circuit, b.Circuit); + } + [Fact] public void ASpawnedShipsOptimumFollowsItsWeaponsAndNeverReadsZero() { diff --git a/tests/Aetheria.Shared.Tests/RunSaveTests.cs b/tests/Aetheria.Shared.Tests/RunSaveTests.cs index 7e32c9928..456561d76 100644 --- a/tests/Aetheria.Shared.Tests/RunSaveTests.cs +++ b/tests/Aetheria.Shared.Tests/RunSaveTests.cs @@ -178,25 +178,25 @@ public sealed class RunSaveTests : IDisposable using (var cache = Open()) { var game = Game(cache); - game.Seed = 0xC0FFEEu; + game.Seed = 0xC0FFEE42u; RunSave.Commit(cache, game, Zones(2, StageZoneContents(cache), 0), new ProvenanceLedger()); } using (var cache = Open()) { var galaxy = new Galaxy(cache, cache.GetGlobal(), _ => { }); - Assert.Equal(0xC0FFEEu, galaxy.Seed); + Assert.Equal(0xC0FFEE42u, galaxy.Seed); var itemManager = new ItemManager(cache, new ProvenanceLedger(), TestSettings(), _ => { }); galaxy.Zones[0].Contents = new Zone(itemManager, new PlanetSettings(), new ZonePack(), galaxy.Zones[0], galaxy); // The zone's drop dice are its name's seed mixed with the run's. - Assert.Equal("Zone 0".StableHash() ^ SimulationDice.Mix(0xC0FFEEu), galaxy.Zones[0].Contents.DropSeed); + Assert.Equal("Zone 0".StableHash() ^ SimulationDice.Mix(0xC0FFEE42u), galaxy.Zones[0].Contents.DropSeed); var (game, zones) = RunSave.Capture(cache, galaxy, galaxy.Zones[0].Contents, null, new SavedActionBarBinding[0], 0); - Assert.Equal(0xC0FFEEu, game.Seed); + Assert.Equal(0xC0FFEE42u, game.Seed); RunSave.Commit(cache, game, zones, RunSave.Lots(cache)); } using (var cache = Open()) - Assert.Equal(0xC0FFEEu, cache.GetGlobal().Seed); + Assert.Equal(0xC0FFEE42u, cache.GetGlobal().Seed); // A save written before run-seed holds no seed, and continues on name-only drops. using (var cache = Open()) diff --git a/tests/Aetheria.Shared.Tests/RunStartTests.cs b/tests/Aetheria.Shared.Tests/RunStartTests.cs index 7c22bb911..4b181f67e 100644 --- a/tests/Aetheria.Shared.Tests/RunStartTests.cs +++ b/tests/Aetheria.Shared.Tests/RunStartTests.cs @@ -115,14 +115,26 @@ public sealed partial class RunStartTests : IDisposable private const float Freezing = 273.15f; - // One minter: a generated galaxy never reads the clock, so seed 0 is refused by both generation constructors. + // One minter: a generated galaxy never reads the clock, so seed 0 is refused by both generation constructors, naming + // the field and echoing no input (no ActualValue). The settings are the authored ones: a galaxy that stops refusing must be able to + // finish generating, so the test fails by assertion rather than wedging in the generator. [Fact] public void AGeneratedGalaxyRefusesSeedZero() { - Assert.Throws(() => - new Galaxy(new SectorGenerationSettings(), new SectorBackgroundSettings(), _cache, _ => { }, null, 0)); - Assert.Throws(() => - new Galaxy(new TutorialGenerationSettings(), new SectorBackgroundSettings(), _cache, new PlayerSettings(), new DirectoryInfo(_root), _ => { }, null, 0)); + var authored = AuthoredSettings.Load(FindRepoRoot()); + var background = authored.Read("SectorBackgroundSettings"); + var sector = authored.Read("SectorGenerationSettings"); + sector.ZoneCount = 8; + var tutorial = authored.Read("TutorialGenerationSettings"); + + var main = Assert.Throws(() => new Galaxy(sector, background, _cache, _ => { }, null, 0)); + var prelude = Assert.Throws(() => + new Galaxy(tutorial, background, _cache, new PlayerSettings(), new DirectoryInfo(_root), _ => { }, null, 0)); + foreach (var refusal in new[] { main, prelude }) + { + Assert.Equal("seed", refusal.ParamName); + Assert.Null(refusal.ActualValue); + } } // A main-sector galaxy: each station's gear must come from manufacturers its faction can reach, the makers its From cb1c093e84b90ea634a708a0eb6bf06ba30f0dce Mon Sep 17 00:00:00 2001 From: Meta Vens Date: Sun, 18 Oct 2026 20:21:31 +0200 Subject: [PATCH 16/31] 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 129e5ff4dc506737721e28c2e1a46445bf8a03d1 Mon Sep 17 00:00:00 2001 From: Meta Vens Date: Sun, 18 Oct 2026 21:08:09 +0200 Subject: [PATCH 17/31] 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 18/31] 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 19/31] 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 20/31] 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 e6a17a5e01dd4d34722de7d857f2ecb95f5f1203 Mon Sep 17 00:00:00 2001 From: Meta Vens Date: Sun, 18 Oct 2026 21:14:14 +0200 Subject: [PATCH 21/31] 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 e5485bb3e0c18eb452374764447fef6106ca2f8c Mon Sep 17 00:00:00 2001 From: Meta Vens Date: Sun, 18 Oct 2026 21:17:42 +0200 Subject: [PATCH 22/31] 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 763fa0987f5975bf29401a91d51aa0a62f69e20c Mon Sep 17 00:00:00 2001 From: soul Date: Sun, 18 Oct 2026 21:57:47 +0200 Subject: [PATCH 23/31] 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 24/31] 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 25/31] 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 26/31] 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 27/31] 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 28/31] 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 29/31] 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 35c93dd6ab34362a2dc1c1a02e668e26726aa24c Mon Sep 17 00:00:00 2001 From: soul Date: Mon, 19 Oct 2026 00:13:44 +0200 Subject: [PATCH 30/31] 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 3ac185a84e66b86fd3b3e8522ee8f850b9c1fe5e Mon Sep 17 00:00:00 2001 From: soul Date: Mon, 19 Oct 2026 02:07:26 +0200 Subject: [PATCH 31/31] 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.