- 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>
607 lines
26 KiB
C#
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);
|
|
}
|
|
}
|