Fire control Cut 4: pin the beam roll, splash, and the OnFire short-circuit fix

tests/Aetheria.Shared.Tests/FireControlCut4Tests.cs, builds its own minimal
fixtures rather than reusing FireAuthorityTests.cs's InstantWeapon-shaped
Engagement:

- BeamRollsPerInterval: over one second a beam produces exactly
  1/BeamResolveInterval outcomes totalling Damage. Interval (.25s) and tick
  (.125s) are both exact dyadic fractions so float accumulation cannot drift
  the count, and the tick is strictly smaller than the interval so a "roll
  every tick" mutant and a "roll once for the burst" mutant both fail.
- InstantWeaponFiresWithoutAnyOnFireSubscriber: pins the InstantWeapon half
  of the null-conditional short-circuit fix, which no existing test could
  catch (FireAuthorityTests.cs always calls FireControl.Fire directly, never
  through Trigger()/Execute()).
- SplashHitsEveryEntityInRadius / SplashIsDirectional: FireControl.Splash
  called directly against a zone of bare Ship entities (Entity.MapEntity,
  run from the constructor, is enough to give a Ship Armor/GearOccupancy/
  Hull without Activate() or a weapon catalog).

tests/mutation_tests_fire_control_cut4.py is the committed harness: a no-op
control plus one mutation per rule above, run against
tests/Aetheria.Shared.Tests via `dotnet test -p:CultLibRoot=...`. All seven
mutations behaved as declared in this pass.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
Meta Vens 2026-09-19 15:17:35 +02:00
commit 034e8d3e78
2 changed files with 530 additions and 0 deletions

View file

