From 095efacd79e7e28993a41f0f9ba7e92863367e58 Mon Sep 17 00:00:00 2001 From: Meta Vens Date: Thu, 15 Oct 2026 00:17:14 +0200 Subject: [PATCH 1/7] Layout-aim: an identified hull shows where its mounts are; a located mount can be aimed at TrySelectTargetItem and ResolvedTargetItem gate on FireControl.IsLocated (an item seen to act, or one on a mount of the believed hull's layout) instead of IsRevealed; Combat keeps IsRevealed. Entity.LayoutOf derives the layout from the Contact profile; SchematicRows appends a mount row per unclaimed hardpoint. A mount aim centres on the mount's cells and is frozen per shot (PendingShot.AimAtMount). The cycle runs over located items, the schematic renders mount rows and marks through SchematicRow.Marks. Not yet built or tested. Co-Authored-By: Claude Opus 5.5 --- Assets/Scripts/Gameplay/ActionGameManager.cs | 14 +++---- Assets/Scripts/ServerShared/Entity.cs | 40 +++++++++++++------- Assets/Scripts/ServerShared/FireControl.cs | 40 ++++++++++++++++---- Assets/Scripts/ServerShared/Sensing.cs | 16 +++++++- Assets/Scripts/UI/HUD/SchematicDisplay.cs | 6 ++- 5 files changed, 83 insertions(+), 33 deletions(-) diff --git a/Assets/Scripts/Gameplay/ActionGameManager.cs b/Assets/Scripts/Gameplay/ActionGameManager.cs index 0877a4a03..6d1b23dfb 100644 --- a/Assets/Scripts/Gameplay/ActionGameManager.cs +++ b/Assets/Scripts/Gameplay/ActionGameManager.cs @@ -441,21 +441,21 @@ public class ActionGameManager : MonoBehaviour Input.Player.TargetNext.performed += context => CurrentEntity.TargetNext(); Input.Player.TargetPrevious.performed += context => CurrentEntity.TargetPrevious(); - // Cut 2 (docs/fire-control-cut.md): cycles the aim point among the current target's revealed - // subsystems -- decides nothing itself, only calls the one writer (TrySelectTargetItem), same - // predicate (FireControl.IsRevealed) the AI path uses. Entity-only: a chunk has no subsystems. + // Cut 2 (docs/fire-control-cut.md): cycles the aim point among the current target's located + // subsystems -- decides nothing itself, only calls the one writer (TrySelectTargetItem), gated on the + // same predicate (FireControl.IsLocated) the writer reads. Entity-only: a chunk has no subsystems. Input.Player.CycleTargetItem.performed += context => { var target = CurrentEntity.Target.Value.Entity; if (target == null) return; - var revealed = target.Equipment.Where(x => FireControl.IsRevealed(CurrentEntity, x)).ToArray(); - if (revealed.Length == 0) + var located = target.Equipment.Where(x => FireControl.IsLocated(CurrentEntity, x)).ToArray(); + if (located.Length == 0) { CurrentEntity.TrySelectTargetItem(null); return; } - var currentItemIndex = Array.IndexOf(revealed, CurrentEntity.ResolvedTargetItem); - CurrentEntity.TrySelectTargetItem(revealed[(currentItemIndex + 1) % revealed.Length]); + var currentItemIndex = Array.IndexOf(located, CurrentEntity.ResolvedTargetItem); + CurrentEntity.TrySelectTargetItem(located[(currentItemIndex + 1) % located.Length]); }; #endregion diff --git a/Assets/Scripts/ServerShared/Entity.cs b/Assets/Scripts/ServerShared/Entity.cs index 9c045eb8e..226204133 100644 --- a/Assets/Scripts/ServerShared/Entity.cs +++ b/Assets/Scripts/ServerShared/Entity.cs @@ -71,9 +71,9 @@ public abstract class Entity // Cut 2 (docs/fire-control-cut.md): the stored aim-point slot. Written only by TrySelectTargetItem // below, and nulled whenever Target changes (subscribed in Activate). Do not read this field directly - // outside Entity -- read ResolvedTargetItem, which re-validates on every call, so decay (or the item - // leaving the target) drops the aim point the moment it is no longer earned, with no loop that has to - // notice and clear it. + // outside Entity -- read ResolvedTargetItem, which re-validates on every call (FireControl.IsLocated), so decay + // (or the item leaving the target, or being destroyed) drops the aim point the moment it is no longer earned, + // with no loop that has to notice and clear it. public ReactiveProperty TargetItem = new ReactiveProperty((EquippedItem)null); // Where the pilot points: reticle, lock cone, tractor, guided rounds, fire direction. Steers nothing; the @@ -408,8 +408,9 @@ public abstract class Entity } // Cut 2 (docs/fire-control-cut.md): the only writer of TargetItem. Accepts null (clearing the aim - // point) or an item belonging to the current Target that FireControl.IsRevealed resolves true for this - // entity as observer. Returns whether the write took effect; a rejected non-null selection leaves + // point) or an item belonging to the current Target that FireControl.IsLocated resolves true for this + // entity as observer (an item it saw act, or one on a mount of the hull it believes the target to be). + // Returns whether the write took effect; a rejected non-null selection leaves // TargetItem unchanged. public bool TrySelectTargetItem(EquippedItem item) { @@ -420,7 +421,7 @@ public abstract class Entity } // Mining Cut 3: entity-only. A chunk has no items, so an item never belongs to a chunk target. - if (Target.Value.Entity == null || item.Entity != Target.Value.Entity || !FireControl.IsRevealed(this, item)) + if (Target.Value.Entity == null || item.Entity != Target.Value.Entity || !FireControl.IsLocated(this, item)) return false; TargetItem.Value = item; @@ -428,10 +429,10 @@ public abstract class Entity } // The read path for TargetItem (see the field's own comment): null once the aimed item is no longer - // revealed to this entity, or no longer belongs to the current Target, even though nothing wrote + // located by this entity, or no longer belongs to the current Target, even though nothing wrote // TargetItem.Value at the moment that became true. Re-checked on every call; never cached. public EquippedItem ResolvedTargetItem => - TargetItem.Value != null && Target.Value.Entity == TargetItem.Value.Entity && FireControl.IsRevealed(this, TargetItem.Value) + TargetItem.Value != null && Target.Value.Entity == TargetItem.Value.Entity && FireControl.IsLocated(this, TargetItem.Value) ? TargetItem.Value : null; @@ -1058,12 +1059,23 @@ public abstract class Entity // The rows of `subject`'s schematic as this ship reads it: its own weapons when `subject` is this ship (each row its // own data and live range, nothing to doubt about your own ship), otherwise the revealed weapons (each row the claimed - // class and that design's rated range) then the revealed gear (each row the claimed class, no range). The one owner of - // which rows, which class and which range an enemy schematic shows; the Unity SchematicDisplay renders it. - public IEnumerable SchematicRows(Entity subject) => - subject == this - ? subject.Equipment.Where(i => i.Behaviors.Any(b => b.Data is WeaponData)).Select(i => new SchematicRow(i, i.Data, true)) - : RevealedWeapons(subject).Concat(RevealedGear(subject)).Select(r => new SchematicRow(r.Item, r.Claimed, false)); + // class and that design's rated range) then the revealed gear (each row the claimed class, no range), then one mount + // row for each mount of the hull this ship believes `subject` to be (LayoutOf) that no claimed-item row sits on + // (ruling quiet-gear-identifiable-hull-layout-known). The one owner of which rows, which class and which range an + // enemy schematic shows; the Unity SchematicDisplay renders it. + public IEnumerable SchematicRows(Entity subject) + { + if (subject == this) + return subject.Equipment.Where(i => i.Behaviors.Any(b => b.Data is WeaponData)).Select(i => new SchematicRow(i, i.Data, true)); + var claimed = RevealedWeapons(subject).Concat(RevealedGear(subject)).Select(r => new SchematicRow(r.Item, r.Claimed, false)).ToList(); + return claimed.Concat(LayoutOf(subject) + .Where(h => !claimed.Any(r => r.Item.Hardpoint == h)) + .Select(h => new SchematicRow(h))); + } + + // Where `subject`'s mounts are as this ship believes them: the hull it is identified as (Contact.Profile, the claimed + // hull), never the subject's own HullData; empty below identification. Derived on every read, nothing stored. + public IReadOnlyList LayoutOf(Entity subject) => ContactOf(subject).Profile?.Hardpoints ?? (IReadOnlyList) Array.Empty(); // Whether this observer's book for `subject` holds `item`: the book's membership, no live gear read. internal bool HasReveal(Entity subject, EquippedItem item) => diff --git a/Assets/Scripts/ServerShared/FireControl.cs b/Assets/Scripts/ServerShared/FireControl.cs index 9ce4e55ce..041c6b2ff 100644 --- a/Assets/Scripts/ServerShared/FireControl.cs +++ b/Assets/Scripts/ServerShared/FireControl.cs @@ -156,10 +156,25 @@ public static class FireControl // quiet-gear-identifiable-hull-layout-known, reveal-ends-on-perception): `observer` can aim at `item` when its own // book holds a reveal of it (Entity.HasReveal: it saw the item act) and the item is still fitted to its ship and // not destroyed. The book says what was seen; the live item says whether it can still be aimed at. Derived on - // every read, never cached; watching a ship reveals no gear. + // every read, never cached; watching a ship reveals no gear. Any aiming choice that reads an item's class or + // stats (Combat's target-item choice, its dps) asks this; the selection gate is IsLocated. public static bool IsRevealed(Entity observer, EquippedItem item) => !item.EquippableItem.IsDestroyed && item.Entity.Equipment.Contains(item) && observer.HasReveal(item.Entity, item); + // The selection gate (ruling quiet-gear-identifiable-hull-layout-known): `observer` can aim at `item` when it has seen + // the item act (IsRevealed's question) or the item sits on a mount of the hull the observer believes its ship to be + // (Entity.LayoutOf, by reference: the believed hull's HardpointData are the true item's only while the belief is the + // truth, so under a hull lie no gear is located and the ship stays targetable, ruling ew-aim-under-hull-lie-ruled). + // Locating reads no class or stats. A Tool item sits on no mount, so only an act locates it. + public static bool IsLocated(Entity observer, EquippedItem item) => + !item.EquippableItem.IsDestroyed && item.Entity.Equipment.Contains(item) && + (observer.HasReveal(item.Entity, item) || item.Hardpoint != null && observer.LayoutOf(item.Entity).Contains(item.Hardpoint)); + + // Whether the aim at `aimed` is at its mount (located, not revealed): the aim point is then the mount's cells, not + // the cells of an item the observer has not learned the size of. + public static bool AimsAtMount(Entity observer, EquippedItem aimed) => + aimed != null && aimed.Hardpoint != null && !IsRevealed(observer, aimed); + // Cut 2, Q4: the stats an entity actually fires with. A working targeting system (equipped, online -- // not destroyed, not shut down) supplies its own resolved stats; anything else falls back to the // authored unaided figures, which make aiming without one a last resort rather than an alternative. @@ -337,7 +352,7 @@ public static class FireControl Silhouette sil; try { - sil = Silhouette(target, targetHull, source.ResolvedTargetItem, bearing, precision, buffer); + sil = Silhouette(target, targetHull, source.ResolvedTargetItem, bearing, precision, buffer, AimsAtMount(source, source.ResolvedTargetItem)); var settings = source.ItemManager.GameplaySettings; pSpread = PSpread(weapon.Spread, sil.Span, range, settings.SchematicCellSize); } @@ -723,6 +738,7 @@ public static class FireControl Target = engaged, Weapon = item, Aimed = source.ResolvedTargetItem, + AimAtMount = AimsAtMount(source, source.ResolvedTargetItem), Damage = damageOverride ?? weapon.Damage, FireMinRange = weapon.MinRange, FireEffectiveRange = weapon.EffectiveRange, @@ -895,7 +911,7 @@ public static class FireControl var bearing = Bearing(shot.Target, shot.TravelDirection); var hullData = shot.Target.ItemManager.GetData(shot.Target.Hull) as HullData; - sil = Silhouette(shot.Target, hullData, shot.Aimed, bearing, shot.Precision); + sil = Silhouette(shot.Target, hullData, shot.Aimed, bearing, shot.Precision, null, shot.AimAtMount); var settings = shot.Target.ItemManager.GameplaySettings; return p * PSpread(shot.Spread, sil.Span, shot.FireRange, settings.SchematicCellSize) * sil.POnHull; } @@ -1763,11 +1779,12 @@ public static class FireControl private static float Sigma(float precision) => max(1f / max(precision, 1e-3f), SigmaFloor); // The aim point a silhouette is centred on (R10/Q6: the same frozen Aimed both HitProbability and Fire - // read) -- the aimed item's own cell centroid when it currently occupies cells on the target, else the + // read) -- the aimed item's own cell centroid when it currently occupies cells on the target (its mount's cells + // when the aim was frozen at the mount, atMount: the shooter has not learned what is fitted there), else the // hull's own centre of mass. One path; the uniform-random branch Cut 6d replaced stays deleted. - private static float2 ResolveAimPoint(Entity target, HullData hullData, EquippedItem aimed) + private static float2 ResolveAimPoint(Entity target, HullData hullData, EquippedItem aimed, bool atMount) { - var aimedCells = aimed != null ? CellsOf(target, aimed) : null; + var aimedCells = aimed == null ? null : atMount && aimed.Hardpoint != null ? MountCells(aimed.Hardpoint) : CellsOf(target, aimed); if (aimedCells != null && aimedCells.Length > 0) return aimedCells.Aggregate(float2.zero, (total, c) => total + (float2) c) / aimedCells.Length; return hullData.Shape.CenterOfMass; @@ -1787,7 +1804,7 @@ public static class FireControl // call that follows, so it still passes null and gets an owned allocation. A caller-supplied buffer may be // larger than coords.Length (ArrayPool rents "at least"), so every loop below is bounded by coords.Length, // never buffer.Length. - public static Silhouette Silhouette(Entity target, HullData hull, EquippedItem aimed, float2 bearing, float precision, Interval[] buffer = null) + public static Silhouette Silhouette(Entity target, HullData hull, EquippedItem aimed, float2 bearing, float precision, Interval[] buffer = null, bool atMount = false) { var ell = Lateral(bearing); var coords = hull.Shape.Coordinates; @@ -1809,7 +1826,7 @@ public static class FireControl } var sigma = Sigma(precision); - var aimPoint = ResolveAimPoint(target, hull, aimed); + var aimPoint = ResolveAimPoint(target, hull, aimed, atMount); var a = dot(aimPoint, ell); var pOnHull = 0f; @@ -1964,6 +1981,10 @@ public static class FireControl exit = min(exit, max(t1, t2)); } + // The hull-schematic cells a hardpoint covers, laid as Entity builds its Hardpoints[,] grid. + private static int2[] MountCells(HardpointData hardpoint) => + hardpoint.Shape.Coordinates.Select(c => hardpoint.Position + c).ToArray(); + // The cells of the target's hull schematic actually occupied by `item` -- GearOccupancy is the one source // of truth for where an equipped item's footprint lands, the same table Detonate and ApplyPooled read. private static int2[] CellsOf(Entity target, EquippedItem item) @@ -1988,6 +2009,9 @@ public struct PendingShot public Entity Target; public EquippedItem Weapon; public EquippedItem Aimed; + // Whether the aim was at Aimed's mount rather than the item (FireControl.AimsAtMount at Fire), frozen with it: a + // reveal between Fire and Commit does not move where this shot was aimed. + public bool AimAtMount; // The frozen payload (R10, Q6): the gun's own stats at the instant the trigger was pulled. Nothing // downstream re-evaluates any of these. diff --git a/Assets/Scripts/ServerShared/Sensing.cs b/Assets/Scripts/ServerShared/Sensing.cs index 526a8db23..fe6d1e78c 100644 --- a/Assets/Scripts/ServerShared/Sensing.cs +++ b/Assets/Scripts/ServerShared/Sensing.cs @@ -127,10 +127,13 @@ public readonly struct Reveal // on the viewer's own ship, the live gear. `Claimed` is the class the row shows. The range label is the live range on // the viewer's own ship and the claimed design's rated range on any other; a row whose claimed class is not a weapon has // no range. The viewer never sees a target item's quality, wear or power. A renderer shows these and decides nothing. +// A mount row (`Mount` set, `Item` and `Claimed` null) is a hardpoint of the hull the viewer believes the ship to be, with +// nothing of the ship's claimed on it: where something is, not what. public readonly struct SchematicRow { public readonly EquippedItem Item; public readonly EquippableItemData Claimed; + public readonly HardpointData Mount; private readonly bool _own; internal SchematicRow(EquippedItem item, EquippableItemData claimed, bool own) @@ -140,12 +143,21 @@ public readonly struct SchematicRow _own = own; } + internal SchematicRow(HardpointData mount) + { + Mount = mount; + } + // What a renderer compares to know the rows changed. Range is a reading, not part of the key. - public (EquippedItem, EquippableItemData) Key => (Item, Claimed); + public (EquippedItem, EquippableItemData, HardpointData) Key => (Item, Claimed, Mount); + + // Whether this row is the one `aimed` points at: a claimed-item row by its item, a mount row by the mount the aimed + // item sits on (an aimed item with a claimed-item row has no mount row). + public bool Marks(EquippedItem aimed) => Item != null ? Item == aimed : aimed != null && aimed.Hardpoint == Mount; public float? Range => _own ? Item.Behaviors.OfType().First().Range - : Claimed.Behaviors.OfType().FirstOrDefault()?.RatedRange; + : Claimed?.Behaviors.OfType().FirstOrDefault()?.RatedRange; } // A subject's removal from a zone as the zone recorded it (death, dock, warp, any removal): where it was and how diff --git a/Assets/Scripts/UI/HUD/SchematicDisplay.cs b/Assets/Scripts/UI/HUD/SchematicDisplay.cs index 1ac5a23a7..9e2f6d9f1 100644 --- a/Assets/Scripts/UI/HUD/SchematicDisplay.cs +++ b/Assets/Scripts/UI/HUD/SchematicDisplay.cs @@ -123,7 +123,9 @@ public class SchematicDisplay : MonoBehaviour .ToArray(); foreach (var x in _schematicItems) { - if(x.Row.Claimed is WeaponItemData weaponItemData) + if (x.Row.Mount != null) + x.ListElement.ShowGear(x.Row.Mount.Type, null); + else if(x.Row.Claimed is WeaponItemData weaponItemData) x.ListElement.ShowWeapon(weaponItemData); else x.ListElement.ShowGear(x.Row.Claimed.HardpointType, x.Row.Claimed.Name); @@ -208,7 +210,7 @@ public class SchematicDisplay : MonoBehaviour // as info decays, with no event to hook. if (_enemy && _schematicItems != null) foreach (var x in _schematicItems) - x.ListElement.SetSelected(x.Row.Item == _player.ResolvedTargetItem); + x.ListElement.SetSelected(x.Row.Marks(_player.ResolvedTargetItem)); if(VisibilityLabel && !_enemy) VisibilityLabel.text = ((int)_entity.Visibility).ToString(); From eb2dc1e501be562b521a015637d71924be42df57 Mon Sep 17 00:00:00 2001 From: Meta Vens Date: Thu, 15 Oct 2026 00:17:17 +0200 Subject: [PATCH 2/7] Scanner progress reads 0 on another target; Program.RoleMapOf exposes roles-backfill's map Scanner.Progress is bound to the target the scan was built on, so a retarget with the scanner switched off no longer draws the old scan on the new target (scan-progress-shown-on-retarget). RoleMapOf is the one accessor HasRoleMap now delegates to. Not yet built or tested. Co-Authored-By: Claude Opus 5.5 --- Assets/Scripts/ServerShared/Behaviors/Scanner.cs | 6 ++++-- tools/AetherDb/Program.cs | 8 ++++++-- 2 files changed, 10 insertions(+), 4 deletions(-) diff --git a/Assets/Scripts/ServerShared/Behaviors/Scanner.cs b/Assets/Scripts/ServerShared/Behaviors/Scanner.cs index d94a8b978..c1e9ede41 100644 --- a/Assets/Scripts/ServerShared/Behaviors/Scanner.cs +++ b/Assets/Scripts/ServerShared/Behaviors/Scanner.cs @@ -46,8 +46,10 @@ public class Scanner : Behavior, IProgressBehavior // The scan's progress now: what Execute last built, less ScanTime-relative decay over the time since a tick last held // it (ruling scan-held-beam: a broken scan decays over ScanTime, the item inactive or destroyed included). Derived from // the zone clock, never from tick order, so a scanner decays on the tick it breaks whether its host runs Execute - // before the always-updated pass (EquippedItem) or after it (ConsumableItemEffect). The one decay site. - public float Progress => Remaining(_heldAt); + // before the always-updated pass (EquippedItem) or after it (ConsumableItemEffect). The one decay site. It is the + // progress against the ship's target now: a scan built on another ship reads 0 the moment the target changes, whether + // or not the item is running to reset it. + public float Progress => _target == Entity.Target.Value.Entity ? Remaining(_heldAt) : 0f; public Scanner(ScannerData data, EquippedItem item) : base(data, item) { diff --git a/tools/AetherDb/Program.cs b/tools/AetherDb/Program.cs index 0d4c96cf1..a99c7526c 100644 --- a/tools/AetherDb/Program.cs +++ b/tools/AetherDb/Program.cs @@ -285,8 +285,12 @@ public static class Program _ => null, }; - // Whether CB-R1 maps this design's stats to roles; the catalog test asks the tool, so it keeps no copy of the kinds. - public static bool HasRoleMap(EquippableItemData item) => RoleMapFor(item, KindOf(item)) != null; + // The field->role map roles-backfill would apply to this design, or null; the catalog tests ask the tool, so they keep no + // copy of the kinds or the maps. + public static IReadOnlyDictionary RoleMapOf(EquippableItemData item) => RoleMapFor(item, KindOf(item)); + + // Whether CB-R1 maps this design's stats to roles. + public static bool HasRoleMap(EquippableItemData item) => RoleMapOf(item) != null; private sealed record RoleAuthoring( EquippableItemData Item, string Kind, List<(string Behavior, string Field, string Role)> StatsAssigned, From 2a69c40bfad390b176b4837248ff07026dd81394 Mon Sep 17 00:00:00 2001 From: Meta Vens Date: Thu, 15 Oct 2026 00:17:21 +0200 Subject: [PATCH 3/7] Tests: layout-aim behaviour, retarget scan progress, Inspection Scanner role map, Entity-free fixture assertion Co-Authored-By: Claude Opus 5.5 --- .../ContactRevealTests.cs | 19 ++ .../InspectionScannerCatalogTests.cs | 13 + tests/Aetheria.Shared.Tests/LayoutAimTests.cs | 279 ++++++++++++++++++ tests/Aetheria.Shared.Tests/ScannerTests.cs | 21 +- 4 files changed, 331 insertions(+), 1 deletion(-) create mode 100644 tests/Aetheria.Shared.Tests/LayoutAimTests.cs diff --git a/tests/Aetheria.Shared.Tests/ContactRevealTests.cs b/tests/Aetheria.Shared.Tests/ContactRevealTests.cs index 83a2b8cd1..a50d79c2d 100644 --- a/tests/Aetheria.Shared.Tests/ContactRevealTests.cs +++ b/tests/Aetheria.Shared.Tests/ContactRevealTests.cs @@ -53,6 +53,25 @@ public sealed partial class ContactRevealTests : IDisposable new HardpointData { Type = HardpointType.Sensors, Position = new int2(2, 0), Shape = new Shape() } } }); + // Layout-aim: a hull whose mounts are bigger than what is fitted to them. The reactor mount covers the 2x2 block + // at the origin while the reactor is one cell at (1,1), so the mount's centre (.5, .5) is not the reactor's (1, 1). + var bayBlock = new Shape(2, 2); + foreach (var cell in bayBlock.AllCoordinates) bayBlock[cell] = true; + var bayHull = new Shape(5, 5); + foreach (var cell in bayHull.AllCoordinates) bayHull[cell] = true; + cache.Upsert(new HullData + { + Name = "Bay", HullType = HullType.Ship, Shape = bayHull, Durability = 1000000, Mass = 1000, + Hardpoints = + { + new HardpointData { Type = HardpointType.Reactor, Position = new int2(0, 0), Shape = bayBlock }, + new HardpointData { Type = HardpointType.Shield, Position = new int2(3, 0), Shape = new Shape() }, + new HardpointData { Type = HardpointType.Sensors, Position = new int2(0, 3), Shape = new Shape() } + } + }); + var bayReactor = Tool("BayReactor", new ReactorData { Charge = Const(1000), Efficiency = Const(1), OverloadEfficiency = Const(1), ThrottlingFactor = Const(2) }); + bayReactor.Hardpoint = HardpointType.Reactor; + cache.Upsert(bayReactor); cache.Upsert(Eye("Eye", sensitivity: 2f)); cache.Upsert(Eye("Ear", sensitivity: 4f)); // Narrow beams (10 degrees, facing +z) so a hearer off the beam is never painted: the ping is heard at its diff --git a/tests/Aetheria.Shared.Tests/InspectionScannerCatalogTests.cs b/tests/Aetheria.Shared.Tests/InspectionScannerCatalogTests.cs index 368762624..43d7fcce4 100644 --- a/tests/Aetheria.Shared.Tests/InspectionScannerCatalogTests.cs +++ b/tests/Aetheria.Shared.Tests/InspectionScannerCatalogTests.cs @@ -26,6 +26,19 @@ public sealed class InspectionScannerCatalogTests Assert.Equal(exponent, term.Exponent, 4); } + // roles-backfill's map for the design (CB-R1): each figure reads the role it is authored on, so a lot's roles move the + // figures the catalog test below reads. Dropping the entry or mapping a figure to the wrong role fails here. + [Fact] + public void RolesBackfillMapsTheInspectionScannersFiguresToTheirRoles() + { + using var cache = RestoredHullsTests.OpenCatalog(); + var map = Program.RoleMapOf(Design(cache)); + Assert.True(map != null, "roles-backfill finds no role map for the Inspection Scanner design"); + Assert.Equal( + new[] { ("InterceptFactor", Discipline), ("Range", ScanArray), ("ScanTime", ScanArray) }, + map.Select(p => (p.Key, p.Value)).OrderBy(p => p.Key, StringComparer.Ordinal).ToArray()); + } + [Fact] public void TheCatalogSellsAnInspectionScanner() { diff --git a/tests/Aetheria.Shared.Tests/LayoutAimTests.cs b/tests/Aetheria.Shared.Tests/LayoutAimTests.cs new file mode 100644 index 000000000..18fc4a495 --- /dev/null +++ b/tests/Aetheria.Shared.Tests/LayoutAimTests.cs @@ -0,0 +1,279 @@ +using System; +using System.Linq; +using CultMath; +using Xunit; +using static CultMath.math; +using float2 = CultMath.float2; +using float3 = CultMath.float3; +using int2 = CultMath.int2; + +// Visibility 5b (rulings quiet-gear-identifiable-hull-layout-known, ew-aim-under-hull-lie-ruled, reveal-ends-on-perception, +// schematic-and-model-are-separate-spaces): an observer that identifies a hull knows where its mounts are, and can aim at a +// mount it has located without knowing what is fitted there. The fixture hull "Bay" has a reactor mount (the 2x2 block at the +// origin, the reactor one cell inside it at (1,1)), a shield mount and a weapon mount, and nothing ever acts unless a test +// makes it. Identified is TargetGearInfoThreshold (.8) of the observer's info on the ship. Every fixture drives the +// production path (Fire, CommitProbability, SchematicRows, TrySelectTargetItem, the AI's Combat state inside Zone.Update). +// Entities and items are compared with ReferenceEquals: a failing xunit assertion formats its arguments, and an Entity's +// graph overflows the test host. +public sealed partial class ContactRevealTests +{ + private const float Identify = .8f; + + // A Bay at `position`: its reactor on the reactor mount, a gun on the weapon mount when asked (with the targeting system + // and reactor tool it needs to fire), and `tools` on free cells. + private Ship AddBay(float3 position, bool gun = false, string[] tools = null) + { + var hullRef = _items.ItemData.RefOf(_items.ItemData.GetByName("Bay")); + var ship = new Ship(_items, _zone, new EquippableItem { Data = hullRef, Durability = 1000000, Lot = _nextLot++ }, new EntitySettings()) + { Faction = new Faction { Name = "F" + Guid.NewGuid().ToString("N") } }; + Assert.True(ship.TryEquip(Make("BayReactor"), new int2(1, 1))); + if (gun) Assert.True(ship.TryEquip(Make("Gun"), new int2(0, 3))); + foreach (var name in (tools ?? new string[0]).Concat(gun ? new[] { "Targeting", "Reactor" } : new string[0])) + { + var item = Make(name); + Assert.True(ship.TryFindSpace(item, out var at)); + Assert.True(ship.TryEquip(item, at)); + } + _zone.Admit(ship, piloted: false); + ship.Position = position; + ship.Aim = float3(0, 0, 1); + _zone.Update(0f); + return ship; + } + + private static EquippedItem Fitted(Entity e, string design) => e.Equipment.Single(i => i.Data.Name == design); + + // An observer at the origin that identifies a Bay 100 ahead (nothing revealed, nothing targeted). + private (Ship bay, Ship observer, EquippedItem reactor) Identified(float info = 1f) + { + var bay = AddBay(float3(0, 0, 100)); + var observer = Add(float3(0, 0, 0)); + observer.EntityInfoGathered[bay] = info; + return (bay, observer, Fitted(bay, "BayReactor")); + } + + private static void Target(Entity observer, Entity subject) + { + observer.SetTarget(subject); + Assert.True(ReferenceEquals(subject, observer.Target.Value.Entity), "fixture: the observer targets the ship"); + } + + // ---- AnIdentifiedHullShowsItsMounts ---- + + // At the identify threshold the observer's layout of the ship is its hull's hardpoints; just below it is empty. + [Fact] + public void AnIdentifiedHullShowsItsMounts() + { + var (bay, observer, _) = Identified(Identify); + Assert.Equal(3, bay.HullData.Hardpoints.Count); + Assert.True(bay.HullData.Hardpoints.SequenceEqual(observer.LayoutOf(bay)), "the layout is the hull's three hardpoints, in order"); + + observer.EntityInfoGathered[bay] = Identify - .01f; + Assert.Empty(observer.LayoutOf(bay)); + } + + // ---- TheReactorIsLocatedNotRevealed ---- + + // Identified, the reactor never acted: the observer has located it (can aim at it) and learned nothing of it. Below the + // identify threshold it can aim at nothing, and a mount aim already taken is dropped when the identification is lost. + [Fact] + public void TheReactorIsLocatedNotRevealed() + { + var (bay, observer, reactor) = Identified(Identify - .01f); + Target(observer, bay); + Assert.False(FireControl.IsLocated(observer, reactor)); + Assert.False(observer.TrySelectTargetItem(reactor)); + + observer.EntityInfoGathered[bay] = Identify; + Assert.True(FireControl.IsLocated(observer, reactor)); + Assert.False(FireControl.IsRevealed(observer, reactor)); + Assert.True(observer.TrySelectTargetItem(reactor)); + Assert.True(ReferenceEquals(reactor, observer.ResolvedTargetItem), "the located reactor is the aim point"); + + observer.EntityInfoGathered[bay] = Identify - .01f; + Assert.True(observer.ResolvedTargetItem == null, "the aim is dropped with the identification"); + } + + // ---- ADestroyedMountItemIsNotLocated ---- + + [Fact] + public void ADestroyedMountItemIsNotLocated() + { + var (bay, observer, reactor) = Identified(); + Target(observer, bay); + Assert.True(observer.TrySelectTargetItem(reactor), "fixture: the live reactor is located and aimed at"); + + reactor.EquippableItem.Durability = 0f; + Assert.True(reactor.EquippableItem.IsDestroyed, "fixture"); + Assert.False(FireControl.IsLocated(observer, reactor)); + Assert.True(observer.ResolvedTargetItem == null); + Assert.False(observer.TrySelectTargetItem(reactor)); + } + + // ---- UnderAHullLieNoGearIsLocated ---- + + // Ruling ew-aim-under-hull-lie-ruled: while the lie is believed the observer's layout is the claimed hull's and the true + // reactor sits on none of its mounts, so no gear can be aimed at and the ship stays targetable; gear aim opens when the + // lie is seen through. + [Fact] + public void UnderAHullLieNoGearIsLocated() + { + var (bay, observer, reactor) = Identified(); + var decoy = new HullData { Name = "Decoy", Hardpoints = { new HardpointData { Type = HardpointType.Reactor, Position = new int2(0, 0), Shape = new Shape() } } }; + bay.Claim(decoy); + + Assert.True(decoy.Hardpoints.SequenceEqual(observer.LayoutOf(bay)) && observer.LayoutOf(bay).Count == 1, "the layout is the claimed hull's"); + Target(observer, bay); + Assert.False(FireControl.IsLocated(observer, reactor)); + Assert.False(observer.TrySelectTargetItem(reactor)); + Assert.True(observer.ResolvedTargetItem == null); + Assert.True(ReferenceEquals(bay, observer.Target.Value.Entity), "the ship stays targetable"); + + bay.Claim(null); + Assert.True(FireControl.IsLocated(observer, reactor)); + Assert.True(observer.TrySelectTargetItem(reactor)); + } + + // ---- ToolGearIsNotLocatedByLayout ---- + + // Tool gear sits on no mount, so the layout locates nothing of it; an act does. + [Fact] + public void ToolGearIsNotLocatedByLayout() + { + var bay = AddBay(float3(0, 0, 100), tools: new[] { "Radiator" }); + var observer = Add(float3(0, 0, 0)); + observer.EntityInfoGathered[bay] = 1f; + var radiator = Fitted(bay, "Radiator"); + Assert.True(radiator.Hardpoint == null, "fixture: a tool item sits on no mount"); + Target(observer, bay); + + Assert.False(FireControl.IsLocated(observer, radiator)); + Assert.False(observer.TrySelectTargetItem(radiator)); + + observer.RecordReveal(bay, radiator, SignatureChannel.LaunchFlash, radiator.Data); + Assert.True(FireControl.IsLocated(observer, radiator)); + } + + // ---- AMountAimIsTheMountsCells ---- + + // A shooter that holds a valid solution on a Bay 30 ahead, with the reactor selected as the aim point. + private (Ship shooter, Ship bay, EquippedItem reactor, Weapon weapon, EquippedItem gun) Gunner() + { + var bay = AddBay(float3(0, 0, 30)); + var shooter = Add(float3(0, 0, 0), new[] { "Eye", "Gun" }); + shooter.SetTarget(bay); + for (var i = 0; i < 3; i++) Hold(shooter, 1, 1f, bay); + Assert.True(shooter.VisibleEntities.Contains(bay), "fixture: the shooter tracks the ship"); + var gun = Guns(shooter).Single(); + var reactor = Fitted(bay, "BayReactor"); + Assert.True(shooter.TrySelectTargetItem(reactor), "fixture: the located reactor is aimed at"); + return (shooter, bay, reactor, (Weapon) gun.Behaviors.Single(b => b is Weapon), gun); + } + + private PendingShot FireAt(Weapon weapon, EquippedItem gun, Entity shooter) + { + var before = _zone.PendingShots.Count; + FireControl.Fire(weapon, gun, shooter); + Assert.Equal(before + 1, _zone.PendingShots.Count); + return _zone.PendingShots[before]; + } + + // The reactor is one cell inside its two by two mount. Aimed at while located, a shot centres on the mount's cells (.5, .5); + // once the reactor is revealed the next shot centres on the reactor's own cell (1, 1). The HUD's forecast reads the same + // aim point as the shot it forecasts. + [Fact] + public void AMountAimIsTheMountsCells() + { + var (shooter, bay, reactor, weapon, gun) = Gunner(); + + var located = FireControl.Inspect(weapon, shooter, bay); + var shot = FireAt(weapon, gun, shooter); + Assert.True(shot.AimAtMount && ReferenceEquals(reactor, shot.Aimed), "the shot is aimed at the located reactor's mount"); + Assert.True(FireControl.CommitProbability(shot, _zone.Time, out var mountSil) > 0f, "fixture: the shot has a price"); + Assert.Equal(.5f, mountSil.AimPoint.x, 4); + Assert.Equal(.5f, mountSil.AimPoint.y, 4); + Assert.Equal(mountSil.POnHull, located.POnHull, 4); + + shooter.RecordReveal(bay, reactor, SignatureChannel.LaunchFlash, reactor.Data); + Assert.True(FireControl.IsRevealed(shooter, reactor), "fixture: the reactor is revealed"); + var revealed = FireControl.Inspect(weapon, shooter, bay); + var next = FireAt(weapon, gun, shooter); + Assert.False(next.AimAtMount); + Assert.True(FireControl.CommitProbability(next, _zone.Time, out var itemSil) > 0f, "fixture: the shot has a price"); + Assert.Equal(1f, itemSil.AimPoint.x, 4); + Assert.Equal(1f, itemSil.AimPoint.y, 4); + Assert.Equal(itemSil.POnHull, revealed.POnHull, 4); + Assert.True(Math.Abs(mountSil.POnHull - itemSil.POnHull) > 1e-3f, "fixture: the two aim points are priced apart"); + } + + // ---- TheShotKeepsTheAimItWasFiredWith ---- + + [Fact] + public void TheShotKeepsTheAimItWasFiredWith() + { + var (shooter, bay, reactor, weapon, gun) = Gunner(); + var shot = FireAt(weapon, gun, shooter); + Assert.True(shot.AimAtMount, "fixture: fired at the mount"); + + shooter.RecordReveal(bay, reactor, SignatureChannel.LaunchFlash, reactor.Data); + Assert.True(FireControl.IsRevealed(shooter, reactor), "fixture: revealed before Commit"); + + Assert.True(FireControl.CommitProbability(shot, _zone.Time, out var sil) > 0f, "fixture: the shot has a price"); + Assert.Equal(.5f, sil.AimPoint.x, 4); + Assert.Equal(.5f, sil.AimPoint.y, 4); + } + + // ---- TheSchematicListsUnfilledMounts ---- + + // Identified, nothing revealed: a mount row for each hardpoint, naming no item and no class and giving no range. After a + // scan the reactor's mount row gives way to its gear row. The aimed reactor is marked on whichever row stands for it, and + // no other row is. + [Fact] + public void TheSchematicListsUnfilledMounts() + { + var (bay, observer, reactor) = Identified(); + var hardpoints = bay.HullData.Hardpoints; + + var rows = observer.SchematicRows(bay).ToArray(); + Assert.Equal(3, rows.Length); + Assert.True(hardpoints.SequenceEqual(rows.Select(r => r.Mount)), "one mount row per hardpoint, in order"); + Assert.All(rows, r => + { + Assert.True(r.Item == null && r.Claimed == null, "a mount row names no item and no class"); + Assert.False(r.Range.HasValue); + }); + + Target(observer, bay); + Assert.True(observer.TrySelectTargetItem(reactor)); + Assert.Equal(new[] { true, false, false }, rows.Select(r => r.Marks(observer.ResolvedTargetItem)).ToArray()); + + observer.RecordInspection(bay); + rows = observer.SchematicRows(bay).ToArray(); + Assert.Equal(3, rows.Length); + Assert.True(rows[0].Mount == null && ReferenceEquals(rows[0].Item, reactor), "the reactor's gear row leads"); + Assert.True(hardpoints.Skip(1).SequenceEqual(rows.Skip(1).Select(r => r.Mount)), "the other two mounts stay mount rows"); + Assert.Equal(new[] { true, false, false }, rows.Select(r => r.Marks(observer.ResolvedTargetItem)).ToArray()); + } + + // ---- TheAIStillChoosesOnlyRevealedWeapons ---- + + // An AI ship that identifies a Bay with a gun on its weapon mount aims at no item: Combat chooses among the weapons it has + // seen, and the located gun is not one. Once the gun fires in sight, Combat selects it. + [Fact] + public void TheAIStillChoosesOnlyRevealedWeapons() + { + var bay = AddBay(float3(0, 0, 40), gun: true); + var gun = Guns(bay).Single(); + var ai = Add(float3(0, 0, 0), new[] { "Eye" }); + _zone.Agents.Add(new Minion(ai)); + ai.SetTarget(bay); + Hold(ai, 12, 1f, bay); + Assert.True(ReferenceEquals(bay, ai.Target.Value.Entity), "fixture: the AI keeps the ship as its target"); + Assert.True(FireControl.IsLocated(ai, gun) && !FireControl.IsRevealed(ai, gun), "fixture: the gun is located and not revealed"); + Assert.True(ai.ResolvedTargetItem == null, "Combat aims at nothing it has not seen act"); + + Fire(bay, gun); + for (var i = 0; i < 40 && ai.ResolvedTargetItem == null; i++) Hold(ai, 1, 1f, bay); + Assert.True(ReferenceEquals(gun, ai.ResolvedTargetItem), "after the gun fires Combat aims at it"); + } +} diff --git a/tests/Aetheria.Shared.Tests/ScannerTests.cs b/tests/Aetheria.Shared.Tests/ScannerTests.cs index 7b84fbebc..e0ddb30e9 100644 --- a/tests/Aetheria.Shared.Tests/ScannerTests.cs +++ b/tests/Aetheria.Shared.Tests/ScannerTests.cs @@ -40,7 +40,7 @@ public sealed partial class ContactRevealTests var s = Add(float3(0, 0, 0), tools: new[] { scanner }); s.EntityInfoGathered[target] = info; s.SetTarget(target); - Assert.Same(target, s.Target.Value.Entity); // fixture + Assert.True(ReferenceEquals(target, s.Target.Value.Entity), "fixture: the scanner's ship targets the ship it scans"); // not Assert.Same: a failing Entity argument is formatted as a graph and overflows the test host return (s, target); } @@ -214,6 +214,25 @@ public sealed partial class ContactRevealTests Assert.Empty(s.RevealsOf(first)); } + // ---- AScanReadsNothingOnAnotherTarget ---- + + // A scan built on one ship is that ship's: with the scanner switched off at 11/16 the player retargets, and the progress + // against the new target is 0 at once, not the old scan decaying (the HUD's outgoing indicator reads it). + [Fact] + public void AScanReadsNothingOnAnotherTarget() + { + var (s, first) = ScanOn(); + var second = Add(float3(100, 0, 0)); + Hold(s, 11, first, second); + Assert.Equal(11f / 16f, ScannerOf(s).Progress, 4); + ScannerItemOf(s).Enabled.Value = false; + + s.SetTarget(second); + Hold(s, 1, first, second); + + Assert.Equal(0f, ScannerOf(s).Progress, 4); + } + // ---- TheScannedShipIsWarned ---- // A paint made in a tick is published the next one, so after four ticks the target holds the third tick's paint. From 1c041cb67bca349954d7b556476667a9177cc80b Mon Sep 17 00:00:00 2001 From: Meta Vens Date: Thu, 15 Oct 2026 00:19:09 +0200 Subject: [PATCH 4/7] SchematicRow: assign every field in both constructors (the struct language level has no auto-default) Fixes the build of the layout-aim sim commit (095efacd). Co-Authored-By: Claude Opus 5.5 --- Assets/Scripts/ServerShared/Sensing.cs | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/Assets/Scripts/ServerShared/Sensing.cs b/Assets/Scripts/ServerShared/Sensing.cs index fe6d1e78c..7ed482a14 100644 --- a/Assets/Scripts/ServerShared/Sensing.cs +++ b/Assets/Scripts/ServerShared/Sensing.cs @@ -140,10 +140,11 @@ public readonly struct SchematicRow { Item = item; Claimed = claimed; + Mount = null; _own = own; } - internal SchematicRow(HardpointData mount) + internal SchematicRow(HardpointData mount) : this() { Mount = mount; } From 1b0a67069e41185249ad2520c18be4d08c79a98c Mon Sep 17 00:00:00 2001 From: Meta Vens Date: Thu, 15 Oct 2026 00:31:57 +0200 Subject: [PATCH 5/7] Layout-aim fixture: reactor mount off the origin so a mount-cell offset term is observable; schematic keys and below-identify rows pinned Co-Authored-By: Claude Opus 5.5 --- .../ContactRevealTests.cs | 7 +++-- tests/Aetheria.Shared.Tests/LayoutAimTests.cs | 28 +++++++++++-------- 2 files changed, 20 insertions(+), 15 deletions(-) diff --git a/tests/Aetheria.Shared.Tests/ContactRevealTests.cs b/tests/Aetheria.Shared.Tests/ContactRevealTests.cs index a50d79c2d..f6c55932a 100644 --- a/tests/Aetheria.Shared.Tests/ContactRevealTests.cs +++ b/tests/Aetheria.Shared.Tests/ContactRevealTests.cs @@ -53,8 +53,9 @@ public sealed partial class ContactRevealTests : IDisposable new HardpointData { Type = HardpointType.Sensors, Position = new int2(2, 0), Shape = new Shape() } } }); - // Layout-aim: a hull whose mounts are bigger than what is fitted to them. The reactor mount covers the 2x2 block - // at the origin while the reactor is one cell at (1,1), so the mount's centre (.5, .5) is not the reactor's (1, 1). + // Layout-aim: a hull whose mounts are bigger than what is fitted to them. The reactor mount covers the 2x2 block at + // (3,3), off the origin and against the hull's far edge, while the reactor is the one cell at (3,3), so the mount's + // centre (3.5, 3.5) is not the reactor's (3, 3) and the two price a shot apart. var bayBlock = new Shape(2, 2); foreach (var cell in bayBlock.AllCoordinates) bayBlock[cell] = true; var bayHull = new Shape(5, 5); @@ -64,7 +65,7 @@ public sealed partial class ContactRevealTests : IDisposable Name = "Bay", HullType = HullType.Ship, Shape = bayHull, Durability = 1000000, Mass = 1000, Hardpoints = { - new HardpointData { Type = HardpointType.Reactor, Position = new int2(0, 0), Shape = bayBlock }, + new HardpointData { Type = HardpointType.Reactor, Position = new int2(3, 3), Shape = bayBlock }, new HardpointData { Type = HardpointType.Shield, Position = new int2(3, 0), Shape = new Shape() }, new HardpointData { Type = HardpointType.Sensors, Position = new int2(0, 3), Shape = new Shape() } } diff --git a/tests/Aetheria.Shared.Tests/LayoutAimTests.cs b/tests/Aetheria.Shared.Tests/LayoutAimTests.cs index 18fc4a495..362d54beb 100644 --- a/tests/Aetheria.Shared.Tests/LayoutAimTests.cs +++ b/tests/Aetheria.Shared.Tests/LayoutAimTests.cs @@ -9,8 +9,8 @@ using int2 = CultMath.int2; // Visibility 5b (rulings quiet-gear-identifiable-hull-layout-known, ew-aim-under-hull-lie-ruled, reveal-ends-on-perception, // schematic-and-model-are-separate-spaces): an observer that identifies a hull knows where its mounts are, and can aim at a -// mount it has located without knowing what is fitted there. The fixture hull "Bay" has a reactor mount (the 2x2 block at the -// origin, the reactor one cell inside it at (1,1)), a shield mount and a weapon mount, and nothing ever acts unless a test +// mount it has located without knowing what is fitted there. The fixture hull "Bay" has a reactor mount (the 2x2 block at +// (3,3), the reactor the one cell at (3,3) inside it), a shield mount and a weapon mount, and nothing ever acts unless a test // makes it. Identified is TargetGearInfoThreshold (.8) of the observer's info on the ship. Every fixture drives the // production path (Fire, CommitProbability, SchematicRows, TrySelectTargetItem, the AI's Combat state inside Zone.Update). // Entities and items are compared with ReferenceEquals: a failing xunit assertion formats its arguments, and an Entity's @@ -26,7 +26,7 @@ public sealed partial class ContactRevealTests var hullRef = _items.ItemData.RefOf(_items.ItemData.GetByName("Bay")); var ship = new Ship(_items, _zone, new EquippableItem { Data = hullRef, Durability = 1000000, Lot = _nextLot++ }, new EntitySettings()) { Faction = new Faction { Name = "F" + Guid.NewGuid().ToString("N") } }; - Assert.True(ship.TryEquip(Make("BayReactor"), new int2(1, 1))); + Assert.True(ship.TryEquip(Make("BayReactor"), new int2(3, 3))); if (gun) Assert.True(ship.TryEquip(Make("Gun"), new int2(0, 3))); foreach (var name in (tools ?? new string[0]).Concat(gun ? new[] { "Targeting", "Reactor" } : new string[0])) { @@ -178,8 +178,8 @@ public sealed partial class ContactRevealTests return _zone.PendingShots[before]; } - // The reactor is one cell inside its two by two mount. Aimed at while located, a shot centres on the mount's cells (.5, .5); - // once the reactor is revealed the next shot centres on the reactor's own cell (1, 1). The HUD's forecast reads the same + // The reactor is one cell inside its two by two mount. Aimed at while located, a shot centres on the mount's cells (3.5, 3.5); + // once the reactor is revealed the next shot centres on the reactor's own cell (3, 3). The HUD's forecast reads the same // aim point as the shot it forecasts. [Fact] public void AMountAimIsTheMountsCells() @@ -190,8 +190,8 @@ public sealed partial class ContactRevealTests var shot = FireAt(weapon, gun, shooter); Assert.True(shot.AimAtMount && ReferenceEquals(reactor, shot.Aimed), "the shot is aimed at the located reactor's mount"); Assert.True(FireControl.CommitProbability(shot, _zone.Time, out var mountSil) > 0f, "fixture: the shot has a price"); - Assert.Equal(.5f, mountSil.AimPoint.x, 4); - Assert.Equal(.5f, mountSil.AimPoint.y, 4); + Assert.Equal(3.5f, mountSil.AimPoint.x, 4); + Assert.Equal(3.5f, mountSil.AimPoint.y, 4); Assert.Equal(mountSil.POnHull, located.POnHull, 4); shooter.RecordReveal(bay, reactor, SignatureChannel.LaunchFlash, reactor.Data); @@ -200,8 +200,8 @@ public sealed partial class ContactRevealTests var next = FireAt(weapon, gun, shooter); Assert.False(next.AimAtMount); Assert.True(FireControl.CommitProbability(next, _zone.Time, out var itemSil) > 0f, "fixture: the shot has a price"); - Assert.Equal(1f, itemSil.AimPoint.x, 4); - Assert.Equal(1f, itemSil.AimPoint.y, 4); + Assert.Equal(3f, itemSil.AimPoint.x, 4); + Assert.Equal(3f, itemSil.AimPoint.y, 4); Assert.Equal(itemSil.POnHull, revealed.POnHull, 4); Assert.True(Math.Abs(mountSil.POnHull - itemSil.POnHull) > 1e-3f, "fixture: the two aim points are priced apart"); } @@ -219,8 +219,8 @@ public sealed partial class ContactRevealTests Assert.True(FireControl.IsRevealed(shooter, reactor), "fixture: revealed before Commit"); Assert.True(FireControl.CommitProbability(shot, _zone.Time, out var sil) > 0f, "fixture: the shot has a price"); - Assert.Equal(.5f, sil.AimPoint.x, 4); - Assert.Equal(.5f, sil.AimPoint.y, 4); + Assert.Equal(3.5f, sil.AimPoint.x, 4); + Assert.Equal(3.5f, sil.AimPoint.y, 4); } // ---- TheSchematicListsUnfilledMounts ---- @@ -231,11 +231,14 @@ public sealed partial class ContactRevealTests [Fact] public void TheSchematicListsUnfilledMounts() { - var (bay, observer, reactor) = Identified(); + var (bay, observer, reactor) = Identified(Identify - .01f); var hardpoints = bay.HullData.Hardpoints; + Assert.Empty(observer.SchematicRows(bay)); + observer.EntityInfoGathered[bay] = Identify; var rows = observer.SchematicRows(bay).ToArray(); Assert.Equal(3, rows.Length); + Assert.Equal(3, rows.Select(r => r.Key).Distinct().Count()); Assert.True(hardpoints.SequenceEqual(rows.Select(r => r.Mount)), "one mount row per hardpoint, in order"); Assert.All(rows, r => { @@ -250,6 +253,7 @@ public sealed partial class ContactRevealTests observer.RecordInspection(bay); rows = observer.SchematicRows(bay).ToArray(); Assert.Equal(3, rows.Length); + Assert.Equal(3, rows.Select(r => r.Key).Distinct().Count()); Assert.True(rows[0].Mount == null && ReferenceEquals(rows[0].Item, reactor), "the reactor's gear row leads"); Assert.True(hardpoints.Skip(1).SequenceEqual(rows.Skip(1).Select(r => r.Mount)), "the other two mounts stay mount rows"); Assert.Equal(new[] { true, false, false }, rows.Select(r => r.Marks(observer.ResolvedTargetItem)).ToArray()); From 3098ff69a56dd2d803b5484655983b9e8f79cfe3 Mon Sep 17 00:00:00 2001 From: Meta Vens Date: Thu, 15 Oct 2026 01:39:37 +0200 Subject: [PATCH 6/7] Layout-aim tests: lookalike-hull lie, asymmetric two-mount Wing hull, Entity assertions without formatting, docked stance through IsHostileTo UnderALookalikeLieNoGearIsLocated: claimed and true hulls of one type and size with a reactor mount of equal type, position and shape. AMountAimReadsThePositionAxesInOrder and TwoMountsOfOneTypeAreTwoRows use a Wing hull off the diagonal with two Sensors mounts. Assert.Same and DoesNotContain over Entities become ReferenceEquals asserts. The docked-stance test reads IsHostileTo on a hostile-by-default subject instead of _iffOverrides. Test-only; no production change. Not yet built. Co-Authored-By: Claude Opus 5.5 --- .../ContactRevealTests.cs | 33 +++-- .../FireControlCut123Tests.cs | 2 +- .../FireControlCut8Tests.cs | 4 +- tests/Aetheria.Shared.Tests/LayoutAimTests.cs | 120 ++++++++++++++++-- .../LockOnScenarioTests.cs | 2 +- .../Aetheria.Shared.Tests/LockWarningTests.cs | 4 +- tests/Aetheria.Shared.Tests/LootTests.cs | 2 +- tests/Aetheria.Shared.Tests/MineTests.cs | 2 +- .../Aetheria.Shared.Tests/MiningIndexTests.cs | 2 +- tests/Aetheria.Shared.Tests/RunGoalTests.cs | 2 +- tests/Aetheria.Shared.Tests/SolutionTests.cs | 2 +- 11 files changed, 142 insertions(+), 33 deletions(-) diff --git a/tests/Aetheria.Shared.Tests/ContactRevealTests.cs b/tests/Aetheria.Shared.Tests/ContactRevealTests.cs index f6c55932a..d2f306217 100644 --- a/tests/Aetheria.Shared.Tests/ContactRevealTests.cs +++ b/tests/Aetheria.Shared.Tests/ContactRevealTests.cs @@ -70,6 +70,22 @@ public sealed partial class ContactRevealTests : IDisposable new HardpointData { Type = HardpointType.Sensors, Position = new int2(0, 3), Shape = new Shape() } } }); + // Layout-aim: a hull off the diagonal with two mounts of one type. The first Sensors mount covers (0,0) and (1,0), the + // second (2,3) and (3,3): a transposed position gives another cell, and a rule that matches mounts by type cannot + // tell the two apart. + var wingMount = new Shape(2, 1); + foreach (var cell in wingMount.AllCoordinates) wingMount[cell] = true; + var wingHull = new Shape(5, 5); + foreach (var cell in wingHull.AllCoordinates) wingHull[cell] = true; + cache.Upsert(new HullData + { + Name = "Wing", HullType = HullType.Ship, Shape = wingHull, Durability = 1000000, Mass = 1000, + Hardpoints = + { + new HardpointData { Type = HardpointType.Sensors, Position = new int2(0, 0), Shape = wingMount }, + new HardpointData { Type = HardpointType.Sensors, Position = new int2(2, 3), Shape = wingMount } + } + }); var bayReactor = Tool("BayReactor", new ReactorData { Charge = Const(1000), Efficiency = Const(1), OverloadEfficiency = Const(1), ThrottlingFactor = Const(2) }); bayReactor.Hardpoint = HardpointType.Reactor; cache.Upsert(bayReactor); @@ -1713,14 +1729,6 @@ public sealed partial class ContactRevealTests : IDisposable Assert.Equal(0L, smallest); } - // The observer's stored decision about a subject (SetIff), read as the entity holds it: a neutral decision (false) is also - // what an unset stance derives to here, so IsHostileTo alone cannot tell it kept from dropped. - private static bool? StoredStance(Entity observer, Entity subject) - { - var overrides = (System.Collections.IDictionary) typeof(Entity).GetField("_iffOverrides", BindingFlags.NonPublic | BindingFlags.Instance).GetValue(observer); - return overrides.Contains(subject) ? (bool) overrides[subject] : (bool?) null; - } - // A docked subject leaves the zone parented to its dock and rejoins on undock: the observer's decision about it, hostile or // neutral, outlives the lapse of the bearing, and is still there when it rejoins. [Theory] @@ -1731,8 +1739,13 @@ public sealed partial class ContactRevealTests : IDisposable var e = Add(float3(0, 0, 100), new[] { "Gun" }); var dock = Add(float3(200, 0, 0)); var o = Add(float3(0, 0, 0), new[] { "Eye" }); + // The observer's faction owns the zone and the subject's presence is not permitted, so the stance derived for an + // undecided subject is hostile: a neutral decision (false) that is dropped would read hostile, and is seen to be kept. + _zone.GalaxyZone.Owner = o.Faction; + e.CurrentSecurityLevel.Value = SecurityLevel.Critical; + Assert.True(o.IsHostileTo(e), "fixture: an undecided subject derives hostile"); o.SetIff(e, hostile); - Assert.Equal(hostile, StoredStance(o, e)); // fixture: the decision is stored + Assert.Equal(hostile, o.IsHostileTo(e)); // fixture: the decision is stored var gun = Guns(e)[0]; Fire(e, gun); ShowOnly(e, 1f, gun); @@ -1741,11 +1754,9 @@ public sealed partial class ContactRevealTests : IDisposable _zone.Remove(e); for (var i = 0; i < 100 && o.RevealedSubjects.Contains(e); i++) Tick(.1f); Assert.False(o.RevealedSubjects.Contains(e), "fixture: the book dropped the docked subject"); - Assert.Equal(hostile, StoredStance(o, e)); // a docked subject keeps the observer's decision Assert.Equal(hostile, o.IsHostileTo(e)); e.Parent = null; _zone.Admit(e, piloted: false); - Assert.Equal(hostile, StoredStance(o, e)); // and it is still there when the subject rejoins Assert.Equal(hostile, o.IsHostileTo(e)); } diff --git a/tests/Aetheria.Shared.Tests/FireControlCut123Tests.cs b/tests/Aetheria.Shared.Tests/FireControlCut123Tests.cs index bfb313a2b..670b96490 100644 --- a/tests/Aetheria.Shared.Tests/FireControlCut123Tests.cs +++ b/tests/Aetheria.Shared.Tests/FireControlCut123Tests.cs @@ -562,7 +562,7 @@ public sealed class FireControlCut123Tests : IDisposable var outcome = FireUntilHit(e, damageOverride: 5f); Assert.NotNull(outcome); Assert.True(outcome.Hit); - Assert.Same(e.Shooter, source); + Assert.True(ReferenceEquals(e.Shooter, source), "the hit names the shooter"); } // Stryker survivors: FireControl.Apply's two `walked[li] > 0` checks (metalLanes counting, and the diff --git a/tests/Aetheria.Shared.Tests/FireControlCut8Tests.cs b/tests/Aetheria.Shared.Tests/FireControlCut8Tests.cs index 8da880f4c..d5ce6a28b 100644 --- a/tests/Aetheria.Shared.Tests/FireControlCut8Tests.cs +++ b/tests/Aetheria.Shared.Tests/FireControlCut8Tests.cs @@ -266,8 +266,8 @@ public sealed class FireControlCut8Tests : IDisposable s.Locker.Deactivate(); Assert.False(s.Locker.Active); - Assert.DoesNotContain(s.Bystander, s.Locker.EntityInfoGathered.Keys); - Assert.Same(s.Bystander, s.Locker.Target.Value.Entity); // Deactivate does not null the entity's own Target + Assert.False(s.Locker.EntityInfoGathered.Keys.Any(k => ReferenceEquals(k, s.Bystander)), "the bystander is dropped from the locker's info"); + Assert.True(ReferenceEquals(s.Bystander, s.Locker.Target.Value.Entity), "the target is kept"); // Deactivate does not null the entity's own Target var ex = Record.Exception(() => s.Locker.Update(.1f)); Assert.Null(ex); diff --git a/tests/Aetheria.Shared.Tests/LayoutAimTests.cs b/tests/Aetheria.Shared.Tests/LayoutAimTests.cs index 362d54beb..32048a232 100644 --- a/tests/Aetheria.Shared.Tests/LayoutAimTests.cs +++ b/tests/Aetheria.Shared.Tests/LayoutAimTests.cs @@ -21,11 +21,25 @@ public sealed partial class ContactRevealTests // A Bay at `position`: its reactor on the reactor mount, a gun on the weapon mount when asked (with the targeting system // and reactor tool it needs to fire), and `tools` on free cells. + private Ship NewShip(string hull) + { + var hullRef = _items.ItemData.RefOf(_items.ItemData.GetByName(hull)); + return new Ship(_items, _zone, new EquippableItem { Data = hullRef, Durability = 1000000, Lot = _nextLot++ }, new EntitySettings()) + { Faction = new Faction { Name = "F" + Guid.NewGuid().ToString("N") } }; + } + + private Ship Place(Ship ship, float3 position) + { + _zone.Admit(ship, piloted: false); + ship.Position = position; + ship.Aim = float3(0, 0, 1); + _zone.Update(0f); + return ship; + } + private Ship AddBay(float3 position, bool gun = false, string[] tools = null) { - var hullRef = _items.ItemData.RefOf(_items.ItemData.GetByName("Bay")); - var ship = new Ship(_items, _zone, new EquippableItem { Data = hullRef, Durability = 1000000, Lot = _nextLot++ }, new EntitySettings()) - { Faction = new Faction { Name = "F" + Guid.NewGuid().ToString("N") } }; + var ship = NewShip("Bay"); Assert.True(ship.TryEquip(Make("BayReactor"), new int2(3, 3))); if (gun) Assert.True(ship.TryEquip(Make("Gun"), new int2(0, 3))); foreach (var name in (tools ?? new string[0]).Concat(gun ? new[] { "Targeting", "Reactor" } : new string[0])) @@ -34,11 +48,20 @@ public sealed partial class ContactRevealTests Assert.True(ship.TryFindSpace(item, out var at)); Assert.True(ship.TryEquip(item, at)); } - _zone.Admit(ship, piloted: false); - ship.Position = position; - ship.Aim = float3(0, 0, 1); - _zone.Update(0f); - return ship; + return Place(ship, position); + } + + // A Wing at `position` with a gun on each of its two Sensors mounts: gunA on the mount at (0,0), gunB at (2,3). + private (Ship wing, EquippedItem gunA, EquippedItem gunB) AddWing(float3 position) + { + var ship = NewShip("Wing"); + Assert.True(ship.TryEquip(Make("Gun"), new int2(0, 0))); + Assert.True(ship.TryEquip(Make("Gun"), new int2(2, 3))); + Place(ship, position); + var gunA = ship.Equipment.Single(i => i.Position.x == 0); + var gunB = ship.Equipment.Single(i => i.Position.x == 2); + Assert.True(gunA.Hardpoint != null && gunB.Hardpoint != null && !ReferenceEquals(gunA.Hardpoint, gunB.Hardpoint), "fixture: each gun sits on its own mount"); + return (ship, gunA, gunB); } private static EquippedItem Fitted(Entity e, string design) => e.Equipment.Single(i => i.Data.Name == design); @@ -134,6 +157,36 @@ public sealed partial class ContactRevealTests Assert.True(observer.TrySelectTargetItem(reactor)); } + // The lie is a lookalike: the claimed hull is of the true hull's type and size, with a reactor mount of the same type, + // position and shape as the true one, and a shield mount elsewhere. What the observer knows is the claimed layout, mount + // object by mount object; nothing of the true hull's layout leaks in because the two are alike. + [Fact] + public void UnderALookalikeLieNoGearIsLocated() + { + var (bay, observer, reactor) = Identified(); + var truth = bay.HullData; + var lookalike = new HullData + { + Name = "Lookalike", HullType = truth.HullType, Shape = new Shape(truth.Shape.Width, truth.Shape.Height), + Hardpoints = + { + new HardpointData { Type = HardpointType.Reactor, Position = truth.Hardpoints[0].Position, Shape = truth.Hardpoints[0].Shape }, + new HardpointData { Type = HardpointType.Shield, Position = new int2(3, 1), Shape = new Shape() } + } + }; + bay.Claim(lookalike); + Assert.True(truth.Shape.Width == lookalike.Shape.Width && truth.Shape.Height == lookalike.Shape.Height && truth.HullType == lookalike.HullType, "fixture: the two hulls are one size and type"); + Assert.True(lookalike.Hardpoints[0].Type == reactor.Hardpoint.Type && lookalike.Hardpoints[0].Position.Equals(reactor.Hardpoint.Position), "fixture: the claimed reactor mount looks like the true one"); + Assert.False(ReferenceEquals(lookalike.Hardpoints[0], reactor.Hardpoint), "fixture: and is not it"); + + Assert.True(lookalike.Hardpoints.SequenceEqual(observer.LayoutOf(bay)), "the layout is the claimed hull's, not the true hull's"); + Target(observer, bay); + Assert.False(FireControl.IsLocated(observer, reactor)); + Assert.False(observer.TrySelectTargetItem(reactor)); + Assert.True(ReferenceEquals(bay, observer.Target.Value.Entity), "the ship stays targetable"); + Assert.Equal(2, observer.SchematicRows(bay).Count(r => r.Mount != null)); + } + // ---- ToolGearIsNotLocatedByLayout ---- // Tool gear sits on no mount, so the layout locates nothing of it; an act does. @@ -160,14 +213,19 @@ public sealed partial class ContactRevealTests private (Ship shooter, Ship bay, EquippedItem reactor, Weapon weapon, EquippedItem gun) Gunner() { var bay = AddBay(float3(0, 0, 30)); + return GunnerOn(bay, Fitted(bay, "BayReactor")); + } + + // A shooter that holds a valid solution on `bay`, with `aimed` (fitted to it) selected as the aim point. + private (Ship shooter, Ship bay, EquippedItem reactor, Weapon weapon, EquippedItem gun) GunnerOn(Ship bay, EquippedItem aimed) + { var shooter = Add(float3(0, 0, 0), new[] { "Eye", "Gun" }); shooter.SetTarget(bay); for (var i = 0; i < 3; i++) Hold(shooter, 1, 1f, bay); Assert.True(shooter.VisibleEntities.Contains(bay), "fixture: the shooter tracks the ship"); var gun = Guns(shooter).Single(); - var reactor = Fitted(bay, "BayReactor"); - Assert.True(shooter.TrySelectTargetItem(reactor), "fixture: the located reactor is aimed at"); - return (shooter, bay, reactor, (Weapon) gun.Behaviors.Single(b => b is Weapon), gun); + Assert.True(shooter.TrySelectTargetItem(aimed), "fixture: the located item is aimed at"); + return (shooter, bay, aimed, (Weapon) gun.Behaviors.Single(b => b is Weapon), gun); } private PendingShot FireAt(Weapon weapon, EquippedItem gun, Entity shooter) @@ -206,6 +264,20 @@ public sealed partial class ContactRevealTests Assert.True(Math.Abs(mountSil.POnHull - itemSil.POnHull) > 1e-3f, "fixture: the two aim points are priced apart"); } + // The mount is a rectangle off the diagonal: the second Sensors mount covers (2,3) and (3,3), centre (2.5, 3). A position + // read with its axes swapped would centre the shot on (3.5, 2). + [Fact] + public void AMountAimReadsThePositionAxesInOrder() + { + var (wing, _, gunB) = AddWing(float3(0, 0, 30)); + var (shooter, _, _, weapon, gun) = GunnerOn(wing, gunB); + var shot = FireAt(weapon, gun, shooter); + Assert.True(shot.AimAtMount && ReferenceEquals(gunB, shot.Aimed), "fixture: the shot is aimed at the located gun's mount"); + Assert.True(FireControl.CommitProbability(shot, _zone.Time, out var sil) > 0f, "fixture: the shot has a price"); + Assert.Equal(2.5f, sil.AimPoint.x, 4); + Assert.Equal(3f, sil.AimPoint.y, 4); + } + // ---- TheShotKeepsTheAimItWasFiredWith ---- [Fact] @@ -259,6 +331,32 @@ public sealed partial class ContactRevealTests Assert.Equal(new[] { true, false, false }, rows.Select(r => r.Marks(observer.ResolvedTargetItem)).ToArray()); } + // Two mounts of one type: the rows tell them apart by mount, not by type. Aiming at one marks its row alone, and revealing + // the other gives way only that mount's row to its gear row, leaving this one a mount row. + [Fact] + public void TwoMountsOfOneTypeAreTwoRows() + { + var (wing, gunA, gunB) = AddWing(float3(0, 0, 100)); + var observer = Add(float3(0, 0, 0)); + observer.EntityInfoGathered[wing] = 1f; + var mountA = gunA.Hardpoint; + var mountB = gunB.Hardpoint; + Target(observer, wing); + Assert.True(observer.TrySelectTargetItem(gunB)); + + var rows = observer.SchematicRows(wing).ToArray(); + Assert.Equal(2, rows.Length); + Assert.True(ReferenceEquals(mountA, rows[0].Mount) && ReferenceEquals(mountB, rows[1].Mount), "one mount row per mount, in order"); + Assert.Equal(new[] { false, true }, rows.Select(r => r.Marks(observer.ResolvedTargetItem)).ToArray()); + + observer.RecordReveal(wing, gunA, SignatureChannel.LaunchFlash, gunA.Data); + rows = observer.SchematicRows(wing).ToArray(); + Assert.Equal(2, rows.Length); + Assert.True(ReferenceEquals(rows[0].Item, gunA) && rows[0].Mount == null, "the revealed gun's row leads"); + Assert.True(ReferenceEquals(rows[1].Mount, mountB) && rows[1].Item == null, "the other mount of the same type stays a mount row"); + Assert.Equal(new[] { false, true }, rows.Select(r => r.Marks(observer.ResolvedTargetItem)).ToArray()); + } + // ---- TheAIStillChoosesOnlyRevealedWeapons ---- // An AI ship that identifies a Bay with a gun on its weapon mount aims at no item: Combat chooses among the weapons it has diff --git a/tests/Aetheria.Shared.Tests/LockOnScenarioTests.cs b/tests/Aetheria.Shared.Tests/LockOnScenarioTests.cs index 995e25af8..5c0632787 100644 --- a/tests/Aetheria.Shared.Tests/LockOnScenarioTests.cs +++ b/tests/Aetheria.Shared.Tests/LockOnScenarioTests.cs @@ -26,7 +26,7 @@ public sealed partial class RunStartTests // The first real sensing tick: the hostile holds no track of the player, and the sim's own passive sensing, asked for // the player's signature at the staged place, settles below the detection threshold: the staging is out of its sight. arena.Update(.1f); - Assert.DoesNotContain(player, hostile.VisibleEnemies); + Assert.False(hostile.VisibleEnemies.Any(x => ReferenceEquals(x, player)), "the player is out of sight"); Assert.DoesNotContain(player.IncomingPaints, paint => paint.Mode == PaintMode.Track); Assert.True(player.Visibility > 0f, "the player shows a signature to be seen by"); var threshold = player.ItemManager.GameplaySettings.TargetDetectionInfoThreshold; diff --git a/tests/Aetheria.Shared.Tests/LockWarningTests.cs b/tests/Aetheria.Shared.Tests/LockWarningTests.cs index a9e1c23cd..582a1b78f 100644 --- a/tests/Aetheria.Shared.Tests/LockWarningTests.cs +++ b/tests/Aetheria.Shared.Tests/LockWarningTests.cs @@ -224,14 +224,14 @@ public sealed class LockWarningTests : IDisposable var locker = NewShip(float3(0, 0, 0), "Launcher"); var b = NewShip(float3(0, 0, 9)); b.EntityInfoGathered[locker] = 0; - Assert.DoesNotContain(locker, b.VisibleEnemies); + Assert.False(b.VisibleEnemies.Any(x => ReferenceEquals(x, locker)), "the locker is not a visible enemy"); Aim(locker, b); Tick(.5f); Tick(0f); Assert.Single(b.IncomingPaints); - Assert.DoesNotContain(locker, b.VisibleEnemies); + Assert.False(b.VisibleEnemies.Any(x => ReferenceEquals(x, locker)), "the locker is not a visible enemy"); } [Fact] diff --git a/tests/Aetheria.Shared.Tests/LootTests.cs b/tests/Aetheria.Shared.Tests/LootTests.cs index 5a5bfce7d..2cac1ae2f 100644 --- a/tests/Aetheria.Shared.Tests/LootTests.cs +++ b/tests/Aetheria.Shared.Tests/LootTests.cs @@ -431,7 +431,7 @@ public sealed class LootTests : IDisposable Kill(ship); var count = lab.Zone.FloatingItems.Count; Assert.NotEmpty(lab.Zone.FloatingItems); - Assert.DoesNotContain(ship, lab.Zone.Entities); + Assert.False(lab.Zone.Entities.Any(x => ReferenceEquals(x, ship)), "the ship is removed from the zone"); // A second death signal from the removed entity reaches no subscription. Kill(ship); Assert.Equal(count, lab.Zone.FloatingItems.Count); diff --git a/tests/Aetheria.Shared.Tests/MineTests.cs b/tests/Aetheria.Shared.Tests/MineTests.cs index 4d42e0add..f5274896b 100644 --- a/tests/Aetheria.Shared.Tests/MineTests.cs +++ b/tests/Aetheria.Shared.Tests/MineTests.cs @@ -651,7 +651,7 @@ public sealed class MineTests : IDisposable lab.Layer.Position = float3(5000, 0, 5000); foreach (var step in Steps(1f, .1f)) lab.Zone.Update(step); lab.Layer.DamageHull(5000000f); - Assert.DoesNotContain(lab.Layer, lab.Zone.Entities); + Assert.False(lab.Zone.Entities.Any(x => ReferenceEquals(x, lab.Layer)), "the layer is removed from the zone"); foreach (var step in Steps(1.5f, .1f)) lab.Zone.Update(step); Assert.Equal(0, removed); diff --git a/tests/Aetheria.Shared.Tests/MiningIndexTests.cs b/tests/Aetheria.Shared.Tests/MiningIndexTests.cs index f1f8eed9d..e398700df 100644 --- a/tests/Aetheria.Shared.Tests/MiningIndexTests.cs +++ b/tests/Aetheria.Shared.Tests/MiningIndexTests.cs @@ -290,7 +290,7 @@ public sealed partial class MiningCut3Tests Assert.Empty(candidates); s.Zone.Targets.Within(new TargetSearch(here, 500f), candidates); Assert.Empty(candidates); - Assert.DoesNotContain(unseen, observer.VisibleEntities); + Assert.False(observer.VisibleEntities.Any(x => ReferenceEquals(x, unseen)), "the unseen entity is not visible"); } private readonly struct NearestChunk : ITargetKey diff --git a/tests/Aetheria.Shared.Tests/RunGoalTests.cs b/tests/Aetheria.Shared.Tests/RunGoalTests.cs index afdaa658a..745a6c2a2 100644 --- a/tests/Aetheria.Shared.Tests/RunGoalTests.cs +++ b/tests/Aetheria.Shared.Tests/RunGoalTests.cs @@ -125,7 +125,7 @@ public sealed partial class RunStartTests // Death removes the boss from the zone (the one death path), and that alone opens the gate. boss.HeatstrokeDeath.OnNext(Unit.Default); - Assert.DoesNotContain(boss, exit.Entities); + Assert.False(exit.Entities.Any(x => ReferenceEquals(x, boss)), "the boss is removed from the exit"); Assert.False(RunGoal.BossAlive(exit)); Assert.True(RunGoal.ExitOpen(exit)); diff --git a/tests/Aetheria.Shared.Tests/SolutionTests.cs b/tests/Aetheria.Shared.Tests/SolutionTests.cs index d3a18141c..94f9b4e7c 100644 --- a/tests/Aetheria.Shared.Tests/SolutionTests.cs +++ b/tests/Aetheria.Shared.Tests/SolutionTests.cs @@ -201,7 +201,7 @@ public sealed partial class FireControlCut124Tests var other = FireControl.Solution(e.Weapon, e.Shooter, decoy).Direction; Assert.True(FireControl.Bears(e.Weapon, e.Shooter, decoy, out _)); Assert.True(length(designated - other) > .3f, "fixture: the two intercepts must differ"); - Assert.Same(e.Target, e.Shooter.Target.Value.Entity); + Assert.True(ReferenceEquals(e.Target, e.Shooter.Target.Value.Entity), "the designated target is kept"); Near(other, FireControl.Solve(e.Weapon, e.WeaponItem, e.Shooter, decoy).TravelDirection); Near(designated, FireControl.Solve(e.Weapon, e.WeaponItem, e.Shooter, e.Target).TravelDirection); From 61e66dac35c069f6d2f81e5c888b36545842c7e2 Mon Sep 17 00:00:00 2001 From: Meta Vens Date: Thu, 15 Oct 2026 01:52:44 +0200 Subject: [PATCH 7/7] Layout-aim tests: AddWing picks the guns by design name (the hull is equipment too) Fixes 3098ff69, whose two Wing tests did not build their fixture. Test-only. Co-Authored-By: Claude Opus 5.5 --- tests/Aetheria.Shared.Tests/LayoutAimTests.cs | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/tests/Aetheria.Shared.Tests/LayoutAimTests.cs b/tests/Aetheria.Shared.Tests/LayoutAimTests.cs index 32048a232..072942184 100644 --- a/tests/Aetheria.Shared.Tests/LayoutAimTests.cs +++ b/tests/Aetheria.Shared.Tests/LayoutAimTests.cs @@ -58,8 +58,8 @@ public sealed partial class ContactRevealTests Assert.True(ship.TryEquip(Make("Gun"), new int2(0, 0))); Assert.True(ship.TryEquip(Make("Gun"), new int2(2, 3))); Place(ship, position); - var gunA = ship.Equipment.Single(i => i.Position.x == 0); - var gunB = ship.Equipment.Single(i => i.Position.x == 2); + var gunA = ship.Equipment.Single(i => i.Data.Name == "Gun" && i.Position.x == 0); + var gunB = ship.Equipment.Single(i => i.Data.Name == "Gun" && i.Position.x == 2); Assert.True(gunA.Hardpoint != null && gunB.Hardpoint != null && !ReferenceEquals(gunA.Hardpoint, gunB.Hardpoint), "fixture: each gun sits on its own mount"); return (ship, gunA, gunB); }