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:
parent
de1b5b8fa2
commit
1702e0549f
13 changed files with 622 additions and 319 deletions
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -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
|
||||
{
|
||||
|
|
|
|||
|
|
@ -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.");
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
}
|
||||
|
|
|
|||
54
tests/Aetheria.Shared.Tests/ShipFixture.cs
Normal file
54
tests/Aetheria.Shared.Tests/ShipFixture.cs
Normal 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;
|
||||
}
|
||||
}
|
||||
|
|
@ -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)));
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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.");
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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]]:
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
Loading…
Reference in a new issue