diff --git a/Assets/Scripts/ServerShared/ShipAuthoring.cs b/Assets/Scripts/ServerShared/ShipAuthoring.cs index d7263fd42..837bcefc2 100644 --- a/Assets/Scripts/ServerShared/ShipAuthoring.cs +++ b/Assets/Scripts/ServerShared/ShipAuthoring.cs @@ -24,8 +24,8 @@ public sealed class ShipAnchor { // Stable semantic identity. HullData.Hardpoints[].Transform names this ID for mounts. [Key(0)] public string Id; - // One of: map-icon, hull-collider, shield, tractor, thruster-emitter, - // weapon-muzzle, radiator-mesh, or articulation. + // One of: map-icon, hull-collider, shield, tractor, thruster-emitter, weapon-mount, + // weapon-muzzle, radiator-mesh, or articulation (a pivot, never a mount). [Key(1)] public string Role; // Stable node ID in the compiled model, never a Blender display name. [Key(2)] public string ModelNodeId; @@ -53,9 +53,19 @@ public static class ShipAuthoringStore // ShipAnchor.Role's vocabulary. private static readonly HashSet Roles = new HashSet(StringComparer.Ordinal) { - "map-icon", "hull-collider", "shield", "tractor", "thruster-emitter", "weapon-muzzle", "radiator-mesh", "articulation" + "map-icon", "hull-collider", "shield", "tractor", "thruster-emitter", "weapon-mount", "weapon-muzzle", "radiator-mesh", + "articulation" }; + // The anchor role a visible hardpoint's mount plays, or null for an internal hardpoint, which has no anchor. + private static string MountRole(HardpointType type) => + type == HardpointType.Thruster ? "thruster-emitter" : + type == HardpointType.Radiator ? "radiator-mesh" : + IsWeapon(type) ? "weapon-mount" : null; + + public static bool IsWeapon(HardpointType type) => + type == HardpointType.Energy || type == HardpointType.Ballistic || type == HardpointType.Launcher; + public static CultCache Open(string path, bool writable = false) { var cache = new CultCache(); @@ -193,13 +203,13 @@ public static class ShipAuthoringStore if (anchors.Count(anchor => anchor.Role == role) != 1) throw new InvalidOperationException($"{ship.Id}: exactly one {role} anchor is required."); - var mounts = new HashSet(StringComparer.Ordinal); + var mounts = new Dictionary(StringComparer.Ordinal); var occupiedHardpointCells = new HashSet<(int x, int y)>(); foreach (var hardpoint in hull.Hardpoints ?? new List()) { - if (hardpoint == null || string.IsNullOrWhiteSpace(hardpoint.Transform) || - !mounts.Add(hardpoint.Transform)) + if (hardpoint == null || string.IsNullOrWhiteSpace(hardpoint.Transform) || mounts.ContainsKey(hardpoint.Transform)) throw new InvalidOperationException($"{ship.Id}: hardpoint IDs must be present and unique."); + mounts.Add(hardpoint.Transform, hardpoint); if (!Enum.IsDefined(typeof(HardpointType), hardpoint.Type)) throw new InvalidOperationException($"{ship.Id}: hardpoint {hardpoint.Transform} has an unknown type {(int)hardpoint.Type}."); if (!Enum.IsDefined(typeof(ItemRotation), hardpoint.Rotation)) @@ -219,19 +229,32 @@ public static class ShipAuthoringStore if (!occupiedHardpointCells.Add((x, y))) throw new InvalidOperationException($"{ship.Id}: hardpoint {hardpoint.Transform} overlaps another hardpoint."); } + // Only a hardpoint that shows on the model has a mount anchor, and its type fixes the role that anchor plays. var mountAnchor = anchors.FirstOrDefault(anchor => anchor.Id == hardpoint.Transform); - if (mountAnchor == null) - throw new InvalidOperationException($"{ship.Id}: hardpoint {hardpoint.Transform} has no model anchor."); - // A mount's anchor plays the mount, so its role is fixed by the hardpoint type; the structural roles (map-icon, - // hull-collider, shield, tractor, weapon-muzzle) name other nodes and never double as a mount. - var mountRole = hardpoint.Type == HardpointType.Thruster ? "thruster-emitter" : - hardpoint.Type == HardpointType.Radiator ? "radiator-mesh" : "articulation"; - if (mountAnchor.Role != mountRole) + var mountRole = MountRole(hardpoint.Type); + if (mountRole == null && mountAnchor != null) + throw new InvalidOperationException($"{ship.Id}: {hardpoint.Type} hardpoint {hardpoint.Transform} is internal, so no anchor may take its id."); + if (mountRole != null && mountAnchor == null) + throw new InvalidOperationException($"{ship.Id}: {hardpoint.Type} hardpoint {hardpoint.Transform} has no model anchor."); + 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 anchor in anchors.Where(anchor => !string.IsNullOrEmpty(anchor.ParentId))) - if (!mounts.Contains(anchor.ParentId)) + 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)) + 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))) + throw new InvalidOperationException($"{ship.Id}: muzzle {anchor.Id} must be parented to a weapon hardpoint's mount."); + } + foreach (var hardpoint in mounts.Values.Where(hardpoint => IsWeapon(hardpoint.Type))) + if (!anchors.Any(anchor => anchor.Role == "weapon-muzzle" && anchor.ParentId == hardpoint.Transform)) + throw new InvalidOperationException($"{ship.Id}: weapon hardpoint {hardpoint.Transform} needs at least one muzzle anchor."); foreach (var line in ship.SchematicLines ?? new List()) { diff --git a/Assets/Scripts/ServerShared/ShipModPlan.cs b/Assets/Scripts/ServerShared/ShipModPlan.cs index 787656a3e..bb80647a1 100644 --- a/Assets/Scripts/ServerShared/ShipModPlan.cs +++ b/Assets/Scripts/ServerShared/ShipModPlan.cs @@ -13,37 +13,18 @@ public sealed class ShipModPlan // Mount ids whose anchor (role thruster-emitter / radiator-mesh) is the emitter / mesh node itself. public string[] Thrusters; public string[] Radiators; - // A weapon mount's muzzle anchors, by Order then id. + // Each weapon mount (role weapon-mount) and its muzzle anchors, by Order then id. public (string Mount, string[] Muzzles)[] Weapons; - // Anchors with role "articulation" become ordinary mount nodes: ArticulationPoint's limits are not authorable until - // pivots move onto HullData, so no ArticulationPoint is planned. + // Maps a pair Validate accepts; every rule about which anchor may play which part is Validate's, so this refuses nothing. public static ShipModPlan Build(HullData hull, ShipAuthoring visual) { ShipAuthoringStore.Validate(hull, visual); var hardpoints = hull.Hardpoints ?? new List(); - var byMount = hardpoints.ToDictionary(hardpoint => hardpoint.Transform, StringComparer.Ordinal); string Only(string wanted) => visual.Anchors.Single(anchor => anchor.Role == wanted).Id; - - foreach (var anchor in visual.Anchors.Where(anchor => anchor.Role == "thruster-emitter" || anchor.Role == "radiator-mesh")) - { - var wanted = anchor.Role == "thruster-emitter" ? HardpointType.Thruster : HardpointType.Radiator; - if (!byMount.ContainsKey(anchor.Id)) - throw new InvalidOperationException($"{visual.Id}: {anchor.Role} anchor {anchor.Id} must be a {wanted} hardpoint's mount."); - } string[] Mounts(HardpointType type) => hardpoints.Where(hardpoint => hardpoint.Type == type).Select(hardpoint => hardpoint.Transform).ToArray(); - var muzzles = visual.Anchors.Where(anchor => anchor.Role == "weapon-muzzle") .OrderBy(anchor => anchor.Order).ThenBy(anchor => anchor.Id, StringComparer.Ordinal).ToArray(); - foreach (var muzzle in muzzles) - if (string.IsNullOrEmpty(muzzle.ParentId) || !IsWeapon(byMount[muzzle.ParentId].Type)) - throw new InvalidOperationException($"{visual.Id}: muzzle {muzzle.Id} must be parented to a weapon hardpoint's mount."); - var weapons = hardpoints.Where(hardpoint => IsWeapon(hardpoint.Type)).Select(hardpoint => ( - hardpoint.Transform, - muzzles.Where(muzzle => muzzle.ParentId == hardpoint.Transform).Select(muzzle => muzzle.Id).ToArray())).ToArray(); - foreach (var (mount, ids) in weapons) - if (ids.Length == 0) - throw new InvalidOperationException($"{visual.Id}: weapon hardpoint {mount} needs at least one muzzle anchor."); return new ShipModPlan { @@ -53,10 +34,9 @@ public sealed class ShipModPlan Tractor = Only("tractor"), Thrusters = Mounts(HardpointType.Thruster), Radiators = Mounts(HardpointType.Radiator), - Weapons = weapons + Weapons = hardpoints.Where(hardpoint => ShipAuthoringStore.IsWeapon(hardpoint.Type)).Select(hardpoint => ( + hardpoint.Transform, + muzzles.Where(muzzle => muzzle.ParentId == hardpoint.Transform).Select(muzzle => muzzle.Id).ToArray())).ToArray() }; } - - private static bool IsWeapon(HardpointType type) => - type == HardpointType.Energy || type == HardpointType.Ballistic || type == HardpointType.Launcher; } diff --git a/tests/Aetheria.Shared.Tests/ShipAuthoringTests.cs b/tests/Aetheria.Shared.Tests/ShipAuthoringTests.cs index 2d6680933..349411ea2 100644 --- a/tests/Aetheria.Shared.Tests/ShipAuthoringTests.cs +++ b/tests/Aetheria.Shared.Tests/ShipAuthoringTests.cs @@ -230,49 +230,137 @@ public sealed class ShipAuthoringTests Assert.Contains("records must be stored under", Assert.Throws(() => ShipAuthoringStore.Load(wrongVisualKey)).Message); } - // A mount's anchor plays the mount, so a structural anchor (the hull collider, the shield, a muzzle...) is never one. - // Interim ruling until an `equipment` role exists: a mount that is not a thruster or radiator is an articulation anchor. - [Theory] - [InlineData("map-icon", "map")] - [InlineData("hull-collider", "collider")] - [InlineData("shield", "shield")] - [InlineData("tractor", "tractor")] - [InlineData("weapon-muzzle", "muzzle")] - [InlineData("thruster-emitter", "emitter")] - [InlineData("radiator-mesh", "mesh")] - public void AStructuralAnchorCannotBeAnEquipmentMount(string role, string anchorId) + private static readonly string[] AllRoles = + { + "map-icon", "hull-collider", "shield", "tractor", "thruster-emitter", "weapon-mount", "weapon-muzzle", "radiator-mesh", "articulation" + }; + + // The fixture with its thruster swapped for one hardpoint of the given type at mount id "mount", and no anchor for it. + private static ShipParts WithOnly(HardpointType type) { var ship = Fixture(); - // The extra anchors exist so the role-count and parent checks pass; the Tool hardpoint then claims the anchor. - ship.Visual.Anchors.Add(new ShipAnchor { Id = "muzzle", Role = "weapon-muzzle", ModelNodeId = "muzzle", ParentId = "thruster.port" }); - ship.Visual.Anchors.Add(new ShipAnchor { Id = "emitter", Role = "thruster-emitter", ModelNodeId = "emitter" }); - ship.Visual.Anchors.Add(new ShipAnchor { Id = "mesh", Role = "radiator-mesh", ModelNodeId = "mesh" }); - ship.Hull.Hardpoints.Add(new HardpointData { Type = HardpointType.Tool, Position = new int2(0, 0), Shape = new Shape(), Transform = anchorId }); + ship.Hull.Hardpoints.Clear(); + ship.Visual.Anchors.RemoveAll(anchor => anchor.Id == "thruster.port"); + ship.Hull.Hardpoints.Add(new HardpointData { Type = type, Position = new int2(0, 0), Shape = new Shape(), Transform = "mount" }); + return ship; + } - Assert.Contains($"Tool hardpoint {anchorId} needs a articulation anchor of that id", Assert.Throws(ship.Validate).Message); - ship = Fixture(); - ship.Visual.Anchors.Add(new ShipAnchor { Id = "tool", Role = "articulation", ModelNodeId = "tool" }); - ship.Hull.Hardpoints.Add(new HardpointData { Type = HardpointType.Tool, Position = new int2(0, 0), Shape = new Shape(), Transform = "tool" }); - ship.Validate(); + private static string Refusal(ShipParts ship) => Assert.Throws(ship.Validate).Message; + + // Mount anchors exist only for hardpoints that show on the model: weapons, radiators and thrusters. + [Theory] + [InlineData(HardpointType.Hull), InlineData(HardpointType.Tool), InlineData(HardpointType.Thermal), InlineData(HardpointType.WarpDrive), + InlineData(HardpointType.Reactor), InlineData(HardpointType.Shield), InlineData(HardpointType.Sensors), + InlineData(HardpointType.ControlModule), InlineData(HardpointType.AetherDrive)] + public void AnInternalHardpointCarriesNoAnchor(HardpointType type) + { + WithOnly(type).Validate(); + + // Any anchor taking the mount's id is refused, a mount role or not. (The four structural roles are one-of-a-kind, + // so they are covered by renaming the existing anchor.) + foreach (var role in AllRoles) + { + var ship = WithOnly(type); + var existing = ship.Visual.Anchors.FirstOrDefault(anchor => anchor.Role == role); + if (existing != null) existing.Id = "mount"; + else ship.Visual.Anchors.Add(new ShipAnchor { Id = "mount", Role = role, ModelNodeId = "mount" }); + Assert.Contains($"mod.skiff: {type} hardpoint mount is internal, so no anchor may take its id", Refusal(ship)); + } + } + + [Theory] + [InlineData(HardpointType.Thruster, "thruster-emitter")] + [InlineData(HardpointType.Radiator, "radiator-mesh")] + [InlineData(HardpointType.Energy, "weapon-mount")] + [InlineData(HardpointType.Ballistic, "weapon-mount")] + [InlineData(HardpointType.Launcher, "weapon-mount")] + public void EachVisibleHardpointNeedsItsRoleAnchor(HardpointType type, string wanted) + { + ShipParts Rig() + { + var ship = WithOnly(type); + if (ShipAuthoringStore.IsWeapon(type)) + ship.Visual.Anchors.Add(new ShipAnchor { Id = "mount.muzzle", Role = "weapon-muzzle", ModelNodeId = "muzzle", ParentId = "mount" }); + return ship; + } + + Assert.Contains($"mod.skiff: {type} hardpoint mount has no model anchor", Refusal(Rig())); + + // A structural anchor (the map icon, the collider...) whose id is the mount, or a mount anchor of the wrong role. + foreach (var role in AllRoles.Where(role => role != wanted)) + { + var ship = Rig(); + var existing = ship.Visual.Anchors.FirstOrDefault(anchor => anchor.Role == role); + if (existing != null) existing.Id = "mount"; + else ship.Visual.Anchors.Add(new ShipAnchor { Id = "mount", Role = role, ModelNodeId = "mount" }); + Assert.Contains($"mod.skiff: {type} hardpoint mount needs a {wanted} anchor of that id", Refusal(ship)); + } + + var valid = Rig(); + valid.Visual.Anchors.Add(new ShipAnchor { Id = "mount", Role = wanted, ModelNodeId = "mount" }); + valid.Validate(); + } + + // Called on Validate itself: a mount-role anchor is the mount of a hardpoint of its own type, never a loose node and + // never another type's mount. + [Theory] + [InlineData("thruster-emitter", HardpointType.Radiator)] + [InlineData("thruster-emitter", HardpointType.Energy)] + [InlineData("thruster-emitter", HardpointType.Reactor)] + [InlineData("radiator-mesh", HardpointType.Thruster)] + [InlineData("radiator-mesh", HardpointType.Launcher)] + [InlineData("radiator-mesh", HardpointType.Sensors)] + [InlineData("weapon-mount", HardpointType.Thruster)] + [InlineData("weapon-mount", HardpointType.Radiator)] + [InlineData("weapon-mount", HardpointType.Tool)] + public void AMountRoleAnchorMustBeAMountOfItsType(string role, HardpointType otherType) + { + string Refused(ShipParts ship) => + Assert.Throws(() => ShipAuthoringStore.Validate(ship.Hull, ship.Visual)).Message; + + var loose = Fixture(); + loose.Visual.Anchors.Add(new ShipAnchor { Id = "loose", Role = role, ModelNodeId = "loose" }); + Assert.Contains($"mod.skiff: {role} anchor loose must be the mount of a hardpoint of its type", Refused(loose)); + + // The anchor takes the id of a hardpoint of another type; whichever side the validator reads first, it refuses. + var other = WithOnly(otherType); + other.Visual.Anchors.Add(new ShipAnchor { Id = "mount", Role = role, ModelNodeId = "mount" }); + if (ShipAuthoringStore.IsWeapon(otherType)) + other.Visual.Anchors.Add(new ShipAnchor { Id = "mount.muzzle", Role = "weapon-muzzle", ModelNodeId = "muzzle", ParentId = "mount" }); + Assert.Contains($"mod.skiff: {otherType} hardpoint mount ", Refused(other)); } [Fact] - public void EveryHardpointTypeHasOneAnchorRoleForItsMount() + public void MuzzleRulesBelongToTheValidator() { - foreach (HardpointType type in Enum.GetValues(typeof(HardpointType))) + ShipParts Armed() { - var wanted = type == HardpointType.Thruster ? "thruster-emitter" : type == HardpointType.Radiator ? "radiator-mesh" : "articulation"; - foreach (var role in new[] { "articulation", "thruster-emitter", "radiator-mesh" }) - { - var ship = Fixture(); - ship.Hull.Hardpoints.Clear(); - ship.Visual.Anchors.RemoveAll(anchor => anchor.Id == "thruster.port"); - ship.Hull.Hardpoints.Add(new HardpointData { Type = type, Position = new int2(0, 0), Shape = new Shape(), Transform = "mount" }); - ship.Visual.Anchors.Add(new ShipAnchor { Id = "mount", Role = role, ModelNodeId = "mount" }); - if (role == wanted) ship.Validate(); - else Assert.Contains($"{type} hardpoint mount needs a {wanted} anchor of that id", Assert.Throws(ship.Validate).Message); - } + var ship = Fixture(); + ship.Hull.Hardpoints.Add(new HardpointData { Type = HardpointType.Energy, Position = new int2(0, 0), Shape = new Shape(), Transform = "gun" }); + ship.Visual.Anchors.Add(new ShipAnchor { Id = "gun", Role = "weapon-mount", ModelNodeId = "gun" }); + ship.Visual.Anchors.Add(new ShipAnchor { Id = "gun.b", Role = "weapon-muzzle", ModelNodeId = "gun-b", ParentId = "gun", Order = 0 }); + ship.Visual.Anchors.Add(new ShipAnchor { Id = "gun.a", Role = "weapon-muzzle", ModelNodeId = "gun-a", ParentId = "gun", Order = 0 }); + ship.Visual.Anchors.Add(new ShipAnchor { Id = "gun.z", Role = "weapon-muzzle", ModelNodeId = "gun-z", ParentId = "gun", Order = -1 }); + return ship; } + + var orphan = Armed(); + orphan.Visual.Anchors.Single(anchor => anchor.Id == "gun.z").ParentId = null; + Assert.Contains("mod.skiff: muzzle gun.z must be parented to a weapon hardpoint's mount", Refusal(orphan)); + + var onThruster = Armed(); + onThruster.Visual.Anchors.Single(anchor => anchor.Id == "gun.z").ParentId = "thruster.port"; + Assert.Contains("mod.skiff: muzzle gun.z must be parented to a weapon hardpoint's mount", Refusal(onThruster)); + + var unarmed = Armed(); + unarmed.Visual.Anchors.RemoveAll(anchor => anchor.Role == "weapon-muzzle"); + Assert.Contains("mod.skiff: weapon hardpoint gun needs at least one muzzle anchor", Refusal(unarmed)); + + // A valid pair plans without a refusal of the plan's own; muzzles by Order, then id ordinally. + var valid = Armed(); + var (mount, muzzles) = Assert.Single(ShipModPlan.Build(valid.Hull, valid.Visual).Weapons); + Assert.Equal("gun", mount); + Assert.Equal(new[] { "gun.z", "gun.a", "gun.b" }, muzzles); } internal static ShipParts Fixture() diff --git a/tests/Aetheria.Shared.Tests/ShipFixture.cs b/tests/Aetheria.Shared.Tests/ShipFixture.cs index eb4f5c01e..2e8d4994e 100644 --- a/tests/Aetheria.Shared.Tests/ShipFixture.cs +++ b/tests/Aetheria.Shared.Tests/ShipFixture.cs @@ -68,7 +68,7 @@ internal static class ShipFixture var ship = ShipAuthoringTests.Fixture(); // A playable rig: the fixture thruster plus one energy weapon mount on the hull's other cell, with its muzzle. ship.Hull.Hardpoints.Add(new HardpointData { Type = HardpointType.Energy, Position = new int2(0, 0), Shape = new Shape(), Transform = "gun" }); - ship.Visual.Anchors.Add(new ShipAnchor { Id = "gun", Role = "articulation", ModelNodeId = "gun" }); + ship.Visual.Anchors.Add(new ShipAnchor { Id = "gun", Role = "weapon-mount", ModelNodeId = "gun" }); ship.Visual.Anchors.Add(new ShipAnchor { Id = "gun.muzzle", Role = "weapon-muzzle", ModelNodeId = "gun-muzzle", ParentId = "gun" }); tweak?.Invoke(ship); ship.WithId(id); diff --git a/tests/Aetheria.Shared.Tests/ShipModPlanTests.cs b/tests/Aetheria.Shared.Tests/ShipModPlanTests.cs index e44c60e83..6df76dc6b 100644 --- a/tests/Aetheria.Shared.Tests/ShipModPlanTests.cs +++ b/tests/Aetheria.Shared.Tests/ShipModPlanTests.cs @@ -17,7 +17,7 @@ public sealed class ShipModPlanTests ship.Hull.Hardpoints[0].Position = new int2(2, 0); ship.Hull.Hardpoints.Add(new HardpointData { Type = HardpointType.Energy, Position = new int2(0, 0), Shape = new Shape(), Transform = "gun" }); ship.Hull.Hardpoints.Add(new HardpointData { Type = HardpointType.Radiator, Position = new int2(1, 0), Shape = new Shape(), Transform = "fin" }); - ship.Visual.Anchors.Add(new ShipAnchor { Id = "gun", Role = "articulation", ModelNodeId = "gun" }); + ship.Visual.Anchors.Add(new ShipAnchor { Id = "gun", Role = "weapon-mount", ModelNodeId = "gun" }); ship.Visual.Anchors.Add(new ShipAnchor { Id = "gun.a", Role = "weapon-muzzle", ModelNodeId = "gun-a", ParentId = "gun", Order = 1 }); ship.Visual.Anchors.Add(new ShipAnchor { Id = "gun.z", Role = "weapon-muzzle", ModelNodeId = "gun-z", ParentId = "gun", Order = 0 }); ship.Visual.Anchors.Add(new ShipAnchor { Id = "fin", Role = "radiator-mesh", ModelNodeId = "fin" }); @@ -40,31 +40,6 @@ public sealed class ShipModPlanTests Assert.Equal(new[] { "gun.z", "gun.a" }, muzzles); } - [Fact] - public void AThrusterOrRadiatorMountMustCarryItsRole() - { - var ship = Rig(); - ship.Visual.Anchors.Single(anchor => anchor.Id == "thruster.port").Role = "articulation"; - Assert.Contains("Thruster hardpoint thruster.port needs a thruster-emitter anchor", Refusal(ship)); - - ship = Rig(); - ship.Visual.Anchors.Single(anchor => anchor.Id == "fin").Role = "articulation"; - Assert.Contains("Radiator hardpoint fin needs a radiator-mesh anchor", Refusal(ship)); - } - - [Fact] - public void AnEmitterOrMeshAnchorMustBeItsHardpointsMount() - { - // A thruster-emitter anchor on a weapon mount, and a radiator-mesh anchor that is no mount at all. - var ship = Rig(); - ship.Visual.Anchors.Single(anchor => anchor.Id == "gun").Role = "thruster-emitter"; - Assert.Contains("Energy hardpoint gun needs a articulation anchor of that id", Refusal(ship)); - - ship = Rig(); - ship.Visual.Anchors.Add(new ShipAnchor { Id = "loose", Role = "radiator-mesh", ModelNodeId = "loose" }); - Assert.Contains("radiator-mesh anchor loose must be a Radiator hardpoint's mount", Refusal(ship)); - } - // Equal Order falls back to the id, compared ordinally, whatever order the anchors were authored in. [Fact] public void MuzzlesOfEqualOrderAreTiedByIdOrdinally() @@ -78,22 +53,6 @@ public sealed class ShipModPlanTests Assert.Equal(new[] { "gun.z", "gun.B", "gun.a", "gun.b", "gun.c" }, Assert.Single(Plan(ship).Weapons).Muzzles); } - [Fact] - public void MuzzlesBelongToWeaponMountsAndEveryWeaponHasOne() - { - var ship = Rig(); - ship.Visual.Anchors.Single(anchor => anchor.Id == "gun.z").ParentId = null; - Assert.Contains("muzzle gun.z must be parented to a weapon hardpoint's mount", Refusal(ship)); - - ship = Rig(); - ship.Visual.Anchors.Single(anchor => anchor.Id == "gun.z").ParentId = "thruster.port"; - Assert.Contains("muzzle gun.z must be parented to a weapon hardpoint's mount", Refusal(ship)); - - ship = Rig(); - ship.Visual.Anchors.RemoveAll(anchor => anchor.Role == "weapon-muzzle"); - Assert.Contains("weapon hardpoint gun needs at least one muzzle anchor", Refusal(ship)); - } - [Theory] [InlineData(HardpointType.Energy)] [InlineData(HardpointType.Ballistic)] @@ -103,10 +62,6 @@ public sealed class ShipModPlanTests var ship = Rig(); ship.Hull.Hardpoints.Single(hardpoint => hardpoint.Transform == "gun").Type = type; Assert.Equal("gun", Assert.Single(Plan(ship).Weapons).Mount); - - // Any other type refuses the same muzzles. - ship.Hull.Hardpoints.Single(hardpoint => hardpoint.Transform == "gun").Type = HardpointType.Tool; - Assert.Contains("must be parented to a weapon hardpoint's mount", Refusal(ship)); } [Fact] diff --git a/tests/Aetheria.Shared.Tests/ShipValidationTests.cs b/tests/Aetheria.Shared.Tests/ShipValidationTests.cs index eb150939a..c67f3e6d3 100644 --- a/tests/Aetheria.Shared.Tests/ShipValidationTests.cs +++ b/tests/Aetheria.Shared.Tests/ShipValidationTests.cs @@ -34,7 +34,7 @@ public sealed class ShipValidationTests ship.Hull.Hardpoints[0].Armor = 5; ship.Hull.Hardpoints[0].FiringArc = 360; ship.Hull.Hardpoints[0].Type = HardpointType.AetherDrive; - ship.Visual.Anchors.Single(anchor => anchor.Id == "thruster.port").Role = "articulation"; + ship.Visual.Anchors.RemoveAll(anchor => anchor.Id == "thruster.port"); ship.Hull.Hardpoints[0].Rotation = ItemRotation.Clockwise; ship.Validate(); } @@ -128,7 +128,9 @@ public sealed class ShipValidationTests public void AnAnchorMayNameItsHardpointAsParent() { var ship = ShipAuthoringTests.Fixture(); - ship.Visual.Anchors.Add(new ShipAnchor { Id = "muzzle", Role = "weapon-muzzle", ModelNodeId = "muzzle", ParentId = "thruster.port" }); + ship.Hull.Hardpoints.Add(new HardpointData { Type = HardpointType.Ballistic, Position = new int2(0, 0), Shape = new Shape(), Transform = "gun" }); + ship.Visual.Anchors.Add(new ShipAnchor { Id = "gun", Role = "weapon-mount", ModelNodeId = "gun" }); + ship.Visual.Anchors.Add(new ShipAnchor { Id = "muzzle", Role = "weapon-muzzle", ModelNodeId = "muzzle", ParentId = "gun" }); ship.Validate(); }