@ -0,0 +1,329 @@
/* 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 4 (docs/fire-control-cut.md): pins the two rules this cut actually adds -- a continuous weapon is a
// sequence of rolls rather than a continuous truth, and splash (mine/airburst blast) is one rule applied to
// every entity in radius, directional over each target's own hull. FireAuthorityTests.cs already pins the
// roll itself (seeding, commit horizon, the shield-absorb branch, the damage rule); this file builds its own
// minimal fixtures rather than reusing that one's InstantWeapon-shaped Engagement, the same convention that
// file's own comment establishes.
public sealed class FireControlCut4Tests : IDisposable
{
private readonly string _root = Path.Combine(Path.GetTempPath(), "aetheria-firecontrolcut4-" + Guid.NewGuid().ToString("N"));
private string Catalog => Path.Combine(_root, "Aetheria.cc");
public FireControlCut4Tests() => 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,
AgentMinHitProbability = 0f,
BeamResolveInterval = .1f
};
private static PerformanceStat Constant(float v) => new PerformanceStat { Min = v, Max = v };
// A 5x5 fully-solid hull, the same shape FireAuthorityTests.Build uses.
private static HullData MakeHullData(float armor = 0) => new HullData
{
Name = "Hull", HullType = HullType.Ship, Shape = SolidShape(), Durability = 1000, Mass = 1000, Armor = armor,
Hardpoints = { new HardpointData { Type = HardpointType.Sensors, Position = new int2(0, 0), Shape = new Shape() } }
};
private static Shape SolidShape()
{
var shape = new Shape(5, 5);
foreach (var cell in shape.AllCoordinates) shape[cell] = true;
return shape;
}
// ---- Cut 4's beam rule: ConstantWeapon rolls once per BeamResolveInterval, not once per tick and not
// once for the whole burst. ----
private sealed class BeamEngagement
{
public ItemManager Items;
public Zone Zone;
public Ship Shooter;
public Ship Target;
public ConstantWeapon Weapon;
}
private BeamEngagement BuildBeam(GameplaySettings settings, float damage, float targetRange = 100)
{
var cache = AetheriaStores.Open(Catalog + Guid.NewGuid().ToString("N"), catalogWritable: true);
_openCaches.Add(cache);
cache.Upsert(new TestCatalogGlobal { Name = "Temperament" });
cache.Upsert(MakeHullData());
cache.Upsert(new GearData
{
Name = "Beam", Hardpoint = HardpointType.Sensors, Shape = new Shape(), Durability = 1,
MinimumTemperature = -1000, MaximumTemperature = 1000, OptimalTemperature = 0, PlateauWidth = 2000,
Behaviors = { new ConstantWeaponData
{
Damage = Constant(damage), Range = Constant(1000), MinRange = Constant(0),
Velocity = Constant(0), Spread = Constant(0), DamageSpread = Constant(0), Penetration = Constant(0),
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(1), Resolution = Constant(1), Precision = Constant(0), Tracking = Constant(1000)
} }
});
// Powers the beam: with no Reactor, PowerBus grants nothing and ConstantWeapon.Execute safes itself
// off (Item.PowerSupply <= 1e-4f) after the first tick, same rake FireAuthorityTests.ShieldTakesHit
// notes for its own Reactor/Shield pair.
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.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 };
ledger.Lots[5] = new Lot { Origin = new Attributed(), Quality = 1 };
ledger.Lots[9] = 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 = 1000, Lot = 1 };
var shooter = new Ship(items, zone, shooterHull, new EntitySettings());
var gunRef = items.ItemData.RefOf<ItemData>(items.ItemData.GetByName<GearData>("Beam"));
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 = (ConstantWeapon) weaponItem.Behaviors.Single(b => b is Weapon);
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 reactorRef = items.ItemData.RefOf<ItemData>(items.ItemData.GetByName<GearData>("Reactor"));
var reactor = new EquippableItem { Data = reactorRef, Durability = 10, Lot = 5 };
Assert.True(shooter.TryFindSpace(reactor, out var rpos));
Assert.True(shooter.TryEquip(reactor, rpos));
var targetHull = new EquippableItem { Data = hullRef, Durability = 1000, Lot = 4 };
var target = new Ship(items, zone, targetHull, new EntitySettings());
var targetGunRef = items.ItemData.RefOf<ItemData>(items.ItemData.GetByName<GearData>("Beam"));
var targetGun = new EquippableItem { Data = targetGunRef, Durability = 1, Lot = 9 };
Assert.True(target.TryEquip(targetGun, new int2(0, 0)));
zone.Entities.Add(shooter);
zone.Entities.Add(target);
shooter.Activate();
target.Activate();
shooter.Position = float3.zero;
target.Position = float3(0, 0, targetRange);
shooter.Target.Value = target;
shooter.EntityInfoGathered[target] = 1f;
shooter.SetIff(target, true);
zone.Update(0f); // warm up resolved stats, same as FireAuthorityTests.Build
return new BeamEngagement { Items = items, Zone = zone, Shooter = shooter, Target = target, Weapon = weapon };
}
// A beam is a sequence of discrete rolls, not a continuous truth: over one second it produces exactly
// 1/BeamResolveInterval outcomes, each for Damage*BeamResolveInterval, totalling Damage. Interval (.25s)
// and tick (.125s) are both exact dyadic fractions so float accumulation cannot drift the count -- and the
// tick is strictly smaller than the interval, so the two are distinguishable from a "one roll per tick"
// mutant (which would produce 8 outcomes here, not 4) and from a "one roll per burst" mutant (1, not 4).
[Fact]
public void BeamRollsPerInterval()
{
var settings = TestSettings();
settings.BeamResolveInterval = .25f;
var e = BuildBeam(settings, damage: 10);
var outcomes = new List<ShotOutcome>();
e.Zone.ShotResolved.Subscribe(outcome => { outcomes.Add(outcome); });
e.Weapon.Activate();
for (var i = 0; i < 8; i++) e.Zone.Update(.125f); // 1 second in 8 ticks of .125s
Assert.Equal(4, outcomes.Count); // 1s / .25s per roll
Assert.All(outcomes, o => Assert.True(o.Hit));
Assert.Equal(10f, outcomes.Count * 10f * settings.BeamResolveInterval, 2); // Damage * interval, per roll
}
// ---- Regression: FireControl.Fire must run whether or not a presentation is listening. Both
// InstantWeapon.Execute and ConstantWeapon.Execute used to write `OnFire?.Invoke(FireControl.Fire(...))`
// inline -- C#'s null-conditional short-circuits the WHOLE expression, argument included, whenever the
// event has no subscriber, so with no Unity EntityInstance wired up (a headless run,
// docs/headless-playground-cut.md) the shot silently never fired. Caught by this cut's own
// BeamRollsPerInterval fixture; this test pins the discrete-weapon half of the same fix, which no
// existing test could catch because FireAuthorityTests.cs always calls FireControl.Fire directly, never
// through Trigger()/Execute(). Mutation: inline the call back into the null-conditional. ----
[Fact]
public void InstantWeaponFiresWithoutAnyOnFireSubscriber()
{
var cache = AetheriaStores.Open(Catalog + Guid.NewGuid().ToString("N"), catalogWritable: true);
_openCaches.Add(cache);
cache.Upsert(new TestCatalogGlobal { Name = "Temperament" });
cache.Upsert(MakeHullData());
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(10), Range = Constant(1000), MinRange = Constant(0), Velocity = Constant(0),
Spread = Constant(0), DamageSpread = Constant(0), Penetration = Constant(0),
Count = Constant(1), BurstTime = Constant(0), Cooldown = Constant(1000),
DamageCurve = new BezierCurve { Keys = new[] { float4(0, 1, 0, 0), float4(1, 1, 0, 0) } }
} }
});
cache.FlushAsync().Wait();
var ledger = new ProvenanceLedger { Lots = { [1] = new Lot { Origin = new Attributed(), Quality = 1 } } };
var items = new ItemManager(cache, ledger, TestSettings(), _ => { });
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 = 1000, 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 = 1 };
Assert.True(shooter.TryEquip(gun, new int2(0, 0)));
var weapon = (InstantWeapon) shooter.Equipment.Single(e => e.EquippableItem == gun).Behaviors.Single(b => b is Weapon);
zone.Entities.Add(shooter);
shooter.Activate();
zone.Update(0f); // warm up resolved stats
// No target, no OnFire subscriber -- exactly a headless shooter with no target and no presentation.
var resolvedCount = 0;
zone.ShotResolved.Subscribe(_ => resolvedCount++);
weapon.Activate(); // Trigger() -> queues a burst
zone.Update(.016f); // one ordinary tick -- Execute() should fire and the zero-velocity shot resolves same tick
Assert.Equal(1, resolvedCount);
}
// ---- Cut 4's splash rule: FireControl.Splash is an unconditional area effect (no roll), directional per
// target's own hull facing. Tested directly against the function, no weapon/gear catalog needed -- Entity.
// MapEntity (called from the Entity constructor) is enough to give a Ship its Armor/GearOccupancy/Hull. ----
private ProvenanceLedger _splashLedger;
private (ItemManager Items, Zone Zone) BuildSplashZone(GameplaySettings settings, float armor = 0)
{
var cache = AetheriaStores.Open(Catalog + Guid.NewGuid().ToString("N"), catalogWritable: true);
_openCaches.Add(cache);
cache.Upsert(new TestCatalogGlobal { Name = "Temperament" });
cache.Upsert(MakeHullData(armor));
cache.FlushAsync().Wait();
_splashLedger = new ProvenanceLedger();
var items = new ItemManager(cache, _splashLedger, settings, _ => { });
var zone = new Zone(items, new PlanetSettings(), new ZonePack(), new GalaxyZone { Name = "Test", Owner = null }, null);
return (items, zone);
}
private Ship AddShip(ItemManager items, Zone zone, float3 position, float2 direction, int lot)
{
_splashLedger.Lots[lot] = new Lot { Origin = new Attributed(), Quality = 1 };
var hullRef = items.ItemData.RefOf<ItemData>(items.ItemData.GetByName<HullData>("Hull"));
var hull = new EquippableItem { Data = hullRef, Durability = 1000, Lot = lot };
var ship = new Ship(items, zone, hull, new EntitySettings());
ship.Position = position;
ship.Direction = direction;
zone.Entities.Add(ship);
return ship;
}
// Splash is one rule, not per-effect: every entity within radius takes damage, one outside radius takes
// none. Mutation: damage only the nearest entity in range instead of every one.
[Fact]
public void SplashHitsEveryEntityInRadius()
{
var (items, zone) = BuildSplashZone(TestSettings());
var near = AddShip(items, zone, float3(10, 0, 0), float2(0, 1), 1);
var far = AddShip(items, zone, float3(20, 0, 0), float2(0, 1), 2);
var outOfRange = AddShip(items, zone, float3(1000, 0, 0), float2(0, 1), 3);
var nearBefore = near.Hull.Durability;
var farBefore = far.Hull.Durability;
var outBefore = outOfRange.Hull.Durability;
FireControl.Splash(zone, float3.zero, radius: 30, damage: 50, damageType: DamageType.Kinetic);
Assert.True(near.Hull.Durability < nearBefore);
Assert.True(far.Hull.Durability < farBefore);
Assert.Equal(outBefore, outOfRange.Hull.Durability);
}
// Splash is directional over each target's own hull: cells facing the blast take it, cells facing away do
// not. Mutation: damage the whole shape regardless of direction.
[Fact]
public void SplashIsDirectional()
{
// Nonzero armor so an undamaged cell is distinguishable from a damaged one -- with armor 0 every cell
// starts and stays at 0 regardless of which half took the schematic hit.
var (items, zone) = BuildSplashZone(TestSettings(), armor: 10);
// Facing +Z (the default); the blast sits behind the ship along -Z, so the near half of the hull
// (negative-Z cells, closest to the blast) should take damage and the far half (positive-Z) should not.
var ship = AddShip(items, zone, float3(0, 0, 10), float2(0, 1), 1);
FireControl.Splash(zone, float3(0, 0, 0), radius: 50, damage: 1000, damageType: DamageType.Kinetic);
var hullData = (HullData) items.GetData(ship.Hull);
var center = hullData.Shape.CenterOfMass;
var nearCellDamaged = false;
var farCellUntouched = false;
foreach (var v in hullData.Shape.Coordinates)
{
var offset = (float2) v - center;
if (offset.y < -.5f && ship.Armor[v.x, v.y] < ship.MaxArmor[v.x, v.y]) nearCellDamaged = true;
if (offset.y > .5f && ship.Armor[v.x, v.y] >= ship.MaxArmor[v.x, v.y]) farCellUntouched = true;
}
Assert.True(nearCellDamaged);
Assert.True(farCellUntouched);
}
}

View file

@ -0,0 +1,201 @@
#!/usr/bin/env python3
"""Mutation tests for docs/fire-control-cut.md's Cut 4 ("The unused kinds join the same path") -- the last five
weapon kinds (ConstantLaser, ConstantLightning, ConstantParticleWeapon, HitscanEffect, Mine) stop deciding
their own hits through live Physics queries and join FireControl, the same owner Cut 3 gave the other four.
Four rules are pinned here, each against tests/Aetheria.Shared.Tests/FireControlCut4Tests.cs:
1. BeamRollsPerInterval: a continuous weapon (ConstantWeapon) is a sequence of discrete rolls, one per
GameplaySettings.BeamResolveInterval, not a roll per tick and not one roll for the whole burst.
2. InstantWeaponFiresWithoutAnyOnFireSubscriber / (implicitly) BeamRollsPerInterval again: FireControl.Fire
must run whether or not a presentation is listening -- a real Cut 3/4 defect this campaign found and
fixed. Inlining the call back into `OnFire?.Invoke(FireControl.Fire(...))` silently stops every shot from
firing in a headless run (no Unity EntityInstance ever subscribes OnFire/OnBeamShot), because C#'s
null-conditional short-circuits the whole expression, argument included, when the event has no
subscriber. Pinned on both InstantWeapon.cs and ConstantWeapon.cs since Cut 3 introduced the pattern once
and Cut 4 copied it a second time.
3. SplashHitsEveryEntityInRadius: FireControl.Splash (mine/airburst blast) is one rule applied to every
entity within radius, not just the nearest.
4. SplashIsDirectional: splash damage lands on the half of each target's own hull that faces the blast, not
the whole hull.
A no-op control mutation proves the read/replace/restore path is transparent.
Usage:
python tests/mutation_tests_fire_control_cut4.py --cultlib-root <path-to-CultLib-45c2f40-worktree>
Only touches files under this repository; every mutation is reversed before the script exits, including on
failure.
"""
from __future__ import annotations
import argparse
import subprocess
import sys
from dataclasses import dataclass
from pathlib import Path
REPO_ROOT = Path(__file__).resolve().parent.parent
TEST_PROJECT = REPO_ROOT / "tests" / "Aetheria.Shared.Tests"
CONSTANT_WEAPON_CS = "Assets/Scripts/ServerShared/Behaviors/ConstantWeapon.cs"
INSTANT_WEAPON_CS = "Assets/Scripts/ServerShared/Behaviors/InstantWeapon.cs"
FIRE_CONTROL_CS = "Assets/Scripts/ServerShared/FireControl.cs"
@dataclass
class Mutation:
rule: str
file: str
anchor: str
mutated: str
test: str
expect: str # "red" (mutant must be killed) or "green" (the no-op control)
MUTATIONS: list[Mutation] = [
# --- No-op control: proves the anchor/replace/restore mechanism is transparent ---
Mutation(
rule="control (no-op round trip)",
file=CONSTANT_WEAPON_CS,
anchor=" while (_beamTimer >= interval)",
mutated=" while (_beamTimer >= interval)",
test="FireControlCut4Tests.BeamRollsPerInterval",
expect="green",
),
# --- A beam is a sequence of rolls, not a continuous truth: roll per tick (many more, much smaller
# --- outcomes over the same second) must be killed. ---
Mutation(
rule="ConstantWeapon must roll once per BeamResolveInterval, not once per tick",
file=CONSTANT_WEAPON_CS,
anchor=" while (_beamTimer >= interval)",
mutated=" while (true)",
test="FireControlCut4Tests.BeamRollsPerInterval",
expect="red",
),
# --- The opposite direction: rolling once for the whole burst (never draining the timer back below
# --- interval) must also be killed -- a single outcome instead of a sequence. ---
Mutation(
rule="ConstantWeapon must not roll once for the whole burst",
file=CONSTANT_WEAPON_CS,
anchor=" _beamTimer -= interval;",
mutated=" _beamTimer = float.NegativeInfinity;",
test="FireControlCut4Tests.BeamRollsPerInterval",
expect="red",
),
# --- The real defect this campaign found: FireControl.Fire must run whether or not OnBeamShot has a
# --- subscriber. Inlining it back into the null-conditional silently stops every beam from firing in a
# --- headless run (nothing ever subscribes OnBeamShot). ---
Mutation(
rule="ConstantWeapon.Execute must evaluate FireControl.Fire even with no OnBeamShot subscriber",
file=CONSTANT_WEAPON_CS,
anchor=" var shotId = FireControl.Fire(this, Item, Entity, Damage * interval);\n OnBeamShot?.Invoke(shotId);",
mutated=" OnBeamShot?.Invoke(FireControl.Fire(this, Item, Entity, Damage * interval));",
test="FireControlCut4Tests.BeamRollsPerInterval",
expect="red",
),
# --- The same defect, in InstantWeapon.cs (Cut 3's original copy of the pattern) -- caught only by this
# --- cut's own regression test, since FireAuthorityTests.cs always calls FireControl.Fire directly and
# --- never exercises Trigger()/Execute() with zero OnFire subscribers. ---
Mutation(
rule="InstantWeapon.Execute must evaluate FireControl.Fire even with no OnFire subscriber",
file=INSTANT_WEAPON_CS,
anchor=" var shotId = FireControl.Fire(this, Item, Entity);\n OnFire?.Invoke(shotId);",
mutated=" OnFire?.Invoke(FireControl.Fire(this, Item, Entity));",
test="FireControlCut4Tests.InstantWeaponFiresWithoutAnyOnFireSubscriber",
expect="red",
),
# --- Splash is one rule, not per-effect: damaging only the nearest entity in range instead of every one
# --- must be killed. ---
Mutation(
rule="FireControl.Splash must damage every entity in radius, not only the nearest",
file=FIRE_CONTROL_CS,
anchor=" var toTarget = (target.Position - position).xz;\n if (length(toTarget) > radius) continue;",
mutated=" var toTarget = (target.Position - position).xz;\n if (length(toTarget) > radius) continue;\n if (zone.Entities.Where(e => length((e.Position - position).xz) <= radius).OrderBy(e => length((e.Position - position).xz)).First() != target) continue;",
test="FireControlCut4Tests.SplashHitsEveryEntityInRadius",
expect="red",
),
# --- Splash is directional over each target's own hull: damaging the whole shape regardless of facing
# --- must be killed. ---
Mutation(
rule="FireControl.Splash must damage only the half of the hull facing the blast, not the whole shape",
file=FIRE_CONTROL_CS,
anchor=" if (dot(normalize((float2) v - hullData.Shape.CenterOfMass), localDirection) < 0)\n hitShape[v] = true;",
mutated=" hitShape[v] = true;",
test="FireControlCut4Tests.SplashIsDirectional",
expect="red",
),
]
def replace_unique(path: Path, anchor: str, mutated: str) -> None:
original = path.read_text(encoding="utf-8")
count = original.count(anchor)
if count != 1:
raise SystemExit(f"{path}: anchor must occur exactly once, found {count}\n---\n{anchor}")
path.write_text(original.replace(anchor, mutated), encoding="utf-8")
def run_test(cultlib_root: str, test: str) -> tuple[int, str]:
proc = subprocess.run(
["dotnet", "test", str(TEST_PROJECT), f"-p:CultLibRoot={cultlib_root}",
"--filter", f"FullyQualifiedName~{test}"],
capture_output=True, text=True, timeout=300,
)
return proc.returncode, proc.stdout + proc.stderr
def main() -> int:
parser = argparse.ArgumentParser()
parser.add_argument("--cultlib-root", required=True)
args = parser.parse_args()
failures: list[str] = []
originals: dict[str, str] = {}
for m in MUTATIONS:
if m.file not in originals:
originals[m.file] = (REPO_ROOT / m.file).read_text(encoding="utf-8")
def restore_all() -> None:
for file, text in originals.items():
(REPO_ROOT / file).write_text(text, encoding="utf-8")
try:
for m in MUTATIONS:
print(f"\n=== {m.rule} (expect {m.expect}) ===")
path = REPO_ROOT / m.file
replace_unique(path, m.anchor, m.mutated)
code, output = run_test(args.cultlib_root, m.test)
passed = "Passed!" in output and code == 0
if m.expect == "green":
if not passed:
failures.append(f"{m.rule}: expected green (test passing), got failure\n{output[-2000:]}")
else:
print(f"OK: {m.test} passed under a no-op mutation, as expected")
else:
if passed:
failures.append(f"{m.rule}: expected red (mutant killed), but {m.test} still passed")
else:
print(f"OK: {m.test} went red under the mutation, as expected")
# Restore this file immediately so the next mutation starts from a clean tree.
path.write_text(originals[m.file], encoding="utf-8")
finally:
restore_all()
if failures:
print("\n=== FAILURES ===")
for f in failures:
print(f"- {f}")
return 1
print("\nAll mutations behaved as declared.")
return 0
if __name__ == "__main__":
sys.exit(main())