From 1702e0549ff06b04fea6ad4a3e2b73f6db3a23ae Mon Sep 17 00:00:00 2001 From: Meta Vens Date: Wed, 30 Sep 2026 03:06:43 +0200 Subject: [PATCH] Ship S1: one hull record, and a hull names its visual A mod ship file now holds a HullData at mod-hull: and the ShipAuthoring visual package at mod-ship:. HullData.Visual (key 32) is the typed ref between them; ShipAuthoring.Hull (key 1) is retired. - One joint validator over (hull, visual) is the only semantic check; Load owns the file structure (one hull, one visual, deterministic keys). - Compose copies the mod store's records verbatim, so the derived catalog holds one hull per mod ship. - ship_cc.py edits the hull record's slots directly; the body[HULL_SLOT] indirection is gone. - The package fixture is generated from ShipAuthoringTests.Fixture() (glTF scene included) and exported when AETHERIA_SHIP_FIXTURE_DIR is set. Co-Authored-By: Claude Sonnet 5.5 --- Assets/Scripts/Gameplay/ShipModVisual.cs | 2 +- Assets/Scripts/ServerShared/ItemData.cs | 5 + Assets/Scripts/ServerShared/ShipAuthoring.cs | 52 +++-- Assets/Scripts/ServerShared/ShipModCatalog.cs | 26 +-- .../ShipAuthoringTests.cs | 182 +++++++++++++----- tests/Aetheria.Shared.Tests/ShipFixture.cs | 54 ++++++ .../ShipModCatalogTests.cs | 113 ++++++----- .../ShipSchemaPinTests.cs | 43 +++-- .../ShipValidationTests.cs | 151 +++++++-------- tools/AetherDb/ShipAuthoringCommands.cs | 24 ++- tools/blender/aetheria_ships/__init__.py | 11 +- tools/blender/aetheria_ships/ship_cc.py | 93 +++++---- tools/blender/tests/test_ship_cc.py | 181 ++++++++++++----- 13 files changed, 620 insertions(+), 317 deletions(-) create mode 100644 tests/Aetheria.Shared.Tests/ShipFixture.cs diff --git a/Assets/Scripts/Gameplay/ShipModVisual.cs b/Assets/Scripts/Gameplay/ShipModVisual.cs index 9c4c0c33b..fd4058d83 100644 --- a/Assets/Scripts/Gameplay/ShipModVisual.cs +++ b/Assets/Scripts/Gameplay/ShipModVisual.cs @@ -33,7 +33,7 @@ public static class ShipModVisual public static async Task LoadAsync(string shipPath, Transform parent) { var package = ShipModCatalog.ReadPackage(shipPath); - var ship = package.Ship; + var ship = package.Visual; var root = new GameObject("mod-ship:" + ship.Id); root.SetActive(false); diff --git a/Assets/Scripts/ServerShared/ItemData.cs b/Assets/Scripts/ServerShared/ItemData.cs index 59d93c1ba..10831042b 100644 --- a/Assets/Scripts/ServerShared/ItemData.cs +++ b/Assets/Scripts/ServerShared/ItemData.cs @@ -530,6 +530,11 @@ public class HullData : EquippableItemData [Inspectable, JsonProperty("canTow"), Key(29)] public bool CanTow; + // The hull's visual: a ShipAuthoring record holding its model package, or unset for a Unity-prefab hull. A hull + // names exactly one of Prefab and Visual. Keys 30 and 31 are EquippableItemData's, so 32 is the next free key. + [JsonProperty("visual"), Key(32)] + public CultRecordRef Visual; + [IgnoreMember] public Shape InteriorCells { diff --git a/Assets/Scripts/ServerShared/ShipAuthoring.cs b/Assets/Scripts/ServerShared/ShipAuthoring.cs index 992e6405f..c9ea6c058 100644 --- a/Assets/Scripts/ServerShared/ShipAuthoring.cs +++ b/Assets/Scripts/ServerShared/ShipAuthoring.cs @@ -6,13 +6,13 @@ using GameCult.Caching; using GameCult.Caching.MessagePack; using MessagePack; -// A mod ship's authoring record. Hull is the existing simulation design, embedded here so a -// proof ship has one writable source; the shipped catalog contains no copy of that ship. +// A mod ship's visual package: model, anchors, schematic lines. The hull semantics (cells, hardpoints, stats) live in +// the ship's own HullData record beside this one, and HullData.Visual is the only binding between the two. [CultDocument("aetheria.ship_authoring", "1"), MessagePackObject] public sealed class ShipAuthoring { [CultName, Key(0)] public string Id; - [Key(1)] public HullData Hull; + // Key 1 held the embedded HullData until S1; it is retired and never reused. [Key(2)] public string ModelAsset; [Key(3)] public List Anchors = new List(); [Key(4)] public List SchematicLines = new List(); @@ -54,7 +54,7 @@ public static class ShipAuthoringStore var cache = new CultCache(); try { - cache.AddBackingStore(new SingleFileMessagePackBackingStore(path, !writable), new[] { typeof(ShipAuthoring) }); + cache.AddBackingStore(new SingleFileMessagePackBackingStore(path, !writable), new[] { typeof(HullData), typeof(ShipAuthoring) }); return cache; } catch @@ -64,29 +64,55 @@ public static class ShipAuthoringStore } } - public static ShipAuthoring Read(string path) + // Writes one ship's two records under their deterministic keys, the shape Load expects. + public static void Write(CultCache cache, HullData hull, ShipAuthoring visual) { - using var cache = Open(path); - var records = cache.GetAll().ToArray(); - if (records.Length != 1) - throw new InvalidOperationException($"{path} must hold exactly one ship authoring record, found {records.Length}."); - Validate(records[0]); - return records[0]; + cache.UpsertAsync(typeof(HullData), hull, ShipModCatalog.HullKey(visual.Id)).GetAwaiter().GetResult(); + cache.UpsertAsync(typeof(ShipAuthoring), visual, ShipModCatalog.AuthoringKey(visual.Id)).GetAwaiter().GetResult(); } - public static void Validate(ShipAuthoring ship) + // The structure of a ship file, without judging its content (drafts load): exactly one hull and one visual, each at + // its deterministic key, the hull naming that visual by its typed ref. + public static (HullData Hull, ShipAuthoring Visual) Load(string path) + { + using var cache = Open(path); + var hulls = cache.GetAll().ToArray(); + var visuals = cache.GetAll().ToArray(); + if (visuals.Length != 1) + throw new InvalidOperationException($"{path} must hold exactly one ship authoring record, found {visuals.Length}."); + if (hulls.Length != 1) + throw new InvalidOperationException($"{path} must hold exactly one hull record, found {hulls.Length}."); + var (hull, visual) = (hulls[0], visuals[0]); + if (!cache.TryGetHandle(hull).Value.Key.Equals(ShipModCatalog.HullKey(visual.Id)) || + !cache.TryGetHandle(visual).Value.Key.Equals(ShipModCatalog.AuthoringKey(visual.Id))) + throw new InvalidOperationException($"{path}: {visual.Id}: records must be stored under {ShipModCatalog.HullKey(visual.Id).Value} and {ShipModCatalog.AuthoringKey(visual.Id).Value}."); + return (hull, visual); + } + + public static (HullData Hull, ShipAuthoring Visual) Read(string path) + { + var (hull, visual) = Load(path); + Validate(hull, visual); + return (hull, visual); + } + + // The one semantic check every path shares: the hull and its visual, judged together. + public static void Validate(HullData hull, ShipAuthoring ship) { if (ship == null) throw new InvalidOperationException("Ship authoring record is null."); if (string.IsNullOrWhiteSpace(ship.Id)) throw new InvalidOperationException("Ship ID is required."); if (!(ship.Id[0] >= 'a' && ship.Id[0] <= 'z' || ship.Id[0] >= '0' && ship.Id[0] <= '9') || ship.Id.Any(c => !(c >= 'a' && c <= 'z' || c >= '0' && c <= '9' || c == '.' || c == '_' || c == '-'))) throw new InvalidOperationException($"{ship.Id}: ship ID must use lower-case ASCII letters, digits, dots, underscores, or hyphens."); - var hull = ship.Hull ?? throw new InvalidOperationException($"{ship.Id}: hull data is required."); + if (hull == null) throw new InvalidOperationException($"{ship.Id}: hull data is required."); if (string.IsNullOrWhiteSpace(hull.Name)) throw new InvalidOperationException($"{ship.Id}: hull name is required."); if (hull.Shape?.Cells == null || !hull.Shape.Cells.Cast().Any(occupied => occupied)) throw new InvalidOperationException($"{ship.Id}: schematic must contain at least one cell."); + // A hull names exactly one visual: a Unity prefab (shipped hulls) or a ShipAuthoring record (mod ships). if (!string.IsNullOrEmpty(hull.Prefab)) throw new InvalidOperationException($"{ship.Id}: a mod ship cannot name a Unity prefab."); + if (!hull.Visual.Key.Equals(ShipModCatalog.AuthoringKey(ship.Id))) + throw new InvalidOperationException($"{ship.Id}: the hull must name its visual record {ShipModCatalog.AuthoringKey(ship.Id).Value}."); if (string.IsNullOrWhiteSpace(ship.ModelAsset) || Path.IsPathRooted(ship.ModelAsset) || ship.ModelAsset.Replace('\\', '/').Split('/').Any(part => part == "..")) throw new InvalidOperationException($"{ship.Id}: model asset must be a relative package path."); diff --git a/Assets/Scripts/ServerShared/ShipModCatalog.cs b/Assets/Scripts/ServerShared/ShipModCatalog.cs index 8bb737d01..148a3372e 100644 --- a/Assets/Scripts/ServerShared/ShipModCatalog.cs +++ b/Assets/Scripts/ServerShared/ShipModCatalog.cs @@ -12,7 +12,8 @@ public static class ShipModCatalog { public sealed class Package { - public ShipAuthoring Ship; + public HullData Hull; + public ShipAuthoring Visual; public string ModelPath; public Dictionary NodeIndices; } @@ -32,13 +33,13 @@ public static class ShipModCatalog if (output.StartsWith(root + Path.DirectorySeparatorChar, StringComparison.OrdinalIgnoreCase)) throw new InvalidOperationException("The derived catalog cannot replace a mod source or asset."); - var ships = new List(); + var packages = new List(); foreach (var directory in Directory.GetDirectories(root).OrderBy(path => path, StringComparer.Ordinal)) { var path = Path.Combine(directory, "ship.cc"); if (!File.Exists(path)) continue; // ReadPackage pins each ID to its directory name, so IDs within one mods root are already unique. - ships.Add(ReadPackage(path).Ship); + packages.Add(ReadPackage(path)); } Directory.CreateDirectory(Path.GetDirectoryName(output)); @@ -52,8 +53,9 @@ public static class ShipModCatalog File.Copy(source, temporary); using (var cache = AetheriaStores.Open(temporary, catalogWritable: true)) { - foreach (var ship in ships) + foreach (var package in packages) { + var (hull, ship) = (package.Hull, package.Visual); var hullKey = HullKey(ship.Id); var authoringKey = AuthoringKey(ship.Id); if (cache.AllStoredDocuments.Any(record => record.Key.Equals(hullKey) || record.Key.Equals(authoringKey))) @@ -61,18 +63,18 @@ public static class ShipModCatalog // CultCache indexes names per concrete type and lets the last writer win, and GetByName over a base // type throws on a match across subtypes. A mod hull named like any shipped item, or another mod's // hull (upserted earlier in this loop), would make name lookups wrong or throw. - if (cache.GetAll().Any(item => string.Equals(item.Name, ship.Hull.Name, StringComparison.Ordinal))) - throw new InvalidOperationException($"{ship.Id}: hull name '{ship.Hull.Name}' collides with an existing catalog item."); + if (cache.GetAll().Any(item => string.Equals(item.Name, hull.Name, StringComparison.Ordinal))) + throw new InvalidOperationException($"{ship.Id}: hull name '{hull.Name}' collides with an existing catalog item."); if (cache.GetAll().Any(existing => string.Equals(existing.Id, ship.Id, StringComparison.Ordinal))) throw new InvalidOperationException($"{ship.Id}: ship ID collides with an existing catalog ship authoring record."); - cache.UpsertAsync(typeof(HullData), ship.Hull, hullKey).GetAwaiter().GetResult(); - cache.UpsertAsync(typeof(ShipAuthoring), ship, authoringKey).GetAwaiter().GetResult(); + // Verbatim: the mod store's own two records, under the keys it already holds them at. + ShipAuthoringStore.Write(cache, hull, ship); } cache.FlushAsync().GetAwaiter().GetResult(); } if (File.Exists(output)) File.Replace(temporary, output, null); else File.Move(temporary, output); - return ships.Count; + return packages.Count; } finally { @@ -84,7 +86,7 @@ public static class ShipModCatalog { var path = Path.GetFullPath(shipPath); var directory = Path.GetDirectoryName(path); - var ship = ShipAuthoringStore.Read(path); + var (hull, ship) = ShipAuthoringStore.Read(path); if (!string.Equals(Path.GetFileName(directory), ship.Id, StringComparison.Ordinal)) throw new InvalidOperationException($"{path}: ship ID must match its package directory name."); var modelPath = Path.GetFullPath(Path.Combine(directory, ship.ModelAsset)); @@ -95,8 +97,8 @@ public static class ShipModCatalog foreach (var anchor in ship.Anchors) if (!modelNodes.ContainsKey(anchor.ModelNodeId)) throw new InvalidOperationException($"{ship.Id}: model has no node with aetheria.id={anchor.ModelNodeId} for anchor {anchor.Id}."); - CultRecordRefs.Validate(ship.Hull); - return new Package { Ship = ship, ModelPath = modelPath, NodeIndices = modelNodes }; + CultRecordRefs.Validate(hull); + return new Package { Hull = hull, Visual = ship, ModelPath = modelPath, NodeIndices = modelNodes }; } // GLB is the xenos asset boundary. Only its node extras are read here; geometry belongs to the runtime importer. diff --git a/tests/Aetheria.Shared.Tests/ShipAuthoringTests.cs b/tests/Aetheria.Shared.Tests/ShipAuthoringTests.cs index dd6574669..54144ffe7 100644 --- a/tests/Aetheria.Shared.Tests/ShipAuthoringTests.cs +++ b/tests/Aetheria.Shared.Tests/ShipAuthoringTests.cs @@ -3,6 +3,7 @@ using System.Collections.Generic; using System.IO; using System.Threading.Tasks; using CultMath; +using GameCult.Caching; using Xunit; public sealed class ShipAuthoringTests @@ -16,19 +17,20 @@ public sealed class ShipAuthoringTests try { var ship = Fixture(); - ShipAuthoringStore.Validate(ship); + ship.Validate(); using (var cache = ShipAuthoringStore.Open(path, writable: true)) { - cache.Upsert(ship); + ShipAuthoringStore.Write(cache, ship.Hull, ship.Visual); await cache.FlushAsync(); } - var reopened = ShipAuthoringStore.Read(path); - Assert.Equal("mod.skiff", reopened.Id); - Assert.Equal("Skiff", reopened.Hull.Name); - Assert.True(reopened.Hull.Shape.Cells[1, 0]); - Assert.Equal("thruster.port", reopened.Hull.Hardpoints[0].Transform); - Assert.Equal("skiff.glb", reopened.ModelAsset); - Assert.Equal(2, reopened.SchematicLines[0].Points.Length / 3); + var (hull, visual) = ShipAuthoringStore.Read(path); + Assert.Equal("mod.skiff", visual.Id); + Assert.Equal("Skiff", hull.Name); + Assert.True(hull.Shape.Cells[1, 0]); + Assert.Equal("thruster.port", hull.Hardpoints[0].Transform); + Assert.Equal(ShipModCatalog.AuthoringKey("mod.skiff"), hull.Visual.Key); + Assert.Equal("skiff.glb", visual.ModelAsset); + Assert.Equal(2, visual.SchematicLines[0].Points.Length / 3); } finally { Directory.Delete(root, true); } } @@ -38,24 +40,15 @@ public sealed class ShipAuthoringTests { var ship = Fixture(); ship.Hull.Shape.Cells[1, 0] = false; - Assert.Contains("outside the hull schematic", Assert.Throws(() => - ShipAuthoringStore.Validate(ship)).Message); + Assert.Contains("outside the hull schematic", Assert.Throws(ship.Validate).Message); ship = Fixture(); - ship.Anchors.RemoveAt(ship.Anchors.Count - 1); - Assert.Contains("has no model anchor", Assert.Throws(() => - ShipAuthoringStore.Validate(ship)).Message); + ship.Visual.Anchors.RemoveAt(ship.Visual.Anchors.Count - 1); + Assert.Contains("has no model anchor", Assert.Throws(ship.Validate).Message); ship = Fixture(); - ship.SchematicLines[0].Points[0] = float.NaN; - Assert.Contains("finite XYZ", Assert.Throws(() => - ShipAuthoringStore.Validate(ship)).Message); - - ship = Fixture(); - ShipAuthoringStore.Validate(ship); - ship.Hull.Shape.Cells[1, 0] = false; - Assert.Contains("outside the hull schematic", Assert.Throws(() => - ShipAuthoringStore.Validate(ship)).Message); + ship.Visual.SchematicLines[0].Points[0] = float.NaN; + Assert.Contains("finite XYZ", Assert.Throws(ship.Validate).Message); ship = Fixture(); ship.Hull.Hardpoints.Add(new HardpointData @@ -65,24 +58,98 @@ public sealed class ShipAuthoringTests Shape = new Shape(), Transform = "thruster.starboard" }); - ship.Anchors.Add(new ShipAnchor { Id = "thruster.starboard", Role = "thruster-emitter", ModelNodeId = "thruster-starboard" }); - Assert.Contains("overlaps another hardpoint", Assert.Throws(() => - ShipAuthoringStore.Validate(ship)).Message); + ship.Visual.Anchors.Add(new ShipAnchor { Id = "thruster.starboard", Role = "thruster-emitter", ModelNodeId = "thruster-starboard" }); + Assert.Contains("overlaps another hardpoint", Assert.Throws(ship.Validate).Message); } - internal static ShipAuthoring Fixture() + // The hull owns hull semantics and the visual owns the model package, so the one joint check refuses a hull mount the + // visual has no anchor for, and a visual anchor parented to a mount the hull does not have. + [Fact] + public void HardpointsMustResolveToTheVisualsAnchors() + { + var ship = Fixture(); + ship.Hull.Hardpoints[0].Transform = "thruster.renamed"; + Assert.Contains("thruster.renamed has no model anchor", Assert.Throws(ship.Validate).Message); + + ship = Fixture(); + ship.Visual.Anchors.Add(new ShipAnchor { Id = "muzzle", Role = "weapon-muzzle", ModelNodeId = "muzzle", ParentId = "thruster.gone" }); + Assert.Contains("unknown hardpoint parent thruster.gone", Assert.Throws(ship.Validate).Message); + + Fixture().Validate(); + } + + [Fact] + public void HullNamesExactlyOneVisual() + { + var ship = Fixture(); + ship.Hull.Prefab = "Djinni"; + Assert.Contains("cannot name a Unity prefab", Assert.Throws(ship.Validate).Message); + + ship = Fixture(); + ship.Hull.Visual = default; + Assert.Contains("the hull must name its visual record mod-ship:mod.skiff", Assert.Throws(ship.Validate).Message); + + ship = Fixture(); + ship.Hull.Visual = new CultRecordRef(ShipModCatalog.AuthoringKey("mod.other")); + Assert.Contains("the hull must name its visual record mod-ship:mod.skiff", Assert.Throws(ship.Validate).Message); + + Fixture().Validate(); + } + + [Fact] + public async Task LoadRefusesAFileWhoseRecordsAreNotOneHullAndOneVisualAtTheirKeys() + { + using var directory = new TempDirectory(); + async Task Write(string name, Action fill) + { + var path = Path.Combine(directory.Path, name); + using var cache = ShipAuthoringStore.Open(path, writable: true); + fill(cache); + await cache.FlushAsync(); + return path; + } + void Hull(CultCache cache, string key) => + cache.UpsertAsync(typeof(HullData), Fixture().Hull, new CultRecordKey(key)).GetAwaiter().GetResult(); + void Visual(CultCache cache, string key, string id = "mod.skiff") => + cache.UpsertAsync(typeof(ShipAuthoring), Fixture().WithId(id).Visual, new CultRecordKey(key)).GetAwaiter().GetResult(); + + var twoShips = await Write("two.cc", cache => + { + ShipAuthoringStore.Write(cache, Fixture().Hull, Fixture().Visual); + Visual(cache, "mod-ship:mod.other", "mod.other"); + }); + Assert.Contains("found 2", Assert.Throws(() => ShipAuthoringStore.Load(twoShips)).Message); + + var noHull = await Write("nohull.cc", cache => Visual(cache, "mod-ship:mod.skiff")); + Assert.Contains("exactly one hull record, found 0", Assert.Throws(() => ShipAuthoringStore.Load(noHull)).Message); + + var twoHulls = await Write("twohulls.cc", cache => + { + ShipAuthoringStore.Write(cache, Fixture().Hull, Fixture().Visual); + Hull(cache, "mod-hull:mod.other"); + }); + Assert.Contains("exactly one hull record, found 2", Assert.Throws(() => ShipAuthoringStore.Load(twoHulls)).Message); + + var wrongHullKey = await Write("hullkey.cc", cache => { Hull(cache, "hull:skiff"); Visual(cache, "mod-ship:mod.skiff"); }); + Assert.Contains("records must be stored under mod-hull:mod.skiff and mod-ship:mod.skiff", + Assert.Throws(() => ShipAuthoringStore.Load(wrongHullKey)).Message); + + var wrongVisualKey = await Write("visualkey.cc", cache => { Hull(cache, "mod-hull:mod.skiff"); Visual(cache, "ship:skiff"); }); + Assert.Contains("records must be stored under", Assert.Throws(() => ShipAuthoringStore.Load(wrongVisualKey)).Message); + } + + internal static ShipParts Fixture() { var shape = new Shape(2, 2); shape.Cells[0, 0] = true; shape.Cells[1, 0] = true; - return new ShipAuthoring + return new ShipParts { - Id = "mod.skiff", - ModelAsset = "skiff.glb", Hull = new HullData { Name = "Skiff", Shape = shape, + Visual = new CultRecordRef(ShipModCatalog.AuthoringKey("mod.skiff")), Hardpoints = new List { new HardpointData @@ -94,26 +161,49 @@ public sealed class ShipAuthoringTests } } }, - Anchors = new List + Visual = new ShipAuthoring { - new ShipAnchor { Id = "map", Role = "map-icon", ModelNodeId = "map" }, - new ShipAnchor { Id = "collider", Role = "hull-collider", ModelNodeId = "collider" }, - new ShipAnchor { Id = "shield", Role = "shield", ModelNodeId = "shield" }, - new ShipAnchor { Id = "tractor", Role = "tractor", ModelNodeId = "tractor" }, - new ShipAnchor { Id = "thruster.port", Role = "thruster-emitter", ModelNodeId = "thruster-port" } - }, - SchematicLines = new List - { - new ShipPolyline + Id = "mod.skiff", + ModelAsset = "skiff.glb", + Anchors = new List { - Layer = "Hull", - Material = "White", - Points = new[] { 0f, 0f, 0f, 1f, 0f, 0f }, - Radii = new[] { .01f, .01f }, - Opacities = new[] { 1f, 1f }, - Color = new[] { 1f, 1f, 1f, 1f } + new ShipAnchor { Id = "map", Role = "map-icon", ModelNodeId = "map" }, + new ShipAnchor { Id = "collider", Role = "hull-collider", ModelNodeId = "collider" }, + new ShipAnchor { Id = "shield", Role = "shield", ModelNodeId = "shield" }, + new ShipAnchor { Id = "tractor", Role = "tractor", ModelNodeId = "tractor" }, + new ShipAnchor { Id = "thruster.port", Role = "thruster-emitter", ModelNodeId = "thruster-port" } + }, + SchematicLines = new List + { + new ShipPolyline + { + Layer = "Hull", + Material = "White", + Points = new[] { 0f, 0f, 0f, 1f, 0f, 0f }, + Radii = new[] { .01f, .01f }, + Opacities = new[] { 1f, 1f }, + Color = new[] { 1f, 1f, 1f, 1f } + } } } }; } } + +// One ship's two records: the hull and the visual it names. +internal sealed class ShipParts +{ + public HullData Hull; + public ShipAuthoring Visual; + + public void Deconstruct(out HullData hull, out ShipAuthoring visual) => (hull, visual) = (Hull, Visual); + + public ShipParts WithId(string id) + { + Visual.Id = id; + Hull.Visual = new CultRecordRef(ShipModCatalog.AuthoringKey(id)); + return this; + } + + public void Validate() => ShipAuthoringStore.Validate(Hull, Visual); +} diff --git a/tests/Aetheria.Shared.Tests/ShipFixture.cs b/tests/Aetheria.Shared.Tests/ShipFixture.cs new file mode 100644 index 000000000..eb8945780 --- /dev/null +++ b/tests/Aetheria.Shared.Tests/ShipFixture.cs @@ -0,0 +1,54 @@ +using System; +using System.IO; +using System.Linq; +using System.Text; + +// Writes the mod package the tests, the Python round-trip check and the Unity smoke (ShipModPreview.Smoke) share. It is +// generated from ShipAuthoringTests.Fixture() so no opaque bytes are committed. +internal static class ShipFixture +{ + public static readonly string[] Nodes = { "map", "collider", "shield", "tractor", "thruster-port" }; + + // The GLB carries a glTF scene naming every node: glTFast instantiates a scene, and a scene-less file has none. + public static string GlbJson(string[] nodes) => + @"{""asset"":{""version"":""2.0""},""scene"":0,""scenes"":[{""nodes"":[" + string.Join(",", nodes.Select((_, index) => index)) + @"]}],""nodes"":[" + + string.Join(",", nodes.Select(node => $@"{{""name"":""{node}"",""extras"":{{""aetheria.id"":""{node}""}}}}")) + "]}"; + + public static byte[] Glb(string json) + { + var body = Encoding.UTF8.GetBytes(json); + var padded = new byte[(body.Length + 3) / 4 * 4]; + Array.Fill(padded, (byte)' '); + body.CopyTo(padded, 0); + var bytes = new byte[20 + padded.Length + 8]; + BitConverter.GetBytes(0x46546C67u).CopyTo(bytes, 0); + BitConverter.GetBytes(2u).CopyTo(bytes, 4); + BitConverter.GetBytes((uint)bytes.Length).CopyTo(bytes, 8); + BitConverter.GetBytes((uint)padded.Length).CopyTo(bytes, 12); + BitConverter.GetBytes(0x4E4F534Au).CopyTo(bytes, 16); + padded.CopyTo(bytes, 20); + BitConverter.GetBytes(0u).CopyTo(bytes, 20 + padded.Length); + BitConverter.GetBytes(0x004E4942u).CopyTo(bytes, 24 + padded.Length); + return bytes; + } + + // //ship.cc plus the GLB at the record's ModelAsset path. + public static string WritePackage(string modsRoot, string id, string hullName = "Skiff", string[] nodes = null, + string modelAsset = "skiff.glb", Action tweak = null) + { + var ship = ShipAuthoringTests.Fixture(); + tweak?.Invoke(ship); + ship.WithId(id); + ship.Hull.Name = hullName; + ship.Visual.ModelAsset = modelAsset; + var directory = Path.Combine(modsRoot, id); + Directory.CreateDirectory(directory); + using (var cache = ShipAuthoringStore.Open(Path.Combine(directory, "ship.cc"), writable: true)) + { + ShipAuthoringStore.Write(cache, ship.Hull, ship.Visual); + cache.FlushAsync().Wait(); + } + File.WriteAllBytes(Path.Combine(directory, modelAsset), Glb(GlbJson(nodes ?? Nodes))); + return directory; + } +} diff --git a/tests/Aetheria.Shared.Tests/ShipModCatalogTests.cs b/tests/Aetheria.Shared.Tests/ShipModCatalogTests.cs index 029ea81c8..54563348f 100644 --- a/tests/Aetheria.Shared.Tests/ShipModCatalogTests.cs +++ b/tests/Aetheria.Shared.Tests/ShipModCatalogTests.cs @@ -5,6 +5,7 @@ using System.Linq; using System.Security.Cryptography; using System.Text; using GameCult.Caching; +using Newtonsoft.Json.Linq; using Xunit; // Compose, ReadPackage and ReadNodeIds: the derived catalog a game would open, and the GLB node ids its anchors bind to. @@ -14,7 +15,7 @@ public sealed class ShipModCatalogTests : IDisposable private string Shipped => Path.Combine(_directory.Path, "Aetheria.cc"); private string Derived => Path.Combine(_directory.Path, "Derived", "Aetheria.modded.cc"); private string Mods => Path.Combine(_directory.Path, "Mods"); - private static readonly string[] FixtureNodes = { "map", "collider", "shield", "tractor", "thruster-port" }; + private static readonly string[] FixtureNodes = ShipFixture.Nodes; public ShipModCatalogTests() { @@ -48,6 +49,60 @@ public sealed class ShipModCatalogTests : IDisposable AssertOnlyTheDerivedFile(); } + // Correction 2 of the S0 map: the derived catalog held each mod hull twice, once as a HullData and once inside the + // ship's authoring record. The hull is now one record, and the visual holds no hull. + [Fact] + public void ComposedCatalogHoldsOneHullPerModShip() + { + WritePackage("mod.skiff", hullName: "Skiffhull"); + ShipModCatalog.Compose(Shipped, Derived, Mods); + + using var cache = AetheriaStores.Open(Derived); + Assert.Equal(1, cache.GetAll().Count(hull => hull.Name == "Skiffhull")); + // The hull's name is stored exactly once in the derived file, so no second copy of the hull hides in another record. + Assert.Equal(1, Occurrences(File.ReadAllBytes(Derived), Encoding.UTF8.GetBytes("Skiffhull"))); + var visual = cache.GetAll().Single(); + Assert.Equal("mod.skiff", visual.Id); + } + + [Fact] + public void ComposeCopiesTheModStoresRecordsUnchangedAndTheHullNamesItsVisualByRef() + { + WritePackage("mod.skiff"); + var (modHull, modVisual) = ShipAuthoringStore.Read(Path.Combine(Mods, "mod.skiff", "ship.cc")); + ShipModCatalog.Compose(Shipped, Derived, Mods); + + using var cache = AetheriaStores.Open(Derived); + var hull = cache.Get(ShipModCatalog.HullKey("mod.skiff")); + var visual = cache.Get(ShipModCatalog.AuthoringKey("mod.skiff")); + Assert.Same(visual, cache.Get(hull.Visual)); + var options = GameCult.Caching.MessagePack.CultDocumentMessagePackSerialization.OptionsFor(typeof(ShipAuthoring).Assembly); + Assert.Equal(MessagePack.MessagePackSerializer.Serialize(modHull, options), MessagePack.MessagePackSerializer.Serialize(hull, options)); + Assert.Equal(MessagePack.MessagePackSerializer.Serialize(modVisual, options), MessagePack.MessagePackSerializer.Serialize(visual, options)); + } + + // The package the Unity smoke (ShipModPreview.Smoke) loads. Set AETHERIA_SHIP_FIXTURE_DIR to have this run leave it + // there: `-shipModPath $AETHERIA_SHIP_FIXTURE_DIR/mod.skiff/ship.cc`. + [Fact] + public void TheFixturePackageIsCompleteAndItsGlbCarriesAScene() + { + var directory = ShipFixture.WritePackage(Mods, "mod.skiff"); + + var package = ShipModCatalog.ReadPackage(Path.Combine(directory, "ship.cc")); + Assert.Equal(ShipFixture.Nodes.Length, package.NodeIndices.Count); + var glb = File.ReadAllBytes(package.ModelPath); + var json = JObject.Parse(Encoding.UTF8.GetString(glb, 20, (int)BitConverter.ToUInt32(glb, 12))); + Assert.Equal(0, (int)json["scene"]); + Assert.Equal(Enumerable.Range(0, ShipFixture.Nodes.Length), json["scenes"][0]["nodes"].Select(node => (int)node)); + Assert.Equal(1, ShipModCatalog.Compose(Shipped, Derived, Mods)); + + var export = Environment.GetEnvironmentVariable("AETHERIA_SHIP_FIXTURE_DIR"); + if (string.IsNullOrEmpty(export)) return; + var target = Path.Combine(export, "mod.skiff"); + Directory.CreateDirectory(target); + foreach (var name in new[] { "ship.cc", "skiff.glb" }) File.Copy(Path.Combine(directory, name), Path.Combine(target, name), true); + } + [Fact] public void ComposeCountsEveryPackageAndReplacesAnExistingDerivedCatalog() { @@ -92,8 +147,7 @@ public sealed class ShipModCatalogTests : IDisposable { if (authoring) { - var stranger = ShipAuthoringTests.Fixture(); - stranger.Id = "stranger"; + var stranger = ShipAuthoringTests.Fixture().WithId("stranger").Visual; cache.UpsertAsync(typeof(ShipAuthoring), stranger, new CultRecordKey(key)).GetAwaiter().GetResult(); } else @@ -143,7 +197,7 @@ public sealed class ShipModCatalogTests : IDisposable { using (var cache = AetheriaStores.Open(Shipped, catalogWritable: true)) { - cache.UpsertAsync(typeof(ShipAuthoring), ShipAuthoringTests.Fixture(), new CultRecordKey("shipped:skiff")).GetAwaiter().GetResult(); + cache.UpsertAsync(typeof(ShipAuthoring), ShipAuthoringTests.Fixture().Visual, new CultRecordKey("shipped:skiff")).GetAwaiter().GetResult(); cache.FlushAsync().Wait(); } WritePackage("mod.skiff", hullName: "Different"); @@ -177,7 +231,7 @@ public sealed class ShipModCatalogTests : IDisposable { WritePackage("mod.skiff"); var package = ShipModCatalog.ReadPackage(Path.Combine(Mods, "mod.skiff", "ship.cc")); - Assert.Equal("mod.skiff", package.Ship.Id); + Assert.Equal("mod.skiff", package.Visual.Id); Assert.Equal(Path.GetFullPath(Path.Combine(Mods, "mod.skiff", "skiff.glb")), package.ModelPath); Assert.Equal(new Dictionary { ["map"] = 0, ["collider"] = 1, ["shield"] = 2, ["tractor"] = 3, ["thruster-port"] = 4 }, package.NodeIndices); } @@ -224,24 +278,24 @@ public sealed class ShipModCatalogTests : IDisposable [Fact] public void NodeIdsMapToTheirGlbNodeIndexSkippingNodesWithoutOne() { - var path = Write("nodes.glb", Glb(@"{""asset"":{""version"":""2.0""},""nodes"":[ + var path = Write("nodes.glb", ShipFixture.Glb(@"{""asset"":{""version"":""2.0""},""nodes"":[ {""name"":""plain""},{""extras"":{""aetheria.id"":""a""}},{""extras"":{}},{""extras"":{""aetheria.id"":""""}},{""extras"":{""aetheria.id"":""b""}}]}")); Assert.Equal(new Dictionary { ["a"] = 1, ["b"] = 4 }, ShipModCatalog.ReadNodeIds(path)); } [Fact] public void AGlbWithoutNodesHasNoIds() => - Assert.Empty(ShipModCatalog.ReadNodeIds(Write("empty.glb", Glb(@"{""asset"":{""version"":""2.0""}}")))); + Assert.Empty(ShipModCatalog.ReadNodeIds(Write("empty.glb", ShipFixture.Glb(@"{""asset"":{""version"":""2.0""}}")))); [Fact] public void ADuplicateNodeIdIsRefused() => Assert.Contains("duplicate GLB node aetheria.id=a", Assert.Throws(() => - ShipModCatalog.ReadNodeIds(Write("dup.glb", Glb(@"{""nodes"":[{""extras"":{""aetheria.id"":""a""}},{""extras"":{""aetheria.id"":""a""}}]}")))).Message); + ShipModCatalog.ReadNodeIds(Write("dup.glb", ShipFixture.Glb(@"{""nodes"":[{""extras"":{""aetheria.id"":""a""}},{""extras"":{""aetheria.id"":""a""}}]}")))).Message); [Fact] public void AMalformedGlbHeaderIsRefused() { - var good = Glb(@"{""nodes"":[]}"); + var good = ShipFixture.Glb(@"{""nodes"":[]}"); void Refuse(string name, byte[] bytes, string message) => Assert.Contains(message, Assert.Throws(() => ShipModCatalog.ReadNodeIds(Write(name, bytes))).Message); @@ -281,24 +335,6 @@ public sealed class ShipModCatalogTests : IDisposable return copy; } - private static byte[] Glb(string json) - { - var body = Encoding.UTF8.GetBytes(json); - var padded = new byte[(body.Length + 3) / 4 * 4]; - Array.Fill(padded, (byte)' '); - body.CopyTo(padded, 0); - var bytes = new byte[20 + padded.Length + 8]; - BitConverter.GetBytes(0x46546C67u).CopyTo(bytes, 0); - BitConverter.GetBytes(2u).CopyTo(bytes, 4); - BitConverter.GetBytes((uint)bytes.Length).CopyTo(bytes, 8); - BitConverter.GetBytes((uint)padded.Length).CopyTo(bytes, 12); - BitConverter.GetBytes(0x4E4F534Au).CopyTo(bytes, 16); - padded.CopyTo(bytes, 20); - BitConverter.GetBytes(0u).CopyTo(bytes, 20 + padded.Length); - BitConverter.GetBytes(0x004E4942u).CopyTo(bytes, 24 + padded.Length); - return bytes; - } - private string Write(string name, byte[] bytes) { var path = Path.Combine(_directory.Path, name); @@ -307,23 +343,14 @@ public sealed class ShipModCatalogTests : IDisposable } private void WritePackage(string id, string hullName = "Skiff", string[] nodes = null, string modelAsset = "skiff.glb", - Action tweak = null) + Action tweak = null) => ShipFixture.WritePackage(Mods, id, hullName, nodes, modelAsset, tweak); + + private static int Occurrences(byte[] haystack, byte[] needle) { - var ship = ShipAuthoringTests.Fixture(); - tweak?.Invoke(ship); - ship.Id = id; - ship.Hull.Name = hullName; - ship.ModelAsset = modelAsset; - var directory = Path.Combine(Mods, id); - Directory.CreateDirectory(directory); - using (var cache = ShipAuthoringStore.Open(Path.Combine(directory, "ship.cc"), writable: true)) - { - cache.Upsert(ship); - cache.FlushAsync().Wait(); - } - var json = @"{""asset"":{""version"":""2.0""},""nodes"":[" + - string.Join(",", (nodes ?? FixtureNodes).Select(node => $@"{{""extras"":{{""aetheria.id"":""{node}""}}}}")) + "]}"; - File.WriteAllBytes(Path.Combine(directory, modelAsset), Glb(json)); + var count = 0; + for (var start = 0; start <= haystack.Length - needle.Length; start++) + if (haystack.AsSpan(start, needle.Length).SequenceEqual(needle)) count++; + return count; } private static string Hash(string path) => Convert.ToHexString(SHA256.HashData(File.ReadAllBytes(path))); diff --git a/tests/Aetheria.Shared.Tests/ShipSchemaPinTests.cs b/tests/Aetheria.Shared.Tests/ShipSchemaPinTests.cs index b892c1908..b5dc61ffe 100644 --- a/tests/Aetheria.Shared.Tests/ShipSchemaPinTests.cs +++ b/tests/Aetheria.Shared.Tests/ShipSchemaPinTests.cs @@ -4,12 +4,13 @@ using System.IO; using System.Linq; using System.Reflection; using System.Text.RegularExpressions; +using GameCult.Caching; using GameCult.Caching.MessagePack; using MessagePack; using Xunit; -// tools/blender/aetheria_ships/ship_cc.py edits a ShipAuthoring record by MessagePack slot number and by hardpoint -// member position. These tests bind that file to the C# types that own the numbers: any renumbered, renamed or +// tools/blender/aetheria_ships/ship_cc.py edits a ship file's HullData and ShipAuthoring records by MessagePack slot +// number and by hardpoint member position. These tests bind that file to the C# types that own the numbers: any renumbered, renamed or // reordered member, enum value, or nested array shape fails here instead of corrupting a ship the next time Blender saves. public sealed class ShipSchemaPinTests { @@ -17,7 +18,6 @@ public sealed class ShipSchemaPinTests [Theory] [InlineData("SCHEMATIC_LINES_SLOT", typeof(ShipAuthoring), nameof(ShipAuthoring.SchematicLines))] - [InlineData("HULL_SLOT", typeof(ShipAuthoring), nameof(ShipAuthoring.Hull))] [InlineData("HULL_SHAPE_SLOT", typeof(HullData), nameof(HullData.Shape))] [InlineData("HULL_HARDPOINTS_SLOT", typeof(HullData), nameof(HullData.Hardpoints))] public void PythonSlotConstantsAreTheKeysOfTheMembersTheyNameInTheirComments(string constant, Type owner, string member) @@ -26,6 +26,16 @@ public sealed class ShipSchemaPinTests Assert.Matches(new Regex($@"^{constant} = \d+ # \w+\.{member}\r?$", RegexOptions.Multiline), Source); } + [Fact] + public void PythonSchemaNamesAreTheDocumentsTheyNameAndTheRetiredSlotIsNotReused() + { + Assert.Equal(typeof(ShipAuthoring).GetCustomAttribute().SchemaName, Text("SCHEMA")); + Assert.Equal(typeof(HullData).GetCustomAttribute().SchemaName, Text("HULL_SCHEMA")); + // Key 1 held the embedded hull until S1. A later member must not take it, or an old file would decode as it. + Assert.DoesNotContain(typeof(ShipAuthoring).GetMembers(BindingFlags.Public | BindingFlags.Instance), + member => member.GetCustomAttribute()?.IntKey == 1); + } + [Fact] public void PythonHardpointMembersAreHardpointDataMembersAtTheirOwnPositions() { @@ -45,18 +55,23 @@ public sealed class ShipSchemaPinTests [Fact] public void SerializedShipHasTheNestedArrayShapesPythonIndexes() { - var ship = ShipAuthoringTests.Fixture(); - var bytes = MessagePackSerializer.Serialize(ship, CultDocumentMessagePackSerialization.OptionsFor(typeof(ShipAuthoring).Assembly)); - var body = Assert.IsType(MessagePackSerializer.Deserialize(bytes, MessagePackSerializerOptions.Standard)); + var (hull, visual) = ShipAuthoringTests.Fixture(); + var options = CultDocumentMessagePackSerialization.OptionsFor(typeof(ShipAuthoring).Assembly); + object[] Body(object document) => Assert.IsType(MessagePackSerializer.Deserialize( + MessagePackSerializer.Serialize(document.GetType(), document, options), MessagePackSerializerOptions.Standard)); + var body = Body(visual); Assert.Equal("mod.skiff", body[0]); - var hull = Assert.IsType(body[Integer("HULL_SLOT")]); + var lines = Assert.IsType(body[Integer("SCHEMATIC_LINES_SLOT")]); + Assert.Equal("Hull", Assert.IsType(Assert.Single(lines))[0]); + + var hullBody = Body(hull); // A schematic is [[width, height, cells]] with cells flattened x-major (index x * height + y). - var hullShape = Assert.IsType(Assert.IsType(hull[Integer("HULL_SHAPE_SLOT")])[0]); + var hullShape = Assert.IsType(Assert.IsType(hullBody[Integer("HULL_SHAPE_SLOT")])[0]); Assert.Equal(new object[] { 2, 2 }, hullShape.Take(2).Select(value => (object)Convert.ToInt32(value))); Assert.Equal(new[] { true, false, true, false }, Assert.IsType(hullShape[2]).Cast()); - var hardpoints = Assert.IsType(hull[Integer("HULL_HARDPOINTS_SLOT")]); + var hardpoints = Assert.IsType(hullBody[Integer("HULL_HARDPOINTS_SLOT")]); var row = Assert.IsType(Assert.Single(hardpoints)); var members = Strings("HARDPOINT_MEMBERS"); Assert.True(row.Length >= members.Length); @@ -65,9 +80,6 @@ public sealed class ShipSchemaPinTests Assert.Equal(3, footprint.Length); Assert.Equal(new[] { true }, Assert.IsType(footprint[2]).Cast()); Assert.Equal(new[] { 1, 0 }, Assert.IsType(row[Array.IndexOf(members, "Position")]).Select(value => Convert.ToInt32(value))); - - var lines = Assert.IsType(body[Integer("SCHEMATIC_LINES_SLOT")]); - Assert.Equal("Hull", Assert.IsType(Assert.Single(lines))[0]); } private static int KeyOf(Type owner, string member) @@ -79,6 +91,13 @@ public sealed class ShipSchemaPinTests private static int Integer(string constant) => int.Parse(Regex.Match(Source, $@"^{constant} = (\d+)", RegexOptions.Multiline).Groups[1].Value); + private static string Text(string constant) + { + var match = Regex.Match(Source, $@"^{constant} = ""([^""]*)""", RegexOptions.Multiline); + Assert.True(match.Success, $"{constant} is not a string in ship_cc.py"); + return match.Groups[1].Value; + } + private static string[] Strings(string constant) { var block = Regex.Match(Source, $@"^{constant} = \((.*?)\)\r?$", RegexOptions.Multiline | RegexOptions.Singleline); diff --git a/tests/Aetheria.Shared.Tests/ShipValidationTests.cs b/tests/Aetheria.Shared.Tests/ShipValidationTests.cs index 17d54ad4b..e7f3815af 100644 --- a/tests/Aetheria.Shared.Tests/ShipValidationTests.cs +++ b/tests/Aetheria.Shared.Tests/ShipValidationTests.cs @@ -11,25 +11,25 @@ using Xunit; // reason) fails its own case and no other. public sealed class ShipValidationTests { - private static readonly Dictionary Break, string Message)> Cases = Build(); + private static readonly Dictionary Break, string Message)> Cases = Build(); public static IEnumerable CaseNames => Cases.Keys.Select(name => new object[] { name }); [Fact] - public void TheFixtureIsValid() => ShipAuthoringStore.Validate(ShipAuthoringTests.Fixture()); + public void TheFixtureIsValid() => ShipAuthoringTests.Fixture().Validate(); [Theory, MemberData(nameof(CaseNames))] public void ValidatorRefusesEachBreakageByItsOwnGuard(string name) { var ship = ShipAuthoringTests.Fixture(); Cases[name].Break(ship); - var error = Assert.Throws(() => ShipAuthoringStore.Validate(ship)); + var error = Assert.Throws(ship.Validate); Assert.Contains(Cases[name].Message, error.Message); } [Fact] public void ValidatorRefusesANullRecord() => - Assert.Contains("is null", Assert.Throws(() => ShipAuthoringStore.Validate(null)).Message); + Assert.Contains("is null", Assert.Throws(() => ShipAuthoringStore.Validate(ShipAuthoringTests.Fixture().Hull, null)).Message); [Theory] [InlineData("a"), InlineData("z"), InlineData("0"), InlineData("9"), InlineData("a0z9"), InlineData("0a"), InlineData("9z"), @@ -37,8 +37,7 @@ public sealed class ShipValidationTests public void IdsMayUseLowerCaseAsciiDigitsDotsUnderscoresAndHyphens(string id) { var ship = ShipAuthoringTests.Fixture(); - ship.Id = id; - ShipAuthoringStore.Validate(ship); + ship.WithId(id).Validate(); } [Theory] @@ -47,8 +46,8 @@ public sealed class ShipValidationTests public void IdsOutsideThatAlphabetAreRefused(string id) { var ship = ShipAuthoringTests.Fixture(); - ship.Id = id; - Assert.Contains("ship ID must use", Assert.Throws(() => ShipAuthoringStore.Validate(ship)).Message); + ship.WithId(id); + Assert.Contains("ship ID must use", Assert.Throws(ship.Validate).Message); } [Theory] @@ -56,8 +55,8 @@ public sealed class ShipValidationTests public void ModelAssetsMayLiveInsideTheirPackage(string asset) { var ship = ShipAuthoringTests.Fixture(); - ship.ModelAsset = asset; - ShipAuthoringStore.Validate(ship); + ship.Visual.ModelAsset = asset; + ship.Validate(); } [Theory] @@ -65,35 +64,35 @@ public sealed class ShipValidationTests public void ModelAssetsCannotEscapeTheirPackage(string asset) { var ship = ShipAuthoringTests.Fixture(); - ship.ModelAsset = asset; - Assert.Contains("relative package path", Assert.Throws(() => ShipAuthoringStore.Validate(ship)).Message); + ship.Visual.ModelAsset = asset; + Assert.Contains("relative package path", Assert.Throws(ship.Validate).Message); } [Fact] public void RootedModelAssetsAreRefused() { var ship = ShipAuthoringTests.Fixture(); - ship.ModelAsset = Path.GetFullPath("skiff.glb"); - Assert.Contains("relative package path", Assert.Throws(() => ShipAuthoringStore.Validate(ship)).Message); + ship.Visual.ModelAsset = Path.GetFullPath("skiff.glb"); + Assert.Contains("relative package path", Assert.Throws(ship.Validate).Message); } [Fact] public void OptionalPartsAndRangeEdgesAreAccepted() { var ship = ShipAuthoringTests.Fixture(); - var line = ship.SchematicLines[0]; + var line = ship.Visual.SchematicLines[0]; line.Points = new[] { 0f, 0f, 0f, 1f, 0f, 0f, 2f, 0f, 0f }; line.Radii = new[] { 0f, 5f, 0f }; line.Opacities = new[] { 0f, 1f, .5f }; - ShipAuthoringStore.Validate(ship); + ship.Validate(); line.Radii = null; line.Opacities = null; line.Color = null; - ShipAuthoringStore.Validate(ship); - ship.SchematicLines = null; + ship.Validate(); + ship.Visual.SchematicLines = null; ship.Hull.Hardpoints = null; - ship.Anchors.RemoveAt(ship.Anchors.Count - 1); - ShipAuthoringStore.Validate(ship); + ship.Visual.Anchors.RemoveAt(ship.Visual.Anchors.Count - 1); + ship.Validate(); } [Fact] @@ -102,39 +101,23 @@ public sealed class ShipValidationTests var ship = ShipAuthoringTests.Fixture(); ship.Hull.Hardpoints[0].Shape = new Shape(2, 1); ship.Hull.Hardpoints[0].Shape.Cells[0, 0] = true; - ShipAuthoringStore.Validate(ship); + ship.Validate(); } [Fact] public void AnEmptyParentIdMeansNoParent() { var ship = ShipAuthoringTests.Fixture(); - ship.Anchors[0].ParentId = ""; - ShipAuthoringStore.Validate(ship); + ship.Visual.Anchors[0].ParentId = ""; + ship.Validate(); } [Fact] public void AnAnchorMayNameItsHardpointAsParent() { var ship = ShipAuthoringTests.Fixture(); - ship.Anchors.Add(new ShipAnchor { Id = "muzzle", Role = "weapon-muzzle", ModelNodeId = "muzzle", ParentId = "thruster.port" }); - ShipAuthoringStore.Validate(ship); - } - - [Fact] - public async Task ReadRefusesAFileThatHoldsTwoShips() - { - using var directory = new TempDirectory(); - var path = Path.Combine(directory.Path, "two.cc"); - using (var cache = ShipAuthoringStore.Open(path, writable: true)) - { - cache.Upsert(ShipAuthoringTests.Fixture()); - var other = ShipAuthoringTests.Fixture(); - other.Id = "mod.other"; - cache.Upsert(other); - await cache.FlushAsync(); - } - Assert.Contains("found 2", Assert.Throws(() => ShipAuthoringStore.Read(path)).Message); + ship.Visual.Anchors.Add(new ShipAnchor { Id = "muzzle", Role = "weapon-muzzle", ModelNodeId = "muzzle", ParentId = "thruster.port" }); + ship.Validate(); } [Fact] @@ -146,20 +129,20 @@ public sealed class ShipValidationTests ship.Hull.Name = ""; using (var cache = ShipAuthoringStore.Open(path, writable: true)) { - cache.Upsert(ship); + ShipAuthoringStore.Write(cache, ship.Hull, ship.Visual); await cache.FlushAsync(); } Assert.Contains("hull name is required", Assert.Throws(() => ShipAuthoringStore.Read(path)).Message); } - private static Dictionary, string)> Build() + private static Dictionary, string)> Build() { - var cases = new Dictionary, string)>(); - void Add(string name, Action breakIt, string message) => cases.Add(name, (breakIt, message)); - HardpointData Mount(ShipAuthoring ship) => ship.Hull.Hardpoints[0]; + var cases = new Dictionary, string)>(); + void Add(string name, Action breakIt, string message) => cases.Add(name, (breakIt, message)); + HardpointData Mount(ShipParts ship) => ship.Hull.Hardpoints[0]; - Add("id-null", s => s.Id = null, "Ship ID is required"); - Add("id-blank", s => s.Id = " ", "Ship ID is required"); + Add("id-null", s => s.Visual.Id = null, "Ship ID is required"); + Add("id-blank", s => s.Visual.Id = " ", "Ship ID is required"); Add("hull-null", s => s.Hull = null, "hull data is required"); Add("hull-name-null", s => s.Hull.Name = null, "hull name is required"); Add("hull-name-blank", s => s.Hull.Name = " ", "hull name is required"); @@ -169,21 +152,21 @@ public sealed class ShipValidationTests Add("hull-height-zero", s => s.Hull.Shape.Cells = new bool[2, 0], "at least one cell"); Add("hull-no-occupied-cell", s => s.Hull.Shape.Cells = new bool[2, 2], "at least one cell"); Add("hull-names-a-prefab", s => s.Hull.Prefab = "Djinni", "cannot name a Unity prefab"); - Add("model-null", s => s.ModelAsset = null, "relative package path"); - Add("model-blank", s => s.ModelAsset = " ", "relative package path"); + Add("model-null", s => s.Visual.ModelAsset = null, "relative package path"); + Add("model-blank", s => s.Visual.ModelAsset = " ", "relative package path"); - Add("anchors-null", s => s.Anchors = null, "anchors are required"); - Add("anchor-null", s => s.Anchors.Add(null), "anchor IDs must be present and unique"); - Add("anchor-id-null", s => s.Anchors[4].Id = null, "anchor IDs must be present and unique"); - Add("anchor-id-blank", s => s.Anchors.Add(new ShipAnchor { Id = " ", Role = "articulation", ModelNodeId = "x" }), "anchor IDs must be present and unique"); - Add("anchor-id-duplicate", s => s.Anchors.Add(new ShipAnchor { Id = "map", Role = "articulation", ModelNodeId = "x" }), "anchor IDs must be present and unique"); - Add("anchor-node-null", s => s.Anchors[4].ModelNodeId = null, "needs a model node ID"); - Add("anchor-node-blank", s => s.Anchors[4].ModelNodeId = " ", "needs a model node ID"); - Add("anchor-node-shared", s => s.Anchors.Add(new ShipAnchor { Id = "other", Role = "articulation", ModelNodeId = "map" }), "claimed by more than one anchor"); + Add("anchors-null", s => s.Visual.Anchors = null, "anchors are required"); + Add("anchor-null", s => s.Visual.Anchors.Add(null), "anchor IDs must be present and unique"); + Add("anchor-id-null", s => s.Visual.Anchors[4].Id = null, "anchor IDs must be present and unique"); + Add("anchor-id-blank", s => s.Visual.Anchors.Add(new ShipAnchor { Id = " ", Role = "articulation", ModelNodeId = "x" }), "anchor IDs must be present and unique"); + Add("anchor-id-duplicate", s => s.Visual.Anchors.Add(new ShipAnchor { Id = "map", Role = "articulation", ModelNodeId = "x" }), "anchor IDs must be present and unique"); + Add("anchor-node-null", s => s.Visual.Anchors[4].ModelNodeId = null, "needs a model node ID"); + Add("anchor-node-blank", s => s.Visual.Anchors[4].ModelNodeId = " ", "needs a model node ID"); + Add("anchor-node-shared", s => s.Visual.Anchors.Add(new ShipAnchor { Id = "other", Role = "articulation", ModelNodeId = "map" }), "claimed by more than one anchor"); foreach (var role in new[] { "map-icon", "hull-collider", "shield", "tractor" }) { - Add("role-missing-" + role, s => s.Anchors.RemoveAll(a => a.Role == role), $"exactly one {role} anchor is required"); - Add("role-doubled-" + role, s => s.Anchors.Add(new ShipAnchor { Id = "extra", Role = role, ModelNodeId = "extra" }), $"exactly one {role} anchor is required"); + Add("role-missing-" + role, s => s.Visual.Anchors.RemoveAll(a => a.Role == role), $"exactly one {role} anchor is required"); + Add("role-doubled-" + role, s => s.Visual.Anchors.Add(new ShipAnchor { Id = "extra", Role = role, ModelNodeId = "extra" }), $"exactly one {role} anchor is required"); } Add("hardpoint-null", s => s.Hull.Hardpoints.Add(null), "hardpoint IDs must be present and unique"); @@ -206,32 +189,32 @@ public sealed class ShipValidationTests Add("hardpoints-overlap", s => { s.Hull.Hardpoints.Add(new HardpointData { Position = new int2(1, 0), Transform = "thruster.starboard" }); - s.Anchors.Add(new ShipAnchor { Id = "thruster.starboard", Role = "thruster-emitter", ModelNodeId = "thruster-starboard" }); + s.Visual.Anchors.Add(new ShipAnchor { Id = "thruster.starboard", Role = "thruster-emitter", ModelNodeId = "thruster-starboard" }); }, "overlaps another hardpoint"); - Add("hardpoint-without-anchor", s => s.Anchors.RemoveAll(a => a.Id == "thruster.port"), "has no model anchor"); - Add("anchor-parent-unknown", s => s.Anchors.Add(new ShipAnchor { Id = "muzzle", Role = "weapon-muzzle", ModelNodeId = "muzzle", ParentId = "nowhere" }), "unknown hardpoint parent"); + Add("hardpoint-without-anchor", s => s.Visual.Anchors.RemoveAll(a => a.Id == "thruster.port"), "has no model anchor"); + Add("anchor-parent-unknown", s => s.Visual.Anchors.Add(new ShipAnchor { Id = "muzzle", Role = "weapon-muzzle", ModelNodeId = "muzzle", ParentId = "nowhere" }), "unknown hardpoint parent"); - Add("line-null", s => s.SchematicLines.Add(null), "finite XYZ points"); - Add("line-points-null", s => s.SchematicLines[0].Points = null, "finite XYZ points"); - Add("line-one-point", s => s.SchematicLines[0].Points = new[] { 0f, 0f, 0f }, "finite XYZ points"); - Add("line-points-not-triples", s => s.SchematicLines[0].Points = new float[7], "finite XYZ points"); - Add("line-point-nan", s => s.SchematicLines[0].Points[3] = float.NaN, "finite XYZ points"); - Add("line-point-infinite", s => s.SchematicLines[0].Points[5] = float.PositiveInfinity, "finite XYZ points"); - Add("line-radii-too-short", s => s.SchematicLines[0].Radii = new[] { .1f }, "attributes have the wrong length"); - Add("line-radii-too-long", s => s.SchematicLines[0].Radii = new[] { .1f, .1f, .1f }, "attributes have the wrong length"); - Add("line-opacities-too-short", s => s.SchematicLines[0].Opacities = new[] { 1f }, "attributes have the wrong length"); - Add("line-opacities-too-long", s => s.SchematicLines[0].Opacities = new[] { 1f, 1f, 1f }, "attributes have the wrong length"); - Add("line-radius-nan", s => s.SchematicLines[0].Radii[1] = float.NaN, "radius and opacity must be finite"); - Add("line-radius-infinite", s => s.SchematicLines[0].Radii[1] = float.PositiveInfinity, "radius and opacity must be finite"); - Add("line-radius-negative", s => s.SchematicLines[0].Radii[0] = -.01f, "radius and opacity must be finite"); - Add("line-opacity-nan", s => s.SchematicLines[0].Opacities[1] = float.NaN, "radius and opacity must be finite"); - Add("line-opacity-infinite", s => s.SchematicLines[0].Opacities[1] = float.PositiveInfinity, "radius and opacity must be finite"); - Add("line-opacity-negative", s => s.SchematicLines[0].Opacities[0] = -.01f, "radius and opacity must be finite"); - Add("line-opacity-above-one", s => s.SchematicLines[0].Opacities[0] = 1.01f, "radius and opacity must be finite"); - Add("line-color-short", s => s.SchematicLines[0].Color = new[] { 1f, 1f, 1f }, "must be finite RGBA"); - Add("line-color-long", s => s.SchematicLines[0].Color = new[] { 1f, 1f, 1f, 1f, 1f }, "must be finite RGBA"); - Add("line-color-nan", s => s.SchematicLines[0].Color[2] = float.NaN, "must be finite RGBA"); - Add("line-color-infinite", s => s.SchematicLines[0].Color[3] = float.NegativeInfinity, "must be finite RGBA"); + Add("line-null", s => s.Visual.SchematicLines.Add(null), "finite XYZ points"); + Add("line-points-null", s => s.Visual.SchematicLines[0].Points = null, "finite XYZ points"); + Add("line-one-point", s => s.Visual.SchematicLines[0].Points = new[] { 0f, 0f, 0f }, "finite XYZ points"); + Add("line-points-not-triples", s => s.Visual.SchematicLines[0].Points = new float[7], "finite XYZ points"); + Add("line-point-nan", s => s.Visual.SchematicLines[0].Points[3] = float.NaN, "finite XYZ points"); + Add("line-point-infinite", s => s.Visual.SchematicLines[0].Points[5] = float.PositiveInfinity, "finite XYZ points"); + Add("line-radii-too-short", s => s.Visual.SchematicLines[0].Radii = new[] { .1f }, "attributes have the wrong length"); + Add("line-radii-too-long", s => s.Visual.SchematicLines[0].Radii = new[] { .1f, .1f, .1f }, "attributes have the wrong length"); + Add("line-opacities-too-short", s => s.Visual.SchematicLines[0].Opacities = new[] { 1f }, "attributes have the wrong length"); + Add("line-opacities-too-long", s => s.Visual.SchematicLines[0].Opacities = new[] { 1f, 1f, 1f }, "attributes have the wrong length"); + Add("line-radius-nan", s => s.Visual.SchematicLines[0].Radii[1] = float.NaN, "radius and opacity must be finite"); + Add("line-radius-infinite", s => s.Visual.SchematicLines[0].Radii[1] = float.PositiveInfinity, "radius and opacity must be finite"); + Add("line-radius-negative", s => s.Visual.SchematicLines[0].Radii[0] = -.01f, "radius and opacity must be finite"); + Add("line-opacity-nan", s => s.Visual.SchematicLines[0].Opacities[1] = float.NaN, "radius and opacity must be finite"); + Add("line-opacity-infinite", s => s.Visual.SchematicLines[0].Opacities[1] = float.PositiveInfinity, "radius and opacity must be finite"); + Add("line-opacity-negative", s => s.Visual.SchematicLines[0].Opacities[0] = -.01f, "radius and opacity must be finite"); + Add("line-opacity-above-one", s => s.Visual.SchematicLines[0].Opacities[0] = 1.01f, "radius and opacity must be finite"); + Add("line-color-short", s => s.Visual.SchematicLines[0].Color = new[] { 1f, 1f, 1f }, "must be finite RGBA"); + Add("line-color-long", s => s.Visual.SchematicLines[0].Color = new[] { 1f, 1f, 1f, 1f, 1f }, "must be finite RGBA"); + Add("line-color-nan", s => s.Visual.SchematicLines[0].Color[2] = float.NaN, "must be finite RGBA"); + Add("line-color-infinite", s => s.Visual.SchematicLines[0].Color[3] = float.NegativeInfinity, "must be finite RGBA"); return cases; } } diff --git a/tools/AetherDb/ShipAuthoringCommands.cs b/tools/AetherDb/ShipAuthoringCommands.cs index 061f58e41..e6b380ed3 100644 --- a/tools/AetherDb/ShipAuthoringCommands.cs +++ b/tools/AetherDb/ShipAuthoringCommands.cs @@ -1,3 +1,4 @@ +using GameCult.Caching; using System; using System.Collections.Generic; using System.IO; @@ -23,17 +24,14 @@ public static class ShipAuthoringCommands } if (args[0] == "inspect" && args.Length == 2) { - using var cache = ShipAuthoringStore.Open(args[1]); - var records = cache.GetAll().ToArray(); - if (records.Length != 1) throw new InvalidOperationException($"Expected one ship, found {records.Length}."); - var ship = records[0]; - Console.WriteLine($"{ship.Id}: {ship.Hull?.Name}, {ship.Hull?.Hardpoints?.Count ?? 0} hardpoints, {ship.SchematicLines?.Count ?? 0} lines, {ship.SchematicLines?.Sum(line => line.Points?.Length / 3 ?? 0) ?? 0} points"); + var (hull, ship) = ShipAuthoringStore.Load(args[1]); + Console.WriteLine($"{ship.Id}: {hull.Name}, {hull.Hardpoints?.Count ?? 0} hardpoints, {ship.SchematicLines?.Count ?? 0} lines, {ship.SchematicLines?.Sum(line => line.Points?.Length / 3 ?? 0) ?? 0} points"); return 0; } if (args[0] == "validate" && args.Length == 2) { - var ship = ShipAuthoringStore.Read(args[1]); - Console.WriteLine($"{ship.Id}: {ship.Hull.Name}, {ship.Hull.Hardpoints.Count} hardpoints, {ship.SchematicLines.Count} lines"); + var (hull, ship) = ShipAuthoringStore.Read(args[1]); + Console.WriteLine($"{ship.Id}: {hull.Name}, {hull.Hardpoints.Count} hardpoints, {ship.SchematicLines.Count} lines"); return 0; } if (args[0] == "create" && args.Length == 4) @@ -41,16 +39,16 @@ public static class ShipAuthoringCommands var path = Path.GetFullPath(args[1]); if (File.Exists(path)) throw new InvalidOperationException($"Refusing to replace {path}"); Directory.CreateDirectory(Path.GetDirectoryName(path)); - var ship = new ShipAuthoring + var ship = new ShipAuthoring { Id = args[2], ModelAsset = "ship.glb", Anchors = new List() }; + var hull = new HullData { - Id = args[2], - Hull = new HullData { Name = args[3], Shape = new Shape() }, - ModelAsset = "ship.glb", - Anchors = new List() + Name = args[3], + Shape = new Shape(), + Visual = new CultRecordRef(ShipModCatalog.AuthoringKey(ship.Id)) }; using (var cache = ShipAuthoringStore.Open(path, writable: true)) { - cache.Upsert(ship); + ShipAuthoringStore.Write(cache, hull, ship); cache.FlushAsync().Wait(); } Console.WriteLine($"Created draft {path}; fill its model bindings and schematic in Blender."); diff --git a/tools/blender/aetheria_ships/__init__.py b/tools/blender/aetheria_ships/__init__.py index e84c4332d..1c21ded35 100644 --- a/tools/blender/aetheria_ships/__init__.py +++ b/tools/blender/aetheria_ships/__init__.py @@ -219,15 +219,15 @@ class AETHERIA_OT_bind_ship_collection(bpy.types.Operator): else: raise ValueError("Select an object in exactly one ship collection") path = bpy.path.abspath(context.scene.aetheria_ship_cc_path) - _, _, body, _ = read(path, _brokkr_cultlib(context)) + ship_id = read(path, _brokkr_cultlib(context)).ship.body[0] if collection.get("aetheria.asset_kind") not in (None, "ship"): raise ValueError(f"{collection.name} has another aetheria.asset_kind") - if collection.get("aetheria.id") not in (None, body[0]): + if collection.get("aetheria.id") not in (None, ship_id): raise ValueError(f"{collection.name} is bound to another ship ID") collection["aetheria.asset_kind"] = "ship" - collection["aetheria.id"] = body[0] + collection["aetheria.id"] = ship_id collection["aetheria.ship_cc"] = bpy.path.relpath(path) - self.report({"INFO"}, f"Bound {collection.name} to {body[0]}") + self.report({"INFO"}, f"Bound {collection.name} to {ship_id}") return {"FINISHED"} except (OSError, ValueError, RuntimeError, ImportError) as exc: self.report({"ERROR"}, str(exc)) @@ -248,8 +248,7 @@ class AETHERIA_OT_capture_ship_lines(bpy.types.Operator): raise ValueError(f"{collection.name} has no bound ship .cc path") path = bpy.path.abspath(collection["aetheria.ship_cc"]) cultlib = _brokkr_cultlib(context) - _, _, body, _ = read(path, cultlib) - if body[0] != collection["aetheria.id"]: + if read(path, cultlib).ship.body[0] != collection["aetheria.id"]: raise ValueError("The bound collection ID does not match its .cc ship ID") lines = capture_grease_pencil( obj, context.evaluated_depsgraph_get(), context.scene.frame_current, diff --git a/tools/blender/aetheria_ships/ship_cc.py b/tools/blender/aetheria_ships/ship_cc.py index 54064b598..08a5b9e28 100644 --- a/tools/blender/aetheria_ships/ship_cc.py +++ b/tools/blender/aetheria_ships/ship_cc.py @@ -1,8 +1,8 @@ -"""Aetheria's Blender-side view of one typed ShipAuthoring CultCache record. +"""Aetheria's Blender-side view of one ship file: a typed HullData record and the ShipAuthoring visual it names. -The schema and authority live in Aetheria's C# ShipAuthoring type. This module -preserves the C# schema catalog and all slots it does not edit. Brokkr supplies -the Blender host and CultLib Python dependency path. +The schemas and authority live in Aetheria's C# HullData and ShipAuthoring types. This module edits the hull's grid +and hardpoints and the visual's lines, and preserves the C# schema catalog and every slot it does not edit. Brokkr +supplies the Blender host and CultLib Python dependency path. """ from __future__ import annotations @@ -12,15 +12,15 @@ import hashlib from dataclasses import replace from datetime import datetime, timezone from pathlib import Path -from typing import Any +from typing import Any, NamedTuple SCHEMA = "aetheria.ship_authoring" +HULL_SCHEMA = "aetheria.hulldata" # MessagePack slots of the C# types, which own them (ShipAuthoring, ItemData.Shape, HullData.Hardpoints, # HardpointData). ShipSchemaPinTests in tests/Aetheria.Shared.Tests fails when any number or name below # disagrees with those types' [Key] attributes, so a renumbered or added member cannot drift silently. SCHEMATIC_LINES_SLOT = 4 # ShipAuthoring.SchematicLines -HULL_SLOT = 1 # ShipAuthoring.Hull HULL_SHAPE_SLOT = 5 # ItemData.Shape HULL_HARDPOINTS_SLOT = 23 # HullData.Hardpoints # HardpointData member names in slot order; a hardpoint row is one value per name, and any slot past the last @@ -51,40 +51,65 @@ def _libraries(cultlib_packages: str): return cultcache_py, msgpack -def read(path: str, cultlib_packages: str): +class Record(NamedTuple): + envelope: Any + body: list + + +class ShipFile(NamedTuple): + store: Any + ship: Record # the ShipAuthoring visual + hull: Record # the HullData it belongs to + msgpack: Any + + +def read(path: str, cultlib_packages: str) -> ShipFile: cultcache, msgpack = _libraries(cultlib_packages) store = cultcache.SingleFileMessagePackBackingStore(path) envelopes = store.pull_all() ships = [envelope for envelope in envelopes if envelope.type == SCHEMA] - if len(envelopes) != 1 or len(ships) != 1: - raise ValueError(f"{path} must hold exactly one {SCHEMA} record") - envelope = ships[0] - body = msgpack.unpackb(envelope.payload, raw=False) - if not isinstance(body, list) or len(body) < SCHEMATIC_LINES_SLOT + 1: + hulls = [envelope for envelope in envelopes if envelope.type == HULL_SCHEMA] + if len(envelopes) != 2 or len(ships) != 1 or len(hulls) != 1: + raise ValueError(f"{path} must hold exactly one {SCHEMA} record and one {HULL_SCHEMA} record") + bodies = [] + for envelope in (ships[0], hulls[0]): + body = msgpack.unpackb(envelope.payload, raw=False) + if not isinstance(body, list): + raise ValueError(f"{path} has an incompatible {envelope.type} payload") + bodies.append(Record(envelope, body)) + ship, hull = bodies + if len(ship.body) < SCHEMATIC_LINES_SLOT + 1: raise ValueError(f"{path} has an incompatible {SCHEMA} payload") - return store, envelope, body, msgpack + return ShipFile(store, ship, hull, msgpack) + + +def _stamped(record: Record, msgpack: Any): + return replace( + record.envelope, + payload=msgpack.packb(record.body, use_bin_type=True), + stored_at=datetime.now(timezone.utc).isoformat(), + ) def replace_lines(path: str, cultlib_packages: str, lines: list[list[Any]]) -> int: - store, envelope, body, msgpack = read(path, cultlib_packages) + file = read(path, cultlib_packages) if not lines: raise ValueError("No Grease Pencil strokes were captured; the ship record was not changed") - body[SCHEMATIC_LINES_SLOT] = lines - updated = replace( - envelope, - payload=msgpack.packb(body, use_bin_type=True), - stored_at=datetime.now(timezone.utc).isoformat(), - ) - store.push(updated) + file.ship.body[SCHEMATIC_LINES_SLOT] = lines + file.store.push(_stamped(file.ship, file.msgpack)) return len(lines) -def read_layout(path: str, cultlib_packages: str): - _, _, body, msgpack = read(path, cultlib_packages) - hull = body[HULL_SLOT] - if not isinstance(hull, list) or len(hull) <= HULL_HARDPOINTS_SLOT: +def _hull_body(file: ShipFile) -> list: + if len(file.hull.body) <= HULL_HARDPOINTS_SLOT: raise ValueError("Ship hull has an incompatible typed payload") - return hull[HULL_SHAPE_SLOT][0], hull[HULL_HARDPOINTS_SLOT], _layout_revision(hull, msgpack) + return file.hull.body + + +def read_layout(path: str, cultlib_packages: str): + file = read(path, cultlib_packages) + hull = _hull_body(file) + return hull[HULL_SHAPE_SLOT][0], hull[HULL_HARDPOINTS_SLOT], _layout_revision(hull, file.msgpack) def _layout_revision(hull, msgpack): @@ -95,11 +120,11 @@ def _layout_revision(hull, msgpack): def replace_layout(path: str, cultlib_packages: str, expected_id: str, expected_revision: str, shape: list[Any], hardpoints: list[list[Any]]) -> str: - store, envelope, body, msgpack = read(path, cultlib_packages) - if body[0] != expected_id: + file = read(path, cultlib_packages) + if file.ship.body[0] != expected_id: raise ValueError("The bound ship ID changed; reload its layout") - hull = body[HULL_SLOT] - if _layout_revision(hull, msgpack) != expected_revision: + hull = _hull_body(file) + if _layout_revision(hull, file.msgpack) != expected_revision: raise ValueError("The .cc layout changed since Load; reload before saving") width, height, cells = shape if not (1 <= width <= 32 and 1 <= height <= 32 and len(cells) == width * height): @@ -122,12 +147,8 @@ def replace_layout(path: str, cultlib_packages: str, expected_id: str, expected_ updated_hardpoints.append(hardpoint + previous.get(mount, [])) hull[HULL_SHAPE_SLOT] = [shape] hull[HULL_HARDPOINTS_SLOT] = updated_hardpoints - store.push(replace( - envelope, - payload=msgpack.packb(body, use_bin_type=True), - stored_at=datetime.now(timezone.utc).isoformat(), - )) - return _layout_revision(hull, msgpack) + file.store.push(_stamped(file.hull, file.msgpack)) + return _layout_revision(hull, file.msgpack) def capture_grease_pencil(obj: Any, depsgraph: Any, frame_number: int, *, evaluated: bool = True) -> list[list[Any]]: diff --git a/tools/blender/tests/test_ship_cc.py b/tools/blender/tests/test_ship_cc.py index 2e6715822..63cc0ba64 100644 --- a/tools/blender/tests/test_ship_cc.py +++ b/tools/blender/tests/test_ship_cc.py @@ -1,4 +1,4 @@ -"""Blender-free tests for aetheria_ships/ship_cc.py, the Python view of a typed ShipAuthoring record. +"""Blender-free tests for aetheria_ships/ship_cc.py, the Python view of a ship file's HullData and ShipAuthoring records. Run with CultLib's cultcache-py and msgpack importable: @@ -10,6 +10,7 @@ ship_cc's own slot constants; ShipSchemaPinTests (C#) pins those constants to th import importlib.util import os +import shutil import sys import tempfile import unittest @@ -40,7 +41,7 @@ def hardpoint_row(mount="thruster.port", tail=("future-hardpoint-slot",)): return row + list(tail) -def hull_slots(): +def hull_body(): hull = [None] * (ship_cc.HULL_HARDPOINTS_SLOT + 1) hull[1] = "Skiff" hull[ship_cc.HULL_SHAPE_SLOT] = [shape(2, 2, [True, False, True, False])] @@ -55,18 +56,28 @@ LINES = [["Hull", "White", [0.0, 0.0, 0.0, 1.0, 0.0, 0.0], [0.01, 0.01], [1.0, 1 def ship_body(): body = [None] * (ship_cc.SCHEMATIC_LINES_SLOT + 1) body[0] = "mod.skiff" - body[ship_cc.HULL_SLOT] = hull_slots() body[2] = "skiff.glb" body[3] = [["map", "map-icon", "map", None, 0]] body[ship_cc.SCHEMATIC_LINES_SLOT] = LINES return body -CATALOG = cultcache_py.CultCacheSchemaCatalogEntry( - schema_id="ship-authoring-schema-1", schema_name=ship_cc.SCHEMA, schema_version="1", - content_hash="hash", canonical_schema_json='{"members":[{"slot":9,"name":"FutureMember"}]}', - compatible_schema_ids=("ship-authoring-schema-1",), - members=(cultcache_py.CultCacheSchemaCatalogMember(slot=9, member_name="FutureMember", type_name="string"),)) +def catalog(schema_id, schema_name): + return cultcache_py.CultCacheSchemaCatalogEntry( + schema_id=schema_id, schema_name=schema_name, schema_version="1", + content_hash="hash", canonical_schema_json='{"members":[{"slot":9,"name":"FutureMember"}]}', + compatible_schema_ids=(schema_id,), + members=(cultcache_py.CultCacheSchemaCatalogMember(slot=9, member_name="FutureMember", type_name="string"),)) + + +CATALOG = catalog("ship-authoring-schema-1", ship_cc.SCHEMA) +HULL_CATALOG = catalog("hull-schema-1", ship_cc.HULL_SCHEMA) + + +def envelope(key, schema, entry, body): + return cultcache_py.CultCacheEnvelope.create( + key=key, type=schema, payload=msgpack.packb(body, use_bin_type=True), + schema_id=entry.schema_id, catalog_entry=entry) class ShipFileCase(unittest.TestCase): @@ -74,38 +85,54 @@ class ShipFileCase(unittest.TestCase): directory = tempfile.TemporaryDirectory() self.addCleanup(directory.cleanup) self.path = str(Path(directory.name) / "ship.cc") - self.write(ship_body()) + self.write(ship_body(), hull_body()) - def write(self, body, path=None): - cultcache_py.SingleFileMessagePackBackingStore(path or self.path).push(cultcache_py.CultCacheEnvelope.create( - key="mod.skiff", type=ship_cc.SCHEMA, payload=msgpack.packb(body, use_bin_type=True), - schema_id=CATALOG.schema_id, catalog_entry=CATALOG)) + def write(self, ship, hull, path=None): + cultcache_py.SingleFileMessagePackBackingStore(path or self.path).push_all([ + envelope("mod-ship:mod.skiff", ship_cc.SCHEMA, CATALOG, ship), + envelope("mod-hull:mod.skiff", ship_cc.HULL_SCHEMA, HULL_CATALOG, hull)]) - def body(self): - return ship_cc.read(self.path, PACKAGES)[2] + def ship(self): + return ship_cc.read(self.path, PACKAGES).ship.body + + def hull(self): + return ship_cc.read(self.path, PACKAGES).hull.body + + def envelopes(self): + return {(e.type, e.key): e for e in cultcache_py.SingleFileMessagePackBackingStore(self.path).pull_all()} def bytes(self): return Path(self.path).read_bytes() class ReadTests(ShipFileCase): - def test_reads_the_one_ship_record(self): - store, envelope, body, _ = ship_cc.read(self.path, PACKAGES) - self.assertEqual("mod.skiff", body[0]) - self.assertEqual(ship_cc.SCHEMA, envelope.type) + def test_reads_the_ship_and_its_hull(self): + file = ship_cc.read(self.path, PACKAGES) + self.assertEqual("mod.skiff", file.ship.body[0]) + self.assertEqual(ship_cc.SCHEMA, file.ship.envelope.type) + self.assertEqual("Skiff", file.hull.body[1]) + self.assertEqual(ship_cc.HULL_SCHEMA, file.hull.envelope.type) - def test_refuses_a_file_with_another_record_beside_the_ship(self): + def test_refuses_a_file_with_another_record_beside_the_pair(self): cultcache_py.SingleFileMessagePackBackingStore(self.path).push(cultcache_py.CultCacheEnvelope.create( key="other", type="aetheria.something", payload=msgpack.packb([1], use_bin_type=True))) with self.assertRaisesRegex(ValueError, "must hold exactly one"): ship_cc.read(self.path, PACKAGES) - def test_refuses_a_file_whose_only_record_is_not_a_ship(self): - other = str(Path(self.path).with_name("other.cc")) - cultcache_py.SingleFileMessagePackBackingStore(other).push(cultcache_py.CultCacheEnvelope.create( - key="other", type="aetheria.something", payload=msgpack.packb([1], use_bin_type=True))) + def test_refuses_a_file_missing_either_half_of_the_pair(self): + for name, schema, entry, body in (("hull only", ship_cc.HULL_SCHEMA, HULL_CATALOG, hull_body()), + ("ship only", ship_cc.SCHEMA, CATALOG, ship_body())): + other = str(Path(self.path).with_name(name.replace(" ", "-") + ".cc")) + cultcache_py.SingleFileMessagePackBackingStore(other).push(envelope("key", schema, entry, body)) + with self.subTest(name): + with self.assertRaisesRegex(ValueError, "must hold exactly one"): + ship_cc.read(other, PACKAGES) + + def test_refuses_a_file_with_two_hulls(self): + cultcache_py.SingleFileMessagePackBackingStore(self.path).push( + envelope("mod-hull:mod.other", ship_cc.HULL_SCHEMA, HULL_CATALOG, hull_body())) with self.assertRaisesRegex(ValueError, "must hold exactly one"): - ship_cc.read(other, PACKAGES) + ship_cc.read(self.path, PACKAGES) def test_refuses_a_file_with_no_record(self): empty = str(Path(self.path).with_name("empty.cc")) @@ -113,15 +140,19 @@ class ReadTests(ShipFileCase): with self.assertRaisesRegex(ValueError, "must hold exactly one"): ship_cc.read(empty, PACKAGES) - def test_refuses_a_payload_too_short_for_the_line_slot(self): - self.write(["mod.skiff", None]) + def test_refuses_a_ship_payload_too_short_for_the_line_slot(self): + self.write(["mod.skiff", None], hull_body()) with self.assertRaisesRegex(ValueError, "incompatible"): ship_cc.read(self.path, PACKAGES) def test_refuses_a_payload_that_is_not_an_array(self): for payload in ({"Id": "mod.skiff"}, {str(index): index for index in range(9)}, "a string long enough to fill every slot"): - with self.subTest(payload=payload): - self.write(payload) + with self.subTest(ship=payload): + self.write(payload, hull_body()) + with self.assertRaisesRegex(ValueError, "incompatible"): + ship_cc.read(self.path, PACKAGES) + with self.subTest(hull=payload): + self.write(ship_body(), payload) with self.assertRaisesRegex(ValueError, "incompatible"): ship_cc.read(self.path, PACKAGES) @@ -129,17 +160,22 @@ class ReadTests(ShipFileCase): class ReplaceLinesTests(ShipFileCase): def test_replaces_only_the_line_slot(self): new_lines = [["Detail", "Red", [0.0] * 6, [0.1, 0.1], [1.0, 1.0], True, [1.0, 0.0, 0.0, 1.0]]] - before = self.body() + before = self.ship() self.assertEqual(1, ship_cc.replace_lines(self.path, PACKAGES, new_lines)) - after = self.body() + after = self.ship() self.assertEqual(new_lines, after[ship_cc.SCHEMATIC_LINES_SLOT]) for slot in range(len(before)): if slot != ship_cc.SCHEMATIC_LINES_SLOT: self.assertEqual(before[slot], after[slot], f"slot {slot} changed") + def test_leaves_the_hull_record_byte_identical(self): + before = self.envelopes()[(ship_cc.HULL_SCHEMA, "mod-hull:mod.skiff")] + ship_cc.replace_lines(self.path, PACKAGES, LINES) + self.assertEqual(before, self.envelopes()[(ship_cc.HULL_SCHEMA, "mod-hull:mod.skiff")]) + def test_keeps_the_schema_catalog(self): ship_cc.replace_lines(self.path, PACKAGES, LINES) - envelope = cultcache_py.SingleFileMessagePackBackingStore(self.path).pull_all()[0] + envelope = self.envelopes()[(ship_cc.SCHEMA, "mod-ship:mod.skiff")] self.assertEqual(CATALOG.members, envelope.catalog_entry.members) self.assertEqual(CATALOG.canonical_schema_json, envelope.catalog_entry.canonical_schema_json) @@ -168,13 +204,14 @@ class LayoutTests(ShipFileCase): def test_the_revision_follows_the_layout_and_only_the_layout(self): _, _, revision = self.layout() - body = self.body() - body[ship_cc.SCHEMATIC_LINES_SLOT] = [] - body[ship_cc.HULL_SLOT][1] = "Renamed" - self.write(body) + ship = self.ship() + ship[ship_cc.SCHEMATIC_LINES_SLOT] = [] + hull = self.hull() + hull[1] = "Renamed" + self.write(ship, hull) self.assertEqual(revision, self.layout()[2]) - body[ship_cc.HULL_SLOT][ship_cc.HULL_SHAPE_SLOT] = [shape(2, 2, [True, True, True, True])] - self.write(body) + hull[ship_cc.HULL_SHAPE_SLOT] = [shape(2, 2, [True, True, True, True])] + self.write(ship, hull) self.assertNotEqual(revision, self.layout()[2]) def test_the_revision_follows_the_hardpoints_too(self): @@ -198,15 +235,16 @@ class LayoutTests(ShipFileCase): def test_preserves_every_slot_it_does_not_edit(self): _, _, revision = self.layout() - before = self.body() + ship_before, hull_before = self.ship(), self.hull() + ship_envelope = self.envelopes()[(ship_cc.SCHEMA, "mod-ship:mod.skiff")] self.edit(revision) - after = self.body() - for slot in range(len(before)): - if slot != ship_cc.HULL_SLOT: - self.assertEqual(before[slot], after[slot], f"ship slot {slot} changed") - for slot in range(len(before[ship_cc.HULL_SLOT])): + self.assertEqual(ship_before, self.ship()) + self.assertEqual(ship_envelope, self.envelopes()[(ship_cc.SCHEMA, "mod-ship:mod.skiff")]) + hull_after = self.hull() + self.assertEqual(len(hull_before), len(hull_after)) + for slot in range(len(hull_before)): if slot not in (ship_cc.HULL_SHAPE_SLOT, ship_cc.HULL_HARDPOINTS_SLOT): - self.assertEqual(before[ship_cc.HULL_SLOT][slot], after[ship_cc.HULL_SLOT][slot], f"hull slot {slot} changed") + self.assertEqual(hull_before[slot], hull_after[slot], f"hull slot {slot} changed") def test_carries_a_later_schemas_hardpoint_slots_by_mount_id(self): _, _, revision = self.layout() @@ -225,8 +263,8 @@ class LayoutTests(ShipFileCase): def test_keeps_the_schema_catalog(self): _, _, revision = self.layout() self.edit(revision) - envelope = cultcache_py.SingleFileMessagePackBackingStore(self.path).pull_all()[0] - self.assertEqual(CATALOG.members, envelope.catalog_entry.members) + envelope = self.envelopes()[(ship_cc.HULL_SCHEMA, "mod-hull:mod.skiff")] + self.assertEqual(HULL_CATALOG.members, envelope.catalog_entry.members) def test_refuses_a_ship_id_that_changed_and_writes_nothing(self): _, _, revision = self.layout() @@ -293,14 +331,55 @@ class LayoutTests(ShipFileCase): self.edit(revision, hardpoints=[row]) self.assertEqual(before, self.bytes()) - def test_refuses_a_payload_without_the_hull_hardpoint_slot(self): - body = self.body() - body[ship_cc.HULL_SLOT] = body[ship_cc.HULL_SLOT][:ship_cc.HULL_HARDPOINTS_SLOT] - self.write(body) + def test_refuses_a_hull_payload_without_the_hardpoint_slot(self): + hull = self.hull()[:ship_cc.HULL_HARDPOINTS_SLOT] + self.write(self.ship(), hull) with self.assertRaisesRegex(ValueError, "incompatible typed payload"): self.layout() +@unittest.skipUnless(os.environ.get("SHIP_FIXTURE_DIR"), "SHIP_FIXTURE_DIR names the C#-written fixture (see below)") +class CSharpWrittenFixtureTests(unittest.TestCase): + """Gate 1's Python half: a package C# wrote survives a Python edit with every untouched slot intact. + + The fixture comes from the C# suite (`AETHERIA_SHIP_FIXTURE_DIR= dotnet test ... --filter + TheFixturePackageIsCompleteAndItsGlbCarriesAScene`). Point SHIP_FIXTURE_DIR at that and, optionally, + SHIP_EDITED_DIR at an empty directory to keep the edited package; C# then reopens and validates it with + `dotnet run --project tools/AetherDb -- ship-authoring validate /mod.skiff/ship.cc`. + """ + + def test_a_layout_and_line_edit_preserves_every_slot_it_does_not_own(self): + with tempfile.TemporaryDirectory() as scratch: + path = str(Path(scratch) / "ship.cc") + shutil.copy(Path(os.environ["SHIP_FIXTURE_DIR"]) / "mod.skiff" / "ship.cc", path) + before = ship_cc.read(path, PACKAGES) + shape_triple, hardpoints, revision = ship_cc.read_layout(path, PACKAGES) + width, height, cells = shape_triple + self.assertEqual(64, len(revision)) + cells = list(cells) + cells[-1] = True + ship_cc.replace_layout(path, PACKAGES, before.ship.body[0], revision, [width, height, cells], hardpoints) + ship_cc.replace_lines(path, PACKAGES, before.ship.body[ship_cc.SCHEMATIC_LINES_SLOT]) + after = ship_cc.read(path, PACKAGES) + + self.assertEqual(before.ship.body, after.ship.body) + self.assertEqual(before.ship.envelope.key, after.ship.envelope.key) + self.assertEqual(before.ship.envelope.catalog_entry, after.ship.envelope.catalog_entry) + self.assertEqual(before.hull.envelope.key, after.hull.envelope.key) + self.assertEqual(before.hull.envelope.catalog_entry, after.hull.envelope.catalog_entry) + for slot in range(len(before.hull.body)): + if slot != ship_cc.HULL_SHAPE_SLOT: + self.assertEqual(before.hull.body[slot], after.hull.body[slot], f"hull slot {slot} changed") + self.assertTrue(after.hull.body[ship_cc.HULL_SHAPE_SLOT][0][2][-1]) + + kept = os.environ.get("SHIP_EDITED_DIR") + if kept: + target = Path(kept) / "mod.skiff" + target.mkdir(parents=True, exist_ok=True) + shutil.copy(path, target / "ship.cc") + shutil.copy(Path(os.environ["SHIP_FIXTURE_DIR"]) / "mod.skiff" / "skiff.glb", target / "skiff.glb") + + class HardpointRowTests(unittest.TestCase): def test_a_row_is_one_value_per_member_in_slot_order(self): fields = dict(Type=1, Position=[2, 3], Shape="shape", Transform="mount", Rotation=2, Armor=4.0, FiringArc=90.0)