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);