Aetheria/tests/Aetheria.Shared.Tests/ConsumableAiTests.cs
soul cc0c164574 determinism-ledger: every ItemManager states its seed; scatter on the zone's dice
- ItemManager(uint seed, ...) refuses seed 0 and seeds Random from it.
- RunStart.ItemSeed(galaxy, step) is public (formula unchanged), refuses a
  zero result; named steps ArenaStep, StageStep, ContinueStep and NoRunSeed.
- Continue seeds the item stream from the saved run's seed (ContinueStep);
  a scene with no galaxy uses NoRunSeed.
- Zone's unpack scatter draws SimulationDice.For(CombatSeed, ScatterStream,
  1-based pack ordinal), never the item stream.
- Loadout's scratch manager and AetherDb's catalog command take fixed seeds;
  every test and Editor construction gains 1u (mechanical rewrite).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-21 19:01:41 +02:00

607 lines
26 KiB
C#

using System;
using System.Collections.Generic;
using System.IO;
using System.Linq;
using System.Reflection;
using CultMath;
using GameCult.Caching;
using Xunit;
using static CultMath.math;
using float2 = CultMath.float2;
using float3 = CultMath.float3;
using int2 = CultMath.int2;
// AI pilots use consumables (aetheria-release cut consumable-ai r3; rulings sensor-stat-set item 4,
// lock-warning-strength, launcher-lock-solution-quality, vent-in-any-state): the agent vents, in every state, when ONE
// Track paint in the ship's own IncomingPaints passes .5; CombatState otherwise overdrives to close a target farther than twice its optimum range while
// heading within 15 degrees of it. It reads only its own ship's senses. A consumable is chosen by what its behaviours
// are, never by name, so the vent design is named "Overdrive" and the overdrive design "Coolant".
//
// The agent's one gun has Range 1000 and EffectiveRange 500 with nothing at maximum range, so its optimum range is
// about 500 while its shortest and longest gun range are both 1000: a threshold taken from either gun range is wrong
// and these tests can tell. The optimum is read by reflection, as FireControlTests does, and the thresholds are
// authored against it.
public sealed class ConsumableAiTests : IDisposable
{
private readonly string _root = Path.Combine(Path.GetTempPath(), "aetheria-consumable-ai-" + Guid.NewGuid().ToString("N"));
private readonly List<CultCache> _caches = new List<CultCache>();
private static readonly int2 HardpointCell = new int2(0, 0);
private static readonly int2 SecondHardpointCell = new int2(2, 2);
private static readonly int2 HoldCell = new int2(1, 1);
private const BindingFlags Private = BindingFlags.Instance | BindingFlags.NonPublic;
public ConsumableAiTests() => Directory.CreateDirectory(_root);
public void Dispose()
{
foreach (var c in _caches) c.Dispose();
Directory.Delete(_root, true);
}
private static PerformanceStat Const(float v) => new PerformanceStat { Min = v, Max = v };
private sealed class Scene
{
public ItemManager Items;
public Zone Zone;
public Ship Agent, Prey, Locker;
public EquippedCargoBay Hold;
public ConsumableItemData Vent, Overdrive, Decoy, StackableVent;
public GearData HeavyGun;
public CultRecordRef<Faction> Maker;
public float Optimum;
public CultRecordKey[] Circuit;
public void Store(ConsumableItemData design) =>
Assert.True(Hold.TryStore((ItemInstance) Items.CreateInstance(Items.CreateLot(design, Maker, .5f))));
public bool Active(ConsumableItemData design) => Agent.FindActiveConsumable(design) != null;
public int Carried => Hold.Cargo.Keys.Count(i => i is ConsumableItem);
public object State => typeof(Agent).GetField("_currentState", Private).GetValue(Zone.Agents.Single());
public void AssertInCombat() => Assert.True(State is CombatState, $"the agent is in {State?.GetType().Name}");
// The target at `distance` and `degrees` off north, the agent at the origin with its hull `hullDegrees` off north.
public void Place(float distance, float degrees, float hullDegrees = 0f)
{
Agent.Position = float3.zero;
var h = hullDegrees * 0.0174533f;
Agent.Direction = float2(sin(h), cos(h));
var a = degrees * 0.0174533f;
Prey.Position = float3(sin(a) * distance, 0, cos(a) * distance);
}
// Paints of the locker's launcher reaching the agent as the zone publishes them, then one tick in which the
// agent decides. The agent reads them before the zone republishes (agents update, then PublishInbox).
public void Tick(params (PaintMode mode, float strength)[] paints)
{
foreach (var (mode, strength) in paints)
Agent.ReceivePaint(Locker, Locker.Equipment.Single(i => i.Behaviors.Any(b => b is LockWeapon)), mode, strength);
Agent.PublishInbox();
Zone.Update(.05f);
}
}
private static GearData GunData(float effective, string name = "Gun", float damage = 10f) => new GearData
{
Name = name, Hardpoint = HardpointType.Sensors, Shape = new Shape(), Durability = 1,
MinimumTemperature = -1000, MaximumTemperature = 1000, OptimalTemperature = 0, PlateauWidth = 2000,
Behaviors =
{
new InstantWeaponData
{
Damage = Const(damage), Range = Const(1000), MinRange = Const(0), Velocity = Const(0),
Spread = Const(0), DamageSpread = Const(0), Penetration = Const(0), Count = Const(1),
BurstTime = Const(0), Cooldown = Const(1),
EffectiveRange = Const(effective), DamageAtMaxRange = 0f
}
}
};
private Scene Build(bool carryVent = false, bool carryOverdrive = false, bool carryDecoy = false, int stackableVents = 0, float effective = 500f, uint? runSeed = null, int orbits = 1)
{
var cache = AetheriaStores.Open(Path.Combine(_root, Guid.NewGuid().ToString("N") + ".cc"), Path.Combine(_root, Guid.NewGuid().ToString("N") + ".run.cc"), catalogWritable: true);
_caches.Add(cache);
cache.Upsert(new VerseGrammar { Revision = 1 });
cache.Upsert(new Faction { Name = "Maker", ShortName = "MKR" });
var hullShape = new Shape(3, 3);
foreach (var cell in hullShape.AllCoordinates) hullShape[cell] = true;
cache.Upsert(new HullData
{
Name = "Skiff", HullType = HullType.Ship, Shape = hullShape, Durability = 1000000, Mass = 1000,
Hardpoints =
{
new HardpointData { Type = HardpointType.Sensors, Position = HardpointCell, Shape = new Shape() },
new HardpointData { Type = HardpointType.Sensors, Position = SecondHardpointCell, Shape = new Shape() }
}
});
cache.Upsert(GunData(effective));
cache.Upsert(GunData(800f, "HeavyGun", damage: 1000f));
cache.Upsert(LockWarningTests.Launcher("Launcher", lockSpeed: .6f, sensorImpact: 1, lockAngle: 30, directionImpact: 1));
var interior = new Shape(4, 1);
foreach (var cell in interior.AllCoordinates) interior[cell] = true;
cache.Upsert(new CargoBayData { Name = "Hold", Shape = new Shape(), InteriorShape = interior, Durability = 10 });
// A vent named like the other kind, an overdrive named like it, a decoy, and a stackable vent.
cache.Upsert(new ConsumableItemData
{
Name = "Overdrive", Duration = 100f, Shape = new Shape(),
Behaviors = { new VapourDumpData { Radius = Const(20), Opacity = Const(.9f), Lifetime = Const(100) } }
});
cache.Upsert(new ConsumableItemData { Name = "Coolant", Duration = 100f, Shape = new Shape(), Behaviors = { new ThrottleLockData() } });
cache.Upsert(new ConsumableItemData { Name = "Flare", Duration = 1f, Shape = new Shape() });
cache.Upsert(new ConsumableItemData
{
Name = "StackVent", Duration = 100f, Stackable = true, Shape = new Shape(),
Behaviors = { new VapourDumpData { Radius = Const(20), Opacity = Const(.9f), Lifetime = Const(100) } }
});
cache.FlushAsync().Wait();
var settings = RunSaveTests.TestSettings();
settings.TargetDetectionInfoThreshold = .1f;
settings.TargetGearInfoThreshold = .8f;
settings.FiringArc = 170;
settings.CommitHorizon = .5f;
settings.SchematicCellSize = 1f;
settings.UnaidedAccuracy = .05f;
settings.UnaidedTracking = 10f;
settings.UnaidedPrecision = 2f;
settings.AgentMinHitProbability = 0f;
settings.BeamResolveInterval = .1f;
var items = new ItemManager(1u, cache, new ProvenanceLedger(), settings, _ => { });
var maker = cache.RefOf(cache.GetByName<Faction>("Maker"));
EquippableItem Make<T>(string name) where T : CraftedItemData =>
(EquippableItem) items.CreateInstance(items.CreateLot(cache.GetByName<T>(name), maker, .5f));
var galaxy = runSeed.HasValue ? RunSeedFixture.Continue(cache, "Test Zone", runSeed.Value) : null;
var zone = galaxy != null
? new Zone(items, new PlanetSettings(), new ZonePack(), galaxy.Zones[0], galaxy)
: new Zone(items, new PlanetSettings(), new ZonePack(), new GalaxyZone { Name = "Test Zone", Owner = null }, null);
Ship NewShip(string gear)
{
var ship = new Ship(items, zone, Make<HullData>("Skiff"), new EntitySettings())
{ Faction = new Faction { Name = "F" + Guid.NewGuid().ToString("N") } };
if (gear != null) Assert.True(ship.TryEquip(Make<GearData>(gear), HardpointCell));
return ship;
}
var scene = new Scene
{
Items = items, Zone = zone, Maker = maker,
Vent = cache.GetByName<ConsumableItemData>("Overdrive"),
Overdrive = cache.GetByName<ConsumableItemData>("Coolant"),
Decoy = cache.GetByName<ConsumableItemData>("Flare"),
StackableVent = cache.GetByName<ConsumableItemData>("StackVent"),
HeavyGun = cache.GetByName<GearData>("HeavyGun"),
Agent = NewShip("Gun"), Prey = NewShip(null), Locker = NewShip("Launcher")
};
Assert.True(scene.Agent.TryEquip(Make<CargoBayData>("Hold"), HoldCell));
scene.Hold = scene.Agent.CargoBays.Single();
// The agent joins as the zone admits a piloted ship (its Minion and CombatState come from production); the
// locker is a gun ship nobody pilots.
// One fixed orbit far from the fight, before the agent joins: a pilot that is not engaged patrols it, as production's would.
for (var i = 0; i < orbits; i++)
zone.Orbits.Add(new CultRecordKey(orbits == 1 ? "test-orbit" : "test-orbit-" + i), new Orbit(new OrbitData { FixedPosition = float2(0, 5000) }));
zone.Admit(scene.Agent, piloted: true);
scene.Circuit = ((PatrolOrbitsTask) zone.Agents.Single().Task).Circuit;
zone.Admit(scene.Prey, piloted: false);
zone.Admit(scene.Locker, piloted: false);
// The flight is the only writer of the agent's Target: the agent senses the prey and holds a grudge on it, so the
// flight engages it.
scene.Agent.SetIff(scene.Prey, true);
scene.Agent.EntityInfoGathered[scene.Prey] = 1f;
scene.Agent.VisibleEnemies.Add(scene.Prey);
zone.Update(0f); // brings the gear online
scene.Locker.Position = float3(0, 0, -40);
scene.Place(100f, 0f);
zone.Update(.05f);
zone.Update(.05f);
zone.Update(.05f); // patrol to combat takes a tick the unengaged-root start did not
scene.AssertInCombat();
// Read, never re-sampled: the optimum is whatever production derived for the spawned ship.
scene.Optimum = (float) typeof(CombatState).GetField("_optimumRange", Private).GetValue(scene.State);
Assert.InRange(scene.Optimum, effective - 1000f / 32f, effective + 1000f / 32f);
if (carryVent) scene.Store(scene.Vent);
if (carryOverdrive) scene.Store(scene.Overdrive);
if (carryDecoy) scene.Store(scene.Decoy);
for (var i = 0; i < stackableVents; i++) scene.Store(scene.StackableVent);
Assert.True(scene.Carried == (carryVent ? 1 : 0) + (carryOverdrive ? 1 : 0) + (carryDecoy ? 1 : 0) + stackableVents,
$"carried {scene.Carried}, cargo entries {scene.Hold.Cargo.Count}: {string.Join(",", scene.Hold.Cargo.Keys.Select(k => k.GetType().Name))}");
return scene;
}
[Theory]
[InlineData(.4f, false)]
[InlineData(.49f, false)]
[InlineData(.5f, false)]
[InlineData(.51f, true)]
[InlineData(.6f, true)]
public void AnAgentVentsWhenLocked(float strength, bool vents)
{
var s = Build(carryVent: true, carryOverdrive: true);
s.AssertInCombat();
s.Tick((PaintMode.Track, strength));
Assert.Equal(vents, s.Active(s.Vent));
Assert.Equal(vents ? 1 : 2, s.Carried);
Assert.False(s.Active(s.Overdrive));
}
[Fact]
public void AnAgentVentsOnOneTrackPaintOnItself()
{
var s = Build(carryVent: true);
s.AssertInCombat();
s.Tick((PaintMode.Track, .3f), (PaintMode.Track, .3f)); // two weak locks never sum
s.Tick((PaintMode.Search, .9f));
s.Tick((PaintMode.Scan, .9f));
Assert.False(s.Active(s.Vent));
Assert.Equal(1, s.Carried);
// The locker is a ship the agent has no information on, and one strong Track paint on it is enough.
s.Tick((PaintMode.Track, .9f));
Assert.True(s.Active(s.Vent));
Assert.Equal(0, s.Carried);
}
// The production path: a real launcher on a ship the agent has never sensed locks it through zone ticks, and the
// agent vents once the paint is published, with its own senses as the only input.
[Fact]
public void AnUnseenLockerThatLocksTheAgentMakesItVent()
{
var s = Build(carryVent: true);
s.AssertInCombat();
s.Locker.SetIff(s.Agent, true);
s.Locker.EntityInfoGathered[s.Agent] = 1f;
s.Locker.SetTarget(s.Agent);
for (var i = 0; i < 4 && !s.Active(s.Vent); i++)
{
s.Place(100f, 0f);
s.Locker.Aim = normalize(s.Agent.Position - s.Locker.Position);
s.Zone.Update(1f);
}
Assert.True(s.Active(s.Vent));
Assert.Equal(0, s.Carried);
Assert.Equal(s.Prey, s.Agent.Target.Value.Entity);
}
[Fact]
public void AnAgentThatIsLockedVentsInsteadOfOverdriving()
{
var s = Build(carryVent: true, carryOverdrive: true);
s.AssertInCombat();
s.Place(3 * s.Optimum, 0f);
s.Tick((PaintMode.Track, .6f));
Assert.True(s.Active(s.Vent));
Assert.False(s.Active(s.Overdrive));
Assert.Equal(1, s.Carried);
}
// Two guns, optimums about 300 and 700: the threshold is twice the optimum the ship derived, so no constant, no gun
// range and no scaling of the arc by distance can sit at 2 x optimum +- 20 for both.
[Theory]
[InlineData(300f, 0f, 2f, 20f, true)]
[InlineData(300f, 14f, 2f, 20f, true)]
[InlineData(300f, 16f, 2f, 20f, false)]
[InlineData(300f, 30f, 2f, 20f, false)]
[InlineData(300f, 180f, 2f, 20f, false)]
[InlineData(300f, 0f, 2f, -20f, false)]
[InlineData(300f, 0f, 1f, 0f, false)]
[InlineData(700f, 0f, 2f, 20f, true)]
[InlineData(700f, 14f, 2f, 20f, true)]
[InlineData(700f, 16f, 2f, 20f, false)]
[InlineData(700f, 0f, 2f, -20f, false)]
[InlineData(700f, 0f, 1f, 0f, false)]
public void AnAgentOverdrivesToClose(float effective, float degrees, float optimums, float offset, bool overdrives)
{
var s = Build(carryVent: true, carryOverdrive: true, effective: effective);
s.AssertInCombat();
s.Place(optimums * s.Optimum + offset, degrees);
s.Tick();
Assert.Equal(overdrives, s.Active(s.Overdrive));
Assert.False(s.Active(s.Vent));
Assert.Equal(overdrives ? 1 : 2, s.Carried);
}
// The arc is the hull's heading against the bearing to the target; the aim always points at the target, so an
// arc taken from the aim would surge with the hull pointing away.
[Theory]
[InlineData(0f, 14f, true)]
[InlineData(0f, -14f, true)]
[InlineData(0f, 16f, false)]
[InlineData(0f, 90f, false)]
public void TheArcIsTheHullsHeadingNotTheAim(float targetDegrees, float hullDegrees, bool overdrives)
{
var s = Build(carryOverdrive: true);
s.AssertInCombat();
s.Place(3 * s.Optimum, targetDegrees, hullDegrees);
s.Tick();
Assert.Equal(overdrives, s.Active(s.Overdrive));
}
// The zone gives each pilot it admits its own ordinal, from 1 and never reused: the key of its own dice
// (SimulationDice.CombatSampleStream). Kills: Zone.CreateAgent not setting Agent.Id.
[Fact]
public void TheZoneGivesEachAdmittedPilotItsOwnId()
{
var s = Build(carryOverdrive: true);
var first = s.Zone.Agents.Single().Id;
Assert.True(first > 0, "an admitted pilot has an id");
Assert.True(s.Zone.Remove(s.Agent));
s.Agent.Deactivate();
s.Zone.Admit(s.Agent, piloted: true);
Assert.True(s.Zone.Agents.Last(a => ReferenceEquals(a.Ship, s.Agent)).Id > first, "a re-admitted pilot is a new id");
}
// Admitting a pilot takes nothing from the item stream: its patrol circuit is its own draw, so how pilots are chosen never
// moves what is minted next. Kills: the circuit drawn from ItemManager.Random.
[Fact]
public void AdmittingAPilotDrawsNothingFromTheItemStream()
{
var s = Build(carryOverdrive: true);
Assert.True(s.Zone.Remove(s.Agent));
s.Agent.Deactivate();
var items = s.Agent.ItemManager;
var before = items.Random;
s.Zone.Admit(s.Agent, piloted: true);
Assert.Equal(before.NextFloat(), items.Random.NextFloat());
}
// The production path for the optimum: a ship the zone admits as piloted derives it from its gun's live stats
// before it needs it, and re-derives it when its loadout changes. The gun's effective range is 500 and the
// heavy gun's is 800, so the optimum moves with the weapons it has now.
// The run seed feeds drops and mines only (ruling run-seed-drops-only): the same pilot in the same zone samples its
// range with the same offset and draws the same patrol circuit at every run seed. Kills: the combat-sample dice or
// the patrol dice read DropSeed.
[Theory]
[InlineData(1u, 2u)]
[InlineData(0xC0FFEE42u, 0x2468ACE1u)]
public void ThePilotsDiceDoNotMoveWithTheRunSeed(uint first, uint second)
{
var a = Build(runSeed: first, orbits: 8);
var b = Build(runSeed: second, orbits: 8);
a.AssertInCombat();
b.AssertInCombat();
Assert.NotEqual(a.Zone.DropSeed, b.Zone.DropSeed);
Assert.Equal(a.Zone.CombatSeed, b.Zone.CombatSeed);
Assert.Equal(a.Optimum, b.Optimum);
Assert.Equal(4, a.Circuit.Length);
Assert.Equal(a.Circuit, b.Circuit);
}
[Fact]
public void ASpawnedShipsOptimumFollowsItsWeaponsAndNeverReadsZero()
{
var s = Build(carryOverdrive: true);
s.AssertInCombat();
Assert.InRange(s.Optimum, 500f - 1000f / 32f, 500f + 1000f / 32f);
s.Place(30f, 0f);
s.Tick();
s.Tick();
Assert.False(s.Active(s.Overdrive)); // 30 m is not "distant" against a derived optimum
Assert.Equal(1, s.Carried);
// A refit is a docked ship's act: out of the zone and inactive, then back in. The heavy gun dominates the
// damage, so with it fitted the optimum sits at its effective range, and without it at the first gun's.
void Refit(Action<Ship> fit)
{
Assert.True(s.Zone.Remove(s.Agent));
s.Agent.Deactivate();
fit(s.Agent);
s.Zone.Admit(s.Agent, piloted: true); // back in as a pilot: the zone gives it a new Minion and CombatState
// Deactivation cleared its senses: it senses the prey again, as the sensors would after the jump in.
s.Agent.SetIff(s.Prey, true);
s.Agent.EntityInfoGathered[s.Prey] = 1f;
if (!s.Agent.VisibleEnemies.Contains(s.Prey)) s.Agent.VisibleEnemies.Add(s.Prey);
s.Place(30f, 0f);
// The new Minion walks root, patrol, orbit, combat; the tick after it enters combat samples the optimum.
for (var i = 0; i < 8 && !(s.State is CombatState); i++) s.Zone.Update(.05f);
s.Zone.Update(.05f);
}
float Optimum() => (float) typeof(CombatState).GetField("_optimumRange", Private).GetValue(s.State);
EquippedItem heavy = null;
Refit(ship =>
{
var before = ship.Equipment.ToList();
Assert.True(ship.TryEquip((EquippableItem) s.Items.CreateInstance(s.Items.CreateLot(s.HeavyGun, s.Maker, .5f)), SecondHardpointCell));
heavy = ship.Equipment.Single(e => !before.Contains(e));
});
Assert.InRange(Optimum(), 800f - 1000f / 32f, 800f + 1000f / 32f);
Refit(ship => Assert.NotNull(ship.TryUnequip(heavy)));
Assert.InRange(Optimum(), 500f - 1000f / 32f, 500f + 1000f / 32f);
}
// A Search or Scan paint and a Track paint in one tick, in either order: the strongest Track paint decides.
[Theory]
[InlineData(PaintMode.Search, PaintMode.Track)]
[InlineData(PaintMode.Track, PaintMode.Search)]
[InlineData(PaintMode.Scan, PaintMode.Track)]
[InlineData(PaintMode.Track, PaintMode.Scan)]
public void AMixOfPaintsInOneTickVentsOnTheTrackPaint(PaintMode first, PaintMode second)
{
var s = Build(carryVent: true);
s.AssertInCombat();
s.Tick((first, first == PaintMode.Track ? .9f : .2f), (second, second == PaintMode.Track ? .9f : .2f));
Assert.True(s.Active(s.Vent));
Assert.Equal(0, s.Carried);
}
[Fact]
public void AMixOfWeakTrackAndStrongSearchPaintsDoesNotVent()
{
var s = Build(carryVent: true);
s.AssertInCombat();
s.Tick((PaintMode.Search, .9f), (PaintMode.Track, .3f));
s.Tick((PaintMode.Track, .3f), (PaintMode.Scan, .9f));
Assert.False(s.Active(s.Vent));
}
[Fact]
public void AnOverdriveIsNotStoppedByAWeakTrackPaint()
{
var s = Build(carryVent: true, carryOverdrive: true);
s.AssertInCombat();
s.Place(2 * s.Optimum + 20f, 0f);
s.Tick((PaintMode.Track, .4f));
Assert.True(s.Active(s.Overdrive));
Assert.False(s.Active(s.Vent));
}
[Theory]
[InlineData(false)]
[InlineData(true)]
public void AnAgentWithoutConsumablesDoesNothing(bool carryDecoy)
{
var s = Build(carryDecoy: carryDecoy);
s.AssertInCombat();
s.Tick((PaintMode.Track, .9f));
s.Place(3 * s.Optimum, 0f);
s.Tick();
Assert.False(s.Active(s.Decoy));
Assert.Equal(carryDecoy ? 1 : 0, s.Carried);
}
[Fact]
public void ADeadOrDockedShipActivatesNothing()
{
var s = Build(carryVent: true, carryOverdrive: true);
s.AssertInCombat();
Assert.True(s.Zone.Remove(s.Agent)); // what death and docking do
s.Agent.Deactivate();
s.Place(3 * s.Optimum, 0f);
s.Tick();
s.Tick();
Assert.Equal(2, s.Carried);
Assert.False(s.Active(s.Vent));
Assert.False(s.Active(s.Overdrive));
Assert.False(s.Agent.TryActivateConsumable(s.Overdrive));
Assert.Equal(2, s.Carried);
}
[Fact]
public void AStackOfVentsIsSpentOneAtATime()
{
var s = Build(stackableVents: 3);
s.AssertInCombat();
s.Tick((PaintMode.Track, .6f));
s.Tick((PaintMode.Track, .6f));
s.Tick((PaintMode.Track, .6f));
Assert.True(s.Active(s.StackableVent));
Assert.Equal(2, s.Carried);
}
// The hold is per kind: a second vent design in cargo is not spent while one vent is active, and an active
// overdrive does not stop a vent.
[Fact]
public void TheHoldIsPerKindNotPerDesignAndNotPerAnyConsumable()
{
var s = Build(carryVent: true, carryOverdrive: true, stackableVents: 1);
s.AssertInCombat();
s.Place(3 * s.Optimum, 0f);
s.Tick(); // overdrive
Assert.True(s.Active(s.Overdrive));
s.Tick((PaintMode.Track, .6f));
s.Tick((PaintMode.Track, .6f));
s.Tick((PaintMode.Track, .6f));
Assert.True(s.Active(s.Overdrive));
Assert.Equal(1, new[] { s.Vent, s.StackableVent }.Count(s.Active)); // one vent, whichever design came first
Assert.Equal(1, s.Carried); // the other vent design is still in cargo
}
// The vent reads only the agent's own paints: with its target gone from VisibleEnemies (still targeted, no
// longer sensed) and the locker unseen, it still vents.
[Fact]
public void AVentDoesNotNeedAnyEnemyInSight()
{
var s = Build(carryVent: true);
s.AssertInCombat();
s.Agent.VisibleEnemies.Clear();
Assert.Equal(s.Prey, s.Agent.Target.Value.Entity);
Assert.DoesNotContain(s.Locker, s.Agent.VisibleEnemies);
s.Tick((PaintMode.Track, .9f));
Assert.Empty(s.Agent.VisibleEnemies);
Assert.True(s.Active(s.Vent));
}
// A patrolling agent has no target and senses no enemy, and it vents all the same (ruling vent-in-any-state).
[Fact]
public void APatrollingAgentVentsToo()
{
var s = Build(carryVent: true);
s.Agent.VisibleEnemies.Clear();
s.Zone.Update(.05f);
s.Zone.Update(.05f);
Assert.False(s.State is CombatState, $"the agent is in {s.State?.GetType().Name}");
Assert.True(s.Agent.Target.Value.IsNone);
Assert.IsType<PatrolOrbitsTask>(s.Zone.Agents.Single().Task);
s.Tick((PaintMode.Track, .3f));
Assert.False(s.Active(s.Vent));
s.Tick((PaintMode.Track, .9f));
Assert.True(s.Active(s.Vent));
Assert.Equal(0, s.Carried);
}
// A following agent has a task to follow and no target, and it vents all the same (ruling vent-in-any-state names
// patrol, follow and combat). The agent is driven directly: the zone's flight is the sole writer of its Task.
[Fact]
public void AFollowingAgentVentsToo()
{
var s = Build(carryVent: true);
var agent = s.Zone.Agents.Single();
s.Agent.VisibleEnemies.Clear();
s.Zone.Update(.05f);
s.Zone.Update(.05f);
Assert.True(s.Agent.Target.Value.IsNone);
agent.Task = new FollowTask { Anchor = s.Prey, Standoff = 100 };
for (var i = 0; i < 6 && !(s.State is FollowState); i++) agent.Update(.05f);
Assert.True(s.State is FollowState, $"the agent is in {s.State?.GetType().Name}");
void Paint(float strength)
{
s.Agent.ReceivePaint(s.Locker, s.Locker.Equipment.Single(i => i.Behaviors.Any(b => b is LockWeapon)), PaintMode.Track, strength);
s.Agent.PublishInbox();
agent.Update(.05f);
}
Paint(.3f);
Assert.False(s.Active(s.Vent));
Paint(.9f);
Assert.True(s.State is FollowState);
Assert.True(s.Active(s.Vent));
Assert.Equal(0, s.Carried);
}
}