diff --git a/Assets/Scripts/ServerShared/Behaviors/Behaviors.cs b/Assets/Scripts/ServerShared/Behaviors/Behaviors.cs index cd0cc98ec..8caec9d5d 100644 --- a/Assets/Scripts/ServerShared/Behaviors/Behaviors.cs +++ b/Assets/Scripts/ServerShared/Behaviors/Behaviors.cs @@ -141,11 +141,21 @@ public interface IPopulationAssignment } [MessagePackObject, - JsonObject(MemberSerialization.OptIn)] + JsonObject(MemberSerialization.OptIn), + Union(0, typeof(MarkerPersistentBehaviorData))] public abstract class PersistentBehaviorData { } +// No IBehavior implements IPersistentBehavior yet, so this abstract type has no production writer. This one +// concrete leaf exists so the wire format (EntitySerializer's PersistedBehaviors pairs array) can be exercised by +// tests ahead of a real persistent behavior landing. +[MessagePackObject] +public class MarkerPersistentBehaviorData : PersistentBehaviorData +{ + [Key(0)] public int Value; +} + [Inspectable, Union(0, typeof(GuidedWeaponData)), Union(1, typeof(LauncherData)), diff --git a/Assets/Scripts/ServerShared/EntitySerializer.cs b/Assets/Scripts/ServerShared/EntitySerializer.cs index 993e8b92e..082f350f7 100644 --- a/Assets/Scripts/ServerShared/EntitySerializer.cs +++ b/Assets/Scripts/ServerShared/EntitySerializer.cs @@ -30,14 +30,16 @@ public static class EntitySerializer pack.Settings = entity.Settings; // Filter item behavior collections by those with any persistent behaviors - // For each item create an object containing the item position and a list of persistent behaviors - // Then turn that into a dictionary mapping from item position to an array of every behaviors persistent data + // For each item create a (position, data) pair holding every one of its behaviors' persistent data. + // A dictionary keyed by int2 has no hash-resistant comparer under CultCache's untrusted-data security, so + // this is an array of pairs, matching Equipment/CargoBays/DockingBays. pack.PersistedBehaviors = entity.Equipment .Where(item => item.Behaviors.Any(b=>b is IPersistentBehavior)) - .Select(item => new {equippable=item, behaviors = item.Behaviors + .Select(item => (item.Position, item.Behaviors .Where(b=>b is IPersistentBehavior) - .Cast()}) - .ToDictionary(x=> x.equippable.Position, x=>x.behaviors.Select(b => b.Store()).ToArray()); + .Cast() + .Select(b => b.Store()).ToArray())) + .ToArray(); pack.Hull = entity.Hull; pack.Name = entity.Name; @@ -121,13 +123,15 @@ public static class EntitySerializer entity.DockingBays[bayIndex].TryStore(item, position); // Iterate only over the behaviors of items which contain persistent data - // Filter the behaviors for each item to get the persistent ones, then cast them and combine with the persisted data array for that item + // Build a local lookup from the pairs array, then filter each item's behaviors for the persistent ones, + // cast them and combine with that item's persisted data array. + var persistedBehaviorsByPosition = pack.PersistedBehaviors.ToDictionary(x => x.position, x => x.data); foreach (var persistentBehaviorData in entity.Equipment - .Where(item => pack.PersistedBehaviors.ContainsKey(item.Position)) + .Where(item => persistedBehaviorsByPosition.ContainsKey(item.Position)) .SelectMany(item => item.Behaviors .Where(b=> b is IPersistentBehavior) .Cast() - .Zip(pack.PersistedBehaviors[item.Position], (behavior, data) => new{behavior, data}))) + .Zip(persistedBehaviorsByPosition[item.Position], (behavior, data) => new{behavior, data}))) persistentBehaviorData.behavior.Restore(persistentBehaviorData.data); entity.Temperature = pack.Temperature; @@ -197,7 +201,7 @@ public abstract class EntityPack [Key(2)] public (int2 position, EquippableItem item)[] Equipment; [Key(3)] public (int2 position, EquippableItem item)[] CargoBays; [Key(4)] public (int2 position, EquippableItem item)[] DockingBays; - [Key(5)] public Dictionary PersistedBehaviors; + [Key(5)] public (int2 position, PersistentBehaviorData[] data)[] PersistedBehaviors; [Key(6)] public float[,] Temperature; [Key(7)] public float[,] Armor; [Key(8)] public bool2[,] Conductivity; diff --git a/tests/Aetheria.Shared.Tests/RunSaveTests.cs b/tests/Aetheria.Shared.Tests/RunSaveTests.cs index 7f7f21709..d46cc10c8 100644 --- a/tests/Aetheria.Shared.Tests/RunSaveTests.cs +++ b/tests/Aetheria.Shared.Tests/RunSaveTests.cs @@ -7,6 +7,7 @@ using CultMath; using GameCult.Caching; using GameCult.Caching.MessagePack; using Xunit; +using Random = CultMath.Random; // The run lifecycle over all three stores: a save is one commit with stable zone keys, and a clear removes every run record. public sealed class RunSaveTests : IDisposable @@ -177,10 +178,123 @@ public sealed class RunSaveTests : IDisposable Assert.Throws(() => ledger.Reachable(new[] { 9 })); } + // F1: PersistedBehaviors is now a pairs array (Dictionary has no hash-resistant comparer under + // CultCache's untrusted-data security, so a store holding any entity could never be reopened). Two positions, + // each with one persisted behavior, must round-trip through a commit and reopen. + [Fact] + public void ReopenedPackKeepsPersistedBehaviors() + { + var posA = new int2(0, 0); + var posB = new int2(1, 0); + var pack = BarePack(hull: null, persistedBehaviors: new (int2, PersistentBehaviorData[])[] + { + (posA, new PersistentBehaviorData[] { new MarkerPersistentBehaviorData { Value = 11 } }), + (posB, new PersistentBehaviorData[] { new MarkerPersistentBehaviorData { Value = 22 } }) + }); + + using (var cache = Open()) + { + var zones = new[] + { + new SavedZone + { + Name = "Zone 0", AdjacentZones = Array.Empty(), Factions = Array.Empty(), Owner = -1, + Contents = new ZonePack { Entities = new List { pack } } + } + }; + RunSave.Commit(cache, Game(cache), zones, new ProvenanceLedger()); + } + + using (var cache = Open()) + { + var run = cache.GetGlobal(); + var zone = cache.Get(run.Zones[0]); + var reopened = zone.Contents.Entities[0].PersistedBehaviors.OrderBy(p => p.position.x).ToArray(); + Assert.Equal(2, reopened.Length); + Assert.Equal(posA, reopened[0].position); + Assert.Equal(11, Assert.IsType(reopened[0].data.Single()).Value); + Assert.Equal(posB, reopened[1].position); + Assert.Equal(22, Assert.IsType(reopened[1].data.Single()).Value); + } + } + + // F1: the reopen defect was found through a hand-built pack; this pins the same defect class at the layer it + // actually failed, a real ship generated by LoadoutGenerator against a catalog fixture, committed into a + // SavedZone and reopened (Soul's probe in docs/headless-playground-cut.md ยง2.6). + [Fact] + public void ReopenSurvivesAGeneratedShipWithEntities() + { + using (var cache = AetheriaStores.Open(Catalog, catalogWritable: true)) + { + var maker = cache.Upsert(new Faction { Name = "Generator Maker", ShortName = "GEN" }); + var hullShape = new Shape(4, 4); + foreach (var cell in hullShape.AllCoordinates) hullShape[cell] = true; + var hull = cache.Upsert(new HullData + { + Name = "Runabout", HullType = HullType.Ship, Shape = hullShape, Price = 50, + Hardpoints = { new HardpointData { Type = HardpointType.Sensors, Position = new int2(0, 0), Shape = new Shape() } } + }); + var gear = cache.Upsert(new GearData { Name = "Scanner", Hardpoint = HardpointType.Sensors, Shape = new Shape(), Price = 5 }); + var cargo = cache.Upsert(new CargoBayData { Name = "Hold", Shape = new Shape(), InteriorShape = new Shape(), Price = 3 }); + var capacitor = cache.Upsert(new GearData { Name = "Cap", Hardpoint = HardpointType.Tool, Shape = new Shape(), Price = 1, Behaviors = { new CapacitorData() } }); + foreach (var (name, design) in new[] { ("Runabout by Generator Maker", hull.Key), ("Scanner by Generator Maker", gear.Key), ("Hold by Generator Maker", cargo.Key), ("Cap by Generator Maker", capacitor.Key) }) + cache.Upsert(new FactionProductData { Name = name, Design = new CultRecordRef(design), Manufacturer = maker }); + cache.FlushAsync().Wait(); + } + + EntityPack pack; + ProvenanceLedger lots; + using (var cache = Open()) + { + var random = new Random(7); + var items = new ItemManager(cache, new ProvenanceLedger(), TestSettings(), _ => { }); + var generator = new LoadoutGenerator(ref random, items, null, null, null, .5f); + pack = generator.GenerateShipLoadout(candidate => candidate.Name == "Runabout"); + Assert.NotNull(pack); + lots = items.Lots; + + var game = new SavedGame + { + Factions = Array.Empty>(), + Relationships = Array.Empty(), + HomeZones = new Dictionary(), + BossZones = new Dictionary(), + DiscoveredZones = new[] { 0 }, + ActionBarBindings = new SavedActionBarBinding[0], + Exit = -1 + }; + var zones = new[] + { + new SavedZone + { + Name = "Zone 0", AdjacentZones = Array.Empty(), Factions = Array.Empty(), Owner = -1, + Contents = new ZonePack { Entities = new List { pack } } + } + }; + RunSave.Commit(cache, game, zones, lots); + } + + using (var cache = Open()) + { + var storedLots = RunSave.Lots(cache); + var run = cache.GetGlobal(); + var zone = cache.Get(run.Zones[0]); + Assert.NotNull(zone.Contents); + var crafted = EntitySerializer.Items(zone.Contents.Entities[0]).OfType().ToArray(); + Assert.NotEmpty(crafted); + foreach (var instance in crafted) + { + var lot = storedLots[instance.Lot]; + Assert.Equal(instance.Data.Key, lot.Design.Key); + } + } + } + // A minimal, valid EntityPack with every collection field non-null, for tests that build packs directly rather // than through EntitySerializer.Pack. private static ShipPack BarePack(EquippableItem hull, EntityPack[] children = null, - (int2, ItemInstance)[][] dockingBayContents = null) => new ShipPack + (int2, ItemInstance)[][] dockingBayContents = null, + (int2, PersistentBehaviorData[])[] persistedBehaviors = null) => new ShipPack { Name = "ship", Hull = hull, @@ -190,7 +304,7 @@ public sealed class RunSaveTests : IDisposable CargoContents = Array.Empty<(int2, ItemInstance)[]>(), DockingBayContents = dockingBayContents ?? Array.Empty<(int2, ItemInstance)[]>(), Children = children ?? Array.Empty(), - PersistedBehaviors = new Dictionary(), + PersistedBehaviors = persistedBehaviors ?? Array.Empty<(int2, PersistentBehaviorData[])>(), Temperature = new float[0, 0], Armor = new float[0, 0], Conductivity = new bool2[0, 0],