From 1f93e22239b05524fea1d82089004de89ecfdc61 Mon Sep 17 00:00:00 2001 From: Nibu Date: Wed, 11 Nov 2026 03:19:46 +0000 Subject: [PATCH 1/6] AetherDb: MunitionsFeed gives the munitions chain real sources; Mines; every round-firing gun names its round MunitionsFeed (run after Munitions) authors the chain's leaf recipes: extraction at mining (Rock, New Metals, Gold, Potassium, Diamond) and volatiles (Water, Methane, Ammonia), Algae at agriculture, Oxygen/Steel/Cellulose Pulp at processing, Circuit Board/Actuator at fabrication. Munitions adds the Mines commodity and recipe, AmmoType=Ammo on Slug Rifle, Long Slug Rifle and Light Machine Gun, AmmoType=Mines on the Mine Launcher, a real Light Machine Gun magazine (0 -> 100 rounds, reload 3 s), and Zhestokost depot stand-ins for mining, volatiles and agriculture so the new recipes' station kinds are placed. Tests read the shipped catalog (pending the blob commit). Co-Authored-By: Claude Sonnet 5.5 --- .../MunitionsSourcingCatalogTests.cs | 79 +++++++++++++++++ tools/AetherDb/Economy/Munitions.cs | 29 ++++++- tools/AetherDb/Economy/MunitionsFeed.cs | 84 +++++++++++++++++++ 3 files changed, 191 insertions(+), 1 deletion(-) create mode 100644 tests/Aetheria.Shared.Tests/MunitionsSourcingCatalogTests.cs create mode 100644 tools/AetherDb/Economy/MunitionsFeed.cs diff --git a/tests/Aetheria.Shared.Tests/MunitionsSourcingCatalogTests.cs b/tests/Aetheria.Shared.Tests/MunitionsSourcingCatalogTests.cs new file mode 100644 index 000000000..45c8db147 --- /dev/null +++ b/tests/Aetheria.Shared.Tests/MunitionsSourcingCatalogTests.cs @@ -0,0 +1,79 @@ +using System.Collections.Generic; +using System.Linq; +using GameCult.Caching; +using Xunit; + +// Ruling ballistics-real-ammo, read from the shipped catalog: every gun that fires a round names the commodity it draws, and every +// such commodity (and the chain behind it) is produced from extraction, not from nothing. A round with no source is a magazine +// nobody can ever refill. +public sealed class MunitionsSourcingCatalogTests +{ + // The guns that fire real rounds: ballistic and launcher hardpoints, and the mine layer (a launcher in all but hardpoint). + private static bool FiresRounds(WeaponItemData item, WeaponData data) => + item.HardpointType == HardpointType.Ballistic || item.HardpointType == HardpointType.Launcher || data is MineLayerData; + + // Kills: a ballistic gun (or the Light Machine Gun's free hose) authored with AmmoType unset, which reloads for nothing; a + // drawn commodity that is not a commodity record; a magazine too small for a reload to mean anything. + [Fact] + public void EveryRoundFiringGunNamesAnAmmunitionCommodityAndHoldsARealMagazine() + { + using var db = AetherDb.Open(); + var guns = db.Cache.GetAll() + .SelectMany(i => i.Behaviors.OfType().Where(d => FiresRounds(i, d)).Select(d => (i.Name, d))).ToArray(); + Assert.True(guns.Length >= 14, $"fixture: the catalog holds its round-firing guns ({guns.Length})"); + foreach (var (name, data) in guns) + { + Assert.True(data.AmmoType.IsSet(), $"{name} draws no ammunition"); + Assert.NotNull(db.Cache.Get(data.AmmoType.Key)); + Assert.True(data.MagazineSize >= 1, $"{name} has no magazine"); + } + Assert.Equal(new[] { "Ammo", "Mines", "Missiles" }, guns.Select(g => db.Cache.Get(g.d.AmmoType.Key).Name).Distinct().OrderBy(n => n).ToArray()); + } + + // Kills: an ammunition commodity no chain of recipes reaches raw goods for (the Ammo, Missiles and Mines leaves had no recipe at all). + [Fact] + public void EveryDrawnAmmunitionHasAFiniteProductionDepth() + { + using var db = AetherDb.Open(); + var depth = EconomyChecks.Deep(db.Cache); + var drawn = db.Cache.GetAll().SelectMany(i => i.Behaviors.OfType()).Where(d => d.AmmoType.IsSet()) + .Select(d => d.AmmoType.Key.Value).Distinct().ToArray(); + Assert.Equal(3, drawn.Length); + foreach (var key in drawn) + Assert.True(depth[key] != null, $"{db.Cache.Get(new CultRecordKey(key)).Name} has no finite production depth"); + } + + // Kills: a leaf of the munitions chain left without a source or a sink (the 19 L-source-sink faults this chain shipped with). + [Fact] + public void TheMunitionsChainHasNoSourceSinkFault() + { + using var db = AetherDb.Open(); + var chain = new HashSet(ChainOf(db.Cache), System.StringComparer.Ordinal); + Assert.True(chain.Count >= 25, $"fixture: the chain is walked ({chain.Count} goods)"); + var faults = EconomyChecks.SourceSink(db.Cache).Where(f => chain.Contains(KeyOfRecipe(f.Record))).ToArray(); + Assert.True(faults.Length == 0, string.Join("\n", faults.Select(f => f.Message))); + } + + private static string KeyOfRecipe(string recipeKey) => recipeKey.StartsWith("recipe:") ? recipeKey.Substring("recipe:".Length) : recipeKey; + + // Every good that feeds a drawn round, or fills a role of a round-firing gun, through the recipe graph; the rounds included. + private static IEnumerable ChainOf(CultCache catalog) + { + var recipes = catalog.GetAll().ToDictionary(r => r.Output.Key.Value); + var open = new Stack(catalog.GetAll().SelectMany(i => i.Behaviors.OfType()) + .Where(d => d.AmmoType.IsSet()).Select(d => d.AmmoType.Key.Value).Distinct()); + foreach (var item in catalog.GetAll().Where(i => i.Behaviors.OfType().Any(d => FiresRounds(i, d)))) + { + var recipe = Recipes.RecipeOf(catalog, item); + if (recipe == null) continue; + foreach (var role in Recipes.RoleInputsOf(catalog, recipe)) + foreach (var filler in Recipes.Fillers(catalog, role.Name)) open.Push(filler.Value); + } + var seen = new HashSet(open); + while (open.Count > 0) + if (recipes.TryGetValue(open.Pop(), out var recipe)) + foreach (var input in recipe.Inputs.Select(c => c.Commodity.Key.Value)) + if (seen.Add(input)) open.Push(input); + return seen; + } +} diff --git a/tools/AetherDb/Economy/Munitions.cs b/tools/AetherDb/Economy/Munitions.cs index 83cbee2f2..c20165253 100644 --- a/tools/AetherDb/Economy/Munitions.cs +++ b/tools/AetherDb/Economy/Munitions.cs @@ -32,7 +32,9 @@ public sealed class Munitions : EconomyBatch // Zhestokost has no station model of any kind yet. Its first planned model is the supply depot (ruling faction-station-models), // so its industrial kinds stand in on the depot until their own models land. One hop: depot itself has no Zhestokost stand-in. var zhestokost = FactionRef("Zhestokost"); - foreach (var kind in new[] { "processing", "fabrication", "munitions-works" }) + // The feed stock for the chain (MunitionsFeed) is extracted and grown where the arsenal's planners can reach it, so the mining, + // volatiles and agriculture kinds stand in on the depot too until the factions that own those industries place them. + foreach (var kind in new[] { "processing", "fabrication", "munitions-works", "mining", "volatiles", "agriculture" }) StandIn(kind, zhestokost, "depot"); // Interface goods: named by this chain, owned by another. @@ -153,6 +155,15 @@ public sealed class Munitions : EconomyBatch "A missile crate is a warhead and a motor in one box, and the box is the arsenal's most counted object: Zhestokost ties each launcher crew's allowance to a serial range, so a cache of loose crates is either a deserter's or a quartermaster's crime.", new string[0], (warhead, 1), (motor, 1)); + // Mines: the crate the mine launcher reloads from, a warhead per mine laid. Shape and stack as Missiles. + var mines = new SimpleCommodityData { Name = "Mines", Shape = new Shape(), MaxStack = 5, Mass = 7f, Price = 1400, + Description = "Crated proximity mines: a high-explosive warhead in a steel drum with a fuzed trigger. The crate a mine launcher reloads from." }; + _a.Put(mines, CommodityKey("mines")); + var minesRef = new CultRecordRef(CommodityKey("mines")); + Recipe(minesRef, "munitions-works", 900, 20, 2, + "A mine is a warhead with patience. Every blockade that holds a lane for a season is paid for in crates of these, and a convoy captain who finds a lane suddenly quiet counts the hours since the last tender came through.", + new string[0], (warhead, 1), (steel, 2)); + var dispenser = Compound("mine-dispenser", "Mine Dispenser", "A rack and ejector that lays mines in a pattern.", 20f, 1600); Recipe(dispenser, "fabrication", 900, 20, 1, "Little more than steel and actuators, which is why every convoy navy can build one and every blockade starts with one.", @@ -171,6 +182,22 @@ public sealed class Munitions : EconomyBatch { foreach (var data in w.Behaviors.OfType()) data.AmmoType = new CultRecordRef(CommodityKey("missiles")); }); + + // Every ballistic gun draws a round (ruling ballistics-real-ammo). The Light Machine Gun had no magazine, a free hose; it gets a + // real one so a single Ammo crate reloads it like the other guns. The mine launcher draws a Mines crate. + foreach (var gun in new[] { "Slug Rifle", "Long Slug Rifle", "Light Machine Gun" }) + _a.Edit(gun, w => + { + foreach (var data in w.Behaviors.OfType()) + { + data.AmmoType = new CultRecordRef(ammo.Key); + if (gun == "Light Machine Gun") { data.MagazineSize = 100; data.ReloadTime = 3f; } + } + }); + _a.Edit("Mine Launcher", w => + { + foreach (var data in w.Behaviors.OfType()) data.AmmoType = new CultRecordRef(CommodityKey("mines")); + }); } private CultRecordRef FactionRef(string name) diff --git a/tools/AetherDb/Economy/MunitionsFeed.cs b/tools/AetherDb/Economy/MunitionsFeed.cs new file mode 100644 index 000000000..eab9c16cc --- /dev/null +++ b/tools/AetherDb/Economy/MunitionsFeed.cs @@ -0,0 +1,84 @@ +using System; +using System.Collections.Generic; +using System.Linq; +using GameCult.Caching; + +// Economy content, batch 1b: where the munitions chain's raw materials come from. Munitions writes the chain and names its leaves +// (Water, Methane, Ammonia, Oxygen, Potassium, Diamond, Steel, Cellulose Pulp, Actuator, Circuit Board) without recipes; this batch +// gives each a source at a station kind whose description already says it does that work: input-free extraction at mining (ore, +// Gold, Potassium, Diamond) and volatiles (Water, Methane, Ammonia), and processing or fabrication from those goods for the rest. +// Run after Munitions: it edits nothing the other batch writes and every output it names must exist. +public sealed class MunitionsFeed : EconomyBatch +{ + private EconomyAuthor _a; + + public override void Author(EconomyAuthor author) + { + _a = author; + + // Extraction: no inputs, so these are the chain's raw goods. + var rock = Make("Rock", "mining", 300, 10, 8, + "Nobody on a mining station calls it rock; it is the overburden, the slag, the thing the real cargo was buried in. It is also what every smelter, board shop and cement yard starts from, so the cheapest hold in the verse is the one that decides who can build."); + var metals = Make("New Metals", "mining", 300, 12, 6, + "The ore that built the cities: whoever holds the claim feeds every foundry downstream, which is why mining rights are the one commodity the garrisons will not leave unguarded."); + var gold = Make("Gold", "mining", 600, 8, 2, + "Gold has a use besides vanity: it does not corrode in a contact. Boards bound for a hull in a cold or wet corridor are gold-plated, and a prospector who finds a seam sells it to the board shops before the jewellers."); + var potassium = Make("Potassium", "mining", 300, 8, 4, + "Potash and saltpetre salts turn up in the same evaporite beds that mark old water. Farms want it for fertiliser and the arsenal wants it for powder; the survey crews tag the beds by which buyer reached them first."); + var diamond = Make("Diamond", "mining", 1200, 12, 1, + "Industrial stones from the deep kimberlite cores: grit for lining barrels and cutting tools rather than jewellery. A miner who sells one for a ring has wasted a barrel."); + var water = Make("Water", "volatiles", 300, 8, 6, + "Every station buys water first and everything else second. The ice haulers run on the knowledge that a station's water reserve is the one stock it cannot be talked out of counting."); + var methane = Make("Methane", "volatiles", 300, 8, 4, + "Skimmed from the cold ice where it keeps for ever. Hauled home it is cooking gas, a feedstock for the formaldehyde plants, and the first thing that burns when a tank is struck."); + var ammonia = Make("Ammonia", "volatiles", 300, 8, 4, + "Ammonia ice is the nitrogen the whole verse eats and shoots: the farms spread it and the arsenals nitrate it. The skimmers who work cold bodies know exactly which of the two their hold is bound for by the hauler waiting at the dock."); + + // Processing from those goods. + var oxygen = Make("Oxygen", "processing", 240, 10, 3, + "Cracking water ice pays twice: the oxygen goes to lungs and acid towers, and the hydrogen is fuel. A station that sells its oxygen cheap is either swimming in ice or hiding a shortage.", + (water, 3)); + + var steel = Make("Steel", "processing", 600, 20, 4, + "Foundries on the ore belts pour bar, sheet and billet from alloy ore and slag flux. A pour is cheap; the rolling mills that finish it are the part a blockade starves, and every gun, hull plate and nail in the verse is waiting behind them.", + (metals, 3), (rock, 1)); + + // Algae is a catalog commodity the agriculture station description says grows from Water and Potassium. + var algae = Make("Algae", "agriculture", 600, 12, 6, + "Vats of green water under lamp-light: the first thing a new habitat grows, because it eats ash and sunlight and gives back food, fibre and air. A farm that loses its algae loses its station within the season.", + (water, 2), (potassium, 1)); + + var pulp = Make("Cellulose Pulp", "processing", 300, 10, 2, + "Algae from the farm vats is washed, digested and pressed into pulp. The same vats make paper for a Zhestokost order book and guncotton stock for its arsenal, and the split is seen in how many hands the washing hall hires.", + (algae, 3)); + + var board = Make("Circuit Board", "fabrication", 600, 14, 2, + "Gold-plated contacts on a silicate board: the oldest reliable electronics. A region that cannot make its own boards imports a shortage in every device that has a fuze, a sensor or a lock.", + (gold, 1), (rock, 2)); + + Make("Actuator", "fabrication", 600, 16, 1, + "A steel housing, a motor and a controller board. It opens hatches and feeds guns, and a repair crew keeps a drawer of them because everything on a ship eventually needs to move on command.", + (steel, 2), (board, 1)); + } + + private CultRecordRef Make(string name, string kind, float cycle, int workforce, int count, string lore, + params (CultRecordRef Commodity, int Count)[] inputs) + { + var named = _a.Catalog.GetAll().Where(i => i.Name == name).ToArray(); + if (named.Length != 1) throw new InvalidOperationException($"{named.Length} items are named '{name}'; the munitions feed needs exactly one."); + var output = new CultRecordRef(_a.Catalog.RefOf(named[0]).Key); + _a.Put(new RecipeData + { + Output = output, + OutputCount = count, + Inputs = inputs.Select(i => new CommodityCount { Commodity = i.Commodity, Count = i.Count }).ToList(), + Cycle = cycle, + Workforce = workforce, + Upkeep = new List(), + StationKind = new CultRecordRef(StationKindData.KeyOf(kind)), + LoreHook = lore, + Fills = new List() + }, RecipeData.KeyOf(output.Key)); + return output; + } +} -- 2.47.3 From 54632d4eb92604e0934b32b67b48143e36b2dadc Mon Sep 17 00:00:00 2001 From: Nibu Date: Wed, 11 Nov 2026 03:32:45 +0000 Subject: [PATCH 2/6] Tests: pin Mines in the commodity count; the stand-in fixture uses salvage, since Zhestokost now stands in for mining Co-Authored-By: Claude Sonnet 5.5 --- .../Aetheria.Shared.Tests/CommodityRecordsTests.cs | 2 +- tests/Aetheria.Shared.Tests/StationKindTests.cs | 14 +++++++------- 2 files changed, 8 insertions(+), 8 deletions(-) diff --git a/tests/Aetheria.Shared.Tests/CommodityRecordsTests.cs b/tests/Aetheria.Shared.Tests/CommodityRecordsTests.cs index 91bce1993..b59885c26 100644 --- a/tests/Aetheria.Shared.Tests/CommodityRecordsTests.cs +++ b/tests/Aetheria.Shared.Tests/CommodityRecordsTests.cs @@ -13,7 +13,7 @@ public sealed class CommodityRecordsTests public void ShippedCatalogOpensWithTheRetiredKeysGone() { using var cache = RestoredHullsTests.OpenCatalog(); - Assert.Equal(23, cache.GetAll().Count()); + Assert.Equal(24, cache.GetAll().Count()); Assert.Equal(63, cache.GetAll().Count()); Assert.Contains(cache.GetAll(), c => c.Name == "Ammo"); Assert.All(cache.GetAll(), c => Assert.False(string.IsNullOrEmpty(c.Name))); diff --git a/tests/Aetheria.Shared.Tests/StationKindTests.cs b/tests/Aetheria.Shared.Tests/StationKindTests.cs index 2082a3719..02db0c081 100644 --- a/tests/Aetheria.Shared.Tests/StationKindTests.cs +++ b/tests/Aetheria.Shared.Tests/StationKindTests.cs @@ -248,17 +248,17 @@ public sealed class StationKindTests : IDisposable { var world = World.Make(this, zenithOffered: false); var hub = world.Sell(world.Zh, "trade-hub", "Zh Hub"); - world.StandIn("mining", world.Zh, "trade-hub"); - var mining = world.Cache.Get(Kind("mining")); + world.StandIn("salvage", world.Zh, "trade-hub"); + var salvage = world.Cache.Get(Kind("salvage")); - Assert.Contains(Kind("mining").Key.Value, StationKinds.KindsOf(world.Cache, world.Zh).Select(kind => kind.Key.Value)); - Assert.Equal(Kind("trade-hub").Key, StationKinds.HullKindFor(world.Cache, world.Zh, Kind("mining")).Key); + Assert.Contains(Kind("salvage").Key.Value, StationKinds.KindsOf(world.Cache, world.Zh).Select(kind => kind.Key.Value)); + Assert.Equal(Kind("trade-hub").Key, StationKinds.HullKindFor(world.Cache, world.Zh, Kind("salvage")).Key); for (uint seed = 1; seed <= 10; seed++) { - var pack = world.Generator(world.Zh, seed).GenerateStationLoadout(Kind("mining")); + var pack = world.Generator(world.Zh, seed).GenerateStationLoadout(Kind("salvage")); Assert.Equal("Zh Hub", world.HullName(pack)); - Assert.Equal(Kind("mining").Key, pack.Kind.Key); - Assert.Equal(mining.Name, pack.Name); + Assert.Equal(Kind("salvage").Key, pack.Kind.Key); + Assert.Equal(salvage.Name, pack.Name); } var own = world.Generator(world.Zh).GenerateStationLoadout(Kind("trade-hub")); Assert.Equal(hub.Name, own.Name); -- 2.47.3 From 8948b99e5964045c7559b9e382cc8d13ff81d7e2 Mon Sep 17 00:00:00 2001 From: Nibu Date: Wed, 11 Nov 2026 03:32:54 +0000 Subject: [PATCH 3/6] GameData/Aetheria.cc: Munitions + MunitionsFeed apply (chain sources, Mines, gun ammo) sha256 0f81d35d4cec8f57178220f20bca98ffaa6709783349efe3cc28aa64ea1f02b1 economy author Munitions MunitionsFeed apply: 14 extraction/processing recipes (Rock, New Metals, Gold, Potassium, Diamond, Water, Methane, Ammonia, Algae, Oxygen, Steel, Cellulose Pulp, Circuit Board, Actuator), Mines commodity and recipe, Zhestokost depot stand-ins for mining, volatiles and agriculture. AmmoType=Ammo on Slug Rifle, Long Slug Rifle, Light Machine Gun; AmmoType=Mines on the Mine Launcher. Stat change: Light Machine Gun MagazineSize 0 -> 100, ReloadTime 1 -> 3 s. Co-Authored-By: Claude Sonnet 5.5 --- GameData/Aetheria.cc | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/GameData/Aetheria.cc b/GameData/Aetheria.cc index e46487c5f..553e6bcce 100644 --- a/GameData/Aetheria.cc +++ b/GameData/Aetheria.cc @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:b737d1c74f48c3c0a2e911efd2824345c8491cd8a0c0d3754d78b85d3ea29c40 -size 213296 +oid sha256:0f81d35d4cec8f57178220f20bca98ffaa6709783349efe3cc28aa64ea1f02b1 +size 220607 -- 2.47.3 From c21fad28f566447b2c2769a87cf456b6d706ff11 Mon Sep 17 00:00:00 2001 From: Nibu Date: Wed, 11 Nov 2026 03:36:58 +0000 Subject: [PATCH 4/6] Ammunition stock has one owner; a named AmmoType makes a one-round magazine Loadouts.StockAmmunition is the single owner of crate stocking. The generator calls it for generated ships and turrets, and Loadouts.Materialize calls it at the end, which covers every Loadout-built entity: scenario stage.Fit/Preset, the boss in ZoneGenerator, and the console-built ship. Paths left alone: Unpack of a pack or save (an entity's cargo is its own and a saved game's cargo is never touched on load), scenario stage.Cargo (adds on top of the stock), the LoadoutCommit validation build (discarded). The new-run player ship is stage.Generated and so already stocked by the generator. A hull with no bay gets no crates and a log line. InstantWeapon: a weapon that names an AmmoType is magazine-fed with an effective magazine of max(1, MagazineSize): every round leaves the magazine and the reload takes a crate. A weapon with no AmmoType and a magazine of 0 or 1 stays free. HUD shows rounds/reserve for every AmmoType weapon. ConstantWeapon keeps its own per-interval crate draw. Co-Authored-By: Claude Sonnet 5.5 --- .../ServerShared/Behaviors/InstantWeapon.cs | 16 ++-- Assets/Scripts/ServerShared/Loadout.cs | 15 ++++ .../Scripts/ServerShared/LoadoutGenerator.cs | 16 +--- Assets/Scripts/UI/HUD/SchematicDisplay.cs | 7 +- .../AmmunitionStockingTests.cs | 74 +++++++++++++++++++ .../FireControlPerWeaponTests.cs | 48 +++++++++++- 6 files changed, 148 insertions(+), 28 deletions(-) diff --git a/Assets/Scripts/ServerShared/Behaviors/InstantWeapon.cs b/Assets/Scripts/ServerShared/Behaviors/InstantWeapon.cs index c8587f72d..96cdc330e 100644 --- a/Assets/Scripts/ServerShared/Behaviors/InstantWeapon.cs +++ b/Assets/Scripts/ServerShared/Behaviors/InstantWeapon.cs @@ -133,13 +133,13 @@ public class InstantWeapon : Weapon, IProgressBehavior, IEventBehavior, IPowerCo public InstantWeapon(InstantWeaponData data, EquippedItem item) : base(data, item) { _data = data; - _ammo = data.MagazineSize; + _ammo = Magazine; } public InstantWeapon(InstantWeaponData data, ConsumableItemEffect item) : base(data, item) { _data = data; - _ammo = data.MagazineSize; + _ammo = Magazine; } protected void Trigger() @@ -229,9 +229,9 @@ public class InstantWeapon : Weapon, IProgressBehavior, IEventBehavior, IPowerCo if (!_coolingDown || !(_cooldown < 0)) return false; _overshoot = min(-_cooldown * CountdownSpan, dt); _coolingDown = false; - if (_data.MagazineSize > 0 && _ammo == 0 && TakeReloadRound()) + if (Magazine > 0 && _ammo == 0 && TakeReloadRound()) { - _ammo = _data.MagazineSize; + _ammo = Magazine; OnReloadComplete?.Invoke(); } else @@ -250,7 +250,7 @@ public class InstantWeapon : Weapon, IProgressBehavior, IEventBehavior, IPowerCo return true; } - private float CountdownSpan => max(ShortestCycle, _data.MagazineSize > 0 && _ammo == 0 ? _data.ReloadTime : Cooldown); + private float CountdownSpan => max(ShortestCycle, Magazine > 0 && _ammo == 0 ? _data.ReloadTime : Cooldown); // A countdown that starts outside a round (a charge failing) carries the last one's overshoot like every restart. protected void StartCountdown() @@ -266,9 +266,13 @@ public class InstantWeapon : Weapon, IProgressBehavior, IEventBehavior, IPowerCo return overshoot; } + // A weapon that names an AmmoType is magazine-fed whatever MagazineSize says: its magazine is at least one round, every round + // leaves it and the reload takes a crate. A weapon with no AmmoType and a magazine of one or none fires free. + private int Magazine => _data.AmmoType.IsSet() ? max(1, _data.MagazineSize) : _data.MagazineSize; + private bool UseAmmo(float dt) { - if (_data.MagazineSize <= 1) return true; + if (!_data.AmmoType.IsSet() && _data.MagazineSize <= 1) return true; if (_ammo > 0) { diff --git a/Assets/Scripts/ServerShared/Loadout.cs b/Assets/Scripts/ServerShared/Loadout.cs index b8b73a0ab..f327837e2 100644 --- a/Assets/Scripts/ServerShared/Loadout.cs +++ b/Assets/Scripts/ServerShared/Loadout.cs @@ -202,9 +202,24 @@ public static class Loadouts built[g] = (items.Select(item => item.GetBehavior()).ToList(), items); } entity.WeaponGroups = built; + StockAmmunition(itemManager, entity); return entity; } + // The crates an entity's weapons reload from: LoadoutGenerator.AmmunitionCrates of each distinct AmmoType, stopping when no hold takes + // another. The one owner of ammunition stock: every path that fits weapons onto a new entity (the generator, a Loadout's Materialize, + // so a scenario's Fit, a boss, a console-built ship) ends here. A saved or packed entity is never stocked: its cargo is its own. + // Draws nothing from the random stream, so adding it moves no other roll. + public static void StockAmmunition(ItemManager itemManager, Entity entity) + { + foreach (var key in entity.Weapons.Select(weapon => weapon.WeaponData.AmmoType).Where(type => type.IsSet()).Select(type => type.Key).Distinct()) + { + var crates = new SimpleCommodity { Data = new CultRecordRef(key), Quantity = LoadoutGenerator.AmmunitionCrates }; + if (!entity.CargoBays.Any(bay => bay.TryStore(crates))) + itemManager.Log($"{entity.Name}: no room for {LoadoutGenerator.AmmunitionCrates} crates of {key.Value}."); + } + } + // The hull, then each slot's gear, made by the given builders and equipped to the loadout's cells: the one placement of // a loadout, run once on the throwaway manager to decide the fit and once on the live one to build. Null with every // misfit listed when any cell does not take its gear. diff --git a/Assets/Scripts/ServerShared/LoadoutGenerator.cs b/Assets/Scripts/ServerShared/LoadoutGenerator.cs index 93c00aff9..fcd554efd 100644 --- a/Assets/Scripts/ServerShared/LoadoutGenerator.cs +++ b/Assets/Scripts/ServerShared/LoadoutGenerator.cs @@ -60,7 +60,7 @@ public class LoadoutGenerator OutfitEntity(entity); EquipPickup(entity); StockConsumables(entity, ShipConsumableChance, ShipConsumableUnits); - StockAmmunition(entity); + Loadouts.StockAmmunition(ItemManager, entity); return EntitySerializer.Pack(entity); } @@ -76,7 +76,7 @@ public class LoadoutGenerator var entity = new OrbitalEntity(ItemManager, null, hull, default, ItemManager.GameplaySettings.DefaultEntitySettings); entity.Faction = Faction; OutfitEntity(entity); - StockAmmunition(entity); + Loadouts.StockAmmunition(ItemManager, entity); return EntitySerializer.Pack(entity) as OrbitalEntityPack; } @@ -168,18 +168,6 @@ public class LoadoutGenerator } } - // The crates the ship's weapons reload from: AmmunitionCrates of each distinct AmmoType, stopping when no hold takes another. - // Draws nothing from the random stream, so adding it moves no other roll. - private void StockAmmunition(Entity entity) - { - foreach (var key in entity.Weapons.Select(weapon => weapon.WeaponData.AmmoType).Where(type => type.IsSet()).Select(type => type.Key).Distinct()) - { - var crates = new SimpleCommodity { Data = new CultRecordRef(key), Quantity = AmmunitionCrates }; - if (!entity.CargoBays.Any(bay => bay.TryStore(crates))) - ItemManager.Log($"{entity.Name}: no room for {AmmunitionCrates} crates of {key.Value}."); - } - } - private static bool IsHeater(GearData item) => item.HardpointType == HardpointType.Tool && item.Behaviors.Any(b => b is ThermotoggleData { HighPass: false }) && diff --git a/Assets/Scripts/UI/HUD/SchematicDisplay.cs b/Assets/Scripts/UI/HUD/SchematicDisplay.cs index f5d6d8af3..526a83c80 100644 --- a/Assets/Scripts/UI/HUD/SchematicDisplay.cs +++ b/Assets/Scripts/UI/HUD/SchematicDisplay.cs @@ -234,12 +234,7 @@ public class SchematicDisplay : MonoBehaviour x.ListElement.CooldownFill.anchorMax = new Vector2(x.Cooldown.Progress, 1); x.ListElement.DurabilityLabel.text = $"{(int)(x.Row.Item.EquippableItem.Durability / itemData.Durability * 100)}%"; if (x.Weapon.WeaponData.AmmoType.IsSet()) - { - if(x.Weapon.WeaponData.MagazineSize > 1) - x.ListElement.AmmoLabel.text = $"{x.Weapon.Ammo}/{x.Weapon.Reserve}"; - else - x.ListElement.AmmoLabel.text = _entity.CountItemsInCargo(x.Weapon.WeaponData.AmmoType.Key).ToString(); - } + x.ListElement.AmmoLabel.text = $"{x.Weapon.Ammo}/{x.Weapon.Reserve}"; } if (x.ListElement.RangeLabel) diff --git a/tests/Aetheria.Shared.Tests/AmmunitionStockingTests.cs b/tests/Aetheria.Shared.Tests/AmmunitionStockingTests.cs index d1d0f1317..95a59f52b 100644 --- a/tests/Aetheria.Shared.Tests/AmmunitionStockingTests.cs +++ b/tests/Aetheria.Shared.Tests/AmmunitionStockingTests.cs @@ -1,4 +1,6 @@ +using System.Collections.Generic; using System.Linq; +using static CultMath.math; using Xunit; // Ruling ballistics-real-ammo: generation stocks combatants with rounds, because a magazine weapon reloads from cargo and a ship with @@ -54,4 +56,76 @@ public sealed partial class RunStartTests } Assert.True(fed > 0, "fixture: some generated turret carries an ammunition-fed weapon"); } + + private static int[] FedTypesStocked(Entity ship, out int fed) + { + var keys = ship.Weapons.Select(w => w.WeaponData.AmmoType).Where(t => t.IsSet()).Select(t => t.Key).Distinct().ToArray(); + fed = keys.Length; + return keys.Select(key => ship.CountItemsInCargo(key)).ToArray(); + } + + // A test hull, a one-cell ammunition-fed gun (a magazine of none, so the single-round rule is in play) and a bay, + // in the shared catalog: the smallest authored ship that can carry crates. + private (string Hull, (string, CultMath.int2, ItemRotation)[] Slots) FedFit(bool bay) + { + var shape = new Shape(7, 7); + foreach (var cell in shape.AllCoordinates) shape[cell] = true; + var hullData = new HullData { Name = "Fed Hull", HullType = HullType.Ship, Shape = shape, Durability = 10, Mass = 100 }; + _cache.Upsert(hullData); + var rounds = _cache.Upsert(new SimpleCommodityData { Name = "Fed Rounds", Shape = new Shape(), Price = 1, MaxStack = 100 }); + _cache.Upsert(new WeaponItemData + { + Name = "Fed Gun", Hardpoint = HardpointType.Tool, Shape = new Shape(), Durability = 1, + Behaviors = { new InstantWeaponData { AmmoType = rounds, MagazineSize = 0 } } + }); + var interior = new Shape(4, 4); + foreach (var cell in interior.AllCoordinates) interior[cell] = true; + _cache.Upsert(new CargoBayData { Name = "Fed Bay", Shape = new Shape(), InteriorShape = interior, Price = 20, Durability = 4 }); + var slots = new List<(string, CultMath.int2, ItemRotation)> { ("Fed Gun", int2(3, 3), ItemRotation.None) }; + if (bay) slots.Add(("Fed Bay", int2(4, 3), ItemRotation.None)); + return ("Fed Hull", slots.ToArray()); + } + + // A ship a scenario fits slot by slot (stage.Fit, no generator) that carries a bay and an ammunition-fed weapon ends with the crates that + // weapon draws, and its own Reserve reads them. Kills: Materialize returning the entity unstocked (the authored ship fires one magazine). + [Fact] + public void AScenarioFitWithAFedWeaponAndABayCarriesItsCrates() + { + var (hull, slots) = FedFit(bay: true); + var (_, _, staged, failures) = Launch(new Scripted(false, stage => stage.Player(stage.Fit(hull, slots), float2(0, 0))), Inputs(() => GalaxySeed)); + Assert.True(failures.Count == 0, string.Join("; ", failures)); + var weapon = Assert.Single(staged.Player.Weapons); + Assert.True(weapon.WeaponData.AmmoType.IsSet(), "fixture: the gun draws ammunition"); + Assert.Equal(LoadoutGenerator.AmmunitionCrates, weapon.Reserve); + } + + // With no hold to put crates in there are none, and the fit still builds: stocking is bounded by what the ship can carry, never a failure. + [Fact] + public void AScenarioFitWithNoBayCarriesNoCratesAndStillBuilds() + { + var (hull, slots) = FedFit(bay: false); + var (_, _, staged, failures) = Launch(new Scripted(false, stage => stage.Player(stage.Fit(hull, slots), float2(0, 0))), Inputs(() => GalaxySeed)); + Assert.True(failures.Count == 0, string.Join("; ", failures)); + Assert.Equal(0, Assert.Single(staged.Player.Weapons).Reserve); + } + + // The new run's player ship (stage.Generated at the starting hull) and every ship the shipped main-galaxy scenario generates carry the crates + // their weapons draw: nothing that reaches play with ammunition-fed weapons is left to one magazine. + [Fact] + public void ANewRunPlayerShipCarriesTheCratesItsWeaponsDraw() + { + var fed = 0; + for (uint seed = GalaxySeed; seed < GalaxySeed + 8; seed++) + { + var (_, _, staged, failures) = Launch(new Scripted(false, stage => stage.Player(stage.Generated(stage.StartingHull), float2(0, 0)), seed), + Inputs(() => seed)); + Assert.True(failures.Count == 0, string.Join("; ", failures)); + foreach (var count in FedTypesStocked(staged.Player, out var types)) + { + Assert.Equal(LoadoutGenerator.AmmunitionCrates, count); + fed += types; + } + } + Assert.True(fed > 0, "fixture: some new-run player ship carries an ammunition-fed weapon"); + } } diff --git a/tests/Aetheria.Shared.Tests/FireControlPerWeaponTests.cs b/tests/Aetheria.Shared.Tests/FireControlPerWeaponTests.cs index 690bd27d3..95260b713 100644 --- a/tests/Aetheria.Shared.Tests/FireControlPerWeaponTests.cs +++ b/tests/Aetheria.Shared.Tests/FireControlPerWeaponTests.cs @@ -1328,9 +1328,9 @@ public sealed class FireControlPerWeaponTests : IDisposable // One launcher-shaped weapon (magazine 2, reload 3 s, Missiles) held on its trigger at no target, `crates` of Missiles in the // hold. Returns the rig and a counter of committed shots. - private (Rig Rig, Func Shots) AmmoRig(int crates) + private (Rig Rig, Func Shots) AmmoRig(int crates, int magazine = 2) { - var r = Build(fuse: WeaponFuse.Proximity, blast: 30f, range: 40f, magazine: 2, auto: true, crates: crates, reload: 3f); + var r = Build(fuse: WeaponFuse.Proximity, blast: 30f, range: 40f, magazine: magazine, auto: true, crates: crates, reload: 3f); r.Shooter.SetTarget(TargetRef.None); Aim(r, float2(0, 1)); var shots = 0; @@ -1406,4 +1406,48 @@ public sealed class FireControlPerWeaponTests : IDisposable Run(r, 30f); Assert.True(shots > 4, $"an unfed magazine weapon fires past its first magazine, fired {shots}"); } + + // A weapon that names an AmmoType is magazine-fed whatever MagazineSize says (0 or 1 is a magazine of one): it fires one round, every + // reload takes a crate, and with none it fires no more. Kills: UseAmmo/reload ignoring AmmoType at MagazineSize <= 1 (free fire, or + // fire that never touches the cargo). + [Theory] + [InlineData(0)] + [InlineData(1)] + public void ASingleRoundAmmunitionFedWeaponFiresOnePerCrateAndNoMore(int magazine) + { + var (r, shots) = AmmoRig(crates: 2, magazine: magazine); + Assert.Equal(1, r.Gun.Weapon.Ammo); + Run(r, 2f); + Assert.Equal(1, shots()); + Assert.Equal(2, r.Gun.Weapon.Reserve); + Run(r, 30f); + Assert.Equal(3, shots()); + Assert.Equal(0, r.Gun.Weapon.Reserve); + Assert.Equal(0, r.Gun.Weapon.Ammo); + } + + [Theory] + [InlineData(0)] + [InlineData(1)] + public void ASingleRoundAmmunitionFedWeaponWithNoCrateFiresOnceAndNoMore(int magazine) + { + var (r, shots) = AmmoRig(crates: 0, magazine: magazine); + Run(r, 30f); + Assert.Equal(1, shots()); + } + + // With no AmmoType a magazine of 0 or 1 is no magazine: the weapon fires free, as before. Kills: the new rule leaking onto unfed weapons. + [Theory] + [InlineData(0)] + [InlineData(1)] + public void AWeaponWithNoAmmunitionTypeAndNoRealMagazineFiresFree(int magazine) + { + var r = Build(fuse: WeaponFuse.Proximity, blast: 30f, range: 40f, magazine: magazine, auto: true, reload: 3f); + r.Shooter.SetTarget(TargetRef.None); + Aim(r, float2(0, 1)); + var shots = 0; + r.Zone.ShotCommitted.Subscribe(_ => shots++); + Run(r, 30f); + Assert.True(shots > 4, $"an unfed weapon fires on, fired {shots}"); + } } -- 2.47.3 From ded74e8b109f7e3406b81706a0195b0012f94103 Mon Sep 17 00:00:00 2001 From: Nibu Date: Wed, 11 Nov 2026 03:40:33 +0000 Subject: [PATCH 5/6] Scenario fits are stocked unless the scenario authors their cargo Materialize takes stockAmmunition (default true: boss, console ship). ScenarioStage passes false and stocks at the end of staging only the fitted entities whose cargo the scenario did not author with Cargo, so a scenario that names what a hold carries gets exactly that. A packed (saved) fit is never stocked. Co-Authored-By: Claude Sonnet 5.5 --- Assets/Scripts/ServerShared/Loadout.cs | 7 ++++--- Assets/Scripts/ServerShared/RunStart.cs | 1 + Assets/Scripts/ServerShared/ScenarioStage.cs | 16 +++++++++++++++- .../AmmunitionStockingTests.cs | 12 ++++++++++++ 4 files changed, 32 insertions(+), 4 deletions(-) diff --git a/Assets/Scripts/ServerShared/Loadout.cs b/Assets/Scripts/ServerShared/Loadout.cs index f327837e2..4866aa406 100644 --- a/Assets/Scripts/ServerShared/Loadout.cs +++ b/Assets/Scripts/ServerShared/Loadout.cs @@ -157,11 +157,12 @@ public static class Loadouts // spawner is to pass LoadoutGenerator.ChooseProduct, with no fallback to any manufacturer; RunStart does, and so does the // boss in ZoneGenerator, which is built here too from its faction's Loadout (Loadouts.BossOf). The hull's type // picks the entity: a ship hull builds a Ship, a turret hull an OrbitalEntity with no orbit, which stays where it is - // put; a station hull is refused. The entity always gets exactly WeaponGroupCount groups; a loadout with fewer is + // put; a station hull is refused. The entity is stocked with the crates its weapons draw (StockAmmunition) unless the caller + // authors the cargo itself and passes stockAmmunition false. The entity always gets exactly WeaponGroupCount groups; a loadout with fewer is // padded with empty groups. A stated quality mints every lot of the fit at it, drawing nothing (a scenario states // one for a fit its checks rest on). public static Entity Materialize(ItemManager itemManager, Zone liveZone, Loadout loadout, - Func choose, List failures, float? quality = null) + Func choose, List failures, float? quality = null, bool stockAmmunition = true) { var cache = itemManager.ItemData; var reported = failures.Count; @@ -202,7 +203,7 @@ public static class Loadouts built[g] = (items.Select(item => item.GetBehavior()).ToList(), items); } entity.WeaponGroups = built; - StockAmmunition(itemManager, entity); + if (stockAmmunition) StockAmmunition(itemManager, entity); return entity; } diff --git a/Assets/Scripts/ServerShared/RunStart.cs b/Assets/Scripts/ServerShared/RunStart.cs index 20269e84c..cadd66bcc 100644 --- a/Assets/Scripts/ServerShared/RunStart.cs +++ b/Assets/Scripts/ServerShared/RunStart.cs @@ -44,6 +44,7 @@ public static class RunStart if (!stage.PlayerPlaced) failures.Add("player: the scenario places no player"); if (failures.Count > reported) return null; + stage.StockUnauthoredAmmunition(); var player = stage.PlayerShip; player.IsPlayerShip = true; diff --git a/Assets/Scripts/ServerShared/ScenarioStage.cs b/Assets/Scripts/ServerShared/ScenarioStage.cs index 453ede8ec..a813bbf49 100644 --- a/Assets/Scripts/ServerShared/ScenarioStage.cs +++ b/Assets/Scripts/ServerShared/ScenarioStage.cs @@ -18,6 +18,10 @@ public sealed class ScenarioStage private readonly List _failures = new List(); private readonly List _placed = new List(); private int _placements; + // The entities this stage built from a fit (a packed fit is a saved ship, whose cargo is its own) and those whose cargo the scenario + // authored with Cargo: the rest are stocked with the crates their weapons draw when staging ends (StockUnauthoredAmmunition). + private readonly HashSet _fitted = new HashSet(); + private readonly HashSet _cargoAuthored = new HashSet(); internal ScenarioStage(ItemManager items, Zone arena, string startingHull, TutorialGenerationSettings tutorialGenerationSettings) { @@ -40,6 +44,14 @@ public sealed class ScenarioStage public int Credits { get; set; } internal IReadOnlyList Failures => _failures; + + // Staging's last step: a fitted entity whose cargo the scenario did not author gets the crates its weapons draw, like every other + // newly built armed entity (Loadouts.StockAmmunition). Draws nothing, so it moves no item roll. + internal void StockUnauthoredAmmunition() + { + foreach (var entity in _fitted.Where(entity => !_cargoAuthored.Contains(entity))) + Loadouts.StockAmmunition(_items, entity); + } internal bool PlayerPlaced { get; private set; } internal Ship PlayerShip { get; private set; } internal IReadOnlyList Placed => _placed; @@ -160,6 +172,7 @@ public sealed class ScenarioStage public void Cargo(Entity entity, params string[] names) { if (entity == null) return; + _cargoAuthored.Add(entity); var where = $"cargo of {entity.Name}"; var bay = entity.CargoBays.FirstOrDefault(); foreach (var name in names) @@ -183,7 +196,8 @@ public sealed class ScenarioStage else { var failures = new List(); - entity = Loadouts.Materialize(_items, Arena, fit.Loadout, _choose, failures, fit.Quality); + entity = Loadouts.Materialize(_items, Arena, fit.Loadout, _choose, failures, fit.Quality, stockAmmunition: false); + if (entity != null) _fitted.Add(entity); _failures.AddRange(failures.Select(failure => $"{where} ({fit.Loadout.Name}): {failure}")); if (entity == null) return null; } diff --git a/tests/Aetheria.Shared.Tests/AmmunitionStockingTests.cs b/tests/Aetheria.Shared.Tests/AmmunitionStockingTests.cs index 95a59f52b..cd91df321 100644 --- a/tests/Aetheria.Shared.Tests/AmmunitionStockingTests.cs +++ b/tests/Aetheria.Shared.Tests/AmmunitionStockingTests.cs @@ -99,6 +99,18 @@ public sealed partial class RunStartTests Assert.Equal(LoadoutGenerator.AmmunitionCrates, weapon.Reserve); } + // A scenario that authors a fit's cargo (stage.Cargo) gets exactly that cargo: its author named what the hold holds, so no crates are added. + // Kills: stocking that ignores authored cargo and puts missiles into a scenario whose check counts what the hold carries. + [Fact] + public void AScenarioFitWhoseCargoIsAuthoredIsNotStocked() + { + var (hull, slots) = FedFit(bay: true); + var (_, _, staged, failures) = Launch(new Scripted(false, stage => + stage.Cargo(stage.Player(stage.Fit(hull, slots), float2(0, 0)), "Fed Gun")), Inputs(() => GalaxySeed)); + Assert.True(failures.Count == 0, string.Join("; ", failures)); + Assert.Equal(0, Assert.Single(staged.Player.Weapons).Reserve); + } + // With no hold to put crates in there are none, and the fit still builds: stocking is bounded by what the ship can carry, never a failure. [Fact] public void AScenarioFitWithNoBayCarriesNoCratesAndStillBuilds() -- 2.47.3 From 36a74478b6852f3578fc915d7fea909505b2a118 Mon Sep 17 00:00:00 2001 From: Nibu Date: Wed, 11 Nov 2026 04:26:16 +0000 Subject: [PATCH 6/6] Test: a Loadout-built ship is stocked by Materialize's default Pins the default path the boss and the console fly use: a fed gun leaves the ship with AmmunitionCrates, a gun that draws nothing leaves none. Removing the default StockAmmunition call fails the fed case. Co-Authored-By: Claude Sonnet 5.5 --- .../AmmunitionStockingTests.cs | 26 +++++++++++++++++++ 1 file changed, 26 insertions(+) diff --git a/tests/Aetheria.Shared.Tests/AmmunitionStockingTests.cs b/tests/Aetheria.Shared.Tests/AmmunitionStockingTests.cs index cd91df321..a5e7e53c9 100644 --- a/tests/Aetheria.Shared.Tests/AmmunitionStockingTests.cs +++ b/tests/Aetheria.Shared.Tests/AmmunitionStockingTests.cs @@ -78,6 +78,11 @@ public sealed partial class RunStartTests Name = "Fed Gun", Hardpoint = HardpointType.Tool, Shape = new Shape(), Durability = 1, Behaviors = { new InstantWeaponData { AmmoType = rounds, MagazineSize = 0 } } }); + _cache.Upsert(new WeaponItemData + { + Name = "Plain Gun", Hardpoint = HardpointType.Tool, Shape = new Shape(), Durability = 1, + Behaviors = { new InstantWeaponData { MagazineSize = 0 } } + }); var interior = new Shape(4, 4); foreach (var cell in interior.AllCoordinates) interior[cell] = true; _cache.Upsert(new CargoBayData { Name = "Fed Bay", Shape = new Shape(), InteriorShape = interior, Price = 20, Durability = 4 }); @@ -99,6 +104,27 @@ public sealed partial class RunStartTests Assert.Equal(LoadoutGenerator.AmmunitionCrates, weapon.Reserve); } + // The Loadout path with the DEFAULT stocking argument (what the boss in ZoneGenerator and the console `fly` call): a ship built from a + // Loadout holds the crates its fed weapon draws, and one whose only weapon draws nothing holds none. Kills: Materialize + // dropping its default StockAmmunition (every Loadout-built armed ship fires one magazine). + [Theory] + [InlineData("Fed Gun", LoadoutGenerator.AmmunitionCrates)] + [InlineData("Plain Gun", 0)] + public void ALoadoutBuiltShipIsStockedByDefaultWithWhatItsWeaponsDraw(string gun, int crates) + { + var (hull, _) = FedFit(bay: true); + EquippableItemData Design(string name) => _cache.GetAll().Single(item => item.Name == name); + var loadout = new Loadout { Name = "default stock", Hull = _cache.RefOf(_cache.GetAll().Single(h => h.Name == hull)), WeaponGroups = new int[0][] }; + loadout.Slots.Add(new LoadoutSlot { Position = int2(3, 3), Rotation = ItemRotation.None, Design = _cache.RefOf(Design(gun)) }); + loadout.Slots.Add(new LoadoutSlot { Position = int2(4, 3), Rotation = ItemRotation.None, Design = _cache.RefOf(Design("Fed Bay")) }); + var failures = new List(); + var ship = Loadouts.Materialize(_items, null, loadout, _ => null, failures); + Assert.True(failures.Count == 0, string.Join("; ", failures)); + var weapon = Assert.Single(ship.Weapons); + Assert.Equal(gun == "Fed Gun", weapon.WeaponData.AmmoType.IsSet()); + Assert.Equal(crates, ship.CargoBays.Sum(bay => bay.Cargo.Keys.OfType().Sum(c => c.Quantity))); + } + // A scenario that authors a fit's cargo (stage.Cargo) gets exactly that cargo: its author named what the hold holds, so no crates are added. // Kills: stocking that ignores authored cargo and puts missiles into a scenario whose check counts what the hold carries. [Fact] -- 2.47.3