View seam tests: per-shooter aim, view gates, sees-hostile, honest track loss (red)

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
This commit is contained in:
soul 2026-10-22 18:21:12 +02:00
commit c40a6e8229
4 changed files with 171 additions and 7 deletions

View file

@ -9,14 +9,14 @@ using float3 = CultMath.float3;
// track from the first step, and the pilot follows it only as long as the sensors keep it.
public sealed partial class RunStartTests
{
private (Zone arena, Ship east, Ship west) StagedApproach()
private (Zone arena, Ship east, Ship west) StagedApproach(float westX = -450f)
{
Ship east = null, west = null;
var (_, arena, _, failures) = Launch(new Scripted(false, stage =>
{
stage.Player(stage.Bare("Djinni"), float2(-50000, -50000));
east = stage.Place(EvLonginus(stage), float2(450, 0), facing: float2(-1, 0), piloted: true) as Ship;
west = stage.Place(EvLonginus(stage), float2(-450, 0), facing: float2(1, 0), piloted: true) as Ship;
west = stage.Place(EvLonginus(stage), float2(westX, 0), facing: float2(1, 0), piloted: true) as Ship;
stage.Approach(east, west);
}));
Assert.True(failures.Count == 0, string.Join("; ", failures));
@ -39,22 +39,27 @@ public sealed partial class RunStartTests
Assert.True(east.Position.x - west.Position.x < startGap - 20f, "the follower closed on its anchor");
}
// The anchor is staged 60 km off, far outside sensor reach: the staged knowledge decays through the sensors like any
// track (the Sensor is the only writer after staging), and the follower stops once they have dropped it. Nothing
// here writes the pilot's knowledge after the stage.
[Fact]
public void AStagedApproachFollowerStopsFollowingOnceTheSensorsLoseTheTrack()
{
var (arena, east, west) = StagedApproach();
var (arena, east, west) = StagedApproach(westX: -60000f);
var view = arena.Agents.Single(a => a.Ship == east).View;
var sentinel = new float3(0, 0, 12345);
for (var i = 0; i < 3; i++) arena.Update(1f / 60f);
Assert.NotNull(view.TrackOf(east, PilotView.Handle(west)));
east.Command.Aim = sentinel;
arena.Update(1f / 60f);
arena.Update(.1f);
Assert.NotEqual(sentinel, east.Command.Aim.Value);
east.EntityInfoGathered[west] = 0f;
var steps = 0;
while (view.TrackOf(east, PilotView.Handle(west)) != null && steps++ < 1200) arena.Update(.1f);
Assert.Null(view.TrackOf(east, PilotView.Handle(west)));
east.Command.Aim = sentinel;
arena.Update(1f / 60f);
arena.Update(.1f);
Assert.Equal(sentinel, east.Command.Aim.Value);
}
}

View file

