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:
commit
27094e8859
7 changed files with 614 additions and 40 deletions
|
|
@ -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)
|
||||
{
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
{
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
|
|
|
|||
|
|
@ -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.");
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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));
|
||||
|
||||
|
|
|
|||
|
|
@ -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));
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
}
|
||||
|
|
|
|||
Loading…
Reference in a new issue