RigChains owns what drives a joint; the judge refuses two actuators on a joint and a reach through a driven joint

RigChains.Actuates/Drives/Actuator are the one definition of actuator kinds; ValidateRig, BindRig and ShipModPlan consume
it, and the hand-derived gun-versus-row check and the chain stop are deleted. A nested actuator is now refused, as ruling
one-joint-one-actuator says. Findings nested-actuators-accepted and actuator-kinds-spelled-in-four-places.
Builds; the tests of the previous batch that pinned nesting are red until the next commit.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
Meta Vens 2026-10-12 21:31:14 +02:00
commit 27ecd1f9c0
3 changed files with 52 additions and 33 deletions

View file

@ -306,8 +306,7 @@ public static class ShipAuthoringStore
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)))
@ -388,21 +387,39 @@ public static class ShipAuthoringStore
{
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")
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}: {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);
@ -411,26 +428,18 @@ public static class ShipAuthoringStore
}
// 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.
// 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();
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)
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(hardpoint => jointOf[hardpoint.Transform], StringComparer.Ordinal))
foreach (var onJoint in tilted.GroupBy(drive => drive.Joint, StringComparer.Ordinal))
{
// A joint has one actuator: a gun group or one thruster row, never both.
if (ridden.Any(mount => mount.Joint == onJoint.Key))
throw new InvalidOperationException($"{ship.Id}: joint {onJoint.Key} carries both a weapon and thruster hardpoint {onJoint.First().Transform}; a joint has one actuator.");
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();
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 (!jointOf.TryGetValue(member.Transform, out var memberJoint) || memberJoint != onJoint.Key)
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)
@ -451,22 +460,31 @@ public static class ShipAuthoringStore
}
}
// A rig's driven joints and the chains that move them. A driven joint is one a weapon-mount or thruster-emitter anchor
// names (a joint has one actuator, a gun group or a thruster row). 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 actuator, and its pose is that
// actuator'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" || anchor.Role == "thruster-emitter") && !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,7 +216,7 @@ 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;

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));