@ -444,6 +444,52 @@ public sealed class FireControlPerWeaponTests : IDisposable
Assert.Equal(expected, view.Intercept(r.Shooter, weapon, contact));
}
// A view answers fire control only for a member that tracks the contact: the shooter outside the view, or having lost
// the track, gets no intercept, no reveals, no designated weapon, a refusal, and no shot; a write for a non-member
// changes nothing. The member that tracks is the positive control, answering as FireControl does. Kills: any of
// Intercept, WeaponReveals, Designated, Refuses, Fires or SelectItem answering without the member's own track or
// membership.
public enum Standing { TrackingMember, NotAMember, LostTheTrack }
[Theory]
[InlineData(Standing.TrackingMember)]
[InlineData(Standing.NotAMember)]
[InlineData(Standing.LostTheTrack)]
public void AViewAnswersFireControlOnlyForAMemberThatTracksTheContact(Standing standing)
{
var r = Build(velocity: 500f);
var targetGun = r.Target.Equipment.Single(i => i.Behaviors.OfType<Weapon>().Any());
r.Shooter.RecordReveal(r.Target, targetGun, SignatureChannel.LaunchFlash, targetGun.Data);
var view = standing == Standing.NotAMember ? new PilotView() : new PilotView(r.Shooter);
var contact = PilotView.Handle(r.Target);
var weapon = r.Gun.Weapon;
var reveal = new PilotView(r.Shooter).WeaponReveals(r.Shooter, contact).Single();
if (standing == Standing.LostTheTrack) r.Shooter.VisibleEntities.Remove(r.Target);
var minHit = 0f;
var intercept = view.Intercept(r.Shooter, weapon, contact);
var reveals = view.WeaponReveals(r.Shooter, contact);
view.SelectItem(r.Shooter, reveal.Id);
if (standing == Standing.TrackingMember)
{
Assert.NotNull(intercept);
Assert.Single(reveals);
Assert.True(view.Designated(r.Shooter, weapon, contact));
Assert.False(view.Refuses(r.Shooter, weapon, contact));
Assert.True(view.Fires(r.Shooter, weapon, contact, minHit, null));
Assert.Single(r.Shooter.Command.Selections);
}
else
{
Assert.Null(intercept);
Assert.Empty(reveals);
Assert.False(view.Designated(r.Shooter, weapon, contact));
Assert.True(view.Refuses(r.Shooter, weapon, contact));
Assert.False(view.Fires(r.Shooter, weapon, contact, minHit, null));
if (standing == Standing.NotAMember) Assert.Empty(r.Shooter.Command.Selections);
}
}
private static void AssertAimsAtTarget(Rig r)
{
var toTarget = r.Target.Position - r.Shooter.Position;

View file

@ -0,0 +1,60 @@
using System;
using System.Linq;
using System.Reflection;
using CultMath;
using Xunit;
using static CultMath.math;
using float2 = CultMath.float2;
using float3 = CultMath.float3;
// Fire control is per shooter (ruling per-shooter-fire-control): a pilot flies, aims and fires on what its own sensors
// track, not on a track only a sidemate holds.
public sealed partial class RunStartTests
{
// East and west are one side, one view; only west tracks x. East is designated on x: it must not aim or pull on west's
// track (Combat reads TrackOf for the shooter, not SideTrack). The positive control has east track x as well, and it aims.
// Kills: Combat flying on SideTrack.
[Theory]
[InlineData(false)]
[InlineData(true)]
public void AMemberDesignatedOnAContactOnlyItsSidemateTracksNeitherAimsNorFires(bool eastTracks)
{
Ship east = null, west = null, x = null;
var (_, arena, _, failures) = Launch(new Scripted(false, stage =>
{
stage.Player(stage.Bare("Djinni"), float2(-50000, -50000));
east = stage.Place(EvLonginus(stage), float2(100, 0), facing: float2(-1, 0), piloted: true) as Ship;
west = stage.Place(EvLonginus(stage), float2(-100, 0), facing: float2(1, 0), piloted: true) as Ship;
x = stage.Place(EvLonginus(stage), float2(0, 300), facing: float2(0, -1)) as Ship;
}));
Assert.True(failures.Count == 0, string.Join("; ", failures));
var known = (east.ItemManager.GameplaySettings.TargetDetectionInfoThreshold + 1f) / 2f;
east.SetIff(x, true);
west.SetIff(x, true);
var side = new PilotView(east, west);
var agent = arena.Agents.Single(a => a.Ship == east);
agent.View = side;
west.EntityInfoGathered[x] = known;
if (eastTracks) east.EntityInfoGathered[x] = known;
Assert.NotNull(side.SideTrack(PilotView.Handle(x)));
Assert.Equal(eastTracks, side.TrackOf(east, PilotView.Handle(x)) != null);
var sentinel = new float3(0, 0, -12345);
object state = null;
for (var i = 0; i < 4; i++)
{
east.Command.Designate(new TargetRef(x));
east.Command.Aim = sentinel;
agent.Update(1f / 60f);
state = typeof(Agent).GetField("_currentState", BindingFlags.NonPublic | BindingFlags.Instance).GetValue(agent);
}
Assert.IsType<CombatState>(state);
var aimed = !east.Command.Aim.Value.Equals(sentinel);
if (eastTracks) Assert.True(aimed, "a member that tracks the contact aims at it");
else
{
Assert.False(aimed, $"a member flew its aim on a sidemate's track: {east.Command.Aim}");
Assert.False(east.Weapons.Any(w => east.Command.Pulls(w)), "a member pulled a trigger on a sidemate's track");
}
}
}

View file

@ -2,6 +2,7 @@ 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
@ -263,4 +264,56 @@ public sealed class PilotViewTests : IDisposable
}
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);
}
}