diff --git a/tests/Aetheria.Shared.Tests/ApproachKnowledgeTests.cs b/tests/Aetheria.Shared.Tests/ApproachKnowledgeTests.cs index 98f857913..cd48907a7 100644 --- a/tests/Aetheria.Shared.Tests/ApproachKnowledgeTests.cs +++ b/tests/Aetheria.Shared.Tests/ApproachKnowledgeTests.cs @@ -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); } } diff --git a/tests/Aetheria.Shared.Tests/FireControlPerWeaponTests.cs b/tests/Aetheria.Shared.Tests/FireControlPerWeaponTests.cs index 2c70cf438..5c0aa19a5 100644 --- a/tests/Aetheria.Shared.Tests/FireControlPerWeaponTests.cs +++ b/tests/Aetheria.Shared.Tests/FireControlPerWeaponTests.cs @@ -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().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; diff --git a/tests/Aetheria.Shared.Tests/PerShooterAimTests.cs b/tests/Aetheria.Shared.Tests/PerShooterAimTests.cs new file mode 100644 index 000000000..e284abf56 --- /dev/null +++ b/tests/Aetheria.Shared.Tests/PerShooterAimTests.cs @@ -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(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"); + } + } +} diff --git a/tests/Aetheria.Shared.Tests/PilotViewTests.cs b/tests/Aetheria.Shared.Tests/PilotViewTests.cs index 5c371de49..6d47fe3c4 100644 --- a/tests/Aetheria.Shared.Tests/PilotViewTests.cs +++ b/tests/Aetheria.Shared.Tests/PilotViewTests.cs @@ -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); + } }