Aetheria/tests/Aetheria.Shared.Tests/FireControlCut11Tests.cs
Meta Vens fbe05c7992 Merge cuts loot-1-present and loot-2 (Soul verdicts cut-loot-1-present.s1, cut-loot-2.s2)
Conflicts: Behaviors.cs keeps Union(48, PickupData) and Union(49, LaunchSuppressorData);
Zone.cs keeps the departure record on _entities with master's Visibility (emissions-heard);
ContactRevealTests.cs keeps master's removal of the emission-departure test.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-13 08:27:47 +02:00

422 lines
21 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.Collections.Generic;
using System.IO;
using System.Linq;
using CultMath;
using GameCult.Caching;
using UniRx;
using Xunit;
using static CultMath.math;
using float2 = CultMath.float2;
using float3 = CultMath.float3;
using int2 = CultMath.int2;
// Cut 11 (docs/fire-control-cut.md): behavioural tests for what a Stryker run against FireControl.cs found
// undefended. Every test here states a rule as behaviour, observed through the public surface, and is the
// test whose absence let a real mutant survive. Builds its own fixtures, the convention every cut's test
// file follows.
public sealed class FireControlCut11Tests : IDisposable
{
private readonly string _root = Path.Combine(Path.GetTempPath(), "aetheria-firecontrolcut11-" + Guid.NewGuid().ToString("N"));
private string Catalog => Path.Combine(_root, "Aetheria.cc");
public FireControlCut11Tests() => Directory.CreateDirectory(_root);
private readonly List<CultCache> _openCaches = new List<CultCache>();
public void Dispose()
{
foreach (var c in _openCaches) c.Dispose();
Directory.Delete(_root, true);
}
private static GameplaySettings TestSettings(float schematicCellSize = 1f) => 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,
TargetGearInfoThreshold = .8f,
FiringArc = 170,
CommitHorizon = .5f,
SchematicCellSize = schematicCellSize,
UnaidedAccuracy = .05f,
UnaidedTracking = 10f,
UnaidedPrecision = 2f,
AgentMinHitProbability = 0f,
BeamResolveInterval = .1f
};
private static PerformanceStat Constant(float v) => new PerformanceStat { Min = v, Max = v };
private static Shape SolidShape(int w, int h)
{
var shape = new Shape(w, h);
foreach (var cell in shape.AllCoordinates) shape[cell] = true;
return shape;
}
private sealed class Engagement
{
public Zone Zone;
public Ship Shooter;
public Ship Target;
public EquippedItem WeaponItem;
public InstantWeapon Weapon;
public EquippedItem PortMarker;
public EquippedItem StarboardMarker;
}
// A shooter and a target. The shooter sits OFF the origin on purpose: a shooter at float3.zero makes
// `target - source` and `target + source` the same vector, which is exactly how four position mutants in
// FireControl.cs survived every earlier suite.
private Engagement Build(
string zoneName = "Cut11",
float damage = 5, float velocity = 0, float accuracy = 1,
bool equipShield = false, float shieldCapacity = 30,
bool markers = false, float spread = 0, float schematicCellSize = 1f)
{
// 5 wide x 3 tall: schematic x runs port (0) to starboard (4). Tool gear may only occupy interior
// cells, which on this hull is the middle row x = 1..3, so the markers sit at (1, 1) and (3, 1) --
// one cell either side of the centre of mass (2, 1).
var hullData = new HullData
{
Name = "Hull", HullType = HullType.Ship, Shape = SolidShape(5, 3), Durability = 100000, Mass = 1000,
Hardpoints = { new HardpointData { Type = HardpointType.Sensors, Position = new int2(2, 2), Shape = new Shape() } }
};
var cache = AetheriaStores.Open(Catalog + Guid.NewGuid().ToString("N"), catalogWritable: true);
_openCaches.Add(cache);
cache.Upsert(new VerseGrammar { Revision = 1 });
cache.Upsert(hullData);
cache.Upsert(new GearData
{
Name = "Gun", Hardpoint = HardpointType.Sensors, Shape = new Shape(), Durability = 1,
MinimumTemperature = -1000, MaximumTemperature = 1000, OptimalTemperature = 0, PlateauWidth = 2000,
Behaviors = { new InstantWeaponData
{
Damage = Constant(damage), Range = Constant(1000), MinRange = Constant(0),
Velocity = Constant(velocity), Spread = Constant(spread), DamageSpread = Constant(0),
Penetration = Constant(0), Count = Constant(1), BurstTime = Constant(0), Cooldown = Constant(1000),
DamageCurve = new BezierCurve { Keys = new[] { float4(0, 1, 0, 0), float4(1, 1, 0, 0) } }
} }
});
cache.Upsert(new GearData
{
Name = "Targeting", Hardpoint = HardpointType.Tool, Shape = new Shape(), Durability = 1,
MinimumTemperature = -1000, MaximumTemperature = 1000, OptimalTemperature = 0, PlateauWidth = 2000,
Behaviors = { new TargetingSystemData
{
Accuracy = Constant(accuracy), Resolution = Constant(1000), Precision = Constant(1), Tracking = Constant(100000)
} }
});
cache.Upsert(new GearData
{
Name = "Reactor", Hardpoint = HardpointType.Tool, Shape = new Shape(), Durability = 10,
MinimumTemperature = -1000, MaximumTemperature = 1000, OptimalTemperature = 0, PlateauWidth = 2000,
Behaviors = { new ReactorData { Charge = Constant(1000), Efficiency = Constant(1), OverloadEfficiency = Constant(1), ThrottlingFactor = Constant(2) } }
});
cache.Upsert(new GearData
{
Name = "Shield", Hardpoint = HardpointType.Tool, Shape = new Shape(), Durability = 10,
MinimumTemperature = -1000, MaximumTemperature = 1000, OptimalTemperature = 0, PlateauWidth = 2000,
Behaviors = { new ShieldData { Efficiency = Constant(1), EnergyUsage = Constant(1), Capacity = Constant(shieldCapacity), RefillDuration = Constant(.01f), RestoreDuration = Constant(.01f) } }
});
cache.Upsert(new GearData
{
Name = "Marker", Hardpoint = HardpointType.Tool, Shape = new Shape(), Durability = 100,
MinimumTemperature = -1000, MaximumTemperature = 1000, OptimalTemperature = 0, PlateauWidth = 2000
});
cache.FlushAsync().Wait();
var ledger = new ProvenanceLedger();
for (var i = 1; i <= 16; i++) ledger.Lots[i] = new Lot { Origin = new Attributed(), Quality = 1 };
var items = new ItemManager(cache, ledger, TestSettings(schematicCellSize), _ => { });
var zone = new Zone(items, new PlanetSettings(), new ZonePack(), new GalaxyZone { Name = zoneName, Owner = null }, null);
EquippableItem Make(string name, int lot, float durability) => new EquippableItem
{
Data = items.ItemData.RefOf<ItemData>(items.ItemData.GetByName<GearData>(name)), Durability = durability, Lot = lot
};
var hullRef = items.ItemData.RefOf<ItemData>(items.ItemData.GetByName<HullData>("Hull"));
var shooter = new Ship(items, zone, new EquippableItem { Data = hullRef, Durability = 100000, Lot = 1 }, new EntitySettings());
var gun = Make("Gun", 2, 1);
Assert.True(shooter.TryEquip(gun, new int2(2, 2)));
var weaponItem = shooter.Equipment.Single(x => x.EquippableItem == gun);
var targeting = Make("Targeting", 3, 1);
Assert.True(shooter.TryFindSpace(targeting, out var tpos));
Assert.True(shooter.TryEquip(targeting, tpos));
var target = new Ship(items, zone, new EquippableItem { Data = hullRef, Durability = 100000, Lot = 4 }, new EntitySettings());
Assert.True(target.TryEquip(Make("Gun", 5, 1), new int2(2, 2)));
EquippedItem port = null, starboard = null;
if (markers)
{
var p = Make("Marker", 6, 100);
var s = Make("Marker", 7, 100);
Assert.True(target.TryEquip(p, new int2(1, 1)), "port marker must fit an interior cell");
Assert.True(target.TryEquip(s, new int2(3, 1)), "starboard marker must fit an interior cell");
port = target.Equipment.Single(x => x.EquippableItem == p);
starboard = target.Equipment.Single(x => x.EquippableItem == s);
}
if (equipShield)
{
var reactor = Make("Reactor", 8, 10);
Assert.True(target.TryFindSpace(reactor, out var rpos));
Assert.True(target.TryEquip(reactor, rpos));
var shield = Make("Shield", 9, 10);
Assert.True(target.TryFindSpace(shield, out var spos));
Assert.True(target.TryEquip(shield, spos));
}
zone.Admit(shooter, piloted: false);
zone.Admit(target, piloted: false);
shooter.Position = float3(37, 0, -11);
target.Position = float3(37, 0, 89); // 100 units straight ahead of the shooter
shooter.SetTarget(target);
shooter.EntityInfoGathered[target] = 1f;
shooter.SetIff(target, true);
if (equipShield) target.Shield.Item.Enabled.Value = true;
zone.Update(0f);
return new Engagement
{
Zone = zone, Shooter = shooter, Target = target, WeaponItem = weaponItem,
Weapon = (InstantWeapon) weaponItem.Behaviors.Single(b => b is Weapon),
PortMarker = port, StarboardMarker = starboard
};
}
private static void Charge(Engagement e)
{
for (var i = 0; i < 20; i++) e.Zone.Update(.1f);
}
// ---- The shield pays for what it absorbs. ----
// Deleting Shield.TakeHit from FireControl.Apply survived every earlier suite, because they asserted only
// that an absorbed hit left the hull untouched -- never that the shield paid for it. A shield that absorbs
// must eventually run dry. Stepped with dt = 0 so the (near-instant) refill cannot top the reserve back up
// between shots. Mutation: delete TakeHit from the discrete path; the reserve never drains.
[Fact]
public void DiscreteShieldAbsorptionDrainsTheReserve()
{
var e = Build(damage: 5, equipShield: true, shieldCapacity: 30);
Charge(e);
Assert.True(e.Target.Shield.CanTakeHit(DamageType.Kinetic, 5));
var hull = e.Target.Hull.Durability;
var shots = 0;
while (e.Target.Shield.CanTakeHit(DamageType.Kinetic, 5) && shots < 200)
{
FireControl.Fire(e.Weapon, e.WeaponItem, e.Shooter);
e.Zone.Update(0f);
shots++;
}
Assert.False(e.Target.Shield.CanTakeHit(DamageType.Kinetic, 5)); // the reserve ran dry
Assert.False(e.Target.Shield.Broken); // by paying, not by breaking
Assert.Equal(hull, e.Target.Hull.Durability); // and every hit was absorbed
}
// The same rule on a blast's path (12.4(b) rewrites this to Detonate, docs/fire-control-cut.md
// "ShieldPaysForWhatReachesIt": "the reserve drops by the entity's covered share, not by the whole
// blast"), whose absorb branch had no test coverage at all. The radius sits well inside the hull's own
// solid interior (the 5x3 shape's centre of mass, at least 1 cell from every edge) so the disc never spills
// off the hull -- the covered share equals the whole damage exactly, the same amount Splash always charged,
// so this expectation is not loosened. Mutation: delete TakeHit from Detonate's absorb branch.
[Fact]
public void ABlastShieldAbsorptionDrainsTheReserve()
{
var e = Build(equipShield: true, shieldCapacity: 30);
Charge(e);
Assert.True(e.Target.Shield.CanTakeHit(DamageType.Kinetic, 5));
var hull = e.Target.Hull.Durability;
var blasts = 0;
while (e.Target.Shield.CanTakeHit(DamageType.Kinetic, 5) && blasts < 200)
{
FireControl.Detonate(e.Zone, e.Target.Position.xz, radius: 1, damage: 5, damageType: DamageType.Kinetic);
blasts++;
}
Assert.False(e.Target.Shield.CanTakeHit(DamageType.Kinetic, 5));
Assert.False(e.Target.Shield.Broken);
Assert.Equal(hull, e.Target.Hull.Durability);
}
// ---- A shot whose target is gone resolves as a miss (Soul C4). ----
// The 0b table says a shot whose target has left the zone resolves as a miss "whichever stage it is at."
// Step only rewrote the outcome on the uncommitted branch, so a hit committed inside the horizon and then
// orphaned by the target dying was republished as a hit on ShotResolved -- a hit marker on a corpse. The
// committed outcome is not mutated (R4 keeps it immutable); the resolution is published as its own miss.
[Fact]
public void ShotAtTargetThatLeavesResolvesAsMiss()
{
var e = Build(velocity: 20); // 100 units at 20 u/s: flight 5 s, commits at 4.5 s
ShotOutcome committed = null, resolved = null;
var commits = 0;
using var c = e.Zone.ShotCommitted.Subscribe(o => { committed = o; commits++; });
using var r = e.Zone.ShotResolved.Subscribe(o => resolved = o);
FireControl.Fire(e.Weapon, e.WeaponItem, e.Shooter);
e.Zone.Update(4.7f); // past the commit, short of arrival
Assert.NotNull(committed);
Assert.True(committed.Hit, "precondition: this fixture's shot must commit as a hit");
Assert.Null(resolved);
// What the HUD's arrival countdown reads: time left in flight at commit, 5 s - 4.7 s.
Assert.Equal(.3f, committed.ArrivalIn, 3);
e.Zone.Remove(e.Target); // the target is gone before the shot arrives
e.Zone.Update(1f);
Assert.NotNull(resolved);
Assert.Equal(committed.ShotId, resolved.ShotId);
Assert.False(resolved.Hit, "a shot whose target left the zone must resolve as a miss, not publish its committed hit");
Assert.True(committed.Hit, "the committed outcome itself is not rewritten (R4)");
Assert.Equal(1, commits); // a shot commits once; its orphaned resolution is not a second commit
}
// The other stage of the same rule: a target gone before the shot even commits. The shot never got its
// commit, so the miss is published as the commit too -- a listener waiting on ShotCommitted for this
// ShotId must not wait forever -- and it is a plain miss, with no shield hit or shield break attached.
[Fact]
public void ShotAtTargetThatLeavesBeforeCommitCommitsAsAMiss()
{
var e = Build(velocity: 20, equipShield: true); // commits at 4.5 s
Charge(e);
var committed = new List<ShotOutcome>();
var resolved = new List<ShotOutcome>();
using var c = e.Zone.ShotCommitted.Subscribe(committed.Add);
using var r = e.Zone.ShotResolved.Subscribe(resolved.Add);
var shotId = FireControl.Fire(e.Weapon, e.WeaponItem, e.Shooter);
e.Zone.Update(1f);
SafeAssert.Empty(committed);
e.Zone.Remove(e.Target);
e.Zone.Update(1f);
foreach (var outcome in new[] { SafeAssert.Only(committed), SafeAssert.Only(resolved) })
{
Assert.Equal(shotId, outcome.ShotId);
Assert.False(outcome.Hit);
Assert.False(outcome.Shielded, "a shot at nothing did not hit a shield");
Assert.False(outcome.ShieldBroken, "a shot at nothing did not break a shield");
}
SafeAssert.NoShots(e.Zone);
}
// ---- A targeting system's Tracking is what forgives a target that moves off its predicted line. ----
// Tracking fell back to GameplaySettings.UnaidedTracking under a mutant that ignored the fitted system,
// and nothing noticed: every fixture's target sat still, so deviation was 0 and Tracking divided nothing.
// Here the target jinks 5 units sideways mid-flight. The fitted system (Tracking 100000) shrugs it off;
// unaided Tracking (10) would halve the shot's chance.
[Fact]
public void AFittedTargetingSystemForgivesAJink()
{
var e = Build(velocity: 20);
FireControl.Fire(e.Weapon, e.WeaponItem, e.Shooter);
e.Zone.Update(1f);
e.Target.Position += float3(5, 0, 0);
var shot = e.Zone.PendingShots.Single();
var p = FireControl.DeviationProbability(shot, e.Zone.Time, out var deviation);
Assert.Equal(5f, deviation, 3);
Assert.True(p > .99f, $"a 5-unit jink against Tracking 100000 must barely matter, got {p}");
}
// ---- A beam's shot carries the interval's damage, not the weapon's. ----
// ConstantWeapon fires one shot per BeamResolveInterval for Damage * interval, through Fire's
// damageOverride. Dropping the override froze the weapon's full Damage into every one of those shots --
// a beam doing ten times its damage at the default interval -- and survived, because the beam test
// counted its rolls and multiplied the count by the expected damage itself.
[Fact]
public void ADamageOverrideIsWhatTheShotCarries()
{
var e = Build(damage: 5, velocity: 20);
var full = FireControl.Fire(e.Weapon, e.WeaponItem, e.Shooter);
var partial = FireControl.Fire(e.Weapon, e.WeaponItem, e.Shooter, damageOverride: .5f);
Assert.Equal(5f, e.Zone.PendingShots.Single(s => s.ShotId == full).Damage);
Assert.Equal(.5f, e.Zone.PendingShots.Single(s => s.ShotId == partial).Damage);
}
// ---- Splash damages the side the blast came from. ----
//
// Superseded (docs/fire-control-cut.md, "Symmetry and frame"): ABlastDamagesTheCellsNearestIt in
// FireControlCut124Tests.cs succeeds this test and Cut4Tests.SplashIsDirectional -- a blast is now an area
// (12.4(b)), not a directional half-hull split, so "the side the blast came from" is pinned by which cells
// the disc actually covers, not by a facing-rotated half-shape.
// ---- pSpread is the share of the spread cone the target's silhouette covers. ----
// Every earlier fixture used zero spread (pSpread is then 1 regardless) and a cell size of exactly 1 (so
// multiplying and dividing by it agree), which left the whole spread term untested. This pins what the
// number MEANS rather than how it is computed: a silhouette that exactly fills the cone scores 1, one half
// the cone's angular size scores .5, and one bigger than the cone saturates at 1. Here the 5-wide hull at a
// cell size of 2 has a half-extent of 5 world units, against a 10-degree cone (5-degree half-angle).
[Fact]
public void PSpreadIsTheSilhouettesShareOfTheSpreadCone()
{
const float halfExtent = 5f, halfCone = 5f;
float At(float range)
{
var e = Build(spread: 2 * halfCone, schematicCellSize: 2f);
e.Target.Position = e.Shooter.Position + float3(0, 0, range);
return FireControl.Inspect(e.Weapon, e.Shooter, e.Target).PSpread;
}
var fills = halfExtent / (float) Math.Tan(halfCone * Math.PI / 180); // silhouette == cone
var half = halfExtent / (float) Math.Tan(halfCone / 2 * Math.PI / 180); // silhouette == half the cone
Assert.Equal(1f, At(fills), 2);
Assert.Equal(.5f, At(half), 2);
Assert.Equal(1f, At(fills / 3), 2); // bigger than the cone: saturates, never exceeds 1
Assert.True(At(half * 2) < At(half)); // and falls away with range
}
// ---- Zones roll differently. ----
// The roll is a function of (zone, shot id). Replacing `CombatSeed *` with `/` in Commit's seed drops the
// zone out of it -- integer division by a large constant is zero for almost every seed -- so every zone
// rolls the same sequence. That survived, because the die test only checks uniformity within one zone.
// Same shot ids in two zones must not produce the same hit sequence.
[Fact]
public void DifferentZonesRollDifferently()
{
bool[] Sequence(string zoneName)
{
var e = Build(zoneName: zoneName, accuracy: .5f);
var p = FireControl.HitProbability(e.Weapon, e.Shooter, e.Target); // accuracy times the other factors
var hits = new List<bool>();
using var r = e.Zone.ShotResolved.Subscribe(o => hits.Add(o.Hit));
for (var i = 0; i < 400; i++)
{
FireControl.Fire(e.Weapon, e.WeaponItem, e.Shooter);
e.Zone.Update(0f);
}
Assert.Equal(400, hits.Count);
var fraction = hits.Count(h => h) / 400f;
Assert.True(Math.Abs(fraction - p) < .08f, $"zone {zoneName}: hit fraction {fraction:F3} is not near p = {p:F3}");
return hits.ToArray();
}
var a = Sequence("Rhea");
var b = Sequence("Mimas");
var differ = a.Zip(b, (x, y) => x != y).Count(d => d) / 400f;
// Two independent rolls at p ~ .4 disagree 2p(1-p) ~ 48% of the time; identical seeds disagree never.
Assert.True(differ > .3f, $"only {differ:P1} of shots differ between zones -- the zone is not reaching the roll");
}
}