Aetheria/tests/Aetheria.Shared.Tests/LayoutAimTests.cs
Meta Vens 86c3a94880 Merge origin/master into faction-play-1
Conflicts: GameData/Aetheria.cc takes master's blob (doctrine-catalog and verse-grammar re-applied in a later commit, follow-up catalog-blob-merge-order); IffAndCombatTests NewShip admits through zone.Admit(ship, piloted); Program.cs keeps master's cases plus doctrine-catalog; FireControlPerWeaponTests keeps master's asserts with the branch's Minion constructor. Master-only compile sites adapted: Minion(ship, doctrine) in FireControlPerWeaponTests, LayoutAimTests, ScannerTests; AgentFires threshold in SolutionTests; Zone.Remove and VerseGrammar in FlightTests.

Not verified to build until the verify run at the end of the pass.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-16 20:18:54 +02:00

430 lines
23 KiB
C#

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<ItemData>(_items.ItemData.GetByName<HullData>(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));
}
// ---- TheReactorMountIsAimedNotRevealed ----
// Identified, the reactor never acted: the observer has located its mount (can aim at it) and learned nothing of the
// item. Below the identify threshold it can aim at no mount, and a mount aim already taken is dropped when the
// identification is lost. An unrevealed item is never an item aim.
[Fact]
public void TheReactorMountIsAimedNotRevealed()
{
var (bay, observer, reactor) = Identified(Identify - .01f);
Target(observer, bay);
Assert.False(observer.TrySelectMount(reactor.Hardpoint));
observer.EntityInfoGathered[bay] = Identify;
Assert.False(FireControl.IsRevealed(observer, reactor));
Assert.False(observer.TrySelectTargetItem(reactor), "an unrevealed item is not an item aim");
Assert.True(observer.TrySelectMount(reactor.Hardpoint));
Assert.True(ReferenceEquals(reactor.Hardpoint, observer.ResolvedAim.Mount), "the located reactor mount is the aim");
Assert.True(observer.ResolvedAim.Item == null && observer.ResolvedTargetItem == null, "a mount aim names no item");
observer.EntityInfoGathered[bay] = Identify - .01f;
Assert.True(observer.ResolvedAim.Mount == null, "the aim is dropped with the identification");
}
// ---- AnUnperceivedDestructionLeavesTheMountAimed ----
// The observer perceives a destruction only through its own Sense: the reactor destroyed unseen leaves its mount aimed
// (a live read of the item's Durability would tell the observer what it has not sensed); once Sensed it is gone.
[Fact]
public void AnUnperceivedDestructionLeavesTheMountAimed()
{
var (bay, observer, reactor) = Identified();
Target(observer, bay);
Assert.True(observer.TrySelectMount(reactor.Hardpoint), "fixture: the reactor mount is aimed at");
reactor.EquippableItem.Durability = 0f;
Assert.True(reactor.EquippableItem.IsDestroyed, "fixture");
Assert.True(ReferenceEquals(reactor.Hardpoint, observer.ResolvedAim.Mount), "nothing sensed yet: the aim stands");
Assert.True(observer.TrySelectMount(reactor.Hardpoint));
SenseOf(observer)();
Assert.True(observer.ResolvedAim.Mount == null, "perceived destroyed: the aim goes");
Assert.False(observer.TrySelectMount(reactor.Hardpoint));
}
// ---- UnderAHullLieNoGearIsAimed ----
// 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 the true item is never an aim: the claimed mount is, and the true reactor's own
// mount is not; the ship stays targetable. The true mount is aimable again when the lie is seen through.
[Fact]
public void UnderAHullLieNoGearIsAimed()
{
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(observer.TrySelectTargetItem(reactor), "the true reactor is not an item aim");
Assert.False(observer.TrySelectMount(reactor.Hardpoint), "nor is its true mount, which the observer does not believe in");
Assert.True(observer.TrySelectMount(decoy.Hardpoints[0]), "the claimed mount is");
Assert.True(ReferenceEquals(decoy.Hardpoints[0], observer.ResolvedAim.Mount));
Assert.True(observer.ResolvedTargetItem == null);
Assert.True(ReferenceEquals(bay, observer.Target.Value.Entity), "the ship stays targetable");
bay.Claim(null);
Assert.True(observer.ResolvedAim.Mount == null, "the claimed mount is not the believed hull's once the lie is seen through");
Assert.True(observer.TrySelectMount(reactor.Hardpoint));
}
// 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 UnderALookalikeLieNoGearIsAimed()
{
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(observer.TrySelectTargetItem(reactor));
Assert.False(observer.TrySelectMount(reactor.Hardpoint), "the true reactor mount is not the claimed one, though they look alike");
Assert.True(observer.TrySelectMount(lookalike.Hardpoints[0]));
Assert.True(ReferenceEquals(bay, observer.Target.Value.Entity), "the ship stays targetable");
Assert.Equal(2, observer.SchematicRows(bay).Count(r => r.Mount != null));
}
// ---- ToolGearIsNotAimedByLayout ----
// Tool gear sits on no mount, so the layout offers nothing of it to aim at; an act reveals it, and then it is an item aim.
[Fact]
public void ToolGearIsNotAimedByLayout()
{
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(observer.TrySelectTargetItem(radiator));
Assert.DoesNotContain(observer.AimCycle(bay), a => a.Item != null);
observer.RecordReveal(bay, radiator, SignatureChannel.LaunchFlash, radiator.Data);
Assert.True(observer.TrySelectTargetItem(radiator));
Assert.Contains(observer.AimCycle(bay), a => ReferenceEquals(a.Item, radiator));
}
// ---- AMountAimIsTheMountsCells ----
// A shooter that holds a valid solution on a Bay 30 ahead, with the reactor's mount selected as the aim.
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 the mount of `aimed` (fitted to it) selected as the aim.
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.TrySelectMount(aimed.Hardpoint), "fixture: the mount 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(ReferenceEquals(reactor.Hardpoint, shot.AimedMount) && shot.Aimed == null, "the shot is aimed at the located reactor's mount, not the item");
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");
Assert.True(shooter.TrySelectTargetItem(reactor), "fixture: the revealed reactor is aimed at as an item");
var revealed = FireControl.Inspect(weapon, shooter, bay);
var next = FireAt(weapon, gun, shooter);
Assert.True(next.AimedMount == null && ReferenceEquals(reactor, next.Aimed), "the next shot is aimed at the item");
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(ReferenceEquals(gunB.Hardpoint, shot.AimedMount), "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);
}
// ---- AMountAimThatLostItsGateDropsOutOfForecastAndFire ----
// The aim slot holds the mount, but what prices and freezes a shot is the aim as it resolves now: a mount the observer has
// since seen destroyed, or whose hull it no longer identifies, is no aim. The forecast prices the unaimed hull and the
// shot is fired aimed at nothing, though nothing wrote the slot at the moment the gate closed.
[Theory]
[InlineData("destroyed")]
[InlineData("unidentified")]
public void AMountAimThatLostItsGateDropsOutOfForecastAndFire(string how)
{
var (shooter, bay, reactor, weapon, gun) = Gunner();
var aimedPrice = FireControl.Inspect(weapon, shooter, bay).POnHull;
shooter.TrySelectTargetItem(null);
var unaimedPrice = FireControl.Inspect(weapon, shooter, bay).POnHull;
Assert.True(Math.Abs(aimedPrice - unaimedPrice) > 1e-3f, "fixture: the mount aim and no aim are priced apart");
Assert.True(shooter.TrySelectMount(reactor.Hardpoint), "fixture: the mount is aimed at again");
if (how == "destroyed")
{
reactor.EquippableItem.Durability = 0f;
SenseOf(shooter)();
}
else
{
shooter.EntityInfoGathered[bay] = Identify - .01f;
}
Assert.True(shooter.ResolvedAim.Mount == null, "fixture: the aim no longer resolves");
if (how == "destroyed")
Assert.Equal(unaimedPrice, FireControl.Inspect(weapon, shooter, bay).POnHull, 4);
var shot = FireAt(weapon, gun, shooter);
Assert.True(shot.AimedMount == null && shot.Aimed == null, "the shot is aimed at nothing");
}
// ---- TheShotKeepsTheAimItWasFiredWith ----
[Fact]
public void TheShotKeepsTheAimItWasFiredWith()
{
var (shooter, bay, reactor, weapon, gun) = Gunner();
var shot = FireAt(weapon, gun, shooter);
Assert.True(ReferenceEquals(reactor.Hardpoint, shot.AimedMount), "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(shooter.TrySelectTargetItem(reactor), "fixture: and aimed at as an item, which does not move a shot already fired");
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 gear row leads and every mount keeps its row, the reactor's included (rows never compare the true
// items to the believed layout). The aimed mount is marked on its row alone.
[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.TrySelectMount(reactor.Hardpoint));
Assert.Equal(new[] { true, false, false }, rows.Select(r => r.Marks(observer.ResolvedAim)).ToArray());
observer.RecordInspection(bay);
rows = observer.SchematicRows(bay).ToArray();
Assert.Equal(4, rows.Length);
Assert.Equal(4, 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.SequenceEqual(rows.Skip(1).Select(r => r.Mount)), "every mount keeps its mount row, the reactor's too");
Assert.Equal(new[] { false, true, false, false }, rows.Select(r => r.Marks(observer.ResolvedAim)).ToArray());
Assert.True(observer.TrySelectTargetItem(reactor));
Assert.Equal(new[] { true, false, false, false }, rows.Select(r => r.Marks(observer.ResolvedAim)).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.TrySelectMount(mountB));
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.ResolvedAim)).ToArray());
observer.RecordReveal(wing, gunA, SignatureChannel.LaunchFlash, gunA.Data);
rows = observer.SchematicRows(wing).ToArray();
Assert.Equal(3, 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, mountA) && ReferenceEquals(rows[2].Mount, mountB), "both mounts keep their mount rows");
Assert.Equal(new[] { false, false, true }, rows.Select(r => r.Marks(observer.ResolvedAim)).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, FactionDoctrine.Default(ai.ItemManager.GameplaySettings).Combatant));
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.False(FireControl.IsRevealed(ai, gun), "fixture: the gun is 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");
}
}