Aetheria/tests/Aetheria.Shared.Tests/FlightTests.cs
Meta Vens 49423b61fe EW 3: allegiance is the observer's belief; telemetry with the track, grudges from acts, one stance writer
Cuts WatchForGrudge, PerceivedStanceOf, the recursive IsHostileTo and the inline compare-and-write; adds _telemetry (ReceiveTelemetry at track entry), Provoke (Track paint, committed shot), RefreshStance, BelievesTrespassing. Not yet verified: unbuilt, tests unrun.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-17 06:45:51 +02:00

1140 lines
43 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;
}
private static void See(Ship member, Entity track)
{
member.EntityInfoGathered[track] = 1;
member.VisibleEnemies.Add(track);
}
private void Tick(float dt)
{
foreach (var flight in _zone.Flights.ToArray()) flight.Update(dt);
}
[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 = new FollowTask { Anchor = anchor, Standoff = 100 } };
minion.Update(.1f);
minion.Update(.1f);
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 = new FollowTask { Anchor = anchor, Standoff = 50 };
minion.Update(.1f);
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);
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 EachFactionFliesAFlightOfItsOwnAndUnaffiliatedShipsShareOne()
{
Pilot(new Faction { Name = "One" });
Pilot(new Faction { Name = "Two" });
Pilot(null);
Pilot(null);
Assert.Equal(new[] { 1, 1, 2 }, _zone.Flights.Select(f => f.Count).OrderBy(c => c));
}
[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.Same(near, ((FollowTask) _zone.Agents.Single().Task).Anchor);
near.Position = new float3(-900, 0, 0);
far.Position = new float3(990, 0, 0);
Tick(.1f);
Assert.Same(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.Same(near, ((FollowTask) _zone.Agents.Single().Task).Anchor);
member.VisibleEnemies.Remove(near);
Tick(.1f);
Assert.Same(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(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 = new FollowTask { Anchor = anchor, Standoff = 100 } };
minion.Update(.1f);
minion.Update(.1f);
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 = new FollowTask { Anchor = anchor, Standoff = 50 } };
minion.Update(.1f);
minion.Update(.1f);
Assert.IsType<FollowState>(CurrentState(minion));
minion.Task = new PatrolOrbitsTask { Circuit = new CultRecordKey[1] };
minion.Update(.1f);
Assert.IsType<PatrolOrbitsState>(CurrentState(minion));
minion.Task = new FollowTask { Anchor = anchor, Standoff = 50 };
minion.Update(.1f);
minion.Update(.1f);
minion.Task = null;
minion.Update(.1f);
minion.Update(.1f);
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.Same(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.Same(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.Same(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);
Assert.IsType<CombatState>(CurrentState(minion));
}
}
[Fact]
public void ATaskThatIsNeitherPatrolNorFollowSendsAPatrollingPilotToTheRoot()
{
var ship = Pilot(null);
var minion = PatrollingMinion(ship);
minion.Task = null;
minion.Update(.1f);
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 = new FollowTask { Anchor = anchor, Standoff = 100 } };
minion.Update(.1f);
minion.Update(.1f);
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. 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(18, slots.Length);
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); }
}
}