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>
259 lines
13 KiB
C#
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");
|
|
}
|
|
}
|