Cut 11.4: behavioural tests for what Stryker found undefended, and Soul C4
Nine tests, each stating as behaviour a rule a real Stryker mutant survived:
- A shield that absorbs must eventually run dry, on both the discrete path and
Splash's (whose absorb branch had no coverage at all). Deleting Shield.TakeHit
survived because earlier tests asserted only that the hull was untouched.
- Splash damages the side the blast came from, across five facings and both
sides. Flipping the sign of `right` survived every suite, and it survives at
the default facing (0, 1) too: the flip only changes the damaged side when a
ship faces more along world x than z. So the test spins the target through
facings where it bites. The current code is correct; it can no longer regress.
- Different zones roll differently. Replacing `CombatSeed *` with `/` dropped the
zone from the seed and survived, because the die test checks one zone only.
- A shot whose target left the zone resolves as a miss.
That last one is a real defect (Soul C4), fixed here. Step rewrote the outcome
only for uncommitted shots, so a hit committed inside the horizon and then
orphaned by its target dying was republished as a hit -- a hit marker on a
corpse. The resolution is now a fresh miss; the committed outcome is left
untouched, since R4 makes it immutable.
The fixture puts the shooter off the origin on purpose: at float3.zero,
`target - source` equals `target + source`, which is how four position mutants
survived.
240 tests pass.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-22 16:06:33 +02:00
|
|
|
/* 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);
|
|
|
|
|
}
|
|
|
|
|
|
Cut 11.3: fix the degenerate fixtures that made mutants indistinguishable
Most of Stryker's surviving mutants in FireControl.cs traced to a handful of
fixture constants that make a mutant compute exactly the same number as the
original -- the same defect class as the zero-velocity target and the damage
that happened to equal an item's durability, found here as a pattern.
- The kernel fixture's shooter sat at float3.zero, where `target - source` and
`target + source` are the same vector. HitProbabilityMatchesInspect gains a
configuration with the shooter far from the origin: the correct separation is
500, in range; the mutated sum is 1300, out of range.
- The kernel fixture's Resolution was 1000, which saturates pSensor to exactly 1
above the detection threshold, so multiplying and dividing by it agreed. Now 2.
- EvasionCountsUntilCommitAndNotAfter fired at zone time 0, so `now - FireTime`
equalled `now + FireTime`. Cut 5 gave this test a moving target to defend its
own declared mutation and it still survived; the clock now advances first.
- Every fixture used zero spread and a cell size of 1, leaving the whole spread
term untested. PSpreadIsTheSilhouettesShareOfTheSpreadCone pins what pSpread
means -- a silhouette filling the cone scores 1, half the cone scores .5, and
a bigger one saturates -- rather than how it is computed.
Triaged as equivalent, not chased: the float-threshold boundary flips, and the
three MixSeed mutants that turn a right shift left -- measured, the die stays
fair under all three (worst deviation from p .017-.030 against .022 correct), so
killing them would mean pinning the mixer's bit arithmetic, not its behaviour.
Recorded, not fixed: FireControl.Apply's derivation of hitDirection from the two
positions has no geometry test through the FireControl path, and
StatResolverTests.AModifierThatTargetsItsOwnMagnitudeStatIsRefusedAtEquip fails
intermittently in the full suite -- likely cross-class pollution through
CultCache's process-wide document registry.
241 tests pass.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-22 16:20:45 +02:00
|
|
|
private static GameplaySettings TestSettings(float schematicCellSize = 1f) => new GameplaySettings
|
Cut 11.4: behavioural tests for what Stryker found undefended, and Soul C4
Nine tests, each stating as behaviour a rule a real Stryker mutant survived:
- A shield that absorbs must eventually run dry, on both the discrete path and
Splash's (whose absorb branch had no coverage at all). Deleting Shield.TakeHit
survived because earlier tests asserted only that the hull was untouched.
- Splash damages the side the blast came from, across five facings and both
sides. Flipping the sign of `right` survived every suite, and it survives at
the default facing (0, 1) too: the flip only changes the damaged side when a
ship faces more along world x than z. So the test spins the target through
facings where it bites. The current code is correct; it can no longer regress.
- Different zones roll differently. Replacing `CombatSeed *` with `/` dropped the
zone from the seed and survived, because the die test checks one zone only.
- A shot whose target left the zone resolves as a miss.
That last one is a real defect (Soul C4), fixed here. Step rewrote the outcome
only for uncommitted shots, so a hit committed inside the horizon and then
orphaned by its target dying was republished as a hit -- a hit marker on a
corpse. The resolution is now a fresh miss; the committed outcome is left
untouched, since R4 makes it immutable.
The fixture puts the shooter off the origin on purpose: at float3.zero,
`target - source` equals `target + source`, which is how four position mutants
survived.
240 tests pass.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-22 16:06:33 +02:00
|
|
|
{
|
|
|
|
|
DefaultEntitySettings = new EntitySettings(),
|
|
|
|
|
Tiers = new[] { new RarityTier { Name = "Common", Quality = .5f, Rarity = 0, Color = new float3(1, 1, 1) } },
|
|
|
|
|
QualityPriceModifier = new ExponentialLerp(),
|
|
|
|
|
TargetDetectionInfoThreshold = .1f,
|
|
|
|
|
TargetArmorInfoThreshold = .2f,
|
|
|
|
|
TargetGearInfoThreshold = .8f,
|
|
|
|
|
FiringArc = 170,
|
|
|
|
|
CommitHorizon = .5f,
|
Cut 11.3: fix the degenerate fixtures that made mutants indistinguishable
Most of Stryker's surviving mutants in FireControl.cs traced to a handful of
fixture constants that make a mutant compute exactly the same number as the
original -- the same defect class as the zero-velocity target and the damage
that happened to equal an item's durability, found here as a pattern.
- The kernel fixture's shooter sat at float3.zero, where `target - source` and
`target + source` are the same vector. HitProbabilityMatchesInspect gains a
configuration with the shooter far from the origin: the correct separation is
500, in range; the mutated sum is 1300, out of range.
- The kernel fixture's Resolution was 1000, which saturates pSensor to exactly 1
above the detection threshold, so multiplying and dividing by it agreed. Now 2.
- EvasionCountsUntilCommitAndNotAfter fired at zone time 0, so `now - FireTime`
equalled `now + FireTime`. Cut 5 gave this test a moving target to defend its
own declared mutation and it still survived; the clock now advances first.
- Every fixture used zero spread and a cell size of 1, leaving the whole spread
term untested. PSpreadIsTheSilhouettesShareOfTheSpreadCone pins what pSpread
means -- a silhouette filling the cone scores 1, half the cone scores .5, and
a bigger one saturates -- rather than how it is computed.
Triaged as equivalent, not chased: the float-threshold boundary flips, and the
three MixSeed mutants that turn a right shift left -- measured, the die stays
fair under all three (worst deviation from p .017-.030 against .022 correct), so
killing them would mean pinning the mixer's bit arithmetic, not its behaviour.
Recorded, not fixed: FireControl.Apply's derivation of hitDirection from the two
positions has no geometry test through the FireControl path, and
StatResolverTests.AModifierThatTargetsItsOwnMagnitudeStatIsRefusedAtEquip fails
intermittently in the full suite -- likely cross-class pollution through
CultCache's process-wide document registry.
241 tests pass.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-22 16:20:45 +02:00
|
|
|
SchematicCellSize = schematicCellSize,
|
Cut 11.4: behavioural tests for what Stryker found undefended, and Soul C4
Nine tests, each stating as behaviour a rule a real Stryker mutant survived:
- A shield that absorbs must eventually run dry, on both the discrete path and
Splash's (whose absorb branch had no coverage at all). Deleting Shield.TakeHit
survived because earlier tests asserted only that the hull was untouched.
- Splash damages the side the blast came from, across five facings and both
sides. Flipping the sign of `right` survived every suite, and it survives at
the default facing (0, 1) too: the flip only changes the damaged side when a
ship faces more along world x than z. So the test spins the target through
facings where it bites. The current code is correct; it can no longer regress.
- Different zones roll differently. Replacing `CombatSeed *` with `/` dropped the
zone from the seed and survived, because the die test checks one zone only.
- A shot whose target left the zone resolves as a miss.
That last one is a real defect (Soul C4), fixed here. Step rewrote the outcome
only for uncommitted shots, so a hit committed inside the horizon and then
orphaned by its target dying was republished as a hit -- a hit marker on a
corpse. The resolution is now a fresh miss; the committed outcome is left
untouched, since R4 makes it immutable.
The fixture puts the shooter off the origin on purpose: at float3.zero,
`target - source` equals `target + source`, which is how four position mutants
survived.
240 tests pass.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-22 16:06:33 +02:00
|
|
|
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,
|
Cut 11.3: fix the degenerate fixtures that made mutants indistinguishable
Most of Stryker's surviving mutants in FireControl.cs traced to a handful of
fixture constants that make a mutant compute exactly the same number as the
original -- the same defect class as the zero-velocity target and the damage
that happened to equal an item's durability, found here as a pattern.
- The kernel fixture's shooter sat at float3.zero, where `target - source` and
`target + source` are the same vector. HitProbabilityMatchesInspect gains a
configuration with the shooter far from the origin: the correct separation is
500, in range; the mutated sum is 1300, out of range.
- The kernel fixture's Resolution was 1000, which saturates pSensor to exactly 1
above the detection threshold, so multiplying and dividing by it agreed. Now 2.
- EvasionCountsUntilCommitAndNotAfter fired at zone time 0, so `now - FireTime`
equalled `now + FireTime`. Cut 5 gave this test a moving target to defend its
own declared mutation and it still survived; the clock now advances first.
- Every fixture used zero spread and a cell size of 1, leaving the whole spread
term untested. PSpreadIsTheSilhouettesShareOfTheSpreadCone pins what pSpread
means -- a silhouette filling the cone scores 1, half the cone scores .5, and
a bigger one saturates -- rather than how it is computed.
Triaged as equivalent, not chased: the float-threshold boundary flips, and the
three MixSeed mutants that turn a right shift left -- measured, the die stays
fair under all three (worst deviation from p .017-.030 against .022 correct), so
killing them would mean pinning the mixer's bit arithmetic, not its behaviour.
Recorded, not fixed: FireControl.Apply's derivation of hitDirection from the two
positions has no geometry test through the FireControl path, and
StatResolverTests.AModifierThatTargetsItsOwnMagnitudeStatIsRefusedAtEquip fails
intermittently in the full suite -- likely cross-class pollution through
CultCache's process-wide document registry.
241 tests pass.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-22 16:20:45 +02:00
|
|
|
bool markers = false, float spread = 0, float schematicCellSize = 1f)
|
Cut 11.4: behavioural tests for what Stryker found undefended, and Soul C4
Nine tests, each stating as behaviour a rule a real Stryker mutant survived:
- A shield that absorbs must eventually run dry, on both the discrete path and
Splash's (whose absorb branch had no coverage at all). Deleting Shield.TakeHit
survived because earlier tests asserted only that the hull was untouched.
- Splash damages the side the blast came from, across five facings and both
sides. Flipping the sign of `right` survived every suite, and it survives at
the default facing (0, 1) too: the flip only changes the damaged side when a
ship faces more along world x than z. So the test spins the target through
facings where it bites. The current code is correct; it can no longer regress.
- Different zones roll differently. Replacing `CombatSeed *` with `/` dropped the
zone from the seed and survived, because the die test checks one zone only.
- A shot whose target left the zone resolves as a miss.
That last one is a real defect (Soul C4), fixed here. Step rewrote the outcome
only for uncommitted shots, so a hit committed inside the horizon and then
orphaned by its target dying was republished as a hit -- a hit marker on a
corpse. The resolution is now a fresh miss; the committed outcome is left
untouched, since R4 makes it immutable.
The fixture puts the shooter off the origin on purpose: at float3.zero,
`target - source` equals `target + source`, which is how four position mutants
survived.
240 tests pass.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-22 16:06:33 +02:00
|
|
|
{
|
|
|
|
|
// 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 TestCatalogGlobal { Name = "Temperament" });
|
|
|
|
|
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),
|
Cut 11.3: fix the degenerate fixtures that made mutants indistinguishable
Most of Stryker's surviving mutants in FireControl.cs traced to a handful of
fixture constants that make a mutant compute exactly the same number as the
original -- the same defect class as the zero-velocity target and the damage
that happened to equal an item's durability, found here as a pattern.
- The kernel fixture's shooter sat at float3.zero, where `target - source` and
`target + source` are the same vector. HitProbabilityMatchesInspect gains a
configuration with the shooter far from the origin: the correct separation is
500, in range; the mutated sum is 1300, out of range.
- The kernel fixture's Resolution was 1000, which saturates pSensor to exactly 1
above the detection threshold, so multiplying and dividing by it agreed. Now 2.
- EvasionCountsUntilCommitAndNotAfter fired at zone time 0, so `now - FireTime`
equalled `now + FireTime`. Cut 5 gave this test a moving target to defend its
own declared mutation and it still survived; the clock now advances first.
- Every fixture used zero spread and a cell size of 1, leaving the whole spread
term untested. PSpreadIsTheSilhouettesShareOfTheSpreadCone pins what pSpread
means -- a silhouette filling the cone scores 1, half the cone scores .5, and
a bigger one saturates -- rather than how it is computed.
Triaged as equivalent, not chased: the float-threshold boundary flips, and the
three MixSeed mutants that turn a right shift left -- measured, the die stays
fair under all three (worst deviation from p .017-.030 against .022 correct), so
killing them would mean pinning the mixer's bit arithmetic, not its behaviour.
Recorded, not fixed: FireControl.Apply's derivation of hitDirection from the two
positions has no geometry test through the FireControl path, and
StatResolverTests.AModifierThatTargetsItsOwnMagnitudeStatIsRefusedAtEquip fails
intermittently in the full suite -- likely cross-class pollution through
CultCache's process-wide document registry.
241 tests pass.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-22 16:20:45 +02:00
|
|
|
Velocity = Constant(velocity), Spread = Constant(spread), DamageSpread = Constant(0),
|
Cut 11.4: behavioural tests for what Stryker found undefended, and Soul C4
Nine tests, each stating as behaviour a rule a real Stryker mutant survived:
- A shield that absorbs must eventually run dry, on both the discrete path and
Splash's (whose absorb branch had no coverage at all). Deleting Shield.TakeHit
survived because earlier tests asserted only that the hull was untouched.
- Splash damages the side the blast came from, across five facings and both
sides. Flipping the sign of `right` survived every suite, and it survives at
the default facing (0, 1) too: the flip only changes the damaged side when a
ship faces more along world x than z. So the test spins the target through
facings where it bites. The current code is correct; it can no longer regress.
- Different zones roll differently. Replacing `CombatSeed *` with `/` dropped the
zone from the seed and survived, because the die test checks one zone only.
- A shot whose target left the zone resolves as a miss.
That last one is a real defect (Soul C4), fixed here. Step rewrote the outcome
only for uncommitted shots, so a hit committed inside the horizon and then
orphaned by its target dying was republished as a hit -- a hit marker on a
corpse. The resolution is now a fresh miss; the committed outcome is left
untouched, since R4 makes it immutable.
The fixture puts the shooter off the origin on purpose: at float3.zero,
`target - source` equals `target + source`, which is how four position mutants
survived.
240 tests pass.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-22 16:06:33 +02:00
|
|
|
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 };
|
Cut 11.3: fix the degenerate fixtures that made mutants indistinguishable
Most of Stryker's surviving mutants in FireControl.cs traced to a handful of
fixture constants that make a mutant compute exactly the same number as the
original -- the same defect class as the zero-velocity target and the damage
that happened to equal an item's durability, found here as a pattern.
- The kernel fixture's shooter sat at float3.zero, where `target - source` and
`target + source` are the same vector. HitProbabilityMatchesInspect gains a
configuration with the shooter far from the origin: the correct separation is
500, in range; the mutated sum is 1300, out of range.
- The kernel fixture's Resolution was 1000, which saturates pSensor to exactly 1
above the detection threshold, so multiplying and dividing by it agreed. Now 2.
- EvasionCountsUntilCommitAndNotAfter fired at zone time 0, so `now - FireTime`
equalled `now + FireTime`. Cut 5 gave this test a moving target to defend its
own declared mutation and it still survived; the clock now advances first.
- Every fixture used zero spread and a cell size of 1, leaving the whole spread
term untested. PSpreadIsTheSilhouettesShareOfTheSpreadCone pins what pSpread
means -- a silhouette filling the cone scores 1, half the cone scores .5, and
a bigger one saturates -- rather than how it is computed.
Triaged as equivalent, not chased: the float-threshold boundary flips, and the
three MixSeed mutants that turn a right shift left -- measured, the die stays
fair under all three (worst deviation from p .017-.030 against .022 correct), so
killing them would mean pinning the mixer's bit arithmetic, not its behaviour.
Recorded, not fixed: FireControl.Apply's derivation of hitDirection from the two
positions has no geometry test through the FireControl path, and
StatResolverTests.AModifierThatTargetsItsOwnMagnitudeStatIsRefusedAtEquip fails
intermittently in the full suite -- likely cross-class pollution through
CultCache's process-wide document registry.
241 tests pass.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-22 16:20:45 +02:00
|
|
|
var items = new ItemManager(cache, ledger, TestSettings(schematicCellSize), _ => { });
|
Cut 11.4: behavioural tests for what Stryker found undefended, and Soul C4
Nine tests, each stating as behaviour a rule a real Stryker mutant survived:
- A shield that absorbs must eventually run dry, on both the discrete path and
Splash's (whose absorb branch had no coverage at all). Deleting Shield.TakeHit
survived because earlier tests asserted only that the hull was untouched.
- Splash damages the side the blast came from, across five facings and both
sides. Flipping the sign of `right` survived every suite, and it survives at
the default facing (0, 1) too: the flip only changes the damaged side when a
ship faces more along world x than z. So the test spins the target through
facings where it bites. The current code is correct; it can no longer regress.
- Different zones roll differently. Replacing `CombatSeed *` with `/` dropped the
zone from the seed and survived, because the die test checks one zone only.
- A shot whose target left the zone resolves as a miss.
That last one is a real defect (Soul C4), fixed here. Step rewrote the outcome
only for uncommitted shots, so a hit committed inside the horizon and then
orphaned by its target dying was republished as a hit -- a hit marker on a
corpse. The resolution is now a fresh miss; the committed outcome is left
untouched, since R4 makes it immutable.
The fixture puts the shooter off the origin on purpose: at float3.zero,
`target - source` equals `target + source`, which is how four position mutants
survived.
240 tests pass.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-22 16:06:33 +02:00
|
|
|
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
|
Mining Cut 3: a chunk is a target, detected by reflected light
- Entity.Target is one slot of TargetRef, read-only from outside
(IReadOnlyReactiveProperty); Entity.SetTarget is its one writer. A chunk is
taken only while the entity can see it, so a rock in darkness cannot be
picked. Every handler, agent, turret and test writes through SetTarget.
- Entity.ChunkInfo (Q14 A): the value the per-tick sensor rule settles at,
computed on demand through Sensor.PassiveRate/Sensor.Gain -- sum of the
active sensors' passive rates over (sensors x TargetInfoDecay), capped at 1.
Stored nowhere; no per-tick loop over chunks. ChunkVisible is the detection
threshold through the chunk path.
- The lost-track rule reads through the chunk path: Activate's reconciliation
and a per-tick check of the one slot drop a chunk target that has gone dark.
- Entity.VisibleChunksInReach (Q13 A): visible chunks within the longest range
among active weapons -- the one reach rule for cycling and programmatic picks.
- Entity-only readers take .Entity: TargetedBy/TargetedByCount, target items,
the stance gate (a chunk has no stance), lock (Q12 A: never on a chunk),
turrets, Combat, Minion, FireControl (a chunk fires untargeted until Cut 4).
TargetRange and the arc gate read a chunk's planar position.
- ActionGameManager: the reticle and next/previous offer visible ships and
visible chunks in reach; nearest stays enemies-only; the target panel,
indicator and fire-control debug take TargetRef. EntityInstance reads .Entity.
- MiningCut3Tests pin parity, darkness, monotone brightness, wear and break,
reach, the chunk query, target survival and loss, TargetRef equality, lock,
and field-kind assignment at generation and first load.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-30 12:56:39 +02:00
|
|
|
shooter.SetTarget(target);
|
Cut 11.4: behavioural tests for what Stryker found undefended, and Soul C4
Nine tests, each stating as behaviour a rule a real Stryker mutant survived:
- A shield that absorbs must eventually run dry, on both the discrete path and
Splash's (whose absorb branch had no coverage at all). Deleting Shield.TakeHit
survived because earlier tests asserted only that the hull was untouched.
- Splash damages the side the blast came from, across five facings and both
sides. Flipping the sign of `right` survived every suite, and it survives at
the default facing (0, 1) too: the flip only changes the damaged side when a
ship faces more along world x than z. So the test spins the target through
facings where it bites. The current code is correct; it can no longer regress.
- Different zones roll differently. Replacing `CombatSeed *` with `/` dropped the
zone from the seed and survived, because the die test checks one zone only.
- A shot whose target left the zone resolves as a miss.
That last one is a real defect (Soul C4), fixed here. Step rewrote the outcome
only for uncommitted shots, so a hit committed inside the horizon and then
orphaned by its target dying was republished as a hit -- a hit marker on a
corpse. The resolution is now a fresh miss; the committed outcome is left
untouched, since R4 makes it immutable.
The fixture puts the shooter off the origin on purpose: at float3.zero,
`target - source` equals `target + source`, which is how four position mutants
survived.
240 tests pass.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-22 16:06:33 +02:00
|
|
|
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
|
|
|
|
|
}
|
|
|
|
|
|
2026-09-26 01:17:19 +02:00
|
|
|
// 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.
|
Cut 11.4: behavioural tests for what Stryker found undefended, and Soul C4
Nine tests, each stating as behaviour a rule a real Stryker mutant survived:
- A shield that absorbs must eventually run dry, on both the discrete path and
Splash's (whose absorb branch had no coverage at all). Deleting Shield.TakeHit
survived because earlier tests asserted only that the hull was untouched.
- Splash damages the side the blast came from, across five facings and both
sides. Flipping the sign of `right` survived every suite, and it survives at
the default facing (0, 1) too: the flip only changes the damaged side when a
ship faces more along world x than z. So the test spins the target through
facings where it bites. The current code is correct; it can no longer regress.
- Different zones roll differently. Replacing `CombatSeed *` with `/` dropped the
zone from the seed and survived, because the die test checks one zone only.
- A shot whose target left the zone resolves as a miss.
That last one is a real defect (Soul C4), fixed here. Step rewrote the outcome
only for uncommitted shots, so a hit committed inside the horizon and then
orphaned by its target dying was republished as a hit -- a hit marker on a
corpse. The resolution is now a fresh miss; the committed outcome is left
untouched, since R4 makes it immutable.
The fixture puts the shooter off the origin on purpose: at float3.zero,
`target - source` equals `target + source`, which is how four position mutants
survived.
240 tests pass.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-22 16:06:33 +02:00
|
|
|
[Fact]
|
2026-09-26 01:17:19 +02:00
|
|
|
public void ABlastShieldAbsorptionDrainsTheReserve()
|
Cut 11.4: behavioural tests for what Stryker found undefended, and Soul C4
Nine tests, each stating as behaviour a rule a real Stryker mutant survived:
- A shield that absorbs must eventually run dry, on both the discrete path and
Splash's (whose absorb branch had no coverage at all). Deleting Shield.TakeHit
survived because earlier tests asserted only that the hull was untouched.
- Splash damages the side the blast came from, across five facings and both
sides. Flipping the sign of `right` survived every suite, and it survives at
the default facing (0, 1) too: the flip only changes the damaged side when a
ship faces more along world x than z. So the test spins the target through
facings where it bites. The current code is correct; it can no longer regress.
- Different zones roll differently. Replacing `CombatSeed *` with `/` dropped the
zone from the seed and survived, because the die test checks one zone only.
- A shot whose target left the zone resolves as a miss.
That last one is a real defect (Soul C4), fixed here. Step rewrote the outcome
only for uncommitted shots, so a hit committed inside the horizon and then
orphaned by its target dying was republished as a hit -- a hit marker on a
corpse. The resolution is now a fresh miss; the committed outcome is left
untouched, since R4 makes it immutable.
The fixture puts the shooter off the origin on purpose: at float3.zero,
`target - source` equals `target + source`, which is how four position mutants
survived.
240 tests pass.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-22 16:06:33 +02:00
|
|
|
{
|
|
|
|
|
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)
|
|
|
|
|
{
|
2026-09-26 01:17:19 +02:00
|
|
|
FireControl.Detonate(e.Zone, e.Target.Position.xz, radius: 1, damage: 5, damageType: DamageType.Kinetic);
|
Cut 11.4: behavioural tests for what Stryker found undefended, and Soul C4
Nine tests, each stating as behaviour a rule a real Stryker mutant survived:
- A shield that absorbs must eventually run dry, on both the discrete path and
Splash's (whose absorb branch had no coverage at all). Deleting Shield.TakeHit
survived because earlier tests asserted only that the hull was untouched.
- Splash damages the side the blast came from, across five facings and both
sides. Flipping the sign of `right` survived every suite, and it survives at
the default facing (0, 1) too: the flip only changes the damaged side when a
ship faces more along world x than z. So the test spins the target through
facings where it bites. The current code is correct; it can no longer regress.
- Different zones roll differently. Replacing `CombatSeed *` with `/` dropped the
zone from the seed and survived, because the die test checks one zone only.
- A shot whose target left the zone resolves as a miss.
That last one is a real defect (Soul C4), fixed here. Step rewrote the outcome
only for uncommitted shots, so a hit committed inside the horizon and then
orphaned by its target dying was republished as a hit -- a hit marker on a
corpse. The resolution is now a fresh miss; the committed outcome is left
untouched, since R4 makes it immutable.
The fixture puts the shooter off the origin on purpose: at float3.zero,
`target - source` equals `target + source`, which is how four position mutants
survived.
240 tests pass.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-22 16:06:33 +02:00
|
|
|
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;
|
2026-09-22 16:39:52 +02:00
|
|
|
var commits = 0;
|
|
|
|
|
using var c = e.Zone.ShotCommitted.Subscribe(o => { committed = o; commits++; });
|
Cut 11.4: behavioural tests for what Stryker found undefended, and Soul C4
Nine tests, each stating as behaviour a rule a real Stryker mutant survived:
- A shield that absorbs must eventually run dry, on both the discrete path and
Splash's (whose absorb branch had no coverage at all). Deleting Shield.TakeHit
survived because earlier tests asserted only that the hull was untouched.
- Splash damages the side the blast came from, across five facings and both
sides. Flipping the sign of `right` survived every suite, and it survives at
the default facing (0, 1) too: the flip only changes the damaged side when a
ship faces more along world x than z. So the test spins the target through
facings where it bites. The current code is correct; it can no longer regress.
- Different zones roll differently. Replacing `CombatSeed *` with `/` dropped the
zone from the seed and survived, because the die test checks one zone only.
- A shot whose target left the zone resolves as a miss.
That last one is a real defect (Soul C4), fixed here. Step rewrote the outcome
only for uncommitted shots, so a hit committed inside the horizon and then
orphaned by its target dying was republished as a hit -- a hit marker on a
corpse. The resolution is now a fresh miss; the committed outcome is left
untouched, since R4 makes it immutable.
The fixture puts the shooter off the origin on purpose: at float3.zero,
`target - source` equals `target + source`, which is how four position mutants
survived.
240 tests pass.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-22 16:06:33 +02:00
|
|
|
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);
|
2026-09-22 16:39:52 +02:00
|
|
|
// What the HUD's arrival countdown reads: time left in flight at commit, 5 s - 4.7 s.
|
|
|
|
|
Assert.Equal(.3f, committed.ArrivalIn, 3);
|
Cut 11.4: behavioural tests for what Stryker found undefended, and Soul C4
Nine tests, each stating as behaviour a rule a real Stryker mutant survived:
- A shield that absorbs must eventually run dry, on both the discrete path and
Splash's (whose absorb branch had no coverage at all). Deleting Shield.TakeHit
survived because earlier tests asserted only that the hull was untouched.
- Splash damages the side the blast came from, across five facings and both
sides. Flipping the sign of `right` survived every suite, and it survives at
the default facing (0, 1) too: the flip only changes the damaged side when a
ship faces more along world x than z. So the test spins the target through
facings where it bites. The current code is correct; it can no longer regress.
- Different zones roll differently. Replacing `CombatSeed *` with `/` dropped the
zone from the seed and survived, because the die test checks one zone only.
- A shot whose target left the zone resolves as a miss.
That last one is a real defect (Soul C4), fixed here. Step rewrote the outcome
only for uncommitted shots, so a hit committed inside the horizon and then
orphaned by its target dying was republished as a hit -- a hit marker on a
corpse. The resolution is now a fresh miss; the committed outcome is left
untouched, since R4 makes it immutable.
The fixture puts the shooter off the origin on purpose: at float3.zero,
`target - source` equals `target + source`, which is how four position mutants
survived.
240 tests pass.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-22 16:06:33 +02:00
|
|
|
|
|
|
|
|
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)");
|
2026-09-22 16:39:52 +02:00
|
|
|
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);
|
2026-09-29 17:05:14 +02:00
|
|
|
SafeAssert.Empty(committed);
|
2026-09-22 16:39:52 +02:00
|
|
|
|
|
|
|
|
e.Zone.Entities.Remove(e.Target);
|
|
|
|
|
e.Zone.Update(1f);
|
|
|
|
|
|
2026-09-29 17:05:14 +02:00
|
|
|
foreach (var outcome in new[] { SafeAssert.Only(committed), SafeAssert.Only(resolved) })
|
2026-09-22 16:39:52 +02:00
|
|
|
{
|
|
|
|
|
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");
|
|
|
|
|
}
|
2026-09-29 17:05:14 +02:00
|
|
|
SafeAssert.NoShots(e.Zone);
|
2026-09-22 16:39:52 +02:00
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// ---- 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);
|
Cut 11.4: behavioural tests for what Stryker found undefended, and Soul C4
Nine tests, each stating as behaviour a rule a real Stryker mutant survived:
- A shield that absorbs must eventually run dry, on both the discrete path and
Splash's (whose absorb branch had no coverage at all). Deleting Shield.TakeHit
survived because earlier tests asserted only that the hull was untouched.
- Splash damages the side the blast came from, across five facings and both
sides. Flipping the sign of `right` survived every suite, and it survives at
the default facing (0, 1) too: the flip only changes the damaged side when a
ship faces more along world x than z. So the test spins the target through
facings where it bites. The current code is correct; it can no longer regress.
- Different zones roll differently. Replacing `CombatSeed *` with `/` dropped the
zone from the seed and survived, because the die test checks one zone only.
- A shot whose target left the zone resolves as a miss.
That last one is a real defect (Soul C4), fixed here. Step rewrote the outcome
only for uncommitted shots, so a hit committed inside the horizon and then
orphaned by its target dying was republished as a hit -- a hit marker on a
corpse. The resolution is now a fresh miss; the committed outcome is left
untouched, since R4 makes it immutable.
The fixture puts the shooter off the origin on purpose: at float3.zero,
`target - source` equals `target + source`, which is how four position mutants
survived.
240 tests pass.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-22 16:06:33 +02:00
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// ---- Splash damages the side the blast came from. ----
|
2026-09-26 01:17:19 +02:00
|
|
|
//
|
|
|
|
|
// 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.
|
Cut 11.4: behavioural tests for what Stryker found undefended, and Soul C4
Nine tests, each stating as behaviour a rule a real Stryker mutant survived:
- A shield that absorbs must eventually run dry, on both the discrete path and
Splash's (whose absorb branch had no coverage at all). Deleting Shield.TakeHit
survived because earlier tests asserted only that the hull was untouched.
- Splash damages the side the blast came from, across five facings and both
sides. Flipping the sign of `right` survived every suite, and it survives at
the default facing (0, 1) too: the flip only changes the damaged side when a
ship faces more along world x than z. So the test spins the target through
facings where it bites. The current code is correct; it can no longer regress.
- Different zones roll differently. Replacing `CombatSeed *` with `/` dropped the
zone from the seed and survived, because the die test checks one zone only.
- A shot whose target left the zone resolves as a miss.
That last one is a real defect (Soul C4), fixed here. Step rewrote the outcome
only for uncommitted shots, so a hit committed inside the horizon and then
orphaned by its target dying was republished as a hit -- a hit marker on a
corpse. The resolution is now a fresh miss; the committed outcome is left
untouched, since R4 makes it immutable.
The fixture puts the shooter off the origin on purpose: at float3.zero,
`target - source` equals `target + source`, which is how four position mutants
survived.
240 tests pass.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-22 16:06:33 +02:00
|
|
|
|
Cut 11.3: fix the degenerate fixtures that made mutants indistinguishable
Most of Stryker's surviving mutants in FireControl.cs traced to a handful of
fixture constants that make a mutant compute exactly the same number as the
original -- the same defect class as the zero-velocity target and the damage
that happened to equal an item's durability, found here as a pattern.
- The kernel fixture's shooter sat at float3.zero, where `target - source` and
`target + source` are the same vector. HitProbabilityMatchesInspect gains a
configuration with the shooter far from the origin: the correct separation is
500, in range; the mutated sum is 1300, out of range.
- The kernel fixture's Resolution was 1000, which saturates pSensor to exactly 1
above the detection threshold, so multiplying and dividing by it agreed. Now 2.
- EvasionCountsUntilCommitAndNotAfter fired at zone time 0, so `now - FireTime`
equalled `now + FireTime`. Cut 5 gave this test a moving target to defend its
own declared mutation and it still survived; the clock now advances first.
- Every fixture used zero spread and a cell size of 1, leaving the whole spread
term untested. PSpreadIsTheSilhouettesShareOfTheSpreadCone pins what pSpread
means -- a silhouette filling the cone scores 1, half the cone scores .5, and
a bigger one saturates -- rather than how it is computed.
Triaged as equivalent, not chased: the float-threshold boundary flips, and the
three MixSeed mutants that turn a right shift left -- measured, the die stays
fair under all three (worst deviation from p .017-.030 against .022 correct), so
killing them would mean pinning the mixer's bit arithmetic, not its behaviour.
Recorded, not fixed: FireControl.Apply's derivation of hitDirection from the two
positions has no geometry test through the FireControl path, and
StatResolverTests.AModifierThatTargetsItsOwnMagnitudeStatIsRefusedAtEquip fails
intermittently in the full suite -- likely cross-class pollution through
CultCache's process-wide document registry.
241 tests pass.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-22 16:20:45 +02:00
|
|
|
// ---- 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
|
|
|
|
|
}
|
|
|
|
|
|
Cut 11.4: behavioural tests for what Stryker found undefended, and Soul C4
Nine tests, each stating as behaviour a rule a real Stryker mutant survived:
- A shield that absorbs must eventually run dry, on both the discrete path and
Splash's (whose absorb branch had no coverage at all). Deleting Shield.TakeHit
survived because earlier tests asserted only that the hull was untouched.
- Splash damages the side the blast came from, across five facings and both
sides. Flipping the sign of `right` survived every suite, and it survives at
the default facing (0, 1) too: the flip only changes the damaged side when a
ship faces more along world x than z. So the test spins the target through
facings where it bites. The current code is correct; it can no longer regress.
- Different zones roll differently. Replacing `CombatSeed *` with `/` dropped the
zone from the seed and survived, because the die test checks one zone only.
- A shot whose target left the zone resolves as a miss.
That last one is a real defect (Soul C4), fixed here. Step rewrote the outcome
only for uncommitted shots, so a hit committed inside the horizon and then
orphaned by its target dying was republished as a hit -- a hit marker on a
corpse. The resolution is now a fresh miss; the committed outcome is left
untouched, since R4 makes it immutable.
The fixture puts the shooter off the origin on purpose: at float3.zero,
`target - source` equals `target + source`, which is how four position mutants
survived.
240 tests pass.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-22 16:06:33 +02:00
|
|
|
// ---- 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");
|
|
|
|
|
}
|
|
|
|
|
}
|