From 27ecd1f9c02acc0ad476a0eaa3ae943140b46a27 Mon Sep 17 00:00:00 2001 From: Meta Vens Date: Mon, 12 Oct 2026 21:31:14 +0200 Subject: [PATCH] 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 --- Assets/Scripts/ServerShared/ShipAuthoring.cs | 80 ++++++++++++------- Assets/Scripts/ServerShared/ShipModCatalog.cs | 2 +- Assets/Scripts/ServerShared/ShipModPlan.cs | 5 +- 3 files changed, 53 insertions(+), 34 deletions(-) diff --git a/Assets/Scripts/ServerShared/ShipAuthoring.cs b/Assets/Scripts/ServerShared/ShipAuthoring.cs index e499396d0..a1af8fbb6 100644 --- a/Assets/Scripts/ServerShared/ShipAuthoring.cs +++ b/Assets/Scripts/ServerShared/ShipAuthoring.cs @@ -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 Driven(ShipAuthoring ship) => - new HashSet(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 Driven(ShipAuthoring ship) => + new HashSet(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 { 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(); diff --git a/Assets/Scripts/ServerShared/ShipModCatalog.cs b/Assets/Scripts/ServerShared/ShipModCatalog.cs index 1f7bd2647..cc30af0ab 100644 --- a/Assets/Scripts/ServerShared/ShipModCatalog.cs +++ b/Assets/Scripts/ServerShared/ShipModCatalog.cs @@ -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; diff --git a/Assets/Scripts/ServerShared/ShipModPlan.cs b/Assets/Scripts/ServerShared/ShipModPlan.cs index f242ab037..6f659edac 100644 --- a/Assets/Scripts/ServerShared/ShipModPlan.cs +++ b/Assets/Scripts/ServerShared/ShipModPlan.cs @@ -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));