Aetheria/tests/Aetheria.Shared.Tests/ConsumableAiTests.cs
Meta Vens f2ecc96a3c ConsumableAiTests: the lock held on someone else is a real lock
The locker aimed at the prey was not hostile to it, so no lock ever built and
the whose-target mutant survived.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-10 10:57:52 +02:00

355 lines
15 KiB
C#

using System;
using System.Collections.Generic;
using System.IO;
using System.Linq;
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, ruling sensor-stat-set: a locked target knows it is
// locked): CombatState vents when an enemy lock on the ship passes .5 and otherwise overdrives to close a target
// farther than twice its optimum range while heading within 15 degrees of it. Both go through TryActivateConsumable,
// and a consumable is chosen by what its behaviours are, never by its name -- so the vent design here is *named*
// "Overdrive" and the overdrive design "Coolant". Observations are the ship's cargo and its active effects after a
// real Zone.Update.
//
// The agent carries a gun of range 400 and one of range 1000, flat damage and no range bias, so its optimum range is
// the first sample, 400 plus a random offset below 18.75: the thresholds are bracketed (799 is below twice any
// optimum, 850 above twice every one) rather than read from the private field.
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>();
public ConsumableAiTests() => Directory.CreateDirectory(_root);
public void Dispose()
{
foreach (var c in _caches) c.Dispose();
Directory.Delete(_root, true);
}
private static PerformanceStat Constant(float v) => new PerformanceStat { Min = v, Max = v };
private static Shape Solid(int w, int h)
{
var shape = new Shape(w, h);
foreach (var cell in shape.AllCoordinates) shape[cell] = true;
return shape;
}
private sealed class Scene
{
public ItemManager Items;
public Zone Zone;
public Ship Agent;
public Ship Prey;
public EquippedCargoBay Hold;
public ConsumableItemData VentDesign, OverdriveDesign, DecoyDesign;
public CultRecordRef<Faction> Maker;
public Func<LockWeaponData, Ship> NewLocker;
public readonly List<Ship> Lockers = new List<Ship>();
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.Count;
}
// The agent, its prey (a ship with a lock gun that targets nobody) and `lockers`: each a ship whose lock gun
// builds `lock` on its target in the first tick, then is turned away so the lock holds (Decay 0).
private Scene Build(float preyDistance, params (float lockSpeed, bool onAgent)[] lockers)
{
var settings = 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 = .1f, TargetArmorInfoThreshold = .2f, TargetGearInfoThreshold = .8f,
FiringArc = 360, SchematicCellSize = 1f
};
var cache = AetheriaStores.Open(Path.Combine(_root, Guid.NewGuid().ToString("N") + ".cc"), catalogWritable: true);
_caches.Add(cache);
cache.Upsert(new TestCatalogGlobal { Name = "Temperament" });
cache.Upsert(new Faction { Name = "Maker", ShortName = "MKR" });
cache.Upsert(new HullData
{
Name = "Hull", HullType = HullType.Ship, Shape = Solid(5, 5), Durability = 1000000, Mass = 1000,
Hardpoints =
{
new HardpointData { Type = HardpointType.Sensors, Position = new int2(0, 0), Shape = new Shape() },
new HardpointData { Type = HardpointType.Sensors, Position = new int2(1, 0), Shape = new Shape() }
}
});
GearData Gear(string name, BehaviorData behavior) => new GearData
{
Name = name, Hardpoint = HardpointType.Sensors, Shape = new Shape(), Durability = 1,
MinimumTemperature = -1000, MaximumTemperature = 1000, OptimalTemperature = 0, PlateauWidth = 2000,
Behaviors = { behavior }
};
InstantWeaponData Gun(float range) => new InstantWeaponData
{
Damage = Constant(10), Range = Constant(range), MinRange = Constant(0), Velocity = Constant(0),
Cooldown = Constant(1),
DamageCurve = new BezierCurve { Keys = new[] { float4(0, 1, 0, 0), float4(1, 1, 0, 0) } }
};
cache.Upsert(Gear("ShortGun", Gun(400)));
cache.Upsert(Gear("LongGun", Gun(1000)));
cache.Upsert(new CargoBayData { Name = "Hold", Shape = new Shape(), InteriorShape = Solid(4, 1), Durability = 10 });
cache.Upsert(new ConsumableItemData { Name = "Flare", Duration = 1f, Shape = new Shape() });
cache.Upsert(new ConsumableItemData
{
Name = "Overdrive", Duration = 100f, Shape = new Shape(), // a vent, named like the other kind
Behaviors = { new VapourDumpData { Radius = Constant(20), Opacity = Constant(.9f), Lifetime = Constant(100) } }
});
cache.Upsert(new ConsumableItemData
{
Name = "Coolant", Duration = 100f, Shape = new Shape(), // an overdrive, named like the other kind
Behaviors = { new ThrottleLockData() }
});
cache.FlushAsync().Wait();
var items = new ItemManager(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 zone = new Zone(items, new PlanetSettings { }, new ZonePack { Radius = 5000 }, new GalaxyZone { Name = "Ai", Owner = null }, null);
var hull = cache.GetByName<HullData>("Hull");
Ship NewShip() => new Ship(items, zone, (EquippableItem) items.CreateInstance(items.CreateLot(hull, maker, .5f)), new EntitySettings());
var scene = new Scene
{
Items = items, Zone = zone, Maker = maker,
VentDesign = cache.GetByName<ConsumableItemData>("Overdrive"),
OverdriveDesign = cache.GetByName<ConsumableItemData>("Coolant"),
DecoyDesign = cache.GetByName<ConsumableItemData>("Flare")
};
scene.Agent = NewShip();
Assert.True(scene.Agent.TryEquip(Make<GearData>("ShortGun"), new int2(0, 0)));
Assert.True(scene.Agent.TryEquip(Make<GearData>("LongGun"), new int2(1, 0)));
Assert.True(scene.Agent.TryEquip(Make<CargoBayData>("Hold"), new int2(3, 3)));
scene.Hold = scene.Agent.CargoBays.Single();
scene.NewLocker = data =>
{
var name = "LockGun" + Guid.NewGuid().ToString("N");
var gear = Gear(name, data);
cache.Upsert(gear);
var ship = NewShip();
Assert.True(ship.TryEquip((EquippableItem) items.CreateInstance(items.CreateLot(gear, maker, .5f)), new int2(0, 0)));
return ship;
};
LockWeaponData LockGun(float speed) => new LockWeaponData
{
Damage = Constant(0), Range = Constant(2000), MinRange = Constant(0), Velocity = Constant(0),
Spread = Constant(0), DamageSpread = Constant(0), Penetration = Constant(0), Count = Constant(1),
BurstTime = Constant(0), Cooldown = Constant(100000),
DamageCurve = new BezierCurve { Keys = new[] { float4(0, 1, 0, 0), float4(1, 1, 0, 0) } },
LockSpeed = Constant(speed), SensorImpact = Constant(1), LockAngle = Constant(30),
DirectionImpact = Constant(1), Decay = Constant(0)
};
scene.Prey = scene.NewLocker(LockGun(0));
var ships = new List<Ship> { scene.Agent, scene.Prey };
foreach (var (speed, _) in lockers)
{
var locker = scene.NewLocker(LockGun(speed));
scene.Lockers.Add(locker);
ships.Add(locker);
}
foreach (var ship in ships) zone.Entities.Add(ship);
foreach (var ship in ships) ship.Activate();
foreach (var other in ships.Skip(1))
{
scene.Agent.SetIff(other, true);
other.SetIff(scene.Agent, true);
}
zone.Update(0f);
// Full information both ways: the agent sees its prey first, so its Minion targets the prey.
zone.Agents.Add(new Minion(scene.Agent));
scene.Agent.Position = float3.zero;
scene.Prey.Position = float3(0, 0, preyDistance);
scene.Agent.EntityInfoGathered[scene.Prey] = 1f;
for (var i = 0; i < scene.Lockers.Count; i++)
{
var locker = scene.Lockers[i];
locker.Position = float3(0, 0, -40 * (i + 1)); // dead astern: a lock's bearing is then exact, never a rounding error from 1
scene.Agent.EntityInfoGathered[locker] = 1f;
locker.EntityInfoGathered[scene.Agent] = 1f;
locker.EntityInfoGathered[scene.Prey] = 1f;
}
Assert.Equal(scene.Prey, scene.Agent.Target.Value.Entity);
for (var i = 0; i < scene.Lockers.Count; i++)
{
var locker = scene.Lockers[i];
if (!lockers[i].onAgent) locker.SetIff(scene.Prey, true); // a lock builds only on a hostile target
locker.SetTarget(lockers[i].onAgent ? scene.Agent : (Entity) scene.Prey);
Aim(locker, towards: true);
}
return scene;
}
private static void Aim(Ship locker, bool towards)
{
var at = locker.Target.Value.Entity.Position - locker.Position;
locker.Aim = towards ? normalize(at) : -normalize(at);
}
// One tick builds each locker's lock (dt 1, info 1, dead ahead: lock = LockSpeed); the next tick the lockers
// look away, so each lock holds at exactly that value while the agent reads it.
private static void HoldLocks(Scene s)
{
s.Zone.Update(1f); // transitions the Minion into combat and builds the locks
foreach (var locker in s.Lockers) Aim(locker, towards: false);
}
private static void Face(Scene s, float degrees)
{
var r = radians(degrees);
s.Agent.Direction = float2(sin(r), cos(r));
s.Agent.Position = float3.zero;
}
private static void Settle(Scene s)
{
foreach (var ship in new[] { s.Agent, s.Prey }.Concat(s.Lockers)) ship.Velocity = float2.zero;
s.Zone.Update(1f);
}
[Theory]
[InlineData(.4f, false)]
[InlineData(.5f, false)]
[InlineData(.6f, true)]
public void AnAgentVentsWhenLocked(float lockValue, bool vents)
{
var s = Build(100, (lockValue, true));
HoldLocks(s);
Assert.Equal(lockValue, s.Lockers[0].Weapons.OfType<LockWeapon>().Single().Lock, 3);
s.Store(s.DecoyDesign);
s.Store(s.OverdriveDesign);
s.Store(s.VentDesign);
Face(s, 0);
Settle(s);
Assert.Equal(vents, s.Active(s.VentDesign));
Assert.False(s.Active(s.OverdriveDesign));
Assert.False(s.Active(s.DecoyDesign));
Assert.Equal(vents ? 2 : 3, s.Carried);
}
// The lock on the ship is what counts: two locks of .3 do not add up to one that passes half, a lock held on
// someone else is not a lock on this ship, and a locker the agent cannot see is not among its visible enemies.
[Fact]
public void AnAgentVentsOnOneLockOnItselfThatItCanSee()
{
var scattered = Build(100, (.3f, true), (.3f, true));
HoldLocks(scattered);
scattered.Store(scattered.VentDesign);
Face(scattered, 0);
Settle(scattered);
Assert.False(scattered.Active(scattered.VentDesign));
var elsewhere = Build(100, (.9f, false));
HoldLocks(elsewhere);
Assert.Equal(.9f, elsewhere.Lockers[0].Weapons.OfType<LockWeapon>().Single().Lock, 3); // the lock exists, on the prey
elsewhere.Store(elsewhere.VentDesign);
Face(elsewhere, 0);
Settle(elsewhere);
Assert.False(elsewhere.Active(elsewhere.VentDesign));
var unseen = Build(100, (.9f, true));
HoldLocks(unseen);
unseen.Agent.EntityInfoGathered[unseen.Lockers[0]] = 0f;
Assert.DoesNotContain(unseen.Lockers[0], unseen.Agent.VisibleEnemies);
unseen.Store(unseen.VentDesign);
Face(unseen, 0);
Settle(unseen);
Assert.False(unseen.Active(unseen.VentDesign));
var painted = Build(100, (.3f, true), (.9f, true));
HoldLocks(painted);
painted.Store(painted.VentDesign);
Face(painted, 0);
Settle(painted);
Assert.True(painted.Active(painted.VentDesign));
}
// Venting is the answer to a lock; the surge to close distance is the answer when nobody is locking.
[Fact]
public void AnAgentThatIsLockedVentsInsteadOfOverdriving()
{
var s = Build(1300, (.6f, true));
HoldLocks(s);
s.Store(s.OverdriveDesign);
s.Store(s.VentDesign);
Face(s, 0);
Settle(s);
Assert.True(s.Active(s.VentDesign));
Assert.False(s.Active(s.OverdriveDesign));
Assert.False(s.Agent.ThrottleLocked);
}
// Past twice the optimum range (400 to 418.75 here) and within 15 degrees of the target; a lock under half
// does not stop it.
[Theory]
[InlineData(1300, 0, 0f, true)]
[InlineData(850, 0, 0f, true)]
[InlineData(850, 0, .4f, true)]
[InlineData(850, 10, 0f, true)]
[InlineData(799, 0, 0f, false)]
[InlineData(700, 0, 0f, false)]
[InlineData(410, 0, 0f, false)]
[InlineData(1300, 20, 0f, false)]
[InlineData(1300, 30, 0f, false)]
[InlineData(1300, 180, 0f, false)]
public void AnAgentOverdrivesToClose(float distance, float degreesOff, float lockValue, bool overdrives)
{
var s = lockValue > 0 ? Build(distance, (lockValue, true)) : Build(distance);
HoldLocks(s);
s.Store(s.DecoyDesign);
s.Store(s.VentDesign);
s.Store(s.OverdriveDesign);
Face(s, degreesOff);
Settle(s);
Assert.Equal(overdrives, s.Active(s.OverdriveDesign));
Assert.Equal(overdrives, s.Agent.ThrottleLocked);
Assert.False(s.Active(s.VentDesign));
Assert.Equal(overdrives ? 2 : 3, s.Carried);
}
// With nothing to use, or nothing of the right kind, the same situations change nothing and do not throw.
[Theory]
[InlineData(false)]
[InlineData(true)]
public void AnAgentWithoutConsumablesDoesNothing(bool carriesOnlyADecoy)
{
var s = Build(1300, (.6f, true));
HoldLocks(s);
if (carriesOnlyADecoy) s.Store(s.DecoyDesign);
Face(s, 0);
var thrown = Record.Exception(() =>
{
Settle(s);
Settle(s);
});
Assert.Null(thrown);
Assert.False(s.Active(s.VentDesign));
Assert.False(s.Active(s.OverdriveDesign));
Assert.False(s.Active(s.DecoyDesign));
Assert.False(s.Agent.ThrottleLocked);
Assert.Equal(carriesOnlyADecoy ? 1 : 0, s.Carried);
}
}