From c21fad28f566447b2c2769a87cf456b6d706ff11 Mon Sep 17 00:00:00 2001 From: Nibu Date: Wed, 11 Nov 2026 03:36:58 +0000 Subject: [PATCH 1/2] 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}"); + } } From ded74e8b109f7e3406b81706a0195b0012f94103 Mon Sep 17 00:00:00 2001 From: Nibu Date: Wed, 11 Nov 2026 03:40:33 +0000 Subject: [PATCH 2/2] 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()