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>
557 lines
31 KiB
C#
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);
|
|
}
|
|
}
|