Aetheria/tests/Aetheria.Shared.Tests/FireControlTests.cs
Meta Vens 3ca3fcde7b TheAIHoldsAtItsEffectiveRange lets the Online change land between toggles
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-14 03:52:32 +02:00

500 lines
26 KiB
C#

/* This Source Code Form is subject to the terms of the Mozilla Public
* License, v. 2.0. If a copy of the MPL was not distributed with this
* file, You can obtain one at https://mozilla.org/MPL/2.0/. */
using System;
using System.IO;
using System.Linq;
using CultMath;
using GameCult.Caching;
using Xunit;
using static CultMath.math;
using float3 = CultMath.float3;
// Cut 1 (docs/fire-control-cut.md): pins mount direction, arc authoring/defaulting and the headless
// engagement this cut exists to unbreak. Builds its own hull/catalog (the IffAndCombatTests Skiff/Gun
// fixture is fixed to one hardpoint) rather than reusing those private helpers.
public sealed class FireControlTests : IDisposable
{
private readonly string _root = Path.Combine(Path.GetTempPath(), "aetheria-firecontrol-" + Guid.NewGuid().ToString("N"));
private string Catalog => Path.Combine(_root, "Aetheria.cc");
public FireControlTests() => Directory.CreateDirectory(_root);
private CultCache _openCache;
public void Dispose()
{
_openCache?.Dispose();
Directory.Delete(_root, true);
}
private static GameplaySettings TestSettings() => new GameplaySettings
{
DefaultEntitySettings = new EntitySettings(),
Tiers = new[] { new RarityTier { Name = "Common", Quality = .5f, Rarity = 0, Color = new float3(1, 1, 1) } },
QualityPriceModifier = new ExponentialLerp(),
TargetDetectionInfoThreshold = .5f,
FiringArc = 120
};
// A hull with one weapon hardpoint per (rotation, firingArc) pair, laid out along the X axis of a 1xN
// shape so each gets its own cell. FiringArc 0 on a hardpoint means "use GameplaySettings.FiringArc".
private (ItemManager items, Ship ship, EquippedItem[] weapons) BuildShipWithHardpoints(
params (ItemRotation rotation, float firingArc)[] hardpoints)
{
var hullShape = new Shape(Math.Max(hardpoints.Length, 3), 3);
foreach (var cell in hullShape.AllCoordinates) hullShape[cell] = true;
// Mass defaults to 0 (ItemData.cs:285), which zeroes ThermalMass (Entity.cs MapEntity) and NaNs
// UpdateTemperature -- author a real mass so the entity's own temperature stays a real number.
var hullData = new HullData { Name = "TestHull", HullType = HullType.Ship, Shape = hullShape, Durability = 1, Mass = 1000 };
for (var i = 0; i < hardpoints.Length; i++)
hullData.Hardpoints.Add(new HardpointData
{
Type = HardpointType.Sensors, Position = new int2(i, 0), Shape = new Shape(),
Rotation = hardpoints[i].rotation, FiringArc = hardpoints[i].firingArc
});
using (var buildCache = AetheriaStores.Open(Catalog, catalogWritable: true))
{
buildCache.Upsert(new VerseGrammar { Revision = 1 });
buildCache.Upsert(hullData);
buildCache.Upsert(new GearData
{
// The default [0,100] temperature bounds (ItemData.cs:379-382) don't necessarily cover
// wherever this hull's heat model settles after a tick: author a wide plateau so
// EquippableItemData.Performance stays 1 and the item actually comes Online.
Name = "Gun", Hardpoint = HardpointType.Sensors, Shape = new Shape(), Durability = 1,
MinimumTemperature = -1000, MaximumTemperature = 1000, OptimalTemperature = 0, PlateauWidth = 2000,
Behaviors = { new InstantWeaponData() }
});
// A second design, so a target can carry two guns whose true figures differ (claim tests).
buildCache.Upsert(new GearData
{
Name = "WeakGun", Hardpoint = HardpointType.Sensors, Shape = new Shape(), Durability = 1,
MinimumTemperature = -1000, MaximumTemperature = 1000, OptimalTemperature = 0, PlateauWidth = 2000,
Behaviors = { new InstantWeaponData() }
});
buildCache.FlushAsync().Wait();
}
_openCache = AetheriaStores.Open(Catalog, catalogWritable: true);
var ledger = new ProvenanceLedger();
ledger.Lots[1] = new Lot { Origin = new Attributed(), Quality = 1 }; // hull
for (var i = 0; i < hardpoints.Length; i++)
ledger.Lots[2 + i] = new Lot { Origin = new Attributed(), Quality = 1 }; // guns
var items = new ItemManager(_openCache, ledger, TestSettings(), _ => { });
var zone = new Zone(items, new PlanetSettings(), new ZonePack(), new GalaxyZone { Name = "Test Zone", Owner = null }, null);
var hullRef = items.ItemData.RefOf<ItemData>(items.ItemData.GetByName<HullData>("TestHull"));
var hull = new EquippableItem { Data = hullRef, Durability = 1, Lot = 1 };
var ship = new Ship(items, zone, hull, new EntitySettings());
// Entity.MapEntity equips the hull itself into Equipment at int2.zero, so hardpoint 0 (also at
// (0,0) here) shares a cell with the hull's own equipped-item entry: match guns by identity, not
// position.
var gunRef = items.ItemData.RefOf<ItemData>(items.ItemData.GetByName<GearData>("Gun"));
var weapons = new EquippedItem[hardpoints.Length];
for (var i = 0; i < hardpoints.Length; i++)
{
var gun = new EquippableItem { Data = gunRef, Durability = 1, Lot = 2 + i };
Assert.True(ship.TryEquip(gun, new int2(i, 0)));
weapons[i] = ship.Equipment.Single(e => e.EquippableItem == gun);
}
zone.Admit(ship, piloted: false);
return (items, ship, weapons);
}
// R6: mount direction is the item's authored rotation, not the hull's facing. A target 90 degrees to
// starboard bears on the Clockwise hardpoint's weapon and not the None hardpoint's, and vice versa dead
// ahead. Mutation: make InArc read Entity.Direction instead of the item's rotation, or swap Rotate's
// Clockwise/CounterClockwise cases -- either breaks one of the four assertions below.
[Fact]
public void ArcFollowsMountRotation()
{
var (_, _, weapons) = BuildShipWithHardpoints((ItemRotation.None, 0f), (ItemRotation.Clockwise, 0f));
var forward = weapons[0];
var starboardMounted = weapons[1];
var deadAhead = float3(0, 0, 1);
var abeamStarboard = float3(1, 0, 0);
Assert.True(FireControl.InArc(forward, deadAhead));
Assert.False(FireControl.InArc(forward, abeamStarboard));
Assert.True(FireControl.InArc(starboardMounted, abeamStarboard));
Assert.False(FireControl.InArc(starboardMounted, deadAhead));
}
// FiringArc is full width: at the default 120, a target 59 degrees off the mount bears and one at 61
// does not. Mutation: compare against cos(radians(arc)) instead of cos(radians(arc/2)).
[Fact]
public void ArcBoundaryIsHalfWidth()
{
var (_, _, weapons) = BuildShipWithHardpoints((ItemRotation.None, 0f));
var weapon = weapons[0];
float3 AtAngle(float degrees)
{
var r = radians(degrees);
return float3(sin(r), 0, cos(r));
}
Assert.True(FireControl.InArc(weapon, AtAngle(59)));
Assert.False(FireControl.InArc(weapon, AtAngle(61)));
}
// R7: the simulation is 2D. A target inside the arc horizontally but far above the firer still bears.
// Mutation: normalize and dot the full 3D vector instead of zeroing height first.
[Fact]
public void ArcIsPlanar()
{
var (_, _, weapons) = BuildShipWithHardpoints((ItemRotation.None, 0f));
var weapon = weapons[0];
var highButAhead = float3(0, 1000, 1);
Assert.True(FireControl.InArc(weapon, highButAhead));
}
// The per-hardpoint override wins over the default, and an authored 0 means "use the default," not
// "zero-degree arc." A hardpoint authored FiringArc: 360 bears 170 degrees off; a hardpoint left at 0
// falls back to GameplaySettings.FiringArc (120) and fails there, but still bears a target only 5
// degrees off. Mutation: ignore HardpointData.FiringArc entirely (both hardpoints fail at 170), or
// treat an authored 0 as a literal zero-width arc (the second hardpoint then fails even at 5 degrees).
[Fact]
public void HardpointOverrideBeatsDefault()
{
var (_, _, weapons) = BuildShipWithHardpoints((ItemRotation.None, 360f), (ItemRotation.None, 0f));
var turret = weapons[0];
var defaultArc = weapons[1];
float3 AtAngle(float degrees)
{
var r = radians(degrees);
return float3(sin(r), 0, cos(r));
}
Assert.True(FireControl.InArc(turret, AtAngle(170)));
Assert.False(FireControl.InArc(defaultArc, AtAngle(170)));
Assert.True(FireControl.InArc(defaultArc, AtAngle(5)));
}
// A shooter with one in-range, in-arc gun, a detected target at `targetPosition`, and a Minion driving the shooter.
private (Ship shooter, Ship target, Zone zone) BuildCombatEncounter(float3 targetPosition, float projectileSpeed = 0f, bool twoTargetGuns = false, float? effectiveRange = null)
{
var mounts = twoTargetGuns ? new[] { (ItemRotation.None, 0f), (ItemRotation.None, 0f) } : new[] { (ItemRotation.None, 0f) };
var (items, shooter, weapons) = BuildShipWithHardpoints(mounts);
var weaponData = (InstantWeaponData) weapons[0].Data.Behaviors.Single(b => b is InstantWeaponData);
weaponData.Damage = new PerformanceStat { Min = 10, Max = 10 };
weaponData.Cooldown = new PerformanceStat { Min = 1, Max = 1 };
weaponData.Range = new PerformanceStat { Min = 1000, Max = 1000 };
weaponData.MinRange = new PerformanceStat { Min = 0, Max = 0 };
if (effectiveRange.HasValue) weaponData.EffectiveRange = new PerformanceStat { Min = effectiveRange.Value, Max = effectiveRange.Value };
weaponData.Velocity = new PerformanceStat { Min = projectileSpeed, Max = projectileSpeed };
var zone = shooter.Zone;
var targetHullRef = items.ItemData.RefOf<ItemData>(items.ItemData.GetByName<HullData>("TestHull"));
var target = new Ship(items, zone, new EquippableItem { Data = targetHullRef, Durability = 1, Lot = 1 }, new EntitySettings());
// Entity._orderedEquipment is populated only by TryEquip (Entity.cs:813); a ship that never equips
// anything leaves it null, and Entity.Update (:932) throws on that regardless of this cut. Equip a
// gun so the target is a real, ordinarily-constructed ship rather than a workaround-shaped one.
var targetGunRef = items.ItemData.RefOf<ItemData>(items.ItemData.GetByName<GearData>("Gun"));
Assert.True(target.TryEquip(new EquippableItem { Data = targetGunRef, Durability = 1, Lot = 1 }, new int2(0, 0)));
if (twoTargetGuns)
{
// Both true guns are finite, authored figures: the strong one rates 10, the weak one 1.
weaponData.Count = new PerformanceStat { Min = 1, Max = 1 };
var weakRef = items.ItemData.RefOf<ItemData>(items.ItemData.GetByName<GearData>("WeakGun"));
var weakData = (InstantWeaponData) items.ItemData.GetByName<GearData>("WeakGun").Behaviors.Single();
weakData.Damage = new PerformanceStat { Min = 1, Max = 1 };
weakData.Count = new PerformanceStat { Min = 1, Max = 1 };
weakData.Cooldown = new PerformanceStat { Min = 1, Max = 1 };
weakData.Range = new PerformanceStat { Min = 1000, Max = 1000 };
weakData.MinRange = new PerformanceStat { Min = 0, Max = 0 };
Assert.True(target.TryEquip(new EquippableItem { Data = weakRef, Durability = 1, Lot = 2 }, new int2(1, 0)));
}
zone.Admit(target, piloted: false);
shooter.Position = float3.zero;
target.Position = targetPosition;
shooter.SetTarget(target);
// Cut 3: CombatState now gates on FireControl.HitProbability, which is zero for an undetected target
// (the target is not in VisibleEntities) regardless of arc -- cross TargetDetectionInfoThreshold
// directly, the same way a Sensor's own info accrual eventually would.
shooter.EntityInfoGathered[target] = 1f;
// This fixture's shooter carries no targeting system, so Accuracy falls back to UnaidedAccuracy (Q4:
// authored deliberately bad). This test's own concern is the headless pipeline reaching a fire
// decision at all (Cut 1's regression), not AgentMinHitProbability tuning -- zero the threshold so an
// unaided shot still counts as "worth it."
items.GameplaySettings.AgentMinHitProbability = 0f;
zone.Agents.Add(new Minion(shooter));
return (shooter, target, zone);
}
// The cut's real deliverable: Entity.HardpointTransforms is gone, so a Minion can step through a full
// Zone.Update tick and reach the fire decision without Unity ever having run. Before this cut,
// Combat.cs:100-102 threw KeyNotFoundException on the first combat tick headless, because the AI's aim
// direction was a readback from a renderer that never ran. Mutation: restore the HardpointTransforms
// read in Behavior.Direction / Combat.cs -- this throws again outside Unity.
[Fact]
public void CombatStateStepsHeadless()
{
// dead ahead of the None-rotation hardpoint, well within range and arc
var (shooter, _, zone) = BuildCombatEncounter(float3(0, 0, 10));
var ex = Record.Exception(() =>
{
zone.Update(1f); // tick 1: transitions the Minion into CombatState and evaluates weapon stats for the first time
zone.Update(1f); // tick 2: CombatState.Update reaches FireControl.InArc -- the line that used to throw headless
});
Assert.Null(ex);
var weapon = shooter.Weapons.Single();
Assert.True(weapon.Firing); // arc + range satisfied: the fight actually decided to fire, not just avoided a crash
}
// Combat picks a target item only among the items its ship holds a reveal of: the target's gun is in range and
// the target is fully tracked, yet nothing is aimed at until the shooter has seen the gun act, and a destroyed
// gun is cleared by Combat's own choice. Mutation:
// Combat tests weapons without IsRevealed, or IsRevealed falls back to the track.
[Fact]
public void TheAIAimsOnlyAtWhatItSaw()
{
var (shooter, target, zone) = BuildCombatEncounter(float3(0, 0, 10));
var targetGun = target.Equipment.Single(x => x.Behaviors.Any(b => b is Weapon));
zone.Update(1f);
zone.Update(1f);
Assert.Null(shooter.TargetItem.Value);
shooter.RecordReveal(target, targetGun, SignatureChannel.LaunchFlash, targetGun.Data);
zone.Update(1f);
Assert.Same(targetGun, shooter.TargetItem.Value);
// The gun is destroyed: nothing qualifies any more, so Combat clears the aim point itself rather than
// leaving the dead gun in the raw field.
targetGun.EquippableItem.Durability = 0f;
zone.Update(1f);
Assert.Null(shooter.TargetItem.Value);
}
// A design as an observer might be told it is: an instant weapon with rated figures, held in memory (a claim is data,
// not a catalog item). Rated damage per second at any range is damage / cooldown (one round, flat curve).
private static GearData ClaimedDesign(string name, float damage, float range = 1000f, float? effective = null, float end = 0f) => new GearData
{
Name = name,
Behaviors =
{
new InstantWeaponData
{
Damage = new PerformanceStat { Min = 0, Max = damage }, Count = new PerformanceStat { Min = 1, Max = 1 },
Cooldown = new PerformanceStat { Min = 1, Max = 1 }, Range = new PerformanceStat { Min = 0, Max = range },
MinRange = new PerformanceStat { Min = 0, Max = 0 },
EffectiveRange = effective.HasValue ? new PerformanceStat { Min = effective.Value, Max = effective.Value } : new PerformanceStat { Min = float.PositiveInfinity, Max = float.PositiveInfinity },
DamageAtMaxRange = end
}
}
};
// Ruling ew-aim: the AI classifies from its book, not the target's gear (aetheria-ew, classify-from-belief). A gun
// the target claims is not a weapon is not something Combat picks as a weapon, though the gun is truly one and
// the shooter has seen it act; clearing the claim and seeing it act again restores the choice. Mutation: Combat
// walks target.Equipment (or reveal.Item's true behaviours) instead of RevealedWeapons.
[Fact]
public void TheAIChoosesFromWhatItBelieves()
{
var (shooter, target, zone) = BuildCombatEncounter(float3(0, 0, 10));
var targetGun = target.Equipment.Single(x => x.Behaviors.Any(b => b is Weapon));
target.Claim(targetGun, new GearData { Name = "NotAGun" });
shooter.RecordReveal(target, targetGun, SignatureChannel.LaunchFlash, target.ClaimOf(targetGun));
zone.Update(1f);
zone.Update(1f);
Assert.Null(shooter.TargetItem.Value);
target.Claim(targetGun, null);
shooter.RecordReveal(target, targetGun, SignatureChannel.LaunchFlash, target.ClaimOf(targetGun));
zone.Update(1f);
Assert.Same(targetGun, shooter.TargetItem.Value);
}
// Ruling ew-claim-weapon-measure-ruled (rated-design): the AI ranks revealed weapons by their claimed design's
// rated figures, so the truly weaker gun claimed as the stronger design is the one chosen, and the unclaimed pair
// picks the truly stronger. Mutation: rank by the true weapon's live figure, or by the carrier's stats.
[Fact]
public void AWeakerGunClaimedAsAStrongerDesignIsChosen()
{
var (shooter, target, zone) = BuildCombatEncounter(float3(0, 0, 10), twoTargetGuns: true);
var strong = target.Equipment.Single(x => x.Data.Name == "Gun");
var weak = target.Equipment.Single(x => x.Data.Name == "WeakGun");
void See()
{
shooter.RecordReveal(target, strong, SignatureChannel.LaunchFlash, target.ClaimOf(strong));
shooter.RecordReveal(target, weak, SignatureChannel.LaunchFlash, target.ClaimOf(weak));
zone.Update(1f);
zone.Update(1f);
}
See();
Assert.Same(strong, shooter.TargetItem.Value); // unclaimed: the truly stronger gun (rated 10 against 1)
target.Claim(strong, ClaimedDesign("Pea", 2f));
target.Claim(weak, ClaimedDesign("Cannon", 90f));
See();
Assert.Same(weak, shooter.TargetItem.Value);
}
// The ranking reads the claimed design at the distance to the target: a short-range design that out-damages a flat one
// up close is worthless at 10 when its Range is 12, and the weaker flat design is chosen. Mutation: Combat halves
// targetDistance (the short design then rates 5.8 at 5 and wins), or the rated read ignores the range.
[Fact]
public void TheAIRanksClaimsAtTheTargetsDistance()
{
var (shooter, target, zone) = BuildCombatEncounter(float3(0, 0, 10), twoTargetGuns: true);
var strong = target.Equipment.Single(x => x.Data.Name == "Gun");
var weak = target.Equipment.Single(x => x.Data.Name == "WeakGun");
target.Claim(strong, ClaimedDesign("Dart", 10f, range: 12f, effective: 0f)); // ~1.7 at 10, ~5.8 at 5
target.Claim(weak, ClaimedDesign("Slug", 5f)); // 5 at any range
shooter.RecordReveal(target, strong, SignatureChannel.LaunchFlash, target.ClaimOf(strong));
shooter.RecordReveal(target, weak, SignatureChannel.LaunchFlash, target.ClaimOf(weak));
zone.Update(1f);
zone.Update(1f);
Assert.Same(weak, shooter.TargetItem.Value);
}
// The AI settles where its guns do the most damage per second, weighted towards range: with full damage to 500 and none
// at 1000 that is the effective range (DF6). Mutation: SampleDps spans from the shortest Range (every sample then reads
// 1000, factor 0, and the optimum stays 0), or the factor reads an offset range.
[Fact]
public void TheAIHoldsAtItsEffectiveRange()
{
var (shooter, _, zone) = BuildCombatEncounter(float3(0, 0, 10), effectiveRange: 500f);
zone.Update(1f); // enters CombatState, which samples before the weapons' first stat evaluation
zone.Update(1f);
var item = shooter.Weapons.Single().Item;
item.Enabled.Value = false; // an Online change is what makes Combat sample again, over the evaluated stats
zone.Update(.1f);
item.Enabled.Value = true;
zone.Update(.1f);
var agent = zone.Agents.Single();
var state = typeof(Agent).GetField("_currentState", System.Reflection.BindingFlags.Instance | System.Reflection.BindingFlags.NonPublic).GetValue(agent);
Assert.True(state is CombatState, $"the agent is in {state?.GetType().Name}");
var optimum = (float) typeof(CombatState).GetField("_optimumRange", System.Reflection.BindingFlags.Instance | System.Reflection.BindingFlags.NonPublic).GetValue(state);
Assert.True(optimum >= 500f - 1000f / 32f && optimum <= 500f + 1000f / 32f, $"optimum range {optimum}");
}
// Ruling ew-claim-weapon-measure-ruled: no observer reads a target weapon's live state. Two unclaimed guns whose
// rated order (10 against 1) is the reverse of their live order (the strong one worn down to a hundredth) are
// ranked by the design, so the aim stays on the rated stronger gun. Mutation: rank an unclaimed reveal by the
// true weapon's live RangeDamagePerSecond.
[Fact]
public void TheAIRanksByTheDesignNotTheTargetsLiveGun()
{
var (shooter, target, zone) = BuildCombatEncounter(float3(0, 0, 10), twoTargetGuns: true);
var strong = target.Equipment.Single(x => x.Data.Name == "Gun");
var weak = target.Equipment.Single(x => x.Data.Name == "WeakGun");
var strongData = (InstantWeaponData) strong.Data.Behaviors.Single(b => b is InstantWeaponData);
target.Resolver.AttachModifier(strong, strongData.Damage, "worn", StatModifierType.Multiplier, .01f);
shooter.RecordReveal(target, strong, SignatureChannel.LaunchFlash, target.ClaimOf(strong));
shooter.RecordReveal(target, weak, SignatureChannel.LaunchFlash, target.ClaimOf(weak));
zone.Update(1f);
zone.Update(1f);
var liveStrong = strong.Behaviors.OfType<Weapon>().Single().RangeDamagePerSecond(10f);
var liveWeak = weak.Behaviors.OfType<Weapon>().Single().RangeDamagePerSecond(10f);
Assert.True(liveStrong < liveWeak); // the fixture: live and rated disagree
Assert.Same(strong, shooter.TargetItem.Value);
}
// Ties are explicit: two revealed weapons of equal rated figure are taken in the subject's equipment order, whatever
// order the shooter saw them in. Mutation: ties go to the last (>=), or the walk follows the book's slot order.
[Fact]
public void EqualClaimsAreTakenInTheTargetsEquipmentOrder()
{
var (shooter, target, zone) = BuildCombatEncounter(float3(0, 0, 10), twoTargetGuns: true);
var first = target.Equipment.Single(x => x.Data.Name == "Gun");
var second = target.Equipment.Single(x => x.Data.Name == "WeakGun");
Assert.True(target.Equipment.IndexOf(first) < target.Equipment.IndexOf(second));
var design = ClaimedDesign("Twin", 5f);
target.Claim(first, design);
target.Claim(second, design);
shooter.RecordReveal(target, second, SignatureChannel.LaunchFlash, target.ClaimOf(second)); // seen first
shooter.RecordReveal(target, first, SignatureChannel.LaunchFlash, target.ClaimOf(first));
zone.Update(1f);
zone.Update(1f);
Assert.Equal(new[] { first, second }, shooter.RevealedWeapons(target).Select(r => r.Item).ToArray());
Assert.Same(first, shooter.TargetItem.Value);
}
// Controls cut (helm split): Combat aims the shooter at the intercept (Aim) and commands the hull toward it
// through the one steering law (Turn); it never turns the hull by writing Aim.
[Fact]
public void CombatAimsAtTheInterceptAndTurnsTowardIt()
{
var (shooter, target, zone) = BuildCombatEncounter(float3(10, 0, 10)); // 45 degrees to starboard, in arc
var direction = shooter.Direction;
zone.Update(1f);
zone.Update(1f);
var bearing = normalize(target.Position.xz - shooter.Position.xz);
var aim = normalize(shooter.Aim.xz);
Assert.True(dot(aim, bearing) > .999f, "Aim points at the stationary target's intercept");
Assert.Equal(direction, shooter.Direction); // no thrusters on this hull: only Turn was commanded
Assert.Equal(Steering.Toward(shooter, shooter.Aim.xz), shooter.Turn);
Assert.True(abs(shooter.Turn) > .5f);
}
// Against a target that moves, Combat aims at where the round will meet it (FireControl.PredictedIntercept),
// not where the target is: the aim leads, and the hull's Turn follows the intercept bearing.
[Fact]
public void CombatLeadsAMovingTarget()
{
var (shooter, target, zone) = BuildCombatEncounter(float3(0, 0, 100), projectileSpeed: 20f);
zone.Update(1f); // transitions the Minion into CombatState and evaluates weapon stats
// Agents update before entities, so Combat reads exactly this state on the next tick.
target.Position = float3(0, 0, 100);
target.Velocity = float2(4, 0);
var weapon = shooter.Weapons.Single();
var intercept = normalize((FireControl.PredictedIntercept(weapon, shooter, target) - shooter.Position).xz);
var bearing = normalize(target.Position.xz - shooter.Position.xz);
Assert.True(dot(intercept, bearing) < cos(radians(5f)), "the fixture really leads the target");
zone.Update(1f);
var aim = normalize(shooter.Aim.xz);
Assert.True(dot(aim, intercept) > .9999f, $"aim {shooter.Aim}");
Assert.Equal(Steering.Toward(shooter, intercept), shooter.Turn);
Assert.True(shooter.Turn > 0f);
}
// A Minion whose target goes away returns to its root state, which pilots nothing: the hull must stop
// turning rather than keep the rate Combat last commanded.
[Fact]
public void AMinionThatLosesItsTargetStopsTurning()
{
var (shooter, _, zone) = BuildCombatEncounter(float3(10, 0, 10));
zone.Update(1f);
zone.Update(1f);
Assert.True(abs(shooter.Turn) > .5f, "Combat commanded a turn");
shooter.SetTarget(TargetRef.None);
zone.Update(1f); // Combat has no target and the Minion transitions to its root state
zone.Update(1f);
Assert.Equal(0f, shooter.Turn);
}
}