diff --git a/Assets/Scripts/ServerShared/Behaviors/Behaviors.cs b/Assets/Scripts/ServerShared/Behaviors/Behaviors.cs index 6b899ee35..300229d3f 100644 --- a/Assets/Scripts/ServerShared/Behaviors/Behaviors.cs +++ b/Assets/Scripts/ServerShared/Behaviors/Behaviors.cs @@ -6,6 +6,7 @@ using System; using System.Collections; using System.Collections.Generic; using System.Linq; +using GameCult.Caching; using MessagePack; using Newtonsoft.Json; using CultMath; @@ -212,4 +213,7 @@ public abstract class BehaviorData { return base.ToString().FormatTypeName(); } + + // Element identity: minted by the cache on write; "" means unset. + [Key(37)] [CultElementId] public string Id = ""; } \ No newline at end of file diff --git a/Assets/Scripts/ServerShared/Corporations.cs b/Assets/Scripts/ServerShared/Corporations.cs index e527606d9..080f42aac 100644 --- a/Assets/Scripts/ServerShared/Corporations.cs +++ b/Assets/Scripts/ServerShared/Corporations.cs @@ -84,5 +84,8 @@ public class FactionPlace // treats an invalid entry as absent; AetherDb place-names refuses a table that holds one. public static bool IsValidName(string name) => !string.IsNullOrEmpty(name) && name == name.Trim() && !name.StartsWith("EAC-", StringComparison.OrdinalIgnoreCase); + + // Element identity: minted by the cache on write; "" means unset. + [Key(2)] [CultElementId] public string Id = ""; } diff --git a/Assets/Scripts/ServerShared/EntitySerializer.cs b/Assets/Scripts/ServerShared/EntitySerializer.cs index 7f0f04ebd..9aefe5a19 100644 --- a/Assets/Scripts/ServerShared/EntitySerializer.cs +++ b/Assets/Scripts/ServerShared/EntitySerializer.cs @@ -294,4 +294,7 @@ public abstract class EntityPack return _price; } + + // Element identity: minted by the cache on write; "" means unset. + [Key(23)] [CultElementId] public string Id = ""; } diff --git a/Assets/Scripts/ServerShared/FactionProduct.cs b/Assets/Scripts/ServerShared/FactionProduct.cs index 14f7af57d..87ac72f7d 100644 --- a/Assets/Scripts/ServerShared/FactionProduct.cs +++ b/Assets/Scripts/ServerShared/FactionProduct.cs @@ -61,6 +61,9 @@ public class ProductRole [Inspectable, JsonProperty("standardDeviation"), Key(2)] public float StandardDeviation = .15f; + + // Element identity: minted by the cache on write; "" means unset. + [Key(3)] [CultElementId] public string Id = ""; } // A bin of one of the maker's lines (ruling maker-design-tiers-allowed): the line it bins (the same design and maker, itself diff --git a/Assets/Scripts/ServerShared/InputLayout.cs b/Assets/Scripts/ServerShared/InputLayout.cs index a688598b2..7e942c519 100644 --- a/Assets/Scripts/ServerShared/InputLayout.cs +++ b/Assets/Scripts/ServerShared/InputLayout.cs @@ -29,7 +29,10 @@ public class InputLayout Union(0, typeof(InputLayoutRowSpacer)), Union(1, typeof(InputLayoutKeyRow)) ] -public abstract class InputLayoutRow { } +public abstract class InputLayoutRow { + // Element identity: minted by the cache on write; "" means unset. + [Key(1)] [CultElementId] public string Id = ""; +} [MessagePackObject, JsonObject(MemberSerialization.OptIn)] public class InputLayoutRowSpacer : InputLayoutRow @@ -52,6 +55,9 @@ public class InputLayoutKeyRow : InputLayoutRow public abstract class InputLayoutColumn { [Key(0), JsonProperty("width")] public float Width; + + // Element identity: minted by the cache on write; "" means unset. + [Key(5)] [CultElementId] public string Id = ""; } [MessagePackObject, JsonObject(MemberSerialization.OptIn)] diff --git a/Assets/Scripts/ServerShared/ItemData.cs b/Assets/Scripts/ServerShared/ItemData.cs index 72c07fc9a..ab9c9dcfc 100644 --- a/Assets/Scripts/ServerShared/ItemData.cs +++ b/Assets/Scripts/ServerShared/ItemData.cs @@ -348,6 +348,9 @@ public class ItemRole [Inspectable, JsonProperty("count"), Key(1)] public int Count = 1; + + // Element identity: minted by the cache on write; "" means unset. + [Key(2)] [CultElementId] public string Id = ""; } [CultDocument("aetheria.compoundcommoditydata", "1"), Inspectable, MessagePackObject] @@ -635,6 +638,9 @@ public class HardpointData } private static Dictionary _tintColors; + + // Element identity: minted by the cache on write; "" means unset. + [Key(8)] [CultElementId] public string Id = ""; } // What a PerformanceStat reads to resolve one number: the lot it prices quality against, and the per-context @@ -680,6 +686,9 @@ public class StatTerm // The design role whose part quality this term reads; unset reads the item's effective quality (Lot.EffectiveQuality). // Njordr states the same rule as a derivation over dimensions; this is that rule with one dimension. [Inspectable, JsonProperty("role"), Key(2)] public string Role; + + // Element identity: minted by the cache on write; "" means unset. + [Key(3)] [CultElementId] public string Id = ""; } [MessagePackObject, JsonObject(MemberSerialization.OptIn)] diff --git a/Assets/Scripts/ServerShared/Loadout.cs b/Assets/Scripts/ServerShared/Loadout.cs index 4866aa406..b423eb1a0 100644 --- a/Assets/Scripts/ServerShared/Loadout.cs +++ b/Assets/Scripts/ServerShared/Loadout.cs @@ -28,6 +28,9 @@ public class LoadoutSlot [Key(1)] public ItemRotation Rotation; [Key(2)] public CultRecordRef Design; [Key(3)] public CultRecordRef Product; // unset: design-only, chosen at materialization + + // Element identity: minted by the cache on write; "" means unset. + [Key(4)] [CultElementId] public string Id = ""; } // Why a faction has no boss Loadout to build; see Loadouts.BossOf. diff --git a/Assets/Scripts/ServerShared/Provenance.cs b/Assets/Scripts/ServerShared/Provenance.cs index 4121332d6..25a4a6175 100644 --- a/Assets/Scripts/ServerShared/Provenance.cs +++ b/Assets/Scripts/ServerShared/Provenance.cs @@ -92,6 +92,9 @@ public class Lot foreach (var fill in Roles) sum += Math.Min(fill.Quality, Quality); return sum / Roles.Count; } + + // Element identity: minted by the cache on write; "" means unset. + [Key(5)] [CultElementId] public string Id = ""; } // One filled slot: the design's role name and how good the part that went into it turned out to be. Stats read it through @@ -101,6 +104,9 @@ public class RoleFill { [Key(0)] public string Role; [Key(1)] public float Quality; + + // Element identity: minted by the cache on write; "" means unset. + [Key(2)] [CultElementId] public string Id = ""; } // Who built a lot, where, and from what. Terminus mints Attributed placeholders; Produced and Extracted are the diff --git a/Assets/Scripts/ServerShared/Recipes.cs b/Assets/Scripts/ServerShared/Recipes.cs index e434a84a5..f9aa3ee4a 100644 --- a/Assets/Scripts/ServerShared/Recipes.cs +++ b/Assets/Scripts/ServerShared/Recipes.cs @@ -63,6 +63,9 @@ public class CommodityCount [JsonProperty("count"), Key(1)] public int Count = 1; + + // Element identity: minted by the cache on write; "" means unset. + [Key(2)] [CultElementId] public string Id = ""; } // A region gate's trade with the world beyond it: what the outside buys and sells, at what price and at most how fast. @@ -96,6 +99,9 @@ public class OutsideMarketRow [JsonProperty("ratePerHour"), Key(4)] public float RatePerHour; + + // Element identity: minted by the cache on write; "" means unset. + [Key(5)] [CultElementId] public string Id = ""; } // The one reader of recipes: which recipe makes a good, a recipe's role slots (RoleInputsOf), which commodities fill a role, and diff --git a/Assets/Scripts/ServerShared/SavedGame.cs b/Assets/Scripts/ServerShared/SavedGame.cs index 88ff8c9bf..5f42de446 100644 --- a/Assets/Scripts/ServerShared/SavedGame.cs +++ b/Assets/Scripts/ServerShared/SavedGame.cs @@ -78,6 +78,9 @@ public class SavedStanding [Key(1)] public FactionRelationship Relationship; + + // Element identity: minted by the cache on write; "" means unset. + [Key(2)] [CultElementId] public string Id = ""; } [MessagePackObject] @@ -88,6 +91,9 @@ public class SavedProvince [Key(1)] public CultRecordRef Type; + + // Element identity: minted by the cache on write; "" means unset. + [Key(2)] [CultElementId] public string Id = ""; } // The run's lifecycle over the run store. Only Commit creates SavedGame, SavedZone and ProvenanceLedger records; @@ -301,6 +307,9 @@ public class SavedZone ] public abstract class SavedActionBarBinding { + + // Element identity: minted by the cache on write; "" means unset. + [Key(2)] [CultElementId] public string Id = ""; } [MessagePackObject] diff --git a/Assets/Scripts/ServerShared/ShipAuthoring.cs b/Assets/Scripts/ServerShared/ShipAuthoring.cs index 3962fa468..8441d0332 100644 --- a/Assets/Scripts/ServerShared/ShipAuthoring.cs +++ b/Assets/Scripts/ServerShared/ShipAuthoring.cs @@ -33,6 +33,9 @@ public sealed class ShipJoint [Key(2)] public float[] Axes; [Key(3)] public float[] Min; [Key(4)] public float[] Max; + + // Element identity, derived from the bone id so it never changes across rewrites. + [Key(5)] [CultElementId(nameof(Id))] public string ElementId = ""; } [MessagePackObject] @@ -50,6 +53,9 @@ public sealed class ShipAnchor [Key(4)] public int Order; // On a weapon-mount anchor: the id of the nearest joint above its node in the model; null or empty is a fixed mount. [Key(5)] public string Joint; + + // Element identity, derived from the anchor id so it never changes across rewrites. + [Key(6)] [CultElementId(nameof(Id))] public string ElementId = ""; } // Evaluated or baked Grease Pencil stroke coordinates in Blender's right-handed Z-up @@ -64,6 +70,9 @@ public sealed class ShipPolyline [Key(4)] public float[] Opacities; [Key(5)] public bool Cyclic; [Key(6)] public float[] Color; + + // Element identity: minted by the cache on write; "" means unset. + [Key(7)] [CultElementId] public string Id = ""; } public static class ShipAuthoringStore diff --git a/Assets/Scripts/ServerShared/StationKinds.cs b/Assets/Scripts/ServerShared/StationKinds.cs index dad1ce2aa..eb2662eee 100644 --- a/Assets/Scripts/ServerShared/StationKinds.cs +++ b/Assets/Scripts/ServerShared/StationKinds.cs @@ -35,6 +35,9 @@ public class StationKindStandIn // The kind whose model this faction builds in place of the owning kind's. [JsonProperty("kind"), Key(1)] public CultRecordRef Kind; + + // Element identity: minted by the cache on write; "" means unset. + [Key(2)] [CultElementId] public string Id = ""; } // The one owner of which kinds a faction builds, which kind a station gets, and which hull kind stands in. diff --git a/Assets/Scripts/ServerShared/VerseGrammar.cs b/Assets/Scripts/ServerShared/VerseGrammar.cs index 00e24833d..1219e70b6 100644 --- a/Assets/Scripts/ServerShared/VerseGrammar.cs +++ b/Assets/Scripts/ServerShared/VerseGrammar.cs @@ -40,6 +40,9 @@ public class VerseRole { [Key(1)] public string Name; [Key(2)] public VerseReferentKind Binds; + + // Element identity: minted by the cache on write; "" means unset. + [Key(3)] [CultElementId] public string Id = ""; } // The actor is the invoking subject and is never a role. diff --git a/Assets/Scripts/ServerShared/ZoneData.cs b/Assets/Scripts/ServerShared/ZoneData.cs index a08895aaa..e76963a43 100644 --- a/Assets/Scripts/ServerShared/ZoneData.cs +++ b/Assets/Scripts/ServerShared/ZoneData.cs @@ -67,6 +67,9 @@ public class ChunkWearPack [JsonProperty("brokenUntil"), Key(3)] public double? BrokenUntil; + + // Element identity: minted by the cache on write; "" means unset. + [Key(4)] [CultElementId] public string Id = ""; } [JsonObject(MemberSerialization.OptIn)] @@ -149,6 +152,9 @@ public class FieldYield [JsonProperty("regional"), Key(2)] public bool Regional; + + // Element identity: minted by the cache on write; "" means unset. + [Key(3)] [CultElementId] public string Id = ""; } // Ore geography (rulings provinces-within-regions, ores-are-regional): a kind of ground, authored. A region deals its @@ -182,6 +188,9 @@ public class ProvinceKindWeight [JsonProperty("weight"), Key(1)] public float Weight = 1; + + // Element identity: minted by the cache on write; "" means unset. + [Key(2)] [CultElementId] public string Id = ""; } [MessagePackObject, JsonObject(MemberSerialization.OptIn)] @@ -192,6 +201,9 @@ public class ProvinceOre [JsonProperty("weight"), Key(1)] public float Weight = 1; + + // Element identity: minted by the cache on write; "" means unset. + [Key(2)] [CultElementId] public string Id = ""; } // Mining Cut 3 (Q16 B): the one owner of which kind a belt is. Ore geography: and of what it yields. @@ -485,6 +497,9 @@ public class Asteroid [JsonProperty("rotationSpeed"), Key(3)] public float RotationSpeed; + + // Element identity: minted by the cache on write; "" means unset. + [Key(4)] [CultElementId] public string Id = ""; } [CultDocument("aetheria.orbitdata", "1"), MessagePackObject, JsonObject(MemberSerialization.OptIn)] diff --git a/Directory.Build.props b/Directory.Build.props index 05d5e4d22..66ff67e19 100644 --- a/Directory.Build.props +++ b/Directory.Build.props @@ -2,8 +2,8 @@ $(CULTLIB_ROOT) $(MSBuildThisFileDirectory)..\CultLib - - b8fc5eb07720a187a0421424ac2cb3aa2966ca1d + + abd1a92eb24e1af3fc7cdd1bf98a88dbdebc2938 $(CULTMATH_ROOT) $(CultLibRoot) diff --git a/docs/element-ids.md b/docs/element-ids.md new file mode 100644 index 000000000..31cc92e17 --- /dev/null +++ b/docs/element-ids.md @@ -0,0 +1,29 @@ +# Element ids and the CultLib pin (abd1a92e) + +CultLib 3.0 (unreleased; headless pin `abd1a92e`, `Directory.Build.props`) gives every element of an object list an identity. Every +element type reachable through a list of a `[CultDocument]` type carries one string member with `[Key(n)]` and `[CultElementId]`; +the registry refuses to open otherwise, naming the type and list. + +- **Where the slot goes.** A union base takes the member above its subclasses' highest key. `BehaviorData.Id` is key 37, so an + array-format behavior is 38 slots on the wire (`PaintModeTests`, `ShipRigTests` pin it). A new `BehaviorData` subclass must keep + its keys below 37 or move the slot, which is a wire change. +- **Derived ids.** `ShipJoint` and `ShipAnchor` already had a readable `Id`; CultLib refuses a readable id inside a marked member, + so each gained a trailing `ElementId` marked `[CultElementId(nameof(Id))]`. +- **Minting.** A writer mints random ids for elements that have none. A store written before ids (every file under `GameData/`) + loads with ids minted in memory and persists them on its next write; `CultCache.MintElementIds()` does that in one pass. + `ElementIdUpgradeTests` opens a copy of the shipped catalog, mints, flushes and reopens it. +- **Comparing content.** Random ids differ between two writes of the same content. Tests that compare bytes use + `ElementIdBlind`. +- **One list element is one object.** The same instance twice in a list is refused (`CultElementIdException`). +- **Retyping.** Writing a different type onto a held key is refused unless the record is removed first + (`EconomyAuthor.Write` does). + +## The bump is one-way + +A store holding element ids is format v3 (single file) or v5 (directory). A pin older than `abd1a92e` can open a single-file v3 +store without error and drops the ids on its next write (CultLib #98). So: upgrade readers before writers, keep a pre-upgrade +copy of any real store (`GameData/Aetheria.cc` and the mod packages are tracked in git; player saves and run stores are not), +and do not roll the pin back after a store has been written under the new one. + +The Unity manifest (`Packages/manifest.json`) still pins `caching-unity-v2.0.0` / `cultlib-unity-v2.0.1`; those move when CultLib +tags a Unity release of 3.0. Until then the editor and the headless build consume different CultLib revisions. diff --git a/tests/Aetheria.Shared.Tests/ElementIdBlind.cs b/tests/Aetheria.Shared.Tests/ElementIdBlind.cs new file mode 100644 index 000000000..6c340ea8f --- /dev/null +++ b/tests/Aetheria.Shared.Tests/ElementIdBlind.cs @@ -0,0 +1,57 @@ +using System; +using System.Collections; +using System.Linq; +using System.Reflection; +using GameCult.Caching; +using GameCult.Caching.MessagePack; +using MessagePack; + +// Element ids are minted at random when a record is written, so two writes of the same content differ in exactly those slots. +// A test that asks "is this the same content" compares the bytes with every element id blank. +public static class ElementIdBlind +{ + private static readonly MessagePackSerializerOptions Options = CultDocumentMessagePackSerialization.OptionsFor(typeof(ItemData).Assembly); + + // The document's bytes with every [CultElementId] member blank, on a copy: the document is untouched. + public static byte[] Pack(object document, Type type = null) + { + type ??= document.GetType(); + var copy = MessagePackSerializer.Deserialize(type, MessagePackSerializer.Serialize(type, document, Options), Options); + Blank(copy); + return MessagePackSerializer.Serialize(type, copy, Options); + } + + // A stored payload of the schema, re-encoded with every element id blank. + public static byte[] Payload(CultDocumentRegistry registry, string schemaId, byte[] payload) + { + var type = registry.GetRequiredBySchemaId(schemaId).DocumentType; + var value = MessagePackSerializer.Deserialize(type, payload, Options); + Blank(value); + return MessagePackSerializer.Serialize(type, value, Options); + } + + private static void Blank(object value) + { + if (value == null || value is string || value.GetType().IsPrimitive || value.GetType().IsEnum) return; + if (value is IEnumerable items && !(value is string)) + { + foreach (var item in items) Blank(item); + return; + } + for (var type = value.GetType(); type != null && type != typeof(object); type = type.BaseType) + foreach (var member in type.GetMembers(BindingFlags.Public | BindingFlags.NonPublic | BindingFlags.Instance | BindingFlags.DeclaredOnly)) + { + if (member is FieldInfo field) + { + if (field.GetCustomAttribute() != null) field.SetValue(value, ""); + else if (!field.FieldType.IsPrimitive && field.GetCustomAttribute() == null && field.GetCustomAttribute() != null) + Blank(field.GetValue(value)); + } + else if (member is PropertyInfo property && property.GetIndexParameters().Length == 0 && property.GetCustomAttribute() != null) + { + if (property.GetCustomAttribute() != null) property.SetValue(value, ""); + else Blank(property.GetValue(value)); + } + } + } +} diff --git a/tests/Aetheria.Shared.Tests/ElementIdUpgradeTests.cs b/tests/Aetheria.Shared.Tests/ElementIdUpgradeTests.cs new file mode 100644 index 000000000..7a38e4622 --- /dev/null +++ b/tests/Aetheria.Shared.Tests/ElementIdUpgradeTests.cs @@ -0,0 +1,56 @@ +using System.IO; +using System.Linq; +using System.Threading.Tasks; +using GameCult.Caching; +using Xunit; + +// The CultLib pin that requires element ids (abd1a92e) reads the stores written before it (b8fc5eb0 and older), which carry none. +// The shipped GameData stores are those stores, byte for byte: they have never been rewritten under the new pin. +// Upgrading is one-way: once a store is written under the new pin it holds element ids, and an older pin can read it only by +// dropping them (CultLib #98). Readers upgrade before writers. +public class ElementIdUpgradeTests +{ + private static string Shipped => Path.Combine(RunStartTests.FindRepoRoot(), "GameData", "Aetheria.cc"); + + [Fact] + public async Task AStoreWrittenBeforeElementIdsOpensMintsThemOnceAndKeepsEveryRecord() + { + using var directory = new TempDirectory(); + var copy = Path.Combine(directory.Path, "Aetheria.cc"); + File.Copy(Shipped, copy); + + int records, minted; + using (var cache = AetheriaStores.Open(copy, catalogWritable: true, registry: TestCatalog.Registry())) + { + records = cache.AllStoredDocuments.Count(); + Assert.True(records > 0); + minted = cache.MintElementIds(); + Assert.True(minted > 0, "the shipped catalog predates element ids; opening it must have minted some"); + Assert.Equal(0, cache.MintElementIds()); + await cache.FlushAsync(); + } + + using var reopened = AetheriaStores.Open(copy, registry: TestCatalog.Registry()); + Assert.Equal(records, reopened.AllStoredDocuments.Count()); + Assert.Equal(0, reopened.MintElementIds()); + foreach (var hull in reopened.GetAll()) + { + var ids = hull.Hardpoints.Select(hardpoint => hardpoint.Id).ToArray(); + Assert.All(ids, id => Assert.False(string.IsNullOrEmpty(id))); + Assert.Equal(ids.Length, ids.Distinct().Count()); + } + } + + [Fact] + public void EveryShippedModPackageStillLoads() + { + var packages = Directory.GetDirectories(Path.Combine(RunStartTests.FindRepoRoot(), "GameData", "Mods")); + Assert.NotEmpty(packages); + foreach (var package in packages) + { + var (hull, visual, _) = ShipAuthoringStore.Load(Path.Combine(package, "ship.cc")); + Assert.NotNull(hull); + Assert.NotNull(visual); + } + } +} diff --git a/tests/Aetheria.Shared.Tests/EmissionTests.cs b/tests/Aetheria.Shared.Tests/EmissionTests.cs index 831f6f520..da8d99e6d 100644 --- a/tests/Aetheria.Shared.Tests/EmissionTests.cs +++ b/tests/Aetheria.Shared.Tests/EmissionTests.cs @@ -281,7 +281,7 @@ public sealed partial class ContactRevealTests var reader = new MessagePackReader(sensor); Assert.Equal(2, reader.ReadArrayHeader()); Assert.Equal(5, reader.ReadInt32()); - Assert.Equal(14, reader.ReadArrayHeader()); + Assert.Equal(38, reader.ReadArrayHeader()); for (var slot = 0; slot < 13; slot++) reader.Skip(); Assert.Equal(2f, MessagePackSerializer.Deserialize(ref reader, EmissionWire).Min); Assert.Equal(2f, JObject.Parse(JsonConvert.SerializeObject(new SensorData { InterceptFactor = Const(2) }))["interceptFactor"].ToObject().Min); diff --git a/tests/Aetheria.Shared.Tests/InspectionScannerCommandTests.cs b/tests/Aetheria.Shared.Tests/InspectionScannerCommandTests.cs index e2d453f51..8dbc47873 100644 --- a/tests/Aetheria.Shared.Tests/InspectionScannerCommandTests.cs +++ b/tests/Aetheria.Shared.Tests/InspectionScannerCommandTests.cs @@ -22,7 +22,7 @@ public sealed class InspectionScannerCommandTests : IDisposable private readonly string[] _shippedProducts; private static byte[] Pack(GearData design) => - MessagePackSerializer.Serialize(design, CultDocumentMessagePackSerialization.OptionsFor(typeof(GearData).Assembly)); + ElementIdBlind.Pack(design, typeof(GearData)); private static string Describe(CultCache cache, FactionProductData product) => $"{cache.Get(product.Design).Name} | {product.Name} | {cache.Get(product.Manufacturer).ShortName} | {product.Description} | " + diff --git a/tests/Aetheria.Shared.Tests/LaunchSuppressorCommandTests.cs b/tests/Aetheria.Shared.Tests/LaunchSuppressorCommandTests.cs index 90c1d3873..47cc9a29e 100644 --- a/tests/Aetheria.Shared.Tests/LaunchSuppressorCommandTests.cs +++ b/tests/Aetheria.Shared.Tests/LaunchSuppressorCommandTests.cs @@ -22,7 +22,7 @@ public sealed class LaunchSuppressorCommandTests : IDisposable private readonly string[] _shippedProducts; private static byte[] Pack(GearData design) => - MessagePackSerializer.Serialize(design, CultDocumentMessagePackSerialization.OptionsFor(typeof(GearData).Assembly)); + ElementIdBlind.Pack(design, typeof(GearData)); private static string Describe(CultCache cache, FactionProductData product) => $"{cache.Get(product.Design).Name} | {product.Name} | {cache.Get(product.Manufacturer).ShortName} | {product.Description} | " + diff --git a/tests/Aetheria.Shared.Tests/LaunchSuppressorTests.cs b/tests/Aetheria.Shared.Tests/LaunchSuppressorTests.cs index eb2ed3ab8..d3dd3ae03 100644 --- a/tests/Aetheria.Shared.Tests/LaunchSuppressorTests.cs +++ b/tests/Aetheria.Shared.Tests/LaunchSuppressorTests.cs @@ -299,7 +299,7 @@ public sealed partial class ContactRevealTests var reader = new MessagePackReader(bytes); Assert.Equal(2, reader.ReadArrayHeader()); Assert.Equal(49, reader.ReadInt32()); // loot-2 holds 48; a key reused across branches would collide on the wire - Assert.Equal(4, reader.ReadArrayHeader()); // Group is key 0, then the three stats + Assert.Equal(38, reader.ReadArrayHeader()); // array-format union: the base's element id sits at key 37, so every behavior row is 38 long; Group is key 0, then the three stats var back = Assert.IsType(MessagePackSerializer.Deserialize(bytes, options)); Assert.Equal(.25f, back.Factor.Min); diff --git a/tests/Aetheria.Shared.Tests/ListenerTests.cs b/tests/Aetheria.Shared.Tests/ListenerTests.cs index 9fe4abf81..1bf1059ae 100644 --- a/tests/Aetheria.Shared.Tests/ListenerTests.cs +++ b/tests/Aetheria.Shared.Tests/ListenerTests.cs @@ -203,7 +203,7 @@ public sealed partial class ContactRevealTests var reader = new MessagePackReader(bytes); Assert.Equal(2, reader.ReadArrayHeader()); Assert.Equal(50, reader.ReadInt32()); - Assert.Equal(2, reader.ReadArrayHeader()); // Group is key 0, then Multiple + Assert.Equal(38, reader.ReadArrayHeader()); // array-format union: the base's element id sits at key 37, so every behavior row is 38 long; Group is key 0, then Multiple var back = Assert.IsType(MessagePackSerializer.Deserialize(bytes, options)); Assert.Equal(2.5f, back.Multiple.Min); diff --git a/tests/Aetheria.Shared.Tests/PaintModeTests.cs b/tests/Aetheria.Shared.Tests/PaintModeTests.cs index 89b6fb39f..ab195813f 100644 --- a/tests/Aetheria.Shared.Tests/PaintModeTests.cs +++ b/tests/Aetheria.Shared.Tests/PaintModeTests.cs @@ -724,7 +724,7 @@ public sealed class PaintModeTests : IDisposable var reader = new MessagePackReader(bytes); Assert.Equal(2, reader.ReadArrayHeader()); Assert.Equal(5, reader.ReadInt32()); - Assert.Equal(14, reader.ReadArrayHeader()); + Assert.Equal(38, reader.ReadArrayHeader()); for (var slot = 0; slot < 12; slot++) reader.Skip(); Assert.Equal(90f, MessagePackSerializer.Deserialize(ref reader, WireOptions).Min); diff --git a/tests/Aetheria.Shared.Tests/ScannerTests.cs b/tests/Aetheria.Shared.Tests/ScannerTests.cs index b470d2ae0..7184659b2 100644 --- a/tests/Aetheria.Shared.Tests/ScannerTests.cs +++ b/tests/Aetheria.Shared.Tests/ScannerTests.cs @@ -484,7 +484,7 @@ public sealed partial class ContactRevealTests var reader = new MessagePackReader(bytes); Assert.Equal(2, reader.ReadArrayHeader()); Assert.Equal(51, reader.ReadInt32()); // sensor-catalog holds 50; a key reused across branches would collide on the wire - Assert.Equal(4, reader.ReadArrayHeader()); // Group is key 0, then the three stats + Assert.Equal(38, reader.ReadArrayHeader()); // array-format union: the base's element id sits at key 37, so every behavior row is 38 long; Group is key 0, then the three stats reader.Skip(); Assert.Equal(200f, MessagePackSerializer.Deserialize(ref reader, options).Min); // key 1 Range Assert.Equal(2f, MessagePackSerializer.Deserialize(ref reader, options).Min); // key 2 ScanTime diff --git a/tests/Aetheria.Shared.Tests/ShipAuthoringTests.cs b/tests/Aetheria.Shared.Tests/ShipAuthoringTests.cs index 850e83dfd..7ed2b84be 100644 --- a/tests/Aetheria.Shared.Tests/ShipAuthoringTests.cs +++ b/tests/Aetheria.Shared.Tests/ShipAuthoringTests.cs @@ -180,24 +180,20 @@ public sealed class ShipAuthoringTests Assert.IsAssignableFrom(pull.InnerException); } - // A store header with a nil format stamp is refused by the runtime with NotSupportedException; Load names the file. + // A store header with a nil format stamp is refused by the runtime with CultStoreUnreadableException; Load names the file. [Fact] public void LoadNamesTheFileWhoseFormatStampIsNil() { using var directory = new TempDirectory(); var path = Path.Combine(directory.Path, "nilstamp.cc"); - File.WriteAllBytes(path, CultDocumentMessagePackSerialization.SerializeSnapshot(new CultPersistedStoreSnapshot - { - FormatVersion = null, - SchemaCatalog = Array.Empty(), - Records = Array.Empty(), - })); + // The runtime no longer writes a store without a format stamp, so the header is spelled by hand: [nil, [], []]. + File.WriteAllBytes(path, new byte[] { 0x93, 0xC0, 0x90, 0x90 }); var error = Assert.Throws(() => ShipAuthoringStore.Load(path)); Assert.Contains(path, error.Message); - Assert.IsType(error.InnerException); + Assert.IsType(error.InnerException); } - // A store format this runtime does not know is refused on read with NotSupportedException from the attach; Load names the file. + // A store format this runtime does not know is refused on read with CultStoreUnreadableException from the attach; Load names the file. [Fact] public async Task LoadNamesTheFileWhoseStoreFormatIsNewer() { @@ -213,7 +209,7 @@ public sealed class ShipAuthoringTests File.WriteAllBytes(path, CultDocumentMessagePackSerialization.SerializeSnapshot(snapshot)); var error = Assert.Throws(() => ShipAuthoringStore.Load(path)); Assert.Contains(path, error.Message); - Assert.IsType(error.InnerException); + Assert.IsType(error.InnerException); } // Decodes one record's payload of the named schema to its slot array, lets the edit change it, and stores it again. diff --git a/tests/Aetheria.Shared.Tests/ShipModCatalogTests.cs b/tests/Aetheria.Shared.Tests/ShipModCatalogTests.cs index cbda4266b..f2b51496f 100644 --- a/tests/Aetheria.Shared.Tests/ShipModCatalogTests.cs +++ b/tests/Aetheria.Shared.Tests/ShipModCatalogTests.cs @@ -796,7 +796,7 @@ public sealed class ShipModCatalogTests : IDisposable // Every stored record's key, schema and payload bytes. StoredAt is a write timestamp, not record content. private static Dictionary Records(string path) => GameCult.Caching.MessagePack.CultDocumentMessagePackSerialization.DeserializeSnapshot(File.ReadAllBytes(path)).Records - .ToDictionary(record => record.Key, record => record.SchemaId + ":" + Convert.ToHexString(record.Payload)); + .ToDictionary(record => record.Key, record => record.SchemaId + ":" + Convert.ToHexString(ElementIdBlind.Payload(TestCatalog.Registry(), record.SchemaId, record.Payload))); private void AssertOnlyTheDerivedFile() { diff --git a/tests/Aetheria.Shared.Tests/ShipProductTests.cs b/tests/Aetheria.Shared.Tests/ShipProductTests.cs index e14950b0d..82f094e88 100644 --- a/tests/Aetheria.Shared.Tests/ShipProductTests.cs +++ b/tests/Aetheria.Shared.Tests/ShipProductTests.cs @@ -331,7 +331,7 @@ public sealed class ShipProductTests : IDisposable var path = Package("mod.skiff", "Skiffhull", 0); var bare = Path.Combine(ShipFixture.WritePackage(Mods, "mod.bare", "Barehull", tweak: ship => ship.Hull.Behaviors.Clear()), "ship.cc"); var twin = Path.Combine(ShipFixture.WritePackage(Mods, "mod.twin", "Twinhull", - tweak: ship => ship.Hull.Behaviors.Add(ship.Hull.Behaviors.OfType().First())), "ship.cc"); + tweak: ship => ship.Hull.Behaviors.Add(new VelocityLimitData())), "ship.cc"); void RefusedAndUntouched(string file, params string[] flags) { var before = File.ReadAllBytes(file); diff --git a/tests/Aetheria.Shared.Tests/ShipRigTests.cs b/tests/Aetheria.Shared.Tests/ShipRigTests.cs index 4bed1989b..34581f34a 100644 --- a/tests/Aetheria.Shared.Tests/ShipRigTests.cs +++ b/tests/Aetheria.Shared.Tests/ShipRigTests.cs @@ -833,7 +833,7 @@ public sealed class ShipRigTests : IDisposable var options = CultDocumentMessagePackSerialization.OptionsFor(typeof(HardpointData).Assembly); var hardpoint = new HardpointData { Type = HardpointType.Thruster, Shape = new Shape(), Transform = "t", FiringArc = 40f, Row = "left" }; var fields = MessagePackSerializer.Deserialize(MessagePackSerializer.Serialize(hardpoint, options), options); - Assert.Equal(8, fields.Length); + Assert.Equal(9, fields.Length); Assert.Equal(40f, Convert.ToSingle(fields[6])); Assert.Equal("left", fields[7]); Assert.Null(MessagePackSerializer.Deserialize(MessagePackSerializer.Serialize(new HardpointData { Shape = new Shape() }, options), options)[7]); @@ -842,7 +842,7 @@ public sealed class ShipRigTests : IDisposable thruster.Thrust.Max = 5f; thruster.Slew.Max = 90f; var slew = MessagePackSerializer.Deserialize(MessagePackSerializer.Serialize(thruster, options), options); - Assert.Equal(7, slew.Length); + Assert.Equal(38, slew.Length); // the behavior base's element id is key 37 Assert.Equal(MessagePackSerializer.ConvertToJson(MessagePackSerializer.Serialize(thruster.Slew, options), options), MessagePackSerializer.ConvertToJson(MessagePackSerializer.Serialize(slew[6], options), options)); Assert.NotEqual(MessagePackSerializer.ConvertToJson(MessagePackSerializer.Serialize(thruster.Thrust, options), options), diff --git a/tests/Aetheria.Shared.Tests/SignatureTests.cs b/tests/Aetheria.Shared.Tests/SignatureTests.cs index 50d0bc824..21bdd7644 100644 --- a/tests/Aetheria.Shared.Tests/SignatureTests.cs +++ b/tests/Aetheria.Shared.Tests/SignatureTests.cs @@ -501,7 +501,7 @@ public sealed class SignatureTests : IDisposable var reader = new MessagePackReader(bytes); Assert.Equal(2, reader.ReadArrayHeader()); Assert.Equal(22, reader.ReadInt32()); - Assert.Equal(4, reader.ReadArrayHeader()); + Assert.Equal(38, reader.ReadArrayHeader()); reader.Skip(); // Group Assert.Equal(1f, MessagePackSerializer.Deserialize(ref reader, Options).Min); Assert.Equal(2f, MessagePackSerializer.Deserialize(ref reader, Options).Min); diff --git a/tests/Aetheria.Shared.Tests/SmokeRoundsCommandTests.cs b/tests/Aetheria.Shared.Tests/SmokeRoundsCommandTests.cs index 40a47eec6..83013d766 100644 --- a/tests/Aetheria.Shared.Tests/SmokeRoundsCommandTests.cs +++ b/tests/Aetheria.Shared.Tests/SmokeRoundsCommandTests.cs @@ -21,7 +21,7 @@ public sealed class SmokeRoundsCommandTests : IDisposable private readonly Dictionary _shipped = new Dictionary(); private static byte[] Pack(WeaponItemData design) => - MessagePackSerializer.Serialize(design, CultDocumentMessagePackSerialization.OptionsFor(typeof(WeaponItemData).Assembly)); + ElementIdBlind.Pack(design, typeof(WeaponItemData)); public SmokeRoundsCommandTests() { diff --git a/tests/Aetheria.Shared.Tests/VapourCloudTests.cs b/tests/Aetheria.Shared.Tests/VapourCloudTests.cs index 6945de2a8..3cd4ab34a 100644 --- a/tests/Aetheria.Shared.Tests/VapourCloudTests.cs +++ b/tests/Aetheria.Shared.Tests/VapourCloudTests.cs @@ -419,7 +419,7 @@ public sealed class VapourCloudTests : IDisposable var reader = new MessagePackReader(bytes); Assert.Equal(2, reader.ReadArrayHeader()); Assert.Equal(42, reader.ReadInt32()); - Assert.Equal(4, reader.ReadArrayHeader()); + Assert.Equal(38, reader.ReadArrayHeader()); reader.Skip(); Assert.Equal(1f, MessagePackSerializer.Deserialize(ref reader, options).Min); Assert.Equal(2f, MessagePackSerializer.Deserialize(ref reader, options).Min); diff --git a/tools/AetherDb/EconomyCommand.cs b/tools/AetherDb/EconomyCommand.cs index cf536cd9e..83349a1c0 100644 --- a/tools/AetherDb/EconomyCommand.cs +++ b/tools/AetherDb/EconomyCommand.cs @@ -45,6 +45,10 @@ public sealed class EconomyAuthor private void Write(Type type, object document, CultRecordKey key) { if (!_touched.Contains(key.Value)) _touched.Add(key.Value); + // A cache refuses to write one type onto a key another type holds (write ownership by written type): a batch that turns + // a compound good into a raw one removes the old record first. + var held = _catalog.Get(key); + if (held != null && held.GetType() != type) _catalog.Remove(key); _catalog.UpsertAsync(type, document, key).GetAwaiter().GetResult(); } }