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/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/ItemManager.cs b/Assets/Scripts/ServerShared/ItemManager.cs index b33b17224..090fe1545 100644 --- a/Assets/Scripts/ServerShared/ItemManager.cs +++ b/Assets/Scripts/ServerShared/ItemManager.cs @@ -17,7 +17,9 @@ using float4 = CultMath.float4; public class ItemManager { - public Random Random = new Random((uint) (DateTime.Now.Ticks%uint.MaxValue)); + // 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/LoadoutGenerator.cs b/Assets/Scripts/ServerShared/LoadoutGenerator.cs index 2e925a201..b75f0d30a 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(ItemManager.ItemData, 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(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 || @@ -231,9 +234,16 @@ 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 stage with no faction included. public bool IsAvailable(FactionProductData product) => - Galaxy == null || Faction == null || - ItemManager.ItemData.Get(product.Manufacturer) == Faction || Faction.Allegiance.ContainsKey(product.Manufacturer); + 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. 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 @@ -248,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))); 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/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/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 d0e98d3d2..8ca6601a1 100644 --- a/Assets/Scripts/ServerShared/Zone.cs +++ b/Assets/Scripts/ServerShared/Zone.cs @@ -266,14 +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. + // 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 = _itemManager.Random.NextFloat3(float3(-pack.Radius * .5f), float3(pack.Radius * .5f)); + 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/docs/aetheria-arena-target.md b/docs/aetheria-arena-target.md new file mode 100644 index 000000000..d6f344501 --- /dev/null +++ b/docs/aetheria-arena-target.md @@ -0,0 +1,178 @@ +# Aetheria arena: deterministic headless matches between swappable brains + +Date: 2026-10-21. Status: target, drafted by Imagination for Self to commit and admit. +Body pinned at `GameCult/Aetheria` origin/master `d52b50f9`. Probes ran on that tree. + +## 1. Why + +The operator ruled that arenas are a design instrument (`aetheria-release:ruling:arenas-explore-the-metagame`). Agents +explore the metagame through virtual play, and the design iterates on what they find. Her words: "We use agents to explore the +metagame space so that we can iterate on the game design through virtual play." The combat Colosseum develops NPC +behaviour by implementing many strategies over shared primitives, and the arena selects among them +(`colosseum-is-the-npc-behaviour-lab`). Colosseum statistics fit the composition model that settles fights off screen +(`colosseum-feeds-matchup-model`, `matchup-composition-model`). The strategic arena trains on generated galaxies varied +across seeds (`strategic-start-r2-generator-trains`). The demo waits on how well that sim performs +(`demo-start-gated-on-sim-quality`). + +All of that rests on one foundation. That foundation is the campaign: + +- A match is a pure function of its inputs. +- A brain reaches the world only through a typed seam. +- A match can be recorded, replayed and checked. +- Matches run headless on Yggdrasil fast enough to beat rating noise. + +The strategic state, its rules, the matchup model and the strategic arena are later campaigns (section 6). They consume this +foundation and do not reshape it. + +## 2. Body facts (probed at d52b50f9) + +Randomness: +- **Combat dice.** Combat dice are per shot: `FireControl.Commit` seeds a local generator from `Zone.CombatSeed` and the + shot id (`FireControl.cs:931`). AI dice are per pilot: `SimulationDice.For(CombatSeed, CombatSampleStream|PatrolStream, + Agent.Id)` (`SimulationDice.cs:9-38`, `Combat.cs:178`, `Zone.cs:320-323`). Drop dice use `Zone.DropSeed` and the + death ordinal (`Zone.cs:131,159,460`). +- **CombatSeed ignores the run seed.** `Zone.CombatSeed` is `GalaxyZone.Name.StableHash()` (`Zone.cs:158`), with no run + seed in it. This is ruled: `aetheria-release:ruling:run-seed-drops-only` (2026-10-18, operator). It means one scenario + replayed at one galaxy seed rolls identical dice every round. +- **The item stream at run start.** `ItemManager.Random` is still clock-seeded at construction (`ItemManager.cs:20`). + `RunStart.SeedItems` reseeds it from the galaxy seed before arena generation (step 1) and before staging (step 2) + (`RunStart.cs:62-65`). After staging, the stream runs on shared. Its draws are lot quality on every mint + (`ItemManager.cs:147,165`), the scatter of an unpacked ship packed at the origin (`Zone.cs:276`), the wormhole exit and + console spawns (`ActionGameManager.cs:589-593,710`), and staged and generated fits (`ScenarioStage.cs:31,92`). +- **Zone generation.** It seeds a local generator from the zone's name hash and its position (`ZoneGenerator.cs:52-53`). A + zone reached by warp is generated in `ActionGameManager.PopulateLevel` (`:716-731`), a Unity owner, and not in + ServerShared. +- **MIConvexHull is gone.** Commit `e56af288` replaced it with a double-precision Delaunay (`Delaunay.cs`). The + `colosseum-npc-lab` follow-up's "unseeded MIConvexHull randoms" item is stale. +- **Trig is the host's libm.** CultMath trig and pow forward to `MathF` (`CultLib packages/cultmath/src/CultMath/math.cs:62-70`). + Cross-host float identity is therefore unproven: Windows UCRT and glibc may differ in the last ulp. + +Determinism probe: +- **What ran.** A scratch partial of `RunStartTests` on Yggdrasil, Linux, .NET 10.0.12. It ran `LaunchDuel`: the Duel + scenario, seed 104, the item stream reseeded. The player is scripted to fire every 90 steps and the rival is an AI. It + stepped 36,000 steps at 1/60 s and hashed `Snapshot` every step. +- **Result.** Three separate processes gave the same hash, `082F7400...`. The first death came at step 1445. Generation + took about 325 ms per launch, and stepping ran about 9,700 steps/s. That is about 160x real time, but mostly with one + ship left after the first death, so it is not yet a two-ship throughput figure. +- **Existing pin.** `SimClockTests.OneHistoryTwice` already pins in-process repeatability of the Duel. + +How brains reach the world: +- **The pilot writes ship fields directly.** `Agent.Update` writes `Ship.Turn`, `Ship.MovementDirection` and + `TryActivateConsumable` (`Agent.cs:53-110`). `Combat` writes `Turn` and calls fire control (`Combat.cs:149` and + others). `MoveTo` writes too. +- **The flight decides targets.** `Flight` picks `Ship.Target` (`Flight.cs:178`) and `Agent.Task` from the union of its + members' `VisibleEnemies`, so target choice is sensor-gated. Flights are keyed by faction, one per zone + (`Zone.cs:75,312`), and tick in `Zone.Update` (`:363`). +- **Aiming reads ground truth.** `Combat` reads the target's true `Position` (`Combat.cs:52`). Its picture of the + target's weapons comes from the EW book (`Combat.cs:55-60`). +- **The player has its own writes.** The player path is `ActionGameManager`, with 12 direct writes of the same fields. +- **Agents hold live references.** An agent holds live `Ship`, `Zone` (through `Ship.Zone`) and `ItemManager` + references (`Agent.cs:27-31`). Nothing narrows what a brain can read or write. +- **Defined but unused tasks.** `HaulingTask`, `Mining`, `StationTowing` and `Survey` are defined, but nothing + constructs them. + +Harness: +- **The run owner.** `RunStart` owns new runs and every run is a `Scenario`, compiled C# + (`Scenario.cs`, `Scenarios/*.cs`: 17 scenarios, Duel and Pursuit among them). +- **Headless today.** `Zone.Update` steps headless. Fights resolve headless, with commit and resolution in `FireControl`. + `SimClock` is the fixed-step owner. +- **No runner.** There is no headless runner command: `tools/AetherDb/Program.cs:25-56` has catalog commands only. The + scenario tests are the only headless driver. +- **Off screen.** Only the player's zone ticks (`ActionGameManager.cs:1357-1361`). Zone ownership is written only at + generation (`Galaxy.cs:346`) and on load (`:77`). There is no job or mission generator (`Galaxy.cs:578`). + +## 3. Invariants + +- `A-pure`: A match is a pure function of (code revision, catalog revision, scenario id, match seed, brain ids per + side, host determinism class). The same inputs give the same per-tick state digest, in any process, run alone or + in a batch. +- `A-no-clock`: No input to a match comes from the wall clock, process identity, hash-code ordering of reference + types, or a stream another subsystem advances. Every draw a match reaches comes from `SimulationDice` (or a successor + owner) keyed by a structured seed. A missing seed is refused, never defaulted to the clock. +- `A-seam`: A brain reads only its side's `PilotView` and writes only `PilotCommand`s. One applier in the zone step + writes ship control fields from commands. Brains, the player's input and the playground use that applier and nothing + else. A brain holds no live `Ship`, `Zone` or `ItemManager` reference. +- `A-truthful-view`: A view holds what that side's sensors and book give, the player's information. It never holds + ground truth about another side. (Depends on question `observation-fidelity`.) +- `A-replay`: A replay holds the match inputs, the command stream and periodic digests. Replaying it reproduces every + digest, or it names the first tick that diverges. A replay records its host determinism class. Under + `cross-host-determinism`, it is trusted only on that class. +- `A-compiled`: Brains and arena scenarios ship as compiled C# content in the Aetheria repo, under stable ids. + No model call, interpreter or DSL runs inside a match (`arenas-explore-the-metagame`, Aetheria's compiled-mods rule). +- `A-one-sim`: The arena runs the game's own simulation. There is no arena-only physics, combat or rules fork. An exploit + the arena finds is a game bug. +- `A-headless`: A match runs with no Unity type loaded, from a command that runs in Yggdrasil's dotnet container. +- `A-records`: Match results, replays and tournaments are CultCache documents in a directory the runner is given. They + never land in `GameData/run.cc`, `player.cc` or the catalog. + +## 4. Not in scope + +- Strategic state: holdings, stockpiles, faction relations, strength or fronts. Also any off-screen zone tick, ownership + change, job board or generator starting-infrastructure rule. These belong to campaign 2. +- The matchup composition model and its fitting or residual retraining (campaign 2 or 3). +- Character or fun scoring, the model judge and the operator's pairwise calibration + (`strategic-scoring-success-and-character`; strategic arena). +- A large strategy library. This campaign lands the seam and two brains, enough to prove a tournament. The vocabulary + grows as content afterwards. +- An Eve/CultMesh operator surface for tournaments. Results are CultCache documents, and a surface is a follow-up once + there is a consumer. +- Moving warp-time zone generation out of `ActionGameManager`. It becomes in scope only if a match crosses a wormhole, + and arena matches are one zone. +- Cross-host deterministic trig in CultMath. It is a CultLib follow-up if `cross-host-determinism` rules that way. +- The Unity presentation of arena matches, and replay viewing in the client. + +## 5. Identity, lifecycle, authority + +| Kind | Identity | Lifecycle | Authority (owner; forbidden writers) | +|---|---|---|---| +| Brain (compiled strategy) | A stable string id declared by the class, such as `doctrine.v1`. Ids are unique across the brain registry, which refuses a duplicate. Behaviour is versioned by code revision, not by the id | Added as code. Revised by commits: a behaviour change that should not merge ratings bumps the id's version. Retired brains stay compiled, become products or faction behaviour (`colosseum-is-the-npc-behaviour-lab`), or are deleted | Owner: the brain registry, compiled. Forbidden: any runtime-loaded or model-written brain, and a brain writing ship fields directly | +| Arena scenario | A stable id string on `Scenario`. Today the identity is `Name`, a display string, so a stable `Id` is a finding to add | Same as brains: code, revised by commits. A match records its id and the code revision | Owner: the `Scenarios` registry. Forbidden: a match staging entities outside `RunStart.Stage` | +| Match seed | A uint chosen by the tournament, nonzero. It feeds the galaxy seed, the item-stream steps and the start variance (per question `match-variance`) | Fixed per match, never rerolled. Replay reuses it | Owner: the runner, from the tournament spec. Forbidden: the clock, and `Scenario.Seed`'s clock fallback inside a match | +| Combat, drop and pilot dice | `SimulationDice.For(zoneSeed, stream, ordinal)`. `CombatSeed` is the zone name hash (ruled) and `DropSeed` mixes in the run seed | Derived each draw, never stored | Owner: `SimulationDice`. Forbidden: any draw on `ItemManager.Random` from behaviour or combat (`ai-sample-offset-off-item-stream`) | +| Item stream (`ItemManager.Random`) | One stream per `ItemManager` | Reseeded by `RunStart` steps 1 and 2. After that it advances on every mint | Owner: `RunStart` seeds it and `ItemManager` draws. Forbidden: its clock-seeded default reaching a match (`A-no-clock`) | +| PilotCommand | (match, tick, side, ship ordinal `Agent.Id`) | Produced by a brain each tick and consumed by the applier the same tick. Recorded in the replay, never re-derived | Owner: the side's brain, or player input. Forbidden: any writer of ship control fields other than the applier | +| PilotView | (match, tick, side) | Derived each tick from the side's sensors and book. Never stored, and never in the replay except as a digest if wanted | Owner: the view builder, one function. Forbidden: a brain reading `Zone`, `Ship` or `ItemManager` | +| Replay | A CultCache document keyed by match id = (tournament id, ordinal) | Written once when the match ends and never edited. Superseded only by a new match. A divergence on replay is a finding, not an edit | Owner: the runner's recorder. Forbidden: a replay written by the game client, until a cut says so | +| Match result | Keyed by match id, like the replay | Written with the replay. Read by the tournament and, later, by the matchup model | Owner: the runner. Forbidden: hand edits, and results with no replay | +| Tournament | A CultCache document keyed by a tournament id (date plus label). It names its brains, scenarios, seed range, code and catalog revisions | Created by the runner. Ratings are derived from results and recomputable. A rerun is a new tournament | Owner: the runner. Forbidden: ratings stored as truth without the results they derive from | +| Host determinism class | (OS, architecture, runtime) | Recorded on every replay and result | Owner: the runner. Forbidden: comparing digests across classes unless `cross-host-determinism` rules them one class | + +No cell is empty. The Scenario row exposes a gap: `Name` is today's only identity, and it is a display string. Cut 1 adds `Id`. + +## 6. Campaign split + +1. `aetheria-arena` (this one): determinism, the seam, replay, the headless runner and the first tournament. +2. `aetheria-living-world` (later): + - strategic state owned in ServerShared; + - the galaxy generator plus faction starting-infrastructure rules; + - the off-screen strategic tick at reduced detail; + - ownership changing hands; + - the lore institutions as damping rules (`snowball-damping-lore-institutions`); + - the job board from needs; + - the strategic arena on generated seeds, scored on success and character. + + It owns the demo gate (`demo-start-gated-on-sim-quality`) and absorbs follow-up `release-target-living-demo`'s map. +3. `aetheria-matchups` (later, after 1 and enough brains): Colosseum statistics fitting the composition model and the + on-screen residual loop. It is split from 2 because its foundation, a fitted statistical model, is a different + pipeline. Campaign 2 consumes its output through one typed predictor. + +## 7. Cut sequence (campaign 1) + +1. **Determinism ledger.** + - Add `Scenario.Id`. + - Refuse an unseeded `ItemManager` in a match (delete the clock default and pass the seed in). + - Move the `Zone.cs:276` scatter onto `SimulationDice`. + - Pin a two-ship AI-versus-AI digest test. + - Run the same digest on Starfire and on Yggdrasil, and record the cross-host result. +2. **Command seam.** Add `PilotCommand` and one applier in `Zone.Update`. Route Agent, Combat, MoveTo and + ActionGameManager's direct writes through it, and delete their direct writes. +3. **View seam.** Add a `PilotView` built from sensors and book. Brains lose their live `Ship`, `Zone` and `ItemManager` + references. `Flight` and `Minion` become `DoctrineBrain` behind an `IBrain` registry. Aiming moves to the view if + `observation-fidelity` rules for it. +4. **Replay.** The recorder writes inputs, commands and digests every N ticks as CultCache. The replayer reproduces + them and names the first divergent tick. Replay tests read an existing match. +5. **Runner.** `AetherDb arena`: a scenario, two brain ids and a seed range produce results and replays in a given + directory. It runs in Yggdrasil's dotnet container, with two-ship throughput measured and recorded. +6. **First tournament.** Round-robin over two or more brains (`DoctrineBrain` plus one variant) and the combat + scenarios, with enough seeds per pairing. Ratings are derived with confidence intervals, and the results document + is ready to feed `aetheria-matchups`. 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`). diff --git a/docs/aetheria-release-map.md b/docs/aetheria-release-map.md index 88267df37..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. @@ -11238,7 +11240,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 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 6a8bd72cb..ccf3fae29 100644 --- a/tests/Aetheria.Shared.Tests/ChildrenWithinReachTests.cs +++ b/tests/Aetheria.Shared.Tests/ChildrenWithinReachTests.cs @@ -44,7 +44,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/DeterminismLedgerTests.cs b/tests/Aetheria.Shared.Tests/DeterminismLedgerTests.cs new file mode 100644 index 000000000..1a1a64b1e --- /dev/null +++ b/tests/Aetheria.Shared.Tests/DeterminismLedgerTests.cs @@ -0,0 +1,244 @@ +/* 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 = "8ba2116ed0ecd574703846f493c408936d536e3b7c9e4652556103ec1b75098c"; + 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(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); + 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); + } + + 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() + { + var drifters = new List(); + var (galaxy, arena, staged, failures) = Launch(new Scripted(false, stage => + { + stage.Player(stage.Bare("Djinni"), float2(-50000, -50000)); + 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.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); + } + + 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); + } + + // 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() + { + 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}"); + } +} 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..76ad7c2f8 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,20 +94,31 @@ 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. 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(); } @@ -133,7 +146,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 +338,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 => { @@ -367,5 +380,129 @@ 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"))); + } + + // 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] + 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); } } 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); } } 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/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; 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/ShipProductTests.cs b/tests/Aetheria.Shared.Tests/ShipProductTests.cs index f6f3bad10..27e84629f 100644 --- a/tests/Aetheria.Shared.Tests/ShipProductTests.cs +++ b/tests/Aetheria.Shared.Tests/ShipProductTests.cs @@ -272,6 +272,11 @@ 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"); + 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); @@ -283,10 +288,30 @@ 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); + Assert.Equal(offOutput.Replace(", 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 +319,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/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 eda1ad0a8..b265ead4d 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; 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); diff --git a/tools/blender/aetheria_ships/factions/zhestokost.scad b/tools/blender/aetheria_ships/factions/zhestokost.scad index 9c4e65bb9..e077351ee 100644 --- a/tools/blender/aetheria_ships/factions/zhestokost.scad +++ b/tools/blender/aetheria_ships/factions/zhestokost.scad @@ -1,62 +1,211 @@ -// 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. +// 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 +// 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 + +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. +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/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/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 41ef836a6..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]"), } @@ -215,7 +216,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": @@ -543,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) diff --git a/tools/blender/tests/test_hull_source.py b/tools/blender/tests/test_hull_source.py index c1045dba0..362664de7 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,17 @@ 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" + MUZZLE = "every weapon-mount has a muzzle and no muzzle is orphaned" - def test_a_muzzle_aft_of_its_mount_is_refused(self): + 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(), [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]) + self.assertEqual(fixture.failed(), []) def test_a_mount_without_a_muzzle_and_a_muzzle_without_a_mount_are_refused(self): fixture = Fixture() @@ -242,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()