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/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/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..7ed482a14 100644 --- a/Assets/Scripts/ServerShared/Sensing.cs +++ b/Assets/Scripts/ServerShared/Sensing.cs @@ -127,25 +127,38 @@ 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) { Item = item; Claimed = claimed; + Mount = null; _own = own; } + internal SchematicRow(HardpointData mount) : this() + { + 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(); diff --git a/tests/Aetheria.Shared.Tests/ContactRevealTests.cs b/tests/Aetheria.Shared.Tests/ContactRevealTests.cs index 83a2b8cd1..d2f306217 100644 --- a/tests/Aetheria.Shared.Tests/ContactRevealTests.cs +++ b/tests/Aetheria.Shared.Tests/ContactRevealTests.cs @@ -53,6 +53,42 @@ 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 + // (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); + 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(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() } + } + }); + // 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); 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 @@ -1693,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] @@ -1711,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); @@ -1721,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/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..072942184 --- /dev/null +++ b/tests/Aetheria.Shared.Tests/LayoutAimTests.cs @@ -0,0 +1,381 @@ +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 +// (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 +// 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 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 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])) + { + var item = Make(name); + Assert.True(ship.TryFindSpace(item, out var at)); + Assert.True(ship.TryEquip(item, at)); + } + 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.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); + } + + 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)); + } + + // 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. + [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)); + 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(); + 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) + { + 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 (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() + { + 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(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); + 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(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"); + } + + // 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] + 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(3.5f, sil.AimPoint.x, 4); + Assert.Equal(3.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(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 => + { + 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.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()); + } + + // 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 + // 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/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/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. 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); 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,