Vectored rows recorded: HardpointData.Row, ThrusterData.Slew, the judge's row rules

Thruster hardpoints state a gimbal arc in FiringArc and join a row by Row (key 7);
the thruster design carries its slew (key 6). The judge refuses a Row on a
non-thruster, rows whose members disagree on Rotation or FiringArc, and a fixed
thruster, a mixed or split row, or a joint too short for the arc on a joint. Bind
treats a thruster emitter's joint as it treats a gun mount's. Not yet verified:
this commit has not been built.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
Meta Vens 2026-10-12 03:38:29 +02:00
commit b1f4a2aa08
6 changed files with 284 additions and 14 deletions

View file

@ -25,6 +25,11 @@ public class ThrusterData : BehaviorData
[Inspectable, CultInspectorAssetGuid, JsonProperty("Particles"), Key(5)]
public string ParticlesPrefab;
// The gimbal's slew rate in degrees per second: the part's own stat, as a weapon's traverse is
// (ruling mount-arc-gun-traverse). The swing limit is the hardpoint's FiringArc, the row its Row.
[Inspectable, JsonProperty("slew"), Key(6), RuntimeInspectable]
public PerformanceStat Slew = new PerformanceStat();
public override Behavior CreateInstance(EquippedItem item)
{

View file

@ -566,7 +566,12 @@ public class HardpointData
[Inspectable, JsonProperty("armor"), Key(5)] public float Armor;
// Full arc width in degrees a weapon on this hardpoint may bear across; 0 means "use
// GameplaySettings.FiringArc" (docs/fire-control-cut.md R6). Turrets author 360 (Q1).
// On a Thruster hardpoint it is the full gimbal width: the row swings +-FiringArc/2 about Rotation, 0 is a
// fixed thruster, and there is no GameplaySettings fallback.
[Inspectable, JsonProperty("firingArc"), Key(6)] public float FiringArc;
// Thruster hardpoints sharing a non-empty Row swing as one row; a Thruster hardpoint with FiringArc > 0 and no Row
// is a row of one. Only a Thruster hardpoint may state one.
[Inspectable, JsonProperty("row"), Key(7)] public string Row;
public override string ToString()
{

View file

@ -262,6 +262,8 @@ public static class ShipAuthoringStore
throw new InvalidOperationException($"{ship.Id}: hardpoint {hardpoint.Transform} has an unknown rotation {(int)hardpoint.Rotation}.");
if (!float.IsFinite(hardpoint.Armor) || hardpoint.Armor < 0 || !float.IsFinite(hardpoint.FiringArc) || hardpoint.FiringArc < 0)
throw new InvalidOperationException($"{ship.Id}: hardpoint {hardpoint.Transform} armor and firing arc must be finite and not negative.");
if (!string.IsNullOrEmpty(hardpoint.Row) && hardpoint.Type != HardpointType.Thruster)
throw new InvalidOperationException($"{ship.Id}: hardpoint {hardpoint.Transform} is a {hardpoint.Type} hardpoint, and only a Thruster hardpoint may state a row.");
if (hardpoint.Shape?.Cells == null || !hardpoint.Shape.Cells.Cast<bool>().Any(occupied => occupied))
throw new InvalidOperationException($"{ship.Id}: hardpoint {hardpoint.Transform} has no cells.");
for (var cellX = 0; cellX < hardpoint.Shape.Width; cellX++)
@ -285,6 +287,16 @@ public static class ShipAuthoringStore
if (mountRole != null && mountAnchor.Role != mountRole)
throw new InvalidOperationException($"{ship.Id}: {hardpoint.Type} hardpoint {hardpoint.Transform} needs a {mountRole} anchor of that id.");
}
foreach (var row in mounts.Values.Where(hardpoint => hardpoint.Type == HardpointType.Thruster && !string.IsNullOrEmpty(hardpoint.Row))
.GroupBy(hardpoint => hardpoint.Row, StringComparer.Ordinal))
{
var lead = row.First();
foreach (var hardpoint in row)
if (hardpoint.Rotation != lead.Rotation || hardpoint.FiringArc != lead.FiringArc)
throw new InvalidOperationException($"{ship.Id}: thruster hardpoints {lead.Transform} and {hardpoint.Transform} are in row {row.Key} and must share Rotation and FiringArc.");
if (!(lead.FiringArc > 0f) || lead.FiringArc > 360f)
throw new InvalidOperationException($"{ship.Id}: thruster row {row.Key} must state a gimbal FiringArc above 0 and at most 360.");
}
foreach (var anchor in anchors)
{
if (!string.IsNullOrEmpty(anchor.ParentId) && !mounts.ContainsKey(anchor.ParentId))
@ -371,12 +383,12 @@ public static class ShipAuthoringStore
foreach (var anchor in anchors.Where(anchor => !string.IsNullOrEmpty(anchor.Joint)))
{
if (anchor.Role == "thruster-emitter" || anchor.Role == "radiator-mesh")
throw new InvalidOperationException($"{ship.Id}: {anchor.Role} anchor {anchor.Id} may not ride a joint (follow-up gimballed-thrusters).");
if (anchor.Role != "weapon-mount")
throw new InvalidOperationException($"{ship.Id}: {anchor.Role} anchor {anchor.Id} may not name a joint; only a weapon mount rides one.");
if (anchor.Role == "radiator-mesh")
throw new InvalidOperationException($"{ship.Id}: {anchor.Role} anchor {anchor.Id} may not ride a joint.");
if (anchor.Role != "weapon-mount" && anchor.Role != "thruster-emitter")
throw new InvalidOperationException($"{ship.Id}: {anchor.Role} anchor {anchor.Id} may not name a joint; only a weapon mount or a thruster emitter rides one.");
if (!byId.ContainsKey(anchor.Joint))
throw new InvalidOperationException($"{ship.Id}: weapon mount {anchor.Id} names an unknown joint.");
throw new InvalidOperationException($"{ship.Id}: {anchor.Role} anchor {anchor.Id} names an unknown joint.");
}
var ridden = anchors.Where(anchor => anchor.Role == "weapon-mount" && !string.IsNullOrEmpty(anchor.Joint))
@ -394,6 +406,30 @@ public static class ShipAuthoringStore
if (reach < lead.FiringArc)
throw new InvalidOperationException($"{ship.Id}: joint {group.Key} chain reaches {reach} degrees of yaw, short of the {lead.FiringArc} degree firing arc of {lead.Transform}.");
}
// A thruster row is a gimbal: its hardpoints' stated FiringArc is how far the row swings, so a row on a joint rides it
// whole and alone, and the joint's chain must be able to sweep that arc. A hardpoint with no Row is a row of one.
var thrusters = mounts.Values.Where(hardpoint => hardpoint.Type == HardpointType.Thruster).ToArray();
string RowOf(HardpointData hardpoint) => string.IsNullOrEmpty(hardpoint.Row) ? null : hardpoint.Row;
var jointOf = anchors.Where(anchor => anchor.Role == "thruster-emitter").ToDictionary(anchor => anchor.Id, anchor => anchor.Joint ?? "", StringComparer.Ordinal);
var tilted = thrusters.Where(hardpoint => jointOf.TryGetValue(hardpoint.Transform, out var joint) && joint.Length > 0).ToArray();
foreach (var hardpoint in tilted)
if (!(hardpoint.FiringArc > 0f))
throw new InvalidOperationException($"{ship.Id}: thruster hardpoint {hardpoint.Transform} rides a joint, so it must state its firing arc above 0; a fixed thruster may not ride a joint.");
foreach (var onJoint in tilted.GroupBy(hardpoint => jointOf[hardpoint.Transform], StringComparer.Ordinal))
{
var lead = onJoint.First();
foreach (var hardpoint in onJoint)
if (RowOf(hardpoint) != RowOf(lead) || RowOf(hardpoint) == null && hardpoint != lead)
throw new InvalidOperationException($"{ship.Id}: joint {onJoint.Key} carries thruster hardpoints {lead.Transform} and {hardpoint.Transform} of different rows; a joint carries one row.");
var members = RowOf(lead) == null ? new[] { lead } : thrusters.Where(hardpoint => RowOf(hardpoint) == RowOf(lead)).ToArray();
foreach (var member in members)
if (!jointOf.TryGetValue(member.Transform, out var memberJoint) || memberJoint != onJoint.Key)
throw new InvalidOperationException($"{ship.Id}: thruster hardpoint {member.Transform} is in the row of {lead.Transform} on joint {onJoint.Key} and must ride it too; a row rides one joint whole.");
var reach = YawReach(ship, onJoint.Key);
if (reach < lead.FiringArc)
throw new InvalidOperationException($"{ship.Id}: joint {onJoint.Key} chain reaches {reach} degrees of yaw, short of the {lead.FiringArc} degree gimbal arc of {lead.Transform}.");
}
}
// The yaw a driven joint's chain can sweep: the summed range of every degree of freedom whose axis is yaw, capped at a turn.

