Aetheria/tests/Aetheria.Shared.Tests/FlightTests.cs
soul 2ad52e7e9e PilotView: brains reach other ships only through their side's view, by opaque handles
One view per side over its members' sensors and books. Flight, Combat, Follow and Minion read contacts as ContactId
and fire-control readouts as view calls; FollowTask.Anchor is a handle. Own-ship reads stay direct.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-22 15:48:02 +02:00

1172 lines
45 KiB
C#

using System;
using System.IO;
using System.Linq;
using System.Reflection;
using CultMath;
using GameCult.Caching;
using GameCult.Caching.MessagePack;
using UniRx;
using Xunit;
using static CultMath.math;
// The flight (Agents/Flight.cs) is the only writer of its members' Target and Task. These tests drive it on the
// IffAndCombatTests world. Perception is the sensors' output (VisibleEnemies, EntityInfoGathered), which
// IffAndCombatTests covers separately, so the tests set it directly; a grudge is a real IFF override.
public sealed class FlightTests : IDisposable
{
private readonly IffAndCombatTests _world = new IffAndCombatTests();
private readonly ItemManager _items;
private readonly Zone _zone;
public FlightTests() => (_items, _zone) = _world.BuildWorld();
public void Dispose() => _world.Dispose();
private static readonly Faction Hostile = new Faction { Name = "Hostile" };
private static Faction WithDoctrine(EngageOn on, float grace = 0, string hail = null, float comply = 0) => new Faction
{
Name = "Escort",
Doctrine = new FactionDoctrine
{
EngageOn = on, Grace = grace, Hail = hail, ComplySpeed = comply,
Combatant = new RoleDoctrine { RangeExponent = .25f, MinHitProbability = .2f }
}
};
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 Track(float x = 100, float z = 0)
{
var track = _world.NewShip(_items, _zone, Hostile);
track.Position = new float3(x, 0, z);
return track;
}
// A follower holds the anchor as a handle and flies on it only while its side tracks it.
private static FollowTask Follows(Ship follower, Entity anchor, float standoff)
{
if (!follower.VisibleEntities.Contains(anchor)) follower.VisibleEntities.Add(anchor);
return new FollowTask { Anchor = PilotView.Handle(anchor), Standoff = standoff };
}
private static void See(Ship member, Entity track)
{
member.EntityInfoGathered[track] = 1;
if (!member.VisibleEntities.Contains(track)) member.VisibleEntities.Add(track);
member.VisibleEnemies.Add(track);
}
private void Tick(float dt)
{
foreach (var flight in _zone.Flights.ToArray()) flight.Update(dt);
// The flight-only step: each member's command (its flight's designation) is applied as the zone step applies it.
foreach (var entity in _zone.Entities.ToArray()) PilotApplier.Apply(entity);
}
[Fact]
public void ADefaultDoctrineEngagesTheVisibleEnemyAsMinionDid()
{
var member = Pilot(new Faction { Name = "Plain" });
var track = Track();
See(member, track);
Tick(.1f);
Assert.Same(track, member.Target.Value.Entity);
member.VisibleEnemies.Remove(track);
Tick(.1f);
Assert.True(member.Target.Value.IsNone);
Assert.IsType<PatrolOrbitsTask>(_zone.Agents.Single().Task);
}
[Fact]
public void OnlyTheFlightWritesTheTarget()
{
var member = Pilot(new Faction { Name = "Plain" });
See(member, Track());
Assert.True(member.Target.Value.IsNone);
Tick(.1f);
Assert.False(member.Target.Value.IsNone);
}
[Fact]
public void IdentifiedWaitsForTheGearTier()
{
_items.GameplaySettings.TargetGearInfoThreshold = .8f;
var faction = WithDoctrine(EngageOn.Identified);
var a = Pilot(faction);
var b = Pilot(faction, 10);
var track = Track();
See(a, track);
See(b, track);
a.EntityInfoGathered[track] = MathF.BitDecrement(.8f); // just under the threshold
b.EntityInfoGathered[track] = MathF.BitDecrement(.8f);
Tick(.1f);
Assert.True(a.Target.Value.IsNone);
Assert.True(b.Target.Value.IsNone);
b.EntityInfoGathered[track] = .8f; // exactly the threshold
Tick(.1f);
Assert.Same(track, a.Target.Value.Entity);
Assert.Same(track, b.Target.Value.Entity);
}
[Fact]
public void ProvokedEngagesOnlyTheGrudgedTrack()
{
var member = Pilot(WithDoctrine(EngageOn.Provoked));
var calm = Track(80);
var grudged = Track(120);
See(member, calm);
See(member, grudged);
member.SetIff(grudged, true);
Tick(.1f);
Assert.Same(grudged, member.Target.Value.Entity);
}
[Fact]
public void NeverIgnoresEvenAGrudge()
{
var member = Pilot(WithDoctrine(EngageOn.Never));
var track = Track();
See(member, track);
member.SetIff(track, true);
Tick(.1f);
Assert.True(member.Target.Value.IsNone);
}
public static System.Collections.Generic.IEnumerable<object[]> BuildOrders() =>
from order in new[] { "NTFMZ", "MFTNZ", "TMZNF", "FNMTZ", "ZNMTF", "TFMZN" }
select new object[] { order };
// The track is off the origin. N is the nearest member by distance, on the diagonal; T is nearer than N in x alone, F
// nearer in z alone, and M nearest by the sum of the axis distances (60 against N's 80), all farther in distance.
// Built in several orders: the speaker is the nearest by distance, not first or last built, nor the nearest on one
// axis or by the sum of components. Kills: the hail from the first or last member, or from the nearest by x, by z
// or by the sum of components (Z is nearest with z halved: 70 against N's 57, 35 against 45).
[Theory]
[MemberData(nameof(BuildOrders))]
public void GraceHailsOnceFromTheNearestThenEngages(string order)
{
var faction = WithDoctrine(EngageOn.Detection, grace: 5, hail: "Hold.");
var (tx, tz) = (300f, -200f);
var spots = new System.Collections.Generic.Dictionary<char, (float x, float z)>
{ ['N'] = (tx + 40, tz + 40), ['T'] = (tx + 5, tz + 120), ['F'] = (tx - 200, tz + 10), ['M'] = (tx + 60, tz), ['Z'] = (tx, tz + 70) };
var ships = order.ToDictionary(c => c, c => Pilot(faction, spots[c].x, spots[c].z));
var track = Track(tx, tz);
foreach (var ship in ships.Values) See(ship, track);
Tick(.1f);
Assert.True(ships['N'].Messages.ContainsKey("Hold."));
Assert.False(ships['T'].Messages.ContainsKey("Hold."));
Assert.False(ships['F'].Messages.ContainsKey("Hold."));
Assert.False(ships['M'].Messages.ContainsKey("Hold."));
Assert.False(ships['Z'].Messages.ContainsKey("Hold."));
Assert.All(ships.Values, m => Assert.True(m.Target.Value.IsNone));
Assert.All(_zone.Agents, a => Assert.IsType<FollowTask>(a.Task));
Tick(4.8f);
Assert.All(ships.Values, m => Assert.True(m.Target.Value.IsNone));
Tick(.4f);
Assert.All(ships.Values, m => Assert.Same(track, m.Target.Value.Entity));
}
[Fact]
public void ASlowTrackComplies()
{
var faction = WithDoctrine(EngageOn.Detection, grace: 5, hail: "Hold.", comply: 10);
var member = Pilot(faction);
var track = Track();
See(member, track);
Tick(.1f);
Tick(5.1f);
Assert.True(member.Target.Value.IsNone);
Assert.IsType<FollowTask>(_zone.Agents.Single().Task);
member.Messages.Clear();
track.Velocity = new float2(20, 0);
Tick(.1f);
Assert.Same(track, member.Target.Value.Entity);
Assert.Empty(member.Messages);
}
// Grace 3 against ComplySpeed 10: a track at 5 is slower than ComplySpeed (complied) though faster than the Grace
// number, and one at 12 is faster than both. Kills: either comparison reading Grace for ComplySpeed.
// The speed is the length of the velocity: along x, along y, and on the diagonal at the same speed. Kills: either
// comparison reading one component.
[Theory]
[InlineData(5f, false, 1f, 0f)]
[InlineData(12f, true, 1f, 0f)]
[InlineData(5f, false, 0f, 1f)]
[InlineData(12f, true, 0f, 1f)]
[InlineData(5f, false, .6f, .8f)]
[InlineData(12f, true, .6f, .8f)]
[InlineData(8f, false, .7071f, .7071f)] // 11.3 by the sum of components, 8 by length
[InlineData(12f, true, .7071f, .7071f)] // 8.5 by the larger component, 12 by length
[InlineData(9.5f, false, 1f, 0f)] // within a tenth of ComplySpeed, each side
[InlineData(10.5f, true, 1f, 0f)]
public void ComplianceIsJudgedOnComplySpeedAndNotOnGrace(float speed, bool engaged, float dx, float dy)
{
var member = Pilot(WithDoctrine(EngageOn.Detection, grace: 3, hail: "Hold.", comply: 10));
var track = Track();
track.Velocity = new float2(dx, dy) * speed;
See(member, track);
Tick(.5f);
Tick(3.5f);
Assert.Equal(engaged, !member.Target.Value.IsNone);
// Once complied the same speed stays complied; once engaged it stays engaged.
Tick(.1f);
Assert.Equal(engaged, !member.Target.Value.IsNone);
}
[Fact]
public void AGrudgeSkipsTheGrace()
{
var member = Pilot(WithDoctrine(EngageOn.Detection, grace: 5, hail: "Hold."));
var track = Track();
See(member, track);
Tick(.1f);
Assert.True(member.Target.Value.IsNone);
member.SetIff(track, true);
Tick(.1f);
Assert.Same(track, member.Target.Value.Entity);
}
[Fact]
public void FollowClosesWhenFarAndOpensWhenNear()
{
var far = Pilot(null, -200, 50);
var near = Pilot(null, -200, 50);
var anchor = Track();
float TurnAfterFollowing(Ship ship, float3 anchorAt)
{
anchor.Position = ship.Position + anchorAt;
var minion = new Minion(ship, FactionDoctrine.Default(_items.GameplaySettings).Combatant)
{ Task = Follows(ship, anchor, 100) };
minion.Update(.1f);
PilotApplier.Apply(minion.Ship);
minion.Update(.1f);
PilotApplier.Apply(minion.Ship);
return ship.Turn;
}
// The hull faces +z, and the pilots are off the origin so the anchor's offset from the ship counts, not its
// coordinates. Anchor 300 ahead: the desired velocity is dead ahead, no turn. Anchor 20 ahead: inside the
// standoff, the desired velocity is astern, and the pilot turns to it.
Assert.Equal(0, TurnAfterFollowing(far, new float3(0, 0, 300)), 3);
Assert.NotEqual(0, TurnAfterFollowing(near, new float3(0, 0, 20)), 3);
}
[Fact]
public void ATaskChangeLeavesThePatrolSubstate()
{
var ship = Pilot(null);
var anchor = Track();
var minion = new Minion(ship, FactionDoctrine.Default(_items.GameplaySettings).Combatant)
{ Task = new PatrolOrbitsTask { Circuit = new CultRecordKey[1] } };
for (var i = 0; i < 4; i++) minion.Update(.1f);
Assert.IsType<MoveToOrbitState>(CurrentState(minion));
minion.Task = Follows(ship, anchor, 50);
minion.Update(.1f);
PilotApplier.Apply(minion.Ship);
Assert.IsType<FollowState>(CurrentState(minion));
}
[Fact]
public void APatrollingPilotStaysOnPatrol()
{
var ship = Pilot(null);
var minion = new Minion(ship, FactionDoctrine.Default(_items.GameplaySettings).Combatant)
{ Task = new PatrolOrbitsTask { Circuit = new CultRecordKey[1] } };
for (var i = 0; i < 30; i++)
{
minion.Update(.1f);
PilotApplier.Apply(minion.Ship);
if (i > 0) Assert.IsNotType<BaseState>(CurrentState(minion));
}
}
private static BaseState CurrentState(Agent agent) =>
(BaseState) typeof(Agent).GetField("_currentState", BindingFlags.NonPublic | BindingFlags.Instance).GetValue(agent);
[Fact]
public void RemovalTakesTheAgentAndTheFlightMember()
{
var faction = new Faction { Name = "Plain" };
var dies = Pilot(faction);
var leaves = Pilot(faction, 10);
var stays = Pilot(faction, 20);
var player = _world.NewShip(_items, _zone, faction, isPlayerShip: true);
var flight = _zone.Flights.Single();
Assert.Equal(3, flight.Count);
dies.Hull.Durability = 0;
dies.HullDamage.OnNext(1f);
Assert.Equal(new[] { leaves, stays }, _zone.Agents.Select(a => a.Ship));
Assert.Equal(2, flight.Count);
_zone.Remove(leaves);
Assert.Equal(new[] { stays }, _zone.Agents.Select(a => a.Ship));
Assert.Equal(1, flight.Count);
Assert.True(flight.Contains(_zone.Agents.Single()));
Assert.DoesNotContain(_zone.Agents, a => a.Ship == player);
}
private static void Forbid(Ship track) => track.PresencePermitted = Observable.Return(false).ToReadOnlyReactiveProperty();
[Fact]
public void TrespassEngagesOnlyWhatTheZoneDoesNotPermit()
{
var member = Pilot(WithDoctrine(EngageOn.Trespass));
var welcome = Track(80);
var trespasser = Track(120);
See(member, welcome);
See(member, trespasser);
welcome.PresencePermitted = Observable.Return(true).ToReadOnlyReactiveProperty();
Forbid(trespasser);
Tick(.1f);
Assert.Same(trespasser, member.Target.Value.Entity);
}
[Fact]
public void ADetectionDoctrineEngagesWhateverIsVisibleWithoutAGrudge()
{
var member = Pilot(WithDoctrine(EngageOn.Detection));
var track = Track();
See(member, track);
Tick(.1f);
Assert.Same(track, member.Target.Value.Entity);
Assert.Null(member.IffOverride(track));
}
[Fact]
public void EachFactionFliesAFlightOfItsOwnAndEachUnaffiliatedShipFliesAlone()
{
Pilot(new Faction { Name = "One" });
Pilot(new Faction { Name = "Two" });
Pilot(null);
var alone = Pilot(null);
Assert.Equal(new[] { 1, 1, 1, 1 }, _zone.Flights.Select(f => f.Count).OrderBy(c => c));
_zone.Remove(alone);
Assert.Equal(3, _zone.Flights.Count());
}
[Fact]
public void TheZoneTickRunsTheFlightsBeforeTheAgents()
{
var member = Pilot(new Faction { Name = "Plain" });
var track = Track();
See(member, track);
_zone.Update(.1f);
Assert.Same(track, member.Target.Value.Entity);
}
[Fact]
public void ADoctrineWithoutACombatantFightsWithTheDefaultRole()
{
var faction = new Faction { Name = "Sparse", Doctrine = new FactionDoctrine { EngageOn = EngageOn.Detection } };
Pilot(faction);
var combatant = _zone.Flights.Single().Combatant;
Assert.Equal(_items.GameplaySettings.AgentRangeExponent, combatant.RangeExponent);
Assert.Equal(_items.GameplaySettings.AgentMinHitProbability, combatant.MinHitProbability);
}
// The picture is the union of the members' senses while overrides are per member: a flightmate engages a track
// that a member has made friendly (a truce), and that member neither targets it nor follows its bearing. A truce is
// no grudge, in either order of building. Kills: the member's own friendly override ignored when it holds a bearing,
// or read as a grudge.
[Theory]
[InlineData(EngageOn.Detection, true)]
[InlineData(EngageOn.Detection, false)]
[InlineData(EngageOn.Provoked, true)]
[InlineData(EngageOn.Provoked, false)]
public void ATrackAMemberHasMadeFriendlyIsNeitherTargetedNorFollowedByIt(EngageOn on, bool friendlyBuiltFirst)
{
var faction = WithDoctrine(on);
Ship friendly, sees;
if (friendlyBuiltFirst) { friendly = Pilot(faction); sees = Pilot(faction, 10); }
else { sees = Pilot(faction, 10); friendly = Pilot(faction); }
var track = Track();
friendly.SetIff(track, false);
See(sees, track);
Tick(.1f);
Assert.Same(on == EngageOn.Detection ? track : null, sees.Target.Value.Entity);
Assert.True(friendly.Target.Value.IsNone);
Assert.IsType<PatrolOrbitsTask>(_zone.Agents.Single(a => a.Ship == friendly).Task);
}
[Fact]
public void AnyMembersGrudgeEngagesTheWholeFlight()
{
var faction = WithDoctrine(EngageOn.Provoked);
var angry = Pilot(faction);
var calm = Pilot(faction, 10);
var track = Track();
See(angry, track);
See(calm, track);
angry.SetIff(track, true);
Tick(.1f);
Assert.Same(track, angry.Target.Value.Entity);
Assert.Same(track, calm.Target.Value.Entity);
}
[Fact]
public void TheGraceEndsExactlyWhenItIsUp()
{
var member = Pilot(WithDoctrine(EngageOn.Detection, grace: 5, hail: "Hold."));
See(member, Track());
Tick(.5f); // hailing starts at 0.5 s
Tick(4.5f);
Assert.True(member.Target.Value.IsNone, "4.5 s into a 5 s grace");
Tick(.5f);
Assert.False(member.Target.Value.IsNone, "5.0 s in");
}
[Fact]
public void NothingComplyingMeansTheGraceEndsInEngagement()
{
var member = Pilot(WithDoctrine(EngageOn.Detection, grace: 1, hail: "Hold.", comply: 0));
See(member, Track());
Tick(.5f);
Tick(1f);
Assert.False(member.Target.Value.IsNone);
}
[Fact]
public void ATrackExactlyAtComplySpeedDoesNotComply()
{
var member = Pilot(WithDoctrine(EngageOn.Detection, grace: 1, hail: "Hold.", comply: 10));
var track = Track();
track.Velocity = new float2(10, 0);
See(member, track);
Tick(.5f);
Tick(1f);
Assert.Same(track, member.Target.Value.Entity);
}
[Fact]
public void ACompliantTrackAtExactlyComplySpeedIsEngaged()
{
var member = Pilot(WithDoctrine(EngageOn.Detection, grace: 1, hail: "Hold.", comply: 10));
var track = Track();
See(member, track);
Tick(.5f);
Tick(1f);
Assert.True(member.Target.Value.IsNone, "complied while still");
track.Velocity = new float2(10, 0);
Tick(.1f);
Assert.Same(track, member.Target.Value.Entity);
}
// The member is off the origin and its nearer track is the one farther from the origin: a pick by coordinates alone
// takes the other. Kills: Flight's nearest pick reading the track's position without the member's.
[Fact]
public void AMemberKeepsItsTargetWhileThatTrackStaysEngaged()
{
var member = Pilot(new Faction { Name = "Plain" }, 1000);
var first = Track(950);
var second = Track(100);
See(member, first);
See(member, second);
Tick(.1f);
Assert.Same(first, member.Target.Value.Entity); // the nearer
first.Position = new float3(-500, 0, 0);
second.Position = new float3(990, 0, 0);
Tick(.1f);
Assert.Same(first, member.Target.Value.Entity); // kept, though the other is nearer now
}
// Three engaged tracks, each nearest by a different measure from the member: A by the sum of components (100), C by
// length (99.2), B by x alone (60). The member picks the nearest by length. Kills: the pick by the sum of components
// or by x alone.
[Fact]
public void AMemberPicksTheNearestEngagedTrackByLength()
{
var member = Pilot(new Faction { Name = "Plain" }, 1000, 500);
var a = Track(1100, 500);
var b = Track(1060, 590);
var c = Track(1075, 565);
See(member, a);
See(member, b);
See(member, c);
Tick(.1f);
Assert.Same(c, member.Target.Value.Entity);
}
[Fact]
public void HoldersCloseOnTheNearestHeldTrackAndKeepItWhileItIsHeld()
{
var member = Pilot(WithDoctrine(EngageOn.Detection, grace: 5, hail: "Hold."), 1000);
var near = Track(950);
var far = Track(100);
See(member, near);
See(member, far);
Tick(.1f);
Assert.Equal(PilotView.Handle(near), ((FollowTask) _zone.Agents.Single().Task).Anchor);
near.Position = new float3(-900, 0, 0);
far.Position = new float3(990, 0, 0);
Tick(.1f);
Assert.Equal(PilotView.Handle(near), ((FollowTask) _zone.Agents.Single().Task).Anchor);
}
// The held anchor leaves the picture: the member re-anchors on what is still held. Kills: the anchor rewritten only
// when it is unchanged (`==` for `!=`), which leaves the member following a track that is gone.
[Fact]
public void AHeldAnchorThatLeavesThePictureIsReplacedByTheRemainingTrack()
{
var member = Pilot(WithDoctrine(EngageOn.Detection, grace: 5, hail: "Hold."), 1000);
var near = Track(950);
var far = Track(100);
See(member, near);
See(member, far);
Tick(.1f);
Assert.Equal(PilotView.Handle(near), ((FollowTask) _zone.Agents.Single().Task).Anchor);
member.VisibleEnemies.Remove(near);
Tick(.1f);
Assert.Equal(PilotView.Handle(far), ((FollowTask) _zone.Agents.Single().Task).Anchor);
}
[Fact]
public void ATrackThatLeavesForgetsItsPhaseAndTheMemberResumesItsPatrol()
{
var member = Pilot(WithDoctrine(EngageOn.Detection, grace: 5, hail: "Hold."));
var agent = _zone.Agents.Single();
var patrol = agent.Task;
var track = Track();
See(member, track);
Tick(.1f);
Assert.IsType<FollowTask>(agent.Task);
member.VisibleEnemies.Remove(track);
Tick(.1f);
Assert.Same(patrol, agent.Task);
Assert.Equal(Flight.Phase.None, _zone.Flights.Single().PhaseOf(PilotView.Handle(track)));
member.Messages.Clear();
See(member, track);
Tick(.1f);
Assert.True(member.Messages.ContainsKey("Hold."), "a returning track is hailed again");
}
[Fact]
public void AnAuthoredStandoffIsTheHoldDistance()
{
var faction = WithDoctrine(EngageOn.Detection, grace: 5);
faction.Doctrine.Combatant.HoldStandoff = 40;
var member = Pilot(faction);
See(member, Track());
Tick(.1f);
Assert.Equal(40f, ((FollowTask) _zone.Agents.Single().Task).Standoff);
}
[Fact]
public void FollowTurnsTowardTheAnchorWhenFarAndAwayFromItWhenNear()
{
var anchor = Track();
float Turn(float anchorX)
{
var ship = Pilot(null, -200, 50);
anchor.Position = ship.Position + new float3(anchorX, 0, 0);
var minion = new Minion(ship, FactionDoctrine.Default(_items.GameplaySettings).Combatant)
{ Task = Follows(ship, anchor, 100) };
minion.Update(.1f);
PilotApplier.Apply(minion.Ship);
minion.Update(.1f);
PilotApplier.Apply(minion.Ship);
return ship.Turn;
}
var far = Turn(300);
var near = Turn(20);
Assert.NotEqual(0f, far);
Assert.NotEqual(0f, near);
Assert.Equal(sign(far), -sign(near));
}
[Fact]
public void ATaskChangeBackToPatrolLeavesTheFollowState()
{
var ship = Pilot(null);
var anchor = Track();
var minion = new Minion(ship, FactionDoctrine.Default(_items.GameplaySettings).Combatant)
{ Task = Follows(ship, anchor, 50) };
minion.Update(.1f);
PilotApplier.Apply(minion.Ship);
minion.Update(.1f);
PilotApplier.Apply(minion.Ship);
Assert.IsType<FollowState>(CurrentState(minion));
minion.Task = new PatrolOrbitsTask { Circuit = new CultRecordKey[1] };
minion.Update(.1f);
PilotApplier.Apply(minion.Ship);
Assert.IsType<PatrolOrbitsState>(CurrentState(minion));
minion.Task = Follows(ship, anchor, 50);
minion.Update(.1f);
PilotApplier.Apply(minion.Ship);
minion.Update(.1f);
PilotApplier.Apply(minion.Ship);
minion.Task = null;
minion.Update(.1f);
PilotApplier.Apply(minion.Ship);
minion.Update(.1f);
PilotApplier.Apply(minion.Ship);
Assert.IsNotType<FollowState>(CurrentState(minion));
}
public static System.Collections.Generic.IEnumerable<object[]> UnseenGeometries() =>
from distance in new[] { 90f, 900f }
from blindIndex in new[] { 0, 1, 2 }
from graced in new[] { false, true }
from right in new[] { true, false }
from blindAt in new[] { new[] { -300f, 250f }, new[] { 300f, -250f } }
select new object[] { distance, blindIndex, graced, right, blindAt[0], blindAt[1] };
// A member that cannot see the engaged track steers on it as a bearing, wherever it is (off the origin, on both axes),
// at any index in the flight, with or without a hail first (the grace ends in a grudge), left or right of the member.
// Kills: the unseen rule holding the track only near, only for one member index, only with no grace, only on one
// side, or only for a member at the origin or below some coordinate.
[Theory]
[MemberData(nameof(UnseenGeometries))]
public void AMemberThatCannotSeeTheEngagedTrackClosesOnItsBearing(float distance, int blindIndex, bool graced, bool right, float x, float z)
{
var faction = WithDoctrine(EngageOn.Detection, grace: graced ? 5 : 0, hail: "Hold.");
var members = new Ship[3];
var sightedOffsets = new[] { 400f, -450f };
for (int i = 0, s = 0; i < 3; i++)
members[i] = i == blindIndex ? Pilot(faction, x, z) : Pilot(faction, x, z + sightedOffsets[s++]);
var blind = members[blindIndex];
var sighted = members.Where(m => m != blind).ToArray();
var track = Track(x + (right ? distance : -distance), z + 60);
foreach (var m in sighted) See(m, track);
if (graced)
{
Tick(.1f); // hailed, not yet engaged
sighted[0].SetIff(track, true);
}
Tick(.1f);
Assert.All(sighted, m => Assert.Same(track, m.Target.Value.Entity));
Assert.True(blind.Target.Value.IsNone);
var task = Assert.IsType<FollowTask>(_zone.Agents.Single(a => a.Ship == blind).Task);
Assert.Equal(PilotView.Handle(track), task.Anchor);
}
// The flight's engaged track outranks a hailed one a blind member does see, though that one is nearer. Both sit
// either side of a blind member off the origin. Kills: the anchor picked by distance alone, or kept once something
// outranks it.
[Fact]
public void ABlindMemberAnchorsOnTheEngagedTrackOverANearerHailingOne()
{
var faction = WithDoctrine(EngageOn.Detection, grace: 5, hail: "Hold.");
var blind = Pilot(faction, -300, 250);
var sighted = Pilot(faction, -300, 650);
var engaged = Track(600, 250);
var hailing = Track(-250, 250);
See(sighted, engaged);
See(blind, hailing);
Tick(.1f);
var task = Assert.IsType<FollowTask>(_zone.Agents.Single(a => a.Ship == blind).Task);
Assert.Equal(PilotView.Handle(hailing), task.Anchor); // nothing outranks it yet: the nearest held
sighted.SetIff(engaged, true);
Tick(.1f);
Assert.Same(engaged, sighted.Target.Value.Entity);
task = Assert.IsType<FollowTask>(_zone.Agents.Single(a => a.Ship == blind).Task);
Assert.Equal(PilotView.Handle(engaged), task.Anchor);
}
// Hail then engage is the production path of every graced doctrine: the member goes from FollowState into combat,
// and out of it again while the Task is a FollowTask on another held track. Kills: the combat interrupt covering
// the patrol sub-states only, or the exit reading a patrol Task only.
[Fact]
public void CombatIsEnteredFromFollowAndLeftWhileAnotherTrackIsHeld()
{
var member = Pilot(WithDoctrine(EngageOn.Detection, grace: 5, hail: "Hold."));
var minion = (Minion) _zone.Agents.Single();
var hot = Track(300);
var held = Track(-300);
See(member, hot);
See(member, held);
Tick(.1f);
UpdateUntil(minion, () => CurrentState(minion) is FollowState);
member.SetIff(hot, true);
Tick(.1f);
Assert.Same(hot, member.Target.Value.Entity);
UpdateUntil(minion, () => CurrentState(minion) is CombatState);
// The grudge listed it a second time (the hostility refresh adds it again), so every listing goes.
while (member.VisibleEnemies.Remove(hot)) { }
Tick(.1f);
Assert.True(member.Target.Value.IsNone);
Assert.IsType<FollowTask>(minion.Task);
UpdateUntil(minion, () => !(CurrentState(minion) is CombatState));
UpdateUntil(minion, () => CurrentState(minion) is FollowState);
}
private Minion PatrollingMinion(Ship ship)
{
// Far from the (unresolved) circuit point, so MoveToOrbit does not hand back to patrol on arrival every tick.
ship.Position = new float3(500, 0, 0);
var minion = new Minion(ship, FactionDoctrine.Default(_items.GameplaySettings).Combatant)
{ Task = new PatrolOrbitsTask { Circuit = new CultRecordKey[1] } };
for (var i = 0; i < 4; i++) minion.Update(.1f);
Assert.IsType<MoveToOrbitState>(CurrentState(minion));
return minion;
}
// Patrol's own sub-states may hand over to one another first, so a transition out of patrol is looked for within a
// few updates; none of them ever reaches combat unless the interrupt covers the patrol sub-states.
private static void UpdateUntil(Minion minion, Func<bool> done)
{
for (var i = 0; i < 3 && !done(); i++) minion.Update(.1f);
Assert.True(done(), "pilot is in " + CurrentState(minion).GetType().Name);
}
[Fact]
public void ATargetPullsAPatrollingPilotIntoCombatAndLosingItReturnsToPatrol()
{
var ship = Pilot(null);
var minion = PatrollingMinion(ship);
var track = Track();
See(ship, track);
ship.SetTarget(new TargetRef(track));
UpdateUntil(minion, () => CurrentState(minion) is CombatState);
ship.SetTarget(TargetRef.None);
UpdateUntil(minion, () => !(CurrentState(minion) is CombatState));
UpdateUntil(minion, () => CurrentState(minion) is PatrolOrbitsState || CurrentState(minion) is MoveToOrbitState);
}
[Fact]
public void CombatHoldsWhileTheTargetStands()
{
var ship = Pilot(null);
var minion = PatrollingMinion(ship);
var track = Track();
See(ship, track);
ship.SetTarget(new TargetRef(track));
UpdateUntil(minion, () => CurrentState(minion) is CombatState);
for (var i = 0; i < 5; i++)
{
minion.Update(.1f);
PilotApplier.Apply(minion.Ship);
Assert.IsType<CombatState>(CurrentState(minion));
}
}
[Fact]
public void ATaskThatIsNeitherPatrolNorFollowSendsAPatrollingPilotToTheRoot()
{
var ship = Pilot(null);
var minion = PatrollingMinion(ship);
minion.Task = null;
minion.Update(.1f);
PilotApplier.Apply(minion.Ship);
Assert.IsType<BaseState>(CurrentState(minion));
}
// A complied track that moves off at ComplySpeed or more is engaged, whichever way it moves: the diagonal's x
// component alone is under the speed. Kills: the Complied exit reading one component.
[Theory]
[InlineData(1f, 0f)]
[InlineData(0f, 1f)]
[InlineData(.6f, .8f)]
public void ACompliedTrackSpeedingOffOnAnyAxisIsEngaged(float dx, float dy)
{
var member = Pilot(WithDoctrine(EngageOn.Detection, grace: 1, hail: "Hold.", comply: 10));
var track = Track();
See(member, track);
Tick(.5f);
Tick(1f);
Assert.True(member.Target.Value.IsNone, "complied while still");
track.Velocity = new float2(dx, dy) * 12f;
Tick(.1f);
Assert.Same(track, member.Target.Value.Entity);
}
// Trespass is the member's belief: a forbidden track it lists as an enemy without ever receiving its telemetry
// (no track entry) is left alone; once it tracks it, the telemetry carries the registry and the doctrine engages.
[Fact]
public void TrespassReadsBelief()
{
var member = Pilot(WithDoctrine(EngageOn.Trespass));
var trespasser = Track(120);
Forbid(trespasser);
member.VisibleEnemies.Add(trespasser);
Tick(.1f);
Assert.True(member.Target.Value.IsNone);
member.EntityInfoGathered[trespasser] = 1;
Tick(.1f);
Assert.Same(trespasser, member.Target.Value.Entity);
}
// A track with no presence value is permitted: a Trespass doctrine leaves it alone.
[Fact]
public void TrespassLeavesATrackWithNoPresenceValueAlone()
{
var member = Pilot(WithDoctrine(EngageOn.Trespass));
var track = Track();
track.PresencePermitted = null;
See(member, track);
Tick(.1f);
Assert.True(member.Target.Value.IsNone);
}
// A follower at a distance from an anchor; the hull faces across the line to the anchor, so a demanded velocity
// along that line needs a turn. Returns the pilot's turn and thrust after two updates, and its top speed.
private (float turn, float2 thrust, float topSpeed) Follow(float2 anchorVelocity, float distance, float2 facing)
{
var ship = Pilot(null, -200, 50);
ship.Direction = facing;
var anchor = Track();
anchor.Position = ship.Position + new float3(0, 0, distance);
anchor.Velocity = anchorVelocity;
var minion = new Minion(ship, FactionDoctrine.Default(_items.GameplaySettings).Combatant)
{ Task = Follows(ship, anchor, 100) };
minion.Update(.1f);
PilotApplier.Apply(minion.Ship);
minion.Update(.1f);
PilotApplier.Apply(minion.Ship);
return (ship.Turn, ship.MovementDirection, minion.TopSpeed);
}
// At the standoff the pull is zero and the demanded velocity is the anchor's alone. Accelerate turns the hull only
// for a velocity error above 20 and thrusts all ways above 1, so the anchor's velocity of 30 turns the hull on
// either axis and one of 15 does not (it thrusts). Kills: the anchor's velocity dropped, halved, doubled, or
// reduced to one component.
[Theory]
[InlineData(30f, 0f, 0f, 1f, true)]
[InlineData(0f, 30f, 1f, 0f, true)]
[InlineData(-30f, 0f, 0f, 1f, true)]
[InlineData(15f, 0f, 0f, 1f, false)]
[InlineData(0f, 15f, 1f, 0f, false)]
[InlineData(22f, 0f, 0f, 1f, true)] // a tenth under the scale still passes 20 only at full scale
[InlineData(19f, 0f, 0f, 1f, false)] // a tenth over it would pass 20
[InlineData(0f, 22f, 1f, 0f, true)]
[InlineData(0f, 19f, 1f, 0f, false)]
public void AFollowerAtItsStandoffMatchesTheAnchorsVelocityAtFullScale(float vx, float vy, float fx, float fy, bool turns)
{
var (turn, thrust, _) = Follow(new float2(vx, vy), 100, new float2(fx, fy));
Assert.Equal(turns, turn != 0f);
if (!turns) Assert.NotEqual(new float2(0, 0), thrust);
}
// The pull toward a stationary anchor is the error over the standoff, as a fraction of the pilot's top speed: a
// pull of 30 turns the hull and one of 15 does not. Kills: the pull halved or doubled.
[Theory]
[InlineData(30f, true)]
[InlineData(15f, false)]
[InlineData(22f, true)]
[InlineData(19f, false)]
public void AFollowersPullIsTheErrorOverTheStandoffAtTopSpeed(float wantedPull, bool turns)
{
var top = Follow(new float2(0, 0), 100, new float2(1, 0)).topSpeed;
Assert.True(top >= 60f, "the fixture needs a top speed above the pull it asks for");
var (turn, _, _) = Follow(new float2(0, 0), 100 + 100 * wantedPull / top, new float2(1, 0));
Assert.Equal(turns, turn != 0f);
}
// Faction.Doctrine rides at wire slot 17; slot 16 is reserved for Relations; BossLoadout rides at 22, which sets the array's length.
// Kills: the key moved.
[Fact]
public void TheFactionDoctrineRidesAtWireSlot17()
{
var faction = new Faction { Name = "Armed", Doctrine = new FactionDoctrine { EngageOn = EngageOn.Trespass, Grace = 6f } };
var slots = MessagePack.MessagePackSerializer.Deserialize<object[]>(MessagePack.MessagePackSerializer.Serialize(faction,
CultDocumentMessagePackSerialization.OptionsFor(typeof(Faction).Assembly)));
Assert.Equal(23, slots.Length); // 19 is Assembly, 21 is Places, 22 is BossLoadout
Assert.Null(slots[16]);
var doctrine = Assert.IsType<object[]>(slots[17]);
Assert.Equal(6f, Convert.ToSingle(doctrine[1]));
}
// A cast faction that already has a doctrine keeps it, whatever it holds: one that differs in its engagement and one
// that differs only below it. Kills: the keep tested on the engagement alone, or on any one field.
[Fact]
public void TheCatalogKeepsATunedDoctrine()
{
var path = TempCatalog();
FactionDoctrine Tuned(EngageOn on) => new FactionDoctrine
{
EngageOn = on, Grace = 99f, Hail = "Mine.", ComplySpeed = 3f,
Combatant = new RoleDoctrine { RangeExponent = .7f, MinHitProbability = .9f, HoldStandoff = 5f }
};
try
{
using (var cache = AetheriaStores.Open(path, catalogWritable: true))
{
cache.Upsert(new VerseGrammar { Revision = 1 });
cache.Upsert(new Faction { Name = "Zhestokost", Doctrine = Tuned(EngageOn.Never) });
cache.Upsert(new Faction { Name = "Lucent Media", Doctrine = Tuned(EngageOn.Detection) });
cache.FlushAsync().Wait();
}
var output = new StringWriter();
using (var cache = AetheriaStores.Open(path, catalogWritable: true))
{
DoctrineCatalog.Run(cache, apply: true, output);
cache.FlushAsync().Wait();
}
using (var cache = AetheriaStores.Open(path))
{
foreach (var (name, on) in new[] { ("Zhestokost", EngageOn.Never), ("Lucent Media", EngageOn.Detection) })
{
var kept = cache.GetAll<Faction>().Single(f => f.Name == name).Doctrine;
Assert.Equal(on, kept.EngageOn);
Assert.Equal(99f, kept.Grace);
Assert.Equal("Mine.", kept.Hail);
Assert.Equal(3f, kept.ComplySpeed);
Assert.Equal(.7f, kept.Combatant.RangeExponent);
Assert.Equal(.9f, kept.Combatant.MinHitProbability);
Assert.Equal(5f, kept.Combatant.HoldStandoff);
Assert.Contains($"{name}: already has a doctrine, kept", output.ToString());
}
}
}
finally { File.Delete(path); }
}
// The catalog names a faction exactly: a prefix, a longer name or another case is another faction. Kills: the match
// widened to StartsWith, Contains or a case-insensitive compare.
[Fact]
public void TheCatalogMatchesFactionNamesExactly()
{
var path = TempCatalog();
try
{
var near = new[] { "Zhestokost Prime", "zhestokost", "The Pirates", "Pirate", "Lucent Media " };
using (var cache = AetheriaStores.Open(path, catalogWritable: true))
{
cache.Upsert(new VerseGrammar { Revision = 1 });
cache.Upsert(new Faction { Name = "Zhestokost" });
foreach (var name in near) cache.Upsert(new Faction { Name = name });
cache.FlushAsync().Wait();
}
using (var cache = AetheriaStores.Open(path, catalogWritable: true))
{
DoctrineCatalog.Run(cache, apply: true, new StringWriter());
cache.FlushAsync().Wait();
}
using (var cache = AetheriaStores.Open(path))
{
var factions = cache.GetAll<Faction>().ToArray();
Assert.NotNull(factions.Single(f => f.Name == "Zhestokost").Doctrine);
Assert.All(factions.Where(f => near.Contains(f.Name)), f => Assert.Null(f.Doctrine));
}
}
finally { File.Delete(path); }
}
// A rerun that finds nothing to author writes nothing: the file is the same bytes.
[Fact]
public void ReapplyingTheCatalogLeavesTheBytesAlone()
{
var path = TempCatalog();
try
{
using (var cache = AetheriaStores.Open(path, catalogWritable: true))
{
cache.Upsert(new VerseGrammar { Revision = 1 });
foreach (var name in new[] { "Zhestokost", "Lucent Media", "Aeronautics Unlimited", "Pirates" }) cache.Upsert(new Faction { Name = name });
cache.FlushAsync().Wait();
}
void Apply()
{
using var cache = AetheriaStores.Open(path, catalogWritable: true);
DoctrineCatalog.Run(cache, apply: true, new StringWriter());
cache.FlushAsync().Wait();
}
Apply();
var first = File.ReadAllBytes(path);
System.Threading.Thread.Sleep(50); // a rewritten stamp would differ
Apply();
Assert.Equal(first, File.ReadAllBytes(path));
}
finally { File.Delete(path); }
}
private static string TempCatalog() => Path.Combine(Path.GetTempPath(), "aetheria-doctrine-" + Guid.NewGuid().ToString("N") + ".cc");
[Fact]
public void TheDoctrineRoundTrips()
{
var path = TempCatalog();
try
{
using (var cache = AetheriaStores.Open(path, catalogWritable: true))
{
cache.Upsert(new VerseGrammar { Revision = 1 });
cache.Upsert(new Faction
{
Name = "Armed",
Doctrine = new FactionDoctrine
{
EngageOn = EngageOn.Trespass, Grace = 6, Hail = "Hold.", ComplySpeed = 10,
Combatant = new RoleDoctrine { RangeExponent = .05f, MinHitProbability = .15f, HoldStandoff = 40 }
}
});
cache.Upsert(new Faction { Name = "Bare" });
cache.FlushAsync().Wait();
}
using (var cache = AetheriaStores.Open(path))
{
var armed = cache.GetAll<Faction>().Single(f => f.Name == "Armed").Doctrine;
Assert.Equal(EngageOn.Trespass, armed.EngageOn);
Assert.Equal(6f, armed.Grace);
Assert.Equal("Hold.", armed.Hail);
Assert.Equal(10f, armed.ComplySpeed);
Assert.Equal(.05f, armed.Combatant.RangeExponent);
Assert.Equal(.15f, armed.Combatant.MinHitProbability);
Assert.Equal(40f, armed.Combatant.HoldStandoff);
Assert.Null(cache.GetAll<Faction>().Single(f => f.Name == "Bare").Doctrine);
}
}
finally { File.Delete(path); }
}
[Fact]
public void ADryRunOfTheCatalogCommandWritesNothing()
{
var path = TempCatalog();
try
{
using (var cache = AetheriaStores.Open(path, catalogWritable: true))
{
cache.Upsert(new VerseGrammar { Revision = 1 });
cache.Upsert(new Faction { Name = "Zhestokost" });
cache.FlushAsync().Wait();
}
var before = File.ReadAllBytes(path);
var output = new StringWriter();
using (var cache = AetheriaStores.Open(path, catalogWritable: true))
{
DoctrineCatalog.Run(cache, apply: false, output);
cache.FlushAsync().Wait();
}
Assert.Contains("Zhestokost: engage on", output.ToString());
Assert.Equal(before, File.ReadAllBytes(path));
using (var cache = AetheriaStores.Open(path))
Assert.Null(cache.GetAll<Faction>().Single().Doctrine);
}
finally { File.Delete(path); }
}
[Fact]
public void TheCatalogCommandNamesAMissingFaction()
{
var path = TempCatalog();
try
{
using (var cache = AetheriaStores.Open(path, catalogWritable: true))
{
cache.Upsert(new VerseGrammar { Revision = 1 });
foreach (var name in new[] { "Zhestokost", "Lucent Media", "Aeronautics Unlimited" }) cache.Upsert(new Faction { Name = name });
cache.FlushAsync().Wait();
}
var output = new StringWriter();
using (var cache = AetheriaStores.Open(path, catalogWritable: true))
{
DoctrineCatalog.Run(cache, apply: true, output);
cache.FlushAsync().Wait();
}
Assert.Contains("Pirates: no Faction record, doctrine not written", output.ToString());
using (var cache = AetheriaStores.Open(path, catalogWritable: true))
{
Assert.All(cache.GetAll<Faction>(), f => Assert.NotNull(f.Doctrine));
Assert.Equal(EngageOn.Trespass, cache.GetAll<Faction>().Single(f => f.Name == "Zhestokost").Doctrine.EngageOn);
cache.Upsert(new Faction { Name = "Pirates" });
DoctrineCatalog.Run(cache, apply: true, new StringWriter());
Assert.Equal(8f, cache.GetAll<Faction>().Single(f => f.Name == "Pirates").Doctrine.Grace);
}
}
finally { File.Delete(path); }
}
}