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:
parent
b064051efd
commit
c40a6e8229
4 changed files with 171 additions and 7 deletions
|
|
@ -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);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
60
tests/Aetheria.Shared.Tests/PerShooterAimTests.cs
Normal file
60
tests/Aetheria.Shared.Tests/PerShooterAimTests.cs
Normal 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");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -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);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
Loading…
Reference in a new issue