View file

@ -193,8 +193,8 @@ public static class ShipModCatalog
}
// The rig the record states must be the rig the model has: every joint is a node, each joint's Parent and each mount's
// Joint is the nearest joint above its node, ancestors without an id being walked through by ReadNodeIds, and a thruster or radiator
// mesh sits under no joint (follow-up gimballed-thrusters).
// Joint is the nearest joint above its node, ancestors without an id being walked through by ReadNodeIds, and a radiator
// mesh sits under no joint.
private static void BindRig(ShipAuthoring ship, Dictionary<string, (uint Index, bool HasMesh, int Parent)> nodes)
{
var joints = new HashSet<string>((ship.Joints ?? new List<ShipJoint>()).Select(joint => joint.Id), StringComparer.Ordinal);
@ -220,9 +220,9 @@ public static class ShipModCatalog
{
var above = JointAbove(anchor.ModelNodeId);
if (string.Equals(above ?? "", anchor.Joint ?? "", StringComparison.Ordinal)) continue;
throw new InvalidOperationException(anchor.Role == "weapon-mount"
? $"{ship.Id}: weapon mount {anchor.Id} must name the nearest joint above its model node as its Joint."
: $"{ship.Id}: {anchor.Role} anchor {anchor.Id} sits under a joint in the model (follow-up gimballed-thrusters).");
throw new InvalidOperationException(anchor.Role == "radiator-mesh"
? $"{ship.Id}: {anchor.Role} anchor {anchor.Id} sits under a joint in the model."
: $"{ship.Id}: {(anchor.Role == "weapon-mount" ? "weapon mount" : "thruster emitter")} {anchor.Id} must name the nearest joint above its model node as its Joint.");
}
}

