Merge cut vector-rows-record (Soul verdict cut-vector-rows-record.s3)

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
This commit is contained in:
Meta Vens 2026-10-13 01:21:40 +02:00
commit 27094e8859
7 changed files with 614 additions and 40 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

@ -302,6 +302,11 @@ 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.");
// A thruster's gimbal range is the judge's alone, a row of one included: no thruster swings further than a turn.
if (hardpoint.Type == HardpointType.Thruster && hardpoint.FiringArc > 360f)
throw new InvalidOperationException($"{ship.Id}: thruster hardpoint {hardpoint.Transform} firing arc may be at most 360 degrees.");
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++)
@ -325,14 +330,23 @@ 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))
throw new InvalidOperationException($"{ship.Id}: thruster row {row.Key} must state a gimbal FiringArc above 0.");
}
foreach (var anchor in anchors)
{
if (!string.IsNullOrEmpty(anchor.ParentId) && !mounts.ContainsKey(anchor.ParentId))
throw new InvalidOperationException($"{ship.Id}: anchor {anchor.Id} has unknown hardpoint parent {anchor.ParentId}.");
// A mount-role anchor is the mount of a hardpoint of its type, never a loose node. The hardpoint loop above has
// already refused one whose id names a hardpoint of another type, so here it only has to name a hardpoint.
if ((anchor.Role == "thruster-emitter" || anchor.Role == "radiator-mesh" || anchor.Role == "weapon-mount") &&
!mounts.ContainsKey(anchor.Id))
if ((RigChains.Actuates(anchor.Role) || anchor.Role == "radiator-mesh") && !mounts.ContainsKey(anchor.Id))
throw new InvalidOperationException($"{ship.Id}: {anchor.Role} anchor {anchor.Id} must be the mount of a hardpoint of its type.");
if (anchor.Role == "weapon-muzzle" &&
(string.IsNullOrEmpty(anchor.ParentId) || !IsWeapon(mounts[anchor.ParentId].Type)))
@ -411,29 +425,66 @@ 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 (!RigChains.Actuates(anchor.Role))
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))
.Select(anchor => (Hardpoint: mounts[anchor.Id], Joint: anchor.Joint)).ToArray();
foreach (var (hardpoint, _) in ridden)
// A joint has one actuator (ruling one-joint-one-actuator), and RigChains.Drives is the only place that says what drives a
// joint: a joint carrying two actuators, or whose reach passes through a joint another actuator drives, is refused here,
// whatever the two actuators are.
var drives = RigChains.Drives(ship).Select(drive => (drive.Joint, Hardpoint: mounts[drive.Anchor.Id])).ToArray();
foreach (var onJoint in drives.GroupBy(drive => drive.Joint, StringComparer.Ordinal))
{
var lead = onJoint.First().Hardpoint;
var other = onJoint.Select(drive => drive.Hardpoint).FirstOrDefault(hardpoint => RigChains.Actuator(hardpoint) != RigChains.Actuator(lead));
if (other != null)
throw new InvalidOperationException($"{ship.Id}: joint {onJoint.Key} carries more than one actuator ({lead.Transform} and {other.Transform}); a joint has one actuator.");
}
var driven = RigChains.Driven(ship);
foreach (var joint in driven)
{
var through = RigChains.Chain(ship, joint).SkipLast(1).FirstOrDefault(driven.Contains);
if (through != null)
throw new InvalidOperationException($"{ship.Id}: joint {joint} reaches through joint {through}, which another actuator drives; a joint has one actuator.");
}
var ridden = drives.Where(drive => IsWeapon(drive.Hardpoint.Type)).ToArray();
foreach (var (_, hardpoint) in ridden)
if (!(hardpoint.FiringArc > 0f))
throw new InvalidOperationException($"{ship.Id}: weapon hardpoint {hardpoint.Transform} rides a joint, so it must state its firing arc above 0.");
foreach (var group in ridden.GroupBy(mount => mount.Joint, StringComparer.Ordinal))
{
var lead = group.First().Hardpoint;
foreach (var (hardpoint, _) in group)
foreach (var (_, hardpoint) in group)
if (hardpoint.Rotation != lead.Rotation || hardpoint.FiringArc != lead.FiringArc)
throw new InvalidOperationException($"{ship.Id}: weapon hardpoints {lead.Transform} and {hardpoint.Transform} ride joint {group.Key} and must share Rotation and FiringArc.");
var reach = YawReach(ship, group.Key);
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 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();
var tilted = drives.Where(drive => drive.Hardpoint.Type == HardpointType.Thruster).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(drive => drive.Joint, StringComparer.Ordinal))
{
var lead = onJoint.First().Hardpoint;
var members = string.IsNullOrEmpty(lead.Row) ? new[] { lead } : thrusters.Where(hardpoint => hardpoint.Row == lead.Row).ToArray();
foreach (var member in members)
if (!tilted.Any(drive => drive.Hardpoint == member && drive.Joint == 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.
@ -449,21 +500,31 @@ public static class ShipAuthoringStore
}
}
// A rig's driven joints and the chains that move them. A driven joint is one a weapon-mount anchor names. Its chain runs
// from the joint up through its ancestors and stops before the nearest ancestor that is itself driven (that joint has
// its own gun, and its pose is that gun's business), listed root-first. The judge and the plan both read this one rule.
// A rig's actuators and the joints they drive. An actuator is a weapon mount or a thruster emitter that names a joint; a gun
// group (every gun on a joint) or a thruster row is one actuator, and a joint has at most one (ruling one-joint-one-actuator).
// This is the only place that says which anchors are actuators, so the judge, the binder and the plan all follow it.
public static class RigChains
{
public static HashSet<string> Driven(ShipAuthoring ship) =>
new HashSet<string>(ship.Anchors.Where(anchor => anchor.Role == "weapon-mount" && !string.IsNullOrEmpty(anchor.Joint)).Select(anchor => anchor.Joint),
StringComparer.Ordinal);
public static bool Actuates(string role) => role == "weapon-mount" || role == "thruster-emitter";
// Every actuator anchor that names a joint, with the joint it drives.
public static IEnumerable<(string Joint, ShipAnchor Anchor)> Drives(ShipAuthoring ship) =>
ship.Anchors.Where(anchor => Actuates(anchor.Role) && !string.IsNullOrEmpty(anchor.Joint)).Select(anchor => (anchor.Joint, anchor));
public static HashSet<string> Driven(ShipAuthoring ship) =>
new HashSet<string>(Drives(ship).Select(drive => drive.Joint), StringComparer.Ordinal);
// The actuator a mount belongs to: all guns on a joint are one, a thruster is its row, and a thruster of no row is its own.
public static string Actuator(HardpointData hardpoint) =>
ShipAuthoringStore.IsWeapon(hardpoint.Type) ? "weapon" : string.IsNullOrEmpty(hardpoint.Row) ? "thruster:" + hardpoint.Transform : "row:" + hardpoint.Row;
// The joint and its ancestors, root-first. The judge refuses a driven joint whose ancestors another actuator drives,
// so for an accepted ship this is the whole chain the actuator's reach is summed over.
public static string[] Chain(ShipAuthoring ship, string jointId)
{
var joints = ship.Joints.ToDictionary(joint => joint.Id, StringComparer.Ordinal);
var driven = Driven(ship);
var chain = new List<string> { jointId };
for (var parent = joints[jointId].Parent; !string.IsNullOrEmpty(parent) && !driven.Contains(parent); parent = joints[parent].Parent)
for (var parent = joints[jointId].Parent; !string.IsNullOrEmpty(parent); parent = joints[parent].Parent)
chain.Add(parent);
chain.Reverse();
return chain.ToArray();

View file

@ -216,8 +216,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);
@ -239,13 +239,13 @@ public static class ShipModCatalog
if (!string.Equals(JointAbove(joint.Id) ?? "", joint.Parent ?? "", StringComparison.Ordinal))
throw new InvalidOperationException($"{ship.Id}: joint {joint.Id} must name the nearest joint above its model node as its Parent.");
}
foreach (var anchor in ship.Anchors.Where(anchor => anchor.Role == "weapon-mount" || anchor.Role == "thruster-emitter" || anchor.Role == "radiator-mesh"))
foreach (var anchor in ship.Anchors.Where(anchor => RigChains.Actuates(anchor.Role) || anchor.Role == "radiator-mesh"))
{
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

@ -30,8 +30,9 @@ public sealed class ShipModPlan
var muzzles = visual.Anchors.Where(anchor => anchor.Role == "weapon-muzzle")
.OrderBy(anchor => anchor.Order).ThenBy(anchor => anchor.Id, StringComparer.Ordinal).ToArray();
var jointOf = visual.Anchors.Where(anchor => anchor.Role == "weapon-mount" && !string.IsNullOrEmpty(anchor.Joint))
.ToDictionary(anchor => anchor.Id, anchor => anchor.Joint, StringComparer.Ordinal);
var weapons = hardpoints.Where(hardpoint => ShipAuthoringStore.IsWeapon(hardpoint.Type)).Select(hardpoint => hardpoint.Transform).ToHashSet(StringComparer.Ordinal);
var jointOf = RigChains.Drives(visual).Where(drive => weapons.Contains(drive.Anchor.Id))
.ToDictionary(drive => drive.Anchor.Id, drive => drive.Joint, StringComparer.Ordinal);
var leads = hardpoints.Where(hardpoint => jointOf.ContainsKey(hardpoint.Transform)).GroupBy(hardpoint => jointOf[hardpoint.Transform], StringComparer.Ordinal)
.Select(group => (Joint: group.Key, Lead: group.First().Transform));

View file

@ -4,6 +4,9 @@ using System.IO;
using System.Linq;
using System.Text;
using CultMath;
using GameCult.Caching;
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
@ -113,20 +116,24 @@ public sealed class ShipRigTests : IDisposable
OneGun(90, (new[] { 0f, -1f, 0f }, -45f, 45f)).Validate();
}
// A joint has one actuator (ruling one-joint-one-actuator): a gun whose reach runs through a joint another gun drives is
// refused, so a child's reach never borrows a parent's sweep and its parent's pose is never two solvers' outcome.
[Fact]
public void AChainStopsAtADrivenAncestorSoItsYawDoesNotCountForTheChild()
public void AGunWhoseReachRunsThroughAJointAnotherGunDrivesIsRefused()
{
var ship = Armed("outer", "inner");
ship.Visual.Joints.Add(Joint("outer", null, (Up, -180f, 180f)));
ship.Visual.Joints.Add(Joint("outer", null, (Up, -90f, 90f)));
ship.Visual.Joints.Add(Joint("inner", "outer", (Up, -10f, 10f)));
ship.Hull.Hardpoints.Single(hardpoint => hardpoint.Transform == "g1").FiringArc = 20;
ship.Validate();
ship.Hull.Hardpoints.Single(hardpoint => hardpoint.Transform == "g1").FiringArc = 100;
Assert.Contains("joint inner chain reaches 20 degrees", Refusal(ship));
var message = Refusal(ship);
Assert.Contains("joint inner reaches through joint outer, which another actuator drives", message);
Assert.Contains("a joint has one actuator", message);
// With nothing riding the outer joint it is only a link in the inner chain, and counts.
ship.Visual.Anchors.Single(anchor => anchor.Id == "g0").Joint = null;
ship.Validate();
ship.Hull.Hardpoints.Single(hardpoint => hardpoint.Transform == "g1").FiringArc = 201;
Assert.Contains("joint inner chain reaches 200 degrees", Refusal(ship));
}
[Fact]
@ -160,7 +167,7 @@ public sealed class ShipRigTests : IDisposable
}
[Fact]
public void AJointRidesOnlyAWeaponMountThatNamesARealJoint()
public void AJointRidesOnlyAWeaponMountOrAThrusterEmitterThatNamesARealJoint()
{
var ship = Armed("ghost");
Assert.Contains("names an unknown joint", Refusal(ship));
@ -168,7 +175,10 @@ public sealed class ShipRigTests : IDisposable
ship = Armed("j");
ship.Visual.Joints.Add(Joint("j", null, (Up, -180f, 180f)));
ship.Visual.Anchors.Single(anchor => anchor.Id == "g0.muzzle").Joint = "j";
Assert.Contains("only a weapon mount rides one", Refusal(ship));
Assert.Contains("only a weapon mount or a thruster emitter rides one", Refusal(ship));
var thruster = Thrusting((null, 90f, "ghost"));
Assert.Contains("thruster-emitter anchor t0 names an unknown joint", Refusal(thruster));
}
[Fact]
@ -193,7 +203,7 @@ public sealed class ShipRigTests : IDisposable
{
new Node("map", "map", null), new Node("collider", "collider", null), new Node("shield", "shield", null),
new Node("tractor", "tractor", null), new Node("thruster-port", "thruster-port", null, true),
new Node("arm", "arm", null), new Node("elbow", "elbow", "arm"), new Node("wrist", "wrist", "elbow"),
new Node("pod", "pod", null), new Node("arm", "arm", null), new Node("elbow", "elbow", "arm"), new Node("wrist", "wrist", "elbow"),
new Node("bracket", null, "wrist"), new Node("gun", "gun", "bracket"), new Node("gun-muzzle", "gun-muzzle", "gun")
};
@ -295,9 +305,44 @@ 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;
// The pod is a joint of its own, apart from the arm the gun rides: a joint has one actuator.
ship.Visual.Joints.Add(Joint("pod", null, (Up, -45f, 45f)));
ship.Visual.Anchors.Single(anchor => anchor.Id == "thruster.port").Joint = "pod";
}, Reparent("thruster-port", "pod")));
Assert.Equal("pod", 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.Joints.Add(Joint("pod", null, (Up, -45f, 45f)));
ship.Visual.Anchors.Single(anchor => anchor.Id == "thruster.port").Joint = "pod";
})));
}
[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()
@ -314,12 +359,12 @@ public sealed class ShipRigTests : IDisposable
// ---- ShipModPlan.Rigs ----
[Fact]
public void RigsAreOnePerDrivenJointInHullOrderOfTheirFirstWeaponWithChainsThatStopAtDrivenAncestors()
public void RigsAreOnePerDrivenJointInHullOrderOfTheirFirstWeapon()
{
// g0 on the inner joint, g1 and g2 on the outer one (g2 authored first among the anchors).
var ship = Armed("barrel", "turret", "turret");
ship.Visual.Joints.Add(Joint("turret", null, (Up, -180f, 180f)));
ship.Visual.Joints.Add(Joint("barrel", "turret", (Up, -180f, 180f)));
ship.Visual.Joints.Add(Joint("barrel", null, (Up, -180f, 180f)));
ship.Visual.Anchors.RemoveAll(anchor => anchor.Id == "g1.muzzle");
ship.Visual.Anchors.Add(new ShipAnchor { Id = "g1.b", Role = "weapon-muzzle", ModelNodeId = "g1-b", ParentId = "g1", Order = 1 });
ship.Visual.Anchors.Add(new ShipAnchor { Id = "g1.z", Role = "weapon-muzzle", ModelNodeId = "g1-z", ParentId = "g1", Order = 0 });
@ -378,7 +423,445 @@ public sealed class ShipRigTests : IDisposable
Assert.Equal(new[] { "arm", "elbow", "wrist" }, rig.Chain);
}
// A rig is a gun group's: a thruster row on a joint of its own is drawn by nobody here, and adds no entry.
[Fact]
public void AThrusterRowOnAJointAddsNoRigBesideTheGunRigs()
{
var ship = Armed("j");
ship.Visual.Joints.Add(Joint("j", null, (Up, -180f, 180f)));
ship.Visual.Joints.Add(Joint("k", null, (Up, -45f, 45f)));
var thruster = ship.Hull.Hardpoints.Single(hardpoint => hardpoint.Type == HardpointType.Thruster);
thruster.FiringArc = 90;
ship.Visual.Anchors.Single(anchor => anchor.Id == thruster.Transform).Joint = "k";
var rig = Assert.Single(ShipModPlan.Build(ship.Hull, ship.Visual).Rigs);
Assert.Equal(("j", "g0"), (rig.Joint, rig.LeadMount));
}
[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("joint j carries more than one actuator (t0 and t1)",
Refusal(OnJoint(Thrusting(("r", 90f, "j"), ("s", 90f, "j")), "j", (Up, -45f, 45f))));
Assert.Contains("joint j carries more than one actuator (t0 and t1)",
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("carries more than one actuator", 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));
}
// A joint has one actuator (ruling one-joint-one-actuator): a gun group or one thruster row, never both, never two rows.
[Fact]
public void AGunAndAThrusterRowMayNotShareAJoint()
{
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";
var message = Refusal(ship);
Assert.Contains("joint j carries more than one actuator", message);
Assert.Contains("a joint has one actuator", message);
// The same ship with the thruster on a joint of its own is the acceptance control.
ship.Visual.Joints.Add(Joint("k", null, (Up, -45f, 45f)));
ship.Visual.Anchors.Single(anchor => anchor.Id == thruster.Transform).Joint = "k";
ship.Validate();
// A thruster of a named row is refused on a gun's joint alike.
thruster.Row = "r";
ship.Visual.Anchors.Single(anchor => anchor.Id == thruster.Transform).Joint = "j";
Assert.Contains("a joint has one actuator", Refusal(ship));
// The gun may be any of several: the second gun is the one sharing the thruster's joint.
var second = Armed("k", "j");
second.Visual.Joints.Add(Joint("k", null, (Up, -90f, 90f)));
second.Visual.Joints.Add(Joint("j", null, (Up, -90f, 90f)));
var lone = second.Hull.Hardpoints.Single(hardpoint => hardpoint.Type == HardpointType.Thruster);
lone.FiringArc = 90;
second.Visual.Anchors.Single(anchor => anchor.Id == lone.Transform).Joint = "k";
Assert.Contains("joint k carries more than one actuator", Refusal(second));
second.Visual.Anchors.Single(anchor => anchor.Id == lone.Transform).Joint = "j";
Assert.Contains("joint j carries more than one actuator", Refusal(second));
}
[Fact]
public void TwoRowsMayNotShareAJointWhateverTheirSizesOrOrder()
{
// A row of three and a row of one, the second row's member last; a row of two and one; and a row of four against one.
Assert.Contains("carries more than one actuator", Refusal(OnJoint(Thrusting(("r", 90f, "j"), ("r", 90f, "j"), ("r", 90f, "j"), ("s", 90f, "j")), "j", (Up, -45f, 45f))));
Assert.Contains("carries more than one actuator", Refusal(OnJoint(Thrusting(("r", 90f, "j"), ("r", 90f, "j"), ("s", 90f, "j")), "j", (Up, -45f, 45f))));
// Rows are compared exactly: "r" and "R" are two rows, and so is a blank name against a letter.
Assert.Contains("carries more than one actuator", Refusal(OnJoint(Thrusting(("r", 90f, "j"), ("R", 90f, "j")), "j", (Up, -45f, 45f))));
Assert.Contains("carries more than one actuator", Refusal(OnJoint(Thrusting(("r", 90f, "j"), (" ", 90f, "j")), "j", (Up, -45f, 45f))));
var apart = Thrusting(("r", 90f, "j"), ("R", 90f, "k"));
OnJoint(apart, "j", (Up, -45f, 45f));
OnJoint(apart, "k", (Up, -45f, 45f));
apart.Validate();
}
[Fact]
public void ARowOfAnySizeRidesItsJointWholeOrIsRefused()
{
foreach (var size in new[] { 3, 4, 5 })
for (var left = 0; left < size; left++)
{
// Every member on j except one, which is on no joint or on another; the one may stand anywhere in the row.
foreach (var elsewhere in new string[] { null, "k" })
{
var members = Enumerable.Range(0, size).Select(index => ("r", 90f, index == left ? elsewhere : "j")).ToArray();
var ship = OnJoint(Thrusting(members), "j", (Up, -45f, 45f));
OnJoint(ship, "k", (Up, -45f, 45f));
Assert.Contains("must ride it too", Refusal(ship));
}
}
// The same rows, every member on j, are valid.
foreach (var size in new[] { 3, 4, 5 })
OnJoint(Thrusting(Enumerable.Range(0, size).Select(_ => ("r", 90f, "j")).ToArray()), "j", (Up, -45f, 45f)).Validate();
}
[Fact]
public void ARowSharesRotationAndTheExactArcAcrossAnyNumberOfMembers()
{
// The offender may be the second, third or fourth member, and may differ by a hair.
foreach (var arc in new[] { 89.5f, 89.99f, 60f, 91f, 120f, 270f })
for (var offender = 1; offender < 4; offender++)
{
var members = Enumerable.Range(0, 4).Select(index => ("r", index == offender ? arc : 90f, (string)null)).ToArray();
Assert.Contains("must share Rotation and FiringArc", Refusal(Thrusting(members)));
}
var rotated = Thrusting(("r", 90f, null), ("r", 90f, null), ("r", 90f, null));
rotated.Hull.Hardpoints[2].Rotation = ItemRotation.Reversed;
Assert.Contains("must share Rotation and FiringArc", Refusal(rotated));
Thrusting(("r", 90f, null), ("r", 90f, null), ("r", 90f, null), ("r", 90f, null)).Validate();
}
[Fact]
public void AnArcIsJudgedAgainstTheReachAtEverySizeUpToATurn()
{
// The reach is the arc to the degree for a row of one, a row of three and a row of four, below, at and above 180.
foreach (var arc in new[] { 10f, 100f, 181f, 270f, 359f, 360f })
foreach (var size in new[] { 1, 3, 4 })
{
var members = Enumerable.Range(0, size).Select(_ => (size == 1 ? null : "r", arc, "j")).ToArray();
OnJoint(Thrusting(members), "j", (Up, -arc / 2, arc / 2)).Validate();
if (arc < 360f)
{
var short1 = Refusal(OnJoint(Thrusting(members), "j", (Up, -(arc - 1f) / 2, (arc - 1f) / 2)));
Assert.Contains($"chain reaches {arc - 1f} degrees", short1);
}
}
}
[Fact]
public void NoThrusterSwingsFurtherThanATurnWhetherOrNotItIsInARow()
{
foreach (var arc in new[] { 360.01f, 360.5f, 361f, 400f, 720f, 1000f })
{
Assert.Contains("firing arc may be at most 360 degrees", Refusal(Thrusting((null, arc, null))));
Assert.Contains("firing arc may be at most 360 degrees", Refusal(Thrusting(("r", arc, null))));
Assert.Contains("firing arc may be at most 360 degrees", Refusal(Thrusting(("r", arc, null), ("r", arc, null), ("r", arc, null))));
Assert.Contains("firing arc may be at most 360 degrees", Refusal(OnJoint(Thrusting((null, arc, "j")), "j", (Up, -180f, 180f))));
}
Thrusting((null, 360f, null)).Validate();
Thrusting((null, 359f, null), ("r", 360f, null), ("r", 360f, null)).Validate();
}
[Fact]
public void OnlyAThrusterHardpointMayStateARowWhateverItsType()
{
foreach (var type in Enum.GetValues(typeof(HardpointType)).Cast<HardpointType>().Where(type => type != HardpointType.Thruster))
{
var ship = Thrusting((null, 0f, null));
ship.Hull.Hardpoints.Single().Type = type;
ship.Hull.Hardpoints.Single().Row = "r";
Assert.Contains($"is a {type} hardpoint, and only a Thruster hardpoint may state a row", Refusal(ship));
}
}
// Nesting is refused whichever actuators nest and whichever sits above: ruling one-joint-one-actuator says the judge refuses a
// row whose reach includes a joint another actuator drives.
[Fact]
public void AnActuatorWhoseReachRunsThroughAJointAnotherActuatorDrivesIsRefusedWhicheverKindsNest()
{
// row under row
var rows = Thrusting(("r", 90f, "outer"), ("s", 40f, "inner"));
OnJoint(rows, "outer", (Up, -45f, 45f));
rows.Visual.Joints.Add(Joint("inner", "outer", (Up, -20f, 20f)));
Assert.Contains("joint inner reaches through joint outer, which another actuator drives", Refusal(rows));
// a row of one under a row, and the row's reach is no excuse
var single = Thrusting(("r", 90f, "outer"), (null, 40f, "inner"));
OnJoint(single, "outer", (Up, -180f, 180f));
single.Visual.Joints.Add(Joint("inner", "outer", (Up, -180f, 180f)));
Assert.Contains("reaches through joint outer", Refusal(single));
// a gun under a row's joint
var gun = Armed("child");
gun.Visual.Joints.Add(Joint("top", null, (Up, -45f, 45f)));
gun.Visual.Joints.Add(Joint("child", "top", (Up, -20f, 20f)));
var thruster = gun.Hull.Hardpoints.Single(hardpoint => hardpoint.Type == HardpointType.Thruster);
thruster.FiringArc = 90;
gun.Visual.Anchors.Single(anchor => anchor.Id == thruster.Transform).Joint = "top";
gun.Hull.Hardpoints.Single(hardpoint => hardpoint.Transform == "g0").FiringArc = 40;
Assert.Contains("joint child reaches through joint top", Refusal(gun));
// a row under a gun's joint
var below = Armed("top");
below.Visual.Joints.Add(Joint("top", null, (Up, -180f, 180f)));
below.Visual.Joints.Add(Joint("low", "top", (Up, -20f, 20f)));
var lowThruster = below.Hull.Hardpoints.Single(hardpoint => hardpoint.Type == HardpointType.Thruster);
lowThruster.FiringArc = 40;
below.Visual.Anchors.Single(anchor => anchor.Id == lowThruster.Transform).Joint = "low";
Assert.Contains("joint low reaches through joint top", Refusal(below));
// Nesting is refused wherever the driven ancestor stands in the chain, with undriven joints between.
var deep = Armed("top", "leaf");
deep.Visual.Joints.Add(Joint("top", null, (Up, -90f, 90f)));
deep.Visual.Joints.Add(Joint("mid", "top", (Up, -90f, 90f)));
deep.Visual.Joints.Add(Joint("leaf", "mid", (Up, -90f, 90f)));
Assert.Contains("joint leaf reaches through joint top", Refusal(deep));
// The driven ancestor may be the immediate parent, or any one of several, under an undriven root.
foreach (var drivenAt in new[] { "mid", "upper", "upper2" })
{
var tall = Armed(drivenAt, "leaf");
tall.Visual.Joints.Add(Joint("root", null, (Up, -90f, 90f)));
tall.Visual.Joints.Add(Joint("mid", "root", (Up, -90f, 90f)));
tall.Visual.Joints.Add(Joint("upper", "mid", (Up, -90f, 90f)));
tall.Visual.Joints.Add(Joint("upper2", "upper", (Up, -90f, 90f)));
tall.Visual.Joints.Add(Joint("leaf", "upper2", (Up, -90f, 90f)));
Assert.Contains($"joint leaf reaches through joint {drivenAt}", Refusal(tall));
}
// Control: the same actuators on joints whose reaches are disjoint are accepted, and an undriven parent counts.
var apart = Armed("top", "leaf");
apart.Visual.Joints.Add(Joint("top", null, (Up, -90f, 90f)));
apart.Visual.Joints.Add(Joint("base", null, (Up, -90f, 90f)));
apart.Visual.Joints.Add(Joint("leaf", "base", (Up, -90f, 90f)));
apart.Validate();
var lowApart = Thrusting(("r", 90f, "low"), ("s", 90f, "other"));
OnJoint(lowApart, "top", (Up, -45f, 45f));
lowApart.Visual.Joints.Add(Joint("low", "top", (Up, -20f, 20f)));
OnJoint(lowApart, "other", (Up, -45f, 45f));
lowApart.Validate();
}
// The refusal does not depend on how many thrusters a row has, or how wide the gun's arc is beside the row's.
[Fact]
public void AGunAndAThrusterRowOnOneJointAreRefusedWhateverTheirSizes()
{
foreach (var gunArc in new[] { 10f, 45f, 89f, 90f, 91f, 180f, 360f })
foreach (var size in new[] { 1, 2, 3, 4, 5 })
foreach (var named in new[] { false, true })
{
var ship = Armed("j");
ship.Visual.Joints.Add(Joint("j", null, (Up, -180f, 180f)));
ship.Hull.Hardpoints.Single(hardpoint => hardpoint.Transform == "g0").FiringArc = gunArc;
// The fixture thruster is the first member; the rest are appended, each its own cell and emitter.
var rowName = named || size > 1 ? "r" : null;
var first = ship.Hull.Hardpoints.Single(hardpoint => hardpoint.Type == HardpointType.Thruster);
first.FiringArc = 90; first.Row = rowName;
ship.Visual.Anchors.Single(anchor => anchor.Id == first.Transform).Joint = "j";
var shape = new Shape(2 + size - 1, 1);
for (var x = 0; x < 2 + size - 1; x++) shape.Cells[x, 0] = true;
ship.Hull.Shape = shape;
for (var extra = 1; extra < size; extra++)
{
ship.Hull.Hardpoints.Add(new HardpointData
{ Type = HardpointType.Thruster, Position = new int2(1 + extra, 0), Shape = new Shape(), Transform = $"x{extra}", FiringArc = 90, Row = "r" });
ship.Visual.Anchors.Add(new ShipAnchor { Id = $"x{extra}", Role = "thruster-emitter", ModelNodeId = $"x{extra}", Joint = "j" });
}
Assert.Contains("joint j carries more than one actuator", Refusal(ship));
}
}
// Every kind of conflict falls out of one definition of what drives a joint: a gun and a row, two rows, two rows of one, a
// row and a thruster of no row, and a gun that nests.
[Fact]
public void EveryKindOfSharedDrivingIsRefusedByTheOneRuleThatSaysWhatDrivesAJoint()
{
var gunRow = Armed("j");
gunRow.Visual.Joints.Add(Joint("j", null, (Up, -180f, 180f)));
var thruster = gunRow.Hull.Hardpoints.Single(hardpoint => hardpoint.Type == HardpointType.Thruster);
thruster.FiringArc = 90;
gunRow.Visual.Anchors.Single(anchor => anchor.Id == thruster.Transform).Joint = "j";
Assert.Contains("more than one actuator", Refusal(gunRow));
Assert.Contains("more than one actuator", Refusal(OnJoint(Thrusting(("r", 90f, "j"), ("s", 90f, "j")), "j", (Up, -180f, 180f))));
Assert.Contains("more than one actuator", Refusal(OnJoint(Thrusting((null, 90f, "j"), (null, 90f, "j")), "j", (Up, -180f, 180f))));
Assert.Contains("more than one actuator", Refusal(OnJoint(Thrusting(("r", 90f, "j"), (null, 90f, "j")), "j", (Up, -180f, 180f))));
var nested = Armed("a", "b");
nested.Visual.Joints.Add(Joint("a", null, (Up, -180f, 180f)));
nested.Visual.Joints.Add(Joint("b", "a", (Up, -180f, 180f)));
Assert.Contains("which another actuator drives", Refusal(nested));
// Control: two guns on one joint are one actuator.
var guns = Armed("j", "j");
guns.Visual.Joints.Add(Joint("j", null, (Up, -180f, 180f)));
guns.Validate();
}
// Shipped hulls name a Unity prefab, not a rig, so the shipped catalog may hold none that rides a joint; every hull that does
// name a visual record must pass the judge, and the count of those is what this run actually judged.
[Fact]
public void EveryShippedHullThatNamesARigPassesTheJudge()
{
var path = Path.Combine(_directory.Path, "shipped.cc");
File.Copy(Path.Combine(RunStartTests.FindRepoRoot(), "GameData", "Aetheria.cc"), path);
using var catalog = new CultCache(TestCatalog.Registry());
catalog.AddBackingStore(new SingleFileMessagePackBackingStore(path), AetheriaStores.CatalogTypes);
var hulls = catalog.GetAll<ItemData>().OfType<HullData>().ToArray();
Assert.NotEmpty(hulls);
foreach (var hull in hulls.Where(hull => hull.Visual.IsSet()))
ShipAuthoringStore.Validate(hull, catalog.Get(hull.Visual));
}
// 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

@ -240,8 +240,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 ''");
@ -303,6 +303,25 @@ 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.None)
{
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.");
Add("row-arc-above-a-turn", s => Row(s, 361, 361), "thruster hardpoint thruster.port firing arc may be at most 360 degrees");
Add("row-of-one-arc-above-a-turn", s => Mount(s).FiringArc = 361, "thruster hardpoint thruster.port firing arc may be at most 360 degrees");
Add("row-of-one-arc-far-above-a-turn", s => Mount(s).FiringArc = 400, "firing arc may be at most 360 degrees");
return cases;
}
}