Ship S1: one hull record, and a hull names its visual

A mod ship file now holds a HullData at mod-hull:<id> and the ShipAuthoring
visual package at mod-ship:<id>. 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 <noreply@anthropic.com>
This commit is contained in:
Meta Vens 2026-09-30 03:06:43 +02:00
commit 1702e0549f
13 changed files with 622 additions and 319 deletions

View file

@ -33,7 +33,7 @@ public static class ShipModVisual
public static async Task<Instance> 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);

View file

@ -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<ShipAuthoring> Visual;
[IgnoreMember]
public Shape InteriorCells
{

View file

@ -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<ShipAnchor> Anchors = new List<ShipAnchor>();
[Key(4)] public List<ShipPolyline> SchematicLines = new List<ShipPolyline>();
@ -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<ShipAuthoring>().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<HullData>().ToArray();
var visuals = cache.GetAll<ShipAuthoring>().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<bool>().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.");

View file

@ -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<string, uint> 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<ShipAuthoring>();
var packages = new List<Package>();
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<ItemData>().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<ItemData>().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<ShipAuthoring>().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.

View file

@ -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<InvalidOperationException>(() =>
ShipAuthoringStore.Validate(ship)).Message);
Assert.Contains("outside the hull schematic", Assert.Throws<InvalidOperationException>(ship.Validate).Message);
ship = Fixture();
ship.Anchors.RemoveAt(ship.Anchors.Count - 1);
Assert.Contains("has no model anchor", Assert.Throws<InvalidOperationException>(() =>
ShipAuthoringStore.Validate(ship)).Message);
ship.Visual.Anchors.RemoveAt(ship.Visual.Anchors.Count - 1);
Assert.Contains("has no model anchor", Assert.Throws<InvalidOperationException>(ship.Validate).Message);
ship = Fixture();
ship.SchematicLines[0].Points[0] = float.NaN;
Assert.Contains("finite XYZ", Assert.Throws<InvalidOperationException>(() =>
ShipAuthoringStore.Validate(ship)).Message);
ship = Fixture();
ShipAuthoringStore.Validate(ship);
ship.Hull.Shape.Cells[1, 0] = false;
Assert.Contains("outside the hull schematic", Assert.Throws<InvalidOperationException>(() =>
ShipAuthoringStore.Validate(ship)).Message);
ship.Visual.SchematicLines[0].Points[0] = float.NaN;
Assert.Contains("finite XYZ", Assert.Throws<InvalidOperationException>(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<InvalidOperationException>(() =>
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<InvalidOperationException>(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<InvalidOperationException>(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<InvalidOperationException>(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<InvalidOperationException>(ship.Validate).Message);
ship = Fixture();
ship.Hull.Visual = default;
Assert.Contains("the hull must name its visual record mod-ship:mod.skiff", Assert.Throws<InvalidOperationException>(ship.Validate).Message);
ship = Fixture();
ship.Hull.Visual = new CultRecordRef<ShipAuthoring>(ShipModCatalog.AuthoringKey("mod.other"));
Assert.Contains("the hull must name its visual record mod-ship:mod.skiff", Assert.Throws<InvalidOperationException>(ship.Validate).Message);
Fixture().Validate();
}
[Fact]
public async Task LoadRefusesAFileWhoseRecordsAreNotOneHullAndOneVisualAtTheirKeys()
{
using var directory = new TempDirectory();
async Task<string> Write(string name, Action<CultCache> 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<InvalidOperationException>(() => 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<InvalidOperationException>(() => 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<InvalidOperationException>(() => 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<InvalidOperationException>(() => 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<InvalidOperationException>(() => 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<ShipAuthoring>(ShipModCatalog.AuthoringKey("mod.skiff")),
Hardpoints = new List<HardpointData>
{
new HardpointData
@ -94,26 +161,49 @@ public sealed class ShipAuthoringTests
}
}
},
Anchors = new List<ShipAnchor>
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<ShipPolyline>
{
new ShipPolyline
Id = "mod.skiff",
ModelAsset = "skiff.glb",
Anchors = new List<ShipAnchor>
{
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<ShipPolyline>
{
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<ShipAuthoring>(ShipModCatalog.AuthoringKey(id));
return this;
}
public void Validate() => ShipAuthoringStore.Validate(Hull, Visual);
}

View file

@ -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;
}
// <modsRoot>/<id>/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<ShipParts> 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;
}
}

View file

@ -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<HullData>().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<ShipAuthoring>().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<HullData>(ShipModCatalog.HullKey("mod.skiff"));
var visual = cache.Get<ShipAuthoring>(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<string, uint> { ["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<string, uint> { ["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<InvalidOperationException>(() =>
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<InvalidOperationException>(() => 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<ShipAuthoring> tweak = null)
Action<ShipParts> 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)));

View file

@ -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<CultDocumentAttribute>().SchemaName, Text("SCHEMA"));
Assert.Equal(typeof(HullData).GetCustomAttribute<CultDocumentAttribute>().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<KeyAttribute>()?.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<object[]>(MessagePackSerializer.Deserialize<object>(bytes, MessagePackSerializerOptions.Standard));
var (hull, visual) = ShipAuthoringTests.Fixture();
var options = CultDocumentMessagePackSerialization.OptionsFor(typeof(ShipAuthoring).Assembly);
object[] Body(object document) => Assert.IsType<object[]>(MessagePackSerializer.Deserialize<object>(
MessagePackSerializer.Serialize(document.GetType(), document, options), MessagePackSerializerOptions.Standard));
var body = Body(visual);
Assert.Equal("mod.skiff", body[0]);
var hull = Assert.IsType<object[]>(body[Integer("HULL_SLOT")]);
var lines = Assert.IsType<object[]>(body[Integer("SCHEMATIC_LINES_SLOT")]);
Assert.Equal("Hull", Assert.IsType<object[]>(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<object[]>(Assert.IsType<object[]>(hull[Integer("HULL_SHAPE_SLOT")])[0]);
var hullShape = Assert.IsType<object[]>(Assert.IsType<object[]>(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<object[]>(hullShape[2]).Cast<bool>());
var hardpoints = Assert.IsType<object[]>(hull[Integer("HULL_HARDPOINTS_SLOT")]);
var hardpoints = Assert.IsType<object[]>(hullBody[Integer("HULL_HARDPOINTS_SLOT")]);
var row = Assert.IsType<object[]>(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<object[]>(footprint[2]).Cast<bool>());
Assert.Equal(new[] { 1, 0 }, Assert.IsType<object[]>(row[Array.IndexOf(members, "Position")]).Select(value => Convert.ToInt32(value)));
var lines = Assert.IsType<object[]>(body[Integer("SCHEMATIC_LINES_SLOT")]);
Assert.Equal("Hull", Assert.IsType<object[]>(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);

View file

@ -11,25 +11,25 @@ using Xunit;
// reason) fails its own case and no other.
public sealed class ShipValidationTests
{
private static readonly Dictionary<string, (Action<ShipAuthoring> Break, string Message)> Cases = Build();
private static readonly Dictionary<string, (Action<ShipParts> Break, string Message)> Cases = Build();
public static IEnumerable<object[]> 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<InvalidOperationException>(() => ShipAuthoringStore.Validate(ship));
var error = Assert.Throws<InvalidOperationException>(ship.Validate);
Assert.Contains(Cases[name].Message, error.Message);
}
[Fact]
public void ValidatorRefusesANullRecord() =>
Assert.Contains("is null", Assert.Throws<InvalidOperationException>(() => ShipAuthoringStore.Validate(null)).Message);
Assert.Contains("is null", Assert.Throws<InvalidOperationException>(() => 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<InvalidOperationException>(() => ShipAuthoringStore.Validate(ship)).Message);
ship.WithId(id);
Assert.Contains("ship ID must use", Assert.Throws<InvalidOperationException>(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<InvalidOperationException>(() => ShipAuthoringStore.Validate(ship)).Message);
ship.Visual.ModelAsset = asset;
Assert.Contains("relative package path", Assert.Throws<InvalidOperationException>(ship.Validate).Message);
}
[Fact]
public void RootedModelAssetsAreRefused()
{
var ship = ShipAuthoringTests.Fixture();
ship.ModelAsset = Path.GetFullPath("skiff.glb");
Assert.Contains("relative package path", Assert.Throws<InvalidOperationException>(() => ShipAuthoringStore.Validate(ship)).Message);
ship.Visual.ModelAsset = Path.GetFullPath("skiff.glb");
Assert.Contains("relative package path", Assert.Throws<InvalidOperationException>(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<InvalidOperationException>(() => 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<InvalidOperationException>(() => ShipAuthoringStore.Read(path)).Message);
}
private static Dictionary<string, (Action<ShipAuthoring>, string)> Build()
private static Dictionary<string, (Action<ShipParts>, string)> Build()
{
var cases = new Dictionary<string, (Action<ShipAuthoring>, string)>();
void Add(string name, Action<ShipAuthoring> breakIt, string message) => cases.Add(name, (breakIt, message));
HardpointData Mount(ShipAuthoring ship) => ship.Hull.Hardpoints[0];
var cases = new Dictionary<string, (Action<ShipParts>, string)>();
void Add(string name, Action<ShipParts> 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;
}
}

View file

@ -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<ShipAuthoring>().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<ShipAnchor>() };
var hull = new HullData
{
Id = args[2],
Hull = new HullData { Name = args[3], Shape = new Shape() },
ModelAsset = "ship.glb",
Anchors = new List<ShipAnchor>()
Name = args[3],
Shape = new Shape(),
Visual = new CultRecordRef<ShipAuthoring>(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.");

View file

@ -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,

View file

@ -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]]:

View file

@ -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=<dir> dotnet test ... --filter
TheFixturePackageIsCompleteAndItsGlbCarriesAScene`). Point SHIP_FIXTURE_DIR at that <dir> 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 <SHIP_EDITED_DIR>/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)