Aetheria/tests/Aetheria.Shared.Tests/PilotViewTests.cs
soul c40a6e8229 View seam tests: per-shooter aim, view gates, sees-hostile, honest track loss (red)
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-22 18:21:12 +02:00

319 lines
11 KiB
C#

using System;
using System.Linq;
using CultMath;
using GameCult.Caching;
using UniRx;
using Xunit;
// A side's brain reaches other ships only through its PilotView (Agents/PilotView.cs): handles it issued, tracks only
// of what a member tracks, picture only of what a member's sensors hold hostile. Driven on the IffAndCombatTests world,
// like FlightTests; perception is set directly, a grudge is a real IFF override.
public sealed class PilotViewTests : IDisposable
{
private readonly IffAndCombatTests _world = new IffAndCombatTests();
private readonly ItemManager _items;
private readonly Zone _zone;
public PilotViewTests() => (_items, _zone) = _world.BuildWorld();
public void Dispose() => _world.Dispose();
private Ship Pilot(Faction faction, float x = 0, float z = 0)
{
var ship = _world.NewShip(_items, _zone, faction, piloted: true);
ship.Position = new float3(x, 0, z);
return ship;
}
private Ship Subject(float x = 100, float z = 0)
{
var ship = _world.NewShip(_items, _zone, new Faction { Name = "Other" });
ship.Position = new float3(x, 0, z);
return ship;
}
private static void See(Ship member, Entity subject)
{
member.EntityInfoGathered[subject] = 1;
if (!member.VisibleEntities.Contains(subject)) member.VisibleEntities.Add(subject);
member.VisibleEnemies.Add(subject);
}
private PilotView ViewOf(Ship ship) => _zone.Agents.Single(a => a.Ship == ship).View;
private void Tick(float dt)
{
foreach (var flight in _zone.Flights.ToArray()) flight.Update(dt);
foreach (var entity in _zone.Entities.ToArray()) PilotApplier.Apply(entity);
}
[Fact]
public void ASideNeverListsItsOwnMember()
{
var a = Pilot(null, -200);
var b = Pilot(null, 200);
a.SetIff(b, true);
b.SetIff(a, true);
See(a, b);
See(b, a);
Assert.Equal(2, _zone.Flights.Count());
Assert.Equal(new[] { PilotView.Handle(b) }, ViewOf(a).Picture());
Assert.Equal(new[] { PilotView.Handle(a) }, ViewOf(b).Picture());
for (var step = 0; step < 600; step++)
{
Tick(.1f);
foreach (var member in new[] { a, b })
Assert.NotEqual(PilotView.Handle(member), (_zone.Agents.Single(x => x.Ship == member).Task as FollowTask)?.Anchor);
}
}
[Fact]
public void AFactionsPictureLeavesOutItsOwnMembers()
{
var faction = new Faction { Name = "Squabblers" };
var a = Pilot(faction, -200);
var b = Pilot(faction, 200);
var other = Subject();
See(a, b);
See(b, a);
See(b, other);
Assert.Equal(new[] { PilotView.Handle(other) }, ViewOf(a).Picture());
}
[Fact]
public void APictureIsTheMembersEnemiesInMemberOrderEachOnce()
{
var faction = new Faction { Name = "Plain" };
var a = Pilot(faction, -10);
var b = Pilot(faction, 10);
var x = Subject(100);
var y = Subject(200);
var z = Subject(300);
See(a, x);
See(a, y);
See(b, y);
See(b, z);
Assert.Equal(new[] { x, y, z }.Select(PilotView.Handle), ViewOf(a).Picture());
}
[Fact]
public void ATrackIsOnlyWhatTheMemberTracks()
{
var member = Pilot(null);
var subject = Subject(120, 30);
subject.Velocity = new float2(3, -4);
subject.Direction = new float2(0, -1);
var threshold = _items.GameplaySettings.TargetDetectionInfoThreshold;
var view = ViewOf(member);
var handle = PilotView.Handle(subject);
member.EntityInfoGathered[subject] = threshold / 2;
Assert.Null(view.TrackOf(member, handle));
Assert.Equal(threshold / 2, view.Info(member, handle));
member.EntityInfoGathered[subject] = threshold * 2;
var track = view.TrackOf(member, handle);
Assert.NotNull(track);
Assert.Equal(subject.Position, track.Value.Position);
Assert.Equal(subject.Velocity, track.Value.Velocity);
Assert.Equal(subject.Direction, track.Value.Direction);
Assert.Equal(threshold * 2, view.Info(member, handle));
}
[Fact]
public void ASideTrackComesFromATrackingMember()
{
var faction = new Faction { Name = "Plain" };
var a = Pilot(faction, -10);
var b = Pilot(faction, 10);
var x = Subject(100);
var y = Subject(200);
See(b, x);
var view = ViewOf(a);
Assert.Null(view.TrackOf(a, PilotView.Handle(x)));
Assert.Equal(x.Position, view.SideTrack(PilotView.Handle(x)).Value.Position);
Assert.Null(view.SideTrack(PilotView.Handle(y)));
}
[Fact]
public void AViewAnswersIffOverridesAndTrespassPerMember()
{
var member = Pilot(null);
var subject = Subject();
var view = ViewOf(member);
var handle = PilotView.Handle(subject);
Assert.Null(view.IffOverride(member, handle));
member.SetIff(subject, true);
Assert.True(view.IffOverride(member, handle));
member.SetIff(subject, false);
Assert.False(view.IffOverride(member, handle));
Assert.Equal(member.BelievesTrespassing(subject), view.BelievesTrespassing(member, handle));
}
[Fact]
public void SeeingIsTheMembersOwnEnemyList()
{
var faction = new Faction { Name = "Plain" };
var a = Pilot(faction, -10);
var b = Pilot(faction, 10);
var x = Subject();
See(b, x);
var view = ViewOf(a);
Assert.True(view.Sees(b, PilotView.Handle(x)));
Assert.False(view.Sees(a, PilotView.Handle(x)));
}
[Fact]
public void ATargetIsThePendingDesignationElseTheAppliedTarget()
{
var member = Pilot(null);
var x = Subject(100);
var y = Subject(200);
See(member, x);
See(member, y);
var view = ViewOf(member);
Assert.Null(view.TargetOf(member));
Assert.Null(view.AppliedTargetOf(member));
view.Designate(member, PilotView.Handle(x));
Assert.Equal(PilotView.Handle(x), view.TargetOf(member));
Assert.Null(view.AppliedTargetOf(member));
PilotApplier.Apply(member);
Assert.Equal(PilotView.Handle(x), view.AppliedTargetOf(member));
Assert.Equal(PilotView.Handle(x), view.TargetOf(member));
view.Designate(member, PilotView.Handle(y));
Assert.Equal(PilotView.Handle(y), view.TargetOf(member));
Assert.Equal(PilotView.Handle(x), view.AppliedTargetOf(member));
}
[Fact]
public void DesignatingTheAppliedTargetAgainWritesNothing()
{
var member = Pilot(null);
var x = Subject(100);
See(member, x);
var view = ViewOf(member);
view.Designate(member, PilotView.Handle(x));
PilotApplier.Apply(member);
view.Designate(member, PilotView.Handle(x));
Assert.Null(member.Command.Designated);
view.Designate(member, null);
Assert.True(member.Command.Designated.Value.IsNone);
PilotApplier.Apply(member);
view.Designate(member, null);
Assert.Null(member.Command.Designated);
}
[Fact]
public void AHandleIsTheContactAcrossItsAbsence()
{
var member = Pilot(null);
var x = Subject(100);
var y = Subject(200);
var view = ViewOf(member);
See(member, x);
var before = view.Picture().Single();
member.VisibleEnemies.Remove(x);
member.VisibleEntities.Remove(x);
Assert.Empty(view.Picture());
See(member, x);
Assert.Equal(before, view.Picture().Single());
Assert.Equal(PilotView.Handle(x), before);
Assert.True(before == PilotView.Handle(x));
Assert.Equal(PilotView.Handle(x).GetHashCode(), before.GetHashCode());
Assert.NotEqual(PilotView.Handle(y), before);
Assert.True(PilotView.Handle(y) != before);
}
[Fact]
public void ACombatingPilotFliesOnlyOnATrackedTarget()
{
var member = Pilot(null);
var subject = Subject(500, 300);
member.SetTarget(subject);
member.Position = new float3(500, 0, 0);
var agent = new Minion(member, FactionDoctrine.Default(_items.GameplaySettings).Combatant)
{ Task = new PatrolOrbitsTask { Circuit = new CultRecordKey[1] } };
for (var i = 0; i < 4; i++)
{
agent.Update(.1f);
PilotApplier.Apply(member);
}
Assert.True((member.Command.Aim?.z ?? 0) < 100, "an untracked target is not flown on");
See(member, subject);
member.SetTarget(subject);
for (var i = 0; i < 4; i++)
{
agent.Update(.1f);
PilotApplier.Apply(member);
}
Assert.Equal(300f, member.Command.Aim.Value.z, 1);
}
// The hostile part of Sees is the member's own: a member holding a sidemate's enemy as friendly (a hail it answered, an
// IFF override) does not see it as an enemy, though it tracks it. Kills: Sees reading VisibleEntities.
[Fact]
public void AMemberThatHoldsASidematesEnemyAsFriendlyDoesNotSeeItAsOne()
{
var faction = new Faction { Name = "Plain" };
var a = Pilot(faction, -10);
var b = Pilot(faction, 10);
var x = Subject();
See(b, x);
a.EntityInfoGathered[x] = 1;
a.VisibleEntities.Add(x);
a.SetIff(x, false);
var view = ViewOf(a);
var handle = PilotView.Handle(x);
Assert.NotNull(view.TrackOf(a, handle));
Assert.True(view.Sees(b, handle));
Assert.False(view.Sees(a, handle));
}
// A view answers for its members only: every per-member read of a ship the view does not hold is empty, however much
// that ship knows, and a write for it changes nothing. One assertion per read, each killing its dropped membership check.
[Fact]
public void AViewAnswersNothingForAShipThatIsNotItsMember()
{
var a = Pilot(null, -200);
var outsider = Pilot(null, 200);
var x = Subject();
See(outsider, x);
outsider.SetIff(x, true);
x.PresencePermitted = Observable.Return(false).ToReadOnlyReactiveProperty();
outsider.Command.Designate(new TargetRef(x));
var handle = PilotView.Handle(x);
var view = ViewOf(a);
Assert.NotNull(outsider.IffOverride(x));
Assert.True(outsider.BelievesTrespassing(x));
Assert.True(outsider.VisibleEnemies.Contains(x));
Assert.Null(view.TrackOf(outsider, handle));
Assert.False(view.Sees(outsider, handle));
Assert.Null(view.IffOverride(outsider, handle));
Assert.Null(view.Info(outsider, handle));
Assert.False(view.BelievesTrespassing(outsider, handle));
Assert.Null(view.TargetOf(outsider));
PilotApplier.Apply(outsider);
Assert.Null(view.AppliedTargetOf(outsider));
view.Designate(outsider, null);
Assert.Null(outsider.Command.Designated);
}
}