Aetheria/tests/Aetheria.Shared.Tests/ScannerTests.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

505 lines
22 KiB
C#

using System;
using System.Collections.Generic;
using System.Linq;
using CultMath;
using GameCult.Caching.MessagePack;
using MessagePack;
using Xunit;
using static CultMath.math;
using float2 = CultMath.float2;
using float3 = CultMath.float3;
// Inspection (rulings quiet-gear-identifiable-hull-layout-known, scan-held-beam, scan-is-an-act, reveal-ends-on-perception,
// sensor-stat-set): a scanner held on its ship's tracked target within Range for ScanTime reveals everything fitted to it,
// as claimed, and is itself an emission the target is warned of and a third party can hear. The fixtures are the
// catalog's: "Scanner" has Range 200 and ScanTime 2, "QuietScanner" the same with intercept factor 4 (heard at 50). A tick
// is 1/8 s, so a scan builds 1/16 a tick and finishes on the sixteenth with no float drift. The scanner's ship keeps
// its track on the target at .5 each tick (above the detection gate, below the one at which gear is resolved, so
// Sense neither loses the track nor perceives a destruction). Every fixture drives the production path, ticking inside
// Zone.Update.
public sealed partial class ContactRevealTests
{
private const float ScanStep = .125f;
private static ScannerData ScannerBehavior(float range, float scanTime, float? interceptFactor = null) => new ScannerData
{
Range = Const(range), ScanTime = Const(scanTime),
InterceptFactor = interceptFactor.HasValue ? Const(interceptFactor.Value) : null
};
private static Scanner ScannerOf(Entity e) => e.Equipment.SelectMany(i => i.Behaviors).OfType<Scanner>().Single();
private static EquippedItem ScannerItemOf(Entity e) => e.Equipment.Single(i => i.Behaviors.Any(b => b is Scanner));
// A scanner ship at the origin, with the named scanner and no sensor (an AI ship with a sensor pings once it loses its
// track, which would paint the target beside the scan), tracking and targeting a ship `distance`
// ahead that carries a reactor, a radiator and a suppressor, none of which ever acts.
private (Ship scanner, Ship target) ScanOn(string scanner = "Scanner", float distance = 100f, float info = .5f, string[] targetMounts = null)
{
var target = Add(float3(0, 0, distance), targetMounts, tools: new[] { "Reactor", "Radiator", "SuppressorCold" });
var s = Add(float3(0, 0, 0), tools: new[] { scanner });
s.EntityInfoGathered[target] = info;
s.SetTarget(target);
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);
}
// Ticks `ticks` steps, holding the observer's track on each of `tracked` at `info` before each.
private void Hold(Entity observer, int ticks, float info, params Entity[] tracked)
{
for (var i = 0; i < ticks; i++)
{
foreach (var t in tracked) observer.EntityInfoGathered[t] = info;
Tick(ScanStep);
}
}
private void Hold(Entity observer, int ticks, params Entity[] tracked) => Hold(observer, ticks, .5f, tracked);
private static EquippedItem[] FittedBesidesTheHull(Entity e) => e.Equipment.Where(i => i != e.EquippedHull).ToArray();
// ---- AScanRevealsQuietGear ----
[Fact]
public void AScanRevealsQuietGear()
{
var (s, target) = ScanOn();
var quiet = FittedBesidesTheHull(target);
Assert.Equal(3, quiet.Length);
Assert.Empty(s.RevealsOf(target)); // fixture: nothing acts
Hold(s, 15, target);
Assert.Empty(s.RevealsOf(target));
Assert.Equal(15f / 16f, ScannerOf(s).Progress, 4);
Hold(s, 1, target);
foreach (var item in quiet)
{
var reveal = Assert.Single(s.RevealsOf(target), r => r.Item == item);
Assert.Null(reveal.Channel);
AssertBearing(0f, 1f, reveal.Bearing); // normalize((target - scanner).xz)
Assert.Equal(_zone.Time, reveal.Time);
}
Assert.Equal(0f, ScannerOf(s).Progress); // a finished scan starts over
}
// ---- AScanSkipsTheHull ----
[Fact]
public void AScanSkipsTheHull()
{
var (s, target) = ScanOn();
Assert.Contains(target.EquippedHull, target.Equipment); // fixture: the hull is an item of Equipment
Hold(s, 16, target);
Assert.Equal(3, s.RevealsOf(target).Count()); // fixture: the scan finished
Assert.DoesNotContain(s.RevealsOf(target), r => r.Item == target.EquippedHull);
}
// ---- AScanSkipsDestroyedGear ----
[Fact]
public void AScanSkipsDestroyedGear()
{
var (s, target) = ScanOn();
var items = FittedBesidesTheHull(target);
items[0].EquippableItem.Durability = 0f;
Assert.True(items[0].EquippableItem.IsDestroyed);
Hold(s, 16, target);
Assert.DoesNotContain(s.RevealsOf(target), r => r.Item == items[0]);
Assert.Equal(2, s.RevealsOf(target).Count());
}
// ---- AScanRecordsTheClaim ----
[Fact]
public void AScanRecordsTheClaim()
{
var (s, target) = ScanOn();
var reactor = target.Equipment.Single(i => i.Behaviors.Any(b => b is Reactor));
var radiator = DataOf("Radiator");
target.Claim(reactor, radiator);
Hold(s, 16, target);
var reveal = Assert.Single(s.RevealsOf(target), r => r.Item == reactor);
Assert.Same(radiator, reveal.Claimed);
Assert.NotSame(reactor.Data, reveal.Claimed);
}
// ---- AScanNeedsATrackAndRange ----
[Theory]
[InlineData(199f, true, true)]
[InlineData(201f, true, false)]
[InlineData(100f, false, false)]
public void AScanNeedsATrackAndRange(float distance, bool tracked, bool builds)
{
var third = Add(float3(40, 0, 0), new[] { "Eye" }); // within 200 of the scanner: it hears an emission
var info = tracked ? .5f : 0f;
var (s, target) = ScanOn(distance: distance, info: info);
Assert.Equal(tracked, s.VisibleEntities.Contains(target)); // fixture
Hold(s, 4, info, target);
Assert.Equal(builds, ScannerOf(s).Progress > 0f);
Assert.Equal(builds, target.IncomingPaints.Count > 0);
Assert.Equal(builds, target.RevealsOf(s).Any());
Assert.Equal(builds, third.RevealsOf(s).Any());
}
// ---- ABrokenScanDecays ----
// A scan at .5 broken for half of ScanTime (one second) falls to 0, a quarter in half of that: by range (Execute
// runs and does not hold) and by the item switched off (Execute does not run at all).
[Theory]
[InlineData(true)]
[InlineData(false)]
public void ABrokenScanDecays(bool byRange)
{
var (s, target) = ScanOn();
Hold(s, 8, target);
Assert.Equal(.5f, ScannerOf(s).Progress, 4);
if (byRange) target.Position = float3(0, 0, 400);
else ScannerItemOf(s).Enabled.Value = false;
Hold(s, 4, target);
Assert.Equal(.25f, ScannerOf(s).Progress, 4);
Hold(s, 4, target);
Assert.Equal(0f, ScannerOf(s).Progress, 4);
Hold(s, 2, target);
Assert.Equal(0f, ScannerOf(s).Progress, 4); // never below 0
}
// ---- ABrokenScanResumesFromWhatDecayed ----
// A scan at .5 loses a sixteenth for each tick it is broken, and a held tick after the gap builds on what is left (the
// held tick itself is not a decay tick): .5, two broken ticks .375, two held ticks .5.
[Fact]
public void ABrokenScanResumesFromWhatDecayed()
{
var (s, target) = ScanOn();
Hold(s, 8, target);
Assert.Equal(.5f, ScannerOf(s).Progress, 4);
target.Position = float3(0, 0, 400);
Hold(s, 2, target);
Assert.Equal(.375f, ScannerOf(s).Progress, 4);
target.Position = float3(0, 0, 100);
Hold(s, 2, target);
Assert.Equal(.5f, ScannerOf(s).Progress, 4);
}
// ---- ATargetChangeResetsTheScan ----
[Fact]
public void ATargetChangeResetsTheScan()
{
var (s, first) = ScanOn();
var second = Add(float3(100, 0, 0));
Hold(s, 11, first, second);
Assert.Equal(11f / 16f, ScannerOf(s).Progress, 4);
s.SetTarget(second);
Hold(s, 1, first, second);
Assert.Equal(1f / 16f, ScannerOf(s).Progress, 4); // reset, then one tick on the new target
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.
[Fact]
public void TheScannedShipIsWarned()
{
var (s, target) = ScanOn();
Hold(s, 4, target);
var paint = Assert.Single(target.IncomingPaints);
Assert.Equal(PaintMode.Scan, paint.Mode);
Assert.Equal(3f / 16f, paint.Strength, 4);
Assert.Same(DataOf("Scanner"), paint.EmitterClass);
AssertBearing(0f, -1f, paint.Bearing); // normalize((scanner - target).xz)
var warned = Assert.Single(target.RevealsOf(s));
Assert.Same(ScannerItemOf(s), warned.Item);
Assert.Equal(SignatureChannel.Emission, warned.Channel);
}
// ---- AThirdPartyHearsTheScanner ----
[Theory]
[InlineData("QuietScanner", 49f, true)]
[InlineData("QuietScanner", 51f, false)]
[InlineData("Scanner", 199f, true)]
[InlineData("Scanner", 201f, false)]
public void AThirdPartyHearsTheScanner(string scanner, float distance, bool heard)
{
var (s, target) = ScanOn(scanner);
var third = Add(float3(-distance, 0, 0), new[] { "Eye" });
Hold(s, 3, target);
Assert.Empty(third.IncomingPaints); // fixture: only the target is painted
if (!heard)
{
Assert.Empty(third.RevealsOf(s));
return;
}
var reveal = Assert.Single(third.RevealsOf(s));
Assert.Same(ScannerItemOf(s), reveal.Item);
Assert.Equal(SignatureChannel.Emission, reveal.Channel);
AssertBearing(1f, 0f, reveal.Bearing); // normalize((scanner - third).xz)
}
// ---- AFinishedScanMakesTheReactorSelectable ----
[Fact]
public void AFinishedScanMakesTheReactorSelectable()
{
var (s, target) = ScanOn();
var reactor = target.Equipment.Single(i => i.Behaviors.Any(b => b is Reactor));
Hold(s, 15, target);
Assert.False(FireControl.IsRevealed(s, reactor));
Assert.False(s.TrySelectTargetItem(reactor));
Hold(s, 1, target);
Assert.True(FireControl.IsRevealed(s, reactor));
Assert.True(s.TrySelectTargetItem(reactor));
}
// ---- AScanEndsNothing ----
// A gun revealed by firing and then destroyed, while the observer's track stays between the detection and gear
// thresholds (so Sense perceives no destruction): a scan neither ends its reveal nor refreshes it.
[Fact]
public void AScanEndsNothing()
{
var target = Add(float3(0, 0, 100), new[] { "Gun" });
var s = Add(float3(0, 0, 0), new[] { "Eye" }, new[] { "Scanner" });
var gun = Guns(target)[0];
var firedAt = Fire(target, gun);
var before = Assert.Single(s.RevealsOf(target), r => r.Item == gun);
Assert.Equal(firedAt, before.Time); // fixture: the gun is in the book
gun.EquippableItem.Durability = 0f;
s.EntityInfoGathered[target] = .5f;
s.SetTarget(target);
Hold(s, 16, target);
var after = Assert.Single(s.RevealsOf(target), r => r.Item == gun);
Assert.Equal(firedAt, after.Time);
Assert.Equal(SignatureChannel.LaunchFlash, after.Channel);
Assert.Contains(s.RevealsOf(target), r => r.Item != gun && r.Channel == null); // fixture: the scan finished
}
// ---- AScanReadsNoHostility ----
// Ruling ew-hostility-from-belief-ruled: a scan is an act of perception and judges nothing, so it proceeds and records the
// claim whatever the stance between the two ships, hostile or friendly, honest or lying. The stance is set both ways
// through SetIff, which decides IsHostileTo, EntityHostility and VisibleEnemies alike. The target carries a sensor that
// tracks the scanner, so a gate on the target's belief about its scanner would stop the scan; the scan builds at the pace
// of one tick's step, shows nothing a tick before it is done, and a third party hears it, in every stance.
[Theory]
[InlineData(true, false)]
[InlineData(true, true)]
[InlineData(false, false)]
[InlineData(false, true)]
public void AScanReadsNoHostility(bool hostile, bool lies)
{
var (s, target) = ScanOn(targetMounts: new[] { "Eye" });
var third = Add(float3(-40, 0, 0), new[] { "Eye" }); // inside Range 200 of the scanner: it hears the emission
var reactor = target.Equipment.Single(i => i.Behaviors.Any(b => b is Reactor));
var radiator = DataOf("Radiator");
if (lies) target.Claim(reactor, radiator);
s.SetIff(target, hostile);
target.SetIff(s, hostile);
target.EntityInfoGathered[s] = .5f;
Tick(ScanStep);
Assert.Equal(hostile, s.IsHostileTo(target)); // fixture
Assert.Equal(hostile, s.VisibleEnemies.Contains(target)); // fixture: the stance reached the readers a gate would use
Assert.Equal(hostile, target.IsHostileTo(s)); // fixture: the target's sensor tracks the scanner as an enemy or a friend
var built = (int) MathF.Round(ScannerOf(s).Progress * 16f);
for (var i = 0; i < 15 - built; i++)
{
target.EntityInfoGathered[s] = .5f;
Hold(s, 1, target);
}
Assert.Equal(15f / 16f, ScannerOf(s).Progress, 4); // the pace, a tick short of ScanTime
Assert.Empty(s.RevealsOf(target));
Hold(s, 1, target);
Assert.Equal(4, s.RevealsOf(target).Count()); // the reactor, radiator, suppressor and the target's own sensor
Assert.Equal(SignatureChannel.Emission, Assert.Single(third.RevealsOf(s)).Channel);
var reveal = Assert.Single(s.RevealsOf(target), r => r.Item == reactor);
Assert.Same(lies ? radiator : reactor.Data, reveal.Claimed);
Assert.NotEmpty(target.IncomingPaints.Where(p => p.Mode == PaintMode.Scan));
}
// ---- AScanTakesTheSamePathForAnyPilot ----
// Ruling same-ship-same-inputs: a ship with an AI agent and one with none (the player's) scan the same target through
// the one Scanner and enter the same reveals in their books, claimed alike. Every reveal in a book comes from
// RecordReveal, whose callers are Sense, PublishInbox and RecordInspection: a second scan path for either pilot would
// leave the two books different (or one empty).
[Fact]
public void AScanTakesTheSamePathForAnyPilot()
{
var target = Add(float3(0, 0, 100), tools: new[] { "Reactor", "Radiator", "SuppressorCold" });
var reactor = target.Equipment.Single(i => i.Behaviors.Any(b => b is Reactor));
target.Claim(reactor, DataOf("Radiator"));
var player = Add(float3(-20, 0, 0), tools: new[] { "Scanner" });
var ai = Add(float3(20, 0, 0), tools: new[] { "Scanner" });
var agent = new Minion(ai, FactionDoctrine.Default(ai.ItemManager.GameplaySettings).Combatant); // the AI pilot, ticked in Zone.Update beside the scan: it takes the combat state once it has a target
_zone.Agents.Add(agent);
Assert.Single(_zone.Agents); // fixture: only the second ship has an agent
foreach (var scanner in new[] { player, ai })
{
scanner.EntityInfoGathered[target] = .5f;
scanner.SetTarget(target);
}
void Step(int ticks)
{
for (var i = 0; i < ticks; i++)
{
player.EntityInfoGathered[target] = .5f;
ai.EntityInfoGathered[target] = .5f;
Tick(ScanStep);
}
}
Step(15);
Assert.IsType<CombatState>(typeof(Agent).GetField("_currentState", System.Reflection.BindingFlags.NonPublic | System.Reflection.BindingFlags.Instance).GetValue(agent)); // fixture: the AI is fighting while it scans
Assert.Empty(player.RevealsOf(target)); // neither pilot has a scan before ScanTime
Assert.Empty(ai.RevealsOf(target));
Step(1);
Assert.Equal(3, player.RevealsOf(target).Count());
Func<Entity, string[]> book = o => o.RevealsOf(target)
.Select(r => r.Item.Data.Name + "|" + r.Claimed.Name + "|" + (r.Channel?.ToString() ?? "scan")).OrderBy(x => x).ToArray();
Assert.Equal(book(player), book(ai));
}
// ---- AFinishedScanListsTheGearOnTheSchematic ----
// Ruling visibility-per-capability-reveal: what a scan reveals shows on the enemy schematic, as claimed. The target carries
// a gun, two reactors (the gun's own and a fitted one), a radiator, a suppressor and a targeting system; the fitted reactor
// claims to be a radiator, and the hull's own flash is in the book too. A row is made for each scanned item and none for
// the hull: weapons first with their rated range, then the gear in fitted order, each as claimed and with no range.
[Fact]
public void AFinishedScanListsTheGearOnTheSchematic()
{
var target = Add(float3(30, 0, 40), new[] { "Gun" }, new[] { "Reactor", "Radiator", "SuppressorCold" });
var s = Add(float3(0, 0, 0), new[] { "Eye" }, new[] { "Scanner" });
var fitted = FittedBesidesTheHull(target);
var gun = Guns(target)[0];
var reactor = target.Equipment.Where(i => i.Behaviors.Any(b => b is Reactor)).Last();
target.Claim(reactor, DataOf("Radiator"));
target.SetSignature(new object(), SignatureChannel.LaunchFlash, 1000000f, target.EquippedHull);
Tick(.1f);
Assert.Equal(target.EquippedHull, Assert.Single(s.RevealsOf(target)).Item); // fixture: the hull is in the book, as a hull class
Assert.IsAssignableFrom<HullData>(s.RevealsOf(target).Single().Claimed);
s.EntityInfoGathered[target] = .5f;
s.SetTarget(target);
Hold(s, 15, target);
Assert.Empty(s.SchematicRows(target)); // fixture: no row before the scan is done
Hold(s, 1, target);
var rows = s.SchematicRows(target).ToArray();
Assert.Equal(fitted.Length, rows.Length); // every scanned item
Assert.DoesNotContain(rows, r => r.Item == target.EquippedHull || r.Claimed is HullData);
Assert.Same(gun, rows[0].Item); // weapons first
Assert.Equal(((WeaponData) DataOf("Gun").Behaviors.Single(b => b is WeaponData)).RatedRange, rows[0].Range);
var gear = rows.Skip(1).ToArray();
Assert.Equal(fitted.Where(i => i != gun).ToArray(), gear.Select(r => r.Item).ToArray()); // fitted order
Assert.All(gear, r => Assert.Null(r.Range));
Assert.All(gear, r => Assert.Same(r.Item == reactor ? DataOf("Radiator") : r.Item.Data, r.Claimed));
}
// ---- AConsumableScannerDecaysOnTheSameTick ----
// The two hosts order Execute and the always-updated pass differently (ConsumableItemEffect.Update runs the pass first),
// so the scan's decay must not depend on that order: a scan broken by range loses dt/ScanTime on the tick it breaks, and
// .5 falls to .25 in four ticks, as ABrokenScanDecays has it for gear.
[Fact]
public void AConsumableScannerDecaysOnTheSameTick()
{
var data = _openCache.GetByName<ConsumableItemData>("ScannerCharge");
var target = Add(float3(0, 0, 100), tools: new[] { "Reactor" });
var s = Add(float3(0, 0, 0));
s.ActivateConsumable(Assert.IsType<ConsumableItem>(_items.CreateInstance(_items.CreateLot(data, _maker, .5f))));
var scanner = s.FindActiveConsumable(data).Behaviors.OfType<Scanner>().Single();
s.EntityInfoGathered[target] = .5f;
s.SetTarget(target);
Hold(s, 8, target);
Assert.Equal(.5f, scanner.Progress, 4); // fixture: the consumable scans like gear
target.Position = float3(0, 0, 400);
Hold(s, 4, target);
Assert.Equal(.25f, scanner.Progress, 4);
Hold(s, 4, target);
Assert.Equal(0f, scanner.Progress, 4);
}
// ---- ScannerDataRoundTripsAsUnionKey51 ----
[Fact]
public void ScannerDataRoundTripsAsUnionKey51()
{
var options = CultDocumentMessagePackSerialization.OptionsFor(typeof(ZonePack).Assembly);
var data = new ScannerData { Range = Const(200f), ScanTime = Const(2f), InterceptFactor = Const(4f) };
var bytes = MessagePackSerializer.Serialize<BehaviorData>(data, options);
var reader = new MessagePackReader(bytes);
Assert.Equal(2, reader.ReadArrayHeader());
Assert.Equal(51, reader.ReadInt32()); // sensor-catalog holds 50; a key reused across branches would collide on the wire
Assert.Equal(4, reader.ReadArrayHeader()); // Group is key 0, then the three stats
reader.Skip();
Assert.Equal(200f, MessagePackSerializer.Deserialize<PerformanceStat>(ref reader, options).Min); // key 1 Range
Assert.Equal(2f, MessagePackSerializer.Deserialize<PerformanceStat>(ref reader, options).Min); // key 2 ScanTime
Assert.Equal(4f, MessagePackSerializer.Deserialize<PerformanceStat>(ref reader, options).Min); // key 3 InterceptFactor
var back = Assert.IsType<ScannerData>(MessagePackSerializer.Deserialize<BehaviorData>(bytes, options));
Assert.Equal(200f, back.Range.Min);
Assert.Equal(2f, back.ScanTime.Min);
Assert.Equal(4f, back.InterceptFactor.Min);
var absent = Assert.IsType<ScannerData>(MessagePackSerializer.Deserialize<BehaviorData>(
MessagePackSerializer.Serialize<BehaviorData>(new ScannerData { Range = Const(1f), ScanTime = Const(1f) }, options), options));
Assert.Null(absent.InterceptFactor);
var unions = typeof(BehaviorData).GetCustomAttributes(typeof(UnionAttribute), false).Cast<UnionAttribute>().ToArray();
Assert.Single(unions, u => u.Key == 51);
Assert.Equal(unions.Length, unions.Select(u => u.Key).Distinct().Count());
}
}