View file

@ -4,6 +4,8 @@ using System.IO;
using System.Linq;
using System.Text;
using CultMath;
using GameCult.Caching.MessagePack;
using MessagePack;
using Xunit;
// The rig in the visual record: what the judge (Validate) refuses about joints and the guns that ride them, what Bind
@ -295,9 +297,41 @@ public sealed class ShipRigTests : IDisposable
BindRefusal(Package(ship => ship.Visual.Joints.Single(joint => joint.Id == "elbow").Parent = null)));
}
// The thruster's emission mesh follows the same rule as a gun mount: its record names the nearest joint above its node.
[Fact]
public void BindRefusesAThrusterMeshUnderAJointNamingTheFollowUp() =>
Assert.Contains("gimballed-thrusters", BindRefusal(Package(null, Reparent("thruster-port", "wrist"))));
public void BindAcceptsAThrusterRowOnTheJointItsMeshHangsUnder()
{
var package = ShipModCatalog.ReadPackage(Package(ship =>
{
var thruster = ship.Hull.Hardpoints.Single(hardpoint => hardpoint.Transform == "thruster.port");
thruster.FiringArc = 90;
ship.Visual.Anchors.Single(anchor => anchor.Id == "thruster.port").Joint = "arm";
}, Reparent("thruster-port", "arm")));
Assert.Equal("arm", package.Visual.Anchors.Single(anchor => anchor.Id == "thruster.port").Joint);
}
[Fact]
public void BindRefusesAThrusterMeshThatTheRecordAndTheModelPlaceDifferently()
{
// The record says the thruster is fixed; the model hangs its mesh under the wrist.
Assert.Contains("thruster emitter thruster.port must name the nearest joint above its model node",
BindRefusal(Package(null, Reparent("thruster-port", "wrist"))));
// The record rides the arm; the model leaves the mesh at the root.
Assert.Contains("thruster emitter thruster.port must name the nearest joint above its model node",
BindRefusal(Package(ship =>
{
ship.Hull.Hardpoints.Single(hardpoint => hardpoint.Transform == "thruster.port").FiringArc = 90;
ship.Visual.Anchors.Single(anchor => anchor.Id == "thruster.port").Joint = "arm";
})));
}
[Fact]
public void BindRefusesARadiatorMeshUnderAJoint() =>
Assert.Contains("radiator-mesh anchor thruster.port sits under a joint in the model", BindRefusal(Package(ship =>
{
ship.Hull.Hardpoints.Single(hardpoint => hardpoint.Transform == "thruster.port").Type = HardpointType.Radiator;
ship.Visual.Anchors.Single(anchor => anchor.Id == "thruster.port").Role = "radiator-mesh";
}, Reparent("thruster-port", "wrist"))));
[Fact]
public void NodeParentsAreTheNearestTaggedAncestorAndRootsHaveNone()
@ -381,4 +415,177 @@ public sealed class ShipRigTests : IDisposable
[Fact]
public void AShipWithNoGunOnAJointHasNoRigs() =>
Assert.Empty(ShipModPlan.Build(Armed((string)null).Hull, Armed((string)null).Visual).Rigs);
// ---- Thruster rows: a row's gimbal is the hardpoints' FiringArc, its members share a Row, its joint only draws it ----
// The fixture's skiff with thrusters "t0".."tN" side by side (one cell each, the fixture's own thruster dropped). Each
// tuple is a thruster's Row (null for none), its FiringArc, and the joint its emitter rides (null for none).
private static ShipParts Thrusting(params (string Row, float Arc, string Joint)[] thrusters)
{
var ship = ShipAuthoringTests.Fixture();
var shape = new Shape(thrusters.Length, 1);
for (var x = 0; x < thrusters.Length; x++) shape.Cells[x, 0] = true;
ship.Hull.Shape = shape;
ship.Hull.Hardpoints.Clear();
ship.Visual.Anchors.RemoveAll(anchor => anchor.Id == "thruster.port");
for (var index = 0; index < thrusters.Length; index++)
{
ship.Hull.Hardpoints.Add(new HardpointData
{
Type = HardpointType.Thruster, Position = new int2(index, 0), Shape = new Shape(), Transform = $"t{index}",
Row = thrusters[index].Row, FiringArc = thrusters[index].Arc
});
ship.Visual.Anchors.Add(new ShipAnchor { Id = $"t{index}", Role = "thruster-emitter", ModelNodeId = $"t{index}", Joint = thrusters[index].Joint });
}
return ship;
}
private static ShipParts OnJoint(ShipParts ship, string id, params (float[] Axis, float Min, float Max)[] dofs)
{
ship.Visual.Joints.Add(Joint(id, null, dofs));
return ship;
}
[Fact]
public void ARowIsOnlyForThrusters()
{
// The same Row that a Thruster hardpoint carries is refused on a weapon hardpoint, naming the hardpoint and its type.
Thrusting(("r", 90f, null)).Validate();
var ship = Armed((string)null);
ship.Hull.Hardpoints.Single(hardpoint => hardpoint.Transform == "g0").Row = "r";
var message = Refusal(ship);
Assert.Contains("hardpoint g0 is a Energy hardpoint", message);
Assert.Contains("only a Thruster hardpoint may state a row", message);
}
[Fact]
public void ARowOfThrustersSharingRotationAndArcIsValidWhateverTheArc()
{
Thrusting(("r", 90f, null), ("r", 90f, null)).Validate();
Thrusting(("r", 360f, null), ("r", 360f, null)).Validate();
Thrusting(("r", .5f, null)).Validate();
// A thruster with no Row and no arc is simply a fixed thruster.
Thrusting((null, 0f, null), (null, 0f, null)).Validate();
}
[Fact]
public void AThrusterRowMayRideAJoint()
{
// gimballed-thrusters: two emitters of one row (arc 90) on a joint that sweeps 90 degrees of yaw.
OnJoint(Thrusting(("r", 90f, "j"), ("r", 90f, "j")), "j", (Up, -45f, 45f)).Validate();
// A thruster with an arc and no Row is a row of one, and rides a joint the same way.
OnJoint(Thrusting((null, 90f, "j")), "j", (Up, -45f, 45f)).Validate();
// Serial degrees of freedom add, exactly as they do for a gun.
OnJoint(Thrusting(("r", 120f, "j")), "j", (Up, -30f, 30f), (Up, -30f, 30f)).Validate();
}
[Fact]
public void AFixedThrusterMayNotRideAJoint()
{
var ship = OnJoint(Thrusting((null, 0f, "j")), "j", (Up, -180f, 180f));
Assert.Contains("thruster hardpoint t0 rides a joint, so it must state its firing arc above 0", Refusal(ship));
// Fixed and on no joint, it is fine.
ship.Visual.Anchors.Single(anchor => anchor.Id == "t0").Joint = null;
ship.Validate();
}
[Fact]
public void AJointShortOfTheRowsArcIsRefused()
{
var short1 = Refusal(OnJoint(Thrusting(("r", 90f, "j"), ("r", 90f, "j")), "j", (Up, -30f, 30f)));
Assert.Contains("joint j chain reaches 60 degrees of yaw", short1);
Assert.Contains("90 degree gimbal arc", short1);
// One degree over the reach is refused for a row of one as well, and the arc itself decides it.
Assert.Contains("reaches 90 degrees", Refusal(OnJoint(Thrusting((null, 91f, "j")), "j", (Up, -45f, 45f))));
OnJoint(Thrusting((null, 90f, "j")), "j", (Up, -45f, 45f)).Validate();
// Pitch adds nothing to the yaw a gimbal swings through.
Assert.Contains("reaches 0 degrees", Refusal(OnJoint(Thrusting(("r", 10f, "j")), "j", (Right, -180f, 180f))));
}
[Fact]
public void ARowOnAJointIsWholeAndAlone()
{
// A row whose members ride two joints.
var split = Thrusting(("r", 90f, "j"), ("r", 90f, "k"));
OnJoint(split, "j", (Up, -45f, 45f));
OnJoint(split, "k", (Up, -45f, 45f));
Assert.Contains("must ride it too", Refusal(split));
// A row with a member on no joint.
Assert.Contains("must ride it too", Refusal(OnJoint(Thrusting(("r", 90f, "j"), ("r", 90f, null)), "j", (Up, -45f, 45f))));
// A joint carrying two rows, and one carrying two rows of one.
Assert.Contains("carries thruster hardpoints t0 and t1 of different rows",
Refusal(OnJoint(Thrusting(("r", 90f, "j"), ("s", 90f, "j")), "j", (Up, -45f, 45f))));
Assert.Contains("carries thruster hardpoints t0 and t1 of different rows",
Refusal(OnJoint(Thrusting((null, 90f, "j"), (null, 90f, "j")), "j", (Up, -45f, 45f))));
// A row and a thruster of no row on one joint.
Assert.Contains("different rows", Refusal(OnJoint(Thrusting(("r", 90f, "j"), (null, 90f, "j")), "j", (Up, -45f, 45f))));
// Each row on a joint of its own is fine.
var apart = Thrusting(("r", 90f, "j"), ("s", 90f, "k"));
OnJoint(apart, "j", (Up, -45f, 45f));
OnJoint(apart, "k", (Up, -45f, 45f));
apart.Validate();
}
[Fact]
public void ARadiatorStillMayNotRideAJoint()
{
var ship = OnJoint(Thrusting((null, 90f, "j")), "j", (Up, -180f, 180f));
ship.Hull.Hardpoints.Single().Type = HardpointType.Radiator;
ship.Visual.Anchors.Single(anchor => anchor.Id == "t0").Role = "radiator-mesh";
Assert.Contains("radiator-mesh anchor t0 may not ride a joint", Refusal(ship));
}
[Fact]
public void AGunAndAThrusterRowOnOneJointAreEachJudgedByTheirOwnArc()
{
// The gun rule is untouched: a gun and a thruster row each judged by their own arc against the one chain.
var ship = Armed("j");
ship.Visual.Joints.Add(Joint("j", null, (Up, -45f, 45f)));
ship.Hull.Hardpoints.Single(hardpoint => hardpoint.Transform == "g0").FiringArc = 90;
var thruster = ship.Hull.Hardpoints.Single(hardpoint => hardpoint.Type == HardpointType.Thruster);
thruster.FiringArc = 90;
ship.Visual.Anchors.Single(anchor => anchor.Id == thruster.Transform).Joint = "j";
ship.Validate();
thruster.FiringArc = 91;
Assert.Contains("90 degrees of yaw, short of the 91 degree gimbal arc", Refusal(ship));
}
// Row and Slew are persistent: a package or catalog written with them is read by key, so their positions are wire format.
[Fact]
public void RowAndSlewKeepTheirWirePositions()
{
var options = CultDocumentMessagePackSerialization.OptionsFor(typeof(HardpointData).Assembly);
var hardpoint = new HardpointData { Type = HardpointType.Thruster, Shape = new Shape(), Transform = "t", FiringArc = 40f, Row = "left" };
var fields = MessagePackSerializer.Deserialize<object[]>(MessagePackSerializer.Serialize(hardpoint, options), options);
Assert.Equal(8, fields.Length);
Assert.Equal(40f, Convert.ToSingle(fields[6]));
Assert.Equal("left", fields[7]);
Assert.Null(MessagePackSerializer.Deserialize<object[]>(MessagePackSerializer.Serialize(new HardpointData { Shape = new Shape() }, options), options)[7]);
var thruster = new ThrusterData();
thruster.Thrust.Max = 5f;
thruster.Slew.Max = 90f;
var slew = MessagePackSerializer.Deserialize<object[]>(MessagePackSerializer.Serialize(thruster, options), options);
Assert.Equal(7, slew.Length);
Assert.Equal(MessagePackSerializer.ConvertToJson(MessagePackSerializer.Serialize(thruster.Slew, options), options),
MessagePackSerializer.ConvertToJson(MessagePackSerializer.Serialize(slew[6], options), options));
Assert.NotEqual(MessagePackSerializer.ConvertToJson(MessagePackSerializer.Serialize(thruster.Thrust, options), options),
MessagePackSerializer.ConvertToJson(MessagePackSerializer.Serialize(slew[6], options), options));
}
// A package written with a row reads back with it, and a fixed thruster with none reads back with none.
[Fact]
public async System.Threading.Tasks.Task ARowRoundTripsThroughAShipPackageAndNoRowStaysNone()
{
var path = Path.Combine(_directory.Path, "rows.cc");
var ship = Thrusting(("r", 90f, null), ("r", 90f, null), (null, 0f, null));
using (var cache = ShipAuthoringStore.Open(path, writable: true))
{
ShipAuthoringStore.Write(cache, ship.Hull, ship.Visual);
await cache.FlushAsync();
}
var (hull, _) = ShipAuthoringStore.Read(path);
Assert.Equal(new[] { "r", "r", null }, hull.Hardpoints.Select(hardpoint => hardpoint.Row));
Assert.Equal(new[] { 90f, 90f, 0f }, hull.Hardpoints.Select(hardpoint => hardpoint.FiringArc));
}
}

