Aetheria/tests/Aetheria.Shared.Tests/ShipAuthoringTests.cs
soul da5ba653cb Tests (red at 42dce361): a CultLib 1.0.60 run.cc opens; Load names a ship file of a newer store format
tests/vectors/cultlib-1.0.60/run.cc is the run store Soul wrote with the base build (cl2-old-run-save-refused).
Both new tests fail at the 2.0.0 pin; the first needs 2.0.1, the second needs ShipAuthoringStore.Load.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-22 23:35:11 +02:00

508 lines
25 KiB
C#

using System;
using System.Collections.Generic;
using System.IO;
using System.Linq;
using System.Threading.Tasks;
using GameCult.Caching.MessagePack;
using MessagePack;
using CultMath;
using GameCult.Caching;
using Xunit;
public sealed class ShipAuthoringTests
{
[Fact]
public async Task StandaloneShipRoundTripsWithoutTouchingTheCatalog()
{
var root = Path.Combine(Path.GetTempPath(), "aetheria-ship-" + Guid.NewGuid().ToString("N"));
Directory.CreateDirectory(root);
var path = Path.Combine(root, "skiff.cc");
try
{
var ship = Fixture();
ship.Validate();
using (var cache = ShipAuthoringStore.Open(path, writable: true))
{
ShipAuthoringStore.Write(cache, ship.Hull, ship.Visual);
await cache.FlushAsync();
}
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); }
}
[Fact]
public void InvalidSchematicOrDanglingMountCannotBePublished()
{
var ship = Fixture();
ship.Hull.Shape.Cells[1, 0] = false;
Assert.Contains("outside the hull schematic", Assert.Throws<InvalidOperationException>(ship.Validate).Message);
ship = Fixture();
ship.Visual.Anchors.RemoveAt(ship.Visual.Anchors.Count - 1);
Assert.Contains("has no model anchor", Assert.Throws<InvalidOperationException>(ship.Validate).Message);
ship = Fixture();
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
{
Type = HardpointType.Thruster,
Position = new int2(1, 0),
Shape = new Shape(),
Transform = "thruster.starboard"
});
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);
}
// 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("names both a Unity prefab and a visual record", Assert.Throws<InvalidOperationException>(ship.Validate).Message);
// A whitespace-only prefab is unset.
ship = Fixture();
ship.Hull.Prefab = " \t";
ship.Validate();
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);
// The key is compared exactly: a differently-cased key is another record.
ship = Fixture();
ship.Hull.Visual = new CultRecordRef<ShipAuthoring>(new CultRecordKey("MOD-SHIP:mod.skiff"));
Assert.Contains("the hull must name its visual record mod-ship:mod.skiff", Assert.Throws<InvalidOperationException>(ship.Validate).Message);
Fixture().Validate();
}
// A hull with both a prefab and a visual is refused wherever hulls are validated or composed.
// ShipModCatalogTests.ComposeRefusesAShippedHullThatNamesTwoBodies covers Compose.
[Fact]
public void RequireOneBodyRefusesBothAndAcceptsEitherAlone()
{
var hull = Fixture().Hull;
ShipAuthoringStore.RequireOneBody(hull, "skiff");
hull.Prefab = "Djinni";
Assert.Contains("skiff: a hull names one body", Assert.Throws<InvalidOperationException>(() => ShipAuthoringStore.RequireOneBody(hull, "skiff")).Message);
hull.Visual = default;
ShipAuthoringStore.RequireOneBody(hull, "skiff");
}
// The ship authoring record held the whole hull at key 1 before S1.
[Fact]
public async Task LoadRefusesAShipAuthoringRecordThatStillCarriesTheRetiredEmbeddedHull()
{
using var directory = new TempDirectory();
var path = Path.Combine(directory.Path, "legacy.cc");
using (var cache = ShipAuthoringStore.Open(path, writable: true))
{
ShipAuthoringStore.Write(cache, Fixture().Hull, Fixture().Visual);
await cache.FlushAsync();
}
RewritePayload(path, "aetheria.ship_authoring", slots => { slots[1] = new object[] { "Skiff" }; });
foreach (var load in new Action[] { () => ShipAuthoringStore.Load(path), () => ShipAuthoringStore.Read(path) })
{
var message = Assert.Throws<InvalidOperationException>(load).Message;
Assert.Contains("legacy embedded hull at retired key 1", message);
Assert.Contains(path, message);
}
// An old file has no separate hull record at all; it is named for what it is, not for a missing hull.
var oldShape = Path.Combine(directory.Path, "old.cc");
using (var cache = ShipAuthoringStore.Open(oldShape, writable: true))
{
cache.UpsertAsync(typeof(ShipAuthoring), Fixture().Visual, ShipModCatalog.AuthoringKey("mod.skiff")).GetAwaiter().GetResult();
await cache.FlushAsync();
}
RewritePayload(oldShape, "aetheria.ship_authoring", slots => { slots[1] = new object[] { "Skiff" }; });
var oldMessage = Assert.Throws<InvalidOperationException>(() => ShipAuthoringStore.Load(oldShape)).Message;
Assert.Contains("legacy embedded hull", oldMessage);
Assert.DoesNotContain("exactly one hull record", oldMessage);
// A nil in the retired slot is the shape every current file has.
RewritePayload(path, "aetheria.ship_authoring", slots => { slots[1] = null; });
ShipAuthoringStore.Load(path);
}
// The layout editor's Python once accepted any msgpack value in a cell; C# then failed with a bare serializer exception.
[Fact]
public async Task LoadNamesTheFileWhoseRecordDoesNotDecode()
{
using var directory = new TempDirectory();
var path = Path.Combine(directory.Path, "cells.cc");
using (var cache = ShipAuthoringStore.Open(path, writable: true))
{
ShipAuthoringStore.Write(cache, Fixture().Hull, Fixture().Visual);
await cache.FlushAsync();
}
// ItemData.Shape is key 5: [[width, height, cells]].
RewritePayload(path, "aetheria.hulldata", slots => ((object[])((object[])slots[5])[0])[2] = new object[] { 5, false, true, false });
var error = Assert.Throws<InvalidOperationException>(() => ShipAuthoringStore.Load(path));
Assert.Contains(path, error.Message);
// The pull names the record key and schema; the MessagePack failure is its cause.
var pull = Assert.IsType<InvalidDataException>(error.InnerException);
Assert.Contains("Record '" + ShipModCatalog.HullKey("mod.skiff") + "'", pull.Message);
Assert.Contains("schema", pull.Message);
Assert.IsAssignableFrom<MessagePackSerializationException>(pull.InnerException);
}
// A store header with a nil format stamp is refused by the runtime with NotSupportedException; Load names the file.
[Fact]
public void LoadNamesTheFileWhoseFormatStampIsNil()
{
using var directory = new TempDirectory();
var path = Path.Combine(directory.Path, "nilstamp.cc");
File.WriteAllBytes(path, CultDocumentMessagePackSerialization.SerializeSnapshot(new CultPersistedStoreSnapshot
{
FormatVersion = null,
SchemaCatalog = Array.Empty<CultSchemaCatalogEntry>(),
Records = Array.Empty<CultPersistedRecord>(),
}));
var error = Assert.Throws<InvalidOperationException>(() => ShipAuthoringStore.Load(path));
Assert.Contains(path, error.Message);
Assert.IsType<NotSupportedException>(error.InnerException);
}
// A store format this runtime does not know is refused on read with NotSupportedException from the attach; Load names the file.
[Fact]
public async Task LoadNamesTheFileWhoseStoreFormatIsNewer()
{
using var directory = new TempDirectory();
var path = Path.Combine(directory.Path, "newer.cc");
using (var cache = ShipAuthoringStore.Open(path, writable: true))
{
ShipAuthoringStore.Write(cache, Fixture().Hull, Fixture().Visual);
await cache.FlushAsync();
}
var snapshot = CultDocumentMessagePackSerialization.DeserializeSnapshot(File.ReadAllBytes(path));
snapshot.FormatVersion = "cultcache.store.v9";
File.WriteAllBytes(path, CultDocumentMessagePackSerialization.SerializeSnapshot(snapshot));
var error = Assert.Throws<InvalidOperationException>(() => ShipAuthoringStore.Load(path));
Assert.Contains(path, error.Message);
Assert.IsType<NotSupportedException>(error.InnerException);
}
// Decodes one record's payload of the named schema to its slot array, lets the edit change it, and stores it again.
private static void RewritePayload(string path, string schemaName, Action<object[]> edit)
{
var snapshot = CultDocumentMessagePackSerialization.DeserializeSnapshot(File.ReadAllBytes(path));
var schemaId = snapshot.SchemaCatalog.Single(entry => entry.SchemaName == schemaName).SchemaId;
var record = snapshot.Records.Single(candidate => candidate.SchemaId == schemaId);
var slots = MessagePackSerializer.Deserialize<object[]>(record.Payload);
edit(slots);
record.Payload = MessagePackSerializer.Serialize(slots);
File.WriteAllBytes(path, CultDocumentMessagePackSerialization.SerializeSnapshot(snapshot));
}
[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);
}
private static readonly string[] AllRoles =
{
"map-icon", "hull-collider", "shield", "tractor", "thruster-emitter", "weapon-mount", "weapon-muzzle", "radiator-mesh"
};
// The fixture with its thruster swapped for one hardpoint of the given type at mount id "mount", and no anchor for it.
private static ShipParts WithOnly(HardpointType type)
{
var ship = Fixture();
ship.Hull.Hardpoints.Clear();
ship.Visual.Anchors.RemoveAll(anchor => anchor.Id == "thruster.port");
ship.Hull.Hardpoints.Add(new HardpointData { Type = type, Position = new int2(0, 0), Shape = new Shape(), Transform = "mount" });
return ship;
}
private static string Refusal(ShipParts ship) => Assert.Throws<InvalidOperationException>(ship.Validate).Message;
// Mount anchors exist only for hardpoints that show on the model: weapons, radiators and thrusters.
[Theory]
[InlineData(HardpointType.Hull), InlineData(HardpointType.Tool), InlineData(HardpointType.Thermal), InlineData(HardpointType.WarpDrive),
InlineData(HardpointType.Reactor), InlineData(HardpointType.Shield), InlineData(HardpointType.Sensors),
InlineData(HardpointType.ControlModule)]
public void AnInternalHardpointCarriesNoAnchor(HardpointType type)
{
WithOnly(type).Validate();
// Any anchor taking the mount's id is refused, a mount role or not. (The four structural roles are one-of-a-kind,
// so they are covered by renaming the existing anchor.)
foreach (var role in AllRoles)
{
var ship = WithOnly(type);
var existing = ship.Visual.Anchors.FirstOrDefault(anchor => anchor.Role == role);
if (existing != null) existing.Id = "mount";
else ship.Visual.Anchors.Add(new ShipAnchor { Id = "mount", Role = role, ModelNodeId = "mount" });
Assert.Contains($"mod.skiff: {type} hardpoint mount is internal, so no anchor may take its id", Refusal(ship));
}
}
[Theory]
[InlineData(HardpointType.Thruster, "thruster-emitter")]
[InlineData(HardpointType.Radiator, "radiator-mesh")]
[InlineData(HardpointType.Energy, "weapon-mount")]
[InlineData(HardpointType.Ballistic, "weapon-mount")]
[InlineData(HardpointType.Launcher, "weapon-mount")]
public void EachVisibleHardpointNeedsItsRoleAnchor(HardpointType type, string wanted)
{
ShipParts Rig()
{
var ship = WithOnly(type);
if (ShipAuthoringStore.IsWeapon(type))
ship.Visual.Anchors.Add(new ShipAnchor { Id = "mount.muzzle", Role = "weapon-muzzle", ModelNodeId = "muzzle", ParentId = "mount" });
return ship;
}
Assert.Contains($"mod.skiff: {type} hardpoint mount has no model anchor", Refusal(Rig()));
// A structural anchor (the map icon, the collider...) whose id is the mount, or a mount anchor of the wrong role.
foreach (var role in AllRoles.Where(role => role != wanted))
{
var ship = Rig();
var existing = ship.Visual.Anchors.FirstOrDefault(anchor => anchor.Role == role);
if (existing != null) existing.Id = "mount";
else ship.Visual.Anchors.Add(new ShipAnchor { Id = "mount", Role = role, ModelNodeId = "mount" });
Assert.Contains($"mod.skiff: {type} hardpoint mount needs a {wanted} anchor of that id", Refusal(ship));
}
var valid = Rig();
valid.Visual.Anchors.Add(new ShipAnchor { Id = "mount", Role = wanted, ModelNodeId = "mount" });
valid.Validate();
}
// Called on Validate itself: a mount-role anchor is the mount of a hardpoint of its own type, never a loose node and
// never another type's mount.
[Theory]
[InlineData("thruster-emitter", HardpointType.Radiator)]
[InlineData("thruster-emitter", HardpointType.Energy)]
[InlineData("thruster-emitter", HardpointType.Reactor)]
[InlineData("radiator-mesh", HardpointType.Thruster)]
[InlineData("radiator-mesh", HardpointType.Launcher)]
[InlineData("radiator-mesh", HardpointType.Sensors)]
[InlineData("weapon-mount", HardpointType.Thruster)]
[InlineData("weapon-mount", HardpointType.Radiator)]
[InlineData("weapon-mount", HardpointType.Tool)]
public void AMountRoleAnchorMustBeAMountOfItsType(string role, HardpointType otherType)
{
string Refused(ShipParts ship) =>
Assert.Throws<InvalidOperationException>(() => ShipAuthoringStore.Validate(ship.Hull, ship.Visual)).Message;
var loose = Fixture();
loose.Visual.Anchors.Add(new ShipAnchor { Id = "loose", Role = role, ModelNodeId = "loose" });
Assert.Contains($"mod.skiff: {role} anchor loose must be the mount of a hardpoint of its type", Refused(loose));
// The anchor takes the id of a hardpoint of another type; whichever side the validator reads first, it refuses.
var other = WithOnly(otherType);
other.Visual.Anchors.Add(new ShipAnchor { Id = "mount", Role = role, ModelNodeId = "mount" });
if (ShipAuthoringStore.IsWeapon(otherType))
other.Visual.Anchors.Add(new ShipAnchor { Id = "mount.muzzle", Role = "weapon-muzzle", ModelNodeId = "muzzle", ParentId = "mount" });
Assert.Contains($"mod.skiff: {otherType} hardpoint mount ", Refused(other));
}
[Fact]
public void MuzzleRulesBelongToTheValidator()
{
ShipParts Armed()
{
var ship = Fixture();
ship.Hull.Hardpoints.Add(new HardpointData { Type = HardpointType.Energy, Position = new int2(0, 0), Shape = new Shape(), Transform = "gun" });
ship.Visual.Anchors.Add(new ShipAnchor { Id = "gun", Role = "weapon-mount", ModelNodeId = "gun" });
ship.Visual.Anchors.Add(new ShipAnchor { Id = "gun.b", Role = "weapon-muzzle", ModelNodeId = "gun-b", ParentId = "gun", Order = 0 });
ship.Visual.Anchors.Add(new ShipAnchor { Id = "gun.a", Role = "weapon-muzzle", ModelNodeId = "gun-a", ParentId = "gun", Order = 0 });
ship.Visual.Anchors.Add(new ShipAnchor { Id = "gun.z", Role = "weapon-muzzle", ModelNodeId = "gun-z", ParentId = "gun", Order = -1 });
return ship;
}
var orphan = Armed();
orphan.Visual.Anchors.Single(anchor => anchor.Id == "gun.z").ParentId = null;
Assert.Contains("mod.skiff: muzzle gun.z must be parented to a weapon hardpoint's mount", Refusal(orphan));
var onThruster = Armed();
onThruster.Visual.Anchors.Single(anchor => anchor.Id == "gun.z").ParentId = "thruster.port";
Assert.Contains("mod.skiff: muzzle gun.z must be parented to a weapon hardpoint's mount", Refusal(onThruster));
var unarmed = Armed();
unarmed.Visual.Anchors.RemoveAll(anchor => anchor.Role == "weapon-muzzle");
Assert.Contains("mod.skiff: weapon hardpoint gun needs at least one muzzle anchor", Refusal(unarmed));
// Each weapon needs a muzzle of its own: another weapon's muzzle does not arm it.
var second = Armed();
second.Hull.Shape.Cells[0, 1] = true;
second.Hull.Hardpoints.Add(new HardpointData { Type = HardpointType.Launcher, Position = new int2(0, 1), Shape = new Shape(), Transform = "tube" });
second.Visual.Anchors.Add(new ShipAnchor { Id = "tube", Role = "weapon-mount", ModelNodeId = "tube" });
Assert.Contains("mod.skiff: weapon hardpoint tube needs at least one muzzle anchor", Refusal(second));
// A valid pair plans without a refusal of the plan's own; muzzles by Order, then id ordinally.
var valid = Armed();
var (mount, muzzles) = Assert.Single(ShipModPlan.Build(valid.Hull, valid.Visual).Weapons);
Assert.Equal("gun", mount);
Assert.Equal(new[] { "gun.z", "gun.a", "gun.b" }, muzzles);
}
internal static ShipParts Fixture()
{
var shape = new Shape(2, 2);
shape.Cells[0, 0] = true;
shape.Cells[1, 0] = true;
return new ShipParts
{
Hull = new HullData
{
Name = "Skiff",
Shape = shape,
Behaviors = { new VelocityLimitData() },
Visual = new CultRecordRef<ShipAuthoring>(ShipModCatalog.AuthoringKey("mod.skiff")),
Hardpoints = new List<HardpointData>
{
new HardpointData
{
Type = HardpointType.Thruster,
Position = new int2(1, 0),
Shape = new Shape(),
Transform = "thruster.port"
}
}
},
Visual = new ShipAuthoring
{
Id = "mod.skiff",
ModelAsset = "skiff.glb",
Anchors = new List<ShipAnchor>
{
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 }
}
}
}
};
}
// create --like: the shipped hull's stats, never its body or layout. Every member a reference carries is copied,
// including one this test does not name, because the copy goes through the type's own serializer.
[Fact]
public void HullLikeCopiesStatsButNotTheBodyOrLayout()
{
var reference = new HullData
{
Name = "Djinni", Price = 1234, Durability = 56, Mass = 78, Armor = 9, Drag = 0.25f, CanTow = true,
HullType = HullType.Ship, Shape = new Shape(3, 2), Prefab = "prefab-guid", Schematic = "schematic-guid",
Hardpoints = new List<HardpointData> { new HardpointData { Type = HardpointType.Thruster, Shape = new Shape(), Transform = "aft" } },
};
reference.Shape.Cells[2, 1] = true;
var hull = ShipAuthoringStore.HullLike(reference, "Gale", "mod.gale");
Assert.Equal((1234, 56f, 78f, 9f, 0.25f, true, HullType.Ship),
(hull.Price, hull.Durability, hull.Mass, hull.Armor, hull.Drag, hull.CanTow, hull.HullType));
Assert.Equal("Gale", hull.Name);
Assert.Equal((1, 1, true), (hull.Shape.Width, hull.Shape.Height, hull.Shape.Cells[0, 0]));
Assert.Empty(hull.Hardpoints);
Assert.Null(hull.Prefab);
Assert.Null(hull.Schematic);
Assert.Equal(ShipModCatalog.AuthoringKey("mod.gale"), hull.Visual.Key);
// A copy, not the reference: the reference keeps its own body.
Assert.Equal(("Djinni", "prefab-guid", 1), (reference.Name, reference.Prefab, reference.Hardpoints.Count));
}
}
// 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);
}