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(); + } +}