Aetheria/tests/Aetheria.Shared.Tests/FireAuthorityTests.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

544 lines
31 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 float3 = CultMath.float3;
using int2 = CultMath.int2;
using Random = CultMath.Random;
// Cut 3 (docs/fire-control-cut.md): pins hit authority itself -- the roll's inputs, the commit horizon, frozen
// payloads (Q6), seeding (Q5), the arc gate on player fire (Q2), and the damage rule moved verbatim onto
// Entity. Builds its own shooter/target fixture rather than reusing Cut 1's or Cut 2's private helpers, the
// same convention those files established. Each test's own comment names the mutation that must kill it.
public sealed class FireAuthorityTests : IDisposable
{
private readonly string _root = Path.Combine(Path.GetTempPath(), "aetheria-fireauthority-" + Guid.NewGuid().ToString("N"));
private string Catalog => Path.Combine(_root, "Aetheria.cc");
public FireAuthorityTests() => 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() => 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,
FiringArc = 170,
CommitHorizon = .5f,
SchematicCellSize = 1f,
UnaidedAccuracy = .05f,
// Cut 6d (docs/fire-control-cut.md): high enough that pOnHull saturates to 1 at this fixture's 5x5
// hull's own centre of mass -- these tests pin the Accuracy cap and hit/miss timing, not the dart-
// throw kernel, so the unaided floor here is authored to stay out of their way, the same reason
// Spread is authored 0 in most of these fixtures to keep pSpread pinned at 1.
UnaidedPrecision = 2f,
AgentMinHitProbability = 0f
};
private static PerformanceStat Constant(float v) => new PerformanceStat { Min = v, Max = v };
private sealed class Engagement
{
public ItemManager Items;
public Zone Zone;
public Ship Shooter;
public Ship Target;
public EquippedItem WeaponItem;
public InstantWeapon Weapon;
}
// A shooter (one Sensors-hardpoint weapon + a Tool-slot targeting system) and a target ship, both
// activated in the same zone, the target `targetRange` metres dead ahead (well within the wide default
// arc), fully detected and marked hostile so StanceAllowsFire and the reveal/detection gates never
// interfere with a test that isn't about them.
private Engagement Build(
GameplaySettings settings,
float damage = 10, float range = 1000, float minRange = 0, float velocity = 0,
float spread = 0, float damageSpread = 0, float penetration = 0,
// Cut 6d (docs/fire-control-cut.md): Precision now feeds HitProbability's pOnHull for every shot, aimed
// or not (the dart-throw kernel's sigma), where it used to matter only for the coin-flip aimed-item
// branch this cut deletes. The old default of 0 -- harmless when Precision only gated that branch --
// would now flatten pOnHull to nearly nothing against this fixture's 5x5 hull (sigma = 1/Precision
// balloons), starving every test in this file that doesn't care about aim placement. 1 keeps pOnHull
// close to its ceiling for an unaimed shot at this hull's centre of mass without pinning it exactly.
float accuracy = 1, float resolution = 1, float precision = 1, float tracking = 1000,
float targetRange = 100, bool equipTargeting = true, float hullDurability = 1000, float armor = 0,
Action<ItemManager, Ship, Ship> beforeActivate = null)
{
// 5x5 so InteriorCells (Shape.Shrink -- every cell with all 8 neighbours present) is the inner 3x3,
// room enough for more than one Tool item (ShieldTakesHit needs a Reactor and a Shield both).
var hullShape = new Shape(5, 5);
foreach (var cell in hullShape.AllCoordinates) hullShape[cell] = true;
var hullData = new HullData
{
Name = "Hull", HullType = HullType.Ship, Shape = hullShape, Durability = hullDurability, Mass = 1000, Armor = armor,
Hardpoints = { new HardpointData { Type = HardpointType.Sensors, Position = new int2(0, 0), 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(range), MinRange = Constant(minRange),
Velocity = Constant(velocity), Spread = Constant(spread), DamageSpread = Constant(damageSpread),
Penetration = Constant(penetration), 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(resolution), Precision = Constant(precision), Tracking = Constant(tracking)
} }
});
// Authored for every engagement (harmless unless equipped): a shield/reactor pair strong and fast
// enough to fill from cold in a handful of ticks, for ShieldTakesHit. Catalog entries are looked up by
// name through this same already-open CultCache handle, which does not see writes made to it after
// this point without a reopen (GetByName is a build-time index) -- so these are authored up front
// rather than upserted later from inside a test's beforeActivate.
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(1000), RefillDuration = Constant(.01f), RestoreDuration = Constant(.01f) } }
});
cache.FlushAsync().Wait();
var ledger = new ProvenanceLedger();
ledger.Lots[1] = new Lot { Origin = new Attributed(), Quality = 1 };
ledger.Lots[2] = new Lot { Origin = new Attributed(), Quality = 1 };
ledger.Lots[3] = new Lot { Origin = new Attributed(), Quality = 1 };
ledger.Lots[4] = new Lot { Origin = new Attributed(), Quality = 1 };
for (var i = 5; i <= 12; i++) ledger.Lots[i] = new Lot { Origin = new Attributed(), Quality = 1 };
var items = new ItemManager(cache, ledger, settings, _ => { });
var zone = new Zone(items, new PlanetSettings(), new ZonePack(), new GalaxyZone { Name = "Test", Owner = null }, null);
var hullRef = items.ItemData.RefOf<ItemData>(items.ItemData.GetByName<HullData>("Hull"));
var shooterHull = new EquippableItem { Data = hullRef, Durability = hullDurability, Lot = 1 };
var shooter = new Ship(items, zone, shooterHull, new EntitySettings());
var gunRef = items.ItemData.RefOf<ItemData>(items.ItemData.GetByName<GearData>("Gun"));
var gun = new EquippableItem { Data = gunRef, Durability = 1, Lot = 2 };
Assert.True(shooter.TryEquip(gun, new int2(0, 0)));
var weaponItem = shooter.Equipment.Single(e => e.EquippableItem == gun);
var weapon = (InstantWeapon) weaponItem.Behaviors.Single(b => b is Weapon);
if (equipTargeting)
{
var targetingRef = items.ItemData.RefOf<ItemData>(items.ItemData.GetByName<GearData>("Targeting"));
var targeting = new EquippableItem { Data = targetingRef, Durability = 1, Lot = 3 };
Assert.True(shooter.TryFindSpace(targeting, out var tpos));
Assert.True(shooter.TryEquip(targeting, tpos));
}
var targetHull = new EquippableItem { Data = hullRef, Durability = hullDurability, Lot = 4 };
var target = new Ship(items, zone, targetHull, new EntitySettings());
// Entity._orderedEquipment is populated only by TryEquip -- a ship that never equips anything leaves
// it null and Entity.Update throws regardless of this cut (FireControlTests' own fixture notes the
// same rake). Equip a harmless gun so the target is an ordinarily-constructed ship.
var targetGunRef = items.ItemData.RefOf<ItemData>(items.ItemData.GetByName<GearData>("Gun"));
var targetGun = new EquippableItem { Data = targetGunRef, Durability = 1, Lot = 9 };
Assert.True(target.TryEquip(targetGun, new int2(0, 0)));
// Entity.TryEquip refuses once an entity is active ("don't allow equipping while deployed") -- any
// extra gear a test needs (a second aimable item, a shield, a reactor) must go on before Activate().
beforeActivate?.Invoke(items, shooter, target);
zone.Entities.Add(shooter);
zone.Entities.Add(target);
shooter.Activate();
target.Activate();
shooter.Position = float3.zero;
target.Position = float3(0, 0, targetRange);
shooter.SetTarget(target);
shooter.EntityInfoGathered[target] = 1f;
shooter.SetIff(target, true);
// A zero-dt warm-up tick populates the resolved weapon/targeting stats (UpdateStats runs inside
// Execute) before FireControl.Fire reads them, the same warm-up InputCapacitorTests uses.
zone.Update(0f);
return new Engagement { Items = items, Zone = zone, Shooter = shooter, Target = target, WeaponItem = weaponItem, Weapon = weapon };
}
private static InstantWeaponData DataOf(EquippedItem weaponItem) =>
(InstantWeaponData) weaponItem.Data.Behaviors.Single(b => b is InstantWeaponData);
// R1/Q5: two zones built identically, seeded identically, fire the same 50 shots (instant, so each
// commits and resolves in the tick it is stepped) and get byte-identical Hit/Cell/Shielded sequences.
// Mutation: FireControl draws from `new Random()` instead of the shooter's ItemManager.Random.
[Fact]
public void RollsAreSeeded()
{
List<(bool Hit, bool Shielded, int2 Cell)> Run(uint seed)
{
var e = Build(TestSettings(), accuracy: .6f, precision: .5f);
e.Items.Random = new Random(seed);
var results = new List<(bool, bool, int2)>();
e.Zone.ShotResolved.Subscribe(outcome => results.Add((outcome.Hit, outcome.Shielded, outcome.Cell)));
for (var i = 0; i < 50; i++)
{
FireControl.Fire(e.Weapon, e.WeaponItem, e.Shooter);
FireControl.Step(e.Zone, 0f);
}
return results;
}
var a = Run(777u);
var b = Run(777u);
Assert.Equal(a, b);
}
// FireControl.HitProbability, R1: zero with no draw when undetected or out of range; rises with info
// toward Resolution; falls with Spread; caps at UnaidedAccuracy with no targeting system equipped.
// Mutations: drop pSensor (probability stops depending on info); drop the range gate (probability nonzero
// outside [MinRange,Range]); drop the UnaidedAccuracy cap (an unaided shooter rolls as well as Accuracy).
[Fact]
public void ProbabilityFollowsInputs()
{
var e = Build(TestSettings(), accuracy: .8f, resolution: 1f, spread: 0f, targetRange: 100, range: 200);
// Undetected: below TargetDetectionInfoThreshold (.1) -- zero regardless of everything else.
e.Shooter.EntityInfoGathered[e.Target] = 0f;
Assert.Equal(0f, FireControl.HitProbability(e.Weapon, e.Shooter, e.Target));
// Rising with info toward Resolution (1): more info, more probability, saturating at Resolution.
e.Shooter.EntityInfoGathered[e.Target] = .2f;
var low = FireControl.HitProbability(e.Weapon, e.Shooter, e.Target);
e.Shooter.EntityInfoGathered[e.Target] = .6f;
var mid = FireControl.HitProbability(e.Weapon, e.Shooter, e.Target);
e.Shooter.EntityInfoGathered[e.Target] = 1f;
var full = FireControl.HitProbability(e.Weapon, e.Shooter, e.Target);
Assert.True(low < mid);
Assert.True(mid <= full);
Assert.True(full > 0f);
// Out of range: zero.
e.Target.Position = float3(0, 0, 500);
Assert.Equal(0f, FireControl.HitProbability(e.Weapon, e.Shooter, e.Target));
e.Target.Position = float3(0, 0, 100);
// Falling with Spread: a wide-spread weapon on a small target scores lower than a zero-spread one.
DataOf(e.WeaponItem).Spread = Constant(0f);
e.Weapon.Execute(0f);
var noSpread = FireControl.HitProbability(e.Weapon, e.Shooter, e.Target);
DataOf(e.WeaponItem).Spread = Constant(60f);
e.Weapon.Execute(0f);
var wideSpread = FireControl.HitProbability(e.Weapon, e.Shooter, e.Target);
Assert.True(wideSpread < noSpread);
// Capped at UnaidedAccuracy with no targeting system: a second engagement, otherwise identical, but
// never equips one.
var unaided = Build(TestSettings(), accuracy: .8f, resolution: 1f, spread: 0f, targetRange: 100, range: 200, equipTargeting: false);
unaided.Shooter.EntityInfoGathered[unaided.Target] = 1f;
Assert.Equal(unaided.Items.GameplaySettings.UnaidedAccuracy, FireControl.HitProbability(unaided.Weapon, unaided.Shooter, unaided.Target), 3);
}
// Cut 7 (docs/fire-control-cut.md): this was OutOfArcConsumesNoDraw, which asserted that a shot gated to
// zero left ItemManager.Random untouched. Cut 6b (6.1) moved the roll onto a per-shot local generator, so
// Commit stopped touching that stream on EVERY path and the old test passed for a reason that had nothing
// to do with arcs -- it would have passed just as well with the target dead ahead, and no mutation of the
// short-circuit it named could ever turn it red. Rewritten to pin the rule that is actually live: no
// combat path draws from the shared stream, in arc or out. Mutation: restore the shared-stream read in
// Commit; the in-arc case then goes red.
[Theory]
[InlineData(100f, 0f)] // directly abeam: gated to zero, outside the default arc
[InlineData(0f, 100f)] // dead ahead: a real firing solution that actually rolls
public void CombatNeverDrawsFromTheSharedStream(float x, float z)
{
var e = Build(TestSettings(), accuracy: 1, resolution: 1, spread: 0);
e.Target.Position = float3(x, 0, z);
const uint seed = 4242u;
var untouched = new Random(seed);
var expected = untouched.NextFloat();
e.Items.Random = new Random(seed);
FireControl.Fire(e.Weapon, e.WeaponItem, e.Shooter);
FireControl.Step(e.Zone, 0f);
var actual = e.Items.Random.NextFloat();
Assert.Equal(expected, actual);
}
// R3: no durability change before the shot arrives (range/Velocity after this cut's fire), and a target
// removed mid-flight takes none. Mutation: apply the outcome at fire/commit time instead of arrival.
[Fact]
public void ShotResolvesOnArrival()
{
var e = Build(TestSettings(), damage: 100, velocity: 10, accuracy: 1, resolution: 1, spread: 0, targetRange: 50);
// range 50, velocity 10 -> flight time 5s, commit at 4.5s (.5s horizon), arrival at 5s.
FireControl.Fire(e.Weapon, e.WeaponItem, e.Shooter);
var before = e.Target.Hull.Durability;
e.Zone.Update(4f); // still short of commit (4.5s)
Assert.Equal(before, e.Target.Hull.Durability);
// Cut 7 (docs/fire-control-cut.md, 7.2): an intermediate check between commit and arrival -- without
// it, jumping straight from before-commit to past-arrival lets "apply as soon as committed, not only
// once also arrived" (Step's arrival gate collapsed to just `if (shot.Committed)`) go unnoticed, since
// a single wide Zone.Update spanning both CommitTime and ArrivalTime crosses both thresholds in one
// call either way.
e.Zone.Update(.55f); // t=4.55: past commit (4.5s), still short of arrival (5s)
Assert.Equal(before, e.Target.Hull.Durability);
e.Zone.Update(1f); // now past arrival
Assert.True(e.Target.Hull.Durability < before);
}
// 0b table: a shot whose target left the zone resolves as a miss and is removed, taking nothing.
[Fact]
public void DeadEntityStopsTakingShots()
{
var e = Build(TestSettings(), damage: 100, velocity: 10, accuracy: 1, resolution: 1, spread: 0, targetRange: 50);
FireControl.Fire(e.Weapon, e.WeaponItem, e.Shooter);
var before = e.Target.Hull.Durability;
e.Zone.Entities.Remove(e.Target);
e.Zone.Update(10f); // well past arrival
Assert.Equal(before, e.Target.Hull.Durability);
SafeAssert.NoShots(e.Zone);
}
// R4: ShotCommitted fires exactly CommitHorizon before ShotResolved, once per shot. Mutation: publish only
// at arrival, or publish twice.
[Fact]
public void OutcomeCommitsBeforeImpact()
{
var e = Build(TestSettings(), velocity: 10, accuracy: 1, resolution: 1, spread: 0, targetRange: 50);
var commits = new List<float>();
var resolves = new List<float>();
e.Zone.ShotCommitted.Subscribe(_ => commits.Add(e.Zone.Time));
e.Zone.ShotResolved.Subscribe(_ => resolves.Add(e.Zone.Time));
FireControl.Fire(e.Weapon, e.WeaponItem, e.Shooter);
for (var i = 0; i < 100; i++) e.Zone.Update(.1f); // 10s of ticks, well past a 5s flight
Assert.Single(commits);
Assert.Single(resolves);
Assert.Equal(e.Items.GameplaySettings.CommitHorizon, resolves[0] - commits[0], 2);
}
// R4's second half: deviation counts up to the commit horizon and not after. A target that jinks off the
// predicted straight-line path before commit is judged on that jink; the identical jink after commit
// changes nothing, because the outcome is already frozen. Mutation: measure deviation at arrival instead
// of commit (both jinks would then matter), or measure it at fire (neither would).
[Fact]
public void EvasionCountsUntilCommitAndNotAfter()
{
// Tracking small enough that a real jink defeats it, Precision moot (no aim), full accuracy/detection.
GameplaySettings Settings() => TestSettings();
bool RunWithJink(float jinkTime, float commitHorizon)
{
var settings = Settings();
settings.CommitHorizon = commitHorizon;
var e = Build(settings, damage: 10, velocity: 10, accuracy: 1, resolution: 1, spread: 0, tracking: 1f, targetRange: 50);
// Cut 5 (Soul's own named survivor): a stationary target left the map's declared mutation
// (elapsed = shot.FlightTime in place of now - shot.FireTime) invisible, because
// FireTargetVelocity * elapsed is identically zero either way. A real, nonzero velocity -- Ship.
// Update already integrates Position.xz += Velocity * delta every tick on its own, no manual
// tracking needed -- makes the mutation observable even with no jink at all: the mutant
// mispredicts the intercept by Velocity * CommitHorizon, which the post-commit-jink assertion
// below (expected to still hit) catches as a spurious miss.
// Cut 11: advance the zone clock before firing. Build leaves it at 0, so every shot fired with
// FireTime = 0 -- and `now - FireTime` equals `now + FireTime` when FireTime is 0. That is why the
// moving target Cut 5 added still left this test's own declared mutation (measure elapsed wrongly)
// alive: Stryker found `now + shot.FireTime` surviving.
e.Zone.Update(2.5f);
e.Target.Velocity = float2(4, 0);
FireControl.Fire(e.Weapon, e.WeaponItem, e.Shooter);
var t = 0f;
const float dt = .05f;
var jinked = false;
var before = e.Target.Hull.Durability;
while (t < 6f && e.Zone.PendingShots.Count > 0)
{
if (!jinked && t >= jinkTime)
{
e.Target.Position += float3(50, 0, 0); // far outside Tracking's forgiveness
jinked = true;
}
e.Zone.Update(dt);
t += dt;
}
return e.Target.Hull.Durability < before; // true if the shot still landed
}
// Flight time 5s, horizon 0.5s -> commit at t=4.5s. A jink well before commit (t=1s) is judged.
Assert.False(RunWithJink(jinkTime: 1f, commitHorizon: .5f));
// The identical jink AFTER commit (t=4.6s, horizon .5s means commit at 4.5s) must not matter.
Assert.True(RunWithJink(jinkTime: 4.6f, commitHorizon: .5f));
}
// R4's graceful degradation: a velocity-0 (or otherwise sub-horizon) weapon commits and resolves within
// the same tick it fires, publishing both events, in order. Mutation: skip the commit for short flights
// (only ShotResolved fires, or damage applies with no ShotCommitted at all).
[Fact]
public void ShortFlightCommitsAtFire()
{
var e = Build(TestSettings(), velocity: 0, accuracy: 1, resolution: 1, spread: 0, damage: 10);
var order = new List<string>();
e.Zone.ShotCommitted.Subscribe(_ => order.Add("committed"));
e.Zone.ShotResolved.Subscribe(_ => order.Add("resolved"));
FireControl.Fire(e.Weapon, e.WeaponItem, e.Shooter);
e.Zone.Update(.016f); // one ordinary tick
Assert.Equal(new[] { "committed", "resolved" }, order);
SafeAssert.NoShots(e.Zone);
}
// R10/Q6: a shot's parameters freeze when the trigger is pulled. Changing the weapon's authored Damage
// stat (and refreshing it into the live behaviour before arrival) must not change what an already-fired
// shot deals. Mutation: re-evaluate Damage at arrival instead of using the frozen snapshot.
[Fact]
public void OutcomeIsSnapshotNotReread()
{
var e = Build(TestSettings(), damage: 10, velocity: 10, accuracy: 1, resolution: 1, spread: 0, targetRange: 50, armor: 0);
FireControl.Fire(e.Weapon, e.WeaponItem, e.Shooter);
var before = e.Target.Hull.Durability;
// Mid-flight (before arrival at 5s): the gun's Damage stat collapses, and a fresh Execute re-reads it
// into the live Weapon.Damage -- simulating the "power cut, heat spike" scenario R10 names.
e.Zone.Update(2f);
DataOf(e.WeaponItem).Damage = Constant(99999f);
e.Weapon.Execute(0f);
Assert.Equal(99999f, e.Weapon.Damage, 1);
e.Zone.Update(4f); // past arrival
var dealt = before - e.Target.Hull.Durability;
Assert.True(dealt > 0f);
Assert.True(dealt < 100f); // nowhere near the inflated 99999 value -- the original 10 landed
}
// R5's payoff: with Precision authored extremely tight (Cut 6d) and p 1, the aimed item's durability
// falls. Sigma floors at half a cell (SigmaFloor) regardless of how tight Precision is authored, so the
// lateral draw is not actually deterministic onto the aimed item's own one-cell-wide lane -- it is
// Phi(.5)-Phi(-.5) ~= .68 per shot, not 1. This one Fire() call is a single draw, not a repeated
// statistical check; it passes reliably only because this file's fixture uses one fixed GalaxyZone name
// ("Test", :146), so CombatSeed and this exact draw are the same on every run (F9, Soul's fix batch,
// 2026-09-24: an earlier comment overclaimed "same-lane-every-time" as if this were architecturally
// guaranteed). Mutation: the draw ignores Aimed and always picks a uniform random hull lateral offset.
// Cut 12.2 (docs/fire-control-cut.md): aiming now only prices and draws the LATERAL offset (the item's
// own aim point still centres the scatter, same as before); depth along the shot's own travel direction is
// resolved by Lane to whichever occupied cell is nearest the entry surface, which for a solid hull is
// always a border cell, never a Tool item's own interior cell (Shape.Shrink). A direct, unpenetrating hit
// (penetration 0, the fixture's old default) can therefore only ever land on the border cell in the aimed
// item's own lane -- reaching the item itself now requires the same penetration march
// AimingAtAnItemCentresTheScatterOnItsLane and HitsLandOnTheFacingEdge exercise, so this fixture is given
// enough penetration (5, well past this 5x5 hull's own width) to march from the entry edge through to the
// interior cell the aimed item occupies.
[Fact]
public void AimedHitLandsOnSelectedItem()
{
EquippableItem aimedGear = null;
var e = Build(TestSettings(), damage: 50, velocity: 0, accuracy: 1, resolution: 1, spread: 0, precision: 1000, armor: 0, penetration: 5,
beforeActivate: (items, shooter, target) =>
{
// A second, distinct interior item on the target to aim at, equipped before Activate() (Entity.
// TryEquip refuses once an entity is active).
var targetingRef = items.ItemData.RefOf<ItemData>(items.ItemData.GetByName<GearData>("Targeting"));
aimedGear = new EquippableItem { Data = targetingRef, Durability = 1, Lot = 5 };
Assert.True(target.TryFindSpace(aimedGear, out var pos));
Assert.True(target.TryEquip(aimedGear, pos));
});
var aimedItem = e.Target.Equipment.Single(x => x.EquippableItem == aimedGear);
e.Shooter.EntityInfoGathered[e.Target] = 1f; // fully tracked
e.Shooter.RecordReveal(e.Target, aimedItem, SignatureChannel.LaunchFlash);
Assert.True(e.Shooter.TrySelectTargetItem(aimedItem));
FireControl.Fire(e.Weapon, e.WeaponItem, e.Shooter);
e.Zone.Update(.1f);
Assert.True(aimedItem.EquippableItem.Durability < 1f);
}
// HardpointHitDamagesItemThenHull and PenetrationMarchIsPlanar (Entity.ApplyHit/DamageSchematic, deleted by
// Cut 12.3 -- docs/fire-control-cut.md) pinned two rules that no longer have that function to call:
// - "damage above armor plus item durability zeroes the item and the remainder hits the hull" is now
// FireControlCut123Tests.DamageIsAbsorbedInOrderAlongTheRay, through the real Fire/Commit/Apply path
// rather than a direct ApplyHit call.
// - "the penetration march is planar, in the target's own schematic frame" is subsumed by 12.2's own
// FireControlCut12Tests.HitsLandOnTheFacingEdge and TurningArmourIntoTheShotTakesItOnTheArmour (the
// bearing FireControl.Apply's lane walk reads is already the committed, schematic-frame bearing Commit
// computed, the same one those tests pin end-to-end) and by FireControlCut123Tests.ArmourFacesTheShot,
// which pins that the lane's own facing cell is planar across several non-axis-aligned facings.
// The absorb rule now exists once, in FireControl: an active shield that CanTakeHit absorbs, and the
// schematic is untouched. Mutation: remove the shield branch (the hit always reaches the hull).
//
// Cut 6d (docs/fire-control-cut.md): damage raised from 10 to 50, well above the Reactor/Shield gear's own
// Durability of 10. Unaimed fire now lands via the dart-throw kernel instead of a uniform pick over the
// whole hull, and Build's own default Precision groups shots tightly enough around this fixture's small
// hull's centre of mass that the deterministic draw for this exact seed lands on one of the two equipped
// Tool items. At damage 10 that coincidentally consumed the item's own durability with zero remainder,
// which left the hull's durability unchanged with or without the shield mutation applied -- the shield
// branch could be deleted outright and this test would not have noticed. Damage well past what any single
// item on this hull could fully absorb makes the assertion mean what it says regardless of which cell the
// kernel happens to land on.
[Fact]
public void ShieldTakesHit()
{
var e = Build(TestSettings(), damage: 50, velocity: 0, accuracy: 1, resolution: 1, spread: 0, armor: 0,
beforeActivate: (items, shooter, target) =>
{
// The Reactor/Shield catalog entries are authored in Build's own upsert batch (see its
// comment). Equipped before Activate() (Entity.TryEquip refuses once an entity is active).
var reactorRef = items.ItemData.RefOf<ItemData>(items.ItemData.GetByName<GearData>("Reactor"));
var reactor = new EquippableItem { Data = reactorRef, Durability = 10, Lot = 7 };
Assert.True(target.TryFindSpace(reactor, out var rpos));
Assert.True(target.TryEquip(reactor, rpos));
var shieldRef = items.ItemData.RefOf<ItemData>(items.ItemData.GetByName<GearData>("Shield"));
var shield = new EquippableItem { Data = shieldRef, Durability = 10, Lot = 8 };
Assert.True(target.TryFindSpace(shield, out var spos));
Assert.True(target.TryEquip(shield, spos));
});
e.Target.Shield.Item.Enabled.Value = true;
for (var i = 0; i < 20; i++) e.Zone.Update(.1f); // charge the reserve
Assert.True(e.Target.Shield.CanTakeHit(DamageType.Kinetic, 50));
var beforeHull = e.Target.Hull.Durability;
FireControl.Fire(e.Weapon, e.WeaponItem, e.Shooter);
e.Zone.Update(.1f);
Assert.Equal(beforeHull, e.Target.Hull.Durability);
}
}