From 1cd6170d7a274c15cd07ff099f40018abee5e0fa Mon Sep 17 00:00:00 2001 From: Meta Vens Date: Mon, 14 Sep 2026 22:38:16 +0200 Subject: [PATCH] Decide loadout affordability in Materialize and test weapon-group unequip (Cut 10 fix) Materialize, which already charges, now also owns affordability: when credits are below Price it lists "cannot afford credits" and builds nothing. The restore menu's separate price < Credits check is deleted; the menu still shows Loadouts.Price and lists Materialize's failures. Tests: one credit short builds nothing, leaves credits unchanged and lists the failure. A fixture gun with an InstantWeapon behavior proves TryUnequip removes the weapon and its item from every weapon group. Co-Authored-By: Claude Opus 5 --- Assets/Scripts/ServerShared/Loadout.cs | 8 ++-- Assets/Scripts/UI/Menu/InventoryPanel.cs | 2 +- tests/Aetheria.Shared.Tests/LoadoutTests.cs | 41 ++++++++++++++++++++- 3 files changed, 45 insertions(+), 6 deletions(-) diff --git a/Assets/Scripts/ServerShared/Loadout.cs b/Assets/Scripts/ServerShared/Loadout.cs index e38033bfc..570521531 100644 --- a/Assets/Scripts/ServerShared/Loadout.cs +++ b/Assets/Scripts/ServerShared/Loadout.cs @@ -65,8 +65,8 @@ public static class Loadouts cache.Commit(batch => batch.Upsert(typeof(Loadout), loadout, key)); } - // All-or-nothing: returns a ship and charges Price only when every design resolved, had an available product and - // fitted. Every failure is listed; on any failure nothing is returned, nothing is charged and nothing outside this + // All-or-nothing: returns a ship and charges Price only when credits cover Price and every design resolved, had an + // available product and fitted. Affordability is decided here and nowhere else. Every failure is listed; on any failure nothing is returned, nothing is charged and nothing outside this // call has changed. 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. public static Ship Materialize(ItemManager itemManager, Zone liveZone, Loadout loadout, @@ -94,6 +94,8 @@ public static class Loadouts var slotProducts = loadout.Slots.Select(slot => Resolve(slot.Design, Cell(slot))).ToArray(); foreach (var index in (loadout.WeaponGroups ?? new int[0][]).SelectMany(group => group)) if (index < 0 || index >= loadout.Slots.Count) failures.Add($"weapon group: no slot {index}"); + 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); @@ -124,7 +126,7 @@ public static class Loadouts return (items.Select(item => item.GetBehavior()).ToList(), items); }) .ToArray(); - credits -= Price(itemManager, loadout); + credits -= price; return ship; } diff --git a/Assets/Scripts/UI/Menu/InventoryPanel.cs b/Assets/Scripts/UI/Menu/InventoryPanel.cs index 8454b47eb..a35a15331 100644 --- a/Assets/Scripts/UI/Menu/InventoryPanel.cs +++ b/Assets/Scripts/UI/Menu/InventoryPanel.cs @@ -189,7 +189,7 @@ public class InventoryPanel : MonoBehaviour, IPointerClickHandler loadouts.Select(loadout => { var price = Loadouts.Price(GameManager.ItemManager, loadout); - return ($"{loadout.Name} - {price:n0}", () => RestoreLoadout(loadout), price < GameManager.Credits); + return ($"{loadout.Name} - {price:n0}", () => RestoreLoadout(loadout), true); })); } diff --git a/tests/Aetheria.Shared.Tests/LoadoutTests.cs b/tests/Aetheria.Shared.Tests/LoadoutTests.cs index cbceb900c..385088fd0 100644 --- a/tests/Aetheria.Shared.Tests/LoadoutTests.cs +++ b/tests/Aetheria.Shared.Tests/LoadoutTests.cs @@ -246,10 +246,47 @@ public sealed class LoadoutTests : IDisposable Assert.Equal(new[] { "weapon group: no slot 2", "weapon group: no slot -1" }, failures); } - // Everything available, credits discarded: for tests about placement and failure lists. + [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 = 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() + { + using var cache = AetheriaStores.Open(Catalog, catalogWritable: true); + var gun = new GearData { Name = "Gun", Hardpoint = HardpointType.Sensors, Shape = new Shape(), Price = 1, Behaviors = { new InstantWeaponData() } }; + cache.Commit(batch => batch.Upsert(typeof(GearData), gun, new CultRecordKey("gun"))); + cache.Commit(batch => batch.Upsert(typeof(FactionProductData), new FactionProductData + { + Name = "Gun by Maker", Design = new CultRecordRef(new CultRecordKey("gun")), + Manufacturer = cache.RefOf(cache.GetByName("Maker")) + }, new CultRecordKey("gun-by-maker"))); + + var items = new ItemManager(cache, RunSaveTests.TestSettings(), _ => { }); + var armed = HandBuilt(cache); + armed.Slots[0].Design = new CultRecordRef(new CultRecordKey("gun")); + var ship = Build(items, armed, new List()); + var item = Assert.Single(ship.WeaponGroups[0].items); + Assert.NotNull(item.GetBehavior()); + + Assert.NotNull(ship.TryUnequip(item)); + Assert.All(ship.WeaponGroups, group => Assert.Empty(group.items)); + Assert.All(ship.WeaponGroups, group => Assert.Empty(group.weapons)); + } + + // Everything available and affordable: for tests about placement and failure lists. private static Ship Build(ItemManager items, Loadout loadout, List failures) { - var credits = 0; + var credits = int.MaxValue; return Loadouts.Materialize(items, null, loadout, _ => true, ref credits, failures); }