From 034e8d3e78242e7875c8e4df4f79a1b2e09706bd Mon Sep 17 00:00:00 2001 From: Meta Vens Date: Sat, 19 Sep 2026 15:17:35 +0200 Subject: [PATCH] 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 --- .../FireControlCut4Tests.cs | 329 ++++++++++++++++++ tests/mutation_tests_fire_control_cut4.py | 201 +++++++++++ 2 files changed, 530 insertions(+) create mode 100644 tests/Aetheria.Shared.Tests/FireControlCut4Tests.cs create mode 100644 tests/mutation_tests_fire_control_cut4.py diff --git a/tests/Aetheria.Shared.Tests/FireControlCut4Tests.cs b/tests/Aetheria.Shared.Tests/FireControlCut4Tests.cs new file mode 100644 index 000000000..cc30e415c --- /dev/null +++ b/tests/Aetheria.Shared.Tests/FireControlCut4Tests.cs @@ -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 _openCaches = new List(); + + 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(items.ItemData.GetByName("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(items.ItemData.GetByName("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(items.ItemData.GetByName("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(items.ItemData.GetByName("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(items.ItemData.GetByName("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(); + 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(items.ItemData.GetByName("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(items.ItemData.GetByName("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(items.ItemData.GetByName("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); + } +} diff --git a/tests/mutation_tests_fire_control_cut4.py b/tests/mutation_tests_fire_control_cut4.py new file mode 100644 index 000000000..87fb19e22 --- /dev/null +++ b/tests/mutation_tests_fire_control_cut4.py @@ -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 + +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())