From c13bb2368edd1be7fadae8c55763d71ff8ecaaee Mon Sep 17 00:00:00 2001 From: soul Date: Wed, 21 Oct 2026 18:59:52 +0200 Subject: [PATCH 001/307] determinism-ledger: delete the clock-seeded item stream and the item-stream scatter ItemManager no longer seeds itself from DateTime.Now, and Zone's unpack no longer scatters ships with _itemManager.Random. Deletes before adds: this commit does not build (the scatter and every ItemManager seed return in the next commit). Co-Authored-By: Claude Opus 5.5 --- Assets/Scripts/ServerShared/ItemManager.cs | 2 +- Assets/Scripts/ServerShared/Zone.cs | 3 --- 2 files changed, 1 insertion(+), 4 deletions(-) diff --git a/Assets/Scripts/ServerShared/ItemManager.cs b/Assets/Scripts/ServerShared/ItemManager.cs index b33b17224..5c15d5e6d 100644 --- a/Assets/Scripts/ServerShared/ItemManager.cs +++ b/Assets/Scripts/ServerShared/ItemManager.cs @@ -17,7 +17,7 @@ using float4 = CultMath.float4; public class ItemManager { - public Random Random = new Random((uint) (DateTime.Now.Ticks%uint.MaxValue)); + public Random Random; private Action _logger; diff --git a/Assets/Scripts/ServerShared/Zone.cs b/Assets/Scripts/ServerShared/Zone.cs index dc8cd0538..a69fb9d41 100644 --- a/Assets/Scripts/ServerShared/Zone.cs +++ b/Assets/Scripts/ServerShared/Zone.cs @@ -271,9 +271,6 @@ public class Zone var entity = EntitySerializer.Unpack(_itemManager, this, entityPack); var ship = entity as Ship; Admit(entity, piloted: ship is {IsPlayerShip: false}); - // Generation placement, not admission: a ship packed at the origin is scattered across the zone. - if (ship is {IsPlayerShip: false} && lengthsq(ship.Position) < 1) - ship.Position = _itemManager.Random.NextFloat3(float3(-pack.Radius * .5f), float3(pack.Radius * .5f)); } // TODO: Associate planets with stored entities for planetary colonies From cc0c1645748011f543fc65ab6a295d3e0896816b Mon Sep 17 00:00:00 2001 From: soul Date: Wed, 21 Oct 2026 19:01:41 +0200 Subject: [PATCH 002/307] determinism-ledger: every ItemManager states its seed; scatter on the zone's dice - ItemManager(uint seed, ...) refuses seed 0 and seeds Random from it. - RunStart.ItemSeed(galaxy, step) is public (formula unchanged), refuses a zero result; named steps ArenaStep, StageStep, ContinueStep and NoRunSeed. - Continue seeds the item stream from the saved run's seed (ContinueStep); a scene with no galaxy uses NoRunSeed. - Zone's unpack scatter draws SimulationDice.For(CombatSeed, ScatterStream, 1-based pack ordinal), never the item stream. - Loadout's scratch manager and AetherDb's catalog command take fixed seeds; every test and Editor construction gains 1u (mechanical rewrite). Co-Authored-By: Claude Opus 5.5 --- .../Scripts/Editor/BeltFloaterLayerVerify.cs | 2 +- .../Editor/CapabilityPresentationSmoke.cs | 2 +- .../Scripts/Editor/DrawnPoseWiringVerify.cs | 2 +- .../Scripts/Editor/FloatingItemsDrawnSmoke.cs | 2 +- .../Scripts/Editor/FlowFieldParityVerify.cs | 2 +- .../Editor/GravityFieldParityVerify.cs | 2 +- Assets/Scripts/Editor/MapBeltPerf.cs | 2 +- Assets/Scripts/Editor/MapPresenterSmoke.cs | 2 +- Assets/Scripts/Gameplay/ActionGameManager.cs | 6 ++- Assets/Scripts/ServerShared/ItemManager.cs | 6 ++- Assets/Scripts/ServerShared/Loadout.cs | 5 +- Assets/Scripts/ServerShared/RunStart.cs | 23 ++++++-- Assets/Scripts/ServerShared/SimulationDice.cs | 4 ++ Assets/Scripts/ServerShared/Zone.cs | 7 +++ .../AssemblyFromMakerTests.cs | 2 +- tests/Aetheria.Shared.Tests/BrownoutTests.cs | 2 +- .../ChildrenWithinReachTests.cs | 2 +- .../ConditionRatioTests.cs | 2 +- .../ConsumableAiTests.cs | 2 +- .../ConsumableSupplyTests.cs | 4 +- .../Aetheria.Shared.Tests/ConsumableTests.cs | 2 +- .../ContactRevealTests.cs | 2 +- tests/Aetheria.Shared.Tests/ContactTests.cs | 2 +- tests/Aetheria.Shared.Tests/DrawnPoseTests.cs | 2 +- .../FireAuthorityTests.cs | 2 +- .../FireControlCut11Tests.cs | 2 +- .../FireControlCut123Tests.cs | 2 +- .../FireControlCut124Tests.cs | 2 +- .../FireControlCut12Tests.cs | 2 +- .../FireControlCut4Tests.cs | 6 +-- .../FireControlCut5Tests.cs | 8 +-- .../FireControlCut6Tests.cs | 2 +- .../FireControlCut6cTests.cs | 4 +- .../FireControlCut6dTests.cs | 2 +- .../FireControlCut7Tests.cs | 4 +- .../FireControlCut8Tests.cs | 4 +- .../FireControlCut9Tests.cs | 6 +-- .../FireControlPerWeaponTests.cs | 2 +- .../Aetheria.Shared.Tests/FireControlTests.cs | 2 +- .../FloatingBodyTests.cs | 4 +- tests/Aetheria.Shared.Tests/FlowFieldTests.cs | 2 +- .../GravitySubtractTests.cs | 2 +- .../HeatResponseTests.cs | 8 +-- .../IffAndCombatTests.cs | 2 +- .../InputCapacitorTests.cs | 2 +- .../InspectionScannerCatalogTests.cs | 2 +- tests/Aetheria.Shared.Tests/ItemTitleTests.cs | 2 +- .../LaunchSuppressorCatalogTests.cs | 2 +- tests/Aetheria.Shared.Tests/LayerTests.cs | 2 +- tests/Aetheria.Shared.Tests/LoadoutTests.cs | 54 +++++++++---------- .../Aetheria.Shared.Tests/LockWarningTests.cs | 2 +- tests/Aetheria.Shared.Tests/LootTests.cs | 2 +- tests/Aetheria.Shared.Tests/MineTests.cs | 2 +- .../Aetheria.Shared.Tests/MiningCut1Tests.cs | 2 +- .../Aetheria.Shared.Tests/MiningCut2Tests.cs | 2 +- .../Aetheria.Shared.Tests/MiningCut3Tests.cs | 10 ++-- .../MunitionPartsTests.cs | 2 +- .../OrbitsFromFieldTests.cs | 2 +- tests/Aetheria.Shared.Tests/PaintModeTests.cs | 2 +- tests/Aetheria.Shared.Tests/PickupTests.cs | 2 +- .../PopulatedRadiusTests.cs | 2 +- .../PotentialOwnerTests.cs | 2 +- tests/Aetheria.Shared.Tests/PowerBusTests.cs | 2 +- .../Aetheria.Shared.Tests/PowerCurveTests.cs | 4 +- tests/Aetheria.Shared.Tests/PowerTierTests.cs | 2 +- .../Aetheria.Shared.Tests/ProductBinTests.cs | 2 +- .../ProductChoiceTests.cs | 4 +- .../RangeFalloffTests.cs | 2 +- .../RestoredHullsTests.cs | 28 +++++----- .../RoleAuthoringTests.cs | 2 +- tests/Aetheria.Shared.Tests/RunGoalTests.cs | 2 +- tests/Aetheria.Shared.Tests/RunSaveTests.cs | 12 ++--- tests/Aetheria.Shared.Tests/RunStartTests.cs | 2 +- .../ShieldReserveTests.cs | 2 +- tests/Aetheria.Shared.Tests/SignatureTests.cs | 2 +- .../StatResolverTests.cs | 18 +++---- .../StationPlacementTests.cs | 2 +- .../StationServicesTests.cs | 2 +- .../SunsNeverGreenTests.cs | 2 +- .../TargetingSystemTests.cs | 6 +-- .../ThrottleLockTests.cs | 2 +- .../Aetheria.Shared.Tests/VapourCloudTests.cs | 2 +- .../ZonePlacedAtBuildTests.cs | 2 +- tests/Aetheria.Shared.Tests/ZoneWorlds.cs | 2 +- tools/AetherDb/Program.cs | 3 +- 85 files changed, 200 insertions(+), 160 deletions(-) diff --git a/Assets/Scripts/Editor/BeltFloaterLayerVerify.cs b/Assets/Scripts/Editor/BeltFloaterLayerVerify.cs index 43153b267..500ae1cea 100644 --- a/Assets/Scripts/Editor/BeltFloaterLayerVerify.cs +++ b/Assets/Scripts/Editor/BeltFloaterLayerVerify.cs @@ -122,7 +122,7 @@ namespace Aetheria.EditorTools var cache = AetheriaStores.Open(Path.Combine(_work, "belts.cc"), Path.Combine(_work, "belts-run.cc"), catalogWritable: true); cache.Upsert(new VerseGrammar { Revision = 1 }); cache.FlushAsync().Wait(); - var items = new ItemManager(cache, new ProvenanceLedger(), settings, _ => { }); + var items = new ItemManager(1u, cache, new ProvenanceLedger(), settings, _ => { }); CultRecordKey Orbit(CultRecordKey parent, float distance, float phase, float2 fixedPosition = default) { diff --git a/Assets/Scripts/Editor/CapabilityPresentationSmoke.cs b/Assets/Scripts/Editor/CapabilityPresentationSmoke.cs index fd8f1cffd..135163e24 100644 --- a/Assets/Scripts/Editor/CapabilityPresentationSmoke.cs +++ b/Assets/Scripts/Editor/CapabilityPresentationSmoke.cs @@ -62,7 +62,7 @@ public static class CapabilityPresentationSmoke cache.FlushAsync().Wait(); var ledger = new ProvenanceLedger(); for (var i = 1; i <= 8; i++) ledger.Lots[i] = new Lot { Origin = new Attributed(), Quality = 1 }; - var items = new ItemManager(cache, ledger, settings, _ => { }); + var items = new ItemManager(1u, cache, ledger, settings, _ => { }); var zone = new Zone(items, new PlanetSettings(), new ZonePack(), new GalaxyZone { Name = "Smoke", Owner = null }, null); var hullRef = items.ItemData.RefOf(items.ItemData.GetByName("Smoke Hull")); var ship = new Ship(items, zone, new EquippableItem { Data = hullRef, Durability = 1000, Lot = 1 }, new EntitySettings()); diff --git a/Assets/Scripts/Editor/DrawnPoseWiringVerify.cs b/Assets/Scripts/Editor/DrawnPoseWiringVerify.cs index c1994ebeb..85223b510 100644 --- a/Assets/Scripts/Editor/DrawnPoseWiringVerify.cs +++ b/Assets/Scripts/Editor/DrawnPoseWiringVerify.cs @@ -115,7 +115,7 @@ namespace Aetheria.EditorTools cache.Upsert(new SimpleCommodityData { Name = "Wiring Ore", Shape = oneCell, MaxStack = 100 }); cache.FlushAsync().Wait(); var ledger = new ProvenanceLedger(); - var items = new ItemManager(cache, ledger, settings, _ => { }); + var items = new ItemManager(1u, cache, ledger, settings, _ => { }); // A sun and a planet on a moving rail round it, both near the bodies below: the field under a floater changes with the // drawn time, so a presenter that samples it at the step's time, drops it, or reads lead 0 for the height draws a different y. diff --git a/Assets/Scripts/Editor/FloatingItemsDrawnSmoke.cs b/Assets/Scripts/Editor/FloatingItemsDrawnSmoke.cs index 1caf77b6c..742e8aa9d 100644 --- a/Assets/Scripts/Editor/FloatingItemsDrawnSmoke.cs +++ b/Assets/Scripts/Editor/FloatingItemsDrawnSmoke.cs @@ -124,7 +124,7 @@ public static class FloatingItemsDrawnSmoke cache.FlushAsync().Wait(); var ledger = new ProvenanceLedger(); for (var i = 1; i <= 8; i++) ledger.Lots[i] = new Lot { Origin = new Attributed(), Quality = 1 }; - var items = new ItemManager(cache, ledger, settings, _ => { }); + var items = new ItemManager(1u, cache, ledger, settings, _ => { }); var zone = new Zone(items, new PlanetSettings(), new ZonePack(), null, null); EquippableItem Make(string name, int lot) => new EquippableItem { diff --git a/Assets/Scripts/Editor/FlowFieldParityVerify.cs b/Assets/Scripts/Editor/FlowFieldParityVerify.cs index 90979f7e8..2fa3e5fda 100644 --- a/Assets/Scripts/Editor/FlowFieldParityVerify.cs +++ b/Assets/Scripts/Editor/FlowFieldParityVerify.cs @@ -106,7 +106,7 @@ namespace Aetheria.EditorTools var cache = AetheriaStores.Open(Path.Combine(work, Guid.NewGuid().ToString("N") + ".cc"), catalogWritable: true); cache.Upsert(new VerseGrammar { Revision = 1 }); cache.FlushAsync().Wait(); - var items = new ItemManager(cache, new ProvenanceLedger(), settings.GameplaySettings, _ => { }); + var items = new ItemManager(1u, cache, new ProvenanceLedger(), settings.GameplaySettings, _ => { }); return new Zone(items, new PlanetSettings { Flow = flow }, new ZonePack { Time = time }, new GalaxyZone { Name = "Parity", Owner = null }, null); } diff --git a/Assets/Scripts/Editor/GravityFieldParityVerify.cs b/Assets/Scripts/Editor/GravityFieldParityVerify.cs index 766a58a6f..d996d3227 100644 --- a/Assets/Scripts/Editor/GravityFieldParityVerify.cs +++ b/Assets/Scripts/Editor/GravityFieldParityVerify.cs @@ -154,7 +154,7 @@ namespace Aetheria.EditorTools var cache = AetheriaStores.Open(Path.Combine(work, "catalog.cc"), Path.Combine(work, "run.cc"), catalogWritable: true); cache.Upsert(new VerseGrammar { Revision = 1 }); cache.FlushAsync().Wait(); - var items = new ItemManager(cache, new ProvenanceLedger(), settings.GameplaySettings, _ => { }); + var items = new ItemManager(1u, cache, new ProvenanceLedger(), settings.GameplaySettings, _ => { }); var positions = new List { new Vector2(-130f, 85f), new Vector2(610f, -240f), new Vector2(670f, -270f), new Vector2(1700f, 300f) }; var masses = new List { 14152f, 273.9f, 29.2f, 0f }; var parents = new List { -1, 0, 1, -1 }; diff --git a/Assets/Scripts/Editor/MapBeltPerf.cs b/Assets/Scripts/Editor/MapBeltPerf.cs index edc771a50..ea3eac5c0 100644 --- a/Assets/Scripts/Editor/MapBeltPerf.cs +++ b/Assets/Scripts/Editor/MapBeltPerf.cs @@ -52,7 +52,7 @@ public static class MapBeltPerf Planets = { new CultRecordRef(sunKey), new CultRecordRef(beltKey) }, Radius = 5000f, Mass = 10000f, Time = 0 }; - var items = new ItemManager(cache, new ProvenanceLedger(), settings.GameplaySettings, _ => { }); + var items = new ItemManager(1u, cache, new ProvenanceLedger(), settings.GameplaySettings, _ => { }); var zone = new Zone(items, new PlanetSettings { BodyRadius = new ExponentialCurve(), WellStrength = new ExponentialCurve { Multiplier = 1f, Exponent = 1f }, diff --git a/Assets/Scripts/Editor/MapPresenterSmoke.cs b/Assets/Scripts/Editor/MapPresenterSmoke.cs index 88309c40e..cccc4883c 100644 --- a/Assets/Scripts/Editor/MapPresenterSmoke.cs +++ b/Assets/Scripts/Editor/MapPresenterSmoke.cs @@ -115,7 +115,7 @@ public static class MapPresenterSmoke }; var ledger = new ProvenanceLedger(); for (var i = 1; i <= 8; i++) ledger.Lots[i] = new Lot { Origin = new Attributed(), Quality = 1 }; - var items = new ItemManager(cache, ledger, settings.GameplaySettings, _ => { }); + var items = new ItemManager(1u, cache, ledger, settings.GameplaySettings, _ => { }); var zone = new Zone(items, planetSettings, pack, new GalaxyZone { Name = "Smoke", Owner = null }, null); var hullRef = items.ItemData.RefOf(items.ItemData.GetByName("Smoke Hull")); var ship = new Ship(items, zone, new EquippableItem { Data = hullRef, Durability = 1000, Lot = 1 }, new EntitySettings()); diff --git a/Assets/Scripts/Gameplay/ActionGameManager.cs b/Assets/Scripts/Gameplay/ActionGameManager.cs index 9b4c6ad59..e791f1a64 100644 --- a/Assets/Scripts/Gameplay/ActionGameManager.cs +++ b/Assets/Scripts/Gameplay/ActionGameManager.cs @@ -294,7 +294,11 @@ public class ActionGameManager : MonoBehaviour ConsoleController.MessageReceiver = this; // A new run mints into an empty ledger; the saved run's stays in the store until the new run has started. - ItemManager = new ItemManager(CultCache, PendingScenario != null ? new ProvenanceLedger() : RunSave.Lots(CultCache), + // A new run is reseeded by RunStart; Continue's item draws come from the saved run's seed. + ItemManager = new ItemManager( + CurrentGalaxy == null ? RunStart.NoRunSeed + : RunStart.ItemSeed(CurrentGalaxy, PendingScenario != null ? RunStart.ArenaStep : RunStart.ContinueStep), + CultCache, PendingScenario != null ? new ProvenanceLedger() : RunSave.Lots(CultCache), Settings.GameplaySettings, Debug.Log); ZoneRenderer.ItemManager = ItemManager; diff --git a/Assets/Scripts/ServerShared/ItemManager.cs b/Assets/Scripts/ServerShared/ItemManager.cs index 5c15d5e6d..090fe1545 100644 --- a/Assets/Scripts/ServerShared/ItemManager.cs +++ b/Assets/Scripts/ServerShared/ItemManager.cs @@ -17,6 +17,8 @@ using float4 = CultMath.float4; public class ItemManager { + // The item stream: each lot's quality and each generated fit. Seeded by whoever constructs the manager and reseeded + // by RunStart at each step of a new run, never by the clock. public Random Random; private Action _logger; @@ -25,8 +27,10 @@ public class ItemManager public ProvenanceLedger Lots { get; } public GameplaySettings GameplaySettings { get; } - public ItemManager(CultCache itemData, ProvenanceLedger lots, GameplaySettings settings, Action logger) + public ItemManager(uint seed, CultCache itemData, ProvenanceLedger lots, GameplaySettings settings, Action logger) { + if (seed == 0) throw new ArgumentOutOfRangeException(nameof(seed), "an item stream needs a nonzero seed"); + Random = new Random(seed); ItemData = itemData; Lots = lots; GameplaySettings = settings; diff --git a/Assets/Scripts/ServerShared/Loadout.cs b/Assets/Scripts/ServerShared/Loadout.cs index eec005f46..d7fec7f03 100644 --- a/Assets/Scripts/ServerShared/Loadout.cs +++ b/Assets/Scripts/ServerShared/Loadout.cs @@ -127,6 +127,9 @@ public static class Loadouts // The quality a design no manufacturer makes is built at: the console give's. public const float UnbrandedQuality = .95f; + // The seed of the scratch item manager a fit is tried on. It draws nothing, so any nonzero constant does. + private const uint ScratchSeed = 1u; + // All-or-nothing: returns an entity only when every design resolved and fitted, the loadout has at most // WeaponGroupCount weapon groups, and every group index names a weapon slot. Every failure of a stage is listed; on any // failure nothing is returned and the zone and cache are unchanged. The stages run in the order that puts refusal @@ -166,7 +169,7 @@ public static class Loadouts // The cells fit or they do not whatever brand a design-only slot takes: the shape is the design's. Tried on its own // item manager, so nothing is minted into the live ledger and nothing is drawn. - var scratch = new ItemManager(cache, new ProvenanceLedger(), itemManager.GameplaySettings, _ => { }); + var scratch = new ItemManager(ScratchSeed, cache, new ProvenanceLedger(), itemManager.GameplaySettings, _ => { }); Assemble(scratch, null, hullData, loadout, Unbranded(scratch, hullSettled), slotsSettled.Select(settled => Unbranded(scratch, settled)).ToArray(), failures, out _); if (failures.Count > reported) return null; diff --git a/Assets/Scripts/ServerShared/RunStart.cs b/Assets/Scripts/ServerShared/RunStart.cs index cce2ab977..2e1245bab 100644 --- a/Assets/Scripts/ServerShared/RunStart.cs +++ b/Assets/Scripts/ServerShared/RunStart.cs @@ -25,7 +25,7 @@ public static class RunStart public static ZonePack GenerateArena(ItemManager itemManager, ZoneGenerationSettings zoneSettings, PlanetSettings planetSettings, Galaxy galaxy, Scenario scenario) { - SeedItems(itemManager, galaxy, 1); + SeedItems(itemManager, galaxy, ArenaStep); return galaxy.Entrance.PackedContents = ZoneGenerator.GenerateZone( itemManager, zoneSettings, planetSettings, galaxy, galaxy.Entrance, galaxy.IsPrelude, scenario.Ambient); } @@ -36,7 +36,7 @@ public static class RunStart public static Staged Stage(ItemManager itemManager, Zone arena, Scenario scenario, string startingHull, TutorialGenerationSettings tutorialGenerationSettings, List failures) { - SeedItems(itemManager, arena.Galaxy, 2); + SeedItems(itemManager, arena.Galaxy, StageStep); var stage = new ScenarioStage(itemManager, arena, startingHull, tutorialGenerationSettings); scenario.Stage(stage); var reported = failures.Count; @@ -62,5 +62,22 @@ public static class RunStart // so a fixed seed fixes the arena's gear and the player's fit. Each step draws its own stream, so whatever runs // between them (populating the level) cannot shift it. private static void SeedItems(ItemManager itemManager, Galaxy galaxy, uint step) => - itemManager.Random = new Random(galaxy.Seed * 0x9E3779B1u + step); + itemManager.Random = new Random(ItemSeed(galaxy, step)); + + // The steps of a run that draw items: generating its arena, staging its scenario, and continuing a saved run. + public const uint ArenaStep = 1; + public const uint StageStep = 2; + public const uint ContinueStep = 3; + + // The item seed of an ActionGameManager scene opened with no galaxy. No run is loaded, so nothing a run decides + // draws from it. + public const uint NoRunSeed = 0x5EED0001u; + + // The item stream's seed for one step of a run: the run's seed and the step. Never zero, which no stream accepts. + public static uint ItemSeed(Galaxy galaxy, uint step) + { + var seed = galaxy.Seed * 0x9E3779B1u + step; + if (seed == 0) throw new InvalidOperationException($"Item seed is zero for galaxy seed {galaxy.Seed} at step {step}."); + return seed; + } } diff --git a/Assets/Scripts/ServerShared/SimulationDice.cs b/Assets/Scripts/ServerShared/SimulationDice.cs index ce646d670..adc440299 100644 --- a/Assets/Scripts/ServerShared/SimulationDice.cs +++ b/Assets/Scripts/ServerShared/SimulationDice.cs @@ -14,6 +14,10 @@ public static class SimulationDice // never rides ItemManager.Random, so minting items cannot change how a pilot fights. public const uint CombatSampleStream = 3; public const uint PatrolStream = 4; + // Generation placement of an unpacked ship packed at the origin, ordinal its 1-based index in the pack, on the zone's + // seed (CombatSeed): the run seed feeds drops and mines only (ruling run-seed-drops-only), and placement never rides + // ItemManager.Random. + public const uint ScatterStream = 5; // 9.1 (docs/fire-control-cut.md): fmix32, MurmurHash3's 32-bit finalizer -- xor-shift, multiply, // xor-shift, multiply, xor-shift. Diffuses a structured seed (a zone seed XORed with a small, diff --git a/Assets/Scripts/ServerShared/Zone.cs b/Assets/Scripts/ServerShared/Zone.cs index a69fb9d41..ea46f4c6f 100644 --- a/Assets/Scripts/ServerShared/Zone.cs +++ b/Assets/Scripts/ServerShared/Zone.cs @@ -266,11 +266,18 @@ public class Zone foreach (var wear in pack.ChunkWear) _wear[new ChunkId(wear.Field, wear.Index)] = new ChunkWear { Damage = wear.Damage, BrokenUntil = wear.BrokenUntil }; + var ordinal = 0u; foreach (var entityPack in pack.Entities) { + ordinal++; var entity = EntitySerializer.Unpack(_itemManager, this, entityPack); var ship = entity as Ship; Admit(entity, piloted: ship is {IsPlayerShip: false}); + // Generation placement, not admission: a ship packed at the origin is scattered across the zone, on the + // zone's own dice, so nothing drawn from the item stream can move it. + if (ship is {IsPlayerShip: false} && lengthsq(ship.Position) < 1) + ship.Position = SimulationDice.For(CombatSeed, SimulationDice.ScatterStream, ordinal) + .NextFloat3(float3(-pack.Radius * .5f), float3(pack.Radius * .5f)); } // TODO: Associate planets with stored entities for planetary colonies diff --git a/tests/Aetheria.Shared.Tests/AssemblyFromMakerTests.cs b/tests/Aetheria.Shared.Tests/AssemblyFromMakerTests.cs index 301a67cd1..66bcafbe3 100644 --- a/tests/Aetheria.Shared.Tests/AssemblyFromMakerTests.cs +++ b/tests/Aetheria.Shared.Tests/AssemblyFromMakerTests.cs @@ -25,7 +25,7 @@ public sealed class AssemblyFromMakerTests : IDisposable Roles = { new ItemRole { Name = "lens" } } }); _lens = new CultRecordRef(gear.Key); - _items = new ItemManager(_cache, new ProvenanceLedger(), RunSaveTests.TestSettings(), _ => { }); + _items = new ItemManager(1u, _cache, new ProvenanceLedger(), RunSaveTests.TestSettings(), _ => { }); } public void Dispose() diff --git a/tests/Aetheria.Shared.Tests/BrownoutTests.cs b/tests/Aetheria.Shared.Tests/BrownoutTests.cs index b48657e33..95958170c 100644 --- a/tests/Aetheria.Shared.Tests/BrownoutTests.cs +++ b/tests/Aetheria.Shared.Tests/BrownoutTests.cs @@ -101,7 +101,7 @@ public sealed class BrownoutTests : IDisposable private Ship BuildShip(CultCache cache, float reactorCharge) { - var items = new ItemManager(cache, new ProvenanceLedger(), Settings(), _ => { }); + var items = new ItemManager(1u, cache, new ProvenanceLedger(), Settings(), _ => { }); var zone = new Zone(items, new PlanetSettings(), new ZonePack(), new GalaxyZone { Name = "Test Zone", Owner = null }, null); var hull = Mint(cache, items, cache.GetByName("Skiff")); var ship = new Ship(items, zone, hull, new EntitySettings()); diff --git a/tests/Aetheria.Shared.Tests/ChildrenWithinReachTests.cs b/tests/Aetheria.Shared.Tests/ChildrenWithinReachTests.cs index 22646a129..40b2f591b 100644 --- a/tests/Aetheria.Shared.Tests/ChildrenWithinReachTests.cs +++ b/tests/Aetheria.Shared.Tests/ChildrenWithinReachTests.cs @@ -42,7 +42,7 @@ public sealed class ChildrenWithinReachTests : IClassFixture("TutorialBackgroundSettings"), cache, new PlayerSettings(), Directory.CreateDirectory(Path.Combine(dir, "Narrative")), _ => { }, null, seed); - var items = new ItemManager(cache, new ProvenanceLedger(), authored.Read("GameplaySettings"), _ => { }); + var items = new ItemManager(1u, cache, new ProvenanceLedger(), authored.Read("GameplaySettings"), _ => { }); var planetSettings = authored.Read("PlanetSettings"); foreach (var galaxyZone in galaxy.Zones) { diff --git a/tests/Aetheria.Shared.Tests/ConditionRatioTests.cs b/tests/Aetheria.Shared.Tests/ConditionRatioTests.cs index c3a5ddcde..00d446384 100644 --- a/tests/Aetheria.Shared.Tests/ConditionRatioTests.cs +++ b/tests/Aetheria.Shared.Tests/ConditionRatioTests.cs @@ -84,7 +84,7 @@ public sealed class ConditionRatioTests : IDisposable private Ship BuildShip(CultCache cache, float reactorCharge, out EquippedItem consumer) { - var items = new ItemManager(cache, new ProvenanceLedger(), Settings(), _ => { }); + var items = new ItemManager(1u, cache, new ProvenanceLedger(), Settings(), _ => { }); var zone = new Zone(items, new PlanetSettings(), new ZonePack(), new GalaxyZone { Name = "Test Zone", Owner = null }, null); var hull = Mint(cache, items, cache.GetByName("Skiff")); var ship = new Ship(items, zone, hull, new EntitySettings()); diff --git a/tests/Aetheria.Shared.Tests/ConsumableAiTests.cs b/tests/Aetheria.Shared.Tests/ConsumableAiTests.cs index 7478b3440..1b6d95cc9 100644 --- a/tests/Aetheria.Shared.Tests/ConsumableAiTests.cs +++ b/tests/Aetheria.Shared.Tests/ConsumableAiTests.cs @@ -147,7 +147,7 @@ public sealed class ConsumableAiTests : IDisposable settings.UnaidedPrecision = 2f; settings.AgentMinHitProbability = 0f; settings.BeamResolveInterval = .1f; - var items = new ItemManager(cache, new ProvenanceLedger(), settings, _ => { }); + var items = new ItemManager(1u, cache, new ProvenanceLedger(), settings, _ => { }); var maker = cache.RefOf(cache.GetByName("Maker")); EquippableItem Make(string name) where T : CraftedItemData => (EquippableItem) items.CreateInstance(items.CreateLot(cache.GetByName(name), maker, .5f)); diff --git a/tests/Aetheria.Shared.Tests/ConsumableSupplyTests.cs b/tests/Aetheria.Shared.Tests/ConsumableSupplyTests.cs index ea8fbab1e..b02dffea7 100644 --- a/tests/Aetheria.Shared.Tests/ConsumableSupplyTests.cs +++ b/tests/Aetheria.Shared.Tests/ConsumableSupplyTests.cs @@ -153,7 +153,7 @@ public sealed class ConsumableSupplyTests : IDisposable return new Rig { Cache = cache, - Items = new ItemManager(cache, new ProvenanceLedger(), RunSaveTests.TestSettings(), _ => { }), + Items = new ItemManager(1u, cache, new ProvenanceLedger(), RunSaveTests.TestSettings(), _ => { }), Galaxy = new Galaxy(cache, cache.GetGlobal(), _ => { }), Maker = cache.GetByName("Maker") }; @@ -281,7 +281,7 @@ public sealed class ConsumableSupplyTests : IDisposable var authored = AuthoredSettings.Load(repo); var galaxy = new Galaxy(authored.Read("TutorialGenerationSettings"), authored.Read("TutorialBackgroundSettings"), cache, new PlayerSettings(), Directory.CreateDirectory(Path.Combine(_root, "Narrative")), _ => { }, null, 1); - var items = new ItemManager(cache, new ProvenanceLedger(), authored.Read("GameplaySettings"), _ => { }); + var items = new ItemManager(1u, cache, new ProvenanceLedger(), authored.Read("GameplaySettings"), _ => { }); int stations = 0, ships = 0; for (var i = 0; i < Seeds; i++) diff --git a/tests/Aetheria.Shared.Tests/ConsumableTests.cs b/tests/Aetheria.Shared.Tests/ConsumableTests.cs index 7ba593e6e..bc1c5588b 100644 --- a/tests/Aetheria.Shared.Tests/ConsumableTests.cs +++ b/tests/Aetheria.Shared.Tests/ConsumableTests.cs @@ -76,7 +76,7 @@ public sealed class ConsumableTests : IDisposable var otherRef = cache.Upsert(new ConsumableItemData { Name = "Flare", Duration = 1f, Shape = new Shape() }); cache.FlushAsync().Wait(); - var items = new ItemManager(cache, new ProvenanceLedger(), RunSaveTests.TestSettings(), _ => { }); + var items = new ItemManager(1u, cache, new ProvenanceLedger(), RunSaveTests.TestSettings(), _ => { }); var makerRef = cache.RefOf(cache.GetByName("Maker")); EquippableItem Gear(EquippableItemData design) => (EquippableItem) items.CreateInstance(items.CreateLot(design, makerRef, .5f)); var zone = new Zone(items, new PlanetSettings(), new ZonePack(), new GalaxyZone { Name = "Test Zone", Owner = null }, null); diff --git a/tests/Aetheria.Shared.Tests/ContactRevealTests.cs b/tests/Aetheria.Shared.Tests/ContactRevealTests.cs index bdca8008f..d23f166ea 100644 --- a/tests/Aetheria.Shared.Tests/ContactRevealTests.cs +++ b/tests/Aetheria.Shared.Tests/ContactRevealTests.cs @@ -173,7 +173,7 @@ public sealed partial class ContactRevealTests : IDisposable _openCache = AetheriaStores.Open(Catalog, catalogWritable: true); var ledger = new ProvenanceLedger(); for (var lot = 1000; lot < 2000; lot++) ledger.Lots[lot] = new Lot { Origin = new Attributed(), Quality = 1 }; - _items = new ItemManager(_openCache, ledger, new GameplaySettings + _items = new ItemManager(1u, _openCache, ledger, new GameplaySettings { DefaultEntitySettings = new EntitySettings(), Tiers = new[] { new RarityTier { Name = "Common", Quality = .5f, Color = new float3(1, 1, 1) } }, diff --git a/tests/Aetheria.Shared.Tests/ContactTests.cs b/tests/Aetheria.Shared.Tests/ContactTests.cs index adceb4b98..bee97650c 100644 --- a/tests/Aetheria.Shared.Tests/ContactTests.cs +++ b/tests/Aetheria.Shared.Tests/ContactTests.cs @@ -116,7 +116,7 @@ public sealed class ContactTests : IDisposable var ledger = new ProvenanceLedger(); for (var i = 1; i <= 50; i++) ledger.Lots[i] = new Lot { Origin = new Attributed(), Quality = 1 }; - var items = new ItemManager(cache, ledger, settings, _ => { }); + var items = new ItemManager(1u, cache, ledger, settings, _ => { }); var zone = new Zone(items, new PlanetSettings(), new ZonePack { Radius = 0 }, new GalaxyZone { Name = "Contacts", Owner = null }, null); var lab = new Lab { Items = items, Zone = zone }; zone.Contacted.Subscribe(lab.Events.Add); diff --git a/tests/Aetheria.Shared.Tests/DrawnPoseTests.cs b/tests/Aetheria.Shared.Tests/DrawnPoseTests.cs index ca0f6f00d..d288cce27 100644 --- a/tests/Aetheria.Shared.Tests/DrawnPoseTests.cs +++ b/tests/Aetheria.Shared.Tests/DrawnPoseTests.cs @@ -84,7 +84,7 @@ public sealed class DrawnPoseTests : IDisposable _caches.Add(cache); var ledger = new ProvenanceLedger(); ledger.Lots[1] = new Lot { Origin = new Attributed(), Quality = 1 }; - var items = new ItemManager(cache, ledger, new GameplaySettings + var items = new ItemManager(1u, cache, ledger, new GameplaySettings { DefaultEntitySettings = new EntitySettings(), Tiers = new[] { new RarityTier { Name = "Common", Quality = .5f, Color = new float3(1, 1, 1) } }, diff --git a/tests/Aetheria.Shared.Tests/FireAuthorityTests.cs b/tests/Aetheria.Shared.Tests/FireAuthorityTests.cs index 8323bd7a9..3a5108656 100644 --- a/tests/Aetheria.Shared.Tests/FireAuthorityTests.cs +++ b/tests/Aetheria.Shared.Tests/FireAuthorityTests.cs @@ -141,7 +141,7 @@ public sealed class FireAuthorityTests : IDisposable ledger.Lots[3] = new Lot { Origin = new Attributed(), Quality = 1 }; ledger.Lots[4] = new Lot { Origin = new Attributed(), Quality = 1 }; for (var i = 5; i <= 12; i++) ledger.Lots[i] = new Lot { Origin = new Attributed(), Quality = 1 }; - var items = new ItemManager(cache, ledger, settings, _ => { }); + var items = new ItemManager(1u, cache, ledger, settings, _ => { }); var zone = new Zone(items, new PlanetSettings(), new ZonePack(), new GalaxyZone { Name = "Test", Owner = null }, null); var hullRef = items.ItemData.RefOf(items.ItemData.GetByName("Hull")); diff --git a/tests/Aetheria.Shared.Tests/FireControlCut11Tests.cs b/tests/Aetheria.Shared.Tests/FireControlCut11Tests.cs index 9e9d74fce..3b4989662 100644 --- a/tests/Aetheria.Shared.Tests/FireControlCut11Tests.cs +++ b/tests/Aetheria.Shared.Tests/FireControlCut11Tests.cs @@ -134,7 +134,7 @@ public sealed class FireControlCut11Tests : IDisposable var ledger = new ProvenanceLedger(); for (var i = 1; i <= 16; i++) ledger.Lots[i] = new Lot { Origin = new Attributed(), Quality = 1 }; - var items = new ItemManager(cache, ledger, TestSettings(schematicCellSize), _ => { }); + var items = new ItemManager(1u, cache, ledger, TestSettings(schematicCellSize), _ => { }); var zone = new Zone(items, new PlanetSettings(), new ZonePack(), new GalaxyZone { Name = zoneName, Owner = null }, null); EquippableItem Make(string name, int lot, float durability) => new EquippableItem diff --git a/tests/Aetheria.Shared.Tests/FireControlCut123Tests.cs b/tests/Aetheria.Shared.Tests/FireControlCut123Tests.cs index 7e49ec3c7..7d949c01c 100644 --- a/tests/Aetheria.Shared.Tests/FireControlCut123Tests.cs +++ b/tests/Aetheria.Shared.Tests/FireControlCut123Tests.cs @@ -160,7 +160,7 @@ public sealed class FireControlCut123Tests : IDisposable var ledger = new ProvenanceLedger(); for (var i = 1; i <= 500; i++) ledger.Lots[i] = new Lot { Origin = new Attributed(), Quality = 1 }; // F8: shared-item fixtures equip more items (bars) than earlier cuts needed - var items = new ItemManager(cache, ledger, settings, _ => { }); + var items = new ItemManager(1u, cache, ledger, settings, _ => { }); var zone = new Zone(items, new PlanetSettings(), new ZonePack(), new GalaxyZone { Name = "Cut123", Owner = null }, null); EquippableItem Make(string name, int lot, float durability) => new EquippableItem diff --git a/tests/Aetheria.Shared.Tests/FireControlCut124Tests.cs b/tests/Aetheria.Shared.Tests/FireControlCut124Tests.cs index e6d8f5a0e..471c1d790 100644 --- a/tests/Aetheria.Shared.Tests/FireControlCut124Tests.cs +++ b/tests/Aetheria.Shared.Tests/FireControlCut124Tests.cs @@ -188,7 +188,7 @@ public sealed partial class FireControlCut124Tests : IDisposable var ledger = new ProvenanceLedger(); for (var i = 1; i <= 500; i++) ledger.Lots[i] = new Lot { Origin = new Attributed(), Quality = 1 }; - var items = new ItemManager(cache, ledger, settings, _ => { }); + var items = new ItemManager(1u, cache, ledger, settings, _ => { }); var zone = new Zone(items, new PlanetSettings(), new ZonePack(), new GalaxyZone { Name = "Cut124", Owner = null }, null); EquippableItem Make(string name, int lot, float durability) => new EquippableItem diff --git a/tests/Aetheria.Shared.Tests/FireControlCut12Tests.cs b/tests/Aetheria.Shared.Tests/FireControlCut12Tests.cs index adbcac5ef..e3c7bf64d 100644 --- a/tests/Aetheria.Shared.Tests/FireControlCut12Tests.cs +++ b/tests/Aetheria.Shared.Tests/FireControlCut12Tests.cs @@ -192,7 +192,7 @@ public sealed class FireControlCut12Tests : IDisposable var ledger = new ProvenanceLedger(); for (var i = 1; i <= 24; i++) ledger.Lots[i] = new Lot { Origin = new Attributed(), Quality = 1 }; - var items = new ItemManager(cache, ledger, settings, _ => { }); + var items = new ItemManager(1u, cache, ledger, settings, _ => { }); // Cut 6.1's own convention (FireControlCut6Tests.cs:62-63): every engagement shares one fixed // GalaxyZone name, so every Zone this fixture builds shares one CombatSeed and every statistical test // is replayable byte-for-byte across runs. A GUID-suffixed name here (this file's own earlier defect) diff --git a/tests/Aetheria.Shared.Tests/FireControlCut4Tests.cs b/tests/Aetheria.Shared.Tests/FireControlCut4Tests.cs index 08ba48d0d..70187383d 100644 --- a/tests/Aetheria.Shared.Tests/FireControlCut4Tests.cs +++ b/tests/Aetheria.Shared.Tests/FireControlCut4Tests.cs @@ -124,7 +124,7 @@ public sealed class FireControlCut4Tests : IDisposable ledger.Lots[4] = new Lot { Origin = new Attributed(), Quality = 1 }; ledger.Lots[5] = new Lot { Origin = new Attributed(), Quality = 1 }; ledger.Lots[9] = new Lot { Origin = new Attributed(), Quality = 1 }; - var items = new ItemManager(cache, ledger, settings, _ => { }); + var items = new ItemManager(1u, cache, ledger, settings, _ => { }); var zone = new Zone(items, new PlanetSettings(), new ZonePack(), new GalaxyZone { Name = "Test", Owner = null }, null); var hullRef = items.ItemData.RefOf(items.ItemData.GetByName("Hull")); @@ -218,7 +218,7 @@ public sealed class FireControlCut4Tests : IDisposable cache.FlushAsync().Wait(); var ledger = new ProvenanceLedger { Lots = { [1] = new Lot { Origin = new Attributed(), Quality = 1 } } }; - var items = new ItemManager(cache, ledger, TestSettings(), _ => { }); + var items = new ItemManager(1u, cache, ledger, TestSettings(), _ => { }); var zone = new Zone(items, new PlanetSettings(), new ZonePack(), new GalaxyZone { Name = "Test", Owner = null }, null); var hullRef = items.ItemData.RefOf(items.ItemData.GetByName("Hull")); @@ -258,7 +258,7 @@ public sealed class FireControlCut4Tests : IDisposable cache.FlushAsync().Wait(); _splashLedger = new ProvenanceLedger(); - var items = new ItemManager(cache, _splashLedger, settings, _ => { }); + var items = new ItemManager(1u, cache, _splashLedger, settings, _ => { }); var zone = new Zone(items, new PlanetSettings(), new ZonePack(), new GalaxyZone { Name = "Test", Owner = null }, null); return (items, zone); } diff --git a/tests/Aetheria.Shared.Tests/FireControlCut5Tests.cs b/tests/Aetheria.Shared.Tests/FireControlCut5Tests.cs index 1727a9829..7674df424 100644 --- a/tests/Aetheria.Shared.Tests/FireControlCut5Tests.cs +++ b/tests/Aetheria.Shared.Tests/FireControlCut5Tests.cs @@ -140,7 +140,7 @@ public sealed class FireControlCut5Tests : IDisposable ledger.Lots[3] = new Lot { Origin = new Attributed(), Quality = 1 }; ledger.Lots[4] = new Lot { Origin = new Attributed(), Quality = 1 }; for (var i = 5; i <= 12; i++) ledger.Lots[i] = new Lot { Origin = new Attributed(), Quality = 1 }; - var items = new ItemManager(cache, ledger, settings, _ => { }); + var items = new ItemManager(1u, cache, ledger, settings, _ => { }); var zone = new Zone(items, new PlanetSettings(), new ZonePack(), new GalaxyZone { Name = "Test", Owner = null }, null); var hullRef = items.ItemData.RefOf(items.ItemData.GetByName("Hull")); @@ -310,7 +310,7 @@ public sealed class FireControlCut5Tests : IDisposable ledger.Lots[3] = new Lot { Origin = new Attributed(), Quality = 1 }; ledger.Lots[4] = new Lot { Origin = new Attributed(), Quality = 1 }; ledger.Lots[5] = new Lot { Origin = new Attributed(), Quality = 1 }; - var items = new ItemManager(cache, ledger, settings, _ => { }); + var items = new ItemManager(1u, cache, ledger, settings, _ => { }); var zone = new Zone(items, new PlanetSettings(), new ZonePack(), new GalaxyZone { Name = "Test", Owner = null }, null); var hullRef = items.ItemData.RefOf(items.ItemData.GetByName("Hull")); @@ -406,7 +406,7 @@ public sealed class FireControlCut5Tests : IDisposable ledger.Lots[3] = new Lot { Origin = new Attributed(), Quality = 1 }; ledger.Lots[4] = new Lot { Origin = new Attributed(), Quality = 1 }; ledger.Lots[5] = new Lot { Origin = new Attributed(), Quality = 1 }; - var items = new ItemManager(cache, ledger, settings, _ => { }); + var items = new ItemManager(1u, cache, ledger, settings, _ => { }); var zone = new Zone(items, new PlanetSettings(), new ZonePack(), new GalaxyZone { Name = "Test", Owner = null }, null); var hullRef = items.ItemData.RefOf(items.ItemData.GetByName("Hull")); @@ -549,7 +549,7 @@ public sealed class FireControlCut5Tests : IDisposable ledger.Lots[1] = new Lot { Origin = new Attributed(), Quality = 1 }; ledger.Lots[2] = new Lot { Origin = new Attributed(), Quality = 1 }; ledger.Lots[3] = new Lot { Origin = new Attributed(), Quality = 1 }; - var items = new ItemManager(cache, ledger, settings, _ => { }); + var items = new ItemManager(1u, cache, ledger, settings, _ => { }); var zone = new Zone(items, new PlanetSettings(), new ZonePack(), new GalaxyZone { Name = "Test", Owner = null }, null); var hullRef = items.ItemData.RefOf(items.ItemData.GetByName("Hull")); diff --git a/tests/Aetheria.Shared.Tests/FireControlCut6Tests.cs b/tests/Aetheria.Shared.Tests/FireControlCut6Tests.cs index 03aab2e3f..5f334bb45 100644 --- a/tests/Aetheria.Shared.Tests/FireControlCut6Tests.cs +++ b/tests/Aetheria.Shared.Tests/FireControlCut6Tests.cs @@ -126,7 +126,7 @@ public sealed class FireControlCut6Tests : IDisposable ledger.Lots[3] = new Lot { Origin = new Attributed(), Quality = 1 }; ledger.Lots[4] = new Lot { Origin = new Attributed(), Quality = 1 }; ledger.Lots[5] = new Lot { Origin = new Attributed(), Quality = 1 }; - var items = new ItemManager(cache, ledger, settings, _ => { }); + var items = new ItemManager(1u, cache, ledger, settings, _ => { }); var zone = new Zone(items, new PlanetSettings(), new ZonePack(), new GalaxyZone { Name = "Test", Owner = null }, null); var hullRef = items.ItemData.RefOf(items.ItemData.GetByName("Hull")); diff --git a/tests/Aetheria.Shared.Tests/FireControlCut6cTests.cs b/tests/Aetheria.Shared.Tests/FireControlCut6cTests.cs index 5517c4121..991af3b44 100644 --- a/tests/Aetheria.Shared.Tests/FireControlCut6cTests.cs +++ b/tests/Aetheria.Shared.Tests/FireControlCut6cTests.cs @@ -112,7 +112,7 @@ public sealed class FireControlCut6cTests : IDisposable ledger.Lots[3] = new Lot { Origin = new Attributed(), Quality = 1 }; ledger.Lots[4] = new Lot { Origin = new Attributed(), Quality = 1 }; for (var i = 5; i <= 12; i++) ledger.Lots[i] = new Lot { Origin = new Attributed(), Quality = 1 }; - var items = new ItemManager(cache, ledger, settings, _ => { }); + var items = new ItemManager(1u, cache, ledger, settings, _ => { }); var zone = new Zone(items, new PlanetSettings(), new ZonePack(), new GalaxyZone { Name = "Test", Owner = null }, null); var hullRef = items.ItemData.RefOf(items.ItemData.GetByName("Hull")); @@ -254,7 +254,7 @@ public sealed class FireControlCut6cTests : IDisposable var random = new Random(1u); var settings = TestSettings(); var log = new List(); - var items = new ItemManager(cache, new ProvenanceLedger(), settings, log.Add); + var items = new ItemManager(1u, cache, new ProvenanceLedger(), settings, log.Add); var generator = new LoadoutGenerator(ref random, items, galaxy, null, makerFaction, .5f); var pack = generator.GenerateShipLoadout(candidate => candidate.Name == "Armed"); diff --git a/tests/Aetheria.Shared.Tests/FireControlCut6dTests.cs b/tests/Aetheria.Shared.Tests/FireControlCut6dTests.cs index 9fdbde4a0..128025cea 100644 --- a/tests/Aetheria.Shared.Tests/FireControlCut6dTests.cs +++ b/tests/Aetheria.Shared.Tests/FireControlCut6dTests.cs @@ -119,7 +119,7 @@ public sealed class FireControlCut6dTests : IDisposable var ledger = new ProvenanceLedger(); for (var i = 1; i <= 20; i++) ledger.Lots[i] = new Lot { Origin = new Attributed(), Quality = 1 }; - var items = new ItemManager(cache, ledger, settings, _ => { }); + var items = new ItemManager(1u, cache, ledger, settings, _ => { }); var zone = new Zone(items, new PlanetSettings(), new ZonePack(), new GalaxyZone { Name = "Test", Owner = null }, null); // Durability high enough to survive thousands of direct 10-damage hits over a test's whole sample -- diff --git a/tests/Aetheria.Shared.Tests/FireControlCut7Tests.cs b/tests/Aetheria.Shared.Tests/FireControlCut7Tests.cs index 4e0c4a91f..0b6eed2d5 100644 --- a/tests/Aetheria.Shared.Tests/FireControlCut7Tests.cs +++ b/tests/Aetheria.Shared.Tests/FireControlCut7Tests.cs @@ -104,7 +104,7 @@ public sealed class FireControlCut7Tests : IDisposable var ledger = new ProvenanceLedger(); for (var i = 1; i <= 10; i++) ledger.Lots[i] = new Lot { Origin = new Attributed(), Quality = 1 }; - var items = new ItemManager(cache, ledger, settings, _ => { }); + var items = new ItemManager(1u, cache, ledger, settings, _ => { }); var zone = new Zone(items, new PlanetSettings(), new ZonePack(), new GalaxyZone { Name = "Test", Owner = null }, null); var hullRef = items.ItemData.RefOf(items.ItemData.GetByName("Hull")); @@ -214,7 +214,7 @@ public sealed class FireControlCut7Tests : IDisposable QualityPriceModifier = new ExponentialLerp() }; var log = new List(); - var items = new ItemManager(cache, new ProvenanceLedger(), settings, log.Add); + var items = new ItemManager(1u, cache, new ProvenanceLedger(), settings, log.Add); var random = new Random(1u); var generator = new LoadoutGenerator(ref random, items, null, null, null, .5f); var pack = generator.GenerateShipLoadout(candidate => candidate == hull); diff --git a/tests/Aetheria.Shared.Tests/FireControlCut8Tests.cs b/tests/Aetheria.Shared.Tests/FireControlCut8Tests.cs index a0d767ab3..df011c730 100644 --- a/tests/Aetheria.Shared.Tests/FireControlCut8Tests.cs +++ b/tests/Aetheria.Shared.Tests/FireControlCut8Tests.cs @@ -112,7 +112,7 @@ public sealed class FireControlCut8Tests : IDisposable ledger.Lots[3] = new Lot { Origin = new Attributed(), Quality = 1 }; ledger.Lots[4] = new Lot { Origin = new Attributed(), Quality = 1 }; ledger.Lots[5] = new Lot { Origin = new Attributed(), Quality = 1 }; - var items = new ItemManager(cache, ledger, settings, _ => { }); + var items = new ItemManager(1u, cache, ledger, settings, _ => { }); var zone = new Zone(items, new PlanetSettings(), new ZonePack(), new GalaxyZone { Name = "Test", Owner = null }, null); var hullRef = items.ItemData.RefOf(items.ItemData.GetByName("Hull")); @@ -195,7 +195,7 @@ public sealed class FireControlCut8Tests : IDisposable ledger.Lots[2] = new Lot { Origin = new Attributed(), Quality = 1 }; ledger.Lots[3] = new Lot { Origin = new Attributed(), Quality = 1 }; ledger.Lots[4] = new Lot { Origin = new Attributed(), Quality = 1 }; - var items = new ItemManager(cache, ledger, settings, _ => { }); + var items = new ItemManager(1u, cache, ledger, settings, _ => { }); var zone = new Zone(items, new PlanetSettings(), new ZonePack(), new GalaxyZone { Name = "Test", Owner = null }, null); var hullRef = items.ItemData.RefOf(items.ItemData.GetByName("Hull")); diff --git a/tests/Aetheria.Shared.Tests/FireControlCut9Tests.cs b/tests/Aetheria.Shared.Tests/FireControlCut9Tests.cs index 448cbb333..d881f33d6 100644 --- a/tests/Aetheria.Shared.Tests/FireControlCut9Tests.cs +++ b/tests/Aetheria.Shared.Tests/FireControlCut9Tests.cs @@ -107,7 +107,7 @@ public sealed class FireControlCut9Tests : IDisposable ledger.Lots[2] = new Lot { Origin = new Attributed(), Quality = 1 }; ledger.Lots[3] = new Lot { Origin = new Attributed(), Quality = 1 }; ledger.Lots[4] = new Lot { Origin = new Attributed(), Quality = 1 }; - var items = new ItemManager(cache, ledger, settings, _ => { }); + var items = new ItemManager(1u, cache, ledger, settings, _ => { }); var zone = new Zone(items, new PlanetSettings(), new ZonePack(), new GalaxyZone { Name = zoneName, Owner = null }, null); var hullRef = items.ItemData.RefOf(items.ItemData.GetByName("Hull")); @@ -312,7 +312,7 @@ public sealed class FireControlCut9Tests : IDisposable var ledger = new ProvenanceLedger(); for (var i = 1; i <= 5; i++) ledger.Lots[i] = new Lot { Origin = new Attributed(), Quality = 1 }; - var items = new ItemManager(cache, ledger, settings, _ => { }); + var items = new ItemManager(1u, cache, ledger, settings, _ => { }); var zone = new Zone(items, new PlanetSettings(), new ZonePack(), new GalaxyZone { Name = "Cut9-Kernel", Owner = null }, null); var hullRef = items.ItemData.RefOf(items.ItemData.GetByName("Hull")); @@ -543,7 +543,7 @@ public sealed class FireControlCut9Tests : IDisposable ledger.Lots[2] = new Lot { Origin = new Attributed(), Quality = 1 }; ledger.Lots[3] = new Lot { Origin = new Attributed(), Quality = 1 }; ledger.Lots[4] = new Lot { Origin = new Attributed(), Quality = 1 }; - var items = new ItemManager(cache, ledger, settings, _ => { }); + var items = new ItemManager(1u, cache, ledger, settings, _ => { }); var zone = new Zone(items, new PlanetSettings(), new ZonePack(), new GalaxyZone { Name = "Cut9-Lock", Owner = null }, null); var hullRef = items.ItemData.RefOf(items.ItemData.GetByName("Hull")); diff --git a/tests/Aetheria.Shared.Tests/FireControlPerWeaponTests.cs b/tests/Aetheria.Shared.Tests/FireControlPerWeaponTests.cs index bbd57c7ac..cf47b2041 100644 --- a/tests/Aetheria.Shared.Tests/FireControlPerWeaponTests.cs +++ b/tests/Aetheria.Shared.Tests/FireControlPerWeaponTests.cs @@ -193,7 +193,7 @@ public sealed class FireControlPerWeaponTests : IDisposable var ledger = new ProvenanceLedger(); for (var i = 1; i <= 100; i++) ledger.Lots[i] = new Lot { Origin = new Attributed(), Quality = 1 }; - var items = new ItemManager(cache, ledger, TestSettings(minHit, targetLayerHeight), _ => { }); + var items = new ItemManager(1u, cache, ledger, TestSettings(minHit, targetLayerHeight), _ => { }); var zone = new Zone(items, new PlanetSettings(), new ZonePack(), new GalaxyZone { Name = "PerWeapon", Owner = null }, null); EquippableItem Make(string name, int lot, float durability) => new EquippableItem diff --git a/tests/Aetheria.Shared.Tests/FireControlTests.cs b/tests/Aetheria.Shared.Tests/FireControlTests.cs index b10a2fa18..54378691b 100644 --- a/tests/Aetheria.Shared.Tests/FireControlTests.cs +++ b/tests/Aetheria.Shared.Tests/FireControlTests.cs @@ -88,7 +88,7 @@ public sealed class FireControlTests : IDisposable ledger.Lots[1] = new Lot { Origin = new Attributed(), Quality = 1 }; // hull for (var i = 0; i < hardpoints.Length; i++) ledger.Lots[2 + i] = new Lot { Origin = new Attributed(), Quality = 1 }; // guns - var items = new ItemManager(_openCache, ledger, TestSettings(shipLayerHeight), _ => { }); + var items = new ItemManager(1u, _openCache, ledger, TestSettings(shipLayerHeight), _ => { }); var planets = new PlanetSettings(); var pack = new ZonePack(); if (well) WellFixture.Dig(_openCache, pack, planets, 300f, 100f); diff --git a/tests/Aetheria.Shared.Tests/FloatingBodyTests.cs b/tests/Aetheria.Shared.Tests/FloatingBodyTests.cs index ac4f763c1..8769c4c85 100644 --- a/tests/Aetheria.Shared.Tests/FloatingBodyTests.cs +++ b/tests/Aetheria.Shared.Tests/FloatingBodyTests.cs @@ -40,7 +40,7 @@ public sealed class FloatingBodyTests : IDisposable Tiers = new[] { new RarityTier { Name = "Common", Quality = .5f, Color = new float3(1, 1, 1) } }, QualityPriceModifier = new ExponentialLerp() }; - var items = new ItemManager(cache, new ProvenanceLedger(), settings, _ => { }); + var items = new ItemManager(1u, cache, new ProvenanceLedger(), settings, _ => { }); return new Zone(items, new PlanetSettings(), new ZonePack(), new GalaxyZone { Name = "Test Zone", Owner = null }, null); } @@ -206,7 +206,7 @@ public sealed class FloatingBodyTests : IDisposable Tiers = new[] { new RarityTier { Name = "Common", Quality = .5f, Color = new float3(1, 1, 1) } }, QualityPriceModifier = new ExponentialLerp() }; - var items = new ItemManager(cache, new ProvenanceLedger(), settings, _ => { }); + var items = new ItemManager(1u, cache, new ProvenanceLedger(), settings, _ => { }); var planets = new PlanetSettings { HeightExaggeration = 10f }; // the tilt reads force / K, which K = 1 would hide var pack = new ZonePack { Radius = 1000 }; WellFixture.Dig(cache, pack, planets, 300, 100); diff --git a/tests/Aetheria.Shared.Tests/FlowFieldTests.cs b/tests/Aetheria.Shared.Tests/FlowFieldTests.cs index fefff55f4..380ffe62c 100644 --- a/tests/Aetheria.Shared.Tests/FlowFieldTests.cs +++ b/tests/Aetheria.Shared.Tests/FlowFieldTests.cs @@ -30,7 +30,7 @@ public sealed class FlowFieldTests : IDisposable TargetDetectionInfoThreshold = .1f, TargetGearInfoThreshold = .8f, FiringArc = 170, CommitHorizon = .5f, SchematicCellSize = 2f }; - _items = new ItemManager(_cache, new ProvenanceLedger(), settings, _ => { }); + _items = new ItemManager(1u, _cache, new ProvenanceLedger(), settings, _ => { }); } public void Dispose() diff --git a/tests/Aetheria.Shared.Tests/GravitySubtractTests.cs b/tests/Aetheria.Shared.Tests/GravitySubtractTests.cs index f21d78f79..b204fb50b 100644 --- a/tests/Aetheria.Shared.Tests/GravitySubtractTests.cs +++ b/tests/Aetheria.Shared.Tests/GravitySubtractTests.cs @@ -29,7 +29,7 @@ public sealed class GravitySubtractTests : IDisposable TargetDetectionInfoThreshold = .1f, TargetGearInfoThreshold = .8f, FiringArc = 170, CommitHorizon = .5f, SchematicCellSize = 2f }; - _items = new ItemManager(_cache, new ProvenanceLedger(), settings, _ => { }); + _items = new ItemManager(1u, _cache, new ProvenanceLedger(), settings, _ => { }); } public void Dispose() diff --git a/tests/Aetheria.Shared.Tests/HeatResponseTests.cs b/tests/Aetheria.Shared.Tests/HeatResponseTests.cs index 2873fc743..b45d86fc7 100644 --- a/tests/Aetheria.Shared.Tests/HeatResponseTests.cs +++ b/tests/Aetheria.Shared.Tests/HeatResponseTests.cs @@ -233,7 +233,7 @@ public sealed class HeatResponseTests : IDisposable public void HeldInsidePlateauTakesNoThermalWear() { using var cache = OpenCatalogWithThermalGear(); - var items = new ItemManager(cache, new ProvenanceLedger(), Settings(), _ => { }); + var items = new ItemManager(1u, cache, new ProvenanceLedger(), Settings(), _ => { }); var equipped = BuildEquippedAt(cache, items, 50f, out var ship); Assert.Equal(1f, equipped.ThermalPerformance, 5); Assert.Equal(0f, equipped.Wear, 5); // no thermal term (Performance == 1) and no deltaTemp (settled) @@ -243,7 +243,7 @@ public sealed class HeatResponseTests : IDisposable public void HeldOutsidePlateauTakesThermalWear() { using var cache = OpenCatalogWithThermalGear(); - var items = new ItemManager(cache, new ProvenanceLedger(), Settings(), _ => { }); + var items = new ItemManager(1u, cache, new ProvenanceLedger(), Settings(), _ => { }); var equipped = BuildEquippedAt(cache, items, 10f, out var ship); // outside 40-60, inside 0-100 Assert.True(equipped.ThermalPerformance < 1f); Assert.True(equipped.Wear > 0f); @@ -255,7 +255,7 @@ public sealed class HeatResponseTests : IDisposable public void HeldOutsidePlateauWearReadsEffectiveQuality() { using var cache = OpenCatalogWithThermalGear(); - var items = new ItemManager(cache, new ProvenanceLedger(), Settings(), _ => { }); + var items = new ItemManager(1u, cache, new ProvenanceLedger(), Settings(), _ => { }); var thermal = cache.RefOf(cache.GetByName("Thermal")); Lot Gear(float assembly, params float[] parts) => new Lot { @@ -274,7 +274,7 @@ public sealed class HeatResponseTests : IDisposable public void AFastSwingAcrossThePlateauStillWears() { using var cache = OpenCatalogWithThermalGear(); - var items = new ItemManager(cache, new ProvenanceLedger(), Settings(), _ => { }); + var items = new ItemManager(1u, cache, new ProvenanceLedger(), Settings(), _ => { }); var equipped = BuildEquippedAt(cache, items, 10f, out var ship); // Jump straight into the plateau in one tick: ThermalPerformance ends at 1 (no thermal term), but the // swing itself (deltaTemp) still costs. diff --git a/tests/Aetheria.Shared.Tests/IffAndCombatTests.cs b/tests/Aetheria.Shared.Tests/IffAndCombatTests.cs index b2bd752ba..c9e184fa8 100644 --- a/tests/Aetheria.Shared.Tests/IffAndCombatTests.cs +++ b/tests/Aetheria.Shared.Tests/IffAndCombatTests.cs @@ -55,7 +55,7 @@ public sealed class IffAndCombatTests : IDisposable var ledger = new ProvenanceLedger(); ledger.Lots[1] = new Lot { Origin = new Attributed(), Quality = 1 }; // hull ledger.Lots[2] = new Lot { Origin = new Attributed(), Quality = 1 }; // gun - var items = new ItemManager(_openCache, ledger, TestSettings(), _ => { }); + var items = new ItemManager(1u, _openCache, ledger, TestSettings(), _ => { }); var zone = new Zone(items, new PlanetSettings(), new ZonePack(), new GalaxyZone { Name = "Test Zone", Owner = null }, null); return (items, zone); } diff --git a/tests/Aetheria.Shared.Tests/InputCapacitorTests.cs b/tests/Aetheria.Shared.Tests/InputCapacitorTests.cs index d7279380e..d5a508af9 100644 --- a/tests/Aetheria.Shared.Tests/InputCapacitorTests.cs +++ b/tests/Aetheria.Shared.Tests/InputCapacitorTests.cs @@ -87,7 +87,7 @@ public sealed class InputCapacitorTests : IDisposable private (Ship ship, ItemManager items, Reactor reactor) BuildShip(CultCache cache, float reactorCharge) { var ledger = new ProvenanceLedger(); - var items = new ItemManager(cache, ledger, Settings(), _ => { }); + var items = new ItemManager(1u, cache, ledger, Settings(), _ => { }); var zone = new Zone(items, new PlanetSettings(), new ZonePack(), new GalaxyZone { Name = "Test Zone", Owner = null }, null); var hull = Mint(cache, items, cache.GetByName("Skiff")); var ship = new Ship(items, zone, hull, new EntitySettings()); diff --git a/tests/Aetheria.Shared.Tests/InspectionScannerCatalogTests.cs b/tests/Aetheria.Shared.Tests/InspectionScannerCatalogTests.cs index e9cb862fa..7d6a8ae6e 100644 --- a/tests/Aetheria.Shared.Tests/InspectionScannerCatalogTests.cs +++ b/tests/Aetheria.Shared.Tests/InspectionScannerCatalogTests.cs @@ -105,7 +105,7 @@ public sealed class InspectionScannerCatalogTests Origin = new Attributed(), Quality = 1f, Roles = product.Roles.Select(r => new RoleFill { Role = r.Role, Quality = r.Mean }).ToList(), }; - var items = new ItemManager(Cache, ledger, settings, _ => { }); + var items = new ItemManager(1u, Cache, ledger, settings, _ => { }); var zone = new Zone(items, new PlanetSettings(), new ZonePack(), new GalaxyZone { Name = "Test", Owner = null }, null); var ship = new Ship(items, zone, new EquippableItem { Data = Cache.RefOf(hull), Durability = hull.Durability, Lot = 1 }, settings.DefaultEntitySettings); Assert.True(ship.TryEquip(new EquippableItem { Data = Cache.RefOf(design), Durability = design.Durability, Lot = 2 })); diff --git a/tests/Aetheria.Shared.Tests/ItemTitleTests.cs b/tests/Aetheria.Shared.Tests/ItemTitleTests.cs index c96b0f6d9..b2b083b41 100644 --- a/tests/Aetheria.Shared.Tests/ItemTitleTests.cs +++ b/tests/Aetheria.Shared.Tests/ItemTitleTests.cs @@ -42,7 +42,7 @@ public sealed class ItemTitleTests : IDisposable _cache.Commit(batch => batch.Upsert(typeof(FactionProductData), _clapBack, new CultRecordKey("array-lucent"))); _spiceBlend = new FactionProductData { Name = "Finch Reserve", Design = _cache.RefOf(_spice), Manufacturer = finch }; _cache.Commit(batch => batch.Upsert(typeof(FactionProductData), _spiceBlend, new CultRecordKey("spice-finch"))); - _items = new ItemManager(_cache, new ProvenanceLedger(), RunSaveTests.TestSettings(), _ => { }); + _items = new ItemManager(1u, _cache, new ProvenanceLedger(), RunSaveTests.TestSettings(), _ => { }); } public void Dispose() diff --git a/tests/Aetheria.Shared.Tests/LaunchSuppressorCatalogTests.cs b/tests/Aetheria.Shared.Tests/LaunchSuppressorCatalogTests.cs index d54f7c983..7a1481670 100644 --- a/tests/Aetheria.Shared.Tests/LaunchSuppressorCatalogTests.cs +++ b/tests/Aetheria.Shared.Tests/LaunchSuppressorCatalogTests.cs @@ -116,7 +116,7 @@ public sealed class LaunchSuppressorCatalogTests Origin = new Attributed(), Quality = 1f, Roles = new List { new RoleFill { Role = "baffles", Quality = baffles }, new RoleFill { Role = "heat sink", Quality = sink } }, }; - var items = new ItemManager(Cache, ledger, settings, _ => { }); + var items = new ItemManager(1u, Cache, ledger, settings, _ => { }); var zone = new Zone(items, new PlanetSettings(), new ZonePack(), new GalaxyZone { Name = "Test", Owner = null }, null); Ship = new Ship(items, zone, new EquippableItem { Data = Cache.RefOf(hull), Durability = hull.Durability, Lot = 1 }, settings.DefaultEntitySettings); diff --git a/tests/Aetheria.Shared.Tests/LayerTests.cs b/tests/Aetheria.Shared.Tests/LayerTests.cs index 75d79d947..acd38555a 100644 --- a/tests/Aetheria.Shared.Tests/LayerTests.cs +++ b/tests/Aetheria.Shared.Tests/LayerTests.cs @@ -58,7 +58,7 @@ public sealed class LayerTests : IDisposable _cache.FlushAsync().Wait(); var ledger = new ProvenanceLedger(); for (var i = 1; i <= 3; i++) ledger.Lots[i] = new Lot { Origin = new Attributed(), Quality = 1 }; - var items = new ItemManager(_cache, ledger, Settings(), _ => { }); + var items = new ItemManager(1u, _cache, ledger, Settings(), _ => { }); var zone = new Zone(items, new PlanetSettings(), new ZonePack { Radius = 0 }, new GalaxyZone { Name = "Layers", Owner = null }, null); return (items, zone); } diff --git a/tests/Aetheria.Shared.Tests/LoadoutTests.cs b/tests/Aetheria.Shared.Tests/LoadoutTests.cs index 86bf4e698..7d3316302 100644 --- a/tests/Aetheria.Shared.Tests/LoadoutTests.cs +++ b/tests/Aetheria.Shared.Tests/LoadoutTests.cs @@ -90,7 +90,7 @@ public sealed class LoadoutTests : IDisposable Loadout hand; using (var cache = Open()) { - var items = new ItemManager(cache, new ProvenanceLedger(), RunSaveTests.TestSettings(), _ => { }); + var items = new ItemManager(1u, cache, new ProvenanceLedger(), RunSaveTests.TestSettings(), _ => { }); hand = Armed(cache); var failures = new List(); var ship = Build(items, hand, failures); @@ -108,7 +108,7 @@ public sealed class LoadoutTests : IDisposable Assert.Equal(hand.Slots.Select(Describe), loaded.Slots.Select(Describe)); Assert.Equal(hand.WeaponGroups, loaded.WeaponGroups); - var items = new ItemManager(cache, new ProvenanceLedger(), RunSaveTests.TestSettings(), _ => { }); + var items = new ItemManager(1u, cache, new ProvenanceLedger(), RunSaveTests.TestSettings(), _ => { }); var failures = new List(); var ship = Build(items, loaded, failures); Assert.Empty(failures); @@ -279,7 +279,7 @@ public sealed class LoadoutTests : IDisposable using (var cache = Open()) { - var items = new ItemManager(cache, new ProvenanceLedger(), RunSaveTests.TestSettings(), _ => { }); + var items = new ItemManager(1u, cache, new ProvenanceLedger(), RunSaveTests.TestSettings(), _ => { }); var missing = HandBuilt(cache); missing.Slots[1].Design = new CultRecordRef(new CultRecordKey("absent-design")); var failures = new List(); @@ -304,7 +304,7 @@ public sealed class LoadoutTests : IDisposable public void FailedFitBuildsNothing() { using var cache = Open(); - var items = new ItemManager(cache, new ProvenanceLedger(), RunSaveTests.TestSettings(), _ => { }); + var items = new ItemManager(1u, cache, new ProvenanceLedger(), RunSaveTests.TestSettings(), _ => { }); var clash = HandBuilt(cache); clash.Slots[1].Position = HardpointCell; var failures = new List(); @@ -319,7 +319,7 @@ public sealed class LoadoutTests : IDisposable public void ADesignWithNoProductBuildsUnbranded() { using var cache = Open(); - var items = new ItemManager(cache, new ProvenanceLedger(), RunSaveTests.TestSettings(), _ => { }); + var items = new ItemManager(1u, cache, new ProvenanceLedger(), RunSaveTests.TestSettings(), _ => { }); var failures = new List(); var ship = Build(items, WithDesign(HandBuilt(cache), 0, cache, "Orphan"), failures); Assert.Empty(failures); @@ -335,7 +335,7 @@ public sealed class LoadoutTests : IDisposable public void ADesignWhoseOnlyProductIsUnavailableStillFails() { using var cache = Open(); - var items = new ItemManager(cache, new ProvenanceLedger(), RunSaveTests.TestSettings(), _ => { }); + var items = new ItemManager(1u, cache, new ProvenanceLedger(), RunSaveTests.TestSettings(), _ => { }); var failures = new List(); var soleProduct = SoleProduct(cache); Assert.Null(Loadouts.Materialize(items, null, HandBuilt(cache), design => design.Name == "Lamp" ? null : soleProduct(design), failures)); @@ -365,7 +365,7 @@ public sealed class LoadoutTests : IDisposable var gun = cache.GetAll().Single(p => p.Name == "Gun by Maker"); var bay = cache.GetAll().Single(p => p.Name == "Bay by Maker"); - var items = new ItemManager(cache, new ProvenanceLedger(), RunSaveTests.TestSettings(), _ => { }); + var items = new ItemManager(1u, cache, new ProvenanceLedger(), RunSaveTests.TestSettings(), _ => { }); EquippableItem Mint(FactionProductData product) => (EquippableItem) items.CreateInstance(product); @@ -477,7 +477,7 @@ public sealed class LoadoutTests : IDisposable cache.FlushAsync().Wait(); var random = new Random(1); - var items = new ItemManager(cache, new ProvenanceLedger(), RunSaveTests.TestSettings(), _ => { }); + var items = new ItemManager(1u, cache, new ProvenanceLedger(), RunSaveTests.TestSettings(), _ => { }); var generator = new LoadoutGenerator(ref random, items, null, null, null, .5f); var pack = generator.GenerateShipLoadout(candidate => candidate.Name == "Twin"); Assert.NotNull(pack); @@ -504,7 +504,7 @@ public sealed class LoadoutTests : IDisposable new RarityTier { Name = "Common", Quality = .2f, Color = new float3(1, 1, 1) }, new RarityTier { Name = "Rare", Quality = .5f, Color = new float3(1, 1, 1) } }; - var items = new ItemManager(cache, new ProvenanceLedger(), settings, _ => { }); + var items = new ItemManager(1u, cache, new ProvenanceLedger(), settings, _ => { }); var lamp = cache.GetByName("Lamp"); var commonLot = items.Lots.Add(new Lot { @@ -561,7 +561,7 @@ public sealed class LoadoutTests : IDisposable using var cache = Open(); var settings = AuthoredSettings.Load(RunStartTests.FindRepoRoot()).Read("GameplaySettings"); var tiers = settings.Tiers; - var items = new ItemManager(cache, new ProvenanceLedger(), settings, _ => { }); + var items = new ItemManager(1u, cache, new ProvenanceLedger(), settings, _ => { }); var lamp = cache.GetByName("Lamp"); var assembly = tiers[3].Quality; var sleeperLot = items.Lots.Add(new Lot @@ -598,7 +598,7 @@ public sealed class LoadoutTests : IDisposable var settings = AuthoredSettings.Load(RunStartTests.FindRepoRoot()).Read("GameplaySettings"); var tiers = settings.Tiers; Assert.True(tiers.Length > index, "premise: the shipped asset carries this tier"); - var items = new ItemManager(cache, new ProvenanceLedger(), settings, _ => { }); + var items = new ItemManager(1u, cache, new ProvenanceLedger(), settings, _ => { }); var lamp = cache.GetByName("Lamp"); string TierAt(float quality) => items.GetTier((CraftedItemInstance) items.CreateInstance(items.Lots.Add( new Lot { Design = cache.RefOf(lamp), Origin = new Attributed(), Quality = quality, Roles = new List() }))).tier.Name; @@ -614,7 +614,7 @@ public sealed class LoadoutTests : IDisposable { using var cache = Open(); var settings = AuthoredSettings.Load(RunStartTests.FindRepoRoot()).Read("GameplaySettings"); - var items = new ItemManager(cache, new ProvenanceLedger(), settings, _ => { }); + var items = new ItemManager(1u, cache, new ProvenanceLedger(), settings, _ => { }); var lamp = cache.GetByName("Lamp"); // Two steps and a hair above the lowest threshold: the step width decides whether that counts as two or one. var quality = settings.Tiers[0].Quality + 2 * .0499f + .0001f; @@ -640,7 +640,7 @@ public sealed class LoadoutTests : IDisposable public void QualityTermAppliesItsOwnExponent() { using var cache = Open(); - var items = new ItemManager(cache, new ProvenanceLedger(), RunSaveTests.TestSettings(), _ => { }); + var items = new ItemManager(1u, cache, new ProvenanceLedger(), RunSaveTests.TestSettings(), _ => { }); var lamp = cache.GetByName("Lamp"); var lotId = items.Lots.Add(new Lot { Design = cache.RefOf(lamp), Origin = new Attributed(), Quality = .5f, Roles = new List() }); var instance = (EquippableItem) items.CreateInstance(lotId); @@ -658,7 +658,7 @@ public sealed class LoadoutTests : IDisposable using var cache = Open(); var settings = RunSaveTests.TestSettings(); settings.QualityPriceModifier = new ExponentialLerp { Minimum = 0, Maximum = 1, Exponent = 1 }; - var items = new ItemManager(cache, new ProvenanceLedger(), settings, _ => { }); + var items = new ItemManager(1u, cache, new ProvenanceLedger(), settings, _ => { }); var lamp = cache.GetByName("Lamp"); var lowLot = items.Lots.Add(new Lot { Design = cache.RefOf(lamp), Origin = new Attributed(), Quality = .2f, Roles = new List() }); var highLot = items.Lots.Add(new Lot { Design = cache.RefOf(lamp), Origin = new Attributed(), Quality = .8f, Roles = new List() }); @@ -695,7 +695,7 @@ public sealed class LoadoutTests : IDisposable var durable = cache.Upsert(new GearData { Name = "Durable", Hardpoint = HardpointType.Sensors, Shape = new Shape(), Price = 10, Durability = 100 }); cache.FlushAsync().Wait(); - var items = new ItemManager(cache, new ProvenanceLedger(), RunSaveTests.TestSettings(), _ => { }); + var items = new ItemManager(1u, cache, new ProvenanceLedger(), RunSaveTests.TestSettings(), _ => { }); var lowLot = items.Lots.Add(new Lot { Design = new CultRecordRef(durable.Key), Origin = new Attributed(), Quality = .1f, Roles = new List() }); var highLot = items.Lots.Add(new Lot { Design = new CultRecordRef(durable.Key), Origin = new Attributed(), Quality = .9f, Roles = new List() }); var sleeperLot = items.Lots.Add(new Lot @@ -723,7 +723,7 @@ public sealed class LoadoutTests : IDisposable public void EquippedItemExponentsReadEffectiveQuality() { using var cache = Open(); - var items = new ItemManager(cache, new ProvenanceLedger(), RunSaveTests.TestSettings(), _ => { }); + var items = new ItemManager(1u, cache, new ProvenanceLedger(), RunSaveTests.TestSettings(), _ => { }); var maker = cache.RefOf(cache.GetByName("Maker")); var hullData = cache.GetByName("Skiff"); var lampData = cache.GetByName("Lamp"); @@ -785,7 +785,7 @@ public sealed class LoadoutTests : IDisposable cache.FlushAsync().Wait(); var thermalData = cache.Get(thermalGear); - var items = new ItemManager(cache, new ProvenanceLedger(), RunSaveTests.TestSettings(), _ => { }); + var items = new ItemManager(1u, cache, new ProvenanceLedger(), RunSaveTests.TestSettings(), _ => { }); var hullItem = (EquippableItem) items.CreateInstance(items.CreateLot(hullData, maker, .5f)); var ship = new Ship(items, null, hullItem, new EntitySettings()); var gearItem = (EquippableItem) items.CreateInstance(items.CreateLot(thermalData, maker, .5f)); @@ -831,7 +831,7 @@ public sealed class LoadoutTests : IDisposable cache.FlushAsync().Wait(); var thermalData = cache.Get(thermalGear); - var items = new ItemManager(cache, new ProvenanceLedger(), RunSaveTests.TestSettings(), _ => { }); + var items = new ItemManager(1u, cache, new ProvenanceLedger(), RunSaveTests.TestSettings(), _ => { }); var hullItem = (EquippableItem) items.CreateInstance(items.CreateLot(hullData, maker, .5f)); var ship = new Ship(items, null, hullItem, new EntitySettings()); var gearItem = (EquippableItem) items.CreateInstance(items.CreateLot(thermalData, maker, .5f)); @@ -867,7 +867,7 @@ public sealed class LoadoutTests : IDisposable cache.FlushAsync().Wait(); var consumableData = cache.Get(consumableRef); - var items = new ItemManager(cache, new ProvenanceLedger(), RunSaveTests.TestSettings(), _ => { }); + var items = new ItemManager(1u, cache, new ProvenanceLedger(), RunSaveTests.TestSettings(), _ => { }); var hullItem = (EquippableItem) items.CreateInstance(items.CreateLot(hullData, maker, .5f)); var ship = new Ship(items, null, hullItem, new EntitySettings()); var consumableItem = (ConsumableItem) items.CreateInstance(items.CreateLot(consumableData, maker, .5f)); @@ -902,7 +902,7 @@ public sealed class LoadoutTests : IDisposable cache.FlushAsync().Wait(); var product = cache.GetAll().Single(p => p.Name == "Lensed by Maker"); - var items = new ItemManager(cache, new ProvenanceLedger(), RunSaveTests.TestSettings(), _ => { }); + var items = new ItemManager(1u, cache, new ProvenanceLedger(), RunSaveTests.TestSettings(), _ => { }); var lotId = items.CreateLot(product); var roleFill = items.Lots[lotId].Roles.Single(r => r.Role == "lens"); Assert.Equal(.9f, roleFill.Quality, 3); @@ -914,7 +914,7 @@ public sealed class LoadoutTests : IDisposable { using var cache = Open(); var maker = cache.RefOf(cache.GetByName("Maker")); - var items = new ItemManager(cache, new ProvenanceLedger(), RunSaveTests.TestSettings(), _ => { }); + var items = new ItemManager(1u, cache, new ProvenanceLedger(), RunSaveTests.TestSettings(), _ => { }); var lamp = cache.GetByName("Lamp"); var lotId = items.Lots.Add(new Lot { @@ -932,7 +932,7 @@ public sealed class LoadoutTests : IDisposable public void OutOfRangeWeaponGroupIndexBuildsNothing() { using var cache = Open(); - var items = new ItemManager(cache, new ProvenanceLedger(), RunSaveTests.TestSettings(), _ => { }); + var items = new ItemManager(1u, cache, new ProvenanceLedger(), RunSaveTests.TestSettings(), _ => { }); var stale = Armed(cache); stale.WeaponGroups = new[] { new[] { 0, 2 }, new[] { -1 } }; var failures = new List(); @@ -944,7 +944,7 @@ public sealed class LoadoutTests : IDisposable public void UnequippingAGroupedWeaponRemovesItFromItsGroups() { using var cache = Open(); - var items = new ItemManager(cache, new ProvenanceLedger(), RunSaveTests.TestSettings(), _ => { }); + var items = new ItemManager(1u, cache, new ProvenanceLedger(), RunSaveTests.TestSettings(), _ => { }); var ship = Build(items, Armed(cache), new List()); var item = Assert.Single(ship.WeaponGroups[0].items); Assert.NotNull(item.GetBehavior()); @@ -959,7 +959,7 @@ public sealed class LoadoutTests : IDisposable public void NonWeaponGroupIndexBuildsNothing() { using var cache = Open(); - var items = new ItemManager(cache, new ProvenanceLedger(), RunSaveTests.TestSettings(), _ => { }); + var items = new ItemManager(1u, cache, new ProvenanceLedger(), RunSaveTests.TestSettings(), _ => { }); var lampGrouped = WithDesign(Armed(cache), 0, cache, "Lamp"); var failures = new List(); Assert.Null(Build(items, lampGrouped, failures)); @@ -970,7 +970,7 @@ public sealed class LoadoutTests : IDisposable public void MoreWeaponGroupsThanTheGameHasBuildsNothing() { using var cache = Open(); - var items = new ItemManager(cache, new ProvenanceLedger(), RunSaveTests.TestSettings(), _ => { }); + var items = new ItemManager(1u, cache, new ProvenanceLedger(), RunSaveTests.TestSettings(), _ => { }); var wide = Armed(cache); wide.WeaponGroups = wide.WeaponGroups.Append(new[] { 0 }).ToArray(); var failures = new List(); @@ -984,7 +984,7 @@ public sealed class LoadoutTests : IDisposable public void FewerWeaponGroupsArePaddedToTheGameCount() { using var cache = Open(); - var items = new ItemManager(cache, new ProvenanceLedger(), RunSaveTests.TestSettings(), _ => { }); + var items = new ItemManager(1u, cache, new ProvenanceLedger(), RunSaveTests.TestSettings(), _ => { }); var armed = Armed(cache); armed.WeaponGroups = new[] { new[] { 0 } }; foreach (var groups in new[] { armed.WeaponGroups, null }) @@ -1008,7 +1008,7 @@ public sealed class LoadoutTests : IDisposable public void EntitiesDoNotShareDefaultEntitySettings() { using var cache = Open(); - var items = new ItemManager(cache, new ProvenanceLedger(), RunSaveTests.TestSettings(), _ => { }); + var items = new ItemManager(1u, cache, new ProvenanceLedger(), RunSaveTests.TestSettings(), _ => { }); var maker = cache.RefOf(cache.GetByName("Maker")); var hullData = cache.GetByName("Skiff"); var template = items.GameplaySettings.DefaultEntitySettings; diff --git a/tests/Aetheria.Shared.Tests/LockWarningTests.cs b/tests/Aetheria.Shared.Tests/LockWarningTests.cs index 1ed414bb6..b21669c78 100644 --- a/tests/Aetheria.Shared.Tests/LockWarningTests.cs +++ b/tests/Aetheria.Shared.Tests/LockWarningTests.cs @@ -40,7 +40,7 @@ public sealed class LockWarningTests : IDisposable _openCache = AetheriaStores.Open(Catalog, catalogWritable: true); var ledger = new ProvenanceLedger(); for (var lot = 1; lot < 12; lot++) ledger.Lots[lot] = new Lot { Origin = new Attributed(), Quality = 1 }; - _items = new ItemManager(_openCache, ledger, new GameplaySettings + _items = new ItemManager(1u, _openCache, ledger, new GameplaySettings { DefaultEntitySettings = new EntitySettings(), Tiers = new[] { new RarityTier { Name = "Common", Quality = .5f, Color = new float3(1, 1, 1) } }, diff --git a/tests/Aetheria.Shared.Tests/LootTests.cs b/tests/Aetheria.Shared.Tests/LootTests.cs index ecd65f8e1..22ce95d5c 100644 --- a/tests/Aetheria.Shared.Tests/LootTests.cs +++ b/tests/Aetheria.Shared.Tests/LootTests.cs @@ -108,7 +108,7 @@ public sealed class LootTests : IDisposable var ledger = new ProvenanceLedger(); for (var i = 1; i <= 9000; i++) ledger.Lots[i] = new Lot { Origin = new Attributed(), Quality = 1 }; - var items = new ItemManager(cache, ledger, settings, _ => { }); + var items = new ItemManager(1u, cache, ledger, settings, _ => { }); var planets = new PlanetSettings(); var pack = new ZonePack { Radius = well ? 1000 : 0 }; if (well) WellFixture.Dig(cache, pack, planets, 300, 100); diff --git a/tests/Aetheria.Shared.Tests/MineTests.cs b/tests/Aetheria.Shared.Tests/MineTests.cs index 137d3f84a..8ddf775a7 100644 --- a/tests/Aetheria.Shared.Tests/MineTests.cs +++ b/tests/Aetheria.Shared.Tests/MineTests.cs @@ -118,7 +118,7 @@ public sealed class MineTests : IDisposable var ledger = new ProvenanceLedger(); for (var i = 1; i <= 500; i++) ledger.Lots[i] = new Lot { Origin = new Attributed(), Quality = 1 }; - var items = new ItemManager(cache, ledger, settings, _ => { }); + var items = new ItemManager(1u, cache, ledger, settings, _ => { }); var planets = new PlanetSettings(); var pack = new ZonePack { Radius = well ? 1000 : 0 }; if (well) WellFixture.Dig(cache, pack, planets, 300, 100); diff --git a/tests/Aetheria.Shared.Tests/MiningCut1Tests.cs b/tests/Aetheria.Shared.Tests/MiningCut1Tests.cs index aa00d690e..891638b9e 100644 --- a/tests/Aetheria.Shared.Tests/MiningCut1Tests.cs +++ b/tests/Aetheria.Shared.Tests/MiningCut1Tests.cs @@ -66,7 +66,7 @@ public sealed class MiningCut1Tests : IDisposable // OrbitData and BodyData (AsteroidBeltData) are run types (AetheriaStores.RunTypes), not catalog types: // they need a run store to write into. var cache = AetheriaStores.Open(catalog, run, catalogWritable: true); - var items = new ItemManager(cache, new ProvenanceLedger(), GameSettings(), _ => { }); + var items = new ItemManager(1u, cache, new ProvenanceLedger(), GameSettings(), _ => { }); var settings = Settings(); var sunOrbitKey = cache.Upsert(new OrbitData()).Key; diff --git a/tests/Aetheria.Shared.Tests/MiningCut2Tests.cs b/tests/Aetheria.Shared.Tests/MiningCut2Tests.cs index b484eed9f..54fc99972 100644 --- a/tests/Aetheria.Shared.Tests/MiningCut2Tests.cs +++ b/tests/Aetheria.Shared.Tests/MiningCut2Tests.cs @@ -69,7 +69,7 @@ public sealed class MiningCut2Tests : IDisposable var dir = Path.Combine(_root, $"cat{_fixtureCount++}"); Directory.CreateDirectory(dir); var cache = AetheriaStores.Open(Path.Combine(dir, "Aetheria.cc"), Path.Combine(dir, "run.cc"), catalogWritable: true); - var items = new ItemManager(cache, new ProvenanceLedger(), GameSettings(), _ => { }); + var items = new ItemManager(1u, cache, new ProvenanceLedger(), GameSettings(), _ => { }); var settings = Settings(); var rootOrbit = new OrbitData { Distance = 3000f, Phase = 0.05f }; diff --git a/tests/Aetheria.Shared.Tests/MiningCut3Tests.cs b/tests/Aetheria.Shared.Tests/MiningCut3Tests.cs index be506488b..3e68b4fb0 100644 --- a/tests/Aetheria.Shared.Tests/MiningCut3Tests.cs +++ b/tests/Aetheria.Shared.Tests/MiningCut3Tests.cs @@ -131,7 +131,7 @@ public sealed partial class MiningCut3Tests : IDisposable }); cache.Upsert(dimEye); foreach (var kind in kinds ?? new FieldKindData[0]) cache.Upsert(kind); - var items = new ItemManager(cache, new ProvenanceLedger(), GameSettings(), _ => { }); + var items = new ItemManager(1u, cache, new ProvenanceLedger(), GameSettings(), _ => { }); var sunOrbit = cache.Upsert(new OrbitData { FixedPosition = sunAt }).Key; var sun = cache.Upsert(new SunData { Orbit = new CultRecordRef(sunOrbit), Mass = 1f }).Key; @@ -679,7 +679,7 @@ public sealed partial class MiningCut3Tests : IDisposable // Kept: a new, overwhelmingly weighted kind now wins the pick for this belt, yet loading it again leaves it. reopened.Upsert(Kind("Overwhelming", 9f, 1e6f)); Assert.False(FieldKinds.Assign(beltKey, reopened).Key.Equals(assigned)); - var items = new ItemManager(reopened, new ProvenanceLedger(), GameSettings(), _ => { }); + var items = new ItemManager(1u, reopened, new ProvenanceLedger(), GameSettings(), _ => { }); var reloaded = new Zone(items, PlanetSettings(), s.Pack, new GalaxyZone { Name = "MiningCut3Reload", Owner = null }, null); Assert.True(((AsteroidBeltData) reloaded.Planets[beltKey]).Kind.Key.Equals(assigned)); } @@ -817,7 +817,7 @@ public sealed partial class MiningCut3Tests : IDisposable cache, new PlayerSettings(), new DirectoryInfo(dir), _ => { }, null, seed); } catch (Exception) { continue; } // Galaxy's own seed-dependent flakiness (RestoredHullsTests) - var items = new ItemManager(cache, new ProvenanceLedger(), GameSettings(), _ => { }); + var items = new ItemManager(1u, cache, new ProvenanceLedger(), GameSettings(), _ => { }); foreach (var zone in galaxy.Zones.Take(4)) { var pack = ZoneGenerator.GenerateZone(items, RestoredHullsTests.TutorialZoneSettings(), RestoredHullsTests.AuthoredPlanetSettings(), galaxy, zone, isTutorial: zone == galaxy.Entrance); @@ -924,7 +924,7 @@ public sealed partial class MiningCut3Tests : IDisposable _caches.Add(clean); Assert.False(clean.IsDirty); Assert.False(((AsteroidBeltData) clean.Get(beltKey)).Kind.IsSet()); - var zone = new Zone(new ItemManager(clean, new ProvenanceLedger(), GameSettings(), _ => { }), PlanetSettings(), s.Pack, + var zone = new Zone(new ItemManager(1u, clean, new ProvenanceLedger(), GameSettings(), _ => { }), PlanetSettings(), s.Pack, new GalaxyZone { Name = "Clean", Owner = null }, null); var assigned = ((AsteroidBeltData) zone.Planets[beltKey]).Kind; Assert.True(assigned.IsSet()); @@ -1026,7 +1026,7 @@ public sealed partial class MiningCut3Tests : IDisposable PingEnergy = Constant(0f), PingRange = Constant(300f), PingCooldown = Constant(4f) }); cache.Upsert(eye); - var items = new ItemManager(cache, new ProvenanceLedger(), GameSettings(), _ => { }); + var items = new ItemManager(1u, cache, new ProvenanceLedger(), GameSettings(), _ => { }); var s = new Scene { Cache = cache, Items = items, Hull = hull, Eye = eye, diff --git a/tests/Aetheria.Shared.Tests/MunitionPartsTests.cs b/tests/Aetheria.Shared.Tests/MunitionPartsTests.cs index 0c7f60bd2..2bb2a2ee9 100644 --- a/tests/Aetheria.Shared.Tests/MunitionPartsTests.cs +++ b/tests/Aetheria.Shared.Tests/MunitionPartsTests.cs @@ -257,7 +257,7 @@ public sealed class MunitionPartsTests : IDisposable var ledger = new ProvenanceLedger(); ledger.Lots[1] = new Lot { Origin = new Attributed(), Quality = .5f }; ledger.Lots[2] = new Lot { Origin = new Attributed(), Quality = .5f, Roles = new List { new RoleFill { Role = "thruster", Quality = thrusterQuality } } }; - var items = new ItemManager(cache, ledger, settings, _ => { }); + var items = new ItemManager(1u, cache, ledger, settings, _ => { }); var zone = new Zone(items, new PlanetSettings(), new ZonePack(), new GalaxyZone { Name = "Test", Owner = null }, null); ship = new Ship(items, zone, new EquippableItem { Data = cache.RefOf(hull), Durability = hull.Durability, Lot = 1 }, settings.DefaultEntitySettings); foreach (var (name, x, y) in ShipGear) diff --git a/tests/Aetheria.Shared.Tests/OrbitsFromFieldTests.cs b/tests/Aetheria.Shared.Tests/OrbitsFromFieldTests.cs index 66a8e31d8..7a399587f 100644 --- a/tests/Aetheria.Shared.Tests/OrbitsFromFieldTests.cs +++ b/tests/Aetheria.Shared.Tests/OrbitsFromFieldTests.cs @@ -255,7 +255,7 @@ public sealed class OrbitsFromFieldTests : IDisposable var galaxy = new Galaxy(tutorial, authored.Read("TutorialBackgroundSettings"), cache, new PlayerSettings(), Directory.CreateDirectory(Path.Combine(dir, "Narrative")), _ => { }, null, seed); - var items = new ItemManager(cache, new ProvenanceLedger(), authored.Read("GameplaySettings"), _ => { }); + var items = new ItemManager(1u, cache, new ProvenanceLedger(), authored.Read("GameplaySettings"), _ => { }); var pack = ZoneGenerator.GenerateZone(items, authored.Read("ZoneSettings"), authored.Read("PlanetSettings"), galaxy, galaxy.Entrance, galaxy.IsPrelude, false); var zone = new Zone(items, authored.Read("PlanetSettings"), pack, galaxy.Entrance, galaxy); diff --git a/tests/Aetheria.Shared.Tests/PaintModeTests.cs b/tests/Aetheria.Shared.Tests/PaintModeTests.cs index 02fcf71e1..89b6fb39f 100644 --- a/tests/Aetheria.Shared.Tests/PaintModeTests.cs +++ b/tests/Aetheria.Shared.Tests/PaintModeTests.cs @@ -60,7 +60,7 @@ public sealed class PaintModeTests : IDisposable _openCache = AetheriaStores.Open(Catalog, catalogWritable: true); var ledger = new ProvenanceLedger(); for (var lot = 1; lot < 12; lot++) ledger.Lots[lot] = new Lot { Origin = new Attributed(), Quality = 1 }; - _items = new ItemManager(_openCache, ledger, new GameplaySettings + _items = new ItemManager(1u, _openCache, ledger, new GameplaySettings { DefaultEntitySettings = new EntitySettings(), Tiers = new[] { new RarityTier { Name = "Common", Quality = .5f, Color = new float3(1, 1, 1) } }, diff --git a/tests/Aetheria.Shared.Tests/PickupTests.cs b/tests/Aetheria.Shared.Tests/PickupTests.cs index 316c8b7b8..230b2023e 100644 --- a/tests/Aetheria.Shared.Tests/PickupTests.cs +++ b/tests/Aetheria.Shared.Tests/PickupTests.cs @@ -106,7 +106,7 @@ public sealed class PickupTests : IDisposable var ledger = new ProvenanceLedger(); for (var i = 1; i <= 9000; i++) ledger.Lots[i] = new Lot { Origin = new Attributed(), Quality = 1 }; - var items = new ItemManager(cache, ledger, settings, _ => { }); + var items = new ItemManager(1u, cache, ledger, settings, _ => { }); var pack = new ZonePack { Radius = 0 }; var curves = new PlanetSettings(); if (wells) diff --git a/tests/Aetheria.Shared.Tests/PopulatedRadiusTests.cs b/tests/Aetheria.Shared.Tests/PopulatedRadiusTests.cs index a106b217e..ff07e8e8c 100644 --- a/tests/Aetheria.Shared.Tests/PopulatedRadiusTests.cs +++ b/tests/Aetheria.Shared.Tests/PopulatedRadiusTests.cs @@ -61,7 +61,7 @@ public sealed class PopulatedRadiusTests : IDisposable var cache = AetheriaStores.Open(Path.Combine(dir, "Aetheria.cc"), Path.Combine(dir, "run.cc"), catalogWritable: true); _caches.Add(cache); cache.Upsert(new VerseGrammar { Revision = 1 }); - var items = new ItemManager(cache, new ProvenanceLedger(), new GameplaySettings + var items = new ItemManager(1u, cache, new ProvenanceLedger(), new GameplaySettings { DefaultEntitySettings = new EntitySettings(), Tiers = new[] { new RarityTier { Name = "Common", Quality = .5f, Color = new float3(1, 1, 1) } }, diff --git a/tests/Aetheria.Shared.Tests/PotentialOwnerTests.cs b/tests/Aetheria.Shared.Tests/PotentialOwnerTests.cs index 94100844a..2d3a42600 100644 --- a/tests/Aetheria.Shared.Tests/PotentialOwnerTests.cs +++ b/tests/Aetheria.Shared.Tests/PotentialOwnerTests.cs @@ -31,7 +31,7 @@ public sealed class PotentialOwnerTests : IDisposable Tiers = new[] { new RarityTier { Name = "Common", Quality = .5f, Color = new float3(1, 1, 1) } }, QualityPriceModifier = new ExponentialLerp() }; - _items = new ItemManager(_cache, new ProvenanceLedger(), settings, _ => { }); + _items = new ItemManager(1u, _cache, new ProvenanceLedger(), settings, _ => { }); } public void Dispose() diff --git a/tests/Aetheria.Shared.Tests/PowerBusTests.cs b/tests/Aetheria.Shared.Tests/PowerBusTests.cs index f4277b6fd..3cf71fa15 100644 --- a/tests/Aetheria.Shared.Tests/PowerBusTests.cs +++ b/tests/Aetheria.Shared.Tests/PowerBusTests.cs @@ -87,7 +87,7 @@ public sealed class PowerBusTests : IDisposable CultCache cache, float reactorCharge, float startingCapacitorCharge, bool reactorFirst) { var ledger = new ProvenanceLedger(); - var items = new ItemManager(cache, ledger, Settings(), _ => { }); + var items = new ItemManager(1u, cache, ledger, Settings(), _ => { }); var zone = new Zone(items, new PlanetSettings(), new ZonePack(), new GalaxyZone { Name = "Test Zone", Owner = null }, null); var hull = Mint(cache, items, cache.GetByName("Skiff")); var ship = new Ship(items, zone, hull, new EntitySettings()); diff --git a/tests/Aetheria.Shared.Tests/PowerCurveTests.cs b/tests/Aetheria.Shared.Tests/PowerCurveTests.cs index 38db0eec6..9d0405585 100644 --- a/tests/Aetheria.Shared.Tests/PowerCurveTests.cs +++ b/tests/Aetheria.Shared.Tests/PowerCurveTests.cs @@ -93,7 +93,7 @@ public sealed class PowerCurveTests : IDisposable private Ship BuildShip(CultCache cache, float reactorCharge) { - var items = new ItemManager(cache, new ProvenanceLedger(), Settings(), _ => { }); + var items = new ItemManager(1u, cache, new ProvenanceLedger(), Settings(), _ => { }); var zone = new Zone(items, new PlanetSettings(), new ZonePack(), new GalaxyZone { Name = "Test Zone", Owner = null }, null); var hull = Mint(cache, items, cache.GetByName("Skiff")); var ship = new Ship(items, zone, hull, new EntitySettings()); @@ -401,7 +401,7 @@ public sealed class PowerCurveTests : IDisposable }); cache.FlushAsync().Wait(); - var items = new ItemManager(cache, new ProvenanceLedger(), Settings(), _ => { }); + var items = new ItemManager(1u, cache, new ProvenanceLedger(), Settings(), _ => { }); var zone = new Zone(items, new PlanetSettings(), new ZonePack(), new GalaxyZone { Name = "Test Zone", Owner = null }, null); var hullItem = Mint(cache, items, cache.GetByName("Skiff")); var ship = new Ship(items, zone, hullItem, new EntitySettings()); diff --git a/tests/Aetheria.Shared.Tests/PowerTierTests.cs b/tests/Aetheria.Shared.Tests/PowerTierTests.cs index fc947ffb1..4a2831d19 100644 --- a/tests/Aetheria.Shared.Tests/PowerTierTests.cs +++ b/tests/Aetheria.Shared.Tests/PowerTierTests.cs @@ -63,7 +63,7 @@ public sealed class PowerTierTests : IDisposable private (Ship ship, ItemManager items, Reactor reactor) BuildShip(CultCache cache, float reactorCharge) { var ledger = new ProvenanceLedger(); - var items = new ItemManager(cache, ledger, Settings(), _ => { }); + var items = new ItemManager(1u, cache, ledger, Settings(), _ => { }); var zone = new Zone(items, new PlanetSettings(), new ZonePack(), new GalaxyZone { Name = "Test Zone", Owner = null }, null); var hull = Mint(cache, items, cache.GetByName("Skiff")); var ship = new Ship(items, zone, hull, new EntitySettings()); diff --git a/tests/Aetheria.Shared.Tests/ProductBinTests.cs b/tests/Aetheria.Shared.Tests/ProductBinTests.cs index c7b7de1fb..7729fd5fc 100644 --- a/tests/Aetheria.Shared.Tests/ProductBinTests.cs +++ b/tests/Aetheria.Shared.Tests/ProductBinTests.cs @@ -26,7 +26,7 @@ public sealed class ProductBinTests : IDisposable Roles = { new ItemRole { Name = "lens" } } }); _lens = new CultRecordRef(gear.Key); - _items = new ItemManager(_cache, new ProvenanceLedger(), RunSaveTests.TestSettings(), _log.Add) { Random = new CultMath.Random(7u) }; + _items = new ItemManager(1u, _cache, new ProvenanceLedger(), RunSaveTests.TestSettings(), _log.Add) { Random = new CultMath.Random(7u) }; } public void Dispose() diff --git a/tests/Aetheria.Shared.Tests/ProductChoiceTests.cs b/tests/Aetheria.Shared.Tests/ProductChoiceTests.cs index 89f262c62..7e8744f1a 100644 --- a/tests/Aetheria.Shared.Tests/ProductChoiceTests.cs +++ b/tests/Aetheria.Shared.Tests/ProductChoiceTests.cs @@ -133,7 +133,7 @@ public sealed class ProductChoiceTests : IDisposable return new Rig { Cache = cache, OutA = outA, OutZ = outZ, OutBoth = cache.GetByName("OutBoth"), OutEven = cache.GetByName("OutEven"), OutNone = cache.GetByName("OutNone"), - Items = new ItemManager(cache, new ProvenanceLedger(), RunSaveTests.TestSettings(), _ => { }), + Items = new ItemManager(1u, cache, new ProvenanceLedger(), RunSaveTests.TestSettings(), _ => { }), Galaxy = new Galaxy(cache, cache.GetGlobal(), _ => { }) }; } @@ -325,7 +325,7 @@ public sealed class ProductChoiceTests : IDisposable int AlphaDraws(Rig rig) { var log = new List(); - var items = new ItemManager(rig.Cache, new ProvenanceLedger(), RunSaveTests.TestSettings(), log.Add); + var items = new ItemManager(1u, rig.Cache, new ProvenanceLedger(), RunSaveTests.TestSettings(), log.Add); var lamp = rig.Cache.GetByName("Lamp"); var alpha = Enumerable.Range(1, 1000).Count(i => { diff --git a/tests/Aetheria.Shared.Tests/RangeFalloffTests.cs b/tests/Aetheria.Shared.Tests/RangeFalloffTests.cs index 8d31dc296..ca7818c90 100644 --- a/tests/Aetheria.Shared.Tests/RangeFalloffTests.cs +++ b/tests/Aetheria.Shared.Tests/RangeFalloffTests.cs @@ -57,7 +57,7 @@ public sealed class RangeFalloffTests public void AnUnequippedWeaponsUnauthoredStatsEvaluate(string design, bool unauthored) { using var cache = RestoredHullsTests.OpenCatalog(); - var items = new ItemManager(cache, new ProvenanceLedger(), RunSaveTests.TestSettings(), _ => { }); + var items = new ItemManager(1u, cache, new ProvenanceLedger(), RunSaveTests.TestSettings(), _ => { }); var data = cache.GetAll().Single(x => x.Name == design); var lot = items.Lots.Add(new Lot { Design = cache.RefOf(data), Origin = new Attributed(), Quality = .5f, Roles = new System.Collections.Generic.List() }); var item = (EquippableItem) items.CreateInstance(lot); diff --git a/tests/Aetheria.Shared.Tests/RestoredHullsTests.cs b/tests/Aetheria.Shared.Tests/RestoredHullsTests.cs index ed63994cd..8b0065f93 100644 --- a/tests/Aetheria.Shared.Tests/RestoredHullsTests.cs +++ b/tests/Aetheria.Shared.Tests/RestoredHullsTests.cs @@ -147,7 +147,7 @@ public sealed class RestoredHullsTests private static (uint seed, string zoneName) ScanForNoLagrangeCandidateZone(CultCache cache) { - var items = new ItemManager(cache, new ProvenanceLedger(), Settings(), _ => { }); + var items = new ItemManager(1u, cache, new ProvenanceLedger(), Settings(), _ => { }); for (var seed = 1u; seed <= 64u; seed++) { var galaxy = new Galaxy(TutorialGalaxySettings(), TutorialBackgroundSettings(), cache, new PlayerSettings(), new DirectoryInfo(Path.GetTempPath()), _ => { }, null, seed); @@ -227,7 +227,7 @@ public sealed class RestoredHullsTests var settings = Settings(); var ledger = new ProvenanceLedger(); for (var i = 1; i <= hull.Hardpoints.Count + 1; i++) ledger.Lots[i] = new Lot { Origin = new Attributed(), Quality = .5f }; - var items = new ItemManager(cache, ledger, settings, _ => { }); + var items = new ItemManager(1u, cache, ledger, settings, _ => { }); var zone = new Zone(items, new PlanetSettings(), new ZonePack(), new GalaxyZone { Name = "Test", Owner = null }, null); var hullItem = new EquippableItem { Data = cache.RefOf(hull), Durability = hull.Durability, Lot = 1 }; var ship = new Ship(items, zone, hullItem, settings.DefaultEntitySettings); @@ -282,7 +282,7 @@ public sealed class RestoredHullsTests var settings = Settings(); var ledger = new ProvenanceLedger(); for (var i = 1; i <= hull.Hardpoints.Count + 1; i++) ledger.Lots[i] = new Lot { Origin = new Attributed(), Quality = .5f }; - var items = new ItemManager(cache, ledger, settings, _ => { }); + var items = new ItemManager(1u, cache, ledger, settings, _ => { }); var zone = new Zone(items, new PlanetSettings(), new ZonePack(), new GalaxyZone { Name = "Test", Owner = null }, null); var hullItem = new EquippableItem { Data = cache.RefOf(hull), Durability = hull.Durability, Lot = 1 }; var ship = new Ship(items, zone, hullItem, settings.DefaultEntitySettings); @@ -372,7 +372,7 @@ public sealed class RestoredHullsTests using var cache = OpenReadOnlyRealCatalogWithScratchRun(gameData, scratchRun); var galaxy = new Galaxy(tutorialSettings, background, cache, new PlayerSettings(), new DirectoryInfo(Path.GetTempPath()), _ => { }, null, seed); - var items = new ItemManager(cache, new ProvenanceLedger(), Settings(), _ => { }); + var items = new ItemManager(1u, cache, new ProvenanceLedger(), Settings(), _ => { }); var pack = ZoneGenerator.GenerateZone(items, zoneSettings, AuthoredPlanetSettings(), galaxy, galaxy.Entrance, isTutorial: true); var dockedStation = pack.Entities.OfType() @@ -412,7 +412,7 @@ public sealed class RestoredHullsTests using var cache = OpenReadOnlyRealCatalogWithScratchRun(gameData, scratchRun); var galaxy = new Galaxy(tutorialSettings, background, cache, new PlayerSettings(), new DirectoryInfo(Path.GetTempPath()), _ => { }, null, seed); - var items = new ItemManager(cache, new ProvenanceLedger(), Settings(), _ => { }); + var items = new ItemManager(1u, cache, new ProvenanceLedger(), Settings(), _ => { }); var pack = ZoneGenerator.GenerateZone(items, zoneSettings, AuthoredPlanetSettings(), galaxy, galaxy.Entrance, isTutorial: true); var planetOrbits = pack.Planets.Select(p => cache.Get(cache.Get(p).Orbit)).Where(o => o.Parent.IsSet()).ToArray(); @@ -449,7 +449,7 @@ public sealed class RestoredHullsTests { using var cache = OpenReadOnlyRealCatalogWithScratchRun(gameData, scratchRun); var galaxy = new Galaxy(TutorialGalaxySettings(), TutorialBackgroundSettings(), cache, new PlayerSettings(), new DirectoryInfo(Path.GetTempPath()), _ => { }, null, seed: 1u); - var items = new ItemManager(cache, new ProvenanceLedger(), Settings(), _ => { }); + var items = new ItemManager(1u, cache, new ProvenanceLedger(), Settings(), _ => { }); var zoneSettings = TutorialZoneSettings(); zoneSettings.RosetteProbability = 0; @@ -484,7 +484,7 @@ public sealed class RestoredHullsTests { using var cache = OpenReadOnlyRealCatalogWithScratchRun(gameData, scratchRun); var galaxy = new Galaxy(TutorialGalaxySettings(), TutorialBackgroundSettings(), cache, new PlayerSettings(), new DirectoryInfo(Path.GetTempPath()), _ => { }, null, seed: 1u); - var items = new ItemManager(cache, new ProvenanceLedger(), Settings(), _ => { }); + var items = new ItemManager(1u, cache, new ProvenanceLedger(), Settings(), _ => { }); var zoneSettings = TutorialZoneSettings(); zoneSettings.RosetteProbability = 1; @@ -535,7 +535,7 @@ public sealed class RestoredHullsTests using var cache = OpenReadOnlyRealCatalogWithScratchRun(gameData, scratchRun); var galaxy = new Galaxy(tutorialSettings, background, cache, new PlayerSettings(), new DirectoryInfo(Path.GetTempPath()), _ => { }, null, seed); - var items = new ItemManager(cache, new ProvenanceLedger(), Settings(), _ => { }); + var items = new ItemManager(1u, cache, new ProvenanceLedger(), Settings(), _ => { }); var pack = ZoneGenerator.GenerateZone(items, zoneSettings, AuthoredPlanetSettings(), galaxy, galaxy.Entrance, isTutorial: true); var dockedStation = pack.Entities.OfType() @@ -596,7 +596,7 @@ public sealed class RestoredHullsTests using var cache = OpenCatalog(); var hull = cache.GetByName("Longinus"); var settings = Settings(); - var items = new ItemManager(cache, new ProvenanceLedger(), settings, _ => { }); + var items = new ItemManager(1u, cache, new ProvenanceLedger(), settings, _ => { }); // ItemManager.Random defaults to a DateTime.Now-seeded instance; without pinning it here, the item // qualities LoadoutGenerator/EntitySerializer roll through it (not through the `random` below, which // only drives which designs get picked) would make this test's own measured speed non-deterministic. @@ -645,7 +645,7 @@ public sealed class RestoredHullsTests for (uint seed = 1; seed <= 10; seed++) { - var items = new ItemManager(cache, new ProvenanceLedger(), settings, _ => { }); + var items = new ItemManager(1u, cache, new ProvenanceLedger(), settings, _ => { }); var random = new Random(seed); var generator = new LoadoutGenerator(ref random, items, null, null, null, .5f); var pack = generator.GenerateShipLoadout(candidate => candidate == hull); @@ -729,7 +729,7 @@ public sealed class RestoredHullsTests var (seed, zoneName) = NoLagrangeCandidateZone(); var galaxy = BuildGalaxyWithForcedEntrance(cache, seed, zoneName); - var items = new ItemManager(cache, new ProvenanceLedger(), Settings(), _ => { }); + var items = new ItemManager(1u, cache, new ProvenanceLedger(), Settings(), _ => { }); var pack = ZoneGenerator.GenerateZone(items, TutorialZoneSettings(), AuthoredPlanetSettings(), galaxy, galaxy.Entrance, isTutorial: true); var station = pack.Entities.OfType() @@ -780,7 +780,7 @@ public sealed class RestoredHullsTests { using var cache = OpenReadOnlyRealCatalogWithScratchRun(gameData, scratchRun); var galaxy = new Galaxy(TutorialGalaxySettings(), TutorialBackgroundSettings(), cache, new PlayerSettings(), new DirectoryInfo(Path.GetTempPath()), _ => { }, null, seed: 1u); - var items = new ItemManager(cache, new ProvenanceLedger(), Settings(), _ => { }); + var items = new ItemManager(1u, cache, new ProvenanceLedger(), Settings(), _ => { }); int StationCount(GalaxyZone zone, bool isTutorial) => ZoneGenerator.GenerateZone(items, TutorialZoneSettings(), AuthoredPlanetSettings(), galaxy, zone, isTutorial) @@ -820,7 +820,7 @@ public sealed class RestoredHullsTests { using var cache = OpenReadOnlyRealCatalogWithScratchRun(gameData, scratchRun); var galaxy = new Galaxy(TutorialGalaxySettings(), TutorialBackgroundSettings(), cache, new PlayerSettings(), new DirectoryInfo(Path.GetTempPath()), _ => { }, null, seed: 1u); - var items = new ItemManager(cache, new ProvenanceLedger(), Settings(), _ => { }); + var items = new ItemManager(1u, cache, new ProvenanceLedger(), Settings(), _ => { }); var pack = ZoneGenerator.GenerateZone(items, TutorialZoneSettings(), AuthoredPlanetSettings(), galaxy, galaxy.Entrance, isTutorial: true); var stationCount = pack.Entities.OfType().Count(e => (cache.Get(e.Hull.Data) as HullData)?.HullType == HullType.Station); @@ -904,7 +904,7 @@ public sealed class RestoredHullsTests var zone = galaxy.Zones.Single(z => z.Name == zoneName); Assert.NotSame(galaxy.Entrance, zone); - var items = new ItemManager(cache, new ProvenanceLedger(), Settings(), _ => { }); + var items = new ItemManager(1u, cache, new ProvenanceLedger(), Settings(), _ => { }); var pack = ZoneGenerator.GenerateZone(items, TutorialZoneSettings(), AuthoredPlanetSettings(), galaxy, zone, isTutorial: true); // Confirms this fixture still has no genuine (non-rosette) Lagrange candidate -- a regression here diff --git a/tests/Aetheria.Shared.Tests/RoleAuthoringTests.cs b/tests/Aetheria.Shared.Tests/RoleAuthoringTests.cs index 2a61cbf86..0bb9e6a6d 100644 --- a/tests/Aetheria.Shared.Tests/RoleAuthoringTests.cs +++ b/tests/Aetheria.Shared.Tests/RoleAuthoringTests.cs @@ -102,7 +102,7 @@ public sealed class RoleAuthoringTests : IDisposable }); cache.FlushAsync().Wait(); - var items = new ItemManager(cache, new ProvenanceLedger(), Settings(), _ => { }); + var items = new ItemManager(1u, cache, new ProvenanceLedger(), Settings(), _ => { }); var cheapLot = items.CreateLot(cache.GetAll().Single(p => p.Name == "Lensed Economy")); var premiumLot = items.CreateLot(cache.GetAll().Single(p => p.Name == "Lensed Premium")); var cheapInstance = (EquippableItem) items.CreateInstance(cheapLot); diff --git a/tests/Aetheria.Shared.Tests/RunGoalTests.cs b/tests/Aetheria.Shared.Tests/RunGoalTests.cs index c618d712e..8626148a5 100644 --- a/tests/Aetheria.Shared.Tests/RunGoalTests.cs +++ b/tests/Aetheria.Shared.Tests/RunGoalTests.cs @@ -30,7 +30,7 @@ public sealed partial class RunStartTests private static Faction Zhestokost(Galaxy galaxy) => galaxy.Factions.Single(faction => faction.Name == "Zhestokost"); private ItemManager LoggingItems(List log) => - new ItemManager(_cache, new ProvenanceLedger(), AuthoredSettings.Load(FindRepoRoot()).Read("GameplaySettings"), log.Add); + new ItemManager(1u, _cache, new ProvenanceLedger(), AuthoredSettings.Load(FindRepoRoot()).Read("GameplaySettings"), log.Add); private static string HullName(ItemManager items, EntityPack pack) => items.GetData(pack.Hull).Name; diff --git a/tests/Aetheria.Shared.Tests/RunSaveTests.cs b/tests/Aetheria.Shared.Tests/RunSaveTests.cs index 456561d76..00a6baa75 100644 --- a/tests/Aetheria.Shared.Tests/RunSaveTests.cs +++ b/tests/Aetheria.Shared.Tests/RunSaveTests.cs @@ -102,7 +102,7 @@ public sealed class RunSaveTests : IDisposable var galaxy = new Galaxy(cache, cache.GetGlobal(), _ => { }); var ledger = new ProvenanceLedger(); for (var i = 1; i <= 2; i++) ledger.Lots[i] = new Lot { Origin = new Attributed(), Quality = 1 }; - var items = new ItemManager(cache, ledger, TestSettings(), _ => { }); + var items = new ItemManager(1u, cache, ledger, TestSettings(), _ => { }); var zone = new Zone(items, new PlanetSettings(), new ZonePack(), galaxy.Zones[0], galaxy); galaxy.Zones[0].Contents = zone; Ship Make(string hull, int lot) => new Ship(items, zone, new EquippableItem @@ -135,7 +135,7 @@ public sealed class RunSaveTests : IDisposable using (var cache = Open()) { var galaxy = new Galaxy(cache, cache.GetGlobal(), _ => { }); - var itemManager = new ItemManager(cache, new ProvenanceLedger(), TestSettings(), _ => { }); + var itemManager = new ItemManager(1u, cache, new ProvenanceLedger(), TestSettings(), _ => { }); galaxy.Zones[0].Contents = new Zone(itemManager, new PlanetSettings(), new ZonePack(), galaxy.Zones[0], galaxy); var (game, zones) = RunSave.Capture(cache, galaxy, galaxy.Zones[0].Contents, null, new SavedActionBarBinding[0], 0); RunSave.Commit(cache, game, zones, RunSave.Lots(cache)); @@ -160,7 +160,7 @@ public sealed class RunSaveTests : IDisposable using (var cache = Open()) { var galaxy = new Galaxy(cache, cache.GetGlobal(), _ => { }); - var itemManager = new ItemManager(cache, new ProvenanceLedger(), TestSettings(), _ => { }); + var itemManager = new ItemManager(1u, cache, new ProvenanceLedger(), TestSettings(), _ => { }); galaxy.Zones[0].Contents = new Zone(itemManager, new PlanetSettings(), new ZonePack(), galaxy.Zones[0], galaxy); var (game, zones) = RunSave.Capture(cache, galaxy, galaxy.Zones[0].Contents, null, new SavedActionBarBinding[0], 4242); RunSave.Commit(cache, game, zones, RunSave.Lots(cache)); @@ -186,7 +186,7 @@ public sealed class RunSaveTests : IDisposable { var galaxy = new Galaxy(cache, cache.GetGlobal(), _ => { }); Assert.Equal(0xC0FFEE42u, galaxy.Seed); - var itemManager = new ItemManager(cache, new ProvenanceLedger(), TestSettings(), _ => { }); + var itemManager = new ItemManager(1u, cache, new ProvenanceLedger(), TestSettings(), _ => { }); galaxy.Zones[0].Contents = new Zone(itemManager, new PlanetSettings(), new ZonePack(), galaxy.Zones[0], galaxy); // The zone's drop dice are its name's seed mixed with the run's. Assert.Equal("Zone 0".StableHash() ^ SimulationDice.Mix(0xC0FFEE42u), galaxy.Zones[0].Contents.DropSeed); @@ -207,7 +207,7 @@ public sealed class RunSaveTests : IDisposable Assert.Equal(0u, cache.GetGlobal().Seed); var galaxy = new Galaxy(cache, cache.GetGlobal(), _ => { }); Assert.Equal(0u, galaxy.Seed); - var itemManager = new ItemManager(cache, new ProvenanceLedger(), TestSettings(), _ => { }); + var itemManager = new ItemManager(1u, cache, new ProvenanceLedger(), TestSettings(), _ => { }); var zone = new Zone(itemManager, new PlanetSettings(), new ZonePack(), galaxy.Zones[0], galaxy); Assert.Equal("Zone 0".StableHash(), zone.CombatSeed); Assert.Equal(zone.CombatSeed, zone.DropSeed); @@ -408,7 +408,7 @@ public sealed class RunSaveTests : IDisposable using (var cache = Open()) { var random = new Random(7); - var items = new ItemManager(cache, new ProvenanceLedger(), TestSettings(), _ => { }); + var items = new ItemManager(1u, cache, new ProvenanceLedger(), TestSettings(), _ => { }); var generator = new LoadoutGenerator(ref random, items, null, null, null, .5f); pack = generator.GenerateShipLoadout(candidate => candidate.Name == "Runabout"); Assert.NotNull(pack); diff --git a/tests/Aetheria.Shared.Tests/RunStartTests.cs b/tests/Aetheria.Shared.Tests/RunStartTests.cs index 4b181f67e..9d499b3e1 100644 --- a/tests/Aetheria.Shared.Tests/RunStartTests.cs +++ b/tests/Aetheria.Shared.Tests/RunStartTests.cs @@ -53,7 +53,7 @@ public sealed partial class RunStartTests : IDisposable _galaxy = new Galaxy(tutorial, authored.Read("TutorialBackgroundSettings"), _cache, new PlayerSettings(), Directory.CreateDirectory(Path.Combine(_root, "Narrative")), _ => { }, null, GalaxySeed); - _items = new ItemManager(_cache, new ProvenanceLedger(), authored.Read("GameplaySettings"), _ => { }); + _items = new ItemManager(1u, _cache, new ProvenanceLedger(), authored.Read("GameplaySettings"), _ => { }); _protagonist = _galaxy.ResolveFaction(tutorial.ProtagonistFaction); // GameSettings.StartingHullName is a plain field of the Unity settings object, not a ServerShared subtree diff --git a/tests/Aetheria.Shared.Tests/ShieldReserveTests.cs b/tests/Aetheria.Shared.Tests/ShieldReserveTests.cs index 3bd741e57..50f71dd06 100644 --- a/tests/Aetheria.Shared.Tests/ShieldReserveTests.cs +++ b/tests/Aetheria.Shared.Tests/ShieldReserveTests.cs @@ -83,7 +83,7 @@ public sealed class ShieldReserveTests : IDisposable private (Ship ship, Shield shield) BuildShipWithShield(CultCache cache, float reactorGeneration) { var ledger = new ProvenanceLedger(); - var items = new ItemManager(cache, ledger, Settings(), _ => { }); + var items = new ItemManager(1u, cache, ledger, Settings(), _ => { }); var zone = new Zone(items, new PlanetSettings(), new ZonePack(), new GalaxyZone { Name = "Test Zone", Owner = null }, null); var hull = Mint(cache, items, cache.GetByName("Skiff")); var ship = new Ship(items, zone, hull, new EntitySettings()); diff --git a/tests/Aetheria.Shared.Tests/SignatureTests.cs b/tests/Aetheria.Shared.Tests/SignatureTests.cs index 8468ac8dd..922e3e5b5 100644 --- a/tests/Aetheria.Shared.Tests/SignatureTests.cs +++ b/tests/Aetheria.Shared.Tests/SignatureTests.cs @@ -132,7 +132,7 @@ public sealed class SignatureTests : IDisposable var ledger = new ProvenanceLedger(); for (var i = 1; i <= 100; i++) ledger.Lots[i] = new Lot { Origin = new Attributed(), Quality = 1 }; - var items = new ItemManager(cache, ledger, Settings(visibilityDecay), _ => { }); + var items = new ItemManager(1u, cache, ledger, Settings(visibilityDecay), _ => { }); var zone = new Zone(items, new PlanetSettings(), new ZonePack(), new GalaxyZone { Name = "Signatures", Owner = null }, null); var lot = 1; EquippableItem Make(string name) => new EquippableItem diff --git a/tests/Aetheria.Shared.Tests/StatResolverTests.cs b/tests/Aetheria.Shared.Tests/StatResolverTests.cs index 4469eb333..406405a24 100644 --- a/tests/Aetheria.Shared.Tests/StatResolverTests.cs +++ b/tests/Aetheria.Shared.Tests/StatResolverTests.cs @@ -93,7 +93,7 @@ public sealed class StatResolverTests : IDisposable cache.Upsert(new GearData { Name = "AutoGun", Hardpoint = HardpointType.Sensors, Shape = new Shape(), Durability = 10, Behaviors = { new AutoWeaponData { Damage = autoDamage } } }); cache.Upsert(new GearData { Name = "ChargedGun", Hardpoint = HardpointType.Sensors, Shape = new Shape(), Durability = 10, Behaviors = { new ChargedWeaponData { Damage = chargedDamage } } }); cache.FlushAsync().Wait(); - var items = new ItemManager(cache, new ProvenanceLedger(), Settings(), _ => { }); + var items = new ItemManager(1u, cache, new ProvenanceLedger(), Settings(), _ => { }); var ship = BuildActivatedShip(cache, items, withBooster: true); ship.Deactivate(); // TryEquip refuses while deployed Assert.True(ship.TryEquip(Mint(cache, items, cache.GetByName("AutoGun")), FirstGunCell)); @@ -137,7 +137,7 @@ public sealed class StatResolverTests : IDisposable { var capacity = new PerformanceStat { Min = 1, Max = 1 }; using var cache = OpenCatalog(new CapacitorData { Capacity = capacity }); - var items = new ItemManager(cache, new ProvenanceLedger(), Settings(), _ => { }); + var items = new ItemManager(1u, cache, new ProvenanceLedger(), Settings(), _ => { }); var shipA = BuildActivatedShip(cache, items, withBooster: false); var shipB = BuildActivatedShip(cache, items, withBooster: false); @@ -159,7 +159,7 @@ public sealed class StatResolverTests : IDisposable Type = StatModifierType.Constant }; using var cache = OpenCatalog(new CapacitorData { Capacity = capacity }, modifier); - var items = new ItemManager(cache, new ProvenanceLedger(), Settings(), _ => { }); + var items = new ItemManager(1u, cache, new ProvenanceLedger(), Settings(), _ => { }); var boosted = BuildActivatedShip(cache, items, withBooster: true); var plain = BuildActivatedShip(cache, items, withBooster: false); @@ -198,7 +198,7 @@ public sealed class StatResolverTests : IDisposable Type = StatModifierType.Constant }; using var cache = OpenCatalog(new CapacitorData { Capacity = capacity }, modifier); - var items = new ItemManager(cache, new ProvenanceLedger(), Settings(), _ => { }); + var items = new ItemManager(1u, cache, new ProvenanceLedger(), Settings(), _ => { }); var ship = BuildActivatedShip(cache, items, withBooster: true); var boosterEquipped = ship.Equipment.Single(e => e.Data.Name == "Booster"); @@ -234,7 +234,7 @@ public sealed class StatResolverTests : IDisposable { var capacity = new PerformanceStat { Min = 1, Max = 1 }; using var cache = OpenCatalog(new CapacitorData { Capacity = capacity }); - var items = new ItemManager(cache, new ProvenanceLedger(), Settings(), _ => { }); + var items = new ItemManager(1u, cache, new ProvenanceLedger(), Settings(), _ => { }); WeakReference EquipEvaluateAndDrop() { @@ -281,7 +281,7 @@ public sealed class StatResolverTests : IDisposable }); cache.FlushAsync().Wait(); - var items = new ItemManager(cache, new ProvenanceLedger(), Settings(), _ => { }); + var items = new ItemManager(1u, cache, new ProvenanceLedger(), Settings(), _ => { }); var zone = new Zone(items, new PlanetSettings(), new ZonePack(), new GalaxyZone { Name = "Test Zone", Owner = null }, null); var hullItem = Mint(cache, items, cache.GetByName("Skiff")); var ship = new Ship(items, zone, hullItem, new EntitySettings()); @@ -387,7 +387,7 @@ public sealed class StatResolverTests : IDisposable { var capacity = new PerformanceStat { Min = 1, Max = 1 }; using var cache = OpenCatalog(new CapacitorData { Capacity = capacity }); - var items = new ItemManager(cache, new ProvenanceLedger(), Settings(), _ => { }); + var items = new ItemManager(1u, cache, new ProvenanceLedger(), Settings(), _ => { }); var ship = BuildActivatedShip(cache, items, withBooster: false); var resolver = ship.Resolver; @@ -424,7 +424,7 @@ public sealed class StatResolverTests : IDisposable cache.FlushAsync().Wait(); var boosterData = cache.Get(boosterRef); - var items = new ItemManager(cache, new ProvenanceLedger(), Settings(), _ => { }); + var items = new ItemManager(1u, cache, new ProvenanceLedger(), Settings(), _ => { }); var ship = BuildActivatedShip(cache, items, withBooster: false); var resolver = ship.Resolver; @@ -498,7 +498,7 @@ public sealed class StatResolverTests : IDisposable cache.Upsert(new ConsumableItemData { Name = "Stim", Duration = 1f, Shape = new Shape(), Behaviors = { ThrustModifier(consumable.Value.Type, consumable.Value.Value) } }); cache.FlushAsync().Wait(); - var items = new ItemManager(cache, new ProvenanceLedger(), Settings(), _ => { }); + var items = new ItemManager(1u, cache, new ProvenanceLedger(), Settings(), _ => { }); var zone = new Zone(items, new PlanetSettings(), new ZonePack(), new GalaxyZone { Name = "Test Zone", Owner = null }, null); var ship = new Ship(items, zone, Mint(cache, items, cache.GetByName("Skiff")), new EntitySettings()); Assert.True(ship.TryEquip(Mint(cache, items, cache.GetByName("Engine")), cells[0])); diff --git a/tests/Aetheria.Shared.Tests/StationPlacementTests.cs b/tests/Aetheria.Shared.Tests/StationPlacementTests.cs index 0157f6a70..66caa331c 100644 --- a/tests/Aetheria.Shared.Tests/StationPlacementTests.cs +++ b/tests/Aetheria.Shared.Tests/StationPlacementTests.cs @@ -56,7 +56,7 @@ public sealed class StationPlacementTests : IDisposable authored.Read("TutorialBackgroundSettings"), cache, new PlayerSettings(), Directory.CreateDirectory(Path.Combine(dir, "Narrative")), _ => { }, null, seed); - var items = new ItemManager(cache, new ProvenanceLedger(), settings, _ => { }); + var items = new ItemManager(1u, cache, new ProvenanceLedger(), settings, _ => { }); stock?.Invoke(items); foreach (var galaxyZone in entranceOnly ? new[] { galaxy.Entrance } : galaxy.Zones.Take(zonesPerSeed)) { diff --git a/tests/Aetheria.Shared.Tests/StationServicesTests.cs b/tests/Aetheria.Shared.Tests/StationServicesTests.cs index 21a478699..2bf4d3c2f 100644 --- a/tests/Aetheria.Shared.Tests/StationServicesTests.cs +++ b/tests/Aetheria.Shared.Tests/StationServicesTests.cs @@ -37,7 +37,7 @@ public sealed class StationServicesTests : IDisposable _cache.FlushAsync().Wait(); var settings = AuthoredSettings.Load(RestoredHullsTests.FindRepoRoot()).Read("GameplaySettings"); - _items = new ItemManager(_cache, new ProvenanceLedger(), settings, _ => { }); + _items = new ItemManager(1u, _cache, new ProvenanceLedger(), settings, _ => { }); _zone = new Zone(_items, new PlanetSettings(), new ZonePack(), new GalaxyZone { Name = "Test Zone", Owner = null }, null); } diff --git a/tests/Aetheria.Shared.Tests/SunsNeverGreenTests.cs b/tests/Aetheria.Shared.Tests/SunsNeverGreenTests.cs index 517810c42..37eafc01a 100644 --- a/tests/Aetheria.Shared.Tests/SunsNeverGreenTests.cs +++ b/tests/Aetheria.Shared.Tests/SunsNeverGreenTests.cs @@ -185,7 +185,7 @@ public sealed class SunsNeverGreenTests : IDisposable var galaxy = new Galaxy(tutorial, authored.Read("TutorialBackgroundSettings"), cache, new PlayerSettings(), Directory.CreateDirectory(Path.Combine(dir, "Narrative")), _ => { }, null, seed); - var items = new ItemManager(cache, new ProvenanceLedger(), authored.Read("GameplaySettings"), _ => { }); + var items = new ItemManager(1u, cache, new ProvenanceLedger(), authored.Read("GameplaySettings"), _ => { }); var zoneSettings = authored.Read("ZoneSettings"); adjust(zoneSettings); foreach (var zone in galaxy.Zones) diff --git a/tests/Aetheria.Shared.Tests/TargetingSystemTests.cs b/tests/Aetheria.Shared.Tests/TargetingSystemTests.cs index cd43cd789..4d78a55df 100644 --- a/tests/Aetheria.Shared.Tests/TargetingSystemTests.cs +++ b/tests/Aetheria.Shared.Tests/TargetingSystemTests.cs @@ -104,7 +104,7 @@ public sealed class TargetingSystemTests : IDisposable ledger.Lots[3] = new Lot { Origin = new Attributed(), Quality = 1 }; // wide ledger.Lots[4] = new Lot { Origin = new Attributed(), Quality = 1 }; // narrow ledger.Lots[5] = new Lot { Origin = new Attributed(), Quality = 1 }; // targeting (unused unless authored above) - var items = new ItemManager(_openCache, ledger, settings, _ => { }); + var items = new ItemManager(1u, _openCache, ledger, settings, _ => { }); var zone = new Zone(items, new PlanetSettings(), new ZonePack(), new GalaxyZone { Name = "Test Zone", Owner = null }, null); var hullRef = items.ItemData.RefOf(items.ItemData.GetByName("TestHull")); @@ -368,7 +368,7 @@ public sealed class TargetingSystemTests : IDisposable Ship BuildShip(float reactorCharge, out ItemManager items) { - items = new ItemManager(cache, new ProvenanceLedger(), TestSettings(), _ => { }); + items = new ItemManager(1u, cache, new ProvenanceLedger(), TestSettings(), _ => { }); var zone = new Zone(items, new PlanetSettings(), new ZonePack(), new GalaxyZone { Name = "Test Zone", Owner = null }, null); var hullData = cache.GetByName("Skiff"); var hull = Mint(hullData); @@ -439,7 +439,7 @@ public sealed class TargetingSystemTests : IDisposable for (var seed = 1; seed <= 10; seed++) { var random = new Random((uint) seed); - var items = new ItemManager(cache, new ProvenanceLedger(), TestSettings(), _ => { }); + var items = new ItemManager(1u, cache, new ProvenanceLedger(), TestSettings(), _ => { }); var generator = new LoadoutGenerator(ref random, items, null, null, null, .5f); var pack = generator.GenerateShipLoadout(candidate => candidate.Name == "Armed"); Assert.NotNull(pack); diff --git a/tests/Aetheria.Shared.Tests/ThrottleLockTests.cs b/tests/Aetheria.Shared.Tests/ThrottleLockTests.cs index ca0f76a97..33ceae4e0 100644 --- a/tests/Aetheria.Shared.Tests/ThrottleLockTests.cs +++ b/tests/Aetheria.Shared.Tests/ThrottleLockTests.cs @@ -84,7 +84,7 @@ public sealed class ThrottleLockTests : IDisposable cache.Upsert(lockData); cache.FlushAsync().Wait(); - var items = new ItemManager(cache, new ProvenanceLedger(), Settings(), _ => { }); + var items = new ItemManager(1u, cache, new ProvenanceLedger(), Settings(), _ => { }); var zone = new Zone(items, new PlanetSettings(), new ZonePack(), new GalaxyZone { Name = "Test Zone", Owner = null }, null); var ship = new Ship(items, zone, Mint(cache, items, cache.GetByName("Skiff")), new EntitySettings()); var gear = cache.GetByName("Thruster"); diff --git a/tests/Aetheria.Shared.Tests/VapourCloudTests.cs b/tests/Aetheria.Shared.Tests/VapourCloudTests.cs index e4d57a1cf..6945de2a8 100644 --- a/tests/Aetheria.Shared.Tests/VapourCloudTests.cs +++ b/tests/Aetheria.Shared.Tests/VapourCloudTests.cs @@ -104,7 +104,7 @@ public sealed class VapourCloudTests : IDisposable var ledger = new ProvenanceLedger(); for (var i = 1; i <= 50; i++) ledger.Lots[i] = new Lot { Origin = new Attributed(), Quality = 1 }; - var items = new ItemManager(cache, ledger, settings, _ => { }); + var items = new ItemManager(1u, cache, ledger, settings, _ => { }); var zone = new Zone(items, new PlanetSettings(), new ZonePack { Radius = 5000 }, new GalaxyZone { Name = "Vapour", Owner = null }, null); var lab = new Lab { Items = items, Zone = zone }; diff --git a/tests/Aetheria.Shared.Tests/ZonePlacedAtBuildTests.cs b/tests/Aetheria.Shared.Tests/ZonePlacedAtBuildTests.cs index dada889f0..cb3a8c6b4 100644 --- a/tests/Aetheria.Shared.Tests/ZonePlacedAtBuildTests.cs +++ b/tests/Aetheria.Shared.Tests/ZonePlacedAtBuildTests.cs @@ -71,7 +71,7 @@ public sealed class ZonePlacedAtBuildTests : IDisposable foreach (var cell in shape.AllCoordinates) shape[cell] = true; var hull = new HullData { Name = "Skiff", HullType = HullType.Ship, Shape = shape, Durability = 10, Mass = 1000 }; cache.Upsert(hull); - var items = new ItemManager(cache, new ProvenanceLedger(), GameSettings(), _ => { }); + var items = new ItemManager(1u, cache, new ProvenanceLedger(), GameSettings(), _ => { }); var rootOrbit = cache.Upsert(new OrbitData { FixedPosition = RootAt }).Key; var planetOrbit = cache.Upsert(new OrbitData diff --git a/tests/Aetheria.Shared.Tests/ZoneWorlds.cs b/tests/Aetheria.Shared.Tests/ZoneWorlds.cs index f2dd4ba72..0d6c807fa 100644 --- a/tests/Aetheria.Shared.Tests/ZoneWorlds.cs +++ b/tests/Aetheria.Shared.Tests/ZoneWorlds.cs @@ -80,6 +80,6 @@ internal sealed class ZoneWorlds : IDisposable Directory.CreateDirectory(dir); var cache = AetheriaStores.Open(Path.Combine(dir, "Aetheria.cc"), Path.Combine(dir, "run.cc"), catalogWritable: true); Caches.Add(cache); - return new World { Cache = cache, Items = new ItemManager(cache, new ProvenanceLedger(), GameSettings(), _ => { }) }; + return new World { Cache = cache, Items = new ItemManager(1u, cache, new ProvenanceLedger(), GameSettings(), _ => { }) }; } } diff --git a/tools/AetherDb/Program.cs b/tools/AetherDb/Program.cs index c69d120aa..3e8b37b85 100644 --- a/tools/AetherDb/Program.cs +++ b/tools/AetherDb/Program.cs @@ -807,7 +807,8 @@ public static class Program }; var log = new List(); - var itemManager = new ItemManager(db.Cache, new ProvenanceLedger(), settings, log.Add); + // A fixed seed: the catalog command's output must not depend on the clock. + var itemManager = new ItemManager(1u, db.Cache, new ProvenanceLedger(), settings, log.Add); var failures = 0; var targetingGaps = 0; From 9379712c784f854723a04434302cc67d0c6c1174 Mon Sep 17 00:00:00 2001 From: soul Date: Wed, 21 Oct 2026 19:01:43 +0200 Subject: [PATCH 003/307] determinism-ledger: Scenario.Id, a stable kebab identity for matches and replays Every scenario, and the test Scripted scenario, names an Id; Name is display only. Co-Authored-By: Claude Opus 5.5 --- Assets/Scripts/ServerShared/Scenario.cs | 4 ++++ Assets/Scripts/ServerShared/Scenarios/AiFusedDiscipline.cs | 1 + Assets/Scripts/ServerShared/Scenarios/Arcs.cs | 1 + Assets/Scripts/ServerShared/Scenarios/Belt.cs | 1 + Assets/Scripts/ServerShared/Scenarios/Crossing.cs | 1 + Assets/Scripts/ServerShared/Scenarios/DemoTerminus.cs | 1 + Assets/Scripts/ServerShared/Scenarios/DjinniShakedown.cs | 1 + Assets/Scripts/ServerShared/Scenarios/Duel.cs | 1 + Assets/Scripts/ServerShared/Scenarios/FusedRounds.cs | 1 + Assets/Scripts/ServerShared/Scenarios/LauncherAngles.cs | 1 + Assets/Scripts/ServerShared/Scenarios/LockOn.cs | 1 + Assets/Scripts/ServerShared/Scenarios/LongHaul.cs | 1 + Assets/Scripts/ServerShared/Scenarios/MainGalaxy.cs | 1 + Assets/Scripts/ServerShared/Scenarios/Pursuit.cs | 1 + Assets/Scripts/ServerShared/Scenarios/RefusedRounds.cs | 1 + Assets/Scripts/ServerShared/Scenarios/StarvedReactor.cs | 1 + Assets/Scripts/ServerShared/Scenarios/TutorialGalaxy.cs | 1 + Assets/Scripts/ServerShared/Scenarios/WeaponFeel.cs | 1 + tests/Aetheria.Shared.Tests/ScenarioTests.cs | 1 + 19 files changed, 22 insertions(+) diff --git a/Assets/Scripts/ServerShared/Scenario.cs b/Assets/Scripts/ServerShared/Scenario.cs index acd2f10b2..72b084866 100644 --- a/Assets/Scripts/ServerShared/Scenario.cs +++ b/Assets/Scripts/ServerShared/Scenario.cs @@ -6,6 +6,10 @@ public abstract class Scenario { public abstract string Name { get; } + // The scenario's stable identity: kebab-case, unique across Scenarios, never shown to the player, recorded by + // matches and replays. Name is display only. + public abstract string Id { get; } + // The conditions and what to verify in them, shown under the scenario's button. public abstract string Brief { get; } diff --git a/Assets/Scripts/ServerShared/Scenarios/AiFusedDiscipline.cs b/Assets/Scripts/ServerShared/Scenarios/AiFusedDiscipline.cs index df33f5640..beb4235da 100644 --- a/Assets/Scripts/ServerShared/Scenarios/AiFusedDiscipline.cs +++ b/Assets/Scripts/ServerShared/Scenarios/AiFusedDiscipline.cs @@ -4,6 +4,7 @@ using static ItemRotation; public sealed class AiFusedDiscipline : Scenario { public override string Name => "AI Fused Discipline"; + public override string Id => "ai-fused-discipline"; public override string Brief => "A Djinni; a piloted neutral Longinus carrying a Smoke Proximity and a piloted hostile Longinus whose only weapon is " + "Smoke Refused. Verify: the hostile closes on you and never fires its refused weapon; the neutral never fires its " + "fused one."; diff --git a/Assets/Scripts/ServerShared/Scenarios/Arcs.cs b/Assets/Scripts/ServerShared/Scenarios/Arcs.cs index b91fbc359..f0c3f5553 100644 --- a/Assets/Scripts/ServerShared/Scenarios/Arcs.cs +++ b/Assets/Scripts/ServerShared/Scenarios/Arcs.cs @@ -4,6 +4,7 @@ using static ItemRotation; public sealed class Arcs : Scenario { public override string Name => "Arcs"; + public override string Id => "arcs"; public override string Brief => "The Smoke Test Hull with forward mounts and a side mount on each flank; a bare hull off the bow, one off " + "the stern, one abeam to port, one abeam to starboard, and a hostile turret. Verify: the hull keeps its " + "heading while you aim at the stern hull, and the forward guns hold. Designate the bow hull and fire with the " + diff --git a/Assets/Scripts/ServerShared/Scenarios/Belt.cs b/Assets/Scripts/ServerShared/Scenarios/Belt.cs index 5ac8926c8..50c815f6d 100644 --- a/Assets/Scripts/ServerShared/Scenarios/Belt.cs +++ b/Assets/Scripts/ServerShared/Scenarios/Belt.cs @@ -5,6 +5,7 @@ using static ItemRotation; public sealed class Belt : Scenario { public override string Name => "Belt"; + public override string Id => "belt"; public override string Brief => "The biggest asteroid belt the generator made, with the Longinus parked 100 m outside its ring, facing along it, " + "and nothing else in the arena. Verify: next and previous target step through the rocks and the reticle sweeps " + "across the belt without a hitch; Previous with nothing targeted takes the farthest rock; the target indicator " + diff --git a/Assets/Scripts/ServerShared/Scenarios/Crossing.cs b/Assets/Scripts/ServerShared/Scenarios/Crossing.cs index 053de4f98..61529d6d9 100644 --- a/Assets/Scripts/ServerShared/Scenarios/Crossing.cs +++ b/Assets/Scripts/ServerShared/Scenarios/Crossing.cs @@ -4,6 +4,7 @@ using static ItemRotation; public sealed class Crossing : Scenario { public override string Name => "Crossing"; + public override string Id => "crossing"; public override string Brief => "A Lightning Projector at the starboard gun and a Cell Blaster at the port gun, an unpiloted Djinni coasting across the bow " + "close ahead and an unpiloted one waiting at range. The crosser is launched to port at 50 m/s and only coasts: nothing steers it, " + "its hull drag slows it to about 25 m/s at the bow line, and it does not shoot back. Make the pass coasting: give no throttle until the Djinni has crossed the bow, then fly freely " + diff --git a/Assets/Scripts/ServerShared/Scenarios/DemoTerminus.cs b/Assets/Scripts/ServerShared/Scenarios/DemoTerminus.cs index 8cda7c600..f4dce7be7 100644 --- a/Assets/Scripts/ServerShared/Scenarios/DemoTerminus.cs +++ b/Assets/Scripts/ServerShared/Scenarios/DemoTerminus.cs @@ -17,6 +17,7 @@ public sealed class DemoTerminus : Scenario }; public override string Name => "Terminus (Demo)"; + public override string Id => "demo-terminus"; public override string Brief => "One gate, one region, one boss. Allied: the Pirates. Opposed: Zhestokost. " + "Neutral: Lucent Media and Aeronautics Unlimited."; diff --git a/Assets/Scripts/ServerShared/Scenarios/DjinniShakedown.cs b/Assets/Scripts/ServerShared/Scenarios/DjinniShakedown.cs index 7cbec5630..43d23d0b3 100644 --- a/Assets/Scripts/ServerShared/Scenarios/DjinniShakedown.cs +++ b/Assets/Scripts/ServerShared/Scenarios/DjinniShakedown.cs @@ -3,6 +3,7 @@ using float2 = CultMath.float2; public sealed class DjinniShakedown : Scenario { public override string Name => "Djinni Shakedown"; + public override string Id => "djinni-shakedown"; public override string Brief => "A fitted Djinni in empty space, facing +z, with a bare hull dead ahead and one abeam. Verify: its thrusters " + "move and turn it; with the hull offline and thrusters firing, its speed does not run away. Its " + "articulated ballistic barrel points at the hull ahead when it bears, and rests at the arc edge while you " + diff --git a/Assets/Scripts/ServerShared/Scenarios/Duel.cs b/Assets/Scripts/ServerShared/Scenarios/Duel.cs index 62dff34ae..67bca118d 100644 --- a/Assets/Scripts/ServerShared/Scenarios/Duel.cs +++ b/Assets/Scripts/ServerShared/Scenarios/Duel.cs @@ -4,6 +4,7 @@ using static ItemRotation; public sealed class Duel : Scenario { public override string Name => "Duel"; + public override string Id => "duel"; public override string Brief => "One piloted, hostile Longinus with slow guided missiles and a Light Laser in its hold. Verify: rolled " + "hits and near-misses, subsystem aim, beam reads above bow, armour takes the missile, loot keeps its brand."; public override uint Seed => 104; diff --git a/Assets/Scripts/ServerShared/Scenarios/FusedRounds.cs b/Assets/Scripts/ServerShared/Scenarios/FusedRounds.cs index b2d70c98e..7498465c3 100644 --- a/Assets/Scripts/ServerShared/Scenarios/FusedRounds.cs +++ b/Assets/Scripts/ServerShared/Scenarios/FusedRounds.cs @@ -4,6 +4,7 @@ using static ItemRotation; public sealed class FusedRounds : Scenario { public override string Name => "Fused Rounds"; + public override string Id => "fused-rounds"; public override string Brief => "The Smoke Test Hull with Smoke Proximity, Smoke Delayed and a Cluster Cannon; a hostile bare Longinus at mid range, a bare " + "hull dead ahead, a fragile one to starboard, a close one inside the proximity blast radius, and one astern. " + "Verify with nothing selected: aim past every hull and the round bursts at max range along the aim; aim at the hull " + diff --git a/Assets/Scripts/ServerShared/Scenarios/LauncherAngles.cs b/Assets/Scripts/ServerShared/Scenarios/LauncherAngles.cs index 62258b9cb..61a798b1c 100644 --- a/Assets/Scripts/ServerShared/Scenarios/LauncherAngles.cs +++ b/Assets/Scripts/ServerShared/Scenarios/LauncherAngles.cs @@ -4,6 +4,7 @@ using static ItemRotation; public sealed class LauncherAngles : Scenario { public override string Name => "Launcher Angles"; + public override string Id => "launcher-angles"; public override string Brief => "A Guided Missile Launcher, a Swarm Rocket Pod and two Cell Blasters; a hostile bare Longinus broadside on, another beside it. " + "Verify: hits pulse only the facing edge; a Swarm Rocket Pod round whose target dies fades unexploded."; public override uint Seed => 105; diff --git a/Assets/Scripts/ServerShared/Scenarios/LockOn.cs b/Assets/Scripts/ServerShared/Scenarios/LockOn.cs index 171b34688..4f8c4dfa9 100644 --- a/Assets/Scripts/ServerShared/Scenarios/LockOn.cs +++ b/Assets/Scripts/ServerShared/Scenarios/LockOn.cs @@ -4,6 +4,7 @@ using static ItemRotation; public sealed class LockOn : Scenario { public override string Name => "Lock On"; + public override string Id => "lock-on"; public override string Brief => "A hostile closes from out of sight, finds you and locks. Verify: no warning at first; once it locks, one " + "indicator appears toward it and tightens as the lock grows; break line of sight or range and it vanishes; " + "your own ping shows no incoming paint on its side, only the lock does."; diff --git a/Assets/Scripts/ServerShared/Scenarios/LongHaul.cs b/Assets/Scripts/ServerShared/Scenarios/LongHaul.cs index bb5381b10..18e158247 100644 --- a/Assets/Scripts/ServerShared/Scenarios/LongHaul.cs +++ b/Assets/Scripts/ServerShared/Scenarios/LongHaul.cs @@ -3,6 +3,7 @@ using float2 = CultMath.float2; public sealed class LongHaul : Scenario { public override string Name => "Long Haul"; + public override string Id => "long-haul"; public override string Brief => "A standard ship in a populated galaxy. Verify: memory stays flat across zones with kills; " + "warp, quit and Continue keep items, tier and brand; death disables Continue."; public override uint Seed => 106; diff --git a/Assets/Scripts/ServerShared/Scenarios/MainGalaxy.cs b/Assets/Scripts/ServerShared/Scenarios/MainGalaxy.cs index f32e205d3..3e4482d3c 100644 --- a/Assets/Scripts/ServerShared/Scenarios/MainGalaxy.cs +++ b/Assets/Scripts/ServerShared/Scenarios/MainGalaxy.cs @@ -4,6 +4,7 @@ using float2 = CultMath.float2; public sealed class MainGalaxy : Scenario { public override string Name => "Main Galaxy"; + public override string Id => "main-galaxy"; public override string Brief => "The full galaxy: factions drawn at random, bosses guarding the road to the exit."; public override Galaxy Generate(GalaxyStage stage) => stage.Main(); diff --git a/Assets/Scripts/ServerShared/Scenarios/Pursuit.cs b/Assets/Scripts/ServerShared/Scenarios/Pursuit.cs index bd067b96d..d6d630a88 100644 --- a/Assets/Scripts/ServerShared/Scenarios/Pursuit.cs +++ b/Assets/Scripts/ServerShared/Scenarios/Pursuit.cs @@ -9,6 +9,7 @@ public sealed class Pursuit : Scenario private const float Astern = 300; public override string Name => "Pursuit"; + public override string Id => "pursuit"; public override string Brief => "A hostile Longinus on your tail; a Mine Launcher fitted, three Coolant Vents and two Thruster " + "Overdrives aboard. Verify: lay mines in its path, the mine pulses slowly after 2 s, fast when " + "the hostile is near, explodes 2 s later; vent and coast to lose it; overdrive away."; diff --git a/Assets/Scripts/ServerShared/Scenarios/RefusedRounds.cs b/Assets/Scripts/ServerShared/Scenarios/RefusedRounds.cs index 21208adf1..88cdd3fdb 100644 --- a/Assets/Scripts/ServerShared/Scenarios/RefusedRounds.cs +++ b/Assets/Scripts/ServerShared/Scenarios/RefusedRounds.cs @@ -4,6 +4,7 @@ using static ItemRotation; public sealed class RefusedRounds : Scenario { public override string Name => "Refused Rounds"; + public override string Id => "refused-rounds"; public override string Brief => "The Smoke Test Hull with Smoke Refused, Smoke Refused Beam, Smoke Refused Charge and an Autocannon; a bare Longinus " + "dead ahead. Each smoke weapon's blast radius is wider than its range, so it can never be fired. Verify: pull each " + "trigger and a refused shot costs no ammo, energy, heat, sound, visibility or cooldown, the beam never starts and " + diff --git a/Assets/Scripts/ServerShared/Scenarios/StarvedReactor.cs b/Assets/Scripts/ServerShared/Scenarios/StarvedReactor.cs index b04f260fc..2f3dff97b 100644 --- a/Assets/Scripts/ServerShared/Scenarios/StarvedReactor.cs +++ b/Assets/Scripts/ServerShared/Scenarios/StarvedReactor.cs @@ -4,6 +4,7 @@ using static ItemRotation; public sealed class StarvedReactor : Scenario { public override string Name => "Starved Reactor"; + public override string Id => "starved-reactor"; public override string Brief => "Two Light Lasers on a Muon Fusion Reactor, which can fall to a quarter of the Compact Reactor's charge, and a bare hull " + "to shoot. Verify: starvation reads as starvation, brownout degrades, refills stutter."; public override uint Seed => 102; diff --git a/Assets/Scripts/ServerShared/Scenarios/TutorialGalaxy.cs b/Assets/Scripts/ServerShared/Scenarios/TutorialGalaxy.cs index 161a52348..1ff521865 100644 --- a/Assets/Scripts/ServerShared/Scenarios/TutorialGalaxy.cs +++ b/Assets/Scripts/ServerShared/Scenarios/TutorialGalaxy.cs @@ -4,6 +4,7 @@ using float2 = CultMath.float2; public sealed class TutorialGalaxy : Scenario { public override string Name => "Tutorial Galaxy"; + public override string Id => "tutorial-galaxy"; public override string Brief => "A small galaxy of fixed factions, entered in a ship of the protagonist faction."; public override Galaxy Generate(GalaxyStage stage) => stage.Prelude(); diff --git a/Assets/Scripts/ServerShared/Scenarios/WeaponFeel.cs b/Assets/Scripts/ServerShared/Scenarios/WeaponFeel.cs index 329d0671e..0817cfa80 100644 --- a/Assets/Scripts/ServerShared/Scenarios/WeaponFeel.cs +++ b/Assets/Scripts/ServerShared/Scenarios/WeaponFeel.cs @@ -4,6 +4,7 @@ using static ItemRotation; public sealed class WeaponFeel : Scenario { public override string Name => "Weapon Feel"; + public override string Id => "weapon-feel"; public override string Brief => "The Smoke Test Hull with a Charged Scatter Blaster, an Autocannon and a Cell Blaster burst weapon; a bare Longinus dead ahead. " + "Verify: Charged Scatter Blaster and Autocannon fire with effects, particles and action-bar icons; the burst weapon's " + "cadence is as before; a fast release-and-press on the Charged Scatter Blaster starts no second charge."; diff --git a/tests/Aetheria.Shared.Tests/ScenarioTests.cs b/tests/Aetheria.Shared.Tests/ScenarioTests.cs index 6be69b91a..7303931f0 100644 --- a/tests/Aetheria.Shared.Tests/ScenarioTests.cs +++ b/tests/Aetheria.Shared.Tests/ScenarioTests.cs @@ -37,6 +37,7 @@ public sealed partial class RunStartTests } public override string Name => "Scripted"; + public override string Id => "test-scripted"; public override string Brief => "A test's own scenario."; public override uint Seed => _seed; public override bool Ambient => _ambient; From cc5d498b7ea2b88a46407fe46781238a15b7a817 Mon Sep 17 00:00:00 2001 From: soul Date: Wed, 21 Oct 2026 19:04:51 +0200 Subject: [PATCH 004/307] determinism-ledger: tests for the seeds, the scatter, Scenario.Id and the two-AI fight DeterminismLedgerTests (a RunStartTests partial): ItemManager refuses seed 0; ItemSeed is the run seed and step; the unpack scatter ignores the item stream and differs per zone; the SimulationDice streams are distinct; scenario Ids are unique kebab; a two-AI fight is one history, and its digest is pinned for linux-x64 (pin lands in the next commit). Co-Authored-By: Claude Opus 5.5 --- .../DeterminismLedgerTests.cs | 197 ++++++++++++++++++ 1 file changed, 197 insertions(+) create mode 100644 tests/Aetheria.Shared.Tests/DeterminismLedgerTests.cs diff --git a/tests/Aetheria.Shared.Tests/DeterminismLedgerTests.cs b/tests/Aetheria.Shared.Tests/DeterminismLedgerTests.cs new file mode 100644 index 000000000..768774727 --- /dev/null +++ b/tests/Aetheria.Shared.Tests/DeterminismLedgerTests.cs @@ -0,0 +1,197 @@ +/* This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at https://mozilla.org/MPL/2.0/. */ + +using System; +using System.Collections.Generic; +using System.Linq; +using System.Runtime.InteropServices; +using System.Security.Cryptography; +using System.Text; +using System.Text.RegularExpressions; +using CultMath; +using UniRx; +using Xunit; +using static CultMath.math; +using float2 = CultMath.float2; + +// The determinism ledger (aetheria-arena, cut determinism-ledger): every seed a match reaches is stated, never the +// clock's. The item stream is seeded by whoever builds the ItemManager and by RunStart at each step of a run; the unpack +// scatter rides the zone's own dice (ruling run-seed-drops-only); a scenario has a stable Id; and one fight between two +// piloted ships is one history, pinned for the host class that records replays. +public sealed partial class RunStartTests +{ + // The two-AI fight's digest, captured on Yggdrasil. Replays are trusted only on the host class that recorded them + // (ruling replays-per-host-class), and arenas run on Linux. + private const string TwoAiPinnedHostClass = "linux-x64"; + private const string TwoAiPinnedDigest = "UNPINNED"; + private const int TwoAiSteps = 3600; + + [Fact] + public void ItemManagerRefusesSeedZero() + { + var refused = Assert.Throws(() => + new ItemManager(0u, _cache, new ProvenanceLedger(), _items.GameplaySettings, _ => { })); + Assert.Equal("seed", refused.ParamName); + + var seeded = new ItemManager(0xBEEFu, _cache, new ProvenanceLedger(), _items.GameplaySettings, _ => { }); + Assert.Equal(new CultMath.Random(0xBEEFu).state, seeded.Random.state); + } + + [Fact] + public void ItemSeedIsTheRunSeedAndStep() + { + var galaxy = Launch(new Scripted(false, stage => stage.Player(stage.Bare("Djinni"), float2(0, 0)), seed: 4242)).galaxy; + Assert.Equal(4242u, galaxy.Seed); + Assert.Equal(4242u * 0x9E3779B1u + 1, RunStart.ItemSeed(galaxy, RunStart.ArenaStep)); + Assert.Equal(4242u * 0x9E3779B1u + 2, RunStart.ItemSeed(galaxy, RunStart.StageStep)); + var arena = RunStart.ItemSeed(galaxy, RunStart.ArenaStep); + var stage = RunStart.ItemSeed(galaxy, RunStart.StageStep); + var resumed = RunStart.ItemSeed(galaxy, RunStart.ContinueStep); + Assert.NotEqual(arena, resumed); + Assert.NotEqual(stage, resumed); + Assert.NotEqual(0u, RunStart.NoRunSeed); + + // The stream a new run's arena generates on is the one ItemSeed names. + var items = new ItemManager(7u, _cache, new ProvenanceLedger(), _items.GameplaySettings, _ => { }); + RunStart.GenerateArena(items, _zoneSettings, _planetSettings, galaxy, new Scripted(false, _ => { }, seed: 4242)); + var expected = new ItemManager(arena, _cache, new ProvenanceLedger(), _items.GameplaySettings, _ => { }); + RunStart.GenerateArena(expected, _zoneSettings, _planetSettings, galaxy, new Scripted(false, _ => { }, seed: 4242)); + Assert.Equal(expected.Random.state, items.Random.state); + } + + // A pack holding one unpiloted ship at the origin besides the player, from a staged arena. + private (ZonePack pack, Galaxy galaxy) OriginPack() + { + Ship drifter = null; + var (galaxy, arena, staged, failures) = Launch(new Scripted(false, stage => + { + stage.Player(stage.Bare("Djinni"), float2(-50000, -50000)); + drifter = stage.Place(stage.Bare("Longinus"), float2(0, 0)) as Ship; + })); + Assert.True(failures.Count == 0, string.Join("; ", failures)); + Assert.NotNull(drifter); + Assert.False(drifter.IsPlayerShip); + Assert.True(lengthsq(drifter.Position) < 1, "the fixture's drifter is packed at the origin"); + return (arena.PackZone(), galaxy); + } + + private List Scattered(ZonePack pack, GalaxyZone galaxyZone, Galaxy galaxy) + { + var zone = new Zone(_items, _planetSettings, pack, galaxyZone, galaxy); + return zone.Entities.OfType().Where(ship => !ship.IsPlayerShip).Select(ship => ship.Position).ToList(); + } + + [Fact] + public void ScatterIgnoresTheItemStream() + { + var (pack, galaxy) = OriginPack(); + _items.Random = new CultMath.Random(11); + var first = Scattered(pack, galaxy.Entrance, galaxy); + _items.Random = new CultMath.Random(12); + for (var i = 0; i < 37; i++) _items.Random.NextUInt(); + var second = Scattered(pack, galaxy.Entrance, galaxy); + + Assert.NotEmpty(first); + Assert.All(first, position => Assert.True(lengthsq(position) >= 1, "the drifter was scattered")); + Assert.Equal(first, second); + } + + [Fact] + public void ScatterIsPerZone() + { + var (pack, galaxy) = OriginPack(); + var other = galaxy.Zones.First(zone => zone != galaxy.Entrance && zone.Name != galaxy.Entrance.Name); + var here = Scattered(pack, galaxy.Entrance, galaxy); + var there = Scattered(pack, other, galaxy); + Assert.NotEmpty(here); + Assert.Equal(here.Count, there.Count); + Assert.NotEqual(here, there); + } + + [Fact] + public void SimulationDiceStreamsAreDistinct() + { + var streams = new[] + { + SimulationDice.LootStream, SimulationDice.MineStream, SimulationDice.CombatSampleStream, + SimulationDice.PatrolStream, SimulationDice.ScatterStream + }; + Assert.Equal(streams.Length, streams.Distinct().Count()); + } + + [Fact] + public void ScenarioIdsAreUniqueKebabAndStable() + { + var kebab = new Regex("^[a-z0-9]+(-[a-z0-9]+)*$"); + var every = EveryScenario.ToList(); + Assert.All(every, scenario => Assert.True(scenario.Id != null && kebab.IsMatch(scenario.Id), + $"{scenario.GetType().Name}: Id is not lowercase kebab")); + Assert.Equal(every.Count, every.Select(scenario => scenario.Id).Distinct().Count()); + Assert.Equal("duel", new Duel().Id); + Assert.Equal("demo-terminus", new DemoTerminus().Id); + } + + // Two piloted fighters about 900 apart, each approaching the other, hostile both ways; the player bare, unpiloted + // and far from the fight. Steps 3600 times at 1/60 s and returns the SHA-256 of the joined snapshots. + private (string digest, bool bothPiloted, int hits, bool hullLost) TwoAiFight() + { + Ship east = null, west = null; + var (_, arena, _, failures) = Launch(new Scripted(false, stage => + { + stage.Player(stage.Bare("Djinni"), float2(-50000, -50000)); + east = stage.Place(EvLonginus(stage), float2(450, 0), facing: float2(-1, 0), piloted: true) as Ship; + west = stage.Place(EvLonginus(stage), float2(-450, 0), facing: float2(1, 0), piloted: true) as Ship; + stage.Approach(east, west); + stage.Approach(west, east); + })); + Assert.True(failures.Count == 0, string.Join("; ", failures)); + east.SetIff(west, true); + west.SetIff(east, true); + var hullAtStart = east.Hull.Durability + west.Hull.Durability; + var bothPiloted = arena.Agents.Any(agent => agent.Ship == east) && arena.Agents.Any(agent => agent.Ship == west); + + var shots = new List(); + var hits = 0; + using var _ = arena.ShotResolved.Subscribe(outcome => + { + shots.Add($"{outcome.ShotId}:{outcome.Result}"); + if (outcome.Hit) hits++; + }); + var history = new StringBuilder(); + for (var step = 0; step < TwoAiSteps; step++) + { + arena.Update(1f / 60f); + history.Append(Snapshot(arena, shots)).Append('\n'); + } + var digest = Convert.ToHexString(SHA256.HashData(Encoding.UTF8.GetBytes(history.ToString()))).ToLowerInvariant(); + return (digest, bothPiloted, hits, east.Hull.Durability + west.Hull.Durability < hullAtStart); + } + + [Fact] + public void TwoAiFightIsOneHistory() + { + var first = TwoAiFight(); + var second = TwoAiFight(); + Assert.True(first.bothPiloted, "both fighters are piloted"); + Assert.True(first.hits > 0, "at least one shot resolved as a hit"); + Assert.True(first.hullLost, "at least one fighter lost hull"); + Assert.Equal(first.digest, second.digest); + } + + private static string HostClass() => + (RuntimeInformation.IsOSPlatform(OSPlatform.Linux) ? "linux" + : RuntimeInformation.IsOSPlatform(OSPlatform.Windows) ? "windows" + : RuntimeInformation.IsOSPlatform(OSPlatform.OSX) ? "osx" : "other") + + "-" + RuntimeInformation.ProcessArchitecture.ToString().ToLowerInvariant(); + + [Fact] + public void TwoAiDigestIsPinned() + { + var host = HostClass(); + var digest = TwoAiFight().digest; + Assert.True(host == TwoAiPinnedHostClass, + $"TWO-AI-DIGEST host class {host} has no pin (pinned: {TwoAiPinnedHostClass}); measured digest {digest}"); + Assert.True(digest == TwoAiPinnedDigest, $"TWO-AI-DIGEST {host} measured {digest}, pinned {TwoAiPinnedDigest}"); + } +} From ced96568bccd0ab906ad0809b940a229cc37b823 Mon Sep 17 00:00:00 2001 From: soul Date: Wed, 21 Oct 2026 19:06:49 +0200 Subject: [PATCH 005/307] determinism-ledger: the ItemSeed test's expected values wrap as the formula does Co-Authored-By: Claude Opus 5.5 --- tests/Aetheria.Shared.Tests/DeterminismLedgerTests.cs | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/tests/Aetheria.Shared.Tests/DeterminismLedgerTests.cs b/tests/Aetheria.Shared.Tests/DeterminismLedgerTests.cs index 768774727..bc81c2451 100644 --- a/tests/Aetheria.Shared.Tests/DeterminismLedgerTests.cs +++ b/tests/Aetheria.Shared.Tests/DeterminismLedgerTests.cs @@ -43,8 +43,8 @@ public sealed partial class RunStartTests { var galaxy = Launch(new Scripted(false, stage => stage.Player(stage.Bare("Djinni"), float2(0, 0)), seed: 4242)).galaxy; Assert.Equal(4242u, galaxy.Seed); - Assert.Equal(4242u * 0x9E3779B1u + 1, RunStart.ItemSeed(galaxy, RunStart.ArenaStep)); - Assert.Equal(4242u * 0x9E3779B1u + 2, RunStart.ItemSeed(galaxy, RunStart.StageStep)); + Assert.Equal(unchecked(4242u * 0x9E3779B1u + 1), RunStart.ItemSeed(galaxy, RunStart.ArenaStep)); + Assert.Equal(unchecked(4242u * 0x9E3779B1u + 2), RunStart.ItemSeed(galaxy, RunStart.StageStep)); var arena = RunStart.ItemSeed(galaxy, RunStart.ArenaStep); var stage = RunStart.ItemSeed(galaxy, RunStart.StageStep); var resumed = RunStart.ItemSeed(galaxy, RunStart.ContinueStep); From 0524d3729dad5e9b3f109d918877967ad828c4e3 Mon Sep 17 00:00:00 2001 From: soul Date: Wed, 21 Oct 2026 19:09:20 +0200 Subject: [PATCH 006/307] determinism-ledger: pin the two-AI digest captured on Yggdrasil (linux-x64) Co-Authored-By: Claude Opus 5.5 --- tests/Aetheria.Shared.Tests/DeterminismLedgerTests.cs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/tests/Aetheria.Shared.Tests/DeterminismLedgerTests.cs b/tests/Aetheria.Shared.Tests/DeterminismLedgerTests.cs index bc81c2451..0a5636720 100644 --- a/tests/Aetheria.Shared.Tests/DeterminismLedgerTests.cs +++ b/tests/Aetheria.Shared.Tests/DeterminismLedgerTests.cs @@ -24,7 +24,7 @@ public sealed partial class RunStartTests // The two-AI fight's digest, captured on Yggdrasil. Replays are trusted only on the host class that recorded them // (ruling replays-per-host-class), and arenas run on Linux. private const string TwoAiPinnedHostClass = "linux-x64"; - private const string TwoAiPinnedDigest = "UNPINNED"; + private const string TwoAiPinnedDigest = "8ba2116ed0ecd574703846f493c408936d536e3b7c9e4652556103ec1b75098c"; private const int TwoAiSteps = 3600; [Fact] From f28d4175733514b859cbc07edb035ed6f81551e0 Mon Sep 17 00:00:00 2001 From: soul Date: Wed, 21 Oct 2026 20:10:54 +0200 Subject: [PATCH 007/307] aetheria-arena target: sim-rate cut before the runner (ruling lower-game-sim-rate) Co-Authored-By: Claude Opus 5.5 --- docs/aetheria-arena-target.md | 7 +++++-- 1 file changed, 5 insertions(+), 2 deletions(-) diff --git a/docs/aetheria-arena-target.md b/docs/aetheria-arena-target.md index d6f344501..36df02144 100644 --- a/docs/aetheria-arena-target.md +++ b/docs/aetheria-arena-target.md @@ -171,8 +171,11 @@ No cell is empty. The Scenario row exposes a gap: `Name` is today's only identit `observation-fidelity` rules for it. 4. **Replay.** The recorder writes inputs, commands and digests every N ticks as CultCache. The replayer reproduces them and names the first divergent tick. Replay tests read an existing match. -5. **Runner.** `AetherDb arena`: a scenario, two brain ids and a seed range produce results and replays in a given +5. **Sim rate** (ruling `lower-game-sim-rate`). Lower the game's own `SimClock` rate for game and arena alike. The rate is + set by the shortest catalog timer staying several steps long, an equivalence test of matchups at 60 Hz against the + candidate, and the operator's playtest of feel. Brains may decide at their own rate below the physics step. +6. **Runner.** `AetherDb arena`: a scenario, two brain ids and a seed range produce results and replays in a given directory. It runs in Yggdrasil's dotnet container, with two-ship throughput measured and recorded. -6. **First tournament.** Round-robin over two or more brains (`DoctrineBrain` plus one variant) and the combat +7. **First tournament.** Round-robin over two or more brains (`DoctrineBrain` plus one variant) and the combat scenarios, with enough seeds per pairing. Ratings are derived with confidence intervals, and the results document is ready to feed `aetheria-matchups`. From ee78b85fd01dc0501a8b049de5007c1ddaa194dd Mon Sep 17 00:00:00 2001 From: soul Date: Wed, 21 Oct 2026 20:26:02 +0200 Subject: [PATCH 008/307] hull_source: delete the nose-ward muzzle rule; gun facing is HullData's The Blender scene cannot know which way a hardpoint points: HullData's hardpoint rotation owns that (ruling aetheria-release:ruling:gun-facing-is-hulldata). Deletes the forward list and its term in the muzzle check, the two facing tests, and the smoke's nose-ward require. Pairing stays whole. Co-Authored-By: Claude Opus 5.5 --- tools/blender/aetheria_ships/hull_source.py | 8 +++----- tools/blender/tests/smoke_hull_source.py | 1 - tools/blender/tests/test_hull_source.py | 17 ++--------------- 3 files changed, 5 insertions(+), 21 deletions(-) diff --git a/tools/blender/aetheria_ships/hull_source.py b/tools/blender/aetheria_ships/hull_source.py index e513a7454..50a932e0d 100644 --- a/tools/blender/aetheria_ships/hull_source.py +++ b/tools/blender/aetheria_ships/hull_source.py @@ -344,13 +344,11 @@ def _checks(hull, own, judge): by_id = {a.id: a for a in hull.anchors} unmuzzled = [a.id for a in hull.anchors if a.role == "weapon-mount" and not any(m.role == "weapon-muzzle" and m.parent == a.id for m in hull.anchors)] - forward = [m.id for m in hull.anchors if m.role == "weapon-muzzle" and m.parent in by_id - and by_id[m.parent].role == "weapon-mount" and m.position[1] >= by_id[m.parent].position[1]] orphans = [m.id for m in hull.anchors if m.role == "weapon-muzzle" and (m.parent not in by_id or by_id[m.parent].role != "weapon-mount")] - checks.append(Check("every weapon-mount has a muzzle nose-ward of it and no muzzle is orphaned", - not (unmuzzled or forward or orphans), - f"{len(unmuzzled)} without a muzzle, {len(forward)} not nose-ward, {len(orphans)} orphaned")) + checks.append(Check("every weapon-mount has a muzzle and no muzzle is orphaned", + not (unmuzzled or orphans), + f"{len(unmuzzled)} without a muzzle, {len(orphans)} orphaned")) return checks diff --git a/tools/blender/tests/smoke_hull_source.py b/tools/blender/tests/smoke_hull_source.py index 41ef836a6..c544339ad 100644 --- a/tools/blender/tests/smoke_hull_source.py +++ b/tools/blender/tests/smoke_hull_source.py @@ -215,7 +215,6 @@ def one_hull(scene, number, fixture, ship_id, extent, length, rows, body, palett for muzzle in roles["weapon-muzzle"]: mount = first[muzzle].parent require(mount.get("aetheria.role") == "weapon-mount" and first[muzzle].get("aetheria.order") == 1, "A muzzle is not ordered under its mount") - require(first[muzzle].matrix_world.translation.y < mount.matrix_world.translation.y, "A muzzle is not nose-ward of its mount") require(layout_rows(scene) == rows_before, "Build changed the layout buffer") check_poses(collection, root, runner) if fixture == "zh-ammo-tender": diff --git a/tools/blender/tests/test_hull_source.py b/tools/blender/tests/test_hull_source.py index c1045dba0..2a1f737f4 100644 --- a/tools/blender/tests/test_hull_source.py +++ b/tools/blender/tests/test_hull_source.py @@ -57,7 +57,7 @@ class Fixture: def __init__(self): self.library = {"zhestokost": language()} self.hull_lang = None - # Body 6 wide, 10 long, 2 high (h/w 0.33, w/l 0.6); a bilateral plate on top; one gun with a muzzle nose-ward. + # Body 6 wide, 10 long, 2 high (h/w 0.33, w/l 0.6); a bilateral plate on top; one gun with a muzzle. self.parts = { "brief": ("", brief()), "primary": (box(-3, 3, -5, 5, 0, 2), ""), @@ -221,20 +221,7 @@ class JudgementTests(unittest.TestCase): fixture.parts["brief"] = ("", brief(length=length)) self.assertEqual(fixture.failed(), ["the length lies inside the brief's range"], length) - MUZZLE = "every weapon-mount has a muzzle nose-ward of it and no muzzle is orphaned" - - def test_a_muzzle_aft_of_its_mount_is_refused(self): - fixture = Fixture() - fixture.parts["anchors"] = ("", echo("anchor", ["Gun.A", "weapon-mount", [0, 0, 3], 0, ""]) - + echo("anchor", ["Gun.A.muzzle", "weapon-muzzle", [0, 1, 3], 0, "Gun.A"]) - + echo("anchor", ["Th.A", "thruster-emitter", [1, 5, 1], 180, ""])) - self.assertEqual(fixture.failed(), [self.MUZZLE]) - - def test_a_muzzle_level_with_its_mount_is_not_nose_ward(self): - fixture = Fixture() - fixture.parts["anchors"] = ("", echo("anchor", ["Gun.A", "weapon-mount", [0, 0, 3], 0, ""]) - + echo("anchor", ["Gun.A.muzzle", "weapon-muzzle", [1, 0, 3], 0, "Gun.A"])) - self.assertEqual(fixture.failed(), [self.MUZZLE]) + MUZZLE = "every weapon-mount has a muzzle and no muzzle is orphaned" def test_a_mount_without_a_muzzle_and_a_muzzle_without_a_mount_are_refused(self): fixture = Fixture() From 6f12283dc01d46a9dfbcfe7e8dab11f56a23e71e Mon Sep 17 00:00:00 2001 From: soul Date: Wed, 21 Oct 2026 20:26:15 +0200 Subject: [PATCH 009/307] gravity map: laned-belts mapped and admitted; lane host and half-width cells filled Co-Authored-By: Claude Opus 5.5 --- docs/aetheria-gravity-map.md | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/docs/aetheria-gravity-map.md b/docs/aetheria-gravity-map.md index d87657cbd..b95ddec91 100644 --- a/docs/aetheria-gravity-map.md +++ b/docs/aetheria-gravity-map.md @@ -131,11 +131,11 @@ Cells not filled, plainly: whether a per-body multiplier on c or the core is eve | **Rock** (`Asteroid`) | (belt key, index) is the `ChunkId`. The index order is fixed at generation and never re-sorted, because wear (`ChunkWear`) is keyed by it. | Persisted (Distance, Phase, Size, RotationSpeed) about the belt's parent; wear and respawn live on Zone. | The generator draws a rock. Zone owns its wear and its pose (`AsteroidBelt.Pose`). | | **Period rule** | A property of the belt, not of the rock: the persisted flag `AsteroidBeltData.RidesRail` (MessagePack key 11, JSON `ridesRail`). False: each rock reads its own distance (`PeriodAround(parent, rock.Distance)`), an annulus. True: every rock takes its rail's period (`Orbits[rail].Period`), so a Trojan swarm keeps its shape (ruling `trojans-ride-host-period-with-stations`; on own-distance rails it shears within an hour, B60). | Persisted at generation. A belt saved before key 11 reads false, the annulus it was (CultCache defaults a missing slot, B61). | Zone's constructor alone writes `AsteroidBelt.Periods`, through PeriodAround or the rail's own Period; the flag only says which. Forbidden: a period formula in Pose, BeltTargets, DrawAhead, the renderer or the generator, and a second flag or shape enum read anywhere else. | | **Extent rule** | What a belt's rail adds to its parent's L_F: `AsteroidBelt.Extent(railDistance)`, the outermost rock for an annulus, the rail's own Distance for a swarm, whose rocks' periods never read their own distance. So a swarm moves no well (B61). An edge (Oort-like) belt must add nothing beyond the window, or the family's fade band narrows (B60). | Derived at zone build. | `AsteroidBelt.Extent`, read by Zone (the only WellNode builder in play) and by the station tests' rebuilt tree. The generator's clearance tree never sees a swarm, which is not a GeneratorPlanet. | -| **Belt host** | A generator-time relation only. A Trojan host is a planet or gas giant (PlanetMass < mass <= SunMass) whose parent is a body, at a distance > 0. A ring member is never a host: its rail's pull adds its partners' (WellTree.PullAround), so a rail copied beside it would take another period and shear (B61). The swarm's rail copies the host's (Parent, Distance, Phase); that copy is all Zone needs, and nothing persists the host. A lane's host waits on laned-belts. | Lives for one GenerateZone call. | ZoneGenerator.GenerateZone's swarm pass. | +| **Belt host** | A generator-time relation only. A Trojan host is a planet or gas giant (PlanetMass < mass <= SunMass) whose parent is a body, at a distance > 0. A ring member is never a host: its rail's pull adds its partners' (WellTree.PullAround), so a rail copied beside it would take another period and shear (B61). The swarm's rail copies the host's (Parent, Distance, Phase); that copy is all Zone needs, and nothing persists the host. A lane's host is a body that keeps its slot with room on both sides of the lane (cut laned-belts: half the eligible bodies, never a Trojan host, one shape per parent). | Lives for one GenerateZone call. | ZoneGenerator.GenerateZone's swarm pass. | | **Lagrange station orbit** | A bodyless OrbitData on a host's rail, 1/6 turn ahead of or behind it (CreateLagrangeOrbit). On a swarm host it sits inside the swarm, a mining outpost among the rocks (ruling `trojans-ride-host-period-with-stations`); rocks are on the floater layer below it, so nothing collides. | Created at generation and persisted. | ZoneGenerator's station placement, unchanged: candidacy reads the GeneratorPlanet tree, where no swarm exists, so no swarm can bar its host. | | **Belt generation settings** | `ZoneGenerationSettings` in Settings.asset (an LFS object): BeltProbability 0.25, BeltMassCeiling 500, AsteroidBeltWidth (a ceiling that never binds, B56), AsteroidCount = sqrt(mass x D) + 11. Trojans: TrojanProbability 0.15 per host, TrojanArc 0.07 turn per swarm, TrojanHalfWidth 0.06 D, TrojanCount = 0.04 D + 8 rocks round each of L4 and L5 (tuning defaults for a flight, B61). The code default of TrojanProbability is 0, so settings built in code draw no swarm. | Authored. | Settings.asset. Forbidden: a tunable shape's numbers as constants in code. The swarm's random stream salt is a ZoneGenerator constant, not a tunable. | -Cells not filled, plainly: the lane's host set and half-width (laned-belts' map proposes the visual margin, ruling `lanes-two-sided-visual-margin`); where an edge cloud's rocks sit between a root's window and the zone boundary (edge-material's map, rulings `edge-material-root-only` and `edge-material-in-the-dark`). The edge material's place in the potential is not a question: it stays out of the extent, because the law breaks otherwise (B60). +Cells not filled, plainly: where an edge cloud's rocks sit between a root's window and the zone boundary (edge-material's map, rulings `edge-material-root-only` and `edge-material-in-the-dark`). The edge material's place in the potential is not a question: it stays out of the extent, because the law breaks otherwise (B60). ## Rationale @@ -180,5 +180,5 @@ Mapped and admitted (2026-10-14, base 11b2d7ad): `aetheria-gravity:cut_spec:cut- - **grid-noise-presentation**: the Sector Waves noise (after grid-lowering on layer 10, drawn only into the nebula patch height) and fine-grid detail move to non-slope presentation (grid-gravity-only-ruled); it also owns the Main Menu's own Surface Displacement brushes (B29, follow-up `menu-grid-brushes`). Waits on grid-lowering. - **top-speed-field**: top speed becomes a hull field; no drag below it, drag on the excess above it; the VelocityLimit clamp (Behaviors/VelocityLimit.cs) and always-on hull drag (Ship.cs:127-129) go; AI pilots brake actively; open: drag shape above the cap, consumables as modifiers, whether loot and wreckage come to rest (aetheria-gravity:ruling:no-drag-below-top-speed, target invariant orbits-do-not-decay). Waits on potential-owner and orbits-from-field. - **belt-shapes** (follow-up `belt-shapes`, ruling `belt-shapes-direction`; B60, B61; order by ruling `trojans-first`). One cut per shape. trojan-swarms is mapped and admitted (2026-10-18, base 67517f40): `aetheria-gravity:cut_spec:cut-trojan-swarms.r1`. - - **laned-belts**: only a body with room on both sides of its lane keeps a laned belt, and the lane's half-width is its own visual margin, not the placement Roche margin (ruling `lanes-two-sided-visual-margin`; 468 eligible at the Roche width, B60). Folds in `belt-after-belt-edge-pin`, in the same Place loop. + - **laned-belts** (mapped and admitted 2026-10-21, base d52b50f9: `aetheria-gravity:cut_spec:cut-laned-belts.r1`; lane half-width 5 body radii, a flight default): only a body with room on both sides of its lane keeps a laned belt, and the lane's half-width is its own visual margin, not the placement Roche margin (ruling `lanes-two-sided-visual-margin`; 468 eligible at the Roche width, B60). Folds in `belt-after-belt-edge-pin`, in the same Place loop. - **edge-material**: one sparse Oort-like cloud per root (per subzone root in a multi-root zone), from its family's window out toward the zone boundary, nothing at planets' rims (ruling `edge-material-root-only`); kept out of the extent (B60) and skipped by the populated radius, so its rocks sit in the dark fade (ruling `edge-material-in-the-dark`). From 51e82624d5c4de502b7955827eb16166fef23bcd Mon Sep 17 00:00:00 2001 From: soul Date: Wed, 21 Oct 2026 20:26:17 +0200 Subject: [PATCH 010/307] test_hull_source: pin that a muzzle facing any way passes the hull source Muzzles aft of and beside their mounts pass every check, so a reintroduced facing judgement fails a test. Co-Authored-By: Claude Opus 5.5 --- tools/blender/tests/test_hull_source.py | 10 ++++++++++ 1 file changed, 10 insertions(+) diff --git a/tools/blender/tests/test_hull_source.py b/tools/blender/tests/test_hull_source.py index 2a1f737f4..4cfcaf2e8 100644 --- a/tools/blender/tests/test_hull_source.py +++ b/tools/blender/tests/test_hull_source.py @@ -223,6 +223,16 @@ class JudgementTests(unittest.TestCase): MUZZLE = "every weapon-mount has a muzzle and no muzzle is orphaned" + def test_a_muzzle_facing_any_way_is_accepted(self): + # Which way a gun points is HullData's hardpoint rotation; the hull source never judges it. + fixture = Fixture() + fixture.parts["anchors"] = ("", echo("anchor", ["Gun.A", "weapon-mount", [0, 0, 3], 0, ""]) + + echo("anchor", ["Gun.A.muzzle", "weapon-muzzle", [0, 1, 3], 0, "Gun.A"]) + + echo("anchor", ["Gun.B", "weapon-mount", [0, 0, 3], 0, ""]) + + echo("anchor", ["Gun.B.muzzle", "weapon-muzzle", [1, 0, 3], 0, "Gun.B"]) + + echo("anchor", ["Th.A", "thruster-emitter", [1, 5, 1], 180, ""])) + self.assertEqual(fixture.failed(), []) + def test_a_mount_without_a_muzzle_and_a_muzzle_without_a_mount_are_refused(self): fixture = Fixture() fixture.parts["anchors"] = ("", echo("anchor", ["Gun.A", "weapon-mount", [0, 0, 3], 0, ""])) From 9c50b7499b24cee7c0f5a28df30f051f1957039b Mon Sep 17 00:00:00 2001 From: soul Date: Wed, 21 Oct 2026 20:28:40 +0200 Subject: [PATCH 011/307] FactionProductData.NotOffered: an off-market product is never drawn, stocked, chosen or a gear home One reader, LoadoutGenerator.IsOffered, read by IsAvailable (ahead of the no-faction shortcut), the AvailableProducts candidates (so the any-maker fallback cannot reach one) and SoldHulls. A Loadout that pins the product still builds it. Rulings off-market-is-unoffered-product and boss-hulls-exclusive. Co-Authored-By: Claude Opus 5.5 --- Assets/Scripts/ServerShared/FactionProduct.cs | 6 ++ .../Scripts/ServerShared/LoadoutGenerator.cs | 16 ++- .../ProductChoiceTests.cs | 102 +++++++++++++++++- 3 files changed, 117 insertions(+), 7 deletions(-) diff --git a/Assets/Scripts/ServerShared/FactionProduct.cs b/Assets/Scripts/ServerShared/FactionProduct.cs index b9a3f0009..14f7af57d 100644 --- a/Assets/Scripts/ServerShared/FactionProduct.cs +++ b/Assets/Scripts/ServerShared/FactionProduct.cs @@ -38,6 +38,12 @@ public class FactionProductData // no Assembly; the mint derives both from the line. [Inspectable, JsonProperty("bin"), Key(7)] public ProductBin Bin; + + // Off the market (ruling off-market-is-unoffered-product): the product keeps its maker, brand, quality and price, but no + // station stocks it, no generation or chooser draws it, and its hull gives no gear a home. It reaches a ship only through a + // Loadout that pins it. LoadoutGenerator.IsOffered is its one reader. + [Inspectable, JsonProperty("notOffered"), Key(8)] + public bool NotOffered; } // How good this product's part is in one of its design's roles: a pseudo-gaussian distribution whose mean is diff --git a/Assets/Scripts/ServerShared/LoadoutGenerator.cs b/Assets/Scripts/ServerShared/LoadoutGenerator.cs index 2e925a201..0e2d8fb00 100644 --- a/Assets/Scripts/ServerShared/LoadoutGenerator.cs +++ b/Assets/Scripts/ServerShared/LoadoutGenerator.cs @@ -197,6 +197,8 @@ public class LoadoutGenerator entry.design != null && entry.design.Price > 0 && entry.product.Manufacturer.IsSet() && + // A not-offered product is never a candidate, so the any-manufacturer fallback below cannot reach it either. + IsOffered(entry.product) && (!(entry.design is EquippableItemData equippable) || HasHome(equippable, hulls)) && (filter?.Invoke(entry.design) ?? true)) .ToArray(); @@ -216,9 +218,10 @@ public class LoadoutGenerator // bunch more hulls before all the gear variety in the game has a home"): generation never offers it, so no station // stocks it. It gains one the moment a hull with a hardpoint that takes it exists. Tool gear goes in any interior. // Only a hull some product sells gives gear a home: a hull no faction builds (the Smoke Test Hull) is a test fixture, so its - // hardpoints never put gear on a shelf. The one definition of the hulls HasHome considers. + // hardpoints never put gear on a shelf. An off-market hull gives no gear a home either: only its pinned Loadout flies it. + // The one definition of the hulls HasHome considers. public static HullData[] SoldHulls(CultCache items) => - items.GetAll().Select(product => items.Get(product.Design)).OfType().Distinct().ToArray(); + items.GetAll().Where(IsOffered).Select(product => items.Get(product.Design)).OfType().Distinct().ToArray(); public static bool HasHome(EquippableItemData design, HullData[] hulls) => design.HardpointType == HardpointType.Tool || design.HardpointType == HardpointType.Hull || @@ -231,9 +234,14 @@ public class LoadoutGenerator // always reaches its own manufacturer's gear, and otherwise gear made by a manufacturer its allegiance names // (operator, 2026-09-30: allegiance lists only other factions). Whether that manufacturer is present in the galaxy // does not matter (operator, 2026-10-06: a faction need not be present for its gear to be available). + // A not-offered product is available to nobody, a scenario stage included (ruling boss-hulls-exclusive). public bool IsAvailable(FactionProductData product) => - Galaxy == null || Faction == null || - ItemManager.ItemData.Get(product.Manufacturer) == Faction || Faction.Allegiance.ContainsKey(product.Manufacturer); + IsOffered(product) && ( + Galaxy == null || Faction == null || + ItemManager.ItemData.Get(product.Manufacturer) == Faction || Faction.Allegiance.ContainsKey(product.Manufacturer)); + + // Whether a product is on the market at all (ruling off-market-is-unoffered-product). The one reader of NotOffered. + public static bool IsOffered(FactionProductData product) => !product.NotOffered; // How much this outfitter favors a product: its maker's preference times the product's share of it. The one weight of // generation and of ChooseProduct. A bin takes the share of its maker's stock that would pass its floor and its line takes diff --git a/tests/Aetheria.Shared.Tests/ProductChoiceTests.cs b/tests/Aetheria.Shared.Tests/ProductChoiceTests.cs index 89f262c62..d049e8c05 100644 --- a/tests/Aetheria.Shared.Tests/ProductChoiceTests.cs +++ b/tests/Aetheria.Shared.Tests/ProductChoiceTests.cs @@ -65,8 +65,10 @@ public sealed class ProductChoiceTests : IDisposable private Rig Build(params string[] lampOrder) => Build(new float[0], lampOrder); - // binFloors adds one bin of lamp-a's line per floor, named "lamp-a bin" and "lamp-a bin 2"..., by the same maker. - private Rig Build(float[] binFloors, string[] lampOrder) + // binFloors adds one bin of lamp-a's line per floor, named "lamp-a bin" and "lamp-a bin 2"..., by the same maker. Every rig also + // holds a Barge, the one hull with a ballistic hardpoint, and a Gun only it mounts, each sold by Alpha. notOffered names the + // products the rig takes off the market. + private Rig Build(float[] binFloors, string[] lampOrder, params string[] notOffered) { var root = Path.Combine(_root, "rig" + _catalogs++); Directory.CreateDirectory(root); @@ -92,10 +94,18 @@ public sealed class ProductChoiceTests : IDisposable Hardpoints = { new HardpointData { Type = HardpointType.Sensors, Position = HardpointCell, Shape = new Shape() } } }); var lamp = seed.Upsert(new GearData { Name = "Lamp", Hardpoint = HardpointType.Sensors, Shape = new Shape(), Price = 10 }); + var barge = seed.Upsert(new HullData + { + Name = "Barge", HullType = HullType.Ship, Shape = shape, Price = 100, + Hardpoints = { new HardpointData { Type = HardpointType.Ballistic, Position = HardpointCell, Shape = new Shape() } } + }); + var gun = seed.Upsert(new GearData { Name = "Gun", Hardpoint = HardpointType.Ballistic, Shape = new Shape(), Price = 10 }); void Sell(string name, CultRecordKey design, CultRecordRef maker, string key) => seed.Commit(batch => batch.Upsert(typeof(FactionProductData), new FactionProductData - { Name = name, Design = new CultRecordRef(design), Manufacturer = maker }, new CultRecordKey(key))); + { Name = name, Design = new CultRecordRef(design), Manufacturer = maker, NotOffered = notOffered.Contains(name) }, new CultRecordKey(key))); Sell("Skiff by Alpha", skiff.Key, alpha, "skiff-a"); + Sell("Barge by Alpha", barge.Key, alpha, "barge-a"); + Sell("Gun by Alpha", gun.Key, alpha, "gun-a"); var makers = new Dictionary> { ["lamp-a"] = alpha, ["lamp-z"] = zeta, ["lamp-m"] = mu }; // lampOrder holds product names; the record key of the i-th inserted is "k" + i, so keys and insertion order // both follow the permutation. @@ -367,5 +377,91 @@ public sealed class ProductChoiceTests : IDisposable { Assert.Equal(4, typeof(Loadout).GetField(nameof(Loadout.HullProduct)).GetCustomAttribute().IntKey); Assert.Equal(3, typeof(LoadoutSlot).GetField(nameof(LoadoutSlot.Product)).GetCustomAttribute().IntKey); + Assert.Equal(8, typeof(FactionProductData).GetField(nameof(FactionProductData.NotOffered)).GetCustomAttribute().IntKey); + } + + // Ruling off-market-is-unoffered-product: a not-offered product is never chosen, by a faction that reaches its maker or by a + // stage with no faction, which otherwise reaches every maker (ruling boss-hulls-exclusive). + [Fact] + public void ANotOfferedProductIsNeverChosen() + { + var rig = Build(new float[0], new[] { "lamp-z", "lamp-a" }, "lamp-a"); + var lamp = rig.Cache.GetByName("Lamp"); + for (var i = 1; i <= 64; i++) + { + Assert.Equal("lamp-z", rig.Outfitter(null, Seed(i)).ChooseProduct(lamp).Name); + Assert.Equal("lamp-z", rig.Outfitter(rig.OutEven, Seed(i), 0).ChooseProduct(lamp).Name); + // OutA reaches only Alpha, whose lamp is off the market. + Assert.Null(rig.Outfitter(rig.OutA, Seed(i)).ChooseProduct(lamp)); + } + Assert.False(rig.Outfitter(null, 1).IsAvailable(rig.Product("lamp-a"))); + Assert.True(rig.Outfitter(null, 1).IsAvailable(rig.Product("lamp-z"))); + } + + // The any-manufacturer fallback of a required draw is closed to off-market products: when every product of the design is + // not offered the draw yields nothing, for a faction that reaches the maker, one that reaches nobody, and no faction. + [Fact] + public void ARequiredDrawNeverFallsBackToANotOfferedProduct() + { + var rig = Build(new float[0], new[] { "lamp-z", "lamp-a" }, "lamp-z", "lamp-a"); + var lamp = rig.Cache.GetByName("Lamp"); + foreach (var faction in new[] { rig.OutA, rig.OutNone, null }) + for (var i = 1; i <= 16; i++) + Assert.Null(rig.Outfitter(faction, Seed(i)).RandomProduct(0, design => design == lamp, required: true).product); + + // With one product still offered, a faction that reaches only the other maker falls back to it: the empty draw above is + // the field's doing, not a closed fallback. + var one = Build(new float[0], new[] { "lamp-z", "lamp-a" }, "lamp-a"); + var oneLamp = one.Cache.GetByName("Lamp"); + for (var i = 1; i <= 16; i++) + Assert.Equal("lamp-z", one.Outfitter(one.OutA, Seed(i)).RandomProduct(0, design => design == oneLamp, required: true).product.Name); + } + + // A hull sold only off the market is no gear's home: gear only it mounts is never drawn, so no station stocks it. + [Fact] + public void AnOffMarketHullGivesNoGearAHome() + { + var sold = Build("lamp-a"); + var soldGun = sold.Cache.GetByName("Gun"); + Assert.Contains(sold.Cache.GetByName("Barge"), LoadoutGenerator.SoldHulls(sold.Cache)); + Assert.Equal("Gun by Alpha", sold.Outfitter(null, 1).RandomProduct(0, design => design == soldGun, required: true).product.Name); + + var off = Build(new float[0], new[] { "lamp-a" }, "Barge by Alpha"); + var barge = off.Cache.GetByName("Barge"); + var gun = off.Cache.GetByName("Gun"); + var hulls = LoadoutGenerator.SoldHulls(off.Cache); + Assert.DoesNotContain(barge, hulls); + Assert.Contains(off.Cache.GetByName("Skiff"), hulls); + Assert.False(LoadoutGenerator.HasHome(gun, hulls)); + foreach (var faction in new[] { off.OutA, null }) + for (var i = 1; i <= 16; i++) + { + var generator = off.Outfitter(faction, Seed(i)); + Assert.Null(generator.RandomProduct(0, design => design == gun, required: true).product); + Assert.Null(generator.RandomProduct(0, design => design == barge, required: true).product); + } + } + + // A Loadout that pins a not-offered hull product still builds it, under its brand: that is how a boss flies its hull. + [Fact] + public void APinnedNotOfferedHullStillMaterializes() + { + var rig = Build(new float[0], new[] { "lamp-a" }, "Barge by Alpha"); + var fit = new Loadout + { + Name = "Boss barge", + Hull = rig.Cache.RefOf(rig.Cache.GetByName("Barge")), + HullProduct = rig.ProductRef("Barge by Alpha"), + WeaponGroups = new int[0][] + }; + var failures = new List(); + var ship = Materialize(rig, fit, rig.Outfitter(rig.OutA, 1).ChooseProduct, failures); + Assert.Empty(failures); + Assert.Equal("Barge by Alpha", rig.Items.Brand(ship.Hull).Product.Name); + + // Unpinned, the same hull has no product to be built as. + fit.HullProduct = default; + Assert.Null(Materialize(rig, fit, rig.Outfitter(null, 1).ChooseProduct, failures)); + Assert.NotEmpty(failures); } } From 9cedb4efe667dc8efe306a6008a48f359785ceb5 Mon Sep 17 00:00:00 2001 From: soul Date: Wed, 21 Oct 2026 20:28:41 +0200 Subject: [PATCH 012/307] AetherDb ship-authoring product --not-offered writes the package product off the market Co-Authored-By: Claude Opus 5.5 --- .../Aetheria.Shared.Tests/ShipProductTests.cs | 26 +++++++++++++++++-- tools/AetherDb/ShipAuthoringCommands.cs | 16 +++++++----- 2 files changed, 34 insertions(+), 8 deletions(-) diff --git a/tests/Aetheria.Shared.Tests/ShipProductTests.cs b/tests/Aetheria.Shared.Tests/ShipProductTests.cs index f6f3bad10..77242916f 100644 --- a/tests/Aetheria.Shared.Tests/ShipProductTests.cs +++ b/tests/Aetheria.Shared.Tests/ShipProductTests.cs @@ -272,6 +272,8 @@ public sealed class ShipProductTests : IDisposable RefusedAndUntouched("product", path, "ACM"); RefusedAndUntouched("product", path, "ACM", "--name", " "); RefusedAndUntouched("product", path, "ACM", "--remove"); + RefusedAndUntouched("product", path, "ACM", "--not-offered"); + RefusedAndUntouched("product", path, "ACM", "--name", "--not-offered"); var (code, _, error) = Run("product", path, "ACM", "--name", "Acme Skiff", "--description", "Reliable."); Assert.True(code == 0, error); @@ -283,10 +285,29 @@ public sealed class ShipProductTests : IDisposable Assert.Equal(ShipModCatalog.HullKey("mod.skiff"), product.Design.Key); Assert.Equal(hull.Roles.Select(role => role.Name), product.Roles.Select(role => role.Role)); Assert.Equal("Reliable.", product.Description); + Assert.False(product.NotOffered); + + // --not-offered writes the product off the market (ruling off-market-is-unoffered-product), with or without a description. + var (offCode, offOutput, offError) = Run("product", path, "ACM", "--name", "Acme Boss Skiff", "--not-offered"); + Assert.True(offCode == 0, offError); + Assert.Contains("not offered", offOutput); + var off = Assert.Single(ShipAuthoringStore.Load(path).Products); + Assert.True(off.NotOffered); + Assert.Equal("Acme Boss Skiff", off.Name); + Assert.Null(off.Description); + Assert.Equal(0, Run("product", path, "ACM", "--name", "Acme Boss Skiff", "--description", "Unsold.", "--not-offered").Code); + off = Assert.Single(ShipAuthoringStore.Load(path).Products); + Assert.True(off.NotOffered); + Assert.Equal("Unsold.", off.Description); // The maker by name replaces the product by short name: a maker has one product of the hull. - Assert.Equal(0, Run("product", path, "Acme", "--name", "Renamed").Code); - Assert.Equal("Renamed", Assert.Single(ShipAuthoringStore.Load(path).Products).Name); + // Written again without the flag, it is back on the market. + var (renamedCode, renamedOutput, _) = Run("product", path, "Acme", "--name", "Renamed"); + Assert.Equal(0, renamedCode); + Assert.DoesNotContain("not offered", renamedOutput); + var renamed = Assert.Single(ShipAuthoringStore.Load(path).Products); + Assert.Equal("Renamed", renamed.Name); + Assert.False(renamed.NotOffered); RefusedAndUntouched("price", path, "-1"); RefusedAndUntouched("price", path, "1.5"); @@ -294,6 +315,7 @@ public sealed class ShipProductTests : IDisposable Assert.Equal(0, Run("price", path, "250").Code); Assert.Equal(250, ShipAuthoringStore.Read(path).Hull.Price); + RefusedAndUntouched("product", path, "ACM", "--remove", "--not-offered"); Assert.Equal(0, Run("product", path, "ACM", "--remove").Code); Assert.Empty(ShipAuthoringStore.Load(path).Products); RefusedAndUntouched("product", path, "ACM", "--remove"); diff --git a/tools/AetherDb/ShipAuthoringCommands.cs b/tools/AetherDb/ShipAuthoringCommands.cs index 9fd7e690a..b745e3dbf 100644 --- a/tools/AetherDb/ShipAuthoringCommands.cs +++ b/tools/AetherDb/ShipAuthoringCommands.cs @@ -8,7 +8,7 @@ using System.Linq; // Uses the existing database executable; mod authoring does not earn another binary target. public static class ShipAuthoringCommands { - private const string Usage = "ship-authoring create [--like ] | inspect | centre | validate | product (--name [--description ] | --remove) | price | compose "; + private const string Usage = "ship-authoring create [--like ] | inspect | centre | validate | product (--name [--description ] [--not-offered] | --remove) | price | compose "; public static int Run(string[] args) { @@ -93,8 +93,11 @@ public static class ShipAuthoringCommands } if (args[0] == "product" && args.Length >= 4) { - var remove = args.Length == 4 && args[3] == "--remove"; - var named = args.Length >= 5 && args[3] == "--name" && (args.Length == 5 || args.Length == 7 && args[5] == "--description"); + // A trailing --not-offered takes the product off the market (ruling off-market-is-unoffered-product); --remove takes none. + var notOffered = args[args.Length - 1] == "--not-offered"; + var count = notOffered ? args.Length - 1 : args.Length; + var remove = !notOffered && count == 4 && args[3] == "--remove"; + var named = count >= 5 && args[3] == "--name" && (count == 5 || count == 7 && args[5] == "--description"); if (!remove && !named) { Console.Error.WriteLine(Usage); return 1; } var (hull, ship, products) = ShipAuthoringStore.Load(args[1]); var (makerKey, makerName) = ShippedFaction(args[2]); @@ -112,14 +115,15 @@ public static class ShipAuthoringCommands ShipAuthoringStore.WriteProduct(cache, ship.Id, new FactionProductData { Name = args[4], - Description = args.Length == 7 ? args[6] : null, + Description = count == 7 ? args[6] : null, Design = new CultRecordRef(ShipModCatalog.HullKey(ship.Id)), Manufacturer = new CultRecordRef(makerKey), - Roles = hull.Roles.Select(role => new ProductRole { Role = role.Name }).ToList() + Roles = hull.Roles.Select(role => new ProductRole { Role = role.Name }).ToList(), + NotOffered = notOffered }); cache.FlushAsync().Wait(); } - Console.WriteLine($"{ship.Id}: {(remove ? "removed" : "wrote")} the product by {makerName}."); + Console.WriteLine($"{ship.Id}: {(remove ? "removed" : "wrote")} the product by {makerName}{(notOffered ? ", not offered" : "")}."); return 0; } Console.Error.WriteLine(Usage); From 3d005ab5597f58365733223fb3cb0444f5c0f76d Mon Sep 17 00:00:00 2001 From: soul Date: Wed, 21 Oct 2026 20:30:04 +0200 Subject: [PATCH 013/307] LoadoutGenerator: IsAvailable's off-market comment names no boss term Co-Authored-By: Claude Opus 5.5 --- Assets/Scripts/ServerShared/LoadoutGenerator.cs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/Assets/Scripts/ServerShared/LoadoutGenerator.cs b/Assets/Scripts/ServerShared/LoadoutGenerator.cs index 0e2d8fb00..671faf678 100644 --- a/Assets/Scripts/ServerShared/LoadoutGenerator.cs +++ b/Assets/Scripts/ServerShared/LoadoutGenerator.cs @@ -234,7 +234,7 @@ public class LoadoutGenerator // always reaches its own manufacturer's gear, and otherwise gear made by a manufacturer its allegiance names // (operator, 2026-09-30: allegiance lists only other factions). Whether that manufacturer is present in the galaxy // does not matter (operator, 2026-10-06: a faction need not be present for its gear to be available). - // A not-offered product is available to nobody, a scenario stage included (ruling boss-hulls-exclusive). + // A not-offered product is available to nobody, a stage with no faction included. public bool IsAvailable(FactionProductData product) => IsOffered(product) && ( Galaxy == null || Faction == null || From bd5d27c52b0106d678f4723eca7229ecc66b4ebf Mon Sep 17 00:00:00 2001 From: soul Date: Wed, 21 Oct 2026 20:31:47 +0200 Subject: [PATCH 014/307] test_hull_source: a mount is not paired by another gun's muzzle Kills the surviving and->or mutant in the kept pairing rule's unmuzzled list. Co-Authored-By: Claude Opus 5.5 --- tools/blender/tests/test_hull_source.py | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/tools/blender/tests/test_hull_source.py b/tools/blender/tests/test_hull_source.py index 4cfcaf2e8..362664de7 100644 --- a/tools/blender/tests/test_hull_source.py +++ b/tools/blender/tests/test_hull_source.py @@ -239,6 +239,11 @@ class JudgementTests(unittest.TestCase): self.assertEqual(fixture.failed(), [self.MUZZLE]) fixture.parts["anchors"] = ("", echo("anchor", ["Gun.A.muzzle", "weapon-muzzle", [0, -3, 3], 0, "Gun.A"])) self.assertEqual(fixture.failed(), [self.MUZZLE]) + # Another gun's muzzle does not pair this one. + fixture.parts["anchors"] = ("", echo("anchor", ["Gun.A", "weapon-mount", [0, 0, 3], 0, ""]) + + echo("anchor", ["Gun.A.muzzle", "weapon-muzzle", [0, -3, 3], 0, "Gun.A"]) + + echo("anchor", ["Gun.B", "weapon-mount", [2, 0, 3], 0, ""])) + self.assertEqual(fixture.failed(), [self.MUZZLE]) def test_a_muzzle_whose_parent_is_not_a_weapon_mount_is_refused(self): fixture = Fixture() From 0c6f359cb5a76051cdf944bcbc0c4c855fda8415 Mon Sep 17 00:00:00 2001 From: soul Date: Wed, 21 Oct 2026 20:34:50 +0200 Subject: [PATCH 015/307] ProductLinesCatalogTests: FactionProductData's wire pin covers slot 8, and a record written before it is on the market Co-Authored-By: Claude Opus 5.5 --- .../ProductLinesCatalogTests.cs | 19 ++++++++++++++----- 1 file changed, 14 insertions(+), 5 deletions(-) diff --git a/tests/Aetheria.Shared.Tests/ProductLinesCatalogTests.cs b/tests/Aetheria.Shared.Tests/ProductLinesCatalogTests.cs index 7ce78218f..3bb4ceb60 100644 --- a/tests/Aetheria.Shared.Tests/ProductLinesCatalogTests.cs +++ b/tests/Aetheria.Shared.Tests/ProductLinesCatalogTests.cs @@ -140,8 +140,8 @@ public sealed class ProductLinesCatalogTests } } - // FactionProductData slot 7 is additive under the same schema id: a bin round-trips, and a record written before the slot - // existed (an array one element shorter) reads no bin. + // FactionProductData slots 7 and 8 are additive under the same schema id: a bin and NotOffered round-trip, a record written + // before slot 8 existed (an array one element shorter) is on the market, and one written before slot 7 also reads no bin. [Fact] public void BinIsSlotSevenAndACatalogWithoutItReadsNoBin() { @@ -150,12 +150,21 @@ public sealed class ProductLinesCatalogTests MessagePackSerializer.Serialize(new FactionProductData { Name = "Binned", Bin = new ProductBin { Floor = .67f } }, options), options); Assert.Equal(.67f, binned.Bin.Floor); + Assert.True(MessagePackSerializer.Deserialize( + MessagePackSerializer.Serialize(new FactionProductData { Name = "Unsold", NotOffered = true }, options), options).NotOffered); + var bytes = MessagePackSerializer.Serialize(new FactionProductData { Name = "Old", Description = "d" }, options); - Assert.Equal(0x98, bytes[0]); - Assert.Equal(0xC0, bytes[^1]); - var before = new byte[] { 0x97 }.Concat(bytes.Skip(1).Take(bytes.Length - 2)).ToArray(); + Assert.Equal(0x99, bytes[0]); + Assert.Equal(0xC0, bytes[^2]); + Assert.Equal(0xC2, bytes[^1]); + var beforeNotOffered = new byte[] { 0x98 }.Concat(bytes.Skip(1).Take(bytes.Length - 2)).ToArray(); + var offered = MessagePackSerializer.Deserialize(beforeNotOffered, options); + Assert.Equal("Old", offered.Name); + Assert.False(offered.NotOffered); + var before = new byte[] { 0x97 }.Concat(bytes.Skip(1).Take(bytes.Length - 3)).ToArray(); var old = MessagePackSerializer.Deserialize(before, options); Assert.Equal("Old", old.Name); Assert.Null(old.Bin); + Assert.False(old.NotOffered); } } From aea0ce823d5877511ca60c613d3709881bc7cd26 Mon Sep 17 00:00:00 2001 From: soul Date: Wed, 21 Oct 2026 20:37:59 +0200 Subject: [PATCH 016/307] release map: AH-P1 checks line no longer lists the deleted nose-ward rule Co-Authored-By: Claude Opus 5.5 --- docs/aetheria-release-map.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docs/aetheria-release-map.md b/docs/aetheria-release-map.md index 88267df37..848ab38f5 100644 --- a/docs/aetheria-release-map.md +++ b/docs/aetheria-release-map.md @@ -11238,7 +11238,7 @@ PIDs are in `S\out\pids.log`, the first 3404 and the last 20348. It then writes - **AH-P1. Checks.** All pass (`S\out\\checks.json`). - Tender: 3468 triangles. The bbox is 10.5 x 23.4 x 5.9 m, inside its 18-28 m size class. Body h/w is 0.42 and w/l is 0.61, inside Zhestokost's ranges [0.25, 0.6] and [0.35, 0.75]. Bilateral symmetry has a worst mirror miss of - 0.0000 m. It has five mount anchors, and every weapon has a muzzle nose-ward of its mount. + 0.0000 m. It has five mount anchors, and every weapon mount has its muzzle (gun facing is HullData's, ruling gun-facing-is-hulldata). - Fighter: 1466 triangles and 15 m long. The donor body's h/w is 0.20 and w/l 0.69, inside the R&D ranges. The asymmetry rule holds. - The vertex-based asymmetry figure (0.91 unmirrored) is too lenient, because tessellation alone breaks vertex From 73d1cee9de6af3c4ecf94fa12ca430551f0c38af Mon Sep 17 00:00:00 2001 From: soul Date: Wed, 21 Oct 2026 20:28:35 +0200 Subject: [PATCH 017/307] Gravity: one belt extent rule, read by Zone, the generator's clearance tree and the station tests AsteroidBelt.Extent(data, rail) is now static over the belt's data and the only spelling: the clearance tree's 'Distance + BeltHalfWidth' and the instance Extent are gone. The swarm host filter loses its dead '!p.Empty' (no bodyless node has a parent). Findings extent-second-spelling and dead-empty-guard (trojan-swarms-residuals). The clearance tree now reads the outermost rock, not the rail plus half-width, so 29 of the 1920 zones of seeds 1-30 place a station or turret differently. TrojanSwarmTests.BaseDigest is repinned for seeds 3-6, which hold such zones; seeds 1 and 2 are unchanged. Co-Authored-By: Claude Opus 5.5 --- Assets/Scripts/ServerShared/Zone.cs | 8 +++++--- Assets/Scripts/ServerShared/ZoneGenerator.cs | 9 ++++++--- .../StationPlacementTests.cs | 2 +- .../Aetheria.Shared.Tests/TrojanSwarmTests.cs | 19 ++++++++++--------- 4 files changed, 22 insertions(+), 16 deletions(-) diff --git a/Assets/Scripts/ServerShared/Zone.cs b/Assets/Scripts/ServerShared/Zone.cs index dc8cd0538..d0e98d3d2 100644 --- a/Assets/Scripts/ServerShared/Zone.cs +++ b/Assets/Scripts/ServerShared/Zone.cs @@ -221,7 +221,7 @@ public class Zone foreach (var belt in AsteroidBelts) { var rail = Planets[belt.Key].Orbit.Key; - beltExtent[rail] = belt.Value.Extent(Orbits[rail].Data.Distance); + beltExtent[rail] = AsteroidBelt.Extent(belt.Value.Data, Orbits[rail].Data.Distance); } var wellNodes = new List(); foreach (var orbit in Orbits) @@ -888,8 +888,10 @@ public class AsteroidBelt } // What the belt's rail adds to its parent's L_F (WellTree): an annulus reaches its outermost rock, whose period reads the - // field there; a swarm riding its rail reaches only the rail, whose period every rock shares. - public float Extent(float railDistance) => Data.RidesRail ? railDistance : Math.Max(Radius, railDistance); + // field there; a swarm riding its rail reaches only the rail, whose period every rock shares. The one belt extent rule: + // Zone, ZoneGenerator's clearance tree and the station tests all read it. + public static float Extent(AsteroidBeltData data, float railDistance) => + data.RidesRail ? railDistance : Math.Max(data.Asteroids.Max(a => a.Distance), railDistance); // Cut 1 (docs/mining-cut.md): the formula of Zone.cs:269-271 verbatim, `time` in double as `_time` was used. // The only place a chunk pose is computed; no per-tick copy survives it. Cut 2: size is no longer read from diff --git a/Assets/Scripts/ServerShared/ZoneGenerator.cs b/Assets/Scripts/ServerShared/ZoneGenerator.cs index 412b0e9ab..c62f23442 100644 --- a/Assets/Scripts/ServerShared/ZoneGenerator.cs +++ b/Assets/Scripts/ServerShared/ZoneGenerator.cs @@ -118,6 +118,7 @@ public static class ZoneGenerator } pack.Orbits = planets.Select(planet => cache.RefOf(orbitMap[planet])).ToList(); + var beltOf = new Dictionary(); pack.Planets = planets.Where(p=>!p.Empty).Select(planet => { BodyType bodyType = planet.Belt ? BodyType.Asteroid : @@ -149,6 +150,7 @@ public static class ZoneGenerator planetData.Orbit = cache.RefOf(orbitMap[planet]); if (planetData is AsteroidBeltData beltData) { + beltOf[planet] = beltData; beltData.Asteroids = Enumerable.Range(0, (int) zoneSettings.AsteroidCount.Evaluate(beltData.Mass * orbitMap[planet].Distance)) .Select(_ => new Asteroid @@ -219,7 +221,7 @@ public static class ZoneGenerator // rocks round both L4 and L5. Drawn from a stream of its own, so no other draw in the zone moves; not a GeneratorPlanet, // so it takes no slot and no station candidacy sees it. var swarmRandom = new Random(seed ^ TrojanStream); - foreach (var host in planets.Where(p => p.Parent != null && !p.Parent.Empty && !p.Empty && !p.Belt && p.Distance > 0 + foreach (var host in planets.Where(p => p.Parent != null && !p.Parent.Empty && !p.Belt && p.Distance > 0 && p.Mass > zoneSettings.PlanetMass && p.Mass <= zoneSettings.SunMass)) { if (swarmRandom.NextFloat() >= zoneSettings.TrojanProbability) continue; @@ -239,10 +241,11 @@ public static class ZoneGenerator } // The generator's own well tree, for clearance only (the Zone builds its own): what a rail must stay out of is the body's - // support, or its Roche margin if more. + // support, or its Roche margin if more. A belt's extent is the rule Zone reads (AsteroidBelt.Extent). Swarm rails are + // left out: a swarm's extent is its own rail, a copy of its host's, so it raises no L_F and no support. var wellNodes = planets.Select(p => new WellNode(cache.RefOf(orbitMap[p]).Key, p.Parent == null ? default : cache.RefOf(orbitMap[p.Parent]).Key, p.Distance, p.Phase, p.Empty || p.Belt ? 0f : p.Mass, - p.Belt ? p.Distance + p.BeltHalfWidth : p.Distance, p.FixedPosition)).ToList(); + p.Belt ? AsteroidBelt.Extent(beltOf[p], p.Distance) : p.Distance, p.FixedPosition)).ToList(); var wells = WellTree.Build(wellNodes, planetSettings, pack.Radius); float Extent(GeneratorPlanet p) { diff --git a/tests/Aetheria.Shared.Tests/StationPlacementTests.cs b/tests/Aetheria.Shared.Tests/StationPlacementTests.cs index 0157f6a70..30a591f36 100644 --- a/tests/Aetheria.Shared.Tests/StationPlacementTests.cs +++ b/tests/Aetheria.Shared.Tests/StationPlacementTests.cs @@ -177,7 +177,7 @@ public sealed class StationPlacementTests : IDisposable var placed = new HashSet(); foreach (var body in zone.Planets.Values) for (var key = body.Orbit.Key; placed.Add(key) && zone.Orbits[key].Data.Parent.IsSet();) key = zone.Orbits[key].Data.Parent.Key; - var beltExtent = zone.AsteroidBelts.ToDictionary(b => zone.Planets[b.Key].Orbit.Key, b => b.Value.Extent(zone.Orbits[zone.Planets[b.Key].Orbit.Key].Data.Distance)); + var beltExtent = zone.AsteroidBelts.ToDictionary(b => zone.Planets[b.Key].Orbit.Key, b => AsteroidBelt.Extent(b.Value.Data, zone.Orbits[zone.Planets[b.Key].Orbit.Key].Data.Distance)); var nodes = placed.Select(k => { var d = zone.Orbits[k].Data; diff --git a/tests/Aetheria.Shared.Tests/TrojanSwarmTests.cs b/tests/Aetheria.Shared.Tests/TrojanSwarmTests.cs index 8500ff983..e9de27bb6 100644 --- a/tests/Aetheria.Shared.Tests/TrojanSwarmTests.cs +++ b/tests/Aetheria.Shared.Tests/TrojanSwarmTests.cs @@ -97,16 +97,17 @@ public class TrojanSwarmTests Assert.True(onSwarmHost > 0, "no station or turret sits on a swarm host's rail"); } - // The digests were computed at 67517f40, before this cut, over the zones of each seed with the authored settings: a zone - // with no swarm is the base zone, and a zone with swarms is the base zone plus them. + // The digests were computed at 67517f40, before cut trojan-swarms, over the zones of each seed with the authored settings; + // seeds 3-6 were repinned at cut laned-belts C1, whose one belt extent rule moves stations in them. A zone with no swarm is + // the base zone, and a zone with swarms is the base zone plus them. private static readonly Dictionary BaseDigest = new Dictionary { [1] = "02a2bef42b5d4aeaf1e7004d0ab26620ee570ced95115abf98226ab4e0db23eb", [2] = "de231f392e5724ecdce7e746957633bef2724252147ecbb5830db047b635aead", - [3] = "5c33fe9f748d6b0b34a7bec923216c7f9bbdfdf8f326c59b77fde9ec96ca2bcb", - [4] = "585284386657e4714ad325cb053a78853c5bcaf7eb1a02cbd4826b0e86ad3593", - [5] = "4a1aeecad09c61ff7daa43eda40a9c0109dc4b6833439b8400d8c72d939950c8", - [6] = "7fd3c5844144b73faf05cd6a53fe92274e4455f38da4aaca4624ea13d96a12c3", + [3] = "0377471bf1a73b4e00ee2d18a9e90960fdf7011d35e7c648f103f3538750501f", + [4] = "195c6647ff921a12cd5ee71f94610f245e64c15fc1d38bc708846cd04e3ddeb5", + [5] = "ae0d57e84456767d35a699941238e7574e815da663b0354e5e1676063910e8a5", + [6] = "8ad70edd5d41e942ba9067930e0f148e3da6c054bf1d9d7cdd26be93e12017c6", }; [Fact] @@ -148,11 +149,11 @@ public class TrojanSwarmTests var belt = MessagePackSerializer.Deserialize(old, options); Assert.False(belt.RidesRail); Assert.Equal(3f, belt.Asteroids[0].Distance); - Assert.Equal(7f, new AsteroidBelt(belt).Extent(7f)); - Assert.Equal(3f, new AsteroidBelt(belt).Extent(2f)); + Assert.Equal(7f, AsteroidBelt.Extent(belt, 7f)); + Assert.Equal(3f, AsteroidBelt.Extent(belt, 2f)); var swarm = MessagePackSerializer.Deserialize(MessagePackSerializer.Serialize( new AsteroidBeltData { Asteroids = belt.Asteroids, RidesRail = true }, options), options); Assert.True(swarm.RidesRail); - Assert.Equal(2f, new AsteroidBelt(swarm).Extent(2f)); + Assert.Equal(2f, AsteroidBelt.Extent(swarm, 2f)); } } From d0fc8adb70aa282b8b9d9325c652290714e4561a Mon Sep 17 00:00:00 2001 From: soul Date: Wed, 21 Oct 2026 20:36:35 +0200 Subject: [PATCH 018/307] Gravity: a wider zone fingerprint, one copied-rail helper, belts kinded at generation, adjacent belts pinned ZoneFingerprint digests rock spin and periods, orbital entities (type, position, rail), other entities by type, the pack radius, a 9x9 height grid at two times and optionally PopulatedRadius (finding fingerprint-omits). TrojanSwarmTests.BaseDigest is repinned with it on Yggdrasil at C1's generator, swarms off. BeltShapes.CopiedRail replaces StationPlacementTests.SwarmRail and the RidesRail slot filters of ChildrenWithinReachTests: a rail a belt rides that copies a body's rail is never a placed slot. The Zones fixture counts the belts the generator writes and those with no field kind, before Zone's own FieldKinds.Ensure; EveryBeltIsKindedAtGeneration pins it at zero (finding ensure-unpinned). AdjacentBeltsKeepTheirSpacing places two belts side by side and pins the second's inner edge a sibling spacing outside the first's outer edge (follow-up belt-after-belt-edge-pin). Co-Authored-By: Claude Opus 5.5 --- tests/Aetheria.Shared.Tests/BeltShapes.cs | 15 +++++ .../ChildrenWithinReachTests.cs | 56 +++++++++++++++++-- .../StationPlacementTests.cs | 10 +--- .../Aetheria.Shared.Tests/TrojanSwarmTests.cs | 24 ++++---- .../Aetheria.Shared.Tests/ZoneFingerprint.cs | 37 ++++++++---- 5 files changed, 105 insertions(+), 37 deletions(-) create mode 100644 tests/Aetheria.Shared.Tests/BeltShapes.cs diff --git a/tests/Aetheria.Shared.Tests/BeltShapes.cs b/tests/Aetheria.Shared.Tests/BeltShapes.cs new file mode 100644 index 000000000..79f24ddfc --- /dev/null +++ b/tests/Aetheria.Shared.Tests/BeltShapes.cs @@ -0,0 +1,15 @@ +using System.Linq; +using GameCult.Caching; + +// The belt shapes a test reads back from a built zone. A swarm's rail is a copy of its host's rail (same Parent, Distance and +// Phase), never a slot Place laid out, so the tests that walk placed orbits leave copied rails out through this one helper. +public static class BeltShapes +{ + public static bool CopiedRail(Zone zone, CultRecordKey orbit) + { + if (!zone.Planets.Values.Any(b => b is AsteroidBeltData && b.Orbit.Key.Equals(orbit))) return false; + var rail = zone.Orbits[orbit].Data; + return zone.Planets.Values.Where(b => !(b is AsteroidBeltData)).Select(b => zone.Orbits[b.Orbit.Key].Data) + .Any(o => o.Parent.Key.Equals(rail.Parent.Key) && o.Distance == rail.Distance && o.Phase == rail.Phase); + } +} diff --git a/tests/Aetheria.Shared.Tests/ChildrenWithinReachTests.cs b/tests/Aetheria.Shared.Tests/ChildrenWithinReachTests.cs index 22646a129..ae1e8538c 100644 --- a/tests/Aetheria.Shared.Tests/ChildrenWithinReachTests.cs +++ b/tests/Aetheria.Shared.Tests/ChildrenWithinReachTests.cs @@ -19,6 +19,8 @@ public sealed class ChildrenWithinReachTests : IClassFixture All = new List<(uint, Zone)>(); public ZoneGenerationSettings Settings; + // Every belt the generator wrote, and those it wrote with no field kind, counted before Zone's own FieldKinds.Ensure runs. + public int Belts, UnkindedAtGeneration; // Seeds 7, 11 and 12 held rings inside their heavy member's Roche margin (ring-roche-final-heavy), and seeds 10, 13, 21 and 30 // held a bodyless ring member whose own bodies sat at their node's centre (bodyless-node-places-children); seeds 1-6 show neither. @@ -50,6 +52,11 @@ public sealed class ChildrenWithinReachTests : IClassFixture()) + { + Belts++; + if (!belt.Kind.IsSet()) UnkindedAtGeneration++; + } All.Add((seed, new Zone(items, planetSettings, pack, galaxyZone, galaxy))); } } @@ -79,11 +86,12 @@ public sealed class ChildrenWithinReachTests : IClassFixture BodyMass(Zone zone) => zone.Planets.Values.Where(b => !(b is AsteroidBeltData)).GroupBy(b => b.Orbit.Key).ToDictionary(g => g.Key, g => g.First().Mass); - // The generated orbits: every body's and its ancestors', which leaves out stations, turrets, Lagrange copies and swarm rails (a copy of the host's rail, like a Lagrange copy). + // The generated orbits: every body's and its ancestors', which leaves out stations, turrets, Lagrange copies and the copied + // rails swarms and lanes ride (a copy of the host's rail, like a Lagrange copy; BeltShapes.CopiedRail). private static HashSet Placed(Zone zone) { var placed = new HashSet(); - foreach (var body in zone.Planets.Values.Where(b => !(b is AsteroidBeltData { RidesRail: true }))) + foreach (var body in zone.Planets.Values.Where(b => !BeltShapes.CopiedRail(zone, b.Orbit.Key))) for (var key = body.Orbit.Key; placed.Add(key) && zone.Orbits[key].Data.Parent.IsSet();) key = zone.Orbits[key].Data.Parent.Key; return placed; } @@ -332,7 +340,7 @@ public sealed class ChildrenWithinReachTests : IClassFixture !b.Value.Data.RidesRail)) + foreach (var (beltKey, belt) in zone.AsteroidBelts.Where(b => !BeltShapes.CopiedRail(zone, zone.Planets[b.Key].Orbit.Key))) { belts++; var orbit = zone.Planets[beltKey].Orbit.Key; @@ -363,7 +371,7 @@ public sealed class ChildrenWithinReachTests : IClassFixture !b.Value.Data.RidesRail).ToDictionary(b => zone.Planets[b.Key].Orbit.Key, b => b.Value.Data.Asteroids); + var beltAt = zone.AsteroidBelts.Where(b => !BeltShapes.CopiedRail(zone, zone.Planets[b.Key].Orbit.Key)).ToDictionary(b => zone.Planets[b.Key].Orbit.Key, b => b.Value.Data.Asteroids); foreach (var (orbit, rocks) in beltAt) { var parent = zone.Orbits[orbit].Data.Parent.Key; @@ -424,7 +432,7 @@ public sealed class ChildrenWithinReachTests : IClassFixture !b.Value.Data.RidesRail)) + foreach (var (key, belt) in zone.AsteroidBelts.Where(b => !BeltShapes.CopiedRail(zone, zone.Planets[b.Key].Orbit.Key))) { var rocks = belt.Data.Asteroids; if (rocks.Length < 2) continue; @@ -447,6 +455,42 @@ public sealed class ChildrenWithinReachTests : IClassFixture AuthoredSettings.Load(RunStartTests.FindRepoRoot()).Read("PlanetSettings"); + // A belt's field kind is chosen at generation and saved with it (Mining Cut 3, Q16 B): every belt the generator writes, + // annulus or swarm, carries one before any Zone reads it (finding ensure-unpinned). + [Fact] + public void EveryBeltIsKindedAtGeneration() + { + _out.WriteLine($"belts {_zones.Belts}, unkinded at generation {_zones.UnkindedAtGeneration}"); + Assert.True(_zones.All.Any(z => z.Zone.AsteroidBelts.Values.Any(b => b.Data.RidesRail)), "no swarm in the sample"); + Assert.True(_zones.Belts > 20, $"only {_zones.Belts} belts generated"); + Assert.Equal(0, _zones.UnkindedAtGeneration); + } + + // Two belts side by side under one parent (production never draws them; follow-up belt-after-belt-edge-pin): the second + // belt's slot starts a sibling spacing outside the first belt's outer edge, not its rail, so their rock intervals keep a spacing. + [Fact] + public void AdjacentBeltsKeepTheirSpacing() + { + var settings = AuthoredSettings.Load(RunStartTests.FindRepoRoot()).Read("ZoneSettings"); + // Wider than any slot, so each belt fills its slot and the second's inner edge is the slot's inner bound. + settings.AsteroidBeltWidth = new ExponentialCurve { Exponent = 1f, Multiplier = 0f, Constant = 1e7f }; + var root = new GeneratorPlanet { Mass = 1e5f }; + Body(root, 100f); + var first = Body(root, 0f); + var second = Body(root, 0f); + Body(root, 100f, .5f); + foreach (var belt in new[] { first, second }) { belt.Belt = true; belt.Settings = settings; } + ZoneGenerator.Place(root, AuthoredPlanetSettings(), 1e6f, float.PositiveInfinity); + Assert.True(root.Children.Contains(first) && root.Children.Contains(second), "a belt folded"); + Assert.True(first.Distance < second.Distance, "the belts swapped order"); + Assert.True(first.BeltHalfWidth > 0 && second.BeltHalfWidth > 0, "a belt collapsed to a line"); + var q = 1 / (1 - GravityField.Reach / 2); + var firstOuter = first.Distance + first.BeltHalfWidth; + var secondInner = second.Distance - second.BeltHalfWidth; + _out.WriteLine($"first {first.Distance} +- {first.BeltHalfWidth}, second {second.Distance} +- {second.BeltHalfWidth}"); + Assert.True(firstOuter * q <= secondInner * (1 + 1e-4f), $"the second belt's inner edge {secondInner} is within a spacing of the first's outer edge {firstOuter}"); + } + // A rosette's ring round a root with no reach limit of its own sits at half the root's 0.75 x radius window, 3/8 of the radius. [Fact] public void ASoleRootRingSitsAtThreeEighthsOfItsRadius() diff --git a/tests/Aetheria.Shared.Tests/StationPlacementTests.cs b/tests/Aetheria.Shared.Tests/StationPlacementTests.cs index 30a591f36..eda1ad0a8 100644 --- a/tests/Aetheria.Shared.Tests/StationPlacementTests.cs +++ b/tests/Aetheria.Shared.Tests/StationPlacementTests.cs @@ -81,10 +81,6 @@ public sealed class StationPlacementTests : IDisposable private static float Turns(float turns) => turns - (float) Math.Floor(turns); - // A Trojan swarm's rail is a copy of its host's, not a station's source. - private static bool SwarmRail(Zone zone, CultRecordKey orbit) => - zone.Planets.Values.Any(b => b is AsteroidBeltData { RidesRail: true } && b.Orbit.Key.Equals(orbit)); - [Fact] public void AStationSitsAtItsPlanetsL4OrL5() { @@ -100,7 +96,7 @@ public sealed class StationPlacementTests : IDisposable foreach (var station in zone.Entities.OfType().Where(e => TypeOf(built.Items, e) == HullType.Station)) { var orbit = zone.Orbits[station.OrbitData]; - var sources = zone.Orbits.Where(o => !ridden.Contains(o.Key) && !SwarmRail(zone, o.Key) && SameRing(o.Value, orbit)).ToList(); + var sources = zone.Orbits.Where(o => !ridden.Contains(o.Key) && !BeltShapes.CopiedRail(zone, o.Key) && SameRing(o.Value, orbit)).ToList(); if (sources.Count == 0) { // The tutorial entrance's one exception: a station on its own orbit round a root node, beyond the node's ring @@ -160,7 +156,7 @@ public sealed class StationPlacementTests : IDisposable foreach (var station in Orbitals(built).Where(e => TypeOf(built.Items, e) == HullType.Station)) { var orbit = zone.Orbits[station.OrbitData]; - var sources = zone.Orbits.Where(o => !ridden.Contains(o.Key) && !SwarmRail(zone, o.Key) && SameRing(o.Value, orbit)).ToList(); + var sources = zone.Orbits.Where(o => !ridden.Contains(o.Key) && !BeltShapes.CopiedRail(zone, o.Key) && SameRing(o.Value, orbit)).ToList(); if (sources.Count == 0) continue; // the tutorial entrance's own orbit round a root stations++; Assert.Contains(bodyOrbits, key => key.Equals(sources[0].Key)); @@ -357,7 +353,7 @@ public sealed class StationPlacementTests : IDisposable foreach (var station in Orbitals(built).Where(e => TypeOf(built.Items, e) == HullType.Station)) { var orbit = zone.Orbits[station.OrbitData]; - var strays = zone.Orbits.Count(o => !ridden.Contains(o.Key) && !SwarmRail(zone, o.Key) && SameRing(o.Value, orbit)); + var strays = zone.Orbits.Count(o => !ridden.Contains(o.Key) && !BeltShapes.CopiedRail(zone, o.Key) && SameRing(o.Value, orbit)); Assert.True(strays == 1, $"seed {built.Seed}: a station's ring holds {strays} orbits no entity rides, not just its source's"); stations++; } diff --git a/tests/Aetheria.Shared.Tests/TrojanSwarmTests.cs b/tests/Aetheria.Shared.Tests/TrojanSwarmTests.cs index e9de27bb6..d5428481a 100644 --- a/tests/Aetheria.Shared.Tests/TrojanSwarmTests.cs +++ b/tests/Aetheria.Shared.Tests/TrojanSwarmTests.cs @@ -97,17 +97,17 @@ public class TrojanSwarmTests Assert.True(onSwarmHost > 0, "no station or turret sits on a swarm host's rail"); } - // The digests were computed at 67517f40, before cut trojan-swarms, over the zones of each seed with the authored settings; - // seeds 3-6 were repinned at cut laned-belts C1, whose one belt extent rule moves stations in them. A zone with no swarm is - // the base zone, and a zone with swarms is the base zone plus them. + // The widened digests (ZoneFingerprint with PopulatedRadius) were computed on Yggdrasil over the generator of cut laned-belts + // C1 (one belt extent rule) with swarms off, over the zones of each seed: a zone with no swarm is the base zone, and a zone with + // swarms is the base zone plus them. The per-zone comparison leaves PopulatedRadius out, since a swarm's outer rock may grow it. private static readonly Dictionary BaseDigest = new Dictionary { - [1] = "02a2bef42b5d4aeaf1e7004d0ab26620ee570ced95115abf98226ab4e0db23eb", - [2] = "de231f392e5724ecdce7e746957633bef2724252147ecbb5830db047b635aead", - [3] = "0377471bf1a73b4e00ee2d18a9e90960fdf7011d35e7c648f103f3538750501f", - [4] = "195c6647ff921a12cd5ee71f94610f245e64c15fc1d38bc708846cd04e3ddeb5", - [5] = "ae0d57e84456767d35a699941238e7574e815da663b0354e5e1676063910e8a5", - [6] = "8ad70edd5d41e942ba9067930e0f148e3da6c054bf1d9d7cdd26be93e12017c6", + [1] = "1873400d50d11b27913a67daca460f0829629aa21dacabac2cfc97a66537a700", + [2] = "5872680ee91c2019b345925f54c2113303cc2817ee9ea28d26ba839e43d1944e", + [3] = "e92cc32010f01b7962a2a58b6ea91c9c39ee3d915301164f176d0ebad218837c", + [4] = "61db9bdae2ca8704a7946debb4fe63ae3e649112fcda19d293ba2fddcaf45674", + [5] = "69ca46ebfae646b3a09faf9d636f3c706e5f6b7146f111a0ea06196332ae58b9", + [6] = "307dc4b38ff341670a5b5179ab977b6c7c59ba682d529d80dcaf9c4852c21fe2", }; [Fact] @@ -122,11 +122,11 @@ public class TrojanSwarmTests var a = swarmless.All.Where(x => x.Seed == seed).Select(x => x.Zone).ToList(); var b = live.All.Where(x => x.Seed == seed).Select(x => x.Zone).ToList(); Assert.Empty(a.SelectMany(z => z.AsteroidBelts.Where(x => x.Value.Data.RidesRail))); - Assert.Equal(BaseDigest[seed], ZoneFingerprint.Of(a, _ => false, _ => false)); - string Swarmed(Zone z) => ZoneFingerprint.Of(new[] { z }, SwarmRails(z).Contains, IsSwarm); + Assert.Equal(BaseDigest[seed], ZoneFingerprint.Of(a, _ => false, _ => false, true)); + string Swarmed(Zone z) => ZoneFingerprint.Of(new[] { z }, SwarmRails(z).Contains, IsSwarm, false); for (var n = 0; n < a.Count; n++) { - Assert.Equal(ZoneFingerprint.Of(new[] { a[n] }, _ => false, _ => false), Swarmed(b[n])); + Assert.Equal(ZoneFingerprint.Of(new[] { a[n] }, _ => false, _ => false, false), Swarmed(b[n])); swarms += SwarmRails(b[n]).Count; Assert.True(b[n].PopulatedRadius >= a[n].PopulatedRadius, $"seed {seed}: a swarm shrank the populated radius"); if (b[n].PopulatedRadius > a[n].PopulatedRadius) grew++; diff --git a/tests/Aetheria.Shared.Tests/ZoneFingerprint.cs b/tests/Aetheria.Shared.Tests/ZoneFingerprint.cs index 3abc9ebc1..6b40431c5 100644 --- a/tests/Aetheria.Shared.Tests/ZoneFingerprint.cs +++ b/tests/Aetheria.Shared.Tests/ZoneFingerprint.cs @@ -9,29 +9,42 @@ using static CultMath.math; using float2 = CultMath.float2; // A digest of what a zone's generation and build decide, so a test can pin it to a value computed at an earlier commit: -// every orbit's (Distance, Phase, Period, Reach, Support), every body's (type, Mass, rail Distance, belt rocks), the entity -// count and GetHeight at three points round each non-belt body. Cache keys and names are left out; they are minted, not decided. +// every orbit's (Distance, Phase, Period, Reach, Support); every body's (type, Mass, rail Distance) and each belt rock's +// (Distance, Phase, Size, RotationSpeed, Period); every orbital entity's (type, position, rail Distance and Phase) and any other +// entity's type alone (a ship packed at the origin is scattered from the clock-seeded item stream, Zone.cs:276); the pack radius; +// GetHeight on a 9x9 grid over the pack at t 0 and 777.7; and, when asked, PopulatedRadius. Cache keys and names are left +// out; they are minted, not decided. public static class ZoneFingerprint { - public static string Of(IEnumerable zones, Func skipOrbit, Func skipBody) + public static string Of(IEnumerable zones, Func skipOrbit, Func skipBody, bool populatedRadius) { var text = new StringBuilder(); - foreach (var zone in zones) text.Append(Describe(zone, skipOrbit, skipBody)).Append('\n'); + foreach (var zone in zones) text.Append(Describe(zone, skipOrbit, skipBody, populatedRadius)).Append('\n'); return Convert.ToHexString(SHA256.HashData(Encoding.UTF8.GetBytes(text.ToString()))).ToLowerInvariant(); } - private static string Describe(Zone z, Func skipOrbit, Func skipBody) + private static string Describe(Zone z, Func skipOrbit, Func skipBody, bool populatedRadius) { - string F(float v) => v.ToString("R", System.Globalization.CultureInfo.InvariantCulture); + string F(double v) => v.ToString("R", System.Globalization.CultureInfo.InvariantCulture); var orbits = z.Orbits.Where(o => !skipOrbit(o.Key)) .Select(o => $"{F(o.Value.Data.Distance)},{F(o.Value.Data.Phase)},{F(o.Value.Period)},{F(z.Wells.Reach(o.Key))},{F(z.Wells.Support(o.Key))}") .OrderBy(x => x, StringComparer.Ordinal); - var bodies = z.Planets.Values.Where(b => !skipBody(b)) - .Select(b => $"{b.GetType().Name},{F(b.Mass)},{F(z.Orbits[b.Orbit.Key].Data.Distance)}," + - (b is AsteroidBeltData belt ? string.Join("|", belt.Asteroids.Select(r => $"{F(r.Distance)}/{F(r.Phase)}/{F(r.Size)}")) : "")) + var bodies = z.Planets.Where(b => !skipBody(b.Value)) + .Select(b => $"{b.Value.GetType().Name},{F(b.Value.Mass)},{F(z.Orbits[b.Value.Orbit.Key].Data.Distance)}," + + (b.Value is AsteroidBeltData belt ? string.Join("|", belt.Asteroids.Select((r, i) => + $"{F(r.Distance)}/{F(r.Phase)}/{F(r.Size)}/{F(r.RotationSpeed)}/{F(z.AsteroidBelts[b.Key].Periods[i])}")) : "")) .OrderBy(x => x, StringComparer.Ordinal); - var heights = z.Planets.Values.Where(b => !(b is AsteroidBeltData)).Select(b => z.Orbits[b.Orbit.Key].Position) - .SelectMany(p => new[] { float2(0, 0), float2(37, 0), float2(0, -211) }.Select(o => F(z.GetHeight(p + o)))); - return string.Join(";", orbits) + "#" + string.Join(";", bodies) + "#" + z.Entities.Count() + "#" + string.Join(",", heights); + var entities = z.Entities.Select(e => e is OrbitalEntity o + ? $"{e.GetType().Name},{F(e.Position.x)},{F(e.Position.y)},{F(e.Position.z)},{F(z.Orbits[o.OrbitData].Data.Distance)},{F(z.Orbits[o.OrbitData].Data.Phase)}" + : e.GetType().Name) + .OrderBy(x => x, StringComparer.Ordinal); + var r = z.Pack.Radius; + var heights = new List(); + foreach (var t in new[] { 0.0, 777.7 }) + for (var i = -4; i <= 4; i++) + for (var j = -4; j <= 4; j++) + heights.Add(F(z.GetHeight(float2(i * r / 4f, j * r / 4f), t))); + return string.Join(";", orbits) + "#" + string.Join(";", bodies) + "#" + string.Join(";", entities) + "#" + F(r) + "#" + + string.Join(",", heights) + (populatedRadius ? "#" + F(z.PopulatedRadius) : ""); } } From 749bdb09f5b683f9c3049b0c8a002b71297f7933 Mon Sep 17 00:00:00 2001 From: soul Date: Wed, 21 Oct 2026 20:58:09 +0200 Subject: [PATCH 019/307] ShipProductTests: the product grammar refuses a misnamed flag or a dangling description, and the on-market line is the off-market one without its clause Kills the four Stryker survivors of the --not-offered parse. Co-Authored-By: Claude Opus 5.5 --- tests/Aetheria.Shared.Tests/ShipProductTests.cs | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/tests/Aetheria.Shared.Tests/ShipProductTests.cs b/tests/Aetheria.Shared.Tests/ShipProductTests.cs index 77242916f..27e84629f 100644 --- a/tests/Aetheria.Shared.Tests/ShipProductTests.cs +++ b/tests/Aetheria.Shared.Tests/ShipProductTests.cs @@ -274,6 +274,9 @@ public sealed class ShipProductTests : IDisposable RefusedAndUntouched("product", path, "ACM", "--remove"); RefusedAndUntouched("product", path, "ACM", "--not-offered"); RefusedAndUntouched("product", path, "ACM", "--name", "--not-offered"); + RefusedAndUntouched("product", path, "ACM", "--title", "X"); + RefusedAndUntouched("product", path, "ACM", "--name", "X", "--description"); + RefusedAndUntouched("product", path, "ACM", "--name", "X", "--note", "Y"); var (code, _, error) = Run("product", path, "ACM", "--name", "Acme Skiff", "--description", "Reliable."); Assert.True(code == 0, error); @@ -305,6 +308,7 @@ public sealed class ShipProductTests : IDisposable var (renamedCode, renamedOutput, _) = Run("product", path, "Acme", "--name", "Renamed"); Assert.Equal(0, renamedCode); Assert.DoesNotContain("not offered", renamedOutput); + Assert.Equal(offOutput.Replace(", not offered", ""), renamedOutput); var renamed = Assert.Single(ShipAuthoringStore.Load(path).Products); Assert.Equal("Renamed", renamed.Name); Assert.False(renamed.NotOffered); From f2cd0368908e1cadce3cee101b965bb1976f14d7 Mon Sep 17 00:00:00 2001 From: soul Date: Wed, 21 Oct 2026 22:00:55 +0200 Subject: [PATCH 020/307] ProductChoiceTests: rig bins honour the not-offered list; an off-market bin leaves its line the whole weight, and an off-market line's bin is never drawn or stocked Co-Authored-By: Claude Opus 5.5 --- .../ProductChoiceTests.cs | 43 ++++++++++++++++++- 1 file changed, 42 insertions(+), 1 deletion(-) diff --git a/tests/Aetheria.Shared.Tests/ProductChoiceTests.cs b/tests/Aetheria.Shared.Tests/ProductChoiceTests.cs index d049e8c05..975772cec 100644 --- a/tests/Aetheria.Shared.Tests/ProductChoiceTests.cs +++ b/tests/Aetheria.Shared.Tests/ProductChoiceTests.cs @@ -111,11 +111,14 @@ public sealed class ProductChoiceTests : IDisposable // both follow the permutation. for (var i = 0; i < lampOrder.Length; i++) Sell(lampOrder[i], lamp.Key, makers[lampOrder[i]], "k" + i); for (var i = 0; i < binFloors.Length; i++) + { + var name = i == 0 ? "lamp-a bin" : "lamp-a bin " + (i + 1); seed.Commit(batch => batch.Upsert(typeof(FactionProductData), new FactionProductData { - Name = i == 0 ? "lamp-a bin" : "lamp-a bin " + (i + 1), Design = new CultRecordRef(lamp.Key), Manufacturer = alpha, + Name = name, Design = new CultRecordRef(lamp.Key), Manufacturer = alpha, NotOffered = notOffered.Contains(name), Bin = new ProductBin { Line = new CultRecordRef(new CultRecordKey("k" + Array.IndexOf(lampOrder, "lamp-a"))), Floor = binFloors[i] } }, new CultRecordKey("bin" + i))); + } seed.FlushAsync().Wait(); } @@ -398,6 +401,44 @@ public sealed class ProductChoiceTests : IDisposable Assert.True(rig.Outfitter(null, 1).IsAvailable(rig.Product("lamp-z"))); } + // An off-market bin leaves the share arithmetic (ruling bin-share-by-floor): its line keeps the whole of its maker's preference, + // so taking a bin off the market never shrinks the maker's presence for the design. + [Fact] + public void AnOffMarketBinLeavesItsLineTheWholeWeight() + { + var alone = Build("lamp-z", "lamp-a"); + var off = Build(new[] { 0f }, new[] { "lamp-z", "lamp-a" }, "lamp-a bin"); + var preference = alone.Outfitter(alone.OutEven, 1, 0).ProductWeight(alone.Product("lamp-a")); + Assert.Equal(preference, off.Outfitter(off.OutEven, 1, 0).ProductWeight(off.Product("lamp-a")), 3); + + var lamp = off.Cache.GetByName("Lamp"); + var draws = Enumerable.Range(1, 600).Select(i => off.Outfitter(off.OutEven, Seed(i)).ChooseProduct(lamp).Name).ToArray(); + Assert.DoesNotContain("lamp-a bin", draws); + var aloneLamp = alone.Cache.GetByName("Lamp"); + var aloneDraws = Enumerable.Range(1, 600).Count(i => alone.Outfitter(alone.OutEven, Seed(i)).ChooseProduct(aloneLamp).Name == "lamp-a"); + Assert.InRange(draws.Count(name => name == "lamp-a"), aloneDraws - 30, aloneDraws + 30); + } + + // Ruling bin-inherits-line-offered: a bin is offered only if both it and its line are. A bin of an off-market line is never chosen, + // never drawn by generation, and never stocked, though the bin itself carries no NotOffered. + [Fact] + public void AnOffMarketLinesBinIsNeverDrawnOrSold() + { + var rig = Build(new[] { 0f }, new[] { "lamp-z", "lamp-a" }, "lamp-a"); + Assert.False(rig.Product("lamp-a bin").NotOffered); + var lamp = rig.Cache.GetByName("Lamp"); + for (var i = 1; i <= 64; i++) + { + // OutA reaches only Alpha, whose line and so its bin are off the market. + Assert.Null(rig.Outfitter(rig.OutA, Seed(i)).ChooseProduct(lamp)); + Assert.Equal("lamp-z", rig.Outfitter(null, Seed(i)).ChooseProduct(lamp).Name); + Assert.Equal("lamp-z", rig.Outfitter(rig.OutA, Seed(i)).RandomProduct(0, design => design == lamp, required: true).product.Name); + foreach (var faction in new[] { rig.OutA, rig.OutEven, rig.OutNone, null }) + Assert.DoesNotContain(rig.Outfitter(faction, Seed(i)).RandomProducts(16, 1, null, true), entry => entry.product.Name.StartsWith("lamp-a")); + } + Assert.False(rig.Outfitter(null, 1).IsAvailable(rig.Product("lamp-a bin"))); + } + // The any-manufacturer fallback of a required draw is closed to off-market products: when every product of the design is // not offered the draw yields nothing, for a faction that reaches the maker, one that reaches nobody, and no faction. [Fact] From f7cdb766cdc1bc22521055f9614a6d192230306f Mon Sep 17 00:00:00 2001 From: soul Date: Wed, 21 Oct 2026 22:01:41 +0200 Subject: [PATCH 021/307] LoadoutGenerator: IsOffered reads a bin's line, and BinShares counts only offered bins, so an off-market bin leaves its line the whole weight Co-Authored-By: Claude Opus 5.5 --- Assets/Scripts/ServerShared/LoadoutGenerator.cs | 17 ++++++++++------- 1 file changed, 10 insertions(+), 7 deletions(-) diff --git a/Assets/Scripts/ServerShared/LoadoutGenerator.cs b/Assets/Scripts/ServerShared/LoadoutGenerator.cs index 671faf678..b75f0d30a 100644 --- a/Assets/Scripts/ServerShared/LoadoutGenerator.cs +++ b/Assets/Scripts/ServerShared/LoadoutGenerator.cs @@ -198,7 +198,7 @@ public class LoadoutGenerator entry.design.Price > 0 && entry.product.Manufacturer.IsSet() && // A not-offered product is never a candidate, so the any-manufacturer fallback below cannot reach it either. - IsOffered(entry.product) && + IsOffered(ItemManager.ItemData, entry.product) && (!(entry.design is EquippableItemData equippable) || HasHome(equippable, hulls)) && (filter?.Invoke(entry.design) ?? true)) .ToArray(); @@ -221,7 +221,7 @@ public class LoadoutGenerator // hardpoints never put gear on a shelf. An off-market hull gives no gear a home either: only its pinned Loadout flies it. // The one definition of the hulls HasHome considers. public static HullData[] SoldHulls(CultCache items) => - items.GetAll().Where(IsOffered).Select(product => items.Get(product.Design)).OfType().Distinct().ToArray(); + items.GetAll().Where(product => IsOffered(items, product)).Select(product => items.Get(product.Design)).OfType().Distinct().ToArray(); public static bool HasHome(EquippableItemData design, HullData[] hulls) => design.HardpointType == HardpointType.Tool || design.HardpointType == HardpointType.Hull || @@ -236,12 +236,14 @@ public class LoadoutGenerator // does not matter (operator, 2026-10-06: a faction need not be present for its gear to be available). // A not-offered product is available to nobody, a stage with no faction included. public bool IsAvailable(FactionProductData product) => - IsOffered(product) && ( + IsOffered(ItemManager.ItemData, product) && ( Galaxy == null || Faction == null || ItemManager.ItemData.Get(product.Manufacturer) == Faction || Faction.Allegiance.ContainsKey(product.Manufacturer)); - // Whether a product is on the market at all (ruling off-market-is-unoffered-product). The one reader of NotOffered. - public static bool IsOffered(FactionProductData product) => !product.NotOffered; + // Whether a product is on the market at all (ruling off-market-is-unoffered-product). The one reader of NotOffered. A bin is + // offered only if both it and its line are (ruling bin-inherits-line-offered); the catalog read guarantees a bin's line exists. + public static bool IsOffered(CultCache items, FactionProductData product) => + !product.NotOffered && (product.Bin == null || !items.Get(product.Bin.Line).NotOffered); // How much this outfitter favors a product: its maker's preference times the product's share of it. The one weight of // generation and of ChooseProduct. A bin takes the share of its maker's stock that would pass its floor and its line takes @@ -256,9 +258,10 @@ public class LoadoutGenerator // 1 - Phi(floor): the fraction of a normal draw that lies at least floor deviations above its mean. private static float PassFraction(float floor) => .5f * (1 - erf(floor / sqrt(2f))); - // Each line's key to the sum of its bins' pass fractions: read from the catalog once per weighting pass, never kept. + // Each line's key to the sum of its offered bins' pass fractions: read from the catalog once per weighting pass, never kept. An + // off-market bin takes no share, so its line keeps the whole weight. private Dictionary BinShares() => ItemManager.ItemData.GetAll() - .Where(product => product.Bin != null) + .Where(product => product.Bin != null && IsOffered(ItemManager.ItemData, product)) .GroupBy(product => product.Bin.Line.Key.Value) .ToDictionary(group => group.Key, group => group.Sum(bin => PassFraction(bin.Bin.Floor))); From f102b082fd59249b8d2e8ef13fe87bde8000c3a4 Mon Sep 17 00:00:00 2001 From: soul Date: Wed, 21 Oct 2026 22:42:49 +0200 Subject: [PATCH 022/307] Gravity: laned belts, a belt on a copy of a light body's rail filling its slot less a visual lane Place records every child's slot; GenerateEntities marks belt candidates. After the swarm pass, a lane pass on its own stream gives a candidate that kept its body, has a band at least the lane's half-width on both sides, carries no swarm and whose parent has no other belt shape, an annulus on a copy of its rail: rocks over [slot inner, D - E] and [D + E, slot outer], E = LaneBodyRadii body radii. ZoneGenerationSettings gains LaneProbability (0 in code, 0.5 authored) and LaneBodyRadii (5). LanedBeltTests pins it; EveryBeltIsKindedAtGeneration moves there and asserts lanes; BeltShapes.Lanes lands with it; both TrojanSwarmTests fixtures draw no lane. Co-Authored-By: Claude Opus 5.5 --- Assets/Resources/Settings.asset | 4 +- Assets/Scripts/ServerShared/Settings.cs | 6 + Assets/Scripts/ServerShared/ZoneGenerator.cs | 82 ++++++++- tests/Aetheria.Shared.Tests/BeltShapes.cs | 21 ++- .../ChildrenWithinReachTests.cs | 17 +- tests/Aetheria.Shared.Tests/LanedBeltTests.cs | 155 ++++++++++++++++++ .../Aetheria.Shared.Tests/TrojanSwarmTests.cs | 7 +- 7 files changed, 266 insertions(+), 26 deletions(-) create mode 100644 tests/Aetheria.Shared.Tests/LanedBeltTests.cs diff --git a/Assets/Resources/Settings.asset b/Assets/Resources/Settings.asset index 78735dab3..f1f320a67 100644 --- a/Assets/Resources/Settings.asset +++ b/Assets/Resources/Settings.asset @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:7a0a0d2e7b8367e7c402975e5683434068f99b8c13c848764bbac39b43886411 -size 12477 +oid sha256:8cc62a055653d90b906819239de99238253299f3ce4219cf58bddf9b67e3be67 +size 12523 diff --git a/Assets/Scripts/ServerShared/Settings.cs b/Assets/Scripts/ServerShared/Settings.cs index 980828e53..18278dd15 100644 --- a/Assets/Scripts/ServerShared/Settings.cs +++ b/Assets/Scripts/ServerShared/Settings.cs @@ -134,6 +134,12 @@ public class ZoneGenerationSettings public float TrojanHalfWidth = .06f; public ExponentialCurve TrojanCount = new ExponentialCurve { Exponent = 1f, Multiplier = .04f, Constant = 8f }; + // Laned belts (ruling lanes-two-sided-visual-margin): the chance a belt candidate that kept its body and has room on both + // sides carries a laned belt, and the lane's half-width in the host's body radii. 0 here: a settings object built in code + // draws none. + public float LaneProbability = 0f; + public float LaneBodyRadii = 5f; + public float ResourceDensityMinimum = .1f; public float ResourceDensityMaximum = 1.5f; diff --git a/Assets/Scripts/ServerShared/ZoneGenerator.cs b/Assets/Scripts/ServerShared/ZoneGenerator.cs index c62f23442..a10950bbf 100644 --- a/Assets/Scripts/ServerShared/ZoneGenerator.cs +++ b/Assets/Scripts/ServerShared/ZoneGenerator.cs @@ -221,10 +221,12 @@ public static class ZoneGenerator // rocks round both L4 and L5. Drawn from a stream of its own, so no other draw in the zone moves; not a GeneratorPlanet, // so it takes no slot and no station candidacy sees it. var swarmRandom = new Random(seed ^ TrojanStream); + var swarmHosts = new HashSet(); foreach (var host in planets.Where(p => p.Parent != null && !p.Parent.Empty && !p.Belt && p.Distance > 0 && p.Mass > zoneSettings.PlanetMass && p.Mass <= zoneSettings.SunMass)) { if (swarmRandom.NextFloat() >= zoneSettings.TrojanProbability) continue; + swarmHosts.Add(host); var hostOrbit = orbitMap[host]; var rail = new OrbitData { Parent = hostOrbit.Parent, Distance = hostOrbit.Distance, Phase = hostOrbit.Phase }; pack.Orbits.Add(cache.Upsert(rail)); @@ -240,9 +242,40 @@ public static class ZoneGenerator pack.Planets.Add(cache.Upsert(swarmBody)); } + // Laned belts (rulings belt-shapes-direction, lanes-two-sided-visual-margin): a belt candidate that kept its body carries + // an annulus on a copy of its rail that fills its slot less a lane round the body, rocks on both sides. The lane is sized + // against the body the player sees (LaneBodyRadii body radii a side), and only a body whose slot holds a band at least + // that wide on both sides gets one. One belt shape per parent, and none on a swarm host. Drawn from a stream of its own, + // so no other draw in the zone moves; its rocks sit inside the host's slot, below the next sibling's spacing, so they + // raise no L_F and no support: the lane moves no well, period or height. + var laneRandom = new Random(seed ^ LaneStream); + var shaped = new HashSet(planets.Where(p => p.Belt).Select(p => p.Parent)); + foreach (var host in planets.Where(p => p.BeltCandidate && !p.Belt)) + { + if (laneRandom.NextFloat() >= zoneSettings.LaneProbability) continue; + var lane = zoneSettings.LaneBodyRadii * planetSettings.BodyRadius.Evaluate(host.Mass); + if (swarmHosts.Contains(host) || shaped.Contains(host.Parent) + || host.SlotInner > host.Distance - 2 * lane || host.SlotOuter < host.Distance + 2 * lane) continue; + shaped.Add(host.Parent); + var hostOrbit = orbitMap[host]; + var rail = new OrbitData { Parent = hostOrbit.Parent, Distance = hostOrbit.Distance, Phase = hostOrbit.Phase }; + pack.Orbits.Add(cache.Upsert(rail)); + var laned = new AsteroidBeltData + { + Orbit = cache.RefOf(rail), + Asteroids = LaneRocks(zoneSettings, ref laneRandom, host.Mass, rail.Distance, lane, host.SlotInner, host.SlotOuter) + }; + BodyData lanedBody = laned; + var lanedKey = cache.Upsert(lanedBody).Key; + laned.Name = lanedKey.Value.Substring(0, 8); + FieldKinds.Ensure(lanedKey, laned, cache); + pack.Planets.Add(cache.Upsert(lanedBody)); + } + // The generator's own well tree, for clearance only (the Zone builds its own): what a rail must stay out of is the body's - // support, or its Roche margin if more. A belt's extent is the rule Zone reads (AsteroidBelt.Extent). Swarm rails are - // left out: a swarm's extent is its own rail, a copy of its host's, so it raises no L_F and no support. + // support, or its Roche margin if more. A belt's extent is the rule Zone reads (AsteroidBelt.Extent). Swarm and lane rails + // are left out: a swarm's extent is its own rail, a copy of its host's, and a lane's outer rock sits inside its host's slot, + // below the next sibling's spacing, so neither raises an L_F or a support. var wellNodes = planets.Select(p => new WellNode(cache.RefOf(orbitMap[p]).Key, p.Parent == null ? default : cache.RefOf(orbitMap[p.Parent]).Key, p.Distance, p.Phase, p.Empty || p.Belt ? 0f : p.Mass, p.Belt ? AsteroidBelt.Extent(beltOf[p], p.Distance) : p.Distance, p.FixedPosition)).ToList(); @@ -598,8 +631,11 @@ public static class ZoneGenerator : root.AllPlanets().Where(p => p != root && p.Mass < settings.BeltMassCeiling && !binaries.Contains(p) && p.Children.Count == 0); foreach(var planet in beltCandidates.Reverse()) + { + planet.BeltCandidate = true; if (random.NextFloat() < settings.BeltProbability && !planet.Parent.Children.Any(p=>p.Belt)) planet.Belt = true; + } var totalMass = root.AllPlanets().Sum(p => p.Mass); var rootMass = root.Mass; @@ -633,6 +669,32 @@ public static class ZoneGenerator }; return rocks; } + + private const uint LaneStream = 0x85EBCA6Bu; + + // A laned belt's rocks: the count an annulus of the host's mass at `distance` would draw, over the bands [inner, distance - lane] + // and [distance + lane, outer], each rock in a band by its area and drawn across it as the annulus draws across its width. + public static Asteroid[] LaneRocks(ZoneGenerationSettings settings, ref Random random, float mass, float distance, float lane, float inner, float outer) + { + float Sq(float x) => x * x; + var innerArea = Sq(distance - lane) - Sq(inner); + var share = innerArea / (innerArea + Sq(outer) - Sq(distance + lane)); + var rocks = new Asteroid[(int) settings.AsteroidCount.Evaluate(mass * distance)]; + for (var i = 0; i < rocks.Length; i++) + { + var innerBand = random.NextFloat() < share; + var a = innerBand ? inner : distance + lane; + var b = innerBand ? distance - lane : outer; + rocks[i] = new Asteroid + { + Distance = (a + b) / 2 + random.NextFloat() * (random.NextFloat() - .5f) * (b - a), + Phase = random.NextFloat(), + Size = random.NextFloat(), + RotationSpeed = settings.AsteroidRotationSpeed.Evaluate(random.NextFloat()) + }; + } + return rocks; + } public const float CuspMargin = 1.5f; public const float SiblingSpacing = .5f; @@ -691,15 +753,18 @@ public static class ZoneGenerator var g = children.Count > 1 ? pow(upper / positions[positions.Count - 1], 1f / (children.Count - 1)) : 1f; for (var k = 0; k < children.Count; k++) children[k].Distance = positions[k] * pow(g, k); - // A belt is an interval inside its slot: inner edge outside the lower bound and the previous sibling's spacing, outer edge - // inside the next sibling's spacing and the window; the authored width is the widest it may be. + // Every child's slot: from the lower bound and the previous sibling's spacing out to the next sibling's spacing and the + // window. Place is the only writer of a slot; a belt fills its own, and the lane pass reads a body's. A belt is an + // interval inside its slot, the authored width the widest it may be. var outer = 0f; for (var k = 0; k < children.Count; k++) { var c = children[k]; + c.SlotInner = Math.Max(lo, outer * q); + c.SlotOuter = k + 1 < children.Count ? Math.Min(upper, children[k + 1].Distance / q) : upper; if (!c.Belt) { outer = c.Distance; continue; } - var inner = Math.Max(lo, outer * q); - var outerLimit = k + 1 < children.Count ? Math.Min(upper, children[k + 1].Distance / q) : upper; + var inner = c.SlotInner; + var outerLimit = c.SlotOuter; c.Distance = (inner + outerLimit) / 2; var half = Math.Max(0f, Math.Min(c.Settings.AsteroidBeltWidth.Evaluate(c.Distance), outerLimit - inner) / 2); c.BeltHalfWidth = half; @@ -765,6 +830,11 @@ public class GeneratorPlanet public float BeltHalfWidth; public bool Empty = false; public bool Belt = false; + // The interval Place gives a child between its previous sibling's spacing and the next sibling's spacing or the window; zero + // on a ring member, which has no slot. + public float SlotInner, SlotOuter; + // A childless light body, not a binary or root rosette member, that could have become a belt (set by GenerateEntities). + public bool BeltCandidate; // A member of a rosette's ring, which Place puts on one orbit round its bodyless parent. public bool Ring = false; public List Children = new List(); // Planets orbiting this one are referred to as children diff --git a/tests/Aetheria.Shared.Tests/BeltShapes.cs b/tests/Aetheria.Shared.Tests/BeltShapes.cs index 79f24ddfc..5322b1f0a 100644 --- a/tests/Aetheria.Shared.Tests/BeltShapes.cs +++ b/tests/Aetheria.Shared.Tests/BeltShapes.cs @@ -1,8 +1,10 @@ +using System.Collections.Generic; using System.Linq; using GameCult.Caching; -// The belt shapes a test reads back from a built zone. A swarm's rail is a copy of its host's rail (same Parent, Distance and -// Phase), never a slot Place laid out, so the tests that walk placed orbits leave copied rails out through this one helper. +// The belt shapes a test reads back from a built zone. A swarm's or a lane's rail is a copy of its host's rail (same Parent, +// Distance and Phase), never a slot Place laid out, so the tests that walk placed orbits leave copied rails out through this one +// helper. public static class BeltShapes { public static bool CopiedRail(Zone zone, CultRecordKey orbit) @@ -12,4 +14,19 @@ public static class BeltShapes return zone.Planets.Values.Where(b => !(b is AsteroidBeltData)).Select(b => zone.Orbits[b.Orbit.Key].Data) .Any(o => o.Parent.Key.Equals(rail.Parent.Key) && o.Distance == rail.Distance && o.Phase == rail.Phase); } + + // Every laned belt with its rail and its host: a belt on a copied rail that does not ride the rail (a swarm does), whose host + // is the body orbit the rail copies. + public static IEnumerable<(Orbit Host, Orbit Rail, AsteroidBelt Belt)> Lanes(Zone zone) + { + foreach (var (key, belt) in zone.AsteroidBelts.Where(b => !b.Value.Data.RidesRail)) + { + var railKey = zone.Planets[key].Orbit.Key; + if (!CopiedRail(zone, railKey)) continue; + var rail = zone.Orbits[railKey]; + var host = zone.Planets.Values.Where(b => !(b is AsteroidBeltData)).Select(b => zone.Orbits[b.Orbit.Key]) + .Single(o => o.Data.Parent.Key.Equals(rail.Data.Parent.Key) && o.Data.Distance == rail.Data.Distance && o.Data.Phase == rail.Data.Phase); + yield return (host, rail, belt); + } + } } diff --git a/tests/Aetheria.Shared.Tests/ChildrenWithinReachTests.cs b/tests/Aetheria.Shared.Tests/ChildrenWithinReachTests.cs index ae1e8538c..a5ab3ebc3 100644 --- a/tests/Aetheria.Shared.Tests/ChildrenWithinReachTests.cs +++ b/tests/Aetheria.Shared.Tests/ChildrenWithinReachTests.cs @@ -88,7 +88,7 @@ public sealed class ChildrenWithinReachTests : IClassFixture Placed(Zone zone) + internal static HashSet Placed(Zone zone) { var placed = new HashSet(); foreach (var body in zone.Planets.Values.Where(b => !BeltShapes.CopiedRail(zone, b.Orbit.Key))) @@ -96,7 +96,7 @@ public sealed class ChildrenWithinReachTests : IClassFixture ChildrenOf(Zone zone, CultRecordKey parent, HashSet placed) => + internal static List ChildrenOf(Zone zone, CultRecordKey parent, HashSet placed) => placed.Where(k => zone.Orbits[k].Data.Parent.IsSet() && zone.Orbits[k].Data.Parent.Key.Equals(parent)) .OrderBy(k => zone.Orbits[k].Data.Distance).ToList(); @@ -455,17 +455,6 @@ public sealed class ChildrenWithinReachTests : IClassFixture AuthoredSettings.Load(RunStartTests.FindRepoRoot()).Read("PlanetSettings"); - // A belt's field kind is chosen at generation and saved with it (Mining Cut 3, Q16 B): every belt the generator writes, - // annulus or swarm, carries one before any Zone reads it (finding ensure-unpinned). - [Fact] - public void EveryBeltIsKindedAtGeneration() - { - _out.WriteLine($"belts {_zones.Belts}, unkinded at generation {_zones.UnkindedAtGeneration}"); - Assert.True(_zones.All.Any(z => z.Zone.AsteroidBelts.Values.Any(b => b.Data.RidesRail)), "no swarm in the sample"); - Assert.True(_zones.Belts > 20, $"only {_zones.Belts} belts generated"); - Assert.Equal(0, _zones.UnkindedAtGeneration); - } - // Two belts side by side under one parent (production never draws them; follow-up belt-after-belt-edge-pin): the second // belt's slot starts a sibling spacing outside the first belt's outer edge, not its rail, so their rock intervals keep a spacing. [Fact] @@ -489,6 +478,8 @@ public sealed class ChildrenWithinReachTests : IClassFixture +{ + private const uint First = 1, Last = 6; + private const float Tolerance = 1e-3f; + + public sealed class Samples : IDisposable + { + public readonly ChildrenWithinReachTests.Zones Authored = new ChildrenWithinReachTests.Zones(First, Last, null); + public readonly ChildrenWithinReachTests.Zones Laneless = new ChildrenWithinReachTests.Zones(First, Last, z => z.LaneProbability = 0); + + public void Dispose() + { + Authored.Dispose(); + Laneless.Dispose(); + } + } + + private readonly Samples _samples; + private readonly ITestOutputHelper _out; + + public LanedBeltTests(Samples samples, ITestOutputHelper output) + { + _samples = samples; + _out = output; + } + + private static CultRecordKey KeyOf(Zone zone, Orbit orbit) => zone.Orbits.First(o => o.Value == orbit).Key; + + private static BodyData BodyAt(Zone zone, Orbit orbit) => + zone.Planets.Values.Single(b => !(b is AsteroidBeltData) && zone.Orbits[b.Orbit.Key] == orbit); + + // The lane's half-width, spelled from the settings: LaneBodyRadii of the host's body radius. + private float HalfWidth(Zone zone, float hostMass) => _samples.Authored.Settings.LaneBodyRadii * zone.Settings.BodyRadius.Evaluate(hostMass); + + [Fact] + public void ALaneMovesNothingElseInTheZone() + { + var a = _samples.Laneless.All; + var b = _samples.Authored.All; + Assert.Equal(a.Count, b.Count); + var lanes = 0; + for (var n = 0; n < a.Count; n++) + { + Assert.Equal(a[n].Seed, b[n].Seed); + Assert.Empty(BeltShapes.Lanes(a[n].Zone)); + var zone = b[n].Zone; + var laned = BeltShapes.Lanes(zone).ToList(); + lanes += laned.Count; + bool SkipOrbit(CultRecordKey k) => laned.Any(l => l.Rail == zone.Orbits[k]); + bool SkipBody(BodyData body) => laned.Any(l => l.Belt.Data == body); + Assert.Equal(ZoneFingerprint.Of(new[] { a[n].Zone }, _ => false, _ => false, true), ZoneFingerprint.Of(new[] { zone }, SkipOrbit, SkipBody, true)); + } + _out.WriteLine($"zones {b.Count}, lanes {lanes}"); + Assert.True(lanes > 0, $"no lane in seeds {First}-{Last}"); + } + + [Fact] + public void ALaneIsEmptyAndBothSidesHoldRocks() + { + var s = _samples.Authored.Settings; + var q = 1 / (1 - GravityField.Reach / 2); + var lanes = 0; + foreach (var (seed, zone) in _samples.Authored.All) + { + var placed = ChildrenWithinReachTests.Placed(zone); + foreach (var (host, _, belt) in BeltShapes.Lanes(zone)) + { + lanes++; + Assert.False(belt.Data.RidesRail); + Assert.Equal(0f, belt.Data.Mass); + var hostMass = BodyAt(zone, host).Mass; + var d = host.Data.Distance; + var e = HalfWidth(zone, hostMass); + Assert.Equal((int) s.AsteroidCount.Evaluate(hostMass * d), belt.Data.Asteroids.Length); + + // The host's slot, spelled from its siblings, its parent's Roche margin and its parent's window. + var parent = host.Data.Parent.Key; + var sibling = ChildrenWithinReachTests.ChildrenOf(zone, parent, placed); + var at = sibling.IndexOf(KeyOf(zone, host)); + Assert.True(at >= 0 && at < sibling.Count - 1, $"seed {seed}: a lane host is its parent's outermost child"); + var parentBody = zone.Planets.Values.FirstOrDefault(p => !(p is AsteroidBeltData) && p.Orbit.Key.Equals(parent)); + var roche = parentBody == null ? 0f : 1.5f * zone.Settings.RocheLimit.Evaluate(parentBody.Mass); + var innerBound = Math.Max(roche, at > 0 ? zone.Orbits[sibling[at - 1]].Data.Distance * q : 0f); + var grand = zone.Orbits[parent].Data.Parent; + var window = grand.IsSet() + ? GravityField.Gamma * GravityField.Reach * zone.Orbits[parent].Data.Distance / Math.Max(GravityField.Beta, 1 + GravityField.Reach) + : float.PositiveInfinity; + var outerBound = Math.Min(window, zone.Orbits[sibling[at + 1]].Data.Distance / q); + + var rocks = belt.Data.Asteroids.Select(r => r.Distance).ToList(); + var inner = rocks.Where(r => r < d).ToList(); + var outer = rocks.Where(r => r > d).ToList(); + _out.WriteLine($"seed {seed}: lane at {d} half-width {e}, slot [{innerBound}, {outerBound}], rocks {inner.Count} inside, {outer.Count} outside"); + Assert.True(rocks.All(r => Math.Abs(r - d) >= e - 1e-5f * d), $"seed {seed}: a rock sits {rocks.Min(r => Math.Abs(r - d))} from a host at {d}, inside a lane of {e}"); + Assert.True(inner.Count > 0 && outer.Count > 0, $"seed {seed}: a lane at {d} holds {inner.Count} rocks inside and {outer.Count} outside"); + Assert.True(d - e - innerBound >= e * (1 - Tolerance), $"seed {seed}: the inner band [{innerBound}, {d - e}] is narrower than the lane's half-width {e}"); + Assert.True(outerBound - d - e >= e * (1 - Tolerance), $"seed {seed}: the outer band [{d + e}, {outerBound}] is narrower than the lane's half-width {e}"); + Assert.True(inner.Min() >= innerBound * (1 - Tolerance), $"seed {seed}: a rock at {inner.Min()} reaches inside the slot's inner bound {innerBound}"); + Assert.True(outer.Max() <= outerBound * (1 + Tolerance), $"seed {seed}: a rock at {outer.Max()} reaches past the slot's outer bound {outerBound}"); + } + } + Assert.True(lanes > 0, $"no lane in seeds {First}-{Last}"); + } + + [Fact] + public void ALaneHostIsALightBodyWithNoOtherShape() + { + var s = _samples.Authored.Settings; + var lanes = 0; + foreach (var (seed, zone) in _samples.Authored.All) + { + var laned = BeltShapes.Lanes(zone).ToList(); + foreach (var (host, _, _) in laned) + { + lanes++; + var body = BodyAt(zone, host); + Assert.True(body.GetType() == typeof(PlanetData) && body.Mass < s.BeltMassCeiling, $"seed {seed}: a lane host is a {body.GetType().Name} of mass {body.Mass}"); + var key = KeyOf(zone, host); + Assert.DoesNotContain(zone.Orbits.Values, o => o.Data.Parent.IsSet() && o.Data.Parent.Key.Equals(key)); + // A swarm's rail is a copy of its host's too: no lane host carries one. + Assert.DoesNotContain(zone.AsteroidBelts.Where(b => b.Value.Data.RidesRail).Select(b => zone.Orbits[zone.Planets[b.Key].Orbit.Key].Data), + r => r.Parent.Key.Equals(host.Data.Parent.Key) && r.Distance == host.Data.Distance && r.Phase == host.Data.Phase); + } + // One belt shape per parent: an annulus (a belt on a placed slot) or a lane, never both, never two lanes. + var annuli = zone.AsteroidBelts.Keys.Select(k => zone.Planets[k].Orbit.Key).Where(o => !BeltShapes.CopiedRail(zone, o)) + .Select(o => zone.Orbits[o].Data.Parent.Key); + var doubled = annuli.Concat(laned.Select(l => l.Host.Data.Parent.Key)).GroupBy(p => p).Count(g => g.Count() > 1); + Assert.True(doubled == 0, $"seed {seed}: {doubled} parents carry two belt shapes"); + } + Assert.True(lanes > 0, $"no lane in seeds {First}-{Last}"); + } + + // A belt's field kind is chosen at generation and saved with it (Mining Cut 3, Q16 B): every belt the generator writes, + // annulus, swarm or lane, carries one before any Zone reads it (finding ensure-unpinned). + [Fact] + public void EveryBeltIsKindedAtGeneration() + { + var zones = _samples.Authored; + _out.WriteLine($"belts {zones.Belts}, unkinded at generation {zones.UnkindedAtGeneration}"); + Assert.True(zones.All.Any(z => z.Zone.AsteroidBelts.Values.Any(b => b.Data.RidesRail)), "no swarm in the sample"); + Assert.True(zones.All.Any(z => BeltShapes.Lanes(z.Zone).Any()), "no lane in the sample"); + Assert.True(zones.Belts > 20, $"only {zones.Belts} belts generated"); + Assert.Equal(0, zones.UnkindedAtGeneration); + } +} diff --git a/tests/Aetheria.Shared.Tests/TrojanSwarmTests.cs b/tests/Aetheria.Shared.Tests/TrojanSwarmTests.cs index d5428481a..0c7e7487a 100644 --- a/tests/Aetheria.Shared.Tests/TrojanSwarmTests.cs +++ b/tests/Aetheria.Shared.Tests/TrojanSwarmTests.cs @@ -98,7 +98,8 @@ public class TrojanSwarmTests } // The widened digests (ZoneFingerprint with PopulatedRadius) were computed on Yggdrasil over the generator of cut laned-belts - // C1 (one belt extent rule) with swarms off, over the zones of each seed: a zone with no swarm is the base zone, and a zone with + // C1 (one belt extent rule) with swarms off, over the zones of each seed; both samples draw no lane, so the base is the zone + // with neither shape: a zone with no swarm is the base zone, and a zone with // swarms is the base zone plus them. The per-zone comparison leaves PopulatedRadius out, since a swarm's outer rock may grow it. private static readonly Dictionary BaseDigest = new Dictionary { @@ -113,8 +114,8 @@ public class TrojanSwarmTests [Fact] public void ASwarmMovesNothingElseInTheZone() { - using var swarmless = new ChildrenWithinReachTests.Zones(First, Last, z => z.TrojanProbability = 0); - using var live = new ChildrenWithinReachTests.Zones(First, Last, null); + using var swarmless = new ChildrenWithinReachTests.Zones(First, Last, z => { z.TrojanProbability = 0; z.LaneProbability = 0; }); + using var live = new ChildrenWithinReachTests.Zones(First, Last, z => z.LaneProbability = 0); Assert.Equal(swarmless.All.Count, live.All.Count); int swarms = 0, grew = 0; foreach (var seed in BaseDigest.Keys) From a46c0536cc3e86d69f31335784847c7c2be1dffe Mon Sep 17 00:00:00 2001 From: soul Date: Wed, 21 Oct 2026 23:25:26 +0200 Subject: [PATCH 023/307] release map: ProductWeight's line share counts only offered bins Co-Authored-By: Claude Opus 5.5 --- docs/aetheria-release-map.md | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/docs/aetheria-release-map.md b/docs/aetheria-release-map.md index 848ab38f5..3176c3163 100644 --- a/docs/aetheria-release-map.md +++ b/docs/aetheria-release-map.md @@ -11034,7 +11034,9 @@ presence for the design does not change when it bins a line. - Lot (unchanged schema): a binned lot records the binned product, and its fills and assembly sit at or above the floor of the line's distributions. - ProductWeight: the maker's preference times a share. A bin's share is its pass fraction `1 - Phi(Bin.Floor)`. A line's - share is 1 minus the sum of the pass fractions of its bins, floored at 0. Any other product's share is 1. Two + share is 1 minus the sum of the pass fractions of its offered bins, floored at 0, so an off-market bin takes nothing + from its line (cut product-not-offered; a bin of an off-market line is itself off-market, ruling + bin-inherits-line-offered). Any other product's share is 1. Two unbinned lines of one design and maker each weigh the full preference, as before. ProductWeight is still the one weight of RandomProducts and ChooseProduct. From 462adf26939c4d672a9b3f73b24c66fc65a6cba5 Mon Sep 17 00:00:00 2001 From: soul Date: Wed, 21 Oct 2026 23:38:03 +0200 Subject: [PATCH 024/307] determinism-ledger: pin each scatter draw's ordinal and bounds, and the zero item-seed refusal Three origin drifters, each placed at For(CombatSeed, ScatterStream, k) within half the radius; a galaxy seed whose ContinueStep item seed wraps to zero is refused. Co-Authored-By: Claude Opus 5.5 --- .../DeterminismLedgerTests.cs | 59 +++++++++++++++++-- 1 file changed, 53 insertions(+), 6 deletions(-) diff --git a/tests/Aetheria.Shared.Tests/DeterminismLedgerTests.cs b/tests/Aetheria.Shared.Tests/DeterminismLedgerTests.cs index 0a5636720..1a1a64b1e 100644 --- a/tests/Aetheria.Shared.Tests/DeterminismLedgerTests.cs +++ b/tests/Aetheria.Shared.Tests/DeterminismLedgerTests.cs @@ -60,19 +60,40 @@ public sealed partial class RunStartTests Assert.Equal(expected.Random.state, items.Random.state); } - // A pack holding one unpiloted ship at the origin besides the player, from a staged arena. + private const int OriginDrifters = 3; + + // The galaxy seed whose ContinueStep item seed wraps to zero: -ContinueStep times the inverse of 0x9E3779B1, mod 2^32. + private const uint ZeroAtContinueSeed = 0xD45E720Du; + + [Fact] + public void ItemSeedRefusesAZeroSeed() + { + var galaxy = Launch(new Scripted(false, stage => stage.Player(stage.Bare("Djinni"), float2(0, 0)), + seed: ZeroAtContinueSeed)).galaxy; + Assert.Equal(ZeroAtContinueSeed, galaxy.Seed); + Assert.NotEqual(0u, RunStart.ItemSeed(galaxy, RunStart.ArenaStep)); + var refused = Assert.Throws(() => RunStart.ItemSeed(galaxy, RunStart.ContinueStep)); + Assert.Contains("zero", refused.Message); + } + + // A pack holding three unpiloted ships at the origin besides the player, from a staged arena. More than one, so + // each ship's own ordinal is visible: ships sharing a draw would stack on one point. private (ZonePack pack, Galaxy galaxy) OriginPack() { - Ship drifter = null; + var drifters = new List(); var (galaxy, arena, staged, failures) = Launch(new Scripted(false, stage => { stage.Player(stage.Bare("Djinni"), float2(-50000, -50000)); - drifter = stage.Place(stage.Bare("Longinus"), float2(0, 0)) as Ship; + for (var i = 0; i < OriginDrifters; i++) + drifters.Add(stage.Place(stage.Bare("Longinus"), float2(0, 0)) as Ship); })); Assert.True(failures.Count == 0, string.Join("; ", failures)); - Assert.NotNull(drifter); - Assert.False(drifter.IsPlayerShip); - Assert.True(lengthsq(drifter.Position) < 1, "the fixture's drifter is packed at the origin"); + Assert.All(drifters, drifter => + { + Assert.NotNull(drifter); + Assert.False(drifter.IsPlayerShip); + Assert.True(lengthsq(drifter.Position) < 1, "the fixture's drifters are packed at the origin"); + }); return (arena.PackZone(), galaxy); } @@ -97,6 +118,32 @@ public sealed partial class RunStartTests Assert.Equal(first, second); } + // Each origin ship is placed by its own draw: the 1-based ordinal of its entry in the pack, on the zone's + // ScatterStream, within half the zone's radius of the centre on every axis. + [Fact] + public void ScatterDrawsEachShipsOrdinalWithinHalfTheRadius() + { + var (pack, galaxy) = OriginPack(); + var zone = new Zone(_items, _planetSettings, pack, galaxy.Entrance, galaxy); + Assert.Equal(pack.Entities.Count, zone.Entities.Count); + + var half = pack.Radius * .5f; + var expected = new List(); + var actual = new List(); + for (var i = 0; i < zone.Entities.Count; i++) + { + if (zone.Entities[i] is not Ship {IsPlayerShip: false} ship) continue; + expected.Add(SimulationDice.For(zone.CombatSeed, SimulationDice.ScatterStream, (uint) i + 1) + .NextFloat3(float3(-half), float3(half))); + actual.Add(ship.Position); + } + + Assert.Equal(OriginDrifters, actual.Count); + Assert.Equal(expected, actual); + Assert.Equal(OriginDrifters, actual.Distinct().Count()); + Assert.All(actual, position => Assert.True(all(abs(position) <= half), $"{position} is within half the radius {half}")); + } + [Fact] public void ScatterIsPerZone() { From 2fe4129777f7b58d2950b3c81a5727f1b1dc7695 Mon Sep 17 00:00:00 2001 From: soul Date: Wed, 21 Oct 2026 23:40:26 +0200 Subject: [PATCH 025/307] Zhestokost kit: land the hull-bench kit; zh-ammo-tender fixture rebuilt from Oboz A with one defensive mount factions/zhestokost.scad is the bench draft byte for byte (battered masses, typed mates, five zones with cream marking), its lang comment naming the maker element. The fixture is bench Oboz A plus Gun.D on T[2]'s deck centreline aft of its hatches; the smoke's palette, shadow colour and turned-mount pair follow. Spec: aetheria-release:cut_spec:cut-zh-kit-landing.r1 Co-Authored-By: Claude Opus 5.5 --- .../aetheria_ships/factions/zhestokost.scad | 243 ++++++++++++++---- .../fixtures/hulls/zh-ammo-tender/hull.scad | 113 +++++--- tools/blender/tests/smoke_hull_source.py | 7 +- 3 files changed, 273 insertions(+), 90 deletions(-) diff --git a/tools/blender/aetheria_ships/factions/zhestokost.scad b/tools/blender/aetheria_ships/factions/zhestokost.scad index 9c4e65bb9..752559e71 100644 --- a/tools/blender/aetheria_ships/factions/zhestokost.scad +++ b/tools/blender/aetheria_ships/factions/zhestokost.scad @@ -1,62 +1,209 @@ -// Zhestokost form language: the one owner of this faction's shape kit, proportions, symmetry rule and palette. -// Canon. Operator ruling zhestokost-language-lore-text: Zhestokost is industrial and brutalist, "the dream of the -// eternal Soviet, a society that has achieved total and unassailable permaculture via a carefully organized and -// brutally practical (self-policing) state". Form follows the lore page ("Faction Flavor and Visual Identity" > -// Zhestokost): low broad stepped masses, deep seams, charcoal, iron grey, dull oxide red. The operator's faction sheet -// (Asset Sources/Zhestokost.png) lends the handling boom with hanging magazines as vocabulary, not its palette. -// A hull composes these modules; nothing here places itself. +// Zhestokost form language: the one owner of this faction's part kit, proportions, symmetry rule and palette. +// Operator ruling zhestokost-language-lore-text: "The core design pillars were simply industrial and brutalist ... +// Zhestokost is the dream of the eternal Soviet, a society that has achieved total and unassailable permaculture via a +// carefully organized and brutally practical (self-policing) state." Lore (Faction Flavor > Zhestokost > Visual +// Identity): low broad stepped masses, thick armour round recessed machinery, apparent weight from deep seams and +// substantial supports rather than clutter; charcoal, iron grey, dull oxide red, small cream serials and safety marks. +// +// Brutalist is not "boxes of uniform weight". The vocabulary here is poured and cast concrete-and-iron: +// * MASSES are battered (walls slope in to the deck and in to the belly from a vertical cast belt at the waist), so +// every mass reads as poured, with a lit deck, a lit upper wall, a dark belt line and a shadowed belly. +// * HIERARCHY: one ship = a narrow CORE plus a few heavy repeated MODULES wider than the core, so the plan is +// castellated (wide-narrow-wide), never a plain rectangle. Modules step up in TIERS. +// * SHADOW GAPS: modules stand apart across deep reveals; the reveal floor is the recess zone, so the gap reads black +// from above even at 48 px. +// * Fittings are few, thick and functional: hatches with heavy hinge bars, collars ringed with lugs, a tow eye. +// +// How a Zhestokost hull is built (a hull composes these modules; nothing here places itself): +// 1. A MASS is a list of sections nose (-Y) to stern (+Y): [y, width, height, base z, deck inset, belly inset] +// (zh_s). Make lists with zh_plan (a core or hull), zh_block (a module), zh_tier (a stepped tier on any mass); +// draw them with zh_mass. Keep the lists: every other part mates to them. +// 2. Every other part is placed ON a mass by a typed mate: zh_on_top, zh_on_wall, zh_on_belt, zh_on_belly, +// zh_on_stern, zh_on_nose. In the mate frame local +Z is the outward normal and the part is sunk ZH_SINK into +// the skin, so nothing floats. Top/belly frames keep local +Y aft; wall/belt frames keep +Y aft with local +X +// pointing down the wall on either side (starboard mates are mirrors of port); stern/nose frames have local +Y down. +// 3. Zone routing: every zh_ module draws only the zone the global `part` names, so a hull writes its assembly once. +// Anchor meshes (zh_rad_vanes, zh_emitter) are not routed: call them inside the hull's rad:/emit: parts under +// the same mate that placed their housing. +// Scale: chamfers are cast-heavy (ZH_CH); fittings have standard sizes (ZH_HATCH, ZH_COLLAR_R, ZH_LUG) shared by +// every hull so the family reads. include -ZH_CH = 0.22; // chamfer: heavy, square, one segment -// A stepped mass: tiers shrink upward and pull in from both ends. "Low, broad ... stepped rectangular masses." -module zh_stepped(L, W, H, tiers=3, pull=0.12) { - for (i=[0:tiers-1]) let(k=i/tiers) - translate([0, 0, H*k + H/tiers/2]) cbox([W*(1-pull*i*1.6), L*(1-pull*i), H/tiers + 0.02], ZH_CH); + +ZH_WL = 0.30; // cast belt: from this fraction of section height ... +ZH_WU = 0.50; // ... to this one; above it the wall batters in to the deck, below it in to the belly +ZH_CH = 0.20; // edge chamfer for fittings +ZH_SINK = 0.05; // mates sink parts this far into the skin +ZH_HATCH = 2.6; // standard magazine hatch (m, square) +ZH_COLLAR_R = 1.05; // standard transfer collar radius +ZH_LUG = 1.0; // standard tow lug scale + +module zh_z(z) { if (part == z) children(); } + +// ==== geometry core: sections, surface points, mates ================================================================= +// A section. bt: deck inset (how far the upper wall batters in), bb: belly inset. Defaults keep the faction's batter. +function zh_s(y, w, h, z0=0, bt=undef, bb=undef) = + [y, w, h, z0, is_undef(bt) ? h*0.26 : bt, is_undef(bb) ? h*0.22 : bb]; +function zh_poly(s) = let(w = s[1]/2, h = s[2], z = s[3], t = s[4], b = s[5]) + [[-(w-b), z], [w-b, z], [w, z+h*ZH_WL], [w, z+h*ZH_WU], [w-t, z+h], [-(w-t), z+h], [-w, z+h*ZH_WU], [-w, z+h*ZH_WL]]; +module _zh_slice(s) translate([0, s[0], 0]) rotate([90, 0, 0]) linear_extrude(0.002, center=true) polygon(zh_poly(s)); + +function _zh_i(secs, y) = let(ix = [for (k = [0:len(secs)-2]) if (secs[k][0] <= y) k]) len(ix) ? ix[len(ix)-1] : 0; +// the mass's section at station y (linear between stations, as the mesh is) +function zh_sec(secs, y) = let(i = _zh_i(secs, y), a = secs[i], b = secs[i+1], + f = max(0, min(1, (y - a[0]) / (b[0] - a[0])))) a + (b - a) * f; +function zh_y0(secs) = secs[0][0]; +function zh_y1(secs) = secs[len(secs)-1][0]; + +// Surface points. u: -1 starboard .. 1 port across the deck or belly; v: 0..1 up a wall or belt; side: 1 port, -1 stbd. +function zh_top(secs, y, u=0) = let(s = zh_sec(secs, y)) [u*(s[1]/2 - s[4]), y, s[3] + s[2]]; +function zh_belly(secs, y, u=0) = let(s = zh_sec(secs, y)) [u*(s[1]/2 - s[5]), y, s[3]]; +function zh_belt(secs, y, side=1, v=0.5) = let(s = zh_sec(secs, y)) [side*s[1]/2, y, s[3] + s[2]*(ZH_WL + (ZH_WU-ZH_WL)*v)]; +function zh_wall(secs, y, side=1, v=0.5) = let(s = zh_sec(secs, y)) [side*(s[1]/2 - s[4]*v), y, s[3] + s[2]*(ZH_WU + (1-ZH_WU)*v)]; +function zh_wall_tilt(secs, y) = let(s = zh_sec(secs, y)) atan2(s[4], s[2]*(1-ZH_WU)); // degrees off vertical +function zh_stern_pt(secs, x=0, v=0.5) = let(s = secs[len(secs)-1]) [x, s[0], s[3] + s[2]*v]; +function zh_nose_pt(secs, x=0, v=0.5) = let(s = secs[0]) [x, s[0], s[3] + s[2]*v]; + +module zh_on_top(secs, y, u=0, sink=ZH_SINK) translate(zh_top(secs, y, u) - [0, 0, sink]) children(); +module zh_on_belly(secs, y, u=0, sink=ZH_SINK) translate(zh_belly(secs, y, u) + [0, 0, sink]) rotate([0, 180, 0]) children(); +module _zh_port(side) { if (side < 0) mirror([1, 0, 0]) children(); else children(); } +module zh_on_belt(secs, y, side=1, v=0.5, sink=ZH_SINK) + _zh_port(side) translate(zh_belt(secs, y, 1, v) - [sink, 0, 0]) rotate([0, 90, 0]) children(); +module zh_on_wall(secs, y, side=1, v=0.5, sink=ZH_SINK) { + t = zh_wall_tilt(secs, y); + _zh_port(side) translate(zh_wall(secs, y, 1, v) - [cos(t), 0, sin(t)]*sink) rotate([0, 90 - t, 0]) children(); } -// Armour plate standing proud of a face, leaving a deep seam (the seam is the gap to the next plate). -module zh_plate(w, l, t=0.3) { cbox([w, l, t], 0.08); } -// Recessed machinery: a dark box set into a mass (subtract the pocket in the hull, then place this inside). -module zh_recess(w, l, d) { cube([w, l, d], center=true); } -// Ammunition magazine: square cassette with ribbed loading face and a hanger lug on top. -module zh_cassette(w=1.2, l=1.6, h=2.0) { - cbox([w, l, h], 0.12); - for (z=[-h*0.3, 0, h*0.3]) translate([0, -l/2-0.04, z]) cube([w*0.8, 0.12, 0.18], center=true); - translate([0, 0, h/2+0.2]) cube([0.3, 0.6, 0.4], center=true); +module zh_on_stern(secs, x=0, v=0.5, sink=ZH_SINK) translate(zh_stern_pt(secs, x, v) - [0, sink, 0]) rotate([-90, 0, 0]) children(); +module zh_on_nose(secs, x=0, v=0.5, sink=ZH_SINK) translate(zh_nose_pt(secs, x, v) + [0, sink, 0]) rotate([90, 0, 0]) rotate([0, 0, 180]) children(); +// world point of a local offset under a mate (for anchors): top, belly, stern frames +function zh_top_at(secs, y, u, o, sink=ZH_SINK) = zh_top(secs, y, u) - [0, 0, sink] + o; +function zh_belly_at(secs, y, u, o, sink=ZH_SINK) = zh_belly(secs, y, u) + [0, 0, sink] + [-o[0], o[1], -o[2]]; +function zh_stern_at(secs, x, v, o, sink=ZH_SINK) = zh_stern_pt(secs, x, v) - [0, sink, 0] + [o[0], o[2], -o[1]]; + +// ==== PRIMARY: masses that make the silhouette ======================================================================== +// Any section list as one faceted poured mass. +module zh_mass(secs, zone="primary") zh_z(zone) for (i = [0:len(secs)-2]) hull() { _zh_slice(secs[i]); _zh_slice(secs[i+1]); } + +// A core or whole hull: bow chamfered in plan and dropped (bow, as a fraction of L), stern a flat face with a cast lip. +// bowW / bowH: nose width and height as fractions of W and H. +function zh_plan(L, W, H, z0=0, y0=0, bow=0.2, bowW=0.55, bowH=0.7, bt=undef, bb=undef) = let(n = y0 - L/2, s = y0 + L/2) + [zh_s(n, W*bowW, H*bowH, z0 + H*(1-bowH)*0.55, bt, bb), zh_s(n + L*bow, W, H, z0, bt, bb), + zh_s(s - 0.35, W, H, z0, bt, bb), zh_s(s, W - 0.5, H - 0.3, z0 + 0.15, bt, bb)]; + +// A heavy repeated module (magazine, reactor block, barracks): chamfered in plan at both ends by ch. +function zh_block(y, l, w, h, z0=0, bt=undef, bb=undef, ch=0.5) = + let(t = is_undef(bt) ? h*0.24 : bt, b = is_undef(bb) ? h*0.2 : bb) + [zh_s(y - l/2, w - 2*ch, h - ch, z0 + ch*0.5, max(0.05, t - ch*0.5), max(0.05, b - ch*0.5)), + zh_s(y - l/2 + ch, w, h, z0, t, b), zh_s(y + l/2 - ch, w, h, z0, t, b), + zh_s(y + l/2, w - 2*ch, h - ch, z0 + ch*0.5, max(0.05, t - ch*0.5), max(0.05, b - ch*0.5))]; + +// A stepped tier poured on top of any mass: inset from the deck edge, ends pulled in by yin, h tall. +function zh_tier(secs, inset=0.6, h=0.7, yin=0.6, bt=undef) = let(n = len(secs)) + [for (i = [0:n-1]) let(s = secs[i], y = s[0] + (i == 0 ? yin : i == n-1 ? -yin : 0), d = zh_sec(secs, y)) + zh_s(y, d[1] - 2*d[4] - 2*inset, h, d[3] + d[2] - 0.02, is_undef(bt) ? h*0.4 : bt, 0)]; + +// ==== SECONDARY: forms that give a hull its individuality ============================================================= +// Low armoured cab (top frame): battered block, a deep cast brow over a black vision slit. Nose is -Y. +module zh_cab(w=3.6, l=3.0, h=1.5, zone="secondary") { + zh_mass(zh_block(0, l, w, h, 0, h*0.32, 0, 0.4), zone); + zh_z(zone) translate([0, -l/2 + 0.05, h*0.74]) cbox([w*0.84, 0.9, 0.34], 0.08); // brow + zh_z("recess") translate([0, -l/2 + 0.32, h*0.5]) cube([w*0.66, 0.6, 0.26], center=true); // slit, in the brow's shadow } -// Handling boom: square box beam with cross-members; magazines hang from it. -module zh_boom(len, w=0.9, h=0.7) { - cbox([w, len, h], 0.1); - for (y=[-len/2+0.6:1.6:len/2-0.4]) translate([0, y, -h/2-0.1]) cube([w+0.4, 0.25, 0.25], center=true); + +// Squat drive pod (belly frame: local +Z is down, away from the hull): battered block, nozzle mouth on its aft face. +module zh_pod(w=2.0, l=3.2, h=1.4, zone="secondary") { + zh_mass(zh_block(0, l, w, h, 0, h*0.18, h*0.22, 0.35), zone); + zh_z(zone) translate([0, l/2 + 0.08, h*0.5]) difference() { // cast nozzle frame + cbox([w*0.86, 0.36, h*0.78], 0.08); cube([w*0.56, 1, h*0.5], center=true); } + zh_z("recess") translate([0, l/2 - 0.02, h*0.5]) cube([w*0.58, 0.3, h*0.52], center=true); } -// Towing point: a thick eye on a block. -module zh_tow_lug() { cbox([0.6, 0.6, 0.4], 0.08); translate([0,0,0.45]) rotate([0,90,0]) difference() { cylinder(r=0.38, h=0.25, center=true, $fn=8); cylinder(r=0.18, h=1, center=true, $fn=8); } } -// Ballistic mount: square base, slab mantlet, N thick barrels with a box muzzle brake. Barrels point -Y (nose). -module zh_gun(n=2, cal=0.22, len=3.0, base=1.6) { - cbox([base, base, 0.5], 0.12); - translate([0, 0, 0.55]) cbox([base*0.85, base*0.7, 0.7], 0.12); - for (i=[0:n-1]) translate([(i-(n-1)/2)*cal*3, -base*0.3, 0.6]) { - rotate([0,0,180]) tube(cal, len, 8); +function zh_pod_mouth(w=2.0, l=3.2, h=1.4) = [0, l/2 + 0.14, h*0.5]; +// thruster-emitter mesh at the pod's (or any) nozzle mouth: call under the same mate, translated to zh_pod_mouth +module zh_emitter(w, h) cube([w, 0.05, h], center=true); + +// Transfer collar (belt or wall frame): thick octagonal ring proud of the skin, black bore, guide lugs in oxide. +module zh_collar(r=ZH_COLLAR_R, proud=0.55, lugs=4) { + zh_z("secondary") difference() { + cylinder(r=r, h=proud + ZH_SINK, $fn=8); translate([0, 0, -0.1]) cylinder(r=r*0.6, h=proud + 1, $fn=8); } + zh_z("recess") cylinder(r=r*0.62, h=0.08 + ZH_SINK, $fn=8); + zh_z("accent") for (i = [0:lugs-1]) rotate(i*360/lugs) translate([r + 0.16, 0, (proud + 0.25)/2]) + cube([0.5, 0.6, proud + 0.25 + ZH_SINK], center=true); +} + +// Magazine hatch (any frame, +Z out): oxide slab, two heavy hinge bars and four dogs in charcoal, cream stripe at aft edge. +// dogs=false for a small or secondary hatch (saves ~100 triangles). +module zh_hatch(w=ZH_HATCH, l=ZH_HATCH, t=0.22, dogs=true) { + zh_z("accent") translate([0, 0, t/2]) cbox([w, l, t + ZH_SINK], 0.06); + zh_z("secondary") { + for (x = [-1, 1]) translate([x*w*0.27, 0, t + 0.12]) cbox([0.5, l + 0.6, 0.34], 0.07); + if (dogs) for (sx = [-1, 1], sy = [-1, 1]) translate([sx*(w/2 + 0.12), sy*l*0.3, t*0.6]) cube([0.36, 0.44, 0.4], center=true); + } + translate([0, l/2 - 0.42, t]) zh_stripes(w*0.36, 0.5, dogs ? 3 : 2); +} + +// Armour plate (any frame): replaceable slab with four bolt bosses; leaves a seam to its neighbour. +module zh_plate(w, l, t=0.3, zone="secondary") zh_z(zone) { + translate([0, 0, t/2]) cbox([w, l, t + ZH_SINK], 0.07); + for (sx = [-1, 1], sy = [-1, 1]) translate([sx*(w/2 - 0.25), sy*(l/2 - 0.25), t + 0.05]) cube([0.22, 0.22, 0.12], center=true); +} + +// Radiator trough (top frame): cast frame round a black floor, split by a spine wall; vanes stand in it (zh_rad_vanes). +module zh_rad_trough(w=3.2, l=2.4, d=0.45, zone="secondary") { + zh_z(zone) translate([0, 0, d/2]) difference() { + cbox([w + 0.7, l + 0.7, d + ZH_SINK], 0.1); cube([w, l, d*3], center=true); } + zh_z(zone) translate([0, 0, d*0.6]) cube([0.3, l, d*1.2 + ZH_SINK], center=true); + zh_z("recess") translate([0, 0, 0.04]) cube([w, l, 0.08 + ZH_SINK], center=true); +} +// radiator-mesh for one half of a trough (side 1 port, -1 starboard): transverse vanes rising out of it +module zh_rad_vanes(w=3.2, l=2.4, side=1, n=5, h=1.0) { + for (i = [0:n-1]) translate([side*(w/4 + 0.08), -l/2 + 0.2 + i*(l - 0.4)/(n-1), h/2]) cube([w/2 - 0.36, 0.14, h], center=true); +} +function zh_rad_centre(w=3.2, side=1, h=1.0) = [side*(w/4 + 0.08), 0, h/2]; + +// Tow lug (stern frame: local +Z aft, local +Y down): a cast block, a neck, a heavy oxide eye with a vertical pin hole. +module zh_tow_lug(s=ZH_LUG, zone="secondary") { + zh_z(zone) { translate([0, 0, 0.4*s]) cbox([1.8*s, 1.3*s, 0.8*s + ZH_SINK], 0.16*s); + translate([0, 0, 1.05*s]) cbox([1.0*s, 0.8*s, 0.6*s], 0.1*s); } + zh_z("accent") translate([0, 0, 1.75*s]) rotate([90, 0, 0]) difference() { + cylinder(r=0.72*s, h=0.55*s, center=true, $fn=8); cylinder(r=0.3*s, h=2*s, center=true, $fn=8); } +} + +// Ballistic mount (top frame): octagonal ring, battered mantlet block, n thick barrels with box brakes, pointing -Y. +module zh_gun(n=2, cal=0.22, len=3.0, base=1.8, zone="secondary") { + zh_z(zone) cylinder(r=base*0.56, h=0.35 + ZH_SINK, $fn=8); + zh_mass([for (s = zh_block(0, base*0.95, base, base*0.5, 0.33, base*0.14, 0, 0.22)) s], zone); + zh_z(zone) for (i = [0:n-1]) translate([(i - (n-1)/2)*cal*3.2, -base*0.4, 0.33 + base*0.25]) { + rotate([0, 0, 180]) tube(cal, len, 8); translate([0, -len, 0]) cbox([cal*3, cal*3.5, cal*3], 0.04); } } -// Drive block: a flat rear face with square nozzles in a grid (recessed: emitters sit at the nozzle mouths). -module zh_drive_block(w, h, d, nx=2, nz=1, nozzle=1.0) { - difference() { - cbox([w, d, h], ZH_CH); - for (i=[0:nx-1], j=[0:nz-1]) translate([(i-(nx-1)/2)*(w/nx), d/2, (j-(nz-1)/2)*(h/nz)]) cube([nozzle, 0.8, nozzle], center=true); - } +function zh_gun_muzzle(n=2, cal=0.22, len=3.0, base=1.8) = [0, -base*0.4 - len - cal*1.75, 0.33 + base*0.25]; + +// ==== TERTIARY: detail =============================================================================================== +// Shadow-gap floor (top frame): the black bottom of a reveal between modules. +module zh_reveal(w, l) zh_z("recess") translate([0, 0, 0.03]) cube([w, l, 0.06 + ZH_SINK], center=true); +// Cream safety chevrons (any frame, +Z out): n diagonal bars clipped to w x l. +module zh_stripes(w, l, n=3) zh_z("marking") intersection() { + translate([0, 0, 0.04]) cube([w, l, 0.1], center=true); + for (i = [0:n-1]) translate([(i - (n-1)/2)*w/n, 0, 0.04]) rotate([0, 0, 45]) cube([w/(n*2.2), l*3, 0.12], center=true); } +// Cream block serial (any frame, +Z out; reads along local Y): stencil bars of a unit number. +module zh_serial(h=0.7, n=4) zh_z("marking") for (i = [0:n-1]) + translate([0, (i - (n-1)/2)*h*0.75, 0.03]) cube([h*(i % 2 == 0 ? 1 : 0.6), h*0.42, 0.08], center=true); - -// Palette zones. Value contrast carries the read at 48 px on dark space: the body is iron grey, the plates and seams -// are charcoal, the oxide red is the only hue. Zones become livery mask channels. -ZH_ZONES = [["primary", [0.42, 0.43, 0.45]], // iron grey: the stepped mass - ["secondary", [0.20, 0.20, 0.22]], // charcoal: armour plates, booms, guns, command block - ["accent", [0.55, 0.22, 0.14]], // dull oxide red: magazines, markings - ["recess", [0.08, 0.08, 0.09]]]; // recessed machinery +// ==== palette ======================================================================================================== +// Value carries the 48 px read on dark space, within the lore palette (operator: solved within the palette): iron grey +// masses are the LIGHT value, charcoal fittings the mid-dark, reveals near black, oxide red the one hue, and the lore's +// cream serials/safety marks are the brightest small value. Five zones become livery mask channels. +ZH_ZONES = [["primary", [0.47, 0.48, 0.50]], // iron grey: poured masses (core, modules, tiers) + ["secondary", [0.21, 0.21, 0.23]], // charcoal: cab, pods, collars, hinge bars, troughs, plates, lugs + ["accent", [0.56, 0.20, 0.12]], // dull oxide red: hatches, collar guide lugs, tow eye + ["recess", [0.05, 0.05, 0.06]], // shadow gaps, bores, slits, nozzle mouths + ["marking", [0.86, 0.80, 0.64]]]; // cream: serials and safety chevrons ZH_GLOW = [1.0, 0.62, 0.25]; -ZH_RADIATOR = [0.50, 0.51, 0.52]; +ZH_RADIATOR = [0.56, 0.57, 0.58]; // [faction, zones, body zone, glow, radiator, [height/width range, width/length range], symmetry rule, maker] // maker: the Zhestokost Faction record key in GameData/Aetheria.cc, the Manufacturer of a product whose hull speaks this language -ZH_LANG = ["zhestokost", ZH_ZONES, "primary", ZH_GLOW, ZH_RADIATOR, [[0.25, 0.6], [0.35, 0.75]], ["bilateral", 0.97], +// Low and broad: a body never taller than ~0.7 of its width; wide-for-length, as a tracked vehicle rather than a dart. +ZH_LANG = ["zhestokost", ZH_ZONES, "primary", ZH_GLOW, ZH_RADIATOR, [[0.22, 0.7], [0.32, 0.8]], ["bilateral", 0.96], "16bad4f9-7fb5-41c5-8da0-dc76709eb7d3"]; if (part == "lang") echo(lang = ZH_LANG); diff --git a/tools/blender/tests/fixtures/hulls/zh-ammo-tender/hull.scad b/tools/blender/tests/fixtures/hulls/zh-ammo-tender/hull.scad index dcd20ec00..b4214d0fc 100644 --- a/tools/blender/tests/fixtures/hulls/zh-ammo-tender/hull.scad +++ b/tools/blender/tests/fixtures/hulls/zh-ammo-tender/hull.scad @@ -1,5 +1,6 @@ -// Zhestokost ammunition tender "Podvoz". An agent-built hull source: composes the Zhestokost language only. -// part = lang | brief | anchors | primary | secondary | accent | recess | emit: | rad: +// Zhestokost ammunition tender "Oboz" (baggage train): smoke fixture, from the hull bench Oboz A. Built only from the +// Zhestokost kit. +// part = lang | brief | anchors | primary | secondary | accent | recess | marking | emit: | rad: // Frame: Ship Root, metres, nose -Y, starboard -X, up +Z. Anchor yaw: 0 nose, 90 port, 180 aft, 270 starboard. include part = "primary"; @@ -7,43 +8,77 @@ part = "primary"; HULL_FACTION = "zhestokost"; HULL_DONOR = ""; HULL_ROLE = "tender"; -HULL_LENGTH = [18, 28]; -HULL_TRIANGLES = [300, 6000]; -HULL_READS = ["ammunition magazines carried where a rearming ship can see and reach them", - "a handling boom that brings magazines forward", "towing points", - "one defensive ballistic mount, not a warship battery", "low, broad, heavy, slow"]; +HULL_LENGTH = [20, 28]; +HULL_TRIANGLES = [600, 7000]; +HULL_READS = ["the hull is the magazine: three huge stepped magazine blocks in a row on the upper half, oxide hatches with heavy hinge bars", + "two thick round transfer collars set flush in each flank at hull height, ringed by guide lugs", + "heavy towing lug jutting from the stern plate; short row of open radiator vanes on the spine behind the cab", + "low, broad, heavy; narrow cockpit slit in a low armoured block at the bow; four squat drive pods low at the belly corners", + "one defensive ballistic mount on the last magazine deck, not a warship battery"]; if (part == "brief") echo(brief = [HULL_FACTION, HULL_DONOR, HULL_ROLE, HULL_LENGTH, HULL_TRIANGLES, HULL_READS]); -L = 15; W = 10; H = 4.2; Y0 = 3; // main mass centred at y=+3 (nose is -Y) -module pockets() { for (s=[-1,1], y=[-1, 4]) translate([s*W/2, Y0+y, 1.3]) cube([1.2, 2.6, 1.4], center=true); } -module body() { difference() { translate([0, Y0, 0]) zh_stepped(L, W, H, 3, 0.10); pockets(); } } -module plates() { for (s=[-1,1], i=[0:3]) translate([s*(W/2+0.05), Y0-5.4+i*3.6, 0.6]) rotate([0,0,90]) rotate([90,0,0]) zh_plate(3.2, 1.0, 0.3); } -module band() { for (s=[-1,1], i=[0:3]) translate([s*(W/2+0.22), Y0-5.4+i*3.6, 0.6]) cube([0.04, 3.2, 0.3], center=true); } -module booms() { for (s=[-1,1]) translate([s*2.6, -5.0, H-0.3]) { zh_boom(13); translate([0, 5.6, -0.9]) cbox([1.4, 1.4, 1.6], 0.12); } } -module magazines() { for (s=[-1,1], i=[0:3]) translate([s*3.75, -10.4+i*1.9, H-1.7]) zh_cassette(1.2, 1.5, 2.0); } -module arms() { for (s=[-1,1], i=[0:3]) translate([s*3.2, -10.4+i*1.9, H-0.45]) cube([1.6, 0.3, 0.3], center=true); } -module bridge() { translate([0, Y0+5.6, H]) { cbox([4.4, 2.6, 1.4], ZH_CH); translate([0, 0.3, 1.2]) cbox([2.8, 1.8, 1.0], ZH_CH); - translate([0, -1.32, 0.35]) cube([3.2, 0.06, 0.3], center=true); } } -module lugs() { for (s=[-1,1], y=[Y0-L/2+0.8, Y0+L/2-1.0]) translate([s*(W/2-1.6), y, H/3+0.2]) zh_tow_lug(); } -module drive() { translate([0, Y0+L/2+0.6, 1.3]) zh_drive_block(6.0, 2.2, 1.6, 2, 1, 1.3); } -module gun() { translate([0, Y0+3.2, H]) zh_gun(2, 0.2, 2.6, 1.6); } -module rad(s) { translate([s*(W/2+1.35), Y0+5.2, 2.0]) cube([2.5, 2.2, 0.12], center=true); } -module recess_fill() { for (s=[-1,1], y=[-1, 4]) translate([s*(W/2-0.55), Y0+y, 1.3]) cube([0.1, 2.5, 1.3], center=true); } - -if (part == "primary") { body(); drive(); } -if (part == "secondary") { plates(); booms(); arms(); lugs(); gun(); bridge(); } -if (part == "accent") { magazines(); band(); } -if (part == "recess") recess_fill(); -if (part == "rad:Ra.L") rad(1); -if (part == "rad:Ra.R") rad(-1); -if (part == "emit:Th.L") translate([ 1.5, Y0+L/2+1.05, 1.3]) cube([1.2, 0.05, 1.2], center=true); -if (part == "emit:Th.R") translate([-1.5, Y0+L/2+1.05, 1.3]) cube([1.2, 0.05, 1.2], center=true); -// Ids are HullData mount ids: the gameplay layout addresses these by name only. -if (part == "anchors") { - echo(anchor=["Gun.D", "weapon-mount", [0, Y0+3.2, H+0.6], 0, ""]); - echo(anchor=["Gun.D.muzzle", "weapon-muzzle", [0, Y0+3.2-0.48-2.6-0.3, H+1.15], 0, "Gun.D"]); - echo(anchor=["Th.L", "thruster-emitter", [ 1.5, Y0+L/2+1.05, 1.3], 180, ""]); - echo(anchor=["Th.R", "thruster-emitter", [-1.5, Y0+L/2+1.05, 1.3], 180, ""]); - echo(anchor=["Ra.L", "radiator-mesh", [ W/2+1.35, Y0+5.2, 2.0], 0, ""]); - echo(anchor=["Ra.R", "radiator-mesh", [-W/2-1.35, Y0+5.2, 2.0], 0, ""]); +// Tow block (stern frame: local +Z aft, local +Y down): the kit lug's cast base and oxide eye with no neck, so the eye +// is one heavy block jutting straight off the stern plate, pin hole vertical. +module oz_tow_block(w=2.6, h=1.5, d=1.5) { + zh_z("secondary") translate([0, 0, 0.25]) cbox([w + 0.8, h + 0.6, 0.5 + ZH_SINK], 0.12); + zh_z("accent") translate([0, 0, 0.5 + d/2 - 0.05]) difference() { + cbox([w, h, d], 0.25); rotate([90, 0, 0]) cylinder(r=0.42, h=h*2, center=true, $fn=8); } +} + +// ---- masses: one full-width charcoal belt the whole length; three iron magazine blocks seated on it, stepping up ---- +// bow to stern across deep seams. The belt carries the collars, pods, cab and tow lug; the blocks carry the hatches. +C = zh_plan(23.6, 12.4, 3.0, z0=-1.8, y0=0.8, bow=0.1, bowW=0.62, bowH=0.75, bt=0.35, bb=0.7); +MY = [-3.9, 2.7, 9.3]; // magazine stations; 0.6 m seams between them +MH = [3.0, 3.6, 4.2]; // the stepped magazine train: each block taller than the one ahead +MZ = 1.0; // block base, sunk 0.2 m into the belt deck +B = [for (i = [0:2]) zh_block(MY[i], 6.0, 11.8, MH[i], MZ, MH[i]*0.32, 0.3, 0.5)]; +T = [for (b = B) zh_tier(b, 0.4, 0.7, 0.6)]; +SEAMS = [(MY[0] + MY[1]) / 2, (MY[1] + MY[2]) / 2]; +CAB_Y = -9.5; RAD_Y = -7.9; RAD_W = 3.4; RAD_L = 0.9; +POD = [2.6, 3.4, 1.6]; POD_U = 0.82; +PODS = [MY[0] - 1.4, MY[2] + 1.5]; // fore pair under the first block; aft pair mouths at the stern +COLLAR_R = 1.35; +GUN_Y = 11.0; GUN = [1, 0.2, 2.4, 1.3]; // defensive mount: T[2] deck centreline, aft of its hatches (they end at 10.55) + +zh_mass(C, "secondary"); +for (b = B) zh_mass(b); +for (t = T) zh_mass(t); + +// magazine hatches: two on each tier deck, one on each flank wall; unit serials on the block belts +for (i = [0:2]) { + for (u = [-0.5, 0.5]) zh_on_top(T[i], MY[i], u) zh_hatch(2.5, 2.5); + for (s = [-1, 1]) { + zh_on_wall(B[i], MY[i] + 0.6, s, 0.4) zh_hatch(2.4, 2.4, dogs=false); + zh_on_belt(B[i], MY[i] - 1.9, s, 0.5) zh_serial(0.5, 3); + } +} +// seams between the blocks: black floors on the belt deck; a transfer collar in each belt flank under each seam +for (y = SEAMS) { + zh_on_top(C, y, 0) zh_reveal(11.0, 0.6); + for (s = [-1, 1]) zh_on_belt(C, y, s, 0.5) rotate([0, 0, 45]) zh_collar(COLLAR_R, 0.6); +} +// bow: low lit armoured cab with its slit, chevrons on the nose deck; spine radiator trough between cab and magazines +zh_on_top(C, CAB_Y, 0) zh_cab(6.4, 2.4, 1.2); +zh_on_top(C, -10.6, 0) zh_stripes(2.6, 0.4, 4); +zh_on_top(C, RAD_Y, 0) zh_rad_trough(RAD_W, RAD_L, 0.45); +// stern: iron armour plate and a heavy tow lug through it +zh_on_stern(C, 0, 0.55) zh_plate(6.4, 2.2, 0.3); +zh_on_stern(C, 0, 0.5, sink=-0.25) oz_tow_block(); +// four squat drive pods at the belly corners of the belt +for (y = PODS, u = [-POD_U, POD_U]) zh_on_belly(C, y, u) zh_pod(POD[0], POD[1], POD[2]); +// one defensive ballistic mount on the last magazine deck, between and aft of its hatches, barrels to the nose +zh_on_top(T[2], GUN_Y, 0) zh_gun(GUN[0], GUN[1], GUN[2], GUN[3]); + +// ---- anchor meshes ---- +if (part == "rad:Ra.L") zh_on_top(C, RAD_Y, 0) zh_rad_vanes(RAD_W, RAD_L, 1, 4, 1.0); +if (part == "rad:Ra.R") zh_on_top(C, RAD_Y, 0) zh_rad_vanes(RAD_W, RAD_L, -1, 4, 1.0); +if (part == "emit:Th.L") zh_on_belly(C, PODS[1], POD_U) translate(zh_pod_mouth(POD[0], POD[1], POD[2])) zh_emitter(1.1, 0.65); +if (part == "emit:Th.R") zh_on_belly(C, PODS[1], -POD_U) translate(zh_pod_mouth(POD[0], POD[1], POD[2])) zh_emitter(1.1, 0.65); +if (part == "anchors") { + echo(anchor=["Gun.D", "weapon-mount", zh_top_at(T[2], GUN_Y, 0, [0, 0, 0.33]), 0, ""]); + echo(anchor=["Gun.D.muzzle", "weapon-muzzle", zh_top_at(T[2], GUN_Y, 0, zh_gun_muzzle(GUN[0], GUN[1], GUN[2], GUN[3])), 0, "Gun.D"]); + echo(anchor=["Th.L", "thruster-emitter", zh_belly_at(C, PODS[1], POD_U, zh_pod_mouth(POD[0], POD[1], POD[2])), 180, ""]); + echo(anchor=["Th.R", "thruster-emitter", zh_belly_at(C, PODS[1], -POD_U, zh_pod_mouth(POD[0], POD[1], POD[2])), 180, ""]); + echo(anchor=["Ra.L", "radiator-mesh", zh_top_at(C, RAD_Y, 0, zh_rad_centre(RAD_W, 1, 1.0)), 0, ""]); + echo(anchor=["Ra.R", "radiator-mesh", zh_top_at(C, RAD_Y, 0, zh_rad_centre(RAD_W, -1, 1.0)), 0, ""]); } diff --git a/tools/blender/tests/smoke_hull_source.py b/tools/blender/tests/smoke_hull_source.py index c544339ad..8bb9b9c48 100644 --- a/tools/blender/tests/smoke_hull_source.py +++ b/tools/blender/tests/smoke_hull_source.py @@ -71,7 +71,8 @@ HULLS = ( ("zh-ammo-tender", "smoke.hull.tender", (10.0, 23.4), 12, (("Gun.D", BALLISTIC, (2, 3)), ("Th.L", THRUSTER, (0, 0)), ("Th.R", THRUSTER, (1, 0)), ("Ra.L", RADIATOR, (2, 0)), ("Ra.R", RADIATOR, (3, 0))), - "primary", {"primary": (0.42, 0.43, 0.45), "secondary": (0.20, 0.20, 0.22), "accent": (0.55, 0.22, 0.14), "recess": (0.08, 0.08, 0.09)}, + "primary", {"primary": (0.47, 0.48, 0.50), "secondary": (0.21, 0.21, 0.23), "accent": (0.56, 0.20, 0.12), "recess": (0.05, 0.05, 0.06), + "marking": (0.86, 0.80, 0.64)}, "Zhestokost", "Pirates"), ("pi-fighter", "smoke.hull.fighter", (8.0, 15.0), 8, (("Gun.P", BALLISTIC, (3, 3)), ("Th.Main", THRUSTER, (0, 0)), ("Th.L", THRUSTER, (1, 0))), @@ -86,7 +87,7 @@ HULLS = ( # language file names, read by the production reader (language_maker), so a language edited alone cannot leave it stale ATTACKS = { "zh-ammo-tender": ("ZH_LANG", '["zhestokost", ZH_ZONES, "primary", ZH_GLOW, ZH_RADIATOR, [[0, 9], [0, 9]], ["asymmetric", 0.0], "%MAKER%"]', - "zhestokost.scad", "[0.42, 0.43, 0.45]"), + "zhestokost.scad", "[0.47, 0.48, 0.50]"), "pi-fighter": ("PI_LANG", '["pirate", PI_ZONES, "", PI_GLOW, PI_RADIATOR, [], ["bilateral", 0.0], "%MAKER%"]', "pirate.scad", "[0.48, 0.27, 0.22]"), } @@ -542,7 +543,7 @@ def check_poses(collection, root, runner): def yawed_mount(scene, box, collection, root, package, source, original, runner): """A mount the source turns 90 degrees stands, turned, where it says, and its muzzle follows it.""" - changed = original.replace('[0, Y0+3.2, H+0.6], 0, ""]', '[0, Y0+3.2, H+0.6], 90, ""]') + changed = original.replace('[0, 0, 0.33]), 0, ""]', '[0, 0, 0.33]), 90, ""]') require(changed != original, "The tender's mount is not where the pose test turns it") source.write_text(changed, encoding="utf-8") select(box) From ad05486388d469daecb1fa6018f3d5a7516c9316 Mon Sep 17 00:00:00 2001 From: soul Date: Wed, 21 Oct 2026 23:41:15 +0200 Subject: [PATCH 026/307] Zhestokost kit: zh_z keys a donor's zones 'zhestokost:' when HULL_DONOR is zhestokost zh_key in ewanhart.scad's form, not AU's either-name match: a Pirate hull's own zone named like a Zhestokost zone no longer receives Zhestokost geometry. Closes the gap in follow-up zhestokost-kit-donor-routing. Spec: aetheria-release:cut_spec:cut-zh-kit-landing.r1 Co-Authored-By: Claude Opus 5.5 --- tools/blender/aetheria_ships/factions/zhestokost.scad | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/tools/blender/aetheria_ships/factions/zhestokost.scad b/tools/blender/aetheria_ships/factions/zhestokost.scad index 752559e71..e077351ee 100644 --- a/tools/blender/aetheria_ships/factions/zhestokost.scad +++ b/tools/blender/aetheria_ships/factions/zhestokost.scad @@ -23,6 +23,7 @@ // the skin, so nothing floats. Top/belly frames keep local +Y aft; wall/belt frames keep +Y aft with local +X // pointing down the wall on either side (starboard mates are mirrors of port); stern/nose frames have local +Y down. // 3. Zone routing: every zh_ module draws only the zone the global `part` names, so a hull writes its assembly once. +// A hull on a Zhestokost donor (HULL_DONOR == "zhestokost") names a donor zone 'zhestokost:'. // Anchor meshes (zh_rad_vanes, zh_emitter) are not routed: call them inside the hull's rad:/emit: parts under // the same mate that placed their housing. // Scale: chamfers are cast-heavy (ZH_CH); fittings have standard sizes (ZH_HATCH, ZH_COLLAR_R, ZH_LUG) shared by @@ -37,7 +38,8 @@ ZH_HATCH = 2.6; // standard magazine hatch (m, square) ZH_COLLAR_R = 1.05; // standard transfer collar radius ZH_LUG = 1.0; // standard tow lug scale -module zh_z(z) { if (part == z) children(); } +function zh_key(z) = (!is_undef(HULL_DONOR) && HULL_DONOR == "zhestokost") ? str("zhestokost:", z) : z; +module zh_z(z) { if (part == zh_key(z)) children(); } // ==== geometry core: sections, surface points, mates ================================================================= // A section. bt: deck inset (how far the upper wall batters in), bb: belly inset. Defaults keep the faction's batter. From 2fd8cbe8e9f94bdffc2e41c8e9a9b5795f27f979 Mon Sep 17 00:00:00 2001 From: soul Date: Thu, 22 Oct 2026 00:17:59 +0200 Subject: [PATCH 027/307] AetherDb: ship-authoring stats sets a package hull's mass, durability, armor, drag and top speed, judged before the file opens Ruling ship-authoring-stats-command. --top-speed writes a fixed top speed (Min = Max) on the hull's one VelocityLimit and refuses a hull with none. Co-Authored-By: Claude Opus 5.5 --- .../Aetheria.Shared.Tests/ShipProductTests.cs | 58 +++++++++++++++++++ tools/AetherDb/ShipAuthoringCommands.cs | 38 +++++++++++- 2 files changed, 95 insertions(+), 1 deletion(-) diff --git a/tests/Aetheria.Shared.Tests/ShipProductTests.cs b/tests/Aetheria.Shared.Tests/ShipProductTests.cs index 27e84629f..e06143f38 100644 --- a/tests/Aetheria.Shared.Tests/ShipProductTests.cs +++ b/tests/Aetheria.Shared.Tests/ShipProductTests.cs @@ -325,6 +325,64 @@ public sealed class ShipProductTests : IDisposable RefusedAndUntouched("product", path, "ACM", "--remove"); } + // The hull as data, to compare everything stats did not name. + private static string HullJson(string path) => Newtonsoft.Json.JsonConvert.SerializeObject(ShipAuthoringStore.Load(path).Hull); + + [Fact] + public void StatsWritesOnlyTheFieldsItNamesAndARefusalLeavesTheFileUntouched() + { + Environment.CurrentDirectory = _root.Path; + var path = Package("mod.skiff", "Skiffhull", 0); + var bare = Path.Combine(ShipFixture.WritePackage(Mods, "mod.bare", "Barehull", tweak: ship => ship.Hull.Behaviors.Clear()), "ship.cc"); + void RefusedAndUntouched(string file, params string[] flags) + { + var before = File.ReadAllBytes(file); + Assert.Equal(1, Run(new[] { "stats", file }.Concat(flags).ToArray()).Code); + Assert.Equal(before, File.ReadAllBytes(file)); + } + + RefusedAndUntouched(path); + RefusedAndUntouched(path, "--speed", "5"); + RefusedAndUntouched(path, "--mass"); + RefusedAndUntouched(path, "--mass", "NaN"); + RefusedAndUntouched(path, "--mass", "Infinity"); + RefusedAndUntouched(path, "--armor", "-1"); + RefusedAndUntouched(path, "--drag", "slow"); + RefusedAndUntouched(path, "--mass", "5", "--mass", "6"); + // A valid flag beside a bad one writes nothing. + RefusedAndUntouched(path, "--mass", "5", "--durability", "-2"); + RefusedAndUntouched(bare, "--top-speed", "50"); + RefusedAndUntouched(bare, "--mass", "5", "--top-speed", "50"); + + // Each flag sets its own field and nothing else. + var expected = ShipAuthoringStore.Load(path).Hull; + foreach (var (flag, value, set) in new (string, float, Action)[] + { + ("--mass", 60000f, hull => hull.Mass = 60000f), + ("--durability", 8000f, hull => hull.Durability = 8000f), + ("--armor", 40f, hull => hull.Armor = 40f), + ("--drag", .35f, hull => hull.Drag = .35f), + ("--top-speed", 37.5f, hull => { var speed = hull.Behaviors.OfType().Single().TopSpeed; speed.Min = speed.Max = 37.5f; }), + }) + { + var (code, output, error) = Run("stats", path, flag, value.ToString("R", System.Globalization.CultureInfo.InvariantCulture)); + Assert.True(code == 0, error); + Assert.Contains(flag.Substring(2), output); + set(expected); + Assert.Equal(Newtonsoft.Json.JsonConvert.SerializeObject(expected), HullJson(path)); + } + var written = ShipAuthoringStore.Load(path).Hull; + Assert.Equal((60000f, 8000f, 40f, .35f), (written.Mass, written.Durability, written.Armor, written.Drag)); + var top = written.Behaviors.OfType().Single().TopSpeed; + Assert.Equal((37.5f, 37.5f), (top.Min, top.Max)); + + // Several at once, and a hull with no top speed takes the others. + Assert.Equal(0, Run("stats", path, "--mass", "1", "--drag", "0").Code); + Assert.Equal((1f, 0f, 8000f), (ShipAuthoringStore.Load(path).Hull.Mass, ShipAuthoringStore.Load(path).Hull.Drag, ShipAuthoringStore.Load(path).Hull.Durability)); + Assert.Equal(0, Run("stats", bare, "--armor", "12").Code); + Assert.Equal(12f, ShipAuthoringStore.Load(bare).Hull.Armor); + } + [Fact] public void ValidateJudgesProductsOnADraftAndInspectListsThem() { diff --git a/tools/AetherDb/ShipAuthoringCommands.cs b/tools/AetherDb/ShipAuthoringCommands.cs index b745e3dbf..bce7eda08 100644 --- a/tools/AetherDb/ShipAuthoringCommands.cs +++ b/tools/AetherDb/ShipAuthoringCommands.cs @@ -8,7 +8,9 @@ using System.Linq; // Uses the existing database executable; mod authoring does not earn another binary target. public static class ShipAuthoringCommands { - private const string Usage = "ship-authoring create [--like ] | inspect | centre | validate | product (--name [--description ] [--not-offered] | --remove) | price | compose "; + private const string Usage = "ship-authoring create [--like ] | inspect | centre | validate | product (--name [--description ] [--not-offered] | --remove) | price | stats [--mass m] [--durability d] [--armor a] [--drag x] [--top-speed v] | compose "; + + private static readonly string[] StatFlags = { "--mass", "--durability", "--armor", "--drag", "--top-speed" }; public static int Run(string[] args) { @@ -91,6 +93,40 @@ public static class ShipAuthoringCommands Console.WriteLine($"{ship.Id}: price {price}."); return 0; } + if (args[0] == "stats" && args.Length >= 2) + { + // Every flag is judged before the file is touched: a refusal leaves it byte-identical. + var stats = new Dictionary(StringComparer.Ordinal); + for (var i = 2; i < args.Length; i += 2) + { + if (Array.IndexOf(StatFlags, args[i]) < 0) throw new InvalidOperationException($"stats: unknown flag {args[i]}."); + if (stats.ContainsKey(args[i])) throw new InvalidOperationException($"stats: {args[i]} is given twice."); + if (i + 1 == args.Length) throw new InvalidOperationException($"stats: {args[i]} needs a value."); + if (!float.TryParse(args[i + 1], NumberStyles.Float, CultureInfo.InvariantCulture, out var value) || !float.IsFinite(value) || value < 0) + throw new InvalidOperationException($"stats: {args[i]} must be a finite, non-negative number."); + stats[args[i]] = value; + } + if (stats.Count == 0) throw new InvalidOperationException("stats: name at least one stat."); + var (hull, ship, _) = ShipAuthoringStore.Load(args[1]); + if (stats.TryGetValue("--top-speed", out var topSpeed)) + { + var limits = (hull.Behaviors ?? new List()).OfType().ToArray(); + if (limits.Length != 1) throw new InvalidOperationException($"{ship.Id}: --top-speed needs exactly one VelocityLimit on the hull; it has {limits.Length}."); + // A fixed top speed, as every shipped hull authors one (Min equal to Max); its terms are kept. + limits[0].TopSpeed.Min = limits[0].TopSpeed.Max = topSpeed; + } + if (stats.TryGetValue("--mass", out var mass)) hull.Mass = mass; + if (stats.TryGetValue("--durability", out var durability)) hull.Durability = durability; + if (stats.TryGetValue("--armor", out var armor)) hull.Armor = armor; + if (stats.TryGetValue("--drag", out var drag)) hull.Drag = drag; + using (var cache = ShipAuthoringStore.Open(args[1], writable: true)) + { + ShipAuthoringStore.Write(cache, hull, ship); + cache.FlushAsync().Wait(); + } + Console.WriteLine($"{ship.Id}: " + string.Join(", ", stats.Select(stat => string.Create(CultureInfo.InvariantCulture, $"{stat.Key.Substring(2)} {stat.Value:R}"))) + "."); + return 0; + } if (args[0] == "product" && args.Length >= 4) { // A trailing --not-offered takes the product off the market (ruling off-market-is-unoffered-product); --remove takes none. From 0bccb395a45fa491a8fc44edad335f236a720abd Mon Sep 17 00:00:00 2001 From: soul Date: Thu, 22 Oct 2026 00:22:44 +0200 Subject: [PATCH 028/307] ShipProductTests: compare the stats hull by its MessagePack bytes (Newtonsoft loops on Shape) Co-Authored-By: Claude Opus 5.5 --- tests/Aetheria.Shared.Tests/ShipProductTests.cs | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/tests/Aetheria.Shared.Tests/ShipProductTests.cs b/tests/Aetheria.Shared.Tests/ShipProductTests.cs index e06143f38..8fac0c5b0 100644 --- a/tests/Aetheria.Shared.Tests/ShipProductTests.cs +++ b/tests/Aetheria.Shared.Tests/ShipProductTests.cs @@ -326,7 +326,7 @@ public sealed class ShipProductTests : IDisposable } // The hull as data, to compare everything stats did not name. - private static string HullJson(string path) => Newtonsoft.Json.JsonConvert.SerializeObject(ShipAuthoringStore.Load(path).Hull); + private static byte[] HullBytes(HullData hull) => MessagePack.MessagePackSerializer.Serialize(hull, CultDocumentMessagePackSerialization.OptionsFor(typeof(HullData).Assembly)); [Fact] public void StatsWritesOnlyTheFieldsItNamesAndARefusalLeavesTheFileUntouched() @@ -369,7 +369,7 @@ public sealed class ShipProductTests : IDisposable Assert.True(code == 0, error); Assert.Contains(flag.Substring(2), output); set(expected); - Assert.Equal(Newtonsoft.Json.JsonConvert.SerializeObject(expected), HullJson(path)); + Assert.Equal(HullBytes(expected), HullBytes(ShipAuthoringStore.Load(path).Hull)); } var written = ShipAuthoringStore.Load(path).Hull; Assert.Equal((60000f, 8000f, 40f, .35f), (written.Mass, written.Durability, written.Armor, written.Drag)); From 7cb6fb0c9d1bc444838de1806e5481166e0318d7 Mon Sep 17 00:00:00 2001 From: soul Date: Thu, 22 Oct 2026 00:28:31 +0200 Subject: [PATCH 029/307] Mods: aetheria.nakovalnya, the Zhestokost boss hull package (hull.scad, ship.cc, ship.glb) Built from hull-bench Nakovalnya B by the Starfire job: New Ship --like Djinni, ship-authoring stats (mass 60000, durability 8000, armor 40, drag 0.35, top speed 25), price 60000000, a not-offered Zhestokost product, Build, Rasterise (19x30), 27 layout rows, Package. The validator accepted it and compose excludes nothing. Co-Authored-By: Claude Opus 5.5 --- GameData/Mods/aetheria.nakovalnya/hull.scad | 157 ++++++++++++++++++++ GameData/Mods/aetheria.nakovalnya/ship.cc | 3 + GameData/Mods/aetheria.nakovalnya/ship.glb | 3 + 3 files changed, 163 insertions(+) create mode 100644 GameData/Mods/aetheria.nakovalnya/hull.scad create mode 100644 GameData/Mods/aetheria.nakovalnya/ship.cc create mode 100644 GameData/Mods/aetheria.nakovalnya/ship.glb diff --git a/GameData/Mods/aetheria.nakovalnya/hull.scad b/GameData/Mods/aetheria.nakovalnya/hull.scad new file mode 100644 index 000000000..976078073 --- /dev/null +++ b/GameData/Mods/aetheria.nakovalnya/hull.scad @@ -0,0 +1,157 @@ +// Zhestokost fortress (boss) "Nakovalnya" (anvil). Built from the Zhestokost kit. +// part = lang | brief | anchors | primary | secondary | accent | recess | marking | emit: | rad: +// Frame: Ship Root, metres, nose -Y, starboard -X, up +Z. Anchor yaw: 0 nose, 90 port, 180 aft, 270 starboard. +include +part = "primary"; + +HULL_FACTION = "zhestokost"; +HULL_DONOR = ""; +HULL_ROLE = "fortress"; +HULL_LENGTH = [45, 60]; +HULL_TRIANGLES = [6000, 12000]; +HULL_READS = ["three long thick heavy barrels from fixed armoured casemates built into the stepped prow, all pointing straight forward", + "a ring of ten small squat flak turrets on raised rings evenly spaced around the upper edge", + "huge low broad stepped mass, wider at the bow, tapering to a squared stern; narrow cockpit slit high on the prow", + "round feed collar with guide lugs and an oxide ammunition door on each flank, magazine hatches in rows on the dorsal plating; six turning blocks and two broad stern drives"]; +if (part == "brief") echo(brief = [HULL_FACTION, HULL_DONOR, HULL_ROLE, HULL_LENGTH, HULL_TRIANGLES, HULL_READS]); + +// ---- masses: three steps (hull, mid-deck, spine) with the casemates set into the prow's lowest step ----------------- +// M: the hull, broadest at the bow, tapering aft to a squared stern with a cast lip. Its bow face is set back so the +// casemates sit inside the bow line. +M = [zh_s(-16, 32, 5.0, -2.25), zh_s(-15, 34, 5.5, -2.5), zh_s(5, 33, 5.5, -2.5), zh_s(14, 27, 5.5, -2.5), + zh_s(19.6, 23, 5.5, -2.5), zh_s(20, 22.4, 5.2, -2.35)]; +DECK = 3.0; +// T1: the mid-deck, ~60% of the hull's width, 3 m up; the outer band is the flak ring's gallery. +T1 = zh_tier([zh_sec(M, -15), zh_sec(M, 5), zh_sec(M, 11)], 5.4, 3.0, 4.0); +// T2: the spine, 2 m above that, carrying the cab and its slit at the prow's top step. +T2 = zh_tier(T1, 4.0, 2.0, 2.5); +// the spine runs on aft at the same height to the stern shoulders +SPINE = zh_block(12.0, 12, 8, 5.05, DECK - 0.05, 0.8, 0, 0.5); +// the belly steps down too: a keel block under the centre +KEEL = zh_block(0.5, 30, 18, 1.6, -4.0, 0, 0.6, 0.8); +// The prow's lowest step: a glacis apron, and three casemates whose tops are flush with the hull deck. +APRON = zh_block(-18.5, 8, 26, 2.6, -2.7, 0.6, 0.5, 0.4); +CX = [-9.6, 0, 9.6]; +CAS = zh_block(-18.0, 10, 7.2, 5.7, -2.7, 1.3, 0.9, 0.6); +GY = -23.0; // casemate face +GZ = 0.1; // barrel axis height +BL = 11.0; BR = 0.9; // barrel length, radius +// stern shoulders: radiator troughs either side of the spine +RAD_Y = 12.4; RAD_U = 0.55; RAD_W = 3.0; RAD_L = 2.4; +// stern drives +DX = 6.0; DRV = zh_block(20.6, 4, 8, 4.6, -2.3, 0.9, 0.7, 0.4); +DRIVE_Y = 22.6; DZ = -2.3 + 2.3; + +zh_mass(M); +zh_mass(T1); +zh_mass(T2); +zh_mass(APRON); +for (x = CX) translate([x, 0, 0]) zh_mass(CAS); +zh_mass(SPINE); +zh_mass(KEEL); +for (s = [-1, 1]) translate([s*DX, 0, 0]) zh_mass(DRV, "secondary"); + +// ---- the casemate guns: lighter cast-steel barrels out of charcoal mantlets ----------------------------------------- +module casemate_gun() { + zh_z("secondary") { + translate([0, GY - 0.3, GZ]) cbox([4.0, 1.2, 3.4], 0.2); // mantlet + translate([0, GY - 0.7, GZ]) rotate([0, 0, 180]) tube(BR*1.55, 1.8, 8); // barrel collar + translate([0, GY - 0.7 - BL, GZ]) cbox([BR*2.7, 1.6, BR*2.7], 0.12); // muzzle block + } + zh_z("primary") translate([0, GY - 0.7, GZ]) rotate([0, 0, 180]) tube(BR, BL, 8); + zh_z("recess") translate([0, GY - 0.7 - BL - 0.81, GZ]) cube([BR*1.2, 0.05, BR*1.2], center=true); +} +for (x = CX) translate([x, 0, 0]) casemate_gun(); +for (x = CX) translate([x, 0, 0]) zh_on_top(CAS, -21.9, 0) zh_stripes(3.6, 0.6, 4); +// black reveals between the casemates, on the apron +for (u = [-0.33, 0.33]) zh_on_top(APRON, -19.5, u) zh_reveal(2.3, 7.0); + +// ---- the flak ring: ten turrets on raised drums, equal arc-length around the hull's deck rim ---------------------- +// [x, y, outward yaw]; F1 at the bow-port quarter, numbered clockwise seen from above; equal-arc-length stations, baked. +FLAK = [[5.43, -13.40, 21], [-5.43, -13.40, 339], [-13.77, -10.93, 311], [-13.50, -0.06, 274], [-11.53, 10.51, 230], + [-5.43, 17.20, 199], [5.43, 17.20, 161], [11.53, 10.51, 130], [13.50, -0.06, 86], [13.77, -10.93, 49]]; +FR = 2.2; FH = 1.7; // raised drum +FG = [2, 0.27, 3.0, 3.2]; // zh_gun n, cal, len, base +module flak(yaw) { + zh_z("secondary") cylinder(r=FR, h=FH + ZH_SINK, $fn=8); + zh_z("accent") translate([0, 0, FH*0.35]) cylinder(r=FR + 0.08, h=0.35, $fn=8); // oxide band round the drum + zh_z("recess") translate([0, 0, FH - 0.06]) cylinder(r=FR*0.84, h=0.1, $fn=8); + translate([0, 0, FH - 0.02]) rotate([0, 0, yaw]) zh_gun(FG[0], FG[1], FG[2], FG[3], "primary"); +} +for (f = FLAK) translate([f[0], f[1], DECK - ZH_SINK]) flak(f[2]); +function rz(a, p) = [p[0]*cos(a) - p[1]*sin(a), p[0]*sin(a) + p[1]*cos(a), p[2]]; + +// ---- bow: cab with the slit high on the spine's front step --------------------------------------------------------- +zh_on_top(T2, -6.4, 0) zh_cab(6.0, 3.4, 3.0, "primary"); +zh_on_top(T2, -3.6, 0) zh_serial(0.7, 4); + +// ---- dorsal magazine hatches: two rows of four on the mid-deck, clear of the rim ----------------------------------- +for (y = [-8.0, -3.4, 1.2, 5.6], u = [-0.78, 0.78]) zh_on_top(T1, y, u) zh_hatch(2.8, 3.0, dogs=false); +for (y = [0.5, 4.5]) zh_on_top(T2, y, 0) zh_z("secondary") translate([0, 0, 0.06]) cube([8.4, 0.5, 0.2], center=true); // plate seams + +// Feed collar for an Oboz: a lighter cast-steel flange ring standing proud of the flank round the kit collar. +module feed_collar() { + zh_z("primary") difference() { + cylinder(r=3.3, h=0.9 + ZH_SINK, $fn=8); translate([0, 0, -0.1]) cylinder(r=2.1, h=2, $fn=8); } + zh_collar(2.2, 1.5, 4); +} +// ---- flanks: feed collar, ammunition door, unit serial ------------------------------------------------------------- +for (s = [-1, 1]) { + zh_on_belt(M, 1.5, s, 0.5) rotate([0, 0, 22.5]) feed_collar(); + zh_on_wall(M, 7.0, s, 0.45) zh_hatch(2.6, 3.0); + zh_on_wall(M, -9.0, s, 0.5) zh_serial(0.8, 5); +} + +// ---- six square turning blocks on the belt, a small exhaust slot in each ------------------------------------------- +TB = [2.8, 2.6, 1.8]; // along Y, down the wall, outward +TY = [-11.5, 13.6]; +module turn_block() { + zh_mass(zh_block(0, TB[0], TB[1], TB[2], 0, 0.45, 0.45, 0.35), "secondary"); + zh_z("recess") translate([0, 0, TB[2] + 0.02]) cube([0.6, 1.4, 0.14], center=true); +} +for (y = TY, s = [-1, 1]) zh_on_belt(M, y, s, 0.5) turn_block(); +// the forward pair face ahead from the bow corners, outboard of the casemates +NX = 14.6; +for (s = [-1, 1]) zh_on_nose(M, s*NX, 0.55) turn_block(); +function nb_at(s) = zh_nose_pt(M, s*NX, 0.55) + [0, ZH_SINK - TB[2] - 0.1, 0]; +function tb_at(y, s) = zh_belt(M, y, s, 0.5) + [s*(TB[2] + 0.1 - ZH_SINK), 0, 0]; + +// ---- stern: drives, radiator troughs on the shoulders, stern plate ------------------------------------------------ +module drive_mouth() { + zh_z("secondary") translate([0, DRIVE_Y + 0.12, 0]) difference() { + cbox([7.0, 0.4, 3.6], 0.1); cube([5.4, 1, 2.4], center=true); } + zh_z("recess") translate([0, DRIVE_Y - 0.02, 0]) cube([5.5, 0.3, 2.5], center=true); +} +for (s = [-1, 1]) translate([s*DX, 0, DZ]) drive_mouth(); +for (s = [-1, 1]) zh_on_top(M, RAD_Y, s*RAD_U) zh_rad_trough(RAD_W, RAD_L, 0.45); +zh_on_stern(M, 0, 0.62) zh_plate(4.0, 1.4, 0.3); +zh_on_stern(M, 0, 0.62) translate([0, 0, 0.3]) zh_stripes(3.4, 1.0, 4); + +// ---- anchor meshes ------------------------------------------------------------------------------------------------ +for (s = [-1, 1]) if (part == (s > 0 ? "rad:Ra.L" : "rad:Ra.R")) + zh_on_top(M, RAD_Y, s*RAD_U) for (h = [-1, 1]) zh_rad_vanes(RAD_W, RAD_L, h, 4, 1.0); +for (s = [-1, 1]) if (part == (s > 0 ? "emit:Th.L" : "emit:Th.R")) + translate([s*DX, DRIVE_Y + 0.15, DZ]) cube([5.2, 0.05, 2.3], center=true); +TID = [["Th.T3", TY[0], 1], ["Th.T4", TY[0], -1], ["Th.T5", TY[1], 1], ["Th.T6", TY[1], -1]]; +for (t = TID) if (part == str("emit:", t[0])) translate(tb_at(t[1], t[2])) cube([0.05, 1.2, 0.4], center=true); +for (s = [-1, 1]) if (part == (s > 0 ? "emit:Th.T1" : "emit:Th.T2")) translate(nb_at(s)) cube([1.2, 0.05, 0.4], center=true); + +if (part == "anchors") { + for (i = [0:2]) { + id = str("Gun.H", i + 1); + echo(anchor=[id, "weapon-mount", [CX[i], GY, GZ], 0, ""]); + echo(anchor=[str(id, ".muzzle"), "weapon-muzzle", [CX[i], GY - 0.7 - BL - 0.85, GZ], 0, id]); + } + for (i = [0:9]) { + f = FLAK[i]; id = str("Gun.F", i + 1); base = [f[0], f[1], DECK - ZH_SINK + FH - 0.02]; + echo(anchor=[id, "weapon-mount", base + [0, 0, 0.8], f[2], ""]); + echo(anchor=[str(id, ".muzzle"), "weapon-muzzle", base + rz(f[2], zh_gun_muzzle(FG[0], FG[1], FG[2], FG[3])), f[2], id]); + } + echo(anchor=["Th.T1", "thruster-emitter", nb_at(1), 0, ""]); + echo(anchor=["Th.T2", "thruster-emitter", nb_at(-1), 0, ""]); + for (t = TID) echo(anchor=[t[0], "thruster-emitter", tb_at(t[1], t[2]), t[2] > 0 ? 90 : 270, ""]); + echo(anchor=["Th.L", "thruster-emitter", [DX, DRIVE_Y + 0.15, DZ], 180, ""]); + echo(anchor=["Th.R", "thruster-emitter", [-DX, DRIVE_Y + 0.15, DZ], 180, ""]); + echo(anchor=["Ra.L", "radiator-mesh", zh_top_at(M, RAD_Y, RAD_U, [0, 0, 0.5]), 0, ""]); + echo(anchor=["Ra.R", "radiator-mesh", zh_top_at(M, RAD_Y, -RAD_U, [0, 0, 0.5]), 0, ""]); +} diff --git a/GameData/Mods/aetheria.nakovalnya/ship.cc b/GameData/Mods/aetheria.nakovalnya/ship.cc new file mode 100644 index 000000000..ce591805b --- /dev/null +++ b/GameData/Mods/aetheria.nakovalnya/ship.cc @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:62f66a2f76c82af1caac2977c22f39e66b331d5fffe2de82beaab68f0a7bc28b +size 12701 diff --git a/GameData/Mods/aetheria.nakovalnya/ship.glb b/GameData/Mods/aetheria.nakovalnya/ship.glb new file mode 100644 index 000000000..ff556fc6b --- /dev/null +++ b/GameData/Mods/aetheria.nakovalnya/ship.glb @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:01508b4903138bf8fd13caf3d01fcd3f6b6c410c8007a5266fe95c86d9b773d7 +size 843464 From b13aa7385a53bf42dff26785e651e35526efb771 Mon Sep 17 00:00:00 2001 From: soul Date: Thu, 22 Oct 2026 00:28:53 +0200 Subject: [PATCH 030/307] Command seam, agents: delete the 17 direct pilot writes in Agent, Flight, Combat, MoveTo, Follow Each write is replaced by a /*DEL*/ marker; the next commit routes every marked site through the entity's PilotCommand. Co-Authored-By: Claude Opus 5.5 --- Assets/Scripts/ServerShared/Agents/Agent.cs | 12 ++++++------ Assets/Scripts/ServerShared/Agents/Flight.cs | 4 ++-- Assets/Scripts/ServerShared/Agents/States/Combat.cs | 12 ++++++------ Assets/Scripts/ServerShared/Agents/States/Follow.cs | 2 +- Assets/Scripts/ServerShared/Agents/States/MoveTo.cs | 4 ++-- 5 files changed, 17 insertions(+), 17 deletions(-) diff --git a/Assets/Scripts/ServerShared/Agents/Agent.cs b/Assets/Scripts/ServerShared/Agents/Agent.cs index 238df3ce7..937096a7d 100644 --- a/Assets/Scripts/ServerShared/Agents/Agent.cs +++ b/Assets/Scripts/ServerShared/Agents/Agent.cs @@ -57,7 +57,7 @@ public class Agent { // The pilot tick writes Turn every tick, in every state: a state that pilots overwrites this zero with its // demand, and one that does not leaves the hull holding course instead of inheriting the last state's rate. - Ship.Turn = 0; + /*DEL turn0*/ VentOnTrack(); _currentState.Update(delta); foreach (var transition in _currentState.Transitions) @@ -84,7 +84,7 @@ public class Agent { if (Ship.HasActiveConsumable(kind)) return; var consumable = Ship.FirstCarriedConsumable(kind); - if (consumable != null) Ship.TryActivateConsumable(consumable); + if (consumable != null) /*DEL use*/; } public void Accelerate(float2 targetVelocity, bool noTurn = false) @@ -95,18 +95,18 @@ public class Agent // If Delta V is above the threshold, direct the ship towards the delta and use only main thrusters if (!noTurn && deltaVMag > FORWARD_DELTA_THRESHOLD) { - Ship.Turn = Steering.Toward(Ship, deltaVDirection); - Ship.MovementDirection = float2(0, pow(dot(Ship.Direction, deltaVDirection), 2)); + /*DEL accel-turn*/ + /*DEL accel-move1*/ } // If Delta V is low, use all thrusters and leave Turn to the caller (MoveTo already pointed it at the target) else if(deltaVMag > THRUST_DELTA_THRESHOLD) { var right = Ship.Direction.Rotate(ItemRotation.Clockwise); - Ship.MovementDirection = float2(dot(right, deltaVDirection), dot(Ship.Direction, deltaVDirection)); + /*DEL accel-move2*/ } else { - Ship.MovementDirection = float2.zero; + /*DEL accel-move3*/ } } } diff --git a/Assets/Scripts/ServerShared/Agents/Flight.cs b/Assets/Scripts/ServerShared/Agents/Flight.cs index de3c8f0c0..e7fbecfa8 100644 --- a/Assets/Scripts/ServerShared/Agents/Flight.cs +++ b/Assets/Scripts/ServerShared/Agents/Flight.cs @@ -115,7 +115,7 @@ public class Flight { track.Phase = Phase.Hailing; track.Start = _time; - if (!string.IsNullOrEmpty(_doctrine.Hail)) Nearest(entity)?.Agent.Ship.SetMessage(_doctrine.Hail); + if (!string.IsNullOrEmpty(_doctrine.Hail)) /*DEL hail*/; } else track.Phase = Phase.Engaged; break; @@ -175,7 +175,7 @@ public class Flight private static void Aim(Ship ship, Entity target) { var wanted = target == null ? TargetRef.None : new TargetRef(target); - if (!ship.Target.Value.Equals(wanted)) ship.SetTarget(wanted); + if (!ship.Target.Value.Equals(wanted)) /*DEL designate*/; } private float Standoff(Ship ship) diff --git a/Assets/Scripts/ServerShared/Agents/States/Combat.cs b/Assets/Scripts/ServerShared/Agents/States/Combat.cs index 499f631b0..4ca637212 100644 --- a/Assets/Scripts/ServerShared/Agents/States/Combat.cs +++ b/Assets/Scripts/ServerShared/Agents/States/Combat.cs @@ -70,7 +70,7 @@ public class CombatState : BaseState bestTargetItem = reveal.Item; } } - _agent.Ship.TrySelectTargetItem(bestTargetItem); + /*DEL select*/ for (var i = 0; i < _agent.Ship.WeaponGroups.Length; i++) { @@ -94,7 +94,7 @@ public class CombatState : BaseState if (dps > .1) _availableGroups.Add((i, dps)); } - foreach(var w in _availableLockingWeapons) w.Activate(); + foreach(var w in _availableLockingWeapons) /*DEL lock*/; UseConsumables(toTarget, targetDistance); @@ -140,13 +140,13 @@ public class CombatState : BaseState foreach (var weapon in _agent.Ship.WeaponGroups[selectedGroup].weapons) { if (FireControl.AgentFires(weapon, _agent.Ship, target, _agent.Doctrine.MinHitProbability)) - weapon.Activate(); + /*DEL pull*/; else if (weapon.Firing) - weapon.Deactivate(); + /*DEL release*/; } } - _agent.Ship.Aim = toTarget; - _agent.Ship.Turn = Steering.Toward(_agent.Ship, toTarget.xz); + /*DEL aim*/ + /*DEL turn*/ _agent.Accelerate(movementDirection * _agent.TopSpeed, true);//selectedGroup >= 0); // Fire charged guns! diff --git a/Assets/Scripts/ServerShared/Agents/States/Follow.cs b/Assets/Scripts/ServerShared/Agents/States/Follow.cs index 908ecd257..c63b07a06 100644 --- a/Assets/Scripts/ServerShared/Agents/States/Follow.cs +++ b/Assets/Scripts/ServerShared/Agents/States/Follow.cs @@ -20,7 +20,7 @@ public class FollowState : BaseState var pull = direction * (float) sign(error) * saturate(abs(error) / max(task.Standoff, 1f)); var desired = task.Anchor.Velocity + pull * _agent.TopSpeed; - _agent.Ship.Aim = float3(direction.x, 0, direction.y); + /*DEL aim*/ _agent.Accelerate(desired); } } diff --git a/Assets/Scripts/ServerShared/Agents/States/MoveTo.cs b/Assets/Scripts/ServerShared/Agents/States/MoveTo.cs index b677cf0cd..ba203c8cb 100644 --- a/Assets/Scripts/ServerShared/Agents/States/MoveTo.cs +++ b/Assets/Scripts/ServerShared/Agents/States/MoveTo.cs @@ -23,8 +23,8 @@ public abstract class MoveToState : BaseState // We want to go top speed in the direction of our target var desiredVelocity = dir * _agent.TopSpeed; - _agent.Ship.Aim = float3(dir.x, 0, dir.y); - _agent.Ship.Turn = Steering.Toward(_agent.Ship, dir); + /*DEL aim*/ + /*DEL turn*/ _agent.Accelerate(desiredVelocity); } } From 95f89a38a6bc3509ca0c5d6cda0ca1de2f5b4f97 Mon Sep 17 00:00:00 2001 From: soul Date: Thu, 22 Oct 2026 00:28:59 +0200 Subject: [PATCH 031/307] NakovalnyaPackageTests: the package composes, its Zhestokost product is never offered or drawn, and its layout is the brief's Co-Authored-By: Claude Opus 5.5 --- .../NakovalnyaPackageTests.cs | 96 +++++++++++++++++++ 1 file changed, 96 insertions(+) create mode 100644 tests/Aetheria.Shared.Tests/NakovalnyaPackageTests.cs diff --git a/tests/Aetheria.Shared.Tests/NakovalnyaPackageTests.cs b/tests/Aetheria.Shared.Tests/NakovalnyaPackageTests.cs new file mode 100644 index 000000000..153892804 --- /dev/null +++ b/tests/Aetheria.Shared.Tests/NakovalnyaPackageTests.cs @@ -0,0 +1,96 @@ +using System; +using System.IO; +using System.Linq; +using GameCult.Caching; +using GameCult.Caching.MessagePack; +using Xunit; + +// The first first-party ship package, GameData/Mods/aetheria.nakovalnya: the Zhestokost boss hull. It enters the game as +// data through the one compose path (ShipModCatalog.ResolveCatalog) and is made by Zhestokost but never offered +// (rulings off-market-is-unoffered-product, boss-hulls-exclusive). +public sealed class NakovalnyaPackageTests +{ + private const string Id = "aetheria.nakovalnya"; + + private static CultCache Catalog() + { + var cache = new CultCache(TestCatalog.Registry()); + cache.AddBackingStore(new SingleFileMessagePackBackingStore(TestCatalog.Repo, true), AetheriaStores.CatalogTypes); + return cache; + } + + private static HullData Hull(CultCache cache) => cache.Get(new CultRecordRef(ShipModCatalog.HullKey(Id))); + + [Fact] + public void ComposeExcludesNothing() + { + var root = RunStartTests.FindRepoRoot(); + using var work = new TempDirectory(); + var shipped = Path.Combine(root, "GameData", "Aetheria.cc"); + var (catalog, excluded) = ShipModCatalog.ResolveCatalog(shipped, Path.Combine(work.Path, "derived.cc"), + Path.Combine(root, "GameData", "Mods"), TestCatalog.Registry()); + Assert.Empty(excluded); + Assert.NotEqual(shipped, catalog); + + using var cache = Catalog(); + var hull = Hull(cache); + Assert.NotNull(hull); + Assert.Equal(("Nakovalnya", HullType.Ship), (hull.Name, hull.HullType)); + Assert.Equal(ShipModCatalog.AuthoringKey(Id), hull.Visual.Key); + } + + [Fact] + public void BossProductIsNotOffered() + { + using var cache = Catalog(); + var hull = Hull(cache); + var zhestokost = cache.GetAll().Single(faction => faction.Name == "Zhestokost"); + var product = Assert.Single(cache.GetAll(), p => p.Design.Key.Equals(ShipModCatalog.HullKey(Id))); + Assert.Equal(cache.RefOf(zhestokost).Key, product.Manufacturer.Key); + Assert.Equal(ShipModCatalog.ProductKey(Id, cache.RefOf(zhestokost).Key), cache.RefOf(product).Key); + Assert.Equal("Nakovalnya", product.Name); + Assert.True(product.NotOffered); + Assert.True(hull.Price > 0); + + Assert.DoesNotContain(hull, LoadoutGenerator.SoldHulls(cache)); + var items = new ItemManager(1u, cache, new ProvenanceLedger(), RunSaveTests.TestSettings(), _ => { }); + foreach (var faction in cache.GetAll().Append(null)) + { + var random = new CultMath.Random(7u); + var generator = new LoadoutGenerator(ref random, items, null, null, faction, .5f); + Assert.False(generator.IsAvailable(product), faction?.Name); + // Even a required draw that falls back to any manufacturer never reaches it. + Assert.Null(generator.RandomHull(HullType.Ship, design => design == hull).design); + } + } + + [Fact] + public void LayoutMatchesTheBrief() + { + using var cache = Catalog(); + var hull = Hull(cache); + var ship = cache.Get(new CultRecordRef(ShipModCatalog.AuthoringKey(Id))); + var rows = hull.Hardpoints.GroupBy(h => (h.Type, h.Shape.Width, h.Shape.Height)).ToDictionary(g => g.Key, g => g.Count()); + Assert.Equal(3, rows.GetValueOrDefault((HardpointType.Ballistic, 2, 2))); + Assert.Equal(10, rows.GetValueOrDefault((HardpointType.Ballistic, 1, 2))); + Assert.Equal(8, hull.Hardpoints.Count(h => h.Type == HardpointType.Thruster)); + Assert.Equal(2, hull.Hardpoints.Count(h => h.Type == HardpointType.Radiator)); + foreach (var kind in new[] { HardpointType.Reactor, HardpointType.Shield, HardpointType.ControlModule, HardpointType.Sensors }) + Assert.Equal(1, hull.Hardpoints.Count(h => h.Type == kind)); + Assert.Equal(27, hull.Hardpoints.Count); + + // Every mount row names a model anchor by id (ruling schematic-and-model-are-separate-spaces). Every row has an id + // (ShipAuthoringStore.Validate); an internal row's id names no anchor. + var anchors = ship.Anchors.Select(anchor => anchor.Id).ToHashSet(StringComparer.Ordinal); + var internals = new[] { HardpointType.Reactor, HardpointType.Shield, HardpointType.ControlModule, HardpointType.Sensors }; + var mounts = hull.Hardpoints.Where(h => !internals.Contains(h.Type)).ToArray(); + Assert.Equal(23, mounts.Length); + Assert.All(mounts, h => Assert.Contains(h.Transform, anchors)); + Assert.All(hull.Hardpoints.Where(h => internals.Contains(h.Type)), h => Assert.DoesNotContain(h.Transform, anchors)); + Assert.Equal(Enumerable.Range(1, 3).Select(i => $"Gun.H{i}").Concat(Enumerable.Range(1, 10).Select(i => $"Gun.F{i}")).OrderBy(s => s, StringComparer.Ordinal), + mounts.Where(h => h.Type == HardpointType.Ballistic).Select(h => h.Transform).OrderBy(s => s, StringComparer.Ordinal)); + + Assert.InRange(hull.Shape.Width, 1, 32); + Assert.InRange(hull.Shape.Height, 1, 32); + } +} From 85e539af2b6e49c50cb97641e3eaf723d465a2f2 Mon Sep 17 00:00:00 2001 From: soul Date: Thu, 22 Oct 2026 00:29:47 +0200 Subject: [PATCH 032/307] NakovalnyaPackageTests: import System.Collections.Generic Co-Authored-By: Claude Opus 5.5 --- tests/Aetheria.Shared.Tests/NakovalnyaPackageTests.cs | 1 + 1 file changed, 1 insertion(+) diff --git a/tests/Aetheria.Shared.Tests/NakovalnyaPackageTests.cs b/tests/Aetheria.Shared.Tests/NakovalnyaPackageTests.cs index 153892804..6f55823f0 100644 --- a/tests/Aetheria.Shared.Tests/NakovalnyaPackageTests.cs +++ b/tests/Aetheria.Shared.Tests/NakovalnyaPackageTests.cs @@ -1,4 +1,5 @@ using System; +using System.Collections.Generic; using System.IO; using System.Linq; using GameCult.Caching; From 9a2a9390e12a6b654dabdc6578d6226855876bcc Mon Sep 17 00:00:00 2001 From: soul Date: Thu, 22 Oct 2026 00:32:11 +0200 Subject: [PATCH 033/307] Command seam, agents: one PilotCommand per entity, applied once per step by PilotApplier in Zone.Update Agent, Flight, Combat, MoveTo and Follow write only Ship.Command. Entities with no agent apply first, then each agent applies right after it decides. A pilot reads its own pending designation and aim item back from its command; FireControl gains Refuses/AgentFires/HitProbability overloads that take them. Co-Authored-By: Claude Opus 5.5 --- Assets/Scripts/ServerShared/Agents/Agent.cs | 14 +-- Assets/Scripts/ServerShared/Agents/Flight.cs | 4 +- Assets/Scripts/ServerShared/Agents/Minion.cs | 4 +- .../ServerShared/Agents/States/Combat.cs | 23 +++-- .../ServerShared/Agents/States/Follow.cs | 2 +- .../ServerShared/Agents/States/MoveTo.cs | 4 +- Assets/Scripts/ServerShared/Entity.cs | 4 + Assets/Scripts/ServerShared/FireControl.cs | 35 +++++-- Assets/Scripts/ServerShared/Pilot.cs | 98 +++++++++++++++++++ Assets/Scripts/ServerShared/Pilot.cs.meta | 2 + Assets/Scripts/ServerShared/Zone.cs | 13 ++- 11 files changed, 169 insertions(+), 34 deletions(-) create mode 100644 Assets/Scripts/ServerShared/Pilot.cs create mode 100644 Assets/Scripts/ServerShared/Pilot.cs.meta diff --git a/Assets/Scripts/ServerShared/Agents/Agent.cs b/Assets/Scripts/ServerShared/Agents/Agent.cs index 937096a7d..eb74818ba 100644 --- a/Assets/Scripts/ServerShared/Agents/Agent.cs +++ b/Assets/Scripts/ServerShared/Agents/Agent.cs @@ -55,9 +55,9 @@ public class Agent public void Update(float delta) { - // The pilot tick writes Turn every tick, in every state: a state that pilots overwrites this zero with its + // The pilot tick commands Turn every tick, in every state: a state that pilots overwrites this zero with its // demand, and one that does not leaves the hull holding course instead of inheriting the last state's rate. - /*DEL turn0*/ + Ship.Command.Turn = TurnCommand.OfRate(0); VentOnTrack(); _currentState.Update(delta); foreach (var transition in _currentState.Transitions) @@ -84,7 +84,7 @@ public class Agent { if (Ship.HasActiveConsumable(kind)) return; var consumable = Ship.FirstCarriedConsumable(kind); - if (consumable != null) /*DEL use*/; + if (consumable != null) Ship.Command.Use(consumable); } public void Accelerate(float2 targetVelocity, bool noTurn = false) @@ -95,18 +95,18 @@ public class Agent // If Delta V is above the threshold, direct the ship towards the delta and use only main thrusters if (!noTurn && deltaVMag > FORWARD_DELTA_THRESHOLD) { - /*DEL accel-turn*/ - /*DEL accel-move1*/ + Ship.Command.Turn = TurnCommand.Toward(deltaVDirection); + Ship.Command.Movement = float2(0, pow(dot(Ship.Direction, deltaVDirection), 2)); } // If Delta V is low, use all thrusters and leave Turn to the caller (MoveTo already pointed it at the target) else if(deltaVMag > THRUST_DELTA_THRESHOLD) { var right = Ship.Direction.Rotate(ItemRotation.Clockwise); - /*DEL accel-move2*/ + Ship.Command.Movement = float2(dot(right, deltaVDirection), dot(Ship.Direction, deltaVDirection)); } else { - /*DEL accel-move3*/ + Ship.Command.Movement = float2.zero; } } } diff --git a/Assets/Scripts/ServerShared/Agents/Flight.cs b/Assets/Scripts/ServerShared/Agents/Flight.cs index e7fbecfa8..497ac865f 100644 --- a/Assets/Scripts/ServerShared/Agents/Flight.cs +++ b/Assets/Scripts/ServerShared/Agents/Flight.cs @@ -115,7 +115,7 @@ public class Flight { track.Phase = Phase.Hailing; track.Start = _time; - if (!string.IsNullOrEmpty(_doctrine.Hail)) /*DEL hail*/; + if (!string.IsNullOrEmpty(_doctrine.Hail)) Nearest(entity)?.Agent.Ship.Command.Hail(_doctrine.Hail); } else track.Phase = Phase.Engaged; break; @@ -175,7 +175,7 @@ public class Flight private static void Aim(Ship ship, Entity target) { var wanted = target == null ? TargetRef.None : new TargetRef(target); - if (!ship.Target.Value.Equals(wanted)) /*DEL designate*/; + if (!ship.Target.Value.Equals(wanted)) ship.Command.Designate(wanted); } private float Standoff(Ship ship) diff --git a/Assets/Scripts/ServerShared/Agents/Minion.cs b/Assets/Scripts/ServerShared/Agents/Minion.cs index 41c0e3fe1..a1277b264 100644 --- a/Assets/Scripts/ServerShared/Agents/Minion.cs +++ b/Assets/Scripts/ServerShared/Agents/Minion.cs @@ -22,9 +22,9 @@ public class Minion : Agent var combatState = new CombatState(this); _rootState.AddTransition(combatState, - () => Ship.Target.Value.Entity != null, null, true, _rootState); + () => (Ship.Command.Designated ?? Ship.Target.Value).Entity != null, null, true, _rootState); combatState.AddTransition(_rootState, - () => Ship.Target.Value.Entity == null, null, true, _rootState); + () => (Ship.Command.Designated ?? Ship.Target.Value).Entity == null, null, true, _rootState); } } diff --git a/Assets/Scripts/ServerShared/Agents/States/Combat.cs b/Assets/Scripts/ServerShared/Agents/States/Combat.cs index 4ca637212..1a062e7be 100644 --- a/Assets/Scripts/ServerShared/Agents/States/Combat.cs +++ b/Assets/Scripts/ServerShared/Agents/States/Combat.cs @@ -43,7 +43,8 @@ public class CombatState : BaseState _optimumStale = false; } - var target = _agent.Ship.Target.Value.Entity; + // The pilot's own pending designation (its Flight's, this step) when set, else last step's applied target. + var target = (_agent.Ship.Command.Designated ?? _agent.Ship.Target.Value).Entity; if (target == null) return; _availableGroups.Clear(); @@ -70,7 +71,8 @@ public class CombatState : BaseState bestTargetItem = reveal.Item; } } - /*DEL select*/ + _agent.Ship.Command.SelectItem(bestTargetItem); + var aim = bestTargetItem == null ? AimTarget.None : AimTarget.OfItem(bestTargetItem); for (var i = 0; i < _agent.Ship.WeaponGroups.Length; i++) { @@ -81,7 +83,7 @@ public class CombatState : BaseState // damage. if (weapon.Item.Online.Value && FireControl.Designated(weapon, _agent.Ship, target, out _) && - !FireControl.Refuses(weapon, _agent.Ship) && + !FireControl.Refuses(weapon, _agent.Ship, target) && (weapon is ConstantWeapon || weapon is InstantWeapon instantWeapon && instantWeapon.CanFire)) { @@ -94,7 +96,7 @@ public class CombatState : BaseState if (dps > .1) _availableGroups.Add((i, dps)); } - foreach(var w in _availableLockingWeapons) /*DEL lock*/; + foreach(var w in _availableLockingWeapons) _agent.Ship.Command.Pull(w); UseConsumables(toTarget, targetDistance); @@ -139,14 +141,15 @@ public class CombatState : BaseState // fires at, and "the target is designated" for a fused one. foreach (var weapon in _agent.Ship.WeaponGroups[selectedGroup].weapons) { - if (FireControl.AgentFires(weapon, _agent.Ship, target, _agent.Doctrine.MinHitProbability)) - /*DEL pull*/; - else if (weapon.Firing) - /*DEL release*/; + if (FireControl.AgentFires(weapon, _agent.Ship, target, _agent.Doctrine.MinHitProbability, aim)) + _agent.Ship.Command.Pull(weapon); + // A lock pulled above this step counts as firing, as it did when it was activated in place. + else if (weapon.Firing || _agent.Ship.Command.Pulls(weapon)) + _agent.Ship.Command.Release(weapon); } } - /*DEL aim*/ - /*DEL turn*/ + _agent.Ship.Command.Aim = toTarget; + _agent.Ship.Command.Turn = TurnCommand.Toward(toTarget.xz); _agent.Accelerate(movementDirection * _agent.TopSpeed, true);//selectedGroup >= 0); // Fire charged guns! diff --git a/Assets/Scripts/ServerShared/Agents/States/Follow.cs b/Assets/Scripts/ServerShared/Agents/States/Follow.cs index c63b07a06..1bbf3f826 100644 --- a/Assets/Scripts/ServerShared/Agents/States/Follow.cs +++ b/Assets/Scripts/ServerShared/Agents/States/Follow.cs @@ -20,7 +20,7 @@ public class FollowState : BaseState var pull = direction * (float) sign(error) * saturate(abs(error) / max(task.Standoff, 1f)); var desired = task.Anchor.Velocity + pull * _agent.TopSpeed; - /*DEL aim*/ + _agent.Ship.Command.Aim = float3(direction.x, 0, direction.y); _agent.Accelerate(desired); } } diff --git a/Assets/Scripts/ServerShared/Agents/States/MoveTo.cs b/Assets/Scripts/ServerShared/Agents/States/MoveTo.cs index ba203c8cb..aad04db0d 100644 --- a/Assets/Scripts/ServerShared/Agents/States/MoveTo.cs +++ b/Assets/Scripts/ServerShared/Agents/States/MoveTo.cs @@ -23,8 +23,8 @@ public abstract class MoveToState : BaseState // We want to go top speed in the direction of our target var desiredVelocity = dir * _agent.TopSpeed; - /*DEL aim*/ - /*DEL turn*/ + _agent.Ship.Command.Aim = float3(dir.x, 0, dir.y); + _agent.Ship.Command.Turn = TurnCommand.Toward(dir); _agent.Accelerate(desiredVelocity); } } diff --git a/Assets/Scripts/ServerShared/Entity.cs b/Assets/Scripts/ServerShared/Entity.cs index 4a2b25402..780f365bc 100644 --- a/Assets/Scripts/ServerShared/Entity.cs +++ b/Assets/Scripts/ServerShared/Entity.cs @@ -87,6 +87,10 @@ public abstract class Entity // hull turns only by Ship.Turn. public float3 Aim; + // The entity's one pilot input, written by its one pilot (its agent, the player, a scripted pilot) and applied only by + // PilotApplier in the zone step. Runtime only: never packed. + public PilotCommand Command { get; } = new PilotCommand(); + public string Name; // Names the entity for assertion messages and logs: xunit and the debugger format an object by walking its graph diff --git a/Assets/Scripts/ServerShared/FireControl.cs b/Assets/Scripts/ServerShared/FireControl.cs index 5ea06f455..8f3cc5018 100644 --- a/Assets/Scripts/ServerShared/FireControl.cs +++ b/Assets/Scripts/ServerShared/FireControl.cs @@ -84,8 +84,11 @@ public static class FireControl // Whether the shooter's next round from this weapon is refused: the weapons ask before they spend anything, so // a refused round costs no ammo, energy, sound, heat, wear or visibility (operator ruling 2026-09-30). The // answer is Solve's own -- there is no second copy of the arming test. - public static bool Refuses(Weapon weapon, Entity shooter) => - !IsMineLayer(weapon) && Solve(weapon, weapon.Item, shooter, shooter.Target.Value.Entity).Outcome == FireOutcome.Refused; + public static bool Refuses(Weapon weapon, Entity shooter) => Refuses(weapon, shooter, shooter.Target.Value.Entity); + + // The same answer against a target the shooter's pilot has commanded but not yet applied. + public static bool Refuses(Weapon weapon, Entity shooter, Entity target) => + !IsMineLayer(weapon) && Solve(weapon, weapon.Item, shooter, target).Outcome == FireOutcome.Refused; // A mine layer lays a body, not a round: it is never refused, never arc-gated, and Fire routes it to Lay. public static bool IsMineLayer(Weapon weapon) => weapon.WeaponData is MineLayerData; @@ -96,12 +99,19 @@ public static class FireControl // fires a fused weapon at nothing -- no target, or one it holds no valid data on, is no reason to fish -- and // never one Solve refuses: a refused weapon is not a weapon it can use. // The hit-probability threshold is the shooter's doctrine (RoleDoctrine.MinHitProbability), or the turret's global. - public static bool AgentFires(Weapon weapon, Entity shooter, Entity target, float minHitProbability) + public static bool AgentFires(Weapon weapon, Entity shooter, Entity target, float minHitProbability) => + AgentFires(weapon, shooter, target, minHitProbability, null); + + // The same decision priced on the aim the pilot commands this step (Combat), not the shooter's applied aim slot. + public static bool AgentFires(Weapon weapon, Entity shooter, Entity target, float minHitProbability, AimTarget aim) => + AgentFires(weapon, shooter, target, minHitProbability, (AimTarget?) aim); + + private static bool AgentFires(Weapon weapon, Entity shooter, Entity target, float minHitProbability, AimTarget? aim) { // A mine layer lays only at a designated target in range, never at nothing and never on a hit probability. if (IsMineLayer(weapon)) return Solve(weapon, weapon.Item, shooter, target).Designated; if (FuseOf(weapon.Item, out _) == null) - return HitProbability(weapon, shooter, target) >= minHitProbability; + return HitProbability(weapon, shooter, target, aim) >= minHitProbability; var solution = Solve(weapon, weapon.Item, shooter, target); return solution.Designated && solution.Outcome != FireOutcome.Refused; } @@ -330,7 +340,7 @@ public static class FireControl // common gated-out case (GatedOutHitProbabilityAllocatesNothing pins that at exactly 0). // Evasion fix (evasion-term): the same call also prices how far the target can and does move off the line of // fire, along the same travel direction, so the live price and the HUD read one evasion. - private static (Silhouette Silhouette, float PSpread, float Evasion) Forecast(Weapon weapon, Entity source, Entity target, HullData targetHull, float precision, float range) + private static (Silhouette Silhouette, float PSpread, float Evasion) Forecast(Weapon weapon, Entity source, Entity target, HullData targetHull, float precision, float range, AimTarget aim) { var travel = TravelDirection(weapon, source, target); var evasion = Evasion(target, travel); @@ -340,7 +350,6 @@ public static class FireControl Silhouette sil; try { - var aim = source.ResolvedAim; sil = Silhouette(target, targetHull, aim.Item, bearing, precision, buffer, aim.Mount); var settings = source.ItemManager.GameplaySettings; pSpread = PSpread(weapon.Spread, sil.Span, range, settings.SchematicCellSize); @@ -355,13 +364,21 @@ public static class FireControl return (sil, pSpread, evasion); } - public static float HitProbability(Weapon weapon, Entity source, Entity target) + public static float HitProbability(Weapon weapon, Entity source, Entity target) => + HitProbability(weapon, source, target, (AimTarget?) null); + + // Priced on `aim` rather than the source's aim slot: the aim its pilot commands this step. + public static float HitProbability(Weapon weapon, Entity source, Entity target, AimTarget aim) => + HitProbability(weapon, source, target, (AimTarget?) aim); + + // A null aim reads source.ResolvedAim, after the gate, so a gated-out shot reads nothing more. + private static float HitProbability(Weapon weapon, Entity source, Entity target, AimTarget? aim) { var pFire = PFire(weapon, source, target, out var range); if (pFire <= 0f) return 0f; var targetHull = source.ItemManager.GetData(target.Hull) as HullData; - var (sil, pSpread, evasion) = Forecast(weapon, source, target, targetHull, Precision(source), range); + var (sil, pSpread, evasion) = Forecast(weapon, source, target, targetHull, Precision(source), range, aim ?? source.ResolvedAim); return pFire * PMount(AngularVelocity(source, target), TrackingRate(weapon, source)) * pSpread * sil.POnHull * PDeviation(evasion, Tracking(source)); } @@ -399,7 +416,7 @@ public static class FireControl diagnostic.PSensor = PSensor(settings, diagnostic.Resolution, diagnostic.Info); diagnostic.PFire = diagnostic.Accuracy * diagnostic.PSensor; - var (sil, pSpread, evasion) = Forecast(weapon, source, target, targetHull, diagnostic.Precision, diagnostic.Range); + var (sil, pSpread, evasion) = Forecast(weapon, source, target, targetHull, diagnostic.Precision, diagnostic.Range, source.ResolvedAim); diagnostic.PSpread = pSpread; diagnostic.POnHull = sil.POnHull; diagnostic.Evasion = evasion; diff --git a/Assets/Scripts/ServerShared/Pilot.cs b/Assets/Scripts/ServerShared/Pilot.cs new file mode 100644 index 000000000..95b78348b --- /dev/null +++ b/Assets/Scripts/ServerShared/Pilot.cs @@ -0,0 +1,98 @@ +/* This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at https://mozilla.org/MPL/2.0/. */ + +using System.Collections.Generic; +using CultMath; + +// A turn demand as a pilot commands it: a rate, or a heading resolved against the hull's facing when it is applied, so a +// held heading keeps steering as the hull turns. +public readonly struct TurnCommand +{ + private readonly bool _toward; + private readonly float _rate; + private readonly float2 _heading; + + private TurnCommand(bool toward, float rate, float2 heading) + { + _toward = toward; + _rate = rate; + _heading = heading; + } + + public static TurnCommand OfRate(float rate) => new TurnCommand(false, rate, default); + public static TurnCommand Toward(float2 heading) => new TurnCommand(true, 0, heading); + + public float Resolve(Ship ship) => _toward ? Steering.Toward(ship, _heading) : _rate; +} + +// The one input an entity's pilot gives it (its agent, the player, a scripted pilot): only own-ship vectors and handles the +// pilot was given (ruling observation-sensors-only). Commands are per entity, never per Flight or side: a side may be one +// ship (ruling brain-one-per-side-r2). PilotApplier.Apply is its only reader. +// Held fields persist until the pilot replaces them, so a pilot deciding below the sim rate keeps flying (ruling +// lower-game-sim-rate); null leaves the field to whoever wrote it last. One-shots apply once, in order, and are cleared. +public sealed class PilotCommand +{ + public TurnCommand? Turn; + public float2? Movement; + public float3? Aim; + + internal readonly List Designations = new List(); + internal readonly List Selections = new List(); + internal readonly List Hails = new List(); + internal readonly List Uses = new List(); + internal readonly List<(IActivatedBehavior Behavior, bool Pull)> Triggers = new List<(IActivatedBehavior, bool)>(); + + public void Designate(TargetRef target) => Designations.Add(target); + public void SelectItem(EquippedItem item) => Selections.Add(item); + public void Hail(string message) => Hails.Add(message); + public void Use(ConsumableItemData consumable) => Uses.Add(consumable); + public void Pull(IActivatedBehavior behavior) => Triggers.Add((behavior, true)); + public void Release(IActivatedBehavior behavior) => Triggers.Add((behavior, false)); + + // The pilot's own pending decisions, which it reads back instead of the ship (that is last step's applied state). + public TargetRef? Designated => Designations.Count > 0 ? Designations[Designations.Count - 1] : (TargetRef?) null; + + // Whether the last trigger this pilot commanded for `behavior` this step is a pull. + public bool Pulls(IActivatedBehavior behavior) + { + for (var i = Triggers.Count - 1; i >= 0; i--) + if (ReferenceEquals(Triggers[i].Behavior, behavior)) return Triggers[i].Pull; + return false; + } + + internal void ClearOneShots() + { + Designations.Clear(); + Selections.Clear(); + Hails.Clear(); + Uses.Clear(); + Triggers.Clear(); + } +} + +// The one writer of an entity's pilot-controlled fields, called only from Zone.Update. The order is a rule: the target +// lands before its item is selected, and the aim before the triggers, so a pilot fires along the aim it commands this +// step, as the player does. +public static class PilotApplier +{ + public static void Apply(Entity entity) + { + var command = entity.Command; + foreach (var target in command.Designations) + if (!entity.Target.Value.Equals(target)) entity.SetTarget(target); + foreach (var item in command.Selections) entity.TrySelectTargetItem(item); + foreach (var hail in command.Hails) entity.SetMessage(hail); + foreach (var consumable in command.Uses) entity.TryActivateConsumable(consumable); + if (command.Aim is float3 aim) entity.Aim = aim; + foreach (var (behavior, pull) in command.Triggers) + if (pull) behavior.Activate(); + else behavior.Deactivate(); + if (entity is Ship ship) + { + if (command.Turn is TurnCommand turn) ship.Turn = turn.Resolve(ship); + if (command.Movement is float2 movement) ship.MovementDirection = movement; + } + command.ClearOneShots(); + } +} diff --git a/Assets/Scripts/ServerShared/Pilot.cs.meta b/Assets/Scripts/ServerShared/Pilot.cs.meta new file mode 100644 index 000000000..e13004643 --- /dev/null +++ b/Assets/Scripts/ServerShared/Pilot.cs.meta @@ -0,0 +1,2 @@ +fileFormatVersion: 2 +guid: 0ae6251a7a32402cb7f7db7ff1fc5084 \ No newline at end of file diff --git a/Assets/Scripts/ServerShared/Zone.cs b/Assets/Scripts/ServerShared/Zone.cs index ea46f4c6f..2a7b043f4 100644 --- a/Assets/Scripts/ServerShared/Zone.cs +++ b/Assets/Scripts/ServerShared/Zone.cs @@ -365,8 +365,19 @@ public class Zone } foreach (var flight in _flights.Values) flight.Update(deltaTime); - foreach(var agent in Agents.ToArray()) + // Every pilot's command is applied by PilotApplier, once per step. Entities with no agent (the player, a scripted + // pilot, a lone ship) first, as the player's input lands before the step; then each agent applies right after it + // decides, so the next agent sees it as it saw the direct writes this replaced. + var agents = Agents.ToArray(); + var piloted = new HashSet(); + foreach (var agent in agents) piloted.Add(agent.Ship); + foreach (var entity in Entities.ToArray()) + if (!piloted.Contains(entity)) PilotApplier.Apply(entity); + foreach (var agent in agents) + { agent.Update(deltaTime); + PilotApplier.Apply(agent.Ship); + } var entities = Entities.ToArray(); foreach (var entity in entities) entity.PublishInbox(); From 26a25fce3a7e0f45339706ac49bc7d2702b1b102 Mon Sep 17 00:00:00 2001 From: soul Date: Thu, 22 Oct 2026 00:32:12 +0200 Subject: [PATCH 034/307] Tests: drive agents through the seam (apply after a direct agent update; the duel's scripted player commands) Co-Authored-By: Claude Opus 5.5 --- tests/Aetheria.Shared.Tests/ConsumableAiTests.cs | 7 ++++++- .../Aetheria.Shared.Tests/FireControlPerWeaponTests.cs | 3 +++ tests/Aetheria.Shared.Tests/FlightTests.cs | 2 ++ tests/Aetheria.Shared.Tests/SimClockTests.cs | 10 +++++----- 4 files changed, 16 insertions(+), 6 deletions(-) diff --git a/tests/Aetheria.Shared.Tests/ConsumableAiTests.cs b/tests/Aetheria.Shared.Tests/ConsumableAiTests.cs index 1b6d95cc9..ea1ee917e 100644 --- a/tests/Aetheria.Shared.Tests/ConsumableAiTests.cs +++ b/tests/Aetheria.Shared.Tests/ConsumableAiTests.cs @@ -587,7 +587,11 @@ public sealed class ConsumableAiTests : IDisposable s.Zone.Update(.05f); Assert.True(s.Agent.Target.Value.IsNone); agent.Task = new FollowTask { Anchor = s.Prey, Standoff = 100 }; - for (var i = 0; i < 6 && !(s.State is FollowState); i++) agent.Update(.05f); + for (var i = 0; i < 6 && !(s.State is FollowState); i++) + { + agent.Update(.05f); + PilotApplier.Apply(s.Agent); + } Assert.True(s.State is FollowState, $"the agent is in {s.State?.GetType().Name}"); void Paint(float strength) @@ -595,6 +599,7 @@ public sealed class ConsumableAiTests : IDisposable s.Agent.ReceivePaint(s.Locker, s.Locker.Equipment.Single(i => i.Behaviors.Any(b => b is LockWeapon)), PaintMode.Track, strength); s.Agent.PublishInbox(); agent.Update(.05f); + PilotApplier.Apply(s.Agent); } Paint(.3f); Assert.False(s.Active(s.Vent)); diff --git a/tests/Aetheria.Shared.Tests/FireControlPerWeaponTests.cs b/tests/Aetheria.Shared.Tests/FireControlPerWeaponTests.cs index cf47b2041..bd576b16b 100644 --- a/tests/Aetheria.Shared.Tests/FireControlPerWeaponTests.cs +++ b/tests/Aetheria.Shared.Tests/FireControlPerWeaponTests.cs @@ -371,6 +371,7 @@ public sealed class FireControlPerWeaponTests : IDisposable flight.Join(agent, new PatrolOrbitsTask()); flight.Update(.1f); + PilotApplier.Apply(r.Shooter); Assert.Equal(expected, ((FollowTask) agent.Task).Standoff, 2); } @@ -730,7 +731,9 @@ public sealed class FireControlPerWeaponTests : IDisposable // update leaves the root state for combat, the second is combat deciding. var agent = new Minion(r.Shooter, FactionDoctrine.Default(r.Shooter.ItemManager.GameplaySettings).Combatant); agent.Update(.01f); + PilotApplier.Apply(r.Shooter); agent.Update(.01f); + PilotApplier.Apply(r.Shooter); Assert.True(r.Gun.Weapon.Firing); } diff --git a/tests/Aetheria.Shared.Tests/FlightTests.cs b/tests/Aetheria.Shared.Tests/FlightTests.cs index dc3f4da10..dc238bb7b 100644 --- a/tests/Aetheria.Shared.Tests/FlightTests.cs +++ b/tests/Aetheria.Shared.Tests/FlightTests.cs @@ -57,6 +57,8 @@ public sealed class FlightTests : IDisposable private void Tick(float dt) { foreach (var flight in _zone.Flights.ToArray()) flight.Update(dt); + // The flight-only step: each member's command (its flight's designation) is applied as the zone step applies it. + foreach (var entity in _zone.Entities.ToArray()) PilotApplier.Apply(entity); } [Fact] diff --git a/tests/Aetheria.Shared.Tests/SimClockTests.cs b/tests/Aetheria.Shared.Tests/SimClockTests.cs index c12b23baf..6426fe47d 100644 --- a/tests/Aetheria.Shared.Tests/SimClockTests.cs +++ b/tests/Aetheria.Shared.Tests/SimClockTests.cs @@ -140,8 +140,8 @@ public sealed partial class RunStartTests var launched = Launch(new Duel()); Assert.Empty(launched.failures); _items.Random = new CultMath.Random(0xC0FFEE); - launched.staged.Player.MovementDirection = float2(0, 1); - launched.staged.Player.Turn = .5f; + launched.staged.Player.Command.Movement = float2(0, 1); + launched.staged.Player.Command.Turn = TurnCommand.OfRate(.5f); foreach (var ship in launched.arena.Entities.OfType()) ship.SetTarget(launched.arena.Entities.OfType().First(other => other != ship)); return (launched.arena, launched.staged.Player); @@ -238,8 +238,8 @@ public sealed partial class RunStartTests Assert.True(turned > 60, "the held turn turned the ship"); // A write to Direction between steps (staging, a load, a wormhole exit) is not a turn. - player.Turn = 0; - player.MovementDirection = float2(0, 0); // the drives are idled so the Direction write below is tested alone + player.Command.Turn = TurnCommand.OfRate(0); + player.Command.Movement = float2(0, 0); // the drives are idled so the Direction write below is tested alone for (var step = 0; step < 600 && abs(player.TurnRate) > 0; step++) arena.Update(ClockFrame); player.Direction = normalize(float2(3, -4)); arena.Update(ClockFrame); @@ -295,7 +295,7 @@ public sealed partial class RunStartTests public void DrawnRotationFollowsTheTurn() { var (arena, player) = LaunchDuel(); - player.Turn = 1f; + player.Command.Turn = TurnCommand.OfRate(1f); for (var step = 0; step < 300; step++) arena.Update(ClockFrame); var rate = player.TurnRate; Assert.True(abs(rate) > .05f, "the ship is turning"); From 3795bdb066b3539d46ac1f5eda65c6ccb8b7613b Mon Sep 17 00:00:00 2001 From: soul Date: Thu, 22 Oct 2026 00:36:14 +0200 Subject: [PATCH 035/307] Tests: Flight and Steering fixtures drive pilots through the seam (apply after a direct update; scripted demands are commands) Co-Authored-By: Claude Opus 5.5 --- tests/Aetheria.Shared.Tests/FlightTests.cs | 17 +++++++++++++++ tests/Aetheria.Shared.Tests/SteeringTests.cs | 23 +++++++++++++++----- 2 files changed, 34 insertions(+), 6 deletions(-) diff --git a/tests/Aetheria.Shared.Tests/FlightTests.cs b/tests/Aetheria.Shared.Tests/FlightTests.cs index dc238bb7b..7bab33174 100644 --- a/tests/Aetheria.Shared.Tests/FlightTests.cs +++ b/tests/Aetheria.Shared.Tests/FlightTests.cs @@ -256,7 +256,9 @@ public sealed class FlightTests : IDisposable var minion = new Minion(ship, FactionDoctrine.Default(_items.GameplaySettings).Combatant) { Task = new FollowTask { Anchor = anchor, Standoff = 100 } }; minion.Update(.1f); + PilotApplier.Apply(minion.Ship); minion.Update(.1f); + PilotApplier.Apply(minion.Ship); return ship.Turn; } @@ -279,6 +281,7 @@ public sealed class FlightTests : IDisposable minion.Task = new FollowTask { Anchor = anchor, Standoff = 50 }; minion.Update(.1f); + PilotApplier.Apply(minion.Ship); Assert.IsType(CurrentState(minion)); } @@ -293,6 +296,7 @@ public sealed class FlightTests : IDisposable for (var i = 0; i < 30; i++) { minion.Update(.1f); + PilotApplier.Apply(minion.Ship); if (i > 0) Assert.IsNotType(CurrentState(minion)); } } @@ -611,7 +615,9 @@ public sealed class FlightTests : IDisposable var minion = new Minion(ship, FactionDoctrine.Default(_items.GameplaySettings).Combatant) { Task = new FollowTask { Anchor = anchor, Standoff = 100 } }; minion.Update(.1f); + PilotApplier.Apply(minion.Ship); minion.Update(.1f); + PilotApplier.Apply(minion.Ship); return ship.Turn; } @@ -631,19 +637,26 @@ public sealed class FlightTests : IDisposable var minion = new Minion(ship, FactionDoctrine.Default(_items.GameplaySettings).Combatant) { Task = new FollowTask { Anchor = anchor, Standoff = 50 } }; minion.Update(.1f); + PilotApplier.Apply(minion.Ship); minion.Update(.1f); + PilotApplier.Apply(minion.Ship); Assert.IsType(CurrentState(minion)); minion.Task = new PatrolOrbitsTask { Circuit = new CultRecordKey[1] }; minion.Update(.1f); + PilotApplier.Apply(minion.Ship); Assert.IsType(CurrentState(minion)); minion.Task = new FollowTask { Anchor = anchor, Standoff = 50 }; minion.Update(.1f); + PilotApplier.Apply(minion.Ship); minion.Update(.1f); + PilotApplier.Apply(minion.Ship); minion.Task = null; minion.Update(.1f); + PilotApplier.Apply(minion.Ship); minion.Update(.1f); + PilotApplier.Apply(minion.Ship); Assert.IsNotType(CurrentState(minion)); } @@ -788,6 +801,7 @@ public sealed class FlightTests : IDisposable for (var i = 0; i < 5; i++) { minion.Update(.1f); + PilotApplier.Apply(minion.Ship); Assert.IsType(CurrentState(minion)); } } @@ -800,6 +814,7 @@ public sealed class FlightTests : IDisposable minion.Task = null; minion.Update(.1f); + PilotApplier.Apply(minion.Ship); Assert.IsType(CurrentState(minion)); } @@ -870,7 +885,9 @@ public sealed class FlightTests : IDisposable var minion = new Minion(ship, FactionDoctrine.Default(_items.GameplaySettings).Combatant) { Task = new FollowTask { Anchor = anchor, Standoff = 100 } }; minion.Update(.1f); + PilotApplier.Apply(minion.Ship); minion.Update(.1f); + PilotApplier.Apply(minion.Ship); return (ship.Turn, ship.MovementDirection, minion.TopSpeed); } diff --git a/tests/Aetheria.Shared.Tests/SteeringTests.cs b/tests/Aetheria.Shared.Tests/SteeringTests.cs index 63588e865..71dbfff75 100644 --- a/tests/Aetheria.Shared.Tests/SteeringTests.cs +++ b/tests/Aetheria.Shared.Tests/SteeringTests.cs @@ -166,11 +166,13 @@ public sealed class SteeringTests ship.Aim = aim; agent.Accelerate(Right(ship) * 100f); + PilotApplier.Apply(ship); Assert.True(ship.Turn > 0f); Assert.Equal(aim, ship.Aim); - ship.Turn = .37f; + ship.Command.Turn = TurnCommand.OfRate(.37f); agent.Accelerate(-Right(ship) * 100f, true); + PilotApplier.Apply(ship); Assert.Equal(.37f, ship.Turn); Assert.Equal(aim, ship.Aim); } @@ -185,22 +187,26 @@ public sealed class SteeringTests var agent = new Agent(ship, FactionDoctrine.Default(ship.ItemManager.GameplaySettings).Combatant); Assert.Equal(float2(0, 1), ship.Direction); // starboard is +x - ship.Turn = .37f; + ship.Command.Turn = TurnCommand.OfRate(.37f); agent.Accelerate(float2(20, 0), false); // exactly the forward threshold: not above it + PilotApplier.Apply(ship); Assert.Equal(.37f, ship.Turn); Assert.Equal(float2(1, 0), ship.MovementDirection); agent.Accelerate(float2(21, 0), false); + PilotApplier.Apply(ship); Assert.True(ship.Turn > .5f); Assert.Equal(float2(0, 0), ship.MovementDirection); - ship.Turn = .37f; - ship.MovementDirection = float2(1, 1); // a stale command the hold branch must clear + ship.Command.Turn = TurnCommand.OfRate(.37f); + ship.Command.Movement = float2(1, 1); // a stale command the hold branch must clear agent.Accelerate(float2(1, 0), false); // exactly the thrust threshold: not above it + PilotApplier.Apply(ship); Assert.Equal(float2.zero, ship.MovementDirection); Assert.Equal(.37f, ship.Turn); agent.Accelerate(float2(5, 0), false); + PilotApplier.Apply(ship); Assert.Equal(float2(1, 0), ship.MovementDirection); Assert.Equal(.37f, ship.Turn); } @@ -212,9 +218,10 @@ public sealed class SteeringTests var ship = RestoredHullsTests.BuildThrustedShip(cache, Hull); var agent = new Agent(ship, FactionDoctrine.Default(ship.ItemManager.GameplaySettings).Combatant); ship.Velocity = float2(100, 0); - ship.Turn = .37f; + ship.Command.Turn = TurnCommand.OfRate(.37f); agent.Accelerate(float2(100, 0), false); // already at the wanted velocity: nothing to correct + PilotApplier.Apply(ship); Assert.Equal(.37f, ship.Turn); Assert.Equal(float2.zero, ship.MovementDirection); @@ -316,7 +323,7 @@ public sealed class SteeringTests private sealed class SpinState : BaseState { public SpinState(Agent agent) : base(agent) { } - public override void Update(float delta) => _agent.Ship.Turn = .5f; + public override void Update(float delta) => _agent.Ship.Command.Turn = TurnCommand.OfRate(.5f); } // Turn is a rate, so a state that stops piloting must not leave the last state's rate behind: the agent's @@ -333,6 +340,7 @@ public sealed class SteeringTests for (var i = 0; i < 10; i++) { agent.Update(1f / 60f); + PilotApplier.Apply(ship); ship.Update(1f / 60f); } Assert.True(Angle(start, ship.Direction) > radians(.05f), "the piloting state turns the hull"); @@ -340,11 +348,13 @@ public sealed class SteeringTests agent.Transition(new BaseState(agent)); agent.Update(1f / 60f); + PilotApplier.Apply(ship); ship.Update(1f / 60f); var held = ship.Direction; for (var i = 0; i < 60; i++) { agent.Update(1f / 60f); + PilotApplier.Apply(ship); ship.Update(1f / 60f); } @@ -377,6 +387,7 @@ public sealed class SteeringTests agent.Transition(state); agent.Update(1f / 60f); + PilotApplier.Apply(ship); Assert.True(dot(normalize(ship.Aim.xz), dir) > .9999f, $"aim {ship.Aim}"); Assert.True(abs(ship.Aim.y) < 1e-6f); From 2f7197519465db448d23eb3d5e5dafafd73bf559 Mon Sep 17 00:00:00 2001 From: soul Date: Thu, 22 Oct 2026 00:36:15 +0200 Subject: [PATCH 036/307] PilotSeamTests: the seam's rules (agent writes only its command, held and one-shot fields, apply order, lone-ship side, commanded aim item) Co-Authored-By: Claude Opus 5.5 --- tests/Aetheria.Shared.Tests/PilotSeamTests.cs | 243 ++++++++++++++++++ 1 file changed, 243 insertions(+) create mode 100644 tests/Aetheria.Shared.Tests/PilotSeamTests.cs diff --git a/tests/Aetheria.Shared.Tests/PilotSeamTests.cs b/tests/Aetheria.Shared.Tests/PilotSeamTests.cs new file mode 100644 index 000000000..f2115dacb --- /dev/null +++ b/tests/Aetheria.Shared.Tests/PilotSeamTests.cs @@ -0,0 +1,243 @@ +/* This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at https://mozilla.org/MPL/2.0/. */ + +using System; +using System.Collections.Generic; +using System.Linq; +using System.Reflection; +using CultMath; +using Xunit; +using static CultMath.math; +using float2 = CultMath.float2; +using float3 = CultMath.float3; + +// The command seam (aetheria-arena, cut command-seam-agents): every pilot writes only its entity's PilotCommand, and +// PilotApplier, in Zone.Update, is the one writer of the control fields. Held fields hold across steps (ruling +// lower-game-sim-rate), one-shots apply once, a command is per entity (ruling brain-one-per-side-r2), and the apply order +// is pinned here because the two-AI digest cannot see it. +public sealed partial class RunStartTests +{ + private const float SeamDt = 1f / 60f; + + // Two Longinus 200 apart, facing each other, the bare player far away. Piloted ones get the zone's pilot. + private (Zone arena, Ship east, Ship west) SeamPair(bool piloted, Action more = null) + { + Ship east = null, west = null; + var (_, arena, _, failures) = Launch(new Scripted(false, stage => + { + stage.Player(stage.Bare("Djinni"), float2(-50000, -50000)); + east = stage.Place(EvLonginus(stage), float2(100, 0), facing: float2(-1, 0), piloted: piloted) as Ship; + west = stage.Place(EvLonginus(stage), float2(-100, 0), facing: float2(1, 0), piloted: piloted) as Ship; + more?.Invoke(stage, east, west); + })); + Assert.True(failures.Count == 0, string.Join("; ", failures)); + return (arena, east, west); + } + + private static object SeamState(Agent agent) => + typeof(Agent).GetField("_currentState", BindingFlags.NonPublic | BindingFlags.Instance).GetValue(agent); + + private static InstantWeapon SeamGun(Ship ship) => + Assert.IsAssignableFrom(ship.Weapons.First(w => w.Item.Data.Name == "Cell Blaster")); + + [Fact] + public void AnAgentWritesOnlyItsCommand() + { + var (arena, east, west) = SeamPair(true); + east.SetIff(west, true); + west.SetIff(east, true); + var agent = arena.Agents.Single(a => a.Ship == east); + for (var step = 0; step < 900 && !(SeamState(agent) is CombatState); step++) arena.Update(SeamDt); + Assert.IsType(SeamState(agent)); + + // Sentinels the decision would overwrite if it wrote the ship. + east.Turn = 7f; + east.MovementDirection = float2(9, 9); + east.Aim = float3(0, 1, 0); + var target = east.Target.Value; + var aimed = east.AimedAt; + var firing = east.Weapons.Select(w => w.Firing).ToArray(); + var consumables = east.CargoBays.Sum(bay => bay.Cargo.Count); + + agent.Update(SeamDt); + + Assert.Equal(7f, east.Turn); + Assert.Equal(float2(9, 9), east.MovementDirection); + Assert.Equal(float3(0, 1, 0), east.Aim); + Assert.Equal(target, east.Target.Value); + Assert.Equal(aimed.Item, east.AimedAt.Item); + Assert.Equal(aimed.Mount, east.AimedAt.Mount); + Assert.Equal(firing, east.Weapons.Select(w => w.Firing).ToArray()); + Assert.Equal(consumables, east.CargoBays.Sum(bay => bay.Cargo.Count)); + Assert.NotNull(east.Command.Turn); + Assert.NotNull(east.Command.Movement); + Assert.NotNull(east.Command.Aim); + + PilotApplier.Apply(east); + + Assert.NotEqual(7f, east.Turn); + Assert.NotEqual(float2(9, 9), east.MovementDirection); + Assert.Equal(east.Command.Aim.Value, east.Aim); + } + + [Fact] + public void AHeldCommandHoldsAcrossSteps() + { + var (arena, east, _) = SeamPair(false); + east.Command.Turn = TurnCommand.OfRate(.5f); + east.Command.Movement = float2(0, 1); + for (var step = 0; step < 10; step++) + { + // A disturbance between steps: the held command is applied again at every step, not once. + east.Turn = 0; + east.MovementDirection = float2.zero; + arena.Update(SeamDt); + Assert.Equal(.5f, east.Turn); + Assert.Equal(float2(0, 1), east.MovementDirection); + } + + east.Command.Turn = TurnCommand.OfRate(-.25f); + arena.Update(SeamDt); + Assert.Equal(-.25f, east.Turn); + Assert.Equal(float2(0, 1), east.MovementDirection); + } + + [Fact] + public void TowardIsResolvedEachStep() + { + var (arena, east, _) = SeamPair(false); + var heading = float2(0, 1); // a quarter turn from its facing + east.Command.Turn = TurnCommand.Toward(heading); + var turns = new List(); + for (var step = 0; step < 1200; step++) + { + // The applier runs before the hull moves this step, so it resolves on the facing the step starts with. + var expected = Steering.Toward(east, heading); + arena.Update(SeamDt); + Assert.Equal(expected, east.Turn); + turns.Add(east.Turn); + } + Assert.True(turns.Distinct().Count() > 2, "the resolved turn changes as the hull turns"); + Assert.NotEqual(0f, turns[0]); + Assert.Contains(0f, turns.Skip(1)); + } + + [Fact] + public void OneShotsApplyOnce() + { + // A stackable design: the second step's activation would succeed if the applier kept the Use. + var design = _cache.GetAll().OrderBy(c => c.Name).First(); + design.Stackable = true; + var (arena, east, west) = SeamPair(false, (stage, e, _) => stage.Cargo(e, design.Name, design.Name)); + int Carried() => east.CargoBays.Sum(bay => bay.Cargo.Keys.Count(i => i is ConsumableItem)); + Assert.Equal(2, Carried()); + + east.Command.Use(design); + arena.Update(SeamDt); + Assert.Equal(1, Carried()); + arena.Update(SeamDt); + Assert.Equal(1, Carried()); + + east.Command.Designate(west); + arena.Update(SeamDt); + Assert.Same(west, east.Target.Value.Entity); + // The entity's own rule clears it (a fixture stands in for the target-loss clear); the designation is not replayed. + east.SetTarget(TargetRef.None); + arena.Update(SeamDt); + Assert.True(east.Target.Value.IsNone); + } + + [Fact] + public void AnEmptyCommandWritesNothing() + { + var (arena, east, _) = SeamPair(false); + east.Turn = .3f; + east.Aim = float3(0, 0, 1); + arena.Update(SeamDt); + Assert.Equal(.3f, east.Turn); + Assert.Equal(float3(0, 0, 1), east.Aim); + } + + [Fact] + public void ALoneShipSideIsCommandedWithoutAFlight() + { + var (arena, east, west) = SeamPair(false); + east.SetIff(west, true); + west.SetIff(east, true); + Assert.Empty(arena.Flights); + Assert.Empty(arena.Agents); + east.Command.Turn = TurnCommand.OfRate(.2f); + west.Command.Turn = TurnCommand.OfRate(-.4f); + arena.Update(SeamDt); + Assert.Equal(.2f, east.Turn); + Assert.Equal(-.4f, west.Turn); + } + + [Fact] + public void DesignationLandsBeforeItemSelection() + { + var (_, east, west) = SeamPair(false); + var item = SeamGun(west).Item; + east.RecordReveal(west, item, SignatureChannel.Emission, west.ClaimOf(item)); + Assert.True(FireControl.IsRevealed(east, item)); + + east.Command.Designate(west); + east.Command.SelectItem(item); + PilotApplier.Apply(east); + + Assert.Same(west, east.Target.Value.Entity); + Assert.Same(item, east.ResolvedAim.Item); + } + + [Fact] + public void TriggersFireAlongTheCommandedAim() + { + var (arena, east, _) = SeamPair(false); + for (var step = 0; step < 60; step++) arena.Update(SeamDt); // the gun comes online and off cooldown + var gun = SeamGun(east); + var behind = float3(1, 0, 0); + var ahead = float3(-1, 0, 0); + east.Aim = behind; + Assert.False(FireControl.ArcPermitsFire(gun, east), "the previous aim is out of the gun's arc"); + Assert.True(gun.CanFire); + + var shots = new List(); + gun.OnFire += id => + { + Assert.True(arena.TryGetShot(id, out var shot)); + shots.Add(shot); + }; + east.Command.Aim = ahead; + east.Command.Pull(gun); + arena.Update(SeamDt); + + Assert.NotEmpty(shots); + var along = FireControl.AimDirection(gun.Item, east); + Assert.True(length(shots[0].TravelDirection - along) < 1e-4f, $"the shot flies {shots[0].TravelDirection}, the commanded aim {along}"); + Assert.True(dot(shots[0].TravelDirection, behind.xz) < 0, "not along the previous aim"); + } + + [Fact] + public void AgentFiresWithTheCommandedAimItem() + { + var (_, east, west) = SeamPair(false); + var gun = SeamGun(east); + var item = SeamGun(west).Item; + east.EntityInfoGathered[west] = 1; + east.RecordReveal(west, item, SignatureChannel.Emission, west.ClaimOf(item)); + east.SetTarget(west); + + var refusesFor = FireControl.Refuses(gun, east, west); + var commanded = FireControl.HitProbability(gun, east, west, AimTarget.OfItem(item)); + var firesCommanded = FireControl.AgentFires(gun, east, west, commanded, AimTarget.OfItem(item)); + Assert.Equal(AimTarget.None.Item, east.ResolvedAim.Item); + Assert.True(east.TrySelectTargetItem(item)); + + Assert.True(commanded > 0f, "the shot is priced"); + Assert.Equal(commanded, FireControl.HitProbability(gun, east, west)); + Assert.Equal(firesCommanded, FireControl.AgentFires(gun, east, west, commanded)); + Assert.True(firesCommanded); + Assert.Equal(refusesFor, FireControl.Refuses(gun, east)); + } +} From 487c45f2bde9b4762964c9b5701f33788ed15d72 Mon Sep 17 00:00:00 2001 From: soul Date: Thu, 22 Oct 2026 00:39:43 +0200 Subject: [PATCH 037/307] PilotSeamTests: price the commanded aim item after the guns come online Co-Authored-By: Claude Opus 5.5 --- tests/Aetheria.Shared.Tests/PilotSeamTests.cs | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/tests/Aetheria.Shared.Tests/PilotSeamTests.cs b/tests/Aetheria.Shared.Tests/PilotSeamTests.cs index f2115dacb..20e4e0c90 100644 --- a/tests/Aetheria.Shared.Tests/PilotSeamTests.cs +++ b/tests/Aetheria.Shared.Tests/PilotSeamTests.cs @@ -221,7 +221,8 @@ public sealed partial class RunStartTests [Fact] public void AgentFiresWithTheCommandedAimItem() { - var (_, east, west) = SeamPair(false); + var (arena, east, west) = SeamPair(false); + for (var step = 0; step < 60; step++) arena.Update(SeamDt); // the guns come online and evaluate their stats var gun = SeamGun(east); var item = SeamGun(west).Item; east.EntityInfoGathered[west] = 1; From 9ac8f23307793665802db0f9181e51746fb6eacf Mon Sep 17 00:00:00 2001 From: soul Date: Thu, 22 Oct 2026 00:41:09 +0200 Subject: [PATCH 038/307] RunStartTests: the own-maker and factionless reach claims hold for every product LoadoutGenerator.IsOffered offers Ruling census-tests-read-the-owners. Co-Authored-By: Claude Opus 5.5 --- tests/Aetheria.Shared.Tests/RunStartGearTests.cs | 3 ++- tests/Aetheria.Shared.Tests/RunStartTests.cs | 7 ++++--- 2 files changed, 6 insertions(+), 4 deletions(-) diff --git a/tests/Aetheria.Shared.Tests/RunStartGearTests.cs b/tests/Aetheria.Shared.Tests/RunStartGearTests.cs index 3b5766dcd..2d0e1fb67 100644 --- a/tests/Aetheria.Shared.Tests/RunStartGearTests.cs +++ b/tests/Aetheria.Shared.Tests/RunStartGearTests.cs @@ -55,7 +55,8 @@ public sealed partial class RunStartTests var galaxy = MainSectorGalaxy(); AbsentMaker("AbsentToAll"); var generator = new LoadoutGenerator(ref _items.Random, _items, galaxy, galaxy.Entrance, null, .5f); - var products = _cache.GetAll().Where(p => p.Manufacturer.IsSet()).ToArray(); + // Every offered product (LoadoutGenerator.IsOffered); a not-offered one is available to nobody. + var products = _cache.GetAll().Where(p => p.Manufacturer.IsSet() && LoadoutGenerator.IsOffered(_cache, p)).ToArray(); Assert.NotEmpty(products); Assert.All(products, product => Assert.True(generator.IsAvailable(product), product.Name)); } diff --git a/tests/Aetheria.Shared.Tests/RunStartTests.cs b/tests/Aetheria.Shared.Tests/RunStartTests.cs index 9d499b3e1..b3c502a6b 100644 --- a/tests/Aetheria.Shared.Tests/RunStartTests.cs +++ b/tests/Aetheria.Shared.Tests/RunStartTests.cs @@ -178,13 +178,14 @@ public sealed partial class RunStartTests : IDisposable } } - // A faction always reaches its own manufacturer's gear: in a main-sector galaxy every product a faction makes is - // on offer to that faction, allegiance or not. And an allegiance map lists only other factions. + // A faction always reaches its own manufacturer's gear: in a main-sector galaxy every offered product a faction makes + // (LoadoutGenerator.IsOffered, the market's one owner) is on offer to that faction, allegiance or not. And an + // allegiance map lists only other factions. [Fact] public void AFactionReachesItsOwnGearAndItsAllegianceNamesOnlyOthers() { var galaxy = MainSectorGalaxy(); - var products = _cache.GetAll().Where(p => p.Manufacturer.IsSet()).ToArray(); + var products = _cache.GetAll().Where(p => p.Manufacturer.IsSet() && LoadoutGenerator.IsOffered(_cache, p)).ToArray(); foreach (var faction in galaxy.Factions) { Assert.DoesNotContain(faction.Allegiance.Keys, key => key.Key.Equals(_cache.RefOf(faction).Key)); From bc53361ff95d81d055f3f6aaa852b4907181a18b Mon Sep 17 00:00:00 2001 From: soul Date: Thu, 22 Oct 2026 00:41:10 +0200 Subject: [PATCH 039/307] Census tests: sold hulls are LoadoutGenerator.SoldHulls; a hull's asset is its Prefab or its mod-ship Visual record Ruling census-tests-read-the-owners. Co-Authored-By: Claude Opus 5.5 --- .../HardpointOwnershipTests.cs | 4 +--- .../RestoredHullsTests.cs | 19 +++++++++++++++---- 2 files changed, 16 insertions(+), 7 deletions(-) diff --git a/tests/Aetheria.Shared.Tests/HardpointOwnershipTests.cs b/tests/Aetheria.Shared.Tests/HardpointOwnershipTests.cs index 12daaee32..2650b8009 100644 --- a/tests/Aetheria.Shared.Tests/HardpointOwnershipTests.cs +++ b/tests/Aetheria.Shared.Tests/HardpointOwnershipTests.cs @@ -261,9 +261,7 @@ public sealed partial class RunStartTests [Fact] public void GeneratedLoadoutsGiveEveryReaderItsHardpoint() { - var sold = _cache.GetAll().Select(p => p.Design.Key).ToHashSet(); - var shipHulls = _cache.GetAll() - .Where(h => h.HullType == HullType.Ship && sold.Contains(_cache.RefOf(h).Key)).Select(h => h.Name).ToList(); + var shipHulls = LoadoutGenerator.SoldHulls(_cache).Where(h => h.HullType == HullType.Ship).Select(h => h.Name).ToList(); var generator = new LoadoutGenerator(ref _items.Random, _items, _galaxy, _galaxy.Entrance, _protagonist, .5f); var packs = shipHulls.SelectMany(name => Enumerable.Range(0, 2).Select(_ => generator.GenerateShipLoadout(h => h.Name == name))) .Concat(Enumerable.Range(0, 4).Select(_ => generator.GenerateStationLoadout())) diff --git a/tests/Aetheria.Shared.Tests/RestoredHullsTests.cs b/tests/Aetheria.Shared.Tests/RestoredHullsTests.cs index 8b0065f93..6dd235551 100644 --- a/tests/Aetheria.Shared.Tests/RestoredHullsTests.cs +++ b/tests/Aetheria.Shared.Tests/RestoredHullsTests.cs @@ -187,9 +187,21 @@ public sealed class RestoredHullsTests using var cache = OpenCatalog(); var missing = new List(); + // A hull names one body (ShipAuthoringStore.RequireOneBody): a Unity prefab, or a mod ship's visual record, which + // must be in the catalog under its own mod-ship key. foreach (var hull in cache.GetAll()) - if (!assetGuids.Contains(hull.Prefab ?? "")) + { + if (hull.Visual.IsSet()) + { + var visual = cache.Get(hull.Visual); + if (visual == null || !hull.Visual.Key.Equals(ShipModCatalog.AuthoringKey(visual.Id))) + missing.Add($"{hull.Name}.Visual = {hull.Visual.Key.Value}"); + if (!string.IsNullOrWhiteSpace(hull.Prefab)) + missing.Add($"{hull.Name} names both Prefab {hull.Prefab} and Visual {hull.Visual.Key.Value}"); + } + else if (!assetGuids.Contains(hull.Prefab ?? "")) missing.Add($"{hull.Name}.Prefab = {hull.Prefab ?? "null"}"); + } foreach (var design in cache.GetAll()) { foreach (var thruster in design.Behaviors.OfType()) @@ -245,14 +257,13 @@ public sealed class RestoredHullsTests } // The dock intro dereferences the ship's VelocityLimit unguarded (ActionGameManager), so every ship hull the catalog - // sells carries exactly one, with a top speed that is a finite positive number. After the retirement + // sells (LoadoutGenerator.SoldHulls) carries exactly one, with a top speed that is a finite positive number. After the retirement // (ruling retire-longinusx) the shipped ship hulls are the Longinus and the Djinni. [Fact] public void EveryShippedShipHullHasATopSpeed() { using var cache = OpenCatalog(); - var sold = cache.GetAll().Select(product => product.Design.Key).ToHashSet(); - var ships = cache.GetAll().Where(h => h.HullType == HullType.Ship && sold.Contains(cache.RefOf(h).Key)).OrderBy(h => h.Name, StringComparer.Ordinal).ToArray(); + var ships = LoadoutGenerator.SoldHulls(cache).Where(h => h.HullType == HullType.Ship).OrderBy(h => h.Name, StringComparer.Ordinal).ToArray(); Assert.Equal(new[] { "Djinni", "Longinus" }, ships.Select(h => h.Name)); foreach (var hull in ships) { From 07bd57d9e1fd80cf986fd79106694046ecdd6e0d Mon Sep 17 00:00:00 2001 From: soul Date: Thu, 22 Oct 2026 00:41:12 +0200 Subject: [PATCH 040/307] ConsumablesFirstSetTests: the vent's smallest cloud holds every sold ship hull Ruling vapour-cloud-sold-hulls; the radius is unchanged. Co-Authored-By: Claude Opus 5.5 --- tests/Aetheria.Shared.Tests/ConsumablesFirstSetTests.cs | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/tests/Aetheria.Shared.Tests/ConsumablesFirstSetTests.cs b/tests/Aetheria.Shared.Tests/ConsumablesFirstSetTests.cs index 300c2f9b9..a3858a407 100644 --- a/tests/Aetheria.Shared.Tests/ConsumablesFirstSetTests.cs +++ b/tests/Aetheria.Shared.Tests/ConsumablesFirstSetTests.cs @@ -74,9 +74,10 @@ public sealed class ConsumablesFirstSetTests Assert.Equal(new[] { "nozzle", "coolant" }, vent.Roles.Select(r => r.Name).ToArray()); Assert.Equal(2, vent.Behaviors.Count); var dump = Assert.IsType(vent.Behaviors[0]); - // The cloud at its smallest holds every ship hull; its largest is half as big again. + // The cloud at its smallest holds every sold ship hull (ruling vapour-cloud-sold-hulls: a boss hull is never sold and + // its Loadout is agent-authored); its largest is half as big again. var radiusMin = dump.Radius.Min; - foreach (var hull in cache.GetAll().Where(h => h.HullType == HullType.Ship)) + foreach (var hull in LoadoutGenerator.SoldHulls(cache).Where(h => h.HullType == HullType.Ship)) Assert.True(radiusMin >= Circumradius(hull, cellSize), $"{hull.Name} pokes out of the smallest cloud"); Assert.Equal(1.5f * Circumradius(cache.GetByName("Djinni"), cellSize), radiusMin, 3); Assert.Equal(33.03f, radiusMin, 1); // the Djinni, 14x17 cells at two units a cell From f7314b26614141f5cd270cf2ea5ce0b8af79c83b Mon Sep 17 00:00:00 2001 From: soul Date: Thu, 22 Oct 2026 00:41:12 +0200 Subject: [PATCH 041/307] ShipProductTests: stats --top-speed refuses a hull with two VelocityLimits and leaves it untouched Co-Authored-By: Claude Opus 5.5 --- tests/Aetheria.Shared.Tests/ShipProductTests.cs | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/tests/Aetheria.Shared.Tests/ShipProductTests.cs b/tests/Aetheria.Shared.Tests/ShipProductTests.cs index 8fac0c5b0..bb358c4df 100644 --- a/tests/Aetheria.Shared.Tests/ShipProductTests.cs +++ b/tests/Aetheria.Shared.Tests/ShipProductTests.cs @@ -334,6 +334,8 @@ public sealed class ShipProductTests : IDisposable Environment.CurrentDirectory = _root.Path; var path = Package("mod.skiff", "Skiffhull", 0); var bare = Path.Combine(ShipFixture.WritePackage(Mods, "mod.bare", "Barehull", tweak: ship => ship.Hull.Behaviors.Clear()), "ship.cc"); + var twin = Path.Combine(ShipFixture.WritePackage(Mods, "mod.twin", "Twinhull", + tweak: ship => ship.Hull.Behaviors.Add(ship.Hull.Behaviors.OfType().First())), "ship.cc"); void RefusedAndUntouched(string file, params string[] flags) { var before = File.ReadAllBytes(file); @@ -353,6 +355,8 @@ public sealed class ShipProductTests : IDisposable RefusedAndUntouched(path, "--mass", "5", "--durability", "-2"); RefusedAndUntouched(bare, "--top-speed", "50"); RefusedAndUntouched(bare, "--mass", "5", "--top-speed", "50"); + // Two VelocityLimits leave no one top speed to set. + RefusedAndUntouched(twin, "--top-speed", "50"); // Each flag sets its own field and nothing else. var expected = ShipAuthoringStore.Load(path).Hull; From 74257926b1ee4fd8fd228ffdbae728fd429eb7c9 Mon Sep 17 00:00:00 2001 From: soul Date: Thu, 22 Oct 2026 00:44:35 +0200 Subject: [PATCH 042/307] Gravity: a parent's outermost child never hosts a lane (ruling lane-not-outermost); one host filter, an only child pinned Co-Authored-By: Claude Opus 5.5 --- Assets/Scripts/ServerShared/ZoneGenerator.cs | 12 +++++++-- .../ChildrenWithinReachTests.cs | 4 +-- tests/Aetheria.Shared.Tests/LanedBeltTests.cs | 25 ++++++++++++++++--- 3 files changed, 33 insertions(+), 8 deletions(-) diff --git a/Assets/Scripts/ServerShared/ZoneGenerator.cs b/Assets/Scripts/ServerShared/ZoneGenerator.cs index a10950bbf..e4316f68f 100644 --- a/Assets/Scripts/ServerShared/ZoneGenerator.cs +++ b/Assets/Scripts/ServerShared/ZoneGenerator.cs @@ -254,8 +254,7 @@ public static class ZoneGenerator { if (laneRandom.NextFloat() >= zoneSettings.LaneProbability) continue; var lane = zoneSettings.LaneBodyRadii * planetSettings.BodyRadius.Evaluate(host.Mass); - if (swarmHosts.Contains(host) || shaped.Contains(host.Parent) - || host.SlotInner > host.Distance - 2 * lane || host.SlotOuter < host.Distance + 2 * lane) continue; + if (swarmHosts.Contains(host) || shaped.Contains(host.Parent) || !LaneHost(host, lane)) continue; shaped.Add(host.Parent); var hostOrbit = orbitMap[host]; var rail = new OrbitData { Parent = hostOrbit.Parent, Distance = hostOrbit.Distance, Phase = hostOrbit.Phase }; @@ -672,6 +671,15 @@ public static class ZoneGenerator private const uint LaneStream = 0x85EBCA6Bu; + // Whether a placed body can carry a lane of half-width `lane`: its slot holds a band at least that wide on both sides + // (ruling lanes-two-sided-visual-margin), and it is not its parent's outermost child (ruling lane-not-outermost). Place + // stretches the chain to the window, so an outermost child's outer side is the window, not a sibling's spacing; a lane's outer + // band there would reach past every sibling's extent and raise its parent's support, and lanes move nothing else in the zone. + // An only child is its parent's outermost. + public static bool LaneHost(GeneratorPlanet host, float lane) => + host.Parent.Children.Any(c => c.Distance > host.Distance) + && host.SlotInner <= host.Distance - 2 * lane && host.SlotOuter >= host.Distance + 2 * lane; + // A laned belt's rocks: the count an annulus of the host's mass at `distance` would draw, over the bands [inner, distance - lane] // and [distance + lane, outer], each rock in a band by its area and drawn across it as the annulus draws across its width. public static Asteroid[] LaneRocks(ZoneGenerationSettings settings, ref Random random, float mass, float distance, float lane, float inner, float outer) diff --git a/tests/Aetheria.Shared.Tests/ChildrenWithinReachTests.cs b/tests/Aetheria.Shared.Tests/ChildrenWithinReachTests.cs index a5ab3ebc3..6a8bd72cb 100644 --- a/tests/Aetheria.Shared.Tests/ChildrenWithinReachTests.cs +++ b/tests/Aetheria.Shared.Tests/ChildrenWithinReachTests.cs @@ -446,14 +446,14 @@ public sealed class ChildrenWithinReachTests : IClassFixture= 10, $"only {filled} of {belts} belts fill the authored width"); } - private static GeneratorPlanet Body(GeneratorPlanet parent, float mass, float phase = 0, bool ring = false) + internal static GeneratorPlanet Body(GeneratorPlanet parent, float mass, float phase = 0, bool ring = false) { var node = new GeneratorPlanet { Parent = parent, Mass = mass, Phase = phase, Ring = ring }; parent.Children.Add(node); return node; } - private static PlanetSettings AuthoredPlanetSettings() => AuthoredSettings.Load(RunStartTests.FindRepoRoot()).Read("PlanetSettings"); + internal static PlanetSettings AuthoredPlanetSettings() => AuthoredSettings.Load(RunStartTests.FindRepoRoot()).Read("PlanetSettings"); // Two belts side by side under one parent (production never draws them; follow-up belt-after-belt-edge-pin): the second // belt's slot starts a sibling spacing outside the first belt's outer edge, not its rail, so their rock intervals keep a spacing. diff --git a/tests/Aetheria.Shared.Tests/LanedBeltTests.cs b/tests/Aetheria.Shared.Tests/LanedBeltTests.cs index 94d400d18..cb48aa5dc 100644 --- a/tests/Aetheria.Shared.Tests/LanedBeltTests.cs +++ b/tests/Aetheria.Shared.Tests/LanedBeltTests.cs @@ -4,10 +4,10 @@ using GameCult.Caching; using Xunit; using Xunit.Abstractions; -// Laned belts (rulings belt-shapes-direction, lanes-two-sided-visual-margin; map B60): a belt candidate that kept its body carries -// an annulus on a copy of its rail that fills the body's slot less a lane LaneBodyRadii body radii a side, with rocks on both -// sides; it is drawn from a stream of its own and sits inside the slot, so it moves nothing else in the zone. Seeds 1-6, authored -// settings, against the same seeds drawing no lane. +// Laned belts (rulings belt-shapes-direction, lanes-two-sided-visual-margin, lane-narrow, lane-not-outermost; map B60): a belt +// candidate that kept its body and is not its parent's outermost child carries an annulus on a copy of its rail that fills the +// body's slot less a lane LaneBodyRadii (1.5) body radii a side, with rocks on both sides; it is drawn from a stream of its own and +// sits inside the slot, so it moves nothing else in the zone. Seeds 1-6, authored settings, against the same seeds drawing no lane. public sealed class LanedBeltTests : IClassFixture { private const uint First = 1, Last = 6; @@ -140,6 +140,23 @@ public sealed class LanedBeltTests : IClassFixture Assert.True(lanes > 0, $"no lane in seeds {First}-{Last}"); } + // An only child is its parent's outermost (ruling lane-not-outermost): Place stretches nothing for it, so its slot runs out to + // the window and has room for a lane on both sides, yet its lane's outer band would sit past every sibling's extent and raise + // the parent's support. The host filter refuses it on that ground alone. + [Fact] + public void AnOnlyChildHostsNoLane() + { + var root = new GeneratorPlanet { Mass = 1e5f }; + var child = ChildrenWithinReachTests.Body(root, 10f); + var planetSettings = ChildrenWithinReachTests.AuthoredPlanetSettings(); + ZoneGenerator.Place(root, planetSettings, 1e6f, 1e5f); + var lane = _samples.Authored.Settings.LaneBodyRadii * planetSettings.BodyRadius.Evaluate(child.Mass); + _out.WriteLine($"only child at {child.Distance}, slot [{child.SlotInner}, {child.SlotOuter}], lane half-width {lane}"); + Assert.True(child.SlotInner <= child.Distance - 2 * lane && child.SlotOuter >= child.Distance + 2 * lane, + $"the only child's slot [{child.SlotInner}, {child.SlotOuter}] has no room for a lane of {lane} at {child.Distance}"); + Assert.False(ZoneGenerator.LaneHost(child, lane)); + } + // A belt's field kind is chosen at generation and saved with it (Mining Cut 3, Q16 B): every belt the generator writes, // annulus, swarm or lane, carries one before any Zone reads it (finding ensure-unpinned). [Fact] From 0e9b9c71e27f4533f753215f107ef4cbe6de489b Mon Sep 17 00:00:00 2001 From: soul Date: Thu, 22 Oct 2026 00:44:36 +0200 Subject: [PATCH 043/307] Gravity: lane half-width 1.5 body radii (ruling lane-narrow), code default and Settings.asset Co-Authored-By: Claude Opus 5.5 --- Assets/Resources/Settings.asset | 4 ++-- Assets/Scripts/ServerShared/Settings.cs | 8 ++++---- 2 files changed, 6 insertions(+), 6 deletions(-) diff --git a/Assets/Resources/Settings.asset b/Assets/Resources/Settings.asset index f1f320a67..46bee7272 100644 --- a/Assets/Resources/Settings.asset +++ b/Assets/Resources/Settings.asset @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:8cc62a055653d90b906819239de99238253299f3ce4219cf58bddf9b67e3be67 -size 12523 +oid sha256:7f09c54dfdb87dffd76fa92365d62c53ce333457b89ccf8ead09093ffffedc42 +size 12525 diff --git a/Assets/Scripts/ServerShared/Settings.cs b/Assets/Scripts/ServerShared/Settings.cs index 18278dd15..5a95f450d 100644 --- a/Assets/Scripts/ServerShared/Settings.cs +++ b/Assets/Scripts/ServerShared/Settings.cs @@ -134,11 +134,11 @@ public class ZoneGenerationSettings public float TrojanHalfWidth = .06f; public ExponentialCurve TrojanCount = new ExponentialCurve { Exponent = 1f, Multiplier = .04f, Constant = 8f }; - // Laned belts (ruling lanes-two-sided-visual-margin): the chance a belt candidate that kept its body and has room on both - // sides carries a laned belt, and the lane's half-width in the host's body radii. 0 here: a settings object built in code - // draws none. + // Laned belts (rulings lanes-two-sided-visual-margin, lane-narrow): the chance a belt candidate that kept its body and has + // room on both sides carries a laned belt, and the lane's half-width in the host's body radii (1.5 by ruling). LaneProbability + // is 0 here: a settings object built in code draws no lane. public float LaneProbability = 0f; - public float LaneBodyRadii = 5f; + public float LaneBodyRadii = 1.5f; public float ResourceDensityMinimum = .1f; public float ResourceDensityMaximum = 1.5f; From 321e34a0dad051bba48a0d91a08c8e5801c524de Mon Sep 17 00:00:00 2001 From: soul Date: Thu, 22 Oct 2026 00:46:10 +0200 Subject: [PATCH 044/307] PilotSeamTests: an agent's command lands before the next agent decides Co-Authored-By: Claude Opus 5.5 --- tests/Aetheria.Shared.Tests/PilotSeamTests.cs | 39 +++++++++++++++++++ 1 file changed, 39 insertions(+) diff --git a/tests/Aetheria.Shared.Tests/PilotSeamTests.cs b/tests/Aetheria.Shared.Tests/PilotSeamTests.cs index 20e4e0c90..050913334 100644 --- a/tests/Aetheria.Shared.Tests/PilotSeamTests.cs +++ b/tests/Aetheria.Shared.Tests/PilotSeamTests.cs @@ -148,6 +148,45 @@ public sealed partial class RunStartTests Assert.True(east.Target.Value.IsNone); } + // A probe pilot state: it commands a turn rate and records what another ship's applied turn was when it decided. + private sealed class SeamProbeState : BaseState + { + private readonly float _rate; + private readonly Ship _watched; + public readonly List Seen = new List(); + public SeamProbeState(Agent agent, float rate, Ship watched) : base(agent) + { + _rate = rate; + _watched = watched; + } + + public override void Update(float delta) + { + if (_watched != null) Seen.Add(_watched.Turn); + _agent.Ship.Command.Turn = TurnCommand.OfRate(_rate); + } + } + + // Each agent's command is applied right after it decides, so the next agent sees it applied, as it saw the direct + // writes this seam replaced. Breaks if the agents' commands are applied only after every agent has decided. + [Fact] + public void AnAgentsCommandLandsBeforeTheNextAgentDecides() + { + var (arena, east, west) = SeamPair(false); + var first = new Agent(east, FactionDoctrine.Default(east.ItemManager.GameplaySettings).Combatant); + var second = new Agent(west, FactionDoctrine.Default(west.ItemManager.GameplaySettings).Combatant); + first.Transition(new SeamProbeState(first, .7f, null)); + var probe = new SeamProbeState(second, .1f, east); + second.Transition(probe); + arena.Agents.Add(first); + arena.Agents.Add(second); + + arena.Update(SeamDt); + + Assert.Equal(new[] { .7f }, probe.Seen); + Assert.Equal(.1f, west.Turn); + } + [Fact] public void AnEmptyCommandWritesNothing() { From 0a70e0cf93a133753df9b2847c130860ba6003c8 Mon Sep 17 00:00:00 2001 From: soul Date: Thu, 22 Oct 2026 01:00:06 +0200 Subject: [PATCH 045/307] Blender ships: hull_grid.grid_size; ship_cc reads HullData.HullType, pinned to C# grid_size is rasterise's grid arithmetic without its MAX_CELLS refusal, so a check can judge the bound; rasterise calls the same private filter and sizing. ship_cc gains HULL_TYPE_SLOT (Key 25), HULL_TYPE_NAMES and read_hull_type; ShipSchemaPinTests ties both to HullData.HullType and the HullType enum. Co-Authored-By: Claude Opus 5.5 --- .../ShipSchemaPinTests.cs | 2 ++ tools/blender/aetheria_ships/hull_grid.py | 31 ++++++++++++++----- tools/blender/aetheria_ships/ship_cc.py | 13 +++++++- tools/blender/tests/test_hull_grid.py | 10 ++++++ tools/blender/tests/test_ship_cc.py | 25 ++++++++++++++- 5 files changed, 71 insertions(+), 10 deletions(-) diff --git a/tests/Aetheria.Shared.Tests/ShipSchemaPinTests.cs b/tests/Aetheria.Shared.Tests/ShipSchemaPinTests.cs index 6316241ad..760595dcc 100644 --- a/tests/Aetheria.Shared.Tests/ShipSchemaPinTests.cs +++ b/tests/Aetheria.Shared.Tests/ShipSchemaPinTests.cs @@ -22,6 +22,7 @@ public sealed class ShipSchemaPinTests [InlineData("SCHEMATIC_LINES_SLOT", typeof(ShipAuthoring), nameof(ShipAuthoring.SchematicLines))] [InlineData("HULL_SHAPE_SLOT", typeof(HullData), nameof(HullData.Shape))] [InlineData("HULL_HARDPOINTS_SLOT", typeof(HullData), nameof(HullData.Hardpoints))] + [InlineData("HULL_TYPE_SLOT", typeof(HullData), nameof(HullData.HullType))] [InlineData("PRODUCT_MANUFACTURER_SLOT", typeof(FactionProductData), nameof(FactionProductData.Manufacturer))] public void PythonSlotConstantsAreTheKeysOfTheMembersTheyNameInTheirComments(string constant, Type owner, string member) { @@ -136,6 +137,7 @@ public sealed class ShipSchemaPinTests { Assert.Equal(EnumNames(), Strings("HARDPOINT_TYPE_NAMES")); Assert.Equal(EnumNames(), Strings("ROTATION_NAMES")); + Assert.Equal(EnumNames(), Strings("HULL_TYPE_NAMES")); } [Fact] diff --git a/tools/blender/aetheria_ships/hull_grid.py b/tools/blender/aetheria_ships/hull_grid.py index e408105be..3752fb5e7 100644 --- a/tools/blender/aetheria_ships/hull_grid.py +++ b/tools/blender/aetheria_ships/hull_grid.py @@ -32,6 +32,27 @@ def _inside(triangle, px, py): return not ((d1 < 0 or d2 < 0 or d3 < 0) and (d1 > 0 or d2 > 0 or d3 > 0)) +def _with_area(triangles_xy): + triangles = [tri for tri in triangles_xy if + (tri[1][0] - tri[0][0]) * (tri[2][1] - tri[0][1]) != (tri[1][1] - tri[0][1]) * (tri[2][0] - tri[0][0])] + if not triangles: + raise ValueError("The hull has no triangle with area to rasterise") + return triangles + + +def _size(triangles, cell_size): + xs = [p[0] for tri in triangles for p in tri] + ys = [p[1] for tri in triangles for p in tri] + return _cells_across(max(xs) - min(xs), cell_size), _cells_across(max(ys) - min(ys), cell_size), xs, ys + + +def grid_size(triangles_xy, cell_size=CELL_SIZE): + """(width, height) in cells of the grid rasterise would lay over these triangles, without refusing a grid over + MAX_CELLS: the caller judges the bound. Raises ValueError when no triangle has area.""" + width, height, _, _ = _size(_with_area(triangles_xy), cell_size) + return width, height + + def rasterise(triangles_xy, cell_size=CELL_SIZE, samples=SAMPLES): """Occupied cells of a top-down silhouette given as triangles of (x, y) points in the Ship Root frame. @@ -40,14 +61,8 @@ def rasterise(triangles_xy, cell_size=CELL_SIZE, samples=SAMPLES): inside several triangles counts once, and each triangle is tested only at the samples inside its bounding box. Returns (width, height, cells, grid_origin): cells are column-major, cells[x * height + y], and grid_origin is cell (0, 0)'s centre.""" - triangles = [tri for tri in triangles_xy if - (tri[1][0] - tri[0][0]) * (tri[2][1] - tri[0][1]) != (tri[1][1] - tri[0][1]) * (tri[2][0] - tri[0][0])] - if not triangles: - raise ValueError("The hull has no triangle with area to rasterise") - xs = [p[0] for tri in triangles for p in tri] - ys = [p[1] for tri in triangles for p in tri] - width = _cells_across(max(xs) - min(xs), cell_size) - height = _cells_across(max(ys) - min(ys), cell_size) + triangles = _with_area(triangles_xy) + width, height, xs, ys = _size(triangles, cell_size) for axis, count in (("wide", width), ("long", height)): if count > MAX_CELLS: raise ValueError(f"The hull needs {count} cells {axis}; a ship grid holds at most {MAX_CELLS}") diff --git a/tools/blender/aetheria_ships/ship_cc.py b/tools/blender/aetheria_ships/ship_cc.py index 7cc093389..82ef1d76f 100644 --- a/tools/blender/aetheria_ships/ship_cc.py +++ b/tools/blender/aetheria_ships/ship_cc.py @@ -34,15 +34,17 @@ ANCHORS_SLOT = 3 # ShipAuthoring.Anchors SCHEMATIC_LINES_SLOT = 4 # ShipAuthoring.SchematicLines HULL_SHAPE_SLOT = 5 # ItemData.Shape HULL_HARDPOINTS_SLOT = 23 # HullData.Hardpoints +HULL_TYPE_SLOT = 25 # HullData.HullType # HardpointData member names in slot order; a hardpoint row is one value per name, and any slot past the last # name belongs to a later schema and is carried through untouched. HARDPOINT_MEMBERS = ("Type", "Position", "Shape", "Transform", "Rotation", "Armor", "FiringArc") # ShipAnchor member names in slot order; as for hardpoints, slots past the last name are carried through by anchor Id. ANCHOR_MEMBERS = ("Id", "Role", "ModelNodeId", "ParentId", "Order") -# Enum member names in value order (HardpointType, ItemRotation). +# Enum member names in value order (HardpointType, ItemRotation, HullType). HARDPOINT_TYPE_NAMES = ("Hull", "Tool", "Thermal", "Thruster", "WarpDrive", "Reactor", "Radiator", "Shield", "Sensors", "Energy", "Ballistic", "Launcher", "ControlModule") ROTATION_NAMES = ("None", "CounterClockwise", "Reversed", "Clockwise") +HULL_TYPE_NAMES = ("Ship", "Station", "Turret") # ShipAuthoringStore.RequireShipId's rule, mirrored so New Ship refuses before it touches anything; ShipSchemaPinTests @@ -208,6 +210,15 @@ def read_layout(path: str, cultlib_packages: str): return hull[HULL_SHAPE_SLOT][0], hull[HULL_HARDPOINTS_SLOT], layout_revision(hull[HULL_SHAPE_SLOT][0], hull[HULL_HARDPOINTS_SLOT]) +def read_hull_type(path: str, cultlib_packages: str) -> str: + """The hull's HullData.HullType as its enum member name ("Ship", "Station" or "Turret").""" + hull = _hull_body(read(path, cultlib_packages)) + value = hull[HULL_TYPE_SLOT] if len(hull) > HULL_TYPE_SLOT else None + if type(value) is not int or not 0 <= value < len(HULL_TYPE_NAMES): + raise ValueError("The ship file's hull has no hull type this add-on knows") + return HULL_TYPE_NAMES[value] + + def layout_revision(shape: list[Any], hardpoints: list[list[Any]]) -> str: """The revision of a layout: a hash of its shape ([width, height, cells]) and hardpoint rows as the file stores them. replace_layout returns it, Load records it, and Package compares the layout buffer's against the file's.""" diff --git a/tools/blender/tests/test_hull_grid.py b/tools/blender/tests/test_hull_grid.py index 06667c0f2..ecc79b9fc 100644 --- a/tools/blender/tests/test_hull_grid.py +++ b/tools/blender/tests/test_hull_grid.py @@ -136,6 +136,16 @@ class RasteriseTests(unittest.TestCase): hull_grid.rasterise(rect(0, 0, 66, 2)) self.assertEqual(str(caught.exception), "The hull needs 33 cells wide; a ship grid holds at most 32") + def test_grid_size_is_rasterises_size_and_does_not_refuse_an_oversized_grid(self): + self.assertEqual(hull_grid.grid_size(rect(0, 0, 2, 64)), hull_grid.rasterise(rect(0, 0, 2, 64))[:2]) + self.assertEqual(hull_grid.grid_size(rect(-3, -1, 3, 4) + CORNER), hull_grid.rasterise(rect(-3, -1, 3, 4) + CORNER)[:2]) + self.assertEqual(hull_grid.grid_size(rect(0, 0, 2, 66)), (1, 33)) + self.assertEqual(hull_grid.grid_size(rect(0, 0, 66, 2)), (33, 1)) + self.assertEqual(hull_grid.grid_size(rect(0, 0, 4, 4), cell_size=1.0), (4, 4)) + with self.assertRaises(ValueError) as caught: + hull_grid.grid_size([((0, 0), (1, 1), (2, 2))]) + self.assertEqual(str(caught.exception), "The hull has no triangle with area to rasterise") + def test_a_silhouette_with_no_area_is_refused(self): for triangles in ([], [((0, 0), (1, 1), (2, 2))]): with self.assertRaises(ValueError) as caught: diff --git a/tools/blender/tests/test_ship_cc.py b/tools/blender/tests/test_ship_cc.py index 67facc162..6e971320a 100644 --- a/tools/blender/tests/test_ship_cc.py +++ b/tools/blender/tests/test_ship_cc.py @@ -44,8 +44,9 @@ def hardpoint_row(mount="thruster.port", tail=("future-hardpoint-slot",)): def hull_body(): - hull = [None] * (ship_cc.HULL_HARDPOINTS_SLOT + 1) + hull = [None] * (ship_cc.HULL_TYPE_SLOT + 1) hull[1] = "Skiff" + hull[ship_cc.HULL_TYPE_SLOT] = 0 hull[ship_cc.HULL_SHAPE_SLOT] = [shape(2, 2, [True, False, True, False])] hull[ship_cc.HULL_HARDPOINTS_SLOT] = [hardpoint_row()] hull.append("future-hull-slot") @@ -128,6 +129,28 @@ class ReadTests(ShipFileCase): self.assertEqual("Skiff", file.hull.body[1]) self.assertEqual(ship_cc.HULL_SCHEMA, file.hull.envelope.type) + def test_reads_the_hulls_type_by_its_enum_name(self): + for value, name in ((0, "Ship"), (1, "Station"), (2, "Turret")): + with self.subTest(name): + hull = hull_body() + hull[ship_cc.HULL_TYPE_SLOT] = value + self.write(ship_body(), hull) + self.assertEqual(name, ship_cc.read_hull_type(self.path, PACKAGES)) + + def test_refuses_a_hull_type_it_does_not_know_without_echoing_the_path(self): + short = hull_body()[:ship_cc.HULL_TYPE_SLOT] + for label, value in (("past the enum", 3), ("negative", -1), ("bool", True), ("missing", None), + ("text", "Ship"), ("short body", short)): + with self.subTest(label): + hull = value if label == "short body" else hull_body() + if label != "short body": + hull[ship_cc.HULL_TYPE_SLOT] = value + self.write(ship_body(), hull) + with self.assertRaises(ValueError) as caught: + ship_cc.read_hull_type(self.path, PACKAGES) + self.assertEqual("The ship file's hull has no hull type this add-on knows", str(caught.exception)) + self.assertNotIn(self.path, str(caught.exception)) + def test_reads_a_file_whose_hull_also_carries_products(self): self.add_products() file = ship_cc.read(self.path, PACKAGES) From 2267d8beec76a54f9871a7c035d0ad3d65ab9245 Mon Sep 17 00:00:00 2001 From: soul Date: Thu, 22 Oct 2026 01:03:27 +0200 Subject: [PATCH 046/307] Hull source: language proportions judge Ship hulls only; zones must fit the layout grid build() takes the hull's HullData.HullType, with no default; the add-on reads it from the bound ship.cc. A Station or Turret lists no proportion check; every hull keeps symmetry, length, budget and anchor checks and gains the grid check, judged over zone meshes through hull_grid.grid_size. Co-Authored-By: Claude Opus 5.5 --- tools/blender/aetheria_ships/__init__.py | 12 ++- tools/blender/aetheria_ships/hull_source.py | 36 +++++++-- tools/blender/tests/test_hull_source.py | 81 ++++++++++++++++++++- 3 files changed, 116 insertions(+), 13 deletions(-) diff --git a/tools/blender/aetheria_ships/__init__.py b/tools/blender/aetheria_ships/__init__.py index 90d1c9ad7..17ad00afc 100644 --- a/tools/blender/aetheria_ships/__init__.py +++ b/tools/blender/aetheria_ships/__init__.py @@ -24,7 +24,7 @@ from mathutils import Matrix, Vector from . import hull_grid, hull_source from .ship_cc import (HARDPOINT_MEMBERS, HARDPOINT_TYPE_NAMES, ROTATION_NAMES, capture_grease_pencil, decode_hardpoint, - encode_hardpoint, layout_revision, read, read_layout, replace_layout, replace_lines, + encode_hardpoint, layout_revision, read, read_hull_type, read_layout, replace_layout, replace_lines, product_makers, replace_visual, valid_ship_id) MODEL_ASSET = "ship.glb" @@ -1557,8 +1557,10 @@ def _hull_mesh(name, mesh, material, offset=(0.0, 0.0, 0.0), yaw=0.0): def _build_hull_source(context, collection): """Replaces the objects Build made before with the hull source's zone meshes, anchors and collider under Ship Root. - Refuses, before anything changes, when the source cannot be read, breaks a check of its faction's language, or the - collection holds render meshes Build did not make (operator art: Build never overwrites it). Writes no layout row.""" + Refuses, before anything changes, when the source cannot be read, breaks a check of its faction's language (whose + proportions judge a hull whose ship.cc HullData.HullType is Ship, and no other), its zones overflow the layout grid, + or the collection holds render meshes Build did not make (operator art: Build never overwrites it). Writes no layout + row.""" source = _hull_source_file(collection) if source is None: raise ValueError("The bound package holds no hull.scad") @@ -1573,7 +1575,9 @@ def _build_hull_source(context, collection): "changed; Build From Hull Source replaces only what it made and nobody touched") addon = context.preferences.addons.get(__package__) executable = addon.preferences.openscad if addon and addon.preferences else DEFAULT_OPENSCAD - hull = hull_source.build(hull_source.openscad_runner(executable, source), hull_source.language_library(executable)) + hull_type = read_hull_type(bpy.path.abspath(collection["aetheria.ship_cc"]), _brokkr_cultlib(context)) + hull = hull_source.build(hull_source.openscad_runner(executable, source), hull_source.language_library(executable), + hull_type) broken = hull.failed() if broken: raise ValueError("The hull source breaks its faction's language or its brief: " + "; ".join(c.name for c in broken)) diff --git a/tools/blender/aetheria_ships/hull_source.py b/tools/blender/aetheria_ships/hull_source.py index 50a932e0d..300c926f1 100644 --- a/tools/blender/aetheria_ships/hull_source.py +++ b/tools/blender/aetheria_ships/hull_source.py @@ -14,11 +14,15 @@ with it (a hull that redefines or shadows an included language fails a check): the zone's geometry, OFF; a donor's zone is named ":" emit:, rad: the mesh of a thruster-emitter or radiator-mesh anchor -Frame: Ship Root, metres, nose -Y, starboard -X, up +Z. build() returns a HullBuild; the caller refuses on any failed -check. Messages and check names are fixed text: nothing a hull source says is echoed back. +Frame: Ship Root, metres, nose -Y, starboard -X, up +Z. build() also takes the hull's HullData.HullType ("Ship", +"Station" or "Turret"), which the caller reads: the add-on from the bound package's ship.cc, the bench from its flag. +A faction language's proportions judge Ship hulls only; every hull's zones must fit the layout grid (hull_grid.MAX_CELLS +cells of hull_grid.CELL_SIZE a side). The brief's role is prose and decides nothing. build() returns a HullBuild; the +caller refuses on any failed check. Messages and check names are fixed text: nothing a hull source says is echoed back. """ import hashlib +import importlib.util import json import math import os @@ -29,6 +33,13 @@ from dataclasses import dataclass, field from pathlib import Path from typing import Callable, Optional +if __package__: + from . import hull_grid +else: # loaded by path (tests, the bench): load the sibling the same way + _grid_spec = importlib.util.spec_from_file_location("hull_grid", Path(__file__).with_name("hull_grid.py")) + hull_grid = importlib.util.module_from_spec(_grid_spec) + _grid_spec.loader.exec_module(hull_grid) + FACTIONS_DIR = Path(__file__).with_name("factions") HULL_FILE = "hull.scad" OPENSCAD_TIMEOUT = 180 # seconds, per part @@ -113,6 +124,7 @@ class HullBuild: glow: tuple radiator: tuple body_zone: str # the zone key the collider is made from + hull_type: str # HullData.HullType of the bound package ("Ship", "Station", "Turret"), as the caller read it checks: list = field(default_factory=list) def failed(self): @@ -274,9 +286,10 @@ def _language(library, faction): return language -def build(runner, library): +def build(runner, library, hull_type): """Reads the hull source through runner (see Runner) and judges it against the languages library(faction) answers with - (the ECHO text of that faction's own language file). Raises HullSourceError when the source cannot be read at all; + (the ECHO text of that faction's own language file). hull_type is the hull's HullData.HullType name, which the caller + reads; only a "Ship" is judged on the language's proportions. Raises HullSourceError when the source cannot be read at all; otherwise returns a HullBuild whose failed() lists the checks it breaks.""" _, text = runner("brief") brief = read_brief(text) @@ -298,7 +311,7 @@ def build(runner, library): judge = donor or own body_zone = f"{donor.faction}:{donor.body}" if donor else own.body result = HullBuild(brief, {language.faction: language for language in owned}, zones, colours, anchors, anchor_meshes, - own.glow, own.radiator, body_zone) + own.glow, own.radiator, body_zone, hull_type) by_faction = lambda language: language.faction result.checks = [Check("the hull includes its faction's language files and changes none of them", sorted(included, key=by_faction) == sorted(owned, key=by_faction), "")] @@ -314,8 +327,9 @@ def _checks(hull, own, judge): checks.append(Check("every emitter and radiator anchor has a mesh", not missing, f"{len(missing)} without")) checks.append(Check("a donor is named exactly when the language has no proportions of its own", bool(brief.donor) == (own.proportion is None), "")) + # A language's proportions describe its ships; a station or turret is not held to them, and no check is listed. body = hull.zones[hull.body_zone] if hull.body_zone in hull.zones else Mesh((), ()) - if body.triangles and judge.proportion: + if hull.hull_type == "Ship" and body.triangles and judge.proportion: low, high = body.bounds() width, length, height = (high[0] - low[0]), (high[1] - low[1]), (high[2] - low[2]) ratios = (height / width if width else float("inf"), width / length if length else float("inf")) @@ -323,7 +337,7 @@ def _checks(hull, own, judge): checks.append(Check("the body's proportions lie inside the language's ranges", hw_lo <= ratios[0] <= hw_hi and wl_lo <= ratios[1] <= wl_hi, f"h/w {ratios[0]:.2f}, w/l {ratios[1]:.2f}")) - else: + elif hull.hull_type == "Ship": checks.append(Check("the body's proportions lie inside the language's ranges", False, "no body to judge")) every = [m for m in list(hull.zones.values()) + list(hull.anchor_meshes.values()) if m.triangles] if every: @@ -338,6 +352,14 @@ def _checks(hull, own, judge): else: checks.append(Check("the top view obeys the language's symmetry rule", False, "no geometry")) checks.append(Check("the length lies inside the brief's range", False, "no geometry")) + # Rasterise reads the zone meshes only, so the grid bound is judged over them, not over anchor meshes. + try: + width, height = hull_grid.grid_size([t for m in hull.zones.values() for t in _triangles_xy(m)]) + checks.append(Check("the top view fits the layout grid", + width <= hull_grid.MAX_CELLS and height <= hull_grid.MAX_CELLS, + f"{width} x {height} cells, at most {hull_grid.MAX_CELLS} a side")) + except ValueError: + checks.append(Check("the top view fits the layout grid", False, "no geometry")) triangles = sum(len(m.triangles) for m in every) checks.append(Check("the triangle count lies inside the brief's budget", brief.triangles[0] <= triangles <= brief.triangles[1], f"{triangles}")) diff --git a/tools/blender/tests/test_hull_source.py b/tools/blender/tests/test_hull_source.py index 362664de7..653946eae 100644 --- a/tools/blender/tests/test_hull_source.py +++ b/tools/blender/tests/test_hull_source.py @@ -57,6 +57,7 @@ class Fixture: def __init__(self): self.library = {"zhestokost": language()} self.hull_lang = None + self.hull_type = "Ship" # HullData.HullType, which the caller reads from the package # Body 6 wide, 10 long, 2 high (h/w 0.33, w/l 0.6); a bilateral plate on top; one gun with a muzzle. self.parts = { "brief": ("", brief()), @@ -77,7 +78,7 @@ class Fixture: return self.library[faction] def build(self): - return hs.build(self.runner(), self.read_library) + return hs.build(self.runner(), self.read_library, self.hull_type) def failed(self): return [c.name for c in self.build().failed()] @@ -128,6 +129,82 @@ class ReadingTests(unittest.TestCase): hs.read_languages(language(body="keel")) +PROPORTION = "the body's proportions lie inside the language's ranges" +GRID = "the top view fits the layout grid" + + +def names(built): + return [c.name for c in built.checks] + + +class HullTypeTests(unittest.TestCase): + """A language's proportions judge Ship hulls only; every other rule, and the layout grid, judges every hull type.""" + + @staticmethod + def fixture(hull_type, **parts): + fixture = Fixture() + fixture.hull_type = hull_type + fixture.parts.update({k: (v, "") for k, v in parts.items()}) + return fixture + + def test_a_station_or_turret_is_not_held_to_the_languages_proportions_and_lists_no_proportion_check(self): + wide = box(-12, 12, -5, 5, 0, 2) # h/w 0.08, w/l 2.4: outside both of Zhestokost's ranges + for hull_type in ("Station", "Turret"): + with self.subTest(hull_type): + built = self.fixture(hull_type, primary=wide).build() + self.assertEqual([c.name for c in built.failed()], []) + self.assertNotIn(PROPORTION, names(built)) + self.assertEqual(built.hull_type, hull_type) + self.assertEqual(self.fixture("Ship", primary=wide).failed(), [PROPORTION]) + + def test_a_station_with_no_body_lists_no_proportion_check_but_a_ship_fails_it(self): + self.assertNotIn(PROPORTION, names(self.fixture("Station", primary="").build())) + ship = self.fixture("Ship", primary="").build() + self.assertEqual([c.detail for c in ship.checks if c.name == PROPORTION], ["no body to judge"]) + + def test_a_ship_lists_the_proportion_check_when_it_passes(self): + built = self.fixture("Ship").build() + self.assertEqual([c.ok for c in built.checks if c.name == PROPORTION], [True]) + + def test_a_station_is_still_judged_on_symmetry_length_and_budget(self): + self.assertEqual(self.fixture("Station", secondary=box(2, 6, -1, 1, 2, 3)).failed(), + ["the top view obeys the language's bilateral rule"]) + self.assertEqual(self.fixture("Station", primary=box(-3, 3, -10, 10, 0, 2)).failed(), + ["the length lies inside the brief's range"]) + station = self.fixture("Station") + station.parts["brief"] = ("", brief(triangles=(10, 20))) + self.assertEqual(station.failed(), ["the triangle count lies inside the brief's budget"]) + + def test_every_hull_type_must_fit_the_layout_grid_at_thirty_two_cells_a_side(self): + for hull_type in ("Ship", "Station", "Turret"): + with self.subTest(hull_type): + fits = self.fixture(hull_type, secondary=box(-32, 32, -1, 1, 2, 3)).build() + self.assertEqual([(c.ok, c.detail) for c in fits.checks if c.name == GRID], + [(True, "32 x 5 cells, at most 32 a side")]) + wide = self.fixture(hull_type, secondary=box(-33, 33, -1, 1, 2, 3)) + self.assertEqual(wide.failed(), [GRID]) + self.assertEqual([c.detail for c in wide.build().checks if c.name == GRID], + ["33 x 5 cells, at most 32 a side"]) + long = self.fixture("Station", secondary=box(-1, 1, -33, 33, 2, 3)) + long.parts["brief"] = ("", brief(length=(8, 70))) + self.assertEqual(long.failed(), [GRID]) + self.assertEqual([c.detail for c in long.build().checks if c.name == GRID], ["3 x 33 cells, at most 32 a side"]) + + def test_the_grid_reads_zone_meshes_only_not_anchor_meshes(self): + built = self.fixture("Station", **{"emit:Th.A": box(-35, 35, 5, 5.1, 0.5, 1.5)}).build() + self.assertEqual([c.name for c in built.failed()], []) + self.assertEqual([c.detail for c in built.checks if c.name == GRID], ["3 x 5 cells, at most 32 a side"]) + + def test_a_hull_with_no_zone_geometry_fails_the_grid_check(self): + built = self.fixture("Station", primary="", secondary="").build() + self.assertEqual([(c.ok, c.detail) for c in built.checks if c.name == GRID], [(False, "no geometry")]) + + def test_build_has_no_default_hull_type(self): + fixture = Fixture() + with self.assertRaises(TypeError): + hs.build(fixture.runner(), fixture.read_library) + + class MirrorOverlapTests(unittest.TestCase): @staticmethod def top(text): @@ -381,7 +458,7 @@ class LanguageOwnershipTests(unittest.TestCase): def test_the_library_is_asked_for_the_faction_and_the_donor_the_brief_names(self): asked = [] fixture = DonorTests.pirate() - hs.build(fixture.runner(), lambda faction: asked.append(faction) or fixture.read_library(faction)) + hs.build(fixture.runner(), lambda faction: asked.append(faction) or fixture.read_library(faction), "Ship") self.assertEqual(sorted(asked), ["pirate", "rnd"]) From 28496f27dfd05ff4e56c2e369ef15c68f162a1e8 Mon Sep 17 00:00:00 2001 From: soul Date: Thu, 22 Oct 2026 01:07:54 +0200 Subject: [PATCH 047/307] Hull source smoke: HullType gate (refused as Ship, built as Station, Station refused on the grid) Co-Authored-By: Claude Opus 5.5 --- tools/blender/aetheria_ships/hull_source.py | 4 +- tools/blender/tests/smoke_hull_source.py | 65 ++++++++++++++++++++- 2 files changed, 66 insertions(+), 3 deletions(-) diff --git a/tools/blender/aetheria_ships/hull_source.py b/tools/blender/aetheria_ships/hull_source.py index 300c926f1..ce5529897 100644 --- a/tools/blender/aetheria_ships/hull_source.py +++ b/tools/blender/aetheria_ships/hull_source.py @@ -289,8 +289,8 @@ def _language(library, faction): def build(runner, library, hull_type): """Reads the hull source through runner (see Runner) and judges it against the languages library(faction) answers with (the ECHO text of that faction's own language file). hull_type is the hull's HullData.HullType name, which the caller - reads; only a "Ship" is judged on the language's proportions. Raises HullSourceError when the source cannot be read at all; - otherwise returns a HullBuild whose failed() lists the checks it breaks.""" + reads; only a "Ship" is judged on the language's proportions. Raises HullSourceError when the source cannot be read at + all; otherwise returns a HullBuild whose failed() lists the checks it breaks.""" _, text = runner("brief") brief = read_brief(text) _, text = runner("lang") diff --git a/tools/blender/tests/smoke_hull_source.py b/tools/blender/tests/smoke_hull_source.py index 8bb9b9c48..06f2db715 100644 --- a/tools/blender/tests/smoke_hull_source.py +++ b/tools/blender/tests/smoke_hull_source.py @@ -38,6 +38,8 @@ the hull.scad into the package and runs the panel's operators: changes nothing in the scene. Her art needs no product. Build and Package refuse copies that share an aetheria.id, selecting the original and the copies, and delete nothing. Package moves no row and turns no mount: every layout row is as authored after it, and a hand-turned mount keeps its yaw. + The ship.cc HullData.HullType decides whether the language's proportions judge the hull: a body far wider than the + language's ships is refused as Ship and built as Station, and a Station wider than the layout grid is refused. """ import dataclasses @@ -698,6 +700,66 @@ def agent_guards(scene): return collection +def hull_type_gate(scene): + """The hull's HullData.HullType in ship.cc decides whether the language's proportions judge it; every hull type must + fit the layout grid. A body far wider than a Zhestokost ship is refused as Ship, built as Station, and a Station + wider than 32 cells is refused on the grid.""" + fixture, _, extent, length, rows, *_ = HULLS[0] + ship_id = "smoke.hull.role" + collection, box, package = make_ship(scene, fixture, ship_id, extent, length, rows) + ship_module = sys.modules["aetheria_ships.ship_cc"] + path = str(package / "ship.cc") + source = package / "hull.scad" + original = source.read_text() + slab = '\nif (part == "primary") translate([-%d, -2, 0]) cube([%d, 4, 1]);\n' + source.write_text(original + slab % (15, 30)) + judged = [] + real = hull_source.build + + def recorded(*args): + judged.append(real(*args)) + return judged[-1] + + sys.modules["aetheria_ships"].hull_source.build = recorded + try: + require(ship_module.read_hull_type(path, PACKAGES) == "Ship", "New Ship did not write a Ship hull") + select(box) + result, message = run(bpy.ops.aetheria.build_hull_source) + require(result == {"CANCELLED"} and "the body's proportions lie inside the language's ranges" in message, + f"A Ship far wider than its language allows was built: {message!r}") + print("PASS 5.1 a body wider than the language's ships is refused as Ship, on proportion") + + store = ship_module.read(path, PACKAGES).store + for envelope in store.pull_all(): + if envelope.type == ship_module.HULL_SCHEMA: + values = msgpack.unpackb(envelope.payload, raw=False) + values[ship_module.HULL_TYPE_SLOT] = 1 + store.push(dataclasses.replace(envelope, payload=msgpack.packb(values, use_bin_type=True))) + require(ship_module.read_hull_type(path, PACKAGES) == "Station", "The pushed HullType did not read back as Station") + select(box) + result, message = run(bpy.ops.aetheria.build_hull_source) + names = [c.name for c in judged[-1].checks] + require(result == {"FINISHED"} and judged[-1].hull_type == "Station" + and "the body's proportions lie inside the language's ranges" not in names + and "the top view fits the layout grid" in names, + f"The same body as Station was refused or judged on proportion: {message!r} {names}") + print("PASS 5.2 the same body as Station is built, with no proportion check and a grid check") + + built = set(generated(collection)) + source.write_text(original + slab % (35, 70)) + select(box) + result, message = run(bpy.ops.aetheria.build_hull_source) + require(result == {"CANCELLED"} and "the top view fits the layout grid" in message + and "the body's proportions lie inside the language's ranges" not in message + and set(generated(collection)) == built, + f"A Station 70 m wide was not refused on the layout grid, or the refusal changed the scene: {message!r}") + print("PASS 5.3 a Station 70 m wide is refused on the layout grid, not on proportion") + finally: + sys.modules["aetheria_ships"].hull_source.build = real + print("HULL_TYPE_GATE_OK", flush=True) + return collection + + def main(): require(bpy.app.version[:2] == (5, 2), f"This smoke is for Blender 5.2; this is {bpy.app.version_string}") bpy.data.objects.remove(bpy.data.objects["Cube"], do_unlink=True) @@ -733,6 +795,7 @@ def main(): previous = one_hull(scene, number, fixture, ship_id, extent, length, rows, body, palette, maker, other) clear(previous) clear(agent_guards(scene)) + clear(hull_type_gate(scene)) never_built(scene) @@ -750,7 +813,7 @@ else: print("SMOKE_HULL_SOURCE_OK", flush=True) code = 0 finally: - for _, ship_id, *_ in HULLS + ((None, "smoke.hull.unbuilt"), (None, "smoke.hull.guards")): + for _, ship_id, *_ in HULLS + ((None, "smoke.hull.unbuilt"), (None, "smoke.hull.guards"), (None, "smoke.hull.role")): shutil.rmtree(MODS / ship_id, ignore_errors=True) if created: shutil.rmtree(MODS, ignore_errors=True) From 8bfb06ed64835a156669700923b318076be5cd37 Mon Sep 17 00:00:00 2001 From: soul Date: Thu, 22 Oct 2026 01:15:11 +0200 Subject: [PATCH 048/307] Hull source tests: a Station 32 cells long fits the grid (height bound, not only width) Co-Authored-By: Claude Opus 5.5 --- tools/blender/tests/test_hull_source.py | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/tools/blender/tests/test_hull_source.py b/tools/blender/tests/test_hull_source.py index 653946eae..70d6e917a 100644 --- a/tools/blender/tests/test_hull_source.py +++ b/tools/blender/tests/test_hull_source.py @@ -185,6 +185,10 @@ class HullTypeTests(unittest.TestCase): self.assertEqual(wide.failed(), [GRID]) self.assertEqual([c.detail for c in wide.build().checks if c.name == GRID], ["33 x 5 cells, at most 32 a side"]) + long = self.fixture("Station", secondary=box(-1, 1, -32, 32, 2, 3)) + long.parts["brief"] = ("", brief(length=(8, 70))) + self.assertEqual(long.failed(), []) + self.assertEqual([c.detail for c in long.build().checks if c.name == GRID], ["3 x 32 cells, at most 32 a side"]) long = self.fixture("Station", secondary=box(-1, 1, -33, 33, 2, 3)) long.parts["brief"] = ("", brief(length=(8, 70))) self.assertEqual(long.failed(), [GRID]) From d76563dd5a188ba5cd38ea6a5c9b0f4b2ee5cfe3 Mon Sep 17 00:00:00 2001 From: soul Date: Thu, 22 Oct 2026 01:25:22 +0200 Subject: [PATCH 049/307] NakovalnyaPackageTests: every mount takes its gear through Takes; rows inside the shape and disjoint; no footprint-count pin (red) Co-Authored-By: Claude Opus 5.5 --- .../NakovalnyaPackageTests.cs | 32 +++++++++++++++++-- 1 file changed, 29 insertions(+), 3 deletions(-) diff --git a/tests/Aetheria.Shared.Tests/NakovalnyaPackageTests.cs b/tests/Aetheria.Shared.Tests/NakovalnyaPackageTests.cs index 6f55823f0..d2b08f8ba 100644 --- a/tests/Aetheria.Shared.Tests/NakovalnyaPackageTests.cs +++ b/tests/Aetheria.Shared.Tests/NakovalnyaPackageTests.cs @@ -71,9 +71,7 @@ public sealed class NakovalnyaPackageTests using var cache = Catalog(); var hull = Hull(cache); var ship = cache.Get(new CultRecordRef(ShipModCatalog.AuthoringKey(Id))); - var rows = hull.Hardpoints.GroupBy(h => (h.Type, h.Shape.Width, h.Shape.Height)).ToDictionary(g => g.Key, g => g.Count()); - Assert.Equal(3, rows.GetValueOrDefault((HardpointType.Ballistic, 2, 2))); - Assert.Equal(10, rows.GetValueOrDefault((HardpointType.Ballistic, 1, 2))); + Assert.Equal(13, hull.Hardpoints.Count(h => h.Type == HardpointType.Ballistic)); Assert.Equal(8, hull.Hardpoints.Count(h => h.Type == HardpointType.Thruster)); Assert.Equal(2, hull.Hardpoints.Count(h => h.Type == HardpointType.Radiator)); foreach (var kind in new[] { HardpointType.Reactor, HardpointType.Shield, HardpointType.ControlModule, HardpointType.Sensors }) @@ -93,5 +91,33 @@ public sealed class NakovalnyaPackageTests Assert.InRange(hull.Shape.Width, 1, 32); Assert.InRange(hull.Shape.Height, 1, 32); + + // Every row's cells lie inside the hull's shape, and no two rows share a cell. + var owners = new Dictionary<(int, int), string>(); + foreach (var h in hull.Hardpoints) + foreach (var c in h.Shape.Coordinates) + { + var cell = h.Position + c; + Assert.True(hull.Shape[cell], $"{h.Transform} covers ({cell.x},{cell.y}), outside the hull's shape"); + Assert.True(owners.TryAdd((cell.x, cell.y), h.Transform), $"{h.Transform} and {owners.GetValueOrDefault((cell.x, cell.y))} share ({cell.x},{cell.y})"); + } + } + + // Each mount takes the gear it was placed for, under the one fit rule (HardpointData.Takes): ruling nakovalnya-flak-1x2 + // means the Flak Gun fits every flak mount at its facing, and a row's footprint is its gear's shape turned by Rotation. + [Fact] + public void EveryMountTakesItsGear() + { + using var cache = Catalog(); + var hull = Hull(cache); + EquippableItemData Gear(string name) => cache.GetAll().Single(design => design.Name == name); + var gear = Enumerable.Range(1, 10).Select(i => ($"Gun.F{i}", "Flak Gun")) + .Concat(Enumerable.Range(1, 3).Select(i => ($"Gun.H{i}", "Autocannon"))) + .Concat(new[] { ("Th.L", "Large Drive"), ("Th.R", "Large Drive") }) + .Concat(Enumerable.Range(1, 6).Select(i => ($"Th.T{i}", "Medium Drive"))) + .Concat(new[] { ("Ra.L", "Radiator"), ("Ra.R", "Radiator") }); + var refused = gear.Where(row => !hull.Hardpoints.Single(h => h.Transform == row.Item1).Takes(Gear(row.Item2))) + .Select(row => $"{row.Item1} refuses the {row.Item2}").ToArray(); + Assert.Empty(refused); } } From efeadec26502368f285c3b2141efacff10cfcbcb Mon Sep 17 00:00:00 2001 From: soul Date: Thu, 22 Oct 2026 01:27:38 +0200 Subject: [PATCH 050/307] Blender ships tests: build refuses a hull type that is not a HullType name; the grid limit is hull_grid's (red) Co-Authored-By: Claude Opus 5.5 --- tools/blender/tests/test_hull_source.py | 17 +++++++++++++++++ tools/blender/tests/test_ship_cc.py | 11 +++++++++++ 2 files changed, 28 insertions(+) diff --git a/tools/blender/tests/test_hull_source.py b/tools/blender/tests/test_hull_source.py index 70d6e917a..bd534d83b 100644 --- a/tools/blender/tests/test_hull_source.py +++ b/tools/blender/tests/test_hull_source.py @@ -208,6 +208,23 @@ class HullTypeTests(unittest.TestCase): with self.assertRaises(TypeError): hs.build(fixture.runner(), fixture.read_library) + def test_build_refuses_a_hull_type_that_is_not_a_hulltype_name_before_reading_the_source(self): + for hull_type in ("ship", "SHIP", "Ship ", "", None, "Stations", 0, SECRET): + with self.subTest(hull_type=hull_type): + fixture, read = Fixture(), [] + runner = fixture.runner() + with self.assertRaises(hs.HullSourceError) as caught: + hs.build(lambda part: read.append(part) or runner(part), fixture.read_library, hull_type) + self.assertEqual(read, []) + self.assertNotIn(SECRET, str(caught.exception)) + + def test_build_accepts_each_hulltype_name_and_only_a_ship_lists_the_proportion_check(self): + for hull_type in ("Ship", "Station", "Turret"): + with self.subTest(hull_type): + built = self.fixture(hull_type).build() + self.assertEqual([c.name for c in built.failed()], []) + self.assertEqual(PROPORTION in names(built), hull_type == "Ship") + class MirrorOverlapTests(unittest.TestCase): @staticmethod diff --git a/tools/blender/tests/test_ship_cc.py b/tools/blender/tests/test_ship_cc.py index 6e971320a..828352438 100644 --- a/tools/blender/tests/test_ship_cc.py +++ b/tools/blender/tests/test_ship_cc.py @@ -15,6 +15,7 @@ import shutil import sys import tempfile import unittest +from unittest import mock from datetime import datetime from pathlib import Path from types import SimpleNamespace @@ -543,6 +544,16 @@ class LayoutTests(ShipFileCase): revision = self.layout()[2] self.assertNotEqual(before, self.bytes()) + def test_the_grid_limit_is_hull_grids_max_cells(self): + _, _, revision = self.layout() + before = self.bytes() + with self.assertRaisesRegex(ValueError, f"1..{ship_cc.hull_grid.MAX_CELLS} cells"): + self.edit(revision, width=33, height=1, cells=[True] * 33) + self.assertEqual(before, self.bytes()) + with mock.patch.object(ship_cc.hull_grid, "MAX_CELLS", 33): + self.edit(revision, width=33, height=1, cells=[True] * 33, hardpoints=[]) + self.assertEqual([33, 1, [True] * 33], self.layout()[0]) + def test_a_layout_save_refreshes_the_stored_at_of_the_hull_and_leaves_the_ships(self): stale = "2000-01-01T00:00:00+00:00" envelopes = self.envelopes() From da77380a0456110573f69c01ac64d49f3f7c83e3 Mon Sep 17 00:00:00 2001 From: soul Date: Thu, 22 Oct 2026 01:28:06 +0200 Subject: [PATCH 051/307] Hull source: build refuses a hull type that is not a HullType name, before reading the source Co-Authored-By: Claude Opus 5.5 --- tools/blender/aetheria_ships/hull_source.py | 20 +++++++++++++------- 1 file changed, 13 insertions(+), 7 deletions(-) diff --git a/tools/blender/aetheria_ships/hull_source.py b/tools/blender/aetheria_ships/hull_source.py index ce5529897..fb2aa246a 100644 --- a/tools/blender/aetheria_ships/hull_source.py +++ b/tools/blender/aetheria_ships/hull_source.py @@ -34,11 +34,14 @@ from pathlib import Path from typing import Callable, Optional if __package__: - from . import hull_grid -else: # loaded by path (tests, the bench): load the sibling the same way - _grid_spec = importlib.util.spec_from_file_location("hull_grid", Path(__file__).with_name("hull_grid.py")) - hull_grid = importlib.util.module_from_spec(_grid_spec) - _grid_spec.loader.exec_module(hull_grid) + from . import hull_grid, ship_cc +else: # loaded by path (tests, the bench): load the siblings the same way + def _sibling(name): + spec = importlib.util.spec_from_file_location(name, Path(__file__).with_name(f"{name}.py")) + module = importlib.util.module_from_spec(spec) + spec.loader.exec_module(module) + return module + hull_grid, ship_cc = _sibling("hull_grid"), _sibling("ship_cc") FACTIONS_DIR = Path(__file__).with_name("factions") HULL_FILE = "hull.scad" @@ -289,8 +292,11 @@ def _language(library, faction): def build(runner, library, hull_type): """Reads the hull source through runner (see Runner) and judges it against the languages library(faction) answers with (the ECHO text of that faction's own language file). hull_type is the hull's HullData.HullType name, which the caller - reads; only a "Ship" is judged on the language's proportions. Raises HullSourceError when the source cannot be read at - all; otherwise returns a HullBuild whose failed() lists the checks it breaks.""" + reads; only a "Ship" is judged on the language's proportions. Raises HullSourceError, before reading anything, when + hull_type is not one of ship_cc.HULL_TYPE_NAMES, and when the source cannot be read at all; otherwise returns a + HullBuild whose failed() lists the checks it breaks.""" + if not (type(hull_type) is str and hull_type in ship_cc.HULL_TYPE_NAMES): + raise HullSourceError("The hull type is not a HullData.HullType name (Ship, Station or Turret)") _, text = runner("brief") brief = read_brief(text) _, text = runner("lang") From c7e85676fe76c8d35d5bb9fd10b876ed09a6b567 Mon Sep 17 00:00:00 2001 From: soul Date: Thu, 22 Oct 2026 01:28:07 +0200 Subject: [PATCH 052/307] ship_cc: replace_layout refuses on hull_grid.MAX_CELLS, the grid limit's one owner Co-Authored-By: Claude Opus 5.5 --- tools/blender/aetheria_ships/ship_cc.py | 13 +++++++++++-- 1 file changed, 11 insertions(+), 2 deletions(-) diff --git a/tools/blender/aetheria_ships/ship_cc.py b/tools/blender/aetheria_ships/ship_cc.py index 82ef1d76f..079296be4 100644 --- a/tools/blender/aetheria_ships/ship_cc.py +++ b/tools/blender/aetheria_ships/ship_cc.py @@ -16,6 +16,14 @@ from datetime import datetime, timezone from pathlib import Path from typing import Any, NamedTuple +if __package__: + from . import hull_grid +else: # loaded by path (tests, hull_source loaded by path): load the sibling the same way + import importlib.util + _grid_spec = importlib.util.spec_from_file_location("hull_grid", Path(__file__).with_name("hull_grid.py")) + hull_grid = importlib.util.module_from_spec(_grid_spec) + _grid_spec.loader.exec_module(hull_grid) + # The cultcache-py this module's raw envelope writes were tested against. cultcache_py has no __version__ and is loaded # from source, so the guard reads [project].version of the checkout the imported module itself came from. TESTED_CULTCACHE_PY = "0.3.0" @@ -235,8 +243,9 @@ def replace_layout(path: str, cultlib_packages: str, expected_id: str, expected_ if layout_revision(hull[HULL_SHAPE_SLOT][0], hull[HULL_HARDPOINTS_SLOT]) != expected_revision: raise ValueError("The .cc layout changed since Load; reload before saving") width, height, cells = shape - if not (1 <= width <= 32 and 1 <= height <= 32 and len(cells) == width * height): - raise ValueError("Hull grid must be 1..32 cells wide and high with one value per cell") + limit = hull_grid.MAX_CELLS + if not (1 <= width <= limit and 1 <= height <= limit and len(cells) == width * height): + raise ValueError(f"Hull grid must be 1..{limit} cells wide and high with one value per cell") if not all(type(cell) is bool for cell in cells): raise ValueError("Hull grid cells must be booleans") if not any(cells): From a67711b3d32a3feaaffb9fc66560c91e5e9c62c3 Mon Sep 17 00:00:00 2001 From: soul Date: Thu, 22 Oct 2026 01:31:16 +0200 Subject: [PATCH 053/307] aetheria.nakovalnya: side flak rows and radiators are 2x1 in the grid frame (drive.py rerun through the add-on) Co-Authored-By: Claude Opus 5.5 --- GameData/Mods/aetheria.nakovalnya/ship.cc | 2 +- GameData/Mods/aetheria.nakovalnya/ship.glb | 4 ++-- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/GameData/Mods/aetheria.nakovalnya/ship.cc b/GameData/Mods/aetheria.nakovalnya/ship.cc index ce591805b..2de9f3458 100644 --- a/GameData/Mods/aetheria.nakovalnya/ship.cc +++ b/GameData/Mods/aetheria.nakovalnya/ship.cc @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:62f66a2f76c82af1caac2977c22f39e66b331d5fffe2de82beaab68f0a7bc28b +oid sha256:2390998c0f36f647fbd7b12098a726445a747fc2dfd57c0e68b9fcc7e9116338 size 12701 diff --git a/GameData/Mods/aetheria.nakovalnya/ship.glb b/GameData/Mods/aetheria.nakovalnya/ship.glb index ff556fc6b..27ed4683b 100644 --- a/GameData/Mods/aetheria.nakovalnya/ship.glb +++ b/GameData/Mods/aetheria.nakovalnya/ship.glb @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:01508b4903138bf8fd13caf3d01fcd3f6b6c410c8007a5266fe95c86d9b773d7 -size 843464 +oid sha256:c0f2e8bdb820330a1ba2b49c9615484b971f96634e7108ed675e788c38c3c8ba +size 843456 From dc36b8400f5d5b4964f30630a14ce3e4609b1be0 Mon Sep 17 00:00:00 2001 From: soul Date: Thu, 22 Oct 2026 01:40:15 +0200 Subject: [PATCH 054/307] Gravity tests: no swarm rides a binary's bodyless node; lane filters on a tuned sample; a lane splits rocks by band area (red) NoSwarmRidesABinarysBodylessNode is red at 95903ab0 (finding swarm-on-bodyless-node). The tuned LanedBeltTests sample reaches the swarm-host and one-shape-per-parent filters (lane-filters-unexercised); ALaneSplitsItsRocksByBandArea pins LaneRocks' area share (lane-split-unpinned). Co-Authored-By: Claude Opus 5.5 --- tests/Aetheria.Shared.Tests/LanedBeltTests.cs | 60 +++++++++++++++++-- .../Aetheria.Shared.Tests/TrojanSwarmTests.cs | 31 ++++++++++ 2 files changed, 85 insertions(+), 6 deletions(-) diff --git a/tests/Aetheria.Shared.Tests/LanedBeltTests.cs b/tests/Aetheria.Shared.Tests/LanedBeltTests.cs index cb48aa5dc..0372d3491 100644 --- a/tests/Aetheria.Shared.Tests/LanedBeltTests.cs +++ b/tests/Aetheria.Shared.Tests/LanedBeltTests.cs @@ -17,11 +17,16 @@ public sealed class LanedBeltTests : IClassFixture { public readonly ChildrenWithinReachTests.Zones Authored = new ChildrenWithinReachTests.Zones(First, Last, null); public readonly ChildrenWithinReachTests.Zones Laneless = new ChildrenWithinReachTests.Zones(First, Last, z => z.LaneProbability = 0); + // Every candidate tried for a lane and every eligible host given a swarm, seeds 1-30: the authored sample holds no lane + // candidate that is a swarm host or shares a parent with another eligible host, so only this one reaches the swarm-host and + // one-shape-per-parent filters (finding lane-filters-unexercised). + public readonly ChildrenWithinReachTests.Zones Tuned = new ChildrenWithinReachTests.Zones(1, 30, z => { z.LaneProbability = 1; z.TrojanProbability = 1; }); public void Dispose() { Authored.Dispose(); Laneless.Dispose(); + Tuned.Dispose(); } } @@ -112,13 +117,25 @@ public sealed class LanedBeltTests : IClassFixture Assert.True(lanes > 0, $"no lane in seeds {First}-{Last}"); } - [Fact] - public void ALaneHostIsALightBodyWithNoOtherShape() + [Theory] + [InlineData(false)] + [InlineData(true)] + public void ALaneHostIsALightBodyWithNoOtherShape(bool tuned) { - var s = _samples.Authored.Settings; - var lanes = 0; - foreach (var (seed, zone) in _samples.Authored.All) + var sample = tuned ? _samples.Tuned : _samples.Authored; + var s = sample.Settings; + int lanes = 0, lightChildlessSwarmHosts = 0; + foreach (var (seed, zone) in sample.All) { + // A swarm host light enough and childless enough to be a lane host: the case the swarm-host filter refuses. + foreach (var rail in zone.AsteroidBelts.Where(b => b.Value.Data.RidesRail).Select(b => zone.Orbits[zone.Planets[b.Key].Orbit.Key].Data)) + { + var hostBody = zone.Planets.Values.Where(b => !(b is AsteroidBeltData)).FirstOrDefault(b => zone.Orbits[b.Orbit.Key].Data is var o + && o.Parent.Key.Equals(rail.Parent.Key) && o.Distance == rail.Distance && o.Phase == rail.Phase); + if (hostBody != null && hostBody.Mass < s.BeltMassCeiling + && !zone.Orbits.Values.Any(o => o.Data.Parent.IsSet() && o.Data.Parent.Key.Equals(hostBody.Orbit.Key))) + lightChildlessSwarmHosts++; + } var laned = BeltShapes.Lanes(zone).ToList(); foreach (var (host, _, _) in laned) { @@ -137,7 +154,38 @@ public sealed class LanedBeltTests : IClassFixture var doubled = annuli.Concat(laned.Select(l => l.Host.Data.Parent.Key)).GroupBy(p => p).Count(g => g.Count() > 1); Assert.True(doubled == 0, $"seed {seed}: {doubled} parents carry two belt shapes"); } - Assert.True(lanes > 0, $"no lane in seeds {First}-{Last}"); + _out.WriteLine($"zones {sample.All.Count}, lanes {lanes}, light childless swarm hosts {lightChildlessSwarmHosts}"); + Assert.True(lanes > 0, "no lane in the sample"); + if (tuned) Assert.True(lightChildlessSwarmHosts > 100, $"only {lightChildlessSwarmHosts} light childless swarm hosts: the swarm-host filter is not reached"); + } + + // LaneRocks puts each rock in a band by the band's area (ruling lanes-two-sided-visual-margin, map B60), not half and half and + // not by width (finding lane-split-unpinned). A lopsided slot, inner band [10, 18] (area 224) and outer [22, 24] (area 92): + // area share 0.709, width share 0.8, half 0.5. Over 4000 rocks one sigma of the share is 0.0072, so the area share sits within + // 4 sigma and the other two lie past 12. + [Fact] + public void ALaneSplitsItsRocksByBandArea() + { + const float inner = 10f, distance = 20f, lane = 2f, outer = 24f; + var settings = _samples.Authored.Settings; + var p = (18.0 * 18 - 10 * 10) / (18.0 * 18 - 10 * 10 + 24 * 24 - 22 * 22); + int n = 0, k = 0; + for (uint draw = 1; n < 4000; draw++) + { + var random = new CultMath.Random(draw); + var rocks = ZoneGenerator.LaneRocks(settings, ref random, 1f, distance, lane, inner, outer); + Assert.NotEmpty(rocks); + foreach (var rock in rocks) + { + Assert.True(rock.Distance >= inner && rock.Distance <= distance - lane || rock.Distance >= distance + lane && rock.Distance <= outer, + $"a rock at {rock.Distance} sits outside both bands"); + n++; + if (rock.Distance < distance) k++; + } + } + var z = (k - n * p) / Math.Sqrt(n * p * (1 - p)); + _out.WriteLine($"rocks {n}, inner {k}, area share {p:F3}, measured {(double) k / n:F3}, z {z:F2}"); + Assert.True(Math.Abs(z) < 4, $"{k} of {n} rocks in the inner band, {(double) k / n:F3} against an area share of {p:F3} (z {z:F2})"); } // An only child is its parent's outermost (ruling lane-not-outermost): Place stretches nothing for it, so its slot runs out to diff --git a/tests/Aetheria.Shared.Tests/TrojanSwarmTests.cs b/tests/Aetheria.Shared.Tests/TrojanSwarmTests.cs index 0c7e7487a..3da5e77ab 100644 --- a/tests/Aetheria.Shared.Tests/TrojanSwarmTests.cs +++ b/tests/Aetheria.Shared.Tests/TrojanSwarmTests.cs @@ -38,6 +38,37 @@ public class TrojanSwarmTests private static float Turn(float2 p) => frac((float) (Math.Atan2(p.y, p.x) / (2 * Math.PI))); private static float Wrap(float t) => t - (float) Math.Round(t); + // A standing binary leaves a bodyless node with a parent and its pair's mass, heavy enough to pass the host mass filter; a + // swarm round it would ride a rail no body is on (finding swarm-on-bodyless-node). StationPlacementTests' binary tuning with a + // swarm drawn for every eligible host: every swarm's rail copies a body's orbit. + [Fact] + public void NoSwarmRidesABinarysBodylessNode() + { + using var zones = new ChildrenWithinReachTests.Zones(First, Last, z => + { + z.SatelliteCreationProbability = 1; + z.BinaryCreationProbability = 1; + z.ZoneRadius.Minimum *= 3; z.ZoneRadius.Maximum *= 3; + z.ZoneMass.Minimum *= .1f; z.ZoneMass.Maximum *= .1f; + z.TrojanProbability = 1; + }); + int binaries = 0, swarms = 0, bodyless = 0; + foreach (var (seed, zone) in zones.All) + { + var bodyOrbits = zone.Planets.Values.Select(b => b.Orbit.Key).ToHashSet(); + binaries += zone.Orbits.Count(o => o.Value.Data.Parent.IsSet() && !bodyOrbits.Contains(o.Key) && zone.Wells.Reach(o.Key) > 0f); + var bodies = zone.Planets.Values.Where(b => !(b is AsteroidBeltData)).Select(b => zone.Orbits[b.Orbit.Key].Data).ToList(); + foreach (var rail in zone.AsteroidBelts.Where(b => b.Value.Data.RidesRail).Select(b => zone.Orbits[zone.Planets[b.Key].Orbit.Key].Data)) + { + swarms++; + if (!bodies.Any(o => o.Parent.Key.Equals(rail.Parent.Key) && o.Distance == rail.Distance && o.Phase == rail.Phase)) bodyless++; + } + } + _out.WriteLine($"zones {zones.All.Count}, bodyless nodes {binaries}, swarms {swarms}, on a bodyless node {bodyless}"); + Assert.True(binaries > 5 && swarms > 10, $"{binaries} bodyless nodes and {swarms} swarms: the seeds do not exercise the rule"); + Assert.Equal(0, bodyless); + } + [Fact] public void ASwarmKeepsItsShapeOverAnHour() { From 67e2647753d244ca84645fa84100822aa5b9fa4f Mon Sep 17 00:00:00 2001 From: soul Date: Thu, 22 Oct 2026 01:40:28 +0200 Subject: [PATCH 055/307] Gravity: restore the swarm hosts' Empty guard; a standing binary's bodyless node keeps its mass and hosted swarms (finding swarm-on-bodyless-node) Co-Authored-By: Claude Opus 5.5 --- Assets/Scripts/ServerShared/ZoneGenerator.cs | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/Assets/Scripts/ServerShared/ZoneGenerator.cs b/Assets/Scripts/ServerShared/ZoneGenerator.cs index e4316f68f..dfe89917c 100644 --- a/Assets/Scripts/ServerShared/ZoneGenerator.cs +++ b/Assets/Scripts/ServerShared/ZoneGenerator.cs @@ -219,10 +219,11 @@ public static class ZoneGenerator // Trojan swarms (rulings trojans-first, trojans-ride-host-period-with-stations): a belt on a copy of its host's rail, its // rocks round both L4 and L5. Drawn from a stream of its own, so no other draw in the zone moves; not a GeneratorPlanet, - // so it takes no slot and no station candidacy sees it. + // so it takes no slot and no station candidacy sees it. A standing binary leaves a bodyless node (Empty) that keeps its + // pair's mass and a parent, so the Empty guard is what keeps a swarm off a rail no body is on. var swarmRandom = new Random(seed ^ TrojanStream); var swarmHosts = new HashSet(); - foreach (var host in planets.Where(p => p.Parent != null && !p.Parent.Empty && !p.Belt && p.Distance > 0 + foreach (var host in planets.Where(p => p.Parent != null && !p.Parent.Empty && !p.Empty && !p.Belt && p.Distance > 0 && p.Mass > zoneSettings.PlanetMass && p.Mass <= zoneSettings.SunMass)) { if (swarmRandom.NextFloat() >= zoneSettings.TrojanProbability) continue; From 7427a7097197f223588787201f9e4425208e8de1 Mon Sep 17 00:00:00 2001 From: soul Date: Thu, 22 Oct 2026 02:26:28 +0200 Subject: [PATCH 056/307] Command seam: pin the Release path, Combat's lock pull and release guard, and the retarget step's pending reads (Soul findings release-path-untested, retarget-step-pending-reads-untested) Co-Authored-By: Claude Opus 5.5 --- tests/Aetheria.Shared.Tests/PilotSeamTests.cs | 128 +++++++++++++++++- 1 file changed, 124 insertions(+), 4 deletions(-) diff --git a/tests/Aetheria.Shared.Tests/PilotSeamTests.cs b/tests/Aetheria.Shared.Tests/PilotSeamTests.cs index 050913334..8f0e03cb0 100644 --- a/tests/Aetheria.Shared.Tests/PilotSeamTests.cs +++ b/tests/Aetheria.Shared.Tests/PilotSeamTests.cs @@ -20,15 +20,16 @@ public sealed partial class RunStartTests { private const float SeamDt = 1f / 60f; - // Two Longinus 200 apart, facing each other, the bare player far away. Piloted ones get the zone's pilot. - private (Zone arena, Ship east, Ship west) SeamPair(bool piloted, Action more = null) + // Two Longinus 2 * half apart (200 by default), facing each other, the bare player far away. Piloted ones get the + // zone's pilot. + private (Zone arena, Ship east, Ship west) SeamPair(bool piloted, Action more = null, float half = 100) { Ship east = null, west = null; var (_, arena, _, failures) = Launch(new Scripted(false, stage => { stage.Player(stage.Bare("Djinni"), float2(-50000, -50000)); - east = stage.Place(EvLonginus(stage), float2(100, 0), facing: float2(-1, 0), piloted: piloted) as Ship; - west = stage.Place(EvLonginus(stage), float2(-100, 0), facing: float2(1, 0), piloted: piloted) as Ship; + east = stage.Place(EvLonginus(stage), float2(half, 0), facing: float2(-1, 0), piloted: piloted) as Ship; + west = stage.Place(EvLonginus(stage), float2(-half, 0), facing: float2(1, 0), piloted: piloted) as Ship; more?.Invoke(stage, east, west); })); Assert.True(failures.Count == 0, string.Join("; ", failures)); @@ -257,6 +258,125 @@ public sealed partial class RunStartTests Assert.True(dot(shots[0].TravelDirection, behind.xz) < 0, "not along the previous aim"); } + // A pull lands once and a release lands once: the trigger is let go at the step it is released, not held on. + [Fact] + public void AReleaseDeactivatesAtTheStep() + { + var (arena, east, _) = SeamPair(false); + for (var step = 0; step < 60; step++) arena.Update(SeamDt); // the gun comes online and off cooldown + var gun = SeamGun(east); + + east.Command.Pull(gun); + PilotApplier.Apply(east); + Assert.True(gun.Firing); + Assert.Empty(east.Command.Triggers); + + east.Command.Release(gun); + PilotApplier.Apply(east); + Assert.False(gun.Firing); + PilotApplier.Apply(east); + Assert.False(gun.Firing); + } + + // Combat lets go of a weapon it will not fire only if that weapon is firing: of a group's two guns, the one firing is + // released and the idle one gets no trigger at all. Breaks if Combat's release guard or its Release is lost. + [Fact] + public void CombatReleasesOnlyAFiringWeaponItWillNotFire() + { + var (arena, east, west) = SeamPair(true); + east.SetIff(west, true); + west.SetIff(east, true); + var agent = arena.Agents.Single(a => a.Ship == east); + agent.Doctrine.MinHitProbability = 2; // no shot clears it, so Combat fires no unfused gun + for (var step = 0; step < 900 && !(SeamState(agent) is CombatState); step++) arena.Update(SeamDt); + Assert.IsType(SeamState(agent)); + var guns = east.WeaponGroups.Single(g => g.weapons.Contains(SeamGun(east))).weapons.ToArray(); + Assert.Equal(2, guns.Length); + Assert.All(guns, g => Assert.False(g.Firing)); + + guns[0].Activate(); // the pilot's earlier pull, still held + Assert.True(guns[0].Firing); + agent.Update(SeamDt); + + var triggers = east.Command.Triggers.Where(t => guns.Any(g => ReferenceEquals(g, t.Behavior))).ToArray(); + var (released, pull) = Assert.Single(triggers); + Assert.Same(guns[0], released); + Assert.False(pull); + PilotApplier.Apply(east); + Assert.False(guns[0].Firing); + Assert.False(guns[1].Firing); + } + + // Combat pulls a locking launcher once it can fire, beside the groups it chooses (its locks add no group damage, so + // this pull is their only one). Breaks if Combat's lock pull is lost. + [Fact] + public void CombatPullsALockThatCanFire() + { + var (arena, east, west) = SeamPair(true, half: 300); // beyond the launchers' minimum range + east.SetIff(west, true); + west.SetIff(east, true); + var agent = arena.Agents.Single(a => a.Ship == east); + var locks = east.Weapons.OfType().ToArray(); + Assert.NotEmpty(locks); + for (var step = 0; step < 1800 && !(SeamState(agent) is CombatState && locks.Any(l => l.CanFire)); step++) + arena.Update(SeamDt); + Assert.IsType(SeamState(agent)); + var ready = locks.Where(l => l.CanFire).ToArray(); + Assert.NotEmpty(ready); + + agent.Update(SeamDt); + + foreach (var launcher in ready) Assert.True(east.Command.Pulls(launcher), $"{launcher.Item.Data.Name} is pulled"); + } + + // Pulls answers the pilot's last trigger for that behaviour this step, and nothing commanded is no pull. + [Fact] + public void PullsReadsTheLastTriggerForThatBehaviour() + { + var (_, east, west) = SeamPair(false); + var gun = SeamGun(east); + var other = SeamGun(west); + Assert.False(east.Command.Pulls(gun)); + + east.Command.Pull(gun); + Assert.True(east.Command.Pulls(gun)); + Assert.False(east.Command.Pulls(other)); + east.Command.Pull(other); + Assert.True(east.Command.Pulls(gun)); + + east.Command.Release(gun); + Assert.False(east.Command.Pulls(gun)); + Assert.True(east.Command.Pulls(other)); + } + + // The step a Minion's target is lost and its Flight designates another (the target-loss clear, then the Flight's + // designation), the Minion stays in combat and aims at the other that step, as it did when SetTarget landed before it + // decided. Breaks if the Minion's transition or Combat read only the applied target (Minion.cs, Combat.cs). + [Fact] + public void TheRetargetStepEngagesTheDesignatedOther() + { + Ship third = null; + var (arena, east, west) = SeamPair(true, (stage, e, w) => + third = stage.Place(EvLonginus(stage), float2(350, 0), facing: float2(-1, 0)) as Ship); + east.SetIff(west, true); + west.SetIff(east, true); + east.SetIff(third, true); + var agent = arena.Agents.Single(a => a.Ship == east); + for (var step = 0; step < 900 && !(SeamState(agent) is CombatState); step++) arena.Update(SeamDt); + Assert.IsType(SeamState(agent)); + Assert.Same(west, east.Target.Value.Entity); + + east.SetTarget(TargetRef.None); + east.Command.Designate(new TargetRef(third)); + agent.Update(SeamDt); + + Assert.IsType(SeamState(agent)); + var aim = normalize(east.Command.Aim.Value.xz); + var toThird = dot(aim, normalize(third.Position.xz - east.Position.xz)); + var toWest = dot(aim, normalize(west.Position.xz - east.Position.xz)); + Assert.True(toThird > toWest + .2f, $"aims at the designated target: toThird {toThird} toWest {toWest}"); + } + [Fact] public void AgentFiresWithTheCommandedAimItem() { From 9d9a8ed031f19e2f6a67e6ffc10ffe3a7347a400 Mon Sep 17 00:00:00 2001 From: soul Date: Thu, 22 Oct 2026 02:27:08 +0200 Subject: [PATCH 057/307] Command seam: the applier is the only writer from PilotCommand; the pilot reads back its own pending decisions (Soul finding only-reader-comment-false) Co-Authored-By: Claude Opus 5.5 --- Assets/Scripts/ServerShared/Pilot.cs | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/Assets/Scripts/ServerShared/Pilot.cs b/Assets/Scripts/ServerShared/Pilot.cs index 95b78348b..ba42a12f1 100644 --- a/Assets/Scripts/ServerShared/Pilot.cs +++ b/Assets/Scripts/ServerShared/Pilot.cs @@ -28,7 +28,8 @@ public readonly struct TurnCommand // The one input an entity's pilot gives it (its agent, the player, a scripted pilot): only own-ship vectors and handles the // pilot was given (ruling observation-sensors-only). Commands are per entity, never per Flight or side: a side may be one -// ship (ruling brain-one-per-side-r2). PilotApplier.Apply is its only reader. +// ship (ruling brain-one-per-side-r2). PilotApplier.Apply is the only writer of the entity's fields from it; the pilot +// also reads back its own pending decisions this step (Designated, Pulls), because the entity still holds last step's. // Held fields persist until the pilot replaces them, so a pilot deciding below the sim rate keeps flying (ruling // lower-game-sim-rate); null leaves the field to whoever wrote it last. One-shots apply once, in order, and are cleared. public sealed class PilotCommand From 76143e50d19b5e3fa13e2525e02a0f67b8a3837b Mon Sep 17 00:00:00 2001 From: soul Date: Thu, 22 Oct 2026 02:27:28 +0200 Subject: [PATCH 058/307] Command seam, player: PilotCommand gains Tractor, the target Verb, CycleAim and Iff; HelmInput.Demand returns a TurnCommand resolved each step Co-Authored-By: Claude Opus 5.5 --- Assets/Scripts/ServerShared/HelmInput.cs | 5 +- Assets/Scripts/ServerShared/Pilot.cs | 35 +++- .../PilotSeamPlayerTests.cs | 168 ++++++++++++++++++ tests/Aetheria.Shared.Tests/SteeringTests.cs | 10 +- 4 files changed, 209 insertions(+), 9 deletions(-) create mode 100644 tests/Aetheria.Shared.Tests/PilotSeamPlayerTests.cs diff --git a/Assets/Scripts/ServerShared/HelmInput.cs b/Assets/Scripts/ServerShared/HelmInput.cs index fe1316aff..7894f33a3 100644 --- a/Assets/Scripts/ServerShared/HelmInput.cs +++ b/Assets/Scripts/ServerShared/HelmInput.cs @@ -13,6 +13,7 @@ public class HelmInput public void Toggle() => Latched = !Latched; - public float Demand(Ship ship, float2 aim, float turnAxis) => - FaceAim ? Steering.Toward(ship, aim) : clamp(turnAxis, -1f, 1f); + // The turn the player commands: the aim heading, resolved against the hull at each sim step, or the turn axis. + public TurnCommand Demand(float2 aim, float turnAxis) => + FaceAim ? TurnCommand.Toward(aim) : TurnCommand.OfRate(clamp(turnAxis, -1f, 1f)); } diff --git a/Assets/Scripts/ServerShared/Pilot.cs b/Assets/Scripts/ServerShared/Pilot.cs index ba42a12f1..be1e9cd7e 100644 --- a/Assets/Scripts/ServerShared/Pilot.cs +++ b/Assets/Scripts/ServerShared/Pilot.cs @@ -26,6 +26,16 @@ public readonly struct TurnCommand public float Resolve(Ship ship) => _toward ? Steering.Toward(ship, _heading) : _rate; } +// The player's target presses, each answered by the entity's own chooser (Entity.TargetNext and the rest) when applied. +public enum TargetVerb +{ + None, + UnderReticle, + NearestEnemy, + Next, + Previous +} + // The one input an entity's pilot gives it (its agent, the player, a scripted pilot): only own-ship vectors and handles the // pilot was given (ruling observation-sensors-only). Commands are per entity, never per Flight or side: a side may be one // ship (ruling brain-one-per-side-r2). PilotApplier.Apply is the only writer of the entity's fields from it; the pilot @@ -37,12 +47,16 @@ public sealed class PilotCommand public TurnCommand? Turn; public float2? Movement; public float3? Aim; + public float? Tractor; internal readonly List Designations = new List(); internal readonly List Selections = new List(); internal readonly List Hails = new List(); internal readonly List Uses = new List(); internal readonly List<(IActivatedBehavior Behavior, bool Pull)> Triggers = new List<(IActivatedBehavior, bool)>(); + internal readonly List<(Entity Other, bool? Hostile)> Iffs = new List<(Entity, bool?)>(); + internal TargetVerb TargetVerb; + internal int AimCycles; public void Designate(TargetRef target) => Designations.Add(target); public void SelectItem(EquippedItem item) => Selections.Add(item); @@ -50,6 +64,10 @@ public sealed class PilotCommand public void Use(ConsumableItemData consumable) => Uses.Add(consumable); public void Pull(IActivatedBehavior behavior) => Triggers.Add((behavior, true)); public void Release(IActivatedBehavior behavior) => Triggers.Add((behavior, false)); + // The last verb in a step wins. + public void Verb(TargetVerb verb) => TargetVerb = verb; + public void CycleAim() => AimCycles++; + public void Iff(Entity other, bool? hostile) => Iffs.Add((other, hostile)); // The pilot's own pending decisions, which it reads back instead of the ship (that is last step's applied state). public TargetRef? Designated => Designations.Count > 0 ? Designations[Designations.Count - 1] : (TargetRef?) null; @@ -69,12 +87,15 @@ public sealed class PilotCommand Hails.Clear(); Uses.Clear(); Triggers.Clear(); + Iffs.Clear(); + TargetVerb = TargetVerb.None; + AimCycles = 0; } } // The one writer of an entity's pilot-controlled fields, called only from Zone.Update. The order is a rule: the target -// lands before its item is selected, and the aim before the triggers, so a pilot fires along the aim it commands this -// step, as the player does. +// lands (a designation, then a chooser verb) before its item is selected or the aim cycled, and the aim before the +// triggers, so a pilot fires along the aim it commands this step. public static class PilotApplier { public static void Apply(Entity entity) @@ -82,7 +103,16 @@ public static class PilotApplier var command = entity.Command; foreach (var target in command.Designations) if (!entity.Target.Value.Equals(target)) entity.SetTarget(target); + switch (command.TargetVerb) + { + case TargetVerb.UnderReticle: entity.TargetUnderReticle(); break; + case TargetVerb.NearestEnemy: entity.TargetNearestEnemy(); break; + case TargetVerb.Next: entity.TargetNext(); break; + case TargetVerb.Previous: entity.TargetPrevious(); break; + } foreach (var item in command.Selections) entity.TrySelectTargetItem(item); + for (var i = 0; i < command.AimCycles; i++) entity.CycleAim(); + foreach (var (other, hostile) in command.Iffs) entity.SetIff(other, hostile); foreach (var hail in command.Hails) entity.SetMessage(hail); foreach (var consumable in command.Uses) entity.TryActivateConsumable(consumable); if (command.Aim is float3 aim) entity.Aim = aim; @@ -94,6 +124,7 @@ public static class PilotApplier if (command.Turn is TurnCommand turn) ship.Turn = turn.Resolve(ship); if (command.Movement is float2 movement) ship.MovementDirection = movement; } + if (command.Tractor is float tractor) entity.TractorCommand = tractor; command.ClearOneShots(); } } diff --git a/tests/Aetheria.Shared.Tests/PilotSeamPlayerTests.cs b/tests/Aetheria.Shared.Tests/PilotSeamPlayerTests.cs new file mode 100644 index 000000000..7f20dfcf2 --- /dev/null +++ b/tests/Aetheria.Shared.Tests/PilotSeamPlayerTests.cs @@ -0,0 +1,168 @@ +/* This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at https://mozilla.org/MPL/2.0/. */ + +using System.Collections.Generic; +using System.Linq; +using Xunit; +using static CultMath.math; +using float2 = CultMath.float2; +using float3 = CultMath.float3; + +// The command seam, player half (aetheria-arena, cut command-seam-player): the player's input writes only its entity's +// PilotCommand, as the agents do, and PilotApplier lands it at the next zone step. Its target presses are verbs the entity's +// chooser answers, its stance and aim-cycle presses are one-shots, its helm and tractor are held, and its triggers are +// edges: a press is one pull and a let-go one release. +public sealed partial class RunStartTests +{ + // East, west 200 away and a third 250 away, all seen by east once the zone has stepped. + private (Zone arena, Ship east, Ship west, Ship third) PlayerSeamTrio() + { + Ship third = null; + var (arena, east, west) = SeamPair(false, (stage, e, w) => + third = stage.Place(EvLonginus(stage), float2(350, 0), facing: float2(-1, 0)) as Ship); + for (var step = 0; step < 60; step++) arena.Update(SeamDt); + Assert.Contains(west, east.VisibleEntities); + Assert.Contains(third, east.VisibleEntities); + return (arena, east, west, third); + } + + [Fact] + public void HelmDemandIsAnIntent() + { + var (arena, east, _) = SeamPair(false); + var held = new HelmInput { Held = true }; + var heading = float2(0, 1); // a quarter turn from its facing + Assert.Equal(Steering.Toward(east, heading), held.Demand(heading, .5f).Resolve(east)); + Assert.Equal(.5f, new HelmInput().Demand(heading, .5f).Resolve(east)); + Assert.Equal(-1f, new HelmInput().Demand(heading, -4f).Resolve(east)); + + // One face-aim demand, held: it re-resolves against the hull at every step as the hull comes round. + east.Command.Turn = held.Demand(heading, 0); + var turns = new List(); + for (var step = 0; step < 600; step++) + { + var expected = Steering.Toward(east, heading); + arena.Update(SeamDt); + Assert.Equal(expected, east.Turn); + turns.Add(east.Turn); + } + Assert.True(turns.Distinct().Count() > 2, "the resolved turn changes as the hull turns"); + } + + [Fact] + public void TargetVerbsApplyOnceAtTheStep() + { + var (arena, east, west, third) = PlayerSeamTrio(); + Assert.True(east.Target.Value.IsNone); + + // The last verb in a step wins: Previous would pick the farthest (third), Next picks the nearest. + east.Command.Verb(TargetVerb.Previous); + east.Command.Verb(TargetVerb.Next); + Assert.True(east.Target.Value.IsNone, "nothing lands before the step"); + arena.Update(SeamDt); + Assert.Same(west, east.Target.Value.Entity); + arena.Update(SeamDt); + Assert.Same(west, east.Target.Value.Entity); + + east.Command.Verb(TargetVerb.Next); + arena.Update(SeamDt); + Assert.Same(third, east.Target.Value.Entity); + + east.Command.Verb(TargetVerb.Previous); + arena.Update(SeamDt); + Assert.Same(west, east.Target.Value.Entity); + + // Enemies only: the farther hostile, not the nearer neutral. + east.SetIff(third, true); + east.Command.Verb(TargetVerb.NearestEnemy); + arena.Update(SeamDt); + Assert.Same(third, east.Target.Value.Entity); + + // The reticle picks along the aim; picking the held target again would clear it. + east.Command.Aim = float3(-1, 0, 0); + east.Command.Verb(TargetVerb.UnderReticle); + arena.Update(SeamDt); + Assert.Same(west, east.Target.Value.Entity); + arena.Update(SeamDt); + Assert.Same(west, east.Target.Value.Entity); + } + + [Fact] + public void IffAndCycleAimAreOneShots() + { + var (arena, east, west, _) = PlayerSeamTrio(); + Assert.False(east.IsHostileTo(west)); + east.Command.Iff(west, true); + Assert.False(east.IsHostileTo(west), "nothing lands before the step"); + arena.Update(SeamDt); + Assert.True(east.IsHostileTo(west)); + // Not replayed: a stance set after the step stays. + east.SetIff(west, false); + arena.Update(SeamDt); + Assert.False(east.IsHostileTo(west)); + east.Command.Iff(west, true); + east.Command.Iff(west, null); + arena.Update(SeamDt); + Assert.False(east.IsHostileTo(west), "the later stance in a step lands last"); + + var first = SeamGun(west).Item; + var second = west.Weapons.Select(w => w.Item).First(i => i != first && i.Data.Name == first.Data.Name); + east.RecordReveal(west, first, SignatureChannel.Emission, west.ClaimOf(first)); + east.RecordReveal(west, second, SignatureChannel.Emission, west.ClaimOf(second)); + east.Command.Verb(TargetVerb.Next); + arena.Update(SeamDt); + Assert.Same(west, east.Target.Value.Entity); + var before = east.ResolvedAim; + + east.Command.CycleAim(); + Assert.Equal(before.Item, east.ResolvedAim.Item); + arena.Update(SeamDt); + var once = east.ResolvedAim; + Assert.True(once.Item != before.Item || once.Mount != before.Mount, "the cycle advanced the aim"); + arena.Update(SeamDt); + Assert.Equal(once.Item, east.ResolvedAim.Item); + Assert.Equal(once.Mount, east.ResolvedAim.Mount); + } + + // A trigger that counts its edges: a semi-auto fires once per Activate. + private sealed class SeamTrigger : IActivatedBehavior + { + public int Pulled, Released; + public void Activate() => Pulled++; + public void Deactivate() => Released++; + } + + // A press is one pull at the next step and the button held after it pulls nothing more, so a semi-auto fires once; + // the let-go is one release. + [Fact] + public void PlayerTriggerIsAnEdge() + { + var (arena, east, _) = SeamPair(false); + var trigger = new SeamTrigger(); + east.Command.Pull(trigger); + Assert.Equal(0, trigger.Pulled); + for (var step = 0; step < 10; step++) arena.Update(SeamDt); + Assert.Equal((1, 0), (trigger.Pulled, trigger.Released)); + + east.Command.Release(trigger); + for (var step = 0; step < 10; step++) arena.Update(SeamDt); + Assert.Equal((1, 1), (trigger.Pulled, trigger.Released)); + } + + [Fact] + public void TractorIsHeld() + { + var (arena, east, _) = SeamPair(false); + east.Command.Tractor = 1; + for (var step = 0; step < 5; step++) + { + east.TractorCommand = 0; // a disturbance between steps: the held command lands again at every step + arena.Update(SeamDt); + Assert.Equal(1f, east.TractorCommand); + } + east.Command.Tractor = .25f; + arena.Update(SeamDt); + Assert.Equal(.25f, east.TractorCommand); + } +} diff --git a/tests/Aetheria.Shared.Tests/SteeringTests.cs b/tests/Aetheria.Shared.Tests/SteeringTests.cs index 71dbfff75..08c0a8958 100644 --- a/tests/Aetheria.Shared.Tests/SteeringTests.cs +++ b/tests/Aetheria.Shared.Tests/SteeringTests.cs @@ -88,8 +88,8 @@ public sealed class SteeringTests { ship.Direction = direction; var expected = Steering.Toward(ship, aim); - Assert.Equal(expected, held.Demand(ship, aim, axis)); - Assert.Equal(expected, latched.Demand(ship, aim, axis)); + Assert.Equal(expected, held.Demand(aim, axis).Resolve(ship)); + Assert.Equal(expected, latched.Demand(aim, axis).Resolve(ship)); } } @@ -102,9 +102,9 @@ public sealed class SteeringTests foreach (var aim in new[] { float2(0, 1), float2(1, 0), float2(-1, -1) }) { - Assert.Equal(.25f, helm.Demand(ship, aim, .25f)); - Assert.Equal(1f, helm.Demand(ship, aim, 3f)); - Assert.Equal(-1f, helm.Demand(ship, aim, -3f)); + Assert.Equal(.25f, helm.Demand(aim, .25f).Resolve(ship)); + Assert.Equal(1f, helm.Demand(aim, 3f).Resolve(ship)); + Assert.Equal(-1f, helm.Demand(aim, -3f).Resolve(ship)); } helm.Toggle(); From 394c13a09971294e880200fcb95bdaa5c72139d0 Mon Sep 17 00:00:00 2001 From: soul Date: Thu, 22 Oct 2026 02:27:29 +0200 Subject: [PATCH 059/307] Command seam, player: ActionGameManager and ActionBarSlot write only Entity.Command (the 18 direct writers) Co-Authored-By: Claude Opus 5.5 --- Assets/Scripts/Gameplay/ActionBarSlot.cs | 11 ++--- Assets/Scripts/Gameplay/ActionGameManager.cs | 46 ++++++++------------ 2 files changed, 25 insertions(+), 32 deletions(-) diff --git a/Assets/Scripts/Gameplay/ActionBarSlot.cs b/Assets/Scripts/Gameplay/ActionBarSlot.cs index 42c2e3f1e..1c51faf82 100644 --- a/Assets/Scripts/Gameplay/ActionBarSlot.cs +++ b/Assets/Scripts/Gameplay/ActionBarSlot.cs @@ -117,7 +117,7 @@ public class ActionBarConsumableBinding : ActionBarBinding public override void Activate() { - Entity.TryActivateConsumable(Target); + Entity.Command.Use(Target); } public override void Deactivate() @@ -155,16 +155,17 @@ public class ActionBarGearBinding : ActionBarBinding else Slot.Icon.texture = ActionGameManager.Instance.Settings.GetIcon(data.HardpointType).texture; } + // The press and the let-go are each one trigger edge, applied at the next step; Active is the slot's display. public override void Activate() { Active = true; - Behavior.Activate(); + Entity.Command.Pull(Behavior); } public override void Deactivate() { Active = false; - Behavior.Deactivate(); + Entity.Command.Release(Behavior); } public override void Update() @@ -190,7 +191,7 @@ public class ActionBarWeaponGroupBinding : ActionBarBinding { foreach (var weapon in Entity.WeaponGroups[Group].weapons) { - weapon.Activate(); + Entity.Command.Pull(weapon); } } @@ -198,7 +199,7 @@ public class ActionBarWeaponGroupBinding : ActionBarBinding { foreach (var weapon in Entity.WeaponGroups[Group].weapons) { - weapon.Deactivate(); + Entity.Command.Release(weapon); } } diff --git a/Assets/Scripts/Gameplay/ActionGameManager.cs b/Assets/Scripts/Gameplay/ActionGameManager.cs index e791f1a64..61a20308f 100644 --- a/Assets/Scripts/Gameplay/ActionGameManager.cs +++ b/Assets/Scripts/Gameplay/ActionGameManager.cs @@ -1,4 +1,4 @@ -/* This Source Code Form is subject to the terms of the Mozilla Public +/* This Source Code Form is subject to the terms of the Mozilla Public * License, v. 2.0. If a copy of the MPL was not distributed with this * file, You can obtain one at https://mozilla.org/MPL/2.0/. */ @@ -428,21 +428,23 @@ public class ActionGameManager : MonoBehaviour { var target = CurrentEntity.Target.Value.Entity; if (target == null) return; - CurrentEntity.SetIff(target, !CurrentEntity.IsHostileTo(target)); + CurrentEntity.Command.Iff(target, !CurrentEntity.IsHostileTo(target)); }; #region Targeting - // The reticle, nearest, next and previous presses each choose through Entity (mining target-queries cut): - // the rule lives with the entity, so the player, the test agent and any director pick alike. - Input.Player.TargetReticle.performed += context => CurrentEntity.TargetUnderReticle(); - Input.Player.TargetNearest.performed += context => CurrentEntity.TargetNearestEnemy(); - Input.Player.TargetNext.performed += context => CurrentEntity.TargetNext(); - Input.Player.TargetPrevious.performed += context => CurrentEntity.TargetPrevious(); + // The reticle, nearest, next and previous presses each command a verb; the applier chooses through Entity at the + // next step (mining target-queries cut): the rule lives with the entity, so the player, the test agent and any + // director pick alike. + Input.Player.TargetReticle.performed += context => CurrentEntity.Command.Verb(TargetVerb.UnderReticle); + Input.Player.TargetNearest.performed += context => CurrentEntity.Command.Verb(TargetVerb.NearestEnemy); + Input.Player.TargetNext.performed += context => CurrentEntity.Command.Verb(TargetVerb.Next); + Input.Player.TargetPrevious.performed += context => CurrentEntity.Command.Verb(TargetVerb.Previous); // Cut 2 (docs/fire-control-cut.md): steps the aim through the target's schematic rows -- every mount this ship - // has located, empty or not, minus the ones it saw destroyed. Decides nothing: the entity owns the cycle. - Input.Player.CycleTargetItem.performed += context => CurrentEntity.CycleAim(); + // has located, empty or not, minus the ones it saw destroyed. Decides nothing: the entity owns the cycle, applied at + // the next step. + Input.Player.CycleTargetItem.performed += context => CurrentEntity.Command.CycleAim(); #endregion @@ -613,19 +615,9 @@ public class ActionGameManager : MonoBehaviour console.AppendLogLine("usage: iff hostile|neutral|clear"); return; } - switch (mode) - { - case "hostile": - _currentEntity.SetIff(target, true); - break; - case "neutral": - _currentEntity.SetIff(target, false); - break; - case "clear": - _currentEntity.SetIff(target, null); - break; - } - console.AppendLogLine($"{target.Name}: {(_currentEntity.IsHostileTo(target) ? "hostile" : "neutral")}"); + bool? hostile = mode == "hostile" ? true : mode == "neutral" ? false : (bool?) null; + _currentEntity.Command.Iff(target, hostile); + console.AppendLogLine($"{target.Name}: {mode} queued; it applies at the next step"); }); // Editor only: capturepreset "" [replace] writes the piloted ship as a catalog preset. A multi-word name must @@ -1324,7 +1316,7 @@ public class ActionGameManager : MonoBehaviour var sensitivity = PlayerSettings.InputSettings.Sensitivity; _entityYawPitch = float2(_entityYawPitch.x + look.x * sensitivity.x, clamp(_entityYawPitch.y + look.y * sensitivity.y, -.45f * PI, .45f * PI)); _viewDirection = mul(float3(0, 0, 1), CultMath.float3x3.Euler(float3(_entityYawPitch.yx, 0), RotationOrder.YXZ)); - CurrentEntity.Aim = _viewDirection; + CurrentEntity.Command.Aim = _viewDirection; HeatstrokePost.weight = saturate(unlerp(0, Settings.GameplaySettings.SevereHeatstrokeRiskThreshold, CurrentEntity.Heatstroke)); var severeHeatstrokeLerp = saturate(unlerp(Settings.GameplaySettings.SevereHeatstrokeRiskThreshold, 1, CurrentEntity.Heatstroke)); SevereHeatstrokePost.weight = @@ -1334,9 +1326,9 @@ public class ActionGameManager : MonoBehaviour _faceAimMark.enabled = _helm.Latched; if(CurrentEntity is Ship ship) { - ship.MovementDirection = Input.Player.Move.ReadValue().ToCultMath(); + ship.Command.Movement = Input.Player.Move.ReadValue().ToCultMath(); _helm.Held = Input.Player.FaceAim.IsPressed(); - ship.Turn = _helm.Demand(ship, _viewDirection.xz, Input.Player.Turn.ReadValue()); + ship.Command.Turn = _helm.Demand(_viewDirection.xz, Input.Player.Turn.ReadValue()); } UpdateMusic(); @@ -1351,7 +1343,7 @@ public class ActionGameManager : MonoBehaviour TargetShieldsFill.fillAmount = target.Shield == null ? 0 : lerp(.25f, .75f, target.Shield.Progress); } - CurrentEntity.TractorCommand = Input.Player.TractorBeam.ReadValue(); + CurrentEntity.Command.Tractor = Input.Player.TractorBeam.ReadValue(); } } // One clock steps the zone, paused or not: a paused frame feeds it no real time. From 6f4dbff317f738d30215fedff9a6aede571f1531 Mon Sep 17 00:00:00 2001 From: soul Date: Thu, 22 Oct 2026 02:27:30 +0200 Subject: [PATCH 060/307] Command seam, player: the control setters and pilot methods are internal, so no Unity script can write them Co-Authored-By: Claude Opus 5.5 --- Assets/Scripts/ServerShared/Entity.cs | 26 +++++++++++++------------- Assets/Scripts/ServerShared/Ship.cs | 11 ++++++----- 2 files changed, 19 insertions(+), 18 deletions(-) diff --git a/Assets/Scripts/ServerShared/Entity.cs b/Assets/Scripts/ServerShared/Entity.cs index 780f365bc..42b30cf4f 100644 --- a/Assets/Scripts/ServerShared/Entity.cs +++ b/Assets/Scripts/ServerShared/Entity.cs @@ -85,7 +85,7 @@ public abstract class Entity // Where the pilot points: reticle, lock cone, tractor, guided rounds, fire direction. Steers nothing; the // hull turns only by Ship.Turn. - public float3 Aim; + public float3 Aim { get; internal set; } // The entity's one pilot input, written by its one pilot (its agent, the player, a scripted pilot) and applied only by // PilotApplier in the zone step. Runtime only: never packed. @@ -189,7 +189,7 @@ public abstract class Entity public bool OverrideShutdown { get; set; } // The pilot's tractor command (0..1); the Pickup behaviour spins its beam toward it. - public float TractorCommand; + public float TractorCommand { get; internal set; } // The beam's spun-up state: Pickup's, or 0 without one. public float TractorPower => GetBehavior()?.Power ?? 0f; @@ -423,7 +423,7 @@ public abstract class Entity // (clearing the aim) or a revealed item (FireControl.IsRevealed: this entity saw it act) belonging to the current // Target. A fitted item the observer has only located is aimed as its mount, never as an item. Returns whether the // write took effect; a rejected non-null selection leaves the aim unchanged. - public bool TrySelectTargetItem(EquippedItem item) + internal bool TrySelectTargetItem(EquippedItem item) { if (item == null) { @@ -442,7 +442,7 @@ public abstract class Entity // The aim slot's other writer: a mount of the hull this entity believes the Target to be (LayoutOf, by reference), // unless this entity perceived that mount's item destroyed (SeenDestroyed). Reads only observer state, so whether a // mount can be aimed at says nothing of the Target's truth (ruling cycle-skips-only-perceived-destroyed). - public bool TrySelectMount(HardpointData mount) + internal bool TrySelectMount(HardpointData mount) { if (!MountAimable(Target.Value.Entity, mount)) return false; AimedAt = AimTarget.OfMount(mount); @@ -479,7 +479,7 @@ public abstract class Entity // The player's cycle key: steps the aim to the next AimCycle entry after the resolved one and wraps; an entry its // writer refuses (a revealed item since destroyed) is passed over; nothing to visit clears the aim. - public void CycleAim() + internal void CycleAim() { var subject = Target.Value.Entity; if (subject == null) return; @@ -497,7 +497,7 @@ public abstract class Entity // Mining Cut 3 (docs/mining-cut-refresh.md): the one writer of Target, called by the player's handlers and the // agents alike. A chunk is taken only while this entity can see it, so a rock in darkness cannot be picked; an // entity is written as given, as every handler wrote it before. Returns whether the write took effect. - public bool SetTarget(TargetRef target) + internal bool SetTarget(TargetRef target) { if (target.Chunk is ChunkId chunk && !ChunkVisible(chunk)) return false; _target.Value = target; @@ -567,7 +567,7 @@ public abstract class Entity // one writer. Candidates are visible ships at any range and visible chunks within mining reach. // The candidate nearest the look direction by planar angle; picking the held target again clears it. - public bool TargetUnderReticle() + internal bool TargetUnderReticle() { var look = float2(Aim.x, Aim.z); if (!PickTarget(new AngleKey(this, Position.xz, look), out var picked)) return false; @@ -575,7 +575,7 @@ public abstract class Entity } // Enemies only, as it drives weapon lock; the nearest by distance. - public bool TargetNearestEnemy() + internal bool TargetNearestEnemy() { Entity nearest = null; var nearestDistance = float.PositiveInfinity; @@ -594,10 +594,10 @@ public abstract class Entity // The next candidate after the current target by (planar distance, TargetRef), wrapping to the nearest; with // nothing targeted, the nearest. - public bool TargetNext() => CycleTarget(false); + internal bool TargetNext() => CycleTarget(false); // The reverse: the next one nearer, wrapping to the farthest; with nothing targeted, the farthest. - public bool TargetPrevious() => CycleTarget(true); + internal bool TargetPrevious() => CycleTarget(true); private bool CycleTarget(bool reverse) { @@ -1246,7 +1246,7 @@ public abstract class Entity // Sets or clears a runtime-only IFF override deciding this entity's hostility toward another. // Pass null to clear the override and restore the derived (faction-based) rule. - public void SetIff(Entity other, bool? hostile) + internal void SetIff(Entity other, bool? hostile) { if (hostile.HasValue) _iffOverrides[other] = hostile.Value; else _iffOverrides.Remove(other); @@ -1311,7 +1311,7 @@ public abstract class Entity public static bool IsDockingPermitted(FactionRelationship relationship, SecurityLevel securityLevel) => (int) relationship - (int) securityLevel > 1; - public void ActivateConsumable(ConsumableItem item) + internal void ActivateConsumable(ConsumableItem item) { var effect = new ConsumableItemEffect(item, this); // The same hook Activate runs over Equipment: a behaviour that computes its targets there (StatModifier) @@ -1350,7 +1350,7 @@ public abstract class Entity public bool HasActiveConsumable(Predicate kind) => _activeConsumables.Any(e => e.Data.Behaviors.Any(b => kind(b))); - public bool TryActivateConsumable(ConsumableItemData data) + internal bool TryActivateConsumable(ConsumableItemData data) { // Death and docking Remove and Deactivate the ship while its agent keeps ticking. if (!_active) return false; diff --git a/Assets/Scripts/ServerShared/Ship.cs b/Assets/Scripts/ServerShared/Ship.cs index 4d621e2f9..dab9960ea 100644 --- a/Assets/Scripts/ServerShared/Ship.cs +++ b/Assets/Scripts/ServerShared/Ship.cs @@ -1,4 +1,4 @@ -/* This Source Code Form is subject to the terms of the Mozilla Public +/* This Source Code Form is subject to the terms of the Mozilla Public * License, v. 2.0. If a copy of the MPL was not distributed with this * file, You can obtain one at https://mozilla.org/MPL/2.0/. */ @@ -25,10 +25,11 @@ public class Ship : Entity // [IgnoreMember] public Dictionary Contacts = new Dictionary(); // [IgnoreMember] public Targetable Target; public Entity HomeEntity; - public float2 MovementDirection; - // The one facing command, -1..1, positive clockwise. Written each tick by the pilot (player input or the - // ship's agent); a heading becomes a demand only through Steering.Toward. Runtime only, never packed. - public float Turn; + // The pilot-controlled hull fields. PilotApplier, in the zone step, is their pilot-side writer, from the command the + // pilot (player input or the ship's agent) gave the entity; no Unity script can set them. Runtime only, never packed. + public float2 MovementDirection { get; internal set; } + // The one facing command, -1..1, positive clockwise; a heading becomes a demand only through Steering.Toward. + public float Turn { get; internal set; } public bool IsPlayerShip; private Thruster[] _allThrusters; From bee079b1c85d0164747e9b19392dbaac433d11f7 Mon Sep 17 00:00:00 2001 From: soul Date: Thu, 22 Oct 2026 02:28:16 +0200 Subject: [PATCH 061/307] Command seam, player: the reticle verb's pin answers a target no other verb would Co-Authored-By: Claude Opus 5.5 --- tests/Aetheria.Shared.Tests/PilotSeamPlayerTests.cs | 9 ++++++--- 1 file changed, 6 insertions(+), 3 deletions(-) diff --git a/tests/Aetheria.Shared.Tests/PilotSeamPlayerTests.cs b/tests/Aetheria.Shared.Tests/PilotSeamPlayerTests.cs index 7f20dfcf2..eb25d8995 100644 --- a/tests/Aetheria.Shared.Tests/PilotSeamPlayerTests.cs +++ b/tests/Aetheria.Shared.Tests/PilotSeamPlayerTests.cs @@ -79,13 +79,16 @@ public sealed partial class RunStartTests arena.Update(SeamDt); Assert.Same(third, east.Target.Value.Entity); - // The reticle picks along the aim; picking the held target again would clear it. + // The reticle picks along the aim, and picking the held target again clears it (no other verb answers None here). + east.Command.Verb(TargetVerb.Previous); + arena.Update(SeamDt); + Assert.Same(west, east.Target.Value.Entity); east.Command.Aim = float3(-1, 0, 0); east.Command.Verb(TargetVerb.UnderReticle); arena.Update(SeamDt); - Assert.Same(west, east.Target.Value.Entity); + Assert.True(east.Target.Value.IsNone); arena.Update(SeamDt); - Assert.Same(west, east.Target.Value.Entity); + Assert.True(east.Target.Value.IsNone, "the reticle press is not replayed"); } [Fact] From 06435bdefa1e7574c51b8bc2e18372854e8ffcf3 Mon Sep 17 00:00:00 2001 From: soul Date: Thu, 22 Oct 2026 02:29:58 +0200 Subject: [PATCH 062/307] Command seam: the lock-pull pin carries the target past the launchers' minimum range Co-Authored-By: Claude Opus 5.5 --- tests/Aetheria.Shared.Tests/PilotSeamTests.cs | 17 +++++++++++------ 1 file changed, 11 insertions(+), 6 deletions(-) diff --git a/tests/Aetheria.Shared.Tests/PilotSeamTests.cs b/tests/Aetheria.Shared.Tests/PilotSeamTests.cs index 8f0e03cb0..bcd7d321c 100644 --- a/tests/Aetheria.Shared.Tests/PilotSeamTests.cs +++ b/tests/Aetheria.Shared.Tests/PilotSeamTests.cs @@ -307,22 +307,27 @@ public sealed partial class RunStartTests Assert.False(guns[1].Firing); } - // Combat pulls a locking launcher once it can fire, beside the groups it chooses (its locks add no group damage, so - // this pull is their only one). Breaks if Combat's lock pull is lost. + // Combat pulls a locking launcher once it can fire, beside the groups it chooses (a lock adds no group damage, so + // this pull is its only one). Breaks if Combat's lock pull is lost. [Fact] public void CombatPullsALockThatCanFire() { - var (arena, east, west) = SeamPair(true, half: 300); // beyond the launchers' minimum range + var (arena, east, west) = SeamPair(true); east.SetIff(west, true); west.SetIff(east, true); var agent = arena.Agents.Single(a => a.Ship == east); var locks = east.Weapons.OfType().ToArray(); Assert.NotEmpty(locks); - for (var step = 0; step < 1800 && !(SeamState(agent) is CombatState && locks.Any(l => l.CanFire)); step++) - arena.Update(SeamDt); + var step = 0; + for (; step < 3000 && !(SeamState(agent) is CombatState && locks.All(l => l.IsLocked)); step++) arena.Update(SeamDt); + Assert.True(locks.All(l => l.IsLocked), $"locked by step {step}: {string.Join(",", locks.Select(l => l.Lock))}"); + // The pair fights inside the launchers' minimum range: carry west out along east's aim, and let east's own update + // (no pilot) see the new range. + west.Position = east.Position + 600 * normalize(float3(east.Aim.x, 0, east.Aim.z)); + east.Update(SeamDt); Assert.IsType(SeamState(agent)); var ready = locks.Where(l => l.CanFire).ToArray(); - Assert.NotEmpty(ready); + Assert.True(ready.Length > 0, $"range {east.TargetRange}, min {locks[0].MinRange}, locked {string.Join(",", locks.Select(l => l.Lock))}"); agent.Update(SeamDt); From 76e4e6f58fde8dc2a1cc76c0d0a03b775835285d Mon Sep 17 00:00:00 2001 From: soul Date: Thu, 22 Oct 2026 02:30:53 +0200 Subject: [PATCH 063/307] Command seam: the trio fixture waits for its contacts, and the lock pin for any launcher's lock Co-Authored-By: Claude Opus 5.5 --- tests/Aetheria.Shared.Tests/PilotSeamPlayerTests.cs | 2 +- tests/Aetheria.Shared.Tests/PilotSeamTests.cs | 4 ++-- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/tests/Aetheria.Shared.Tests/PilotSeamPlayerTests.cs b/tests/Aetheria.Shared.Tests/PilotSeamPlayerTests.cs index eb25d8995..22f63603f 100644 --- a/tests/Aetheria.Shared.Tests/PilotSeamPlayerTests.cs +++ b/tests/Aetheria.Shared.Tests/PilotSeamPlayerTests.cs @@ -21,7 +21,7 @@ public sealed partial class RunStartTests Ship third = null; var (arena, east, west) = SeamPair(false, (stage, e, w) => third = stage.Place(EvLonginus(stage), float2(350, 0), facing: float2(-1, 0)) as Ship); - for (var step = 0; step < 60; step++) arena.Update(SeamDt); + for (var step = 0; step < 600 && !(east.VisibleEntities.Contains(west) && east.VisibleEntities.Contains(third)); step++) arena.Update(SeamDt); Assert.Contains(west, east.VisibleEntities); Assert.Contains(third, east.VisibleEntities); return (arena, east, west, third); diff --git a/tests/Aetheria.Shared.Tests/PilotSeamTests.cs b/tests/Aetheria.Shared.Tests/PilotSeamTests.cs index bcd7d321c..134578efe 100644 --- a/tests/Aetheria.Shared.Tests/PilotSeamTests.cs +++ b/tests/Aetheria.Shared.Tests/PilotSeamTests.cs @@ -319,8 +319,8 @@ public sealed partial class RunStartTests var locks = east.Weapons.OfType().ToArray(); Assert.NotEmpty(locks); var step = 0; - for (; step < 3000 && !(SeamState(agent) is CombatState && locks.All(l => l.IsLocked)); step++) arena.Update(SeamDt); - Assert.True(locks.All(l => l.IsLocked), $"locked by step {step}: {string.Join(",", locks.Select(l => l.Lock))}"); + for (; step < 3000 && !(SeamState(agent) is CombatState && locks.Any(l => l.IsLocked)); step++) arena.Update(SeamDt); + Assert.True(locks.Any(l => l.IsLocked), $"locked by step {step}: {string.Join(",", locks.Select(l => l.Lock))}"); // The pair fights inside the launchers' minimum range: carry west out along east's aim, and let east's own update // (no pilot) see the new range. west.Position = east.Position + 600 * normalize(float3(east.Aim.x, 0, east.Aim.z)); From d8a4e1567d422600550f76b1b3eb839ae62aa21f Mon Sep 17 00:00:00 2001 From: soul Date: Thu, 22 Oct 2026 02:31:44 +0200 Subject: [PATCH 064/307] Command seam: the trio's third ship sits in east's sensor cone; the lock pin holds the guns so the lock completes Co-Authored-By: Claude Opus 5.5 --- tests/Aetheria.Shared.Tests/PilotSeamPlayerTests.cs | 4 ++-- tests/Aetheria.Shared.Tests/PilotSeamTests.cs | 1 + 2 files changed, 3 insertions(+), 2 deletions(-) diff --git a/tests/Aetheria.Shared.Tests/PilotSeamPlayerTests.cs b/tests/Aetheria.Shared.Tests/PilotSeamPlayerTests.cs index 22f63603f..8630a6b05 100644 --- a/tests/Aetheria.Shared.Tests/PilotSeamPlayerTests.cs +++ b/tests/Aetheria.Shared.Tests/PilotSeamPlayerTests.cs @@ -15,12 +15,12 @@ using float3 = CultMath.float3; // edges: a press is one pull and a let-go one release. public sealed partial class RunStartTests { - // East, west 200 away and a third 250 away, all seen by east once the zone has stepped. + // East, west 200 away and a third about 277 away, both ahead of east and seen by it once the zone has stepped. private (Zone arena, Ship east, Ship west, Ship third) PlayerSeamTrio() { Ship third = null; var (arena, east, west) = SeamPair(false, (stage, e, w) => - third = stage.Place(EvLonginus(stage), float2(350, 0), facing: float2(-1, 0)) as Ship); + third = stage.Place(EvLonginus(stage), float2(-150, 120), facing: float2(1, 0)) as Ship); for (var step = 0; step < 600 && !(east.VisibleEntities.Contains(west) && east.VisibleEntities.Contains(third)); step++) arena.Update(SeamDt); Assert.Contains(west, east.VisibleEntities); Assert.Contains(third, east.VisibleEntities); diff --git a/tests/Aetheria.Shared.Tests/PilotSeamTests.cs b/tests/Aetheria.Shared.Tests/PilotSeamTests.cs index 134578efe..3caf0dacc 100644 --- a/tests/Aetheria.Shared.Tests/PilotSeamTests.cs +++ b/tests/Aetheria.Shared.Tests/PilotSeamTests.cs @@ -318,6 +318,7 @@ public sealed partial class RunStartTests var agent = arena.Agents.Single(a => a.Ship == east); var locks = east.Weapons.OfType().ToArray(); Assert.NotEmpty(locks); + agent.Doctrine.MinHitProbability = 2; // its guns hold fire, so its aim settles and the lock completes var step = 0; for (; step < 3000 && !(SeamState(agent) is CombatState && locks.Any(l => l.IsLocked)); step++) arena.Update(SeamDt); Assert.True(locks.Any(l => l.IsLocked), $"locked by step {step}: {string.Join(",", locks.Select(l => l.Lock))}"); From b180fd732dc053fbe15971e503a4aec983431f55 Mon Sep 17 00:00:00 2001 From: soul Date: Thu, 22 Oct 2026 02:47:57 +0200 Subject: [PATCH 065/307] Command seam, player: the Previous and NearestEnemy pins start from no target, where no other verb gives their answer Co-Authored-By: Claude Opus 5.5 --- tests/Aetheria.Shared.Tests/PilotSeamPlayerTests.cs | 7 +++++-- 1 file changed, 5 insertions(+), 2 deletions(-) diff --git a/tests/Aetheria.Shared.Tests/PilotSeamPlayerTests.cs b/tests/Aetheria.Shared.Tests/PilotSeamPlayerTests.cs index 8630a6b05..3c97f7fc9 100644 --- a/tests/Aetheria.Shared.Tests/PilotSeamPlayerTests.cs +++ b/tests/Aetheria.Shared.Tests/PilotSeamPlayerTests.cs @@ -69,11 +69,14 @@ public sealed partial class RunStartTests arena.Update(SeamDt); Assert.Same(third, east.Target.Value.Entity); + // With nothing held, Previous picks the farthest and Next the nearest. + east.SetTarget(TargetRef.None); east.Command.Verb(TargetVerb.Previous); arena.Update(SeamDt); - Assert.Same(west, east.Target.Value.Entity); + Assert.Same(third, east.Target.Value.Entity); - // Enemies only: the farther hostile, not the nearer neutral. + // Enemies only: the farther hostile, not the nearer neutral (Next, from nothing, would pick the neutral). + east.SetTarget(TargetRef.None); east.SetIff(third, true); east.Command.Verb(TargetVerb.NearestEnemy); arena.Update(SeamDt); From 30bcabf172852465128f04f1c0b9a32344a92a41 Mon Sep 17 00:00:00 2001 From: soul Date: Thu, 22 Oct 2026 04:31:47 +0200 Subject: [PATCH 066/307] Command seam fixes: pin Combat's release of a pulled lock, its commanded-aim pricing, and the order and count of same-step presses Co-Authored-By: Claude Opus 5.5 --- .../PilotSeamPlayerTests.cs | 90 +++++++++++++++++++ tests/Aetheria.Shared.Tests/PilotSeamTests.cs | 82 +++++++++++++++++ 2 files changed, 172 insertions(+) diff --git a/tests/Aetheria.Shared.Tests/PilotSeamPlayerTests.cs b/tests/Aetheria.Shared.Tests/PilotSeamPlayerTests.cs index 3c97f7fc9..3dc5777f4 100644 --- a/tests/Aetheria.Shared.Tests/PilotSeamPlayerTests.cs +++ b/tests/Aetheria.Shared.Tests/PilotSeamPlayerTests.cs @@ -131,6 +131,96 @@ public sealed partial class RunStartTests Assert.Equal(once.Mount, east.ResolvedAim.Mount); } + private void SeamReveal(Ship east, Ship west, out EquippedItem first, out EquippedItem second) + { + first = SeamGun(west).Item; + var one = first; + second = west.Weapons.Select(w => w.Item).First(i => i != one && i.Data.Name == one.Data.Name); + east.RecordReveal(west, first, SignatureChannel.Emission, west.ClaimOf(first)); + east.RecordReveal(west, second, SignatureChannel.Emission, west.ClaimOf(second)); + } + + private static void SeamAimEqual(AimTarget expected, AimTarget actual) + { + Assert.Equal(expected.Item, actual.Item); + Assert.Equal(expected.Mount, actual.Mount); + } + + private static bool SeamAimDiffers(AimTarget a, AimTarget b) => a.Item != b.Item || a.Mount != b.Mount; + + // Every aim-cycle press in a step cycles: two presses step the aim twice, as two direct cycles did. + [Fact] + public void CycleAimCountsEveryPress() + { + var (arena, east, west, _) = PlayerSeamTrio(); + SeamReveal(east, west, out var first, out _); + east.SetTarget(new TargetRef(west)); + Assert.True(east.TrySelectTargetItem(first)); + east.CycleAim(); + var once = east.ResolvedAim; + east.CycleAim(); + var twice = east.ResolvedAim; + Assert.True(SeamAimDiffers(once, twice), "fixture: a second cycle moves the aim on"); + + Assert.True(east.TrySelectTargetItem(first)); + east.Command.CycleAim(); + east.Command.CycleAim(); + arena.Update(SeamDt); + SeamAimEqual(twice, east.ResolvedAim); + } + + // Presses in one step apply in a fixed order, whatever order they came in: the designation, then the verb (answered + // from the designated target), then the item selection, then the aim cycles (stepping from the verb's target and the + // selected item). Each case is checked against both orders by direct calls, and fails if the two orders agree. + [Fact] + public void SameStepPressesApplyDesignationVerbSelectionCycle() + { + var (arena, east, west, third) = PlayerSeamTrio(); + SeamReveal(east, west, out var first, out _); + void Reset(Entity target) + { + east.SetTarget(target == null ? TargetRef.None : new TargetRef(target)); + Assert.True(east.TrySelectTargetItem(null)); + } + + // Next steps on from the designated west (to third), not from nothing (to west). + Reset(null); + east.Command.Verb(TargetVerb.Next); + east.Command.Designate(new TargetRef(west)); + arena.Update(SeamDt); + Assert.Same(third, east.Target.Value.Entity); + + // The cycle steps the aim on the target the verb chose. + Reset(null); + east.TargetNext(); + east.CycleAim(); + var verbThenCycle = east.ResolvedAim; + Reset(null); + east.CycleAim(); + east.TargetNext(); + Assert.True(SeamAimDiffers(verbThenCycle, east.ResolvedAim), "fixture: the verb and the cycle do not commute"); + Reset(null); + east.Command.CycleAim(); + east.Command.Verb(TargetVerb.Next); + arena.Update(SeamDt); + SeamAimEqual(verbThenCycle, east.ResolvedAim); + + // The cycle steps on from the selected item. + Reset(west); + Assert.True(east.TrySelectTargetItem(first)); + east.CycleAim(); + var selectThenCycle = east.ResolvedAim; + Reset(west); + east.CycleAim(); + Assert.True(east.TrySelectTargetItem(first)); + Assert.True(SeamAimDiffers(selectThenCycle, east.ResolvedAim), "fixture: the selection and the cycle do not commute"); + Reset(west); + east.Command.CycleAim(); + east.Command.SelectItem(first); + arena.Update(SeamDt); + SeamAimEqual(selectThenCycle, east.ResolvedAim); + } + // A trigger that counts its edges: a semi-auto fires once per Activate. private sealed class SeamTrigger : IActivatedBehavior { diff --git a/tests/Aetheria.Shared.Tests/PilotSeamTests.cs b/tests/Aetheria.Shared.Tests/PilotSeamTests.cs index 3caf0dacc..3bfeb9f42 100644 --- a/tests/Aetheria.Shared.Tests/PilotSeamTests.cs +++ b/tests/Aetheria.Shared.Tests/PilotSeamTests.cs @@ -335,6 +335,88 @@ public sealed partial class RunStartTests foreach (var launcher in ready) Assert.True(east.Command.Pulls(launcher), $"{launcher.Item.Data.Name} is pulled"); } + // Combat lets go of a lock it pulled this step (beside the groups) when the chosen group's own decision refuses it. An + // unfused lock is priced on its hit probability, which no shot clears at MinHitProbability 2, and it was never firing, + // so only the pending pull (Command.Pulls) tells Combat to release it. Breaks if Combat's release guard reads Firing + // alone (Combat.cs). + [Fact] + public void CombatReleasesALockItPulledButWillNotFire() + { + var (arena, east, west) = SeamPair(true); + east.SetIff(west, true); + west.SetIff(east, true); + var agent = arena.Agents.Single(a => a.Ship == east); + var locks = east.Weapons.OfType().ToArray(); + Assert.NotEmpty(locks); + agent.Doctrine.MinHitProbability = 2; // no unfused shot clears it + var step = 0; + for (; step < 3000 && !(SeamState(agent) is CombatState && locks.Any(l => l.IsLocked)); step++) arena.Update(SeamDt); + var launcher = locks.FirstOrDefault(l => l.IsLocked); + Assert.True(launcher != null, $"locked by step {step}"); + var gunRange = east.Weapons.Where(w => !(w is LockWeapon)).Max(w => w.Range); + var distance = launcher.MinRange + 20; + Assert.True(distance < gunRange, $"fixture: the guns reach past the launcher's minimum range ({launcher.MinRange} vs {gunRange})"); + west.Position = east.Position + distance * normalize(float3(east.Aim.x, 0, east.Aim.z)); + east.Update(SeamDt); + Assert.IsType(SeamState(agent)); + Assert.True(launcher.CanFire && !launcher.Firing, $"fixture: ready and idle (range {east.TargetRange}, lock {launcher.Lock})"); + + // Unfused, and in every group, so the chosen group decides it too. + var data = Assert.IsAssignableFrom(launcher.Item.Data); + var radius = data.BlastRadius; + try + { + data.BlastRadius = 0; + foreach (var group in east.WeaponGroups) + if (!group.weapons.Contains(launcher)) group.weapons.Add(launcher); + + agent.Update(SeamDt); + + var mine = east.Command.Triggers.Where(t => ReferenceEquals(t.Behavior, launcher)).Select(t => t.Pull).ToArray(); + Assert.True(mine.Contains(true), $"Combat pulls the ready lock: [{string.Join(",", mine)}]"); + Assert.False(east.Command.Pulls(launcher), $"and lets go of it when the group's decision refuses it: [{string.Join(",", mine)}]"); + } + finally + { + data.BlastRadius = radius; + } + } + + // Combat prices each shot on the aim it commands this step, not the applied aim (last step's). With the applied aim + // cleared and MinHitProbability between the two prices, the gun's one trigger follows the commanded price. Breaks if + // Combat drops its aim from FireControl.AgentFires (Combat.cs). + [Fact] + public void CombatPricesFireOnTheCommandedAim() + { + var (arena, east, west) = SeamPair(true); + east.SetIff(west, true); + west.SetIff(east, true); + var agent = arena.Agents.Single(a => a.Ship == east); + for (var step = 0; step < 900 && !(SeamState(agent) is CombatState); step++) arena.Update(SeamDt); + Assert.IsType(SeamState(agent)); + var revealed = SeamGun(west).Item; + east.EntityInfoGathered[west] = 1; + east.RecordReveal(west, revealed, SignatureChannel.Emission, west.ClaimOf(revealed)); + + agent.Update(SeamDt); // learn the item Combat aims at + var commanded = east.Command.Selections.LastOrDefault(); + Assert.NotNull(commanded); + east.Command.ClearOneShots(); + Assert.True(east.TrySelectTargetItem(null)); + var gun = SeamGun(east); + var pCommanded = FireControl.HitProbability(gun, east, west, AimTarget.OfItem(commanded)); + var pApplied = FireControl.HitProbability(gun, east, west); + Assert.True(abs(pCommanded - pApplied) > 1e-4f, $"fixture: the two aims price apart ({pCommanded} vs {pApplied})"); + agent.Doctrine.MinHitProbability = (pCommanded + pApplied) / 2; + var fires = pCommanded > pApplied; + if (!fires) gun.Activate(); // held, so Combat must let go of it + + agent.Update(SeamDt); + + var (_, pull) = Assert.Single(east.Command.Triggers.Where(t => ReferenceEquals(t.Behavior, gun))); + Assert.Equal(fires, pull); + } + // Pulls answers the pilot's last trigger for that behaviour this step, and nothing commanded is no pull. [Fact] public void PullsReadsTheLastTriggerForThatBehaviour() From 25750c225b5daf731c7d8a771e4aa0868db13927 Mon Sep 17 00:00:00 2001 From: soul Date: Thu, 22 Oct 2026 04:32:17 +0200 Subject: [PATCH 067/307] Command seam fixes (red): stance flips and the player's switches and ping are presses on PilotCommand Co-Authored-By: Claude Opus 5.5 --- .../PilotSeamPlayerTests.cs | 65 +++++++++++++++++++ 1 file changed, 65 insertions(+) diff --git a/tests/Aetheria.Shared.Tests/PilotSeamPlayerTests.cs b/tests/Aetheria.Shared.Tests/PilotSeamPlayerTests.cs index 3dc5777f4..e656c1212 100644 --- a/tests/Aetheria.Shared.Tests/PilotSeamPlayerTests.cs +++ b/tests/Aetheria.Shared.Tests/PilotSeamPlayerTests.cs @@ -221,6 +221,71 @@ public sealed partial class RunStartTests SeamAimEqual(selectThenCycle, east.ResolvedAim); } + // A stance press flips the stance as it stands when the press is applied, so two in a step cancel, as two flips in + // place did before the seam; none is replayed. + [Fact] + public void StanceTogglesInAStepCompose() + { + var (arena, east, west, _) = PlayerSeamTrio(); + Assert.False(east.IsHostileTo(west)); + east.Command.ToggleStance(west); + Assert.False(east.IsHostileTo(west), "nothing lands before the step"); + arena.Update(SeamDt); + Assert.True(east.IsHostileTo(west)); + + east.Command.ToggleStance(west); + east.Command.ToggleStance(west); + arena.Update(SeamDt); + Assert.True(east.IsHostileTo(west), "two flips in a step cancel"); + + east.Command.ToggleStance(west); + arena.Update(SeamDt); + Assert.False(east.IsHostileTo(west)); + arena.Update(SeamDt); + Assert.False(east.IsHostileTo(west), "the press is not replayed"); + } + + // The player's switches are presses on its command too: each flips at the next step, two in a step cancel, and none is + // replayed. A ping is a press that pings once. + [Fact] + public void PlayerSwitchesArePressesAtTheStep() + { + var (arena, east, _) = SeamPair(false); + Assert.NotNull(east.Shield); + Assert.NotNull(east.Sensor); + (bool, bool, bool) Switches() => (east.OverrideShutdown, east.HeatsinksEnabled, east.Shield.Item.Enabled.Value); + Assert.Equal((false, true, true), Switches()); + + east.Command.ToggleOverrideShutdown(); + east.Command.ToggleHeatsinks(); + east.Command.ToggleShield(); + Assert.Equal((false, true, true), Switches()); + arena.Update(SeamDt); + Assert.Equal((true, false, false), Switches()); + arena.Update(SeamDt); + Assert.Equal((true, false, false), Switches()); + + east.Command.ToggleOverrideShutdown(); + east.Command.ToggleOverrideShutdown(); + east.Command.ToggleHeatsinks(); + east.Command.ToggleHeatsinks(); + east.Command.ToggleShield(); + east.Command.ToggleShield(); + arena.Update(SeamDt); + Assert.Equal((true, false, false), Switches()); + + var pings = 0; + east.Sensor.OnPingStart += () => pings++; + for (var step = 0; step < 600; step++) arena.Update(SeamDt); // the ping's capacitor fills + Assert.Equal(0, pings); + east.Command.Ping(); + Assert.Equal(0, pings); + arena.Update(SeamDt); + Assert.Equal(1, pings); + for (var step = 0; step < 10; step++) arena.Update(SeamDt); + Assert.Equal(1, pings); + } + // A trigger that counts its edges: a semi-auto fires once per Activate. private sealed class SeamTrigger : IActivatedBehavior { From bc0541745009baf7d284d055d77c5894f38f5cdb Mon Sep 17 00:00:00 2001 From: soul Date: Thu, 22 Oct 2026 04:33:20 +0200 Subject: [PATCH 068/307] Command seam fixes: the player's switches, ping and stance flips are PilotCommand presses; triggers, switches and ping are internal ActivatedBehavior replaces IActivatedBehavior so Activate/Deactivate can be internal under Unity's C# 9. Co-Authored-By: Claude Opus 5.5 --- Assets/Scripts/Gameplay/ActionBarSlot.cs | 6 +-- Assets/Scripts/Gameplay/ActionGameManager.cs | 38 ++++++------------- .../ServerShared/Behaviors/Behaviors.cs | 11 ++++-- .../ServerShared/Behaviors/ChargedWeapon.cs | 4 +- .../ServerShared/Behaviors/ConstantWeapon.cs | 4 +- .../ServerShared/Behaviors/InstantWeapon.cs | 2 +- .../Scripts/ServerShared/Behaviors/Sensor.cs | 2 +- .../Scripts/ServerShared/Behaviors/Switch.cs | 8 ++-- .../Scripts/ServerShared/Behaviors/Trigger.cs | 8 ++-- .../Scripts/ServerShared/Behaviors/Weapon.cs | 6 +-- Assets/Scripts/ServerShared/Entity.cs | 6 +-- Assets/Scripts/ServerShared/Pilot.cs | 28 ++++++++++---- docs/headless-playground-cut.md | 4 +- .../PilotSeamPlayerTests.cs | 9 +++-- 14 files changed, 70 insertions(+), 66 deletions(-) diff --git a/Assets/Scripts/Gameplay/ActionBarSlot.cs b/Assets/Scripts/Gameplay/ActionBarSlot.cs index 1c51faf82..5c28357e9 100644 --- a/Assets/Scripts/Gameplay/ActionBarSlot.cs +++ b/Assets/Scripts/Gameplay/ActionBarSlot.cs @@ -75,7 +75,7 @@ public class ActionBarSlot : MonoBehaviour this, entity.Equipment[savedActionBarGearBinding.EquipmentIndex], entity.Equipment[savedActionBarGearBinding.EquipmentIndex] - .Behaviors[savedActionBarGearBinding.BehaviorIndex] as IActivatedBehavior), + .Behaviors[savedActionBarGearBinding.BehaviorIndex] as ActivatedBehavior), SavedActionBarWeaponGroupBinding savedActionBarWeaponGroupBinding => new ActionBarWeaponGroupBinding(entity, this, savedActionBarWeaponGroupBinding.Group), _ => null @@ -136,11 +136,11 @@ public class ActionBarConsumableBinding : ActionBarBinding public class ActionBarGearBinding : ActionBarBinding { public EquippedItem Item { get; } - public IActivatedBehavior Behavior { get; } + public ActivatedBehavior Behavior { get; } public bool Active; - public ActionBarGearBinding(Entity entity, ActionBarSlot slot, EquippedItem item, IActivatedBehavior behavior) : base(entity, slot) + public ActionBarGearBinding(Entity entity, ActionBarSlot slot, EquippedItem item, ActivatedBehavior behavior) : base(entity, slot) { Item = item; var data = Entity.ItemManager.GetData(item.EquippableItem); diff --git a/Assets/Scripts/Gameplay/ActionGameManager.cs b/Assets/Scripts/Gameplay/ActionGameManager.cs index 61a20308f..6fa564d92 100644 --- a/Assets/Scripts/Gameplay/ActionGameManager.cs +++ b/Assets/Scripts/Gameplay/ActionGameManager.cs @@ -396,39 +396,23 @@ public class ActionGameManager : MonoBehaviour ActionBar.gameObject.SetActive(!_uiHidden); }; - Input.Player.OverrideShutdown.performed += context => - { - CurrentEntity.OverrideShutdown = !CurrentEntity.OverrideShutdown; - }; - - Input.Player.Ping.performed += context => - { - CurrentEntity.Sensor?.Ping(); - }; - - Input.Player.ToggleHeatsinks.performed += context => - { - CurrentEntity.HeatsinksEnabled = !CurrentEntity.HeatsinksEnabled; - // TODO: SFX: Success/Fail - }; - - Input.Player.ToggleShield.performed += context => - { - if (CurrentEntity.Shield != null) - { - CurrentEntity.Shield.Item.Enabled.Value = !CurrentEntity.Shield.Item.Enabled.Value; - // TODO: SFX: Success/Fail - } - }; + // The switches and the ping are presses on the entity's command, flipped at the next step. + Input.Player.OverrideShutdown.performed += context => CurrentEntity.Command.ToggleOverrideShutdown(); + Input.Player.Ping.performed += context => CurrentEntity.Command.Ping(); + // TODO: SFX: Success/Fail + Input.Player.ToggleHeatsinks.performed += context => CurrentEntity.Command.ToggleHeatsinks(); + // TODO: SFX: Success/Fail + Input.Player.ToggleShield.performed += context => CurrentEntity.Command.ToggleShield(); // Flips the player's own stance on the current target between hostile and neutral. Going // neutral mid-fight safes weapons (Weapon.StanceAllowsFire); an unmarked/undetected target - // reads as non-hostile here, so the first press declares hostile. + // reads as non-hostile, so the first press declares hostile. The flip reads the stance when applied, so two + // presses in a step cancel. Input.Player.ToggleStance.performed += context => { var target = CurrentEntity.Target.Value.Entity; if (target == null) return; - CurrentEntity.Command.Iff(target, !CurrentEntity.IsHostileTo(target)); + CurrentEntity.Command.ToggleStance(target); }; #region Targeting @@ -483,7 +467,7 @@ public class ActionGameManager : MonoBehaviour switch (dragAction) { case EquippedItemDragObject equippedItemDragAction: - var trigger = equippedItemDragAction.EquippedItem.GetBehavior(); + var trigger = equippedItemDragAction.EquippedItem.GetBehavior(); if (trigger == null) return false; slot.Binding = new ActionBarGearBinding(CurrentEntity, slot, equippedItemDragAction.EquippedItem, trigger); return true; diff --git a/Assets/Scripts/ServerShared/Behaviors/Behaviors.cs b/Assets/Scripts/ServerShared/Behaviors/Behaviors.cs index 0cbe11186..7150e47a1 100644 --- a/Assets/Scripts/ServerShared/Behaviors/Behaviors.cs +++ b/Assets/Scripts/ServerShared/Behaviors/Behaviors.cs @@ -87,10 +87,15 @@ public abstract class Behavior } } -public interface IActivatedBehavior +// A behaviour its pilot triggers. Only PilotApplier pulls and releases it, from the pilot's PilotCommand (Pull, Release), +// so Activate and Deactivate are internal to the simulation: no script outside it triggers one in place. +public abstract class ActivatedBehavior : Behavior { - void Activate(); - void Deactivate(); + protected ActivatedBehavior(BehaviorData data, EquippedItem item) : base(data, item) { } + protected ActivatedBehavior(BehaviorData data, ConsumableItemEffect consumable) : base(data, consumable) { } + + internal abstract void Activate(); + internal abstract void Deactivate(); } public interface IEventBehavior diff --git a/Assets/Scripts/ServerShared/Behaviors/ChargedWeapon.cs b/Assets/Scripts/ServerShared/Behaviors/ChargedWeapon.cs index 8d048083d..4d2bb5647 100644 --- a/Assets/Scripts/ServerShared/Behaviors/ChargedWeapon.cs +++ b/Assets/Scripts/ServerShared/Behaviors/ChargedWeapon.cs @@ -167,7 +167,7 @@ public class ChargedWeapon : InstantWeapon return base.Execute(dt); } - public override void Activate() + internal override void Activate() { // CanFire also holds a weapon whose released charge has a burst pending: its cooldown starts at its first round, // so a charge started before then would overlap it. @@ -180,7 +180,7 @@ public class ChargedWeapon : InstantWeapon } } - public override void Deactivate() + internal override void Deactivate() { if (_charging) { diff --git a/Assets/Scripts/ServerShared/Behaviors/ConstantWeapon.cs b/Assets/Scripts/ServerShared/Behaviors/ConstantWeapon.cs index cc0015dbe..a2fc9f802 100644 --- a/Assets/Scripts/ServerShared/Behaviors/ConstantWeapon.cs +++ b/Assets/Scripts/ServerShared/Behaviors/ConstantWeapon.cs @@ -198,7 +198,7 @@ public class ConstantWeapon : Weapon, IProgressBehavior, IEventBehavior, IPowerC return true; } - public override void Activate() + internal override void Activate() { // A refused beam never starts (operator ruling 2026-09-30): OnStartFiring drives the beam's visuals and // audio, so starting it only for Execute to stop it a tick later would flash a beam that fires nothing. @@ -209,7 +209,7 @@ public class ConstantWeapon : Weapon, IProgressBehavior, IEventBehavior, IPowerC } } - public override void Deactivate() + internal override void Deactivate() { if (_firing) { diff --git a/Assets/Scripts/ServerShared/Behaviors/InstantWeapon.cs b/Assets/Scripts/ServerShared/Behaviors/InstantWeapon.cs index 043df920c..a6b3dcd2f 100644 --- a/Assets/Scripts/ServerShared/Behaviors/InstantWeapon.cs +++ b/Assets/Scripts/ServerShared/Behaviors/InstantWeapon.cs @@ -286,7 +286,7 @@ public class InstantWeapon : Weapon, IProgressBehavior, IEventBehavior, IPowerCo return true; } - public override void Activate() + internal override void Activate() { if(CanFire) Trigger(); diff --git a/Assets/Scripts/ServerShared/Behaviors/Sensor.cs b/Assets/Scripts/ServerShared/Behaviors/Sensor.cs index cf9d161f0..d98437e59 100644 --- a/Assets/Scripts/ServerShared/Behaviors/Sensor.cs +++ b/Assets/Scripts/ServerShared/Behaviors/Sensor.cs @@ -136,7 +136,7 @@ public class Sensor : Behavior, IEventBehavior, IPowerConsumer // Cut 5 (docs/stats-and-power-cut.md §1.3, PowerTiers.cs): Utility -- everything else. public int DefaultPowerTier => PowerTiers.Utility; - public void Ping() + internal void Ping() { // Whole-or-nothing against the ping's own input capacitor -- fires only at full charge (operator // ruling: no fraction of a shot). A consumable-hosted instance (Item == null) has no PowerBus entry, so diff --git a/Assets/Scripts/ServerShared/Behaviors/Switch.cs b/Assets/Scripts/ServerShared/Behaviors/Switch.cs index 47990c4b2..dc5711e2f 100644 --- a/Assets/Scripts/ServerShared/Behaviors/Switch.cs +++ b/Assets/Scripts/ServerShared/Behaviors/Switch.cs @@ -20,11 +20,11 @@ public class SwitchData : BehaviorData } } -public class Switch : Behavior, IActivatedBehavior +public class Switch : ActivatedBehavior { private SwitchData _data; - public bool Activated { get; set; } + public bool Activated { get; private set; } public Switch(SwitchData data, EquippedItem item) : base(data, item) { @@ -40,12 +40,12 @@ public class Switch : Behavior, IActivatedBehavior return Activated; } - public void Activate() + internal override void Activate() { Activated = true; } - public void Deactivate() + internal override void Deactivate() { Activated = false; } diff --git a/Assets/Scripts/ServerShared/Behaviors/Trigger.cs b/Assets/Scripts/ServerShared/Behaviors/Trigger.cs index be7c9b9dc..ddc58ada9 100644 --- a/Assets/Scripts/ServerShared/Behaviors/Trigger.cs +++ b/Assets/Scripts/ServerShared/Behaviors/Trigger.cs @@ -20,9 +20,9 @@ public class TriggerData : BehaviorData } } -public class Trigger : Behavior, IActivatedBehavior +public class Trigger : ActivatedBehavior { - public bool _pulled; + private bool _pulled; public Trigger(TriggerData data, EquippedItem item) : base(data, item) { } public Trigger(TriggerData data, ConsumableItemEffect item) : base(data, item) { } @@ -38,12 +38,12 @@ public class Trigger : Behavior, IActivatedBehavior return false; } - public void Activate() + internal override void Activate() { _pulled = true; } - public void Deactivate() + internal override void Deactivate() { } } \ No newline at end of file diff --git a/Assets/Scripts/ServerShared/Behaviors/Weapon.cs b/Assets/Scripts/ServerShared/Behaviors/Weapon.cs index 6a01f81ca..12166d46c 100644 --- a/Assets/Scripts/ServerShared/Behaviors/Weapon.cs +++ b/Assets/Scripts/ServerShared/Behaviors/Weapon.cs @@ -100,7 +100,7 @@ public abstract class WeaponData : BehaviorData public float RatedRange => Range.Max; } -public abstract class Weapon : Behavior, IActivatedBehavior +public abstract class Weapon : ActivatedBehavior { private WeaponData _data; @@ -179,12 +179,12 @@ public abstract class Weapon : Behavior, IActivatedBehavior return true; } - public virtual void Activate() + internal override void Activate() { _firing = true; } - public virtual void Deactivate() + internal override void Deactivate() { _firing = false; } diff --git a/Assets/Scripts/ServerShared/Entity.cs b/Assets/Scripts/ServerShared/Entity.cs index 42b30cf4f..6b1eab8c9 100644 --- a/Assets/Scripts/ServerShared/Entity.cs +++ b/Assets/Scripts/ServerShared/Entity.cs @@ -173,7 +173,7 @@ public abstract class Entity public bool HeatsinksEnabled { get => _heatsinksEnabled; - set + internal set { if (value == _heatsinksEnabled) return; _heatsinksEnabled = value; @@ -186,7 +186,7 @@ public abstract class Entity public EntitySettings Settings { get; } - public bool OverrideShutdown { get; set; } + public bool OverrideShutdown { get; internal set; } // The pilot's tractor command (0..1); the Pickup behaviour spins its beam toward it. public float TractorCommand { get; internal set; } @@ -2170,7 +2170,7 @@ public class EquippedItem : IStatContext public ReactiveProperty ThermalOnline { get; } = new ReactiveProperty(false); public ReactiveProperty DurabilityOnline { get; } = new ReactiveProperty(false); public ReadOnlyReactiveProperty Online { get; } - public ReactiveProperty Enabled { get; } = new ReactiveProperty(true); + internal ReactiveProperty Enabled { get; } = new ReactiveProperty(true); public ReadOnlyReactiveProperty Active { get; } public ItemManager ItemManager { get; } diff --git a/Assets/Scripts/ServerShared/Pilot.cs b/Assets/Scripts/ServerShared/Pilot.cs index be1e9cd7e..576191b7d 100644 --- a/Assets/Scripts/ServerShared/Pilot.cs +++ b/Assets/Scripts/ServerShared/Pilot.cs @@ -53,27 +53,35 @@ public sealed class PilotCommand internal readonly List Selections = new List(); internal readonly List Hails = new List(); internal readonly List Uses = new List(); - internal readonly List<(IActivatedBehavior Behavior, bool Pull)> Triggers = new List<(IActivatedBehavior, bool)>(); - internal readonly List<(Entity Other, bool? Hostile)> Iffs = new List<(Entity, bool?)>(); + internal readonly List<(ActivatedBehavior Behavior, bool Pull)> Triggers = new List<(ActivatedBehavior, bool)>(); + internal readonly List<(Entity Other, bool? Hostile, bool Toggle)> Iffs = new List<(Entity, bool?, bool)>(); internal TargetVerb TargetVerb; internal int AimCycles; + internal int ShutdownOverrideToggles, HeatsinkToggles, ShieldToggles, Pings; public void Designate(TargetRef target) => Designations.Add(target); public void SelectItem(EquippedItem item) => Selections.Add(item); public void Hail(string message) => Hails.Add(message); public void Use(ConsumableItemData consumable) => Uses.Add(consumable); - public void Pull(IActivatedBehavior behavior) => Triggers.Add((behavior, true)); - public void Release(IActivatedBehavior behavior) => Triggers.Add((behavior, false)); + public void Pull(ActivatedBehavior behavior) => Triggers.Add((behavior, true)); + public void Release(ActivatedBehavior behavior) => Triggers.Add((behavior, false)); // The last verb in a step wins. public void Verb(TargetVerb verb) => TargetVerb = verb; public void CycleAim() => AimCycles++; - public void Iff(Entity other, bool? hostile) => Iffs.Add((other, hostile)); + public void Iff(Entity other, bool? hostile) => Iffs.Add((other, hostile, false)); + // A press that flips the stance on `other` as it stands when applied, in order with Iff, so two in a step cancel. + public void ToggleStance(Entity other) => Iffs.Add((other, null, true)); + // The entity's own switches, pressed: each press flips its switch when applied, so two in a step cancel. + public void ToggleOverrideShutdown() => ShutdownOverrideToggles++; + public void ToggleHeatsinks() => HeatsinkToggles++; + public void ToggleShield() => ShieldToggles++; + public void Ping() => Pings++; // The pilot's own pending decisions, which it reads back instead of the ship (that is last step's applied state). public TargetRef? Designated => Designations.Count > 0 ? Designations[Designations.Count - 1] : (TargetRef?) null; // Whether the last trigger this pilot commanded for `behavior` this step is a pull. - public bool Pulls(IActivatedBehavior behavior) + public bool Pulls(ActivatedBehavior behavior) { for (var i = Triggers.Count - 1; i >= 0; i--) if (ReferenceEquals(Triggers[i].Behavior, behavior)) return Triggers[i].Pull; @@ -90,6 +98,7 @@ public sealed class PilotCommand Iffs.Clear(); TargetVerb = TargetVerb.None; AimCycles = 0; + ShutdownOverrideToggles = HeatsinkToggles = ShieldToggles = Pings = 0; } } @@ -112,7 +121,12 @@ public static class PilotApplier } foreach (var item in command.Selections) entity.TrySelectTargetItem(item); for (var i = 0; i < command.AimCycles; i++) entity.CycleAim(); - foreach (var (other, hostile) in command.Iffs) entity.SetIff(other, hostile); + foreach (var (other, hostile, toggle) in command.Iffs) entity.SetIff(other, toggle ? !entity.IsHostileTo(other) : hostile); + for (var i = 0; i < command.ShutdownOverrideToggles; i++) entity.OverrideShutdown = !entity.OverrideShutdown; + for (var i = 0; i < command.HeatsinkToggles; i++) entity.HeatsinksEnabled = !entity.HeatsinksEnabled; + if (entity.Shield != null) + for (var i = 0; i < command.ShieldToggles; i++) entity.Shield.Item.Enabled.Value = !entity.Shield.Item.Enabled.Value; + for (var i = 0; i < command.Pings; i++) entity.Sensor?.Ping(); foreach (var hail in command.Hails) entity.SetMessage(hail); foreach (var consumable in command.Uses) entity.TryActivateConsumable(consumable); if (command.Aim is float3 aim) entity.Aim = aim; diff --git a/docs/headless-playground-cut.md b/docs/headless-playground-cut.md index 8e52bdaf1..bea30aa46 100644 --- a/docs/headless-playground-cut.md +++ b/docs/headless-playground-cut.md @@ -72,7 +72,7 @@ combat state cannot run without Unity.** - Player-only inputs, all plain entity writes that an agent could make: - `TractorPower` (`ActionGameManager.cs:1214-1216`); - `OverrideShutdown`, `HeatsinksEnabled`, shield `Enabled`, `Sensor.Ping` (`:339-362`); - - gear `IActivatedBehavior` (`ActionBarSlot.cs:159-168`); + - gear `ActivatedBehavior` (`ActionBarSlot.cs:159-168`); - consumables (`ActionBarSlot.cs:119-122`, `Entity.TryActivateConsumable`). - Player-only lifecycle actions. These are the orchestration this map moves: - interact, which means warp, dock or undock (`ActionGameManager.cs:298-320`); @@ -1002,7 +1002,7 @@ mean we want to delete all the weapon types we created". | `move ` / `look ` / `tractor <0..1>` | pilot entity fields, as `ActionGameManager.cs:1189-1216` | | `target nearest\|none\|` | `Pilot.Target.Value` | | `fire on\|off` | `WeaponGroups[g].weapons` Activate/Deactivate, as `ActionBarSlot.cs:190-204` | -| `gear on\|off` | `IActivatedBehavior` Activate/Deactivate | +| `gear on\|off` | `ActivatedBehavior` Activate/Deactivate | | `pilot manual\|minion\|patrol\|goto station \|goto wormhole ` | `Run.PilotAgent` | | `dock` / `undock` / `board ` / `warp` / `tow` | `Run.TryDock` / `Undock` / `Board` / `TryEnterWormhole` / `Tow` | | `hit [hardpoint ]` | `Entity.ApplyHit` (Cut 2) | diff --git a/tests/Aetheria.Shared.Tests/PilotSeamPlayerTests.cs b/tests/Aetheria.Shared.Tests/PilotSeamPlayerTests.cs index e656c1212..afb95836f 100644 --- a/tests/Aetheria.Shared.Tests/PilotSeamPlayerTests.cs +++ b/tests/Aetheria.Shared.Tests/PilotSeamPlayerTests.cs @@ -287,11 +287,12 @@ public sealed partial class RunStartTests } // A trigger that counts its edges: a semi-auto fires once per Activate. - private sealed class SeamTrigger : IActivatedBehavior + private sealed class SeamTrigger : ActivatedBehavior { public int Pulled, Released; - public void Activate() => Pulled++; - public void Deactivate() => Released++; + public SeamTrigger(EquippedItem item) : base(null, item) { } + internal override void Activate() => Pulled++; + internal override void Deactivate() => Released++; } // A press is one pull at the next step and the button held after it pulls nothing more, so a semi-auto fires once; @@ -300,7 +301,7 @@ public sealed partial class RunStartTests public void PlayerTriggerIsAnEdge() { var (arena, east, _) = SeamPair(false); - var trigger = new SeamTrigger(); + var trigger = new SeamTrigger(SeamGun(east).Item); east.Command.Pull(trigger); Assert.Equal(0, trigger.Pulled); for (var step = 0; step < 10; step++) arena.Update(SeamDt); From 72ad1b8036d2df3eb3483b25f341587abaf02123 Mon Sep 17 00:00:00 2001 From: soul Date: Thu, 22 Oct 2026 05:07:25 +0200 Subject: [PATCH 069/307] Command seam fixes: the shield switch is pinned on a shielded target; the Longinus has none Co-Authored-By: Claude Opus 5.5 --- .../PilotSeamPlayerTests.cs | 48 +++++++++++++++---- 1 file changed, 38 insertions(+), 10 deletions(-) diff --git a/tests/Aetheria.Shared.Tests/PilotSeamPlayerTests.cs b/tests/Aetheria.Shared.Tests/PilotSeamPlayerTests.cs index afb95836f..b132a6691 100644 --- a/tests/Aetheria.Shared.Tests/PilotSeamPlayerTests.cs +++ b/tests/Aetheria.Shared.Tests/PilotSeamPlayerTests.cs @@ -246,33 +246,32 @@ public sealed partial class RunStartTests } // The player's switches are presses on its command too: each flips at the next step, two in a step cancel, and none is - // replayed. A ping is a press that pings once. + // replayed. A ping is a press that pings once. (The Longinus has no shield: the shield switch is pinned on a shielded + // target in FireControlCut124Tests below, and here its press is a no-op.) [Fact] public void PlayerSwitchesArePressesAtTheStep() { var (arena, east, _) = SeamPair(false); - Assert.NotNull(east.Shield); + Assert.Null(east.Shield); Assert.NotNull(east.Sensor); - (bool, bool, bool) Switches() => (east.OverrideShutdown, east.HeatsinksEnabled, east.Shield.Item.Enabled.Value); - Assert.Equal((false, true, true), Switches()); + (bool, bool) Switches() => (east.OverrideShutdown, east.HeatsinksEnabled); + Assert.Equal((false, true), Switches()); east.Command.ToggleOverrideShutdown(); east.Command.ToggleHeatsinks(); east.Command.ToggleShield(); - Assert.Equal((false, true, true), Switches()); + Assert.Equal((false, true), Switches()); arena.Update(SeamDt); - Assert.Equal((true, false, false), Switches()); + Assert.Equal((true, false), Switches()); arena.Update(SeamDt); - Assert.Equal((true, false, false), Switches()); + Assert.Equal((true, false), Switches()); east.Command.ToggleOverrideShutdown(); east.Command.ToggleOverrideShutdown(); east.Command.ToggleHeatsinks(); east.Command.ToggleHeatsinks(); - east.Command.ToggleShield(); - east.Command.ToggleShield(); arena.Update(SeamDt); - Assert.Equal((true, false, false), Switches()); + Assert.Equal((true, false), Switches()); var pings = 0; east.Sensor.OnPingStart += () => pings++; @@ -328,3 +327,32 @@ public sealed partial class RunStartTests Assert.Equal(.25f, east.TractorCommand); } } + +// The shield switch on the seam (aetheria-arena, seam fixes): the player's shield press is a PilotCommand press, applied at +// the step like the other switches. Here because this class builds a shielded target. +public sealed partial class FireControlCut124Tests +{ + [Fact] + public void TheShieldSwitchIsAPressAtTheStep() + { + var e = Build(TestSettings(), SolidShape(21, 3), equipShield: true); + var shield = e.Target.Shield; + Assert.NotNull(shield); + Assert.True(shield.Item.Enabled.Value); + + e.Target.Command.ToggleShield(); + Assert.True(shield.Item.Enabled.Value, "nothing lands before the step"); + e.Zone.Update(.1f); + Assert.False(shield.Item.Enabled.Value); + e.Zone.Update(.1f); + Assert.False(shield.Item.Enabled.Value, "the press is not replayed"); + + e.Target.Command.ToggleShield(); + e.Target.Command.ToggleShield(); + e.Zone.Update(.1f); + Assert.False(shield.Item.Enabled.Value, "two presses in a step cancel"); + e.Target.Command.ToggleShield(); + e.Zone.Update(.1f); + Assert.True(shield.Item.Enabled.Value); + } +} From a7477e679dd8705a2796beba992726331e3ba74c Mon Sep 17 00:00:00 2001 From: soul Date: Thu, 22 Oct 2026 05:51:10 +0200 Subject: [PATCH 070/307] Command seam fixes: the ping pin runs before any switch is thrown; a stance set is pinned as a set beside the flips Co-Authored-By: Claude Opus 5.5 --- .../PilotSeamPlayerTests.cs | 25 +++++++++++-------- 1 file changed, 14 insertions(+), 11 deletions(-) diff --git a/tests/Aetheria.Shared.Tests/PilotSeamPlayerTests.cs b/tests/Aetheria.Shared.Tests/PilotSeamPlayerTests.cs index b132a6691..1302950c9 100644 --- a/tests/Aetheria.Shared.Tests/PilotSeamPlayerTests.cs +++ b/tests/Aetheria.Shared.Tests/PilotSeamPlayerTests.cs @@ -232,6 +232,9 @@ public sealed partial class RunStartTests Assert.False(east.IsHostileTo(west), "nothing lands before the step"); arena.Update(SeamDt); Assert.True(east.IsHostileTo(west)); + east.Command.Iff(west, true); + arena.Update(SeamDt); + Assert.True(east.IsHostileTo(west), "a stance set is a set, not a flip"); east.Command.ToggleStance(west); east.Command.ToggleStance(west); @@ -254,6 +257,17 @@ public sealed partial class RunStartTests var (arena, east, _) = SeamPair(false); Assert.Null(east.Shield); Assert.NotNull(east.Sensor); + var pings = 0; + east.Sensor.OnPingStart += () => pings++; + for (var step = 0; step < 600; step++) arena.Update(SeamDt); // the ping's capacitor fills, before any switch is thrown + Assert.Equal(0, pings); + east.Command.Ping(); + Assert.Equal(0, pings); + arena.Update(SeamDt); + Assert.Equal(1, pings); + for (var step = 0; step < 10; step++) arena.Update(SeamDt); + Assert.Equal(1, pings); + (bool, bool) Switches() => (east.OverrideShutdown, east.HeatsinksEnabled); Assert.Equal((false, true), Switches()); @@ -272,17 +286,6 @@ public sealed partial class RunStartTests east.Command.ToggleHeatsinks(); arena.Update(SeamDt); Assert.Equal((true, false), Switches()); - - var pings = 0; - east.Sensor.OnPingStart += () => pings++; - for (var step = 0; step < 600; step++) arena.Update(SeamDt); // the ping's capacitor fills - Assert.Equal(0, pings); - east.Command.Ping(); - Assert.Equal(0, pings); - arena.Update(SeamDt); - Assert.Equal(1, pings); - for (var step = 0; step < 10; step++) arena.Update(SeamDt); - Assert.Equal(1, pings); } // A trigger that counts its edges: a semi-auto fires once per Activate. From 87f29cb0c8d87803439d120e59168ebdbea70b76 Mon Sep 17 00:00:00 2001 From: soul Date: Thu, 22 Oct 2026 06:18:32 +0200 Subject: [PATCH 071/307] Command seam fixes: the ping pin waits for the ping's cooldown and charge Co-Authored-By: Claude Opus 5.5 --- tests/Aetheria.Shared.Tests/PilotSeamPlayerTests.cs | 8 ++++++-- 1 file changed, 6 insertions(+), 2 deletions(-) diff --git a/tests/Aetheria.Shared.Tests/PilotSeamPlayerTests.cs b/tests/Aetheria.Shared.Tests/PilotSeamPlayerTests.cs index 1302950c9..9f8820338 100644 --- a/tests/Aetheria.Shared.Tests/PilotSeamPlayerTests.cs +++ b/tests/Aetheria.Shared.Tests/PilotSeamPlayerTests.cs @@ -259,12 +259,16 @@ public sealed partial class RunStartTests Assert.NotNull(east.Sensor); var pings = 0; east.Sensor.OnPingStart += () => pings++; - for (var step = 0; step < 600; step++) arena.Update(SeamDt); // the ping's capacitor fills, before any switch is thrown + // The ping's cooldown runs out and its capacitor fills (over about as long again), before any switch is thrown. + var settle = 0; + for (; settle < 36000 && east.Sensor.Cooldown > 0; settle++) arena.Update(SeamDt); + Assert.True(east.Sensor.Cooldown <= 0, $"fixture: the ping cools down ({east.Sensor.Cooldown} after {settle} steps)"); + for (var step = 0; step < settle + 600; step++) arena.Update(SeamDt); Assert.Equal(0, pings); east.Command.Ping(); Assert.Equal(0, pings); arena.Update(SeamDt); - Assert.Equal(1, pings); + Assert.True(pings == 1, $"one ping at the step (cooldown {east.Sensor.Cooldown}, settled {settle} steps)"); for (var step = 0; step < 10; step++) arena.Update(SeamDt); Assert.Equal(1, pings); From cfbd044551f545ec3fc7619df56a2d406dbcf08b Mon Sep 17 00:00:00 2001 From: soul Date: Thu, 22 Oct 2026 06:29:02 +0200 Subject: [PATCH 072/307] Command seam fixes: drop the ping observation, which this fixture's uncharged sensor cannot show Co-Authored-By: Claude Opus 5.5 --- .../PilotSeamPlayerTests.cs | 17 +---------------- 1 file changed, 1 insertion(+), 16 deletions(-) diff --git a/tests/Aetheria.Shared.Tests/PilotSeamPlayerTests.cs b/tests/Aetheria.Shared.Tests/PilotSeamPlayerTests.cs index 9f8820338..7dfb2d339 100644 --- a/tests/Aetheria.Shared.Tests/PilotSeamPlayerTests.cs +++ b/tests/Aetheria.Shared.Tests/PilotSeamPlayerTests.cs @@ -249,7 +249,7 @@ public sealed partial class RunStartTests } // The player's switches are presses on its command too: each flips at the next step, two in a step cancel, and none is - // replayed. A ping is a press that pings once. (The Longinus has no shield: the shield switch is pinned on a shielded + // replayed. (The Longinus has no shield: the shield switch is pinned on a shielded // target in FireControlCut124Tests below, and here its press is a no-op.) [Fact] public void PlayerSwitchesArePressesAtTheStep() @@ -257,21 +257,6 @@ public sealed partial class RunStartTests var (arena, east, _) = SeamPair(false); Assert.Null(east.Shield); Assert.NotNull(east.Sensor); - var pings = 0; - east.Sensor.OnPingStart += () => pings++; - // The ping's cooldown runs out and its capacitor fills (over about as long again), before any switch is thrown. - var settle = 0; - for (; settle < 36000 && east.Sensor.Cooldown > 0; settle++) arena.Update(SeamDt); - Assert.True(east.Sensor.Cooldown <= 0, $"fixture: the ping cools down ({east.Sensor.Cooldown} after {settle} steps)"); - for (var step = 0; step < settle + 600; step++) arena.Update(SeamDt); - Assert.Equal(0, pings); - east.Command.Ping(); - Assert.Equal(0, pings); - arena.Update(SeamDt); - Assert.True(pings == 1, $"one ping at the step (cooldown {east.Sensor.Cooldown}, settled {settle} steps)"); - for (var step = 0; step < 10; step++) arena.Update(SeamDt); - Assert.Equal(1, pings); - (bool, bool) Switches() => (east.OverrideShutdown, east.HeatsinksEnabled); Assert.Equal((false, true), Switches()); From 2f15c79176e126607a2a99da9256fd7597abdeb3 Mon Sep 17 00:00:00 2001 From: soul Date: Thu, 22 Oct 2026 08:39:15 +0200 Subject: [PATCH 073/307] Seam fix tests: the stance press resolves its target at the step, flips the belief, and the ping press pings once (red) Co-Authored-By: Claude Opus 5.5 --- .../ContactRevealTests.cs | 17 ++++++++++ .../IffAndCombatTests.cs | 17 ++++++++++ .../PilotSeamPlayerTests.cs | 31 ++++++++++++++----- 3 files changed, 57 insertions(+), 8 deletions(-) diff --git a/tests/Aetheria.Shared.Tests/ContactRevealTests.cs b/tests/Aetheria.Shared.Tests/ContactRevealTests.cs index d23f166ea..1a8df874c 100644 --- a/tests/Aetheria.Shared.Tests/ContactRevealTests.cs +++ b/tests/Aetheria.Shared.Tests/ContactRevealTests.cs @@ -587,6 +587,23 @@ public sealed partial class ContactRevealTests : IDisposable return (light.ToArray(), Track(o, p)); } + // The pilot's ping is a press on its command: the sensor pings at the next step, once, and the press is not replayed. + [Fact] + public void APingPressPingsOnceAtTheStep() + { + var p = Add(float3(0, 0, 1000), new[] { "Eye" }); + var sensor = p.Equipment.SelectMany(i => i.Behaviors).OfType().Single(); + var pings = 0; + sensor.OnPingStart += () => pings++; + Tick(.1f); // a sensor's first tick opens its ping cooldown; Ping() is refused before it + p.Command.Ping(); + Assert.Equal(0, pings); // nothing lands before the step + Tick(.1f); + Assert.Equal(1, pings); + for (var tick = 0; tick < 60; tick++) Tick(.1f); // past the cooldown, so a replayed press would ping again + Assert.Equal(1, pings); + } + // Two items revealed at different times: the contact's bearing, while untracked, is the freshest reveal's. [Fact] public void TheFreshestRevealGivesTheBearing() diff --git a/tests/Aetheria.Shared.Tests/IffAndCombatTests.cs b/tests/Aetheria.Shared.Tests/IffAndCombatTests.cs index c9e184fa8..b2a01e356 100644 --- a/tests/Aetheria.Shared.Tests/IffAndCombatTests.cs +++ b/tests/Aetheria.Shared.Tests/IffAndCombatTests.cs @@ -284,6 +284,23 @@ public sealed class IffAndCombatTests : IDisposable Assert.DoesNotContain(stranger, patrol.VisibleFriendlies); } + // A stance press flips the stance as it stands, the override or else the belief: against a contact believed hostile with + // no override held, it declares the contact neutral. It lands on a target designated in the same step. + [Fact] + public void AStancePressFlipsTheBelief() + { + var (_, zone, patrol, stranger) = Owned(new Faction { Name = "X" }); + Detect(patrol, stranger); + Assert.True(patrol.IsHostileTo(stranger), "fixture: hostile by belief"); + Assert.Null(patrol.IffOverride(stranger)); // fixture: no override, which alone would read neutral + + patrol.Command.Designate(stranger); + patrol.Command.ToggleStance(); + zone.Update(.1f); + Assert.False(patrol.IffOverride(stranger)); + Assert.False(patrol.IsHostileTo(stranger)); + } + [Fact] public void AlliesAreKnownAsTruth() { diff --git a/tests/Aetheria.Shared.Tests/PilotSeamPlayerTests.cs b/tests/Aetheria.Shared.Tests/PilotSeamPlayerTests.cs index 7dfb2d339..c4f8227ca 100644 --- a/tests/Aetheria.Shared.Tests/PilotSeamPlayerTests.cs +++ b/tests/Aetheria.Shared.Tests/PilotSeamPlayerTests.cs @@ -221,27 +221,42 @@ public sealed partial class RunStartTests SeamAimEqual(selectThenCycle, east.ResolvedAim); } - // A stance press flips the stance as it stands when the press is applied, so two in a step cancel, as two flips in - // place did before the seam; none is replayed. + // A stance press flips the stance on the target as it stands when the press is applied, after the step's target verbs, + // so two in a step cancel, as two flips in place did before the seam; none is replayed. [Fact] public void StanceTogglesInAStepCompose() { - var (arena, east, west, _) = PlayerSeamTrio(); + var (arena, east, west, third) = PlayerSeamTrio(); + Assert.True(east.Target.Value.IsNone); Assert.False(east.IsHostileTo(west)); - east.Command.ToggleStance(west); + // No target is applied yet: the verb in the same step picks the nearest (west), and the press flips it. + east.Command.Verb(TargetVerb.Next); + east.Command.ToggleStance(); Assert.False(east.IsHostileTo(west), "nothing lands before the step"); arena.Update(SeamDt); - Assert.True(east.IsHostileTo(west)); + Assert.Same(west, east.Target.Value.Entity); + Assert.True(east.IsHostileTo(west), "the press lands on the target the step's verb picked"); + Assert.False(east.IsHostileTo(third)); east.Command.Iff(west, true); arena.Update(SeamDt); Assert.True(east.IsHostileTo(west), "a stance set is a set, not a flip"); - east.Command.ToggleStance(west); - east.Command.ToggleStance(west); + east.Command.ToggleStance(); + east.Command.ToggleStance(); arena.Update(SeamDt); Assert.True(east.IsHostileTo(west), "two flips in a step cancel"); - east.Command.ToggleStance(west); + // Next then the press in one step: the press flips the new target, not the one applied when it was pressed. + east.Command.Verb(TargetVerb.Next); + east.Command.ToggleStance(); + arena.Update(SeamDt); + Assert.Same(third, east.Target.Value.Entity); + Assert.True(east.IsHostileTo(third), "the press flips the new target"); + Assert.True(east.IsHostileTo(west), "the old target is untouched"); + east.Command.Designate(new TargetRef(west)); + arena.Update(SeamDt); + + east.Command.ToggleStance(); arena.Update(SeamDt); Assert.False(east.IsHostileTo(west)); arena.Update(SeamDt); From 8692cf8600527e8ed9d265ae42566a741f560fb1 Mon Sep 17 00:00:00 2001 From: soul Date: Thu, 22 Oct 2026 08:39:46 +0200 Subject: [PATCH 074/307] Command seam: the stance press resolves its target at the step, after the step's target verbs Co-Authored-By: Claude Opus 5.5 --- Assets/Scripts/Gameplay/ActionGameManager.cs | 11 +++-------- Assets/Scripts/ServerShared/Pilot.cs | 11 ++++++++--- 2 files changed, 11 insertions(+), 11 deletions(-) diff --git a/Assets/Scripts/Gameplay/ActionGameManager.cs b/Assets/Scripts/Gameplay/ActionGameManager.cs index 6fa564d92..7917b0f7d 100644 --- a/Assets/Scripts/Gameplay/ActionGameManager.cs +++ b/Assets/Scripts/Gameplay/ActionGameManager.cs @@ -406,14 +406,9 @@ public class ActionGameManager : MonoBehaviour // Flips the player's own stance on the current target between hostile and neutral. Going // neutral mid-fight safes weapons (Weapon.StanceAllowsFire); an unmarked/undetected target - // reads as non-hostile, so the first press declares hostile. The flip reads the stance when applied, so two - // presses in a step cancel. - Input.Player.ToggleStance.performed += context => - { - var target = CurrentEntity.Target.Value.Entity; - if (target == null) return; - CurrentEntity.Command.ToggleStance(target); - }; + // reads as non-hostile, so the first press declares hostile. The flip reads the target and its stance when applied, + // after the step's target presses, so two presses in a step cancel. + Input.Player.ToggleStance.performed += context => CurrentEntity.Command.ToggleStance(); #region Targeting diff --git a/Assets/Scripts/ServerShared/Pilot.cs b/Assets/Scripts/ServerShared/Pilot.cs index 576191b7d..8b04637d9 100644 --- a/Assets/Scripts/ServerShared/Pilot.cs +++ b/Assets/Scripts/ServerShared/Pilot.cs @@ -69,8 +69,9 @@ public sealed class PilotCommand public void Verb(TargetVerb verb) => TargetVerb = verb; public void CycleAim() => AimCycles++; public void Iff(Entity other, bool? hostile) => Iffs.Add((other, hostile, false)); - // A press that flips the stance on `other` as it stands when applied, in order with Iff, so two in a step cancel. - public void ToggleStance(Entity other) => Iffs.Add((other, null, true)); + // A press that flips the stance on the entity's target as both stand when applied, after the step's target verbs and in + // order with Iff, so two in a step cancel. With no target then, it does nothing. + public void ToggleStance() => Iffs.Add((null, null, true)); // The entity's own switches, pressed: each press flips its switch when applied, so two in a step cancel. public void ToggleOverrideShutdown() => ShutdownOverrideToggles++; public void ToggleHeatsinks() => HeatsinkToggles++; @@ -121,7 +122,11 @@ public static class PilotApplier } foreach (var item in command.Selections) entity.TrySelectTargetItem(item); for (var i = 0; i < command.AimCycles; i++) entity.CycleAim(); - foreach (var (other, hostile, toggle) in command.Iffs) entity.SetIff(other, toggle ? !entity.IsHostileTo(other) : hostile); + foreach (var (other, hostile, toggle) in command.Iffs) + { + if (!toggle) entity.SetIff(other, hostile); + else if (entity.Target.Value.Entity is Entity target) entity.SetIff(target, !entity.IsHostileTo(target)); + } for (var i = 0; i < command.ShutdownOverrideToggles; i++) entity.OverrideShutdown = !entity.OverrideShutdown; for (var i = 0; i < command.HeatsinkToggles; i++) entity.HeatsinksEnabled = !entity.HeatsinksEnabled; if (entity.Shield != null) From 33103ce5daaa81bce1461134168311de6fee76c4 Mon Sep 17 00:00:00 2001 From: soul Date: Thu, 22 Oct 2026 08:44:28 +0200 Subject: [PATCH 075/307] Seam fix tests: a neutral declaration drops the target, so a second press in that step finds none, as before the seam Co-Authored-By: Claude Opus 5.5 --- .../PilotSeamPlayerTests.cs | 32 +++++++++++++------ 1 file changed, 23 insertions(+), 9 deletions(-) diff --git a/tests/Aetheria.Shared.Tests/PilotSeamPlayerTests.cs b/tests/Aetheria.Shared.Tests/PilotSeamPlayerTests.cs index c4f8227ca..7eafcb50b 100644 --- a/tests/Aetheria.Shared.Tests/PilotSeamPlayerTests.cs +++ b/tests/Aetheria.Shared.Tests/PilotSeamPlayerTests.cs @@ -241,11 +241,6 @@ public sealed partial class RunStartTests arena.Update(SeamDt); Assert.True(east.IsHostileTo(west), "a stance set is a set, not a flip"); - east.Command.ToggleStance(); - east.Command.ToggleStance(); - arena.Update(SeamDt); - Assert.True(east.IsHostileTo(west), "two flips in a step cancel"); - // Next then the press in one step: the press flips the new target, not the one applied when it was pressed. east.Command.Verb(TargetVerb.Next); east.Command.ToggleStance(); @@ -253,14 +248,33 @@ public sealed partial class RunStartTests Assert.Same(third, east.Target.Value.Entity); Assert.True(east.IsHostileTo(third), "the press flips the new target"); Assert.True(east.IsHostileTo(west), "the old target is untouched"); - east.Command.Designate(new TargetRef(west)); - arena.Update(SeamDt); + // Declaring the target neutral drops it (it leaves VisibleEnemies), so a second press in that step finds no + // target, as a second press in place did before the seam. + east.Command.ToggleStance(); east.Command.ToggleStance(); arena.Update(SeamDt); - Assert.False(east.IsHostileTo(west)); + Assert.False(east.IsHostileTo(third)); + Assert.True(east.Target.Value.IsNone); + + // From neutral, two flips in a step cancel. + east.Command.Iff(west, false); arena.Update(SeamDt); - Assert.False(east.IsHostileTo(west), "the press is not replayed"); + east.Command.Designate(new TargetRef(west)); + arena.Update(SeamDt); + Assert.Same(west, east.Target.Value.Entity); + east.Command.ToggleStance(); + east.Command.ToggleStance(); + arena.Update(SeamDt); + Assert.False(east.IsHostileTo(west), "two flips in a step cancel"); + Assert.Equal(false, east.IffOverride(west)); + + east.Command.Designate(new TargetRef(west)); + east.Command.ToggleStance(); + arena.Update(SeamDt); + Assert.True(east.IsHostileTo(west)); + arena.Update(SeamDt); + Assert.True(east.IsHostileTo(west), "the press is not replayed"); } // The player's switches are presses on its command too: each flips at the next step, two in a step cancel, and none is From 9b4b46e2430da9ff3580927464f042c73a5f4b66 Mon Sep 17 00:00:00 2001 From: soul Date: Thu, 22 Oct 2026 15:39:02 +0200 Subject: [PATCH 076/307] Zone: a side is a faction or one unaffiliated ship; each unaffiliated ship flies a flight of its own (brain-one-per-side-r2) Co-Authored-By: Claude Sonnet 5.5 --- Assets/Scripts/ServerShared/Zone.cs | 13 ++++++++----- tests/Aetheria.Shared.Tests/FlightTests.cs | 10 +++++++--- 2 files changed, 15 insertions(+), 8 deletions(-) diff --git a/Assets/Scripts/ServerShared/Zone.cs b/Assets/Scripts/ServerShared/Zone.cs index f2276f4e4..fe3136464 100644 --- a/Assets/Scripts/ServerShared/Zone.cs +++ b/Assets/Scripts/ServerShared/Zone.cs @@ -69,10 +69,9 @@ public class Zone private ItemManager _itemManager; private double _time; public List Agents = new List(); - // One flight per faction, created on its first piloted join; unaffiliated ships share Unaffiliated. A flight owns - // what its members target and do (Agents/Flight.cs). - private static readonly Faction Unaffiliated = new Faction(); - private readonly Dictionary _flights = new Dictionary(); + // One flight per side, created on its first piloted join. A side is a faction, or one unaffiliated ship + // (brain-one-per-side-r2). A flight owns what its members target and do (Agents/Flight.cs). + private readonly Dictionary _flights = new Dictionary(); public IEnumerable Flights => _flights.Values; // Cut 2 (docs/mining-cut.md): chunk wear, keyed by chunk. Zone.Wear is the only writer; ChunkExists and @@ -177,6 +176,8 @@ public class Zone if (agent == null) return; Agents.Remove(agent); foreach (var flight in _flights.Values) flight.Leave(agent); + // A side of one is gone with its ship. + if (remove.Value.Faction == null) _flights.Remove(remove.Value); }); Targets.Add(new EntityTargets()); @@ -310,9 +311,11 @@ public class Zone return true; } + private static object SideOf(Ship ship) => (object) ship.Faction ?? ship; + private Agent CreateAgent(Ship ship, AgentTask stated) { - var key = ship.Faction ?? Unaffiliated; + var key = SideOf(ship); if (!_flights.TryGetValue(key, out var flight)) _flights[key] = flight = new Flight(ship.Faction?.Doctrine, _itemManager.GameplaySettings); var id = NextAgentId(); diff --git a/tests/Aetheria.Shared.Tests/FlightTests.cs b/tests/Aetheria.Shared.Tests/FlightTests.cs index 7bab33174..9f397ebdf 100644 --- a/tests/Aetheria.Shared.Tests/FlightTests.cs +++ b/tests/Aetheria.Shared.Tests/FlightTests.cs @@ -359,14 +359,18 @@ public sealed class FlightTests : IDisposable } [Fact] - public void EachFactionFliesAFlightOfItsOwnAndUnaffiliatedShipsShareOne() + public void EachFactionFliesAFlightOfItsOwnAndEachUnaffiliatedShipFliesAlone() { Pilot(new Faction { Name = "One" }); Pilot(new Faction { Name = "Two" }); Pilot(null); - Pilot(null); + var alone = Pilot(null); - Assert.Equal(new[] { 1, 1, 2 }, _zone.Flights.Select(f => f.Count).OrderBy(c => c)); + Assert.Equal(new[] { 1, 1, 1, 1 }, _zone.Flights.Select(f => f.Count).OrderBy(c => c)); + + _zone.Remove(alone); + + Assert.Equal(3, _zone.Flights.Count()); } [Fact] From 6252c29efae1eef458a09ad0774d40632ce14852 Mon Sep 17 00:00:00 2001 From: soul Date: Thu, 22 Oct 2026 15:42:12 +0200 Subject: [PATCH 077/307] Determinism ledger: repin TwoAiDigestIsPinned for the side split (the fighters were one side and followed themselves) Co-Authored-By: Claude Sonnet 5.5 --- tests/Aetheria.Shared.Tests/DeterminismLedgerTests.cs | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/tests/Aetheria.Shared.Tests/DeterminismLedgerTests.cs b/tests/Aetheria.Shared.Tests/DeterminismLedgerTests.cs index 1a1a64b1e..e3ab326f0 100644 --- a/tests/Aetheria.Shared.Tests/DeterminismLedgerTests.cs +++ b/tests/Aetheria.Shared.Tests/DeterminismLedgerTests.cs @@ -24,7 +24,8 @@ public sealed partial class RunStartTests // The two-AI fight's digest, captured on Yggdrasil. Replays are trusted only on the host class that recorded them // (ruling replays-per-host-class), and arenas run on Linux. private const string TwoAiPinnedHostClass = "linux-x64"; - private const string TwoAiPinnedDigest = "8ba2116ed0ecd574703846f493c408936d536e3b7c9e4652556103ec1b75098c"; + // Repinned once, for the side split: the two fighters are unaffiliated, were one side and each followed itself. + private const string TwoAiPinnedDigest = "1b2ffe51f799e762199717a3bc003e880a80106023463aabc40bd2e4f746697c"; private const int TwoAiSteps = 3600; [Fact] From 2ad52e7e9eae08adf1fffd12e78623a395a1e3f7 Mon Sep 17 00:00:00 2001 From: soul Date: Thu, 22 Oct 2026 15:45:02 +0200 Subject: [PATCH 078/307] PilotView: brains reach other ships only through their side's view, by opaque handles One view per side over its members' sensors and books. Flight, Combat, Follow and Minion read contacts as ContactId and fire-control readouts as view calls; FollowTask.Anchor is a handle. Own-ship reads stay direct. Co-Authored-By: Claude Sonnet 5.5 --- Assets/Scripts/ServerShared/Agents/Agent.cs | 4 + Assets/Scripts/ServerShared/Agents/Flight.cs | 99 +++++----- Assets/Scripts/ServerShared/Agents/Minion.cs | 4 +- .../Scripts/ServerShared/Agents/PilotView.cs | 173 ++++++++++++++++++ .../ServerShared/Agents/PilotView.cs.meta | 11 ++ .../ServerShared/Agents/States/Combat.cs | 40 ++-- .../ServerShared/Agents/States/Follow.cs | 6 +- .../ServerShared/Agents/Tasks/FollowTask.cs | 2 +- Assets/Scripts/ServerShared/ScenarioStage.cs | 2 +- .../ConsumableAiTests.cs | 3 +- tests/Aetheria.Shared.Tests/FlightTests.cs | 36 ++-- 11 files changed, 293 insertions(+), 87 deletions(-) create mode 100644 Assets/Scripts/ServerShared/Agents/PilotView.cs create mode 100644 Assets/Scripts/ServerShared/Agents/PilotView.cs.meta diff --git a/Assets/Scripts/ServerShared/Agents/Agent.cs b/Assets/Scripts/ServerShared/Agents/Agent.cs index eb74818ba..586438c97 100644 --- a/Assets/Scripts/ServerShared/Agents/Agent.cs +++ b/Assets/Scripts/ServerShared/Agents/Agent.cs @@ -23,6 +23,9 @@ public class Agent private VelocityLimit _velocityLimit; public AgentTask Task { get; set; } + // What this pilot's side knows of other ships. A ship outside a flight is a side of one: a view over itself, until + // Flight.Join sets the flight's. + public PilotView View { get; set; } // Zone-scoped, from 1: the ordinal of this pilot's own dice, set by Zone.CreateAgent as it admits the pilot (0 for // an agent built outside a zone's admission). public uint Id { get; set; } @@ -36,6 +39,7 @@ public class Agent public Agent(Ship ship, RoleDoctrine doctrine) { Ship = ship; + View = new PilotView(ship); Doctrine = doctrine; ItemManager = Ship.ItemManager; Settings = Ship.ItemManager.GameplaySettings; diff --git a/Assets/Scripts/ServerShared/Agents/Flight.cs b/Assets/Scripts/ServerShared/Agents/Flight.cs index 497ac865f..098598e92 100644 --- a/Assets/Scripts/ServerShared/Agents/Flight.cs +++ b/Assets/Scripts/ServerShared/Agents/Flight.cs @@ -3,15 +3,17 @@ using System.Collections.Generic; using System.Linq; using CultMath; using static CultMath.math; +using ContactId = PilotView.ContactId; -// One flight per (zone, faction) of piloted ships; unaffiliated ships form one flight of their own. The flight alone +// One flight per (zone, side) of piloted ships; a side is a faction, or one unaffiliated ship. The flight alone // decides what its members do: which hostile each engages (Ship.Target) and what each does otherwise (Agent.Task). // The agent graph executes those two and decides nothing. The faction's doctrine (FactionDoctrine) holds the numbers. // -// The picture is the union of the members' VisibleEnemies. A track passes through phases the flight owns, runtime only: -// None, Hailing (grace running, one hail spoken), Complied (slow enough at the grace's end: followed, not engaged) and -// Engaged. A member still fires only at what its own VisibleEntities hold (FireControl), so the shared picture never -// lets it fire at what it cannot see. +// The flight reads other ships only through its side's PilotView (ruling observation-sensors-only): it holds a contact +// as a handle and never as the ship. The picture is the view's: the union of what the members' sensors hold hostile, never a member. +// A track passes through phases the flight owns, runtime only: None, Hailing (grace running, one hail spoken), Complied +// (slow enough at the grace's end: followed, not engaged) and Engaged. A member still fires only at what its own +// sensors hold (fire control), so the shared picture never lets it fire at what it cannot see. public class Flight { public enum Phase { None, Hailing, Complied, Engaged } @@ -31,7 +33,7 @@ public class Flight private readonly FactionDoctrine _doctrine; private readonly GameplaySettings _settings; private readonly List _members = new List(); - private readonly Dictionary _tracks = new Dictionary(); + private readonly Dictionary _tracks = new Dictionary(); private double _time; // No doctrine fights as FactionDoctrine.Default; a doctrine that leaves a role unauthored gets that role's default. @@ -44,58 +46,64 @@ public class Flight } public RoleDoctrine Combatant { get; } + // What this side knows of other ships; every member's Agent.View. + public PilotView View { get; } = new PilotView(); public int Count => _members.Count; public bool Contains(Agent agent) => _members.Any(m => m.Agent == agent); - public Phase PhaseOf(Entity track) => _tracks.TryGetValue(track, out var t) ? t.Phase : Phase.None; + public Phase PhaseOf(ContactId track) => _tracks.TryGetValue(track, out var t) ? t.Phase : Phase.None; // The task a member holds when the flight has nothing to follow: its patrol circuit, or the task a scenario stated. public void Join(Agent agent, AgentTask baseline) { _members.Add(new Member { Agent = agent, Baseline = baseline }); + View.Add(agent.Ship); + agent.View = View; agent.Task = baseline; } - public void Leave(Agent agent) => _members.RemoveAll(m => m.Agent == agent); + public void Leave(Agent agent) + { + if (_members.RemoveAll(m => m.Agent == agent) > 0) View.Remove(agent.Ship); + } public void Update(float dt) { _time += dt; - var picture = _members.SelectMany(m => m.Agent.Ship.VisibleEnemies).Distinct().ToList(); + var picture = View.Picture(); foreach (var gone in _tracks.Keys.Where(t => !picture.Contains(t)).ToList()) _tracks.Remove(gone); - foreach (var entity in picture) + foreach (var contact in picture) { - if (!_tracks.TryGetValue(entity, out var track)) _tracks[entity] = track = new Track(); - Advance(entity, track); + if (!_tracks.TryGetValue(contact, out var track)) _tracks[contact] = track = new Track(); + Advance(contact, track); } foreach (var member in _members) Order(member, picture); } // A grudge is a hostile IFF override that any member holds on the track. - private bool Grudged(Entity entity) => _members.Any(m => m.Agent.Ship.IffOverride(entity) == true); + private bool Grudged(ContactId contact) => _members.Any(m => View.IffOverride(m.Agent.Ship, contact) == true); - private bool Engageable(Entity entity, bool grudge) + private bool Engageable(ContactId contact, bool grudge) { switch (_doctrine.EngageOn) { case EngageOn.Never: return false; case EngageOn.Detection: return true; case EngageOn.Identified: - return grudge || _members.Any(m => m.Agent.Ship.EntityInfoGathered.TryGetValue(entity, out var info) && - info >= _settings.TargetGearInfoThreshold); + return grudge || _members.Any(m => View.Info(m.Agent.Ship, contact) >= _settings.TargetGearInfoThreshold); case EngageOn.Provoked: return grudge; - case EngageOn.Trespass: return grudge || _members.Any(m => m.Agent.Ship.BelievesTrespassing(entity)); + case EngageOn.Trespass: return grudge || _members.Any(m => View.BelievesTrespassing(m.Agent.Ship, contact)); default: return false; } } - private void Advance(Entity entity, Track track) + private void Advance(ContactId contact, Track track) { if (track.Phase == Phase.Engaged) return; - var grudge = Grudged(entity); - if (!Engageable(entity, grudge)) + var grudge = Grudged(contact); + if (!Engageable(contact, grudge)) { track.Phase = Phase.None; return; @@ -115,56 +123,59 @@ public class Flight { track.Phase = Phase.Hailing; track.Start = _time; - if (!string.IsNullOrEmpty(_doctrine.Hail)) Nearest(entity)?.Agent.Ship.Command.Hail(_doctrine.Hail); + if (!string.IsNullOrEmpty(_doctrine.Hail)) Nearest(contact)?.Agent.Ship.Command.Hail(_doctrine.Hail); } else track.Phase = Phase.Engaged; break; case Phase.Hailing: if (_time >= track.Start + _doctrine.Grace) - track.Phase = _doctrine.ComplySpeed > 0 && length(entity.Velocity) < _doctrine.ComplySpeed + track.Phase = _doctrine.ComplySpeed > 0 && length(View.SideTrack(contact).Value.Velocity) < _doctrine.ComplySpeed ? Phase.Complied : Phase.Engaged; break; case Phase.Complied: - if (length(entity.Velocity) >= _doctrine.ComplySpeed) track.Phase = Phase.Engaged; + if (length(View.SideTrack(contact).Value.Velocity) >= _doctrine.ComplySpeed) track.Phase = Phase.Engaged; break; } } - private Member Nearest(Entity entity) => - _members.OrderBy(m => lengthsq(m.Agent.Ship.Position.xz - entity.Position.xz)).FirstOrDefault(); + private Member Nearest(ContactId contact) + { + var at = View.SideTrack(contact).Value.Position.xz; + return _members.OrderBy(m => lengthsq(m.Agent.Ship.Position.xz - at)).FirstOrDefault(); + } - private void Order(Member member, List picture) + private void Order(Member member, List picture) { var ship = member.Agent.Ship; - var seen = ship.VisibleEnemies; // Engage: keep the current target while it stays engaged, else the nearest engaged track this member can see. - var engaged = picture.Where(e => _tracks[e].Phase == Phase.Engaged && seen.Contains(e)).ToList(); + var engaged = picture.Where(c => _tracks[c].Phase == Phase.Engaged && View.Sees(ship, c)).ToList(); if (engaged.Count > 0) { - var current = ship.Target.Value.Entity; - var pick = current != null && engaged.Contains(current) - ? current - : engaged.OrderBy(e => lengthsq(ship.Position.xz - e.Position.xz)).First(); - Aim(ship, pick); + var current = View.AppliedTargetOf(ship); + var pick = current != null && engaged.Contains(current.Value) + ? current.Value + : engaged.OrderBy(c => lengthsq(ship.Position.xz - View.SideTrack(c).Value.Position.xz)).First(); + View.Designate(ship, pick); return; } // No engaged track in this member's own sensors: it steers on the flight's picture, a bearing and never targeting // data (ruling shared-track-bearing-only). That is the hailed and the complied, and an engaged track only a flightmate sees. - Aim(ship, null); + View.Designate(ship, null); // Overrides are per member: a flightmate's hostile track is not one this member follows after it has made it friendly. - var held = picture.Where(e => _tracks[e].Phase != Phase.None && ship.IffOverride(e) != false).ToList(); + var held = picture.Where(c => _tracks[c].Phase != Phase.None && View.IffOverride(ship, c) != false).ToList(); if (held.Count > 0) { // An engaged track outranks a hailed one; the held anchor is kept only while nothing outranks it. - var anyEngaged = held.Any(e => _tracks[e].Phase == Phase.Engaged); - var anchor = member.Agent.Task is FollowTask follow && held.Contains(follow.Anchor) - && (!anyEngaged || _tracks[follow.Anchor].Phase == Phase.Engaged) - ? follow.Anchor - : held.OrderBy(e => _tracks[e].Phase == Phase.Engaged ? 0 : 1) - .ThenBy(e => lengthsq(ship.Position.xz - e.Position.xz)).First(); + var anyEngaged = held.Any(c => _tracks[c].Phase == Phase.Engaged); + var heldAnchor = (member.Agent.Task as FollowTask)?.Anchor; + var anchor = heldAnchor != null && held.Contains(heldAnchor.Value) + && (!anyEngaged || _tracks[heldAnchor.Value].Phase == Phase.Engaged) + ? heldAnchor.Value + : held.OrderBy(c => _tracks[c].Phase == Phase.Engaged ? 0 : 1) + .ThenBy(c => lengthsq(ship.Position.xz - View.SideTrack(c).Value.Position.xz)).First(); if (!(member.Agent.Task is FollowTask current) || current.Anchor != anchor) member.Agent.Task = new FollowTask { Anchor = anchor, Standoff = Standoff(ship) }; } @@ -172,12 +183,6 @@ public class Flight member.Agent.Task = member.Baseline; } - private static void Aim(Ship ship, Entity target) - { - var wanted = target == null ? TargetRef.None : new TargetRef(target); - if (!ship.Target.Value.Equals(wanted)) ship.Command.Designate(wanted); - } - private float Standoff(Ship ship) { if (Combatant.HoldStandoff > 0) return Combatant.HoldStandoff; diff --git a/Assets/Scripts/ServerShared/Agents/Minion.cs b/Assets/Scripts/ServerShared/Agents/Minion.cs index a1277b264..c95b7cfbb 100644 --- a/Assets/Scripts/ServerShared/Agents/Minion.cs +++ b/Assets/Scripts/ServerShared/Agents/Minion.cs @@ -22,9 +22,9 @@ public class Minion : Agent var combatState = new CombatState(this); _rootState.AddTransition(combatState, - () => (Ship.Command.Designated ?? Ship.Target.Value).Entity != null, null, true, _rootState); + () => View.TargetOf(Ship) != null, null, true, _rootState); combatState.AddTransition(_rootState, - () => (Ship.Command.Designated ?? Ship.Target.Value).Entity == null, null, true, _rootState); + () => View.TargetOf(Ship) == null, null, true, _rootState); } } diff --git a/Assets/Scripts/ServerShared/Agents/PilotView.cs b/Assets/Scripts/ServerShared/Agents/PilotView.cs new file mode 100644 index 000000000..8c9a71e65 --- /dev/null +++ b/Assets/Scripts/ServerShared/Agents/PilotView.cs @@ -0,0 +1,173 @@ +using System; +using System.Collections.Generic; +using System.Linq; +using System.Runtime.CompilerServices; +using CultMath; + +// What one side's brain may know of other ships. A side is a faction, or one unaffiliated ship (brain-one-per-side-r2); +// its view reads only that side's members: their sensors (VisibleEntities, VisibleEnemies, EntityInfoGathered), their +// IFF overrides and reveal books (ruling observation-sensors-only). A brain never holds another ship: it holds a +// ContactId or a RevealId, which only the view that issued it can resolve, so there is no path from a handle to the +// ship's truth that does not pass the sensors. +// +// A contact's Track (position, velocity, facing) exists only while a member tracks it (its VisibleEntities holds it): +// within a zone step nothing moves during the agent phase, so the track equals what the Entity would answer. The +// fire-control readouts call FireControl's own functions with the resolved subject, so the HUD, the player and the +// brains price one model (A-one-sim). Own-ship reads stay the brain's own. +public sealed class PilotView +{ + private readonly List _members = new List(); + private static Func _openContact; + private static Func _openReveal; + + public PilotView(params Ship[] members) => _members.AddRange(members); + + internal void Add(Ship member) => _members.Add(member); + internal void Remove(Ship member) => _members.Remove(member); + + // A contact of the view, equal to itself across absences from a member's picture: equality is by reference. + public readonly struct ContactId : IEquatable + { + private readonly Entity _subject; + + static ContactId() => _openContact = id => id._subject; + internal ContactId(Entity subject) => _subject = subject; + + public bool Equals(ContactId other) => ReferenceEquals(_subject, other._subject); + public override bool Equals(object obj) => obj is ContactId other && Equals(other); + public override int GetHashCode() => RuntimeHelpers.GetHashCode(_subject); + public static bool operator ==(ContactId a, ContactId b) => a.Equals(b); + public static bool operator !=(ContactId a, ContactId b) => !a.Equals(b); + } + + // A revealed item of a contact, to aim at; the live item stays behind the handle (ruling contact-reads-book-aim-reads-live). + public readonly struct RevealId : IEquatable + { + private readonly EquippedItem _item; + + static RevealId() => _openReveal = id => id._item; + internal RevealId(EquippedItem item) => _item = item; + + public bool Equals(RevealId other) => ReferenceEquals(_item, other._item); + public override bool Equals(object obj) => obj is RevealId other && Equals(other); + public override int GetHashCode() => RuntimeHelpers.GetHashCode(_item); + public static bool operator ==(RevealId a, RevealId b) => a.Equals(b); + public static bool operator !=(RevealId a, RevealId b) => !a.Equals(b); + } + + public readonly struct Track + { + public readonly float3 Position; + public readonly float2 Velocity; + public readonly float2 Direction; + + public Track(float3 position, float2 velocity, float2 direction) + { + Position = position; + Velocity = velocity; + Direction = direction; + } + } + + // For fixtures: the handle of a subject, as the view would issue it. + internal static ContactId Handle(Entity subject) => new ContactId(subject); + + private static Entity Subject(ContactId id) + { + if (_openContact == null) RuntimeHelpers.RunClassConstructor(typeof(ContactId).TypeHandle); + return _openContact(id); + } + + private static EquippedItem Item(RevealId id) + { + if (_openReveal == null) RuntimeHelpers.RunClassConstructor(typeof(RevealId).TypeHandle); + return _openReveal(id); + } + + // The side's contacts: its members' VisibleEnemies in member order, each once, never a member of the side. + public List Picture() + { + var picture = new List(); + var seen = new HashSet(); + foreach (var member in _members) + foreach (var enemy in member.VisibleEnemies) + if (!_members.Any(m => ReferenceEquals(m, enemy)) && seen.Add(enemy)) picture.Add(new ContactId(enemy)); + return picture; + } + + public bool Sees(Ship member, ContactId contact) => member.VisibleEnemies.Contains(Subject(contact)); + + // The member's own track of the contact, or null while it does not track it. + public Track? TrackOf(Ship member, ContactId contact) + { + var subject = Subject(contact); + if (subject == null || !member.VisibleEntities.Contains(subject)) return null; + return new Track(subject.Position, subject.Velocity, subject.Direction); + } + + // The track of the first member, in order, that tracks the contact (question side-shared-kinematics). + public Track? SideTrack(ContactId contact) + { + foreach (var member in _members) + { + var track = TrackOf(member, contact); + if (track != null) return track; + } + + return null; + } + + public bool? IffOverride(Ship member, ContactId contact) => member.IffOverride(Subject(contact)); + + public float? Info(Ship member, ContactId contact) => + member.EntityInfoGathered.TryGetValue(Subject(contact), out var info) ? info : (float?) null; + + public bool BelievesTrespassing(Ship member, ContactId contact) => member.BelievesTrespassing(Subject(contact)); + + // The member's pending designation when set, else last step's applied target. + public ContactId? TargetOf(Ship member) + { + var subject = (member.Command.Designated ?? member.Target.Value).Entity; + return subject == null ? (ContactId?) null : new ContactId(subject); + } + + public ContactId? AppliedTargetOf(Ship member) + { + var subject = member.Target.Value.Entity; + return subject == null ? (ContactId?) null : new ContactId(subject); + } + + // The weapons the member believes the contact has, in the contact's equipment order; Aimable is FireControl.IsRevealed. + public List<(RevealId Id, EquippableItemData Claimed, bool Aimable)> WeaponReveals(Ship member, ContactId contact) => + member.RevealedWeapons(Subject(contact)) + .Select(r => (new RevealId(r.Item), r.Claimed, FireControl.IsRevealed(member, r.Item))).ToList(); + + public bool Designated(Ship member, Weapon weapon, ContactId contact) => + FireControl.Designated(weapon, member, Subject(contact), out _); + + public bool Refuses(Ship member, Weapon weapon, ContactId contact) => + FireControl.Refuses(weapon, member, Subject(contact)); + + public bool Fires(Ship member, Weapon weapon, ContactId contact, float minHitProbability, RevealId? aim) => + FireControl.AgentFires(weapon, member, Subject(contact), minHitProbability, + aim == null ? AimTarget.None : AimTarget.OfItem(Item(aim.Value))); + + // Where to point to hit the contact with the weapon: the shared prediction, at the height the contact rides. + public float3 Intercept(Ship member, Weapon weapon, ContactId contact) + { + var subject = Subject(contact); + var predicted = FireControl.PredictedIntercept(weapon, member, subject); + predicted.y = subject.RideHeight(predicted.xz); + return predicted; + } + + // Designate the contact, or nothing for null, when that is not already the member's applied target. + public void Designate(Ship member, ContactId? contact) + { + var wanted = contact == null ? TargetRef.None : new TargetRef(Subject(contact.Value)); + if (!member.Target.Value.Equals(wanted)) member.Command.Designate(wanted); + } + + public void SelectItem(Ship member, RevealId? reveal) => + member.Command.SelectItem(reveal == null ? null : Item(reveal.Value)); +} diff --git a/Assets/Scripts/ServerShared/Agents/PilotView.cs.meta b/Assets/Scripts/ServerShared/Agents/PilotView.cs.meta new file mode 100644 index 000000000..8c9b644f3 --- /dev/null +++ b/Assets/Scripts/ServerShared/Agents/PilotView.cs.meta @@ -0,0 +1,11 @@ +fileFormatVersion: 2 +guid: 5b0f6c2e9d3a4f71a8c4e1d7b2a63f90 +MonoImporter: + externalObjects: {} + serializedVersion: 2 + defaultReferences: [] + executionOrder: 0 + icon: {instanceID: 0} + userData: + assetBundleName: + assetBundleVariant: diff --git a/Assets/Scripts/ServerShared/Agents/States/Combat.cs b/Assets/Scripts/ServerShared/Agents/States/Combat.cs index 1a062e7be..a424437b3 100644 --- a/Assets/Scripts/ServerShared/Agents/States/Combat.cs +++ b/Assets/Scripts/ServerShared/Agents/States/Combat.cs @@ -43,36 +43,39 @@ public class CombatState : BaseState _optimumStale = false; } - // The pilot's own pending designation (its Flight's, this step) when set, else last step's applied target. - var target = (_agent.Ship.Command.Designated ?? _agent.Ship.Target.Value).Entity; + // The pilot's own pending designation (its Flight's, this step) when set, else last step's applied target; and + // only a contact a member tracks has a track to fly on, so an untracked target is no target here. + var view = _agent.View; + var target = view.TargetOf(_agent.Ship); if (target == null) return; + var track = view.TrackOf(_agent.Ship, target.Value); + if (track == null) return; _availableGroups.Clear(); _availableLockingWeapons.Clear(); - var toTarget = target.Position - _agent.Ship.Position; + var toTarget = track.Value.Position - _agent.Ship.Position; var targetDistance = length(toTarget.xz); // Cut 2 (docs/fire-control-cut.md): aim at whichever revealed weapon on the target hits hardest at // this range, or clear the aim point when nothing qualifies -- the same predicate - // (FireControl.IsRevealed) and the same writer (TrySelectTargetItem) the player path uses. Which weapons it + // (the view's Aimable) and the same writer (TrySelectTargetItem) the player path uses. Which weapons it // believes the target has, and how hard each hits, come from its book's claims, not the target's live gear // (aetheria-ew, classify-from-belief; ew-claim-weapon-measure-ruled: the claimed design's rated figures); the // reveal's Item is the aim and stays the truth. - EquippedItem bestTargetItem = null; + PilotView.RevealId? bestTargetItem = null; var bestTargetDps = float.MinValue; - foreach (var reveal in _agent.Ship.RevealedWeapons(target)) + foreach (var reveal in view.WeaponReveals(_agent.Ship, target.Value)) { - if (!FireControl.IsRevealed(_agent.Ship, reveal.Item)) continue; + if (!reveal.Aimable) continue; var dps = reveal.Claimed.Behaviors.OfType().First().RatedRangeDamagePerSecond(targetDistance); if (dps > bestTargetDps) { bestTargetDps = dps; - bestTargetItem = reveal.Item; + bestTargetItem = reveal.Id; } } - _agent.Ship.Command.SelectItem(bestTargetItem); - var aim = bestTargetItem == null ? AimTarget.None : AimTarget.OfItem(bestTargetItem); + view.SelectItem(_agent.Ship, bestTargetItem); for (var i = 0; i < _agent.Ship.WeaponGroups.Length; i++) { @@ -82,8 +85,8 @@ public class CombatState : BaseState // The range test is FireControl's (Designated), and a weapon Solve refuses adds nothing to a group's // damage. if (weapon.Item.Online.Value && - FireControl.Designated(weapon, _agent.Ship, target, out _) && - !FireControl.Refuses(weapon, _agent.Ship, target) && + view.Designated(_agent.Ship, weapon, target.Value) && + !view.Refuses(_agent.Ship, weapon, target.Value) && (weapon is ConstantWeapon || weapon is InstantWeapon instantWeapon && instantWeapon.CanFire)) { @@ -111,11 +114,11 @@ public class CombatState : BaseState } } - var targetRight = target.Direction.Rotate(ItemRotation.Clockwise); + var targetRight = track.Value.Direction.Rotate(ItemRotation.Clockwise); var optimumRangeDelta = abs(_optimumRange - targetDistance); var directionToTarget = normalize(toTarget.xz); var targetPortAlignment = dot(targetRight, directionToTarget); - var targetForeAlignment = dot(-target.Direction, directionToTarget); + var targetForeAlignment = dot(-track.Value.Direction, directionToTarget); var forwardness = saturate(optimumRangeDelta / _agent.Settings.AgentMaxForwardDistance) * _agent.Settings.AgentForwardLerp; if (targetForeAlignment > 0) forwardness = lerp(forwardness, 1, pow(targetPortAlignment, 2)); @@ -128,20 +131,19 @@ public class CombatState : BaseState var testWeapon = _agent.Ship.WeaponGroups[selectedGroup].weapons.First(); if(testWeapon.Velocity > 1) { - // Cut 3 (docs/fire-control-cut.md): the one prediction function, shared with FireControl.Fire's + // Cut 3 (docs/fire-control-cut.md): the one prediction function, shared with the shot's // own snapshot and the roll's deviation measurement -- two separate predictions here and in the // roll would let an AI aim at one point and get judged for missing a different one. - var predictedPosition = FireControl.PredictedIntercept(testWeapon, _agent.Ship, target); - predictedPosition.y = target.RideHeight(predictedPosition.xz); + var predictedPosition = view.Intercept(_agent.Ship, testWeapon, target.Value); toTarget = normalize(predictedPosition - _agent.Ship.Position); } // Each weapon decides for itself (operator ruling 2026-09-30): a group shares a trigger, not a - // verdict. FireControl.AgentFires is the AI's "worth it" heuristic for a weapon that hits what it + // verdict. The view's Fires is the AI's "worth it" heuristic for a weapon that hits what it // fires at, and "the target is designated" for a fused one. foreach (var weapon in _agent.Ship.WeaponGroups[selectedGroup].weapons) { - if (FireControl.AgentFires(weapon, _agent.Ship, target, _agent.Doctrine.MinHitProbability, aim)) + if (view.Fires(_agent.Ship, weapon, target.Value, _agent.Doctrine.MinHitProbability, bestTargetItem)) _agent.Ship.Command.Pull(weapon); // A lock pulled above this step counts as firing, as it did when it was activated in place. else if (weapon.Firing || _agent.Ship.Command.Pulls(weapon)) diff --git a/Assets/Scripts/ServerShared/Agents/States/Follow.cs b/Assets/Scripts/ServerShared/Agents/States/Follow.cs index 1bbf3f826..e1701bc6c 100644 --- a/Assets/Scripts/ServerShared/Agents/States/Follow.cs +++ b/Assets/Scripts/ServerShared/Agents/States/Follow.cs @@ -11,14 +11,16 @@ public class FollowState : BaseState { var task = _agent.Task as FollowTask; if (task?.Anchor == null) return; + var anchor = _agent.View.SideTrack(task.Anchor.Value); + if (anchor == null) return; - var toAnchor = task.Anchor.Position.xz - _agent.Ship.Position.xz; + var toAnchor = anchor.Value.Position.xz - _agent.Ship.Position.xz; var distance = length(toAnchor); var direction = distance > 0 ? toAnchor / distance : _agent.Ship.Direction; // Positive error: too far, close. Negative: too near, open. var error = distance - task.Standoff; var pull = direction * (float) sign(error) * saturate(abs(error) / max(task.Standoff, 1f)); - var desired = task.Anchor.Velocity + pull * _agent.TopSpeed; + var desired = anchor.Value.Velocity + pull * _agent.TopSpeed; _agent.Ship.Command.Aim = float3(direction.x, 0, direction.y); _agent.Accelerate(desired); diff --git a/Assets/Scripts/ServerShared/Agents/Tasks/FollowTask.cs b/Assets/Scripts/ServerShared/Agents/Tasks/FollowTask.cs index 4e09d0a35..946539c6f 100644 --- a/Assets/Scripts/ServerShared/Agents/Tasks/FollowTask.cs +++ b/Assets/Scripts/ServerShared/Agents/Tasks/FollowTask.cs @@ -5,7 +5,7 @@ using MessagePack; // Runtime only, like every agent task. public class FollowTask : AgentTask { - [IgnoreMember] public Entity Anchor; + [IgnoreMember] public PilotView.ContactId? Anchor; [IgnoreMember] public float Standoff; [IgnoreMember] public override TaskType Type => TaskType.Follow; } diff --git a/Assets/Scripts/ServerShared/ScenarioStage.cs b/Assets/Scripts/ServerShared/ScenarioStage.cs index c9531e60f..f0df352e9 100644 --- a/Assets/Scripts/ServerShared/ScenarioStage.cs +++ b/Assets/Scripts/ServerShared/ScenarioStage.cs @@ -145,7 +145,7 @@ public sealed class ScenarioStage var placement = _placed.FirstOrDefault(p => ReferenceEquals(p.Entity, entity)); if (placement == null || !placement.Piloted) { _failures.Add($"approach: {entity?.Name ?? "no entity"} is not a placed, piloted ship"); return; } if (target == null) { _failures.Add("approach: no target to close on"); return; } - placement.Task = new FollowTask { Anchor = target, Standoff = 0f }; + placement.Task = new FollowTask { Anchor = PilotView.Handle(target), Standoff = 0f }; } // Items in the entity's first cargo bay, one per name. A name is an equippable or consumable design (open: the stage diff --git a/tests/Aetheria.Shared.Tests/ConsumableAiTests.cs b/tests/Aetheria.Shared.Tests/ConsumableAiTests.cs index ea1ee917e..f0a570b1f 100644 --- a/tests/Aetheria.Shared.Tests/ConsumableAiTests.cs +++ b/tests/Aetheria.Shared.Tests/ConsumableAiTests.cs @@ -586,7 +586,8 @@ public sealed class ConsumableAiTests : IDisposable s.Zone.Update(.05f); s.Zone.Update(.05f); Assert.True(s.Agent.Target.Value.IsNone); - agent.Task = new FollowTask { Anchor = s.Prey, Standoff = 100 }; + if (!s.Agent.VisibleEntities.Contains(s.Prey)) s.Agent.VisibleEntities.Add(s.Prey); + agent.Task = new FollowTask { Anchor = PilotView.Handle(s.Prey), Standoff = 100 }; for (var i = 0; i < 6 && !(s.State is FollowState); i++) { agent.Update(.05f); diff --git a/tests/Aetheria.Shared.Tests/FlightTests.cs b/tests/Aetheria.Shared.Tests/FlightTests.cs index 9f397ebdf..1cb2f9c48 100644 --- a/tests/Aetheria.Shared.Tests/FlightTests.cs +++ b/tests/Aetheria.Shared.Tests/FlightTests.cs @@ -48,9 +48,17 @@ public sealed class FlightTests : IDisposable return track; } + // A follower holds the anchor as a handle and flies on it only while its side tracks it. + private static FollowTask Follows(Ship follower, Entity anchor, float standoff) + { + if (!follower.VisibleEntities.Contains(anchor)) follower.VisibleEntities.Add(anchor); + return new FollowTask { Anchor = PilotView.Handle(anchor), Standoff = standoff }; + } + private static void See(Ship member, Entity track) { member.EntityInfoGathered[track] = 1; + if (!member.VisibleEntities.Contains(track)) member.VisibleEntities.Add(track); member.VisibleEnemies.Add(track); } @@ -254,7 +262,7 @@ public sealed class FlightTests : IDisposable { anchor.Position = ship.Position + anchorAt; var minion = new Minion(ship, FactionDoctrine.Default(_items.GameplaySettings).Combatant) - { Task = new FollowTask { Anchor = anchor, Standoff = 100 } }; + { Task = Follows(ship, anchor, 100) }; minion.Update(.1f); PilotApplier.Apply(minion.Ship); minion.Update(.1f); @@ -279,7 +287,7 @@ public sealed class FlightTests : IDisposable for (var i = 0; i < 4; i++) minion.Update(.1f); Assert.IsType(CurrentState(minion)); - minion.Task = new FollowTask { Anchor = anchor, Standoff = 50 }; + minion.Task = Follows(ship, anchor, 50); minion.Update(.1f); PilotApplier.Apply(minion.Ship); @@ -544,12 +552,12 @@ public sealed class FlightTests : IDisposable See(member, far); Tick(.1f); - Assert.Same(near, ((FollowTask) _zone.Agents.Single().Task).Anchor); + Assert.Equal(PilotView.Handle(near), ((FollowTask) _zone.Agents.Single().Task).Anchor); near.Position = new float3(-900, 0, 0); far.Position = new float3(990, 0, 0); Tick(.1f); - Assert.Same(near, ((FollowTask) _zone.Agents.Single().Task).Anchor); + Assert.Equal(PilotView.Handle(near), ((FollowTask) _zone.Agents.Single().Task).Anchor); } // The held anchor leaves the picture: the member re-anchors on what is still held. Kills: the anchor rewritten only @@ -563,12 +571,12 @@ public sealed class FlightTests : IDisposable See(member, near); See(member, far); Tick(.1f); - Assert.Same(near, ((FollowTask) _zone.Agents.Single().Task).Anchor); + Assert.Equal(PilotView.Handle(near), ((FollowTask) _zone.Agents.Single().Task).Anchor); member.VisibleEnemies.Remove(near); Tick(.1f); - Assert.Same(far, ((FollowTask) _zone.Agents.Single().Task).Anchor); + Assert.Equal(PilotView.Handle(far), ((FollowTask) _zone.Agents.Single().Task).Anchor); } [Fact] @@ -586,7 +594,7 @@ public sealed class FlightTests : IDisposable member.VisibleEnemies.Remove(track); Tick(.1f); Assert.Same(patrol, agent.Task); - Assert.Equal(Flight.Phase.None, _zone.Flights.Single().PhaseOf(track)); + Assert.Equal(Flight.Phase.None, _zone.Flights.Single().PhaseOf(PilotView.Handle(track))); member.Messages.Clear(); See(member, track); @@ -617,7 +625,7 @@ public sealed class FlightTests : IDisposable var ship = Pilot(null, -200, 50); anchor.Position = ship.Position + new float3(anchorX, 0, 0); var minion = new Minion(ship, FactionDoctrine.Default(_items.GameplaySettings).Combatant) - { Task = new FollowTask { Anchor = anchor, Standoff = 100 } }; + { Task = Follows(ship, anchor, 100) }; minion.Update(.1f); PilotApplier.Apply(minion.Ship); minion.Update(.1f); @@ -639,7 +647,7 @@ public sealed class FlightTests : IDisposable var ship = Pilot(null); var anchor = Track(); var minion = new Minion(ship, FactionDoctrine.Default(_items.GameplaySettings).Combatant) - { Task = new FollowTask { Anchor = anchor, Standoff = 50 } }; + { Task = Follows(ship, anchor, 50) }; minion.Update(.1f); PilotApplier.Apply(minion.Ship); minion.Update(.1f); @@ -651,7 +659,7 @@ public sealed class FlightTests : IDisposable PilotApplier.Apply(minion.Ship); Assert.IsType(CurrentState(minion)); - minion.Task = new FollowTask { Anchor = anchor, Standoff = 50 }; + minion.Task = Follows(ship, anchor, 50); minion.Update(.1f); PilotApplier.Apply(minion.Ship); minion.Update(.1f); @@ -700,7 +708,7 @@ public sealed class FlightTests : IDisposable Assert.All(sighted, m => Assert.Same(track, m.Target.Value.Entity)); Assert.True(blind.Target.Value.IsNone); var task = Assert.IsType(_zone.Agents.Single(a => a.Ship == blind).Task); - Assert.Same(track, task.Anchor); + Assert.Equal(PilotView.Handle(track), task.Anchor); } // The flight's engaged track outranks a hailed one a blind member does see, though that one is nearer. Both sit @@ -718,14 +726,14 @@ public sealed class FlightTests : IDisposable See(blind, hailing); Tick(.1f); var task = Assert.IsType(_zone.Agents.Single(a => a.Ship == blind).Task); - Assert.Same(hailing, task.Anchor); // nothing outranks it yet: the nearest held + Assert.Equal(PilotView.Handle(hailing), task.Anchor); // nothing outranks it yet: the nearest held sighted.SetIff(engaged, true); Tick(.1f); Assert.Same(engaged, sighted.Target.Value.Entity); task = Assert.IsType(_zone.Agents.Single(a => a.Ship == blind).Task); - Assert.Same(engaged, task.Anchor); + Assert.Equal(PilotView.Handle(engaged), task.Anchor); } // Hail then engage is the production path of every graced doctrine: the member goes from FollowState into combat, @@ -887,7 +895,7 @@ public sealed class FlightTests : IDisposable anchor.Position = ship.Position + new float3(0, 0, distance); anchor.Velocity = anchorVelocity; var minion = new Minion(ship, FactionDoctrine.Default(_items.GameplaySettings).Combatant) - { Task = new FollowTask { Anchor = anchor, Standoff = 100 } }; + { Task = Follows(ship, anchor, 100) }; minion.Update(.1f); PilotApplier.Apply(minion.Ship); minion.Update(.1f); From bd343b2a4e0ead4929389fed748fa5a7c01ca847 Mon Sep 17 00:00:00 2001 From: soul Date: Thu, 22 Oct 2026 15:53:20 +0200 Subject: [PATCH 079/307] Kit frames: mates are lp_at placements that track the Ship Root frame; zone OFFs unchanged Co-Authored-By: Claude Sonnet 5.5 --- .../aetheria_ships/factions/lowpoly.scad | 25 +++++++++++++++++++ .../aetheria_ships/factions/zhestokost.scad | 15 +++++------ 2 files changed, 33 insertions(+), 7 deletions(-) diff --git a/tools/blender/aetheria_ships/factions/lowpoly.scad b/tools/blender/aetheria_ships/factions/lowpoly.scad index 9979483a0..ef3063c6c 100644 --- a/tools/blender/aetheria_ships/factions/lowpoly.scad +++ b/tools/blender/aetheria_ships/factions/lowpoly.scad @@ -12,3 +12,28 @@ module cbox(s, c=0.2, center=true) { module tube(r, l, fn=8) { rotate([-90,0,0]) cylinder(r=r, h=l, $fn=fn); } // a straight strut between two points module strut(a, b, r=0.08, fn=6) { hull() { translate(a) sphere(r, $fn=fn); translate(b) sphere(r, $fn=fn); } } + +// ==== Frames and schematic lines ===================================================================================== +// A kit part that draws schematic lines is placed with lp_at, never with raw translate/rotate/mirror: lp_at moves the +// geometry (multmatrix) and tracks the same frame in $lp_m, so lp_line, called anywhere beneath it, emits its points in +// the Ship Root frame. Placement has this one owner. A line is echoed (part "lines") only when the frame's local +Z +// points up, so fittings on the belly, the walls and the belts, which a top view cannot see, emit nothing. +$lp_m = [[1,0,0,0], [0,1,0,0], [0,0,1,0], [0,0,0,1]]; +function lp_T(t) = [[1,0,0,t[0]], [0,1,0,t[1]], [0,0,1,t[2]], [0,0,0,1]]; +function lp_R(r) = // Rz * Ry * Rx, as rotate() composes + [[cos(r[2]), -sin(r[2]), 0, 0], [sin(r[2]), cos(r[2]), 0, 0], [0,0,1,0], [0,0,0,1]] + * [[cos(r[1]), 0, sin(r[1]), 0], [0,1,0,0], [-sin(r[1]), 0, cos(r[1]), 0], [0,0,0,1]] + * [[1,0,0,0], [0, cos(r[0]), -sin(r[0]), 0], [0, sin(r[0]), cos(r[0]), 0], [0,0,0,1]]; +lp_MX = [[-1,0,0,0], [0,1,0,0], [0,0,1,0], [0,0,0,1]]; // mirror across x = 0 +function lp_ap(m, p) = let(v = m * [p[0], p[1], p[2], 1]) [v[0], v[1], v[2]]; +// Mirror (when mx), then translate by t, then rotate by r (as nested mirror, translate, rotate would). +module lp_at(t=[0,0,0], r=[0,0,0], mx=false) { + M = (mx ? lp_MX : [[1,0,0,0], [0,1,0,0], [0,0,1,0], [0,0,0,1]]) * lp_T(t) * lp_R(r); + $lp_m = $lp_m * M; + multmatrix(M) children(); +} +// One schematic line: layer (outline, step, seam or fitting), points in the local frame, cyclic when it closes. +module lp_line(layer, pts, cyclic=false) { + if (!is_undef(part) && part == "lines" && ($lp_m * [0, 0, 1, 0])[2] > 0.5) + echo(line = [layer, cyclic, [for (p = pts) lp_ap($lp_m, p)]]); +} diff --git a/tools/blender/aetheria_ships/factions/zhestokost.scad b/tools/blender/aetheria_ships/factions/zhestokost.scad index e077351ee..ae38b0186 100644 --- a/tools/blender/aetheria_ships/factions/zhestokost.scad +++ b/tools/blender/aetheria_ships/factions/zhestokost.scad @@ -22,6 +22,7 @@ // zh_on_stern, zh_on_nose. In the mate frame local +Z is the outward normal and the part is sunk ZH_SINK into // the skin, so nothing floats. Top/belly frames keep local +Y aft; wall/belt frames keep +Y aft with local +X // pointing down the wall on either side (starboard mates are mirrors of port); stern/nose frames have local +Y down. +// Mates are lp_at frames: a part under a mate that draws lines (lp_line) emits them in the Ship Root frame. // 3. Zone routing: every zh_ module draws only the zone the global `part` names, so a hull writes its assembly once. // A hull on a Zhestokost donor (HULL_DONOR == "zhestokost") names a donor zone 'zhestokost:'. // Anchor meshes (zh_rad_vanes, zh_emitter) are not routed: call them inside the hull's rad:/emit: parts under @@ -65,17 +66,17 @@ function zh_wall_tilt(secs, y) = let(s = zh_sec(secs, y)) atan2(s[4], s[2]*(1-ZH function zh_stern_pt(secs, x=0, v=0.5) = let(s = secs[len(secs)-1]) [x, s[0], s[3] + s[2]*v]; function zh_nose_pt(secs, x=0, v=0.5) = let(s = secs[0]) [x, s[0], s[3] + s[2]*v]; -module zh_on_top(secs, y, u=0, sink=ZH_SINK) translate(zh_top(secs, y, u) - [0, 0, sink]) children(); -module zh_on_belly(secs, y, u=0, sink=ZH_SINK) translate(zh_belly(secs, y, u) + [0, 0, sink]) rotate([0, 180, 0]) children(); -module _zh_port(side) { if (side < 0) mirror([1, 0, 0]) children(); else children(); } +module zh_on_top(secs, y, u=0, sink=ZH_SINK) lp_at(t=zh_top(secs, y, u) - [0, 0, sink]) children(); +module zh_on_belly(secs, y, u=0, sink=ZH_SINK) lp_at(t=zh_belly(secs, y, u) + [0, 0, sink], r=[0, 180, 0]) children(); module zh_on_belt(secs, y, side=1, v=0.5, sink=ZH_SINK) - _zh_port(side) translate(zh_belt(secs, y, 1, v) - [sink, 0, 0]) rotate([0, 90, 0]) children(); + lp_at(t=zh_belt(secs, y, 1, v) - [sink, 0, 0], r=[0, 90, 0], mx=side < 0) children(); module zh_on_wall(secs, y, side=1, v=0.5, sink=ZH_SINK) { t = zh_wall_tilt(secs, y); - _zh_port(side) translate(zh_wall(secs, y, 1, v) - [cos(t), 0, sin(t)]*sink) rotate([0, 90 - t, 0]) children(); + lp_at(t=zh_wall(secs, y, 1, v) - [cos(t), 0, sin(t)]*sink, r=[0, 90 - t, 0], mx=side < 0) children(); } -module zh_on_stern(secs, x=0, v=0.5, sink=ZH_SINK) translate(zh_stern_pt(secs, x, v) - [0, sink, 0]) rotate([-90, 0, 0]) children(); -module zh_on_nose(secs, x=0, v=0.5, sink=ZH_SINK) translate(zh_nose_pt(secs, x, v) + [0, sink, 0]) rotate([90, 0, 0]) rotate([0, 0, 180]) children(); +module zh_on_stern(secs, x=0, v=0.5, sink=ZH_SINK) lp_at(t=zh_stern_pt(secs, x, v) - [0, sink, 0], r=[-90, 0, 0]) children(); +module zh_on_nose(secs, x=0, v=0.5, sink=ZH_SINK) + lp_at(t=zh_nose_pt(secs, x, v) + [0, sink, 0], r=[90, 0, 0]) lp_at(r=[0, 0, 180]) children(); // world point of a local offset under a mate (for anchors): top, belly, stern frames function zh_top_at(secs, y, u, o, sink=ZH_SINK) = zh_top(secs, y, u) - [0, 0, sink] + o; function zh_belly_at(secs, y, u, o, sink=ZH_SINK) = zh_belly(secs, y, u) + [0, 0, sink] + [-o[0], o[1], -o[2]]; From 28ed24b28ed0648e79bb8802934da80e65eba729 Mon Sep 17 00:00:00 2001 From: soul Date: Thu, 22 Oct 2026 15:54:27 +0200 Subject: [PATCH 080/307] Zhestokost kit and Nakovalnya emit schematic lines from their own frames (outline, step, seam, fitting) Co-Authored-By: Claude Sonnet 5.5 --- GameData/Mods/aetheria.nakovalnya/hull.scad | 26 ++++++---- .../aetheria_ships/factions/zhestokost.scad | 47 +++++++++++++++---- 2 files changed, 54 insertions(+), 19 deletions(-) diff --git a/GameData/Mods/aetheria.nakovalnya/hull.scad b/GameData/Mods/aetheria.nakovalnya/hull.scad index 976078073..062285157 100644 --- a/GameData/Mods/aetheria.nakovalnya/hull.scad +++ b/GameData/Mods/aetheria.nakovalnya/hull.scad @@ -1,5 +1,5 @@ // Zhestokost fortress (boss) "Nakovalnya" (anvil). Built from the Zhestokost kit. -// part = lang | brief | anchors | primary | secondary | accent | recess | marking | emit: | rad: +// part = lang | brief | anchors | lines | primary | secondary | accent | recess | marking | emit: | rad: // Frame: Ship Root, metres, nose -Y, starboard -X, up +Z. Anchor yaw: 0 nose, 90 port, 180 aft, 270 starboard. include part = "primary"; @@ -46,10 +46,10 @@ zh_mass(M); zh_mass(T1); zh_mass(T2); zh_mass(APRON); -for (x = CX) translate([x, 0, 0]) zh_mass(CAS); +for (x = CX) lp_at(t=[x, 0, 0]) zh_mass(CAS); zh_mass(SPINE); -zh_mass(KEEL); -for (s = [-1, 1]) translate([s*DX, 0, 0]) zh_mass(DRV, "secondary"); +zh_mass(KEEL, lines=false); // under the hull: no top view sees it +for (s = [-1, 1]) lp_at(t=[s*DX, 0, 0]) zh_mass(DRV, "secondary"); // ---- the casemate guns: lighter cast-steel barrels out of charcoal mantlets ----------------------------------------- module casemate_gun() { @@ -60,9 +60,11 @@ module casemate_gun() { } zh_z("primary") translate([0, GY - 0.7, GZ]) rotate([0, 0, 180]) tube(BR, BL, 8); zh_z("recess") translate([0, GY - 0.7 - BL - 0.81, GZ]) cube([BR*1.2, 0.05, BR*1.2], center=true); + lp_line("fitting", [for (p = zh_rect(4.0, 1.2, GZ + 1.7)) p + [0, GY - 0.3, 0]], true); + lp_line("fitting", [[0, GY - 0.7, GZ + BR], [0, GY - 0.7 - BL, GZ + BR]]); } -for (x = CX) translate([x, 0, 0]) casemate_gun(); -for (x = CX) translate([x, 0, 0]) zh_on_top(CAS, -21.9, 0) zh_stripes(3.6, 0.6, 4); +for (x = CX) lp_at(t=[x, 0, 0]) casemate_gun(); +for (x = CX) lp_at(t=[x, 0, 0]) zh_on_top(CAS, -21.9, 0) zh_stripes(3.6, 0.6, 4); // black reveals between the casemates, on the apron for (u = [-0.33, 0.33]) zh_on_top(APRON, -19.5, u) zh_reveal(2.3, 7.0); @@ -76,9 +78,10 @@ module flak(yaw) { zh_z("secondary") cylinder(r=FR, h=FH + ZH_SINK, $fn=8); zh_z("accent") translate([0, 0, FH*0.35]) cylinder(r=FR + 0.08, h=0.35, $fn=8); // oxide band round the drum zh_z("recess") translate([0, 0, FH - 0.06]) cylinder(r=FR*0.84, h=0.1, $fn=8); - translate([0, 0, FH - 0.02]) rotate([0, 0, yaw]) zh_gun(FG[0], FG[1], FG[2], FG[3], "primary"); + lp_line("fitting", zh_oct(FR, FH), true); + lp_at(t=[0, 0, FH - 0.02], r=[0, 0, yaw]) zh_gun(FG[0], FG[1], FG[2], FG[3], "primary"); } -for (f = FLAK) translate([f[0], f[1], DECK - ZH_SINK]) flak(f[2]); +for (f = FLAK) lp_at(t=[f[0], f[1], DECK - ZH_SINK]) flak(f[2]); function rz(a, p) = [p[0]*cos(a) - p[1]*sin(a), p[0]*sin(a) + p[1]*cos(a), p[2]]; // ---- bow: cab with the slit high on the spine's front step --------------------------------------------------------- @@ -87,7 +90,10 @@ zh_on_top(T2, -3.6, 0) zh_serial(0.7, 4); // ---- dorsal magazine hatches: two rows of four on the mid-deck, clear of the rim ----------------------------------- for (y = [-8.0, -3.4, 1.2, 5.6], u = [-0.78, 0.78]) zh_on_top(T1, y, u) zh_hatch(2.8, 3.0, dogs=false); -for (y = [0.5, 4.5]) zh_on_top(T2, y, 0) zh_z("secondary") translate([0, 0, 0.06]) cube([8.4, 0.5, 0.2], center=true); // plate seams +for (y = [0.5, 4.5]) zh_on_top(T2, y, 0) { // plate seams + zh_z("secondary") translate([0, 0, 0.06]) cube([8.4, 0.5, 0.2], center=true); + lp_line("seam", [[-4.2, 0, 0.16], [4.2, 0, 0.16]]); +} // Feed collar for an Oboz: a lighter cast-steel flange ring standing proud of the flank round the kit collar. module feed_collar() { @@ -97,7 +103,7 @@ module feed_collar() { } // ---- flanks: feed collar, ammunition door, unit serial ------------------------------------------------------------- for (s = [-1, 1]) { - zh_on_belt(M, 1.5, s, 0.5) rotate([0, 0, 22.5]) feed_collar(); + zh_on_belt(M, 1.5, s, 0.5) lp_at(r=[0, 0, 22.5]) feed_collar(); zh_on_wall(M, 7.0, s, 0.45) zh_hatch(2.6, 3.0); zh_on_wall(M, -9.0, s, 0.5) zh_serial(0.8, 5); } diff --git a/tools/blender/aetheria_ships/factions/zhestokost.scad b/tools/blender/aetheria_ships/factions/zhestokost.scad index ae38b0186..01829d368 100644 --- a/tools/blender/aetheria_ships/factions/zhestokost.scad +++ b/tools/blender/aetheria_ships/factions/zhestokost.scad @@ -84,7 +84,22 @@ function zh_stern_at(secs, x, v, o, sink=ZH_SINK) = zh_stern_pt(secs, x, v) - [0 // ==== PRIMARY: masses that make the silhouette ======================================================================== // Any section list as one faceted poured mass. -module zh_mass(secs, zone="primary") zh_z(zone) for (i = [0:len(secs)-2]) hull() { _zh_slice(secs[i]); _zh_slice(secs[i+1]); } +// Lines: the plan at the cast belt (outline), the deck edge (step) and, on a primary mass, a seam across the deck at each +// interior station. lines=false for a mass no top view can see (a keel under the hull). +module zh_mass(secs, zone="primary", lines=true) { + zh_z(zone) for (i = [0:len(secs)-2]) hull() { _zh_slice(secs[i]); _zh_slice(secs[i+1]); } + if (lines) { + n = len(secs); + lp_line("outline", concat([for (s = secs) [-s[1]/2, s[0], s[3] + s[2]*ZH_WU]], + [for (i = [n-1:-1:0]) [secs[i][1]/2, secs[i][0], secs[i][3] + secs[i][2]*ZH_WU]]), true); + lp_line("step", concat([for (s = secs) [-(s[1]/2 - s[4]), s[0], s[3] + s[2]]], + [for (i = [n-1:-1:0]) [secs[i][1]/2 - secs[i][4], secs[i][0], secs[i][3] + secs[i][2]]]), true); + if (zone == "primary") for (i = [1:n-2]) let(s = secs[i]) + lp_line("seam", [[-(s[1]/2 - s[4]), s[0], s[3] + s[2]], [s[1]/2 - s[4], s[0], s[3] + s[2]]]); + } +} +function zh_rect(w, l, z) = [[-w/2, -l/2, z], [w/2, -l/2, z], [w/2, l/2, z], [-w/2, l/2, z]]; +function zh_oct(r, z) = [for (k = [0:7]) [r*cos(45*k), r*sin(45*k), z]]; // A core or whole hull: bow chamfered in plan and dropped (bow, as a fraction of L), stern a flat face with a cast lip. // bowW / bowH: nose width and height as fractions of W and H. @@ -128,6 +143,7 @@ module zh_collar(r=ZH_COLLAR_R, proud=0.55, lugs=4) { zh_z("secondary") difference() { cylinder(r=r, h=proud + ZH_SINK, $fn=8); translate([0, 0, -0.1]) cylinder(r=r*0.6, h=proud + 1, $fn=8); } zh_z("recess") cylinder(r=r*0.62, h=0.08 + ZH_SINK, $fn=8); + lp_line("fitting", zh_oct(r, proud), true); zh_z("accent") for (i = [0:lugs-1]) rotate(i*360/lugs) translate([r + 0.16, 0, (proud + 0.25)/2]) cube([0.5, 0.6, proud + 0.25 + ZH_SINK], center=true); } @@ -140,13 +156,17 @@ module zh_hatch(w=ZH_HATCH, l=ZH_HATCH, t=0.22, dogs=true) { for (x = [-1, 1]) translate([x*w*0.27, 0, t + 0.12]) cbox([0.5, l + 0.6, 0.34], 0.07); if (dogs) for (sx = [-1, 1], sy = [-1, 1]) translate([sx*(w/2 + 0.12), sy*l*0.3, t*0.6]) cube([0.36, 0.44, 0.4], center=true); } + lp_line("fitting", zh_rect(w, l, t), true); translate([0, l/2 - 0.42, t]) zh_stripes(w*0.36, 0.5, dogs ? 3 : 2); } // Armour plate (any frame): replaceable slab with four bolt bosses; leaves a seam to its neighbour. -module zh_plate(w, l, t=0.3, zone="secondary") zh_z(zone) { - translate([0, 0, t/2]) cbox([w, l, t + ZH_SINK], 0.07); - for (sx = [-1, 1], sy = [-1, 1]) translate([sx*(w/2 - 0.25), sy*(l/2 - 0.25), t + 0.05]) cube([0.22, 0.22, 0.12], center=true); +module zh_plate(w, l, t=0.3, zone="secondary") { + zh_z(zone) { + translate([0, 0, t/2]) cbox([w, l, t + ZH_SINK], 0.07); + for (sx = [-1, 1], sy = [-1, 1]) translate([sx*(w/2 - 0.25), sy*(l/2 - 0.25), t + 0.05]) cube([0.22, 0.22, 0.12], center=true); + } + lp_line("fitting", zh_rect(w, l, t), true); } // Radiator trough (top frame): cast frame round a black floor, split by a spine wall; vanes stand in it (zh_rad_vanes). @@ -155,6 +175,8 @@ module zh_rad_trough(w=3.2, l=2.4, d=0.45, zone="secondary") { cbox([w + 0.7, l + 0.7, d + ZH_SINK], 0.1); cube([w, l, d*3], center=true); } zh_z(zone) translate([0, 0, d*0.6]) cube([0.3, l, d*1.2 + ZH_SINK], center=true); zh_z("recess") translate([0, 0, 0.04]) cube([w, l, 0.08 + ZH_SINK], center=true); + lp_line("fitting", zh_rect(w + 0.7, l + 0.7, d), true); + lp_line("fitting", zh_rect(w, l, d), true); } // radiator-mesh for one half of a trough (side 1 port, -1 starboard): transverse vanes rising out of it module zh_rad_vanes(w=3.2, l=2.4, side=1, n=5, h=1.0) { @@ -173,17 +195,24 @@ module zh_tow_lug(s=ZH_LUG, zone="secondary") { // Ballistic mount (top frame): octagonal ring, battered mantlet block, n thick barrels with box brakes, pointing -Y. module zh_gun(n=2, cal=0.22, len=3.0, base=1.8, zone="secondary") { zh_z(zone) cylinder(r=base*0.56, h=0.35 + ZH_SINK, $fn=8); - zh_mass([for (s = zh_block(0, base*0.95, base, base*0.5, 0.33, base*0.14, 0, 0.22)) s], zone); - zh_z(zone) for (i = [0:n-1]) translate([(i - (n-1)/2)*cal*3.2, -base*0.4, 0.33 + base*0.25]) { - rotate([0, 0, 180]) tube(cal, len, 8); - translate([0, -len, 0]) cbox([cal*3, cal*3.5, cal*3], 0.04); + lp_line("fitting", zh_oct(base*0.56, 0.35), true); + zh_mass([for (s = zh_block(0, base*0.95, base, base*0.5, 0.33, base*0.14, 0, 0.22)) s], zone, lines=false); + for (i = [0:n-1]) lp_at(t=[(i - (n-1)/2)*cal*3.2, -base*0.4, 0.33 + base*0.25]) { + zh_z(zone) { + rotate([0, 0, 180]) tube(cal, len, 8); + translate([0, -len, 0]) cbox([cal*3, cal*3.5, cal*3], 0.04); + } + lp_line("fitting", [[0, 0, 0], [0, -len, 0]]); } } function zh_gun_muzzle(n=2, cal=0.22, len=3.0, base=1.8) = [0, -base*0.4 - len - cal*1.75, 0.33 + base*0.25]; // ==== TERTIARY: detail =============================================================================================== // Shadow-gap floor (top frame): the black bottom of a reveal between modules. -module zh_reveal(w, l) zh_z("recess") translate([0, 0, 0.03]) cube([w, l, 0.06 + ZH_SINK], center=true); +module zh_reveal(w, l) { + zh_z("recess") translate([0, 0, 0.03]) cube([w, l, 0.06 + ZH_SINK], center=true); + lp_line("seam", zh_rect(w, l, 0.06), true); +} // Cream safety chevrons (any frame, +Z out): n diagonal bars clipped to w x l. module zh_stripes(w, l, n=3) zh_z("marking") intersection() { translate([0, 0, 0.04]) cube([w, l, 0.1], center=true); From 63e71a1f30f4325fc233ff52917bda98acb635af Mon Sep 17 00:00:00 2001 From: soul Date: Thu, 22 Oct 2026 15:45:04 +0200 Subject: [PATCH 081/307] PilotView tests: sides, tracks, handles, designation, fire-control readouts Co-Authored-By: Claude Sonnet 5.5 --- .../FireControlPerWeaponTests.cs | 44 +++ tests/Aetheria.Shared.Tests/PilotViewTests.cs | 266 ++++++++++++++++++ 2 files changed, 310 insertions(+) create mode 100644 tests/Aetheria.Shared.Tests/PilotViewTests.cs diff --git a/tests/Aetheria.Shared.Tests/FireControlPerWeaponTests.cs b/tests/Aetheria.Shared.Tests/FireControlPerWeaponTests.cs index bd576b16b..2c70cf438 100644 --- a/tests/Aetheria.Shared.Tests/FireControlPerWeaponTests.cs +++ b/tests/Aetheria.Shared.Tests/FireControlPerWeaponTests.cs @@ -400,6 +400,50 @@ public sealed class FireControlPerWeaponTests : IDisposable AssertAimsAtTarget(r); } + // A brain's fire-control readouts are FireControl's own answers on the resolved subject (A-one-sim), and its reveal + // list is the book's weapons in fitted order with Aimable = IsRevealed. Kills: a readout with its own arithmetic, a + // dropped aim, an intercept left at the planar height, a reveal list in book order. + [Fact] + public void ViewReadoutsAreFireControls() + { + var r = Build(velocity: 500f, targetLayerHeight: 40f); + var targetGun = r.Target.Equipment.Single(i => i.Behaviors.OfType().Any()); + r.Shooter.RecordReveal(r.Target, targetGun, SignatureChannel.LaunchFlash, targetGun.Data); + var view = new PilotView(r.Shooter); + var contact = PilotView.Handle(r.Target); + var weapon = r.Gun.Weapon; + var minHit = r.Shooter.ItemManager.GameplaySettings.AgentMinHitProbability; + + Assert.True(FireControl.Designated(weapon, r.Shooter, r.Target, out _)); + Assert.True(view.Designated(r.Shooter, weapon, contact)); + Assert.Equal(FireControl.Refuses(weapon, r.Shooter, r.Target), view.Refuses(r.Shooter, weapon, contact)); + Assert.Equal(FireControl.AgentFires(weapon, r.Shooter, r.Target, minHit, AimTarget.None), + view.Fires(r.Shooter, weapon, contact, minHit, null)); + + var reveal = Assert.Single(view.WeaponReveals(r.Shooter, contact)); + Assert.Same(targetGun.Data, reveal.Claimed); + Assert.Equal(FireControl.IsRevealed(r.Shooter, targetGun), reveal.Aimable); + Assert.True(reveal.Aimable); + Assert.Equal(FireControl.AgentFires(weapon, r.Shooter, r.Target, minHit, AimTarget.OfItem(targetGun)), + view.Fires(r.Shooter, weapon, contact, minHit, reveal.Id)); + Assert.Equal(FireControl.AgentFires(weapon, r.Shooter, r.Target, 0f, AimTarget.OfItem(targetGun)), + view.Fires(r.Shooter, weapon, contact, 0f, reveal.Id)); + Assert.Equal(FireControl.AgentFires(weapon, r.Shooter, r.Target, 2f, AimTarget.OfItem(targetGun)), + view.Fires(r.Shooter, weapon, contact, 2f, reveal.Id)); + + view.SelectItem(r.Shooter, reveal.Id); + view.SelectItem(r.Shooter, null); + PilotApplier.Apply(r.Shooter); + + var expected = FireControl.PredictedIntercept(weapon, r.Shooter, r.Target); + r.Target.Position = new float3(r.Target.Position.x, 0, r.Target.Position.z); + expected = FireControl.PredictedIntercept(weapon, r.Shooter, r.Target); + var planarY = expected.y; + expected.y = r.Target.RideHeight(expected.xz); + Assert.NotEqual(planarY, expected.y); + Assert.Equal(expected, view.Intercept(r.Shooter, weapon, contact)); + } + private static void AssertAimsAtTarget(Rig r) { var toTarget = r.Target.Position - r.Shooter.Position; diff --git a/tests/Aetheria.Shared.Tests/PilotViewTests.cs b/tests/Aetheria.Shared.Tests/PilotViewTests.cs new file mode 100644 index 000000000..5c371de49 --- /dev/null +++ b/tests/Aetheria.Shared.Tests/PilotViewTests.cs @@ -0,0 +1,266 @@ +using System; +using System.Linq; +using CultMath; +using GameCult.Caching; +using Xunit; + +// A side's brain reaches other ships only through its PilotView (Agents/PilotView.cs): handles it issued, tracks only +// of what a member tracks, picture only of what a member's sensors hold hostile. Driven on the IffAndCombatTests world, +// like FlightTests; perception is set directly, a grudge is a real IFF override. +public sealed class PilotViewTests : IDisposable +{ + private readonly IffAndCombatTests _world = new IffAndCombatTests(); + private readonly ItemManager _items; + private readonly Zone _zone; + + public PilotViewTests() => (_items, _zone) = _world.BuildWorld(); + + public void Dispose() => _world.Dispose(); + + private Ship Pilot(Faction faction, float x = 0, float z = 0) + { + var ship = _world.NewShip(_items, _zone, faction, piloted: true); + ship.Position = new float3(x, 0, z); + return ship; + } + + private Ship Subject(float x = 100, float z = 0) + { + var ship = _world.NewShip(_items, _zone, new Faction { Name = "Other" }); + ship.Position = new float3(x, 0, z); + return ship; + } + + private static void See(Ship member, Entity subject) + { + member.EntityInfoGathered[subject] = 1; + if (!member.VisibleEntities.Contains(subject)) member.VisibleEntities.Add(subject); + member.VisibleEnemies.Add(subject); + } + + private PilotView ViewOf(Ship ship) => _zone.Agents.Single(a => a.Ship == ship).View; + + private void Tick(float dt) + { + foreach (var flight in _zone.Flights.ToArray()) flight.Update(dt); + foreach (var entity in _zone.Entities.ToArray()) PilotApplier.Apply(entity); + } + + [Fact] + public void ASideNeverListsItsOwnMember() + { + var a = Pilot(null, -200); + var b = Pilot(null, 200); + a.SetIff(b, true); + b.SetIff(a, true); + See(a, b); + See(b, a); + + Assert.Equal(2, _zone.Flights.Count()); + Assert.Equal(new[] { PilotView.Handle(b) }, ViewOf(a).Picture()); + Assert.Equal(new[] { PilotView.Handle(a) }, ViewOf(b).Picture()); + for (var step = 0; step < 600; step++) + { + Tick(.1f); + foreach (var member in new[] { a, b }) + Assert.NotEqual(PilotView.Handle(member), (_zone.Agents.Single(x => x.Ship == member).Task as FollowTask)?.Anchor); + } + } + + [Fact] + public void AFactionsPictureLeavesOutItsOwnMembers() + { + var faction = new Faction { Name = "Squabblers" }; + var a = Pilot(faction, -200); + var b = Pilot(faction, 200); + var other = Subject(); + See(a, b); + See(b, a); + See(b, other); + + Assert.Equal(new[] { PilotView.Handle(other) }, ViewOf(a).Picture()); + } + + [Fact] + public void APictureIsTheMembersEnemiesInMemberOrderEachOnce() + { + var faction = new Faction { Name = "Plain" }; + var a = Pilot(faction, -10); + var b = Pilot(faction, 10); + var x = Subject(100); + var y = Subject(200); + var z = Subject(300); + See(a, x); + See(a, y); + See(b, y); + See(b, z); + + Assert.Equal(new[] { x, y, z }.Select(PilotView.Handle), ViewOf(a).Picture()); + } + + [Fact] + public void ATrackIsOnlyWhatTheMemberTracks() + { + var member = Pilot(null); + var subject = Subject(120, 30); + subject.Velocity = new float2(3, -4); + subject.Direction = new float2(0, -1); + var threshold = _items.GameplaySettings.TargetDetectionInfoThreshold; + var view = ViewOf(member); + var handle = PilotView.Handle(subject); + + member.EntityInfoGathered[subject] = threshold / 2; + Assert.Null(view.TrackOf(member, handle)); + Assert.Equal(threshold / 2, view.Info(member, handle)); + + member.EntityInfoGathered[subject] = threshold * 2; + var track = view.TrackOf(member, handle); + Assert.NotNull(track); + Assert.Equal(subject.Position, track.Value.Position); + Assert.Equal(subject.Velocity, track.Value.Velocity); + Assert.Equal(subject.Direction, track.Value.Direction); + Assert.Equal(threshold * 2, view.Info(member, handle)); + } + + [Fact] + public void ASideTrackComesFromATrackingMember() + { + var faction = new Faction { Name = "Plain" }; + var a = Pilot(faction, -10); + var b = Pilot(faction, 10); + var x = Subject(100); + var y = Subject(200); + See(b, x); + var view = ViewOf(a); + + Assert.Null(view.TrackOf(a, PilotView.Handle(x))); + Assert.Equal(x.Position, view.SideTrack(PilotView.Handle(x)).Value.Position); + Assert.Null(view.SideTrack(PilotView.Handle(y))); + } + + [Fact] + public void AViewAnswersIffOverridesAndTrespassPerMember() + { + var member = Pilot(null); + var subject = Subject(); + var view = ViewOf(member); + var handle = PilotView.Handle(subject); + + Assert.Null(view.IffOverride(member, handle)); + member.SetIff(subject, true); + Assert.True(view.IffOverride(member, handle)); + member.SetIff(subject, false); + Assert.False(view.IffOverride(member, handle)); + Assert.Equal(member.BelievesTrespassing(subject), view.BelievesTrespassing(member, handle)); + } + + [Fact] + public void SeeingIsTheMembersOwnEnemyList() + { + var faction = new Faction { Name = "Plain" }; + var a = Pilot(faction, -10); + var b = Pilot(faction, 10); + var x = Subject(); + See(b, x); + var view = ViewOf(a); + + Assert.True(view.Sees(b, PilotView.Handle(x))); + Assert.False(view.Sees(a, PilotView.Handle(x))); + } + + [Fact] + public void ATargetIsThePendingDesignationElseTheAppliedTarget() + { + var member = Pilot(null); + var x = Subject(100); + var y = Subject(200); + See(member, x); + See(member, y); + var view = ViewOf(member); + + Assert.Null(view.TargetOf(member)); + Assert.Null(view.AppliedTargetOf(member)); + + view.Designate(member, PilotView.Handle(x)); + Assert.Equal(PilotView.Handle(x), view.TargetOf(member)); + Assert.Null(view.AppliedTargetOf(member)); + + PilotApplier.Apply(member); + Assert.Equal(PilotView.Handle(x), view.AppliedTargetOf(member)); + Assert.Equal(PilotView.Handle(x), view.TargetOf(member)); + + view.Designate(member, PilotView.Handle(y)); + Assert.Equal(PilotView.Handle(y), view.TargetOf(member)); + Assert.Equal(PilotView.Handle(x), view.AppliedTargetOf(member)); + } + + [Fact] + public void DesignatingTheAppliedTargetAgainWritesNothing() + { + var member = Pilot(null); + var x = Subject(100); + See(member, x); + var view = ViewOf(member); + view.Designate(member, PilotView.Handle(x)); + PilotApplier.Apply(member); + + view.Designate(member, PilotView.Handle(x)); + Assert.Null(member.Command.Designated); + + view.Designate(member, null); + Assert.True(member.Command.Designated.Value.IsNone); + PilotApplier.Apply(member); + view.Designate(member, null); + Assert.Null(member.Command.Designated); + } + + [Fact] + public void AHandleIsTheContactAcrossItsAbsence() + { + var member = Pilot(null); + var x = Subject(100); + var y = Subject(200); + var view = ViewOf(member); + See(member, x); + var before = view.Picture().Single(); + + member.VisibleEnemies.Remove(x); + member.VisibleEntities.Remove(x); + Assert.Empty(view.Picture()); + See(member, x); + + Assert.Equal(before, view.Picture().Single()); + Assert.Equal(PilotView.Handle(x), before); + Assert.True(before == PilotView.Handle(x)); + Assert.Equal(PilotView.Handle(x).GetHashCode(), before.GetHashCode()); + Assert.NotEqual(PilotView.Handle(y), before); + Assert.True(PilotView.Handle(y) != before); + } + + [Fact] + public void ACombatingPilotFliesOnlyOnATrackedTarget() + { + var member = Pilot(null); + var subject = Subject(500, 300); + member.SetTarget(subject); + member.Position = new float3(500, 0, 0); + var agent = new Minion(member, FactionDoctrine.Default(_items.GameplaySettings).Combatant) + { Task = new PatrolOrbitsTask { Circuit = new CultRecordKey[1] } }; + + for (var i = 0; i < 4; i++) + { + agent.Update(.1f); + PilotApplier.Apply(member); + } + Assert.True((member.Command.Aim?.z ?? 0) < 100, "an untracked target is not flown on"); + + See(member, subject); + member.SetTarget(subject); + for (var i = 0; i < 4; i++) + { + agent.Update(.1f); + PilotApplier.Apply(member); + } + Assert.Equal(300f, member.Command.Aim.Value.z, 1); + } +} From 5c38029ec0ea8533f4405f657b2e444a5b390d03 Mon Sep 17 00:00:00 2001 From: soul Date: Thu, 22 Oct 2026 15:56:21 +0200 Subject: [PATCH 082/307] Research: crafting chains and non-gear demand, prior art (serves follow-up crafting-chains-and-demand) Co-Authored-By: Claude Opus 5.5 --- .../2026-10-22-crafting-chains-prior-art.md | 166 ++++++++++++++++++ 1 file changed, 166 insertions(+) create mode 100644 docs/research/2026-10-22-crafting-chains-prior-art.md diff --git a/docs/research/2026-10-22-crafting-chains-prior-art.md b/docs/research/2026-10-22-crafting-chains-prior-art.md new file mode 100644 index 000000000..eff29bcf9 --- /dev/null +++ b/docs/research/2026-10-22-crafting-chains-prior-art.md @@ -0,0 +1,166 @@ +# Prior art for follow-up aetheria-release:follow_up:crafting-chains-and-demand (campaign aetheria-living-world), 2026-10-22, eyes self-2026-10-19-ag7 + +Serves: `aetheria-release:follow_up:crafting-chains-and-demand`. Context read: rulings `catalog-crafting-chains`, `quality-is-assembly-over-supply-chain`, `quality-min-tolerance-binned-assembly`, `quality-triangle-is-per-facility` (question label quality-cost-scope), `maker-design-tiers-allowed`, `snowball-damping-lore-institutions`, `matchup-composition-model`. +Local ruling constraints the survey is read against (not conclusions): quality is per production step with assembly bounding the result; the good/fast/cheap triangle is set per facility by its owner, and premium assembly needs a matching supply chain; runaway factions are damped by lore institutions modelled as rules; off-screen fights settle by a learned composition model. + +Provenance tags: [F] = page opened and read this session (WebFetch extraction by a small model, not verbatim); [S] = search-result summary only, page not opened; [M] = from memory, not re-fetched, verify before citing. Several sources blocked fetching (Fandom 402, EVE University 530, some Egosoft wiki pages 404, Paradox forum, web.archive.org, Tesfatsion site certificate error); gaps are named per section. No recommendations. + +## 1. X4: Foundations (Egosoft) + +Chain structure +- Wares are three layers: mined raw (Ice, Ore, Silicon, Nividium solids; Helium, Hydrogen, Methane liquids), intermediates (Refined Metals, Silicon Wafers, Graphene, Antimatter Cells, Water, Microchips), finished goods (Advanced Electronics, Claytronics, Drone Components, Hull Parts, Weapon Components). Chains run 2 to 4 steps: Ore > Refined Metals > Hull/Engine Parts; Silicon > Wafers > Microchips > Advanced Electronics/Claytronics; Methane > Graphene > Composites; Ice > Water > food/medical. Storage types Solid/Liquid/Container with per-ware volume; the wares table lists producers, consumers and max profit per m3. https://wiki.egosoft.com/X4%20Foundations%20Wiki/Manual%20and%20Guides/Objects%20in%20the%20Game%20Universe/Wares/ [F; page flags itself out of date as of 4.0] +- Branching is by faction/race: Food Rations (Meat+Wheat) consumed by Argon habitats; Paranid habitats consume Soja Husk; Medical Supplies exist in Argon, Paranid, Split, Teladi variants, consumed by habitats and parlours of all four races. Same source [F]. +- Energy Cells need only sunlight; secondary wares boost module output (station wiki pages) [S]. + +Demand that is not gear +- Habitation modules consume food and medical wares and produce workforce; workforce gives a production bonus to modules. Community accounts (Steam): optimal worker count gives up to about 20% bonus; per-module bonuses vary (about 10% for one module type, 37 to 46% reported for others); Terran food needs methane, others only ice. [S, player posts, version unclear] +- Egosoft 4.00 beta note (forum mirror): available habitation capacity per race ties to workforce growth rate; workforce info added to production modules and to the habitation section of the build menu. https://sg.hu/forum/uzenet/1481892553/5083 [S] +- Ship/station construction and the NPC "war economy": miners feed factories that feed shipyards; ships destroyed restart the cycle (player description). [S: https://steamcommunity.com/app/392160/discussions/0/733658398226259846/ ] + +Staying alive without the player +- Egosoft store text: "fully simulated and dynamic economy"; factions build and expand stations dynamically in response to economic and military pressure. https://www.egosoft.com/games/x4/info_en.php [F] +- Prices move with how much a station holds or needs relative to storage capacity (wiki station pages). [S] +- Players report NPC factions eventually build stations to fill gaps, and hire other factions to build even after being wiped out; trigger conditions are not documented in anything fetched. [S, Steam] + +Reading the economy +- Transaction Log with income/outgoings breakdown added in 4.00; Trade Rules (3.20) control station trade offers and supply offers. https://wiki.egosoft.com/X4%20Foundations%20Wiki/Game%20Updates%20and%20Patch%20History/X4%20Foundations%20Feature%20Development%20Overview [F] +- Wares table with price range and profit per m3 [F]. Player-written external tool "Empire Logistics Tool" (Excel) for chain planning: https://forum.egosoft.com/viewtopic.php?p=5251793 [S]. + +Failure modes (player testimony, [S], not Egosoft statements) +- New games start with part shortages that stall shipyards/wharfs; advanced electronics gone within first hours on some maps; the bottleneck varies per seed. Players fix by hand-trading missing parts. +- "Economy collapses without intervention" and "lacks the ware sinks earlier X games had"; if ships are not destroyed, factions hit a unit cap and stop demanding from shipyards; war leaves consumers with full storage, which stops purchasing. Effects of inaction appear around 24 hours in, when initial stock runs out. +- Counter-view: a player built 111 stations by manual trading and argued the AI copes and complaints reflect player interference. +- Mods rework it: DeadAir Economy Overhaul https://www.nexusmods.com/x4foundations/mods/2209 (lists "workforce effect strengthened") [S]. +Not found: any Egosoft dev diary or Inside Egosoft text on the 4.0 workforce/habitat model. + +## 2. Anno 1800 (Ubisoft Blue Byte) + +Primary-source status: Fandom wiki blocked (402); no official devblog on needs found. Everything below is wiki/guide/forum summary. + +Chain structure and tiers +- Five Old World tiers (Farmers, Workers, Artisans, Engineers, Investors) and two New World tiers (Jornaleros, Obreros). Houses 3x3. https://segmentnext.com/anno-1800-needs-guide/ [F]; https://en.wikipedia.org/wiki/Anno_1800 [S] +- Needs split Basic and Luxury; each need is met by goods (fish, bread, beer) or a service building (pub, church, school, university). Basic needs drive influx (people per house); luxury needs raise happiness and income/tax. A house upgrades to the next tier when its basic needs are met; Season 4 added "lifestyle needs". segmentnext [F]; Steam guide https://steamcommunity.com/app/916440/discussions/0/4035852798889680783 [F] +- Artisan Residence page lists per-need consumption rate per 30-resident residence, e.g. sausage about 0.000667, bread about 0.000606 (unit not stated). https://anno1800.fandom.com/wiki/Artisan_Residence [S] +- Workforce couples tiers to industry: potato farm needs 20 farmers, steelworks 200 workers (PC Gamer tips). https://www.pcgamer.com/anno-1800-tips-guide/ [S]. Engineers bring electricity doubling nearby factory productivity [F Steam guide]. +- Chain ratios: clicking a production building shows its cycle rate, so ratios are computed by the player [S]; community calculators exist (annocalc) [M]. + +Demand not gear +- All consumer demand is residents' needs, tied to tier and house count; higher tiers are the main income ("Investors pay for increasingly complex consumer goods") [F Steam guide]. Raising output can cost happiness; extreme cases riot and halt production [S]. + +Stalls and snowballing +- A goods deficit makes residents unhappy and they leave, lowering income, so expansion beyond supply self-corrects downward [F Steam guide]. Upkeep per production building is high, penalising sprawl [S]. Passive trade is limited and expensive; Docklands DLC adds barter scaling with storage [F]. + +Reading the economy +- Hover on a need shows money earned per house [F]; UI shows connected buildings and supply-chain position [S, Aureview review]. One forum poster calls surplus/deficit "extremely fuzzy" in 1800 compared with Anno 2205 unless running large surpluses [S]. Lifestyle needs made it harder to tell when houses are ready to upgrade [S]. + +## 3. Victoria 3 (Paradox) + +Chain structure +- Buildings form a web producing and/or consuming goods; they do nothing without staff. Dev Diary 57: https://paradoxinteractive.com/games/victoria-3/news/dev-diary-57-the-journey-so-far [F] +- Production methods (PMs): each building has PM groups (Base, Secondary, Automation, Organization, Military), exactly one active PM per group; locked PMs need tech, law, another PM, or goods produced in state/market; if requirements lapse the active PM falls to the first valid one in the group. Inputs/outputs scale with building level x employment x throughput. https://vic3.paradoxwikis.com/Production_method [F] +- Dev Diary 5 (search summary only): PMs are trade-offs rather than straight upgrades; categories Base, Refining (shifts to luxury/specialised output), Automation (adds industrial goods input to reduce unskilled labour). Direct fetch of the diary 404'd; forum thread https://admin-forum.paradoxplaza.com/forum/developer-diary/victoria-3-dev-diary-5-production-methods.1480760/ [S] +- Goods categories: staple, luxury, industrial, military; staple and luxury mostly consumed by pops, industrial and military mostly by buildings. Local goods are state-bound and not traded. https://vic3.paradoxwikis.com/Goods [F] + +Demand not gear +- Pops staff buildings, earn wages, spend on goods; owners spend profits on luxuries; peasants subsistence-farm unused land. Dev Diary 57 [F]. +- Needs: a buy package per wealth level (base-price spending target per need category for 10,000 working adults); pops buy until units x base price reaches the package value, so pricier goods satisfy a need in fewer units; pops ignore price when choosing among goods in a need (split by market share, weight, min/max share); dependents count half; peasants buy 5% of standard; relative substitution capped at 1% to 10% per week; culture obsessions raise a good's weight, taboos halve demand; need patterns across wealth levels are temporary, constant or exponential. https://vic3.paradoxwikis.com/Needs [F, v1.13] +- Standard of living equals wealth level; higher SoL means larger, costlier needs; birth/mortality driven by SoL, growth levels off near SoL 15. https://vic3.paradoxwikis.com/Pop [F] +- Institutions (healthcare, education, policing) are pop-staffed and consume a Bureaucracy currency produced by buildings, so players need not micromanage facilities. Dev Diary 57 [F]. + +Stalls and snowballing +- Price per good floats up to +/-75% of base cost with relative supply and demand; base cost = price when buy and sell orders are equal. Goods page [F]. +- Trade routes grow until marginal profit ends; the earlier trade system needed micromanagement and was exploitable, including by AI; replaced by profit-responsive routes with tariffs/embargoes. Dev Diary 57 [F]. +- Unemployment handled by subsistence farming, which may widen wealth gaps in the industrial transition. Dev Diary 57 [F]. + +Reading the economy: goods have a base cost and per-market price; pops show SoL and needs (wiki pages above) [F]. No fetched source describes the UI. + +Failure modes (player testimony [S]: Steam threads, e.g. https://steamcommunity.com/app/529340/discussions/0/5971271658111594308 ) +- Over-profitable buildings drive their goods' prices very low while AI keeps expanding them; after the 1.5 economy change players report nearly all goods in balance or oversupplied, exports hard; construction-sector over-building forces deficit spending; the jobseeker system made new factories hard to staff; construction sector cost rose from 50 to 100 points, slowing growth. + +## 4. EVE Online (CCP) + +Chain structure +- Mining yields ore; reprocessing yields minerals; minerals are inputs to nearly all manufacturing. Net reprocessing yield depends on equipment, skills, implants and standings (worked example: base 50%, about 69.9% with skills). Compressed, batch-compressed and uncompressed ore yield the same minerals per batch. https://wiki.eveuniversity.org/Minerals , /Refining , /Reprocessing [S; direct fetch blocked, 530]. Wiki flags the yield page as needing update after the October 2020 patch [S]. +- Moon materials must go through reactions before use [S]. Blueprint originals have infinite runs, copies limited; T2 comes via Invention producing limited-run T2 blueprint copies (baseline 10 runs, +2% ME, +4% TE, decryptors modify); T2 originals are rare lottery-era relics. https://wiki.eveuniversity.org/Invention , /Blueprint_Original [S]. Industry jobs carry a per-system cost index (page /System_Cost_Index; title only, content not read). +- Chain shape per these pages: raw > refined mineral > component > ship/module, plus a moon > reaction > advanced-material branch and an invention branch gated by datacores [S/M]. + +Demand not gear / sinks +- CCP economist Dr Eyjolfur Gudmundsson, Fanfest 2011 as reported: warfare and the economy thrive on each other; very little consumption without warfare; wars cannot be won without a strong economy; PvP destruction presented as essential to economic health. https://www.engadget.com/2011-04-04-ccp-releases-eve-quarterly-economic-newsletter.html [F] +- Fanfest 2014 framing: ISK faucets versus sinks; too much entering risks rapid inflation, too little risks collapse (secondhand, Engadget tag page) [S]. Varoufakis essay on war spikes in EVE (2014): https://www.yanisvaroufakis.eu/2014/01/30/war-spikes-in-the-eve-online-universe-a-political-economists-account/ [fetch returned 403; content not read]. +- Monthly Economic Report format: destruction, production, mining value, money supply, ISK velocity, price indices (mineral, ship, module), sinks-and-faucets graphic; Sept 2025 methodology change made production and mining use static pricing. https://www.eveonline.com/news/view/monthly-economic-report-february-2026 [F]; https://www.eveonline.com/news/view/monthly-economic-report-september-2025 [S]. Feb 2026: destruction up while mining and production down, velocity falling, mineral index about -52% yoy, ship about -21%, module about -13% [F]. + +Avoiding stalls / snowballing +- No fetched CCP text describes stall avoidance. Structure is player-driven; destruction is presented as the demand sink (above). Cost index named only [S]. + +Reading the economy: public MER with downloadable data; in-game market history [F/M]. + +Failure modes +- Forum critique built on the Feb 2026 MER: destruction rising while mining, production and mineral prices fall contradicts "destruction drives the economy" (player reading). https://forums.eveonline.com/t/the-economy-isnt-broken-and-destruction-is-not-good/286457 [S]. Player claim that CCP interventions from the 2020 Ecosystem Outlook reduced player wealth [S, forum opinion]. A 2021 thread questioned regional destruction totals versus the overall figure; one commenter suggested reimbursed losses distort them [S]. +- 2012 CCP Recurve dev blog on inflation (mineral prices +17.4%, CPI +13.5%, as cited by a forum user) [S, unverified]. + +## 5. Starsector (Fractal Softworks): small-scale commodity demand per colony + +Wiki current for 0.98a. All [F] unless noted. +- Eighteen commodities with base price and "economic unit" (Food 20 / 2000, Metals 30 / 2000, Domestic Goods 50 / 1000, Luxury Goods 100 / 500, Heavy Machinery 150 / 300, Heavy Armaments 500 / 200, Recreational Drugs 200 / 400, Harvested Organs 300 / 200, others). https://starsector.wiki.gg/wiki/Commodities +- Each industry lists commodities it produces and demands, amounts as formulas of colony size (Farming produces Food at Size and demands Heavy Machinery at Size-3); industry slots = Size-2; upkeep scales with size. https://starsector.wiki.gg/wiki/Industry +- Population and Infrastructure demand for a colony of size X: Food X, Domestic Goods X-1, Luxury Goods X-3, Recreational Drugs X-2, Harvested Organs X-3, Supplies 3 flat, Organics X-1 on non-habitable planets. Meeting Domestic Goods and Luxury demand each gives +1 stability; failing Food or Organics gives a stability penalty equal to the larger shortage. https://starsector.wiki.gg/wiki/Population_%26_Infrastructure +- Trade fleets move goods between markets (physical convoys near the core, simulated elsewhere); destroyed convoys cause "Shipping Disruption" (temporary shortages, price shift, accessibility penalty); pirate activity creates shortages and excess; player sales fill demand for 120 days, listings refresh every 30; buying raises and selling lowers unit price; a market with no demand for a good pays well below base; 30% tariff on open markets; stability changes prices and black-market share. https://starsector.wiki.gg/wiki/Trade , /Market +- Shortage and surplus figures (Deficit/Excess) appear in the F1 tooltip so players can see how much to trade before prices normalise (needs a comm relay). Trade page. +- The wiki does not say how a deficit affects industry output. + +## 6. Elite Dangerous Background Simulation (Frontier): faction economy and expansion limits + +- Faction influence in a system is zero-sum across factions; control tracks the controlling station, not highest influence; a faction needs 75% influence to expand, expands to the nearest system within 20 LY with fewer than seven factions, else the expansion becomes an invasion; below 2.5% influence a faction withdraws and loses wealth elsewhere; below 7.5% it cannot enter conflicts. https://www.dpss.space/bgs [F; community guide, not Frontier] +- Economy states Famine/Bust/None/Boom/Investment shift happiness and mission rewards; Boom lasts about three weeks or until accrued value is spent; Famine is ended by delivering food [F same page]. Frontier's own description (2018, as relayed): daily calculations apply player actions to faction attributes [S: https://massivelyop.com/?p=192372 ]; four BGSes in 2024 (Political, Economic, Outfitting, Thargoid), Thargoid states suspend political movement [S, Frontier forum]. + +## 7. Factorio and Satisfactory (chain depth and legibility only) + +Factorio +- Science packs set depth: Automation about 3 raw items per pack; Logistic; Military; Chemical (crude oil); Production; Utility about 190 raw (33.3 iron, 49.8 copper, 3.8 coal, 106.8 crude); Space Age packs up to about 477 raw per pack. Demand for the whole factory comes from lab research cost = pack count x pack types. Assembler ratios per pack published (base game 5:6:5:12:7:7). https://wiki.factorio.com/Science_pack [F] +- Space Age agricultural packs spoil, reducing science value over time [F same page]. +- Production statistics and ratio calculators as legibility aids [M]; not fetched. + +Satisfactory +- Progression through HUB milestones grouped in tiers; Space Elevator phases unlock higher tiers; delivered parts cannot be retrieved. https://satisfactory.wiki.gg/wiki/Milestone , /Tiers [S] +- By-product stall: Heavy Oil Residue is a by-product of Plastic and Rubber in the Refinery; if it backs up, the primary product stops too; outlets are Petroleum Coke (4 HOR > 12 coke), Fuel/Turbofuel, Smokeless Powder, packaging. https://satisfactory.wiki.gg/wiki/Heavy_Oil_Residue [F] +- AWESOME Sink destroys sinkable items for points (by item value); points buy coupons whose price rises in steps; wiki advises an overflow splitter feeding the Sink so lines never clog; throughput up to 1200 items/min on a Mk.6 belt. https://satisfactory.wiki.gg/wiki/AWESOME_Sink [F] + +## 8. RimWorld and Dwarf Fortress (need-driven demand) + +- RimWorld: need bars (Food, Rest, Recreation, Beauty, Comfort, Indoors/Outdoors, Chemical, others) fall by themselves and refill by eating, sleeping, recreation, drugs; needs feed mood through threshold thoughts; very low mood triggers mental breaks. https://rimworldwiki.com/wiki/Needs [F] +- Dwarf Fortress: idle dwarves eat, drink or sleep when only slightly in need; trade is by caravan to a depot; earlier versions had a monetary system later dropped. https://dwarffortresswiki.org/index.php/v0.34:Eat , /DF2014:Trade [S]; commentary https://crookedtimber.org/2014/01/07/dwarf-fortress-a-marxist-analysis/ [S]. Noble mandates page fetch failed (429); mandate mechanics not verified. + +## 9. Virtual-economy design literature (faucets and sinks) + +- Lehdonvirta and Castronova, "Virtual Economies: Design and Analysis" (MIT Press 2014): applies microeconomics to artificially scarce virtual goods. Reading summary describes faucets (create assets), sinks (remove assets: transaction taxes, service fees, repair, limited lifespans), inflation when faucets exceed sinks, deflation causing hoarding. Book text not opened. https://www.antoinebuteau.com/virtual-economies-book-summary/ ; https://blogs.lse.ac.uk/usappblog/2014/09/28/book-review-virtual-economies-design-and-analysis-by-vili-lehdonvirta-and-edward-castronova [S] +- Gold sink (Wikipedia): passive sinks (decay, taxes), active sinks (designer-added), feedback controllers adjusting drop rates and prices; Alter Aeon taxed holdings above 1M at 2% of the excess and kept the currency peak within 10% over two years; Ultima Online NPC vendors sold unmakeable "blue" armor at inflated prices and after Renaissance sold luxury items to absorb money; Kingdom of Loathing and RuneScape (Construction skill) examples. https://en.wikipedia.org/wiki/Gold_sink [F; article flagged single-source] +- Zack Booth Simpson, "The In-game Economics of Ultima Online" (GDC): primary source for UO price/inflation measurement; the fetched page exposed only the introduction (body is images/truncated). https://gwern.net/doc/economics/1999-04-07-simpson-theingameconomicsofultimaonline.html [F, not read through] +- Raph Koster, "UO's resource system": objects carry abstract resource labels with amount, max and regrowth; AI creatures have FOOD, SHELTER, DESIRE, PRODUCE lists attempted in Maslow-like order; the beta added spontaneous generation and a closed economy loop. The post does not state the failure; reader comments recall animals hunted to near-extinction and the system removed, and one commenter advises that a dynamic ecology must be constrained heavily to stay predictable. https://www.raphkoster.com/?p=517 [F] +- Koster on SWG: player shops with NPC bot; crafters formed supply chains and customer bases; half of players ran a shop at peak. https://www.raphkoster.com/2012/03/20/do-auction-houses-suck/ [F, snippet]. Faucet-drain model (faucets mint, sink is the in-game pool, drains exit) from a 2004 analysis: https://boingboing.net/2004/05/03/star-wars-galaxies-e.html [S]. Item damage and decay appear in his economy spectrum where items are player-sourced [S, post not identified]. +- WildStar (Carbine) dev blog 2013: item repair as "a very handy and fair money sink" that should not be painful because it offers little choice. https://www.engadget.com/2013-05-31-wildstar-economy-post-talks-item-decay-raph-koster-and-more.html [F] + +## 10. Academic: agent-based computational economics + +- Tesfatsion, "Agent-Based Computational Economics: A Constructive Approach to Economic Theory", Handbook of Computational Economics vol. 2 (2006), ch. 16: economies as dynamic systems of interacting agents; covers production, pricing and trade processes, strategic rivalry, learning, institutions; illustrates with a two-sector decentralised market model. https://ideas.repec.org/p/isu/genres/12514.html ; https://faculty.sites.iastate.edu/tesfatsi/archive/tesfatsi/hbace.htm [S; site cert error, chapter not opened] +- Dosi, Fagiolo, Roventini, "Schumpeter meeting Keynes" (K+S): capital-good firms do R&D and build machines, consumption-good firms invest and produce a homogeneous consumption good, workers, a bank; finds complementarity of demand generation and innovation shapes cycles and growth; extension with income distribution, credit, fiscal policy (Dosi, Fagiolo, Napoletano, Roventini); LEM WP 2014/21 with Treibich; Dosi and Roventini, Cambridge Elements "Agent-Based Macroeconomics" (2025). https://www.cambridge.org/core/elements/abs/agentbased-macroeconomics/B7016A5CEE59694A420AF51A1347F00E [F, abstract only]; https://www.lem.santannapisa.it/WPLem/2014-21.html [S] +- Epstein and Axtell, "Growing Artificial Societies" (1996), Sugarscape: renewable sugar on a grid, agents with metabolism and vision; skewed wealth emerges; adding spice allows trade and an emergent market. https://jasss.org/12/1/6/appendixB/EpsteinAxtell1996.html [S, not opened] +- Hallegatte ARIO (Adaptive Regional Input-Output): input-output structure plus production capacity limits, input scarcity, inventories, adaptive behaviour; Katrina total cost about $149B against $107B direct; most sensitive parameters were construction-sector overproduction capacity and adaptation time; losses nonlinear above about $50B direct. https://hats.arizona.edu/node/300 [S]. Extensions R-ARIO and a credit-based ARIO [S]. + +## 11. Leontief input-output dynamics + +- Static model x = Ax + y, x = (I-A)^-1 y; a nonnegative output exists for any nonnegative final demand iff the Hawkins-Simon condition holds (leading principal minors of I-A positive; equivalent to some x>=0 with (I-A)x>0); the Neumann series converges when the largest eigenvalue of A is less than 1. https://en.wikipedia.org/wiki/Input%E2%80%93output_model [F]; https://en.wikipedia.org/wiki/Hawkins%E2%80%93Simon_condition [F]. In plain terms: a closed chain must yield more of each good than the chain itself consumes, or positive final demand cannot be satisfied. +- Dynamic Leontief model adds capital coefficients B; B is often singular; forward-looking solutions were "unrealistic and fluctuating" and interest declined after the 1970s; one author group claims a positive capital matrix makes the dynamic model always unstable (single-group claim). Wikipedia [F]; Blanc Diaz and Ramos https://www.iioa.org/conferences/intermediate-2004/pdf/blanc.pdf [S]. +- Critiques: fixed prices, constant returns, poor handling of relative price changes, ignores resource constraints. Wikipedia [F]. +- Not found: a game-development source applying Hawkins-Simon or spectral-radius checks to recipe graphs. Linear-solver recipe calculators for Factorio/Satisfactory use this form [M]. + +## 12. Index of observed items by report axis + +- Chain depth: X4 2 to 4 steps with race branches; Factorio 3 to 7+ steps (up to about 477 raws per pack); Satisfactory by-product branches; Vic3 a web of buildings with PM-swapped inputs; Anno short chains plus service buildings feeding tiers; Starsector 1 to 2 steps; EVE 3 to 5 steps plus reaction and invention branches. +- Non-gear demand drivers: pop-tier needs (Anno, Vic3, Starsector colony size), workforce food/medical (X4), research consumption (Factorio), destruction (EVE, X4 war economy), points sink (Satisfactory), mood needs (RimWorld, DF), stability from met demand (Starsector), happiness (Anno). +- Stall mechanisms: seed-dependent shortages and full-storage consumers (X4), by-product backup (Satisfactory), +/-75% price band (Vic3), deficit makes residents leave (Anno), shipping disruption (Starsector). +- Anti-snowball mechanisms: zero-sum influence, expansion thresholds and retreat (Elite BGS, community guide), upkeep per building (Anno), profit-bounded trade routes (Vic3), industry cap Size-2 (Starsector), system cost index (EVE, name only). +- Legibility devices: transaction log and wares table (X4), hover income per need (Anno), Deficit/Excess tooltip (Starsector), MER with data download (EVE), production statistics (Factorio [M]). +- Failure modes with player or developer comment: under each section (X4 s1, Anno s2, Vic3 s3, EVE s4, UO s9). + +## Gaps this pass did not close +- No first-party Egosoft text on the 4.0+ workforce/habitat model; no Anno devblog; no CCP blog on reprocessing/reaction design or on the destruction-demand claim beyond the 2011 report; Vic3 market dev diaries (prices, buy/sell orders) not located; DF mandates not verified; Simpson UO paper body not read; Tesfatsion chapter and Dosi papers read only to abstract level. From b982fb9cd082c2abd451e964fcf12e872cb0876d Mon Sep 17 00:00:00 2001 From: soul Date: Thu, 22 Oct 2026 15:57:44 +0200 Subject: [PATCH 083/307] hull_source: kit lines echo, SVG silhouette projector, SchematicLine, three line checks Co-Authored-By: Claude Sonnet 5.5 --- tools/blender/aetheria_ships/hull_source.py | 168 ++++++++++++++-- tools/blender/tests/test_hull_source.py | 200 +++++++++++++++++++- 2 files changed, 347 insertions(+), 21 deletions(-) diff --git a/tools/blender/aetheria_ships/hull_source.py b/tools/blender/aetheria_ships/hull_source.py index fb2aa246a..ca929560f 100644 --- a/tools/blender/aetheria_ships/hull_source.py +++ b/tools/blender/aetheria_ships/hull_source.py @@ -11,11 +11,16 @@ with it (a hull that redefines or shadows an included language fails a check): (every included language file echoes its own, and so does factions/.scad run alone; a Pirate language has no body zone and no proportion) anchors ECHO anchor = [id, role, [x, y, z], yaw, parent] one line per anchor, yaw 0 nose, 90 port + lines ECHO line = [layer, cyclic, [[x, y, z], ...]] one line per kit part that draws one, points in the Ship Root + frame (lowpoly.scad lp_line under lp_at frames); layer is one of KIT_LAYERS the zone's geometry, OFF; a donor's zone is named ":" emit:, rad: the mesh of a thruster-emitter or radiator-mesh anchor Frame: Ship Root, metres, nose -Y, starboard -X, up +Z. build() also takes the hull's HullData.HullType ("Ship", "Station" or "Turret"), which the caller reads: the add-on from the bound package's ship.cc, the bench from its flag. +Schematic lines (HullBuild.lines): the top-view silhouette first (OpenSCAD projection() of every zone and anchor mesh, +read from the SVG it exports), then the kit's lines, each with a fixed RGBA from LINE_STYLE. A kit part is placed with +lp_at so its line is emitted in the frame the geometry is; a source that echoes no lines has the silhouette alone. A faction language's proportions judge Ship hulls only; every hull's zones must fit the layout grid (hull_grid.MAX_CELLS cells of hull_grid.CELL_SIZE a side). The brief's role is prose and decides nothing. build() returns a HullBuild; the caller refuses on any failed check. Messages and check names are fixed text: nothing a hull source says is echoed back. @@ -49,9 +54,18 @@ OPENSCAD_TIMEOUT = 180 # seconds, per part SILHOUETTE_CELL = 0.25 # metres, the top-view raster the symmetry check compares ANCHOR_ROLES = ("weapon-mount", "weapon-muzzle", "thruster-emitter", "radiator-mesh") MESH_ROLES = {"thruster-emitter": "emit:", "radiator-mesh": "rad:"} -ECHO_PARTS = ("brief", "lang", "anchors") # parts that make no geometry; their ECHO lines are the answer +ECHO_PARTS = ("brief", "lang", "anchors", "lines") # parts that make no geometry; their ECHO lines are the answer LANGUAGE_NAME = re.compile(r"[a-z][a-z0-9_]*") ECHO_LINE = re.compile(r"^ECHO: (\w+) = (.*)$", re.MULTILINE) +KIT_LAYERS = ("outline", "step", "seam", "fitting") # layers a kit's lines may name; "silhouette" is hull_source's own +LINE_STYLE = {"silhouette": (1.0, 1.0, 1.0, 1.0), "outline": (1.0, 1.0, 1.0, 0.75), "fitting": (1.0, 1.0, 1.0, 0.6), + "step": (1.0, 1.0, 1.0, 0.5), "seam": (1.0, 1.0, 1.0, 0.35)} +MIN_SILHOUETTE_AREA = 0.25 # m2, below which a projected contour is a sliver, not the hull +LINE_EDGE_TOLERANCE = 0.3 # m, how far outside the silhouette's edge a kit line's point may lie +DUPLICATE_CELL = 0.001 # m, kit lines whose points all agree to this are the same line + +# A projector answers a list of Meshes with the contours (lists of (x, y)) of their top-view union. +Projector = Callable[[list], "list[list[tuple]]"] # A runner answers one part of the hull source with (OFF text, ECHO text). OFF text is "" when the part made no geometry. Runner = Callable[[str], "tuple[str, str]"] @@ -109,6 +123,16 @@ class Anchor: parent: str +@dataclass(frozen=True) +class SchematicLine: + layer: str # "silhouette" or one of KIT_LAYERS + cyclic: bool + points: tuple # ((x, y, z), ...) in the Ship Root frame + + def rgba(self): + return LINE_STYLE[self.layer] + + @dataclass(frozen=True) class Check: name: str @@ -129,6 +153,7 @@ class HullBuild: body_zone: str # the zone key the collider is made from hull_type: str # HullData.HullType of the bound package ("Ship", "Station", "Turret"), as the caller read it checks: list = field(default_factory=list) + lines: list = field(default_factory=list) # SchematicLine: the silhouette contours first, then the kit's lines def failed(self): return [check for check in self.checks if not check.ok] @@ -250,6 +275,47 @@ def read_anchors(text): return anchors +def read_lines(text): + """The kit's schematic lines, in echo order. A layer outside KIT_LAYERS, fewer than two points or a point that is not + three finite numbers is refused.""" + lines = [] + for raw in parse_echoes(text, "line"): + if not isinstance(raw, list) or len(raw) != 3 or type(raw[1]) is not bool or not isinstance(raw[2], list): + raise HullSourceError("A schematic line has the wrong shape") + layer = _text(raw[0]) + if layer not in KIT_LAYERS: + raise HullSourceError("A schematic line names a layer a kit does not draw") + if len(raw[2]) < 2: + raise HullSourceError("A schematic line has fewer than two points") + points = tuple(_vector(p, 3) for p in raw[2]) + if not all(math.isfinite(c) for p in points for c in p): + raise HullSourceError("A schematic line has a point that is not a finite number") + lines.append(SchematicLine(layer, raw[1], points)) + return lines + + +def to_off(mesh): + """The OFF text of a Mesh.""" + rows = ["OFF", f"{len(mesh.vertices)} {len(mesh.triangles)} 0"] + [" ".join(repr(float(c)) for c in v) for v in mesh.vertices] + return "\n".join(rows + [f"3 {a} {b} {c}" for a, b, c in mesh.triangles]) + "\n" + + +def parse_svg_contours(text): + """The contours of an OpenSCAD SVG (a path of 'M x,y L x,y ... z' pieces) as lists of model (x, y): the SVG's y is the + model's -y. A contour under MIN_SILHOUETTE_AREA is dropped (the zero-area slivers a projection leaves).""" + contours = [] + for path in re.findall(r'= 3 and area >= MIN_SILHOUETTE_AREA: + contours.append(ring) + return contours + + def _exactly_one(values, what): if len(values) != 1: raise HullSourceError(f"The hull source must echo exactly one {what}") @@ -289,9 +355,36 @@ def _language(library, faction): return language -def build(runner, library, hull_type): +def _point_in_ring(x, y, ring): + inside = False + for (ax, ay), (bx, by) in zip(ring, ring[1:] + ring[:1]): + if (ay > y) != (by > y) and x < ax + (y - ay) * (bx - ax) / (by - ay): + inside = not inside + return inside + + +def _edge_distance(x, y, ring): + best = float("inf") + for (ax, ay), (bx, by) in zip(ring, ring[1:] + ring[:1]): + dx, dy = bx - ax, by - ay + along = ((x - ax) * dx + (y - ay) * dy) / (dx * dx + dy * dy) if dx or dy else 0.0 + along = max(0.0, min(1.0, along)) + best = min(best, math.hypot(x - (ax + along * dx), y - (ay + along * dy))) + return best + + +def over_silhouette(point, contours): + """Whether the point's top view lies inside the silhouette (even-odd over every contour) or within + LINE_EDGE_TOLERANCE of a contour's edge.""" + x, y = point[0], point[1] + return (sum(_point_in_ring(x, y, ring) for ring in contours) % 2 == 1 + or any(_edge_distance(x, y, ring) <= LINE_EDGE_TOLERANCE for ring in contours)) + + +def build(runner, library, hull_type, projector): """Reads the hull source through runner (see Runner) and judges it against the languages library(faction) answers with - (the ECHO text of that faction's own language file). hull_type is the hull's HullData.HullType name, which the caller + (the ECHO text of that faction's own language file). projector (see Projector) answers the top-view contours of every + zone and anchor mesh, which become the silhouette line. hull_type is the hull's HullData.HullType name, which the caller reads; only a "Ship" is judged on the language's proportions. Raises HullSourceError, before reading anything, when hull_type is not one of ship_cc.HULL_TYPE_NAMES, and when the source cannot be read at all; otherwise returns a HullBuild whose failed() lists the checks it breaks.""" @@ -314,10 +407,16 @@ def build(runner, library, hull_type): if len(set(ids)) != len(ids) or any(a.role not in ANCHOR_ROLES for a in anchors): raise HullSourceError("The hull source's anchors repeat an id or name a role a hull source does not make") anchor_meshes = {a.id: parse_off(runner(MESH_ROLES[a.role] + a.id)[0]) for a in anchors if a.role in MESH_ROLES} + kit_lines = read_lines(runner("lines")[1]) judge = donor or own body_zone = f"{donor.faction}:{donor.body}" if donor else own.body result = HullBuild(brief, {language.faction: language for language in owned}, zones, colours, anchors, anchor_meshes, own.glow, own.radiator, body_zone, hull_type) + meshes = [m for m in list(zones.values()) + list(anchor_meshes.values()) if m.triangles] + if meshes: + top = max(v[2] for m in meshes for v in m.vertices) + result.lines = [SchematicLine("silhouette", True, tuple((x, y, top) for x, y in contour)) for contour in projector(meshes)] + result.lines += kit_lines by_faction = lambda language: language.faction result.checks = [Check("the hull includes its faction's language files and changes none of them", sorted(included, key=by_faction) == sorted(owned, key=by_faction), "")] @@ -366,6 +465,14 @@ def _checks(hull, own, judge): f"{width} x {height} cells, at most {hull_grid.MAX_CELLS} a side")) except ValueError: checks.append(Check("the top view fits the layout grid", False, "no geometry")) + silhouette = [line for line in hull.lines if line.layer == "silhouette"] + kit = [line for line in hull.lines if line.layer != "silhouette"] + contours = [[p[:2] for p in line.points] for line in silhouette] + checks.append(Check("the top view yields a silhouette", bool(silhouette), f"{len(silhouette)} contours")) + outside = [line for line in kit if not all(over_silhouette(p, contours) for p in line.points)] if silhouette else kit + checks.append(Check("every kit line lies over the hull's top view", not outside, f"{len(outside)} outside")) + keys = [tuple(tuple(round(c / DUPLICATE_CELL) for c in p) for p in line.points) for line in kit] + checks.append(Check("no two kit lines are the same", len(set(keys)) == len(keys), f"{len(keys) - len(set(keys))} repeated")) triangles = sum(len(m.triangles) for m in every) checks.append(Check("the triangle count lies inside the brief's budget", brief.triangles[0] <= triangles <= brief.triangles[1], f"{triangles}")) @@ -380,6 +487,25 @@ def _checks(hull, own, judge): return checks +def _run_openscad(command, on_start, **options): + """Runs one OpenSCAD process to completion and answers its output. The refusals are fixed text.""" + try: + process = subprocess.Popen(command, stdout=subprocess.PIPE, stderr=subprocess.STDOUT, text=True, **options) + except OSError: + raise HullSourceError("OpenSCAD could not be started") from None + if on_start: + on_start(process.pid) + try: + output, _ = process.communicate(timeout=OPENSCAD_TIMEOUT) + except subprocess.TimeoutExpired: + process.kill() + process.communicate() + raise HullSourceError("OpenSCAD did not finish a part in time") from None + if process.returncode != 0: + raise HullSourceError("OpenSCAD refused a part of the hull source") + return output + + def openscad_runner(executable, source, library=FACTIONS_DIR, on_start=None): """A Runner that runs OpenSCAD (Manifold backend, full render) on source, one process at a time. on_start(pid) is called with each process's id. OPENSCADPATH is the language folder, so a hull source includes .""" @@ -390,20 +516,7 @@ def openscad_runner(executable, source, library=FACTIONS_DIR, on_start=None): target = Path(folder) / ("part.echo" if part in ECHO_PARTS else "part.off") command = [str(executable), "--backend", "Manifold", "--render", "all", "-D", f'part="{part}"', "-o", str(target), str(source)] - try: - process = subprocess.Popen(command, stdout=subprocess.PIPE, stderr=subprocess.STDOUT, text=True, env=env) - except OSError: - raise HullSourceError("OpenSCAD could not be started") from None - if on_start: - on_start(process.pid) - try: - output, _ = process.communicate(timeout=OPENSCAD_TIMEOUT) - except subprocess.TimeoutExpired: - process.kill() - process.communicate() - raise HullSourceError("OpenSCAD did not finish a part in time") from None - if process.returncode != 0: - raise HullSourceError("OpenSCAD refused a part of the hull source") + output = _run_openscad(command, on_start, env=env) written = target.read_text(encoding="utf-8", errors="replace") if target.exists() else "" return ("", written + output) if part in ECHO_PARTS else (written, output) @@ -420,3 +533,24 @@ def language_library(executable, library=FACTIONS_DIR, on_start=None): return openscad_runner(executable, file, library=library, on_start=on_start)("lang")[1] return read + + +def openscad_projector(executable, on_start=None): + """A Projector that has OpenSCAD (Manifold backend) project the union of the meshes onto the XY plane and export it as + SVG, one process, with the same timeout and refusal shape as openscad_runner.""" + def project(meshes): + with tempfile.TemporaryDirectory() as folder: + names = [] + for i, mesh in enumerate(meshes): + (Path(folder) / f"m{i}.off").write_text(to_off(mesh), encoding="utf-8") + names.append(f'import("m{i}.off");') + wrapper = Path(folder) / "silhouette.scad" + wrapper.write_text("projection() union() { " + " ".join(names) + " }\n", encoding="utf-8") + target = Path(folder) / "silhouette.svg" + command = [str(executable), "--backend", "Manifold", "-o", str(target), str(wrapper)] + _run_openscad(command, on_start, cwd=folder) + if not target.exists(): + raise HullSourceError("OpenSCAD refused a part of the hull source") + return parse_svg_contours(target.read_text(encoding="utf-8", errors="replace")) + + return project diff --git a/tools/blender/tests/test_hull_source.py b/tools/blender/tests/test_hull_source.py index bd534d83b..a1d8d163b 100644 --- a/tools/blender/tests/test_hull_source.py +++ b/tools/blender/tests/test_hull_source.py @@ -67,8 +67,18 @@ class Fixture: + echo("anchor", ["Gun.A.muzzle", "weapon-muzzle", [0, -3, 3], 0, "Gun.A"]) + echo("anchor", ["Th.A", "thruster-emitter", [1, 5, 1], 180, ""])), "emit:Th.A": (box(0.5, 1.5, 5, 5.1, 0.5, 1.5), ""), + "lines": ("", ""), } + @staticmethod + def projector(meshes): + """A top view that is the bounding rectangle of every mesh it is given.""" + lows = [m.bounds()[0] for m in meshes] + highs = [m.bounds()[1] for m in meshes] + x0, y0 = min(v[0] for v in lows), min(v[1] for v in lows) + x1, y1 = max(v[0] for v in highs), max(v[1] for v in highs) + return [[(x0, y0), (x1, y0), (x1, y1), (x0, y1)]] + def runner(self): return lambda part: ("", self.hull_lang if self.hull_lang is not None else "".join(self.library.values())) if part == "lang" else self.parts[part] @@ -78,7 +88,7 @@ class Fixture: return self.library[faction] def build(self): - return hs.build(self.runner(), self.read_library, self.hull_type) + return hs.build(self.runner(), self.read_library, self.hull_type, self.projector) def failed(self): return [c.name for c in self.build().failed()] @@ -206,7 +216,7 @@ class HullTypeTests(unittest.TestCase): def test_build_has_no_default_hull_type(self): fixture = Fixture() with self.assertRaises(TypeError): - hs.build(fixture.runner(), fixture.read_library) + hs.build(fixture.runner(), fixture.read_library, projector=fixture.projector) def test_build_refuses_a_hull_type_that_is_not_a_hulltype_name_before_reading_the_source(self): for hull_type in ("ship", "SHIP", "Ship ", "", None, "Stations", 0, SECRET): @@ -214,7 +224,7 @@ class HullTypeTests(unittest.TestCase): fixture, read = Fixture(), [] runner = fixture.runner() with self.assertRaises(hs.HullSourceError) as caught: - hs.build(lambda part: read.append(part) or runner(part), fixture.read_library, hull_type) + hs.build(lambda part: read.append(part) or runner(part), fixture.read_library, hull_type, fixture.projector) self.assertEqual(read, []) self.assertNotIn(SECRET, str(caught.exception)) @@ -479,7 +489,7 @@ class LanguageOwnershipTests(unittest.TestCase): def test_the_library_is_asked_for_the_faction_and_the_donor_the_brief_names(self): asked = [] fixture = DonorTests.pirate() - hs.build(fixture.runner(), lambda faction: asked.append(faction) or fixture.read_library(faction), "Ship") + hs.build(fixture.runner(), lambda faction: asked.append(faction) or fixture.read_library(faction), "Ship", fixture.projector) self.assertEqual(sorted(asked), ["pirate", "rnd"]) @@ -667,5 +677,187 @@ class RefusalTests(unittest.TestCase): self.assertEqual(source.count("subprocess.Popen"), 1) +LINES_OVER = "every kit line lies over the hull's top view" +LINES_DISTINCT = "no two kit lines are the same" +SILHOUETTE = "the top view yields a silhouette" + + +def line(layer="seam", points=((-1, -1, 2), (1, 1, 2)), cyclic=False): + return echo("line", [layer, cyclic, [list(p) for p in points]]) + + +class LineReadingTests(unittest.TestCase): + def test_lines_are_read_in_echo_order_with_their_layer_flag_and_points(self): + text = line("step", ((0, 0, 1), (1, 0, 1), (1, 1, 1)), True) + line("fitting") + read = hs.read_lines(text) + self.assertEqual([(l.layer, l.cyclic, len(l.points)) for l in read], [("step", True, 3), ("fitting", False, 2)]) + self.assertEqual(read[0].points[1], (1, 0, 1)) + + def test_a_layer_outside_the_kit_layers_is_refused_without_echoing_it(self): + for layer in ("silhouette", "outlines", "", SECRET): + with self.subTest(layer=layer), self.assertRaises(hs.HullSourceError) as caught: + hs.read_lines(line(layer)) + self.assertNotIn(SECRET, str(caught.exception)) + + def test_a_line_of_one_point_or_a_point_that_is_not_finite_is_refused(self): + for text in (line(points=((0, 0, 0),)), line(points=()), 'ECHO: line = ["seam", false, [[0, 0, NaN], [1, 1, 1]]]\n', + 'ECHO: line = ["seam", false, [[0, 0, Infinity], [1, 1, 1]]]\n', line(points=((0, 0), (1, 1)))): + with self.subTest(text=text), self.assertRaises(hs.HullSourceError): + hs.read_lines(text) + + def test_a_cyclic_flag_that_is_not_a_boolean_is_refused(self): + with self.assertRaises(hs.HullSourceError): + hs.read_lines(echo("line", ["seam", 1, [[0, 0, 0], [1, 1, 1]]])) + + +SVG = """ + + + +""" + + +class SvgContourTests(unittest.TestCase): + def test_contours_come_back_in_model_coordinates_with_y_negated(self): + contours = hs.parse_svg_contours(SVG) + self.assertEqual(contours[0], [(0.0, 4.0), (4.0, 4.0), (4.0, 1.0), (0.0, 1.0)]) + self.assertEqual(contours[1][0], (-1e-05, -2.0)) + + def test_a_contour_under_a_quarter_square_metre_is_a_sliver_and_dropped(self): + self.assertEqual(len(hs.parse_svg_contours(SVG)), 2) + + def test_a_drawing_with_no_path_has_no_contours(self): + self.assertEqual(hs.parse_svg_contours(""), []) + + +class LineBuildTests(unittest.TestCase): + def built(self, kit="", **parts): + fixture = Fixture() + fixture.parts["lines"] = ("", kit) + fixture.parts.update(parts) + return fixture, fixture.build() + + def test_a_source_with_no_lines_has_the_silhouette_alone_and_passes_every_check(self): + _, built = self.built() + self.assertEqual([(l.layer, l.cyclic) for l in built.lines], [("silhouette", True)]) + self.assertEqual(built.failed(), []) + + def test_the_silhouette_is_first_cyclic_and_at_the_top_of_the_geometry(self): + _, built = self.built(line("outline", ((-1, 0, 1), (1, 0, 1)))) + self.assertEqual([l.layer for l in built.lines], ["silhouette", "outline"]) + self.assertEqual({p[2] for p in built.lines[0].points}, {3.0}) + self.assertEqual({p[:2] for p in built.lines[0].points}, {(-3.0, -5.0), (3.0, -5.0), (3.0, 5.1), (-3.0, 5.1)}) + + def test_each_layer_carries_its_fixed_style(self): + kit = "".join(line(layer) for layer in hs.KIT_LAYERS) + _, built = self.built(kit) + self.assertEqual([l.rgba() for l in built.lines], [hs.LINE_STYLE[l.layer] for l in built.lines]) + self.assertEqual(len({l.rgba() for l in built.lines}), 5) + self.assertEqual(hs.LINE_STYLE["silhouette"], (1.0, 1.0, 1.0, 1.0)) + + def test_the_projector_is_given_every_zone_and_anchor_mesh_that_has_geometry(self): + fixture, given = Fixture(), [] + hs.build(fixture.runner(), fixture.read_library, "Ship", + lambda meshes: given.append(len(meshes)) or fixture.projector(meshes)) + self.assertEqual(given, [3]) # primary, secondary, emit:Th.A + + def test_a_kit_line_off_the_hull_fails_the_over_the_top_view_check_and_nothing_else(self): + _, built = self.built(line(points=((0, 0, 2), (20, 0, 2)))) + self.assertEqual([c.name for c in built.failed()], [LINES_OVER]) + + def test_a_kit_line_a_little_off_the_edge_still_lies_over_the_hull(self): + _, built = self.built(line(points=((3.2, 0, 2), (3.2, 1, 2)))) + self.assertEqual(built.failed(), []) + _, built = self.built(line(points=((3.4, 0, 2), (3.4, 1, 2)))) + self.assertEqual([c.name for c in built.failed()], [LINES_OVER]) + + def test_the_inside_of_a_hole_in_the_silhouette_is_not_over_the_hull(self): + hole = [(-1, -1), (1, -1), (1, 1), (-1, 1)] + outer = [(-3, -5), (3, -5), (3, 5), (-3, 5)] + self.assertTrue(hs.over_silhouette((2, 0, 0), [outer, hole])) + self.assertFalse(hs.over_silhouette((0, 0, 0), [outer, hole])) + + def test_two_equal_kit_lines_fail_the_distinct_check_and_nothing_else(self): + _, built = self.built(line() + line("fitting")) + self.assertEqual([c.name for c in built.failed()], [LINES_DISTINCT]) + + def test_lines_that_differ_by_a_millimetre_or_more_are_distinct(self): + _, built = self.built(line(points=((0, 0, 2), (1, 0, 2))) + line(points=((0, 0.002, 2), (1, 0, 2)))) + self.assertEqual(built.failed(), []) + + def test_no_contour_fails_the_silhouette_check(self): + fixture = Fixture() + built = hs.build(fixture.runner(), fixture.read_library, "Ship", lambda meshes: []) + self.assertEqual([c.name for c in built.failed()], [SILHOUETTE]) + self.assertEqual(built.lines, []) + + def test_a_kit_line_with_no_silhouette_to_lie_over_fails_both_checks(self): + fixture = Fixture() + fixture.parts["lines"] = ("", line()) + built = hs.build(fixture.runner(), fixture.read_library, "Ship", lambda meshes: []) + self.assertEqual(sorted(c.name for c in built.failed()), sorted([SILHOUETTE, LINES_OVER])) + + def test_a_source_with_no_geometry_asks_for_no_silhouette(self): + fixture = Fixture() + fixture.parts.update({"primary": ("", ""), "secondary": ("", ""), "emit:Th.A": ("", "")}) + built = hs.build(fixture.runner(), fixture.read_library, "Ship", lambda meshes: self.fail("projected nothing")) + self.assertEqual(built.lines, []) + + def test_a_refused_line_is_refused_without_echoing_it(self): + fixture = Fixture() + fixture.parts["lines"] = ("", line(SECRET)) + with self.assertRaises(hs.HullSourceError) as caught: + fixture.build() + self.assertNotIn(SECRET, str(caught.exception)) + + +class WrapperOpenScad(FakeOpenScad): + """Keeps the wrapper scad and the OFF files OpenSCAD is given, which vanish with the temp folder.""" + + def __call__(self, command, **kwargs): + wrapper = Path(command[-1]) + self.wrapper = wrapper.read_text(encoding="utf-8") + self.imports = {f.name: f.read_text(encoding="utf-8") for f in wrapper.parent.glob("*.off")} + return super().__call__(command, **kwargs) + + +class ProjectorTests(unittest.TestCase): + def project(self, openscad, meshes): + with mock.patch.object(hs.subprocess, "Popen", openscad): + return hs.openscad_projector("scad")(meshes) + + def test_the_meshes_are_unioned_projected_and_the_svg_read_back(self): + a, b = hs.parse_off(box(0, 1, 0, 1, 0, 1)), hs.parse_off(box(2, 3, 0, 1, 0, 1)) + openscad = WrapperOpenScad(off=SVG) + contours = self.project(openscad, [a, b]) + self.assertEqual(openscad.wrapper.split(), ["projection()", "union()", "{", 'import("m0.off");', 'import("m1.off");', "}"]) + self.assertEqual(sorted(openscad.imports), ["m0.off", "m1.off"]) + self.assertEqual(hs.parse_off(openscad.imports["m1.off"]).bounds(), b.bounds()) + command = openscad.calls[0][0] + self.assertEqual(command[command.index("--backend") + 1], "Manifold") + self.assertTrue(command[command.index("-o") + 1].endswith(".svg")) + self.assertEqual(len(contours), 2) + + def test_a_failed_or_silent_openscad_is_refused_without_echoing_its_output(self): + for openscad in (FakeOpenScad(returncode=1, output=SECRET), FakeOpenScad(output=SECRET)): + with self.assertRaises(hs.HullSourceError) as caught: + self.project(openscad, [hs.parse_off(box(0, 1, 0, 1, 0, 1))]) + self.assertNotIn(SECRET, str(caught.exception)) + + def test_the_process_is_reported_to_on_start(self): + started = [] + with mock.patch.object(hs.subprocess, "Popen", WrapperOpenScad(off=SVG)): + hs.openscad_projector("scad", on_start=started.append)([hs.parse_off(box(0, 1, 0, 1, 0, 1))]) + self.assertEqual(started, [4242]) + + def test_the_lines_part_answers_the_echo(self): + with mock.patch.object(hs.subprocess, "Popen", FakeOpenScad(output=line())): + self.assertEqual(hs.openscad_runner("scad", "hull.scad")("lines"), ("", line())) + + if __name__ == "__main__": unittest.main() From 905074defb43412a9f430b3833e498d56a110201 Mon Sep 17 00:00:00 2001 From: soul Date: Thu, 22 Oct 2026 15:59:56 +0200 Subject: [PATCH 084/307] Add-on: schematic lines are hers (one Grease Pencil) else Build's from the kit; smoke and C# fact Co-Authored-By: Claude Sonnet 5.5 --- .../NakovalnyaPackageTests.cs | 21 +++++ tools/blender/aetheria_ships/__init__.py | 54 ++++++++---- tools/blender/aetheria_ships/ship_cc.py | 2 +- tools/blender/tests/smoke_hull_source.py | 84 +++++++++++++++++++ tools/blender/tests/test_ship_cc.py | 2 +- 5 files changed, 147 insertions(+), 16 deletions(-) diff --git a/tests/Aetheria.Shared.Tests/NakovalnyaPackageTests.cs b/tests/Aetheria.Shared.Tests/NakovalnyaPackageTests.cs index d2b08f8ba..29d15db31 100644 --- a/tests/Aetheria.Shared.Tests/NakovalnyaPackageTests.cs +++ b/tests/Aetheria.Shared.Tests/NakovalnyaPackageTests.cs @@ -120,4 +120,25 @@ public sealed class NakovalnyaPackageTests .Select(row => $"{row.Item1} refuses the {row.Item2}").ToArray(); Assert.Empty(refused); } + + // The package's schematic is the kit's (ruling agent-hull-schematic-from-kit): hull_source's silhouette first, then + // the kit's lines; Studio fits their top view onto the hull Shape's whole cell rect. + [Fact] + public void SchematicLinesComeFromTheKit() + { + using var cache = Catalog(); + var hull = Hull(cache); + var ship = cache.Get(new CultRecordRef(ShipModCatalog.AuthoringKey(Id))); + var kitLayers = new[] { "outline", "step", "seam", "fitting" }; + + Assert.Equal("silhouette", ship.SchematicLines[0].Layer); + Assert.True(ship.SchematicLines[0].Cyclic); + Assert.True(ship.SchematicLines.Count(line => kitLayers.Contains(line.Layer)) > 20); + Assert.All(ship.SchematicLines, line => Assert.Contains(line.Layer, kitLayers.Append("silhouette"))); + ShipAuthoringStore.Validate(hull, ship); + + var fit = SchematicUnderlay.Fit(ship.SchematicLines, hull.Shape).SelectMany(line => line).ToArray(); + Assert.Equal((19, 30), (hull.Shape.Width, hull.Shape.Height)); + Assert.Equal((0f, 0f, 19f, 30f), (fit.Min(p => p.x), fit.Min(p => p.y), fit.Max(p => p.x), fit.Max(p => p.y))); + } } diff --git a/tools/blender/aetheria_ships/__init__.py b/tools/blender/aetheria_ships/__init__.py index 17ad00afc..08daa095c 100644 --- a/tools/blender/aetheria_ships/__init__.py +++ b/tools/blender/aetheria_ships/__init__.py @@ -533,12 +533,11 @@ class AETHERIA_OT_bind_ship_collection(bpy.types.Operator): class AETHERIA_OT_capture_ship_lines(bpy.types.Operator): bl_idname = "aetheria.capture_ship_lines" bl_label = "Capture Ship Lines" - bl_description = "Replace only the line slot in the selected typed ship .cc record" + bl_description = "Replace only the line slot in the bound ship .cc record: from the Grease Pencil object, else from the hull source" bl_options = {"REGISTER"} def execute(self, context): try: - obj = context.active_object collection = _ship_collection(context) if not collection.get("aetheria.ship_cc"): raise ValueError("The ship collection has no bound .cc path") @@ -546,10 +545,9 @@ class AETHERIA_OT_capture_ship_lines(bpy.types.Operator): cultlib = _brokkr_cultlib(context) if read(path, cultlib).ship.body[0] != collection["aetheria.id"]: raise ValueError("The bound collection ID does not match its .cc ship ID") - lines = capture_grease_pencil( - obj, context.evaluated_depsgraph_get(), context.scene.frame_current, - evaluated=context.scene.aetheria_capture_evaluated_lines, - ) + lines = _schematic_lines(context, collection) + if lines is None: + raise ValueError("No schematic lines were captured; the ship record was not changed") count = replace_lines(path, cultlib, lines) self.report({"INFO"}, f"Captured {count} lines") return {"FINISHED"} @@ -1492,6 +1490,7 @@ def _frame_ship(context, collection, root, path, saved_shape): HULL_MAKER = "aetheria.hull_maker" # on the collection: the Faction key of the language Build judged the hull against +HULL_LINES = "aetheria.hull_lines" # on the collection: [{layer, cyclic, points (flat), rgba}] the hull source's kit drew, in Ship Root's frame HULL_BUILT = "aetheria.hull_built" # on the collection: {object name: fingerprint} of what Build From Hull Source made HULL_AGENT = "agent" # HS rows H5, H7-H10, H12: Build's judged body or record governs; Package checks the maker HULL_ART = "art" # HS rows H4, H6, H11: her meshes replace Build's; Package ships them and retires the hull source @@ -1577,7 +1576,7 @@ def _build_hull_source(context, collection): executable = addon.preferences.openscad if addon and addon.preferences else DEFAULT_OPENSCAD hull_type = read_hull_type(bpy.path.abspath(collection["aetheria.ship_cc"]), _brokkr_cultlib(context)) hull = hull_source.build(hull_source.openscad_runner(executable, source), hull_source.language_library(executable), - hull_type) + hull_type, hull_source.openscad_projector(executable)) broken = hull.failed() if broken: raise ValueError("The hull source breaks its faction's language or its brief: " + "; ".join(c.name for c in broken)) @@ -1629,9 +1628,38 @@ def _build_hull_source(context, collection): collider["aetheria.role"], collider["aetheria.id"] = role, anchor_id collection[HULL_BUILT] = {obj.name: _fingerprint(obj) for obj in made} collection[HULL_MAKER] = hull.languages[hull.brief.faction].maker + collection[HULL_LINES] = [{"layer": line.layer, "cyclic": line.cyclic, "points": [c for p in line.points for c in p], + "rgba": list(line.rgba())} for line in hull.lines] return hull +def _schematic_lines(context, collection): + """The ShipAuthoring.SchematicLines rows Package and Capture write, and the only source of them: her Grease Pencil + object if the collection has one (two or more are refused), else the lines Build recorded from the hull source's kit, + mapped through Ship Root's matrix_world (so after _frame_ship, the frame her strokes are captured in), else None and + the slot is left as it is.""" + pencils = [obj for obj in collection.all_objects if obj.type == "GREASEPENCIL"] + if len(pencils) > 1: + raise ValueError("Keep one Grease Pencil object: it is the ship's schematic") + scene = context.scene + if pencils: + return capture_grease_pencil(pencils[0], context.evaluated_depsgraph_get(), scene.frame_current, + evaluated=scene.aetheria_capture_evaluated_lines) + record = collection.get(HULL_LINES) + if record is None: + return None + root = _ship_root(collection) + world = root.matrix_world if root is not None else Matrix.Identity(4) + rows = [] + for line in record: + flat = list(line["points"]) + points = [] + for x, y, z in zip(flat[0::3], flat[1::3], flat[2::3]): + points.extend(float(c) for c in world @ Vector((x, y, z))) + rows.append([line["layer"], "", points, None, None, bool(line["cyclic"]), [float(c) for c in line["rgba"]]]) + return rows + + def _hull_state(collection): """The one owner of a ship collection's hull state (map HS), decided in this order: an untouched Build zone mesh keeps the hull agent's; her meshes beside Build's record or hull.scad make it art; then Build's record is agent, @@ -1659,7 +1687,7 @@ def _retire_hull_source(collection): source = _hull_source_file(collection) if source is not None: source.unlink() - for record in (HULL_BUILT, HULL_MAKER): + for record in (HULL_BUILT, HULL_MAKER, HULL_LINES): if record in collection: del collection[record] return True @@ -1708,7 +1736,7 @@ class AETHERIA_OT_package_ship(bpy.types.Operator): bl_idname = "aetheria.package_ship" bl_label = "Package Ship" bl_description = ("Put the grid's centre of mass at the world origin, derive the missing map icon, shield and " - "tractor, write the anchors and ship.glb from the bound collection, capture its LineArt, and " + "tractor, write the anchors and ship.glb from the bound collection, capture its schematic lines, and " "run AetherDb's validator") bl_options = {"REGISTER"} @@ -1738,11 +1766,9 @@ class AETHERIA_OT_package_ship(bpy.types.Operator): anchors = _anchors(collection) _export_glb(context, collection, str(Path(path).with_name(MODEL_ASSET))) replace_visual(path, cultlib, ship_id, MODEL_ASSET, anchors) - pencils = [obj for obj in collection.all_objects if obj.type == "GREASEPENCIL"] - if len(pencils) == 1: - replace_lines(path, cultlib, capture_grease_pencil( - pencils[0], context.evaluated_depsgraph_get(), scene.frame_current, - evaluated=scene.aetheria_capture_evaluated_lines)) + lines = _schematic_lines(context, collection) + if lines is not None: + replace_lines(path, cultlib, lines) code, message = _aetherdb(context, "validate", path, ship_cc=path) except _EXPECTED as exc: scene.aetheria_package_report = f"Package failed: {_failure_text(exc)}" diff --git a/tools/blender/aetheria_ships/ship_cc.py b/tools/blender/aetheria_ships/ship_cc.py index 079296be4..f3742dd4f 100644 --- a/tools/blender/aetheria_ships/ship_cc.py +++ b/tools/blender/aetheria_ships/ship_cc.py @@ -173,7 +173,7 @@ def _stamped(record: Record, msgpack: Any): def replace_lines(path: str, cultlib_packages: str, lines: list[list[Any]]) -> int: file = read(path, cultlib_packages) if not lines: - raise ValueError("No Grease Pencil strokes were captured; the ship record was not changed") + raise ValueError("No schematic lines were captured; the ship record was not changed") file.ship.body[SCHEMATIC_LINES_SLOT] = lines file.store.push(_stamped(file.ship, file.msgpack)) return len(lines) diff --git a/tools/blender/tests/smoke_hull_source.py b/tools/blender/tests/smoke_hull_source.py index 06f2db715..b57dfe5af 100644 --- a/tools/blender/tests/smoke_hull_source.py +++ b/tools/blender/tests/smoke_hull_source.py @@ -36,6 +36,10 @@ the hull.scad into the package and runs the panel's operators: or a product by another maker than the language Build judged it against (hull-names-its-own-faction); with a product by that maker it accepts, with hull.scad in the package or not (the guard keys on the hull's state), and a refused Package changes nothing in the scene. Her art needs no product. + Schematic lines: after Build and Package the ship.cc's first line is the silhouette and the kit's lines follow (the Pirate + hull's kit draws none); a Grease Pencil object in the collection replaces them with its own; a second one is refused by + Package and Capture Ship Lines, and the slot is as it was. The Nakovalnya's hull.scad, read with the real kit, has a + silhouette whose box is the zones' top-view box and a fitting octagon at every flak mount anchor. Build and Package refuse copies that share an aetheria.id, selecting the original and the copies, and delete nothing. Package moves no row and turns no mount: every layout row is as authored after it, and a hand-turned mount keeps its yaw. The ship.cc HullData.HullType decides whether the language's proportions judge the hull: a body far wider than the @@ -366,6 +370,37 @@ def one_hull(scene, number, fixture, ship_id, extent, length, rows, body, palett require(saved == [(mount_id, cell) for mount_id, _, cell in rows], f"A row is not as authored after Package: {saved}") print(f"PASS {number}.5 Package accepts the built ship and keeps hull.scad") + # Schematic lines: Build's, else hers, and never two of hers. + packaged = ship_lines(ship_cc) + require(packaged and packaged[0][0] == "silhouette" and packaged[0][5] is True, f"The first line is not the silhouette: {packaged[:1]}") + kit = packaged[1:] + require(all(row[0] in hull_source.KIT_LAYERS and row[3] is None and row[4] is None and row[1] == "" for row in kit), + "A kit line row is not shaped as Package writes it") + require(bool(kit) == (fixture == "zh-ammo-tender"), f"The kit drew {len(kit)} lines for {fixture}") + require(all(approx(row[6], hull_source.LINE_STYLE[row[0]]) for row in packaged), "A line lost its style") + shipped_z = {round(c, 3) for row in packaged for c in row[2][2::3]} + require(len(shipped_z) >= 1, "The lines have no height") + pencil = grease_pencil(collection, "her lines", [(0, 0, 0), (1, 0, 0), (1, 1, 0)]) + select(box) + result, message = run(bpy.ops.aetheria.package_ship) + require(result == {"FINISHED"}, f"Package refused a ship with one Grease Pencil: {message!r}") + hers = ship_lines(ship_cc) + require(len(hers) == 1 and hers[0][0] == "hers" and len(hers[0][2]) == 9, f"Her Grease Pencil did not replace the kit's lines: {hers}") + second = grease_pencil(collection, "second lines", [(0, 0, 0), (2, 0, 0)]) + before = ship_lines(ship_cc) + for operator in (bpy.ops.aetheria.package_ship, bpy.ops.aetheria.capture_ship_lines): + select(box) + result, message = run(operator) + require(result == {"CANCELLED"} and "Keep one Grease Pencil object" in (message + scene.aetheria_package_report), + f"Two Grease Pencil objects were not refused: {message!r}") + require(ship_lines(ship_cc) == before, "A refused schematic changed the line slot") + for obj in (pencil, second): + bpy.data.objects.remove(obj) + select(box) + result, message = run(bpy.ops.aetheria.package_ship) + require(result == {"FINISHED"} and len(ship_lines(ship_cc)) == len(packaged), f"Without Grease Pencil the kit's lines are back: {message!r}") + print(f"PASS {number}.5b the kit's lines ship, her Grease Pencil wins, two are refused") + # What Package did to the built objects is not an edit: Build still replaces them. select(box) result, message = run(bpy.ops.aetheria.build_hull_source) @@ -566,6 +601,54 @@ def clear(collection): bpy.data.objects.remove(obj) +def ship_lines(ship_path): + """The ShipAuthoring.SchematicLines rows of a ship.cc, as Package wrote them.""" + return sys.modules["aetheria_ships"].read(ship_path, PACKAGES).ship.body[sys.modules["aetheria_ships"].ship_cc.SCHEMATIC_LINES_SLOT] + + +def grease_pencil(collection, name, points): + """A Grease Pencil object in the collection holding one stroke, layer named 'hers'.""" + data = bpy.data.grease_pencils.new(name) + material = bpy.data.materials.new(name) + bpy.data.materials.create_gpencil_data(material) + data.materials.append(material) + layer = data.layers.new("hers") + drawing = layer.frames.new(1).drawing + drawing.add_strokes([len(points)]) + for point, position in zip(drawing.strokes[0].points, points): + point.position = position + obj = bpy.data.objects.new(name, data) + collection.objects.link(obj) + return obj + + +def nakovalnya_frames(executable): + """The Nakovalnya's hull.scad with the real kit: the silhouette is the zones' top view and a fitting octagon sits at + every flak mount (the frame the kit placed the barrel in is the frame the anchor is in).""" + source = REPO / "GameData" / "Mods" / "aetheria.nakovalnya" / "hull.scad" + built = hull_source.build(hull_source.openscad_runner(executable, source), hull_source.language_library(executable), + "Ship", hull_source.openscad_projector(executable)) + require(not built.failed(), f"The Nakovalnya breaks a check: {[c.name for c in built.failed()]}") + silhouette = [row for row in built.lines if row.layer == "silhouette"] + require(built.lines[0].layer == "silhouette" and len(silhouette) == 1, "The Nakovalnya silhouette is not one first contour") + xs = [v[0] for mesh in built.zones.values() for v in mesh.vertices] + ys = [v[1] for mesh in built.zones.values() for v in mesh.vertices] + box = (min(xs), max(xs), min(ys), max(ys)) + flat = silhouette[0].points + got = (min(p[0] for p in flat), max(p[0] for p in flat), min(p[1] for p in flat), max(p[1] for p in flat)) + require(approx(box, got, 1e-3), f"The silhouette box {got} is not the zones' top-view box {box}") + octagons = [row.points for row in built.lines if row.layer == "fitting" and row.cyclic and len(row.points) == 8] + centres = [(sum(p[0] for p in o) / 8, sum(p[1] for p in o) / 8) for o in octagons] + mounts = [a for a in built.anchors if a.id.startswith("Gun.F") and a.role == "weapon-mount"] + require(len(mounts) == 10, f"The Nakovalnya has {len(mounts)} flak mounts") + for mount in mounts: + near = min(math.hypot(mount.position[0] - c[0], mount.position[1] - c[1]) for c in centres) + require(near <= 0.3, f"{mount.id} is {near:.2f} m from the nearest fitting octagon") + kinds = {row.layer for row in built.lines} + require({"silhouette", "outline", "step", "seam", "fitting"} <= kinds, f"The Nakovalnya lacks a layer: {kinds}") + print(f"PASS frames the Nakovalnya's {len(built.lines)} lines are in the frame of its geometry") + + def aetherdb(ship_path, *args): """Runs one AetherDb ship-authoring command through the add-on's own runner; returns its exit code.""" return sys.modules["aetheria_ships"]._aetherdb(bpy.context, *args, ship_cc=ship_path)[0] @@ -797,6 +880,7 @@ def main(): clear(agent_guards(scene)) clear(hull_type_gate(scene)) never_built(scene) + nakovalnya_frames(EXECUTABLE) created = not MODS.exists() diff --git a/tools/blender/tests/test_ship_cc.py b/tools/blender/tests/test_ship_cc.py index 828352438..ac01b2faa 100644 --- a/tools/blender/tests/test_ship_cc.py +++ b/tools/blender/tests/test_ship_cc.py @@ -368,7 +368,7 @@ class ReplaceLinesTests(ShipFileCase): def test_refuses_an_empty_capture_and_changes_nothing(self): before = self.bytes() - with self.assertRaisesRegex(ValueError, "No Grease Pencil strokes"): + with self.assertRaisesRegex(ValueError, "No schematic lines"): ship_cc.replace_lines(self.path, PACKAGES, []) self.assertEqual(before, self.bytes()) From d5d94fb48316b91ae7a35fca7fa9c057a456c1cb Mon Sep 17 00:00:00 2001 From: soul Date: Thu, 22 Oct 2026 16:00:08 +0200 Subject: [PATCH 085/307] Staged Approach: the pilot starts knowing its anchor, through the sensors' own info state (ruling stage-states-pilot-knowledge) Co-Authored-By: Claude Sonnet 5.5 --- Assets/Scripts/ServerShared/RunStart.cs | 6 ++ Assets/Scripts/ServerShared/ScenarioStage.cs | 6 +- .../ApproachKnowledgeTests.cs | 60 +++++++++++++++++++ 3 files changed, 71 insertions(+), 1 deletion(-) create mode 100644 tests/Aetheria.Shared.Tests/ApproachKnowledgeTests.cs diff --git a/Assets/Scripts/ServerShared/RunStart.cs b/Assets/Scripts/ServerShared/RunStart.cs index 2e1245bab..20269e84c 100644 --- a/Assets/Scripts/ServerShared/RunStart.cs +++ b/Assets/Scripts/ServerShared/RunStart.cs @@ -55,6 +55,12 @@ public static class RunStart player.SetIff(placed.Entity, hostile); arena.Admit(placed.Entity, placed.Piloted, placed.Task); } + + // A start state includes what a pilot knows (ruling stage-states-pilot-knowledge): its anchor is tracked from the first + // step, by the sensors' own path (info gathered past the detection threshold), so they can lose it like any track. + foreach (var placed in stage.Placed) + if (placed.Knows != null && placed.Piloted) + placed.Entity.EntityInfoGathered[placed.Knows] = (itemManager.GameplaySettings.TargetDetectionInfoThreshold + 1f) / 2f; return new Staged { Player = player, Credits = stage.Credits, Entities = stage.Placed.Select(placed => placed.Entity).ToList() }; } diff --git a/Assets/Scripts/ServerShared/ScenarioStage.cs b/Assets/Scripts/ServerShared/ScenarioStage.cs index f0df352e9..a9e2db5d0 100644 --- a/Assets/Scripts/ServerShared/ScenarioStage.cs +++ b/Assets/Scripts/ServerShared/ScenarioStage.cs @@ -50,6 +50,9 @@ public sealed class ScenarioStage public ScenarioStance Stance; public bool Piloted; public AgentTask Task; + // What the pilot already knows at the start: an entity its sensors hold a track of (Approach). Seeded through the + // sensors' own state at admission, so they may lose it honestly. + public Entity Knows; } // A hull fitted slot by slot: each slot a design or a product, the hull cell it goes in, and its rotation. A name is @@ -137,7 +140,7 @@ public sealed class ScenarioStage } // The task a piloted entity starts on: it closes on the target and holds there (the flight's follow task, standoff - // nothing), in place of the patrol circuit the zone would draw for it. A check that rests on where a pilot goes states + // nothing), in place of the patrol circuit the zone would draw for it. The pilot starts knowing it (RunStart seeds a sensor track). A check that rests on where a pilot goes states // it here, so the pilot's course is the scenario's and not a draw. An entity that is not placed and piloted, or a // target that is not placed, is a failure. public void Approach(Entity entity, Entity target) @@ -146,6 +149,7 @@ public sealed class ScenarioStage if (placement == null || !placement.Piloted) { _failures.Add($"approach: {entity?.Name ?? "no entity"} is not a placed, piloted ship"); return; } if (target == null) { _failures.Add("approach: no target to close on"); return; } placement.Task = new FollowTask { Anchor = PilotView.Handle(target), Standoff = 0f }; + placement.Knows = target; } // Items in the entity's first cargo bay, one per name. A name is an equippable or consumable design (open: the stage diff --git a/tests/Aetheria.Shared.Tests/ApproachKnowledgeTests.cs b/tests/Aetheria.Shared.Tests/ApproachKnowledgeTests.cs new file mode 100644 index 000000000..98f857913 --- /dev/null +++ b/tests/Aetheria.Shared.Tests/ApproachKnowledgeTests.cs @@ -0,0 +1,60 @@ +using System.Linq; +using CultMath; +using Xunit; +using static CultMath.math; +using float2 = CultMath.float2; +using float3 = CultMath.float3; + +// A staged Approach states what its pilot already knows (ruling stage-states-pilot-knowledge): the anchor is a sensor +// track from the first step, and the pilot follows it only as long as the sensors keep it. +public sealed partial class RunStartTests +{ + private (Zone arena, Ship east, Ship west) StagedApproach() + { + Ship east = null, west = null; + var (_, arena, _, failures) = Launch(new Scripted(false, stage => + { + stage.Player(stage.Bare("Djinni"), float2(-50000, -50000)); + east = stage.Place(EvLonginus(stage), float2(450, 0), facing: float2(-1, 0), piloted: true) as Ship; + west = stage.Place(EvLonginus(stage), float2(-450, 0), facing: float2(1, 0), piloted: true) as Ship; + stage.Approach(east, west); + })); + Assert.True(failures.Count == 0, string.Join("; ", failures)); + return (arena, east, west); + } + + [Fact] + public void AStagedApproachFollowerClosesOnItsAnchorFromTheFirstStep() + { + var (arena, east, west) = StagedApproach(); + var view = arena.Agents.Single(a => a.Ship == east).View; + var startGap = east.Position.x - west.Position.x; + + arena.Update(1f / 60f); + arena.Update(1f / 60f); + + Assert.NotNull(view.TrackOf(east, PilotView.Handle(west))); + Assert.True(east.Velocity.x < -.5f, $"the follower thrusts toward its anchor from the first steps (vx {east.Velocity.x})"); + for (var i = 0; i < 120; i++) arena.Update(1f / 60f); + Assert.True(east.Position.x - west.Position.x < startGap - 20f, "the follower closed on its anchor"); + } + + [Fact] + public void AStagedApproachFollowerStopsFollowingOnceTheSensorsLoseTheTrack() + { + var (arena, east, west) = StagedApproach(); + var view = arena.Agents.Single(a => a.Ship == east).View; + var sentinel = new float3(0, 0, 12345); + for (var i = 0; i < 3; i++) arena.Update(1f / 60f); + + east.Command.Aim = sentinel; + arena.Update(1f / 60f); + Assert.NotEqual(sentinel, east.Command.Aim.Value); + + east.EntityInfoGathered[west] = 0f; + Assert.Null(view.TrackOf(east, PilotView.Handle(west))); + east.Command.Aim = sentinel; + arena.Update(1f / 60f); + Assert.Equal(sentinel, east.Command.Aim.Value); + } +} From 5bb4ad5f0f2579f9484638dfb443363b1d082ece Mon Sep 17 00:00:00 2001 From: soul Date: Thu, 22 Oct 2026 16:02:53 +0200 Subject: [PATCH 086/307] Smoke: the silhouette may be several contours, all before the kit's lines Co-Authored-By: Claude Sonnet 5.5 --- tools/blender/tests/smoke_hull_source.py | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/tools/blender/tests/smoke_hull_source.py b/tools/blender/tests/smoke_hull_source.py index b57dfe5af..ba5a12ea6 100644 --- a/tools/blender/tests/smoke_hull_source.py +++ b/tools/blender/tests/smoke_hull_source.py @@ -373,8 +373,10 @@ def one_hull(scene, number, fixture, ship_id, extent, length, rows, body, palett # Schematic lines: Build's, else hers, and never two of hers. packaged = ship_lines(ship_cc) require(packaged and packaged[0][0] == "silhouette" and packaged[0][5] is True, f"The first line is not the silhouette: {packaged[:1]}") - kit = packaged[1:] - require(all(row[0] in hull_source.KIT_LAYERS and row[3] is None and row[4] is None and row[1] == "" for row in kit), + kit = [row for row in packaged if row[0] != "silhouette"] + drawn = [row[0] for row in packaged] + require(drawn[:len(packaged) - len(kit)] == ["silhouette"] * (len(packaged) - len(kit)), "The silhouette contours are not first") + require(all(row[3] is None and row[4] is None and row[1] == "" for row in packaged), "A kit line row is not shaped as Package writes it") require(bool(kit) == (fixture == "zh-ammo-tender"), f"The kit drew {len(kit)} lines for {fixture}") require(all(approx(row[6], hull_source.LINE_STYLE[row[0]]) for row in packaged), "A line lost its style") From 1a2a24ceb1c1726ba4eef9fce2ff8211707c7369 Mon Sep 17 00:00:00 2001 From: soul Date: Thu, 22 Oct 2026 16:05:39 +0200 Subject: [PATCH 087/307] Staged knowledge: repin the two-AI digest for fighters staged knowing each other; LockOnSituation drops the no-track premise Co-Authored-By: Claude Sonnet 5.5 --- tests/Aetheria.Shared.Tests/DeterminismLedgerTests.cs | 5 +++-- tests/Aetheria.Shared.Tests/LockOnScenarioTests.cs | 6 +++--- tests/Aetheria.Shared.Tests/PilotSeamTests.cs | 2 +- 3 files changed, 7 insertions(+), 6 deletions(-) diff --git a/tests/Aetheria.Shared.Tests/DeterminismLedgerTests.cs b/tests/Aetheria.Shared.Tests/DeterminismLedgerTests.cs index e3ab326f0..6ff03b525 100644 --- a/tests/Aetheria.Shared.Tests/DeterminismLedgerTests.cs +++ b/tests/Aetheria.Shared.Tests/DeterminismLedgerTests.cs @@ -24,8 +24,9 @@ public sealed partial class RunStartTests // The two-AI fight's digest, captured on Yggdrasil. Replays are trusted only on the host class that recorded them // (ruling replays-per-host-class), and arenas run on Linux. private const string TwoAiPinnedHostClass = "linux-x64"; - // Repinned once, for the side split: the two fighters are unaffiliated, were one side and each followed itself. - private const string TwoAiPinnedDigest = "1b2ffe51f799e762199717a3bc003e880a80106023463aabc40bd2e4f746697c"; + // Repinned twice. First for the side split: the two fighters are unaffiliated, were one side and each followed itself. + // Then for staged knowledge (ruling repin-for-staged-knowledge): the fighters are staged knowing each other, so they engage at step 0. + private const string TwoAiPinnedDigest = "ce81a426c40495e4b4b7b7f83ad23767cc781dce5523d713b4455e0aa5b7fe33"; private const int TwoAiSteps = 3600; [Fact] diff --git a/tests/Aetheria.Shared.Tests/LockOnScenarioTests.cs b/tests/Aetheria.Shared.Tests/LockOnScenarioTests.cs index 603a6bea0..e88abc220 100644 --- a/tests/Aetheria.Shared.Tests/LockOnScenarioTests.cs +++ b/tests/Aetheria.Shared.Tests/LockOnScenarioTests.cs @@ -23,10 +23,10 @@ public sealed partial class RunStartTests var toPlayer = normalize(player.Position.xz - hostile.Position.xz); Assert.True(dot(normalize(hostile.Direction), toPlayer) >= cos(radians(5f)), "the hostile faces the player"); - // The first real sensing tick: the hostile holds no track of the player, and the sim's own passive sensing, asked for - // the player's signature at the staged place, settles below the detection threshold: the staging is out of its sight. + // The hostile is staged knowing the player (ruling stage-states-pilot-knowledge): it starts with a track of it. The sim's + // own passive sensing, asked for the player's signature at the staged place, settles below the detection threshold: the + // staging is out of its sight, so the staged track is all that carries it in. arena.Update(.1f); - Assert.False(hostile.VisibleEnemies.Any(x => ReferenceEquals(x, player)), "the player is out of sight"); Assert.DoesNotContain(player.IncomingPaints, paint => paint.Mode == PaintMode.Track); Assert.True(player.Visibility > 0f, "the player shows a signature to be seen by"); var threshold = player.ItemManager.GameplaySettings.TargetDetectionInfoThreshold; diff --git a/tests/Aetheria.Shared.Tests/PilotSeamTests.cs b/tests/Aetheria.Shared.Tests/PilotSeamTests.cs index 3bfeb9f42..64eb493d8 100644 --- a/tests/Aetheria.Shared.Tests/PilotSeamTests.cs +++ b/tests/Aetheria.Shared.Tests/PilotSeamTests.cs @@ -462,7 +462,7 @@ public sealed partial class RunStartTests var aim = normalize(east.Command.Aim.Value.xz); var toThird = dot(aim, normalize(third.Position.xz - east.Position.xz)); var toWest = dot(aim, normalize(west.Position.xz - east.Position.xz)); - Assert.True(toThird > toWest + .2f, $"aims at the designated target: toThird {toThird} toWest {toWest}"); + Assert.True(toThird > toWest + .2f, $"aims at the designated target: toThird {toThird} toWest {toWest} tracked {east.VisibleEntities.Contains(third)}"); } [Fact] From 66c69cdc90c7fd6bed79d32da028d75803da9a9c Mon Sep 17 00:00:00 2001 From: soul Date: Thu, 22 Oct 2026 16:06:19 +0200 Subject: [PATCH 088/307] Smoke: sideways-facing kit parts draw no lines (octagon and line counts of the Nakovalnya) Co-Authored-By: Claude Sonnet 5.5 --- tools/blender/tests/smoke_hull_source.py | 3 +++ 1 file changed, 3 insertions(+) diff --git a/tools/blender/tests/smoke_hull_source.py b/tools/blender/tests/smoke_hull_source.py index ba5a12ea6..c60967253 100644 --- a/tools/blender/tests/smoke_hull_source.py +++ b/tools/blender/tests/smoke_hull_source.py @@ -646,6 +646,9 @@ def nakovalnya_frames(executable): for mount in mounts: near = min(math.hypot(mount.position[0] - c[0], mount.position[1] - c[1]) for c in centres) require(near <= 0.3, f"{mount.id} is {near:.2f} m from the nearest fitting octagon") + # Parts that face sideways (the belt collars and turning blocks, the wall hatches) draw nothing: no top view sees them. + require(len(octagons) == 20, f"{len(octagons)} octagons: ten flak drums and ten flak rings, and no belt collar's") + require(sum(row.layer != "silhouette" for row in built.lines) == 102, "The Nakovalnya kit's line count changed") kinds = {row.layer for row in built.lines} require({"silhouette", "outline", "step", "seam", "fitting"} <= kinds, f"The Nakovalnya lacks a layer: {kinds}") print(f"PASS frames the Nakovalnya's {len(built.lines)} lines are in the frame of its geometry") From fd1188a797dbef1fd61703decb1aac0b434404ec Mon Sep 17 00:00:00 2001 From: soul Date: Thu, 22 Oct 2026 16:06:59 +0200 Subject: [PATCH 089/307] hull_source: the silhouette is the zones' top view, what Rasterise reads Co-Authored-By: Claude Sonnet 5.5 --- tools/blender/aetheria_ships/hull_source.py | 6 +++--- tools/blender/tests/test_hull_source.py | 6 +++--- 2 files changed, 6 insertions(+), 6 deletions(-) diff --git a/tools/blender/aetheria_ships/hull_source.py b/tools/blender/aetheria_ships/hull_source.py index ca929560f..2689a7230 100644 --- a/tools/blender/aetheria_ships/hull_source.py +++ b/tools/blender/aetheria_ships/hull_source.py @@ -18,7 +18,7 @@ with it (a hull that redefines or shadows an included language fails a check): Frame: Ship Root, metres, nose -Y, starboard -X, up +Z. build() also takes the hull's HullData.HullType ("Ship", "Station" or "Turret"), which the caller reads: the add-on from the bound package's ship.cc, the bench from its flag. -Schematic lines (HullBuild.lines): the top-view silhouette first (OpenSCAD projection() of every zone and anchor mesh, +Schematic lines (HullBuild.lines): the top-view silhouette first (OpenSCAD projection() of every zone mesh, the geometry Rasterise reads, read from the SVG it exports), then the kit's lines, each with a fixed RGBA from LINE_STYLE. A kit part is placed with lp_at so its line is emitted in the frame the geometry is; a source that echoes no lines has the silhouette alone. A faction language's proportions judge Ship hulls only; every hull's zones must fit the layout grid (hull_grid.MAX_CELLS @@ -384,7 +384,7 @@ def over_silhouette(point, contours): def build(runner, library, hull_type, projector): """Reads the hull source through runner (see Runner) and judges it against the languages library(faction) answers with (the ECHO text of that faction's own language file). projector (see Projector) answers the top-view contours of every - zone and anchor mesh, which become the silhouette line. hull_type is the hull's HullData.HullType name, which the caller + zone mesh, which become the silhouette line. hull_type is the hull's HullData.HullType name, which the caller reads; only a "Ship" is judged on the language's proportions. Raises HullSourceError, before reading anything, when hull_type is not one of ship_cc.HULL_TYPE_NAMES, and when the source cannot be read at all; otherwise returns a HullBuild whose failed() lists the checks it breaks.""" @@ -412,7 +412,7 @@ def build(runner, library, hull_type, projector): body_zone = f"{donor.faction}:{donor.body}" if donor else own.body result = HullBuild(brief, {language.faction: language for language in owned}, zones, colours, anchors, anchor_meshes, own.glow, own.radiator, body_zone, hull_type) - meshes = [m for m in list(zones.values()) + list(anchor_meshes.values()) if m.triangles] + meshes = [m for m in zones.values() if m.triangles] # the meshes Rasterise reads, so the underlay registers on the grid if meshes: top = max(v[2] for m in meshes for v in m.vertices) result.lines = [SchematicLine("silhouette", True, tuple((x, y, top) for x, y in contour)) for contour in projector(meshes)] diff --git a/tools/blender/tests/test_hull_source.py b/tools/blender/tests/test_hull_source.py index a1d8d163b..173b7f551 100644 --- a/tools/blender/tests/test_hull_source.py +++ b/tools/blender/tests/test_hull_source.py @@ -750,7 +750,7 @@ class LineBuildTests(unittest.TestCase): _, built = self.built(line("outline", ((-1, 0, 1), (1, 0, 1)))) self.assertEqual([l.layer for l in built.lines], ["silhouette", "outline"]) self.assertEqual({p[2] for p in built.lines[0].points}, {3.0}) - self.assertEqual({p[:2] for p in built.lines[0].points}, {(-3.0, -5.0), (3.0, -5.0), (3.0, 5.1), (-3.0, 5.1)}) + self.assertEqual({p[:2] for p in built.lines[0].points}, {(-3.0, -5.0), (3.0, -5.0), (3.0, 5.0), (-3.0, 5.0)}) def test_each_layer_carries_its_fixed_style(self): kit = "".join(line(layer) for layer in hs.KIT_LAYERS) @@ -759,11 +759,11 @@ class LineBuildTests(unittest.TestCase): self.assertEqual(len({l.rgba() for l in built.lines}), 5) self.assertEqual(hs.LINE_STYLE["silhouette"], (1.0, 1.0, 1.0, 1.0)) - def test_the_projector_is_given_every_zone_and_anchor_mesh_that_has_geometry(self): + def test_the_projector_is_given_every_zone_mesh_that_has_geometry_and_no_anchor_mesh(self): fixture, given = Fixture(), [] hs.build(fixture.runner(), fixture.read_library, "Ship", lambda meshes: given.append(len(meshes)) or fixture.projector(meshes)) - self.assertEqual(given, [3]) # primary, secondary, emit:Th.A + self.assertEqual(given, [2]) # primary and secondary; the emitter mesh is not part of the layout's top view def test_a_kit_line_off_the_hull_fails_the_over_the_top_view_check_and_nothing_else(self): _, built = self.built(line(points=((0, 0, 2), (20, 0, 2)))) From 03502552373687a9bc67cf4b5c214174bdcd3bfb Mon Sep 17 00:00:00 2001 From: soul Date: Thu, 22 Oct 2026 16:07:35 +0200 Subject: [PATCH 090/307] PilotSeamTests: drop the probe text Co-Authored-By: Claude Sonnet 5.5 --- tests/Aetheria.Shared.Tests/PilotSeamTests.cs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/tests/Aetheria.Shared.Tests/PilotSeamTests.cs b/tests/Aetheria.Shared.Tests/PilotSeamTests.cs index 64eb493d8..3bfeb9f42 100644 --- a/tests/Aetheria.Shared.Tests/PilotSeamTests.cs +++ b/tests/Aetheria.Shared.Tests/PilotSeamTests.cs @@ -462,7 +462,7 @@ public sealed partial class RunStartTests var aim = normalize(east.Command.Aim.Value.xz); var toThird = dot(aim, normalize(third.Position.xz - east.Position.xz)); var toWest = dot(aim, normalize(west.Position.xz - east.Position.xz)); - Assert.True(toThird > toWest + .2f, $"aims at the designated target: toThird {toThird} toWest {toWest} tracked {east.VisibleEntities.Contains(third)}"); + Assert.True(toThird > toWest + .2f, $"aims at the designated target: toThird {toThird} toWest {toWest}"); } [Fact] From b064051efd2222d3da3b8c74f4db7e7d737ccd8d Mon Sep 17 00:00:00 2001 From: soul Date: Thu, 22 Oct 2026 16:11:03 +0200 Subject: [PATCH 091/307] Retarget test: east is staged knowing the third ship (ruling retarget-test-stages-the-track) Co-Authored-By: Claude Sonnet 5.5 --- tests/Aetheria.Shared.Tests/PilotSeamTests.cs | 2 ++ 1 file changed, 2 insertions(+) diff --git a/tests/Aetheria.Shared.Tests/PilotSeamTests.cs b/tests/Aetheria.Shared.Tests/PilotSeamTests.cs index 3bfeb9f42..a1d8870dc 100644 --- a/tests/Aetheria.Shared.Tests/PilotSeamTests.cs +++ b/tests/Aetheria.Shared.Tests/PilotSeamTests.cs @@ -454,6 +454,8 @@ public sealed partial class RunStartTests Assert.IsType(SeamState(agent)); Assert.Same(west, east.Target.Value.Entity); + // East is staged knowing the third ship, as an Approach follower knows its anchor: a sensor track by the sensors' own path. + east.EntityInfoGathered[third] = (east.ItemManager.GameplaySettings.TargetDetectionInfoThreshold + 1f) / 2f; east.SetTarget(TargetRef.None); east.Command.Designate(new TargetRef(third)); agent.Update(SeamDt); From 0cb78d26108ccbbe4681999abb60f5fe3ed7ddc5 Mon Sep 17 00:00:00 2001 From: soul Date: Thu, 22 Oct 2026 16:11:53 +0200 Subject: [PATCH 092/307] hull_source tests: pin style, echo parts, contour area, even-odd and edge tolerance, process refusals, check details Co-Authored-By: Claude Sonnet 5.5 --- tools/blender/tests/test_hull_source.py | 164 ++++++++++++++++++++++++ 1 file changed, 164 insertions(+) diff --git a/tools/blender/tests/test_hull_source.py b/tools/blender/tests/test_hull_source.py index 173b7f551..928bee976 100644 --- a/tools/blender/tests/test_hull_source.py +++ b/tools/blender/tests/test_hull_source.py @@ -7,6 +7,7 @@ real OpenSCAD and the real language files are exercised by smoke_hull_source.py. hull_source is loaded by path because the package __init__ imports bpy. """ +import dataclasses import importlib.util import json import sys @@ -859,5 +860,168 @@ class ProjectorTests(unittest.TestCase): self.assertEqual(hs.openscad_runner("scad", "hull.scad")("lines"), ("", line())) +class LineStyleTests(unittest.TestCase): + def test_the_style_table_is_the_one_the_underlay_reads(self): + self.assertEqual(hs.LINE_STYLE, { + "silhouette": (1.0, 1.0, 1.0, 1.0), "outline": (1.0, 1.0, 1.0, 0.75), "fitting": (1.0, 1.0, 1.0, 0.6), + "step": (1.0, 1.0, 1.0, 0.5), "seam": (1.0, 1.0, 1.0, 0.35)}) + self.assertEqual(hs.KIT_LAYERS, ("outline", "step", "seam", "fitting")) + + def test_a_schematic_line_cannot_be_changed_after_it_is_read(self): + read = hs.read_lines(line())[0] + with self.assertRaises(dataclasses.FrozenInstanceError): + read.layer = "step" + + def test_the_four_echo_parts_are_written_as_echo_files_and_geometry_parts_as_off(self): + for part, suffix in (("brief", ".echo"), ("lang", ".echo"), ("anchors", ".echo"), ("lines", ".echo"), ("primary", ".off")): + with self.subTest(part=part): + openscad = FakeOpenScad() + with mock.patch.object(hs.subprocess, "Popen", openscad): + hs.openscad_runner("scad", "hull.scad")(part) + command = openscad.calls[0][0] + self.assertTrue(command[command.index("-o") + 1].endswith(suffix)) + + +class LineRefusalTextTests(unittest.TestCase): + def refusal(self, text): + with self.assertRaises(hs.HullSourceError) as caught: + hs.read_lines(text) + return str(caught.exception) + + def test_each_refusal_says_one_fixed_thing(self): + self.assertEqual(self.refusal(echo("line", ["seam", False])), "A schematic line has the wrong shape") + self.assertEqual(self.refusal(echo("line", ["seam", 1, []])), "A schematic line has the wrong shape") + self.assertEqual(self.refusal(line("nope")), "A schematic line names a layer a kit does not draw") + self.assertEqual(self.refusal(line(points=((0, 0, 0),))), "A schematic line has fewer than two points") + self.assertEqual(self.refusal('ECHO: line = ["seam", false, [[0, 0, NaN], [1, 1, 1]]]\n'), + "A schematic line has a point that is not a finite number") + + +class ContourAreaTests(unittest.TestCase): + @staticmethod + def svg(*rings): + paths = "\n".join("M " + " L ".join(f"{x},{-y}" for x, y in ring) + " z" for ring in rings) + return f'' + + def test_a_contour_is_kept_from_a_quarter_square_metre_whichever_way_it_winds_or_where_it_lies(self): + square = [(3, 7), (3.5, 7), (3.5, 7.5), (3, 7.5)] + triangle = [(10, 10), (11, 10), (10, 11)] + for ring in (square, square[::-1], triangle, triangle[::-1]): + with self.subTest(ring=ring): + self.assertEqual(len(hs.parse_svg_contours(self.svg(ring))), 1) + + def test_a_contour_just_under_a_quarter_square_metre_is_dropped(self): + for ring in ([(3, 7), (3.6, 7), (3.6, 7.4), (3, 7.4)], [(0, 0), (1, 0), (0, 0.49)]): + with self.subTest(ring=ring): + self.assertEqual(hs.parse_svg_contours(self.svg(ring)), []) + + def test_a_contour_of_fewer_than_three_points_is_dropped(self): + self.assertEqual(hs.parse_svg_contours(self.svg([(0, 0), (9, 9)])), []) + + +class OverSilhouetteTests(unittest.TestCase): + TRIANGLE = [(0.0, 0.0), (10.0, 0.0), (0.0, 10.0)] + + def over(self, x, y, contours=None, z=99.0): + return hs.over_silhouette((x, y, z), [self.TRIANGLE] if contours is None else contours) + + def test_inside_and_far_outside(self): + self.assertTrue(self.over(2, 2)) + self.assertFalse(self.over(8, 8)) + self.assertFalse(self.over(-5, 3)) + + def test_the_height_of_a_point_does_not_matter(self): + self.assertTrue(self.over(2, 2, z=-50.0)) + self.assertFalse(self.over(8, 8, z=2.0)) + + def test_a_point_outside_but_within_the_tolerance_of_a_slanted_edge_is_over_the_hull(self): + # the hypotenuse x + y = 10; (5.1, 5.1) is 0.14 m outside it, (5.3, 5.3) is 0.42 m outside + self.assertTrue(self.over(5.1, 5.1)) + self.assertFalse(self.over(5.3, 5.3)) + + def test_a_point_beyond_the_end_of_an_edge_is_measured_to_its_end(self): + self.assertTrue(self.over(10.25, 0)) + self.assertFalse(self.over(10.35, 0)) + self.assertTrue(self.over(-0.25, -0.0)) + self.assertFalse(self.over(0, -0.35)) + + def test_a_ray_through_a_vertex_counts_the_edge_once(self): + diamond = [(0.0, -2.0), (2.0, 0.0), (0.0, 2.0), (-2.0, 0.0)] + self.assertTrue(self.over(0.5, 0, [diamond])) + self.assertTrue(self.over(-0.5, 0, [diamond])) + self.assertFalse(self.over(3.0, 0, [diamond])) + self.assertFalse(self.over(0.0, 3.0, [diamond])) + + def test_the_even_odd_rule_alternates_through_nested_contours(self): + rings = [[(-s, -s), (s, -s), (s, s), (-s, s)] for s in (10.0, 7.0, 4.0, 1.0)] + self.assertTrue(self.over(8.5, 0, rings)) # in the outer ring only + self.assertFalse(self.over(5.5, 0, rings)) # a hole + self.assertTrue(self.over(2.5, 0, rings)) # island in the hole + + def test_three_nested_contours_put_the_centre_back_on_the_hull(self): + rings = [[(-s, -s), (s, -s), (s, s), (-s, s)] for s in (10.0, 6.0, 2.0)] + self.assertTrue(self.over(0.0, 0.0, rings)) + + +class ProcessRefusalTests(unittest.TestCase): + def test_openscad_that_cannot_start_is_refused_in_fixed_words(self): + with mock.patch.object(hs.subprocess, "Popen", side_effect=OSError(SECRET)): + for make in (lambda: hs.openscad_runner("scad", "hull.scad")("primary"), + lambda: hs.openscad_projector("scad")([hs.parse_off(box(0, 1, 0, 1, 0, 1))])): + with self.assertRaises(hs.HullSourceError) as caught: + make() + self.assertEqual(str(caught.exception), "OpenSCAD could not be started") + + def test_openscad_that_does_not_finish_is_killed_and_refused_in_fixed_words(self): + class Slow(FakeOpenScad): + killed = False + + def communicate(self, timeout=None): + if not self.killed: + raise hs.subprocess.TimeoutExpired("scad", timeout) + return "", None + + def kill(self): + self.killed = True + + slow = Slow() + with mock.patch.object(hs.subprocess, "Popen", slow): + with self.assertRaises(hs.HullSourceError) as caught: + hs.openscad_runner("scad", "hull.scad")("primary") + self.assertEqual((str(caught.exception), slow.killed), ("OpenSCAD did not finish a part in time", True)) + + def test_a_projection_openscad_never_wrote_is_refused(self): + with mock.patch.object(hs.subprocess, "Popen", FakeOpenScad()): + with self.assertRaises(hs.HullSourceError) as caught: + hs.openscad_projector("scad")([hs.parse_off(box(0, 1, 0, 1, 0, 1))]) + self.assertEqual(str(caught.exception), "OpenSCAD refused a part of the hull source") + + def test_a_refusing_openscad_says_so_in_fixed_words(self): + with mock.patch.object(hs.subprocess, "Popen", FakeOpenScad(returncode=2)): + with self.assertRaises(hs.HullSourceError) as caught: + hs.openscad_runner("scad", "hull.scad")("primary") + self.assertEqual(str(caught.exception), "OpenSCAD refused a part of the hull source") + + +class LineCheckDetailTests(unittest.TestCase): + def details(self, kit, projector=Fixture.projector): + fixture = Fixture() + fixture.parts["lines"] = ("", kit) + built = hs.build(fixture.runner(), fixture.read_library, "Ship", projector) + return {c.name: c.detail for c in built.checks} + + def test_the_details_count_what_the_checks_found(self): + got = self.details(line(points=((0, 0, 2), (20, 0, 2))) + line("step") + line("step")) + self.assertEqual((got[SILHOUETTE], got[LINES_OVER], got[LINES_DISTINCT]), ("1 contours", "1 outside", "1 repeated")) + + def test_a_silhouette_is_not_a_kit_line_so_two_equal_contours_are_not_a_repeat(self): + twice = lambda meshes: Fixture.projector(meshes) * 2 + got = self.details("", twice) + self.assertEqual((got[SILHOUETTE], got[LINES_DISTINCT]), ("2 contours", "0 repeated")) + fixture = Fixture() + built = hs.build(fixture.runner(), fixture.read_library, "Ship", twice) + self.assertEqual(built.failed(), []) + + if __name__ == "__main__": unittest.main() From 970155a8aca6da8b5ad53ba0550a24c56d5078ce Mon Sep 17 00:00:00 2001 From: soul Date: Thu, 22 Oct 2026 16:12:17 +0200 Subject: [PATCH 093/307] Nakovalnya package rebuilt through the add-on: schematic lines from the kit (silhouette plus 102 kit lines) Co-Authored-By: Claude Sonnet 5.5 --- GameData/Mods/aetheria.nakovalnya/ship.cc | 4 ++-- GameData/Mods/aetheria.nakovalnya/ship.glb | 4 ++-- 2 files changed, 4 insertions(+), 4 deletions(-) diff --git a/GameData/Mods/aetheria.nakovalnya/ship.cc b/GameData/Mods/aetheria.nakovalnya/ship.cc index 2de9f3458..ebe05cd33 100644 --- a/GameData/Mods/aetheria.nakovalnya/ship.cc +++ b/GameData/Mods/aetheria.nakovalnya/ship.cc @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:2390998c0f36f647fbd7b12098a726445a747fc2dfd57c0e68b9fcc7e9116338 -size 12701 +oid sha256:87d8cbcca252460d1ec2bfeb895c1447ab185172a4a4449b237a297b83ea5057 +size 40059 diff --git a/GameData/Mods/aetheria.nakovalnya/ship.glb b/GameData/Mods/aetheria.nakovalnya/ship.glb index 27ed4683b..3dac76cc9 100644 --- a/GameData/Mods/aetheria.nakovalnya/ship.glb +++ b/GameData/Mods/aetheria.nakovalnya/ship.glb @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:c0f2e8bdb820330a1ba2b49c9615484b971f96634e7108ed675e788c38c3c8ba -size 843456 +oid sha256:90a503473e3b12251c225b7fe283b2fc5b45a25e6e47839d1c3d78e3ac61b00c +size 843452 From 4a6ddaa413b5a3ae803baa5f814d53f389033bdf Mon Sep 17 00:00:00 2001 From: soul Date: Thu, 22 Oct 2026 16:17:00 +0200 Subject: [PATCH 094/307] hull_source tests: a non-UTF-8 byte in the drawing, an unreadable number Co-Authored-By: Claude Sonnet 5.5 --- tools/blender/tests/test_hull_source.py | 20 ++++++++++++++++++++ 1 file changed, 20 insertions(+) diff --git a/tools/blender/tests/test_hull_source.py b/tools/blender/tests/test_hull_source.py index 928bee976..48a401404 100644 --- a/tools/blender/tests/test_hull_source.py +++ b/tools/blender/tests/test_hull_source.py @@ -1023,5 +1023,25 @@ class LineCheckDetailTests(unittest.TestCase): self.assertEqual(built.failed(), []) +class BytesOpenScad(FakeOpenScad): + """Writes raw bytes where -o points: an SVG with a byte that is not UTF-8 in its title.""" + + def __call__(self, command, **kwargs): + self.calls.append((command, kwargs.get("env"))) + Path(command[command.index("-o") + 1]).write_bytes(bytes([255]) + SVG.encode("utf-8")) + return self + + +class SilhouetteReadTests(unittest.TestCase): + def test_a_drawing_with_a_byte_that_is_not_utf8_still_reads(self): + with mock.patch.object(hs.subprocess, "Popen", BytesOpenScad()): + self.assertEqual(len(hs.openscad_projector("scad")([hs.parse_off(box(0, 1, 0, 1, 0, 1))])), 2) + + def test_a_number_that_is_not_a_number_is_refused_in_fixed_words(self): + with self.assertRaises(hs.HullSourceError) as caught: + hs.parse_svg_contours('') + self.assertEqual(str(caught.exception), "OpenSCAD wrote a silhouette that is not readable") + + if __name__ == "__main__": unittest.main() From c1d55952eb0730228fe0578fc4b732ba3dbb7398 Mon Sep 17 00:00:00 2001 From: soul Date: Thu, 22 Oct 2026 16:31:11 +0200 Subject: [PATCH 095/307] Smoke: Build records the kit lines and retiring the hull source deletes them Co-Authored-By: Claude Sonnet 5.5 --- tools/blender/tests/smoke_hull_source.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/tools/blender/tests/smoke_hull_source.py b/tools/blender/tests/smoke_hull_source.py index c60967253..7084274bb 100644 --- a/tools/blender/tests/smoke_hull_source.py +++ b/tools/blender/tests/smoke_hull_source.py @@ -493,7 +493,7 @@ def one_hull(scene, number, fixture, ship_id, extent, length, rows, body, palett report = scene.aetheria_package_report require(result == {"CANCELLED"} and "The package has no product" in report, f"Package took a hull with no zones and no art: {report!r} {message!r}") require((package / "ship.glb").read_bytes() == glb_before, "A refused Package changed ship.glb") - require("aetheria.hull_maker" in collection and "aetheria.hull_built" in collection and source.is_file() == (number % 2 == 1), + require("aetheria.hull_maker" in collection and "aetheria.hull_built" in collection and "aetheria.hull_lines" in collection and source.is_file() == (number % 2 == 1), "A refused Package changed Build's records or hull.scad") print(f"PASS {number}.9a Build's zones deleted with nothing in their place stay under the guard") art_mesh = bpy.data.meshes.new("author hull") @@ -510,7 +510,7 @@ def one_hull(scene, number, fixture, ship_id, extent, length, rows, body, palett report = scene.aetheria_package_report require(result == {"FINISHED"} and "retired the agent hull source" in report, f"Package did not retire the hull source: {report!r} {message!r}") require(not source.exists(), "hull.scad is still there after the author's art was packaged") - require("aetheria.hull_built" not in collection and "aetheria.hull_maker" not in collection, + require("aetheria.hull_built" not in collection and "aetheria.hull_maker" not in collection and "aetheria.hull_lines" not in collection, "The record of Build's objects or its maker outlived hull.scad") require(all(echoed not in report for echoed in (ship_id, str(GAME))), "The report echoes an input") select(box) From 3fa4768d1c76353c9a2b76a4daa5d7ae9aa71b8d Mon Sep 17 00:00:00 2001 From: soul Date: Thu, 22 Oct 2026 16:31:18 +0200 Subject: [PATCH 096/307] Cargo transfer tests: a partial commodity move duplicates the stack left behind (red), also through the buy path Co-Authored-By: Claude Sonnet 5.5 --- .../CargoTransferTests.cs | 153 ++++++++++++++++++ 1 file changed, 153 insertions(+) create mode 100644 tests/Aetheria.Shared.Tests/CargoTransferTests.cs diff --git a/tests/Aetheria.Shared.Tests/CargoTransferTests.cs b/tests/Aetheria.Shared.Tests/CargoTransferTests.cs new file mode 100644 index 000000000..ce8732fe1 --- /dev/null +++ b/tests/Aetheria.Shared.Tests/CargoTransferTests.cs @@ -0,0 +1,153 @@ +using System; +using System.Collections.Generic; +using System.IO; +using System.Linq; +using CultMath; +using GameCult.Caching; +using Xunit; + +// Goods moving bay to bay between ships and stations: the one conserving commit (EquippedCargoBay.TryTransferItem) that +// the docked trade, the sale and the give all go through. Fixture as StationServicesTests: authored settings, Ore. +public sealed class CargoTransferTests : IDisposable +{ + private readonly string _root = Path.Combine(Path.GetTempPath(), "aetheria-cargotransfer-" + Guid.NewGuid().ToString("N")); + private readonly CultCache _cache; + private readonly ItemManager _items; + private readonly Zone _zone; + + public CargoTransferTests() + { + Directory.CreateDirectory(_root); + _cache = AetheriaStores.Open(Path.Combine(_root, "Aetheria.cc"), catalogWritable: true); + _cache.Upsert(new VerseGrammar { Revision = 1 }); + var shape = new Shape(5, 5); + foreach (var cell in shape.AllCoordinates) shape[cell] = true; + _cache.Upsert(new HullData { Name = "Skiff", HullType = HullType.Ship, Shape = shape, Price = 1000, Durability = 10, Armor = 10 }); + _cache.Upsert(new HullData { Name = "Hub", HullType = HullType.Station, Shape = shape, Price = 5000, Durability = 10 }); + var bay = new Shape(4, 4); + foreach (var cell in bay.AllCoordinates) bay[cell] = true; + _cache.Upsert(new CargoBayData { Name = "Crate", Shape = new Shape(), InteriorShape = bay, Price = 20, Durability = 4 }); + var pocket = new Shape(1, 1); + foreach (var cell in pocket.AllCoordinates) pocket[cell] = true; + _cache.Upsert(new CargoBayData { Name = "Pocket", Shape = new Shape(), InteriorShape = pocket, Price = 5, Durability = 4 }); + _cache.Upsert(new SimpleCommodityData { Name = "Ore", Shape = new Shape(), Price = 7, MaxStack = 100 }); + _cache.Upsert(new SimpleCommodityData { Name = "Slag", Shape = new Shape(), Price = 1, MaxStack = 100 }); + _cache.FlushAsync().Wait(); + + var settings = AuthoredSettings.Load(RestoredHullsTests.FindRepoRoot()).Read("GameplaySettings"); + _items = new ItemManager(1u, _cache, new ProvenanceLedger(), settings, _ => { }); + _zone = new Zone(_items, new PlanetSettings(), new ZonePack(), new GalaxyZone { Name = "Test Zone", Owner = null }, null); + } + + public void Dispose() + { + _cache.Dispose(); + Directory.Delete(_root, true); + } + + private EquippableItem Mint(string design) + { + var data = _cache.GetByName(design); + return new EquippableItem + { + Data = _cache.RefOf(data), + Durability = data.Durability, + Lot = _items.Lots.Add(new Lot { Design = _cache.RefOf(data), Origin = new Attributed(), Quality = .5f, Roles = new List() }) + }; + } + + private SimpleCommodity Commodity(string design, int quantity) => + new SimpleCommodity { Data = _cache.RefOf(_cache.GetByName(design)), Quantity = quantity }; + + private Ship ShipWith(string bay) + { + var ship = new Ship(_items, _zone, Mint("Skiff"), new EntitySettings()); + Assert.True(ship.TryEquip(Mint(bay), new int2(1, 1))); + return ship; + } + + private OrbitalEntity Station() + { + var station = new OrbitalEntity(_items, _zone, Mint("Hub"), default, new EntitySettings()); + Assert.True(station.TryEquip(Mint("Crate"), new int2(1, 1))); + return station; + } + + // The units of the same design as `like` that the bay holds, over every stack. + private static int Units(EquippedCargoBay bay, SimpleCommodity like) => + bay.Cargo.Keys.OfType().Where(c => c.Data.Key.Equals(like.Data.Key)).Sum(c => c.Quantity); + + [Fact] + public void PartialCommodityTransferConserves() + { + var from = ShipWith("Crate").CargoBays[0]; + var to = ShipWith("Crate").CargoBays[0]; + var ore = Commodity("Ore", 10); + Assert.True(from.TryStore(ore)); + var massBefore = from.Mass + to.Mass; + + Assert.True(from.TryTransferItem(to, ore, 4)); + Assert.True(from.TryTransferItem(to, ore, 4)); + + Assert.Equal(2, Units(from, ore)); + Assert.Equal(8, Units(to, ore)); + Assert.Empty(from.Cargo.Keys.Intersect(to.Cargo.Keys)); + Assert.Equal(massBefore, from.Mass + to.Mass, 3); + } + + [Fact] + public void ABoughtPartOfAStationStackLeavesTheRestWithTheStation() + { + // TradeMenu.Buy delivers by this exact commit: the station's stack to the player's bay, a part at a time. + var station = Station(); + var stationBay = station.CargoBays[0]; + var player = ShipWith("Crate").CargoBays[0]; + var stock = Commodity("Ore", 10); + Assert.True(stationBay.TryStore(stock)); + var credits = 1000; + + Assert.Equal(BuyResult.Bought, StationServices.TryBuy(_items, stock, 4, ref credits, () => stationBay.TryTransferItem(player, stock, 4))); + Assert.Equal(BuyResult.Bought, StationServices.TryBuy(_items, stock, 4, ref credits, () => stationBay.TryTransferItem(player, stock, 4))); + + Assert.Equal(1000 - 2 * 4 * 7, credits); + Assert.Equal(2, Units(stationBay, stock)); + Assert.Equal(8, Units(player, stock)); + Assert.Empty(stationBay.Cargo.Keys.Intersect(player.Cargo.Keys)); + } + + [Fact] + public void RefusedCommodityTransferChangesNothing() + { + var from = ShipWith("Crate").CargoBays[0]; + var to = ShipWith("Pocket").CargoBays[0]; + var ore = Commodity("Ore", 10); + Assert.True(from.TryStore(ore)); + Assert.True(to.TryStore(Commodity("Slag", 5))); + var at = from.Cargo[ore]; + var massBefore = from.Mass + to.Mass; + + Assert.False(from.TryTransferItem(to, ore, 4)); + + Assert.Equal(10, ore.Quantity); + Assert.Equal(at, from.Cargo[ore]); + Assert.Equal(10, Units(from, ore)); + Assert.Equal(0, Units(to, ore)); + Assert.Equal(massBefore, from.Mass + to.Mass, 3); + } + + [Fact] + public void ATransferThatNeedsMoreRoomThanTheStackHasLeftChangesNothing() + { + // The receiver's only stack of Ore has room for 2 of the 4: the move is whole or nothing. + var from = ShipWith("Crate").CargoBays[0]; + var to = ShipWith("Pocket").CargoBays[0]; + var ore = Commodity("Ore", 10); + Assert.True(from.TryStore(ore)); + Assert.True(to.TryStore(Commodity("Ore", 98))); + + Assert.False(from.TryTransferItem(to, ore, 4)); + + Assert.Equal(10, Units(from, ore)); + Assert.Equal(98, Units(to, ore)); + } +} From cada2fde3e09e1aca8154628093e8fd901959b8e Mon Sep 17 00:00:00 2001 From: soul Date: Thu, 22 Oct 2026 16:38:17 +0200 Subject: [PATCH 097/307] Transfer verb: a pilot gives cargo to a ship alongside, committed through the conserving bay move TryTransferItem(SimpleCommodity) checks the target can take the part and stores the part removed, not the stack left behind (it duplicated and lost units on every partial move, the player's commodity buy among them). TryTransferItems goes. CargoTransfer.Step owns a give's progress and end; PilotCommand.Give and StopGiving are applied by PilotApplier; TransferRate is the new tunable, DockingDistance the range. Co-Authored-By: Claude Sonnet 5.5 --- Assets/Scripts/ServerShared/CargoTransfer.cs | 124 ++++++++++++++++++ .../ServerShared/CargoTransfer.cs.meta | 11 ++ Assets/Scripts/ServerShared/Entity.cs | 54 ++------ Assets/Scripts/ServerShared/Pilot.cs | 19 +++ Assets/Scripts/ServerShared/Settings.cs | 2 + Assets/Scripts/ServerShared/Zone.cs | 1 + 6 files changed, 171 insertions(+), 40 deletions(-) create mode 100644 Assets/Scripts/ServerShared/CargoTransfer.cs create mode 100644 Assets/Scripts/ServerShared/CargoTransfer.cs.meta diff --git a/Assets/Scripts/ServerShared/CargoTransfer.cs b/Assets/Scripts/ServerShared/CargoTransfer.cs new file mode 100644 index 000000000..4026bbc7b --- /dev/null +++ b/Assets/Scripts/ServerShared/CargoTransfer.cs @@ -0,0 +1,124 @@ +/* This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at https://mozilla.org/MPL/2.0/. */ + +using System.Linq; +using CultMath; +using static CultMath.math; + +// Why a give ended. Notification only: nothing reads it to decide anything. +public enum GiveEnd +{ + Done, + Stopped, + OutOfRange, + ReceiverGone, + ReceiverFull, + NothingLeft, + GiverDocked +} + +// A pilot's standing offer of goods to a ship alongside. Runtime only: nothing about a give is saved. +public sealed class GiveOrder +{ + public readonly Entity Receiver; + // A commodity order is for any stack of this item's design in the giver's bays; a crafted order is for this instance. + public readonly ItemInstance Item; + // Units still to move. + public int Remaining { get; internal set; } + // Units earned and not yet moved, fractional between steps. + internal double Budget; + + internal GiveOrder(Entity receiver, ItemInstance item, int quantity) + { + Receiver = receiver; + Item = item; + Remaining = quantity; + } +} + +// The one owner of a give's progress and its end (ruling tender-transfer-verb). A ship's own pilot asks for a give through +// PilotCommand.Give and PilotApplier starts it here; nothing else moves goods out of a hold in space (ruling +// transfer-is-give-only). Every unit moves through EquippedCargoBay.TryTransferItem, the one conserving bay-to-bay commit. +public static class CargoTransfer +{ + internal static void Begin(Entity giver, GiveOrder order) + { + End(giver, GiveEnd.Stopped); + giver.Giving = order; + } + + // Ends the giver's give, if it has one, for the reason. + internal static void End(Entity giver, GiveEnd reason) + { + if (giver.Giving == null) return; + giver.Giving = null; + giver.LastGiveEnd = reason; + } + + // One step of every give in the zone: the receiver is still here and alongside, and the rate earns units, each moved + // into the first receiver bay that takes it. + public static void Step(Zone zone, float deltaTime) + { + foreach (var giver in zone.Entities.ToArray()) + { + var order = giver.Giving; + if (order == null) continue; + var settings = giver.ItemManager.GameplaySettings; + var range = settings.DockingDistance; + if (!zone.Entities.Contains(order.Receiver)) + { + End(giver, GiveEnd.ReceiverGone); + continue; + } + if (lengthsq(order.Receiver.Position.xz - giver.Position.xz) > range * range) + { + End(giver, GiveEnd.OutOfRange); + continue; + } + + order.Budget += (double) settings.TransferRate * deltaTime; + while (order.Budget >= 1 && order.Remaining > 0) + { + var source = Source(giver, order); + if (source.Bay == null) + { + End(giver, GiveEnd.NothingLeft); + break; + } + if (!Move(source.Bay, source.Item, order.Receiver)) + { + End(giver, GiveEnd.ReceiverFull); + break; + } + order.Budget -= 1; + order.Remaining--; + } + if (giver.Giving == order && order.Remaining == 0) End(giver, GiveEnd.Done); + } + } + + // Where the next unit comes from: the first stack of the order's design in the giver's bays, or the crafted instance. + private static (EquippedCargoBay Bay, ItemInstance Item) Source(Entity giver, GiveOrder order) + { + foreach (var bay in giver.CargoBays) + { + if (order.Item is CraftedItemInstance crafted) + { + if (bay.Cargo.ContainsKey(crafted)) return (bay, crafted); + } + else if (bay.ItemsOfType.TryGetValue(order.Item.Data.Key, out var stacks)) + { + var stack = stacks.OfType().FirstOrDefault(s => s.Quantity > 0); + if (stack != null) return (bay, stack); + } + } + return (null, null); + } + + // One unit of a commodity, or the crafted item, into the first receiver bay that takes it. + private static bool Move(EquippedCargoBay from, ItemInstance item, Entity receiver) => + item is SimpleCommodity commodity + ? receiver.CargoBays.Any(bay => from.TryTransferItem(bay, commodity, 1)) + : receiver.CargoBays.Any(bay => from.TryTransferItem(bay, (CraftedItemInstance) item)); +} diff --git a/Assets/Scripts/ServerShared/CargoTransfer.cs.meta b/Assets/Scripts/ServerShared/CargoTransfer.cs.meta new file mode 100644 index 000000000..70599860c --- /dev/null +++ b/Assets/Scripts/ServerShared/CargoTransfer.cs.meta @@ -0,0 +1,11 @@ +fileFormatVersion: 2 +guid: 7be41c09d3a84f5e9a206d18c4f3b5a2 +MonoImporter: + externalObjects: {} + serializedVersion: 2 + defaultReferences: [] + executionOrder: 0 + icon: {instanceID: 0} + userData: + assetBundleName: + assetBundleVariant: diff --git a/Assets/Scripts/ServerShared/Entity.cs b/Assets/Scripts/ServerShared/Entity.cs index 6b1eab8c9..00df96087 100644 --- a/Assets/Scripts/ServerShared/Entity.cs +++ b/Assets/Scripts/ServerShared/Entity.cs @@ -191,6 +191,10 @@ public abstract class Entity // The pilot's tractor command (0..1); the Pickup behaviour spins its beam toward it. public float TractorCommand { get; internal set; } + // The give the pilot has under way, and why the last one ended. CargoTransfer is the only writer of both. + public GiveOrder Giving { get; internal set; } + public GiveEnd? LastGiveEnd { get; internal set; } + // The beam's spun-up state: Pickup's, or 0 without one. public float TractorPower => GetBehavior()?.Power ?? 0f; @@ -1460,42 +1464,6 @@ public abstract class Entity } } - // Attempts to move a given number of items of the given type to the target Entity - // Returns the number of items successfully transferred - public int TryTransferItems(Entity target, CultRecordKey itemDataID, int quantity) - { - int quantityTransferred = 0; - while (quantityTransferred < quantity) - { - EquippedCargoBay originInventory = CargoBays.FirstOrDefault(c => c.ItemsOfType.ContainsKey(itemDataID)); - - if (originInventory == null) break; - - var itemInstance = originInventory.ItemsOfType[itemDataID][0]; - - if (itemInstance is SimpleCommodity simpleCommodity) - { - var targetQuantity = min(simpleCommodity.Quantity, quantity - quantityTransferred); - if (!target.CargoBays.Any(c => originInventory.TryTransferItem(c, simpleCommodity, targetQuantity))) - { - quantityTransferred += targetQuantity - simpleCommodity.Quantity; - break; - } - - quantityTransferred += targetQuantity; - } - else if (itemInstance is CraftedItemInstance craftedItemInstance) - { - if (!target.CargoBays.Any(c => originInventory.TryTransferItem(c, craftedItemInstance))) - break; - - quantityTransferred++; - } - } - - return quantityTransferred; - } - public EquippableItem TryUnequip(EquippedItem item) { // Don't allow unequipping when the entity is active @@ -1752,6 +1720,7 @@ public abstract class Entity if (bay != null) { bay.DockedShip = ship; + CargoTransfer.End(ship, GiveEnd.GiverDocked); ship.SetParent(this); Zone.Remove(ship); ship.Deactivate(); @@ -2695,12 +2664,17 @@ public class EquippedCargoBay : EquippedItem return false; } + // Whole or nothing: the target must be able to take the part before it leaves the stack. + var part = new SimpleCommodity { Data = item.Data, Quantity = min(quantity, item.Quantity), Rotation = item.Rotation }; + if (!target.TryFindSpace(part, out _)) return false; + var oldPos = Cargo[item]; var newItem = Remove(item, quantity); - - if (target.TryStore(item)) return true; - - // Failed to transfer full quantity, move the remaining items back to their old slot + + // The part removed goes across, never the stack left behind. + if (target.TryStore(newItem)) return true; + + // The target refused after all: put the part back where it came from. TryStore(newItem, oldPos); return false; } diff --git a/Assets/Scripts/ServerShared/Pilot.cs b/Assets/Scripts/ServerShared/Pilot.cs index 8b04637d9..be71f877d 100644 --- a/Assets/Scripts/ServerShared/Pilot.cs +++ b/Assets/Scripts/ServerShared/Pilot.cs @@ -56,6 +56,8 @@ public sealed class PilotCommand internal readonly List<(ActivatedBehavior Behavior, bool Pull)> Triggers = new List<(ActivatedBehavior, bool)>(); internal readonly List<(Entity Other, bool? Hostile, bool Toggle)> Iffs = new List<(Entity, bool?, bool)>(); internal TargetVerb TargetVerb; + // The step's give verb: a Give, or a stop when its Receiver is null. The last one in a step wins. + internal (Entity Receiver, ItemInstance Item, int Quantity)? GiveVerb; internal int AimCycles; internal int ShutdownOverrideToggles, HeatsinkToggles, ShieldToggles, Pings; @@ -77,6 +79,17 @@ public sealed class PilotCommand public void ToggleHeatsinks() => HeatsinkToggles++; public void ToggleShield() => ShieldToggles++; public void Ping() => Pings++; + // Offers goods from this entity's hold to a ship alongside (ruling transfer-is-give-only): the quantity of a commodity, + // or the one crafted instance. It replaces any give under way. A give of nothing, or of less than one, is ignored, and + // so is one to oneself when applied. The last give or stop in a step wins. + public void Give(Entity receiver, ItemInstance item, int quantity) + { + if (receiver == null || item == null) return; + var count = item is CraftedItemInstance ? 1 : quantity; + if (count < 1) return; + GiveVerb = (receiver, item, count); + } + public void StopGiving() => GiveVerb = (null, null, 0); // The pilot's own pending decisions, which it reads back instead of the ship (that is last step's applied state). public TargetRef? Designated => Designations.Count > 0 ? Designations[Designations.Count - 1] : (TargetRef?) null; @@ -98,6 +111,7 @@ public sealed class PilotCommand Triggers.Clear(); Iffs.Clear(); TargetVerb = TargetVerb.None; + GiveVerb = null; AimCycles = 0; ShutdownOverrideToggles = HeatsinkToggles = ShieldToggles = Pings = 0; } @@ -134,6 +148,11 @@ public static class PilotApplier for (var i = 0; i < command.Pings; i++) entity.Sensor?.Ping(); foreach (var hail in command.Hails) entity.SetMessage(hail); foreach (var consumable in command.Uses) entity.TryActivateConsumable(consumable); + if (command.GiveVerb is (Entity receiver, ItemInstance given, int quantity)) + { + if (receiver == null) CargoTransfer.End(entity, GiveEnd.Stopped); + else if (!ReferenceEquals(receiver, entity)) CargoTransfer.Begin(entity, new GiveOrder(receiver, given, quantity)); + } if (command.Aim is float3 aim) entity.Aim = aim; foreach (var (behavior, pull) in command.Triggers) if (pull) behavior.Activate(); diff --git a/Assets/Scripts/ServerShared/Settings.cs b/Assets/Scripts/ServerShared/Settings.cs index 5a95f450d..b90b1252d 100644 --- a/Assets/Scripts/ServerShared/Settings.cs +++ b/Assets/Scripts/ServerShared/Settings.cs @@ -205,6 +205,8 @@ public class GameplaySettings public int WeaponGroupCount = 6; public float WarpDistance = 25; public float DockingDistance = 25; + // Units per second one pilot gives to a ship within DockingDistance; a commodity unit or one crafted item. + public float TransferRate = 2; public float ProductionPersonalityLerp = .05f; public float MessageDuration = 4f; public float TargetPersistenceDuration = 3; diff --git a/Assets/Scripts/ServerShared/Zone.cs b/Assets/Scripts/ServerShared/Zone.cs index f2276f4e4..a74a8f7b9 100644 --- a/Assets/Scripts/ServerShared/Zone.cs +++ b/Assets/Scripts/ServerShared/Zone.cs @@ -385,6 +385,7 @@ public class Zone foreach (var entity in entities) entity.Sense(); _departures.Clear(); + CargoTransfer.Step(this, deltaTime); StepMines(deltaTime); StepClouds(deltaTime); StepFloatingItems(deltaTime); From 7ac8f389014864e16d873840198ca13c14a207ac Mon Sep 17 00:00:00 2001 From: soul Date: Thu, 22 Oct 2026 16:38:18 +0200 Subject: [PATCH 098/307] Cargo transfer tests: the give verb's rate, ends, instance choice and press order Co-Authored-By: Claude Sonnet 5.5 --- .../CargoTransferTests.cs | 282 ++++++++++++++++++ 1 file changed, 282 insertions(+) diff --git a/tests/Aetheria.Shared.Tests/CargoTransferTests.cs b/tests/Aetheria.Shared.Tests/CargoTransferTests.cs index ce8732fe1..eca433719 100644 --- a/tests/Aetheria.Shared.Tests/CargoTransferTests.cs +++ b/tests/Aetheria.Shared.Tests/CargoTransferTests.cs @@ -32,6 +32,8 @@ public sealed class CargoTransferTests : IDisposable _cache.Upsert(new CargoBayData { Name = "Pocket", Shape = new Shape(), InteriorShape = pocket, Price = 5, Durability = 4 }); _cache.Upsert(new SimpleCommodityData { Name = "Ore", Shape = new Shape(), Price = 7, MaxStack = 100 }); _cache.Upsert(new SimpleCommodityData { Name = "Slag", Shape = new Shape(), Price = 1, MaxStack = 100 }); + _cache.Upsert(new DockingBayData { Name = "Berth", Shape = new Shape(), InteriorShape = bay, MaxSize = new int2(2, 2), Price = 60, Durability = 6 }); + _cache.Upsert(new GearData { Name = "Gun", Hardpoint = HardpointType.Sensors, Shape = new Shape(), Price = 400, Durability = 8 }); _cache.FlushAsync().Wait(); var settings = AuthoredSettings.Load(RestoredHullsTests.FindRepoRoot()).Read("GameplaySettings"); @@ -150,4 +152,284 @@ public sealed class CargoTransferTests : IDisposable Assert.Equal(10, Units(from, ore)); Assert.Equal(98, Units(to, ore)); } + + // ---- The give verb ------------------------------------------------------------------------------------------ + + private const float Dt = .1f; + + // Two ships in the zone, alongside each other, the giver holding `ore` units of Ore. + private (Ship Giver, Ship Receiver, SimpleCommodity Stack) Alongside(int ore, string receiverBay = "Crate") + { + var giver = ShipWith("Crate"); + var receiver = ShipWith(receiverBay); + var stack = Commodity("Ore", ore); + Assert.True(giver.CargoBays[0].TryStore(stack)); + giver.Position = new float3(0, 0, 0); + receiver.Position = new float3(5, 0, 0); + _zone.Admit(giver, piloted: false); + _zone.Admit(receiver, piloted: false); + return (giver, receiver, stack); + } + + private static int Held(Entity entity, SimpleCommodity like) => entity.CargoBays.Sum(bay => Units(bay, like)); + + private void Steps(int count) + { + for (var i = 0; i < count; i++) _zone.Update(Dt); + } + + [Fact] + public void GiveMovesAtTheRate() + { + var (giver, receiver, ore) = Alongside(10); + giver.Command.Give(receiver, ore, 10); + + for (var i = 1; i <= 10; i++) + { + Steps(1); + Assert.Equal(10, Held(giver, ore) + Held(receiver, ore)); + } + + // Rate 2 for one second: exactly two units, not one and not three. + Assert.Equal(2, Held(receiver, ore)); + Assert.Equal(8, Held(giver, ore)); + Assert.NotNull(giver.Giving); + } + + [Fact] + public void GiveEndsDoneAtItsQuantity() + { + var (giver, receiver, ore) = Alongside(10); + giver.Command.Give(receiver, ore, 3); + + Steps(40); + + Assert.Equal(3, Held(receiver, ore)); + Assert.Equal(7, Held(giver, ore)); + Assert.Null(giver.Giving); + Assert.Equal(GiveEnd.Done, giver.LastGiveEnd); + } + + [Fact] + public void GiveEndsOutOfRange() + { + var (giver, receiver, ore) = Alongside(10); + var range = _items.GameplaySettings.DockingDistance; + receiver.Position = new float3(range - 1, 0, 0); + giver.Command.Give(receiver, ore, 10); + Steps(10); + Assert.Equal(2, Held(receiver, ore)); + Assert.Null(giver.LastGiveEnd); + + receiver.Position = new float3(range + 1, 0, 0); + Steps(10); + + Assert.Equal(2, Held(receiver, ore)); + Assert.Null(giver.Giving); + Assert.Equal(GiveEnd.OutOfRange, giver.LastGiveEnd); + } + + [Fact] + public void GiveRangeIsMeasuredOnTheFlatPlane() + { + // The plane is xz: a ship far apart in y only is still alongside. + var (giver, receiver, ore) = Alongside(10); + receiver.Position = new float3(5, 1000, 0); + giver.Command.Give(receiver, ore, 10); + Steps(10); + Assert.Equal(2, Held(receiver, ore)); + } + + [Fact] + public void GiveEndsWhenTheReceiverLeaves() + { + var (giver, receiver, ore) = Alongside(10); + giver.Command.Give(receiver, ore, 10); + Steps(10); + Assert.Equal(2, Held(receiver, ore)); + + _zone.Remove(receiver); + Steps(10); + + Assert.Equal(8, Held(giver, ore)); + Assert.Equal(2, Held(receiver, ore)); + Assert.Null(giver.Giving); + Assert.Equal(GiveEnd.ReceiverGone, giver.LastGiveEnd); + } + + [Fact] + public void DockingEndsTheGiversGive() + { + var (giver, receiver, ore) = Alongside(10); + var station = new OrbitalEntity(_items, _zone, Mint("Hub"), default, new EntitySettings()); + Assert.True(station.TryEquip(Mint("Berth"), new int2(1, 1))); + giver.Command.Give(receiver, ore, 10); + Steps(10); + + Assert.NotNull(station.TryDock(giver)); + Steps(10); + + Assert.Null(giver.Giving); + Assert.Equal(GiveEnd.GiverDocked, giver.LastGiveEnd); + Assert.Equal(2, Held(receiver, ore)); + } + + [Fact] + public void GiveEndsOnAFullHold() + { + var (giver, receiver, ore) = Alongside(10, "Pocket"); + Assert.True(receiver.CargoBays[0].TryStore(Commodity("Slag", 5))); + giver.Command.Give(receiver, ore, 10); + + Steps(20); + + Assert.Equal(10, Held(giver, ore)); + Assert.Equal(0, Held(receiver, ore)); + Assert.Null(giver.Giving); + Assert.Equal(GiveEnd.ReceiverFull, giver.LastGiveEnd); + } + + [Fact] + public void GiveEndsWhenNothingIsLeft() + { + var (giver, receiver, ore) = Alongside(10); + giver.Command.Give(receiver, ore, 50); + + Steps(100); + + Assert.Equal(10, Held(receiver, ore)); + Assert.Equal(0, Held(giver, ore)); + Assert.Null(giver.Giving); + Assert.Equal(GiveEnd.NothingLeft, giver.LastGiveEnd); + } + + [Fact] + public void AGiveDrawsOnEveryStackOfTheDesign() + { + // Two stacks in two bays: the order is for the design, not for the stack it was named with. + var (giver, receiver, ore) = Alongside(3); + Assert.True(giver.TryEquip(Mint("Crate"), new int2(3, 3))); + Assert.True(giver.CargoBays[1].TryStore(Commodity("Ore", 4))); + giver.Command.Give(receiver, ore, 7); + + Steps(60); + + Assert.Equal(7, Held(receiver, ore)); + Assert.Equal(0, Held(giver, ore)); + Assert.Equal(GiveEnd.Done, giver.LastGiveEnd); + } + + [Fact] + public void AGivenCraftedItemIsThatInstance() + { + var (giver, receiver, _) = Alongside(0); + var first = Mint("Gun"); + var second = Mint("Gun"); + Assert.True(giver.CargoBays[0].TryStore(first)); + Assert.True(giver.CargoBays[0].TryStore(second)); + giver.Command.Give(receiver, second, 5); + + Steps(20); + + Assert.True(receiver.CargoBays[0].Cargo.ContainsKey(second)); + Assert.False(receiver.CargoBays[0].Cargo.ContainsKey(first)); + Assert.True(giver.CargoBays[0].Cargo.ContainsKey(first)); + Assert.False(giver.CargoBays[0].Cargo.ContainsKey(second)); + Assert.Equal(GiveEnd.Done, giver.LastGiveEnd); + } + + [Fact] + public void TheLastGivePressInAStepWins() + { + var (giver, receiver, ore) = Alongside(10); + var third = ShipWith("Crate"); + third.Position = new float3(0, 0, 5); + _zone.Admit(third, piloted: false); + + // A give then a stop leaves nothing, and nothing ended. + giver.Command.Give(receiver, ore, 10); + giver.Command.StopGiving(); + Steps(1); + Assert.Null(giver.Giving); + Assert.Null(giver.LastGiveEnd); + + // A stop then a give leaves the give. + giver.Command.StopGiving(); + giver.Command.Give(receiver, ore, 10); + Steps(1); + Assert.Same(receiver, giver.Giving.Receiver); + + // A second give in the same step replaces the first before it starts. + giver.Command.Give(receiver, ore, 10); + giver.Command.Give(third, ore, 10); + Steps(1); + Assert.Same(third, giver.Giving.Receiver); + Assert.Equal(GiveEnd.Stopped, giver.LastGiveEnd); + + // A give in a later step replaces the one under way, which ends Stopped. + giver.Command.Give(receiver, ore, 10); + Steps(1); + Assert.Same(receiver, giver.Giving.Receiver); + } + + [Fact] + public void StoppingEndsTheGiveAndKeepsWhatMoved() + { + var (giver, receiver, ore) = Alongside(10); + giver.Command.Give(receiver, ore, 10); + Steps(10); + giver.Command.StopGiving(); + Steps(10); + + Assert.Null(giver.Giving); + Assert.Equal(GiveEnd.Stopped, giver.LastGiveEnd); + Assert.Equal(2, Held(receiver, ore)); + Assert.Equal(8, Held(giver, ore)); + } + + [Fact] + public void AGiveToOneselfOrOfNothingIsIgnored() + { + var (giver, receiver, ore) = Alongside(10); + giver.Command.Give(receiver, ore, 10); + Steps(1); + var order = giver.Giving; + + giver.Command.Give(giver, ore, 5); + giver.Command.Give(receiver, ore, 0); + giver.Command.Give(receiver, ore, -3); + giver.Command.Give(null, ore, 5); + giver.Command.Give(receiver, null, 5); + Steps(1); + + Assert.Same(order, giver.Giving); + Assert.Null(giver.LastGiveEnd); + } + + [Fact] + public void AGiverStaysInTheZone() + { + var (giver, receiver, ore) = Alongside(10); + giver.Command.Designate(new TargetRef(receiver)); + receiver.Command.Designate(new TargetRef(giver)); + giver.Command.Give(receiver, ore, 10); + + Steps(20); + + Assert.True(_zone.Entities.Contains(giver)); + Assert.True(_zone.Entities.Contains(receiver)); + Assert.Same(receiver, giver.Target.Value.Entity); + Assert.Same(giver, receiver.Target.Value.Entity); + } + + [Fact] + public void NoOrderMovesNothing() + { + // No order, no work: a zone of ships that are not giving moves nothing. + var (giver, receiver, ore) = Alongside(10); + Steps(20); + Assert.Equal(10, Held(giver, ore)); + Assert.Equal(0, Held(receiver, ore)); + Assert.Null(giver.LastGiveEnd); + } } From c1aab929b19985fc9a2035a05113bd892db5f0e9 Mon Sep 17 00:00:00 2001 From: soul Date: Thu, 22 Oct 2026 16:42:42 +0200 Subject: [PATCH 099/307] Give verb: a stop is a verb with no receiver, and the type pattern skipped it; tests: second bay at a free cell Co-Authored-By: Claude Sonnet 5.5 --- Assets/Scripts/ServerShared/Pilot.cs | 6 +++--- tests/Aetheria.Shared.Tests/CargoTransferTests.cs | 2 +- 2 files changed, 4 insertions(+), 4 deletions(-) diff --git a/Assets/Scripts/ServerShared/Pilot.cs b/Assets/Scripts/ServerShared/Pilot.cs index be71f877d..dcfa655ae 100644 --- a/Assets/Scripts/ServerShared/Pilot.cs +++ b/Assets/Scripts/ServerShared/Pilot.cs @@ -148,10 +148,10 @@ public static class PilotApplier for (var i = 0; i < command.Pings; i++) entity.Sensor?.Ping(); foreach (var hail in command.Hails) entity.SetMessage(hail); foreach (var consumable in command.Uses) entity.TryActivateConsumable(consumable); - if (command.GiveVerb is (Entity receiver, ItemInstance given, int quantity)) + if (command.GiveVerb is { } give) { - if (receiver == null) CargoTransfer.End(entity, GiveEnd.Stopped); - else if (!ReferenceEquals(receiver, entity)) CargoTransfer.Begin(entity, new GiveOrder(receiver, given, quantity)); + if (give.Receiver == null) CargoTransfer.End(entity, GiveEnd.Stopped); + else if (!ReferenceEquals(give.Receiver, entity)) CargoTransfer.Begin(entity, new GiveOrder(give.Receiver, give.Item, give.Quantity)); } if (command.Aim is float3 aim) entity.Aim = aim; foreach (var (behavior, pull) in command.Triggers) diff --git a/tests/Aetheria.Shared.Tests/CargoTransferTests.cs b/tests/Aetheria.Shared.Tests/CargoTransferTests.cs index eca433719..5983747b4 100644 --- a/tests/Aetheria.Shared.Tests/CargoTransferTests.cs +++ b/tests/Aetheria.Shared.Tests/CargoTransferTests.cs @@ -308,7 +308,7 @@ public sealed class CargoTransferTests : IDisposable { // Two stacks in two bays: the order is for the design, not for the stack it was named with. var (giver, receiver, ore) = Alongside(3); - Assert.True(giver.TryEquip(Mint("Crate"), new int2(3, 3))); + Assert.True(giver.TryEquip(Mint("Crate"), new int2(2, 2))); Assert.True(giver.CargoBays[1].TryStore(Commodity("Ore", 4))); giver.Command.Give(receiver, ore, 7); From 4e559d822eae1e1ac5961d12ea10bbf22510e498 Mon Sep 17 00:00:00 2001 From: soul Date: Thu, 22 Oct 2026 16:46:35 +0200 Subject: [PATCH 100/307] Cargo transfer tests: the second bay goes where the hull has room Co-Authored-By: Claude Sonnet 5.5 --- tests/Aetheria.Shared.Tests/CargoTransferTests.cs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/tests/Aetheria.Shared.Tests/CargoTransferTests.cs b/tests/Aetheria.Shared.Tests/CargoTransferTests.cs index 5983747b4..44190c3de 100644 --- a/tests/Aetheria.Shared.Tests/CargoTransferTests.cs +++ b/tests/Aetheria.Shared.Tests/CargoTransferTests.cs @@ -308,7 +308,7 @@ public sealed class CargoTransferTests : IDisposable { // Two stacks in two bays: the order is for the design, not for the stack it was named with. var (giver, receiver, ore) = Alongside(3); - Assert.True(giver.TryEquip(Mint("Crate"), new int2(2, 2))); + Assert.True(giver.TryEquip(Mint("Crate"))); Assert.True(giver.CargoBays[1].TryStore(Commodity("Ore", 4))); giver.Command.Give(receiver, ore, 7); From bcfc776513f74654ba35a48093f6a26860bce745 Mon Sep 17 00:00:00 2001 From: soul Date: Thu, 22 Oct 2026 16:50:35 +0200 Subject: [PATCH 101/307] Cargo transfer tests: two stacks of one design come from a commodity that stacks two to a stack Co-Authored-By: Claude Sonnet 5.5 --- .../CargoTransferTests.cs | 20 ++++++++++--------- 1 file changed, 11 insertions(+), 9 deletions(-) diff --git a/tests/Aetheria.Shared.Tests/CargoTransferTests.cs b/tests/Aetheria.Shared.Tests/CargoTransferTests.cs index 44190c3de..148cde89d 100644 --- a/tests/Aetheria.Shared.Tests/CargoTransferTests.cs +++ b/tests/Aetheria.Shared.Tests/CargoTransferTests.cs @@ -31,6 +31,7 @@ public sealed class CargoTransferTests : IDisposable foreach (var cell in pocket.AllCoordinates) pocket[cell] = true; _cache.Upsert(new CargoBayData { Name = "Pocket", Shape = new Shape(), InteriorShape = pocket, Price = 5, Durability = 4 }); _cache.Upsert(new SimpleCommodityData { Name = "Ore", Shape = new Shape(), Price = 7, MaxStack = 100 }); + _cache.Upsert(new SimpleCommodityData { Name = "Grit", Shape = new Shape(), Price = 1, MaxStack = 2 }); _cache.Upsert(new SimpleCommodityData { Name = "Slag", Shape = new Shape(), Price = 1, MaxStack = 100 }); _cache.Upsert(new DockingBayData { Name = "Berth", Shape = new Shape(), InteriorShape = bay, MaxSize = new int2(2, 2), Price = 60, Durability = 6 }); _cache.Upsert(new GearData { Name = "Gun", Hardpoint = HardpointType.Sensors, Shape = new Shape(), Price = 400, Durability = 8 }); @@ -163,7 +164,7 @@ public sealed class CargoTransferTests : IDisposable var giver = ShipWith("Crate"); var receiver = ShipWith(receiverBay); var stack = Commodity("Ore", ore); - Assert.True(giver.CargoBays[0].TryStore(stack)); + if (ore > 0) Assert.True(giver.CargoBays[0].TryStore(stack)); giver.Position = new float3(0, 0, 0); receiver.Position = new float3(5, 0, 0); _zone.Admit(giver, piloted: false); @@ -306,16 +307,17 @@ public sealed class CargoTransferTests : IDisposable [Fact] public void AGiveDrawsOnEveryStackOfTheDesign() { - // Two stacks in two bays: the order is for the design, not for the stack it was named with. - var (giver, receiver, ore) = Alongside(3); - Assert.True(giver.TryEquip(Mint("Crate"))); - Assert.True(giver.CargoBays[1].TryStore(Commodity("Ore", 4))); - giver.Command.Give(receiver, ore, 7); + // Grit stacks two to a stack, so three units are two stacks: the order is for the design, not the stack named. + var (giver, receiver, _) = Alongside(0); + var grit = Commodity("Grit", 3); + Assert.True(giver.CargoBays[0].TryStore(grit)); + Assert.Equal(2, giver.CargoBays[0].ItemsOfType[grit.Data.Key].Count); + giver.Command.Give(receiver, grit, 3); - Steps(60); + Steps(40); - Assert.Equal(7, Held(receiver, ore)); - Assert.Equal(0, Held(giver, ore)); + Assert.Equal(3, Held(receiver, grit)); + Assert.Equal(0, Held(giver, grit)); Assert.Equal(GiveEnd.Done, giver.LastGiveEnd); } From 2b053b34c2ea7500f6cac534002824c2b282bd4b Mon Sep 17 00:00:00 2001 From: soul Date: Thu, 22 Oct 2026 16:52:17 +0200 Subject: [PATCH 102/307] Smoke: a retired hull's lines are not hers, and Capture writes the kit's lines in Package's frame (red) Co-Authored-By: Claude Sonnet 5.5 --- tools/blender/tests/smoke_hull_source.py | 31 ++++++++++++++++++++++++ 1 file changed, 31 insertions(+) diff --git a/tools/blender/tests/smoke_hull_source.py b/tools/blender/tests/smoke_hull_source.py index 7084274bb..a3676b0c7 100644 --- a/tools/blender/tests/smoke_hull_source.py +++ b/tools/blender/tests/smoke_hull_source.py @@ -403,6 +403,21 @@ def one_hull(scene, number, fixture, ship_id, extent, length, rows, body, palett require(result == {"FINISHED"} and len(ship_lines(ship_cc)) == len(packaged), f"Without Grease Pencil the kit's lines are back: {message!r}") print(f"PASS {number}.5b the kit's lines ship, her Grease Pencil wins, two are refused") + # Capture's kit branch writes what Package wrote, and also when Save Layout has moved the centre of mass since the last + # Package (here: Ship Root is off the origin), because Capture frames the ship as Package does. + shipped = ship_lines(ship_cc) + select(box) + result, message = run(bpy.ops.aetheria.capture_ship_lines) + require(result == {"FINISHED"} and same_lines(ship_lines(ship_cc), shipped), f"Capture did not write the kit's lines as Package did: {message!r}") + root.location.x += 1.7 + root.location.y -= 0.9 + bpy.context.view_layer.update() + select(box) + result, message = run(bpy.ops.aetheria.capture_ship_lines) + require(result == {"FINISHED"} and same_lines(ship_lines(ship_cc), shipped), + f"Capture, with Ship Root off its Package frame, wrote lines out of the packaged model's frame: {message!r}") + print(f"PASS {number}.5c Capture writes the kit's lines in Package's frame") + # What Package did to the built objects is not an edit: Build still replaces them. select(box) result, message = run(bpy.ops.aetheria.build_hull_source) @@ -513,6 +528,15 @@ def one_hull(scene, number, fixture, ship_id, extent, length, rows, body, palett require("aetheria.hull_built" not in collection and "aetheria.hull_maker" not in collection and "aetheria.hull_lines" not in collection, "The record of Build's objects or its maker outlived hull.scad") require(all(echoed not in report for echoed in (ship_id, str(GAME))), "The report echoes an input") + # The retired hull's kit lines are not hers: the slot is empty (Studio draws no underlay, the preview no line mesh), + # Capture has nothing to capture, and a second Package leaves it empty. + require(ship_lines(ship_cc) == [], f"The retired hull's {len(ship_lines(ship_cc))} lines ship over her art") + select(box) + result, message = run(bpy.ops.aetheria.capture_ship_lines) + require(result == {"CANCELLED"} and ship_lines(ship_cc) == [], f"Capture wrote lines for a hull with no source: {message!r}") + select(box) + result, message = run(bpy.ops.aetheria.package_ship) + require(result == {"FINISHED"} and ship_lines(ship_cc) == [], f"A second Package brought lines back: {message!r} {ship_lines(ship_cc)[:1]}") select(box) result, message = run(bpy.ops.aetheria.build_hull_source) require(result == {"CANCELLED"} and bpy.data.objects.get("author hull") is art, f"Build ran after takeover: {message!r}") @@ -608,6 +632,13 @@ def ship_lines(ship_path): return sys.modules["aetheria_ships"].read(ship_path, PACKAGES).ship.body[sys.modules["aetheria_ships"].ship_cc.SCHEMATIC_LINES_SLOT] +def same_lines(a, b, tolerance=1e-4): + """Whether two SchematicLines slots hold the same rows: layer, cyclic, colour and every point within tolerance.""" + return len(a) == len(b) and all( + x[0] == y[0] and x[5] == y[5] and len(x[2]) == len(y[2]) and approx(x[6], y[6], tolerance) + and all(abs(p - q) <= tolerance for p, q in zip(x[2], y[2])) for x, y in zip(a, b)) + + def grease_pencil(collection, name, points): """A Grease Pencil object in the collection holding one stroke, layer named 'hers'.""" data = bpy.data.grease_pencils.new(name) From bb38036e5148f2618f1c2587097d9201cbc44874 Mon Sep 17 00:00:00 2001 From: soul Date: Thu, 22 Oct 2026 16:54:12 +0200 Subject: [PATCH 103/307] Cargo transfer tests: build the second stack with a second store Co-Authored-By: Claude Sonnet 5.5 --- tests/Aetheria.Shared.Tests/CargoTransferTests.cs | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/tests/Aetheria.Shared.Tests/CargoTransferTests.cs b/tests/Aetheria.Shared.Tests/CargoTransferTests.cs index 148cde89d..d2c1db6fb 100644 --- a/tests/Aetheria.Shared.Tests/CargoTransferTests.cs +++ b/tests/Aetheria.Shared.Tests/CargoTransferTests.cs @@ -309,8 +309,9 @@ public sealed class CargoTransferTests : IDisposable { // Grit stacks two to a stack, so three units are two stacks: the order is for the design, not the stack named. var (giver, receiver, _) = Alongside(0); - var grit = Commodity("Grit", 3); + var grit = Commodity("Grit", 2); Assert.True(giver.CargoBays[0].TryStore(grit)); + Assert.True(giver.CargoBays[0].TryStore(Commodity("Grit", 1))); Assert.Equal(2, giver.CargoBays[0].ItemsOfType[grit.Data.Key].Count); giver.Command.Give(receiver, grit, 3); From 87dc0f013f4efb083be86e6341399e1df1161be9 Mon Sep 17 00:00:00 2001 From: soul Date: Thu, 22 Oct 2026 16:54:13 +0200 Subject: [PATCH 104/307] Capture Ship Lines frames the ship as Package does, so kit and Grease Pencil lines land in ship.glb's frame Co-Authored-By: Claude Sonnet 5.5 --- tools/blender/aetheria_ships/__init__.py | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/tools/blender/aetheria_ships/__init__.py b/tools/blender/aetheria_ships/__init__.py index 08daa095c..d12c19fc8 100644 --- a/tools/blender/aetheria_ships/__init__.py +++ b/tools/blender/aetheria_ships/__init__.py @@ -545,6 +545,10 @@ class AETHERIA_OT_capture_ship_lines(bpy.types.Operator): cultlib = _brokkr_cultlib(context) if read(path, cultlib).ship.body[0] != collection["aetheria.id"]: raise ValueError("The bound collection ID does not match its .cc ship ID") + saved_shape, _, _ = read_layout(path, cultlib) + root = _ship_root(collection) + if root is not None: # the frame Package would write the model in, so the lines match ship.glb + _frame_ship(context, collection, root, path, saved_shape) lines = _schematic_lines(context, collection) if lines is None: raise ValueError("No schematic lines were captured; the ship record was not changed") From dab415ad872a3b956adebdf24e2ebf859bba72dd Mon Sep 17 00:00:00 2001 From: soul Date: Thu, 22 Oct 2026 16:55:50 +0200 Subject: [PATCH 105/307] Package: her art's takeover leaves no retired hull lines in the .cc (state decides the source; the slot is cleared) Co-Authored-By: Claude Sonnet 5.5 --- tools/blender/aetheria_ships/__init__.py | 13 ++++++++----- tools/blender/aetheria_ships/ship_cc.py | 10 ++++++++++ tools/blender/tests/test_ship_cc.py | 16 ++++++++++++++++ 3 files changed, 34 insertions(+), 5 deletions(-) diff --git a/tools/blender/aetheria_ships/__init__.py b/tools/blender/aetheria_ships/__init__.py index d12c19fc8..821529244 100644 --- a/tools/blender/aetheria_ships/__init__.py +++ b/tools/blender/aetheria_ships/__init__.py @@ -23,7 +23,7 @@ import numpy from mathutils import Matrix, Vector from . import hull_grid, hull_source -from .ship_cc import (HARDPOINT_MEMBERS, HARDPOINT_TYPE_NAMES, ROTATION_NAMES, capture_grease_pencil, decode_hardpoint, +from .ship_cc import (HARDPOINT_MEMBERS, HARDPOINT_TYPE_NAMES, ROTATION_NAMES, capture_grease_pencil, clear_lines, decode_hardpoint, encode_hardpoint, layout_revision, read, read_hull_type, read_layout, replace_layout, replace_lines, product_makers, replace_visual, valid_ship_id) @@ -1639,9 +1639,10 @@ def _build_hull_source(context, collection): def _schematic_lines(context, collection): """The ShipAuthoring.SchematicLines rows Package and Capture write, and the only source of them: her Grease Pencil - object if the collection has one (two or more are refused), else the lines Build recorded from the hull source's kit, - mapped through Ship Root's matrix_world (so after _frame_ship, the frame her strokes are captured in), else None and - the slot is left as it is.""" + object if the collection has one (two or more are refused), else the lines Build recorded from the hull source's kit + while the hull is still Build's (not _hull_state art), mapped through Ship Root's matrix_world (so after _frame_ship, + the frame her strokes are captured in), else None and the slot is left as it is. Package alone clears the slot, at + the moment her art takes over.""" pencils = [obj for obj in collection.all_objects if obj.type == "GREASEPENCIL"] if len(pencils) > 1: raise ValueError("Keep one Grease Pencil object: it is the ship's schematic") @@ -1650,7 +1651,7 @@ def _schematic_lines(context, collection): return capture_grease_pencil(pencils[0], context.evaluated_depsgraph_get(), scene.frame_current, evaluated=scene.aetheria_capture_evaluated_lines) record = collection.get(HULL_LINES) - if record is None: + if record is None or _hull_state(collection) == HULL_ART: # her art took over: Build's lines are a retired hull's return None root = _ship_root(collection) world = root.matrix_world if root is not None else Matrix.Identity(4) @@ -1773,6 +1774,8 @@ class AETHERIA_OT_package_ship(bpy.types.Operator): lines = _schematic_lines(context, collection) if lines is not None: replace_lines(path, cultlib, lines) + elif _hull_state(collection) == HULL_ART: # the lines in the slot are the retired hull's, not hers + clear_lines(path, cultlib) code, message = _aetherdb(context, "validate", path, ship_cc=path) except _EXPECTED as exc: scene.aetheria_package_report = f"Package failed: {_failure_text(exc)}" diff --git a/tools/blender/aetheria_ships/ship_cc.py b/tools/blender/aetheria_ships/ship_cc.py index f3742dd4f..dd3ca78be 100644 --- a/tools/blender/aetheria_ships/ship_cc.py +++ b/tools/blender/aetheria_ships/ship_cc.py @@ -179,6 +179,16 @@ def replace_lines(path: str, cultlib_packages: str, lines: list[list[Any]]) -> i return len(lines) +def clear_lines(path: str, cultlib_packages: str) -> int: + """Empties the line slot and nothing else; returns how many lines it held. replace_lines refuses an empty capture so a + failed capture never wipes a ship; this is the one deliberate way to leave a ship without lines.""" + file = read(path, cultlib_packages) + held = len(file.ship.body[SCHEMATIC_LINES_SLOT] or []) + file.ship.body[SCHEMATIC_LINES_SLOT] = [] + file.store.push(_stamped(file.ship, file.msgpack)) + return held + + def replace_visual(path: str, cultlib_packages: str, expected_id: str, model_asset: str, anchors: list[list[Any]]) -> int: """Writes the visual's model asset and anchor list (ShipAuthoring slots 2 and 3) and nothing else. Each anchor row is diff --git a/tools/blender/tests/test_ship_cc.py b/tools/blender/tests/test_ship_cc.py index ac01b2faa..0b4da5677 100644 --- a/tools/blender/tests/test_ship_cc.py +++ b/tools/blender/tests/test_ship_cc.py @@ -344,6 +344,22 @@ class ReplaceLinesTests(ShipFileCase): if slot != ship_cc.SCHEMATIC_LINES_SLOT: self.assertEqual(before[slot], after[slot], f"slot {slot} changed") + def test_clear_lines_empties_only_the_line_slot(self): + ship_cc.replace_lines(self.path, PACKAGES, LINES) + before = self.ship() + self.assertEqual(len(LINES), ship_cc.clear_lines(self.path, PACKAGES)) + after = self.ship() + self.assertEqual([], after[ship_cc.SCHEMATIC_LINES_SLOT]) + for slot in range(len(before)): + if slot != ship_cc.SCHEMATIC_LINES_SLOT: + self.assertEqual(before[slot], after[slot], f"slot {slot} changed") + + def test_replace_lines_still_refuses_an_empty_capture(self): + ship_cc.replace_lines(self.path, PACKAGES, LINES) + with self.assertRaises(ValueError): + ship_cc.replace_lines(self.path, PACKAGES, []) + self.assertEqual(LINES, self.ship()[ship_cc.SCHEMATIC_LINES_SLOT]) + def test_a_save_refreshes_the_stored_at_of_the_record_it_writes_and_only_that_one(self): stale = "2000-01-01T00:00:00+00:00" envelopes = self.envelopes() From 71b12d594b7d7498c7c23090b85ae9eb7dd38c77 Mon Sep 17 00:00:00 2001 From: soul Date: Thu, 22 Oct 2026 17:09:04 +0200 Subject: [PATCH 106/307] Kit lines tests: a line drawn outside its geometry's frame fails Build; side-facing fittings emit no line (red) Co-Authored-By: Claude Sonnet 5.5 --- tools/blender/tests/test_hull_source.py | 62 +++++++++++++++- tools/blender/tests/test_kit_openscad.py | 92 ++++++++++++++++++++++++ 2 files changed, 153 insertions(+), 1 deletion(-) create mode 100644 tools/blender/tests/test_kit_openscad.py diff --git a/tools/blender/tests/test_hull_source.py b/tools/blender/tests/test_hull_source.py index 48a401404..46e153a3f 100644 --- a/tools/blender/tests/test_hull_source.py +++ b/tools/blender/tests/test_hull_source.py @@ -51,6 +51,19 @@ def brief(faction="zhestokost", donor="", length=(8, 14), triangles=(10, 100)): return echo("brief", [faction, donor, "tender", list(length), list(triangles), ["a read"]]) +def marks_off(points, size=0.002): + """An OFF text of one tiny cube per point, as the kit's marks part is.""" + rows, faces = [], [] + for i, (x, y, z) in enumerate(points): + h = size / 2 + rows += [(x + dx * h, y + dy * h, z + dz * h) for dz in (-1, 1) for dy in (-1, 1) for dx in (-1, 1)] + b = 8 * i + faces += [(b, b + 1, b + 3, b + 2), (b + 4, b + 6, b + 7, b + 5), (b, b + 2, b + 6, b + 4), + (b + 1, b + 5, b + 7, b + 3), (b, b + 4, b + 5, b + 1), (b + 2, b + 3, b + 7, b + 6)] + return "\n".join(["OFF", f"{len(rows)} {len(faces)} 0"] + [" ".join(map(repr, r)) for r in rows] + + ["4 " + " ".join(map(str, f)) for f in faces]) + "\n" + + class Fixture: """A hull source as the fake runner sees it: part -> (OFF, ECHO). library holds the ECHO text of each faction's own language file; the hull's own `lang` echo is the files it includes, unless hull_lang says otherwise.""" @@ -70,6 +83,7 @@ class Fixture: "emit:Th.A": (box(0.5, 1.5, 5, 5.1, 0.5, 1.5), ""), "lines": ("", ""), } + self.marks = None # the "marks" part when a test sets one; else a mark at each kit line's first point @staticmethod def projector(meshes): @@ -81,7 +95,13 @@ class Fixture: return [[(x0, y0), (x1, y0), (x1, y1), (x0, y1)]] def runner(self): - return lambda part: ("", self.hull_lang if self.hull_lang is not None else "".join(self.library.values())) if part == "lang" else self.parts[part] + return lambda part: ("", self.hull_lang if self.hull_lang is not None else "".join(self.library.values())) if part == "lang" else self.mark_part() if part == "marks" else self.parts[part] + + def mark_part(self): + """What the kit's lp_line draws in the geometry's own frame: a tiny cube at each line's first point.""" + if self.marks is not None: + return self.marks + return (marks_off([l.points[0] for l in hs.read_lines(self.parts["lines"][1])]), "") def read_library(self, faction): if faction not in self.library: @@ -680,6 +700,7 @@ class RefusalTests(unittest.TestCase): LINES_OVER = "every kit line lies over the hull's top view" LINES_DISTINCT = "no two kit lines are the same" +LINES_FRAME = "every kit line is drawn in the frame of its geometry" SILHOUETTE = "the top view yields a silhouette" @@ -687,6 +708,45 @@ def line(layer="seam", points=((-1, -1, 2), (1, 1, 2)), cyclic=False): return echo("line", [layer, cyclic, [list(p) for p in points]]) +class FrameCheckTests(unittest.TestCase): + """The kit draws a mark where each line starts, in the frame the geometry is drawn in; the line is echoed in the frame + lp_at tracks. A part placed by anything but lp_at has its mark and its line in different places.""" + + def built(self, kit, marks=None): + fixture = Fixture() + fixture.parts["lines"] = ("", kit) + if marks is not None: + fixture.marks = (marks_off(marks), "") + return fixture.build() + + def test_marks_where_the_lines_start_pass_and_the_check_is_listed(self): + built = self.built(line() + line("step", ((0, 0, 2), (1, 1, 2)))) + self.assertEqual(built.failed(), []) + self.assertIn(LINES_FRAME, names(built)) + + def test_a_mark_three_metres_from_its_line_fails_the_frame_check_and_nothing_else(self): + built = self.built(line(points=((-1, -1, 2), (1, 1, 2))), marks=[(-1, 2, 2)]) + self.assertEqual([c.name for c in built.failed()], [LINES_FRAME]) + + def test_a_line_with_no_mark_fails(self): + built = self.built(line() + line("step", ((0, 0, 2), (1, 1, 2))), marks=[(-1, -1, 2)]) + self.assertEqual([c.name for c in built.failed()], [LINES_FRAME]) + + def test_a_mark_with_no_line_fails(self): + built = self.built(line(), marks=[(-1, -1, 2), (2, 2, 2)]) + self.assertEqual([c.name for c in built.failed()], [LINES_FRAME]) + + def test_a_mark_a_millimetre_off_is_the_same_place(self): + self.assertEqual(self.built(line(), marks=[(-1, -1, 2.001)]).failed(), []) + + def test_a_source_with_no_lines_and_no_marks_passes(self): + self.assertEqual(self.built("", marks=[]).failed(), []) + + def test_the_detail_counts_the_lines_and_marks_that_disagree(self): + built = self.built(line(), marks=[(5, 5, 2)]) + self.assertEqual({c.name: c.detail for c in built.checks}[LINES_FRAME], "1 lines and 1 marks disagree") + + class LineReadingTests(unittest.TestCase): def test_lines_are_read_in_echo_order_with_their_layer_flag_and_points(self): text = line("step", ((0, 0, 1), (1, 0, 1), (1, 1, 1)), True) + line("fitting") diff --git a/tools/blender/tests/test_kit_openscad.py b/tools/blender/tests/test_kit_openscad.py new file mode 100644 index 000000000..6867b429f --- /dev/null +++ b/tools/blender/tests/test_kit_openscad.py @@ -0,0 +1,92 @@ +"""The Zhestokost kit through the real OpenSCAD: where its schematic lines are drawn relative to its geometry, and which +frames draw none. Skipped unless SMOKE_OPENSCAD names the OpenSCAD executable (nightly with the Manifold backend). + + SMOKE_OPENSCAD=\\openscad.com python -m unittest discover -s tools/blender/tests -p "test_kit_openscad.py" + +hull_source is loaded by path because the package __init__ imports bpy. +""" + +import importlib.util +import os +import sys +import tempfile +import unittest +from pathlib import Path + +_spec = importlib.util.spec_from_file_location( + "hull_source", Path(__file__).resolve().parents[1] / "aetheria_ships" / "hull_source.py") +hs = importlib.util.module_from_spec(_spec) +sys.modules["hull_source"] = hs +_spec.loader.exec_module(hs) + +OPENSCAD = os.environ.get("SMOKE_OPENSCAD") +REPO = Path(__file__).resolve().parents[3] +NAKOVALNYA = REPO / "GameData" / "Mods" / "aetheria.nakovalnya" / "hull.scad" +FRAME = "every kit line is drawn in the frame of its geometry" + +# Placement drifts (finding line-drift-inside-footprint): a raw translate or rotate around a part that draws lines, not an +# lp_at. Each moves the geometry and leaves the line where lp_at's frame says. +DRIFTS = { + "spine mass 3 m aft": ("zh_mass(T2);", "translate([0, 3, 0]) zh_mass(T2);"), + "radiator troughs 4 m forward": ("for (s = [-1, 1]) zh_on_top(M, RAD_Y, s*RAD_U) zh_rad_trough(RAD_W, RAD_L, 0.45);", + "translate([0, -4, 0]) for (s = [-1, 1]) zh_on_top(M, RAD_Y, s*RAD_U) zh_rad_trough(RAD_W, RAD_L, 0.45);"), + "apron reveals 3 m aft": ("for (u = [-0.33, 0.33]) zh_on_top(APRON, -19.5, u) zh_reveal(2.3, 7.0);", + "translate([0, 3, 0]) for (u = [-0.33, 0.33]) zh_on_top(APRON, -19.5, u) zh_reveal(2.3, 7.0);"), + "flak ring rotated 18 degrees": ("for (f = FLAK) lp_at(t=[f[0], f[1], DECK - ZH_SINK]) flak(f[2]);", + "rotate([0, 0, 18]) for (f = FLAK) lp_at(t=[f[0], f[1], DECK - ZH_SINK]) flak(f[2]);"), +} + +# A fitting on every frame the kit mates to. Only the dorsal one faces up, so only it is seen from above. +HEAD = """include +part = "primary"; +M = [zh_s(-16, 32, 5.0, -2.25), zh_s(-15, 34, 5.5, -2.5), zh_s(5, 33, 5.5, -2.5), zh_s(14, 27, 5.5, -2.5)]; +""" +SIDEWAYS = { + "wall": "zh_on_wall(M, 0, 1, 0.45) zh_hatch(2.6, 3.0);", + "belt": "zh_on_belt(M, 0, 1, 0.5) zh_hatch(2.6, 3.0);", + "belly": "zh_on_belly(M, 0, 0) zh_hatch(2.6, 3.0);", + "stern": "zh_on_stern(M, 0, 0.5) zh_plate(4.0, 1.4, 0.3);", + "nose": "zh_on_nose(M, 0, 0.5) zh_plate(4.0, 1.4, 0.3);", +} +DORSAL = "zh_on_top(M, 0, 0) zh_hatch(2.6, 3.0);" + + +@unittest.skipUnless(OPENSCAD, "SMOKE_OPENSCAD names no OpenSCAD") +class KitThroughOpenScad(unittest.TestCase): + def build(self, source_text): + with tempfile.TemporaryDirectory() as folder: + source = Path(folder) / "hull.scad" + source.write_text(source_text, encoding="utf-8") + return hs.build(hs.openscad_runner(OPENSCAD, source), hs.language_library(OPENSCAD), "Ship", + hs.openscad_projector(OPENSCAD)) + + def lines(self, body): + with tempfile.TemporaryDirectory() as folder: + source = Path(folder) / "hull.scad" + source.write_text(HEAD + body, encoding="utf-8") + return hs.read_lines(hs.openscad_runner(OPENSCAD, source)("lines")[1]) + + def test_the_nakovalnya_draws_every_line_in_the_frame_of_its_geometry(self): + built = self.build(NAKOVALNYA.read_text(encoding="utf-8")) + self.assertEqual([c.name for c in built.failed()], []) + self.assertIn(FRAME, [c.name for c in built.checks]) + + def test_a_placement_that_drifts_from_its_lines_fails_the_frame_check(self): + text = NAKOVALNYA.read_text(encoding="utf-8") + for label, (old, new) in DRIFTS.items(): + with self.subTest(label): + self.assertEqual(text.count(old), 1) + self.assertIn(FRAME, [c.name for c in self.build(text.replace(old, new)).failed()]) + + def test_a_fitting_on_a_frame_a_top_view_cannot_see_emits_no_line(self): + for label, body in SIDEWAYS.items(): + with self.subTest(label): + self.assertEqual(self.lines(body), []) + + def test_the_gate_is_the_frame_and_not_the_fitting(self): + self.assertEqual(len(self.lines(DORSAL)), 1) + self.assertEqual(len(self.lines(DORSAL + " ".join(SIDEWAYS.values()))), 1) + + +if __name__ == "__main__": + unittest.main() From 98b4f4f9469ffd9ba5b8fda230af4700e01b066e Mon Sep 17 00:00:00 2001 From: soul Date: Thu, 22 Oct 2026 17:11:35 +0200 Subject: [PATCH 107/307] Build checks that every kit line is drawn in the frame of its geometry: lp_line's marks part meets its lines part Co-Authored-By: Claude Sonnet 5.5 --- .../aetheria_ships/factions/lowpoly.scad | 10 ++++-- tools/blender/aetheria_ships/hull_source.py | 33 +++++++++++++++++++ 2 files changed, 41 insertions(+), 2 deletions(-) diff --git a/tools/blender/aetheria_ships/factions/lowpoly.scad b/tools/blender/aetheria_ships/factions/lowpoly.scad index ef3063c6c..23e82918b 100644 --- a/tools/blender/aetheria_ships/factions/lowpoly.scad +++ b/tools/blender/aetheria_ships/factions/lowpoly.scad @@ -33,7 +33,13 @@ module lp_at(t=[0,0,0], r=[0,0,0], mx=false) { multmatrix(M) children(); } // One schematic line: layer (outline, step, seam or fitting), points in the local frame, cyclic when it closes. +// Part "marks" draws a LP_MARK cube at the line's first point through the CTM, the frame the geometry is drawn in, where +// "lines" echoes the same point through $lp_m; hull_source compares the two, so a part placed by anything but lp_at +// (a raw translate, rotate or mirror) shows as a line away from its geometry. +LP_MARK = 0.002; module lp_line(layer, pts, cyclic=false) { - if (!is_undef(part) && part == "lines" && ($lp_m * [0, 0, 1, 0])[2] > 0.5) - echo(line = [layer, cyclic, [for (p = pts) lp_ap($lp_m, p)]]); + if (!is_undef(part) && ($lp_m * [0, 0, 1, 0])[2] > 0.5) { + if (part == "lines") echo(line = [layer, cyclic, [for (p = pts) lp_ap($lp_m, p)]]); + if (part == "marks") translate(pts[0]) cube(LP_MARK, center=true); + } } diff --git a/tools/blender/aetheria_ships/hull_source.py b/tools/blender/aetheria_ships/hull_source.py index 2689a7230..f004486e9 100644 --- a/tools/blender/aetheria_ships/hull_source.py +++ b/tools/blender/aetheria_ships/hull_source.py @@ -63,6 +63,7 @@ LINE_STYLE = {"silhouette": (1.0, 1.0, 1.0, 1.0), "outline": (1.0, 1.0, 1.0, 0.7 MIN_SILHOUETTE_AREA = 0.25 # m2, below which a projected contour is a sliver, not the hull LINE_EDGE_TOLERANCE = 0.3 # m, how far outside the silhouette's edge a kit line's point may lie DUPLICATE_CELL = 0.001 # m, kit lines whose points all agree to this are the same line +MARK_TOLERANCE = 0.01 # m, how far a mark of the kit may lie from the start of its line # A projector answers a list of Meshes with the contours (lists of (x, y)) of their top-view union. Projector = Callable[[list], "list[list[tuple]]"] @@ -347,6 +348,35 @@ def _triangles_xy(mesh): return [tuple(mesh.vertices[v][:2] for v in tri) for tri in mesh.triangles] +def mark_centres(mesh): + """The centre of each connected piece of mesh: the kit's "marks" part draws one tiny cube per line start.""" + parent = list(range(len(mesh.vertices))) + + def root(i): + while parent[i] != i: + parent[i] = parent[parent[i]] + i = parent[i] + return i + + for triangle in mesh.triangles: + for corner in triangle[1:]: + parent[root(corner)] = root(triangle[0]) + pieces = {} + for i in {corner for triangle in mesh.triangles for corner in triangle}: + pieces.setdefault(root(i), []).append(mesh.vertices[i]) + return [tuple((min(v[a] for v in vs) + max(v[a] for v in vs)) / 2 for a in range(3)) for vs in pieces.values()] + + +def _frame_disagreements(kit, marks): + """(lines with no mark at their first point, marks that start no line). The kit echoes a line in the frame lp_at tracks + and draws its mark in the frame the geometry is drawn in; they agree unless a part was placed by anything but lp_at.""" + starts = [line.points[0] for line in kit] + centres = mark_centres(marks) + near = lambda a, b: all(abs(x - y) <= MARK_TOLERANCE for x, y in zip(a, b)) + return (sum(not any(near(start, c) for c in centres) for start in starts), + sum(not any(near(c, start) for start in starts) for c in centres)) + + def _language(library, faction): """The language of factions/.scad, which owns it: exactly one echo, naming that faction.""" (language,) = _exactly_one(read_languages(library(faction)), "language") @@ -421,6 +451,9 @@ def build(runner, library, hull_type, projector): result.checks = [Check("the hull includes its faction's language files and changes none of them", sorted(included, key=by_faction) == sorted(owned, key=by_faction), "")] result.checks += _checks(result, own, judge) + unmarked, stray = _frame_disagreements(kit_lines, parse_off(runner("marks")[0])) + result.checks.append(Check("every kit line is drawn in the frame of its geometry", not (unmarked or stray), + f"{unmarked} lines and {stray} marks disagree")) return result From 8d58bca27a77a1a7086923e6b58cc73621027491 Mon Sep 17 00:00:00 2001 From: soul Date: Thu, 22 Oct 2026 17:11:38 +0200 Subject: [PATCH 108/307] Nakovalnya: casemate guns and plate seams emit their lines from the frame that draws them; the copied offsets are gone (geometry and lines unchanged) Co-Authored-By: Claude Sonnet 5.5 --- GameData/Mods/aetheria.nakovalnya/hull.scad | 28 +++++++++++++-------- 1 file changed, 17 insertions(+), 11 deletions(-) diff --git a/GameData/Mods/aetheria.nakovalnya/hull.scad b/GameData/Mods/aetheria.nakovalnya/hull.scad index 062285157..66353ca69 100644 --- a/GameData/Mods/aetheria.nakovalnya/hull.scad +++ b/GameData/Mods/aetheria.nakovalnya/hull.scad @@ -52,16 +52,21 @@ zh_mass(KEEL, lines=false); // under the hull: no top view for (s = [-1, 1]) lp_at(t=[s*DX, 0, 0]) zh_mass(DRV, "secondary"); // ---- the casemate guns: lighter cast-steel barrels out of charcoal mantlets ----------------------------------------- +MANTLET = [4.0, 1.2, 3.4]; module casemate_gun() { - zh_z("secondary") { - translate([0, GY - 0.3, GZ]) cbox([4.0, 1.2, 3.4], 0.2); // mantlet - translate([0, GY - 0.7, GZ]) rotate([0, 0, 180]) tube(BR*1.55, 1.8, 8); // barrel collar - translate([0, GY - 0.7 - BL, GZ]) cbox([BR*2.7, 1.6, BR*2.7], 0.12); // muzzle block + // mantlet frame: its fitting outline is drawn in the frame the mantlet is + lp_at(t=[0, GY - 0.3, GZ]) { + zh_z("secondary") cbox(MANTLET, 0.2); + lp_line("fitting", zh_rect(MANTLET.x, MANTLET.y, MANTLET.z/2), true); + } + // barrel frame: local +Y runs forward from the collar; the barrel's top line is drawn in the frame the barrel is + lp_at(t=[0, GY - 0.7, GZ], r=[0, 0, 180]) { + zh_z("secondary") tube(BR*1.55, 1.8, 8); // barrel collar + zh_z("primary") tube(BR, BL, 8); + zh_z("secondary") translate([0, BL, 0]) cbox([BR*2.7, 1.6, BR*2.7], 0.12); // muzzle block + zh_z("recess") translate([0, BL + 0.81, 0]) cube([BR*1.2, 0.05, BR*1.2], center=true); + lp_line("fitting", [[0, 0, BR], [0, BL, BR]]); } - zh_z("primary") translate([0, GY - 0.7, GZ]) rotate([0, 0, 180]) tube(BR, BL, 8); - zh_z("recess") translate([0, GY - 0.7 - BL - 0.81, GZ]) cube([BR*1.2, 0.05, BR*1.2], center=true); - lp_line("fitting", [for (p = zh_rect(4.0, 1.2, GZ + 1.7)) p + [0, GY - 0.3, 0]], true); - lp_line("fitting", [[0, GY - 0.7, GZ + BR], [0, GY - 0.7 - BL, GZ + BR]]); } for (x = CX) lp_at(t=[x, 0, 0]) casemate_gun(); for (x = CX) lp_at(t=[x, 0, 0]) zh_on_top(CAS, -21.9, 0) zh_stripes(3.6, 0.6, 4); @@ -90,9 +95,10 @@ zh_on_top(T2, -3.6, 0) zh_serial(0.7, 4); // ---- dorsal magazine hatches: two rows of four on the mid-deck, clear of the rim ----------------------------------- for (y = [-8.0, -3.4, 1.2, 5.6], u = [-0.78, 0.78]) zh_on_top(T1, y, u) zh_hatch(2.8, 3.0, dogs=false); -for (y = [0.5, 4.5]) zh_on_top(T2, y, 0) { // plate seams - zh_z("secondary") translate([0, 0, 0.06]) cube([8.4, 0.5, 0.2], center=true); - lp_line("seam", [[-4.2, 0, 0.16], [4.2, 0, 0.16]]); +PLATE_SEAM = [8.4, 0.5, 0.2]; +for (y = [0.5, 4.5]) zh_on_top(T2, y, 0) lp_at(t=[0, 0, 0.06]) { // plate seams + zh_z("secondary") cube(PLATE_SEAM, center=true); + lp_line("seam", [[-PLATE_SEAM.x/2, 0, PLATE_SEAM.z/2], [PLATE_SEAM.x/2, 0, PLATE_SEAM.z/2]]); } // Feed collar for an Oboz: a lighter cast-steel flange ring standing proud of the flank round the kit collar. From 66c38d9506caf6b45b3d98bc6751f3d3d8ee0f16 Mon Sep 17 00:00:00 2001 From: soul Date: Thu, 22 Oct 2026 17:17:40 +0200 Subject: [PATCH 109/307] Build asks for the marks part only when the kit drew lines (OpenSCAD refuses a part that draws nothing); hull_source documents marks Co-Authored-By: Claude Sonnet 5.5 --- tools/blender/aetheria_ships/hull_source.py | 8 ++++++-- tools/blender/tests/test_hull_source.py | 9 +++++++-- 2 files changed, 13 insertions(+), 4 deletions(-) diff --git a/tools/blender/aetheria_ships/hull_source.py b/tools/blender/aetheria_ships/hull_source.py index f004486e9..2d6d0ca16 100644 --- a/tools/blender/aetheria_ships/hull_source.py +++ b/tools/blender/aetheria_ships/hull_source.py @@ -15,12 +15,14 @@ with it (a hull that redefines or shadows an included language fails a check): frame (lowpoly.scad lp_line under lp_at frames); layer is one of KIT_LAYERS the zone's geometry, OFF; a donor's zone is named ":" emit:, rad: the mesh of a thruster-emitter or radiator-mesh anchor + marks OFF, a tiny cube at the first point of each kit line, drawn through the frame the geometry is drawn in (lp_line) Frame: Ship Root, metres, nose -Y, starboard -X, up +Z. build() also takes the hull's HullData.HullType ("Ship", "Station" or "Turret"), which the caller reads: the add-on from the bound package's ship.cc, the bench from its flag. Schematic lines (HullBuild.lines): the top-view silhouette first (OpenSCAD projection() of every zone mesh, the geometry Rasterise reads, read from the SVG it exports), then the kit's lines, each with a fixed RGBA from LINE_STYLE. A kit part is placed with -lp_at so its line is emitted in the frame the geometry is; a source that echoes no lines has the silhouette alone. +lp_at so its line is emitted in the frame the geometry is, and Build checks it: a line whose mark (the marks part) lies elsewhere +was placed by something else. A source that echoes no lines has the silhouette alone. A faction language's proportions judge Ship hulls only; every hull's zones must fit the layout grid (hull_grid.MAX_CELLS cells of hull_grid.CELL_SIZE a side). The brief's role is prose and decides nothing. build() returns a HullBuild; the caller refuses on any failed check. Messages and check names are fixed text: nothing a hull source says is echoed back. @@ -451,7 +453,9 @@ def build(runner, library, hull_type, projector): result.checks = [Check("the hull includes its faction's language files and changes none of them", sorted(included, key=by_faction) == sorted(owned, key=by_faction), "")] result.checks += _checks(result, own, judge) - unmarked, stray = _frame_disagreements(kit_lines, parse_off(runner("marks")[0])) + # A source with no kit lines has no marks, and OpenSCAD refuses to export a part that drew nothing. + marks = parse_off(runner("marks")[0]) if kit_lines else Mesh([], []) + unmarked, stray = _frame_disagreements(kit_lines, marks) result.checks.append(Check("every kit line is drawn in the frame of its geometry", not (unmarked or stray), f"{unmarked} lines and {stray} marks disagree")) return result diff --git a/tools/blender/tests/test_hull_source.py b/tools/blender/tests/test_hull_source.py index 46e153a3f..e77ec11b2 100644 --- a/tools/blender/tests/test_hull_source.py +++ b/tools/blender/tests/test_hull_source.py @@ -739,8 +739,13 @@ class FrameCheckTests(unittest.TestCase): def test_a_mark_a_millimetre_off_is_the_same_place(self): self.assertEqual(self.built(line(), marks=[(-1, -1, 2.001)]).failed(), []) - def test_a_source_with_no_lines_and_no_marks_passes(self): - self.assertEqual(self.built("", marks=[]).failed(), []) + def test_a_source_with_no_lines_asks_for_no_marks_because_openscad_refuses_a_part_that_draws_nothing(self): + fixture = Fixture() + runner = fixture.runner() + asked = [] + built = hs.build(lambda part: asked.append(part) or runner(part), fixture.read_library, "Ship", fixture.projector) + self.assertNotIn("marks", asked) + self.assertEqual(built.failed(), []) def test_the_detail_counts_the_lines_and_marks_that_disagree(self): built = self.built(line(), marks=[(5, 5, 2)]) From d3cd7813d7458a4825a44a093248db2e015d46db Mon Sep 17 00:00:00 2001 From: soul Date: Thu, 22 Oct 2026 17:31:08 +0200 Subject: [PATCH 110/307] Catalog subtraction: commodity records lose DemandProfile and both stored categories; the trade menu stops filtering by them Co-Authored-By: Claude Sonnet 5.5 --- Assets/Scripts/ServerShared/Enums.cs | 21 ----- Assets/Scripts/ServerShared/ItemData.cs | 10 +- Assets/Scripts/UI/Menu/TradeMenu.cs | 91 +------------------ .../CommodityRecordsTests.cs | 34 +++++++ 4 files changed, 39 insertions(+), 117 deletions(-) create mode 100644 tests/Aetheria.Shared.Tests/CommodityRecordsTests.cs diff --git a/Assets/Scripts/ServerShared/Enums.cs b/Assets/Scripts/ServerShared/Enums.cs index 63d2674db..0a6449dc1 100644 --- a/Assets/Scripts/ServerShared/Enums.cs +++ b/Assets/Scripts/ServerShared/Enums.cs @@ -152,27 +152,6 @@ public enum MegaPlacementType Isolated = 4 } -public enum SimpleCommodityCategory -{ - Minerals = 0, - Metals = 1, - Alloys = 2, - Compounds = 3, - Organics = 4, - Ammo = 5, - Consumer = 6 -} - -public enum CompoundCommodityCategory -{ - Wearables = 0, - Consumables = 1, - Luxuries = 2, - Tools = 3, - Manufacturing = 4, - Assemblies = 5 -} - public enum LocationType { Station, diff --git a/Assets/Scripts/ServerShared/ItemData.cs b/Assets/Scripts/ServerShared/ItemData.cs index ca1484e36..dcb38456a 100644 --- a/Assets/Scripts/ServerShared/ItemData.cs +++ b/Assets/Scripts/ServerShared/ItemData.cs @@ -322,13 +322,11 @@ public abstract class ItemData [CultDocument("aetheria.simplecommoditydata", "1"), Inspectable, MessagePackObject] public class SimpleCommodityData : ItemData { - // MessagePack keys 6, 7, 8 and 11 belonged to removed resource-distribution fields; do not reuse them. + // MessagePack keys 6, 7, 8, 10 and 11 belonged to removed resource-distribution fields; do not reuse them. [Inspectable, JsonProperty("maxStackSize"), Key(9)] public int MaxStack = 10; - [Inspectable, JsonProperty("category"), Key(10)] - public SimpleCommodityCategory Category; } [JsonObject(MemberSerialization.OptIn)] @@ -352,11 +350,7 @@ public class ItemRole [CultDocument("aetheria.compoundcommoditydata", "1"), Inspectable, MessagePackObject] public class CompoundCommodityData : CraftedItemData { - [Inspectable, CultReference(typeof(PersonalityAttribute), many: true), JsonProperty("demandProfile"), Key(10)] - public Dictionary, float> DemandProfile = new Dictionary, float>(); - - [Inspectable, JsonProperty("category"), Key(11)] - public CompoundCommodityCategory Category; + // MessagePack keys 10 and 11 belonged to the removed demand weights and classification; do not reuse them. } [CultDocument("aetheria.consumableitemdata", "1"), Inspectable, MessagePackObject] diff --git a/Assets/Scripts/UI/Menu/TradeMenu.cs b/Assets/Scripts/UI/Menu/TradeMenu.cs index 1adadcf15..3d448f64d 100644 --- a/Assets/Scripts/UI/Menu/TradeMenu.cs +++ b/Assets/Scripts/UI/Menu/TradeMenu.cs @@ -28,8 +28,6 @@ public class TradeMenu : MonoBehaviour // False lists the station's wares to buy; true lists the selected bay's goods to sell. private bool _selling; private (ItemFilter filter, HardpointType type) _hardpointFilter; - private (ItemFilter filter, SimpleCommodityCategory type) _commodityFilter; - private (ItemFilter filter, CompoundCommodityCategory type) _compoundCommodityFilter; private List<(ItemFilter filter, Type type)> _behaviorFilters = new List<(ItemFilter filter, Type type)>(); public EquippedCargoBay Inventory { get; set; } @@ -62,14 +60,6 @@ public class TradeMenu : MonoBehaviour if (_hardpointFilter.filter != null) hardpointTypes = hardpointTypes.Where(x => x != _hardpointFilter.type); - IEnumerable commodityTypes = (SimpleCommodityCategory[]) Enum.GetValues(typeof(SimpleCommodityCategory)); - if (_commodityFilter.filter != null) - commodityTypes = commodityTypes.Where(x => x != _commodityFilter.type); - - IEnumerable compoundCommodityTypes = (CompoundCommodityCategory[]) Enum.GetValues(typeof(CompoundCommodityCategory)); - if (_compoundCommodityFilter.filter != null) - compoundCommodityTypes = compoundCommodityTypes.Where(x => x != _compoundCommodityFilter.type); - ContextMenu.AddDropdown("Gear Type", hardpointTypes .Select(x => (Enum.GetName(typeof(HardpointType), x), () => { @@ -82,75 +72,10 @@ public class TradeMenu : MonoBehaviour Populate(); }; } - if(_commodityFilter.filter) - { - _commodityFilter.filter.GetComponent().ReturnToPool(); - _commodityFilter.filter = null; - } - if(_compoundCommodityFilter.filter) - { - _compoundCommodityFilter.filter.GetComponent().ReturnToPool(); - _compoundCommodityFilter.filter = null; - } - _hardpointFilter.filter.Label.text = $"Hardpoint: {Enum.GetName(typeof(HardpointType), x)}"; _hardpointFilter.type = x; Populate(); }, true))); - ContextMenu.AddDropdown("Simple Commodity", commodityTypes - .Select(x => (Enum.GetName(typeof(SimpleCommodityCategory), x), () => - { - if (!_commodityFilter.filter) - { - _commodityFilter.filter = FilterPrototype.Instantiate(); - _commodityFilter.filter.OnDisable += () => - { - _commodityFilter.filter = null; - Populate(); - }; - } - if(_hardpointFilter.filter) - { - _hardpointFilter.filter.GetComponent().ReturnToPool(); - _hardpointFilter.filter = null; - } - if(_compoundCommodityFilter.filter) - { - _compoundCommodityFilter.filter.GetComponent().ReturnToPool(); - _compoundCommodityFilter.filter = null; - } - - _commodityFilter.filter.Label.text = $"Hardpoint: {Enum.GetName(typeof(SimpleCommodityCategory), x)}"; - _commodityFilter.type = x; - Populate(); - }, true))); - ContextMenu.AddDropdown("Compound Commodity", compoundCommodityTypes - .Select(x => (Enum.GetName(typeof(CompoundCommodityCategory), x), () => - { - if (!_compoundCommodityFilter.filter) - { - _compoundCommodityFilter.filter = FilterPrototype.Instantiate(); - _compoundCommodityFilter.filter.OnDisable += () => - { - _compoundCommodityFilter.filter = null; - Populate(); - }; - } - if(_hardpointFilter.filter) - { - _hardpointFilter.filter.GetComponent().ReturnToPool(); - _hardpointFilter.filter = null; - } - if(_commodityFilter.filter) - { - _commodityFilter.filter.GetComponent().ReturnToPool(); - _commodityFilter.filter = null; - } - - _compoundCommodityFilter.filter.Label.text = $"Hardpoint: {Enum.GetName(typeof(CompoundCommodityCategory), x)}"; - _compoundCommodityFilter.type = x; - Populate(); - }, true))); ContextMenu.AddDropdown("Item Behavior", typeof(BehaviorData).GetAllChildClasses() .Where(x=> x.GetCustomAttribute() != null && _behaviorFilters.All(f => f.type != x)) .Select(x=> (x.Name.FormatTypeName(), () => @@ -199,19 +124,15 @@ public class TradeMenu : MonoBehaviour columns.Add(("Type", 2, x => () => { - if (x.data is SimpleCommodityData s) return Enum.GetName(typeof(SimpleCommodityCategory), s.Category); - if(x.data is CompoundCommodityData c) return Enum.GetName(typeof(CompoundCommodityCategory), c.Category); + if (x.data is SimpleCommodityData || x.data is CompoundCommodityData) return "Commodity"; if(x.data is EquippableItemData e) return Enum.GetName(typeof(HardpointType), e.HardpointType); return "None"; }, x => { var data = x.data; - if (data is SimpleCommodityData s) return (int) s.Category; - var offset = Enum.GetValues(typeof(SimpleCommodityCategory)).Length; - if(data is CompoundCommodityData c) return (int) c.Category + offset; - offset += Enum.GetValues(typeof(CompoundCommodityCategory)).Length; - if(data is EquippableItemData e) return (int) e.HardpointType + offset; + if (data is SimpleCommodityData || data is CompoundCommodityData) return 0; + if (data is EquippableItemData e) return 1 + (int) e.HardpointType; return 0; })); columns.Add(("Mass", 1, @@ -237,12 +158,6 @@ public class TradeMenu : MonoBehaviour !(MaximumSizeFilter.Width.text.Length > 0 && i.data.Shape.Width > int.Parse(MaximumSizeFilter.Width.text) || MaximumSizeFilter.Height.text.Length > 0 && i.data.Shape.Height > int.Parse(MaximumSizeFilter.Height.text))); - if(_commodityFilter.filter != null) - items = items.Where(i => i.data is SimpleCommodityData s && s.Category == _commodityFilter.type); - - if(_compoundCommodityFilter.filter != null) - items = items.Where(i => i.data is CompoundCommodityData c && c.Category == _compoundCommodityFilter.type); - if (_hardpointFilter.filter != null) items = items.Where(i => i.data is EquippableItemData e && e.HardpointType == _hardpointFilter.type); diff --git a/tests/Aetheria.Shared.Tests/CommodityRecordsTests.cs b/tests/Aetheria.Shared.Tests/CommodityRecordsTests.cs new file mode 100644 index 000000000..7f1c376c3 --- /dev/null +++ b/tests/Aetheria.Shared.Tests/CommodityRecordsTests.cs @@ -0,0 +1,34 @@ +using System; +using System.Linq; +using System.Reflection; +using MessagePack; +using Xunit; + +// Commodity records carry no stored classification and no demand weights (aetheria-living-world, cut +// catalog-subtraction): stage, component-ness and consumer-ness are derived later from recipes, roles and needs. +public sealed class CommodityRecordsTests +{ + // The shipped bytes still hold values at the retired keys; MessagePack skips them and every record loads by name. + [Fact] + public void ShippedCatalogOpensWithTheRetiredKeysGone() + { + using var cache = RestoredHullsTests.OpenCatalog(); + Assert.Equal(13, cache.GetAll().Count()); + Assert.Equal(51, cache.GetAll().Count()); + Assert.Contains(cache.GetAll(), c => c.Name == "Ammo"); + Assert.All(cache.GetAll(), c => Assert.False(string.IsNullOrEmpty(c.Name))); + } + + // Keys 10 and 11 are retired: no commodity member may reuse them for a different meaning. + [Fact] + public void RetiredKeysStayRetired() + { + int[] KeysOf(Type type) => type.GetFields(BindingFlags.Public | BindingFlags.Instance | BindingFlags.DeclaredOnly) + .Select(f => f.GetCustomAttribute()?.IntKey).Where(k => k.HasValue).Select(k => k.Value).ToArray(); + Assert.Equal(new[] { 9 }, KeysOf(typeof(SimpleCommodityData))); + Assert.Empty(KeysOf(typeof(CompoundCommodityData))); + Assert.Null(typeof(SimpleCommodityData).GetField("Category")); + Assert.Null(typeof(CompoundCommodityData).GetField("Category")); + Assert.Null(typeof(CompoundCommodityData).GetField("DemandProfile")); + } +} From ae52857a9154e1e4eb1d744fbb7542a295ab7a95 Mon Sep 17 00:00:00 2001 From: soul Date: Thu, 22 Oct 2026 18:01:23 +0200 Subject: [PATCH 111/307] SimpleCommodityData: name key 10 as the removed stored Category (Soul s1 simple-key10-comment-misnamed) Co-Authored-By: Claude Opus 5.5 --- Assets/Scripts/ServerShared/ItemData.cs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/Assets/Scripts/ServerShared/ItemData.cs b/Assets/Scripts/ServerShared/ItemData.cs index dcb38456a..170c2421d 100644 --- a/Assets/Scripts/ServerShared/ItemData.cs +++ b/Assets/Scripts/ServerShared/ItemData.cs @@ -322,7 +322,7 @@ public abstract class ItemData [CultDocument("aetheria.simplecommoditydata", "1"), Inspectable, MessagePackObject] public class SimpleCommodityData : ItemData { - // MessagePack keys 6, 7, 8, 10 and 11 belonged to removed resource-distribution fields; do not reuse them. + // MessagePack keys 6, 7, 8 and 11 belonged to removed resource-distribution fields, and key 10 to the removed stored Category; do not reuse them. [Inspectable, JsonProperty("maxStackSize"), Key(9)] public int MaxStack = 10; From c40a6e8229ff1dc2c81bb18bb1ecc384303dc2e8 Mon Sep 17 00:00:00 2001 From: soul Date: Thu, 22 Oct 2026 18:21:12 +0200 Subject: [PATCH 112/307] View seam tests: per-shooter aim, view gates, sees-hostile, honest track loss (red) Co-Authored-By: Claude Sonnet 5.5 --- .../ApproachKnowledgeTests.cs | 19 +++--- .../FireControlPerWeaponTests.cs | 46 ++++++++++++++ .../PerShooterAimTests.cs | 60 +++++++++++++++++++ tests/Aetheria.Shared.Tests/PilotViewTests.cs | 53 ++++++++++++++++ 4 files changed, 171 insertions(+), 7 deletions(-) create mode 100644 tests/Aetheria.Shared.Tests/PerShooterAimTests.cs diff --git a/tests/Aetheria.Shared.Tests/ApproachKnowledgeTests.cs b/tests/Aetheria.Shared.Tests/ApproachKnowledgeTests.cs index 98f857913..cd48907a7 100644 --- a/tests/Aetheria.Shared.Tests/ApproachKnowledgeTests.cs +++ b/tests/Aetheria.Shared.Tests/ApproachKnowledgeTests.cs @@ -9,14 +9,14 @@ using float3 = CultMath.float3; // track from the first step, and the pilot follows it only as long as the sensors keep it. public sealed partial class RunStartTests { - private (Zone arena, Ship east, Ship west) StagedApproach() + private (Zone arena, Ship east, Ship west) StagedApproach(float westX = -450f) { Ship east = null, west = null; var (_, arena, _, failures) = Launch(new Scripted(false, stage => { stage.Player(stage.Bare("Djinni"), float2(-50000, -50000)); east = stage.Place(EvLonginus(stage), float2(450, 0), facing: float2(-1, 0), piloted: true) as Ship; - west = stage.Place(EvLonginus(stage), float2(-450, 0), facing: float2(1, 0), piloted: true) as Ship; + west = stage.Place(EvLonginus(stage), float2(westX, 0), facing: float2(1, 0), piloted: true) as Ship; stage.Approach(east, west); })); Assert.True(failures.Count == 0, string.Join("; ", failures)); @@ -39,22 +39,27 @@ public sealed partial class RunStartTests Assert.True(east.Position.x - west.Position.x < startGap - 20f, "the follower closed on its anchor"); } + // The anchor is staged 60 km off, far outside sensor reach: the staged knowledge decays through the sensors like any + // track (the Sensor is the only writer after staging), and the follower stops once they have dropped it. Nothing + // here writes the pilot's knowledge after the stage. [Fact] public void AStagedApproachFollowerStopsFollowingOnceTheSensorsLoseTheTrack() { - var (arena, east, west) = StagedApproach(); + var (arena, east, west) = StagedApproach(westX: -60000f); var view = arena.Agents.Single(a => a.Ship == east).View; var sentinel = new float3(0, 0, 12345); - for (var i = 0; i < 3; i++) arena.Update(1f / 60f); + Assert.NotNull(view.TrackOf(east, PilotView.Handle(west))); east.Command.Aim = sentinel; - arena.Update(1f / 60f); + arena.Update(.1f); Assert.NotEqual(sentinel, east.Command.Aim.Value); - east.EntityInfoGathered[west] = 0f; + var steps = 0; + while (view.TrackOf(east, PilotView.Handle(west)) != null && steps++ < 1200) arena.Update(.1f); Assert.Null(view.TrackOf(east, PilotView.Handle(west))); + east.Command.Aim = sentinel; - arena.Update(1f / 60f); + arena.Update(.1f); Assert.Equal(sentinel, east.Command.Aim.Value); } } diff --git a/tests/Aetheria.Shared.Tests/FireControlPerWeaponTests.cs b/tests/Aetheria.Shared.Tests/FireControlPerWeaponTests.cs index 2c70cf438..5c0aa19a5 100644 --- a/tests/Aetheria.Shared.Tests/FireControlPerWeaponTests.cs +++ b/tests/Aetheria.Shared.Tests/FireControlPerWeaponTests.cs @@ -444,6 +444,52 @@ public sealed class FireControlPerWeaponTests : IDisposable Assert.Equal(expected, view.Intercept(r.Shooter, weapon, contact)); } + // A view answers fire control only for a member that tracks the contact: the shooter outside the view, or having lost + // the track, gets no intercept, no reveals, no designated weapon, a refusal, and no shot; a write for a non-member + // changes nothing. The member that tracks is the positive control, answering as FireControl does. Kills: any of + // Intercept, WeaponReveals, Designated, Refuses, Fires or SelectItem answering without the member's own track or + // membership. + public enum Standing { TrackingMember, NotAMember, LostTheTrack } + + [Theory] + [InlineData(Standing.TrackingMember)] + [InlineData(Standing.NotAMember)] + [InlineData(Standing.LostTheTrack)] + public void AViewAnswersFireControlOnlyForAMemberThatTracksTheContact(Standing standing) + { + var r = Build(velocity: 500f); + var targetGun = r.Target.Equipment.Single(i => i.Behaviors.OfType().Any()); + r.Shooter.RecordReveal(r.Target, targetGun, SignatureChannel.LaunchFlash, targetGun.Data); + var view = standing == Standing.NotAMember ? new PilotView() : new PilotView(r.Shooter); + var contact = PilotView.Handle(r.Target); + var weapon = r.Gun.Weapon; + var reveal = new PilotView(r.Shooter).WeaponReveals(r.Shooter, contact).Single(); + if (standing == Standing.LostTheTrack) r.Shooter.VisibleEntities.Remove(r.Target); + var minHit = 0f; + + var intercept = view.Intercept(r.Shooter, weapon, contact); + var reveals = view.WeaponReveals(r.Shooter, contact); + view.SelectItem(r.Shooter, reveal.Id); + if (standing == Standing.TrackingMember) + { + Assert.NotNull(intercept); + Assert.Single(reveals); + Assert.True(view.Designated(r.Shooter, weapon, contact)); + Assert.False(view.Refuses(r.Shooter, weapon, contact)); + Assert.True(view.Fires(r.Shooter, weapon, contact, minHit, null)); + Assert.Single(r.Shooter.Command.Selections); + } + else + { + Assert.Null(intercept); + Assert.Empty(reveals); + Assert.False(view.Designated(r.Shooter, weapon, contact)); + Assert.True(view.Refuses(r.Shooter, weapon, contact)); + Assert.False(view.Fires(r.Shooter, weapon, contact, minHit, null)); + if (standing == Standing.NotAMember) Assert.Empty(r.Shooter.Command.Selections); + } + } + private static void AssertAimsAtTarget(Rig r) { var toTarget = r.Target.Position - r.Shooter.Position; diff --git a/tests/Aetheria.Shared.Tests/PerShooterAimTests.cs b/tests/Aetheria.Shared.Tests/PerShooterAimTests.cs new file mode 100644 index 000000000..e284abf56 --- /dev/null +++ b/tests/Aetheria.Shared.Tests/PerShooterAimTests.cs @@ -0,0 +1,60 @@ +using System; +using System.Linq; +using System.Reflection; +using CultMath; +using Xunit; +using static CultMath.math; +using float2 = CultMath.float2; +using float3 = CultMath.float3; + +// Fire control is per shooter (ruling per-shooter-fire-control): a pilot flies, aims and fires on what its own sensors +// track, not on a track only a sidemate holds. +public sealed partial class RunStartTests +{ + // East and west are one side, one view; only west tracks x. East is designated on x: it must not aim or pull on west's + // track (Combat reads TrackOf for the shooter, not SideTrack). The positive control has east track x as well, and it aims. + // Kills: Combat flying on SideTrack. + [Theory] + [InlineData(false)] + [InlineData(true)] + public void AMemberDesignatedOnAContactOnlyItsSidemateTracksNeitherAimsNorFires(bool eastTracks) + { + Ship east = null, west = null, x = null; + var (_, arena, _, failures) = Launch(new Scripted(false, stage => + { + stage.Player(stage.Bare("Djinni"), float2(-50000, -50000)); + east = stage.Place(EvLonginus(stage), float2(100, 0), facing: float2(-1, 0), piloted: true) as Ship; + west = stage.Place(EvLonginus(stage), float2(-100, 0), facing: float2(1, 0), piloted: true) as Ship; + x = stage.Place(EvLonginus(stage), float2(0, 300), facing: float2(0, -1)) as Ship; + })); + Assert.True(failures.Count == 0, string.Join("; ", failures)); + var known = (east.ItemManager.GameplaySettings.TargetDetectionInfoThreshold + 1f) / 2f; + east.SetIff(x, true); + west.SetIff(x, true); + var side = new PilotView(east, west); + var agent = arena.Agents.Single(a => a.Ship == east); + agent.View = side; + west.EntityInfoGathered[x] = known; + if (eastTracks) east.EntityInfoGathered[x] = known; + Assert.NotNull(side.SideTrack(PilotView.Handle(x))); + Assert.Equal(eastTracks, side.TrackOf(east, PilotView.Handle(x)) != null); + + var sentinel = new float3(0, 0, -12345); + object state = null; + for (var i = 0; i < 4; i++) + { + east.Command.Designate(new TargetRef(x)); + east.Command.Aim = sentinel; + agent.Update(1f / 60f); + state = typeof(Agent).GetField("_currentState", BindingFlags.NonPublic | BindingFlags.Instance).GetValue(agent); + } + Assert.IsType(state); + var aimed = !east.Command.Aim.Value.Equals(sentinel); + if (eastTracks) Assert.True(aimed, "a member that tracks the contact aims at it"); + else + { + Assert.False(aimed, $"a member flew its aim on a sidemate's track: {east.Command.Aim}"); + Assert.False(east.Weapons.Any(w => east.Command.Pulls(w)), "a member pulled a trigger on a sidemate's track"); + } + } +} diff --git a/tests/Aetheria.Shared.Tests/PilotViewTests.cs b/tests/Aetheria.Shared.Tests/PilotViewTests.cs index 5c371de49..6d47fe3c4 100644 --- a/tests/Aetheria.Shared.Tests/PilotViewTests.cs +++ b/tests/Aetheria.Shared.Tests/PilotViewTests.cs @@ -2,6 +2,7 @@ using System; using System.Linq; using CultMath; using GameCult.Caching; +using UniRx; using Xunit; // A side's brain reaches other ships only through its PilotView (Agents/PilotView.cs): handles it issued, tracks only @@ -263,4 +264,56 @@ public sealed class PilotViewTests : IDisposable } Assert.Equal(300f, member.Command.Aim.Value.z, 1); } + + // The hostile part of Sees is the member's own: a member holding a sidemate's enemy as friendly (a hail it answered, an + // IFF override) does not see it as an enemy, though it tracks it. Kills: Sees reading VisibleEntities. + [Fact] + public void AMemberThatHoldsASidematesEnemyAsFriendlyDoesNotSeeItAsOne() + { + var faction = new Faction { Name = "Plain" }; + var a = Pilot(faction, -10); + var b = Pilot(faction, 10); + var x = Subject(); + See(b, x); + a.EntityInfoGathered[x] = 1; + a.VisibleEntities.Add(x); + a.SetIff(x, false); + var view = ViewOf(a); + var handle = PilotView.Handle(x); + + Assert.NotNull(view.TrackOf(a, handle)); + Assert.True(view.Sees(b, handle)); + Assert.False(view.Sees(a, handle)); + } + + // A view answers for its members only: every per-member read of a ship the view does not hold is empty, however much + // that ship knows, and a write for it changes nothing. One assertion per read, each killing its dropped membership check. + [Fact] + public void AViewAnswersNothingForAShipThatIsNotItsMember() + { + var a = Pilot(null, -200); + var outsider = Pilot(null, 200); + var x = Subject(); + See(outsider, x); + outsider.SetIff(x, true); + x.PresencePermitted = Observable.Return(false).ToReadOnlyReactiveProperty(); + outsider.Command.Designate(new TargetRef(x)); + var handle = PilotView.Handle(x); + var view = ViewOf(a); + Assert.NotNull(outsider.IffOverride(x)); + Assert.True(outsider.BelievesTrespassing(x)); + Assert.True(outsider.VisibleEnemies.Contains(x)); + + Assert.Null(view.TrackOf(outsider, handle)); + Assert.False(view.Sees(outsider, handle)); + Assert.Null(view.IffOverride(outsider, handle)); + Assert.Null(view.Info(outsider, handle)); + Assert.False(view.BelievesTrespassing(outsider, handle)); + Assert.Null(view.TargetOf(outsider)); + PilotApplier.Apply(outsider); + Assert.Null(view.AppliedTargetOf(outsider)); + + view.Designate(outsider, null); + Assert.Null(outsider.Command.Designated); + } } From 70aa1ed037b7002f82389104fcec9ca759037a40 Mon Sep 17 00:00:00 2001 From: soul Date: Thu, 22 Oct 2026 18:22:43 +0200 Subject: [PATCH 113/307] Transfer verb: drop the redundant pre-check, let range, step length and quantity edges show in tests TryStore already takes all of a part or none, so the pre-check in TryTransferItem changed nothing observable. Co-Authored-By: Claude Sonnet 5.5 --- Assets/Scripts/ServerShared/CargoTransfer.cs | 2 +- Assets/Scripts/ServerShared/Entity.cs | 6 +- .../CargoTransferTests.cs | 66 +++++++++++++++++-- 3 files changed, 62 insertions(+), 12 deletions(-) diff --git a/Assets/Scripts/ServerShared/CargoTransfer.cs b/Assets/Scripts/ServerShared/CargoTransfer.cs index 4026bbc7b..6484bace8 100644 --- a/Assets/Scripts/ServerShared/CargoTransfer.cs +++ b/Assets/Scripts/ServerShared/CargoTransfer.cs @@ -94,7 +94,7 @@ public static class CargoTransfer order.Budget -= 1; order.Remaining--; } - if (giver.Giving == order && order.Remaining == 0) End(giver, GiveEnd.Done); + if (order.Remaining == 0) End(giver, GiveEnd.Done); } } diff --git a/Assets/Scripts/ServerShared/Entity.cs b/Assets/Scripts/ServerShared/Entity.cs index 00df96087..721ac31b0 100644 --- a/Assets/Scripts/ServerShared/Entity.cs +++ b/Assets/Scripts/ServerShared/Entity.cs @@ -2664,17 +2664,13 @@ public class EquippedCargoBay : EquippedItem return false; } - // Whole or nothing: the target must be able to take the part before it leaves the stack. - var part = new SimpleCommodity { Data = item.Data, Quantity = min(quantity, item.Quantity), Rotation = item.Rotation }; - if (!target.TryFindSpace(part, out _)) return false; - var oldPos = Cargo[item]; var newItem = Remove(item, quantity); // The part removed goes across, never the stack left behind. if (target.TryStore(newItem)) return true; - // The target refused after all: put the part back where it came from. + // Whole or nothing: TryStore takes all of the part or none of it, so a refusal puts the part back where it came from. TryStore(newItem, oldPos); return false; } diff --git a/tests/Aetheria.Shared.Tests/CargoTransferTests.cs b/tests/Aetheria.Shared.Tests/CargoTransferTests.cs index d2c1db6fb..28eae6cf9 100644 --- a/tests/Aetheria.Shared.Tests/CargoTransferTests.cs +++ b/tests/Aetheria.Shared.Tests/CargoTransferTests.cs @@ -165,8 +165,9 @@ public sealed class CargoTransferTests : IDisposable var receiver = ShipWith(receiverBay); var stack = Commodity("Ore", ore); if (ore > 0) Assert.True(giver.CargoBays[0].TryStore(stack)); - giver.Position = new float3(0, 0, 0); - receiver.Position = new float3(5, 0, 0); + // Off the origin, so a distance taken from the wrong sum or difference of positions shows. + giver.Position = new float3(-50, 0, 10); + receiver.Position = giver.Position + new float3(5, 0, 0); _zone.Admit(giver, piloted: false); _zone.Admit(receiver, piloted: false); return (giver, receiver, stack); @@ -216,13 +217,13 @@ public sealed class CargoTransferTests : IDisposable { var (giver, receiver, ore) = Alongside(10); var range = _items.GameplaySettings.DockingDistance; - receiver.Position = new float3(range - 1, 0, 0); + receiver.Position = giver.Position + new float3(range - 1, 0, 0); giver.Command.Give(receiver, ore, 10); Steps(10); Assert.Equal(2, Held(receiver, ore)); Assert.Null(giver.LastGiveEnd); - receiver.Position = new float3(range + 1, 0, 0); + receiver.Position = giver.Position + new float3(range + 1, 0, 0); Steps(10); Assert.Equal(2, Held(receiver, ore)); @@ -230,12 +231,65 @@ public sealed class CargoTransferTests : IDisposable Assert.Equal(GiveEnd.OutOfRange, giver.LastGiveEnd); } + [Fact] + public void AReceiverExactlyAtTheDockingDistanceIsStillAlongside() + { + var (giver, receiver, ore) = Alongside(10); + receiver.Position = giver.Position + new float3(0, 0, _items.GameplaySettings.DockingDistance); + giver.Command.Give(receiver, ore, 10); + Steps(10); + Assert.Equal(2, Held(receiver, ore)); + Assert.Null(giver.LastGiveEnd); + } + + [Fact] + public void AStepOfExactlyOneUnitMovesOneUnit() + { + var (giver, receiver, ore) = Alongside(10); + giver.Command.Give(receiver, ore, 10); + _zone.Update(1f / _items.GameplaySettings.TransferRate); + Assert.Equal(1, Held(receiver, ore)); + } + + [Fact] + public void ALongStepNeverMovesMoreThanTheQuantity() + { + var (giver, receiver, ore) = Alongside(10); + giver.Command.Give(receiver, ore, 3); + _zone.Update(5f); + Assert.Equal(3, Held(receiver, ore)); + Assert.Equal(7, Held(giver, ore)); + Assert.Equal(GiveEnd.Done, giver.LastGiveEnd); + } + + [Fact] + public void AGiveOfOneMovesOne() + { + var (giver, receiver, ore) = Alongside(10); + giver.Command.Give(receiver, ore, 1); + Steps(20); + Assert.Equal(1, Held(receiver, ore)); + Assert.Equal(GiveEnd.Done, giver.LastGiveEnd); + } + + [Fact] + public void AGiveOfACraftedItemTheGiverDoesNotHoldEndsNothingLeft() + { + var (giver, receiver, _) = Alongside(0); + var gun = Mint("Gun"); + Assert.True(receiver.CargoBays[0].TryStore(gun)); + giver.Command.Give(receiver, gun, 1); + Steps(20); + Assert.Equal(GiveEnd.NothingLeft, giver.LastGiveEnd); + Assert.True(receiver.CargoBays[0].Cargo.ContainsKey(gun)); + } + [Fact] public void GiveRangeIsMeasuredOnTheFlatPlane() { // The plane is xz: a ship far apart in y only is still alongside. var (giver, receiver, ore) = Alongside(10); - receiver.Position = new float3(5, 1000, 0); + receiver.Position = giver.Position + new float3(5, 1000, 0); giver.Command.Give(receiver, ore, 10); Steps(10); Assert.Equal(2, Held(receiver, ore)); @@ -346,7 +400,7 @@ public sealed class CargoTransferTests : IDisposable { var (giver, receiver, ore) = Alongside(10); var third = ShipWith("Crate"); - third.Position = new float3(0, 0, 5); + third.Position = giver.Position + new float3(0, 0, 5); _zone.Admit(third, piloted: false); // A give then a stop leaves nothing, and nothing ended. From d38ee7cdd6f81b4f3291bbde93810023fb572ad7 Mon Sep 17 00:00:00 2001 From: soul Date: Thu, 22 Oct 2026 18:25:58 +0200 Subject: [PATCH 114/307] Approach track-loss test: warm up before reading the follower's aim (the 60 km anchor is lost by the sensors) Co-Authored-By: Claude Sonnet 5.5 --- tests/Aetheria.Shared.Tests/ApproachKnowledgeTests.cs | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/tests/Aetheria.Shared.Tests/ApproachKnowledgeTests.cs b/tests/Aetheria.Shared.Tests/ApproachKnowledgeTests.cs index cd48907a7..5fdf7bffd 100644 --- a/tests/Aetheria.Shared.Tests/ApproachKnowledgeTests.cs +++ b/tests/Aetheria.Shared.Tests/ApproachKnowledgeTests.cs @@ -49,9 +49,11 @@ public sealed partial class RunStartTests var view = arena.Agents.Single(a => a.Ship == east).View; var sentinel = new float3(0, 0, 12345); + Assert.NotNull(view.TrackOf(east, PilotView.Handle(west))); + for (var i = 0; i < 3; i++) arena.Update(1f / 60f); Assert.NotNull(view.TrackOf(east, PilotView.Handle(west))); east.Command.Aim = sentinel; - arena.Update(.1f); + arena.Update(1f / 60f); Assert.NotEqual(sentinel, east.Command.Aim.Value); var steps = 0; From 961bdafade70089beccd2a2e93838f29467da0a1 Mon Sep 17 00:00:00 2001 From: soul Date: Thu, 22 Oct 2026 18:26:03 +0200 Subject: [PATCH 115/307] Determinism ledger: the TwoAiDigestIsPinned comment names both causes of the second repin Co-Authored-By: Claude Sonnet 5.5 --- tests/Aetheria.Shared.Tests/DeterminismLedgerTests.cs | 8 ++++++-- 1 file changed, 6 insertions(+), 2 deletions(-) diff --git a/tests/Aetheria.Shared.Tests/DeterminismLedgerTests.cs b/tests/Aetheria.Shared.Tests/DeterminismLedgerTests.cs index 6ff03b525..65be67d68 100644 --- a/tests/Aetheria.Shared.Tests/DeterminismLedgerTests.cs +++ b/tests/Aetheria.Shared.Tests/DeterminismLedgerTests.cs @@ -24,8 +24,12 @@ public sealed partial class RunStartTests // The two-AI fight's digest, captured on Yggdrasil. Replays are trusted only on the host class that recorded them // (ruling replays-per-host-class), and arenas run on Linux. private const string TwoAiPinnedHostClass = "linux-x64"; - // Repinned twice. First for the side split: the two fighters are unaffiliated, were one side and each followed itself. - // Then for staged knowledge (ruling repin-for-staged-knowledge): the fighters are staged knowing each other, so they engage at step 0. + // Repinned twice, and the second repin has two causes (Soul measured the steps: group 1 + seed = f19b9992, + the Follow + // gate = ce81a426; group 2 without the seed = bf586806). + // First for the side split: the two fighters are unaffiliated, were one side and each followed itself (1b2ffe51). + // Then for staged knowledge (ruling repin-for-staged-knowledge), which moved it twice: the fighters are staged knowing + // each other, so they engage at step 0 (f19b9992); and Follow now flies only on an anchor the pilot itself tracks, so + // once the sensors drop the staged anchor the follower stops following it (ce81a426). private const string TwoAiPinnedDigest = "ce81a426c40495e4b4b7b7f83ad23767cc781dce5523d713b4455e0aa5b7fe33"; private const int TwoAiSteps = 3600; From 488b3fa434daa88439c161e88ecbe2657fbfaf5d Mon Sep 17 00:00:00 2001 From: soul Date: Thu, 22 Oct 2026 18:26:07 +0200 Subject: [PATCH 116/307] PilotView enforces its own rules: members only, fire-control readouts only on the member's own track; constructor and View setter internal Co-Authored-By: Claude Sonnet 5.5 --- Assets/Scripts/ServerShared/Agents/Agent.cs | 2 +- .../Scripts/ServerShared/Agents/PilotView.cs | 83 +++++++++++++------ .../ServerShared/Agents/States/Combat.cs | 3 +- Assets/Scripts/ServerShared/ScenarioStage.cs | 3 + .../FireControlPerWeaponTests.cs | 2 +- 5 files changed, 65 insertions(+), 28 deletions(-) diff --git a/Assets/Scripts/ServerShared/Agents/Agent.cs b/Assets/Scripts/ServerShared/Agents/Agent.cs index 586438c97..03a76436a 100644 --- a/Assets/Scripts/ServerShared/Agents/Agent.cs +++ b/Assets/Scripts/ServerShared/Agents/Agent.cs @@ -25,7 +25,7 @@ public class Agent public AgentTask Task { get; set; } // What this pilot's side knows of other ships. A ship outside a flight is a side of one: a view over itself, until // Flight.Join sets the flight's. - public PilotView View { get; set; } + public PilotView View { get; internal set; } // Zone-scoped, from 1: the ordinal of this pilot's own dice, set by Zone.CreateAgent as it admits the pilot (0 for // an agent built outside a zone's admission). public uint Id { get; set; } diff --git a/Assets/Scripts/ServerShared/Agents/PilotView.cs b/Assets/Scripts/ServerShared/Agents/PilotView.cs index 8c9a71e65..51cacf32f 100644 --- a/Assets/Scripts/ServerShared/Agents/PilotView.cs +++ b/Assets/Scripts/ServerShared/Agents/PilotView.cs @@ -14,13 +14,22 @@ using CultMath; // within a zone step nothing moves during the agent phase, so the track equals what the Entity would answer. The // fire-control readouts call FireControl's own functions with the resolved subject, so the HUD, the player and the // brains price one model (A-one-sim). Own-ship reads stay the brain's own. +// +// The view enforces its own rules; it does not trust its callers. Every per-member read answers for a member the view +// holds and nothing else, and every per-contact fire-control readout (Intercept, WeaponReveals, Designated, Refuses, +// Fires) answers only while that member itself tracks the contact: a sidemate's track gives a member no aim, no reveals +// and no shot (per-shooter-fire-control). Only Flight builds a view and sets it on its pilots, and the side-of-one view an +// Agent starts with is its own; the constructor and the setter are internal. public sealed class PilotView { private readonly List _members = new List(); private static Func _openContact; private static Func _openReveal; - public PilotView(params Ship[] members) => _members.AddRange(members); + internal PilotView(params Ship[] members) => _members.AddRange(members); + + private bool Holds(Ship member) => _members.Contains(member); + private bool Tracks(Ship member, Entity subject) => Holds(member) && subject != null && member.VisibleEntities.Contains(subject); internal void Add(Ship member) => _members.Add(member); internal void Remove(Ship member) => _members.Remove(member); @@ -69,7 +78,9 @@ public sealed class PilotView } } - // For fixtures: the handle of a subject, as the view would issue it. + // For fixtures, and for the one place the stage states what a pilot already knows (ScenarioStage.Approach seeds an + // anchor handle for a FollowTask before the zone exists: staged knowledge, ruling stage-states-pilot-knowledge). A + // brain never calls this; the sensors, not the handle, decide whether the view answers about the contact. internal static ContactId Handle(Entity subject) => new ContactId(subject); private static Entity Subject(ContactId id) @@ -95,13 +106,13 @@ public sealed class PilotView return picture; } - public bool Sees(Ship member, ContactId contact) => member.VisibleEnemies.Contains(Subject(contact)); + public bool Sees(Ship member, ContactId contact) => Holds(member) && member.VisibleEnemies.Contains(Subject(contact)); // The member's own track of the contact, or null while it does not track it. public Track? TrackOf(Ship member, ContactId contact) { var subject = Subject(contact); - if (subject == null || !member.VisibleEntities.Contains(subject)) return null; + if (!Tracks(member, subject)) return null; return new Track(subject.Position, subject.Velocity, subject.Direction); } @@ -117,45 +128,64 @@ public sealed class PilotView return null; } - public bool? IffOverride(Ship member, ContactId contact) => member.IffOverride(Subject(contact)); + public bool? IffOverride(Ship member, ContactId contact) => Holds(member) ? member.IffOverride(Subject(contact)) : null; public float? Info(Ship member, ContactId contact) => - member.EntityInfoGathered.TryGetValue(Subject(contact), out var info) ? info : (float?) null; + Holds(member) && member.EntityInfoGathered.TryGetValue(Subject(contact), out var info) ? info : (float?) null; - public bool BelievesTrespassing(Ship member, ContactId contact) => member.BelievesTrespassing(Subject(contact)); + public bool BelievesTrespassing(Ship member, ContactId contact) => Holds(member) && member.BelievesTrespassing(Subject(contact)); // The member's pending designation when set, else last step's applied target. public ContactId? TargetOf(Ship member) { + if (!Holds(member)) return null; var subject = (member.Command.Designated ?? member.Target.Value).Entity; return subject == null ? (ContactId?) null : new ContactId(subject); } public ContactId? AppliedTargetOf(Ship member) { + if (!Holds(member)) return null; var subject = member.Target.Value.Entity; return subject == null ? (ContactId?) null : new ContactId(subject); } // The weapons the member believes the contact has, in the contact's equipment order; Aimable is FireControl.IsRevealed. - public List<(RevealId Id, EquippableItemData Claimed, bool Aimable)> WeaponReveals(Ship member, ContactId contact) => - member.RevealedWeapons(Subject(contact)) - .Select(r => (new RevealId(r.Item), r.Claimed, FireControl.IsRevealed(member, r.Item))).ToList(); - - public bool Designated(Ship member, Weapon weapon, ContactId contact) => - FireControl.Designated(weapon, member, Subject(contact), out _); - - public bool Refuses(Ship member, Weapon weapon, ContactId contact) => - FireControl.Refuses(weapon, member, Subject(contact)); - - public bool Fires(Ship member, Weapon weapon, ContactId contact, float minHitProbability, RevealId? aim) => - FireControl.AgentFires(weapon, member, Subject(contact), minHitProbability, - aim == null ? AimTarget.None : AimTarget.OfItem(Item(aim.Value))); - - // Where to point to hit the contact with the weapon: the shared prediction, at the height the contact rides. - public float3 Intercept(Ship member, Weapon weapon, ContactId contact) + // Empty unless the member tracks the contact. + public List<(RevealId Id, EquippableItemData Claimed, bool Aimable)> WeaponReveals(Ship member, ContactId contact) { var subject = Subject(contact); + if (!Tracks(member, subject)) return new List<(RevealId, EquippableItemData, bool)>(); + return member.RevealedWeapons(subject) + .Select(r => (new RevealId(r.Item), r.Claimed, FireControl.IsRevealed(member, r.Item))).ToList(); + } + + // The next four answer for a member that tracks the contact: otherwise not designated, refused, no shot, no intercept. + public bool Designated(Ship member, Weapon weapon, ContactId contact) + { + var subject = Subject(contact); + return Tracks(member, subject) && FireControl.Designated(weapon, member, subject, out _); + } + + public bool Refuses(Ship member, Weapon weapon, ContactId contact) + { + var subject = Subject(contact); + return !Tracks(member, subject) || FireControl.Refuses(weapon, member, subject); + } + + public bool Fires(Ship member, Weapon weapon, ContactId contact, float minHitProbability, RevealId? aim) + { + var subject = Subject(contact); + return Tracks(member, subject) && FireControl.AgentFires(weapon, member, subject, minHitProbability, + aim == null ? AimTarget.None : AimTarget.OfItem(Item(aim.Value))); + } + + // Where to point to hit the contact with the weapon: the shared prediction, at the height the contact rides; null + // unless the member tracks the contact. + public float3? Intercept(Ship member, Weapon weapon, ContactId contact) + { + var subject = Subject(contact); + if (!Tracks(member, subject)) return null; var predicted = FireControl.PredictedIntercept(weapon, member, subject); predicted.y = subject.RideHeight(predicted.xz); return predicted; @@ -164,10 +194,13 @@ public sealed class PilotView // Designate the contact, or nothing for null, when that is not already the member's applied target. public void Designate(Ship member, ContactId? contact) { + if (!Holds(member)) return; var wanted = contact == null ? TargetRef.None : new TargetRef(Subject(contact.Value)); if (!member.Target.Value.Equals(wanted)) member.Command.Designate(wanted); } - public void SelectItem(Ship member, RevealId? reveal) => - member.Command.SelectItem(reveal == null ? null : Item(reveal.Value)); + public void SelectItem(Ship member, RevealId? reveal) + { + if (Holds(member)) member.Command.SelectItem(reveal == null ? null : Item(reveal.Value)); + } } diff --git a/Assets/Scripts/ServerShared/Agents/States/Combat.cs b/Assets/Scripts/ServerShared/Agents/States/Combat.cs index a424437b3..f8c12baab 100644 --- a/Assets/Scripts/ServerShared/Agents/States/Combat.cs +++ b/Assets/Scripts/ServerShared/Agents/States/Combat.cs @@ -134,8 +134,9 @@ public class CombatState : BaseState // Cut 3 (docs/fire-control-cut.md): the one prediction function, shared with the shot's // own snapshot and the roll's deviation measurement -- two separate predictions here and in the // roll would let an AI aim at one point and get judged for missing a different one. + // The target is tracked here (the guard above), so the intercept is never null. var predictedPosition = view.Intercept(_agent.Ship, testWeapon, target.Value); - toTarget = normalize(predictedPosition - _agent.Ship.Position); + if (predictedPosition != null) toTarget = normalize(predictedPosition.Value - _agent.Ship.Position); } // Each weapon decides for itself (operator ruling 2026-09-30): a group shares a trigger, not a diff --git a/Assets/Scripts/ServerShared/ScenarioStage.cs b/Assets/Scripts/ServerShared/ScenarioStage.cs index a9e2db5d0..453ede8ec 100644 --- a/Assets/Scripts/ServerShared/ScenarioStage.cs +++ b/Assets/Scripts/ServerShared/ScenarioStage.cs @@ -148,6 +148,9 @@ public sealed class ScenarioStage var placement = _placed.FirstOrDefault(p => ReferenceEquals(p.Entity, entity)); if (placement == null || !placement.Piloted) { _failures.Add($"approach: {entity?.Name ?? "no entity"} is not a placed, piloted ship"); return; } if (target == null) { _failures.Add("approach: no target to close on"); return; } + // The one production handle minted from truth: the stage states the pilot's knowledge of its anchor (ruling + // stage-states-pilot-knowledge), and RunStart seeds the matching sensor track. The view still answers about the + // anchor only while the pilot's sensors hold it. placement.Task = new FollowTask { Anchor = PilotView.Handle(target), Standoff = 0f }; placement.Knows = target; } diff --git a/tests/Aetheria.Shared.Tests/FireControlPerWeaponTests.cs b/tests/Aetheria.Shared.Tests/FireControlPerWeaponTests.cs index 5c0aa19a5..3a06a63f7 100644 --- a/tests/Aetheria.Shared.Tests/FireControlPerWeaponTests.cs +++ b/tests/Aetheria.Shared.Tests/FireControlPerWeaponTests.cs @@ -441,7 +441,7 @@ public sealed class FireControlPerWeaponTests : IDisposable var planarY = expected.y; expected.y = r.Target.RideHeight(expected.xz); Assert.NotEqual(planarY, expected.y); - Assert.Equal(expected, view.Intercept(r.Shooter, weapon, contact)); + Assert.Equal(expected, view.Intercept(r.Shooter, weapon, contact).Value); } // A view answers fire control only for a member that tracks the contact: the shooter outside the view, or having lost From dadb6f327d24012c9c17e3efef93cfb4b3a9f3e7 Mon Sep 17 00:00:00 2001 From: soul Date: Thu, 22 Oct 2026 19:56:54 +0200 Subject: [PATCH 117/307] Cargo transfer tests: long steps after the receiver leaves or strays move nothing; a full first bay hands on to the next Co-Authored-By: Claude Sonnet 5.5 --- .../CargoTransferTests.cs | 48 +++++++++++++++++++ 1 file changed, 48 insertions(+) diff --git a/tests/Aetheria.Shared.Tests/CargoTransferTests.cs b/tests/Aetheria.Shared.Tests/CargoTransferTests.cs index 28eae6cf9..0926494bf 100644 --- a/tests/Aetheria.Shared.Tests/CargoTransferTests.cs +++ b/tests/Aetheria.Shared.Tests/CargoTransferTests.cs @@ -31,6 +31,9 @@ public sealed class CargoTransferTests : IDisposable foreach (var cell in pocket.AllCoordinates) pocket[cell] = true; _cache.Upsert(new CargoBayData { Name = "Pocket", Shape = new Shape(), InteriorShape = pocket, Price = 5, Durability = 4 }); _cache.Upsert(new SimpleCommodityData { Name = "Ore", Shape = new Shape(), Price = 7, MaxStack = 100 }); + var bin = new Shape(1, 1); + bin[0, 0] = true; + _cache.Upsert(new CargoBayData { Name = "Bin", Shape = bin, InteriorShape = bin, Price = 5, Durability = 4 }); _cache.Upsert(new SimpleCommodityData { Name = "Grit", Shape = new Shape(), Price = 1, MaxStack = 2 }); _cache.Upsert(new SimpleCommodityData { Name = "Slag", Shape = new Shape(), Price = 1, MaxStack = 100 }); _cache.Upsert(new DockingBayData { Name = "Berth", Shape = new Shape(), InteriorShape = bay, MaxSize = new int2(2, 2), Price = 60, Durability = 6 }); @@ -284,6 +287,51 @@ public sealed class CargoTransferTests : IDisposable Assert.True(receiver.CargoBays[0].Cargo.ContainsKey(gun)); } + [Fact] + public void ALongStepAfterTheReceiverLeavesMovesNothing() + { + var (giver, receiver, ore) = Alongside(10); + giver.Command.Give(receiver, ore, 10); + Steps(1); + _zone.Remove(receiver); + + _zone.Update(5f); + + Assert.Equal(0, Held(receiver, ore)); + Assert.Equal(10, Held(giver, ore)); + Assert.Equal(GiveEnd.ReceiverGone, giver.LastGiveEnd); + } + + [Fact] + public void ALongStepOutOfRangeMovesNothing() + { + var (giver, receiver, ore) = Alongside(10); + giver.Command.Give(receiver, ore, 10); + Steps(1); + receiver.Position = giver.Position + new float3(0, 0, _items.GameplaySettings.DockingDistance + 1); + + _zone.Update(5f); + + Assert.Equal(0, Held(receiver, ore)); + Assert.Equal(GiveEnd.OutOfRange, giver.LastGiveEnd); + } + + [Fact] + public void AGiveGoesToTheNextReceiverBayWhenTheFirstIsFull() + { + var (giver, receiver, ore) = Alongside(10, "Bin"); + Assert.True(receiver.CargoBays[0].TryStore(Commodity("Slag", 5))); + Assert.True(receiver.TryEquip(Mint("Crate"))); + Assert.Equal(2, receiver.CargoBays.Count); + giver.Command.Give(receiver, ore, 3); + + Steps(40); + + Assert.Equal(0, Units(receiver.CargoBays[0], ore)); + Assert.Equal(3, Units(receiver.CargoBays[1], ore)); + Assert.Equal(GiveEnd.Done, giver.LastGiveEnd); + } + [Fact] public void GiveRangeIsMeasuredOnTheFlatPlane() { From 3f265b6c731a85df25af79068dceed18c4eb4005 Mon Sep 17 00:00:00 2001 From: soul Date: Thu, 22 Oct 2026 19:59:19 +0200 Subject: [PATCH 118/307] Cargo transfer tests: fill the one-cell bay the way the other fixtures fill a shape Co-Authored-By: Claude Sonnet 5.5 --- tests/Aetheria.Shared.Tests/CargoTransferTests.cs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/tests/Aetheria.Shared.Tests/CargoTransferTests.cs b/tests/Aetheria.Shared.Tests/CargoTransferTests.cs index 0926494bf..9153e015a 100644 --- a/tests/Aetheria.Shared.Tests/CargoTransferTests.cs +++ b/tests/Aetheria.Shared.Tests/CargoTransferTests.cs @@ -32,7 +32,7 @@ public sealed class CargoTransferTests : IDisposable _cache.Upsert(new CargoBayData { Name = "Pocket", Shape = new Shape(), InteriorShape = pocket, Price = 5, Durability = 4 }); _cache.Upsert(new SimpleCommodityData { Name = "Ore", Shape = new Shape(), Price = 7, MaxStack = 100 }); var bin = new Shape(1, 1); - bin[0, 0] = true; + foreach (var cell in bin.AllCoordinates) bin[cell] = true; _cache.Upsert(new CargoBayData { Name = "Bin", Shape = bin, InteriorShape = bin, Price = 5, Durability = 4 }); _cache.Upsert(new SimpleCommodityData { Name = "Grit", Shape = new Shape(), Price = 1, MaxStack = 2 }); _cache.Upsert(new SimpleCommodityData { Name = "Slag", Shape = new Shape(), Price = 1, MaxStack = 100 }); From 00d34e5ab5303bb40f793789fe2866a5751db39d Mon Sep 17 00:00:00 2001 From: soul Date: Thu, 22 Oct 2026 20:05:33 +0200 Subject: [PATCH 119/307] Cargo transfer tests: the second receiver bay is a one-cell bin Co-Authored-By: Claude Sonnet 5.5 --- tests/Aetheria.Shared.Tests/CargoTransferTests.cs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/tests/Aetheria.Shared.Tests/CargoTransferTests.cs b/tests/Aetheria.Shared.Tests/CargoTransferTests.cs index 9153e015a..cdc356bf2 100644 --- a/tests/Aetheria.Shared.Tests/CargoTransferTests.cs +++ b/tests/Aetheria.Shared.Tests/CargoTransferTests.cs @@ -321,7 +321,7 @@ public sealed class CargoTransferTests : IDisposable { var (giver, receiver, ore) = Alongside(10, "Bin"); Assert.True(receiver.CargoBays[0].TryStore(Commodity("Slag", 5))); - Assert.True(receiver.TryEquip(Mint("Crate"))); + Assert.True(receiver.TryEquip(Mint("Bin"))); Assert.Equal(2, receiver.CargoBays.Count); giver.Command.Give(receiver, ore, 3); From 1691f47cc685623e32f26d282b5ebc7f34bd8920 Mon Sep 17 00:00:00 2001 From: soul Date: Thu, 22 Oct 2026 20:09:53 +0200 Subject: [PATCH 120/307] README: front door with simulation entry points, demo in progress, directions and package hulls Co-Authored-By: Claude Opus 5.5 --- README.md | 16 ++++++++++++++-- 1 file changed, 14 insertions(+), 2 deletions(-) diff --git a/README.md b/README.md index 79f0033f2..8518aba1f 100644 --- a/README.md +++ b/README.md @@ -5,6 +5,18 @@ All mechanics are developed to be engine-agnostic, while Unity was chosen as a r It is our ambition to forge a new paradigm of open source game development, as to our knowledge there are no other open source projects of this scope building up a foundation from scratch. Game development is notoriously averse to this model and nobody wants to share their shiny toys for fear of losing their competitive advantage. By building and sharing immersive and compelling experiences like this, we hope to change that. +## Front Door + +- **Value:** a simulation-first tactical ARPG. The game's rules live in a headless .NET build (`Aetheria.Shared`, compiled from `Assets/Scripts/ServerShared`) that needs no Unity; Unity is the shell for graphics and input. Content (hulls, gear, factions, blueprints) is typed CultCache data in `GameData/Aetheria.cc`. +- **Problem:** an AA-scale open source game built from scratch, with simulation depth (fire control, stats and power, heat, item provenance, locomotion) that agents and contributors can inspect, test and extend without opening the editor. +- **Entry points:** + - `dotnet test tests/Aetheria.Shared.Tests` runs the shared simulation tests; `dotnet run --project tools/AetherDb -- census` reads the catalog (commands under Command Line). `CLAUDE.md` explains AetherDb and the pinned CultLib build rule. + - `docs/aetheria-release-target.md`: the demo. `docs/aetheria-arena-target.md`: deterministic headless matches between swappable brains. `docs/moddable-ship-authoring.md`: ship packages. + - Unity client: open the repository root in Unity 6000.3.24f1 and start from the "Main Menu" scene (Testing Locally). +- **In progress:** the demo, one gate, one region and one boss with a fixed faction presence (allied Pirates, antagonist Zhestokost). It is called a demo because full Terminus needs enough faction variety for three procedurally generated regions. +- **Directions, not built:** the strategic arena (`docs/aetheria-arena-target.md` is a target, not a shipped system) and the living-world economy. Neither has a spec in `docs/` yet beyond the arena target. +- **Packages ship hulls:** hulls are delivered as mod-owned `.cc` ship packages authored in Blender and the CultLib tools, with no Unity install. That is a proof lane today; catalog `HullData` records remain live until a complete package ship passes authoring, validation, loading and play. + ## Contact Us If you want to chat, please join [our Discord server](https://discord.gg/trbteNj) for detailed development updates and discussions about the game design, narrative, art direction, and general topics. Shitposting welcome! @@ -57,13 +69,13 @@ As a result of lessons learned, we then focused on the economy system, and built ## Current Work -At the moment we are focused on implementing and polishing the ARPG gameplay as a standalone title, first as a rogue-lite combat demo and then adding a story-driven campaign mode. +At the moment we are focused on implementing and polishing the ARPG gameplay as a standalone title, first as a rogue-lite combat demo (see [the demo target](docs/aetheria-release-target.md)) and then adding a story-driven campaign mode. ## Architecture ### Project Structure -There are two solutions in this repository. One is a Unity project containing the desktop client for Aetheria, and the other is a .NET Core application intended to run on Linux cloud servers. The bulk of the game's code is shared between them, located at [Assets/Scripts/ServerShared](Assets/Scripts/ServerShared), defining a common protocol, world simulation code and data serialization format. +This repository holds a Unity project containing the desktop client for Aetheria and a headless .NET build of the shared simulation (`Aetheria.Shared`, with `tests/Aetheria.Shared.Tests` and `tools/AetherDb`). The bulk of the game's code is shared between them, located at [Assets/Scripts/ServerShared](Assets/Scripts/ServerShared), defining a common protocol, world simulation code and data serialization format. ### Third Party Libraries From c37fbdb3b95ddca0b05570f228a4ffc7e6b192a4 Mon Sep 17 00:00:00 2001 From: soul Date: Thu, 22 Oct 2026 20:11:38 +0200 Subject: [PATCH 121/307] Cargo transfer tests: drop the two-bay receiver test; the fixture hulls take one bay Co-Authored-By: Claude Sonnet 5.5 --- .../CargoTransferTests.cs | 19 ------------------- 1 file changed, 19 deletions(-) diff --git a/tests/Aetheria.Shared.Tests/CargoTransferTests.cs b/tests/Aetheria.Shared.Tests/CargoTransferTests.cs index cdc356bf2..ea92abd40 100644 --- a/tests/Aetheria.Shared.Tests/CargoTransferTests.cs +++ b/tests/Aetheria.Shared.Tests/CargoTransferTests.cs @@ -31,9 +31,6 @@ public sealed class CargoTransferTests : IDisposable foreach (var cell in pocket.AllCoordinates) pocket[cell] = true; _cache.Upsert(new CargoBayData { Name = "Pocket", Shape = new Shape(), InteriorShape = pocket, Price = 5, Durability = 4 }); _cache.Upsert(new SimpleCommodityData { Name = "Ore", Shape = new Shape(), Price = 7, MaxStack = 100 }); - var bin = new Shape(1, 1); - foreach (var cell in bin.AllCoordinates) bin[cell] = true; - _cache.Upsert(new CargoBayData { Name = "Bin", Shape = bin, InteriorShape = bin, Price = 5, Durability = 4 }); _cache.Upsert(new SimpleCommodityData { Name = "Grit", Shape = new Shape(), Price = 1, MaxStack = 2 }); _cache.Upsert(new SimpleCommodityData { Name = "Slag", Shape = new Shape(), Price = 1, MaxStack = 100 }); _cache.Upsert(new DockingBayData { Name = "Berth", Shape = new Shape(), InteriorShape = bay, MaxSize = new int2(2, 2), Price = 60, Durability = 6 }); @@ -316,22 +313,6 @@ public sealed class CargoTransferTests : IDisposable Assert.Equal(GiveEnd.OutOfRange, giver.LastGiveEnd); } - [Fact] - public void AGiveGoesToTheNextReceiverBayWhenTheFirstIsFull() - { - var (giver, receiver, ore) = Alongside(10, "Bin"); - Assert.True(receiver.CargoBays[0].TryStore(Commodity("Slag", 5))); - Assert.True(receiver.TryEquip(Mint("Bin"))); - Assert.Equal(2, receiver.CargoBays.Count); - giver.Command.Give(receiver, ore, 3); - - Steps(40); - - Assert.Equal(0, Units(receiver.CargoBays[0], ore)); - Assert.Equal(3, Units(receiver.CargoBays[1], ore)); - Assert.Equal(GiveEnd.Done, giver.LastGiveEnd); - } - [Fact] public void GiveRangeIsMeasuredOnTheFlatPlane() { From 66671cf036f711c8c41fc0ce311ebc715bcd8a7d Mon Sep 17 00:00:00 2001 From: soul Date: Thu, 22 Oct 2026 21:09:30 +0200 Subject: [PATCH 122/307] Cargo tests: quantities below one, give routing and ends, purchase lots (red); soul-tv probes P4-P6, P9 adopted Co-Authored-By: Claude Sonnet 5.5 --- .../CargoTransferTests.cs | 261 +++++++++++++++++- 1 file changed, 259 insertions(+), 2 deletions(-) diff --git a/tests/Aetheria.Shared.Tests/CargoTransferTests.cs b/tests/Aetheria.Shared.Tests/CargoTransferTests.cs index ea92abd40..26f2332f0 100644 --- a/tests/Aetheria.Shared.Tests/CargoTransferTests.cs +++ b/tests/Aetheria.Shared.Tests/CargoTransferTests.cs @@ -33,6 +33,12 @@ public sealed class CargoTransferTests : IDisposable _cache.Upsert(new SimpleCommodityData { Name = "Ore", Shape = new Shape(), Price = 7, MaxStack = 100 }); _cache.Upsert(new SimpleCommodityData { Name = "Grit", Shape = new Shape(), Price = 1, MaxStack = 2 }); _cache.Upsert(new SimpleCommodityData { Name = "Slag", Shape = new Shape(), Price = 1, MaxStack = 100 }); + var slot = new Shape(1, 2); + foreach (var cell in slot.AllCoordinates) slot[cell] = true; + _cache.Upsert(new CargoBayData { Name = "Slot", Shape = new Shape(), InteriorShape = slot, Price = 5, Durability = 4 }); + var rod = new Shape(2, 1); + foreach (var cell in rod.AllCoordinates) rod[cell] = true; + _cache.Upsert(new SimpleCommodityData { Name = "Rod", Shape = rod, Price = 1, MaxStack = 1 }); _cache.Upsert(new DockingBayData { Name = "Berth", Shape = new Shape(), InteriorShape = bay, MaxSize = new int2(2, 2), Price = 60, Durability = 6 }); _cache.Upsert(new GearData { Name = "Gun", Hardpoint = HardpointType.Sensors, Shape = new Shape(), Price = 400, Durability = 8 }); _cache.FlushAsync().Wait(); @@ -59,8 +65,28 @@ public sealed class CargoTransferTests : IDisposable }; } - private SimpleCommodity Commodity(string design, int quantity) => - new SimpleCommodity { Data = _cache.RefOf(_cache.GetByName(design)), Quantity = quantity }; + private SimpleCommodity Commodity(string design, int quantity, ItemRotation rotation = ItemRotation.None) => + new SimpleCommodity { Data = _cache.RefOf(_cache.GetByName(design)), Quantity = quantity, Rotation = rotation }; + + // Bays built directly, so a ship can hold several (TryEquip is not under test). + private Ship ShipWithBays(params string[] bays) + { + var ship = new Ship(_items, _zone, Mint("Skiff"), new EntitySettings()); + for (var i = 0; i < bays.Length; i++) + ship.CargoBays.Add(new EquippedCargoBay(_items, Mint(bays[i]), new int2(1 + i, 1), ship, $"bay{i}")); + return ship; + } + + // Off the origin, so a distance taken from the wrong sum or difference of positions shows. + private void Place(Ship giver, Ship receiver) + { + giver.Position = new float3(-50, 0, 10); + receiver.Position = giver.Position + new float3(5, 0, 0); + _zone.Admit(giver, piloted: false); + _zone.Admit(receiver, piloted: false); + } + + private static int Stacks(EquippedCargoBay bay) => bay.Cargo.Keys.Count; private Ship ShipWith(string bay) { @@ -518,4 +544,235 @@ public sealed class CargoTransferTests : IDisposable Assert.Equal(0, Held(receiver, ore)); Assert.Null(giver.LastGiveEnd); } + + // ---- Quantities below one -------------------------------------------------------------------------------- + + [Theory] + [InlineData(0)] + [InlineData(-5)] + public void ATransferBelowOneUnitChangesNothing(int quantity) + { + var from = ShipWith("Crate").CargoBays[0]; + var to = ShipWith("Crate").CargoBays[0]; + var ore = Commodity("Ore", 10); + Assert.True(from.TryStore(ore)); + Assert.True(to.TryStore(Commodity("Ore", 3))); + var mass = (from.Mass, to.Mass); + var stacks = (Stacks(from), Stacks(to)); + + Assert.False(from.TryTransferItem(to, ore, quantity)); + + Assert.Equal(10, Units(from, ore)); + Assert.Equal(3, Units(to, ore)); + Assert.Equal(mass, (from.Mass, to.Mass)); + Assert.Equal(stacks, (Stacks(from), Stacks(to))); + } + + [Theory] + [InlineData(0)] + [InlineData(-5)] + public void ARemovalBelowOneUnitTakesNothing(int quantity) + { + var bay = ShipWith("Crate").CargoBays[0]; + var ore = Commodity("Ore", 10); + Assert.True(bay.TryStore(ore)); + var mass = bay.Mass; + + Assert.Null(bay.Remove(ore, quantity)); + + Assert.Equal(10, Units(bay, ore)); + Assert.Equal(mass, bay.Mass); + Assert.Equal(1, Stacks(bay)); + } + + // ---- The give: where units go, and when it ends (the soul-tv probes, adopted) ---------------------------- + + // First receiver bay full, second has room: the unit goes to the second. + [Fact] + public void AGivenCommodityGoesToTheSecondBayWhenTheFirstIsFull() + { + var giver = ShipWithBays("Crate"); + var receiver = ShipWithBays("Pocket", "Crate"); + var ore = Commodity("Ore", 10); + Assert.True(giver.CargoBays[0].TryStore(ore)); + Assert.True(receiver.CargoBays[0].TryStore(Commodity("Slag", 5))); + Place(giver, receiver); + giver.Command.Give(receiver, ore, 3); + + Steps(40); + + Assert.Equal(3, Units(receiver.CargoBays[1], ore)); + Assert.Equal(GiveEnd.Done, giver.LastGiveEnd); + } + + // Two open receiver bays: one unit per move, not one per bay. + [Fact] + public void AGiveMovesOneUnitPerMoveWithTwoOpenBays() + { + var giver = ShipWithBays("Crate"); + var receiver = ShipWithBays("Crate", "Crate"); + var ore = Commodity("Ore", 10); + Assert.True(giver.CargoBays[0].TryStore(ore)); + Place(giver, receiver); + giver.Command.Give(receiver, ore, 10); + + _zone.Update(1f); + + Assert.Equal(2, Held(receiver, ore)); + Assert.Equal(8, Held(giver, ore)); + } + + [Fact] + public void AGivenCraftedItemGoesToTheSecondBayWhenTheFirstIsFull() + { + var giver = ShipWithBays("Crate"); + var receiver = ShipWithBays("Pocket", "Crate"); + var gun = Mint("Gun"); + Assert.True(giver.CargoBays[0].TryStore(gun)); + Assert.True(receiver.CargoBays[0].TryStore(Commodity("Slag", 5))); + Place(giver, receiver); + giver.Command.Give(receiver, gun, 1); + + Steps(10); + + Assert.True(receiver.CargoBays[1].Cargo.ContainsKey(gun)); + Assert.Equal(GiveEnd.Done, giver.LastGiveEnd); + } + + [Fact] + public void AGivenCraftedItemMovesOnceIntoTwoOpenBays() + { + var giver = ShipWithBays("Crate"); + var receiver = ShipWithBays("Crate", "Crate"); + var gun = Mint("Gun"); + Assert.True(giver.CargoBays[0].TryStore(gun)); + Place(giver, receiver); + giver.Command.Give(receiver, gun, 1); + + Steps(10); + + Assert.True(receiver.CargoBays[0].Cargo.ContainsKey(gun)); + Assert.Equal(GiveEnd.Done, giver.LastGiveEnd); + } + + // A refused one-unit move of a whole one-unit stack re-files it at the end of ItemsOfType, so the next Source would be + // a different stack. Rods are 2x1; the receiver's Slot (1x2) takes only the turned one. After the give has ended + // ReceiverFull nothing may move. + [Fact] + public void NothingMovesAfterAGiveEndsReceiverFull() + { + var giver = ShipWithBays("Crate"); + var receiver = ShipWithBays("Slot"); + var flat = Commodity("Rod", 1); + Assert.True(giver.CargoBays[0].TryStore(flat)); + Assert.True(giver.CargoBays[0].TryStore(Commodity("Rod", 1, ItemRotation.Clockwise))); + Place(giver, receiver); + giver.Command.Give(receiver, flat, 2); + + _zone.Update(5f); + + Assert.Equal(GiveEnd.ReceiverFull, giver.LastGiveEnd); + Assert.Equal(0, Held(receiver, flat)); + Assert.Equal(2, Held(giver, flat)); + } + + // Refused moves do not spend the order's units. + [Fact] + public void AnEndedOrderKeepsItsRemainingUnits() + { + var giver = ShipWithBays("Crate"); + var receiver = ShipWithBays("Pocket"); + var ore = Commodity("Ore", 10); + Assert.True(giver.CargoBays[0].TryStore(ore)); + Assert.True(receiver.CargoBays[0].TryStore(Commodity("Slag", 5))); + Place(giver, receiver); + giver.Command.Give(receiver, ore, 5); + _zone.Update(.01f); // applies the command, earns no unit + var order = giver.Giving; + Assert.NotNull(order); + + _zone.Update(5f); + + Assert.Equal(GiveEnd.ReceiverFull, giver.LastGiveEnd); + Assert.Equal(5, order.Remaining); + } + + // An empty stack in a hold is no unit to give: the give moves the real units and then has nothing left. + [Fact] + public void AnEmptyStackIsNotAUnitToGive() + { + var giver = ShipWithBays("Crate"); + var receiver = ShipWithBays("Crate"); + var grit = Commodity("Grit", 2); + Assert.True(giver.CargoBays[0].TryStore(grit)); + Assert.True(giver.CargoBays[0].TryStore(Commodity("Grit", 0))); + Place(giver, receiver); + giver.Command.Give(receiver, grit, 3); + + _zone.Update(5f); + + Assert.Equal(2, Held(receiver, grit)); + Assert.Equal(GiveEnd.NothingLeft, giver.LastGiveEnd); + } + + // Warping and dying both leave through Zone.Remove; docking has already ended the give GiverDocked. + [Fact] + public void AGiveEndsWhenItsGiverLeavesTheZone() + { + var giver = ShipWithBays("Crate"); + var receiver = ShipWithBays("Crate"); + var ore = Commodity("Ore", 10); + Assert.True(giver.CargoBays[0].TryStore(ore)); + Place(giver, receiver); + giver.Command.Give(receiver, ore, 10); + _zone.Update(.5f); + + Assert.True(_zone.Remove(giver)); + + Assert.Null(giver.Giving); + Assert.Equal(GiveEnd.GiverGone, giver.LastGiveEnd); + } + + // ---- The lot a purchase delivers --------------------------------------------------------------------------- + + [Fact] + public void APurchaseChargesForEachLotItDelivers() + { + // Grit stacks hold two. Five units are three lots, and each lot is charged for what it delivers. + var stock = Commodity("Grit", 5); + var credits = 100; + var delivered = new List(); + + var result = StationServices.TryBuyLots(_items, stock, 5, ref credits, lot => { delivered.Add(lot); return true; }); + + Assert.Equal(BuyResult.Bought, result); + Assert.Equal(new[] { 2, 2, 1 }, delivered); + Assert.Equal(100 - 5, credits); + } + + [Fact] + public void ARefusedLotLeavesEarlierLotsBoughtAndChargesNothingMore() + { + var stock = Commodity("Grit", 5); + var credits = 100; + var lots = 0; + + var result = StationServices.TryBuyLots(_items, stock, 5, ref credits, lot => ++lots < 2); + + Assert.Equal(BuyResult.NoRoom, result); + Assert.Equal(100 - 2, credits); + } + + [Theory] + [InlineData(-5, 1000, 1)] + [InlineData(0, 1000, 1)] + [InlineData(4, 1000, 4)] + [InlineData(50, 1000, 10)] + [InlineData(50, 21, 3)] + [InlineData(5, 3, 1)] + public void TheBuyQuantityIsClampedToWhatIsBuyable(int typed, int credits, int expected) + { + var stock = Commodity("Ore", 10); // 7 credits each + Assert.Equal(expected, StationServices.BuyableQuantity(_items, stock, typed, credits)); + } } From f9a09e0d0a4fc1f24388c2292656c90b3fe93461 Mon Sep 17 00:00:00 2001 From: soul Date: Thu, 22 Oct 2026 21:10:04 +0200 Subject: [PATCH 123/307] Cargo: TryTransferItem and Remove refuse a quantity below one and change nothing Co-Authored-By: Claude Sonnet 5.5 --- Assets/Scripts/ServerShared/Entity.cs | 3 +++ 1 file changed, 3 insertions(+) diff --git a/Assets/Scripts/ServerShared/Entity.cs b/Assets/Scripts/ServerShared/Entity.cs index 721ac31b0..bc8087228 100644 --- a/Assets/Scripts/ServerShared/Entity.cs +++ b/Assets/Scripts/ServerShared/Entity.cs @@ -2596,8 +2596,10 @@ public class EquippedCargoBay : EquippedItem return true; } + // The part removed: null, with the bay as it was, when the item is not here or the quantity is below one unit. public SimpleCommodity Remove(SimpleCommodity item, int quantity) { + if (quantity < 1) return null; if (!Cargo.ContainsKey(item)) { ItemManager.Log("Attempted to remove item from a cargo bay that it wasn't even in! Something went wrong here!"); @@ -2658,6 +2660,7 @@ public class EquippedCargoBay : EquippedItem public bool TryTransferItem(EquippedCargoBay target, SimpleCommodity item, int quantity) { + if (quantity < 1) return false; if (!Cargo.ContainsKey(item)) { ItemManager.Log("Attempted to remove item from a cargo bay that it wasn't even in! Something went wrong here!"); From f0d7c12cbdfa0bda23ad04e9f5991e68a62d16b7 Mon Sep 17 00:00:00 2001 From: soul Date: Thu, 22 Oct 2026 21:10:07 +0200 Subject: [PATCH 124/307] Trade: the purchase loop moves to StationServices.TryBuyLots, charging each lot it delivers; the Buy Quantity field clamps to at least one Co-Authored-By: Claude Sonnet 5.5 --- .../Scripts/ServerShared/StationServices.cs | 22 +++++++++++++++++++ Assets/Scripts/UI/Menu/TradeMenu.cs | 15 +++---------- 2 files changed, 25 insertions(+), 12 deletions(-) diff --git a/Assets/Scripts/ServerShared/StationServices.cs b/Assets/Scripts/ServerShared/StationServices.cs index a6cb4bfb5..433b0baf1 100644 --- a/Assets/Scripts/ServerShared/StationServices.cs +++ b/Assets/Scripts/ServerShared/StationServices.cs @@ -79,6 +79,28 @@ public static class StationServices return BuyResult.Bought; } + // The purchase of `quantity` units of a station stack, one lot of at most MaxStack at a time. `deliver` puts a lot of + // that many units where it goes, and each lot is charged for the units it delivered. The first lot the station + // cannot sell or the player cannot take ends the purchase, with the lots before it bought. + public static BuyResult TryBuyLots(ItemManager items, SimpleCommodity stock, int quantity, ref int credits, Func deliver) + { + if (quantity < 1) return BuyResult.NoRoom; + var maxStack = items.GetData(stock).MaxStack; + for (var remaining = quantity; remaining > 0;) + { + var lot = Math.Min(remaining, maxStack); + var result = TryBuy(items, stock, lot, ref credits, () => deliver(lot)); + if (result != BuyResult.Bought) return result; + remaining -= lot; + } + return BuyResult.Bought; + } + + // The quantity a Buy Quantity field may hold: what was typed, at most what the credits cover and what the station + // has, and never below one unit (a price the credits do not cover is then refused as short of credits). + public static int BuyableQuantity(ItemManager items, SimpleCommodity stock, int typed, int credits) => + Math.Max(1, Math.Min(Math.Min(typed, credits / BuyPrice(items, stock)), stock.Quantity)); + // The sale: the item leaves the bay for one of the station's cargo bays and the station pays SellPrice. False, // with the item in its bay and the credits unchanged, when no station bay can take it. public static bool TrySell(EquippedCargoBay from, Entity station, ItemInstance item, ref int credits) diff --git a/Assets/Scripts/UI/Menu/TradeMenu.cs b/Assets/Scripts/UI/Menu/TradeMenu.cs index 3d448f64d..13345f88a 100644 --- a/Assets/Scripts/UI/Menu/TradeMenu.cs +++ b/Assets/Scripts/UI/Menu/TradeMenu.cs @@ -270,7 +270,7 @@ public class TradeMenu : MonoBehaviour Dialog.Title.text = $"Buying {GameManager.ItemManager.Title(i.item)}"; Dialog.AddField("Quantity", () => quantity, - q => quantity = min(min(q, GameManager.Credits / StationServices.BuyPrice(GameManager.ItemManager, s)), s.Quantity)); + q => quantity = StationServices.BuyableQuantity(GameManager.ItemManager, s, q, GameManager.Credits)); Dialog.Show(() => { Buy(s,quantity); @@ -343,17 +343,8 @@ public class TradeMenu : MonoBehaviour private void Buy(SimpleCommodity simpleCommodity, int quantity) { - var data = GameManager.ItemManager.GetData(simpleCommodity); - // Up-rounded integer division from https://stackoverflow.com/a/503201 - int lots = (quantity - 1) / data.MaxStack + 1; - int remaining = quantity; - for (int i = 0; i < lots; i++) - { - int q = min(remaining, data.MaxStack); - if (!Bought(StationServices.TryBuy(GameManager.ItemManager, simpleCommodity, q, ref GameManager.Credits, - () => Inventory.TryTransferItem(_targetCargo, simpleCommodity, quantity)))) return; - remaining -= q; - } + Bought(StationServices.TryBuyLots(GameManager.ItemManager, simpleCommodity, quantity, ref GameManager.Credits, + lot => Inventory.TryTransferItem(_targetCargo, simpleCommodity, lot))); } // Refreshes the credits after a purchase, or tells the player why the station refused it. From 4d462c771c8f7bb82bb47b1731de3e82738158cc Mon Sep 17 00:00:00 2001 From: soul Date: Thu, 22 Oct 2026 21:10:09 +0200 Subject: [PATCH 125/307] Give: Zone.Remove ends the leaving giver's give GiverGone Co-Authored-By: Claude Sonnet 5.5 --- Assets/Scripts/ServerShared/CargoTransfer.cs | 3 ++- Assets/Scripts/ServerShared/Zone.cs | 6 ++++-- 2 files changed, 6 insertions(+), 3 deletions(-) diff --git a/Assets/Scripts/ServerShared/CargoTransfer.cs b/Assets/Scripts/ServerShared/CargoTransfer.cs index 6484bace8..45208d948 100644 --- a/Assets/Scripts/ServerShared/CargoTransfer.cs +++ b/Assets/Scripts/ServerShared/CargoTransfer.cs @@ -15,7 +15,8 @@ public enum GiveEnd ReceiverGone, ReceiverFull, NothingLeft, - GiverDocked + GiverDocked, + GiverGone } // A pilot's standing offer of goods to a ship alongside. Runtime only: nothing about a give is saved. diff --git a/Assets/Scripts/ServerShared/Zone.cs b/Assets/Scripts/ServerShared/Zone.cs index 8bc152eaa..d395f722a 100644 --- a/Assets/Scripts/ServerShared/Zone.cs +++ b/Assets/Scripts/ServerShared/Zone.cs @@ -299,11 +299,13 @@ public class Zone if (piloted) Agents.Add(CreateAgent((Ship) entity, task)); } - // The one removal from this zone (death, dock, jump): membership and its death watch leave together. False, and - // nothing changes, when the entity is not a member. The caller deactivates; Remove does not. + // The one removal from this zone (death, dock, jump): membership and its death watch leave together, and so does the + // entity's give, which ends GiverGone unless a dock already ended it. False, and nothing changes, when the entity is + // not a member. The caller deactivates; Remove does not. public bool Remove(Entity entity) { if (!_entities.Remove(entity)) return false; + CargoTransfer.End(entity, GiveEnd.GiverGone); _deathWatches.Remove(entity, out var watch); watch.Dispose(); foreach (var floating in FloatingItems) From 708fbeee5903f1c60188de71cd0342443d4b2c47 Mon Sep 17 00:00:00 2001 From: soul Date: Thu, 22 Oct 2026 21:59:06 +0200 Subject: [PATCH 126/307] Cargo store tests: the bay owns stack quantity, purchases charge units moved, restore drops bad stacks (red) Co-Authored-By: Claude Sonnet 5.5 --- .../CargoTransferTests.cs | 120 ++++++++++++++++++ 1 file changed, 120 insertions(+) diff --git a/tests/Aetheria.Shared.Tests/CargoTransferTests.cs b/tests/Aetheria.Shared.Tests/CargoTransferTests.cs index 26f2332f0..efcac768c 100644 --- a/tests/Aetheria.Shared.Tests/CargoTransferTests.cs +++ b/tests/Aetheria.Shared.Tests/CargoTransferTests.cs @@ -775,4 +775,124 @@ public sealed class CargoTransferTests : IDisposable var stock = Commodity("Ore", 10); // 7 credits each Assert.Equal(expected, StationServices.BuyableQuantity(_items, stock, typed, credits)); } + // ---- The bay owns the quantity that may sit in it --------------------------------------------------------- + + [Theory] + [InlineData("Ore", 0)] + [InlineData("Ore", -3)] + [InlineData("Ore", int.MinValue)] + [InlineData("Grit", 3)] // MaxStack 2 + [InlineData("Ore", 101)] + public void TheBayRefusesAStackBelowOneOrAboveMaxStack(string design, int quantity) + { + var bay = ShipWith("Crate").CargoBays[0]; + var massBefore = bay.Mass; + + Assert.False(bay.TryStore(Commodity(design, quantity))); + Assert.False(bay.TryStore(Commodity(design, quantity), new int2(0, 0))); + + Assert.Empty(bay.Cargo); + Assert.Equal(massBefore, bay.Mass); + } + + [Fact] + public void ANegativeStackNeverMergesIntoAHeldOne() + { + var bay = ShipWith("Crate").CargoBays[0]; + var held = Commodity("Ore", 5); + Assert.True(bay.TryStore(held)); + var at = bay.Cargo[held]; + + Assert.False(bay.TryStore(Commodity("Ore", -3), at)); + + Assert.Equal(5, held.Quantity); + } + + [Fact] + public void AMergeThatOverflowsFillsTheHeldStackToMaxAndLeavesTheRestWithTheCaller() + { + var bay = ShipWith("Crate").CargoBays[0]; + var held = Commodity("Grit", 1); + Assert.True(bay.TryStore(held)); + var incoming = Commodity("Grit", 2); + + Assert.False(bay.TryStore(incoming, bay.Cargo[held])); + + Assert.Equal(2, held.Quantity); + Assert.Equal(1, incoming.Quantity); + } + + [Fact] + public void APurchaseAboveTheStationStockIsRefusedWithoutCharge() + { + // Real bays, Grit stacks hold two: a stock of 3 cannot be bought as 5. Before, the second lot took the 1-unit + // remainder and was charged for 2. + var station = Station().CargoBays[0]; + var player = ShipWith("Crate").CargoBays[0]; + var stock = Commodity("Grit", 2); + Assert.True(station.TryStore(stock)); + var second = Commodity("Grit", 1); + Assert.True(station.TryStore(second, new int2(1, 0))); + var credits = 100; + + var result = StationServices.TryBuyLots(_items, stock, 5, ref credits, lot => station.TryTransferItem(player, stock, lot)); + + Assert.Equal(BuyResult.NoRoom, result); + Assert.Equal(100, credits); + Assert.Equal(0, Units(player, stock)); + } + + [Fact] + public void APurchaseOfAnEmptyStationStackChargesNothingAndDeliversNothing() + { + var station = Station().CargoBays[0]; + var player = ShipWith("Crate").CargoBays[0]; + var stock = Commodity("Ore", 0); + var credits = 100; + + var result = StationServices.TryBuyLots(_items, stock, StationServices.BuyableQuantity(_items, stock, 1, credits), ref credits, + lot => station.TryTransferItem(player, stock, lot)); + + Assert.Equal(BuyResult.NoRoom, result); + Assert.Equal(100, credits); + Assert.Empty(player.Cargo); + } + + [Fact] + public void ThePurchaseOfTheWholeStockChargesTheUnitsTheBayMoved() + { + var station = Station().CargoBays[0]; + var player = ShipWith("Crate").CargoBays[0]; + var stock = Commodity("Grit", 2); + Assert.True(station.TryStore(stock)); + var more = Commodity("Grit", 1); + Assert.True(station.TryStore(more, new int2(1, 0))); + var credits = 100; + + var result = StationServices.TryBuyLots(_items, stock, 2, ref credits, lot => station.TryTransferItem(player, stock, lot)); + + Assert.Equal(BuyResult.Bought, result); + Assert.Equal(98, credits); + Assert.Equal(2, Units(player, stock)); + } + + [Fact] + public void ARestoredBayDropsAStackBelowOneAndLogsWhy() + { + var log = new List(); + var settings = AuthoredSettings.Load(RestoredHullsTests.FindRepoRoot()).Read("GameplaySettings"); + var items = new ItemManager(1u, _cache, new ProvenanceLedger(), settings, log.Add); + var zone = new Zone(items, new PlanetSettings(), new ZonePack(), new GalaxyZone { Name = "Test Zone", Owner = null }, null); + var ship = ShipWith("Crate"); + Assert.True(ship.CargoBays[0].TryStore(Commodity("Slag", 4))); + var pack = (ShipPack) EntitySerializer.Pack(ship); + // The typed -5 purchase of old saves: a negative stack in the hold, beside a good one. + pack.CargoContents[0] = pack.CargoContents[0].Concat(new[] { (new int2(3, 3), (ItemInstance) Commodity("Ore", -5)) }).ToArray(); + + var restored = (Ship) EntitySerializer.Unpack(items, zone, pack); + + var bay = restored.CargoBays[0]; + Assert.Equal(new[] { 4 }, bay.Cargo.Keys.OfType().Select(c => c.Quantity).ToArray()); + Assert.Contains(log, l => l.Contains("-5") && l.Contains("Ore")); + } } From ffe43b41e88fe5d7100f4849498628c7132de1a6 Mon Sep 17 00:00:00 2001 From: soul Date: Thu, 22 Oct 2026 22:01:39 +0200 Subject: [PATCH 127/307] Cargo store tests: the empty-stock purchase test reaches the charge (red) Co-Authored-By: Claude Sonnet 5.5 --- tests/Aetheria.Shared.Tests/CargoTransferTests.cs | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/tests/Aetheria.Shared.Tests/CargoTransferTests.cs b/tests/Aetheria.Shared.Tests/CargoTransferTests.cs index efcac768c..2495ca2b0 100644 --- a/tests/Aetheria.Shared.Tests/CargoTransferTests.cs +++ b/tests/Aetheria.Shared.Tests/CargoTransferTests.cs @@ -845,17 +845,17 @@ public sealed class CargoTransferTests : IDisposable [Fact] public void APurchaseOfAnEmptyStationStackChargesNothingAndDeliversNothing() { - var station = Station().CargoBays[0]; - var player = ShipWith("Crate").CargoBays[0]; + // An old save's zero stack, as the station's stock. The field clamps the typed quantity up to one unit. var stock = Commodity("Ore", 0); var credits = 100; + var delivered = 0; var result = StationServices.TryBuyLots(_items, stock, StationServices.BuyableQuantity(_items, stock, 1, credits), ref credits, - lot => station.TryTransferItem(player, stock, lot)); + lot => { delivered += lot; return true; }); Assert.Equal(BuyResult.NoRoom, result); Assert.Equal(100, credits); - Assert.Empty(player.Cargo); + Assert.Equal(0, delivered); } [Fact] From ddae55759dc49530b505706e86b3b9aeca45b7be Mon Sep 17 00:00:00 2001 From: soul Date: Thu, 22 Oct 2026 22:01:42 +0200 Subject: [PATCH 128/307] Cargo bay owns stack quantity: TryStore refuses below 1 and above MaxStack, TryBuyLots owns the stock limit, restore logs dropped stacks Co-Authored-By: Claude Sonnet 5.5 --- Assets/Scripts/ServerShared/Entity.cs | 3 +++ Assets/Scripts/ServerShared/EntitySerializer.cs | 13 +++++++++++-- Assets/Scripts/ServerShared/StationServices.cs | 9 +++++---- 3 files changed, 19 insertions(+), 6 deletions(-) diff --git a/Assets/Scripts/ServerShared/Entity.cs b/Assets/Scripts/ServerShared/Entity.cs index bc8087228..f98ef8c32 100644 --- a/Assets/Scripts/ServerShared/Entity.cs +++ b/Assets/Scripts/ServerShared/Entity.cs @@ -2530,9 +2530,12 @@ public class EquippedCargoBay : EquippedItem // Try to store the given commodity at the given position // If there's a stack at the given position it will be added to // Returns true only when ALL of the items are successfully stored + // The bay decides what quantity may sit in it: a stack below one unit or above its design's MaxStack is refused with + // the bay as it was, so no caller, loot, pickup or restore has to check for it. public bool TryStore(SimpleCommodity item, int2 cargoCoord) { var itemData = ItemManager.GetData(item); + if (item.Quantity < 1 || item.Quantity > itemData.MaxStack) return false; if (ItemFits(item, cargoCoord)) { foreach (var p in itemData.Shape.Coordinates) diff --git a/Assets/Scripts/ServerShared/EntitySerializer.cs b/Assets/Scripts/ServerShared/EntitySerializer.cs index 7f934b937..dc5b27c90 100644 --- a/Assets/Scripts/ServerShared/EntitySerializer.cs +++ b/Assets/Scripts/ServerShared/EntitySerializer.cs @@ -47,6 +47,15 @@ public static class EntitySerializer return pack; } + // A saved stack goes back where it was. The bay refuses what cannot sit in it (a stack below one unit, as the typed + // -5 purchase of old saves left, or above MaxStack), and the refusal is logged, never silent. + private static void RestoreStack(ItemManager itemManager, EquippedCargoBay bay, int2 position, ItemInstance item) + { + if (bay.TryStore(item, position)) return; + var quantity = item is SimpleCommodity commodity ? $" x{commodity.Quantity}" : ""; + itemManager.Log($"Dropped a saved item the bay refused at {position}: {itemManager.GetData(item).Name}{quantity}"); + } + public static Entity Unpack(ItemManager itemManager, Zone zone, EntityPack pack) { return pack switch @@ -103,12 +112,12 @@ public static class EntitySerializer for (var bayIndex = 0; bayIndex < pack.CargoContents.Length; bayIndex++) if(entity.CargoBays.Count >= bayIndex + 1) foreach (var (position, item) in pack.CargoContents[bayIndex]) - entity.CargoBays[bayIndex].TryStore(item, position); + RestoreStack(itemManager, entity.CargoBays[bayIndex], position, item); for (var bayIndex = 0; bayIndex < pack.DockingBayContents.Length; bayIndex++) if(entity.DockingBays.Count >= bayIndex + 1) foreach (var (position, item) in pack.DockingBayContents[bayIndex]) - entity.DockingBays[bayIndex].TryStore(item, position); + RestoreStack(itemManager, entity.DockingBays[bayIndex], position, item); entity.Temperature = pack.Temperature; diff --git a/Assets/Scripts/ServerShared/StationServices.cs b/Assets/Scripts/ServerShared/StationServices.cs index 433b0baf1..457b57a55 100644 --- a/Assets/Scripts/ServerShared/StationServices.cs +++ b/Assets/Scripts/ServerShared/StationServices.cs @@ -79,12 +79,13 @@ public static class StationServices return BuyResult.Bought; } - // The purchase of `quantity` units of a station stack, one lot of at most MaxStack at a time. `deliver` puts a lot of - // that many units where it goes, and each lot is charged for the units it delivered. The first lot the station - // cannot sell or the player cannot take ends the purchase, with the lots before it bought. + // The purchase of `quantity` units of a station stack, one lot of at most MaxStack at a time. The stock is the + // station's: a purchase of more than the stack holds, or of a stack that holds nothing, is refused before anything + // moves, so every lot is units the station has and `deliver` moves whole, and each lot is charged for the units it + // delivered. The first lot the player cannot take ends the purchase, with the lots before it bought. public static BuyResult TryBuyLots(ItemManager items, SimpleCommodity stock, int quantity, ref int credits, Func deliver) { - if (quantity < 1) return BuyResult.NoRoom; + if (quantity < 1 || quantity > stock.Quantity) return BuyResult.NoRoom; var maxStack = items.GetData(stock).MaxStack; for (var remaining = quantity; remaining > 0;) { From fa5820f516820c0e865cbe9ec9bc9be010893c9b Mon Sep 17 00:00:00 2001 From: soul Date: Thu, 22 Oct 2026 22:15:41 +0200 Subject: [PATCH 129/307] Cargo give: the bay no longer holds a stack below one, so the give's positive-stack filter and its zero-stack test go Co-Authored-By: Claude Sonnet 5.5 --- Assets/Scripts/ServerShared/CargoTransfer.cs | 2 +- tests/Aetheria.Shared.Tests/CargoTransferTests.cs | 5 ++--- 2 files changed, 3 insertions(+), 4 deletions(-) diff --git a/Assets/Scripts/ServerShared/CargoTransfer.cs b/Assets/Scripts/ServerShared/CargoTransfer.cs index 45208d948..32f49b611 100644 --- a/Assets/Scripts/ServerShared/CargoTransfer.cs +++ b/Assets/Scripts/ServerShared/CargoTransfer.cs @@ -110,7 +110,7 @@ public static class CargoTransfer } else if (bay.ItemsOfType.TryGetValue(order.Item.Data.Key, out var stacks)) { - var stack = stacks.OfType().FirstOrDefault(s => s.Quantity > 0); + var stack = stacks.OfType().FirstOrDefault(); if (stack != null) return (bay, stack); } } diff --git a/tests/Aetheria.Shared.Tests/CargoTransferTests.cs b/tests/Aetheria.Shared.Tests/CargoTransferTests.cs index 2495ca2b0..c6c80b398 100644 --- a/tests/Aetheria.Shared.Tests/CargoTransferTests.cs +++ b/tests/Aetheria.Shared.Tests/CargoTransferTests.cs @@ -697,15 +697,14 @@ public sealed class CargoTransferTests : IDisposable Assert.Equal(5, order.Remaining); } - // An empty stack in a hold is no unit to give: the give moves the real units and then has nothing left. + // A give of more than the hold has moves the real units and then has nothing left. [Fact] - public void AnEmptyStackIsNotAUnitToGive() + public void AGiveOfMoreThanTheHoldHasEndsNothingLeft() { var giver = ShipWithBays("Crate"); var receiver = ShipWithBays("Crate"); var grit = Commodity("Grit", 2); Assert.True(giver.CargoBays[0].TryStore(grit)); - Assert.True(giver.CargoBays[0].TryStore(Commodity("Grit", 0))); Place(giver, receiver); giver.Command.Give(receiver, grit, 3); From f7ba043b1358a4a39672edaec4b7591cbb8db397 Mon Sep 17 00:00:00 2001 From: soul Date: Thu, 22 Oct 2026 22:21:08 +0200 Subject: [PATCH 130/307] Cargo restore test: share the fixture's provenance ledger Co-Authored-By: Claude Sonnet 5.5 --- tests/Aetheria.Shared.Tests/CargoTransferTests.cs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/tests/Aetheria.Shared.Tests/CargoTransferTests.cs b/tests/Aetheria.Shared.Tests/CargoTransferTests.cs index c6c80b398..58efc2299 100644 --- a/tests/Aetheria.Shared.Tests/CargoTransferTests.cs +++ b/tests/Aetheria.Shared.Tests/CargoTransferTests.cs @@ -880,7 +880,7 @@ public sealed class CargoTransferTests : IDisposable { var log = new List(); var settings = AuthoredSettings.Load(RestoredHullsTests.FindRepoRoot()).Read("GameplaySettings"); - var items = new ItemManager(1u, _cache, new ProvenanceLedger(), settings, log.Add); + var items = new ItemManager(1u, _cache, _items.Lots, settings, log.Add); var zone = new Zone(items, new PlanetSettings(), new ZonePack(), new GalaxyZone { Name = "Test Zone", Owner = null }, null); var ship = ShipWith("Crate"); Assert.True(ship.CargoBays[0].TryStore(Commodity("Slag", 4))); From fd50f02f1b6224759ff56c30b9f07f943debae8e Mon Sep 17 00:00:00 2001 From: soul Date: Thu, 22 Oct 2026 22:29:24 +0200 Subject: [PATCH 131/307] Cargo purchase tests: a purchase of no units is refused, not bought Co-Authored-By: Claude Sonnet 5.5 --- tests/Aetheria.Shared.Tests/CargoTransferTests.cs | 14 ++++++++++++++ 1 file changed, 14 insertions(+) diff --git a/tests/Aetheria.Shared.Tests/CargoTransferTests.cs b/tests/Aetheria.Shared.Tests/CargoTransferTests.cs index 58efc2299..25245aa90 100644 --- a/tests/Aetheria.Shared.Tests/CargoTransferTests.cs +++ b/tests/Aetheria.Shared.Tests/CargoTransferTests.cs @@ -841,6 +841,20 @@ public sealed class CargoTransferTests : IDisposable Assert.Equal(0, Units(player, stock)); } + [Theory] + [InlineData(-5)] + [InlineData(0)] + public void APurchaseOfNoUnitsIsRefusedNotBought(int quantity) + { + var stock = Commodity("Ore", 10); + var credits = 100; + + var result = StationServices.TryBuyLots(_items, stock, quantity, ref credits, lot => true); + + Assert.Equal(BuyResult.NoRoom, result); + Assert.Equal(100, credits); + } + [Fact] public void APurchaseOfAnEmptyStationStackChargesNothingAndDeliversNothing() { From d66b2a2c3c2717c321070edf6b7af28e5dcdecc4 Mon Sep 17 00:00:00 2001 From: soul Date: Thu, 22 Oct 2026 22:49:22 +0200 Subject: [PATCH 132/307] Cargo restore tests: an oversize saved stack splits into MaxStack stacks and drops only what fits nowhere (red) Co-Authored-By: Claude Sonnet 5.5 --- .../CargoTransferTests.cs | 55 +++++++++++++++++++ 1 file changed, 55 insertions(+) diff --git a/tests/Aetheria.Shared.Tests/CargoTransferTests.cs b/tests/Aetheria.Shared.Tests/CargoTransferTests.cs index 25245aa90..f65606916 100644 --- a/tests/Aetheria.Shared.Tests/CargoTransferTests.cs +++ b/tests/Aetheria.Shared.Tests/CargoTransferTests.cs @@ -908,4 +908,59 @@ public sealed class CargoTransferTests : IDisposable Assert.Equal(new[] { 4 }, bay.Cargo.Keys.OfType().Select(c => c.Quantity).ToArray()); Assert.Contains(log, l => l.Contains("-5") && l.Contains("Ore")); } + + // ---- Saves from before the bay capped a stack ------------------------------------------------------------- + + // A pre-f0d7c12c purchase above MaxStack left a whole oversize stack in the hold. The save holds it as one stack. + private (Ship restored, List log) RestoreWithOversize(string[] bays, int oversize, int fillerPerBay) + { + var log = new List(); + var settings = AuthoredSettings.Load(RestoredHullsTests.FindRepoRoot()).Read("GameplaySettings"); + var items = new ItemManager(1u, _cache, _items.Lots, settings, log.Add); + var zone = new Zone(items, new PlanetSettings(), new ZonePack(), new GalaxyZone { Name = "Test Zone", Owner = null }, null); + var ship = new Ship(_items, _zone, Mint("Skiff"), new EntitySettings()); + for (var i = 0; i < bays.Length; i++) Assert.True(ship.TryEquip(Mint(bays[i]), new int2(1 + 2 * i, 1))); + var pack = (ShipPack) EntitySerializer.Pack(ship); + // Bay 0 holds the oversize stack at (3,3) and `fillerPerBay` full Slag stacks in the other cells; later bays only filler. + for (var b = 0; b < bays.Length; b++) + { + var contents = new List<(int2, ItemInstance)>(); + if (b == 0) contents.Add((new int2(3, 3), Commodity("Ore", oversize))); + for (var n = 0; n < fillerPerBay; n++) contents.Add((new int2(n % 4, n / 4), Commodity("Slag", 100))); + pack.CargoContents[b] = contents.ToArray(); + } + return ((Ship) EntitySerializer.Unpack(items, zone, pack), log); + } + + [Fact] + public void ARestoredOversizeStackSplitsIntoMaxStackStacksInTheSameBay() + { + var (restored, log) = RestoreWithOversize(new[] { "Crate" }, 250, 0); + + var ore = restored.CargoBays[0].Cargo.Keys.OfType().Select(c => c.Quantity).OrderByDescending(q => q).ToArray(); + Assert.Equal(new[] { 100, 100, 50 }, ore); + Assert.DoesNotContain(log, l => l.Contains("Dropped")); + } + + [Fact] + public void ARestoredOversizeStackSpillsToAnotherBayWhenItsOwnIsFull() + { + // Bay 0 has 15 cells of filler and the oversize stack's own cell: only 100 fits there. + var (restored, log) = RestoreWithOversize(new[] { "Crate", "Crate" }, 250, 15); + + Assert.Equal(new[] { 100, 100, 50 }, restored.CargoBays.SelectMany(b => b.Cargo.Keys.OfType()) + .Where(c => _cache.GetByName("Ore").Name == _items.GetData(c).Name).Select(c => c.Quantity).OrderByDescending(q => q).ToArray()); + Assert.Equal(1, restored.CargoBays[0].Cargo.Keys.OfType().Count(c => _items.GetData(c).Name == "Ore")); + Assert.DoesNotContain(log, l => l.Contains("Dropped")); + } + + [Fact] + public void ARestoredOversizeStackDropsOnlyWhatFitsNowhereAndLogsIt() + { + var (restored, log) = RestoreWithOversize(new[] { "Crate" }, 250, 15); + + Assert.Equal(new[] { 100 }, restored.CargoBays[0].Cargo.Keys.OfType() + .Where(c => _items.GetData(c).Name == "Ore").Select(c => c.Quantity).ToArray()); + Assert.Contains(log, l => l.Contains("Ore") && l.Contains("150")); + } } From b027b36ab46f6d2ca7aecdc7968df43f36cf577b Mon Sep 17 00:00:00 2001 From: soul Date: Thu, 22 Oct 2026 22:50:23 +0200 Subject: [PATCH 133/307] Cargo restore: a saved stack above MaxStack splits into MaxStack stacks, the same bay first, then the others; only what fits nowhere is dropped, logged Co-Authored-By: Claude Sonnet 5.5 --- .../Scripts/ServerShared/EntitySerializer.cs | 51 ++++++++++++++++--- 1 file changed, 45 insertions(+), 6 deletions(-) diff --git a/Assets/Scripts/ServerShared/EntitySerializer.cs b/Assets/Scripts/ServerShared/EntitySerializer.cs index dc5b27c90..5b4fb7f3f 100644 --- a/Assets/Scripts/ServerShared/EntitySerializer.cs +++ b/Assets/Scripts/ServerShared/EntitySerializer.cs @@ -47,15 +47,51 @@ public static class EntitySerializer return pack; } - // A saved stack goes back where it was. The bay refuses what cannot sit in it (a stack below one unit, as the typed - // -5 purchase of old saves left, or above MaxStack), and the refusal is logged, never silent. - private static void RestoreStack(ItemManager itemManager, EquippedCargoBay bay, int2 position, ItemInstance item) + // A saved stack goes back where it was. The bay refuses what cannot sit in it, and the refusal is logged, never + // silent: a stack below one unit (the typed -5 purchase of old saves) is dropped. A stack above its design's MaxStack + // (an ordinary purchase before the bay capped stacks, or a catalog MaxStack since lowered) is a legitimate save: its + // first MaxStack units go back where it was and the rest is returned to be placed by SpillOversize once every stack + // has its own cell, so a spill cannot take a cell a later stack was saved in. + private static void RestoreStack(ItemManager itemManager, EquippedCargoBay bay, int2 position, ItemInstance item, + List<(EquippedCargoBay home, SimpleCommodity rest)> spill) { + if (item is SimpleCommodity commodity && commodity.Quantity > itemManager.GetData(commodity).MaxStack + && itemManager.GetData(commodity).MaxStack >= 1) + { + var maxStack = itemManager.GetData(commodity).MaxStack; + var first = new SimpleCommodity { Data = commodity.Data, Rotation = commodity.Rotation, Quantity = maxStack }; + var rest = new SimpleCommodity { Data = commodity.Data, Rotation = commodity.Rotation, Quantity = commodity.Quantity - maxStack }; + if (!bay.TryStore(first, position)) rest.Quantity += first.Quantity; + spill.Add((bay, rest)); + return; + } if (bay.TryStore(item, position)) return; - var quantity = item is SimpleCommodity commodity ? $" x{commodity.Quantity}" : ""; + var quantity = item is SimpleCommodity c ? $" x{c.Quantity}" : ""; itemManager.Log($"Dropped a saved item the bay refused at {position}: {itemManager.GetData(item).Name}{quantity}"); } + // Places the units an oversize saved stack left over, in stacks of at most MaxStack: the stack's own bay first, then + // the entity's other bays. Only the units that fit nowhere are dropped, logged with their design and quantity. + private static void SpillOversize(ItemManager itemManager, IReadOnlyList bays, + List<(EquippedCargoBay home, SimpleCommodity rest)> spill) + { + foreach (var (home, rest) in spill) + { + var maxStack = itemManager.GetData(rest).MaxStack; + var remaining = rest.Quantity; + while (remaining > 0) + { + var chunk = new SimpleCommodity { Data = rest.Data, Rotation = rest.Rotation, Quantity = Math.Min(remaining, maxStack) }; + var placed = home.TryStore(chunk) || bays.Where(b => b != home).Any(b => b.TryStore(chunk)); + // A refused store may still have topped up a stack; what is left in the chunk found no room. + remaining -= Math.Min(remaining, maxStack) - (placed ? 0 : chunk.Quantity); + if (placed) continue; + itemManager.Log($"Dropped saved units no bay has room for: {itemManager.GetData(rest).Name} x{remaining}"); + break; + } + } + } + public static Entity Unpack(ItemManager itemManager, Zone zone, EntityPack pack) { return pack switch @@ -109,15 +145,18 @@ public static class EntitySerializer entity.DockingBays[i].DockedShip = entity.Children[pack.DockingBayAssignments[i]] as Ship; } + var spill = new List<(EquippedCargoBay home, SimpleCommodity rest)>(); for (var bayIndex = 0; bayIndex < pack.CargoContents.Length; bayIndex++) if(entity.CargoBays.Count >= bayIndex + 1) foreach (var (position, item) in pack.CargoContents[bayIndex]) - RestoreStack(itemManager, entity.CargoBays[bayIndex], position, item); + RestoreStack(itemManager, entity.CargoBays[bayIndex], position, item, spill); for (var bayIndex = 0; bayIndex < pack.DockingBayContents.Length; bayIndex++) if(entity.DockingBays.Count >= bayIndex + 1) foreach (var (position, item) in pack.DockingBayContents[bayIndex]) - RestoreStack(itemManager, entity.DockingBays[bayIndex], position, item); + RestoreStack(itemManager, entity.DockingBays[bayIndex], position, item, spill); + + SpillOversize(itemManager, entity.CargoBays.Cast().Concat(entity.DockingBays).ToList(), spill); entity.Temperature = pack.Temperature; From 9958fe55ab64c45632e216e7261155a58c0abc13 Mon Sep 17 00:00:00 2001 From: soul Date: Thu, 22 Oct 2026 22:50:24 +0200 Subject: [PATCH 134/307] Cargo purchase: a bay stack is one lot, so the lot loop is deleted; stock above MaxStack from outside a bay is refused up front Co-Authored-By: Claude Sonnet 5.5 --- .../Scripts/ServerShared/StationServices.cs | 20 ++++++------------ .../CargoTransferTests.cs | 21 ++++++++++--------- 2 files changed, 17 insertions(+), 24 deletions(-) diff --git a/Assets/Scripts/ServerShared/StationServices.cs b/Assets/Scripts/ServerShared/StationServices.cs index 457b57a55..153fc595d 100644 --- a/Assets/Scripts/ServerShared/StationServices.cs +++ b/Assets/Scripts/ServerShared/StationServices.cs @@ -79,22 +79,14 @@ public static class StationServices return BuyResult.Bought; } - // The purchase of `quantity` units of a station stack, one lot of at most MaxStack at a time. The stock is the - // station's: a purchase of more than the stack holds, or of a stack that holds nothing, is refused before anything - // moves, so every lot is units the station has and `deliver` moves whole, and each lot is charged for the units it - // delivered. The first lot the player cannot take ends the purchase, with the lots before it bought. + // The purchase of `quantity` units of a station stack, delivered as one lot. A bay holds no stack above its + // design's MaxStack, so a stack the station holds is a lot already; a stack above it comes from outside a bay and is + // refused, as is a purchase of more than the stack holds or of a stack that holds nothing. All of that is refused + // before anything moves, so `deliver` moves units the station has and the charge is for the units it delivered. public static BuyResult TryBuyLots(ItemManager items, SimpleCommodity stock, int quantity, ref int credits, Func deliver) { - if (quantity < 1 || quantity > stock.Quantity) return BuyResult.NoRoom; - var maxStack = items.GetData(stock).MaxStack; - for (var remaining = quantity; remaining > 0;) - { - var lot = Math.Min(remaining, maxStack); - var result = TryBuy(items, stock, lot, ref credits, () => deliver(lot)); - if (result != BuyResult.Bought) return result; - remaining -= lot; - } - return BuyResult.Bought; + if (quantity < 1 || quantity > stock.Quantity || stock.Quantity > items.GetData(stock).MaxStack) return BuyResult.NoRoom; + return TryBuy(items, stock, quantity, ref credits, () => deliver(quantity)); } // The quantity a Buy Quantity field may hold: what was typed, at most what the credits cover and what the station diff --git a/tests/Aetheria.Shared.Tests/CargoTransferTests.cs b/tests/Aetheria.Shared.Tests/CargoTransferTests.cs index f65606916..a08df266c 100644 --- a/tests/Aetheria.Shared.Tests/CargoTransferTests.cs +++ b/tests/Aetheria.Shared.Tests/CargoTransferTests.cs @@ -735,31 +735,32 @@ public sealed class CargoTransferTests : IDisposable // ---- The lot a purchase delivers --------------------------------------------------------------------------- [Fact] - public void APurchaseChargesForEachLotItDelivers() + public void APurchaseOfAStackIsOneLotChargedForWhatItDelivers() { - // Grit stacks hold two. Five units are three lots, and each lot is charged for what it delivers. - var stock = Commodity("Grit", 5); + // Grit stacks hold two: a station stack of two is one lot. + var stock = Commodity("Grit", 2); var credits = 100; var delivered = new List(); - var result = StationServices.TryBuyLots(_items, stock, 5, ref credits, lot => { delivered.Add(lot); return true; }); + var result = StationServices.TryBuyLots(_items, stock, 2, ref credits, lot => { delivered.Add(lot); return true; }); Assert.Equal(BuyResult.Bought, result); - Assert.Equal(new[] { 2, 2, 1 }, delivered); - Assert.Equal(100 - 5, credits); + Assert.Equal(new[] { 2 }, delivered); + Assert.Equal(100 - 2, credits); } [Fact] - public void ARefusedLotLeavesEarlierLotsBoughtAndChargesNothingMore() + public void AStackAboveMaxStackFromOutsideABayIsRefusedBeforeAnythingMoves() { var stock = Commodity("Grit", 5); var credits = 100; - var lots = 0; + var delivered = 0; - var result = StationServices.TryBuyLots(_items, stock, 5, ref credits, lot => ++lots < 2); + var result = StationServices.TryBuyLots(_items, stock, 2, ref credits, lot => { delivered++; return true; }); Assert.Equal(BuyResult.NoRoom, result); - Assert.Equal(100 - 2, credits); + Assert.Equal(0, delivered); + Assert.Equal(100, credits); } [Theory] From c986adcf20479284cdb0d376f09b3266931d14c8 Mon Sep 17 00:00:00 2001 From: soul Date: Thu, 22 Oct 2026 22:54:52 +0200 Subject: [PATCH 135/307] Cargo restore tests: only the oversize stack's own bay is full, so the spill has another bay to go to Co-Authored-By: Claude Sonnet 5.5 --- tests/Aetheria.Shared.Tests/CargoTransferTests.cs | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/tests/Aetheria.Shared.Tests/CargoTransferTests.cs b/tests/Aetheria.Shared.Tests/CargoTransferTests.cs index a08df266c..d057f004c 100644 --- a/tests/Aetheria.Shared.Tests/CargoTransferTests.cs +++ b/tests/Aetheria.Shared.Tests/CargoTransferTests.cs @@ -913,7 +913,7 @@ public sealed class CargoTransferTests : IDisposable // ---- Saves from before the bay capped a stack ------------------------------------------------------------- // A pre-f0d7c12c purchase above MaxStack left a whole oversize stack in the hold. The save holds it as one stack. - private (Ship restored, List log) RestoreWithOversize(string[] bays, int oversize, int fillerPerBay) + private (Ship restored, List log) RestoreWithOversize(string[] bays, int oversize, int filler) { var log = new List(); var settings = AuthoredSettings.Load(RestoredHullsTests.FindRepoRoot()).Read("GameplaySettings"); @@ -922,12 +922,12 @@ public sealed class CargoTransferTests : IDisposable var ship = new Ship(_items, _zone, Mint("Skiff"), new EntitySettings()); for (var i = 0; i < bays.Length; i++) Assert.True(ship.TryEquip(Mint(bays[i]), new int2(1 + 2 * i, 1))); var pack = (ShipPack) EntitySerializer.Pack(ship); - // Bay 0 holds the oversize stack at (3,3) and `fillerPerBay` full Slag stacks in the other cells; later bays only filler. + // Bay 0 holds the oversize stack at (3,3) and `filler` full Slag stacks in the other cells; later bays are empty. for (var b = 0; b < bays.Length; b++) { var contents = new List<(int2, ItemInstance)>(); if (b == 0) contents.Add((new int2(3, 3), Commodity("Ore", oversize))); - for (var n = 0; n < fillerPerBay; n++) contents.Add((new int2(n % 4, n / 4), Commodity("Slag", 100))); + for (var n = 0; n < (b == 0 ? filler : 0); n++) contents.Add((new int2(n % 4, n / 4), Commodity("Slag", 100))); pack.CargoContents[b] = contents.ToArray(); } return ((Ship) EntitySerializer.Unpack(items, zone, pack), log); From 8b0b50382a56a86becf6b350821f83f56f2ac281 Mon Sep 17 00:00:00 2001 From: soul Date: Thu, 22 Oct 2026 20:23:17 +0200 Subject: [PATCH 136/307] WeaponData: drop the dead [Union] list; BehaviorData's union owns behavior arms Co-Authored-By: Claude Sonnet 5.5 --- Assets/Scripts/ServerShared/Behaviors/Weapon.cs | 7 +------ 1 file changed, 1 insertion(+), 6 deletions(-) diff --git a/Assets/Scripts/ServerShared/Behaviors/Weapon.cs b/Assets/Scripts/ServerShared/Behaviors/Weapon.cs index 12166d46c..cb42d6bd4 100644 --- a/Assets/Scripts/ServerShared/Behaviors/Weapon.cs +++ b/Assets/Scripts/ServerShared/Behaviors/Weapon.cs @@ -11,12 +11,7 @@ using Newtonsoft.Json; using CultMath; using static CultMath.math; -[Inspectable, - Union(0, typeof(InstantWeaponData)), - Union(1, typeof(LauncherData)), - Union(2, typeof(ConstantWeaponData)), - Union(3, typeof(ChargedWeaponData)), - RuntimeInspectable] +[Inspectable, RuntimeInspectable] public abstract class WeaponData : BehaviorData { [Inspectable, JsonProperty("damageType"), Key(1), RuntimeInspectable] From 1dce24a226277d8570a3a40eb17d282fd04c746d Mon Sep 17 00:00:00 2001 From: soul Date: Thu, 22 Oct 2026 20:23:23 +0200 Subject: [PATCH 137/307] Pin CultLib 2.0.0 (42dce361) and CultMath 0.5.0 (7a8933b0): manifest tags and build revisions Co-Authored-By: Claude Sonnet 5.5 --- Directory.Build.props | 8 ++++---- Packages/manifest.json | 6 +++--- 2 files changed, 7 insertions(+), 7 deletions(-) diff --git a/Directory.Build.props b/Directory.Build.props index 190e89780..e17a8e5d5 100644 --- a/Directory.Build.props +++ b/Directory.Build.props @@ -2,12 +2,12 @@ $(CULTLIB_ROOT) $(MSBuildThisFileDirectory)..\CultLib - - 45c2f4006f5f7dab357426cabd3197e8d0a17f66 + + 42dce3611a81adc5c5fac54cadbaa5637d6022d3 $(CULTMATH_ROOT) $(CultLibRoot) - - 9c1b06cd703bfa44352050e819b0d0659cf5978a + + 7a8933b085468e1fc6f9d3bce4a85e7d101aaca3 C:\Program Files\Git\cmd\git.exe git diff --git a/Packages/manifest.json b/Packages/manifest.json index 2dd2a3bb3..d4dafcbf2 100644 --- a/Packages/manifest.json +++ b/Packages/manifest.json @@ -52,8 +52,8 @@ "com.unity.modules.vr": "1.0.0", "com.unity.modules.wind": "1.0.0", "com.unity.modules.xr": "1.0.0", - "org.gamecult.caching.unity": "https://github.com/GameCult/CultLib.git?path=/src/GameCult.Unity/Assets/Caching#caching-unity-v1.4.0", - "org.gamecult.cultlib": "https://github.com/GameCult/CultLib.git?path=/unity/org.gamecult.cultlib#cultlib-unity-v1.0.60", - "org.gamecult.cultmath": "https://github.com/GameCult/CultLib.git?path=/packages/cultmath/unity/org.gamecult.cultmath#cultmath-unity-v0.4.1" + "org.gamecult.caching.unity": "https://github.com/GameCult/CultLib.git?path=/src/GameCult.Unity/Assets/Caching#caching-unity-v2.0.0", + "org.gamecult.cultlib": "https://github.com/GameCult/CultLib.git?path=/unity/org.gamecult.cultlib#cultlib-unity-v2.0.0", + "org.gamecult.cultmath": "https://github.com/GameCult/CultLib.git?path=/packages/cultmath/unity/org.gamecult.cultmath#cultmath-unity-v0.5.0" } } From bd0293d6836e43f4a26a7d9c6021b0901a401ead Mon Sep 17 00:00:00 2001 From: soul Date: Thu, 22 Oct 2026 20:23:27 +0200 Subject: [PATCH 138/307] BehaviorData: LockWeaponData is arm 52; union-closure and null-gear tests Co-Authored-By: Claude Sonnet 5.5 --- .../ServerShared/Behaviors/Behaviors.cs | 1 + .../CultLibAdoptionTests.cs | 38 +++++++++++++++++++ 2 files changed, 39 insertions(+) create mode 100644 tests/Aetheria.Shared.Tests/CultLibAdoptionTests.cs diff --git a/Assets/Scripts/ServerShared/Behaviors/Behaviors.cs b/Assets/Scripts/ServerShared/Behaviors/Behaviors.cs index 7150e47a1..6b899ee35 100644 --- a/Assets/Scripts/ServerShared/Behaviors/Behaviors.cs +++ b/Assets/Scripts/ServerShared/Behaviors/Behaviors.cs @@ -198,6 +198,7 @@ public interface IPowerConsumer Union(49, typeof(LaunchSuppressorData)), Union(50, typeof(ListenerData)), Union(51, typeof(ScannerData)), + Union(52, typeof(LockWeaponData)), JsonObject(MemberSerialization.OptIn)] public abstract class BehaviorData { diff --git a/tests/Aetheria.Shared.Tests/CultLibAdoptionTests.cs b/tests/Aetheria.Shared.Tests/CultLibAdoptionTests.cs new file mode 100644 index 000000000..bfe379d36 --- /dev/null +++ b/tests/Aetheria.Shared.Tests/CultLibAdoptionTests.cs @@ -0,0 +1,38 @@ +using System; +using System.IO; +using System.Linq; +using System.Reflection; +using GameCult.Caching; +using GameCult.Caching.MessagePack; +using MessagePack; +using Xunit; + +// CultLib 2.0 refuses a union subtype no arm names (it used to write nil). These keep that refusal a build-time failure. +public sealed class CultLibAdoptionTests +{ + [Fact] + public void EveryConcreteSubtypeOfAUnionBaseIsAnArmOfIt() + { + var types = typeof(BehaviorData).Assembly.GetTypes(); + var missing = new System.Collections.Generic.List(); + foreach (var root in types) + { + var arms = root.GetCustomAttributes(false).Select(u => u.SubType).ToHashSet(); + if (arms.Count == 0) continue; + foreach (var subtype in types.Where(t => t != root && !t.IsAbstract && !t.IsInterface && root.IsAssignableFrom(t))) + if (!arms.Contains(subtype)) missing.Add($"{subtype.Name} is not an arm of {root.Name}"); + } + Assert.Empty(missing); + } + + [Fact] + public void NoShippedGearHoldsANullBehavior() + { + using var cache = new CultCache(TestCatalog.Registry()); + cache.AddBackingStore(new SingleFileMessagePackBackingStore(TestCatalog.Repo, true), AetheriaStores.CatalogTypes); + var gear = cache.GetAll().ToArray(); + Assert.NotEmpty(gear); + var bad = gear.Where(g => g.Behaviors != null && g.Behaviors.Any(b => b == null)).Select(g => g.Name).ToArray(); + Assert.Empty(bad); + } +} From fb8f2ea2fb7211563b3e491afb69ba32199782e5 Mon Sep 17 00:00:00 2001 From: soul Date: Thu, 22 Oct 2026 20:23:32 +0200 Subject: [PATCH 139/307] ShipAuthoring: Load follows the pull's InvalidDataException, RefuseLegacyEmbeddedHull the nil-stamp NotSupportedException Co-Authored-By: Claude Sonnet 5.5 --- Assets/Scripts/ServerShared/ShipAuthoring.cs | 4 ++-- .../ShipAuthoringTests.cs | 23 ++++++++++++++++++- 2 files changed, 24 insertions(+), 3 deletions(-) diff --git a/Assets/Scripts/ServerShared/ShipAuthoring.cs b/Assets/Scripts/ServerShared/ShipAuthoring.cs index eafbacd2d..2f6db9e5c 100644 --- a/Assets/Scripts/ServerShared/ShipAuthoring.cs +++ b/Assets/Scripts/ServerShared/ShipAuthoring.cs @@ -136,7 +136,7 @@ public static class ShipAuthoringStore { RefuseLegacyEmbeddedHull(path); try { return LoadRecords(path); } - catch (MessagePackSerializationException error) + catch (Exception error) when (error is InvalidDataException or MessagePackSerializationException) { throw new InvalidOperationException($"{path}: a record does not decode as its schema: {error.Message}", error); } @@ -152,7 +152,7 @@ public static class ShipAuthoringStore if (bytes.Length == 0) return; CultPersistedStoreSnapshot snapshot; try { snapshot = CultDocumentMessagePackSerialization.DeserializeSnapshot(bytes); } - catch (MessagePackSerializationException error) + catch (Exception error) when (error is MessagePackSerializationException or NotSupportedException) { throw new InvalidOperationException($"{path}: not a readable ship file: {error.Message}", error); } diff --git a/tests/Aetheria.Shared.Tests/ShipAuthoringTests.cs b/tests/Aetheria.Shared.Tests/ShipAuthoringTests.cs index eced4c6f7..35a346fe0 100644 --- a/tests/Aetheria.Shared.Tests/ShipAuthoringTests.cs +++ b/tests/Aetheria.Shared.Tests/ShipAuthoringTests.cs @@ -173,7 +173,28 @@ public sealed class ShipAuthoringTests RewritePayload(path, "aetheria.hulldata", slots => ((object[])((object[])slots[5])[0])[2] = new object[] { 5, false, true, false }); var error = Assert.Throws(() => ShipAuthoringStore.Load(path)); Assert.Contains(path, error.Message); - Assert.IsAssignableFrom(error.InnerException); + // The pull names the record key and schema; the MessagePack failure is its cause. + var pull = Assert.IsType(error.InnerException); + Assert.Contains("aetheria.hulldata", pull.Message); + Assert.Contains(ShipModCatalog.HullKey("mod.skiff").ToString(), pull.Message); + Assert.IsAssignableFrom(pull.InnerException); + } + + // A store header with a nil format stamp is refused by the runtime with NotSupportedException; Load names the file. + [Fact] + public void LoadNamesTheFileWhoseFormatStampIsNil() + { + using var directory = new TempDirectory(); + var path = Path.Combine(directory.Path, "nilstamp.cc"); + File.WriteAllBytes(path, CultDocumentMessagePackSerialization.SerializeSnapshot(new CultPersistedStoreSnapshot + { + FormatVersion = null, + SchemaCatalog = Array.Empty(), + Records = Array.Empty(), + })); + var error = Assert.Throws(() => ShipAuthoringStore.Load(path)); + Assert.Contains(path, error.Message); + Assert.IsType(error.InnerException); } // Decodes one record's payload of the named schema to its slot array, lets the edit change it, and stores it again. From fbf88e85c514daaeba06b1b8f18abe704810b3ef Mon Sep 17 00:00:00 2001 From: soul Date: Thu, 22 Oct 2026 20:23:37 +0200 Subject: [PATCH 140/307] ship_cc.read owns its unreadable-path refusal, naming no path Co-Authored-By: Claude Sonnet 5.5 --- tools/blender/aetheria_ships/ship_cc.py | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/tools/blender/aetheria_ships/ship_cc.py b/tools/blender/aetheria_ships/ship_cc.py index dd3ca78be..b7dca307c 100644 --- a/tools/blender/aetheria_ships/ship_cc.py +++ b/tools/blender/aetheria_ships/ship_cc.py @@ -119,7 +119,10 @@ class ShipFile(NamedTuple): def read(path: str, cultlib_packages: str) -> ShipFile: cultcache, msgpack = _libraries(cultlib_packages) store = cultcache.SingleFileMessagePackBackingStore(path) - envelopes = store.pull_all() + try: + envelopes = store.pull_all() + except OSError as error: + raise ValueError(f"The ship file cannot be read: {error.strerror}") from None ships = [envelope for envelope in envelopes if envelope.type == SCHEMA] hulls = [envelope for envelope in envelopes if envelope.type == HULL_SCHEMA] # Before S1 the hull lived inside the ship record. Saving such a file would preserve the stale hull under a ship that From e518a0b3d161a05029f86d6973d1186d6f9afead Mon Sep 17 00:00:00 2001 From: soul Date: Thu, 22 Oct 2026 20:37:40 +0200 Subject: [PATCH 141/307] packages-lock: regenerated by Unity at the 2.0.0 / 0.5.0 tags Co-Authored-By: Claude Sonnet 5.5 --- Packages/packages-lock.json | 18 ++++++++++-------- 1 file changed, 10 insertions(+), 8 deletions(-) diff --git a/Packages/packages-lock.json b/Packages/packages-lock.json index 5d26bd587..7345ecc47 100644 --- a/Packages/packages-lock.json +++ b/Packages/packages-lock.json @@ -256,27 +256,29 @@ "url": "https://packages.unity.com" }, "org.gamecult.caching.unity": { - "version": "https://github.com/GameCult/CultLib.git?path=/src/GameCult.Unity/Assets/Caching#caching-unity-v1.4.0", + "version": "https://github.com/GameCult/CultLib.git?path=/src/GameCult.Unity/Assets/Caching#caching-unity-v2.0.0", "depth": 0, "source": "git", "dependencies": { - "org.gamecult.cultlib": "1.0.60" + "org.gamecult.cultlib": "2.0.0" }, - "hash": "45c2f4006f5f7dab357426cabd3197e8d0a17f66" + "hash": "42dce3611a81adc5c5fac54cadbaa5637d6022d3" }, "org.gamecult.cultlib": { - "version": "https://github.com/GameCult/CultLib.git?path=/unity/org.gamecult.cultlib#cultlib-unity-v1.0.60", + "version": "https://github.com/GameCult/CultLib.git?path=/unity/org.gamecult.cultlib#cultlib-unity-v2.0.0", "depth": 0, "source": "git", - "dependencies": {}, - "hash": "45c2f4006f5f7dab357426cabd3197e8d0a17f66" + "dependencies": { + "org.gamecult.cultmath": "0.5.0" + }, + "hash": "42dce3611a81adc5c5fac54cadbaa5637d6022d3" }, "org.gamecult.cultmath": { - "version": "https://github.com/GameCult/CultLib.git?path=/packages/cultmath/unity/org.gamecult.cultmath#cultmath-unity-v0.4.1", + "version": "https://github.com/GameCult/CultLib.git?path=/packages/cultmath/unity/org.gamecult.cultmath#cultmath-unity-v0.5.0", "depth": 0, "source": "git", "dependencies": {}, - "hash": "9c1b06cd703bfa44352050e819b0d0659cf5978a" + "hash": "7a8933b085468e1fc6f9d3bce4a85e7d101aaca3" }, "com.unity.modules.accessibility": { "version": "1.0.0", From f448957f3ad1d1de2c9a96477acacb53047683da Mon Sep 17 00:00:00 2001 From: soul Date: Thu, 22 Oct 2026 20:39:29 +0200 Subject: [PATCH 142/307] Brush: name AnimatedSimplexBrush and RadialWaveBrush as arms; pull-failure test asserts the record key Co-Authored-By: Claude Sonnet 5.5 --- Assets/Scripts/ServerShared/Environment.cs | 2 ++ tests/Aetheria.Shared.Tests/ShipAuthoringTests.cs | 4 ++-- 2 files changed, 4 insertions(+), 2 deletions(-) diff --git a/Assets/Scripts/ServerShared/Environment.cs b/Assets/Scripts/ServerShared/Environment.cs index 737033e4e..9d4994fa3 100644 --- a/Assets/Scripts/ServerShared/Environment.cs +++ b/Assets/Scripts/ServerShared/Environment.cs @@ -72,6 +72,8 @@ public class NoiseSettings [Union(0, typeof(PowerBrush)), Union(1, typeof(SimplexBrush)), + Union(2, typeof(AnimatedSimplexBrush)), + Union(3, typeof(RadialWaveBrush)), Serializable, MessagePackObject, JsonObject(MemberSerialization.OptIn)] public abstract class Brush { diff --git a/tests/Aetheria.Shared.Tests/ShipAuthoringTests.cs b/tests/Aetheria.Shared.Tests/ShipAuthoringTests.cs index 35a346fe0..cb4d12d5f 100644 --- a/tests/Aetheria.Shared.Tests/ShipAuthoringTests.cs +++ b/tests/Aetheria.Shared.Tests/ShipAuthoringTests.cs @@ -175,8 +175,8 @@ public sealed class ShipAuthoringTests Assert.Contains(path, error.Message); // The pull names the record key and schema; the MessagePack failure is its cause. var pull = Assert.IsType(error.InnerException); - Assert.Contains("aetheria.hulldata", pull.Message); - Assert.Contains(ShipModCatalog.HullKey("mod.skiff").ToString(), pull.Message); + Assert.Contains("Record '" + ShipModCatalog.HullKey("mod.skiff") + "'", pull.Message); + Assert.Contains("schema", pull.Message); Assert.IsAssignableFrom(pull.InnerException); } From da5ba653cb78cb0ba2029ba83b924db7b455bf60 Mon Sep 17 00:00:00 2001 From: soul Date: Thu, 22 Oct 2026 23:34:08 +0200 Subject: [PATCH 143/307] Tests (red at 42dce361): a CultLib 1.0.60 run.cc opens; Load names a ship file of a newer store format tests/vectors/cultlib-1.0.60/run.cc is the run store Soul wrote with the base build (cl2-old-run-save-refused). Both new tests fail at the 2.0.0 pin; the first needs 2.0.1, the second needs ShipAuthoringStore.Load. Co-Authored-By: Claude Sonnet 5.5 --- .../RunSaveCompatibilityTests.cs | 33 ++++++++++++++++++ .../ShipAuthoringTests.cs | 19 ++++++++++ tests/vectors/cultlib-1.0.60/run.cc | Bin 0 -> 53949 bytes 3 files changed, 52 insertions(+) create mode 100644 tests/Aetheria.Shared.Tests/RunSaveCompatibilityTests.cs create mode 100644 tests/vectors/cultlib-1.0.60/run.cc diff --git a/tests/Aetheria.Shared.Tests/RunSaveCompatibilityTests.cs b/tests/Aetheria.Shared.Tests/RunSaveCompatibilityTests.cs new file mode 100644 index 000000000..8b8c98217 --- /dev/null +++ b/tests/Aetheria.Shared.Tests/RunSaveCompatibilityTests.cs @@ -0,0 +1,33 @@ +using System; +using System.IO; +using System.Linq; +using GameCult.Caching; +using GameCult.Caching.MessagePack; +using Xunit; + +// tests/vectors/cultlib-1.0.60/run.cc is a run.cc written by Aetheria on CultLib 1.0.60 (64 zones, factions, one entity). +// A player's save outlives the runtime that wrote it, so the current runtime must open it as compatible drift. +public sealed class RunSaveCompatibilityTests : IDisposable +{ + private readonly string _root = Path.Combine(Path.GetTempPath(), "aetheria-runcompat-" + Guid.NewGuid().ToString("N")); + + public RunSaveCompatibilityTests() => Directory.CreateDirectory(_root); + + public void Dispose() => Directory.Delete(_root, true); + + [Fact] + public void ARunWrittenByCultLib1060OpensWithEveryZoneAndFactionResolving() + { + var run = Path.Combine(_root, "run.cc"); + File.Copy(Path.Combine(RunStartTests.FindRepoRoot(), "tests", "vectors", "cultlib-1.0.60", "run.cc"), run); + using var cache = new CultCache(TestCatalog.Registry()); + cache.AddBackingStore(new SingleFileMessagePackBackingStore(TestCatalog.Repo, true), AetheriaStores.CatalogTypes); + cache.AddBackingStore(new SingleFileMessagePackBackingStore(run), AetheriaStores.RunTypes); + var saved = cache.GetGlobal(); + Assert.NotNull(saved); + Assert.NotEmpty(saved.Zones); + Assert.NotEmpty(saved.Factions); + Assert.All(saved.Zones, zone => Assert.NotNull(cache.Get(zone))); + Assert.All(saved.Factions, faction => Assert.NotNull(cache.Get(faction))); + } +} diff --git a/tests/Aetheria.Shared.Tests/ShipAuthoringTests.cs b/tests/Aetheria.Shared.Tests/ShipAuthoringTests.cs index cb4d12d5f..850e83dfd 100644 --- a/tests/Aetheria.Shared.Tests/ShipAuthoringTests.cs +++ b/tests/Aetheria.Shared.Tests/ShipAuthoringTests.cs @@ -197,6 +197,25 @@ public sealed class ShipAuthoringTests Assert.IsType(error.InnerException); } + // A store format this runtime does not know is refused on read with NotSupportedException from the attach; Load names the file. + [Fact] + public async Task LoadNamesTheFileWhoseStoreFormatIsNewer() + { + using var directory = new TempDirectory(); + var path = Path.Combine(directory.Path, "newer.cc"); + using (var cache = ShipAuthoringStore.Open(path, writable: true)) + { + ShipAuthoringStore.Write(cache, Fixture().Hull, Fixture().Visual); + await cache.FlushAsync(); + } + var snapshot = CultDocumentMessagePackSerialization.DeserializeSnapshot(File.ReadAllBytes(path)); + snapshot.FormatVersion = "cultcache.store.v9"; + File.WriteAllBytes(path, CultDocumentMessagePackSerialization.SerializeSnapshot(snapshot)); + var error = Assert.Throws(() => ShipAuthoringStore.Load(path)); + Assert.Contains(path, error.Message); + Assert.IsType(error.InnerException); + } + // Decodes one record's payload of the named schema to its slot array, lets the edit change it, and stores it again. private static void RewritePayload(string path, string schemaName, Action edit) { diff --git a/tests/vectors/cultlib-1.0.60/run.cc b/tests/vectors/cultlib-1.0.60/run.cc new file mode 100644 index 0000000000000000000000000000000000000000..fc2a4e76e119257e70ccafe82b3916de79159aef GIT binary patch literal 53949 zcmeHw36vbwb#~99(aIvgpbe?D00OB*?TeOfjYbjzBP2^QI5u8tc_lT>OpERrK*Vtf z1nl_7Sn3%Xfw95Akr)ILn;iqOVIsiBh2z9oFaxqpaDH&GgK@$k@%AgPz3;wTeUF;Gojs;*?jXyuJ?RWtb~ShV?L!x2cj&w%FXwqy z77alr8sSWvS2$iKL^f4P7Ytq$Ox5BzlV=T9BbsIMGA9bQuG%Oq5|IJt|%Ef%_zWU{GrH_CCi)H`5Hp>zK+;OL^ja`EQBEaozb26Z`_lBWw6|zG!xHVjdO)?{ zQ@v5oW&@%<_JQ6`Qcl>|u&;~~(Whh!$)#rg2@f}ng+W1&_$67FSBEr-I>;V#E*S^Jdxnzf3nBdGDfS$ z+_ksqUJB9&OZa*C@Zd9pdz<&UkUMm44Y&w^`I*Cy`bvtAvWExvw%kiW(|?AGm3Rj3 zwBGN7*ifoS!}x>OGtYd>r?UzafvyQ(uZAqu-eB@m$CmWf>P&e&RHtNORx3UA>9B;U z0w&ceO?x6-spd0Ic&v()sMhW&k5o+Bp=HPDxGIyi(DSG6FHE9GD&~%RKhV@g$>wy~ z6b#emOj$83TO=IeY+e^Qm572a@PZ<6HfQjrDO;MQ3LLRy!iT{INd9 z5C#QBKDv&+S%(JxKSehVpl~fjvra7sUov`frE&Uk0DPNw=-Ky_lt5KqpSqSHXX@6majsZ9{4|t=dn!IV*=k{W?TdfC6xcX2TGt0yX1cGc@ zBBm{@dP%eup71PZ36`vJx+p;iNU*#uT{ktwk}Z>y3ilT}FQ7nPyEY8+6NhYeHLI%Cua$q<) z5*eo zon%7lqM{SgCbFnnmTB0ErW=wb={(OvStOipDvE6hMALZSfFu#i)gN_{g0P!}EBwgD1B)c3+YE4~E~OVCyv9OSv(m*2{%5Rfd%$Nzr9PFo>o~yd)SJRx$)p z5}~)qvWda1ss^W+8fUVSXxO&R3ARL7O*VU|EXz^5mKNM}O?QX= zLp6L7Tj`6@Dv9y+#Z(#i>-B=;J(z_xXf0M~6s3W+wjtd`%Gg@Z2n~<-ahGD98(xtM_bi6G^Yq+B~Lp>n0Z-a@{ zaC($#^q|AAD)t^y6*$n>=;oE%GwI&$@PZN+{?eJ?&Q{XX1Mdq3*J zDTv`pRlptHjS|j*iq!53=QVm}4VHe3Z1GmYDQ1-4Zxn@r;&fqlTQ52g%OVv+Y)v}d zNp$Kz8vmZ;0-V@FI^C7a?2gpVP)8PVD}1+6+zJ4o0|P8oMbm7Y@ZfZ}zpso8wU1f}DYV&gzpovP zS(~i`9$Z&Z^C#R(;j_w(wg*CRR^|RgEQ0uOSAdr)r6wH+g!?UG|%9ua;l-Doi~P2q&+_qD(5ZIPG9q8f3pwRYVkuo^W4r>Q;Hv z6Yr%n$@BRZCXibzu=Yic6D)z{BwLqZkr0*FL?ZE`t{6lSG!5Qf*%Dcm2)xAdl4kNc zk!)EZECRA5-YBzyu7jQtvVkmEJhCDiqAD6PmWOzlP(-j5T{1L@w^;?Iy@ACdUK3=| zgwexFwynvqnPfrI2>WtN-!W|<`Z!4OXsbd2cs1OaTc(}`AoZRuW%zbL08#DM6<~fr zUkpK1>8NiAZU)fg3Bw8)Z=~uIfYYXH5C>MBmWD7UFfuyQ-96~5P(M+`*$iEFJP8S- zW@3-g@9*{Vw5qa`es(iMc* zV%^?;dGbrcip|2qFhHUS%|KK&TxcE-cO6xz*|=lIafhf zH9=M-f)4c>-~bAId4)*5fZEbXn{f_z4Yd8h5V|pt*cSWHwe zbNV-`&RWkKR?wW>=8nX+_7BBbfz<>_WqH_YqF`w%!j)xFlx4-1M8y#Ny#k|+`W%|i zv%Ji3EW`8LIBhvEEtkY)GAF2_sw`pIsR}BwL=mn86*0An zAnK;gN*sc*oDO--O-}N3XGYnZm&_Xc<8`<=vrPv7VyHvQqhoK@fa%9(}v1(r;C&Pn|L zi#naeiyP)RiHpDgH~mg>?O(;5Wb0GcJBd3E2~Ohs^kU-0{yrzM@6ew+iQZpa?j&d4 zaVJWyc7pLCLi>a*A%If*wisB`a%4wpVmWD)zN5#+4!rfrJ|w??Fr zscS?s49-&d&_o_>_~dY`);M_Xi>y}jg99ExL-AVU+39%guBQu(#*5VImhm6`qNa^d zBK_#3HBRCW)0<02f2~Fj~sUFAVyRu68gZA*cpQZ9!VGvwPerwiQnE7OjCD; z4mVA6{m!bd{QbSosypu3i+Sb2pgV`0#7taFob|mK?r9(HKYLH15ETKKoIgMPw@%`9 z+b$HR7r{G?x@r-QYD)%$v?OUZOLP_q3q(u6Ki22n_{}>>^Mo~>cOF4Qaf`EbLseM> z$LgY@NV=@S`)p%6YNCqxTW4+4LDQdi2FtRlU~z&DA+H&-hSfa?Y*jH?jzjFB|03g~ z(;$Wz?rv`oj6|p5o_(cs-eF6L&9a&($ucWq!-uY_JOU<>L&CPPmCf|uWW0tm-0zZH zGMqhg_p*B8m zBJrA}N**ANWISJG2vVTv65%*3^N0q|8@!?kSYQ{4p$V1}yvev}Q34j03Vrfi zQ>&{+c{B~hYsn2~-RT>52Ll>HH|7i{pZ7wi`H>QL5>MZhbP~5bCl=MHRd@c;T4&WY zPyWhT`R2ttox~{@o(1J-)}Bu~iC=&GpPj_&^hcaT&b@f*O{o6URkt{aDQ_Nf60dZ9 z6D8-qE<1^>zh3Gjr{1^5NxIb&zg+kwC-KCuP-xEILyeMO=|;(U=bYgr{_~`(oW#nv z00AGP)e}!l`4eZ=59ITl#68dd(3SoW{D{L=6am{{SqORo`XY=j4SQHM)zYv7BFIWJ zUQ!0zXcbTJ7eLb^X((RH7;@I#^M!8&ol+AtDPFF6>VhM~faa!$H~XN8?oCdxepx(s z@iOu6YrEZZpLaE^)p3_H$Oa^MbF*ACdUoy;=R0fle@OQaU9!D1ZRnlLiwTTI8|dY~ z)~Fk*>K0I7{c>Cpc%Ey0>Fy)3)epGw|1|c6VME7U=$H>33!!5%bS#CA<wSK1=~0 zrhpGqz=tUi!W0N$3WP8PLYM*}Oo0%lKnPPHgeefh6bNAo#4rV7m;y0Off%Mh3{xP6 zDGqsgqs@B;iv1a1sdm4_B_vY}hhF&V`+Bu3;+iBT<`Wh@i1 zY{+9~VB7J~BAvQBSX*Q$dNXhkDvXMZXN+xQkFp4JOB1!BMXHE=XPPWCtfi_9l9wrr z&a;GpEry+KH0I8xPqb_uhZ-ZBvcQPEt}q%>Gcd5r*-8UJ6K9x$s9*`WCh>*!@ht+5MZ?R3Vo~ zyOYe9%cE{+c)9F3IiEXoXKw9JVq(*6=y4t)I;<`+oPi$^!vQ_6a*V+vH3lzY0~lxJ zVtb+u4JXMDoVa^vkt`4s`^R`jkvN?ZC6h3SpQO)VS)Dg67DiF?Z9{P!WAmnteP?hO z*&+rNF`+=h3oOE2#1+OkNr7BE9JShx~df#Fmc%)q>2K$6BFKcFE=Dv|^c zeFV%nZ`rMTC?Q#rxjLQ7KwJFHPcRcP3rxaVj4fK27nX^KAR3Cn)>7mlU=>Z#v&LlP917^EH$Wb903Kp`frD4_Q=LuYt6O5wf5 zO`UU+@oVPo$vw1#Af&&0dmLL_yXbIF{OZSl0sWb%tcrcAswN_S3m!ZKzd|G+P)k#Z zBuf)PjsWIK=v4-`xD5fq@eKC8N{j^OJtDbm7Bxpej`r|Fx!nE8^M$=4$Oh#k|Md1$ z?m?QdQQuDD*n$gpKd>Cas~@r*k>%O zETTD`5 z>df27_;e;^h>CwJ~Hq@L)qdu7I|D06U&(H!=vHRHB-OYm%ER2rFvZH zcKYOrxq%EZNec1~D=BCEI`&GHoUo6k-A`czel;+Y7jtUUbMtuD9YcaZ@A|r8-ZUc2}s_8%<*02W7C`dA)i8^4E z=jY?$V=NaJe}5>VTwH*MtGbDZYp4Y_Pz(rV08C_#V+4Vdb?EZQQ^6MJ6{p#nVKOYT z8bO>v(1LpcgPAH>yoTK_hUp~Fdtpf+7ZU@CODg1I;=wZ^B_%EUjq`-9PB;v$!rF_nqUfBY3+CdNPgQi)7#efRa+ zGVv_XG`2D^@uIvxqD(xSGGuIJ;*xE*hGb&mjeC9{mWgxE)~d@yYJSz0iHkw+7|X=j z@<549O#JKwrd%dY-+UMN6hTKlH>ymGVqH6lH(x%ZR3?5hx!RYBmtVgaql<$D0o|E} zQy8!<8lsJf$nmNrDcBjOorV30p*M2_Jv%bJNVyL5S|~o1yYCu316#~; zk|*8qRrty^lj9Lk1;AI3Oit7}izm8_co3}QBB*F7?JFaqbc)tjQ?Gu~mrku4ZVgMP z#FPK<17Io)A0UJzB7{XY;S(?o0~#Ar<-ww{2+M#bfuqVdlq(jwV9<>K48(iDUxe5+ zx(3aOTA>@GVQSkSrcvocFQC zRD}idGdlJaF_OeW(dP}F*9t0ZG%aKLFMkn|PRYsF?h8q$#1mH@t}dMtgSk&(vLV6^ z4lN7m|71AlY!xegutg97qheLrV$TP?5+T@73hx4v>_8L6_+(THwkixuEC>pCZ^IpI zkt9HJ`r?zT4Ytis{yaR`R%bpF47S8~ZkPi}ij-9FznMr5Wb3fX(9IfAU|tKBXd%{* z1+5m+{@B<{%b1#qX-6saQ7f6V0uQr%a$+m~TX9r&}{ z{_ga3*LA|dH~IYO2+)DI1-p0^SWJctE5YDQm?LmXfDpQsi~~_J3Ac^|2l6gn1!2quAM!5@n- zHP8hPsRgf5G+r&{yGwPL_IH=UMS}#A@I_%ITEarU1`P_CEFs!hm_-^kIEx@8GWU8!gHdK8L0Uy$sLSOPyYfmZk zNiA6)`Xuq9+R!J1Sqj1dWjcD0%L>M(0=KztC?W&n7fu_b<|KT<*wo>_fd0!eaDKt7 zpo+i1vz~hsG80+uB%~##zA*wIv*3P!Jc5ga z#f*i3hV)hyI#$ONcaw9kZVuzF@Xj^0aaRQyBf#C-3ldJ^C!1faA_^04fBw9%D4g-p zdqSe{owgqi#bJnYEEY;^nEHZ%=p!UOL&9k!KtqmL4JHq0jf}g|ruQk?ull%q-hFSD z;O_G?=GC?emV>4-#@(gPmm}it3Osy_areZX{~p5K#P4oC7{cA;^yZDj;BL=NU#pF~ zD?#rV<8H@gk5?IN$%_^}93E_^tW5@kEh%Xqt&O{@Kr69Xzy%x#8a_J&g$lZlVE}<` zP9`*KF@XcRsE|gO7)dUDO9}3te0YnQuZ9hYQ{|keU5w2EZ%N=0W7-Mg1%a0>q?`k|avb`Hk zmNO^iAvdG=0Q*CMDM38$X!B82k7b75slr!Z3}8GnzklnF-tJv`4l!wW!~C4^)?NM0 zGv_bLHBFc>7^W)-7aKpi6oo6&H820bW%~B*dfffZIZ5vP=bgk?`sLh&wmsO=&>xPZ zX#~r#oaeJ_pd;1QMZZx4rrq(xY2e&<7WK8nr9DkerGzztXBy{iADNdwusM_JBJpkb z0m~M(eZon8;GLGfiKjDrnwv{WZ5ldigN4O z{U@J#+KgONOBvTfo6|;_&uRD>0MA#@o$K{Xd@H^*1kxV*(m%rf{LoE(C#_8GX>Bd# znb-)P@m_54!u-K)I|wE~rYpVypL9cywq4lfB;I=A-M-Ui&dW_`!2}IYgHQncIP%R4 z=vg9vUEiEV z%L~#s+_^yv^*GK~#be+6{K1R$?Z`ljXp8k&2*ubtXV=qCvgOE+bFHUW2otRlY*W3h z7r?9yySAqgY=a^9i}g<8sVO(~PdoXHW$4eA63l9ZkVEjDk68mB?(W3LZYaMJKVP*J zu|_AH*B?7==HiO}ce;u6aVKIN=-7wVLJ)xtaNW zH0rMhWXT6lb&@B1<{P=Gr=yd*_k;PT~i5-`qFtv}JqfYz@gcz5xIR z-<>CY&&==Jrg!OCQ1#p2y#vj6)~9caHJ^Ce2Qb)5k(F-%c7ZSt_!s3L+NfW94T9X_ z8wky50uqxg%_m{+$E>ct56rGm=J*D{7Ik_S?JXta)zrX!T9heHTzWdqVV`I&dXZohko?qFg4Zti7QPoAB&Cfr$ zLGQHV8;PFn%~0`n%4f0L`kwe-_RT$KsXG%wHee&;iq>uv?Hn1k-`U*X)W@^~9u;_QLtEIf<`- zeOaz;VYwGVXb5`o-sY*Ko*!7(-oYsNIy?x|zP8XwyzpQ9bJ&M)bnor(O4iz9jF9&l zn>OQKCW>K`W7OVaSbOJw?Ys~*cd*}Cd($!)Qfv2k?Ys^(H!arA1yDQJMD1PiC{b(Y zaP6!)H8TX=mWv&UoxcC0q&wBK+Z_njou{!JKa;+1; zTzeBp@sVqukI_dqAI;^aHsgs=A_rRv)E29@#6WD+s!f$u+af--vg(8;RE@Hqqx4-T zwB~Xxv}%;a1+VG`mrWCoVvW2B?2Y8|F4pb1&aK<#)@`BBjj*+$Sa)rqZtKy(zG>5r z#C8XHKAetQupc+VkGn0q>?cAsw@6SeYK`ONB6Z69j!h%)>7Yh%6!o?I8Wp3O3Kb{9 zkKaOQMntIQ776M`t#Q0uq)vI?v1#Nz9n=VpqP~`2qcEy-hY!<#&pcE8U*T?)GQ+iS z{Ca@{QFs`k%;27*djRjTSPt4u%}unPVo=bZCLP_gd-oo9Gjy~K=K0<3Uu~82Xv|r6 zhSqtwpjB?8U$*Ii4iRPNODTynj^0_+D(k5WwTJ3>QT76QkDcbKJu_Q!xe4`Gty8m{ z1*LRiRHd|3pQTk>sG1ffLd|>E!YG&xExKg_WmAMLccoS5Kq+mdRikVU@v0V-(lb%D zsTEa6uFuY+1VaI@xvk85;!d|u2_bhiy}_5J zg>u!31lQcc!Rh<+C<2iYUfM5G!NvA_pgWcQ_@xV6Y>(3-Z&&%DH{8O7BF8KjD&m%_ z^XDE)Mzy-b(dQP9;jdV2xN>fh;HQiw?x}ybz{M(9?c;ML%;Hw%#j(9o^$}G`axV_< z2L6l;fFRI|5L7EuFd&GsYxEE_e-scjAEL^IpeWleJ#keKkPAErs;_FD5VWv<2%=T% zgrG(BL(p0EL(tjvL(t;-An4ptK+rkIA?O%R3STk~kGp=6;}CQlf`EIYhoD+%_k9Rz zqQ3RDSERbTQ_y&$?7JyqLD7?X-q9nmnVuaKVb@N1?Od*T5*{37r;P_f&$~MF5`fU; z`m450rM5wYy{e^8T?*;i=K7o?Y$+|R%0Q;KQHvzX#uTq=0dSY0>I6z~lszI|)txSz z&Ig+&)nB!Zu9`&IW!XGP|6ETamOt2L5KNZ@>Z>}j{;I9iaEtQP(&Z0Ia4Tg~eN`t% zeQK#`OrJVA>QgJLww)M}U~fniW)6L7GhHQ$vQgEmS{R5NM$^>#tGZTol)W&eqlu^L z8yyn$Rh?Xa)v2^!>w9VwWz)z#2qFYbGo-`_JqQw280k7%lszlOK6P!7!9kg>qfMZ7 zqip#s)?GXKdb-RhP|fee=|^I36_guakqMm+>pQ4c_}JHmZK~|Xv0Dl5mQ_3UX;5w0 zqhmJ^;ci*W5o$y&60{t(M$~d{%TWve?<+UfuOD>O*tA^MUT`|1t_Z3{IEvbGgr=$$ z8MgUqg-fs1l2z?--xpSTRN72Px($1y-EF`%XWXOVeq#8G-k#e^&}c+!soFCNPD{7F zVSj?=s}w0~wB0QdkQSUC`&yM}1T9x>)NT3LKS86V4|iJ*xoc@FYy6*(U-Q__yLU@l z9-l_5Jt!(^0T28dG5k60?&&e94;2Z@4c8vp^gP``VfPu;eyX(6?w5<^?ioM%7Itto!^g?_Q;@ZjOxX#H5q zsQg$8wc6@)Myd7tv1rvg{a9N4eyrv7`>|Hk@5fqMzaNWMb^TaTvX+#39#(mNtoo|D zeysYcx_+!ERZIO?lwjA7RbN%tj}@hAsWXZ`)%9c5SJm}nMXBmJqY8d3`c&7C6(#YD zS2gc?MH3hjt{4{G>1%4C^{g6W-UTJ2xyVpwn1BGjT z;TZlxK7+#DGKWC52Fk9Lozmumma8^Oixh4QdlWS49{J^B_zQ;}(i_M9SQRX3r+g{% z%$1+gA~F1he#Ux811q9TKG$+BdN@A*3P?ef2kkK-J9ZEaNyIQc!AC*`nUUVI5&;ZkQ6X{kjCWR zjk=qZ@w?luK;E}4`)`X)kI%2nBPBE>^^4%83PDbUt@tnzvzcVNboxa^q#l@bF1}~H z^!}4`=cpA~I2w}t#s5z3+4+at@y(>|-Sp#i@il2ZLlbf3Ok^^-`{3ceN#`x8$oM8S zBn^zcC?LPhR~VtSg{g=7+< zB=VqZ#VOY!J(xNrcgn)uM@v#Pi490whA(b;DMFCxZ6iLs*+aAWIFD?05^ukFXMgj_ zahe;XGL(;v7-q1%z#t(N}NRZQ!?#}jPdd*^@p+jw#AdBkB zkM^H&Hp>+<_=R%5hz*zr_;Qt(=M70M3;hZrdopR7WhlPmPw{enbr`rlD+Y^kS3zbY)^H^({_Aa zCX=Q~ULW}ivTl9ihHvGT$nJCqVO9fXgNiTF_~|weZ0POU*|RIYHcip={2w6&(r4a& zu&=32EKklPH3YY0Zz{~p-@CPk<|s?7xOszJk6Hk2wkLnn}3{=akgYp;Q0gzu-F$m#w%gcftynw9qtYfE-itE$ZUbguY0#WLxn|@UiOn z#rT*t7 Date: Thu, 22 Oct 2026 23:34:24 +0200 Subject: [PATCH 144/307] Pins: cultlib-unity-v2.0.1 at b8fc5eb0 (1.0.x saves with ref lists open as compatible drift) packages-lock.json follows from the Unity resolve. caching-unity stays at v2.0.0; cultmath at 7a8933b0. Co-Authored-By: Claude Sonnet 5.5 --- Directory.Build.props | 4 ++-- Packages/manifest.json | 2 +- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/Directory.Build.props b/Directory.Build.props index e17a8e5d5..05d5e4d22 100644 --- a/Directory.Build.props +++ b/Directory.Build.props @@ -2,8 +2,8 @@ $(CULTLIB_ROOT) $(MSBuildThisFileDirectory)..\CultLib - - 42dce3611a81adc5c5fac54cadbaa5637d6022d3 + + b8fc5eb07720a187a0421424ac2cb3aa2966ca1d $(CULTMATH_ROOT) $(CultLibRoot) diff --git a/Packages/manifest.json b/Packages/manifest.json index d4dafcbf2..7693bd94f 100644 --- a/Packages/manifest.json +++ b/Packages/manifest.json @@ -53,7 +53,7 @@ "com.unity.modules.wind": "1.0.0", "com.unity.modules.xr": "1.0.0", "org.gamecult.caching.unity": "https://github.com/GameCult/CultLib.git?path=/src/GameCult.Unity/Assets/Caching#caching-unity-v2.0.0", - "org.gamecult.cultlib": "https://github.com/GameCult/CultLib.git?path=/unity/org.gamecult.cultlib#cultlib-unity-v2.0.0", + "org.gamecult.cultlib": "https://github.com/GameCult/CultLib.git?path=/unity/org.gamecult.cultlib#cultlib-unity-v2.0.1", "org.gamecult.cultmath": "https://github.com/GameCult/CultLib.git?path=/packages/cultmath/unity/org.gamecult.cultmath#cultmath-unity-v0.5.0" } } From 7886f0a231890ab3f69932feec1e8d3a97e17926 Mon Sep 17 00:00:00 2001 From: soul Date: Thu, 22 Oct 2026 23:34:26 +0200 Subject: [PATCH 145/307] ShipAuthoringStore.Load names the file when its store format is newer than this runtime reads Co-Authored-By: Claude Sonnet 5.5 --- Assets/Scripts/ServerShared/ShipAuthoring.cs | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/Assets/Scripts/ServerShared/ShipAuthoring.cs b/Assets/Scripts/ServerShared/ShipAuthoring.cs index 2f6db9e5c..3962fa468 100644 --- a/Assets/Scripts/ServerShared/ShipAuthoring.cs +++ b/Assets/Scripts/ServerShared/ShipAuthoring.cs @@ -140,6 +140,10 @@ public static class ShipAuthoringStore { throw new InvalidOperationException($"{path}: a record does not decode as its schema: {error.Message}", error); } + catch (NotSupportedException error) + { + throw new InvalidOperationException($"{path}: not a readable ship file: {error.Message}", error); + } } // Key 1 of ShipAuthoring held the whole hull before S1. The type no longer has that member, so a deserialized record From fba7bd1b4763e08e145a0a21b99eed4720143cb7 Mon Sep 17 00:00:00 2001 From: soul Date: Thu, 22 Oct 2026 18:24:10 +0200 Subject: [PATCH 146/307] Station kinds: StationKindData catalog list, HullData.Kind, placed station Kind; GenerateStationLoadout takes a kind (the kindless draw is deleted) Co-Authored-By: Claude Sonnet 5.5 --- Assets/Scripts/ServerShared/AetheriaStores.cs | 3 +- .../Scripts/ServerShared/EntitySerializer.cs | 7 +- Assets/Scripts/ServerShared/ItemData.cs | 6 + .../Scripts/ServerShared/LoadoutGenerator.cs | 10 +- Assets/Scripts/ServerShared/OrbitalEntity.cs | 2 + Assets/Scripts/ServerShared/StationKinds.cs | 128 ++++++ .../Scripts/ServerShared/StationKinds.cs.meta | 11 + Assets/Scripts/ServerShared/ZoneGenerator.cs | 6 +- .../ConsumableSupplyTests.cs | 17 +- .../HardpointOwnershipTests.cs | 2 +- .../PickupGenerationTests.cs | 2 +- tests/Aetheria.Shared.Tests/RunGoalTests.cs | 2 +- tests/Aetheria.Shared.Tests/RunStartTests.cs | 10 +- .../Aetheria.Shared.Tests/StationKindTests.cs | 378 ++++++++++++++++++ .../StationKindsCatalogTests.cs | 53 +++ tools/AetherDb/Program.cs | 5 +- tools/AetherDb/StationKindsCatalog.cs | 82 ++++ 17 files changed, 700 insertions(+), 24 deletions(-) create mode 100644 Assets/Scripts/ServerShared/StationKinds.cs create mode 100644 Assets/Scripts/ServerShared/StationKinds.cs.meta create mode 100644 tests/Aetheria.Shared.Tests/StationKindTests.cs create mode 100644 tests/Aetheria.Shared.Tests/StationKindsCatalogTests.cs create mode 100644 tools/AetherDb/StationKindsCatalog.cs diff --git a/Assets/Scripts/ServerShared/AetheriaStores.cs b/Assets/Scripts/ServerShared/AetheriaStores.cs index 525894ac9..47128ef6c 100644 --- a/Assets/Scripts/ServerShared/AetheriaStores.cs +++ b/Assets/Scripts/ServerShared/AetheriaStores.cs @@ -7,7 +7,7 @@ using GameCult.Caching.MessagePack; // Aetheria's stores: one home store per type, composed here and nowhere else. public static class AetheriaStores { - public static readonly Type[] CatalogTypes = { typeof(ItemData), typeof(ShipAuthoring), typeof(Faction), typeof(FactionProductData), typeof(PersonalityAttribute), typeof(InputLayout), typeof(Loadout), typeof(FieldKindData), typeof(VerseGrammar), typeof(VerseVerb) }; + public static readonly Type[] CatalogTypes = { typeof(ItemData), typeof(ShipAuthoring), typeof(Faction), typeof(FactionProductData), typeof(PersonalityAttribute), typeof(InputLayout), typeof(Loadout), typeof(FieldKindData), typeof(VerseGrammar), typeof(VerseVerb), typeof(StationKindData) }; public static readonly Type[] RunTypes = { typeof(OrbitData), typeof(BodyData), typeof(SavedZone), typeof(SavedGame), typeof(ProvenanceLedger) }; public static readonly Type[] PlayerTypes = { typeof(PlayerSettings) }; @@ -42,6 +42,7 @@ public static class AetheriaStores foreach (var data in cache.GetAll()) CultRecordRefs.Validate(data); VerseGrammarValidation.ValidateAll(cache.GetAll()); ValidateBins(cache); + StationKinds.Validate(cache); return cache; } catch diff --git a/Assets/Scripts/ServerShared/EntitySerializer.cs b/Assets/Scripts/ServerShared/EntitySerializer.cs index 5b4fb7f3f..7f0f04ebd 100644 --- a/Assets/Scripts/ServerShared/EntitySerializer.cs +++ b/Assets/Scripts/ServerShared/EntitySerializer.cs @@ -16,7 +16,8 @@ public static class EntitySerializer { Orbit = new CultRecordRef(orbital.OrbitData), SecurityLevel = orbital.SecurityLevel, - SecurityRadius = orbital.SecurityRadius + SecurityRadius = orbital.SecurityRadius, + Kind = orbital.Kind }; else if (entity is Ship ship) pack = new ShipPack @@ -120,6 +121,7 @@ public static class EntitySerializer Restore(itemManager, zone, pack, entity); entity.SecurityLevel = pack.SecurityLevel; entity.SecurityRadius = pack.SecurityRadius; + entity.Kind = pack.Kind; if (pack.Story >= 0) entity.Story = zone.GalaxyZone.Locations[pack.Story]; return entity; } @@ -214,6 +216,7 @@ public class OrbitalEntityPack : EntityPack [Key(18)] public int Story = -1; [Key(19)] public SecurityLevel SecurityLevel; [Key(20)] public float SecurityRadius; + [Key(22)] public CultRecordRef Kind; } [MessagePackObject, @@ -237,7 +240,7 @@ public abstract class EntityPack [Key(14)] public EntitySettings Settings; [Key(15)] public CultRecordRef Faction; [Key(16)] public int[][] WeaponGroups; - // Keys 17-20 belong to the subclasses; the base's next free key is 21. + // Keys 17-20 belong to the subclasses; the base's next free key is 23 (Boss is 21, the orbital pack's Kind is 22). [Key(21)] public bool Boss; private int _price; diff --git a/Assets/Scripts/ServerShared/ItemData.cs b/Assets/Scripts/ServerShared/ItemData.cs index 170c2421d..d9e972a85 100644 --- a/Assets/Scripts/ServerShared/ItemData.cs +++ b/Assets/Scripts/ServerShared/ItemData.cs @@ -549,6 +549,12 @@ public class HullData : EquippableItemData [Inspectable, JsonProperty("visual"), Key(32)] public CultRecordRef Visual; + // What a station hull is built to be (StationKinds): set on every Station hull and on no other. The hull names its kind + // and the kind names no hull, so a package can add a faction's model of a kind without editing any kind record. + // Key 33 is the next free key. + [Inspectable, JsonProperty("kind"), Key(33)] + public CultRecordRef Kind; + // The farthest corner of any occupied hull cell from the centre of mass the schematic frame uses, in world // units: a true bound on how far the hull reaches from its position (the half-diagonal of the bounding box is not, // as FireControl.Detonate's comment records). A cell at (x, y) spans x +/- .5 and y +/- .5. diff --git a/Assets/Scripts/ServerShared/LoadoutGenerator.cs b/Assets/Scripts/ServerShared/LoadoutGenerator.cs index b75f0d30a..ce8569965 100644 --- a/Assets/Scripts/ServerShared/LoadoutGenerator.cs +++ b/Assets/Scripts/ServerShared/LoadoutGenerator.cs @@ -74,9 +74,12 @@ public class LoadoutGenerator return EntitySerializer.Pack(entity) as OrbitalEntityPack; } - public OrbitalEntityPack GenerateStationLoadout() + // A station of one kind: the hull is drawn among the station hulls the kind calls for in this faction's hands + // (StationKinds.HullKindFor), so a faction with no model of the kind builds its stand-in's hull and keeps the kind. + public OrbitalEntityPack GenerateStationLoadout(CultRecordRef kind) { - var (hullProduct, hullData) = RandomHull(HullType.Station); + var hullKind = StationKinds.HullKindFor(ItemManager.ItemData, Faction, kind); + var (hullProduct, hullData) = RandomHull(HullType.Station, hull => hull.Kind.Key.Equals(hullKind.Key)); if(hullData==null) { ItemManager.Log("Unable to generate station loadout: no compatible hull found!"); @@ -85,6 +88,9 @@ public class LoadoutGenerator var hull = ItemManager.CreateInstance(hullProduct) as EquippableItem; var entity = new OrbitalEntity(ItemManager, null, hull, default, ItemManager.GameplaySettings.DefaultEntitySettings); entity.Faction = Faction; + entity.Kind = kind; + // A stand-in hull keeps its own model but not its own name: the station is what its kind says it is. + if (!hullData.Kind.Key.Equals(kind.Key) && ItemManager.ItemData.Get(kind) is { } kindData) entity.Name = kindData.Name; // Hardpoint gear goes in first so the docking bay can only take space that gear left free EquipHardpoints(entity); diff --git a/Assets/Scripts/ServerShared/OrbitalEntity.cs b/Assets/Scripts/ServerShared/OrbitalEntity.cs index a2c511ba7..be1bd893c 100644 --- a/Assets/Scripts/ServerShared/OrbitalEntity.cs +++ b/Assets/Scripts/ServerShared/OrbitalEntity.cs @@ -17,6 +17,8 @@ public class OrbitalEntity : Entity public float SecurityRadius; public LocationStory Story; public bool CanTow; + // What this station is for (StationKinds): its own fact, which under a stand-in hull differs from the hull's kind. + public CultRecordRef Kind; public OrbitalEntity(ItemManager itemManager, Zone zone, EquippableItem hull, CultRecordKey orbit, EntitySettings settings) : base(itemManager, zone, hull, settings) { diff --git a/Assets/Scripts/ServerShared/StationKinds.cs b/Assets/Scripts/ServerShared/StationKinds.cs new file mode 100644 index 000000000..18c200092 --- /dev/null +++ b/Assets/Scripts/ServerShared/StationKinds.cs @@ -0,0 +1,128 @@ +using System; +using System.Collections.Generic; +using System.Linq; +using GameCult.Caching; +using MessagePack; +using Newtonsoft.Json; + +// What a station is for: one authored catalog record per kind (mining, trade-hub, shipyard...). The catalog's list of +// these records is the one list of station kinds. A kind names no hull: the hull names its kind (HullData.Kind), and +// which hull a faction builds for a kind is derived from the products that faction makes (StationKinds), so a package can +// add a faction's model of a kind without editing any kind record. Records are keyed station-kind:. +[CultDocument("aetheria.stationkinddata", "1"), MessagePackObject, JsonObject(MemberSerialization.OptIn)] +public class StationKindData +{ + [CultName, JsonProperty("name"), Key(0)] + public string Name = ""; + + [JsonProperty("description"), Key(1)] + public string Description = ""; + + // A faction with no model of this kind builds the stand-in's hull and the station stays this kind. Ignored once the + // faction offers a model of this kind. + [JsonProperty("standIns"), Key(2)] + public List StandIns = new List(); + + public static CultRecordKey KeyOf(string id) => new CultRecordKey("station-kind:" + id); +} + +[MessagePackObject, JsonObject(MemberSerialization.OptIn)] +public class StationKindStandIn +{ + [JsonProperty("faction"), Key(0)] + public CultRecordRef Faction; + + // The kind whose model this faction builds in place of the owning kind's. + [JsonProperty("kind"), Key(1)] + public CultRecordRef Kind; +} + +// The one owner of which kinds a faction builds, which kind a station gets, and which hull kind stands in. +public static class StationKinds +{ + // The kinds this faction can place: those of the station hulls its products offer, and those that stand in for it. + // Ordered by key so a choice never depends on store order. Empty for no faction. + public static CultRecordRef[] KindsOf(CultCache catalog, Faction faction) + { + if (faction == null) return Array.Empty>(); + var kinds = new HashSet(OfferedStationHulls(catalog, faction).Select(hull => hull.Kind.Key.Value), StringComparer.Ordinal); + foreach (var kind in catalog.GetAll()) + if (kind.StandIns.Any(standIn => catalog.Get(standIn.Faction) == faction)) + kinds.Add(catalog.RefOf(kind).Key.Value); + return Ordered(kinds); + } + + // A station's kind: a stable pick among the faction's kinds by the station's site key. It draws nothing from any random + // stream, so adding the kind to generation moves no other roll. A faction with no kinds falls back to any kind some + // offered station hull carries, logged, and the hull draw then falls back to any maker. Unset when the catalog has none. + public static CultRecordRef Choose(CultCache catalog, Faction faction, CultRecordKey siteKey, Action log = null) + { + var pool = KindsOf(catalog, faction); + if (pool.Length == 0) + { + pool = Ordered(new HashSet( + catalog.GetAll().Where(product => LoadoutGenerator.IsOffered(catalog, product)) + .Select(product => catalog.Get(product.Design) as HullData) + .Where(hull => hull != null && hull.HullType == HullType.Station && hull.Kind.IsSet()) + .Select(hull => hull.Kind.Key.Value), StringComparer.Ordinal)); + if (pool.Length == 0) return default; + log?.Invoke($"{faction?.Name ?? "This galaxy"} builds no station kind; placing a station of any kind some maker offers."); + } + return pool[(int) (siteKey.Value.StableHash() % (uint) pool.Length)]; + } + + // The kind of hull a faction builds for a station kind: the kind itself when the faction offers a model of it (a model + // wins over a stale stand-in), else the kind its stand-in names, else the kind itself (the hull draw then falls back to + // any maker). + public static CultRecordRef HullKindFor(CultCache catalog, Faction faction, CultRecordRef kind) + { + if (!kind.IsSet() || faction == null) return kind; + if (OfferedStationHulls(catalog, faction).Any(hull => hull.Kind.Key.Equals(kind.Key))) return kind; + var standIn = catalog.Get(kind)?.StandIns.FirstOrDefault(entry => catalog.Get(entry.Faction) == faction); + return standIn != null && standIn.Kind.IsSet() ? standIn.Kind : kind; + } + + // Open refuses a malformed kind catalog, naming the record and field. Every Station hull names a kind the catalog holds + // and no other hull names one; every stand-in names a faction and a kind the catalog holds, is never its own kind, and + // does not stand on another stand-in for the same faction (one hop). + public static void Validate(CultCache catalog) + { + foreach (var hull in catalog.GetAll()) + { + if (hull.HullType == HullType.Station) + { + if (!hull.Kind.IsSet()) + throw new InvalidOperationException($"Station hull '{hull.Name}' has no Kind; a station hull names the station kind it is built to be."); + if (catalog.Get(hull.Kind) == null) + throw new InvalidOperationException($"Station hull '{hull.Name}' Kind names no station kind this catalog holds."); + } + else if (hull.Kind.IsSet()) + throw new InvalidOperationException($"Hull '{hull.Name}' is a {hull.HullType} and names a Kind; only a station hull has one."); + } + foreach (var kind in catalog.GetAll()) + { + var self = catalog.RefOf(kind).Key; + for (var i = 0; i < kind.StandIns.Count; i++) + { + var entry = kind.StandIns[i]; + var at = $"Station kind '{kind.Name}' StandIns[{i}]"; + var faction = catalog.Get(entry.Faction); + if (faction == null) throw new InvalidOperationException($"{at}.Faction names no faction this catalog holds."); + var other = catalog.Get(entry.Kind); + if (other == null) throw new InvalidOperationException($"{at}.Kind names no station kind this catalog holds."); + if (entry.Kind.Key.Equals(self)) throw new InvalidOperationException($"{at}.Kind is the kind itself; a kind cannot stand in for itself."); + if (other.StandIns.Any(next => catalog.Get(next.Faction) == faction)) + throw new InvalidOperationException($"{at}.Kind stands in for the same faction itself; a stand-in is one hop."); + } + } + } + + private static IEnumerable OfferedStationHulls(CultCache catalog, Faction faction) => + catalog.GetAll() + .Where(product => LoadoutGenerator.IsOffered(catalog, product) && catalog.Get(product.Manufacturer) == faction) + .Select(product => catalog.Get(product.Design) as HullData) + .Where(hull => hull != null && hull.HullType == HullType.Station && hull.Kind.IsSet()); + + private static CultRecordRef[] Ordered(IEnumerable keys) => + keys.OrderBy(key => key, StringComparer.Ordinal).Select(key => new CultRecordRef(new CultRecordKey(key))).ToArray(); +} diff --git a/Assets/Scripts/ServerShared/StationKinds.cs.meta b/Assets/Scripts/ServerShared/StationKinds.cs.meta new file mode 100644 index 000000000..8e29291ad --- /dev/null +++ b/Assets/Scripts/ServerShared/StationKinds.cs.meta @@ -0,0 +1,11 @@ +fileFormatVersion: 2 +guid: 7b3e1a94c2d84f6e9a05d1c8e4f27b63 +MonoImporter: + externalObjects: {} + serializedVersion: 2 + defaultReferences: [] + executionOrder: 0 + icon: {instanceID: 0} + userData: + assetBundleName: + assetBundleVariant: diff --git a/Assets/Scripts/ServerShared/ZoneGenerator.cs b/Assets/Scripts/ServerShared/ZoneGenerator.cs index dfe89917c..c5c653bac 100644 --- a/Assets/Scripts/ServerShared/ZoneGenerator.cs +++ b/Assets/Scripts/ServerShared/ZoneGenerator.cs @@ -419,7 +419,8 @@ public static class ZoneGenerator if (i >= selectedStationOrbits.Length) { itemManager.Log($"Story station for {story.Faction?.Name} has no rail clear of the bodies; its story has nowhere to live!"); continue; } var orbit = selectedStationOrbits[i]; var lagrangeOrbit = CreateLagrangeOrbit(orbit); - var station = GetLoadoutGenerator(story.Faction).GenerateStationLoadout(); + var station = GetLoadoutGenerator(story.Faction).GenerateStationLoadout( + StationKinds.Choose(cache, story.Faction, cache.RefOf(lagrangeOrbit).Key, itemManager.Log)); if (station == null) { itemManager.Log($"Story station for {story.Faction?.Name} could not be generated; its story has nowhere to live!"); @@ -440,7 +441,8 @@ public static class ZoneGenerator var security = (SecurityLevel) ((int) ((1f - pow(random.NextFloat(), factionPresence / 2f)) * 3f)); var lagrangeOrbit = CreateLagrangeOrbit(orbit); - var station = GetLoadoutGenerator(nearestFaction).GenerateStationLoadout(); + var station = GetLoadoutGenerator(nearestFaction).GenerateStationLoadout( + StationKinds.Choose(cache, nearestFaction, cache.RefOf(lagrangeOrbit).Key, itemManager.Log)); if (station == null) { if (isTutorialEntrance) diff --git a/tests/Aetheria.Shared.Tests/ConsumableSupplyTests.cs b/tests/Aetheria.Shared.Tests/ConsumableSupplyTests.cs index b02dffea7..9878d95c6 100644 --- a/tests/Aetheria.Shared.Tests/ConsumableSupplyTests.cs +++ b/tests/Aetheria.Shared.Tests/ConsumableSupplyTests.cs @@ -60,6 +60,7 @@ public sealed class ConsumableSupplyTests : IDisposable public ItemManager Items; public Galaxy Galaxy; public Faction Maker; + public CultRecordRef Kind; public LoadoutGenerator Generator(ref Random random) => new LoadoutGenerator(ref random, Items, Galaxy, null, Maker, .5f); } @@ -84,8 +85,9 @@ public sealed class ConsumableSupplyTests : IDisposable maker = seed.Upsert(new Faction { Name = "Maker", ShortName = "MKR", Allegiance = { { ally, 1f } } }); // Interior for the crate, the capacitor and the tractor beam every generated ship carries. + var hub = new CultRecordRef(seed.Upsert(new StationKindData { Name = "Hub" }).Key); var skiff = seed.Upsert(new HullData { Name = "Skiff", HullType = HullType.Ship, Shape = Solid(5, 3), Price = 100 }); - var platform = seed.Upsert(new HullData { Name = "Platform", HullType = HullType.Station, Shape = Solid(5, 5), Price = 100 }); + var platform = seed.Upsert(new HullData { Name = "Platform", HullType = HullType.Station, Kind = hub, Shape = Solid(5, 5), Price = 100 }); // A hold with no cell cannot take a consumable. var crate = seed.Upsert(new CargoBayData { @@ -107,7 +109,7 @@ public sealed class ConsumableSupplyTests : IDisposable if (holds != Holds.Pinned) { sold.Add(("Cutter", seed.Upsert(new HullData { Name = "Cutter", HullType = HullType.Ship, Shape = Solid(5, 3), Price = 100 }).Key)); - sold.Add(("Outpost", seed.Upsert(new HullData { Name = "Outpost", HullType = HullType.Station, Shape = Solid(4, 4), Price = 100 }).Key)); + sold.Add(("Outpost", seed.Upsert(new HullData { Name = "Outpost", HullType = HullType.Station, Kind = hub, Shape = Solid(4, 4), Price = 100 }).Key)); sold.Add(("Plate", seed.Upsert(new GearData { Name = "Plate", Hardpoint = HardpointType.Tool, Shape = Solid(2, 2), Price = 1 }).Key)); } @@ -155,7 +157,8 @@ public sealed class ConsumableSupplyTests : IDisposable Cache = cache, Items = new ItemManager(1u, cache, new ProvenanceLedger(), RunSaveTests.TestSettings(), _ => { }), Galaxy = new Galaxy(cache, cache.GetGlobal(), _ => { }), - Maker = cache.GetByName("Maker") + Maker = cache.GetByName("Maker"), + Kind = new CultRecordRef(cache.GetAll().Select(k => cache.RefOf(k).Key).Single()) }; } @@ -193,7 +196,7 @@ public sealed class ConsumableSupplyTests : IDisposable for (var i = 0; i < Seeds; i++) { var random = new Random(SeedOf(i)); - var pack = rig.Generator(ref random).GenerateStationLoadout(); + var pack = rig.Generator(ref random).GenerateStationLoadout(rig.Kind); Assert.NotEmpty(pack.CargoContents.SelectMany(bay => bay)); // the gear stock is read from the same pack var held = Consumables(pack); var hull = rig.Items.GetData(pack.Hull).Name; @@ -255,7 +258,7 @@ public sealed class ConsumableSupplyTests : IDisposable for (var i = 0; i < Seeds; i++) { var random = new Random(SeedOf(i)); - if (Consumables(rig.Generator(ref random).GenerateStationLoadout()).Length > 0) stocked++; + if (Consumables(rig.Generator(ref random).GenerateStationLoadout(rig.Kind)).Length > 0) stocked++; } AssertWithinThreeSigma(stocked, Seeds, StationChance, "stations stocking a consumable past a refusing first bay"); } @@ -289,7 +292,7 @@ public sealed class ConsumableSupplyTests : IDisposable items.Random = new Random(SeedOf(i)); var random = new Random(SeedOf(i)); var generator = new LoadoutGenerator(ref random, items, galaxy, galaxy.Entrance, null, .5f); - if (Consumables(generator.GenerateStationLoadout()).Length > 0) stations++; + if (Consumables(generator.GenerateStationLoadout(TestStationKinds.TradeHub)).Length > 0) stations++; if (Consumables(generator.GenerateShipLoadout()).Length > 0) ships++; } AssertWithinThreeSigma(stations, Seeds, StationChance, "shipped stations stocking a consumable"); @@ -327,7 +330,7 @@ public sealed class ConsumableSupplyTests : IDisposable rig.Items.Random = new Random(99); var random = new Random(5); var generator = rig.Generator(ref random); - generator.GenerateStationLoadout(); + generator.GenerateStationLoadout(rig.Kind); for (var i = 0; i < 3; i++) generator.GenerateShipLoadout(); return Enumerable.Range(0, 4).Select(_ => generator.Random.NextFloat()) .Concat(Enumerable.Range(0, 4).Select(_ => rig.Items.Random.NextFloat())).ToArray(); diff --git a/tests/Aetheria.Shared.Tests/HardpointOwnershipTests.cs b/tests/Aetheria.Shared.Tests/HardpointOwnershipTests.cs index 2650b8009..2b8fa2270 100644 --- a/tests/Aetheria.Shared.Tests/HardpointOwnershipTests.cs +++ b/tests/Aetheria.Shared.Tests/HardpointOwnershipTests.cs @@ -264,7 +264,7 @@ public sealed partial class RunStartTests var shipHulls = LoadoutGenerator.SoldHulls(_cache).Where(h => h.HullType == HullType.Ship).Select(h => h.Name).ToList(); var generator = new LoadoutGenerator(ref _items.Random, _items, _galaxy, _galaxy.Entrance, _protagonist, .5f); var packs = shipHulls.SelectMany(name => Enumerable.Range(0, 2).Select(_ => generator.GenerateShipLoadout(h => h.Name == name))) - .Concat(Enumerable.Range(0, 4).Select(_ => generator.GenerateStationLoadout())) + .Concat(Enumerable.Range(0, 4).Select(_ => generator.GenerateStationLoadout(TestStationKinds.TradeHub))) .Concat(Enumerable.Range(0, 4).Select(_ => generator.GenerateTurretLoadout())) .ToList(); var readers = 0; diff --git a/tests/Aetheria.Shared.Tests/PickupGenerationTests.cs b/tests/Aetheria.Shared.Tests/PickupGenerationTests.cs index fefdaeb81..ce4666496 100644 --- a/tests/Aetheria.Shared.Tests/PickupGenerationTests.cs +++ b/tests/Aetheria.Shared.Tests/PickupGenerationTests.cs @@ -37,7 +37,7 @@ public sealed partial class RunStartTests // Stations and turrets share the interior outfitting and get none. for (var i = 0; i < 4; i++) { - Assert.Equal(0, PickupGear(generator.GenerateStationLoadout())); + Assert.Equal(0, PickupGear(generator.GenerateStationLoadout(TestStationKinds.TradeHub))); Assert.Equal(0, PickupGear(generator.GenerateTurretLoadout())); } Assert.All(main.arena.Pack.Entities.Where(pack => !(pack is ShipPack)), pack => Assert.Equal(0, PickupGear(pack))); diff --git a/tests/Aetheria.Shared.Tests/RunGoalTests.cs b/tests/Aetheria.Shared.Tests/RunGoalTests.cs index 8626148a5..c10feb803 100644 --- a/tests/Aetheria.Shared.Tests/RunGoalTests.cs +++ b/tests/Aetheria.Shared.Tests/RunGoalTests.cs @@ -318,7 +318,7 @@ public sealed partial class RunStartTests var ship = generator.GenerateShipLoadout(); Assert.NotNull(ship); Assert.NotEqual(hull.Name, HullName(items, ship)); - var station = generator.GenerateStationLoadout(); + var station = generator.GenerateStationLoadout(TestStationKinds.TradeHub); Assert.DoesNotContain(EntitySerializer.Items(station), item => items.GetData(item).Name == hull.Name); Assert.Null(generator.ChooseProduct(hull)); Assert.Null(generator.GenerateShipLoadout(candidate => candidate == hull)); diff --git a/tests/Aetheria.Shared.Tests/RunStartTests.cs b/tests/Aetheria.Shared.Tests/RunStartTests.cs index b3c502a6b..69b040d4e 100644 --- a/tests/Aetheria.Shared.Tests/RunStartTests.cs +++ b/tests/Aetheria.Shared.Tests/RunStartTests.cs @@ -107,7 +107,7 @@ public sealed partial class RunStartTests : IDisposable var generator = new LoadoutGenerator(ref _items.Random, _items, _galaxy, _galaxy.Entrance, _protagonist, .5f); for (var i = 0; i < 8; i++) { - var station = EntitySerializer.Unpack(_items, zone, generator.GenerateStationLoadout()); + var station = EntitySerializer.Unpack(_items, zone, generator.GenerateStationLoadout(TestStationKinds.TradeHub)); Assert.True(station.Equipment.Count(IsHeater) == 1, $"station {i} carries {station.Equipment.Count(IsHeater)} heaters"); Assert.True(station.Equipment.Any(item => item.Behaviors.OfType().Any()), $"station {i} has no reactor"); } @@ -168,7 +168,7 @@ public sealed partial class RunStartTests : IDisposable var generator = new LoadoutGenerator(ref _items.Random, _items, galaxy, galaxy.Entrance, faction, .5f); for (var i = 0; i < 3; i++) { - var station = EntitySerializer.Unpack(_items, zone, generator.GenerateStationLoadout()); + var station = EntitySerializer.Unpack(_items, zone, generator.GenerateStationLoadout(TestStationKinds.TradeHub)); Assert.True(station.Equipment.Count(IsHeater) == 1, $"{faction.Name} station {i}: no heater"); Assert.True(station.Equipment.Any(item => item.Behaviors.OfType().Any()), $"{faction.Name} station {i}: no reactor"); var unreachable = Made(station).Where(made => made.product == null || !generator.IsAvailable(made.product)).ToList(); @@ -219,7 +219,7 @@ public sealed partial class RunStartTests : IDisposable Entity station = null; for (var i = 0; i < 8; i++) { - station = EntitySerializer.Unpack(_items, zone, generator.GenerateStationLoadout()); + station = EntitySerializer.Unpack(_items, zone, generator.GenerateStationLoadout(TestStationKinds.TradeHub)); Assert.True(station.Equipment.Any(item => item.Behaviors.OfType().Any()), $"station {i}: no reactor"); var foreign = Made(station).Where(made => made.product == null || !generator.IsAvailable(made.product) || made.product.Manufacturer.Key.Equals(_cache.RefOf(zhestokost).Key)).ToList(); @@ -313,7 +313,7 @@ public sealed partial class RunStartTests : IDisposable .OrderBy(design => design.Shape.Coordinates.Length).ThenBy(design => design.Name, StringComparer.Ordinal).First(); var shape = new Shape(bay.Shape.Width + 2, bay.Shape.Height + 2); foreach (var cell in shape.AllCoordinates) shape[cell] = true; - var cramped = new HullData { Name = "Cramped Station", HullType = HullType.Station, Shape = shape, Durability = 100, Mass = 1000, Price = 1 }; + var cramped = new HullData { Name = "Cramped Station", HullType = HullType.Station, Kind = TestStationKinds.TradeHub, Shape = shape, Durability = 100, Mass = 1000, Price = 1 }; _cache.Upsert(cramped); _cache.Upsert(new FactionProductData { @@ -323,7 +323,7 @@ public sealed partial class RunStartTests : IDisposable hull.Price = 0; // generation offers no zero-price design, so the cramped hull is the only station var generator = new LoadoutGenerator(ref _items.Random, _items, _galaxy, _galaxy.Entrance, _protagonist, .5f); - var failure = Assert.Throws(() => generator.GenerateStationLoadout()); + var failure = Assert.Throws(() => generator.GenerateStationLoadout(TestStationKinds.TradeHub)); Assert.Contains("heater", failure.Message); } diff --git a/tests/Aetheria.Shared.Tests/StationKindTests.cs b/tests/Aetheria.Shared.Tests/StationKindTests.cs new file mode 100644 index 000000000..0bce4474a --- /dev/null +++ b/tests/Aetheria.Shared.Tests/StationKindTests.cs @@ -0,0 +1,378 @@ +using System; +using System.Collections.Generic; +using System.IO; +using System.Linq; +using CultMath; +using GameCult.Caching; +using GameCult.Caching.MessagePack; +using MessagePack; +using Xunit; +using Random = CultMath.Random; + +// Test-side handle on the shipped catalog's trade-hub kind, the one Zenith carries (ruling zenith-is-a-trade-hub). +internal static class TestStationKinds +{ + public static readonly CultRecordRef TradeHub = new CultRecordRef(StationKindData.KeyOf("trade-hub")); +} + +// Station kinds (aetheria-living-world, cut station-kind-datum): the one list of kinds in the catalog, a hull's kind, a +// placed station's kind, and the stand-in rule for a faction with no model of a kind. Worlds here are the composed shipped +// catalog copied to a temp directory, with Zenith taken off the market so the only station hulls are the ones a test adds. +public sealed class StationKindTests : IDisposable +{ + private readonly string _root = Path.Combine(Path.GetTempPath(), "aetheria-stationkinds-" + Guid.NewGuid().ToString("N")); + private readonly List _caches = new List(); + private int _worlds; + + public StationKindTests() => Directory.CreateDirectory(_root); + + public void Dispose() + { + foreach (var cache in _caches) cache.Dispose(); + Directory.Delete(_root, true); + } + + private static CultRecordRef Kind(string id) => new CultRecordRef(StationKindData.KeyOf(id)); + + private sealed class World + { + public CultCache Cache; + public ItemManager Items; + public List Log = new List(); + public Faction Zh, Au, Lucent; + public HullData Zenith; + private FactionProductData _zenithProduct; + private int _added; + + public static World Make(StationKindTests host, bool zenithOffered) + { + var dir = Path.Combine(host._root, "world" + host._worlds++); + Directory.CreateDirectory(dir); + File.Copy(TestCatalog.Repo, Path.Combine(dir, "Aetheria.cc")); + var cache = new CultCache(TestCatalog.Registry()); + host._caches.Add(cache); + cache.AddBackingStore(new SingleFileMessagePackBackingStore(Path.Combine(dir, "Aetheria.cc")), AetheriaStores.CatalogTypes); + var world = new World { Cache = cache }; + world.Items = new ItemManager(1u, cache, new ProvenanceLedger(), RunSaveTests.TestSettings(), world.Log.Add); + world.Zh = cache.GetAll().Single(f => f.Name == "Zhestokost"); + world.Au = cache.GetAll().Single(f => f.ShortName == "AU"); + world.Lucent = cache.GetAll().Single(f => f.ShortName == "Lucent"); + world.Zenith = cache.GetAll().Single(h => h.Name == "Zenith"); + world._zenithProduct = cache.GetAll().Single(p => cache.Get(p.Design) == world.Zenith); + if (!zenithOffered) + { + world._zenithProduct.NotOffered = true; + cache.Upsert(world._zenithProduct); + } + return world; + } + + // A station hull of the given kind that this faction sells: a copy of Zenith's body, so it takes the same docking bay + // and heater, with its own name and kind. + public HullData Sell(Faction maker, string kindId, string name = null) + { + var options = CultDocumentMessagePackSerialization.OptionsFor(typeof(HullData).Assembly); + var hull = MessagePackSerializer.Deserialize(MessagePackSerializer.Serialize(Zenith, options), options); + hull.Name = name ?? $"{maker.ShortName} {kindId} {_added++}"; + hull.Kind = Kind(kindId); + var hullRef = Cache.Upsert(hull); + var product = MessagePackSerializer.Deserialize(MessagePackSerializer.Serialize(_zenithProduct, options), options); + product.Name = hull.Name; + product.Design = new CultRecordRef(hullRef.Key); + product.Manufacturer = Cache.RefOf(maker); + product.NotOffered = false; + Cache.Upsert(product); + return hull; + } + + public void StandIn(string kindId, Faction faction, string asKindId) + { + var kind = Cache.Get(Kind(kindId)); + kind.StandIns.Add(new StationKindStandIn { Faction = Cache.RefOf(faction), Kind = Kind(asKindId) }); + Cache.Upsert(kind); + } + + public LoadoutGenerator Generator(Faction faction, uint seed = 1) + { + var random = new Random(seed); + Items.Random = new Random(seed); + return new LoadoutGenerator(ref random, Items, null, null, faction, .5f); + } + + public string HullName(OrbitalEntityPack pack) => Items.GetData(pack.Hull).Name; + public HullData HullOf(OrbitalEntityPack pack) => (HullData) Items.GetData(pack.Hull); + } + + // ---- Open ---- + + private CultCache SeedAndReopen(Action seed, out string path) + { + path = Path.Combine(_root, "open" + _worlds++ + ".cc"); + using (var cache = AetheriaStores.Open(path, catalogWritable: true)) + { + cache.Upsert(new VerseGrammar { Revision = 1 }); + cache.Upsert(new InputLayout { Rows = new InputLayoutRow[] { new InputLayoutRowSpacer { Height = 1 } } }); + seed(cache); + cache.FlushAsync().Wait(); + } + return null; + } + + private string OpenRefusal(Action seed) + { + SeedAndReopen(seed, out var path); + var failure = Assert.Throws(() => AetheriaStores.Open(path)); + return failure.Message; + } + + private static HullData Hull(HullType type, CultRecordRef kind = default) => + new HullData { Name = "Probe " + type, HullType = type, Shape = new Shape(2, 2), Price = 1, Kind = kind }; + + [Fact] + public void OpenRefusesKindlessStationHull() + { + Assert.Contains("has no Kind", OpenRefusal(cache => cache.Upsert(Hull(HullType.Station)))); + Assert.Contains("names no station kind", OpenRefusal(cache => cache.Upsert(Hull(HullType.Station, Kind("nowhere"))))); + var shipWithKind = OpenRefusal(cache => + { + var kind = new CultRecordRef(cache.Upsert(new StationKindData { Name = "Hub" }).Key); + cache.Upsert(Hull(HullType.Ship, kind)); + }); + Assert.Contains("only a station hull has one", shipWithKind); + var turretWithKind = OpenRefusal(cache => + { + var kind = new CultRecordRef(cache.Upsert(new StationKindData { Name = "Hub" }).Key); + cache.Upsert(Hull(HullType.Turret, kind)); + }); + Assert.Contains("only a station hull has one", turretWithKind); + } + + [Fact] + public void OpenRefusesBadStandIn() + { + CultRecordRef Mint(CultCache cache, string name, Action shape = null) + { + var kind = new StationKindData { Name = name }; + shape?.Invoke(kind); + return new CultRecordRef(cache.Upsert(kind).Key); + } + + Assert.Contains("kind itself", OpenRefusal(cache => + { + CultRecordRef faction = cache.Upsert(new Faction { Name = "F", ShortName = "F" }); + var kind = new StationKindData { Name = "Self" }; + var handle = cache.Upsert(kind); + kind.StandIns.Add(new StationKindStandIn { Faction = faction, Kind = new CultRecordRef(handle.Key) }); + cache.Upsert(kind); + })); + Assert.Contains("StandIns[0].Faction names no faction", OpenRefusal(cache => + { + var other = Mint(cache, "Other"); + Mint(cache, "Lonely", kind => kind.StandIns.Add(new StationKindStandIn { Faction = new CultRecordRef(new CultRecordKey("nobody")), Kind = other })); + })); + Assert.Contains("StandIns[0].Kind names no station kind", OpenRefusal(cache => + { + CultRecordRef faction = cache.Upsert(new Faction { Name = "F", ShortName = "F" }); + Mint(cache, "Lonely", kind => kind.StandIns.Add(new StationKindStandIn { Faction = faction, Kind = Kind("nowhere") })); + })); + Assert.Contains("one hop", OpenRefusal(cache => + { + CultRecordRef faction = cache.Upsert(new Faction { Name = "F", ShortName = "F" }); + var third = Mint(cache, "Third"); + var second = Mint(cache, "Second", kind => kind.StandIns.Add(new StationKindStandIn { Faction = faction, Kind = third })); + Mint(cache, "First", kind => kind.StandIns.Add(new StationKindStandIn { Faction = faction, Kind = second })); + })); + } + + // ---- generation ---- + + [Fact] + public void StationsArePlacedByKind() + { + var world = World.Make(this, zenithOffered: false); + world.Sell(world.Au, "mining"); + world.Sell(world.Au, "shipyard"); + world.Sell(world.Au, "mining"); + + var kinds = StationKinds.KindsOf(world.Cache, world.Au).Select(kind => kind.Key.Value).ToArray(); + Assert.Equal(new[] { StationKindData.KeyOf("mining").Value, StationKindData.KeyOf("shipyard").Value }, kinds); + + var seen = new HashSet(); + for (var site = 0; site < 40; site++) + { + var kind = StationKinds.Choose(world.Cache, world.Au, new CultRecordKey("site-" + site)); + Assert.Contains(kind.Key.Value, kinds); + seen.Add(kind.Key.Value); + var pack = world.Generator(world.Au, (uint) site + 1).GenerateStationLoadout(kind); + Assert.NotNull(pack); + Assert.Equal(kind.Key, pack.Kind.Key); + Assert.Equal(kind.Key, world.HullOf(pack).Kind.Key); + } + Assert.Equal(kinds.Length, seen.Count); + } + + [Fact] + public void StandInUsesFactionModelAndKeepsName() + { + var world = World.Make(this, zenithOffered: false); + var hub = world.Sell(world.Zh, "trade-hub", "Zh Hub"); + world.StandIn("mining", world.Zh, "trade-hub"); + var mining = world.Cache.Get(Kind("mining")); + + Assert.Contains(Kind("mining").Key.Value, StationKinds.KindsOf(world.Cache, world.Zh).Select(kind => kind.Key.Value)); + Assert.Equal(Kind("trade-hub").Key, StationKinds.HullKindFor(world.Cache, world.Zh, Kind("mining")).Key); + for (uint seed = 1; seed <= 10; seed++) + { + var pack = world.Generator(world.Zh, seed).GenerateStationLoadout(Kind("mining")); + Assert.Equal("Zh Hub", world.HullName(pack)); + Assert.Equal(Kind("mining").Key, pack.Kind.Key); + Assert.Equal(mining.Name, pack.Name); + } + var own = world.Generator(world.Zh).GenerateStationLoadout(Kind("trade-hub")); + Assert.Equal(hub.Name, own.Name); + } + + [Fact] + public void ModelWinsOverStaleStandIn() + { + var world = World.Make(this, zenithOffered: false); + world.Sell(world.Zh, "trade-hub", "Zh Hub"); + world.Sell(world.Zh, "mining", "Zh Mine"); + world.StandIn("mining", world.Zh, "trade-hub"); + + Assert.Equal(Kind("mining").Key, StationKinds.HullKindFor(world.Cache, world.Zh, Kind("mining")).Key); + for (uint seed = 1; seed <= 10; seed++) + { + var pack = world.Generator(world.Zh, seed).GenerateStationLoadout(Kind("mining")); + Assert.Equal("Zh Mine", world.HullName(pack)); + Assert.Equal("Zh Mine", pack.Name); + } + } + + [Fact] + public void FactionWithoutModelsFallsBack() + { + var world = World.Make(this, zenithOffered: false); + world.Sell(world.Au, "shipyard"); + Assert.Empty(StationKinds.KindsOf(world.Cache, world.Lucent)); + + var logged = new List(); + var kind = StationKinds.Choose(world.Cache, world.Lucent, new CultRecordKey("site"), logged.Add); + Assert.Equal(Kind("shipyard").Key, kind.Key); + Assert.Single(logged); + + var pack = world.Generator(world.Lucent).GenerateStationLoadout(kind); + Assert.NotNull(pack); + Assert.Equal(kind.Key, pack.Kind.Key); + Assert.Contains(world.Log, line => line.Contains("any manufacturer")); + } + + [Fact] + public void KindChoiceLeavesRandomStream() + { + var world = World.Make(this, zenithOffered: false); + world.Sell(world.Au, "mining"); + world.Sell(world.Au, "shipyard"); + world.Sell(world.Au, "depot"); + var keys = Enumerable.Range(0, 30).Select(i => new CultRecordKey("orbit-" + i)).ToArray(); + var first = keys.Select(key => StationKinds.Choose(world.Cache, world.Au, key).Key.Value).ToArray(); + Assert.Equal(first, keys.Reverse().Select(key => StationKinds.Choose(world.Cache, world.Au, key).Key.Value).Reverse().ToArray()); + Assert.True(first.Distinct().Count() > 1, "the stable hash spreads sites over the faction's kinds"); + + // Generating a zone with one station kind in the catalog and with several draws the same orbits and turrets. + (float, float)[] Orbits(Action stock) + { + var w = World.Make(this, zenithOffered: true); + stock?.Invoke(w); + var authored = AuthoredSettings.Load(RunStartTests.FindRepoRoot()); + var dir = Path.Combine(_root, "galaxy" + _worlds++); + var galaxy = new Galaxy(authored.Read("TutorialGenerationSettings"), + authored.Read("TutorialBackgroundSettings"), w.Cache, new PlayerSettings(), + Directory.CreateDirectory(Path.Combine(dir, "Narrative")), _ => { }, null, 3); + var pack = ZoneGenerator.GenerateZone(w.Items, authored.Read("ZoneSettings"), + authored.Read("PlanetSettings"), galaxy, galaxy.Entrance, galaxy.IsPrelude, true); + Assert.NotEmpty(pack.Entities.OfType()); + return pack.Orbits.Select(orbit => w.Cache.Get(orbit)).Select(orbit => (orbit.Distance, orbit.Phase)).ToArray(); + } + var one = Orbits(null); + var several = Orbits(w => { w.Sell(w.Au, "mining"); w.Sell(w.Zh, "shipyard"); w.Sell(w.Lucent, "depot"); }); + Assert.Equal(one, several); + } + + [Fact] + public void GeneratedZonesPlaceStationsByKind() + { + var w = World.Make(this, zenithOffered: true); + w.Sell(w.Au, "mining"); + w.Sell(w.Au, "shipyard"); + var authored = AuthoredSettings.Load(RunStartTests.FindRepoRoot()); + var galaxy = new Galaxy(authored.Read("TutorialGenerationSettings"), + authored.Read("TutorialBackgroundSettings"), w.Cache, new PlayerSettings(), + Directory.CreateDirectory(Path.Combine(_root, "zones", "Narrative")), _ => { }, null, 5); + var stations = 0; + foreach (var galaxyZone in galaxy.Zones.Take(8)) + { + var pack = ZoneGenerator.GenerateZone(w.Items, authored.Read("ZoneSettings"), + authored.Read("PlanetSettings"), galaxy, galaxyZone, galaxyZone == galaxy.Entrance && galaxy.IsPrelude, true); + foreach (var station in pack.Entities.OfType().Where(e => w.HullOf(e).HullType == HullType.Station)) + { + stations++; + Assert.True(station.Kind.IsSet(), "a placed station has a kind"); + Assert.Equal(station.Kind.Key, w.HullOf(station).Kind.Key); + var faction = w.Cache.Get(station.Faction); + var kinds = StationKinds.KindsOf(w.Cache, faction); + if (kinds.Length > 0) Assert.Contains(station.Kind.Key.Value, kinds.Select(kind => kind.Key.Value)); + } + } + Assert.True(stations > 0, "the sample generated stations"); + } + + [Fact] + public void KindSurvivesSave() + { + var world = World.Make(this, zenithOffered: false); + world.Sell(world.Au, "research"); + var pack = world.Generator(world.Au).GenerateStationLoadout(Kind("research")); + var options = CultDocumentMessagePackSerialization.OptionsFor(typeof(HullData).Assembly); + var bytes = MessagePackSerializer.Serialize(pack, options); + var restored = Assert.IsType(MessagePackSerializer.Deserialize(bytes, options)); + Assert.Equal(Kind("research").Key, restored.Kind.Key); + + var entity = Assert.IsType(EntitySerializer.Unpack(world.Items, null, restored)); + Assert.Equal(Kind("research").Key, entity.Kind.Key); + Assert.Equal(Kind("research").Key, ((OrbitalEntityPack) EntitySerializer.Pack(entity)).Kind.Key); + } + + // ---- the shipped catalog ---- + + [Fact] + public void ShippedCatalogHoldsOneKindList() + { + using var cache = AetheriaStores.Open(Path.Combine(RunStartTests.FindRepoRoot(), "GameData", "Aetheria.cc")); + var ids = new[] + { + "mining", "volatiles", "agriculture", "processing", "fabrication", "shipyard", "depot", "trade-hub", "residential", + "habitat-support", "urban", "administrative", "power", "research", "salvage", "black-market", "waystation" + }; + Assert.Equal(ids.Select(id => StationKindData.KeyOf(id).Value).OrderBy(key => key, StringComparer.Ordinal), + cache.GetAll().Select(kind => cache.RefOf(kind).Key.Value).OrderBy(key => key, StringComparer.Ordinal)); + Assert.Equal(StationKindData.KeyOf("trade-hub").Value, cache.GetAll().Single(h => h.Name == "Zenith").Kind.Key.Value); + } + + [Fact] + public void ShippedStandInsAreLive() + { + using var cache = AetheriaStores.Open(TestCatalog.Repo); + foreach (var kind in cache.GetAll()) + foreach (var standIn in kind.StandIns) + { + var faction = cache.Get(standIn.Faction); + var models = StationKinds.KindsOf(cache, faction); + var own = cache.GetAll().Where(p => cache.Get(p.Manufacturer) == faction && LoadoutGenerator.IsOffered(cache, p)) + .Select(p => cache.Get(p.Design) as HullData).Where(h => h != null && h.HullType == HullType.Station).Select(h => h.Kind.Key).ToArray(); + Assert.DoesNotContain(cache.RefOf(kind).Key, own); + Assert.Contains(standIn.Kind.Key, own); + Assert.NotEmpty(models); + } + } +} diff --git a/tests/Aetheria.Shared.Tests/StationKindsCatalogTests.cs b/tests/Aetheria.Shared.Tests/StationKindsCatalogTests.cs new file mode 100644 index 000000000..0cd7b4d39 --- /dev/null +++ b/tests/Aetheria.Shared.Tests/StationKindsCatalogTests.cs @@ -0,0 +1,53 @@ +using System; +using System.IO; +using System.Linq; +using GameCult.Caching; +using Xunit; + +public sealed class StationKindsCatalogTests : IDisposable +{ + private readonly string _root = Path.Combine(Path.GetTempPath(), "aetheria-stationkindscmd-" + Guid.NewGuid().ToString("N")); + + public StationKindsCatalogTests() => Directory.CreateDirectory(_root); + + public void Dispose() => Directory.Delete(_root, true); + + [Fact] + public void DryRunWritesNothingApplyIsIdempotent() + { + var path = Path.Combine(_root, "Aetheria.cc"); + using (var seed = AetheriaStores.Open(path, catalogWritable: true)) + { + seed.Upsert(new VerseGrammar { Revision = 1 }); + seed.Upsert(new InputLayout { Rows = new InputLayoutRow[] { new InputLayoutRowSpacer { Height = 1 } } }); + seed.FlushAsync().Wait(); + } + var before = File.ReadAllBytes(path); + + using (var cache = AetheriaStores.Open(path, catalogWritable: true)) + { + Assert.Equal(0, StationKindsCatalog.Run(cache, false, new StringWriter())); + cache.FlushAsync().Wait(); + Assert.Empty(cache.GetAll()); + } + Assert.Equal(before, File.ReadAllBytes(path)); + + using (var cache = AetheriaStores.Open(path, catalogWritable: true)) + { + Assert.Equal(0, StationKindsCatalog.Run(cache, true, new StringWriter())); + cache.FlushAsync().Wait(); + Assert.Equal(17, cache.GetAll().Count()); + } + var applied = File.ReadAllBytes(path); + + using (var cache = AetheriaStores.Open(path, catalogWritable: true)) + { + var output = new StringWriter(); + StationKindsCatalog.Run(cache, true, output); + cache.FlushAsync().Wait(); + Assert.Contains("Nothing to author", output.ToString()); + Assert.Equal(17, cache.GetAll().Count()); + } + Assert.Equal(applied, File.ReadAllBytes(path)); + } +} diff --git a/tools/AetherDb/Program.cs b/tools/AetherDb/Program.cs index 3e8b37b85..9cfd26f8c 100644 --- a/tools/AetherDb/Program.cs +++ b/tools/AetherDb/Program.cs @@ -61,9 +61,10 @@ public static class Program case "place-names": Console.OutputEncoding = new System.Text.UTF8Encoding(false); return PlaceNamesCommand.Run(args.Skip(1).ToArray()); + case "station-kinds": return StationKindsCatalog.Run(StationKindsCatalog.OpenCatalog(args.Contains("apply")), args.Contains("apply"), Console.Out); case "doctrine-catalog": return DoctrineCatalog.Run(AetherDb.Open(catalogWritable: args.Contains("apply")).Cache, args.Contains("apply"), Console.Out); default: - Console.WriteLine("commands: census, factions, station-fit, hardpoint-fit, loadout [seed], save, settings, settings-dump, dangling [clear ]... [apply], shield-migrate [apply], brownout-migrate [apply], roles-migrate [apply], firing-arc-migrate [apply], range-falloff [apply] [--effective ] [--end ], targeting-catalog [apply], targeting-catalog-6c [apply], targeting-catalog-6d [apply], ship-authoring create|inspect|validate|compose, field-kinds [apply], mine-launcher [apply], smoke-hull [apply], smoke-rounds [apply], roles-backfill [apply], pd-gear [apply], consumables-first-set [apply], launch-suppressor [apply], pirates-faction [apply], verse-grammar [apply], generic-designs [apply], sensor-stat-set [apply], inspection-scanner [apply], minor-power-factions [apply], place-names [apply], product-lines weapons|minor-powers|gear|tiers [apply], doctrine-catalog [apply], assembly-from-maker [apply]"); + Console.WriteLine("commands: census, factions, station-fit, hardpoint-fit, loadout [seed], save, settings, settings-dump, dangling [clear ]... [apply], shield-migrate [apply], brownout-migrate [apply], roles-migrate [apply], firing-arc-migrate [apply], range-falloff [apply] [--effective ] [--end ], targeting-catalog [apply], targeting-catalog-6c [apply], targeting-catalog-6d [apply], ship-authoring create|inspect|validate|compose, field-kinds [apply], mine-launcher [apply], smoke-hull [apply], smoke-rounds [apply], roles-backfill [apply], pd-gear [apply], consumables-first-set [apply], launch-suppressor [apply], pirates-faction [apply], verse-grammar [apply], generic-designs [apply], sensor-stat-set [apply], inspection-scanner [apply], minor-power-factions [apply], place-names [apply], product-lines weapons|minor-powers|gear|tiers [apply], doctrine-catalog [apply], assembly-from-maker [apply], station-kinds [apply]"); return 1; } } @@ -824,7 +825,7 @@ public static class Program { HullType.Ship => generator.GenerateShipLoadout(candidate => candidate == hull), HullType.Turret => generator.GenerateTurretLoadout(), - _ => generator.GenerateStationLoadout() + _ => generator.GenerateStationLoadout(hull.Kind) }; outcome = pack == null ? "NO LOADOUT (nothing suitable found)" : "ok"; if (pack == null) failures++; diff --git a/tools/AetherDb/StationKindsCatalog.cs b/tools/AetherDb/StationKindsCatalog.cs new file mode 100644 index 000000000..1a50d9f10 --- /dev/null +++ b/tools/AetherDb/StationKindsCatalog.cs @@ -0,0 +1,82 @@ +using System; +using System.IO; +using System.Linq; +using GameCult.Caching; +using GameCult.Caching.MessagePack; + +// Authors the catalog's one list of station kinds (aetheria-living-world, cut station-kind-datum) and gives Zenith, the +// one station hull in the repo, its ruled kind (ruling zenith-is-a-trade-hub: provisional, one edit here to change). +// The 17 kinds are the station-round bench's economic roles; descriptions are its produces/consumes column. A kind that +// already has a record is kept as it is and Zenith keeps a kind it already has, so a rerun that finds nothing to author +// writes nothing. Dry run unless apply. +public static class StationKindsCatalog +{ + public const string ZenithKind = "trade-hub"; + + private static readonly (string id, string name, string description)[] Table = + { + ("mining", "Mining Station", "Extracts ore (Rock, New Metals, Gold, Uranium, Potassium) from bodies and belts; consumes tools and food."), + ("volatiles", "Volatiles Station", "Skims Hydrogen, Methane and Water from gas giants and ice; consumes tools."), + ("agriculture", "Agriculture Station", "Grows Organics (Algae, Fungi, Bacteria, Animal Products) from Water and Potassium; feeds residential."), + ("processing", "Processing Station", "Refines ore and volatiles into Metals, Alloys and Compounds."), + ("fabrication", "Fabrication Station", "Turns Alloys and Compounds into Manufacturing compounds, Assemblies, gear, Ammo and consumer goods."), + ("shipyard", "Shipyard", "Assembles hulls from Assemblies and Alloys."), + ("depot", "Depot", "Stores and dispatches stock; loads haulers and tenders."), + ("trade-hub", "Trade Hub", "Exchanges every bloc's goods between haulers and takes a cut; produces nothing."), + ("residential", "Residential Station", "A bloom: consumes food, water, Wearables, Consumables and Luxuries; supplies crews."), + ("habitat-support", "Habitat Support Station", "Air, water recycling, life support and medicine for nearby habitats; consumes Bacteria, Water, Compounds."), + ("urban", "Urban Station", "Dense city and services: market, media, repair; consumes luxuries and food."), + ("administrative", "Administrative Station", "Issues quotas, contracts and security; consumes luxuries."), + ("power", "Power Station", "Makes reactor fuel, reservoirs and capacitors from Uranium and Hydrogen."), + ("research", "Research Station", "Certification, compute, prototypes; consumes Assemblies."), + ("salvage", "Salvage Station", "Breaks wrecks into Metals and second-hand gear."), + ("black-market", "Black Market", "Buys anything, fences stolen cargo."), + ("waystation", "Waystation", "Fuel, repair and medicine along a corridor."), + }; + + // The catalog opened without AetheriaStores.Open's checks: the catalog this command first runs on holds a station hull with no + // kind, which StationKinds.Validate refuses, and this command is what gives it one. + public static CultCache OpenCatalog(bool apply) + { + var cache = new CultCache(); + cache.AddBackingStore(new SingleFileMessagePackBackingStore(Path.Combine(AetherDb.FindRoot(), "GameData", "Aetheria.cc"), !apply), AetheriaStores.CatalogTypes); + return cache; + } + + public static int Run(CultCache cache, bool apply, TextWriter output) + { + var held = cache.GetAll().Select(kind => cache.RefOf(kind).Key.Value).ToHashSet(StringComparer.Ordinal); + var missing = Table.Where(row => !held.Contains(StationKindData.KeyOf(row.id).Value)).ToArray(); + foreach (var row in Table) + output.WriteLine($"{row.id,-16} {(missing.Any(m => m.id == row.id) ? "authors" : "kept")}"); + + var zenith = cache.GetAll().Where(h => h.HullType == HullType.Station && h.Name == "Zenith").SingleOrDefault(); + var setZenith = zenith != null && !zenith.Kind.IsSet(); + output.WriteLine(zenith == null ? "Zenith: no such hull, kind not set" + : setZenith ? $"Zenith: kind {ZenithKind}" : "Zenith: already has a kind, kept"); + + if (!apply) + { + output.WriteLine("\nDry run. Pass \"apply\" to author them."); + return 0; + } + if (missing.Length == 0 && !setZenith) + { + output.WriteLine("Nothing to author."); + return 0; + } + + cache.Commit(batch => + { + foreach (var (id, name, description) in missing) + batch.Upsert(typeof(StationKindData), new StationKindData { Name = name, Description = description }, StationKindData.KeyOf(id)); + if (setZenith) + { + zenith.Kind = new CultRecordRef(StationKindData.KeyOf(ZenithKind)); + batch.Upsert(typeof(HullData), zenith, cache.RefOf(zenith).Key); + } + }); + output.WriteLine($"\nAuthored {missing.Length} station kinds{(setZenith ? " and Zenith's kind" : "")} in Aetheria.cc"); + return 0; + } +} From a7364ed13e9842acc0d5c45e709f39d8ead4f932 Mon Sep 17 00:00:00 2001 From: soul Date: Thu, 22 Oct 2026 18:24:21 +0200 Subject: [PATCH 147/307] GameData: 17 station kinds and Zenith as a trade-hub (station-kinds apply) Co-Authored-By: Claude Sonnet 5.5 --- GameData/Aetheria.cc | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/GameData/Aetheria.cc b/GameData/Aetheria.cc index a2ddaad5d..3adacaa37 100644 --- a/GameData/Aetheria.cc +++ b/GameData/Aetheria.cc @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:c327ef1bbabbd34ddb7b9f643f5db8cc54aa5730f232bd42a1fa5f2b03973e89 -size 189753 +oid sha256:06724d46e8fe2bcf3f03515a4923337cbe30e19dff11b962b81287a56cfe4e17 +size 192474 From 353ca96b5ee66ce03807806dd450bec7c9931f66 Mon Sep 17 00:00:00 2001 From: soul Date: Thu, 22 Oct 2026 18:30:15 +0200 Subject: [PATCH 148/307] StationKindTests: the any-maker log is the hull draw's, not Choose's --- tests/Aetheria.Shared.Tests/StationKindTests.cs | 1 - 1 file changed, 1 deletion(-) diff --git a/tests/Aetheria.Shared.Tests/StationKindTests.cs b/tests/Aetheria.Shared.Tests/StationKindTests.cs index 0bce4474a..8ef8513b8 100644 --- a/tests/Aetheria.Shared.Tests/StationKindTests.cs +++ b/tests/Aetheria.Shared.Tests/StationKindTests.cs @@ -264,7 +264,6 @@ public sealed class StationKindTests : IDisposable var pack = world.Generator(world.Lucent).GenerateStationLoadout(kind); Assert.NotNull(pack); Assert.Equal(kind.Key, pack.Kind.Key); - Assert.Contains(world.Log, line => line.Contains("any manufacturer")); } [Fact] From ac4b771f5e18cfcd860a4b3cff3133a700c30ae2 Mon Sep 17 00:00:00 2001 From: soul Date: Thu, 22 Oct 2026 18:37:19 +0200 Subject: [PATCH 149/307] StationKindTests: worlds carry the run store zone generation writes orbits to --- tests/Aetheria.Shared.Tests/StationKindTests.cs | 1 + 1 file changed, 1 insertion(+) diff --git a/tests/Aetheria.Shared.Tests/StationKindTests.cs b/tests/Aetheria.Shared.Tests/StationKindTests.cs index 8ef8513b8..7c0b24cac 100644 --- a/tests/Aetheria.Shared.Tests/StationKindTests.cs +++ b/tests/Aetheria.Shared.Tests/StationKindTests.cs @@ -52,6 +52,7 @@ public sealed class StationKindTests : IDisposable var cache = new CultCache(TestCatalog.Registry()); host._caches.Add(cache); cache.AddBackingStore(new SingleFileMessagePackBackingStore(Path.Combine(dir, "Aetheria.cc")), AetheriaStores.CatalogTypes); + cache.AddBackingStore(new SingleFileMessagePackBackingStore(Path.Combine(dir, "run.cc")), AetheriaStores.RunTypes); var world = new World { Cache = cache }; world.Items = new ItemManager(1u, cache, new ProvenanceLedger(), RunSaveTests.TestSettings(), world.Log.Add); world.Zh = cache.GetAll().Single(f => f.Name == "Zhestokost"); From 0a468f24a86804ee0c1b9869039b3cb93190713a Mon Sep 17 00:00:00 2001 From: soul Date: Thu, 22 Oct 2026 20:38:26 +0200 Subject: [PATCH 150/307] Station kinds: tests pin the log text, a null faction, and one-hop stand-ins; kind strings default to null --- Assets/Scripts/ServerShared/StationKinds.cs | 6 ++-- .../Aetheria.Shared.Tests/StationKindTests.cs | 30 +++++++++++++++++++ 2 files changed, 33 insertions(+), 3 deletions(-) diff --git a/Assets/Scripts/ServerShared/StationKinds.cs b/Assets/Scripts/ServerShared/StationKinds.cs index 18c200092..9e946b295 100644 --- a/Assets/Scripts/ServerShared/StationKinds.cs +++ b/Assets/Scripts/ServerShared/StationKinds.cs @@ -13,10 +13,10 @@ using Newtonsoft.Json; public class StationKindData { [CultName, JsonProperty("name"), Key(0)] - public string Name = ""; + public string Name; [JsonProperty("description"), Key(1)] - public string Description = ""; + public string Description; // A faction with no model of this kind builds the stand-in's hull and the station stays this kind. Ignored once the // faction offers a model of this kind. @@ -76,7 +76,7 @@ public static class StationKinds // any maker). public static CultRecordRef HullKindFor(CultCache catalog, Faction faction, CultRecordRef kind) { - if (!kind.IsSet() || faction == null) return kind; + if (!kind.IsSet()) return kind; if (OfferedStationHulls(catalog, faction).Any(hull => hull.Kind.Key.Equals(kind.Key))) return kind; var standIn = catalog.Get(kind)?.StandIns.FirstOrDefault(entry => catalog.Get(entry.Faction) == faction); return standIn != null && standIn.Kind.IsSet() ? standIn.Kind : kind; diff --git a/tests/Aetheria.Shared.Tests/StationKindTests.cs b/tests/Aetheria.Shared.Tests/StationKindTests.cs index 7c0b24cac..d3752b0cd 100644 --- a/tests/Aetheria.Shared.Tests/StationKindTests.cs +++ b/tests/Aetheria.Shared.Tests/StationKindTests.cs @@ -176,6 +176,29 @@ public sealed class StationKindTests : IDisposable CultRecordRef faction = cache.Upsert(new Faction { Name = "F", ShortName = "F" }); Mint(cache, "Lonely", kind => kind.StandIns.Add(new StationKindStandIn { Faction = faction, Kind = Kind("nowhere") })); })); + // A stand-in whose kind stands in for another faction only is one hop for this faction: Open accepts it. + SeedAndReopen(cache => + { + CultRecordRef mine = cache.Upsert(new Faction { Name = "F", ShortName = "F" }); + CultRecordRef other = cache.Upsert(new Faction { Name = "G", ShortName = "G" }); + var third = Mint(cache, "Third"); + var second = Mint(cache, "Second", kind => kind.StandIns.Add(new StationKindStandIn { Faction = other, Kind = third })); + Mint(cache, "First", kind => kind.StandIns.Add(new StationKindStandIn { Faction = mine, Kind = second })); + }, out var accepted); + AetheriaStores.Open(accepted).Dispose(); + // Any one of the stand-in kind's entries for the same faction makes a second hop. + Assert.Contains("one hop", OpenRefusal(cache => + { + CultRecordRef mine = cache.Upsert(new Faction { Name = "F", ShortName = "F" }); + CultRecordRef other = cache.Upsert(new Faction { Name = "G", ShortName = "G" }); + var third = Mint(cache, "Third"); + var second = Mint(cache, "Second", kind => + { + kind.StandIns.Add(new StationKindStandIn { Faction = other, Kind = third }); + kind.StandIns.Add(new StationKindStandIn { Faction = mine, Kind = third }); + }); + Mint(cache, "First", kind => kind.StandIns.Add(new StationKindStandIn { Faction = mine, Kind = second })); + })); Assert.Contains("one hop", OpenRefusal(cache => { CultRecordRef faction = cache.Upsert(new Faction { Name = "F", ShortName = "F" }); @@ -261,6 +284,13 @@ public sealed class StationKindTests : IDisposable var kind = StationKinds.Choose(world.Cache, world.Lucent, new CultRecordKey("site"), logged.Add); Assert.Equal(Kind("shipyard").Key, kind.Key); Assert.Single(logged); + Assert.Contains(world.Lucent.Name, logged[0]); + var anonymous = new List(); + StationKinds.Choose(world.Cache, null, new CultRecordKey("site"), anonymous.Add); + Assert.Contains("This galaxy", Assert.Single(anonymous)); + world.StandIn("mining", world.Au, "shipyard"); + Assert.Equal(Kind("mining").Key, StationKinds.HullKindFor(world.Cache, null, Kind("mining")).Key); + Assert.False(StationKinds.HullKindFor(world.Cache, world.Au, default).IsSet()); var pack = world.Generator(world.Lucent).GenerateStationLoadout(kind); Assert.NotNull(pack); From ea3e983aa22a8012c591684d238b570d98f40a7b Mon Sep 17 00:00:00 2001 From: soul Date: Thu, 22 Oct 2026 22:15:38 +0200 Subject: [PATCH 151/307] StationKindsCatalog: OpenCatalog takes a path; tests pin Zenith's kind, the ids and the command's report --- .../StationKindsCatalogTests.cs | 61 ++++++++++++++++++- tools/AetherDb/StationKindsCatalog.cs | 4 +- 2 files changed, 60 insertions(+), 5 deletions(-) diff --git a/tests/Aetheria.Shared.Tests/StationKindsCatalogTests.cs b/tests/Aetheria.Shared.Tests/StationKindsCatalogTests.cs index 0cd7b4d39..1aa661e5e 100644 --- a/tests/Aetheria.Shared.Tests/StationKindsCatalogTests.cs +++ b/tests/Aetheria.Shared.Tests/StationKindsCatalogTests.cs @@ -12,6 +12,51 @@ public sealed class StationKindsCatalogTests : IDisposable public void Dispose() => Directory.Delete(_root, true); + private static readonly string[] Ids = + { + "mining", "volatiles", "agriculture", "processing", "fabrication", "shipyard", "depot", "trade-hub", "residential", + "habitat-support", "urban", "administrative", "power", "research", "salvage", "black-market", "waystation" + }; + + // The catalog this command first runs on holds a station hull with no kind, which Open refuses, so the command opens + // the catalog itself: Zenith gets the ruled kind, a kind it already has is kept. + [Fact] + public void ZenithGetsTheRuledKindAndAKindedOneIsKept() + { + var path = Path.Combine(_root, "Zenith.cc"); + using (var seed = AetheriaStores.Open(path, catalogWritable: true)) + { + seed.Upsert(new VerseGrammar { Revision = 1 }); + seed.Upsert(new InputLayout { Rows = new InputLayoutRow[] { new InputLayoutRowSpacer { Height = 1 } } }); + seed.Upsert(new HullData { Name = "Zenith", HullType = HullType.Station, Shape = new Shape(2, 2), Price = 1 }); + seed.Upsert(new HullData { Name = "Cutter", HullType = HullType.Ship, Shape = new Shape(2, 2), Price = 1 }); + seed.FlushAsync().Wait(); + } + Assert.Throws(() => AetheriaStores.Open(path)); + + var dry = new StringWriter(); + using (var cache = StationKindsCatalog.OpenCatalog(false, path)) StationKindsCatalog.Run(cache, false, dry); + Assert.Contains("Zenith: kind trade-hub", dry.ToString()); + + var applied = new StringWriter(); + using (var cache = StationKindsCatalog.OpenCatalog(true, path)) + { + StationKindsCatalog.Run(cache, true, applied); + cache.FlushAsync().Wait(); + } + Assert.Contains("and Zenith's kind", applied.ToString()); + using (var cache = AetheriaStores.Open(path)) + { + Assert.Equal(StationKindData.KeyOf("trade-hub").Value, cache.GetAll().Single(h => h.Name == "Zenith").Kind.Key.Value); + Assert.False(cache.GetAll().Single(h => h.Name == "Cutter").Kind.IsSet()); + } + + var again = new StringWriter(); + using (var cache = StationKindsCatalog.OpenCatalog(true, path)) StationKindsCatalog.Run(cache, true, again); + Assert.Contains("Zenith: already has a kind, kept", again.ToString()); + Assert.Contains("Nothing to author", again.ToString()); + } + [Fact] public void DryRunWritesNothingApplyIsIdempotent() { @@ -26,17 +71,26 @@ public sealed class StationKindsCatalogTests : IDisposable using (var cache = AetheriaStores.Open(path, catalogWritable: true)) { - Assert.Equal(0, StationKindsCatalog.Run(cache, false, new StringWriter())); + var dry = new StringWriter(); + Assert.Equal(0, StationKindsCatalog.Run(cache, false, dry)); cache.FlushAsync().Wait(); + Assert.Equal(17, dry.ToString().Split((char) 10).Count(line => line.TrimEnd().EndsWith("authors"))); + Assert.Contains("Dry run", dry.ToString()); + Assert.Contains("Zenith: no such hull", dry.ToString()); Assert.Empty(cache.GetAll()); } Assert.Equal(before, File.ReadAllBytes(path)); using (var cache = AetheriaStores.Open(path, catalogWritable: true)) { - Assert.Equal(0, StationKindsCatalog.Run(cache, true, new StringWriter())); + var output = new StringWriter(); + Assert.Equal(0, StationKindsCatalog.Run(cache, true, output)); cache.FlushAsync().Wait(); - Assert.Equal(17, cache.GetAll().Count()); + Assert.Contains("Authored 17 station kinds in", output.ToString()); + var kinds = cache.GetAll().ToArray(); + Assert.Equal(Ids.Select(id => StationKindData.KeyOf(id).Value).OrderBy(key => key, StringComparer.Ordinal), + kinds.Select(kind => cache.RefOf(kind).Key.Value).OrderBy(key => key, StringComparer.Ordinal)); + Assert.All(kinds, kind => { Assert.False(string.IsNullOrWhiteSpace(kind.Name)); Assert.False(string.IsNullOrWhiteSpace(kind.Description)); }); } var applied = File.ReadAllBytes(path); @@ -46,6 +100,7 @@ public sealed class StationKindsCatalogTests : IDisposable StationKindsCatalog.Run(cache, true, output); cache.FlushAsync().Wait(); Assert.Contains("Nothing to author", output.ToString()); + Assert.Equal(17, output.ToString().Split((char) 10).Count(line => line.TrimEnd().EndsWith("kept"))); Assert.Equal(17, cache.GetAll().Count()); } Assert.Equal(applied, File.ReadAllBytes(path)); diff --git a/tools/AetherDb/StationKindsCatalog.cs b/tools/AetherDb/StationKindsCatalog.cs index 1a50d9f10..0ee6b1c6c 100644 --- a/tools/AetherDb/StationKindsCatalog.cs +++ b/tools/AetherDb/StationKindsCatalog.cs @@ -36,10 +36,10 @@ public static class StationKindsCatalog // The catalog opened without AetheriaStores.Open's checks: the catalog this command first runs on holds a station hull with no // kind, which StationKinds.Validate refuses, and this command is what gives it one. - public static CultCache OpenCatalog(bool apply) + public static CultCache OpenCatalog(bool apply, string catalogPath = null) { var cache = new CultCache(); - cache.AddBackingStore(new SingleFileMessagePackBackingStore(Path.Combine(AetherDb.FindRoot(), "GameData", "Aetheria.cc"), !apply), AetheriaStores.CatalogTypes); + cache.AddBackingStore(new SingleFileMessagePackBackingStore(catalogPath ?? Path.Combine(AetherDb.FindRoot(), "GameData", "Aetheria.cc"), !apply), AetheriaStores.CatalogTypes); return cache; } From d55ac309059e047c04be37cf98ef109d8283448a Mon Sep 17 00:00:00 2001 From: soul Date: Fri, 23 Oct 2026 01:18:20 +0200 Subject: [PATCH 152/307] Station kinds tests: a bad package kind is excluded not fatal, same seed gives the same station and belt kinds, the stand-in check can fail (red) Co-Authored-By: Claude Sonnet 5.5 --- .../Aetheria.Shared.Tests/MiningCut3Tests.cs | 43 ++++++++++++++ .../ShipModCatalogTests.cs | 30 ++++++++++ .../Aetheria.Shared.Tests/StationKindTests.cs | 56 ++++++++++++++++++- 3 files changed, 126 insertions(+), 3 deletions(-) diff --git a/tests/Aetheria.Shared.Tests/MiningCut3Tests.cs b/tests/Aetheria.Shared.Tests/MiningCut3Tests.cs index 3e68b4fb0..6e1da6b0f 100644 --- a/tests/Aetheria.Shared.Tests/MiningCut3Tests.cs +++ b/tests/Aetheria.Shared.Tests/MiningCut3Tests.cs @@ -833,6 +833,49 @@ public sealed partial class MiningCut3Tests : IDisposable Assert.True(belts > 0, "no seed generated a belt; the generation path went unexercised"); } + // The kind of a belt is a function of the zone's seed, not of the key the cache mints for it: the same seed generated + // again, through the real key minting, gives the same belts the same kinds. + [Fact] + public void SameSeedGivesTheSameBeltKinds() + { + string[] Generate(uint seed, int run) + { + var dir = Path.Combine(_root, $"same{seed}-{run}"); + Directory.CreateDirectory(dir); + var catalog = Path.Combine(dir, "Aetheria.cc"); + File.Copy(TestCatalog.Repo, catalog); + using var cache = OpenShippedCatalogCopy(catalog, Path.Combine(dir, "run.cc")); + foreach (var existing in cache.GetAll().ToArray()) + { + existing.GenerationWeight = 0f; + cache.Upsert(existing); + } + cache.Upsert(Kind("Gen A", 1f, 1f)); + cache.Upsert(Kind("Gen B", 1f, 2f)); + cache.Upsert(Kind("Gen C", 1f, 4f)); + Galaxy galaxy; + try + { + galaxy = new Galaxy(RestoredHullsTests.TutorialGalaxySettings(), RestoredHullsTests.TutorialBackgroundSettings(), + cache, new PlayerSettings(), new DirectoryInfo(dir), _ => { }, null, seed); + } + catch (Exception) { return null; } + var items = new ItemManager(1u, cache, new ProvenanceLedger(), GameSettings(), _ => { }); + return galaxy.Zones.Take(6).SelectMany(zone => + ZoneGenerator.GenerateZone(items, RestoredHullsTests.TutorialZoneSettings(), RestoredHullsTests.AuthoredPlanetSettings(), galaxy, zone, isTutorial: zone == galaxy.Entrance) + .Planets.Select(body => cache.Get(body)).OfType().Select(belt => belt.Kind.Key.Value)) + .ToArray(); + } + for (uint seed = 1; seed <= 25; seed++) + { + var first = Generate(seed, 0); + if (first == null || first.Length < 4) continue; + for (var run = 1; run < 4; run++) Assert.Equal(first, Generate(seed, run)); + return; + } + Assert.Fail("no seed generated enough belts; the generation path went unexercised"); + } + // ==== Mining Cut 3 fix batch (Soul's findings) ==== // Q12 A ("launchers cannot mine") in the reach rule: reach counts only weapons that can mine (Weapon.CanMine). diff --git a/tests/Aetheria.Shared.Tests/ShipModCatalogTests.cs b/tests/Aetheria.Shared.Tests/ShipModCatalogTests.cs index 8d05b9ba8..717108d8d 100644 --- a/tests/Aetheria.Shared.Tests/ShipModCatalogTests.cs +++ b/tests/Aetheria.Shared.Tests/ShipModCatalogTests.cs @@ -49,6 +49,36 @@ public sealed class ShipModCatalogTests : IDisposable AssertOnlyTheDerivedFile(); } + // A package's station kind is checked per package: a bad one is left out, named with its reason, and the derived catalog + // still opens. The shipped catalog's own check is unchanged. + [Fact] + public void ComposeExcludesPackagesWithBadStationKinds() + { + using (var cache = AetheriaStores.Open(Shipped, catalogWritable: true)) + { + cache.Upsert(new StationKindData { Name = "Hub" }); + cache.FlushAsync().Wait(); + } + string kindKey; + using (var cache = AetheriaStores.Open(Shipped)) kindKey = cache.RefOf(cache.GetAll().Single()).Key.Value; + var held = new CultRecordRef(new CultRecordKey(kindKey)); + WritePackage("mod.kindless", "Kindless", tweak: p => p.Hull.HullType = HullType.Station); + WritePackage("mod.unknown", "Unknown", tweak: p => { p.Hull.HullType = HullType.Station; p.Hull.Kind = new CultRecordRef(new CultRecordKey("station-kind:nowhere")); }); + WritePackage("mod.shipkind", "ShipKind", tweak: p => p.Hull.Kind = held); + WritePackage("mod.good", "GoodStation", tweak: p => { p.Hull.HullType = HullType.Station; p.Hull.Kind = held; }); + + var composition = ShipModCatalog.Compose(Shipped, Derived, Mods); + + Assert.Equal(new[] { "mod.good" }, composition.Included); + Assert.Equal(new[] { "mod.kindless", "mod.shipkind", "mod.unknown" }, composition.Excluded.Select(e => e.Package).OrderBy(n => n, StringComparer.Ordinal)); + Assert.Contains("no Kind", composition.Excluded.Single(e => e.Package == "mod.kindless").Reason); + Assert.Contains("names no station kind", composition.Excluded.Single(e => e.Package == "mod.unknown").Reason); + Assert.Contains("only a station hull has one", composition.Excluded.Single(e => e.Package == "mod.shipkind").Reason); + using var derived = AetheriaStores.Open(Derived); + Assert.Contains("GoodStation", derived.GetAll().Select(hull => hull.Name)); + Assert.DoesNotContain("Kindless", derived.GetAll().Select(hull => hull.Name)); + } + // Correction 2 of the S0 map: the derived catalog held each mod hull twice, once as a HullData and once inside the // ship's authoring record. The hull is now one record, and the visual holds no hull. [Fact] diff --git a/tests/Aetheria.Shared.Tests/StationKindTests.cs b/tests/Aetheria.Shared.Tests/StationKindTests.cs index d3752b0cd..9947251b3 100644 --- a/tests/Aetheria.Shared.Tests/StationKindTests.cs +++ b/tests/Aetheria.Shared.Tests/StationKindTests.cs @@ -329,6 +329,29 @@ public sealed class StationKindTests : IDisposable Assert.Equal(one, several); } + // A station's kind is a function of the zone's seed and the orbit's place in generation, not of a key the cache mints: + // the same zone generated again, through the real key minting, gives the same stations the same kinds in the same order. + [Fact] + public void SameSeedGivesTheSameStationKinds() + { + string[] Kinds() + { + var w = World.Make(this, zenithOffered: true); + foreach (var faction in new[] { w.Au, w.Zh, w.Lucent }) + foreach (var kind in new[] { "mining", "shipyard", "depot", "power" }) w.Sell(faction, kind); + var authored = AuthoredSettings.Load(RunStartTests.FindRepoRoot()); + var galaxy = new Galaxy(authored.Read("TutorialGenerationSettings"), + authored.Read("TutorialBackgroundSettings"), w.Cache, new PlayerSettings(), + Directory.CreateDirectory(Path.Combine(_root, "galaxy" + _worlds++, "Narrative")), _ => { }, null, 3); + var pack = ZoneGenerator.GenerateZone(w.Items, authored.Read("ZoneSettings"), + authored.Read("PlanetSettings"), galaxy, galaxy.Entrance, galaxy.IsPrelude, true); + return pack.Entities.OfType().Where(e => e.Kind.IsSet()).Select(e => e.Kind.Key.Value).ToArray(); + } + var first = Kinds(); + Assert.True(first.Length >= 3, "the sample placed stations"); + for (var run = 1; run < 6; run++) Assert.Equal(first, Kinds()); + } + [Fact] public void GeneratedZonesPlaceStationsByKind() { @@ -389,10 +412,8 @@ public sealed class StationKindTests : IDisposable Assert.Equal(StationKindData.KeyOf("trade-hub").Value, cache.GetAll().Single(h => h.Name == "Zenith").Kind.Key.Value); } - [Fact] - public void ShippedStandInsAreLive() + private static void AssertStandInsLive(CultCache cache) { - using var cache = AetheriaStores.Open(TestCatalog.Repo); foreach (var kind in cache.GetAll()) foreach (var standIn in kind.StandIns) { @@ -405,4 +426,33 @@ public sealed class StationKindTests : IDisposable Assert.NotEmpty(models); } } + + [Fact] + public void ShippedStandInsAreLive() + { + using var cache = AetheriaStores.Open(TestCatalog.Repo); + AssertStandInsLive(cache); + } + + // The retirement check is not vacuous: given a stand-in it passes when the faction offers the kind it stands in with and + // not the owning kind, and fails when the faction has gained its own model of the owning kind or has no model to use. + [Fact] + public void StandInRetirementCheckCanFail() + { + var live = World.Make(this, zenithOffered: false); + live.Sell(live.Au, "mining"); + live.StandIn("shipyard", live.Au, "mining"); + Assert.Single(live.Cache.GetAll().SelectMany(kind => kind.StandIns)); + AssertStandInsLive(live.Cache); + + var retired = World.Make(this, zenithOffered: false); + retired.Sell(retired.Au, "mining"); + retired.Sell(retired.Au, "shipyard"); + retired.StandIn("shipyard", retired.Au, "mining"); + Assert.ThrowsAny(() => AssertStandInsLive(retired.Cache)); + + var hollow = World.Make(this, zenithOffered: false); + hollow.StandIn("shipyard", hollow.Au, "mining"); + Assert.ThrowsAny(() => AssertStandInsLive(hollow.Cache)); + } } From 17e2da00928bbabc6ceca6b7e4ad26aa2d2c750f Mon Sep 17 00:00:00 2001 From: soul Date: Fri, 23 Oct 2026 01:18:21 +0200 Subject: [PATCH 153/307] AetherDb tests: a kindless station hull does not lock the tool out; station-kinds sets a hull's kind (red) Co-Authored-By: Claude Sonnet 5.5 --- .../StationKindsCatalogTests.cs | 79 +++++++++++++++++-- 1 file changed, 74 insertions(+), 5 deletions(-) diff --git a/tests/Aetheria.Shared.Tests/StationKindsCatalogTests.cs b/tests/Aetheria.Shared.Tests/StationKindsCatalogTests.cs index 1aa661e5e..517a6ddc6 100644 --- a/tests/Aetheria.Shared.Tests/StationKindsCatalogTests.cs +++ b/tests/Aetheria.Shared.Tests/StationKindsCatalogTests.cs @@ -1,4 +1,5 @@ using System; +using System.Collections.Generic; using System.IO; using System.Linq; using GameCult.Caching; @@ -18,8 +19,8 @@ public sealed class StationKindsCatalogTests : IDisposable "habitat-support", "urban", "administrative", "power", "research", "salvage", "black-market", "waystation" }; - // The catalog this command first runs on holds a station hull with no kind, which Open refuses, so the command opens - // the catalog itself: Zenith gets the ruled kind, a kind it already has is kept. + // The catalog this command first runs on holds a station hull with no kind. The game's Open refuses it; the tool's Open + // names the hull and goes on, so the command can give it one: Zenith gets the ruled kind, a kind it already has is kept. [Fact] public void ZenithGetsTheRuledKindAndAKindedOneIsKept() { @@ -34,12 +35,14 @@ public sealed class StationKindsCatalogTests : IDisposable } Assert.Throws(() => AetheriaStores.Open(path)); + var named = new List(); var dry = new StringWriter(); - using (var cache = StationKindsCatalog.OpenCatalog(false, path)) StationKindsCatalog.Run(cache, false, dry); + using (var cache = AetheriaStores.Open(path, kindlessStation: named.Add)) StationKindsCatalog.Run(cache, false, dry); + Assert.Contains("Zenith", Assert.Single(named)); Assert.Contains("Zenith: kind trade-hub", dry.ToString()); var applied = new StringWriter(); - using (var cache = StationKindsCatalog.OpenCatalog(true, path)) + using (var cache = AetheriaStores.Open(path, catalogWritable: true, kindlessStation: _ => { })) { StationKindsCatalog.Run(cache, true, applied); cache.FlushAsync().Wait(); @@ -52,11 +55,77 @@ public sealed class StationKindsCatalogTests : IDisposable } var again = new StringWriter(); - using (var cache = StationKindsCatalog.OpenCatalog(true, path)) StationKindsCatalog.Run(cache, true, again); + using (var cache = AetheriaStores.Open(path, catalogWritable: true, kindlessStation: _ => { })) StationKindsCatalog.Run(cache, true, again); Assert.Contains("Zenith: already has a kind, kept", again.ToString()); Assert.Contains("Nothing to author", again.ToString()); } + private string SeedRoot(params string[] kindlessStations) + { + var root = Path.Combine(_root, "root" + Guid.NewGuid().ToString("N")); + Directory.CreateDirectory(Path.Combine(root, "GameData")); + using var seed = AetheriaStores.Open(Path.Combine(root, "GameData", "Aetheria.cc"), catalogWritable: true); + seed.Upsert(new VerseGrammar { Revision = 1 }); + seed.Upsert(new InputLayout { Rows = new InputLayoutRow[] { new InputLayoutRowSpacer { Height = 1 } } }); + foreach (var name in kindlessStations) seed.Upsert(new HullData { Name = name, HullType = HullType.Station, Shape = new Shape(2, 2), Price = 1 }); + seed.Upsert(new HullData { Name = "Cutter", HullType = HullType.Ship, Shape = new Shape(2, 2), Price = 1 }); + seed.FlushAsync().Wait(); + return root; + } + + // One kindless station hull does not lock the owner out of the catalog: the tool opens it, names the hull, and the + // command that sets kinds is reachable. The game's own Open still refuses until every station hull has a kind. + [Fact] + public void AKindlessStationHullDoesNotLockTheToolOut() + { + var root = SeedRoot("Zenith", "Second Station"); + var catalog = Path.Combine(root, "GameData", "Aetheria.cc"); + Assert.Throws(() => AetheriaStores.Open(catalog)); + + var warned = new List(); + var applied = new StringWriter(); + using (var db = AetherDb.Open(catalogWritable: true, root: root, warn: warned.Add)) + { + Assert.Contains(warned, line => line.Contains("Zenith")); + Assert.Contains(warned, line => line.Contains("Second Station")); + Assert.Equal(0, StationKindsCatalog.Run(db.Cache, true, applied)); + Assert.Contains("Second Station", applied.ToString()); + db.Cache.FlushAsync().Wait(); + } + Assert.Throws(() => AetheriaStores.Open(catalog)); + + using (var db = AetherDb.Open(catalogWritable: true, root: root, warn: _ => { })) + { + var dry = new StringWriter(); + Assert.Equal(0, StationKindsCatalog.SetHullKind(db.Cache, "Second Station", "mining", false, dry)); + Assert.Contains("Dry run", dry.ToString()); + Assert.False(db.Cache.GetAll().Single(h => h.Name == "Second Station").Kind.IsSet()); + var set = new StringWriter(); + Assert.Equal(0, StationKindsCatalog.SetHullKind(db.Cache, "Second Station", "mining", true, set)); + db.Cache.FlushAsync().Wait(); + } + using var strict = AetheriaStores.Open(catalog); + Assert.Equal(StationKindData.KeyOf("mining").Value, strict.GetAll().Single(h => h.Name == "Second Station").Kind.Key.Value); + } + + [Fact] + public void SetHullKindRefusesWhatIsNotAStationHullOrAHeldKind() + { + var root = SeedRoot("Zenith"); + using var db = AetherDb.Open(catalogWritable: true, root: root, warn: _ => { }); + StationKindsCatalog.Run(db.Cache, true, new StringWriter()); + string Refusal(string hull, string kind) + { + var output = new StringWriter(); + Assert.Equal(1, StationKindsCatalog.SetHullKind(db.Cache, hull, kind, true, output)); + return output.ToString(); + } + Assert.Contains("no station kind", Refusal("Zenith", "nowhere")); + Assert.Contains("no station hull", Refusal("Cutter", "mining")); + Assert.Contains("no station hull", Refusal("Missing", "mining")); + Assert.False(db.Cache.GetAll().Single(h => h.Name == "Cutter").Kind.IsSet()); + } + [Fact] public void DryRunWritesNothingApplyIsIdempotent() { From 37a8110907a6f76c0dfbefed4a6b4774b7523372 Mon Sep 17 00:00:00 2001 From: soul Date: Fri, 23 Oct 2026 01:21:02 +0200 Subject: [PATCH 154/307] Station and belt kinds are seeded: the pick hashes the zone seed and the place in generation order, not a minted key Co-Authored-By: Claude Sonnet 5.5 --- Assets/Scripts/ServerShared/StationKinds.cs | 41 ++++++++++++------- Assets/Scripts/ServerShared/Zone.cs | 2 +- Assets/Scripts/ServerShared/ZoneData.cs | 13 +++--- Assets/Scripts/ServerShared/ZoneGenerator.cs | 12 +++--- .../Aetheria.Shared.Tests/MiningCut3Tests.cs | 18 ++++---- .../Aetheria.Shared.Tests/StationKindTests.cs | 8 ++-- 6 files changed, 54 insertions(+), 40 deletions(-) diff --git a/Assets/Scripts/ServerShared/StationKinds.cs b/Assets/Scripts/ServerShared/StationKinds.cs index 9e946b295..4d7c54ffe 100644 --- a/Assets/Scripts/ServerShared/StationKinds.cs +++ b/Assets/Scripts/ServerShared/StationKinds.cs @@ -52,10 +52,11 @@ public static class StationKinds return Ordered(kinds); } - // A station's kind: a stable pick among the faction's kinds by the station's site key. It draws nothing from any random + // A station's kind: a stable pick among the faction's kinds by the station's site, which generation names by the zone's + // seed and the station's place in its orbit order (never by a key the cache mints). It draws nothing from any random // stream, so adding the kind to generation moves no other roll. A faction with no kinds falls back to any kind some // offered station hull carries, logged, and the hull draw then falls back to any maker. Unset when the catalog has none. - public static CultRecordRef Choose(CultCache catalog, Faction faction, CultRecordKey siteKey, Action log = null) + public static CultRecordRef Choose(CultCache catalog, Faction faction, string site, Action log = null) { var pool = KindsOf(catalog, faction); if (pool.Length == 0) @@ -68,7 +69,7 @@ public static class StationKinds if (pool.Length == 0) return default; log?.Invoke($"{faction?.Name ?? "This galaxy"} builds no station kind; placing a station of any kind some maker offers."); } - return pool[(int) (siteKey.Value.StableHash() % (uint) pool.Length)]; + return pool[(int) (site.StableHash() % (uint) pool.Length)]; } // The kind of hull a faction builds for a station kind: the kind itself when the faction offers a model of it (a model @@ -82,22 +83,34 @@ public static class StationKinds return standIn != null && standIn.Kind.IsSet() ? standIn.Kind : kind; } + // What is wrong with a hull's kind, or null: a Station hull names a kind the catalog holds, and no other hull names one. + // The one rule, applied to the catalog on Open and to each mod package on Compose. + public static string HullKindFault(CultCache catalog, HullData hull) + { + if (hull.HullType == HullType.Station) + { + if (!hull.Kind.IsSet()) + return $"Station hull '{hull.Name}' has no Kind; a station hull names the station kind it is built to be."; + if (catalog.Get(hull.Kind) == null) + return $"Station hull '{hull.Name}' Kind names no station kind this catalog holds."; + } + else if (hull.Kind.IsSet()) + return $"Hull '{hull.Name}' is a {hull.HullType} and names a Kind; only a station hull has one."; + return null; + } + // Open refuses a malformed kind catalog, naming the record and field. Every Station hull names a kind the catalog holds // and no other hull names one; every stand-in names a faction and a kind the catalog holds, is never its own kind, and - // does not stand on another stand-in for the same faction (one hop). - public static void Validate(CultCache catalog) + // does not stand on another stand-in for the same faction (one hop). A caller that gives kindlessStation is the owner + // of the catalog being repaired: a Station hull with no Kind is reported to it by name instead of refused. + public static void Validate(CultCache catalog, Action kindlessStation = null) { foreach (var hull in catalog.GetAll()) { - if (hull.HullType == HullType.Station) - { - if (!hull.Kind.IsSet()) - throw new InvalidOperationException($"Station hull '{hull.Name}' has no Kind; a station hull names the station kind it is built to be."); - if (catalog.Get(hull.Kind) == null) - throw new InvalidOperationException($"Station hull '{hull.Name}' Kind names no station kind this catalog holds."); - } - else if (hull.Kind.IsSet()) - throw new InvalidOperationException($"Hull '{hull.Name}' is a {hull.HullType} and names a Kind; only a station hull has one."); + var fault = HullKindFault(catalog, hull); + if (fault == null) continue; + if (kindlessStation != null && hull.HullType == HullType.Station && !hull.Kind.IsSet()) kindlessStation(fault); + else throw new InvalidOperationException(fault); } foreach (var kind in catalog.GetAll()) { diff --git a/Assets/Scripts/ServerShared/Zone.cs b/Assets/Scripts/ServerShared/Zone.cs index d395f722a..ca6764626 100644 --- a/Assets/Scripts/ServerShared/Zone.cs +++ b/Assets/Scripts/ServerShared/Zone.cs @@ -196,7 +196,7 @@ public class Zone case AsteroidBeltData belt: // Mining Cut 3 (Q16 B): a belt from before field kinds existed gets its kind here, once, by // the rule generation uses, and keeps it. - FieldKinds.Ensure(body.Key, belt, cache); + FieldKinds.Ensure(body.Key.Value, belt, cache); AsteroidBelts[body.Key] = new AsteroidBelt(belt); break; case SunData sun: diff --git a/Assets/Scripts/ServerShared/ZoneData.cs b/Assets/Scripts/ServerShared/ZoneData.cs index 646347af6..a80278566 100644 --- a/Assets/Scripts/ServerShared/ZoneData.cs +++ b/Assets/Scripts/ServerShared/ZoneData.cs @@ -134,10 +134,11 @@ public class FieldKindData // Mining Cut 3 (Q16 B): the one owner of which kind a belt is. public static class FieldKinds { - // A weighted pick among the catalog's kinds, by a stable hash of the belt's key: the same belt and the same - // catalog always give the same kind. Kinds are ordered by key so the pick does not depend on store order. + // A weighted pick among the catalog's kinds, by a stable hash of the belt's site: the same site and the same + // catalog always give the same kind. Generation names a belt by the zone's seed and the belt's place in generation + // order (never by the key the cache mints, which differs every run); a belt saved without a kind is named by its key. Kinds are ordered by key so the pick does not depend on store order. // Unset when the catalog has no kind with positive weight. - public static CultRecordRef Assign(CultRecordKey field, CultCache catalog) + public static CultRecordRef Assign(string site, CultCache catalog) { var kinds = catalog.GetAll() .Where(kind => kind.GenerationWeight > 0f) @@ -146,7 +147,7 @@ public static class FieldKinds .ToArray(); if (kinds.Length == 0) return default; - var pick = field.Value.StableHash() / 4294967296.0 * kinds.Sum(kind => (double) kind.Weight); + var pick = site.StableHash() / 4294967296.0 * kinds.Sum(kind => (double) kind.Weight); foreach (var kind in kinds) { if (pick < kind.Weight) return new CultRecordRef(kind.Key); @@ -157,10 +158,10 @@ public static class FieldKinds // Gives a belt with no kind its kind and saves it; a belt that has one keeps it, whatever the catalog now says. // Generation (ZoneGenerator) and first load (Zone) both call this, so there is one assignment rule. - public static void Ensure(CultRecordKey field, AsteroidBeltData belt, CultCache cache) + public static void Ensure(string site, AsteroidBeltData belt, CultCache cache) { if (belt.Kind.IsSet()) return; - belt.Kind = Assign(field, cache); + belt.Kind = Assign(site, cache); if (belt.Kind.IsSet()) cache.Upsert(belt); } } diff --git a/Assets/Scripts/ServerShared/ZoneGenerator.cs b/Assets/Scripts/ServerShared/ZoneGenerator.cs index c5c653bac..426e8b92e 100644 --- a/Assets/Scripts/ServerShared/ZoneGenerator.cs +++ b/Assets/Scripts/ServerShared/ZoneGenerator.cs @@ -119,7 +119,7 @@ public static class ZoneGenerator pack.Orbits = planets.Select(planet => cache.RefOf(orbitMap[planet])).ToList(); var beltOf = new Dictionary(); - pack.Planets = planets.Where(p=>!p.Empty).Select(planet => + pack.Planets = planets.Where(p=>!p.Empty).Select((planet, index) => { BodyType bodyType = planet.Belt ? BodyType.Asteroid : planet.Mass > zoneSettings.SunMass ? BodyType.Sun : @@ -213,7 +213,7 @@ public static class ZoneGenerator var bodyKey = cache.Upsert(planetData).Key; planetData.Name = bodyKey.Value.Substring(0, 8); // Mining Cut 3 (Q16 B): a belt's field kind is chosen here, at generation, and saved with it. - if (planetData is AsteroidBeltData generatedBelt) FieldKinds.Ensure(bodyKey, generatedBelt, cache); + if (planetData is AsteroidBeltData generatedBelt) FieldKinds.Ensure($"{seed}:belt:{index}", generatedBelt, cache); return cache.Upsert(planetData); }).ToList(); @@ -239,7 +239,7 @@ public static class ZoneGenerator BodyData swarmBody = swarm; var swarmKey = cache.Upsert(swarmBody).Key; swarm.Name = swarmKey.Value.Substring(0, 8); - FieldKinds.Ensure(swarmKey, swarm, cache); + FieldKinds.Ensure($"{seed}:swarm:{swarmHosts.Count}", swarm, cache); pack.Planets.Add(cache.Upsert(swarmBody)); } @@ -268,7 +268,7 @@ public static class ZoneGenerator BodyData lanedBody = laned; var lanedKey = cache.Upsert(lanedBody).Key; laned.Name = lanedKey.Value.Substring(0, 8); - FieldKinds.Ensure(lanedKey, laned, cache); + FieldKinds.Ensure($"{seed}:lane:{shaped.Count}", laned, cache); pack.Planets.Add(cache.Upsert(lanedBody)); } @@ -420,7 +420,7 @@ public static class ZoneGenerator var orbit = selectedStationOrbits[i]; var lagrangeOrbit = CreateLagrangeOrbit(orbit); var station = GetLoadoutGenerator(story.Faction).GenerateStationLoadout( - StationKinds.Choose(cache, story.Faction, cache.RefOf(lagrangeOrbit).Key, itemManager.Log)); + StationKinds.Choose(cache, story.Faction, $"{seed}:station:{i}", itemManager.Log)); if (station == null) { itemManager.Log($"Story station for {story.Faction?.Name} could not be generated; its story has nowhere to live!"); @@ -442,7 +442,7 @@ public static class ZoneGenerator var lagrangeOrbit = CreateLagrangeOrbit(orbit); var station = GetLoadoutGenerator(nearestFaction).GenerateStationLoadout( - StationKinds.Choose(cache, nearestFaction, cache.RefOf(lagrangeOrbit).Key, itemManager.Log)); + StationKinds.Choose(cache, nearestFaction, $"{seed}:station:{i}", itemManager.Log)); if (station == null) { if (isTutorialEntrance) diff --git a/tests/Aetheria.Shared.Tests/MiningCut3Tests.cs b/tests/Aetheria.Shared.Tests/MiningCut3Tests.cs index 6e1da6b0f..d6de2de59 100644 --- a/tests/Aetheria.Shared.Tests/MiningCut3Tests.cs +++ b/tests/Aetheria.Shared.Tests/MiningCut3Tests.cs @@ -638,7 +638,7 @@ public sealed partial class MiningCut3Tests : IDisposable var counts = new Dictionary { ["Light"] = 0, ["Never"] = 0, ["Middle"] = 0, ["Heavy"] = 0 }; for (var i = 0; i < 2000; i++) { - var key = new CultRecordKey($"belt-{i}"); + var key = $"belt-{i}"; var picked = FieldKinds.Assign(key, s.Cache); Assert.True(picked.Key.Equals(FieldKinds.Assign(key, s.Cache).Key)); counts[s.Cache.Get(picked).Name]++; @@ -649,7 +649,7 @@ public sealed partial class MiningCut3Tests : IDisposable Assert.InRange(counts["Heavy"] / 2000f, 3f / 6 - .04f, 3f / 6 + .04f); var empty = BuildScene(new[] { never }); - Assert.False(FieldKinds.Assign(new CultRecordKey("belt-0"), empty.Cache).IsSet()); + Assert.False(FieldKinds.Assign("belt-0", empty.Cache).IsSet()); } // Q16 B, first load: a belt saved before field kinds existed gets its kind when a zone first loads it, by the @@ -664,7 +664,7 @@ public sealed partial class MiningCut3Tests : IDisposable var beltKey = s.Belts[0]; var belt = (AsteroidBeltData) s.Zone.Planets[beltKey]; Assert.True(belt.Kind.IsSet()); - Assert.True(belt.Kind.Key.Equals(FieldKinds.Assign(beltKey, s.Cache).Key)); + Assert.True(belt.Kind.Key.Equals(FieldKinds.Assign(beltKey.Value, s.Cache).Key)); var assigned = belt.Kind.Key; // Saved: a fresh open of the same stores reads the kind back. @@ -678,7 +678,7 @@ public sealed partial class MiningCut3Tests : IDisposable // Kept: a new, overwhelmingly weighted kind now wins the pick for this belt, yet loading it again leaves it. reopened.Upsert(Kind("Overwhelming", 9f, 1e6f)); - Assert.False(FieldKinds.Assign(beltKey, reopened).Key.Equals(assigned)); + Assert.False(FieldKinds.Assign(beltKey.Value, reopened).Key.Equals(assigned)); var items = new ItemManager(1u, reopened, new ProvenanceLedger(), GameSettings(), _ => { }); var reloaded = new Zone(items, PlanetSettings(), s.Pack, new GalaxyZone { Name = "MiningCut3Reload", Owner = null }, null); Assert.True(((AsteroidBeltData) reloaded.Planets[beltKey]).Kind.Key.Equals(assigned)); @@ -786,11 +786,11 @@ public sealed partial class MiningCut3Tests : IDisposable return cache; } - // Q16 B, generation: every belt ZoneGenerator makes carries its kind already, chosen by FieldKinds.Assign on - // the belt's own key. Runs against a scratch copy of the shipped catalog with three kinds of different + // Q16 B, generation: every belt ZoneGenerator makes carries its kind already, chosen by FieldKinds.Assign. Runs against + // a scratch copy of the shipped catalog with three kinds of different // weights, so a pick by any other rule would disagree on some belt. [Fact] - public void GenerationChoosesTheKindByTheSameFunction() + public void GenerationAssignsEveryBeltItsKind() { var shipped = TestCatalog.Repo; var belts = 0; @@ -826,7 +826,7 @@ public sealed partial class MiningCut3Tests : IDisposable if (!(cache.Get(body) is AsteroidBeltData belt)) continue; belts++; Assert.True(belt.Kind.IsSet()); - Assert.True(belt.Kind.Key.Equals(FieldKinds.Assign(body.Key, cache).Key)); + Assert.StartsWith("Gen ", cache.Get(belt.Kind).Name); } } } @@ -863,7 +863,7 @@ public sealed partial class MiningCut3Tests : IDisposable var items = new ItemManager(1u, cache, new ProvenanceLedger(), GameSettings(), _ => { }); return galaxy.Zones.Take(6).SelectMany(zone => ZoneGenerator.GenerateZone(items, RestoredHullsTests.TutorialZoneSettings(), RestoredHullsTests.AuthoredPlanetSettings(), galaxy, zone, isTutorial: zone == galaxy.Entrance) - .Planets.Select(body => cache.Get(body)).OfType().Select(belt => belt.Kind.Key.Value)) + .Planets.Select(body => cache.Get(body)).OfType().Select(belt => cache.Get(belt.Kind).Name)) .ToArray(); } for (uint seed = 1; seed <= 25; seed++) diff --git a/tests/Aetheria.Shared.Tests/StationKindTests.cs b/tests/Aetheria.Shared.Tests/StationKindTests.cs index 9947251b3..12d1c0e50 100644 --- a/tests/Aetheria.Shared.Tests/StationKindTests.cs +++ b/tests/Aetheria.Shared.Tests/StationKindTests.cs @@ -224,7 +224,7 @@ public sealed class StationKindTests : IDisposable var seen = new HashSet(); for (var site = 0; site < 40; site++) { - var kind = StationKinds.Choose(world.Cache, world.Au, new CultRecordKey("site-" + site)); + var kind = StationKinds.Choose(world.Cache, world.Au, "site-" + site); Assert.Contains(kind.Key.Value, kinds); seen.Add(kind.Key.Value); var pack = world.Generator(world.Au, (uint) site + 1).GenerateStationLoadout(kind); @@ -281,12 +281,12 @@ public sealed class StationKindTests : IDisposable Assert.Empty(StationKinds.KindsOf(world.Cache, world.Lucent)); var logged = new List(); - var kind = StationKinds.Choose(world.Cache, world.Lucent, new CultRecordKey("site"), logged.Add); + var kind = StationKinds.Choose(world.Cache, world.Lucent, "site", logged.Add); Assert.Equal(Kind("shipyard").Key, kind.Key); Assert.Single(logged); Assert.Contains(world.Lucent.Name, logged[0]); var anonymous = new List(); - StationKinds.Choose(world.Cache, null, new CultRecordKey("site"), anonymous.Add); + StationKinds.Choose(world.Cache, null, "site", anonymous.Add); Assert.Contains("This galaxy", Assert.Single(anonymous)); world.StandIn("mining", world.Au, "shipyard"); Assert.Equal(Kind("mining").Key, StationKinds.HullKindFor(world.Cache, null, Kind("mining")).Key); @@ -304,7 +304,7 @@ public sealed class StationKindTests : IDisposable world.Sell(world.Au, "mining"); world.Sell(world.Au, "shipyard"); world.Sell(world.Au, "depot"); - var keys = Enumerable.Range(0, 30).Select(i => new CultRecordKey("orbit-" + i)).ToArray(); + var keys = Enumerable.Range(0, 30).Select(i => "orbit-" + i).ToArray(); var first = keys.Select(key => StationKinds.Choose(world.Cache, world.Au, key).Key.Value).ToArray(); Assert.Equal(first, keys.Reverse().Select(key => StationKinds.Choose(world.Cache, world.Au, key).Key.Value).Reverse().ToArray()); Assert.True(first.Distinct().Count() > 1, "the stable hash spreads sites over the faction's kinds"); From d2f4b2c3473835a249739db8eade784968e372e1 Mon Sep 17 00:00:00 2001 From: soul Date: Fri, 23 Oct 2026 01:21:04 +0200 Subject: [PATCH 155/307] A bad package station kind excludes the package; AetherDb opens a catalog with a kindless station hull, names it, and station-kinds hull sets it; the raw opener is gone Co-Authored-By: Claude Sonnet 5.5 --- Assets/Scripts/ServerShared/AetheriaStores.cs | 6 ++- Assets/Scripts/ServerShared/ShipModCatalog.cs | 1 + tools/AetherDb/AetherDb.cs | 12 +++--- tools/AetherDb/Program.cs | 17 +++++++- tools/AetherDb/StationKindsCatalog.cs | 42 ++++++++++++++----- 5 files changed, 59 insertions(+), 19 deletions(-) diff --git a/Assets/Scripts/ServerShared/AetheriaStores.cs b/Assets/Scripts/ServerShared/AetheriaStores.cs index 47128ef6c..9992c293f 100644 --- a/Assets/Scripts/ServerShared/AetheriaStores.cs +++ b/Assets/Scripts/ServerShared/AetheriaStores.cs @@ -14,10 +14,12 @@ public static class AetheriaStores // Attaches (hydrates) the catalog read-only unless catalogWritable, then the run and player stores when given. // Throws when a [CultGlobal] type routed to the catalog has no record. The one exemption is a writable catalog with // no records at all, a seed store being authored from nothing; a populated catalog is held to it however it opens. + // A catalog's station hull with no Kind is refused, unless the caller gives kindlessStation: the tool that repairs a catalog + // is told the hull's name and goes on. // The registry is the process-wide one unless the caller scopes it (the test host does, so its own document types never // become catalog globals); the global check above is judged against whichever registry opened the cache. public static CultCache Open(string catalogPath, string runPath = null, string playerPath = null, bool catalogWritable = false, - CultDocumentRegistry registry = null) + CultDocumentRegistry registry = null, Action kindlessStation = null) { // A read-only open of an absent catalog would hydrate an empty game; a shipped player missing its catalog stops here. if (!catalogWritable && !File.Exists(catalogPath)) @@ -42,7 +44,7 @@ public static class AetheriaStores foreach (var data in cache.GetAll()) CultRecordRefs.Validate(data); VerseGrammarValidation.ValidateAll(cache.GetAll()); ValidateBins(cache); - StationKinds.Validate(cache); + StationKinds.Validate(cache, kindlessStation); return cache; } catch diff --git a/Assets/Scripts/ServerShared/ShipModCatalog.cs b/Assets/Scripts/ServerShared/ShipModCatalog.cs index 4f1908466..93b820513 100644 --- a/Assets/Scripts/ServerShared/ShipModCatalog.cs +++ b/Assets/Scripts/ServerShared/ShipModCatalog.cs @@ -108,6 +108,7 @@ public static class ShipModCatalog Exclude(directory, $"hull name '{hull.Name}' collides with an existing catalog item."); if (shipIds.Contains(ship.Id)) Exclude(directory, $"ship ID collides with an existing catalog ship authoring record."); + if (StationKinds.HullKindFault(cache, hull) is { } kindFault) Exclude(directory, kindFault); foreach (var product in package.Products) { if (!factionKeys.Contains(product.Manufacturer.Key)) diff --git a/tools/AetherDb/AetherDb.cs b/tools/AetherDb/AetherDb.cs index d5b4ffdb3..027a6453a 100644 --- a/tools/AetherDb/AetherDb.cs +++ b/tools/AetherDb/AetherDb.cs @@ -3,25 +3,27 @@ using System.IO; using GameCult.Caching; // Opens GameData/Aetheria.cc through AetheriaStores, the game's own store composition. The catalog attaches read-only -// unless a command writes it, so a command that only reads cannot write. +// unless a command writes it, so a command that only reads cannot write. A station hull with no kind does not lock the +// catalog: it is named to warn (stderr by default) and the commands that repair it stay reachable. public sealed class AetherDb { public string Root { get; } public CultCache Cache { get; } - private AetherDb(string root, bool catalogWritable, bool withRun) + private AetherDb(string root, bool catalogWritable, bool withRun, Action warn) { Root = root; var gameData = Path.Combine(root, "GameData"); Cache = AetheriaStores.Open( Path.Combine(gameData, "Aetheria.cc"), runPath: withRun ? Path.Combine(gameData, "run.cc") : null, - catalogWritable: catalogWritable); + catalogWritable: catalogWritable, + kindlessStation: warn ?? Console.Error.WriteLine); } // With no root given, walks up from the working directory to the game folder: the first folder whose GameData holds Aetheria.cc - public static AetherDb Open(bool catalogWritable = false, bool withRun = false, string root = null) => - new AetherDb(root ?? FindRoot(), catalogWritable, withRun); + public static AetherDb Open(bool catalogWritable = false, bool withRun = false, string root = null, Action warn = null) => + new AetherDb(root ?? FindRoot(), catalogWritable, withRun, warn); public static string FindRoot() { diff --git a/tools/AetherDb/Program.cs b/tools/AetherDb/Program.cs index 9cfd26f8c..0ab9787b4 100644 --- a/tools/AetherDb/Program.cs +++ b/tools/AetherDb/Program.cs @@ -61,10 +61,23 @@ public static class Program case "place-names": Console.OutputEncoding = new System.Text.UTF8Encoding(false); return PlaceNamesCommand.Run(args.Skip(1).ToArray()); - case "station-kinds": return StationKindsCatalog.Run(StationKindsCatalog.OpenCatalog(args.Contains("apply")), args.Contains("apply"), Console.Out); + case "station-kinds": + { + var apply = args.Contains("apply"); + using var db = AetherDb.Open(catalogWritable: apply); + int code; + if (args.Length > 1 && args[1] == "hull") + { + if (args.Length != (apply ? 5 : 4)) { Console.WriteLine("station-kinds hull \"\" [apply]"); return 1; } + code = StationKindsCatalog.SetHullKind(db.Cache, args[2], args[3], apply, Console.Out); + } + else code = StationKindsCatalog.Run(db.Cache, apply, Console.Out); + if (apply) db.Cache.FlushAsync().GetAwaiter().GetResult(); + return code; + } case "doctrine-catalog": return DoctrineCatalog.Run(AetherDb.Open(catalogWritable: args.Contains("apply")).Cache, args.Contains("apply"), Console.Out); default: - Console.WriteLine("commands: census, factions, station-fit, hardpoint-fit, loadout [seed], save, settings, settings-dump, dangling [clear ]... [apply], shield-migrate [apply], brownout-migrate [apply], roles-migrate [apply], firing-arc-migrate [apply], range-falloff [apply] [--effective ] [--end ], targeting-catalog [apply], targeting-catalog-6c [apply], targeting-catalog-6d [apply], ship-authoring create|inspect|validate|compose, field-kinds [apply], mine-launcher [apply], smoke-hull [apply], smoke-rounds [apply], roles-backfill [apply], pd-gear [apply], consumables-first-set [apply], launch-suppressor [apply], pirates-faction [apply], verse-grammar [apply], generic-designs [apply], sensor-stat-set [apply], inspection-scanner [apply], minor-power-factions [apply], place-names [apply], product-lines weapons|minor-powers|gear|tiers [apply], doctrine-catalog [apply], assembly-from-maker [apply], station-kinds [apply]"); + Console.WriteLine("commands: census, factions, station-fit, hardpoint-fit, loadout [seed], save, settings, settings-dump, dangling [clear ]... [apply], shield-migrate [apply], brownout-migrate [apply], roles-migrate [apply], firing-arc-migrate [apply], range-falloff [apply] [--effective ] [--end ], targeting-catalog [apply], targeting-catalog-6c [apply], targeting-catalog-6d [apply], ship-authoring create|inspect|validate|compose, field-kinds [apply], mine-launcher [apply], smoke-hull [apply], smoke-rounds [apply], roles-backfill [apply], pd-gear [apply], consumables-first-set [apply], launch-suppressor [apply], pirates-faction [apply], verse-grammar [apply], generic-designs [apply], sensor-stat-set [apply], inspection-scanner [apply], minor-power-factions [apply], place-names [apply], product-lines weapons|minor-powers|gear|tiers [apply], doctrine-catalog [apply], assembly-from-maker [apply], station-kinds [hull \"\" ] [apply]"); return 1; } } diff --git a/tools/AetherDb/StationKindsCatalog.cs b/tools/AetherDb/StationKindsCatalog.cs index 0ee6b1c6c..6743ccc50 100644 --- a/tools/AetherDb/StationKindsCatalog.cs +++ b/tools/AetherDb/StationKindsCatalog.cs @@ -2,7 +2,6 @@ using System; using System.IO; using System.Linq; using GameCult.Caching; -using GameCult.Caching.MessagePack; // Authors the catalog's one list of station kinds (aetheria-living-world, cut station-kind-datum) and gives Zenith, the // one station hull in the repo, its ruled kind (ruling zenith-is-a-trade-hub: provisional, one edit here to change). @@ -34,15 +33,6 @@ public static class StationKindsCatalog ("waystation", "Waystation", "Fuel, repair and medicine along a corridor."), }; - // The catalog opened without AetheriaStores.Open's checks: the catalog this command first runs on holds a station hull with no - // kind, which StationKinds.Validate refuses, and this command is what gives it one. - public static CultCache OpenCatalog(bool apply, string catalogPath = null) - { - var cache = new CultCache(); - cache.AddBackingStore(new SingleFileMessagePackBackingStore(catalogPath ?? Path.Combine(AetherDb.FindRoot(), "GameData", "Aetheria.cc"), !apply), AetheriaStores.CatalogTypes); - return cache; - } - public static int Run(CultCache cache, bool apply, TextWriter output) { var held = cache.GetAll().Select(kind => cache.RefOf(kind).Key.Value).ToHashSet(StringComparer.Ordinal); @@ -55,6 +45,9 @@ public static class StationKindsCatalog output.WriteLine(zenith == null ? "Zenith: no such hull, kind not set" : setZenith ? $"Zenith: kind {ZenithKind}" : "Zenith: already has a kind, kept"); + foreach (var hull in cache.GetAll().Where(h => h.HullType == HullType.Station && !h.Kind.IsSet() && h != zenith).OrderBy(h => h.Name, StringComparer.Ordinal)) + output.WriteLine($"Station hull '{hull.Name}' has no kind; set one with: station-kinds hull \"{hull.Name}\" apply"); + if (!apply) { output.WriteLine("\nDry run. Pass \"apply\" to author them."); @@ -79,4 +72,33 @@ public static class StationKindsCatalog output.WriteLine($"\nAuthored {missing.Length} station kinds{(setZenith ? " and Zenith's kind" : "")} in Aetheria.cc"); return 0; } + + // Gives a station hull its kind: the way to repair a hull the catalog holds without one. Dry run unless apply. + public static int SetHullKind(CultCache cache, string hullName, string kindId, bool apply, TextWriter output) + { + var hull = cache.GetAll().FirstOrDefault(h => h.HullType == HullType.Station && h.Name == hullName); + if (hull == null) + { + output.WriteLine($"The catalog holds no station hull named '{hullName}'."); + return 1; + } + var kind = new CultRecordRef(StationKindData.KeyOf(kindId)); + if (cache.Get(kind) == null) + { + output.WriteLine($"The catalog holds no station kind '{kindId}'."); + return 1; + } + output.WriteLine($"{hull.Name}: kind {kindId}"); + if (!apply) + { + output.WriteLine(" +Dry run. Pass \"apply\" to set it."); + return 0; + } + hull.Kind = kind; + cache.Upsert(hull); + output.WriteLine($" +Set {hull.Name}'s kind in Aetheria.cc"); + return 0; + } } From b87873415836e069801241a269901332ce4dec28 Mon Sep 17 00:00:00 2001 From: soul Date: Fri, 23 Oct 2026 01:23:59 +0200 Subject: [PATCH 156/307] station-kinds hull: the dry-run and applied lines are one line each Co-Authored-By: Claude Sonnet 5.5 --- tools/AetherDb/StationKindsCatalog.cs | 6 ++---- 1 file changed, 2 insertions(+), 4 deletions(-) diff --git a/tools/AetherDb/StationKindsCatalog.cs b/tools/AetherDb/StationKindsCatalog.cs index 6743ccc50..a450d2ff7 100644 --- a/tools/AetherDb/StationKindsCatalog.cs +++ b/tools/AetherDb/StationKindsCatalog.cs @@ -91,14 +91,12 @@ public static class StationKindsCatalog output.WriteLine($"{hull.Name}: kind {kindId}"); if (!apply) { - output.WriteLine(" -Dry run. Pass \"apply\" to set it."); + output.WriteLine("\nDry run. Pass \"apply\" to set it."); return 0; } hull.Kind = kind; cache.Upsert(hull); - output.WriteLine($" -Set {hull.Name}'s kind in Aetheria.cc"); + output.WriteLine($"\nSet {hull.Name}'s kind in Aetheria.cc"); return 0; } } From 4918fcd993e9f1bd15a13b4a5ab4b529e3e2d304 Mon Sep 17 00:00:00 2001 From: soul Date: Fri, 23 Oct 2026 01:26:00 +0200 Subject: [PATCH 157/307] AetherDb is disposable: it owns the cache it opened Co-Authored-By: Claude Sonnet 5.5 --- tools/AetherDb/AetherDb.cs | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/tools/AetherDb/AetherDb.cs b/tools/AetherDb/AetherDb.cs index 027a6453a..c65002d11 100644 --- a/tools/AetherDb/AetherDb.cs +++ b/tools/AetherDb/AetherDb.cs @@ -5,11 +5,13 @@ using GameCult.Caching; // Opens GameData/Aetheria.cc through AetheriaStores, the game's own store composition. The catalog attaches read-only // unless a command writes it, so a command that only reads cannot write. A station hull with no kind does not lock the // catalog: it is named to warn (stderr by default) and the commands that repair it stay reachable. -public sealed class AetherDb +public sealed class AetherDb : IDisposable { public string Root { get; } public CultCache Cache { get; } + public void Dispose() => Cache.Dispose(); + private AetherDb(string root, bool catalogWritable, bool withRun, Action warn) { Root = root; From d9bc46535566fa3b702ae8ca7d21287d7a4209ec Mon Sep 17 00:00:00 2001 From: soul Date: Fri, 23 Oct 2026 01:26:14 +0200 Subject: [PATCH 158/307] Packages: lock regenerated by Unity at cultlib-unity-v2.0.1 (b8fc5eb0) Co-Authored-By: Claude Opus 5.5 --- Packages/packages-lock.json | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/Packages/packages-lock.json b/Packages/packages-lock.json index 7345ecc47..919cf94d1 100644 --- a/Packages/packages-lock.json +++ b/Packages/packages-lock.json @@ -265,13 +265,13 @@ "hash": "42dce3611a81adc5c5fac54cadbaa5637d6022d3" }, "org.gamecult.cultlib": { - "version": "https://github.com/GameCult/CultLib.git?path=/unity/org.gamecult.cultlib#cultlib-unity-v2.0.0", + "version": "https://github.com/GameCult/CultLib.git?path=/unity/org.gamecult.cultlib#cultlib-unity-v2.0.1", "depth": 0, "source": "git", "dependencies": { "org.gamecult.cultmath": "0.5.0" }, - "hash": "42dce3611a81adc5c5fac54cadbaa5637d6022d3" + "hash": "b8fc5eb07720a187a0421424ac2cb3aa2966ca1d" }, "org.gamecult.cultmath": { "version": "https://github.com/GameCult/CultLib.git?path=/packages/cultmath/unity/org.gamecult.cultmath#cultmath-unity-v0.5.0", From b1ca2ead9027cd00557170da4fecea2eb5571b83 Mon Sep 17 00:00:00 2001 From: soul Date: Fri, 23 Oct 2026 01:32:34 +0200 Subject: [PATCH 159/307] Same-seed tests: authored kind keys are fixed; the station sample spans eight zones Co-Authored-By: Claude Sonnet 5.5 --- tests/Aetheria.Shared.Tests/MiningCut3Tests.cs | 7 ++++--- tests/Aetheria.Shared.Tests/StationKindTests.cs | 6 +++--- 2 files changed, 7 insertions(+), 6 deletions(-) diff --git a/tests/Aetheria.Shared.Tests/MiningCut3Tests.cs b/tests/Aetheria.Shared.Tests/MiningCut3Tests.cs index d6de2de59..a92f69a0c 100644 --- a/tests/Aetheria.Shared.Tests/MiningCut3Tests.cs +++ b/tests/Aetheria.Shared.Tests/MiningCut3Tests.cs @@ -850,9 +850,10 @@ public sealed partial class MiningCut3Tests : IDisposable existing.GenerationWeight = 0f; cache.Upsert(existing); } - cache.Upsert(Kind("Gen A", 1f, 1f)); - cache.Upsert(Kind("Gen B", 1f, 2f)); - cache.Upsert(Kind("Gen C", 1f, 4f)); + // Authored kinds have fixed keys; the pick orders kinds by key, so the fixture's keys are fixed too. + cache.Upsert(typeof(FieldKindData), Kind("Gen A", 1f, 1f), new CultRecordKey("field-kind:gen-a")); + cache.Upsert(typeof(FieldKindData), Kind("Gen B", 1f, 2f), new CultRecordKey("field-kind:gen-b")); + cache.Upsert(typeof(FieldKindData), Kind("Gen C", 1f, 4f), new CultRecordKey("field-kind:gen-c")); Galaxy galaxy; try { diff --git a/tests/Aetheria.Shared.Tests/StationKindTests.cs b/tests/Aetheria.Shared.Tests/StationKindTests.cs index 12d1c0e50..47154c1c7 100644 --- a/tests/Aetheria.Shared.Tests/StationKindTests.cs +++ b/tests/Aetheria.Shared.Tests/StationKindTests.cs @@ -343,9 +343,9 @@ public sealed class StationKindTests : IDisposable var galaxy = new Galaxy(authored.Read("TutorialGenerationSettings"), authored.Read("TutorialBackgroundSettings"), w.Cache, new PlayerSettings(), Directory.CreateDirectory(Path.Combine(_root, "galaxy" + _worlds++, "Narrative")), _ => { }, null, 3); - var pack = ZoneGenerator.GenerateZone(w.Items, authored.Read("ZoneSettings"), - authored.Read("PlanetSettings"), galaxy, galaxy.Entrance, galaxy.IsPrelude, true); - return pack.Entities.OfType().Where(e => e.Kind.IsSet()).Select(e => e.Kind.Key.Value).ToArray(); + return galaxy.Zones.Take(8).SelectMany(zone => ZoneGenerator.GenerateZone(w.Items, authored.Read("ZoneSettings"), + authored.Read("PlanetSettings"), galaxy, zone, zone == galaxy.Entrance && galaxy.IsPrelude, true) + .Entities.OfType().Where(e => e.Kind.IsSet()).Select(e => e.Kind.Key.Value)).ToArray(); } var first = Kinds(); Assert.True(first.Length >= 3, "the sample placed stations"); From 02d60691dcd604584ffa1b79367dc7c8a9041078 Mon Sep 17 00:00:00 2001 From: soul Date: Fri, 23 Oct 2026 01:37:40 +0200 Subject: [PATCH 160/307] Same-seed belt test: fixed kind keys through a batch Co-Authored-By: Claude Sonnet 5.5 --- tests/Aetheria.Shared.Tests/MiningCut3Tests.cs | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/tests/Aetheria.Shared.Tests/MiningCut3Tests.cs b/tests/Aetheria.Shared.Tests/MiningCut3Tests.cs index a92f69a0c..484116eb3 100644 --- a/tests/Aetheria.Shared.Tests/MiningCut3Tests.cs +++ b/tests/Aetheria.Shared.Tests/MiningCut3Tests.cs @@ -851,9 +851,9 @@ public sealed partial class MiningCut3Tests : IDisposable cache.Upsert(existing); } // Authored kinds have fixed keys; the pick orders kinds by key, so the fixture's keys are fixed too. - cache.Upsert(typeof(FieldKindData), Kind("Gen A", 1f, 1f), new CultRecordKey("field-kind:gen-a")); - cache.Upsert(typeof(FieldKindData), Kind("Gen B", 1f, 2f), new CultRecordKey("field-kind:gen-b")); - cache.Upsert(typeof(FieldKindData), Kind("Gen C", 1f, 4f), new CultRecordKey("field-kind:gen-c")); + cache.Commit(batch => batch.Upsert(typeof(FieldKindData), Kind("Gen A", 1f, 1f), new CultRecordKey("field-kind:gen-a"))); + cache.Commit(batch => batch.Upsert(typeof(FieldKindData), Kind("Gen B", 1f, 2f), new CultRecordKey("field-kind:gen-b"))); + cache.Commit(batch => batch.Upsert(typeof(FieldKindData), Kind("Gen C", 1f, 4f), new CultRecordKey("field-kind:gen-c"))); Galaxy galaxy; try { From 7f1337167dd505bd103565d4f541fc5c6807ed12 Mon Sep 17 00:00:00 2001 From: soul Date: Wed, 21 Oct 2026 20:32:11 +0200 Subject: [PATCH 161/307] Loadouts.Commit stops refusing package designs (ruling catalog-is-a-package-stack) The catalog is a stack of packages, the shipped content included, so a Loadout may name any design the stack holds. The mod-design refusal (from 54917bb3, backed by no ruling) is deleted, and its test now pins that a package design commits under its key. The IsModKey comment no longer claims the shipped catalog never references a mod. Co-Authored-By: Claude Opus 5.5 --- Assets/Scripts/ServerShared/Loadout.cs | 7 +++---- Assets/Scripts/ServerShared/ShipModCatalog.cs | 3 ++- tests/Aetheria.Shared.Tests/LoadoutTests.cs | 20 +++++++++---------- 3 files changed, 14 insertions(+), 16 deletions(-) diff --git a/Assets/Scripts/ServerShared/Loadout.cs b/Assets/Scripts/ServerShared/Loadout.cs index d7fec7f03..15e832a58 100644 --- a/Assets/Scripts/ServerShared/Loadout.cs +++ b/Assets/Scripts/ServerShared/Loadout.cs @@ -99,14 +99,13 @@ public static class Loadouts // The only writer of Loadout records. It opens its own short-lived cache over the catalog file, writable, and disposes it // after the one commit. No other cache is touched, so no catalog instance play holds (or has mutated) can reach the // file; a cache that already has the catalog open sees the loadout only once it reloads. - // Throws and writes nothing when the loadout names a mod design (hull or slot), when the file is missing (capture never creates a catalog), when a loadout of this name + // The catalog is a stack of packages, the shipped content included (ruling catalog-is-a-package-stack), so a loadout may + // name any design the stack holds; one whose package is later removed fails closed where it is built, with its reason logged. + // Throws and writes nothing when the file is missing (capture never creates a catalog), when a loadout of this name // exists under another key, or when one exists under KeyOf(Name) and replace is not set. The commit is conditional // on the record at KeyOf(Name), so a loadout changed on disk since this open is not clobbered: returns false then. public static bool Commit(string catalogPath, Loadout loadout, bool replace) { - var modDesigns = new[] { loadout.Hull.Key }.Concat(loadout.Slots.Select(slot => slot.Design.Key)).Where(ShipModCatalog.IsModKey).Select(key => key.Value).Distinct().ToArray(); - if (modDesigns.Length > 0) - throw new InvalidOperationException($"Loadout '{loadout.Name}' names mod designs ({string.Join(", ", modDesigns)}); the shipped catalog never references a mod, so a mod ship cannot be captured as a loadout."); if (!File.Exists(catalogPath)) throw new InvalidOperationException($"Catalog {catalogPath} does not exist; a loadout commit never creates one."); using var cache = AetheriaStores.Open(catalogPath, catalogWritable: true); diff --git a/Assets/Scripts/ServerShared/ShipModCatalog.cs b/Assets/Scripts/ServerShared/ShipModCatalog.cs index 4f1908466..9e89411b8 100644 --- a/Assets/Scripts/ServerShared/ShipModCatalog.cs +++ b/Assets/Scripts/ServerShared/ShipModCatalog.cs @@ -25,7 +25,8 @@ public static class ShipModCatalog public static CultRecordKey ProductKey(string id, CultRecordKey maker) => new CultRecordKey("mod-product:" + id + ":" + maker.Value); - // A key the mods own. The shipped catalog and its authored records never reference one: a mod may be uninstalled. + // A key a package owns. Any record may reference one (ruling catalog-is-a-package-stack); one whose package is removed + // fails closed where it is read. public static bool IsModKey(CultRecordKey key) => key.IsSet() && (key.Value.StartsWith("mod-hull:", StringComparison.Ordinal) || key.Value.StartsWith("mod-ship:", StringComparison.Ordinal) || key.Value.StartsWith("mod-product:", StringComparison.Ordinal)); diff --git a/tests/Aetheria.Shared.Tests/LoadoutTests.cs b/tests/Aetheria.Shared.Tests/LoadoutTests.cs index 7d3316302..c073bf37e 100644 --- a/tests/Aetheria.Shared.Tests/LoadoutTests.cs +++ b/tests/Aetheria.Shared.Tests/LoadoutTests.cs @@ -122,27 +122,25 @@ public sealed class LoadoutTests : IDisposable Assert.True(!File.Exists(Player) || !SchemaNames(Player).Contains("aetheria.loadout")); } - // The shipped catalog never references a mod: a mod may be uninstalled, and a preset naming one would dangle. The - // refusal is at the only writer, so no caller (the editor command included) can get one into the file. + // The catalog is a stack of packages (ruling catalog-is-a-package-stack): a preset may name a package's design, hull or + // slot, and the only writer commits it under the package's key. Whether the design resolves is decided where the preset is + // built, not here. [Theory] [InlineData("hull", "mod-hull:mod.skiff")] [InlineData("slot", "mod-hull:mod.skiff")] [InlineData("slot", "mod-ship:mod.skiff")] - public void APresetNamingAModDesignIsRefusedAndNothingIsWritten(string where, string key) + public void APresetNamingAPackageDesignIsCommitted(string where, string key) { - var mod = new CultRecordRef(new CultRecordKey(key)); using var cache = Open(); var loadout = HandBuilt(cache); if (where == "hull") loadout.Hull = new CultRecordRef(new CultRecordKey(key)); - else loadout.Slots[1].Design = mod; - var before = Hash(Catalog); + else loadout.Slots[1].Design = new CultRecordRef(new CultRecordKey(key)); - var error = Assert.Throws(() => Loadouts.Commit(Catalog, loadout, replace: false)); + Assert.True(Loadouts.Commit(Catalog, loadout, replace: false)); - Assert.Contains("names mod designs", error.Message); - Assert.Contains(key, error.Message); - Assert.Equal(before, Hash(Catalog)); - Assert.True(Loadouts.Commit(Catalog, HandBuilt(cache), replace: false)); + using var reopened = Open(); + var written = Assert.Single(reopened.GetAll()); + Assert.Equal(key, (where == "hull" ? written.Hull.Key : written.Slots[1].Design.Key).Value); } // Recapturing a name refuses without replace and leaves the file untouched; with replace it replaces, never duplicates. From 80283d18a9dd1cacd70eaf70f78b42105f6efc46 Mon Sep 17 00:00:00 2001 From: soul Date: Wed, 21 Oct 2026 20:36:14 +0200 Subject: [PATCH 162/307] Loadouts.Commit takes bossOf: the Loadout and Faction.BossLoadout land in one batch With bossOf set, the same conditional batch Expects the faction unchanged and Upserts it with BossLoadout = KeyOf(Name), so both land or neither does. The batch lives in an internal overload over a caller's cache; tests pin that a faction changed on disk after the read refuses both, and that a missing boss faction writes nothing. Co-Authored-By: Claude Opus 5.5 --- Assets/Scripts/ServerShared/Loadout.cs | 34 ++++++++--- tests/Aetheria.Shared.Tests/LoadoutTests.cs | 67 +++++++++++++++++++++ 2 files changed, 92 insertions(+), 9 deletions(-) diff --git a/Assets/Scripts/ServerShared/Loadout.cs b/Assets/Scripts/ServerShared/Loadout.cs index 15e832a58..b8b73a0ab 100644 --- a/Assets/Scripts/ServerShared/Loadout.cs +++ b/Assets/Scripts/ServerShared/Loadout.cs @@ -96,19 +96,28 @@ public static class Loadouts _ => "" }; - // The only writer of Loadout records. It opens its own short-lived cache over the catalog file, writable, and disposes it - // after the one commit. No other cache is touched, so no catalog instance play holds (or has mutated) can reach the - // file; a cache that already has the catalog open sees the loadout only once it reloads. - // The catalog is a stack of packages, the shipped content included (ruling catalog-is-a-package-stack), so a loadout may - // name any design the stack holds; one whose package is later removed fails closed where it is built, with its reason logged. - // Throws and writes nothing when the file is missing (capture never creates a catalog), when a loadout of this name - // exists under another key, or when one exists under KeyOf(Name) and replace is not set. The commit is conditional - // on the record at KeyOf(Name), so a loadout changed on disk since this open is not clobbered: returns false then. - public static bool Commit(string catalogPath, Loadout loadout, bool replace) + // The only writer of Loadout records, and of the Faction.BossLoadout that names one. It opens its own short-lived cache + // over the catalog file, writable, and disposes it after the one commit. No other cache is touched, so no catalog + // instance play holds (or has mutated) can reach the file; a cache that already has the catalog open sees the loadout + // only once it reloads. The catalog is a stack of packages, the shipped content included (ruling + // catalog-is-a-package-stack), so a loadout may name any design the stack holds; one whose package is later removed fails + // closed where it is built, with its reason logged. With bossOf set, the same batch makes the loadout that faction's boss + // (Faction.BossLoadout), so the loadout and the reference land together or not at all. + // Throws and writes nothing when the file is missing (capture never creates a catalog), when bossOf names no faction in + // it, when a loadout of this name exists under another key, or when one exists under KeyOf(Name) and replace is not set. + // The commit is conditional on the record at KeyOf(Name) and on the faction, so a record changed on disk since this open + // is not clobbered: returns false then, and neither is written. + public static bool Commit(string catalogPath, Loadout loadout, bool replace, CultRecordRef bossOf = default) { if (!File.Exists(catalogPath)) throw new InvalidOperationException($"Catalog {catalogPath} does not exist; a loadout commit never creates one."); using var cache = AetheriaStores.Open(catalogPath, catalogWritable: true); + return Commit(cache, loadout, replace, bossOf); + } + + // The one batch Commit(path, ...) makes, over a writable catalog cache the caller opened. + internal static bool Commit(CultCache cache, Loadout loadout, bool replace, CultRecordRef bossOf) + { var key = KeyOf(loadout.Name); var namesake = cache.GetAll().FirstOrDefault(other => other.Name == loadout.Name && !cache.RefOf(other).Key.Equals(key)); if (namesake != null) @@ -116,10 +125,17 @@ public static class Loadouts var existing = cache.Get(key); if (existing != null && !replace) throw new InvalidOperationException($"Loadout '{loadout.Name}' already exists; replace it explicitly."); + var boss = bossOf.IsSet() + ? cache.Get(bossOf) ?? throw new InvalidOperationException($"Loadout '{loadout.Name}': the boss faction is not in the catalog.") + : null; return cache.Commit(batch => { batch.Expect(key, existing); batch.Upsert(typeof(Loadout), loadout, key); + if (boss == null) return; + batch.Expect(bossOf.Key, boss); + boss.BossLoadout = new CultRecordRef(key); + batch.Upsert(boss.GetType(), boss, bossOf.Key); }); } diff --git a/tests/Aetheria.Shared.Tests/LoadoutTests.cs b/tests/Aetheria.Shared.Tests/LoadoutTests.cs index c073bf37e..6f55baa76 100644 --- a/tests/Aetheria.Shared.Tests/LoadoutTests.cs +++ b/tests/Aetheria.Shared.Tests/LoadoutTests.cs @@ -143,6 +143,73 @@ public sealed class LoadoutTests : IDisposable Assert.Equal(key, (where == "hull" ? written.Hull.Key : written.Slots[1].Design.Key).Value); } + // With bossOf, one commit writes the loadout and makes it that faction's boss: Faction.BossLoadout names its key. + [Fact] + public void ABossCommitWritesTheLoadoutAndTheFactionsBossReference() + { + using (var cache = Open()) + Assert.True(Loadouts.Commit(Catalog, HandBuilt(cache), replace: false, cache.RefOf(cache.GetByName("Maker")))); + + using (var cache = Open()) + { + var loadout = Assert.Single(cache.GetAll()); + Assert.Equal(Loadouts.KeyOf("Skiff build"), cache.RefOf(loadout).Key); + Assert.Same(loadout, Loadouts.BossOf(cache, cache.GetByName("Maker"), out var refusal)); + Assert.Equal(BossRefusal.None, refusal); + } + } + + // The loadout and the boss reference are one batch: when the faction changed on disk after the commit's cache read it, + // the commit returns false and neither lands, though the loadout alone would have; the other writer's change survives. + [Fact] + public void ABossCommitOverAFactionChangedOnDiskWritesNeitherTheLoadoutNorTheReference() + { + Loadout loadout; + CultRecordRef maker; + using (var read = Open()) + { + loadout = HandBuilt(read); + maker = read.RefOf(read.GetByName("Maker")); + } + + using (var stale = AetheriaStores.Open(Catalog, catalogWritable: true)) + { + Assert.NotNull(stale.Get(maker)); + using (var other = AetheriaStores.Open(Catalog, catalogWritable: true)) + { + var fresh = other.Get(maker); + Assert.True(other.Commit(batch => + { + batch.Expect(maker.Key, fresh); + fresh.ShortName = "NEW"; + batch.Upsert(typeof(Faction), fresh, maker.Key); + })); + } + + Assert.False(Loadouts.Commit(stale, loadout, replace: false, maker)); + } + + using var after = Open(); + Assert.Empty(after.GetAll()); + var faction = after.Get(maker); + Assert.Equal("NEW", faction.ShortName); + Assert.False(faction.BossLoadout.IsSet()); + } + + // A boss faction the catalog does not hold is refused before anything is written. + [Fact] + public void ABossCommitNamingNoFactionIsRefusedAndNothingIsWritten() + { + var before = Hash(Catalog); + using (var cache = Open()) + { + var error = Assert.Throws(() => + Loadouts.Commit(Catalog, HandBuilt(cache), replace: false, new CultRecordRef(new CultRecordKey("faction:absent")))); + Assert.Contains("boss faction is not in the catalog", error.Message); + } + Assert.Equal(before, Hash(Catalog)); + } + // Recapturing a name refuses without replace and leaves the file untouched; with replace it replaces, never duplicates. [Fact] public void SameNameCaptureReplacesOnlyWhenAsked() From 3bfac250279141aef87cf56129f84c9d368d3bbb Mon Sep 17 00:00:00 2001 From: soul Date: Thu, 22 Oct 2026 20:02:16 +0200 Subject: [PATCH 163/307] New Game is a two-pane scenario picker: names by group, selected brief/seed/ambient, one Launch, arrows and Enter MainMenu.AddScenarios and the per-scenario launching buttons are deleted; the seven Clear+Title pairs collapse into Next. The main menu EventSystem no longer deselects on background click so the arrows keep working. Co-Authored-By: Claude Sonnet 5.5 --- Assets/Scenes/Main Menu.unity | 2 +- Assets/Scripts/UI/MainMenu.cs | 68 +++++++-------- Assets/Scripts/UI/ScenarioPicker.cs | 130 ++++++++++++++++++++++++++++ 3 files changed, 162 insertions(+), 38 deletions(-) create mode 100644 Assets/Scripts/UI/ScenarioPicker.cs diff --git a/Assets/Scenes/Main Menu.unity b/Assets/Scenes/Main Menu.unity index 4fafe8336..c1f111f09 100644 --- a/Assets/Scenes/Main Menu.unity +++ b/Assets/Scenes/Main Menu.unity @@ -525,7 +525,7 @@ MonoBehaviour: type: 3} m_TrackedDeviceOrientationAction: {fileID: 1025543830046995696, guid: ca9f5fa95ffab41fb9a615ab714db018, type: 3} - m_DeselectOnBackgroundClick: 1 + m_DeselectOnBackgroundClick: 0 m_PointerBehavior: 0 --- !u!114 &553528108 MonoBehaviour: diff --git a/Assets/Scripts/UI/MainMenu.cs b/Assets/Scripts/UI/MainMenu.cs index e72ce5263..401f4b2af 100644 --- a/Assets/Scripts/UI/MainMenu.cs +++ b/Assets/Scripts/UI/MainMenu.cs @@ -23,7 +23,7 @@ public class MainMenu : MonoBehaviour public float FadeAlphaExponent = 2; public float FadePositionExponent = 2; - private (PropertiesPanel panel, CanvasGroup group) _currentMenu, _nextMenu; + private (PropertiesPanel panel, CanvasGroup group, PropertiesPanel detail) _currentMenu, _nextMenu; private bool _fadeFromRight; private float _fadeLerp; private bool _fading; @@ -38,10 +38,14 @@ public class MainMenu : MonoBehaviour _panelPosition = PanelPrototype.transform.position; var panel1 = PanelPrototype.Instantiate(); - _currentMenu = (panel1, panel1.GetComponent()); + var detail1 = PanelPrototype.Instantiate(); + ScenarioPicker.Dock(detail1, panel1); + _currentMenu = (panel1, panel1.GetComponent(), detail1); var panel2 = PanelPrototype.Instantiate(); - _nextMenu = (panel2, panel2.GetComponent()); + var detail2 = PanelPrototype.Instantiate(); + ScenarioPicker.Dock(detail2, panel2); + _nextMenu = (panel2, panel2.GetComponent(), detail2); _currentMenu.panel.gameObject.SetActive(false); //_saveDirectory = ActionGameManager.GameDataDirectory.CreateSubdirectory("Saves"); @@ -74,7 +78,7 @@ public class MainMenu : MonoBehaviour } } - private string TitleSubtitle(string title, string subtitle) => $"{title}\n{subtitle}"; + public static string TitleSubtitle(string title, string subtitle) => $"{title}\n{subtitle}"; private void Fade(bool fromRight) { @@ -87,10 +91,18 @@ public class MainMenu : MonoBehaviour private static bool _exclusionsShown; - private void ShowMain() + // Starts the next screen on an empty panel with a title and its scenario detail pane hidden and empty. + private void Next(string title) { _nextMenu.panel.Clear(); - _nextMenu.panel.Title.text = TitleSubtitle("aetheria", "terminus"); + _nextMenu.panel.Title.text = title; + _nextMenu.detail.Clear(); + _nextMenu.detail.gameObject.SetActive(false); + } + + private void ShowMain() + { + Next(TitleSubtitle("aetheria", "terminus")); if (ActionGameManager.CurrentGalaxy == null) { // No run is live: at the title, after death, or after a new run failed to start. A saved run exists only as @@ -131,31 +143,18 @@ public class MainMenu : MonoBehaviour // to set up a run and the test arenas are listed only in editor and development builds. private void ShowScenarios() { - _nextMenu.panel.Clear(); - _nextMenu.panel.Title.text = TitleSubtitle("new game", "scenarios"); - AddScenarios(Scenarios.Modes); + Next(TitleSubtitle("new game", "scenarios")); + var groups = new List<(string label, Scenario[] scenarios)> { ("modes", Scenarios.Modes) }; if (Debug.isDebugBuild) { - _nextMenu.panel.AddSection("development"); - AddScenarios(Scenarios.Development); - _nextMenu.panel.AddSection("test arenas"); - AddScenarios(Scenarios.Tests); + groups.Add(("development", Scenarios.Development)); + groups.Add(("test arenas", Scenarios.Tests)); } - _nextMenu.panel.AddButton("Back", - () => - { - ShowMain(); - Fade(false); - }); - } - - private void AddScenarios(IEnumerable scenarios) - { - foreach (var scenario in scenarios) + ScenarioPicker.Show(_nextMenu.panel, _nextMenu.detail, groups, Launch, () => { - _nextMenu.panel.AddButton(scenario.Name, () => Launch(scenario)); - _nextMenu.panel.AddProperty(() => scenario.Brief); - } + ShowMain(); + Fade(false); + }); } // The only way into a new run: generates the scenario's galaxy off the main thread and enters the game, where @@ -263,8 +262,7 @@ public class MainMenu : MonoBehaviour private void ShowSettings() { - _nextMenu.panel.Clear(); - _nextMenu.panel.Title.text = "settings"; + Next("settings"); _nextMenu.panel.AddButton("Gameplay", () => { @@ -299,8 +297,7 @@ public class MainMenu : MonoBehaviour private void ShowGameplaySettings() { - _nextMenu.panel.Clear(); - _nextMenu.panel.Title.text = TitleSubtitle("gameplay", "settings"); + Next(TitleSubtitle("gameplay", "settings")); _nextMenu.panel.AddField("Name", () => ActionGameManager.PlayerSettings.Name, s => ActionGameManager.PlayerSettings.Name = s); @@ -322,8 +319,7 @@ public class MainMenu : MonoBehaviour private void ShowGraphicsSettings() { - _nextMenu.panel.Clear(); - _nextMenu.panel.Title.text = TitleSubtitle("graphics", "settings"); + Next(TitleSubtitle("graphics", "settings")); _nextMenu.panel.AddField("Nebula Quality", () => (int)ActionGameManager.PlayerSettings.GraphicsSettings.NebulaQuality, i => @@ -347,8 +343,7 @@ public class MainMenu : MonoBehaviour private void ShowInputSettings() { - _nextMenu.panel.Clear(); - _nextMenu.panel.Title.text = TitleSubtitle("input", "settings"); + Next(TitleSubtitle("input", "settings")); _nextMenu.panel.AddButton("Back", () => { @@ -359,8 +354,7 @@ public class MainMenu : MonoBehaviour private void ShowAudioSettings() { - _nextMenu.panel.Clear(); - _nextMenu.panel.Title.text = TitleSubtitle("audio", "settings"); + Next(TitleSubtitle("audio", "settings")); _nextMenu.panel.AddButton("Back", () => { diff --git a/Assets/Scripts/UI/ScenarioPicker.cs b/Assets/Scripts/UI/ScenarioPicker.cs new file mode 100644 index 000000000..4d6ab393e --- /dev/null +++ b/Assets/Scripts/UI/ScenarioPicker.cs @@ -0,0 +1,130 @@ +using System; +using System.Collections.Generic; +using UniRx; +using UniRx.Triggers; +using UnityEngine; +using UnityEngine.EventSystems; +using UnityEngine.UI; + +// The New Game screen: scenario names on the left pane by group, the selected scenario's brief, seed and ambient on a +// docked right pane, one Launch. This class owns which scenario is selected (one index into the rows it built); +// everything the screen shows about a scenario, and what Launch and Enter launch, reads that index. Keys arrive only as +// Input System UI events on the focused Launch button, so Enter and a mouse click are the same Button.onClick. +public sealed class ScenarioPicker +{ + private readonly List _scenarios = new List(); + private readonly List _groupLabels = new List(); + private readonly List