Aetheria/tests/Aetheria.Shared.Tests/MountCycleTests.cs
Meta Vens a54e2f53df Pin the mount aim's resolved reads, the record's isolation and its Deactivate clear; delete two dead guards
Forecast and Fire drop a mount aim whose gate closed (destroyed, unidentified); a record for one Bay never hides another's mount; Deactivate clears the record. Deleted: the located-or-booked guard in RecordSeenDestroyed and the Entity == subject check in ResolvedAim (the record is read only for layout mounts; a Target change already clears the slot). Comments name AimedAt. Not built or run yet.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-15 07:14:35 +02:00

259 lines
13 KiB
C#

using System;
using System.Collections.Generic;
using System.Linq;
using CultMath;
using Xunit;
using static CultMath.math;
using float2 = CultMath.float2;
using float3 = CultMath.float3;
// Mount cycle (rulings cycle-skips-only-perceived-destroyed, quiet-gear-identifiable-hull-layout-known, reveal-ends-on-perception,
// same-ship-same-inputs): the player's target-item cycle visits every mount the observer has located on its target, empty or
// not, and skips only the mounts whose item it perceived destroyed. What the cycle holds depends on the observer's own
// state -- its identification of the ship, its book, the destructions it sensed -- and on nothing the target alone knows:
// a ship lying about its hull and an honest ship of the claimed hull offer the same cycle. Fixtures are LayoutAimTests'
// (Bay: reactor mount, shield mount, sensors mount; Wing: two sensors mounts). Hardpoints and items are compared with
// ReferenceEquals, as there.
public sealed partial class ContactRevealTests
{
private static HardpointData[] MountsOf(IEnumerable<AimTarget> cycle) => cycle.Where(a => a.Mount != null).Select(a => a.Mount).ToArray();
private static bool SameMounts(HardpointData[] a, HardpointData[] b) => a.Length == b.Length && a.Zip(b, (x, y) => ReferenceEquals(x, y)).All(same => same);
// Whether a cycle entry is exactly `item` or exactly `mount`, by reference.
private static bool Is(AimTarget aim, EquippedItem item, HardpointData mount) => ReferenceEquals(aim.Item, item) && ReferenceEquals(aim.Mount, mount);
// ---- TheCycleCoversEveryLocatedMount ----
// An identified honest Bay with its gun seen firing and its shield mount empty. The cycle lists the revealed gun, then every
// mount of the hull in layout order -- the empty shield mount included -- and CycleAim visits each in turn and wraps.
[Fact]
public void TheCycleCoversEveryLocatedMount()
{
var bay = AddBay(float3(0, 0, 100), gun: true);
var observer = Add(float3(0, 0, 0));
observer.EntityInfoGathered[bay] = 1f;
var gun = Guns(bay).Single();
observer.RecordReveal(bay, gun, SignatureChannel.LaunchFlash, gun.Data);
var mounts = bay.HullData.Hardpoints;
Assert.True(bay.Equipment.All(i => i.Hardpoint == null || !ReferenceEquals(i.Hardpoint, mounts[1])), "fixture: the shield mount is empty");
Target(observer, bay);
var cycle = observer.AimCycle(bay).ToArray();
Assert.Equal(4, cycle.Length);
Assert.True(Is(cycle[0], gun, null), "the revealed gun leads");
Assert.True(Is(cycle[1], null, mounts[0]) && Is(cycle[2], null, mounts[1]) && Is(cycle[3], null, mounts[2]), "then every mount in layout order, the empty one included");
for (var i = 0; i < 5; i++)
{
observer.CycleAim();
var want = cycle[i % 4];
Assert.True(Is(observer.ResolvedAim, want.Item, want.Mount), $"step {i} of the cycle visits entry {i % 4}, wrapping after the last");
}
}
// ---- UnderAHullLieTheCycleIsTheClaimedHulls ----
// A Wing claiming to be a Bay and an honest Bay, in the observer's info alike (identified, nothing revealed): the cycle's
// mounts are the claimed hull's own HardpointData in its order -- three, not the Wing's two -- each can be selected, and a
// mount aim prices a shot at the same cells whichever ship it is aimed on. The cycle shows nothing of the lie.
[Fact]
public void UnderAHullLieTheCycleIsTheClaimedHulls()
{
var honest = AddBay(float3(0, 0, 100));
var (liar, _, _) = AddWing(float3(0, 0, -100));
var observer = Add(float3(0, 0, 0));
observer.EntityInfoGathered[honest] = 1f;
observer.EntityInfoGathered[liar] = 1f;
liar.Claim(honest.HullData);
Assert.Equal(2, liar.HullData.Hardpoints.Count);
var honestMounts = MountsOf(observer.AimCycle(honest));
var liarMounts = MountsOf(observer.AimCycle(liar));
Assert.Equal(3, honestMounts.Length);
Assert.True(SameMounts(honestMounts, liarMounts), "the same HardpointData in the same order");
Assert.True(SameMounts(honest.HullData.Hardpoints.ToArray(), liarMounts), "and they are the claimed hull's");
Assert.Equal(3, observer.AimCycle(liar).Count());
foreach (var (subject, mounts) in new[] { (honest, honestMounts), (liar, liarMounts) })
{
Target(observer, subject);
foreach (var mount in mounts) Assert.True(observer.TrySelectMount(mount), "each cycle mount can be selected");
}
for (var i = 0; i < honestMounts.Length; i++)
{
var onHonest = FireControl.Silhouette(honest, honest.HullData, null, float2(0, 1), 1f, null, honestMounts[i]).AimPoint;
var onLiar = FireControl.Silhouette(liar, liar.HullData, null, float2(0, 1), 1f, null, liarMounts[i]).AimPoint;
Assert.Equal(onHonest.x, onLiar.x, 4);
Assert.Equal(onHonest.y, onLiar.y, 4);
}
var reactorAim = FireControl.Silhouette(liar, liar.HullData, null, float2(0, 1), 1f, null, liarMounts[0]).AimPoint;
Assert.Equal(3.5f, reactorAim.x, 4);
Assert.Equal(3.5f, reactorAim.y, 4);
}
// ---- TheIdentificationGateReadsOnlyObserverState ----
// Whether the cycle holds any mount is the observer's identification of the ship (its info against
// TargetGearInfoThreshold), the same for a liar and an honest ship and with the claim set or not: just below the threshold
// the cycle holds no mount, at it, all of the hull the observer believes in.
[Fact]
public void TheIdentificationGateReadsOnlyObserverState()
{
var honest = AddBay(float3(0, 0, 100));
var (liar, _, _) = AddWing(float3(0, 0, -100));
var observer = Add(float3(0, 0, 0));
foreach (var claimed in new[] { false, true })
{
liar.Claim(claimed ? honest.HullData : null);
var believed = claimed ? 3 : 2;
foreach (var (subject, count) in new[] { (honest, 3), (liar, believed) })
{
observer.EntityInfoGathered[subject] = Identify - .01f;
Assert.Empty(MountsOf(observer.AimCycle(subject)));
observer.EntityInfoGathered[subject] = Identify;
Assert.Equal(count, MountsOf(observer.AimCycle(subject)).Length);
}
}
}
// ---- AMountSeenDestroyedLeavesTheCycle ----
// The reactor is destroyed while the observer is below identification: it sees nothing, and its mount stays in the cycle
// when the identification is later reached but not yet sensed. Sensed at identification, the mount leaves and an aim at
// it resolves to none; it stays out when the info falls back below and recovers (the record is the observer's, not a
// live read), and the other mounts stay.
[Fact]
public void AMountSeenDestroyedLeavesTheCycle()
{
var (bay, observer, reactor) = Identified(Identify - .01f);
var mounts = bay.HullData.Hardpoints;
var sense = SenseOf(observer);
Target(observer, bay);
reactor.EquippableItem.Durability = 0f;
Assert.True(reactor.EquippableItem.IsDestroyed, "fixture");
sense();
observer.EntityInfoGathered[bay] = Identify;
Assert.True(MountsOf(observer.AimCycle(bay)).Any(m => ReferenceEquals(m, reactor.Hardpoint)), "destroyed below identification: the mount stays");
Assert.True(observer.TrySelectMount(reactor.Hardpoint), "fixture: aimed at it");
sense();
Assert.True(SameMounts(new[] { mounts[1], mounts[2] }, MountsOf(observer.AimCycle(bay))), "sensed at identification: the reactor mount leaves, the others stay");
Assert.True(observer.ResolvedAim.Mount == null, "an aim at it resolves to none");
Assert.False(observer.TrySelectMount(reactor.Hardpoint));
observer.EntityInfoGathered[bay] = Identify - .01f;
Assert.Empty(MountsOf(observer.AimCycle(bay)));
observer.EntityInfoGathered[bay] = Identify;
Assert.True(SameMounts(new[] { mounts[1], mounts[2] }, MountsOf(observer.AimCycle(bay))), "it stays out after the identification recovers");
Assert.False(observer.TrySelectMount(reactor.Hardpoint));
}
// ---- ADestroyedItemKnownOnlyByItsActLeavesItsMountUntilPerceived ----
// The reactor acted in the observer's sight (it is in the book) and is then destroyed while the observer does not resolve the
// ship: the book is no perception of the destruction. Its mount is still in the cycle when the identification is later
// reached, until a Sense at identification perceives it.
[Fact]
public void ADestroyedItemKnownOnlyByItsActLeavesItsMountUntilPerceived()
{
var (bay, observer, reactor) = Identified(Identify - .01f);
observer.RecordReveal(bay, reactor, SignatureChannel.LaunchFlash, reactor.Data);
Assert.True(FireControl.IsRevealed(observer, reactor), "fixture: the reactor is in the book");
var sense = SenseOf(observer);
reactor.EquippableItem.Durability = 0f;
sense();
observer.EntityInfoGathered[bay] = Identify;
Assert.True(MountsOf(observer.AimCycle(bay)).Any(m => ReferenceEquals(m, reactor.Hardpoint)), "not perceived: the mount stays");
sense();
Assert.False(MountsOf(observer.AimCycle(bay)).Any(m => ReferenceEquals(m, reactor.Hardpoint)), "perceived at identification: the mount leaves");
}
// ---- TheSeenDestroyedRecordGoesWithTheSubject ----
// The record is the observer's memory of one subject: when that subject has left the zone and the observer holds no book
// of it (a quiet ship, never seen to act), the record is dropped at the observer's next Sense, not held for the sitting.
[Theory]
[InlineData("remove")]
[InlineData("warp")]
public void TheSeenDestroyedRecordGoesWithTheSubject(string how)
{
var (bay, observer, reactor) = Identified();
var sense = SenseOf(observer);
reactor.EquippableItem.Durability = 0f;
sense();
Assert.False(MountsOf(observer.AimCycle(bay)).Any(m => ReferenceEquals(m, reactor.Hardpoint)), "fixture: recorded while the ship is here");
Assert.Empty(observer.RevealsOf(bay));
Leave(_zone, bay, how);
sense();
observer.EntityInfoGathered[bay] = 1f;
Assert.True(MountsOf(observer.AimCycle(bay)).Any(m => ReferenceEquals(m, reactor.Hardpoint)), "the record went with the ship");
}
// ---- ADestroyedClaimedMountIsNotSkippedUnderALie ----
// The known gap (follow-up perceived-destruction-under-hull-lie): perceiving a destruction reads the true item, and the
// record keys the true item's mount, so under a hull lie no claimed mount is recorded. The Wing's destroyed guns leave
// all three claimed Bay mounts in the cycle. This pins the gap as it stands, so closing it (aetheria-ew) edits this test.
[Fact]
public void ADestroyedClaimedMountIsNotSkippedUnderALie()
{
var honest = AddBay(float3(0, 0, 100));
var (liar, gunA, gunB) = AddWing(float3(0, 0, -100));
var observer = Add(float3(0, 0, 0));
observer.EntityInfoGathered[liar] = 1f;
liar.Claim(honest.HullData);
gunA.EquippableItem.Durability = 0f;
gunB.EquippableItem.Durability = 0f;
SenseOf(observer)();
Assert.True(SameMounts(honest.HullData.Hardpoints.ToArray(), MountsOf(observer.AimCycle(liar))), "no claimed mount is recorded destroyed");
}
// ---- TheRecordIsPerSubject ----
// Two ships of one hull share their HardpointData. The reactor seen destroyed on one Bay leaves that Bay's cycle and no
// other: the observer's memory is of the ship it saw, not of the mount.
[Fact]
public void TheRecordIsPerSubject()
{
var (bayA, observer, reactorA) = Identified();
var bayB = AddBay(float3(100, 0, 100));
observer.EntityInfoGathered[bayB] = 1f;
var reactorB = Fitted(bayB, "BayReactor");
Assert.True(ReferenceEquals(reactorA.Hardpoint, reactorB.Hardpoint), "fixture: the two Bays share their mount objects");
reactorA.EquippableItem.Durability = 0f;
SenseOf(observer)();
Assert.False(MountsOf(observer.AimCycle(bayA)).Any(m => ReferenceEquals(m, reactorA.Hardpoint)), "fixture: seen destroyed on A");
Assert.True(MountsOf(observer.AimCycle(bayB)).Any(m => ReferenceEquals(m, reactorB.Hardpoint)), "B's reactor mount stays in B's cycle");
Target(observer, bayB);
Assert.True(observer.TrySelectMount(reactorB.Hardpoint), "and can be aimed at on B");
}
// ---- DeactivateClearsTheRecord ----
// A ship that warps out forgets what it saw: its fresh book no longer supports the record, so the mount seen destroyed
// is back in the cycle.
[Fact]
public void DeactivateClearsTheRecord()
{
var (bay, observer, reactor) = Identified();
reactor.EquippableItem.Durability = 0f;
SenseOf(observer)();
Assert.False(MountsOf(observer.AimCycle(bay)).Any(m => ReferenceEquals(m, reactor.Hardpoint)), "fixture: recorded");
observer.Deactivate();
observer.EntityInfoGathered[bay] = 1f;
Assert.True(MountsOf(observer.AimCycle(bay)).Any(m => ReferenceEquals(m, reactor.Hardpoint)), "the record went with the observer's memory");
}
}