Fix F1: PersistedBehaviors as a pairs array, per Cut 0's ruling
Dictionary<int2, PersistentBehaviorData[]> has no hash-resistant comparer under CultCache's UntrustedData security, so any run store holding an entity threw MessagePackSerializationException on reopen, even when the dictionary was empty. EntityPack.PersistedBehaviors (key 5, unchanged) becomes (int2 position, PersistentBehaviorData[] data)[], matching Equipment/CargoBays/DockingBays. Pack writes pairs; Unpack builds a local lookup before restoring behaviors. PersistentBehaviorData had no concrete leaf and no Union, so nothing could ever exercise its wire format; added MarkerPersistentBehaviorData as the one placeholder leaf so ReopenedPackKeepsPersistedBehaviors can round-trip real data ahead of a real IPersistentBehavior implementer landing. Tests: RunSaveTests.BarePack uses an empty pairs array. Added ReopenedPackKeepsPersistedBehaviors (a hand-built pack with two positions, each with one persisted behavior, round-trips through commit and reopen) and ReopenSurvivesAGeneratedShipWithEntities (a real ship from LoadoutGenerator against a catalog fixture, committed into a SavedZone and reopened, pinning the defect at the layer Soul's probe found it). MaterializedLotsSurviveSaveAndReload and CommitKeepsOnlyReachableLots go green; 36 of 36 tests pass. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
parent
562cdcf2b8
commit
e4df73e4fe
3 changed files with 140 additions and 12 deletions
|
|
@ -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)),
|
||||
|
|
|
|||
|
|
@ -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<IPersistentBehavior>()})
|
||||
.ToDictionary(x=> x.equippable.Position, x=>x.behaviors.Select(b => b.Store()).ToArray());
|
||||
.Cast<IPersistentBehavior>()
|
||||
.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<IPersistentBehavior>()
|
||||
.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<int2, PersistentBehaviorData[]> PersistedBehaviors;
|
||||
[Key(5)] public (int2 position, PersistentBehaviorData[] data)[] PersistedBehaviors;
|
||||
[Key(6)] public float[,] Temperature;
|
||||
[Key(7)] public float[,] Armor;
|
||||
[Key(8)] public bool2[,] Conductivity;
|
||||
|
|
|
|||
|
|
@ -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<InvalidOperationException>(() => ledger.Reachable(new[] { 9 }));
|
||||
}
|
||||
|
||||
// F1: PersistedBehaviors is now a pairs array (Dictionary<int2,...> 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<int>(), Factions = Array.Empty<int>(), Owner = -1,
|
||||
Contents = new ZonePack { Entities = new List<EntityPack> { pack } }
|
||||
}
|
||||
};
|
||||
RunSave.Commit(cache, Game(cache), zones, new ProvenanceLedger());
|
||||
}
|
||||
|
||||
using (var cache = Open())
|
||||
{
|
||||
var run = cache.GetGlobal<SavedGame>();
|
||||
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<MarkerPersistentBehaviorData>(reopened[0].data.Single()).Value);
|
||||
Assert.Equal(posB, reopened[1].position);
|
||||
Assert.Equal(22, Assert.IsType<MarkerPersistentBehaviorData>(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<CraftedItemData>(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<CultRecordRef<Faction>>(),
|
||||
Relationships = Array.Empty<FactionRelationship>(),
|
||||
HomeZones = new Dictionary<int, int>(),
|
||||
BossZones = new Dictionary<int, int>(),
|
||||
DiscoveredZones = new[] { 0 },
|
||||
ActionBarBindings = new SavedActionBarBinding[0],
|
||||
Exit = -1
|
||||
};
|
||||
var zones = new[]
|
||||
{
|
||||
new SavedZone
|
||||
{
|
||||
Name = "Zone 0", AdjacentZones = Array.Empty<int>(), Factions = Array.Empty<int>(), Owner = -1,
|
||||
Contents = new ZonePack { Entities = new List<EntityPack> { pack } }
|
||||
}
|
||||
};
|
||||
RunSave.Commit(cache, game, zones, lots);
|
||||
}
|
||||
|
||||
using (var cache = Open())
|
||||
{
|
||||
var storedLots = RunSave.Lots(cache);
|
||||
var run = cache.GetGlobal<SavedGame>();
|
||||
var zone = cache.Get(run.Zones[0]);
|
||||
Assert.NotNull(zone.Contents);
|
||||
var crafted = EntitySerializer.Items(zone.Contents.Entities[0]).OfType<CraftedItemInstance>().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<EntityPack>(),
|
||||
PersistedBehaviors = new Dictionary<int2, PersistentBehaviorData[]>(),
|
||||
PersistedBehaviors = persistedBehaviors ?? Array.Empty<(int2, PersistentBehaviorData[])>(),
|
||||
Temperature = new float[0, 0],
|
||||
Armor = new float[0, 0],
|
||||
Conductivity = new bool2[0, 0],
|
||||
|
|
|
|||
Loading…
Reference in a new issue