Aetheria/tests/Aetheria.Shared.Tests/FireAuthorityTests.cs
Meta Vens 49423b61fe EW 3: allegiance is the observer's belief; telemetry with the track, grudges from acts, one stance writer
Cuts WatchForGrudge, PerceivedStanceOf, the recursive IsHostileTo and the inline compare-and-write; adds _telemetry (ReceiveTelemetry at track entry), Provoke (Track paint, committed shot), RefreshStance, BelievesTrespassing. Not yet verified: unbuilt, tests unrun.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-17 06:45:51 +02:00

557 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),
} }
});
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.Admit(shooter, piloted: false);
zone.Admit(target, piloted: false);
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);
}
// A committed shot is an act on the target's side (ruling ew-hostility-from-belief-ruled): the target holds a hostile
// override on the shooter whether the shot hits or misses.
[Theory]
[InlineData(0f)]
[InlineData(1f)]
public void AShotProvokes(float accuracy)
{
var e = Build(TestSettings(), accuracy: accuracy, precision: .5f);
Assert.Null(e.Target.IffOverride(e.Shooter));
FireControl.Fire(e.Weapon, e.WeaponItem, e.Shooter);
FireControl.Step(e.Zone, 0f);
Assert.True(e.Target.IffOverride(e.Shooter));
}
// 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.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, aimedItem.Data);
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);
}
}