View file

@ -226,8 +226,8 @@ public sealed class ShipValidationTests
Add("joint-min-minus-181", s => Joint(s, "j", null, yaw, new[] { -181f }, new[] { 0f }), "must satisfy -180 <= Min <= Max <= 180");
Add("joint-parent-dangling", s => Joint(s, "j", "ghost", yaw, new[] { -1f }, new[] { 1f }), "has an unknown parent joint");
Add("joint-cycle", s => { Joint(s, "a", "b", yaw, new[] { -1f }, new[] { 1f }); Joint(s, "b", "a", yaw, new[] { -1f }, new[] { 1f }); }, "is on a parent cycle");
Add("joint-on-a-thruster-mount", s => { Joint(s, "j", null, yaw, new[] { -1f }, new[] { 1f }); s.Visual.Anchors[4].Joint = "j"; }, "gimballed-thrusters");
Add("joint-on-a-radiator-mount", s => { Joint(s, "j", null, yaw, new[] { -1f }, new[] { 1f }); s.Visual.Anchors[4].Role = "radiator-mesh"; s.Visual.Anchors[4].Joint = "j"; s.Hull.Hardpoints[0].Type = HardpointType.Radiator; }, "gimballed-thrusters");
Add("joint-on-a-thruster-mount", s => { Joint(s, "j", null, yaw, new[] { -1f }, new[] { 1f }); s.Visual.Anchors[4].Joint = "j"; }, "a fixed thruster may not ride a joint");
Add("joint-on-a-radiator-mount", s => { Joint(s, "j", null, yaw, new[] { -1f }, new[] { 1f }); s.Visual.Anchors[4].Role = "radiator-mesh"; s.Visual.Anchors[4].Joint = "j"; s.Hull.Hardpoints[0].Type = HardpointType.Radiator; }, "radiator-mesh anchor thruster.port may not ride a joint");
Add("joint-on-a-map-icon", s => { Joint(s, "j", null, yaw, new[] { -1f }, new[] { 1f }); s.Visual.Anchors[0].Joint = "j"; }, "may not name a joint");
Add("anchor-role-unknown", s => s.Visual.Anchors.Add(new ShipAnchor { Id = "x", Role = "engine", ModelNodeId = "x" }), "has unknown role 'engine'");
Add("anchor-role-null", s => s.Visual.Anchors.Add(new ShipAnchor { Id = "x", Role = null, ModelNodeId = "x" }), "has unknown role ''");
@ -289,6 +289,23 @@ public sealed class ShipValidationTests
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");
// Thruster rows: the fixture's thruster joined by a second one at the hull's other cell, both in row "r".
void Row(ShipParts s, float arc, float otherArc, ItemRotation otherRotation = ItemRotation.Normal)
{
Mount(s).Row = "r";
Mount(s).FiringArc = arc;
s.Hull.Hardpoints.Add(new HardpointData
{
Type = HardpointType.Thruster, Position = new int2(0, 0), Shape = new Shape(), Transform = "thruster.starboard",
Row = "r", FiringArc = otherArc, Rotation = otherRotation
});
s.Visual.Anchors.Add(new ShipAnchor { Id = "thruster.starboard", Role = "thruster-emitter", ModelNodeId = "thruster-starboard" });
}
Add("row-on-weapon", s => { Mount(s).Type = HardpointType.Energy; Mount(s).Row = "r"; }, "only a Thruster hardpoint may state a row");
Add("row-rotation-mismatch", s => Row(s, 90, 90, ItemRotation.Reversed), "are in row r and must share Rotation and FiringArc");
Add("row-arc-mismatch", s => Row(s, 90, 60), "are in row r and must share Rotation and FiringArc");
Add("row-arc-zero", s => Row(s, 0, 0), "thruster row r must state a gimbal FiringArc above 0 and at most 360");
Add("row-arc-above-a-turn", s => Row(s, 361, 361), "thruster row r must state a gimbal FiringArc above 0 and at most 360");
return cases;
}
}