diff --git a/Assets/Scripts/Gameplay/ActionGameManager.cs b/Assets/Scripts/Gameplay/ActionGameManager.cs index 3ace90249..36ed44af2 100644 --- a/Assets/Scripts/Gameplay/ActionGameManager.cs +++ b/Assets/Scripts/Gameplay/ActionGameManager.cs @@ -50,10 +50,12 @@ public class ActionGameManager : MonoBehaviour // All three stores attach once and stay attached until the process exits. The run lifecycle is record-level // inside this cache; reopening would replace the catalog instances the galaxy and live entities hold. + // The editor opens the catalog writable so the capturepreset console command can author presets in play. _cultCache = AetheriaStores.Open( Path.Combine(GameDataDirectory.FullName, "Aetheria.cc"), runPath: Path.Combine(GameDataDirectory.FullName, "run.cc"), - playerPath: Path.Combine(GameDataDirectory.FullName, "player.cc")); + playerPath: Path.Combine(GameDataDirectory.FullName, "player.cc"), + catalogWritable: Application.isEditor); return _cultCache; } @@ -543,6 +545,25 @@ public class ActionGameManager : MonoBehaviour }); //Temporary, or not ConsoleController.AddCommand("tow", _ => TowShip()); + + // Editor only: capturepreset "" [replace] writes the piloted ship as a catalog preset. + if (Application.isEditor) + ConsoleController.AddCommand("capturepreset", args => + { + var console = ConsoleController.Instance; + var name = args.Length > 0 ? args[0] : ""; + if (string.IsNullOrWhiteSpace(name)) { console.AppendLogLine("usage: capturepreset \"\" [replace]"); return; } + if (!(_currentEntity is Ship ship)) { console.AppendLogLine("capturepreset: pilot a ship first"); return; } + try + { + var written = Loadouts.Commit(CultCache, Loadouts.Capture(ItemManager, ship, name), args.Length > 1 && args[1] == "replace"); + console.AppendLogLine(written ? $"Preset '{name}' written to the catalog" : $"Preset '{name}' changed on disk since load; nothing written"); + } + catch (InvalidOperationException e) + { + console.AppendLogLine(e.Message); + } + }); } public void BeginDrag(DragObject dragObject) diff --git a/Assets/Scripts/ServerShared/AetheriaStores.cs b/Assets/Scripts/ServerShared/AetheriaStores.cs index 10e0fb4e0..e5c03a1e8 100644 --- a/Assets/Scripts/ServerShared/AetheriaStores.cs +++ b/Assets/Scripts/ServerShared/AetheriaStores.cs @@ -6,9 +6,9 @@ using GameCult.Caching.MessagePack; // Aetheria's stores: one home store per type, composed here and nowhere else. public static class AetheriaStores { - public static readonly Type[] CatalogTypes = { typeof(ItemData), typeof(Faction), typeof(FactionProductData), typeof(PersonalityAttribute), typeof(NameFile), typeof(InputLayout) }; + public static readonly Type[] CatalogTypes = { typeof(ItemData), typeof(Faction), typeof(FactionProductData), typeof(PersonalityAttribute), typeof(NameFile), typeof(InputLayout), typeof(Loadout) }; public static readonly Type[] RunTypes = { typeof(OrbitData), typeof(BodyData), typeof(SavedZone), typeof(SavedGame) }; - public static readonly Type[] PlayerTypes = { typeof(PlayerSettings), typeof(Loadout) }; + public static readonly Type[] PlayerTypes = { typeof(PlayerSettings) }; // Attaches (hydrates) the catalog read-only unless catalogWritable, then the run and player stores when given. // A read-only catalog throws when a [CultGlobal] type routed to it has no record. A writable catalog is being diff --git a/Assets/Scripts/ServerShared/Loadout.cs b/Assets/Scripts/ServerShared/Loadout.cs index 6c31b422d..ec3ea8f4e 100644 --- a/Assets/Scripts/ServerShared/Loadout.cs +++ b/Assets/Scripts/ServerShared/Loadout.cs @@ -5,9 +5,10 @@ using GameCult.Caching; using MessagePack; using CultMath; -// A ship build as item designs only: the hull's design, and per hull cell a design and its rotation. A design names no -// manufacturer; which product builds a slot is a fact of the galaxy the loadout is materialized in, which keeps a -// loadout portable across galaxies. Player-store state: a loadout outlives runs. +// A ship preset (a variant): item designs only, the hull's design, and per hull cell a design and its rotation. A design +// names no manufacturer; which product builds a slot is a fact of the galaxy the preset is materialized in, which keeps +// a preset portable across galaxies. Authored catalog data: written in CultCache Studio or by the editor capture +// command, read at runtime. [CultDocument("aetheria.loadout", "1"), MessagePackObject] public class Loadout { @@ -56,24 +57,34 @@ public static class Loadouts }; } - // One Commit to the player store. A loadout with the same name is replaced under its existing key; names are - // unique by construction because GetByName throws on duplicates. - public static void Save(CultCache cache, Loadout loadout) + // A preset's key derives from its name, so capturing the same name again addresses the same record. + public static CultRecordKey KeyOf(string name) => new CultRecordKey("loadout:" + name); + + // Writes the preset to the catalog under KeyOf(Name). An existing preset of that name is replaced only when replace + // is set; otherwise this throws and writes nothing. The commit is conditional on the record at that key being what + // this cache holds, so it lands only this record onto the catalog file as it is on disk: no other in-memory catalog + // state is written, and a preset changed on disk since load (Studio) is not clobbered. Returns false on that conflict. + public static bool Commit(CultCache cache, Loadout loadout, bool replace) { - var existing = cache.GetByName(loadout.Name); - var key = existing == null ? (CultRecordKey?) null : cache.TryGetHandle(existing)?.Key; - cache.Commit(batch => batch.Upsert(typeof(Loadout), loadout, key)); + var key = KeyOf(loadout.Name); + var existing = cache.Get(key); + if (existing != null && !replace) + throw new InvalidOperationException($"Preset '{loadout.Name}' already exists; replace it explicitly."); + return cache.Commit(batch => + { + batch.Expect(key, existing); + batch.Upsert(typeof(Loadout), loadout, key); + }); } - // All-or-nothing: returns a ship and charges Price only when credits cover Price, every design resolved, had an - // available product and fitted, the loadout has at most WeaponGroupCount weapon groups, and every group index names - // a weapon slot. Affordability is decided here and nowhere else. Every failure is listed; on any failure nothing is - // returned and nothing is charged. The ship, credits, zone and cache are unchanged, but a failed fit has already - // drawn from itemManager.Random. A design is built by its first available product in record-key order. The game - // passes LoadoutGenerator.IsAvailable as isAvailable, with no fallback to any manufacturer. The ship always gets - // exactly WeaponGroupCount groups; a loadout with fewer is padded with empty groups. + // All-or-nothing: returns a ship only when every design resolved, had an available product and fitted, the loadout + // has at most WeaponGroupCount weapon groups, and every group index names a weapon slot. Every failure is listed; on + // any failure nothing is returned and the zone and cache are unchanged, but a failed fit has already drawn from + // itemManager.Random. A design is built by its first available product in record-key order. The game passes + // LoadoutGenerator.IsAvailable as isAvailable, with no fallback to any manufacturer. The ship always gets exactly + // WeaponGroupCount groups; a loadout with fewer is padded with empty groups. public static Ship Materialize(ItemManager itemManager, Zone liveZone, Loadout loadout, - Predicate isAvailable, ref int credits, List failures) + Predicate isAvailable, List failures) { var cache = itemManager.ItemData; var products = cache.GetAll().OrderBy(p => cache.RefOf(p).Key.Value, StringComparer.Ordinal).ToArray(); @@ -104,8 +115,6 @@ public static class Loadouts else if (cache.Get(loadout.Slots[index].Design) is { } design && !design.Behaviors.Any(behavior => behavior is WeaponData)) failures.Add($"weapon group: {Cell(loadout.Slots[index])}: {design.Name} is not a weapon"); } - var price = Price(itemManager, loadout); - if (credits < price) failures.Add($"cannot afford {price:n0} credits"); if (failures.Count > reported) return null; var hull = (EquippableItem) itemManager.CreateInstance(hullProduct); @@ -136,17 +145,8 @@ public static class Loadouts built[g] = (items.Select(item => item.GetBehavior()).ToList(), items); } ship.WeaponGroups = built; - credits -= (int) price; return ship; } - // The hull design's price plus each slot design's price. Summed as long so no loadout wraps to a negative price; - // a price beyond any int credit balance is simply unaffordable. - public static long Price(ItemManager itemManager, Loadout loadout) - { - var cache = itemManager.ItemData; - return (long) (cache.Get(loadout.Hull)?.Price ?? 0) + loadout.Slots.Sum(slot => (long) (cache.Get(slot.Design)?.Price ?? 0)); - } - private static string Cell(LoadoutSlot slot) => $"slot {slot.Position.x},{slot.Position.y}"; } diff --git a/Assets/Scripts/ServerShared/LoadoutGenerator.cs b/Assets/Scripts/ServerShared/LoadoutGenerator.cs index 378d96568..29ed9be4c 100644 --- a/Assets/Scripts/ServerShared/LoadoutGenerator.cs +++ b/Assets/Scripts/ServerShared/LoadoutGenerator.cs @@ -149,7 +149,7 @@ public class LoadoutGenerator // No galaxy means no availability to filter by: every product is on offer. A fixture generates loadouts that // way, so a test can exercise placement and products without standing up a whole galaxy; no game path does. - // Restoring a loadout passes this to Loadouts.Materialize, so a loadout and generation share one availability rule. + // Materializing a preset passes this to Loadouts.Materialize, so presets and generation share one availability rule. public bool IsAvailable(FactionProductData product) => Galaxy == null || Galaxy.IsPrelude || Galaxy.ContainsFaction(product.Manufacturer) && (Faction == null || Faction.Allegiance.ContainsKey(product.Manufacturer)); diff --git a/Assets/Scripts/UI/Menu/InventoryPanel.cs b/Assets/Scripts/UI/Menu/InventoryPanel.cs index a35a15331..2087a3870 100644 --- a/Assets/Scripts/UI/Menu/InventoryPanel.cs +++ b/Assets/Scripts/UI/Menu/InventoryPanel.cs @@ -167,64 +167,11 @@ public class InventoryPanel : MonoBehaviour, IPointerClickHandler if(GameManager.DockingBay!=null && _displayedCargo!=GameManager.DockingBay) ContextMenu.AddOption(GameManager.DockingBay.Name, () => Display(GameManager.DockingBay)); - if (_displayedEntity is Ship displayedShip) - { - ContextMenu.AddOption("Save Loadout", - () => - { - var loadoutName = displayedShip.Name; - Dialog.Clear(); - Dialog.Title.text = "Save Loadout"; - Dialog.AddField("Name", () => loadoutName, s => loadoutName = s); - Dialog.Show(() => Loadouts.Save(ActionGameManager.CultCache, - Loadouts.Capture(GameManager.ItemManager, displayedShip, loadoutName))); - Dialog.MoveToCursor(); - }); - } - - var loadouts = ActionGameManager.CultCache.GetAll().ToArray(); - if (GameManager.DockedEntity != null && loadouts.Any()) - { - ContextMenu.AddDropdown("Restore Loadout", - loadouts.Select(loadout => - { - var price = Loadouts.Price(GameManager.ItemManager, loadout); - return ($"{loadout.Name} - {price:n0}", () => RestoreLoadout(loadout), true); - })); - } - ContextMenu.Show(); }); } } - // Builds the loadout against the current galaxy at the docked station. On failure nothing changes: no ship, no - // charge, and a dialog lists every slot that could not be built here. - private void RestoreLoadout(Loadout loadout) - { - var station = GameManager.DockedEntity; - var failures = new List(); - var availability = new LoadoutGenerator(ref GameManager.ItemManager.Random, GameManager.ItemManager, - ActionGameManager.CurrentGalaxy, GameManager.Zone.GalaxyZone, station.Faction, 0); - var ship = Loadouts.Materialize(GameManager.ItemManager, GameManager.Zone, loadout, availability.IsAvailable, - ref GameManager.Credits, failures); - if (ship == null) - { - Dialog.Clear(); - Dialog.Title.text = "Loadout cannot be built here"; - foreach (var failure in failures) Dialog.AddProperty(failure); - Dialog.Show(); - Dialog.MoveToCursor(); - return; - } - - ship.SetParent(station); - ship.IsPlayerShip = true; - GameManager.DockingBay.DockedShip = ship; - GameManager.CurrentEntity = ship; - Display(ship); - } - private void Update() { if (_displayedEntity != null && TemperatureDisplay) diff --git a/tests/Aetheria.Shared.Tests/LoadoutTests.cs b/tests/Aetheria.Shared.Tests/LoadoutTests.cs index c7916df6f..a0115f680 100644 --- a/tests/Aetheria.Shared.Tests/LoadoutTests.cs +++ b/tests/Aetheria.Shared.Tests/LoadoutTests.cs @@ -8,7 +8,7 @@ using GameCult.Caching; using GameCult.Caching.MessagePack; using Xunit; -// Loadouts hold only item designs, live in the player store, and materialize all-or-nothing against the catalog. +// Loadouts are ship presets: item designs only, authored into the catalog, materialized all-or-nothing against it. // The fixture hull is 3x3: InteriorCells is Shape.Shrink(), so a 2x2 hull has no interior cell a cargo bay could use. public sealed class LoadoutTests : IDisposable { @@ -46,8 +46,12 @@ public sealed class LoadoutTests : IDisposable public void Dispose() => Directory.Delete(_root, true); + // The game's shape: a read-only catalog with the run and player stores. private CultCache Open() => AetheriaStores.Open(Catalog, Run, Player); + // The editor's shape: the catalog writable, as the capture command sees it. + private CultCache OpenAuthoring() => AetheriaStores.Open(Catalog, Run, Player, catalogWritable: true); + // Gear on the hull's hardpoint cell, cargo on an interior cell, and two empty weapon groups (fewer than the game's six). private static Loadout HandBuilt(CultCache cache, string name = "Skiff build") => new Loadout { @@ -62,9 +66,9 @@ public sealed class LoadoutTests : IDisposable }; // HandBuilt with the Gun on the hardpoint, alone in the first of the game's six weapon groups. - private static Loadout Armed(CultCache cache) + private static Loadout Armed(CultCache cache, string name = "Armed build") { - var armed = HandBuilt(cache, "Armed build"); + var armed = HandBuilt(cache, name); armed.Slots[0].Design = cache.RefOf(cache.GetByName("Gun")); armed.WeaponGroups = Enumerable.Range(0, 6).Select(g => g == 0 ? new[] { 0 } : new int[0]).ToArray(); return armed; @@ -76,33 +80,34 @@ public sealed class LoadoutTests : IDisposable return loadout; } + // Capture from a built ship, commit to the catalog, reopen fresh read-only: the preset and the ship it builds match. [Fact] - public void LoadoutRoundTripsThroughAFreshCache() + public void CapturedPresetRoundTripsThroughTheCatalog() { Loadout hand; - using (var cache = Open()) + using (var cache = OpenAuthoring()) { var items = new ItemManager(cache, RunSaveTests.TestSettings(), _ => { }); hand = Armed(cache); var failures = new List(); - var ship = Build(items,hand, failures); + var ship = Build(items, hand, failures); Assert.Empty(failures); Assert.NotNull(ship); - Loadouts.Save(cache, Loadouts.Capture(items, ship, hand.Name)); - Loadouts.Save(cache, Loadouts.Capture(items, ship, hand.Name)); + Assert.True(Loadouts.Commit(cache, Loadouts.Capture(items, ship, hand.Name), replace: false)); } using (var cache = Open()) { - Assert.Single(cache.GetAll()); - var loaded = cache.GetByName(hand.Name); + var loaded = Assert.Single(cache.GetAll()); + Assert.Equal(Loadouts.KeyOf(hand.Name), cache.RefOf(loaded).Key); + Assert.Equal(hand.Name, loaded.Name); Assert.Equal(hand.Hull.Key, loaded.Hull.Key); Assert.Equal(hand.Slots.Select(Describe), loaded.Slots.Select(Describe)); Assert.Equal(hand.WeaponGroups, loaded.WeaponGroups); var items = new ItemManager(cache, RunSaveTests.TestSettings(), _ => { }); var failures = new List(); - var ship = Build(items,loaded, failures); + var ship = Build(items, loaded, failures); Assert.Empty(failures); Assert.Equal(hand.Slots.Select(slot => (slot.Position, slot.Design.Key)), ship.Equipment.Concat(ship.CargoBays) @@ -110,7 +115,46 @@ public sealed class LoadoutTests : IDisposable .Select(item => (item.Position, cache.RefOf(items.GetData(item.EquippableItem)).Key))); } - Assert.Single(SchemaNames(Player), name => name == "aetheria.loadout"); + Assert.Single(SchemaNames(Catalog), name => name == "aetheria.loadout"); + Assert.True(!File.Exists(Player) || !SchemaNames(Player).Contains("aetheria.loadout")); + } + + // Recapturing a name refuses without replace and leaves the file untouched; with replace it replaces, never duplicates. + [Fact] + public void SameNameCaptureReplacesOnlyWhenAsked() + { + using (var cache = OpenAuthoring()) + { + Assert.True(Loadouts.Commit(cache, HandBuilt(cache), replace: false)); + var before = Hash(Catalog); + var error = Assert.Throws(() => Loadouts.Commit(cache, Armed(cache, "Skiff build"), replace: false)); + Assert.Contains("Skiff build", error.Message); + Assert.Equal(before, Hash(Catalog)); + Assert.Equal(2, Assert.Single(cache.GetAll()).WeaponGroups.Length); + + Assert.True(Loadouts.Commit(cache, Armed(cache, "Skiff build"), replace: true)); + Assert.Equal(6, Assert.Single(cache.GetAll()).WeaponGroups.Length); + } + + using (var cache = Open()) + Assert.Equal(6, Assert.Single(cache.GetAll()).WeaponGroups.Length); + } + + // The editor's catalog is writable while play mutates live catalog instances; a preset commit must land only itself. + [Fact] + public void PresetCommitWritesNoOtherInMemoryCatalogState() + { + using (var cache = OpenAuthoring()) + { + cache.GetByName("Maker").ShortName = "XXX"; + Assert.True(Loadouts.Commit(cache, HandBuilt(cache), replace: false)); + } + + using (var cache = Open()) + { + Assert.Equal("MKR", cache.GetByName("Maker").ShortName); + Assert.Single(cache.GetAll()); + } } [Fact] @@ -138,10 +182,11 @@ public sealed class LoadoutTests : IDisposable pending.Enqueue(field.FieldType); } - using (var cache = Open()) - Loadouts.Save(cache, HandBuilt(cache)); - Assert.Contains("aetheria.loadout", SchemaNames(Player)); + using (var cache = OpenAuthoring()) + Loadouts.Commit(cache, HandBuilt(cache), replace: false); + Assert.Contains("aetheria.loadout", SchemaNames(Catalog)); Assert.True(!File.Exists(Run) || SchemaNames(Run).Length == 0); + Assert.True(!File.Exists(Player) || SchemaNames(Player).Length == 0); } [Fact] @@ -157,14 +202,14 @@ public sealed class LoadoutTests : IDisposable var missing = HandBuilt(cache); missing.Slots[1].Design = new CultRecordRef(new CultRecordKey("absent-design")); var failures = new List(); - Assert.Null(Build(items,missing, failures)); + Assert.Null(Build(items, missing, failures)); var failure = Assert.Single(failures); Assert.Contains("1,1", failure); Assert.Contains("absent-design", failure); missing.Slots.Add(new LoadoutSlot { Position = new int2(2, 2), Design = cache.RefOf(cache.GetByName("Orphan")) }); failures.Clear(); - Assert.Null(Build(items,missing, failures)); + Assert.Null(Build(items, missing, failures)); Assert.Equal(2, failures.Count); Assert.Contains("slot 1,1", failures[0]); Assert.Contains("slot 2,2", failures[1]); @@ -175,32 +220,18 @@ public sealed class LoadoutTests : IDisposable } [Fact] - public void FailedFitBuildsNothingAndChargesNothing() + public void FailedFitBuildsNothing() { using var cache = Open(); var items = new ItemManager(cache, RunSaveTests.TestSettings(), _ => { }); var clash = HandBuilt(cache); clash.Slots[1].Position = HardpointCell; var failures = new List(); - var credits = 1000; - Assert.Null(Loadouts.Materialize(items, null, clash, _ => true, ref credits, failures)); - Assert.Equal(1000, credits); + Assert.Null(Build(items, clash, failures)); var failure = Assert.Single(failures); Assert.Contains("slot 0,0: Crate does not fit", failure); } - [Fact] - public void PriceIsTheSumOfDesignPricesAndIsCharged() - { - using var cache = Open(); - var items = new ItemManager(cache, RunSaveTests.TestSettings(), _ => { }); - var hand = HandBuilt(cache); - Assert.Equal(100 + 10 + 5, Loadouts.Price(items, hand)); - var credits = 1000; - Assert.NotNull(Loadouts.Materialize(items, null, hand, _ => true, ref credits, new List())); - Assert.Equal(1000 - 115, credits); - } - // Two more Lamp products, upserted into the live cache so its enumeration order (z before a) disagrees with // record-key order; a reopened snapshot could already hand them back sorted. [Fact] @@ -217,8 +248,7 @@ public sealed class LoadoutTests : IDisposable var hand = HandBuilt(cache); string LampProduct(Predicate available, List failures) { - var credits = 1000; - var ship = Loadouts.Materialize(items, null, hand, available, ref credits, failures); + var ship = Loadouts.Materialize(items, null, hand, available, failures); return ship == null ? null : cache.Get(ship.Equipment.Single(item => item.EquippableItem != ship.Hull && item.Position.Equals(HardpointCell)).EquippableItem.Product).Name; } @@ -238,25 +268,10 @@ public sealed class LoadoutTests : IDisposable var stale = Armed(cache); stale.WeaponGroups = new[] { new[] { 0, 2 }, new[] { -1 } }; var failures = new List(); - var credits = 1000; - Assert.Null(Loadouts.Materialize(items, null, stale, _ => true, ref credits, failures)); - Assert.Equal(1000, credits); + Assert.Null(Build(items, stale, failures)); Assert.Equal(new[] { "weapon group: no slot 2", "weapon group: no slot -1" }, failures); } - [Fact] - public void OneCreditShortBuildsNothing() - { - using var cache = Open(); - var items = new ItemManager(cache, RunSaveTests.TestSettings(), _ => { }); - var hand = HandBuilt(cache); - var failures = new List(); - var credits = (int) Loadouts.Price(items, hand) - 1; - Assert.Null(Loadouts.Materialize(items, null, hand, _ => true, ref credits, failures)); - Assert.Equal(114, credits); - Assert.Equal("cannot afford 115 credits", Assert.Single(failures)); - } - [Fact] public void UnequippingAGroupedWeaponRemovesItFromItsGroups() { @@ -279,9 +294,7 @@ public sealed class LoadoutTests : IDisposable var items = new ItemManager(cache, RunSaveTests.TestSettings(), _ => { }); var lampGrouped = WithDesign(Armed(cache), 0, cache, "Lamp"); var failures = new List(); - var credits = 1000; - Assert.Null(Loadouts.Materialize(items, null, lampGrouped, _ => true, ref credits, failures)); - Assert.Equal(1000, credits); + Assert.Null(Build(items, lampGrouped, failures)); Assert.Equal("weapon group: slot 0,0: Lamp is not a weapon", Assert.Single(failures)); } @@ -293,9 +306,7 @@ public sealed class LoadoutTests : IDisposable var wide = Armed(cache); wide.WeaponGroups = wide.WeaponGroups.Append(new[] { 0 }).ToArray(); var failures = new List(); - var credits = 1000; - Assert.Null(Loadouts.Materialize(items, null, wide, _ => true, ref credits, failures)); - Assert.Equal(1000, credits); + Assert.Null(Build(items, wide, failures)); Assert.Equal("weapon groups: 7 groups, at most 6", Assert.Single(failures)); } @@ -321,30 +332,9 @@ public sealed class LoadoutTests : IDisposable } } - // The slots alone sum to exactly int.MaxValue; adding the hull must not wrap the total negative and pay the player. - [Fact] - public void PriceBeyondIntIsUnaffordable() - { - using var cache = AetheriaStores.Open(Catalog, catalogWritable: true); - var brick = cache.Upsert(new GearData { Name = "Brick", Hardpoint = HardpointType.Sensors, Shape = new Shape(), Price = int.MaxValue - 5 }); - cache.Upsert(new FactionProductData { Name = "Brick by Maker", Design = new CultRecordRef(brick.Key), Manufacturer = cache.RefOf(cache.GetByName("Maker")) }); - - var items = new ItemManager(cache, RunSaveTests.TestSettings(), _ => { }); - var gold = WithDesign(HandBuilt(cache), 0, cache, "Brick"); - Assert.Equal(100L + int.MaxValue, Loadouts.Price(items, gold)); - var failures = new List(); - var credits = int.MaxValue; - Assert.Null(Loadouts.Materialize(items, null, gold, _ => true, ref credits, failures)); - Assert.Equal(int.MaxValue, credits); - Assert.StartsWith("cannot afford", Assert.Single(failures)); - } - - // Everything available and affordable: for tests about placement and failure lists. - private static Ship Build(ItemManager items, Loadout loadout, List failures) - { - var credits = int.MaxValue; - return Loadouts.Materialize(items, null, loadout, _ => true, ref credits, failures); - } + // Everything available: for tests about placement and failure lists. + private static Ship Build(ItemManager items, Loadout loadout, List failures) => + Loadouts.Materialize(items, null, loadout, _ => true, failures); private static (int2, ItemRotation, CultRecordKey) Describe(LoadoutSlot slot) => (slot.Position, slot.Rotation, slot.Design.Key);