From 00f62acc34d8fb486d9159081c0e2f43af3157cb Mon Sep 17 00:00:00 2001 From: Meta Vens Date: Mon, 14 Sep 2026 22:06:06 +0200 Subject: [PATCH] Make loadouts player-store design documents built all-or-nothing (Cut 10) Loadout.cs owns loadouts: the document, capture, save and materialization. - Loadout ("aetheria.loadout", player store) holds a hull design ref, and per slot a hull cell, a rotation and an EquippableItemData design ref, plus weapon groups as slot indices. It names no product, manufacturer, unit roll, cargo, faction, position or behaviour state. - Loadouts.Capture records the hull design and one slot per equipped item (gear, cargo bays, docking bays). Entity.Equipment also holds the hull's own unit (MapEntity adds EquippedHull); that is Loadout.Hull, not a slot. - Loadouts.Save is the only writer: one Commit to the player store, reusing the existing key for the name. - Loadouts.Materialize is the only builder of a ship from a loadout. For the hull and each slot in order it resolves the design and takes the first product in catalog order that LoadoutGenerator.IsAvailable (now public) accepts, with no any-manufacturer fallback. Every failing slot is listed; units are built only after everything resolved, and any resolve or fit failure returns null with no ship, no charge and no write. - Loadouts.Price is the hull design's price plus each slot design's price. - InventoryPanel: Save Loadout asks for a name (default: the ship's name) and saves the capture. Restore Loadout, shown when docked with saved loadouts, lists each with its price, debits credits only after Materialize succeeds, then docks the ship as before; a failure opens "Loadout cannot be built here" with one line per failing slot. Tests: new LoadoutTests (3): LoadoutRoundTripsThroughAFreshCache, LoadoutHoldsNoRunOrGalaxyRefs, MissingDesignReports. Each fails with its rule mutated out. The fixture hull is 3x3, not the spec's 2x2: HullData.InteriorCells is Shape.Shrink(), so a 2x2 hull has no interior cell a cargo bay can occupy. Builds: headless, 14 tests pass, Unity batchmode compile reports no error CS. Co-Authored-By: Claude Opus 5 --- Assets/Scripts/ServerShared/AetheriaStores.cs | 2 +- Assets/Scripts/ServerShared/Loadout.cs | 135 ++++++++++++++ Assets/Scripts/ServerShared/Loadout.cs.meta | 2 + .../Scripts/ServerShared/LoadoutGenerator.cs | 3 +- Assets/Scripts/UI/Menu/InventoryPanel.cs | 50 +++++ tests/Aetheria.Shared.Tests/LoadoutTests.cs | 172 ++++++++++++++++++ 6 files changed, 362 insertions(+), 2 deletions(-) create mode 100644 Assets/Scripts/ServerShared/Loadout.cs create mode 100644 Assets/Scripts/ServerShared/Loadout.cs.meta create mode 100644 tests/Aetheria.Shared.Tests/LoadoutTests.cs diff --git a/Assets/Scripts/ServerShared/AetheriaStores.cs b/Assets/Scripts/ServerShared/AetheriaStores.cs index 924777f41..10e0fb4e0 100644 --- a/Assets/Scripts/ServerShared/AetheriaStores.cs +++ b/Assets/Scripts/ServerShared/AetheriaStores.cs @@ -8,7 +8,7 @@ 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[] RunTypes = { typeof(OrbitData), typeof(BodyData), typeof(SavedZone), typeof(SavedGame) }; - public static readonly Type[] PlayerTypes = { typeof(PlayerSettings) }; + public static readonly Type[] PlayerTypes = { typeof(PlayerSettings), typeof(Loadout) }; // 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 new file mode 100644 index 000000000..df6ad7c7d --- /dev/null +++ b/Assets/Scripts/ServerShared/Loadout.cs @@ -0,0 +1,135 @@ +using System.Collections.Generic; +using System.Linq; +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. +[CultDocument("aetheria.loadout", "1"), MessagePackObject] +public class Loadout +{ + [CultName, Key(0)] public string Name; + [Key(1)] public CultRecordRef Hull; + [Key(2)] public List Slots = new List(); + [Key(3)] public int[][] WeaponGroups; // indices into Slots +} + +[MessagePackObject] +public class LoadoutSlot +{ + [Key(0)] public int2 Position; // hull cell passed to Entity.TryEquip(item, int2) + [Key(1)] public ItemRotation Rotation; + [Key(2)] public CultRecordRef Design; +} + +// The owner of loadouts: capture from a live ship, the only writer, and the only builder of a ship from a loadout. +public static class Loadouts +{ + // Records the hull design and one slot per equipped item (gear, then cargo bays, then docking bays) with its cell, + // rotation and design. Entity.Equipment also holds the hull's own unit; that is Hull, not a slot. Nothing about the + // units themselves: no product, quality, ingredients, cargo or state. + public static Loadout Capture(ItemManager itemManager, Entity entity, string name) + { + var cache = itemManager.ItemData; + var items = entity.Equipment + .Concat(entity.CargoBays) + .Concat(entity.DockingBays) + .Where(item => item.EquippableItem != entity.Hull) + .Distinct() + .ToList(); + return new Loadout + { + Name = name, + Hull = cache.RefOf((HullData) itemManager.GetData(entity.Hull)), + Slots = items.Select(item => new LoadoutSlot + { + Position = item.Position, + Rotation = item.EquippableItem.Rotation, + Design = cache.RefOf(itemManager.GetData(item.EquippableItem)) + }).ToList(), + WeaponGroups = entity.WeaponGroups + .Select(group => group.items.Select(item => items.IndexOf(item)).ToArray()) + .ToArray() + }; + } + + // 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) + { + 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)); + } + + // All-or-nothing: returns a ship only when every design resolved, had an available product and fitted. Every + // failing slot is listed; on any failure nothing is returned and nothing outside this call has changed. + // Availability is LoadoutGenerator.IsAvailable, with no fallback to any manufacturer. + public static Ship Materialize(ItemManager itemManager, Galaxy galaxy, GalaxyZone zone, Faction stationFaction, + Zone liveZone, Loadout loadout, List failures) + { + var cache = itemManager.ItemData; + var availability = new LoadoutGenerator(ref itemManager.Random, itemManager, galaxy, zone, stationFaction, 0); + var products = cache.GetAll().ToArray(); + var reported = failures.Count; + + FactionProductData Resolve(CultRecordRef design, string where) where T : EquippableItemData + { + var data = cache.Get(design); + if (data == null) + { + failures.Add($"{where}: design {design.Key} is not in the catalog"); + return null; + } + + var product = products.FirstOrDefault(p => p.Design.Key.Equals(design.Key) && availability.IsAvailable(p)); + if (product == null) failures.Add($"{where}: no available product of {data.Name}"); + return product; + } + + var hullProduct = Resolve(loadout.Hull, "hull"); + var slotProducts = loadout.Slots.Select(slot => Resolve(slot.Design, Cell(slot))).ToArray(); + if (failures.Count > reported) return null; + + var hull = (EquippableItem) itemManager.CreateInstance(hullProduct); + var ship = new Ship(itemManager, liveZone, hull, itemManager.GameplaySettings.DefaultEntitySettings); + var equipped = new EquippedItem[loadout.Slots.Count]; + for (var i = 0; i < loadout.Slots.Count; i++) + { + var slot = loadout.Slots[i]; + var unit = (EquippableItem) itemManager.CreateInstance(slotProducts[i]); + unit.Rotation = slot.Rotation; + if (!ship.TryEquip(unit, slot.Position)) + { + failures.Add($"{Cell(slot)}: {cache.Get(slot.Design).Name} does not fit"); + continue; + } + + equipped[i] = ship.Equipment + .Concat(ship.CargoBays) + .Concat(ship.DockingBays) + .First(item => item.EquippableItem == unit); + } + if (failures.Count > reported) return null; + + ship.WeaponGroups = (loadout.WeaponGroups ?? new int[0][]) + .Select(indices => + { + var items = indices.Select(i => equipped[i]).ToList(); + return (items.Select(item => item.GetBehavior()).ToList(), items); + }) + .ToArray(); + return ship; + } + + // The hull design's price plus each slot design's price. + public static int Price(ItemManager itemManager, Loadout loadout) + { + var cache = itemManager.ItemData; + return (cache.Get(loadout.Hull)?.Price ?? 0) + loadout.Slots.Sum(slot => 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/Loadout.cs.meta b/Assets/Scripts/ServerShared/Loadout.cs.meta new file mode 100644 index 000000000..d4b4c562f --- /dev/null +++ b/Assets/Scripts/ServerShared/Loadout.cs.meta @@ -0,0 +1,2 @@ +fileFormatVersion: 2 +guid: faa68026d2f830340aaafc08ba99224d \ No newline at end of file diff --git a/Assets/Scripts/ServerShared/LoadoutGenerator.cs b/Assets/Scripts/ServerShared/LoadoutGenerator.cs index 571c52d15..a588689c3 100644 --- a/Assets/Scripts/ServerShared/LoadoutGenerator.cs +++ b/Assets/Scripts/ServerShared/LoadoutGenerator.cs @@ -149,7 +149,8 @@ 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. - private bool IsAvailable(FactionProductData product) => + // Loadouts.Materialize asks this too, so a loadout 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 2087a3870..7648c342c 100644 --- a/Assets/Scripts/UI/Menu/InventoryPanel.cs +++ b/Assets/Scripts/UI/Menu/InventoryPanel.cs @@ -167,11 +167,61 @@ public class InventoryPanel : MonoBehaviour, IPointerClickHandler if(GameManager.DockingBay!=null && _displayedCargo!=GameManager.DockingBay) ContextMenu.AddOption(GameManager.DockingBay.Name, () => Display(GameManager.DockingBay)); + ContextMenu.AddOption("Save Loadout", + () => + { + var entity = _displayedEntity; + var loadoutName = entity.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, entity, 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, price), price < GameManager.Credits); + })); + } + 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, int price) + { + var station = GameManager.DockedEntity; + var failures = new List(); + var ship = Loadouts.Materialize(GameManager.ItemManager, ActionGameManager.CurrentGalaxy, GameManager.Zone.GalaxyZone, + station.Faction, GameManager.Zone, loadout, 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; + } + + GameManager.Credits -= price; + 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 new file mode 100644 index 000000000..c347f0b47 --- /dev/null +++ b/tests/Aetheria.Shared.Tests/LoadoutTests.cs @@ -0,0 +1,172 @@ +using System; +using System.Collections.Generic; +using System.IO; +using System.Linq; +using System.Security.Cryptography; +using CultMath; +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. +// 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 +{ + private readonly string _root = Path.Combine(Path.GetTempPath(), "aetheria-loadout-" + Guid.NewGuid().ToString("N")); + private string Catalog => Path.Combine(_root, "Aetheria.cc"); + private string Run => Path.Combine(_root, "run.cc"); + private string Player => Path.Combine(_root, "player.cc"); + + private static readonly int2 HardpointCell = new int2(0, 0); + private static readonly int2 InteriorCell = new int2(1, 1); + + public LoadoutTests() + { + Directory.CreateDirectory(_root); + using var cache = AetheriaStores.Open(Catalog, catalogWritable: true); + cache.Upsert(new TestCatalogGlobal { Name = "Temperament" }); + var maker = cache.Upsert(new Faction { Name = "Maker", ShortName = "MKR" }); + + var hullShape = new Shape(3, 3); + foreach (var cell in hullShape.AllCoordinates) hullShape[cell] = true; + var hull = cache.Upsert(new HullData + { + Name = "Skiff", HullType = HullType.Ship, Shape = hullShape, Price = 100, + Hardpoints = { new HardpointData { Type = HardpointType.Sensors, Position = HardpointCell, Shape = new Shape() } } + }); + var gear = cache.Upsert(new GearData { Name = "Lamp", Hardpoint = HardpointType.Sensors, Shape = new Shape(), Price = 10 }); + var cargo = cache.Upsert(new CargoBayData { Name = "Crate", Shape = new Shape(), InteriorShape = new Shape(), Price = 5 }); + cache.Upsert(new GearData { Name = "Orphan", Hardpoint = HardpointType.Sensors, Shape = new Shape(), Price = 1 }); + + foreach (var (name, design) in new[] { ("Skiff by Maker", hull.Key), ("Lamp by Maker", gear.Key), ("Crate by Maker", cargo.Key) }) + cache.Upsert(new FactionProductData { Name = name, Design = new CultRecordRef(design), Manufacturer = maker }); + cache.FlushAsync().Wait(); + } + + public void Dispose() => Directory.Delete(_root, true); + + private CultCache Open() => AetheriaStores.Open(Catalog, Run, Player); + + // Gear on the hull's hardpoint cell, cargo on an interior cell, and the gear alone in the first weapon group. + private static Loadout HandBuilt(CultCache cache, string name = "Skiff build") => new Loadout + { + Name = name, + Hull = cache.RefOf(cache.GetByName("Skiff")), + Slots = + { + new LoadoutSlot { Position = HardpointCell, Design = cache.RefOf(cache.GetByName("Lamp")) }, + new LoadoutSlot { Position = InteriorCell, Design = cache.RefOf(cache.GetByName("Crate")) } + }, + WeaponGroups = new[] { new[] { 0 }, new int[0] } + }; + + [Fact] + public void LoadoutRoundTripsThroughAFreshCache() + { + Loadout hand; + using (var cache = Open()) + { + var items = new ItemManager(cache, RunSaveTests.TestSettings(), _ => { }); + hand = HandBuilt(cache); + var failures = new List(); + var ship = Loadouts.Materialize(items, null, null, null, null, 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)); + } + + using (var cache = Open()) + { + Assert.Single(cache.GetAll()); + var loaded = cache.GetByName(hand.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 = Loadouts.Materialize(items, null, null, null, null, loaded, failures); + Assert.Empty(failures); + Assert.Equal(hand.Slots.Select(slot => (slot.Position, slot.Design.Key)), + ship.Equipment.Concat(ship.CargoBays) + .Where(item => item != ship.EquippedHull) + .Select(item => (item.Position, cache.RefOf(items.GetData(item.EquippableItem)).Key))); + } + + Assert.Single(SchemaNames(Player), name => name == "aetheria.loadout"); + } + + [Fact] + public void LoadoutHoldsNoRunOrGalaxyRefs() + { + var forbidden = new[] { typeof(ItemInstance), typeof(EntityPack), typeof(FactionProductData), typeof(Faction) } + .Concat(AetheriaStores.RunTypes) + .ToArray(); + var seen = new HashSet(); + var pending = new Queue(new[] { typeof(Loadout) }); + while (pending.Count > 0) + { + var type = pending.Dequeue(); + if (!seen.Add(type)) continue; + Assert.DoesNotContain(forbidden, bad => bad.IsAssignableFrom(type)); + if (type.IsGenericType && type.GetGenericTypeDefinition() == typeof(CultRecordRef<>)) + { + Assert.True(typeof(EquippableItemData).IsAssignableFrom(type.GetGenericArguments()[0]), $"{type} names a non-design record"); + continue; + } + if (type.IsArray) pending.Enqueue(type.GetElementType()); + foreach (var argument in type.IsGenericType ? type.GetGenericArguments() : Type.EmptyTypes) pending.Enqueue(argument); + if (type.Assembly == typeof(Loadout).Assembly) + foreach (var field in type.GetFields(System.Reflection.BindingFlags.Instance | System.Reflection.BindingFlags.Public | System.Reflection.BindingFlags.NonPublic)) + pending.Enqueue(field.FieldType); + } + + using (var cache = Open()) + Loadouts.Save(cache, HandBuilt(cache)); + Assert.Contains("aetheria.loadout", SchemaNames(Player)); + Assert.True(!File.Exists(Run) || SchemaNames(Run).Length == 0); + } + + [Fact] + public void MissingDesignReports() + { + using (var cache = Open()) + cache.Commit(batch => batch.Upsert(new PlayerSettings())); + var before = new[] { Catalog, Run, Player }.Select(Hash).ToArray(); + + using (var cache = Open()) + { + var items = new ItemManager(cache, RunSaveTests.TestSettings(), _ => { }); + var missing = HandBuilt(cache); + missing.Slots[1].Design = new CultRecordRef(new CultRecordKey("absent-design")); + var failures = new List(); + Assert.Null(Loadouts.Materialize(items, null, null, null, null, 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(Loadouts.Materialize(items, null, null, null, null, missing, failures)); + Assert.Equal(2, failures.Count); + Assert.Contains("slot 1,1", failures[0]); + Assert.Contains("slot 2,2", failures[1]); + Assert.Contains("no available product of Orphan", failures[1]); + } + + Assert.Equal(before, new[] { Catalog, Run, Player }.Select(Hash).ToArray()); + } + + private static (int2, ItemRotation, CultRecordKey) Describe(LoadoutSlot slot) => (slot.Position, slot.Rotation, slot.Design.Key); + + private static string Hash(string path) => File.Exists(path) ? Convert.ToHexString(SHA256.HashData(File.ReadAllBytes(path))) : "absent"; + + private static string[] SchemaNames(string path) + { + var snapshot = CultDocumentMessagePackSerialization.DeserializeSnapshot(File.ReadAllBytes(path)); + return snapshot.Records + .Select(record => snapshot.SchemaCatalog.Single(entry => entry.SchemaId == record.SchemaId).SchemaName) + .ToArray(); + } +}