- Validate: a hardpoint's anchor role is fixed by its type (thruster-emitter, radiator-mesh, else articulation); ShipModPlan's duplicate role check is deleted. - Loadouts.Commit refuses a preset naming a mod design (mod-hull:/mod-ship:), so the shipped catalog never references a mod. - Compose quarantines each package: a bad package is excluded and named, a collision excludes every package involved; the game boots on the rest (or the shipped catalog) and the main menu lists the exclusions. - RequireDesigns refuses a save with a null item or lot instead of throwing NullReferenceException. - Tests: per structural role, muzzle id ties, every slot/zone/lot in RequireDesigns, listing order, exclusion cases. Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
346 lines
17 KiB
C#
346 lines
17 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);
|
|
Assert.IsAssignableFrom<MessagePackSerializationException>(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);
|
|
}
|
|
|
|
// A mount's anchor plays the mount, so a structural anchor (the hull collider, the shield, a muzzle...) is never one.
|
|
// Interim ruling until an `equipment` role exists: a mount that is not a thruster or radiator is an articulation anchor.
|
|
[Theory]
|
|
[InlineData("map-icon", "map")]
|
|
[InlineData("hull-collider", "collider")]
|
|
[InlineData("shield", "shield")]
|
|
[InlineData("tractor", "tractor")]
|
|
[InlineData("weapon-muzzle", "muzzle")]
|
|
[InlineData("thruster-emitter", "emitter")]
|
|
[InlineData("radiator-mesh", "mesh")]
|
|
public void AStructuralAnchorCannotBeAnEquipmentMount(string role, string anchorId)
|
|
{
|
|
var ship = Fixture();
|
|
// The extra anchors exist so the role-count and parent checks pass; the Tool hardpoint then claims the anchor.
|
|
ship.Visual.Anchors.Add(new ShipAnchor { Id = "muzzle", Role = "weapon-muzzle", ModelNodeId = "muzzle", ParentId = "thruster.port" });
|
|
ship.Visual.Anchors.Add(new ShipAnchor { Id = "emitter", Role = "thruster-emitter", ModelNodeId = "emitter" });
|
|
ship.Visual.Anchors.Add(new ShipAnchor { Id = "mesh", Role = "radiator-mesh", ModelNodeId = "mesh" });
|
|
ship.Hull.Hardpoints.Add(new HardpointData { Type = HardpointType.Tool, Position = new int2(0, 0), Shape = new Shape(), Transform = anchorId });
|
|
|
|
Assert.Contains($"Tool hardpoint {anchorId} needs a articulation anchor of that id", Assert.Throws<InvalidOperationException>(ship.Validate).Message);
|
|
ship = Fixture();
|
|
ship.Visual.Anchors.Add(new ShipAnchor { Id = "tool", Role = "articulation", ModelNodeId = "tool" });
|
|
ship.Hull.Hardpoints.Add(new HardpointData { Type = HardpointType.Tool, Position = new int2(0, 0), Shape = new Shape(), Transform = "tool" });
|
|
ship.Validate();
|
|
}
|
|
|
|
[Fact]
|
|
public void EveryHardpointTypeHasOneAnchorRoleForItsMount()
|
|
{
|
|
foreach (HardpointType type in Enum.GetValues(typeof(HardpointType)))
|
|
{
|
|
var wanted = type == HardpointType.Thruster ? "thruster-emitter" : type == HardpointType.Radiator ? "radiator-mesh" : "articulation";
|
|
foreach (var role in new[] { "articulation", "thruster-emitter", "radiator-mesh" })
|
|
{
|
|
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" });
|
|
ship.Visual.Anchors.Add(new ShipAnchor { Id = "mount", Role = role, ModelNodeId = "mount" });
|
|
if (role == wanted) ship.Validate();
|
|
else Assert.Contains($"{type} hardpoint mount needs a {wanted} anchor of that id", Assert.Throws<InvalidOperationException>(ship.Validate).Message);
|
|
}
|
|
}
|
|
}
|
|
|
|
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,
|
|
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 }
|
|
}
|
|
}
|
|
}
|
|
};
|
|
}
|
|
}
|
|
|
|
// 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);
|
|
}
|