Aetheria/tests/Aetheria.Shared.Tests/FireControlCut11Tests.cs
Meta Vens eebf6f375a Visibility 5: an item can be aimed at when the observer's book holds it and it is still fitted and whole
IsRevealed is now the observer's book (Entity.HasReveal) plus the live filter
(still on its ship, not IsDestroyed). The tier ladder and
GameplaySettings.TargetArmorInfoThreshold are deleted, with the 19 fixture
initializers and the Settings.asset line. Fixtures that aim at an item record
its reveal through RecordReveal; Aetheria.Shared exposes internals to
Aetheria.Shared.Tests for that. Unbuilt and untested at this commit.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-13 04:15:35 +02:00

424 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.Entities.Add(shooter);
zone.Entities.Add(target);
shooter.Activate();
target.Activate();
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.Entities.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.Entities.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");
}
}