Merge Cuts 10 and 11: the hot path gates first, and mutation testing moves to Stryker
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
commit
cd846916bc
32 changed files with 803 additions and 5864 deletions
3
.gitignore
vendored
3
.gitignore
vendored
|
|
@ -109,3 +109,6 @@ tests/_scratch_*
|
|||
.nuget-sandbox/
|
||||
.dotnet-sandbox/
|
||||
.appdata-sandbox/
|
||||
|
||||
# Stryker.NET mutation reports (regenerated per run)
|
||||
StrykerOutput/
|
||||
|
|
|
|||
|
|
@ -141,13 +141,37 @@ public static class FireControl
|
|||
// reads this to decide whether a shot is worth taking; the HUD reads it to draw a number; Fire below reads
|
||||
// it once, at the instant the trigger is pulled, and freezes the result into the shot (R10, Q6). Two reads
|
||||
// a tick apart may differ; nothing here or anywhere else caches one.
|
||||
// Cut 10 (docs/fire-control-cut.md): the hot path. Every AI and turret calls this per weapon per tick,
|
||||
// and the common case is a target out of range or out of arc -- so the cheap gates run first and bail,
|
||||
// exactly as they did before the diagnostics refactor. The factors are computed only for a shot that is
|
||||
// actually possible, and they come from the same functions Inspect uses: one fire-control model, shared
|
||||
// at the level of its factors rather than by routing the hot path through a diagnostic struct.
|
||||
// HitProbabilityMatchesInspect pins that the two can never disagree.
|
||||
public static float HitProbability(Weapon weapon, Entity source, Entity target)
|
||||
{
|
||||
return Inspect(weapon, source, target).PBase;
|
||||
if (target == null) return 0f;
|
||||
if (!source.VisibleEntities.Contains(target)) return 0f;
|
||||
|
||||
var toTarget = target.Position - source.Position;
|
||||
var range = length(toTarget);
|
||||
if (range < weapon.MinRange || range > weapon.Range) return 0f;
|
||||
if (weapon is LockWeapon lockWeapon && !lockWeapon.IsLocked) return 0f;
|
||||
if (!InArc(weapon.Item, toTarget)) return 0f;
|
||||
|
||||
var settings = source.ItemManager.GameplaySettings;
|
||||
var targetHull = source.ItemManager.GetData(target.Hull) as HullData;
|
||||
var info = source.EntityInfoGathered.TryGetValue(target, out var gathered) ? gathered : 0f;
|
||||
return Accuracy(source)
|
||||
* PSensor(settings, Resolution(source), info)
|
||||
* PSpread(weapon, targetHull, range, settings)
|
||||
* POnHull(source, target, targetHull, Precision(source));
|
||||
}
|
||||
|
||||
// Presentation-only inspection of the exact factors HitProbability owns. Keeping this calculation here
|
||||
// prevents the debug HUD from growing a second, subtly different fire-control model.
|
||||
// Presentation only: the debug HUD's view of exactly the factors HitProbability multiplies. It computes
|
||||
// every factor unconditionally, gates included, because a HUD wants to see why a shot is impossible --
|
||||
// which is precisely why the hot path must not be routed through it. Both read the same factor
|
||||
// functions below, and PBase is HitProbability's own answer, so the gates and the product have one
|
||||
// owner and the HUD cannot grow a second, subtly different model.
|
||||
public static FireControlDiagnostic Inspect(Weapon weapon, Entity source, Entity target)
|
||||
{
|
||||
var settings = source.ItemManager.GameplaySettings;
|
||||
|
|
@ -170,44 +194,45 @@ public static class FireControl
|
|||
diagnostic.Locked = !(weapon is LockWeapon lockWeapon) || lockWeapon.IsLocked;
|
||||
diagnostic.InArc = InArc(weapon.Item, toTarget);
|
||||
|
||||
var info = source.EntityInfoGathered.TryGetValue(target, out var gathered) ? gathered : 0f;
|
||||
// Cut 6c, 6c.1 (operator ruling 2026-09-19): Resolution stays a benefit -- higher is better -- so the
|
||||
// formula takes its reciprocal to derive the actual info ceiling instead of reading Resolution as
|
||||
// that ceiling directly. Resolution 1 (the unaided fallback above) yields a ceiling of 1: needs
|
||||
// complete information, the floor by construction. A higher Resolution pulls the ceiling down toward
|
||||
// the detection threshold, needing less info before sensor state stops limiting hits. The max guards
|
||||
// a zero or negative authored Resolution; no authored value should ever reach it.
|
||||
diagnostic.Info = info;
|
||||
diagnostic.InfoDemandCeiling = settings.TargetDetectionInfoThreshold +
|
||||
(1f - settings.TargetDetectionInfoThreshold) / max(diagnostic.Resolution, 1e-3f);
|
||||
diagnostic.PSensor = saturate(unlerp(settings.TargetDetectionInfoThreshold, diagnostic.InfoDemandCeiling, info));
|
||||
|
||||
var targetHull = source.ItemManager.GetData(target.Hull) as HullData;
|
||||
diagnostic.Info = source.EntityInfoGathered.TryGetValue(target, out var gathered) ? gathered : 0f;
|
||||
diagnostic.InfoDemandCeiling = InfoDemandCeiling(settings, diagnostic.Resolution);
|
||||
diagnostic.PSensor = PSensor(settings, diagnostic.Resolution, diagnostic.Info);
|
||||
diagnostic.PSpread = PSpread(weapon, targetHull, diagnostic.Range, settings);
|
||||
diagnostic.POnHull = POnHull(source, target, targetHull, diagnostic.Precision);
|
||||
|
||||
diagnostic.PSpread = 1f;
|
||||
if (weapon.Spread > 0)
|
||||
{
|
||||
var halfExtent = .5f * max(targetHull.Shape.Width, targetHull.Shape.Height) * settings.SchematicCellSize;
|
||||
var angularRadius = degrees(atan(halfExtent / diagnostic.Range));
|
||||
diagnostic.PSpread = saturate(angularRadius / (weapon.Spread / 2f));
|
||||
}
|
||||
|
||||
// Cut 6d (docs/fire-control-cut.md): the dart-throw kernel's other half. pSpread above prices whether
|
||||
// the weapon's own barrel-dispersion cone even reaches the ship's silhouette at this range (weapon
|
||||
// hardware, whole-hull bounding size, range-dependent, aim-point-blind). pOnHull prices whether the
|
||||
// targeting system's own aim scatter, centred on whatever is actually aimed at, lands on the hull's
|
||||
// real schematic footprint rather than open grid around it (targeting hardware, per-cell shape,
|
||||
// range-blind, aim-point-aware). Different inputs, different failure modes; see Cut(0's report for the
|
||||
// double-charging call). Aim point is source.ResolvedTargetItem, exactly what Fire freezes below --
|
||||
// HitProbability and Fire read the same reveal-gated aim point, never two.
|
||||
diagnostic.POnHull = HullKernel(targetHull,
|
||||
ResolveAimPoint(target, targetHull, source.ResolvedTargetItem).AimPoint, diagnostic.Precision).POnHull;
|
||||
|
||||
if (diagnostic.Visible && diagnostic.InRange && diagnostic.Locked && diagnostic.InArc)
|
||||
diagnostic.PBase = diagnostic.Accuracy * diagnostic.PSensor * diagnostic.PSpread * diagnostic.POnHull;
|
||||
diagnostic.PBase = HitProbability(weapon, source, target);
|
||||
return diagnostic;
|
||||
}
|
||||
|
||||
// Cut 6c, 6c.1 (operator ruling 2026-09-19): Resolution stays a benefit -- higher is better -- so the
|
||||
// formula takes its reciprocal to derive the actual info ceiling instead of reading Resolution as that
|
||||
// ceiling directly. Resolution 1 (the unaided fallback) yields a ceiling of 1: needs complete
|
||||
// information, the floor by construction. The max guards a zero or negative authored Resolution; no
|
||||
// authored value should ever reach it.
|
||||
private static float InfoDemandCeiling(GameplaySettings settings, float resolution) =>
|
||||
settings.TargetDetectionInfoThreshold + (1f - settings.TargetDetectionInfoThreshold) / max(resolution, 1e-3f);
|
||||
|
||||
private static float PSensor(GameplaySettings settings, float resolution, float info) =>
|
||||
saturate(unlerp(settings.TargetDetectionInfoThreshold, InfoDemandCeiling(settings, resolution), info));
|
||||
|
||||
// Whether the weapon's own barrel-dispersion cone reaches the ship's silhouette at this range: weapon
|
||||
// hardware, whole-hull bounding size, range-dependent, aim-point-blind. 1 for any zero-spread weapon.
|
||||
private static float PSpread(Weapon weapon, HullData targetHull, float range, GameplaySettings settings)
|
||||
{
|
||||
if (weapon.Spread <= 0) return 1f;
|
||||
var halfExtent = .5f * max(targetHull.Shape.Width, targetHull.Shape.Height) * settings.SchematicCellSize;
|
||||
var angularRadius = degrees(atan(halfExtent / range));
|
||||
return saturate(angularRadius / (weapon.Spread / 2f));
|
||||
}
|
||||
|
||||
// Cut 6d: whether the targeting system's own aim scatter, centred on whatever is actually aimed at, lands
|
||||
// on the hull's real schematic footprint rather than open grid around it. The aim point is
|
||||
// source.ResolvedTargetItem, exactly what Fire freezes -- HitProbability, Inspect and Fire read the same
|
||||
// reveal-gated aim point, never two.
|
||||
private static float POnHull(Entity source, Entity target, HullData targetHull, float precision) =>
|
||||
HullKernel(targetHull, ResolveAimPoint(target, targetHull, source.ResolvedTargetItem).AimPoint, precision).POnHull;
|
||||
|
||||
public static float DeviationProbability(PendingShot shot, float now, out float deviation)
|
||||
{
|
||||
var elapsed = now - shot.FireTime;
|
||||
|
|
@ -235,7 +260,7 @@ public static class FireControl
|
|||
|
||||
// R1's engage gate and probability, evaluated now and frozen: nothing at commit time re-reads a stat,
|
||||
// an info level or a range. Only live target *position* (deviation) is read again, at commit.
|
||||
var pBase = target != null ? HitProbability(weapon, source, target) : 0f;
|
||||
var pBase = HitProbability(weapon, source, target);
|
||||
|
||||
var targetVelocity = float3.zero;
|
||||
var targetPosition = source.Position;
|
||||
|
|
@ -303,12 +328,14 @@ public static class FireControl
|
|||
var targetGone = shot.Target != null && !zone.Entities.Contains(shot.Target);
|
||||
if (sourceGone || targetGone)
|
||||
{
|
||||
if (!shot.Committed)
|
||||
{
|
||||
shot.Outcome = MakeOutcome(shot, false, false, false, int2.zero, null, now);
|
||||
zone.ShotCommitted.OnNext(shot.Outcome);
|
||||
}
|
||||
zone.ShotResolved.OnNext(shot.Outcome);
|
||||
// Cut 11 (Soul C4): a shot whose source or target has left the zone resolves as a miss whichever
|
||||
// stage it is at (0b table). The resolution is published as a fresh miss rather than by
|
||||
// rewriting shot.Outcome: a committed outcome is immutable (R4) and stays a true record of what
|
||||
// the commit decided, while ShotResolved reports what actually happened -- nothing, because the
|
||||
// target is gone. Republishing the committed outcome here used to put a hit marker on a corpse.
|
||||
var miss = MakeOutcome(shot, false, false, false, int2.zero, null, now);
|
||||
if (!shot.Committed) zone.ShotCommitted.OnNext(miss);
|
||||
zone.ShotResolved.OnNext(miss);
|
||||
shots.RemoveAt(i);
|
||||
continue;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1697,3 +1697,154 @@ configuration, not the range it claims), plus the harness-audit items: the
|
|||
tree-clean verdict restores before it measures, a red result cannot distinguish a
|
||||
killed mutant from a compile failure, and the no-op control covers only one file
|
||||
per harness.
|
||||
|
||||
---
|
||||
|
||||
## Cut 11: mutation testing moves to Stryker, and the survivors it found
|
||||
|
||||
Date: 2026-09-22. Operator, on the hand-written anchor harnesses: "What is the
|
||||
point of those mutation anchors? Tests should cover how the code behaves, not how
|
||||
it is shaped." And on Stryker: "sounds good; we don't need to run with every
|
||||
mutation active, right? > to >= on float thresholds is indeed petty."
|
||||
|
||||
### Why
|
||||
|
||||
The Eureka principle (2026-09-15) is sound and stays: every rule needs a test that
|
||||
fails when the rule breaks. The mechanism that accreted on it over the following
|
||||
week -- literal-text anchors, perl substitution, per-cut bash and Python harnesses
|
||||
-- coupled mutation testing to code shape. It stranded anchors on every refactor
|
||||
(`72c0109c`, Cut 9, Cut 10), and at Cut 10 an anchor kept matching exactly once
|
||||
while silently re-targeting onto `Inspect`, mutating code the tests no longer ran.
|
||||
Soul's harness audit also found the tree-clean verdict restoring before it
|
||||
measured, and a compile failure counting as a kill.
|
||||
|
||||
A Stryker spike against `FireControl.cs` alone: 276 mutants, 7m18s, 208 killed,
|
||||
67 survived. It killed every mutation the hand-written harnesses defended, never
|
||||
edits a source file, and has nothing to re-anchor.
|
||||
|
||||
### 11.1 Retire the ServerShared harnesses
|
||||
|
||||
Delete the 23 hand-rolled harnesses (5,816 lines) whose targets are all under
|
||||
`Assets/Scripts/ServerShared` -- every `mutation_tests_fire_control_*`, every
|
||||
`mutation_tests_stats_power_*`, `mutation_tests_shield_reserve.py`,
|
||||
`mutation_tests_condition_ratio_cut8.py` and `mutation_tests.py`. Stryker's scope
|
||||
covers all of their targets; the behavioural tests they measured stay.
|
||||
|
||||
**Kept, recorded, not yet answered:** `mutation_tests_addressables_cut.py` and the
|
||||
three `mutation_tests_shield_panel_cut*.py`. They target Unity Editor probes
|
||||
(`EngineAssetCheck.cs`, `ShieldPanelCut*Verify.cs`) that Unity compiles outside
|
||||
`Aetheria.Shared`, so Stryker cannot reach them. They carry the same anchor
|
||||
fragility; the right answer for GPU and editor code is probably that the batchmode
|
||||
probes are themselves the behavioural tests, but that is a separate decision.
|
||||
|
||||
### 11.2 Scope, pinned
|
||||
|
||||
`tests/Aetheria.Shared.Tests/stryker-config.json` scopes mutation to
|
||||
`Assets/Scripts/ServerShared/**` and excludes plugins. Per cut, run against the
|
||||
cut's own diff:
|
||||
|
||||
dotnet tool restore
|
||||
cd tests/Aetheria.Shared.Tests
|
||||
CULTLIB_ROOT=<admitted CultLib worktree> dotnet stryker --since:<base commit>
|
||||
|
||||
**Float-threshold boundary flips are known-equivalent.** Stryker files `>`→`>=`
|
||||
under the same Equality mutator as `==`→`!=`, and the latter catches real bugs, so
|
||||
the mutator stays on. A survivor that only moves a float comparison across its
|
||||
boundary is triaged as equivalent and never chased. For the same reason the raw
|
||||
mutation score is not a gate; the survivor list is the signal.
|
||||
|
||||
### 11.3 Degenerate fixtures
|
||||
|
||||
Most of the 67 survivors trace to three constants shared across the fire-control
|
||||
fixtures, each of which makes a family of mutants arithmetically identical to the
|
||||
original -- the same defect class as the zero-velocity target and the durability
|
||||
coincidence, found here as a pattern instead of one at a time:
|
||||
|
||||
- **Shooter at `float3.zero`.** `target - source` equals `target + source`, so four
|
||||
separate position mutations survive (`FireControl.cs` HitProbability, Inspect,
|
||||
Fire, Apply).
|
||||
- **Fire time zero.** `now - FireTime` equals `now + FireTime`, so the elapsed-time
|
||||
mutation survives -- which means `EvasionCountsUntilCommitAndNotAfter` still does
|
||||
not defend the rule Cut 5 gave it a moving target to defend.
|
||||
- **Spread zero everywhere.** `PSpread` returns 1 on every fixture, so it is
|
||||
effectively untested, and `*` versus `/` against it is indistinguishable.
|
||||
|
||||
Move shooters off the origin, fire at nonzero times, and give some fixtures real
|
||||
spread. Fix the fixture, never weaken the assertion.
|
||||
|
||||
### 11.4 Behavioural tests the survivors point at
|
||||
|
||||
- **Splash handedness.** Flipping the sign in `var right = float2(forward.y,
|
||||
-forward.x)` passes every test. That is the operator's "damage on the wrong side
|
||||
of the ship," and Self said on 2026-09-19 that Cut 6b would add a test pinning
|
||||
that a port blast damages port cells; it never landed and nothing checked. Pin it.
|
||||
- **The shield loses energy when it absorbs.** Deleting `Shield.TakeHit` from the
|
||||
discrete path survives, because tests assert the hull was untouched and never
|
||||
that the shield paid for it.
|
||||
- **Splash's shield-absorb branch** has no coverage at all.
|
||||
- **Zones roll differently.** `CombatSeed *` → `/` makes the seed zone-independent
|
||||
and survives; `TheDieIsUniform` checks uniformity within a zone only. Tighten it
|
||||
enough that the `MixSeed` weakenings that survive it (`>>`→`<<`, `^=`→`|=`) die.
|
||||
- **A shot whose target left the zone resolves as a miss** (Soul C4). Today only
|
||||
the uncommitted branch rewrites the outcome; a committed hit on a target that
|
||||
died mid-flight is published as a hit. Fix the code and pin it.
|
||||
|
||||
### Verification
|
||||
|
||||
Stryker over `FireControl.cs` before and after: every non-equivalent survivor
|
||||
above killed, the remainder triaged by name. 231 tests plus the new ones; Unity
|
||||
batchmode clean.
|
||||
|
||||
### Status (2026-09-22)
|
||||
|
||||
Landed on `codex/fire-control-10`: `8ef3b34c` (Stryker tool), `b305debd` (map,
|
||||
config), `724ec5e7` (11.1), `a6543af7` (11.4 and the C4 fix), `1b187db4` (11.3),
|
||||
`b0fd3cd0` (second round, below). 244 tests.
|
||||
|
||||
Stryker over `FireControl.cs`, 278 mutants per run:
|
||||
|
||||
| Run | Killed | Survived | Score |
|
||||
|---|---|---|---|
|
||||
| Spike, before Cut 11 | 208 | 67 | 74.6% |
|
||||
| After 11.4 | 219 | 58 | 78.6% |
|
||||
| After 11.3 fixtures | 225 | 51 | 81.1% |
|
||||
| After the second round | 225 | 43 | 83.3% |
|
||||
|
||||
**The second round** came from reading the 51. Four were real undefended
|
||||
behaviour and one was a duplicated formula:
|
||||
|
||||
- `Inspect` recomputed `PBase` as its own product of the factors, so the gates and
|
||||
the product had two owners, and a `*`→`/` mutant of the HUD's copy survived the
|
||||
agreement test (every fixture had spread 0, so `PSpread` was 1). `PBase` is now
|
||||
`HitProbability`'s own answer; the test became
|
||||
`TheHudShowsTheFactorsTheSimulationMultiplies`, over a spread cone that does not
|
||||
fill the silhouette. `Fire`'s redundant `target != null ?` guard around
|
||||
`HitProbability` went with it.
|
||||
- A beam shot's `damageOverride` could be dropped: `BeamRollsPerInterval` computed
|
||||
the expected damage from its own roll count, a tautology, now deleted.
|
||||
`ADamageOverrideIsWhatTheShotCarries`.
|
||||
- `Tracking` could ignore the fitted targeting system: no fixture's target ever
|
||||
moved off its predicted line. `AFittedTargetingSystemForgivesAJink`.
|
||||
- A shot orphaned before commit could skip `ShotCommitted`, or publish its miss as
|
||||
shielded. `ShotAtTargetThatLeavesBeforeCommitCommitsAsAMiss`.
|
||||
- `ArrivalIn` (the HUD countdown) was unchecked; asserted in the C4 test.
|
||||
|
||||
**The 43 that remain, triaged:**
|
||||
|
||||
- *Boundary flips, equivalent (23):* L52, 54, 56, 75, 135 (two), 157 (two), 193
|
||||
(two), 223, 273, 410, 418 (two), 465 (two), 503, 518, 524, 586, 605, 608.
|
||||
- *`MixSeed` shift and xor weakenings, measured equivalent (4):* L382, 384, 386
|
||||
(two). 11.4 asked for them to die; measured, the die stays uniform and
|
||||
zone-distinct under each, so there is no behaviour to pin. They change how the
|
||||
mixer is built, not whether the die is fair.
|
||||
- *Unreachable through the live callers (8):* L240 and L241
|
||||
(`DeviationProbability`'s null-target branch; the engine guards it, only the HUD
|
||||
can pass null), L283 (three; airburst has no authored radius anywhere, recorded
|
||||
since Cut 6b), L320 (empty-list early return, an optimisation), L410 `||` (a
|
||||
nonzero `PBase` implies a target), L158 and L610 (no coverage: an unlocked lock
|
||||
weapon reaching this line, and the last-cell fallback of the weighted pick).
|
||||
- *Recorded gaps, not yet reached (8):* L135 ternary (two; no test that the AI in
|
||||
`Combat.cs` leads a moving target), L435 (three; the aimed flag on an outcome),
|
||||
L464 and L465 negate (the `Apply` hit direction through the `FireControl`
|
||||
path), L585 and L587 (the aimed item's centroid as aim point). These are "not
|
||||
yet reached," not "unreachable."
|
||||
|
|
|
|||
13
dotnet-tools.json
Normal file
13
dotnet-tools.json
Normal file
|
|
@ -0,0 +1,13 @@
|
|||
{
|
||||
"version": 1,
|
||||
"isRoot": true,
|
||||
"tools": {
|
||||
"dotnet-stryker": {
|
||||
"version": "5.0.0",
|
||||
"commands": [
|
||||
"dotnet-stryker"
|
||||
],
|
||||
"rollForward": false
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -375,6 +375,11 @@ public sealed class FireAuthorityTests : IDisposable
|
|||
// tracking needed -- makes the mutation observable even with no jink at all: the mutant
|
||||
// mispredicts the intercept by Velocity * CommitHorizon, which the post-commit-jink assertion
|
||||
// below (expected to still hit) catches as a spurious miss.
|
||||
// Cut 11: advance the zone clock before firing. Build leaves it at 0, so every shot fired with
|
||||
// FireTime = 0 -- and `now - FireTime` equals `now + FireTime` when FireTime is 0. That is why the
|
||||
// moving target Cut 5 added still left this test's own declared mutation (measure elapsed wrongly)
|
||||
// alive: Stryker found `now + shot.FireTime` surviving.
|
||||
e.Zone.Update(2.5f);
|
||||
e.Target.Velocity = float2(4, 0);
|
||||
FireControl.Fire(e.Weapon, e.WeaponItem, e.Shooter);
|
||||
var t = 0f;
|
||||
|
|
|
|||
449
tests/Aetheria.Shared.Tests/FireControlCut11Tests.cs
Normal file
449
tests/Aetheria.Shared.Tests/FireControlCut11Tests.cs
Normal file
|
|
@ -0,0 +1,449 @@
|
|||
/* 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);
|
||||
}
|
||||
|
||||
private static GameplaySettings TestSettings(float schematicCellSize = 1f) => 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,
|
||||
TargetArmorInfoThreshold = .2f,
|
||||
TargetGearInfoThreshold = .8f,
|
||||
FiringArc = 170,
|
||||
CommitHorizon = .5f,
|
||||
SchematicCellSize = schematicCellSize,
|
||||
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,
|
||||
bool markers = false, float spread = 0, float schematicCellSize = 1f)
|
||||
{
|
||||
// 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),
|
||||
Velocity = Constant(velocity), Spread = Constant(spread), 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.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 };
|
||||
var items = new ItemManager(cache, ledger, TestSettings(schematicCellSize), _ => { });
|
||||
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
|
||||
shooter.Target.Value = target;
|
||||
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
|
||||
}
|
||||
|
||||
// The same rule on Splash's path, whose absorb branch had no test coverage at all. Mutation: delete
|
||||
// TakeHit from Splash's absorb branch.
|
||||
[Fact]
|
||||
public void SplashShieldAbsorptionDrainsTheReserve()
|
||||
{
|
||||
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)
|
||||
{
|
||||
FireControl.Splash(e.Zone, e.Target.Position, radius: 20, damage: 5, damageType: DamageType.Kinetic);
|
||||
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;
|
||||
var commits = 0;
|
||||
using var c = e.Zone.ShotCommitted.Subscribe(o => { committed = o; commits++; });
|
||||
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);
|
||||
// What the HUD's arrival countdown reads: time left in flight at commit, 5 s - 4.7 s.
|
||||
Assert.Equal(.3f, committed.ArrivalIn, 3);
|
||||
|
||||
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)");
|
||||
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);
|
||||
Assert.Empty(committed);
|
||||
|
||||
e.Zone.Entities.Remove(e.Target);
|
||||
e.Zone.Update(1f);
|
||||
|
||||
foreach (var outcome in new[] { Assert.Single(committed), Assert.Single(resolved) })
|
||||
{
|
||||
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");
|
||||
}
|
||||
Assert.Empty(e.Zone.PendingShots);
|
||||
}
|
||||
|
||||
// ---- 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);
|
||||
}
|
||||
|
||||
// ---- Splash damages the side the blast came from. ----
|
||||
|
||||
// The operator's "damage on the wrong side of the ship." Flipping the sign in Splash's
|
||||
// `right = float2(forward.y, -forward.x)` survived every earlier suite, and it survives this test too at
|
||||
// the default facing (0, 1): the flip only changes which side is hit when a ship faces more along world x
|
||||
// than z. So the target is spun through facings where the flip bites as well as ones where it does not,
|
||||
// and blasted from each side in turn. The blast side is computed here independently of FireControl --
|
||||
// starboard is the facing rotated clockwise -- so this is not the code checking itself.
|
||||
[Theory]
|
||||
[InlineData(0f, 1f)]
|
||||
[InlineData(1f, 0f)]
|
||||
[InlineData(.8f, .6f)]
|
||||
[InlineData(-.8f, -.6f)]
|
||||
[InlineData(-.6f, .8f)]
|
||||
public void SplashDamagesTheSideTheBlastCameFrom(float fx, float fy)
|
||||
{
|
||||
foreach (var fromStarboard in new[] { true, false })
|
||||
{
|
||||
var e = Build(markers: true);
|
||||
var facing = normalize(float2(fx, fy));
|
||||
e.Target.Direction = facing;
|
||||
var starboard = float2(facing.y, -facing.x);
|
||||
var side = fromStarboard ? starboard : -starboard;
|
||||
var blast = e.Target.Position + float3(side.x, 0, side.y) * 10f;
|
||||
|
||||
FireControl.Splash(e.Zone, blast, radius: 20, damage: 10, damageType: DamageType.Kinetic);
|
||||
|
||||
var nearMarker = fromStarboard ? e.StarboardMarker : e.PortMarker;
|
||||
var farMarker = fromStarboard ? e.PortMarker : e.StarboardMarker;
|
||||
var label = $"facing ({fx}, {fy}), blast from {(fromStarboard ? "starboard" : "port")}";
|
||||
Assert.True(nearMarker.EquippableItem.Durability < 100, $"{label}: the marker facing the blast took no damage");
|
||||
Assert.Equal(100f, farMarker.EquippableItem.Durability);
|
||||
}
|
||||
}
|
||||
|
||||
// ---- 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
|
||||
}
|
||||
|
||||
// ---- 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");
|
||||
}
|
||||
}
|
||||
|
|
@ -171,7 +171,7 @@ public sealed class FireControlCut4Tests : IDisposable
|
|||
}
|
||||
|
||||
// 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)
|
||||
// 1/BeamResolveInterval outcomes (the damage each carries is FireControlCut11Tests.ADamageOverrideIsWhatTheShotCarries). 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).
|
||||
|
|
@ -190,7 +190,6 @@ public sealed class FireControlCut4Tests : IDisposable
|
|||
|
||||
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
|
||||
|
|
|
|||
|
|
@ -255,7 +255,7 @@ public sealed class FireControlCut9Tests : IDisposable
|
|||
public Weapon Weapon;
|
||||
}
|
||||
|
||||
private KernelEngagement BuildKernelEngagement(GameplaySettings settings, Shape hullShape, float precision)
|
||||
private KernelEngagement BuildKernelEngagement(GameplaySettings settings, Shape hullShape, float precision, float spread = 0)
|
||||
{
|
||||
var hullData = new HullData
|
||||
{
|
||||
|
|
@ -274,7 +274,7 @@ public sealed class FireControlCut9Tests : IDisposable
|
|||
Behaviors = { new InstantWeaponData
|
||||
{
|
||||
Damage = Constant(10), Range = Constant(1000), MinRange = Constant(0),
|
||||
Velocity = Constant(0), Spread = Constant(0), DamageSpread = Constant(0),
|
||||
Velocity = Constant(0), Spread = Constant(spread), 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) } }
|
||||
} }
|
||||
|
|
@ -285,7 +285,10 @@ public sealed class FireControlCut9Tests : IDisposable
|
|||
MinimumTemperature = -1000, MaximumTemperature = 1000, OptimalTemperature = 0, PlateauWidth = 2000,
|
||||
Behaviors = { new TargetingSystemData
|
||||
{
|
||||
Accuracy = Constant(1f), Resolution = Constant(1000f), Precision = Constant(precision), Tracking = Constant(100000f)
|
||||
// Cut 11: a finite Resolution, so pSensor actually varies with info. At 1000 it saturated to exactly 1
|
||||
// for any info above the detection threshold, which made `x * pSensor` and `x / pSensor` the same
|
||||
// number and let a mutant of Inspect's product survive HitProbabilityMatchesInspect.
|
||||
Accuracy = Constant(1f), Resolution = Constant(2f), Precision = Constant(precision), Tracking = Constant(100000f)
|
||||
} }
|
||||
});
|
||||
cache.FlushAsync().Wait();
|
||||
|
|
@ -374,6 +377,99 @@ public sealed class FireControlCut9Tests : IDisposable
|
|||
$"pOnHull fell from {samples[peakIndex].POnHull:F4} at Precision {samples[peakIndex].Precision} to {samples[i].POnHull:F4} at Precision {samples[i].Precision} -- the cliff is back");
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------------------------------
|
||||
// Cut 10 (docs/fire-control-cut.md): one fire-control model, shared at the level of its factors.
|
||||
// HitProbability is the hot path -- every AI and turret, per weapon, per tick -- and Inspect is the
|
||||
// debug HUD's view. The diagnostics refactor (72c0109c) kept them as one model by routing the hot path
|
||||
// THROUGH Inspect, which made every call build the diagnostic struct and run the aim-point and kernel
|
||||
// work before checking whether the target was even in range: 9.87 us and 448 B per call, measured by a
|
||||
// Soul pass, on paths that used to bail in nanoseconds. Both now call the same factor functions.
|
||||
// ---------------------------------------------------------------------------------------------------
|
||||
|
||||
// The invariant the diagnostics refactor was protecting: the HUD can never report a probability the
|
||||
// simulation does not use. Inspect.PBase is HitProbability's own answer, so the gates and the product
|
||||
// have one owner; what is left to defend is that the factors the HUD displays are the ones the
|
||||
// simulation multiplies, and that a gate the HUD shows closed is one the simulation refuses on. Swept
|
||||
// across Precision, info levels and a spread cone that does not fill the silhouette -- every factor
|
||||
// strictly between 0 and 1 somewhere, so a factor dropped, doubled or divided in either caller shows.
|
||||
[Fact]
|
||||
public void TheHudShowsTheFactorsTheSimulationMultiplies()
|
||||
{
|
||||
var settings = TestSettings();
|
||||
var checkedConfigurations = 0;
|
||||
var sawPartialSpread = false;
|
||||
foreach (var precision in new[] { .3f, 1.19f, 5f })
|
||||
{
|
||||
// 6 cells wide at cell size 1 subtends ~3.4 degrees at 100 units, inside an 8-degree cone.
|
||||
var e = BuildKernelEngagement(settings, SolidShape(6, 12), precision, spread: 8);
|
||||
|
||||
foreach (var info in new[] { .2f, .5f, 1f })
|
||||
{
|
||||
e.Shooter.EntityInfoGathered[e.Target] = info;
|
||||
var hud = FireControl.Inspect(e.Weapon, e.Shooter, e.Target);
|
||||
var hot = FireControl.HitProbability(e.Weapon, e.Shooter, e.Target);
|
||||
Assert.True(hud.Visible && hud.InRange && hud.Locked && hud.InArc, $"info {info}: precondition, every gate open");
|
||||
Assert.True(hot > 0f, $"info {info}, Precision {precision}: precondition, a shot that can land");
|
||||
Assert.Equal(hot, hud.Accuracy * hud.PSensor * hud.PSpread * hud.POnHull, 5);
|
||||
sawPartialSpread |= hud.PSpread < .99f;
|
||||
checkedConfigurations++;
|
||||
}
|
||||
e.Shooter.EntityInfoGathered[e.Target] = 1f;
|
||||
|
||||
void Closed(string label, Func<FireControlDiagnostic, bool> gate)
|
||||
{
|
||||
var hud = FireControl.Inspect(e.Weapon, e.Shooter, e.Target);
|
||||
Assert.False(gate(hud), $"{label}: the HUD must show this gate closed");
|
||||
Assert.Equal(0f, FireControl.HitProbability(e.Weapon, e.Shooter, e.Target));
|
||||
checkedConfigurations++;
|
||||
}
|
||||
|
||||
e.Target.Position = float3(0, 0, 5000);
|
||||
Closed("out of range", d => d.InRange);
|
||||
e.Target.Position = float3(0, 0, -100);
|
||||
Closed("behind the mount", d => d.InArc);
|
||||
|
||||
// A shooter far from the origin: at float3.zero `target - source` and `target + source` are the
|
||||
// same vector, so a sign mutant in the bearing only shows here (500 in range vs 1300 out).
|
||||
e.Shooter.Position = float3(600, 0, 0);
|
||||
e.Target.Position = float3(600, 0, 500);
|
||||
var far = FireControl.Inspect(e.Weapon, e.Shooter, e.Target);
|
||||
Assert.Equal(500f, far.Range, 3);
|
||||
Assert.True(far.InRange && FireControl.HitProbability(e.Weapon, e.Shooter, e.Target) > 0f, "shooter far from the origin");
|
||||
checkedConfigurations++;
|
||||
e.Shooter.Position = float3.zero;
|
||||
e.Target.Position = float3(0, 0, 100);
|
||||
|
||||
e.Shooter.VisibleEntities.Remove(e.Target);
|
||||
Closed("not visible", d => d.Visible);
|
||||
|
||||
Assert.False(FireControl.Inspect(e.Weapon, e.Shooter, null).HasTarget);
|
||||
Assert.Equal(0f, FireControl.HitProbability(e.Weapon, e.Shooter, null));
|
||||
}
|
||||
Assert.True(sawPartialSpread, "fixture: the spread cone must not fill the silhouette, or PSpread is 1 and proves nothing");
|
||||
Assert.Equal(21, checkedConfigurations); // 3 precisions x (3 info levels + out of range + behind + far shooter + not visible)
|
||||
}
|
||||
|
||||
// The cost regression itself. A target out of range is the common case for every AI and turret, and it
|
||||
// must bail before any factor is computed. Mutation: route HitProbability back through Inspect; the
|
||||
// gated-out call then allocates the aim-point list and the kernel's arrays on every evaluation.
|
||||
[Fact]
|
||||
public void GatedOutHitProbabilityAllocatesNothing()
|
||||
{
|
||||
var e = BuildKernelEngagement(TestSettings(), SolidShape(6, 12), 1.19f);
|
||||
e.Target.Position = float3(0, 0, 5000); // out of range, still visible and in arc
|
||||
|
||||
for (var i = 0; i < 100; i++) FireControl.HitProbability(e.Weapon, e.Shooter, e.Target); // JIT warm-up
|
||||
|
||||
var before = GC.GetAllocatedBytesForCurrentThread();
|
||||
var sum = 0f;
|
||||
for (var i = 0; i < 1000; i++) sum += FireControl.HitProbability(e.Weapon, e.Shooter, e.Target);
|
||||
var allocated = GC.GetAllocatedBytesForCurrentThread() - before;
|
||||
|
||||
Assert.Equal(0f, sum);
|
||||
Assert.True(allocated == 0, $"1000 gated-out HitProbability calls allocated {allocated} bytes; the hot path must bail before any factor work");
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------------------------------
|
||||
// 9.3: a Target surviving a dock/die/undock cycle must not crash LockWeapon. Same fixture shape as
|
||||
// FireControlCut8Tests.BuildLockScenario -- this is that cut's own crash, still reachable.
|
||||
|
|
|
|||
12
tests/Aetheria.Shared.Tests/stryker-config.json
Normal file
12
tests/Aetheria.Shared.Tests/stryker-config.json
Normal file
|
|
@ -0,0 +1,12 @@
|
|||
{
|
||||
"stryker-config": {
|
||||
"project": "Aetheria.Shared.csproj",
|
||||
"mutate": [
|
||||
"**/Assets/Scripts/ServerShared/**/*.cs",
|
||||
"!**/Assets/Plugins/**"
|
||||
],
|
||||
"mutation-level": "Standard",
|
||||
"reporters": [ "progress", "html", "json" ],
|
||||
"thresholds": { "high": 80, "low": 60, "break": 0 }
|
||||
}
|
||||
}
|
||||
|
|
@ -1,555 +0,0 @@
|
|||
#!/usr/bin/env python3
|
||||
"""Mutation tests for the Cut B and Cut C provenance rules (docs/item-provenance-cut.md).
|
||||
|
||||
For each rule this applies its named mutation to an exact, unique anchor of
|
||||
source text, runs the one test that should catch it, restores the file
|
||||
byte-for-byte, and reports whether the mutant was killed. A no-op control
|
||||
mutation (anchor == replacement) proves the read/replace/write/restore path
|
||||
itself is transparent: every test it touches must stay green.
|
||||
|
||||
Usage:
|
||||
python tests/mutation_tests.py --cultlib-root <path-to-CultLib-a0813c6-worktree>
|
||||
|
||||
Requires the CultLib worktree described in docs/item-provenance-cut.md section 6.
|
||||
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"
|
||||
|
||||
|
||||
@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="Assets/Scripts/ServerShared/Provenance.cs",
|
||||
anchor=' [Key(0)] public int NextLot = 1;',
|
||||
mutated=' [Key(0)] public int NextLot = 1;',
|
||||
test="RunSaveTests.MissingLotIsLoud",
|
||||
expect="green",
|
||||
),
|
||||
# --- MissingLotIsLoud: the indexer and Reachable must throw on an absent lot ---
|
||||
Mutation(
|
||||
rule="ProvenanceLedger indexer throws on an absent id",
|
||||
file="Assets/Scripts/ServerShared/Provenance.cs",
|
||||
anchor=(
|
||||
" public Lot this[int id]\n"
|
||||
" {\n"
|
||||
" get\n"
|
||||
" {\n"
|
||||
" if (!Lots.TryGetValue(id, out var lot))\n"
|
||||
" throw new InvalidOperationException($\"Lot {id} is not in the provenance ledger.\");\n"
|
||||
" return lot;\n"
|
||||
" }\n"
|
||||
" }"
|
||||
),
|
||||
mutated=(
|
||||
" public Lot this[int id]\n"
|
||||
" {\n"
|
||||
" get\n"
|
||||
" {\n"
|
||||
" Lots.TryGetValue(id, out var lot);\n"
|
||||
" return lot;\n"
|
||||
" }\n"
|
||||
" }"
|
||||
),
|
||||
test="RunSaveTests.MissingLotIsLoud",
|
||||
expect="red",
|
||||
),
|
||||
Mutation(
|
||||
rule="Reachable throws on a missing root instead of skipping it",
|
||||
file="Assets/Scripts/ServerShared/Provenance.cs",
|
||||
anchor=" var lot = this[id];\n reached[id] = lot;",
|
||||
mutated=" if (!Lots.TryGetValue(id, out var lot)) continue;\n reached[id] = lot;",
|
||||
test="RunSaveTests.MissingLotIsLoud",
|
||||
expect="red",
|
||||
),
|
||||
# --- MatchingHardpointsShareALot: the second unit must mint from the first unit's lot ---
|
||||
Mutation(
|
||||
rule="matching hardpoints share a lot, not a fresh mint",
|
||||
file="Assets/Scripts/ServerShared/LoadoutGenerator.cs",
|
||||
anchor=(
|
||||
" // Matching hardpoints carry matching units: same lot\n"
|
||||
" var item = (previousItem != null\n"
|
||||
" ? ItemManager.CreateInstance(previousItem.EquippableItem.Lot)\n"
|
||||
" : ItemManager.CreateInstance(entry.product)) as EquippableItem;"
|
||||
),
|
||||
mutated=(
|
||||
" // Matching hardpoints carry matching units: same lot\n"
|
||||
" var item = ItemManager.CreateInstance(entry.product) as EquippableItem;"
|
||||
),
|
||||
test="LoadoutTests.MatchingHardpointsShareALot",
|
||||
expect="red",
|
||||
),
|
||||
# --- FirstAvailableProductInKeyOrderBuildsTheSlot: Brand and Materialize must both respect key order ---
|
||||
Mutation(
|
||||
rule="Brand matches the lot's maker, not just the design",
|
||||
file="Assets/Scripts/ServerShared/ItemManager.cs",
|
||||
anchor=(
|
||||
" var product = ItemData.GetAll<FactionProductData>()\n"
|
||||
" .Where(p => p.Manufacturer.Key.Equals(maker.Key) && p.Design.Key.Equals(item.Data.Key))"
|
||||
),
|
||||
mutated=(
|
||||
" var product = ItemData.GetAll<FactionProductData>()\n"
|
||||
" .Where(p => p.Design.Key.Equals(item.Data.Key))"
|
||||
),
|
||||
test="LoadoutTests.FirstAvailableProductInKeyOrderBuildsTheSlot",
|
||||
expect="red",
|
||||
),
|
||||
Mutation(
|
||||
rule="Materialize resolves a design's product in record-key order",
|
||||
file="Assets/Scripts/ServerShared/Loadout.cs",
|
||||
anchor="var products = cache.GetAll<FactionProductData>().OrderBy(p => cache.RefOf(p).Key.Value, StringComparer.Ordinal).ToArray();",
|
||||
mutated="var products = cache.GetAll<FactionProductData>().ToArray();",
|
||||
test="LoadoutTests.FirstAvailableProductInKeyOrderBuildsTheSlot",
|
||||
expect="red",
|
||||
),
|
||||
# --- BrandPicksFirstProductInKeyOrderForSameMakerAndDesign: Brand's own tie-break must be ascending key order ---
|
||||
Mutation(
|
||||
rule="Brand's tie-break among a maker's products for one design is ascending record-key order",
|
||||
file="Assets/Scripts/ServerShared/ItemManager.cs",
|
||||
anchor=" .OrderBy(p => ItemData.RefOf(p).Key.Value, StringComparer.Ordinal)",
|
||||
mutated=" .OrderByDescending(p => ItemData.RefOf(p).Key.Value, StringComparer.Ordinal)",
|
||||
test="LoadoutTests.BrandPicksFirstProductInKeyOrderForSameMakerAndDesign",
|
||||
expect="red",
|
||||
),
|
||||
# --- StatsReadTheLot: stats must read the lot, including its per-role fills ---
|
||||
Mutation(
|
||||
rule="Evaluate reads the lot's per-role fill for a role stat",
|
||||
file="Assets/Scripts/ServerShared/ItemManager.cs",
|
||||
anchor="var quality = pow(lot.QualityForRole(stat.FromRole), stat.QualityExponent);",
|
||||
mutated="var quality = pow(lot.Quality, stat.QualityExponent);",
|
||||
test="LoadoutTests.StatsReadTheLot",
|
||||
expect="red",
|
||||
),
|
||||
Mutation(
|
||||
rule="QualityForRole falls back to the lot's own workmanship, not a fixed value",
|
||||
file="Assets/Scripts/ServerShared/Provenance.cs",
|
||||
anchor=" if (string.IsNullOrEmpty(role) || Roles == null) return Quality;",
|
||||
mutated=" if (string.IsNullOrEmpty(role) || Roles == null) return 1f;",
|
||||
test="LoadoutTests.StatsReadTheLot",
|
||||
expect="red",
|
||||
),
|
||||
# --- F2 (item-provenance-cut.md): the GC root walk must cover every EntityPack root ---
|
||||
Mutation(
|
||||
rule="Items yields the Equipment root, not just the hull",
|
||||
file="Assets/Scripts/ServerShared/EntitySerializer.cs",
|
||||
anchor=(
|
||||
" yield return pack.Hull;\n"
|
||||
" foreach (var (_, item) in pack.Equipment) yield return item;"
|
||||
),
|
||||
mutated=" yield return pack.Hull;",
|
||||
test="LoadoutTests.MaterializedLotsSurviveSaveAndReload",
|
||||
expect="red",
|
||||
),
|
||||
# --- F3: GetTier and CreateInstance(int) must read/preserve the lot's real quality ---
|
||||
Mutation(
|
||||
rule="GetTier reads the lot's quality, not a fixed value",
|
||||
file="Assets/Scripts/ServerShared/ItemManager.cs",
|
||||
anchor=" var quality = GetLot(item).Quality;",
|
||||
mutated=" var quality = .999f;",
|
||||
test="LoadoutTests.StatsReadTheLot",
|
||||
expect="red",
|
||||
),
|
||||
Mutation(
|
||||
rule="CreateInstance(int) does not re-roll the lot's quality",
|
||||
file="Assets/Scripts/ServerShared/ItemManager.cs",
|
||||
anchor=(
|
||||
" public CraftedItemInstance CreateInstance(int lot)\n"
|
||||
" {\n"
|
||||
" var l = Lots[lot];"
|
||||
),
|
||||
mutated=(
|
||||
" public CraftedItemInstance CreateInstance(int lot)\n"
|
||||
" {\n"
|
||||
" var l = Lots[lot];\n"
|
||||
" l.Quality = RollQuality();"
|
||||
),
|
||||
test="LoadoutTests.StatsReadTheLot",
|
||||
expect="red",
|
||||
),
|
||||
# --- F4: GetPrice, the durability/thermal exponent paths, role fills, Produced brand, multi-zone roots ---
|
||||
Mutation(
|
||||
rule="GetPrice reads the lot's quality, not a fixed value",
|
||||
file="Assets/Scripts/ServerShared/ItemManager.cs",
|
||||
anchor=" return (int) (GameplaySettings.QualityPriceModifier.Evaluate(GetLot(item).Quality) * data.Price);",
|
||||
mutated=" return (int) (GameplaySettings.QualityPriceModifier.Evaluate(.5f) * data.Price);",
|
||||
test="LoadoutTests.GetPriceReadsLotQuality",
|
||||
expect="red",
|
||||
),
|
||||
Mutation(
|
||||
rule="Evaluate's durability exponent reads the lot's quality",
|
||||
file="Assets/Scripts/ServerShared/ItemManager.cs",
|
||||
anchor=(
|
||||
" var durabilityExponent = lerp(\n"
|
||||
" GameplaySettings.DurabilityQualityMin,\n"
|
||||
" GameplaySettings.DurabilityQualityMax,\n"
|
||||
" pow(lot.Quality, GameplaySettings.DurabilityQualityExponent));"
|
||||
),
|
||||
mutated=(
|
||||
" var durabilityExponent = lerp(\n"
|
||||
" GameplaySettings.DurabilityQualityMin,\n"
|
||||
" GameplaySettings.DurabilityQualityMax,\n"
|
||||
" .5f);"
|
||||
),
|
||||
test="LoadoutTests.EvaluateDurabilityExponentReadsLotQuality",
|
||||
expect="red",
|
||||
),
|
||||
Mutation(
|
||||
rule="EquippedItem's thermal exponent reads the lot's quality",
|
||||
file="Assets/Scripts/ServerShared/Entity.cs",
|
||||
anchor=(
|
||||
" ThermalExponent = lerp(\n"
|
||||
" ItemManager.GameplaySettings.ThermalQualityMin,\n"
|
||||
" ItemManager.GameplaySettings.ThermalQualityMax,\n"
|
||||
" pow(Lot.Quality, ItemManager.GameplaySettings.ThermalQualityExponent));"
|
||||
),
|
||||
mutated=(
|
||||
" ThermalExponent = lerp(\n"
|
||||
" ItemManager.GameplaySettings.ThermalQualityMin,\n"
|
||||
" ItemManager.GameplaySettings.ThermalQualityMax,\n"
|
||||
" .5f);"
|
||||
),
|
||||
test="LoadoutTests.EquippedItemThermalExponentReadsLotQuality",
|
||||
expect="red",
|
||||
),
|
||||
Mutation(
|
||||
rule="CreateLot(product) fills roles from the product's own spread, not the design default",
|
||||
file="Assets/Scripts/ServerShared/ItemManager.cs",
|
||||
anchor=" var build = product.Roles?.FirstOrDefault(b => b.Role == role.Name) ?? new ProductRole();",
|
||||
mutated=" var build = new ProductRole();",
|
||||
test="LoadoutTests.CreateLotFillsRolesFromProductSpread",
|
||||
expect="red",
|
||||
),
|
||||
Mutation(
|
||||
rule="Brand reads the maker off a Produced origin too, not only Attributed",
|
||||
file="Assets/Scripts/ServerShared/ItemManager.cs",
|
||||
anchor=(
|
||||
" var maker = lot.Origin switch\n"
|
||||
" {\n"
|
||||
" Attributed attributed => attributed.Faction,\n"
|
||||
" Produced produced => produced.Faction,\n"
|
||||
" _ => default\n"
|
||||
" };"
|
||||
),
|
||||
mutated=(
|
||||
" var maker = lot.Origin switch\n"
|
||||
" {\n"
|
||||
" Attributed attributed => attributed.Faction,\n"
|
||||
" _ => default\n"
|
||||
" };"
|
||||
),
|
||||
test="LoadoutTests.BrandReadsProducedFaction",
|
||||
expect="red",
|
||||
),
|
||||
Mutation(
|
||||
rule="RunSave.Commit takes GC roots from every zone, not just the first",
|
||||
file="Assets/Scripts/ServerShared/SavedGame.cs",
|
||||
anchor=" var roots = zones\n .SelectMany(zone => zone.Contents?.Entities ?? new List<EntityPack>())",
|
||||
mutated=" var roots = zones.Take(1)\n .SelectMany(zone => zone.Contents?.Entities ?? new List<EntityPack>())",
|
||||
test="RunSaveTests.CommitTakesRootsFromEveryZone",
|
||||
expect="red",
|
||||
),
|
||||
# --- Cut C: the manufacturer moves from the design to the product; the catalog fixture and its
|
||||
# round-trip assertion (FactionIsASingletonInstance) must actually depend on that wiring. ---
|
||||
Mutation(
|
||||
rule="the catalog fixture's product carries the maker, not an unset reference",
|
||||
file="tests/Aetheria.Shared.Tests/AetheriaStoresTests.cs",
|
||||
anchor=(
|
||||
'cache.Upsert(new FactionProductData { Name = "Lance", '
|
||||
"Design = new CultRecordRef<CraftedItemData>(lance.Key), Manufacturer = cache.RefOf(faction) });"
|
||||
),
|
||||
mutated=(
|
||||
'cache.Upsert(new FactionProductData { Name = "Lance", '
|
||||
"Design = new CultRecordRef<CraftedItemData>(lance.Key), Manufacturer = default });"
|
||||
),
|
||||
test="AetheriaStoresTests.FactionIsASingletonInstance",
|
||||
expect="red",
|
||||
),
|
||||
# --- Hand-testing combat (2026-09-17): IFF overrides, grudge, detection-gating, and the shooter-
|
||||
# stance weapon fire gate on Entity/Weapon (docs not yet written; see IffAndCombatTests.cs). ---
|
||||
Mutation(
|
||||
rule="an IFF override decides IsHostileTo outright at the top level",
|
||||
file="Assets/Scripts/ServerShared/Entity.cs",
|
||||
anchor=" if (!recursive && _iffOverrides.TryGetValue(other, out var overrideHostile))\n return overrideHostile;",
|
||||
mutated=" if (false)\n return default;",
|
||||
test="IffAndCombatTests.OverrideDecidesOutrightOverDerivedRule",
|
||||
expect="red",
|
||||
),
|
||||
Mutation(
|
||||
rule="the recursive reciprocity sub-query skips overrides (no mirroring another entity's stance)",
|
||||
file="Assets/Scripts/ServerShared/Entity.cs",
|
||||
anchor=" if (!recursive && _iffOverrides.TryGetValue(other, out var overrideHostile))\n return overrideHostile;",
|
||||
mutated=" if (_iffOverrides.TryGetValue(other, out var overrideHostile))\n return overrideHostile;",
|
||||
test="IffAndCombatTests.DerivedReciprocityDoesNotMirrorAnotherEntitysOverride",
|
||||
expect="red",
|
||||
),
|
||||
Mutation(
|
||||
rule="leaving the zone clears this entity's IFF overrides on the departing entity",
|
||||
file="Assets/Scripts/ServerShared/Entity.cs",
|
||||
anchor=" _iffOverrides.Remove(remove.Value);\n if (_grudgeSubscriptions.TryGetValue(remove.Value, out var grudgeSubs))",
|
||||
mutated=" if (_grudgeSubscriptions.TryGetValue(remove.Value, out var grudgeSubs))",
|
||||
test="IffAndCombatTests.LeavingZoneClearsOverrides",
|
||||
expect="red",
|
||||
),
|
||||
Mutation(
|
||||
rule="an override change refreshes EntityHostility immediately (event-driven, no tick required)",
|
||||
file="Assets/Scripts/ServerShared/Entity.cs",
|
||||
anchor=" _subscriptions.Add(_iffOverrides.ObserveAdd().Subscribe(add => RefreshHostilityFromOverride(add.Key)));\n _subscriptions.Add(_iffOverrides.ObserveReplace().Subscribe(replace => RefreshHostilityFromOverride(replace.Key)));",
|
||||
mutated=" // _subscriptions.Add(_iffOverrides.ObserveAdd().Subscribe(add => RefreshHostilityFromOverride(add.Key)));\n // _subscriptions.Add(_iffOverrides.ObserveReplace().Subscribe(replace => RefreshHostilityFromOverride(replace.Key)));",
|
||||
test="IffAndCombatTests.OverrideDecidesOutrightOverDerivedRule",
|
||||
expect="red",
|
||||
),
|
||||
Mutation(
|
||||
rule="non-player entities wire a grudge watch; player-controlled entities never do",
|
||||
file="Assets/Scripts/ServerShared/Entity.cs",
|
||||
anchor=" if (!IsPlayerControlled) WatchForGrudge(entity);",
|
||||
mutated=" WatchForGrudge(entity);",
|
||||
test="IffAndCombatTests.PlayerControlledEntitiesNeverAutoGrudge",
|
||||
expect="red",
|
||||
),
|
||||
Mutation(
|
||||
rule="grudge only takes hold while this entity currently detects the marker",
|
||||
file="Assets/Scripts/ServerShared/Entity.cs",
|
||||
anchor=" if (otherIsHostileToThis && VisibleEntities.Contains(other) && !_iffOverrides.ContainsKey(other))",
|
||||
mutated=" if (otherIsHostileToThis && !_iffOverrides.ContainsKey(other))",
|
||||
test="IffAndCombatTests.NonPlayerEntityGrudgesOnlyWhileItDetectsTheHostileMarker",
|
||||
expect="red",
|
||||
),
|
||||
Mutation(
|
||||
rule="detecting an already-hostile entity grudges it immediately (VisibleEntities.ObserveAdd wiring)",
|
||||
file="Assets/Scripts/ServerShared/Entity.cs",
|
||||
anchor=(
|
||||
" VisibleEntities.ObserveAdd()\n"
|
||||
" .Where(add => add.Value == other)\n"
|
||||
" .Subscribe(_ =>\n"
|
||||
" {\n"
|
||||
" if (other.EntityHostility.TryGetValue(this, out var hostile))\n"
|
||||
" CheckGrudge(hostile);\n"
|
||||
" })"
|
||||
),
|
||||
mutated=(
|
||||
" VisibleEntities.ObserveAdd()\n"
|
||||
" .Where(add => false)\n"
|
||||
" .Subscribe(_ =>\n"
|
||||
" {\n"
|
||||
" if (other.EntityHostility.TryGetValue(this, out var hostile))\n"
|
||||
" CheckGrudge(hostile);\n"
|
||||
" })"
|
||||
),
|
||||
test="IffAndCombatTests.NonPlayerEntityGrudgesOnlyWhileItDetectsTheHostileMarker",
|
||||
expect="red",
|
||||
),
|
||||
Mutation(
|
||||
rule="a grudge is sticky: it never re-evaluates (mirrors current stance) once already grudged",
|
||||
file="Assets/Scripts/ServerShared/Entity.cs",
|
||||
anchor=" if (otherIsHostileToThis && VisibleEntities.Contains(other) && !_iffOverrides.ContainsKey(other))\n SetIff(other, true);",
|
||||
mutated=" if (VisibleEntities.Contains(other))\n SetIff(other, otherIsHostileToThis);",
|
||||
test="IffAndCombatTests.GrudgeIsStickyAndDoesNotForgiveWhenMarkerGoesNeutral",
|
||||
expect="red",
|
||||
),
|
||||
Mutation(
|
||||
rule="PerceivedStanceOf is unknown (null) until this entity detects the other",
|
||||
file="Assets/Scripts/ServerShared/Entity.cs",
|
||||
anchor=(
|
||||
" public bool? PerceivedStanceOf(Entity other) =>\n"
|
||||
" VisibleEntities.Contains(other) && other.EntityHostility.TryGetValue(this, out var hostile)\n"
|
||||
" ? hostile\n"
|
||||
" : (bool?) null;"
|
||||
),
|
||||
mutated=(
|
||||
" public bool? PerceivedStanceOf(Entity other) =>\n"
|
||||
" other.EntityHostility.TryGetValue(this, out var hostile)\n"
|
||||
" ? hostile\n"
|
||||
" : (bool?) null;"
|
||||
),
|
||||
test="IffAndCombatTests.PerceivedStanceIsUnknownUntilDetectedThenReadsTheActualStance",
|
||||
expect="red",
|
||||
),
|
||||
Mutation(
|
||||
rule="Weapon.StanceAllowsFire gates on the shooter's own stance, not an unconditional true",
|
||||
file="Assets/Scripts/ServerShared/Behaviors/Weapon.cs",
|
||||
anchor=" public bool StanceAllowsFire => Entity.Target.Value == null || Entity.IsHostileTo(Entity.Target.Value);",
|
||||
mutated=" public bool StanceAllowsFire => true;",
|
||||
test="IffAndCombatTests.WeaponDoesNotFireWhenShooterIsNotHostileToItsTarget",
|
||||
expect="red",
|
||||
),
|
||||
Mutation(
|
||||
rule="InstantWeapon.Trigger is safed by StanceAllowsFire before it starts a burst",
|
||||
file="Assets/Scripts/ServerShared/Behaviors/InstantWeapon.cs",
|
||||
anchor=" if (!StanceAllowsFire) return;",
|
||||
mutated=" if (false) return;",
|
||||
test="IffAndCombatTests.WeaponDoesNotFireWhenShooterIsNotHostileToItsTarget",
|
||||
expect="red",
|
||||
),
|
||||
Mutation(
|
||||
rule="LockWeapon builds lock on the shooter's own hostility toward the target, not the reverse",
|
||||
file="Assets/Scripts/ServerShared/Behaviors/LockWeapon.cs",
|
||||
anchor=" if (Entity.Target.Value != null && Entity.IsHostileTo(Entity.Target.Value))",
|
||||
mutated=" if (Entity.Target.Value != null && Entity.Target.Value.IsHostileTo(Entity))",
|
||||
test="IffAndCombatTests.LockWeaponBuildsLockOnTheShootersOwnStanceNotTheTargets",
|
||||
expect="red",
|
||||
),
|
||||
Mutation(
|
||||
rule="ZoneGenerator's neutral-wanderer pool excludes the zone owner and the nearest faction",
|
||||
file="Assets/Scripts/ServerShared/ZoneGenerator.cs",
|
||||
anchor="\tpublic static Faction[] EligibleWandererFactions(Faction[] factions, Faction owner, Faction nearest) =>\n\t\tfactions.Where(f => f != owner && f != nearest).ToArray();",
|
||||
mutated="\tpublic static Faction[] EligibleWandererFactions(Faction[] factions, Faction owner, Faction nearest) =>\n\t\tfactions.ToArray();",
|
||||
test="IffAndCombatTests.EligibleWandererFactionsExcludesOwnerAndNearest",
|
||||
expect="red",
|
||||
),
|
||||
# --- Capability event contract (docs/headless-playground-cut.md fork L, 2026-09-17): each event
|
||||
# kind must reach only its own subscribers. ---
|
||||
Mutation(
|
||||
rule="control (no-op round trip) for the capability event routing test",
|
||||
file="Assets/Scripts/ServerShared/CapabilityEvents.cs",
|
||||
anchor=" public void PublishAbsorb(AbsorbEvent e) => _absorb.OnNext(e);",
|
||||
mutated=" public void PublishAbsorb(AbsorbEvent e) => _absorb.OnNext(e);",
|
||||
test="CapabilityEventsTests.EachEventKindReachesOnlyItsOwnSubscribers",
|
||||
expect="green",
|
||||
),
|
||||
Mutation(
|
||||
rule="PublishAbsorb notifies only the Absorb stream, not Thrust as well",
|
||||
file="Assets/Scripts/ServerShared/CapabilityEvents.cs",
|
||||
anchor=" public void PublishAbsorb(AbsorbEvent e) => _absorb.OnNext(e);",
|
||||
mutated=" public void PublishAbsorb(AbsorbEvent e) { _absorb.OnNext(e); _thrust.OnNext(default); }",
|
||||
test="CapabilityEventsTests.EachEventKindReachesOnlyItsOwnSubscribers",
|
||||
expect="red",
|
||||
),
|
||||
# --- Cut 0 (docs/settings-globals-cut.md): Entity must copy the EntitySettings it is given, so no
|
||||
# entity aliases GameplaySettings.DefaultEntitySettings (or, once settings are a catalog global, the
|
||||
# cached record itself). ---
|
||||
Mutation(
|
||||
rule="control (no-op round trip) for the entity-settings-copy test",
|
||||
file="Assets/Scripts/ServerShared/Entity.cs",
|
||||
anchor=" Settings = MessagePackSerializer.Deserialize<EntitySettings>(MessagePackSerializer.Serialize(settings));",
|
||||
mutated=" Settings = MessagePackSerializer.Deserialize<EntitySettings>(MessagePackSerializer.Serialize(settings));",
|
||||
test="LoadoutTests.EntitiesDoNotShareDefaultEntitySettings",
|
||||
expect="green",
|
||||
),
|
||||
Mutation(
|
||||
rule="Entity's constructor copies the settings it is given, instead of aliasing the template",
|
||||
file="Assets/Scripts/ServerShared/Entity.cs",
|
||||
anchor=" Settings = MessagePackSerializer.Deserialize<EntitySettings>(MessagePackSerializer.Serialize(settings));",
|
||||
mutated=" Settings = settings;",
|
||||
test="LoadoutTests.EntitiesDoNotShareDefaultEntitySettings",
|
||||
expect="red",
|
||||
),
|
||||
]
|
||||
|
||||
|
||||
def run_test(cultlib_root: str, filter_expr: str) -> tuple[str, str]:
|
||||
"""Runs the one filtered test. Returns ("ERROR", ...) when the build itself failed (a
|
||||
compile error is never a legitimate kill or survival — it means the mutation broke
|
||||
something the test never got a chance to exercise), otherwise ("PASS"|"FAIL", output)."""
|
||||
proc = subprocess.run(
|
||||
[
|
||||
"dotnet", "test", str(TEST_PROJECT),
|
||||
f"-p:CultLibRoot={cultlib_root}",
|
||||
"--filter", f"FullyQualifiedName~{filter_expr}",
|
||||
"--nologo",
|
||||
],
|
||||
cwd=REPO_ROOT,
|
||||
capture_output=True,
|
||||
text=True,
|
||||
)
|
||||
output = proc.stdout + proc.stderr
|
||||
# A build failure never reaches "Test run for ..."/"Passed!"/"Failed!"; it reports
|
||||
# "Build FAILED." and one or more "error CS..." lines instead.
|
||||
if "Build FAILED" in output or "error CS" in output:
|
||||
return "ERROR", output
|
||||
passed = proc.returncode == 0 and "Failed!" not in output
|
||||
return ("PASS" if passed else "FAIL"), output
|
||||
|
||||
|
||||
def detect_newline(original: bytes) -> bytes:
|
||||
return b"\r\n" if b"\r\n" in original else b"\n"
|
||||
|
||||
|
||||
def main() -> int:
|
||||
parser = argparse.ArgumentParser()
|
||||
parser.add_argument("--cultlib-root", required=True)
|
||||
args = parser.parse_args()
|
||||
|
||||
results = []
|
||||
failures = []
|
||||
|
||||
for m in MUTATIONS:
|
||||
path = REPO_ROOT / m.file
|
||||
original = path.read_bytes()
|
||||
newline = detect_newline(original).decode("ascii")
|
||||
# Anchors are authored with plain "\n"; translate to the file's own line ending
|
||||
# before matching, so CRLF-checked-out files (this repo's default) still match.
|
||||
anchor = m.anchor.replace("\n", newline)
|
||||
mutated = m.mutated.replace("\n", newline)
|
||||
text = original.decode("utf-8")
|
||||
count = text.count(anchor)
|
||||
if count != 1:
|
||||
print(f"[ABORT] anchor for '{m.rule}' matches {count} times in {m.file}, expected exactly 1")
|
||||
failures.append(m.rule)
|
||||
continue
|
||||
|
||||
mutated_text = text.replace(anchor, mutated, 1)
|
||||
path.write_bytes(mutated_text.encode("utf-8"))
|
||||
try:
|
||||
status_code, output = run_test(args.cultlib_root, m.test)
|
||||
finally:
|
||||
# Reverse write: restore the exact original bytes regardless of outcome.
|
||||
path.write_bytes(original)
|
||||
restored = path.read_bytes()
|
||||
if restored != original:
|
||||
print(f"[FATAL] {m.file} did not restore byte-exact after '{m.rule}'!")
|
||||
return 2
|
||||
|
||||
if status_code == "ERROR":
|
||||
status = "ERROR (build failure)"
|
||||
failures.append(m.rule)
|
||||
elif m.expect == "red":
|
||||
killed = status_code == "FAIL"
|
||||
status = "KILLED" if killed else "SURVIVED (bad)"
|
||||
if not killed:
|
||||
failures.append(m.rule)
|
||||
else: # control: must stay green
|
||||
passed = status_code == "PASS"
|
||||
status = "GREEN (ok)" if passed else "RED (bad: round trip corrupted something)"
|
||||
if not passed:
|
||||
failures.append(m.rule)
|
||||
|
||||
results.append((m.rule, m.test, status))
|
||||
print(f"{status:22} | {m.test:55} | {m.rule}")
|
||||
|
||||
print()
|
||||
print("Summary:")
|
||||
for rule, test, status in results:
|
||||
print(f" {status:22} {test:55} {rule}")
|
||||
|
||||
if failures:
|
||||
print(f"\n{len(failures)} mutation(s) did not behave as expected:")
|
||||
for f in failures:
|
||||
print(f" - {f}")
|
||||
return 1
|
||||
|
||||
print(f"\nAll {len(results)} mutations behaved as expected.")
|
||||
return 0
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
sys.exit(main())
|
||||
|
|
@ -1,194 +0,0 @@
|
|||
#!/usr/bin/env python3
|
||||
"""Mutation tests for Cut 8, the operator's condition-ratio ask (2026-09-19): "you can't currently tell when
|
||||
[a thruster is] busted -- the particle system it spawns responds only to thrust intent. Should multiply that by
|
||||
actual performance." This follows Cut 7's brownout work (mutation_tests_stats_power_cut7.py) in this repo's own
|
||||
numbering.
|
||||
|
||||
EquippedItem.ConditionRatio (Entity.cs) is the one number that answers "how healthy does this item actually
|
||||
look": the item's resolved value for a stat against what the same item, same lot, same modifier stack would
|
||||
produce with Heat/Durability/PowerSupply pinned to their identity (NominalContext, the private struct right
|
||||
below it). Thruster.Condition and AetherDrive.Condition (Thruster.cs/AetherDrive.cs) are that ratio read against
|
||||
each behaviour's own governing stat. This script mutates each rule's own changed line and asserts the matching
|
||||
ConditionRatioTests.cs Fact goes red for exactly that reason. A no-op control mutation proves the
|
||||
read/replace/restore path is transparent.
|
||||
|
||||
Usage:
|
||||
python tests/mutation_tests_condition_ratio_cut8.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"
|
||||
|
||||
|
||||
@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="Assets/Scripts/ServerShared/Entity.cs",
|
||||
anchor=" public float ConditionRatio(PerformanceStat stat) => saturate(Evaluate(stat) / stat.Evaluate(new NominalContext(this)));",
|
||||
mutated=" public float ConditionRatio(PerformanceStat stat) => saturate(Evaluate(stat) / stat.Evaluate(new NominalContext(this)));",
|
||||
test="ConditionRatioTests.APerfectItemsConditionIsOne",
|
||||
expect="green",
|
||||
),
|
||||
|
||||
# --- The ratio must divide actual by nominal, not the reverse. A stat pinned to the identity on the nominal
|
||||
# --- side but degraded (half durability) on the actual side proves direction: dividend and divisor swapped
|
||||
# --- turns "half sits strictly below full" into "both saturate to 1" (nominal/actual > 1 either way, and
|
||||
# --- saturate clamps it there), which is exactly as wrong as it sounds and just as easy to write by accident.
|
||||
Mutation(
|
||||
rule="ConditionRatio must divide actual by nominal, not the reverse",
|
||||
file="Assets/Scripts/ServerShared/Entity.cs",
|
||||
anchor=" public float ConditionRatio(PerformanceStat stat) => saturate(Evaluate(stat) / stat.Evaluate(new NominalContext(this)));",
|
||||
mutated=" public float ConditionRatio(PerformanceStat stat) => saturate(stat.Evaluate(new NominalContext(this)) / Evaluate(stat));",
|
||||
test="ConditionRatioTests.ConditionFallsWithDurabilityAlone",
|
||||
expect="red",
|
||||
),
|
||||
|
||||
# --- Durability must actually be one of the terms NominalContext pins to identity -- if it is left to read
|
||||
# --- the item's own live DurabilityFactor instead, a half-durability item's nominal denominator shrinks right
|
||||
# --- along with its numerator and the ratio never leaves 1. ---
|
||||
Mutation(
|
||||
rule="NominalContext must pin DurabilityFactor to 1, not forward the item's live durability",
|
||||
file="Assets/Scripts/ServerShared/Entity.cs",
|
||||
anchor=" public float DurabilityFactor(float exponent) => 1f;",
|
||||
mutated=" public float DurabilityFactor(float exponent) => _item.DurabilityFactor(exponent);",
|
||||
test="ConditionRatioTests.ConditionFallsWithDurabilityAlone",
|
||||
expect="red",
|
||||
),
|
||||
|
||||
# --- Same shape of bug, on Heat: if NominalContext forwards the item's live thermal performance instead of
|
||||
# --- pinning it to 1, an off-plateau item's own nominal denominator degrades with it and the ratio never
|
||||
# --- moves off 1. ---
|
||||
Mutation(
|
||||
rule="NominalContext must pin HeatFactor to 1, not forward the item's live thermal performance",
|
||||
file="Assets/Scripts/ServerShared/Entity.cs",
|
||||
anchor=" public float HeatFactor(float exponent) => 1f;",
|
||||
mutated=" public float HeatFactor(float exponent) => _item.HeatFactor(exponent);",
|
||||
test="ConditionRatioTests.ConditionFallsWithTemperatureAwayFromThePlateauAlone",
|
||||
expect="red",
|
||||
),
|
||||
|
||||
# --- Same shape again, on PowerSupply -- the exact term Cut 7's brownout curve depends on. ---
|
||||
Mutation(
|
||||
rule="NominalContext must pin PowerSupplyFactor to 1, not forward the item's live grant",
|
||||
file="Assets/Scripts/ServerShared/Entity.cs",
|
||||
anchor=" public float PowerSupplyFactor(float exponent) => 1f;",
|
||||
mutated=" public float PowerSupplyFactor(float exponent) => _item.PowerSupplyFactor(exponent);",
|
||||
test="ConditionRatioTests.ConditionFallsWithAPartialPowerGrantAlone",
|
||||
expect="red",
|
||||
),
|
||||
|
||||
# --- Zero must land exactly on zero, not divide-by-zero into NaN, when the nominal denominator itself
|
||||
# --- degenerates (a Max=0 stat: both actual and nominal evaluate to exactly 0). ConditionRatio relies on
|
||||
# --- CultMath's own NaN-favoring math.min/max (0/0 saturates to a real 0f, not NaN) rather than a manual
|
||||
# --- epsilon guard -- dropping the saturate() call removes that protection and 0/0 surfaces as raw NaN, which
|
||||
# --- is not equal to 0f. ConditionIsZeroWhenTheItemProducesNothing cannot catch this by itself, since its
|
||||
# --- nominal stays 100 and only the numerator goes to 0 there. ---
|
||||
Mutation(
|
||||
rule="ConditionRatio must saturate the division, not return the raw quotient",
|
||||
file="Assets/Scripts/ServerShared/Entity.cs",
|
||||
anchor=" public float ConditionRatio(PerformanceStat stat) => saturate(Evaluate(stat) / stat.Evaluate(new NominalContext(this)));",
|
||||
mutated=" public float ConditionRatio(PerformanceStat stat) => Evaluate(stat) / stat.Evaluate(new NominalContext(this));",
|
||||
test="ConditionRatioTests.ConditionIsZeroNotNaNWhenTheNominalValueItselfDegeneratesToZero",
|
||||
expect="red",
|
||||
),
|
||||
|
||||
# --- Thruster.Condition must read Thrust, the stat that actually governs this behaviour's force -- reading
|
||||
# --- some other stat (here, the item's Visibility) would silently condition against the wrong quantity. ---
|
||||
Mutation(
|
||||
rule="Thruster.Condition must condition against the Thrust stat, not an unrelated one",
|
||||
file="Assets/Scripts/ServerShared/Behaviors/Thruster.cs",
|
||||
anchor=" public float Condition => Item?.ConditionRatio(_data.Thrust) ?? 1f;",
|
||||
mutated=" public float Condition => Item?.ConditionRatio(_data.Visibility) ?? 1f;",
|
||||
test="ConditionRatioTests.ConditionFallsWithDurabilityAlone",
|
||||
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())
|
||||
|
|
@ -1,277 +0,0 @@
|
|||
#!/usr/bin/env bash
|
||||
# Mutation tests for docs/fire-control-cut.md's Cut 1 ("Mount direction and arc become simulation data").
|
||||
#
|
||||
# Five mutations, each against tests/Aetheria.Shared.Tests/FireControlTests.cs, the cut's own declared table:
|
||||
#
|
||||
# 1. ArcFollowsMountRotation -- MountDirection reads Entity.Direction directly instead of rotating it by the
|
||||
# item's own authored rotation, so a side-mounted weapon aims like the hull instead of like its mount.
|
||||
# 2. ArcBoundaryIsHalfWidth -- the bearing test compares against cos(radians(arc)) instead of
|
||||
# cos(radians(arc/2)), so an authored FiringArc reads as a half-width arc.
|
||||
# 3. ArcIsPlanar -- InArc dots the raw 3D vector instead of zeroing height first (R7).
|
||||
# 4. HardpointOverrideBeatsDefault -- two variants, both against ArcFor: ignoring HardpointData.FiringArc
|
||||
# entirely (both hardpoints then fall back to the default), and treating an authored 0 as a literal
|
||||
# zero-width arc instead of "use the default."
|
||||
# 5. CombatStateStepsHeadless -- the cut's own regression: Behaviors.cs:30-35's HardpointTransforms readback
|
||||
# branch is gone from this tree (confirmed by negative grep, see below), so there is no literal text left
|
||||
# to revert it to, and post-Cut-3 the AI's own gate (Combat.cs) reaches aim direction through
|
||||
# FireControl.MountDirection, not the Behavior.Direction property Cut 1 itself touched -- a mutation there
|
||||
# is provably off this test's actual path (verified: it survived). The equivalent-shape mutation instead
|
||||
# throws from MountDirection itself, the same KeyNotFoundException shape a live Unity-transform readback
|
||||
# threw headless before this cut, so the test's own Assert.Null(ex) still catches the regression the cut
|
||||
# exists to prevent.
|
||||
#
|
||||
# A no-op control (a byte-identical rewrite of one target file through this script's own read/write path)
|
||||
# proves the mechanism itself is transparent before any real mutation is trusted. Modelled on
|
||||
# tests/mutation_tests_fire_control_cut5.sh, the good example: byte-exact I/O, sha256-verified restore after
|
||||
# every single mutation, and an explicit tree-clean verdict line at exit.
|
||||
#
|
||||
# Usage:
|
||||
# tests/mutation_tests_fire_control_cut1.sh <path-to-CultLib-worktree>
|
||||
#
|
||||
# Every mutation is reversed (byte-exact, from an on-disk copy) before the script exits, including on failure
|
||||
# or interrupt, and every restore is verified against a sha256 recorded before any mutation ran: the tree-
|
||||
# clean verdict at the end states PASS/FAIL explicitly rather than leaving a reader to infer it. A SIGKILL of
|
||||
# the whole process group is the one termination bash cannot trap at all -- verify the tree independently
|
||||
# after a run that was killed that hard.
|
||||
|
||||
set -u
|
||||
|
||||
if [ $# -lt 1 ]; then
|
||||
echo "Usage: $0 <cultlib-root>" >&2
|
||||
exit 2
|
||||
fi
|
||||
CULTLIB_ROOT="$1"
|
||||
|
||||
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
|
||||
REPO_ROOT="$(cd "$SCRIPT_DIR/.." && pwd)"
|
||||
TEST_PROJECT="$REPO_ROOT/tests/Aetheria.Shared.Tests"
|
||||
|
||||
FIRE_CONTROL_CS="$REPO_ROOT/Assets/Scripts/ServerShared/FireControl.cs"
|
||||
|
||||
# --- byte-exact backup/restore: preserves line endings exactly, no text-mode translation. ---
|
||||
declare -A ORIGINALS
|
||||
declare -A ORIGINAL_HASH
|
||||
FILES=("$FIRE_CONTROL_CS")
|
||||
BACKUP_DIR="$(mktemp -d)"
|
||||
|
||||
hash_file() {
|
||||
sha256sum "$1" 2>/dev/null | awk '{print $1}'
|
||||
}
|
||||
|
||||
for f in "${FILES[@]}"; do
|
||||
base="$(basename "$f")"
|
||||
cp -p "$f" "$BACKUP_DIR/$base.orig"
|
||||
ORIGINALS["$f"]="$BACKUP_DIR/$base.orig"
|
||||
ORIGINAL_HASH["$f"]="$(hash_file "$f")"
|
||||
done
|
||||
|
||||
restore_and_verify() {
|
||||
local file="$1"
|
||||
cp -p "${ORIGINALS[$file]}" "$file"
|
||||
local actual
|
||||
actual="$(hash_file "$file")"
|
||||
if [ "$actual" != "${ORIGINAL_HASH[$file]}" ]; then
|
||||
echo "" >&2
|
||||
echo "FATAL: restoring $file did not reproduce its pre-mutation hash -- aborting." >&2
|
||||
echo " expected sha256: ${ORIGINAL_HASH[$file]}" >&2
|
||||
echo " actual sha256: $actual" >&2
|
||||
exit 1
|
||||
fi
|
||||
}
|
||||
|
||||
TREE_CLEAN_OK=1
|
||||
TREE_VERDICT_DONE=0
|
||||
|
||||
verify_tree_clean() {
|
||||
if [ "$TREE_VERDICT_DONE" = "1" ]; then return; fi
|
||||
local f actual
|
||||
for f in "${FILES[@]}"; do
|
||||
cp -p "${ORIGINALS[$f]}" "$f" 2>/dev/null
|
||||
actual="$(hash_file "$f")"
|
||||
if [ "$actual" != "${ORIGINAL_HASH[$f]}" ]; then
|
||||
echo "FATAL: $f is NOT byte-identical to how this script found it." >&2
|
||||
echo " expected sha256: ${ORIGINAL_HASH[$f]}" >&2
|
||||
echo " actual sha256: $actual" >&2
|
||||
TREE_CLEAN_OK=0
|
||||
fi
|
||||
done
|
||||
echo ""
|
||||
if [ "$TREE_CLEAN_OK" = "1" ]; then
|
||||
echo "=== tree-clean verdict: PASS -- every target file is byte-identical to how this script found it ==="
|
||||
else
|
||||
echo "=== tree-clean verdict: FAIL -- see FATAL lines above; the working tree is contaminated ==="
|
||||
fi
|
||||
TREE_VERDICT_DONE=1
|
||||
}
|
||||
trap verify_tree_clean EXIT INT TERM
|
||||
|
||||
FAILURES=()
|
||||
|
||||
# replace_unique FILE ANCHOR REPLACEMENT
|
||||
# Anchor must occur exactly once (byte-exact match via perl, not sed's line-oriented rewriting, so a
|
||||
# multi-line anchor is matched as literal text). Writes the mutated content back byte-exact.
|
||||
replace_unique() {
|
||||
local file="$1" anchor="$2" replacement="$3"
|
||||
local count
|
||||
count=$(perl -0777 -e '
|
||||
my ($anchor, $file) = @ARGV;
|
||||
local $/; open(my $fh, "<:raw", $file) or die $!;
|
||||
my $text = <$fh>; close $fh;
|
||||
my $n = () = $text =~ /\Q$anchor\E/g;
|
||||
print $n;
|
||||
' "$anchor" "$file")
|
||||
if [ "$count" != "1" ]; then
|
||||
echo "ERROR: $file: anchor must occur exactly once, found $count" >&2
|
||||
echo "--- anchor ---" >&2
|
||||
echo "$anchor" >&2
|
||||
return 1
|
||||
fi
|
||||
perl -0777 -e '
|
||||
my ($file, $anchor, $replacement) = @ARGV;
|
||||
local $/; open(my $fh, "<:raw", $file) or die $!;
|
||||
my $text = <$fh>; close $fh;
|
||||
my $qa = quotemeta($anchor);
|
||||
$text =~ s/$qa/$replacement/;
|
||||
open(my $out, ">:raw", $file) or die $!;
|
||||
print $out $text; close $out;
|
||||
' "$file" "$anchor" "$replacement"
|
||||
}
|
||||
|
||||
ARTIFACTS_PATH="$(mktemp -d)"
|
||||
|
||||
run_test() {
|
||||
local test="$1"
|
||||
local out
|
||||
out=$(dotnet test "$TEST_PROJECT" -p:CultLibRoot="$CULTLIB_ROOT" --artifacts-path "$ARTIFACTS_PATH" --filter "FullyQualifiedName~$test" 2>&1)
|
||||
if echo "$out" | grep -q "Passed!" && ! echo "$out" | grep -q "Failed: [1-9]"; then
|
||||
return 0
|
||||
fi
|
||||
LAST_OUTPUT="$out"
|
||||
return 1
|
||||
}
|
||||
|
||||
check_mutation() {
|
||||
local name="$1" file="$2" anchor="$3" mutated="$4" test="$5" expect="$6"
|
||||
echo ""
|
||||
echo "=== $name (expect $expect) ==="
|
||||
if ! replace_unique "$file" "$anchor" "$mutated"; then
|
||||
FAILURES+=("$name: anchor replacement failed")
|
||||
restore_and_verify "$file"
|
||||
return
|
||||
fi
|
||||
if run_test "$test"; then
|
||||
passed=1
|
||||
else
|
||||
passed=0
|
||||
fi
|
||||
restore_and_verify "$file"
|
||||
|
||||
if [ "$expect" = "green" ]; then
|
||||
if [ "$passed" = "1" ]; then
|
||||
echo "OK: $test passed under a no-op mutation, as expected"
|
||||
else
|
||||
FAILURES+=("$name: expected green (test passing), got failure")
|
||||
echo "$LAST_OUTPUT" | tail -40
|
||||
fi
|
||||
else
|
||||
if [ "$passed" = "0" ]; then
|
||||
echo "OK: $test went red under the mutation, as expected"
|
||||
else
|
||||
FAILURES+=("$name: expected red (mutant killed), but $test still passed")
|
||||
fi
|
||||
fi
|
||||
}
|
||||
|
||||
# --- No-op control ---
|
||||
check_mutation \
|
||||
"control (no-op round trip)" \
|
||||
"$FIRE_CONTROL_CS" \
|
||||
'public static float3 MountDirection(EquippedItem item)' \
|
||||
'public static float3 MountDirection(EquippedItem item)' \
|
||||
"FireControlTests.ArcFollowsMountRotation" \
|
||||
"green"
|
||||
|
||||
# --- ArcFollowsMountRotation: mount direction must come from the item's own authored rotation, not the
|
||||
# hull's raw facing. ---
|
||||
check_mutation \
|
||||
"ArcFollowsMountRotation must read the item's rotation, not the hull's raw facing" \
|
||||
"$FIRE_CONTROL_CS" \
|
||||
'var itemDirection = item.Entity.Direction.Rotate(item.EquippableItem.Rotation);' \
|
||||
'var itemDirection = item.Entity.Direction;' \
|
||||
"FireControlTests.ArcFollowsMountRotation" \
|
||||
"red"
|
||||
|
||||
# --- ArcBoundaryIsHalfWidth: FiringArc is full width -- the bearing test must halve it before taking cos. ---
|
||||
check_mutation \
|
||||
"ArcBoundaryIsHalfWidth: FiringArc must be full width (halved before cos), not read as a half-width arc" \
|
||||
"$FIRE_CONTROL_CS" \
|
||||
'return dot(MountDirection(weapon), planarTarget) >= cos(radians(arc / 2f));' \
|
||||
'return dot(MountDirection(weapon), planarTarget) >= cos(radians(arc));' \
|
||||
"FireControlTests.ArcBoundaryIsHalfWidth" \
|
||||
"red"
|
||||
|
||||
# --- ArcIsPlanar (R7): target height must never enter the bearing test. ---
|
||||
check_mutation \
|
||||
"ArcIsPlanar (R7): InArc must zero height, not dot the raw 3D vector" \
|
||||
"$FIRE_CONTROL_CS" \
|
||||
'var planarToTarget = float3(toTarget.x, 0, toTarget.z);' \
|
||||
'var planarToTarget = toTarget;' \
|
||||
"FireControlTests.ArcIsPlanar" \
|
||||
"red"
|
||||
|
||||
# --- HardpointOverrideBeatsDefault, variant 1: ignore HardpointData.FiringArc entirely (both hardpoints in
|
||||
# the test then fall back to the default, so the 360-authored one fails at 170 degrees too). ---
|
||||
check_mutation \
|
||||
"HardpointOverrideBeatsDefault (variant 1): ArcFor must read HardpointData.FiringArc, not ignore it" \
|
||||
"$FIRE_CONTROL_CS" \
|
||||
$' var arc = hardpoint?.FiringArc ?? 0f;\r\n return arc > 0 ? arc : item.ItemManager.GameplaySettings.FiringArc;' \
|
||||
' return item.ItemManager.GameplaySettings.FiringArc;' \
|
||||
"FireControlTests.HardpointOverrideBeatsDefault" \
|
||||
"red"
|
||||
|
||||
# --- HardpointOverrideBeatsDefault, variant 2: an authored 0 must mean "use the default," not a literal
|
||||
# zero-width arc -- flipping > to >= makes a 0-authored hardpoint fail even 5 degrees off. ---
|
||||
check_mutation \
|
||||
"HardpointOverrideBeatsDefault (variant 2): an authored 0 must fall back to the default, not read as zero width" \
|
||||
"$FIRE_CONTROL_CS" \
|
||||
'return arc > 0 ? arc : item.ItemManager.GameplaySettings.FiringArc;' \
|
||||
'return arc >= 0 ? arc : item.ItemManager.GameplaySettings.FiringArc;' \
|
||||
"FireControlTests.HardpointOverrideBeatsDefault" \
|
||||
"red"
|
||||
|
||||
# --- CombatStateStepsHeadless: the cut's own regression -- aim direction must never depend on a live Unity
|
||||
# readback. HardpointTransforms and its readback branch are already fully deleted from this tree (see the
|
||||
# negative grep below), so there is no literal "restore the read" text left to revert to, and post-Cut-3 the
|
||||
# AI's own gate reaches aim direction through FireControl.MountDirection (Combat.cs calls
|
||||
# FireControl.HitProbability, which calls InArc, which calls MountDirection), not the Behavior.Direction
|
||||
# property this cut itself touched. The equivalent-shape mutation throws from MountDirection itself -- the
|
||||
# exact KeyNotFoundException shape the old HardpointTransforms dictionary lookup threw headless, before this
|
||||
# cut -- so the test's own Assert.Null(Record.Exception(...)) still catches the regression this cut exists to
|
||||
# prevent. ---
|
||||
check_mutation \
|
||||
"CombatStateStepsHeadless: aim direction must never throw when Unity never ran (HardpointTransforms shape)" \
|
||||
"$FIRE_CONTROL_CS" \
|
||||
'return normalize(float3(itemDirection.x, 0, itemDirection.y));' \
|
||||
'throw new System.Collections.Generic.KeyNotFoundException("HardpointTransforms readback restored");' \
|
||||
"FireControlTests.CombatStateStepsHeadless" \
|
||||
"red"
|
||||
|
||||
echo ""
|
||||
verify_tree_clean
|
||||
|
||||
if [ "${#FAILURES[@]}" -gt 0 ]; then
|
||||
echo ""
|
||||
echo "=== FAILURES ==="
|
||||
for f in "${FAILURES[@]}"; do
|
||||
echo "- $f"
|
||||
done
|
||||
fi
|
||||
|
||||
if [ "${#FAILURES[@]}" -gt 0 ] || [ "$TREE_CLEAN_OK" != "1" ]; then
|
||||
echo ""
|
||||
echo "RESULT: FAILED -- mutations: $([ "${#FAILURES[@]}" -gt 0 ] && echo "${#FAILURES[@]} failure(s)" || echo "all behaved as declared"), tree-clean: $([ "$TREE_CLEAN_OK" = "1" ] && echo PASS || echo FAIL)"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
echo "RESULT: All mutations behaved as declared. tree-clean: PASS"
|
||||
exit 0
|
||||
|
|
@ -1,276 +0,0 @@
|
|||
#!/usr/bin/env bash
|
||||
# Mutation tests for docs/fire-control-cut.md's Cut 2 ("Targeting system and reveal").
|
||||
#
|
||||
# Six mutations, each against tests/Aetheria.Shared.Tests/TargetingSystemTests.cs, the cut's own declared
|
||||
# table -- with two adaptations forced by later cuts changing the Body underneath this one (see the notes on
|
||||
# each, and the report):
|
||||
#
|
||||
# 1. SelectionNeedsReveal -- IsRevealed returns true unconditionally instead of checking the tier.
|
||||
# 2. RevealOrder -- drop the ranking (hardpoint-first, size-descending) entirely.
|
||||
# 3. RevealSpansBothThresholds -- swap the two authored thresholds in the lerp.
|
||||
# 4. UnaidedFiresWorse -- Accuracy/Precision/Resolution fall back to the (destroyed/offline) system's own
|
||||
# stats instead of the unaided settings, i.e. drop the Active.Value gate.
|
||||
# 5. StarvedTargetingRollsWorse -- the brownout ruling becomes a switch (PowerSupply <= 1e-4 ? 0 : 1)
|
||||
# instead of the authored pow(PowerSupply, exponent) gradient. This is Entity.cs's PowerSupplyFactor,
|
||||
# shared production code the brownout ruling (docs/stats-and-power-cut.md) applies well beyond Accuracy
|
||||
# -- restored immediately after the single filtered test run, same as every other mutation here.
|
||||
# 6. EveryArmedLoadoutGetsATargetingSystem -- the doc's own declared mutation ("drop required: true") no
|
||||
# longer has a `required: true` to drop: Cut 6c.2 (LoadoutGenerator.cs, 2026-09-19) deliberately changed
|
||||
# this call from required:true/throw to required:false/degrade-and-log, an operator ruling this harness
|
||||
# must not fight. The adapted mutation targets the surviving acquisition attempt instead -- the predicate
|
||||
# that finds a targeting-system seller never matches -- which is what "the required-equipment rule
|
||||
# regresses to nothing" now looks like on this Body. See the report for the full account.
|
||||
#
|
||||
# A no-op control (a byte-identical rewrite of one target file through this script's own read/write path)
|
||||
# proves the mechanism itself is transparent before any real mutation is trusted. Modelled on
|
||||
# tests/mutation_tests_fire_control_cut5.sh, the good example: byte-exact I/O, sha256-verified restore after
|
||||
# every single mutation, and an explicit tree-clean verdict line at exit.
|
||||
#
|
||||
# Usage:
|
||||
# tests/mutation_tests_fire_control_cut2.sh <path-to-CultLib-worktree>
|
||||
#
|
||||
# Every mutation is reversed (byte-exact, from an on-disk copy) before the script exits, including on failure
|
||||
# or interrupt, and every restore is verified against a sha256 recorded before any mutation ran: the tree-
|
||||
# clean verdict at the end states PASS/FAIL explicitly rather than leaving a reader to infer it. A SIGKILL of
|
||||
# the whole process group is the one termination bash cannot trap at all -- verify the tree independently
|
||||
# after a run that was killed that hard.
|
||||
|
||||
set -u
|
||||
|
||||
if [ $# -lt 1 ]; then
|
||||
echo "Usage: $0 <cultlib-root>" >&2
|
||||
exit 2
|
||||
fi
|
||||
CULTLIB_ROOT="$1"
|
||||
|
||||
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
|
||||
REPO_ROOT="$(cd "$SCRIPT_DIR/.." && pwd)"
|
||||
TEST_PROJECT="$REPO_ROOT/tests/Aetheria.Shared.Tests"
|
||||
|
||||
FIRE_CONTROL_CS="$REPO_ROOT/Assets/Scripts/ServerShared/FireControl.cs"
|
||||
ENTITY_CS="$REPO_ROOT/Assets/Scripts/ServerShared/Entity.cs"
|
||||
LOADOUT_GENERATOR_CS="$REPO_ROOT/Assets/Scripts/ServerShared/LoadoutGenerator.cs"
|
||||
|
||||
# --- byte-exact backup/restore: preserves line endings exactly, no text-mode translation. ---
|
||||
declare -A ORIGINALS
|
||||
declare -A ORIGINAL_HASH
|
||||
FILES=("$FIRE_CONTROL_CS" "$ENTITY_CS" "$LOADOUT_GENERATOR_CS")
|
||||
BACKUP_DIR="$(mktemp -d)"
|
||||
|
||||
hash_file() {
|
||||
sha256sum "$1" 2>/dev/null | awk '{print $1}'
|
||||
}
|
||||
|
||||
for f in "${FILES[@]}"; do
|
||||
base="$(basename "$f")"
|
||||
cp -p "$f" "$BACKUP_DIR/$base.orig"
|
||||
ORIGINALS["$f"]="$BACKUP_DIR/$base.orig"
|
||||
ORIGINAL_HASH["$f"]="$(hash_file "$f")"
|
||||
done
|
||||
|
||||
restore_and_verify() {
|
||||
local file="$1"
|
||||
cp -p "${ORIGINALS[$file]}" "$file"
|
||||
local actual
|
||||
actual="$(hash_file "$file")"
|
||||
if [ "$actual" != "${ORIGINAL_HASH[$file]}" ]; then
|
||||
echo "" >&2
|
||||
echo "FATAL: restoring $file did not reproduce its pre-mutation hash -- aborting." >&2
|
||||
echo " expected sha256: ${ORIGINAL_HASH[$file]}" >&2
|
||||
echo " actual sha256: $actual" >&2
|
||||
exit 1
|
||||
fi
|
||||
}
|
||||
|
||||
TREE_CLEAN_OK=1
|
||||
TREE_VERDICT_DONE=0
|
||||
|
||||
verify_tree_clean() {
|
||||
if [ "$TREE_VERDICT_DONE" = "1" ]; then return; fi
|
||||
local f actual
|
||||
for f in "${FILES[@]}"; do
|
||||
cp -p "${ORIGINALS[$f]}" "$f" 2>/dev/null
|
||||
actual="$(hash_file "$f")"
|
||||
if [ "$actual" != "${ORIGINAL_HASH[$f]}" ]; then
|
||||
echo "FATAL: $f is NOT byte-identical to how this script found it." >&2
|
||||
echo " expected sha256: ${ORIGINAL_HASH[$f]}" >&2
|
||||
echo " actual sha256: $actual" >&2
|
||||
TREE_CLEAN_OK=0
|
||||
fi
|
||||
done
|
||||
echo ""
|
||||
if [ "$TREE_CLEAN_OK" = "1" ]; then
|
||||
echo "=== tree-clean verdict: PASS -- every target file is byte-identical to how this script found it ==="
|
||||
else
|
||||
echo "=== tree-clean verdict: FAIL -- see FATAL lines above; the working tree is contaminated ==="
|
||||
fi
|
||||
TREE_VERDICT_DONE=1
|
||||
}
|
||||
trap verify_tree_clean EXIT INT TERM
|
||||
|
||||
FAILURES=()
|
||||
|
||||
# replace_unique FILE ANCHOR REPLACEMENT
|
||||
replace_unique() {
|
||||
local file="$1" anchor="$2" replacement="$3"
|
||||
local count
|
||||
count=$(perl -0777 -e '
|
||||
my ($anchor, $file) = @ARGV;
|
||||
local $/; open(my $fh, "<:raw", $file) or die $!;
|
||||
my $text = <$fh>; close $fh;
|
||||
my $n = () = $text =~ /\Q$anchor\E/g;
|
||||
print $n;
|
||||
' "$anchor" "$file")
|
||||
if [ "$count" != "1" ]; then
|
||||
echo "ERROR: $file: anchor must occur exactly once, found $count" >&2
|
||||
echo "--- anchor ---" >&2
|
||||
echo "$anchor" >&2
|
||||
return 1
|
||||
fi
|
||||
perl -0777 -e '
|
||||
my ($file, $anchor, $replacement) = @ARGV;
|
||||
local $/; open(my $fh, "<:raw", $file) or die $!;
|
||||
my $text = <$fh>; close $fh;
|
||||
my $qa = quotemeta($anchor);
|
||||
$text =~ s/$qa/$replacement/;
|
||||
open(my $out, ">:raw", $file) or die $!;
|
||||
print $out $text; close $out;
|
||||
' "$file" "$anchor" "$replacement"
|
||||
}
|
||||
|
||||
ARTIFACTS_PATH="$(mktemp -d)"
|
||||
|
||||
run_test() {
|
||||
local test="$1"
|
||||
local out
|
||||
out=$(dotnet test "$TEST_PROJECT" -p:CultLibRoot="$CULTLIB_ROOT" --artifacts-path "$ARTIFACTS_PATH" --filter "FullyQualifiedName~$test" 2>&1)
|
||||
if echo "$out" | grep -q "Passed!" && ! echo "$out" | grep -q "Failed: [1-9]"; then
|
||||
return 0
|
||||
fi
|
||||
LAST_OUTPUT="$out"
|
||||
return 1
|
||||
}
|
||||
|
||||
check_mutation() {
|
||||
local name="$1" file="$2" anchor="$3" mutated="$4" test="$5" expect="$6"
|
||||
echo ""
|
||||
echo "=== $name (expect $expect) ==="
|
||||
if ! replace_unique "$file" "$anchor" "$mutated"; then
|
||||
FAILURES+=("$name: anchor replacement failed")
|
||||
restore_and_verify "$file"
|
||||
return
|
||||
fi
|
||||
if run_test "$test"; then
|
||||
passed=1
|
||||
else
|
||||
passed=0
|
||||
fi
|
||||
restore_and_verify "$file"
|
||||
|
||||
if [ "$expect" = "green" ]; then
|
||||
if [ "$passed" = "1" ]; then
|
||||
echo "OK: $test passed under a no-op mutation, as expected"
|
||||
else
|
||||
FAILURES+=("$name: expected green (test passing), got failure")
|
||||
echo "$LAST_OUTPUT" | tail -40
|
||||
fi
|
||||
else
|
||||
if [ "$passed" = "0" ]; then
|
||||
echo "OK: $test went red under the mutation, as expected"
|
||||
else
|
||||
FAILURES+=("$name: expected red (mutant killed), but $test still passed")
|
||||
fi
|
||||
fi
|
||||
}
|
||||
|
||||
# --- No-op control ---
|
||||
check_mutation \
|
||||
"control (no-op round trip)" \
|
||||
"$FIRE_CONTROL_CS" \
|
||||
'return info >= tier;' \
|
||||
'return info >= tier;' \
|
||||
"TargetingSystemTests.SelectionNeedsReveal" \
|
||||
"green"
|
||||
|
||||
# --- SelectionNeedsReveal: IsRevealed must check the tier, not return true unconditionally. ---
|
||||
check_mutation \
|
||||
"SelectionNeedsReveal: IsRevealed must check the tier, not return true unconditionally" \
|
||||
"$FIRE_CONTROL_CS" \
|
||||
'return info >= tier;' \
|
||||
'return true;' \
|
||||
"TargetingSystemTests.SelectionNeedsReveal" \
|
||||
"red"
|
||||
|
||||
# --- RevealOrder: hardpoint-mounted items must rank before interior ones, larger before smaller. ---
|
||||
check_mutation \
|
||||
"RevealOrder: IsRevealed's ranking must be hardpoint-first, size-descending, not equipment order alone" \
|
||||
"$FIRE_CONTROL_CS" \
|
||||
$' var ranked = target.Equipment\r\n .Where(e => e.Data.HardpointType != HardpointType.Hull && e.EquippableItem.Durability >= .01f)\r\n .OrderByDescending(e => e.Data.HardpointType != HardpointType.Tool)\r\n .ThenByDescending(e => e.Data.Shape.Coordinates.Length)\r\n .ToList();' \
|
||||
$' var ranked = target.Equipment\r\n .Where(e => e.Data.HardpointType != HardpointType.Hull && e.EquippableItem.Durability >= .01f)\r\n .ToList();' \
|
||||
"TargetingSystemTests.RevealOrder" \
|
||||
"red"
|
||||
|
||||
# --- RevealSpansBothThresholds: the first item must reveal at TargetArmorInfoThreshold and the last at
|
||||
# TargetGearInfoThreshold -- swap them and the ends flip. ---
|
||||
check_mutation \
|
||||
"RevealSpansBothThresholds: the ranking's tiers must not be swapped" \
|
||||
"$FIRE_CONTROL_CS" \
|
||||
'var tier = lerp(settings.TargetArmorInfoThreshold, settings.TargetGearInfoThreshold,' \
|
||||
'var tier = lerp(settings.TargetGearInfoThreshold, settings.TargetArmorInfoThreshold,' \
|
||||
"TargetingSystemTests.RevealSpansBothThresholds" \
|
||||
"red"
|
||||
|
||||
# --- UnaidedFiresWorse (Q4): a destroyed or disabled targeting system must fall back to the unaided figures,
|
||||
# not keep supplying its own (now-meaningless) stats. Mutation: drop the Active.Value gate from Accuracy. ---
|
||||
check_mutation \
|
||||
"UnaidedFiresWorse: Accuracy must gate on Active.Value, not just existence, before trusting the system" \
|
||||
"$FIRE_CONTROL_CS" \
|
||||
$' var system = entity.GetBehavior<TargetingSystem>();\r\n return system != null && system.Item.Active.Value\r\n ? system.Accuracy\r\n : entity.ItemManager.GameplaySettings.UnaidedAccuracy;' \
|
||||
$' var system = entity.GetBehavior<TargetingSystem>();\r\n return system != null\r\n ? system.Accuracy\r\n : entity.ItemManager.GameplaySettings.UnaidedAccuracy;' \
|
||||
"TargetingSystemTests.UnaidedFiresWorse" \
|
||||
"red"
|
||||
|
||||
# --- StarvedTargetingRollsWorse: the brownout ruling on Accuracy (a shared PowerSupply term, F1 of
|
||||
# docs/stats-and-power-cut.md) must be a gradient (pow(PowerSupply, exponent)), not a switch. This is Entity's
|
||||
# own PowerSupplyFactor, read by every stat carrying a PowerSupply term, not just targeting -- restored
|
||||
# immediately after this one filtered test run, same discipline as every mutation in this harness. ---
|
||||
check_mutation \
|
||||
"StarvedTargetingRollsWorse: PowerSupplyFactor must be a gradient, not a >1e-4 on/off switch" \
|
||||
"$ENTITY_CS" \
|
||||
'public float PowerSupplyFactor(float exponent) => pow(PowerSupply, exponent);' \
|
||||
'public float PowerSupplyFactor(float exponent) => PowerSupply <= 1e-4f ? 0f : 1f;' \
|
||||
"TargetingSystemTests.StarvedTargetingRollsWorse" \
|
||||
"red"
|
||||
|
||||
# --- EveryArmedLoadoutGetsATargetingSystem (Q4): every armed hull's loadout must still try to acquire a
|
||||
# targeting system. The doc's own mutation ("drop required: true") no longer applies -- Cut 6c.2 already
|
||||
# changed this call to required:false/degrade-and-log, an operator ruling (see the header comment and the
|
||||
# report). The adapted mutation makes the acquisition predicate never match, which is what "the rule regresses
|
||||
# to nothing" now looks like: FillInterior degrades exactly as it does for a galaxy with no seller, silently. ---
|
||||
check_mutation \
|
||||
"EveryArmedLoadoutGetsATargetingSystem: FillInterior must still attempt to acquire a targeting system for an armed hull" \
|
||||
"$LOADOUT_GENERATOR_CS" \
|
||||
$' var (targetingProduct, targetingData) = RandomProduct<GearData>(2, item =>\r\n item.Behaviors.Any(b => b is TargetingSystemData) &&\r\n item.Shape.FitsWithin(emptyShape, out _, out _));' \
|
||||
$' var (targetingProduct, targetingData) = RandomProduct<GearData>(2, item => false);' \
|
||||
"TargetingSystemTests.EveryArmedLoadoutGetsATargetingSystem" \
|
||||
"red"
|
||||
|
||||
echo ""
|
||||
verify_tree_clean
|
||||
|
||||
if [ "${#FAILURES[@]}" -gt 0 ]; then
|
||||
echo ""
|
||||
echo "=== FAILURES ==="
|
||||
for f in "${FAILURES[@]}"; do
|
||||
echo "- $f"
|
||||
done
|
||||
fi
|
||||
|
||||
if [ "${#FAILURES[@]}" -gt 0 ] || [ "$TREE_CLEAN_OK" != "1" ]; then
|
||||
echo ""
|
||||
echo "RESULT: FAILED -- mutations: $([ "${#FAILURES[@]}" -gt 0 ] && echo "${#FAILURES[@]} failure(s)" || echo "all behaved as declared"), tree-clean: $([ "$TREE_CLEAN_OK" = "1" ] && echo PASS || echo FAIL)"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
echo "RESULT: All mutations behaved as declared. tree-clean: PASS"
|
||||
exit 0
|
||||
|
|
@ -1,376 +0,0 @@
|
|||
#!/usr/bin/env bash
|
||||
# Mutation tests for docs/fire-control-cut.md's Cut 3 ("Hit authority moves").
|
||||
#
|
||||
# Fourteen check_mutation calls covering the cut's own thirteen declared tests (one, ProbabilityFollowsInputs,
|
||||
# carries three sub-mutations from its own declared list, so fifteen mutations run in total) against
|
||||
# tests/Aetheria.Shared.Tests/FireAuthorityTests.cs, plus one more against the test Cut 7 rewrote in its place:
|
||||
#
|
||||
# OutOfArcConsumesNoDraw no longer exists. It asserted against ItemManager.Random (`e.Items.Random`), but
|
||||
# Cut 6b (6.1) moved every shot's roll onto its own freshly-seeded, immediately-discarded
|
||||
# `new Random(MixSeed((zone.CombatSeed * 2654435761u) ^ (uint) shot.ShotId | 1u))` local to Commit -- a deliberate,
|
||||
# good change (reproducibility independent of unrelated draws elsewhere) that also meant Commit stopped
|
||||
# reading or writing ItemManager.Random on any path, arc or no arc, which made the old test's own declared
|
||||
# mutation ("roll first and multiply by zero") pass for a reason that had nothing to do with arcs. Cut 7
|
||||
# (fire-control-cut.md, 7.2's own header comment, and FireAuthorityTests.cs's comment on the replacement)
|
||||
# rewrote it as CombatNeverDrawsFromTheSharedStream: no combat path draws from the shared stream, in arc or
|
||||
# out. That rule IS externally observable (Commit has no reference to ItemManager to draw from at all under
|
||||
# the current design, so the only way to make it "touch the shared stream" is to add a read that was not
|
||||
# there before) -- this harness's own mutation below does exactly that, through the one handle Commit does
|
||||
# hold (`shot.Source.ItemManager`, already read elsewhere in this same function for hull data).
|
||||
#
|
||||
# A no-op control (a byte-identical rewrite of one target file through this script's own read/write path)
|
||||
# proves the mechanism itself is transparent before any real mutation is trusted. Modelled on
|
||||
# tests/mutation_tests_fire_control_cut5.sh, the good example: byte-exact I/O, sha256-verified restore after
|
||||
# every single mutation, and an explicit tree-clean verdict line at exit.
|
||||
#
|
||||
# Usage:
|
||||
# tests/mutation_tests_fire_control_cut3.sh <path-to-CultLib-worktree>
|
||||
#
|
||||
# Every mutation is reversed (byte-exact, from an on-disk copy) before the script exits, including on failure
|
||||
# or interrupt, and every restore is verified against a sha256 recorded before any mutation ran: the tree-
|
||||
# clean verdict at the end states PASS/FAIL explicitly rather than leaving a reader to infer it. A SIGKILL of
|
||||
# the whole process group is the one termination bash cannot trap at all -- verify the tree independently
|
||||
# after a run that was killed that hard.
|
||||
|
||||
set -u
|
||||
|
||||
if [ $# -lt 1 ]; then
|
||||
echo "Usage: $0 <cultlib-root>" >&2
|
||||
exit 2
|
||||
fi
|
||||
CULTLIB_ROOT="$1"
|
||||
|
||||
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
|
||||
REPO_ROOT="$(cd "$SCRIPT_DIR/.." && pwd)"
|
||||
TEST_PROJECT="$REPO_ROOT/tests/Aetheria.Shared.Tests"
|
||||
|
||||
FIRE_CONTROL_CS="$REPO_ROOT/Assets/Scripts/ServerShared/FireControl.cs"
|
||||
ENTITY_CS="$REPO_ROOT/Assets/Scripts/ServerShared/Entity.cs"
|
||||
|
||||
# --- byte-exact backup/restore: preserves line endings exactly, no text-mode translation. ---
|
||||
declare -A ORIGINALS
|
||||
declare -A ORIGINAL_HASH
|
||||
FILES=("$FIRE_CONTROL_CS" "$ENTITY_CS")
|
||||
BACKUP_DIR="$(mktemp -d)"
|
||||
|
||||
hash_file() {
|
||||
sha256sum "$1" 2>/dev/null | awk '{print $1}'
|
||||
}
|
||||
|
||||
for f in "${FILES[@]}"; do
|
||||
base="$(basename "$f")"
|
||||
cp -p "$f" "$BACKUP_DIR/$base.orig"
|
||||
ORIGINALS["$f"]="$BACKUP_DIR/$base.orig"
|
||||
ORIGINAL_HASH["$f"]="$(hash_file "$f")"
|
||||
done
|
||||
|
||||
restore_and_verify() {
|
||||
local file="$1"
|
||||
cp -p "${ORIGINALS[$file]}" "$file"
|
||||
local actual
|
||||
actual="$(hash_file "$file")"
|
||||
if [ "$actual" != "${ORIGINAL_HASH[$file]}" ]; then
|
||||
echo "" >&2
|
||||
echo "FATAL: restoring $file did not reproduce its pre-mutation hash -- aborting." >&2
|
||||
echo " expected sha256: ${ORIGINAL_HASH[$file]}" >&2
|
||||
echo " actual sha256: $actual" >&2
|
||||
exit 1
|
||||
fi
|
||||
}
|
||||
|
||||
TREE_CLEAN_OK=1
|
||||
TREE_VERDICT_DONE=0
|
||||
|
||||
verify_tree_clean() {
|
||||
if [ "$TREE_VERDICT_DONE" = "1" ]; then return; fi
|
||||
local f actual
|
||||
for f in "${FILES[@]}"; do
|
||||
cp -p "${ORIGINALS[$f]}" "$f" 2>/dev/null
|
||||
actual="$(hash_file "$f")"
|
||||
if [ "$actual" != "${ORIGINAL_HASH[$f]}" ]; then
|
||||
echo "FATAL: $f is NOT byte-identical to how this script found it." >&2
|
||||
echo " expected sha256: ${ORIGINAL_HASH[$f]}" >&2
|
||||
echo " actual sha256: $actual" >&2
|
||||
TREE_CLEAN_OK=0
|
||||
fi
|
||||
done
|
||||
echo ""
|
||||
if [ "$TREE_CLEAN_OK" = "1" ]; then
|
||||
echo "=== tree-clean verdict: PASS -- every target file is byte-identical to how this script found it ==="
|
||||
else
|
||||
echo "=== tree-clean verdict: FAIL -- see FATAL lines above; the working tree is contaminated ==="
|
||||
fi
|
||||
TREE_VERDICT_DONE=1
|
||||
}
|
||||
trap verify_tree_clean EXIT INT TERM
|
||||
|
||||
FAILURES=()
|
||||
|
||||
# replace_unique FILE ANCHOR REPLACEMENT
|
||||
replace_unique() {
|
||||
local file="$1" anchor="$2" replacement="$3"
|
||||
local count
|
||||
count=$(perl -0777 -e '
|
||||
my ($anchor, $file) = @ARGV;
|
||||
local $/; open(my $fh, "<:raw", $file) or die $!;
|
||||
my $text = <$fh>; close $fh;
|
||||
my $n = () = $text =~ /\Q$anchor\E/g;
|
||||
print $n;
|
||||
' "$anchor" "$file")
|
||||
if [ "$count" != "1" ]; then
|
||||
echo "ERROR: $file: anchor must occur exactly once, found $count" >&2
|
||||
echo "--- anchor ---" >&2
|
||||
echo "$anchor" >&2
|
||||
return 1
|
||||
fi
|
||||
perl -0777 -e '
|
||||
my ($file, $anchor, $replacement) = @ARGV;
|
||||
local $/; open(my $fh, "<:raw", $file) or die $!;
|
||||
my $text = <$fh>; close $fh;
|
||||
my $qa = quotemeta($anchor);
|
||||
$text =~ s/$qa/$replacement/;
|
||||
open(my $out, ">:raw", $file) or die $!;
|
||||
print $out $text; close $out;
|
||||
' "$file" "$anchor" "$replacement"
|
||||
}
|
||||
|
||||
ARTIFACTS_PATH="$(mktemp -d)"
|
||||
|
||||
run_test() {
|
||||
local test="$1"
|
||||
local out
|
||||
out=$(dotnet test "$TEST_PROJECT" -p:CultLibRoot="$CULTLIB_ROOT" --artifacts-path "$ARTIFACTS_PATH" --filter "FullyQualifiedName~$test" 2>&1)
|
||||
if echo "$out" | grep -q "Passed!" && ! echo "$out" | grep -q "Failed: [1-9]"; then
|
||||
return 0
|
||||
fi
|
||||
LAST_OUTPUT="$out"
|
||||
return 1
|
||||
}
|
||||
|
||||
check_mutation() {
|
||||
local name="$1" file="$2" anchor="$3" mutated="$4" test="$5" expect="$6"
|
||||
echo ""
|
||||
echo "=== $name (expect $expect) ==="
|
||||
if ! replace_unique "$file" "$anchor" "$mutated"; then
|
||||
FAILURES+=("$name: anchor replacement failed")
|
||||
restore_and_verify "$file"
|
||||
return
|
||||
fi
|
||||
if run_test "$test"; then
|
||||
passed=1
|
||||
else
|
||||
passed=0
|
||||
fi
|
||||
restore_and_verify "$file"
|
||||
|
||||
if [ "$expect" = "green" ]; then
|
||||
if [ "$passed" = "1" ]; then
|
||||
echo "OK: $test passed under a no-op mutation, as expected"
|
||||
else
|
||||
FAILURES+=("$name: expected green (test passing), got failure")
|
||||
echo "$LAST_OUTPUT" | tail -40
|
||||
fi
|
||||
else
|
||||
if [ "$passed" = "0" ]; then
|
||||
echo "OK: $test went red under the mutation, as expected"
|
||||
else
|
||||
FAILURES+=("$name: expected red (mutant killed), but $test still passed")
|
||||
fi
|
||||
fi
|
||||
}
|
||||
|
||||
# --- No-op control ---
|
||||
check_mutation \
|
||||
"control (no-op round trip)" \
|
||||
"$FIRE_CONTROL_CS" \
|
||||
'var elapsed = now - shot.FireTime;' \
|
||||
'var elapsed = now - shot.FireTime;' \
|
||||
"FireAuthorityTests.EvasionCountsUntilCommitAndNotAfter" \
|
||||
"green"
|
||||
|
||||
# --- RollsAreSeeded (Q5): a fight must be reproducible from its seed. Mutation: draw from an unseeded,
|
||||
# non-reproducible generator instead of the pure function of (zone identity, shot id). A first attempt seeded
|
||||
# from System.DateTime.UtcNow.Ticks survived: this whole 50-shot fixture runs faster than the clock's own tick
|
||||
# resolution, so two runs a few milliseconds apart drew the identical "wall clock" value every time and the
|
||||
# mutation was accidentally still deterministic. Guid.NewGuid() carries its own entropy source independent of
|
||||
# clock resolution and reliably breaks reproducibility. ---
|
||||
check_mutation \
|
||||
"RollsAreSeeded: Commit's draw must be a pure function of (zone, shot id), not fresh entropy per call" \
|
||||
"$FIRE_CONTROL_CS" \
|
||||
'var random = new Random(MixSeed((zone.CombatSeed * 2654435761u) ^ (uint) shot.ShotId | 1u));' \
|
||||
'var random = new Random((uint) System.Guid.NewGuid().GetHashCode() | 1u);' \
|
||||
"FireAuthorityTests.RollsAreSeeded" \
|
||||
"red"
|
||||
|
||||
# --- ProbabilityFollowsInputs, variant 1/3: drop pSensor -- probability stops depending on detection info. ---
|
||||
# Cut 7 (docs/fire-control-cut.md, Cut 8 header) routed HitProbability through Inspect, replacing this same
|
||||
# return line's five early returns with flags and one final gate -- the anchor below tracks Inspect's own
|
||||
# spelling (diagnostic.PBase's product of factors) rather than the pre-Inspect line, which no longer exists.
|
||||
check_mutation \
|
||||
"ProbabilityFollowsInputs (1/3): HitProbability must scale with pSensor, not drop it" \
|
||||
"$FIRE_CONTROL_CS" \
|
||||
'diagnostic.PBase = diagnostic.Accuracy * diagnostic.PSensor * diagnostic.PSpread * diagnostic.POnHull;' \
|
||||
'diagnostic.PBase = diagnostic.Accuracy * diagnostic.PSpread * diagnostic.POnHull;' \
|
||||
"FireAuthorityTests.ProbabilityFollowsInputs" \
|
||||
"red"
|
||||
|
||||
# --- ProbabilityFollowsInputs, variant 2/3: drop the range gate -- nonzero probability outside [MinRange,Range].
|
||||
# Cut 8's own re-anchor: Inspect computes InRange as its own flag rather than an early return; forcing it to
|
||||
# true unconditionally is the current spelling of "skip the check." ---
|
||||
check_mutation \
|
||||
"ProbabilityFollowsInputs (2/3): HitProbability must gate on [MinRange, Range], not skip the check" \
|
||||
"$FIRE_CONTROL_CS" \
|
||||
'diagnostic.InRange = diagnostic.Range >= weapon.MinRange && diagnostic.Range <= weapon.Range;' \
|
||||
'diagnostic.InRange = true;' \
|
||||
"FireAuthorityTests.ProbabilityFollowsInputs" \
|
||||
"red"
|
||||
|
||||
# --- ProbabilityFollowsInputs, variant 3/3: drop the UnaidedAccuracy cap -- an unaided shooter rolls at full
|
||||
# strength (1f) instead of the authored handicap. ---
|
||||
check_mutation \
|
||||
"ProbabilityFollowsInputs (3/3): an unaided shooter must cap at UnaidedAccuracy, not roll at full strength" \
|
||||
"$FIRE_CONTROL_CS" \
|
||||
': entity.ItemManager.GameplaySettings.UnaidedAccuracy;' \
|
||||
': 1f;' \
|
||||
"FireAuthorityTests.ProbabilityFollowsInputs" \
|
||||
"red"
|
||||
|
||||
# --- ShotResolvesOnArrival (R3): no durability change before arrival. Mutation: apply as soon as committed,
|
||||
# not only once the shot has also arrived. ---
|
||||
check_mutation \
|
||||
"ShotResolvesOnArrival: Step must apply only at ArrivalTime, not as soon as a shot commits" \
|
||||
"$FIRE_CONTROL_CS" \
|
||||
'if (shot.Committed && now >= shot.ArrivalTime)' \
|
||||
'if (shot.Committed)' \
|
||||
"FireAuthorityTests.ShotResolvesOnArrival" \
|
||||
"red"
|
||||
|
||||
# --- DeadEntityStopsTakingShots (0b table): a shot whose target left the zone must resolve as a miss and be
|
||||
# removed outright, not go on resolving against a target no longer in play. ---
|
||||
check_mutation \
|
||||
"DeadEntityStopsTakingShots: Step must detect a target that left the zone, not resolve against it anyway" \
|
||||
"$FIRE_CONTROL_CS" \
|
||||
'var targetGone = shot.Target != null && !zone.Entities.Contains(shot.Target);' \
|
||||
'var targetGone = false;' \
|
||||
"FireAuthorityTests.DeadEntityStopsTakingShots" \
|
||||
"red"
|
||||
|
||||
# --- OutcomeCommitsBeforeImpact (R4): ShotCommitted must fire exactly once per shot. Mutation: publish it
|
||||
# twice from the same commit branch. ---
|
||||
check_mutation \
|
||||
"OutcomeCommitsBeforeImpact: ShotCommitted must publish exactly once per shot, not twice" \
|
||||
"$FIRE_CONTROL_CS" \
|
||||
$' zone.ShotCommitted.OnNext(shot.Outcome);\r\n shots[i] = shot;\r\n }\r\n\r\n if (shot.Committed && now >= shot.ArrivalTime)' \
|
||||
$' zone.ShotCommitted.OnNext(shot.Outcome);\r\n zone.ShotCommitted.OnNext(shot.Outcome);\r\n shots[i] = shot;\r\n }\r\n\r\n if (shot.Committed && now >= shot.ArrivalTime)' \
|
||||
"FireAuthorityTests.OutcomeCommitsBeforeImpact" \
|
||||
"red"
|
||||
|
||||
# --- EvasionCountsUntilCommitAndNotAfter (R4's second half): deviation must be measured against the elapsed
|
||||
# time at commit, not at fire (elapsed pinned to 0, i.e. no drift accounted for at all). Cut 5's own harness
|
||||
# already pins this test's OTHER declared mutation (elapsed pinned to the shot's fixed total duration, i.e.
|
||||
# measured as if always at arrival) as a named survivor; this is the complementary variant from this cut's own
|
||||
# table. ---
|
||||
check_mutation \
|
||||
"EvasionCountsUntilCommitAndNotAfter: deviation must track elapsed time, not measure as if fired an instant ago" \
|
||||
"$FIRE_CONTROL_CS" \
|
||||
'var elapsed = now - shot.FireTime;' \
|
||||
'var elapsed = 0f;' \
|
||||
"FireAuthorityTests.EvasionCountsUntilCommitAndNotAfter" \
|
||||
"red"
|
||||
|
||||
# --- ShortFlightCommitsAtFire (R4's graceful degradation): a sub-horizon flight must commit at fire time, in
|
||||
# the same tick. Mutation: always require the full CommitHorizon regardless of how short the flight is, so a
|
||||
# zero-velocity shot's CommitTime lands after its own ArrivalTime and the commit/resolve order breaks. ---
|
||||
check_mutation \
|
||||
"ShortFlightCommitsAtFire: CommitTime must degrade to fire time for a sub-horizon flight, not always wait a full horizon" \
|
||||
"$FIRE_CONTROL_CS" \
|
||||
'CommitTime = now + max(0f, flightTime - commitHorizon),' \
|
||||
'CommitTime = now + commitHorizon,' \
|
||||
"FireAuthorityTests.ShortFlightCommitsAtFire" \
|
||||
"red"
|
||||
|
||||
# --- OutcomeIsSnapshotNotReread (R10/Q6): Apply must use the frozen shot.Damage, not re-read the weapon's
|
||||
# live (possibly since-changed) Damage at arrival. ---
|
||||
check_mutation \
|
||||
"OutcomeIsSnapshotNotReread: Apply must use the frozen shot.Damage, not re-read the live weapon stat" \
|
||||
"$FIRE_CONTROL_CS" \
|
||||
'shot.Target.ApplyHit(shot.Source, shot.Outcome.Cell, shot.DamageSpread, shot.Penetration, shot.Damage, hitDirection);' \
|
||||
'shot.Target.ApplyHit(shot.Source, shot.Outcome.Cell, shot.DamageSpread, shot.Penetration, ((Weapon) shot.Weapon.Behaviors.First(b => b is Weapon)).Damage, hitDirection);' \
|
||||
"FireAuthorityTests.OutcomeIsSnapshotNotReread" \
|
||||
"red"
|
||||
|
||||
# --- AimedHitLandsOnSelectedItem (R5's payoff): with Precision authored extremely tight (Cut 6d: the test now
|
||||
# passes precision: 1000, a sigma a tiny fraction of one cell), a hit must land on the aimed item's own cell,
|
||||
# not the hull's centre of mass. Cut 6d deleted the coin-flip branch this mutation used to sabotage
|
||||
# (`random.NextFloat() < shot.Precision` picking between the aimed cell and a uniform-random one); the
|
||||
# equivalent sabotage under the dart-throw kernel is feeding Commit's aim-point resolver a null Aimed, so it
|
||||
# falls back to the centre-of-mass path unconditionally -- the same "aim is ignored" defect, on the new code. ---
|
||||
check_mutation \
|
||||
"AimedHitLandsOnSelectedItem: the kernel's aim point must respect Aimed, not fall back to centre of mass" \
|
||||
"$FIRE_CONTROL_CS" \
|
||||
'var (aimPoint, aimedCells) = ResolveAimPoint(shot.Target, hullData, shot.Aimed);' \
|
||||
'var (aimPoint, aimedCells) = ResolveAimPoint(shot.Target, hullData, null);' \
|
||||
"FireAuthorityTests.AimedHitLandsOnSelectedItem" \
|
||||
"red"
|
||||
|
||||
# --- HardpointHitDamagesItemThenHull: damage above armor plus item durability must pass its remainder on to
|
||||
# the hull. Mutation: drop the remainder (it never reaches the hull). ---
|
||||
check_mutation \
|
||||
"HardpointHitDamagesItemThenHull: the remainder after armor and item must reach the hull, not be dropped" \
|
||||
"$ENTITY_CS" \
|
||||
'hullDamage += d;' \
|
||||
'// dropped -- remainder never reaches the hull' \
|
||||
"FireAuthorityTests.HardpointHitDamagesItemThenHull" \
|
||||
"red"
|
||||
|
||||
# --- PenetrationMarchIsPlanar (R7): the march must rotate the hit direction into the target's own facing, not
|
||||
# march along the raw world-space direction. ---
|
||||
check_mutation \
|
||||
"PenetrationMarchIsPlanar: the penetration march must rotate into the target's own frame, not use raw world direction" \
|
||||
"$ENTITY_CS" \
|
||||
$' var forward = normalize(Direction);\r\n var right = float2(forward.y, -forward.x);\r\n var penetrationVector = normalize(float2(dot(hitDirection, right), dot(hitDirection, forward)));' \
|
||||
' var penetrationVector = normalize(hitDirection);' \
|
||||
"FireAuthorityTests.PenetrationMarchIsPlanar" \
|
||||
"red"
|
||||
|
||||
# --- ShieldTakesHit: the absorb rule must have one owner (FireControl.Commit). Mutation: remove the shield
|
||||
# branch entirely -- shielded/shieldBroken never get set, so Apply always lets the hit reach the hull. ---
|
||||
check_mutation \
|
||||
"ShieldTakesHit: Commit must decide the shield branch, not skip it (hit always reaches the hull)" \
|
||||
"$FIRE_CONTROL_CS" \
|
||||
$' var shield = shot.Target.Shield;\r\n var shieldActive = shield != null && shield.Item.Active.Value;\r\n if (shieldActive && shield.CanTakeHit(shot.DamageType, shot.Damage)) shielded = true;\r\n else if (shieldActive) shieldBroken = true;' \
|
||||
$' var shield = shot.Target.Shield;' \
|
||||
"FireAuthorityTests.ShieldTakesHit" \
|
||||
"red"
|
||||
|
||||
# --- Cut 7's replacement for OutOfArcConsumesNoDraw: CombatNeverDrawsFromTheSharedStream. Mutation: Commit
|
||||
# reads shot.Source.ItemManager.Random (the shared stream FireAuthorityTests.cs's own Build exposes as
|
||||
# e.Items.Random) once per call, restoring exactly the kind of shared-stream touch Cut 6b (6.1) removed --
|
||||
# unconditionally, so both the theory's in-arc and out-of-arc cases go red. ---
|
||||
check_mutation \
|
||||
"CombatNeverDrawsFromTheSharedStream: Commit must never read ItemManager's shared stream, on any path" \
|
||||
"$FIRE_CONTROL_CS" \
|
||||
'var random = new Random(MixSeed((zone.CombatSeed * 2654435761u) ^ (uint) shot.ShotId | 1u));' \
|
||||
'var random = new Random(MixSeed((zone.CombatSeed * 2654435761u) ^ (uint) shot.ShotId | 1u)); shot.Source.ItemManager.Random.NextFloat();' \
|
||||
"FireAuthorityTests.CombatNeverDrawsFromTheSharedStream" \
|
||||
"red"
|
||||
|
||||
echo ""
|
||||
verify_tree_clean
|
||||
|
||||
if [ "${#FAILURES[@]}" -gt 0 ]; then
|
||||
echo ""
|
||||
echo "=== FAILURES ==="
|
||||
for f in "${FAILURES[@]}"; do
|
||||
echo "- $f"
|
||||
done
|
||||
fi
|
||||
|
||||
if [ "${#FAILURES[@]}" -gt 0 ] || [ "$TREE_CLEAN_OK" != "1" ]; then
|
||||
echo ""
|
||||
echo "RESULT: FAILED -- mutations: $([ "${#FAILURES[@]}" -gt 0 ] && echo "${#FAILURES[@]} failure(s)" || echo "all behaved as declared"), tree-clean: $([ "$TREE_CLEAN_OK" = "1" ] && echo PASS || echo FAIL)"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
echo "RESULT: All mutations behaved as declared. tree-clean: PASS"
|
||||
exit 0
|
||||
|
|
@ -1,201 +0,0 @@
|
|||
#!/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())
|
||||
|
|
@ -1,353 +0,0 @@
|
|||
#!/usr/bin/env bash
|
||||
# Mutation tests for docs/fire-control-cut.md's Cut 5 ("the rules that landed in one path and not its twin").
|
||||
#
|
||||
# Eleven mutations, each against tests/Aetheria.Shared.Tests/FireControlCut5Tests.cs or
|
||||
# tests/Aetheria.Shared.Tests/FireAuthorityTests.cs:
|
||||
#
|
||||
# 1-7. 5.1 through 5.7, each killing the test this cut wrote or extended to pin it:
|
||||
# 5.1 UnaidedTrackingFallsBackToSettingNotZero / TrackingIsAFloorNotACliff
|
||||
# 5.2 UnabsorbableHitBreaksShield
|
||||
# 5.3 ConstantWeaponObeysArc
|
||||
# 5.4 PointBlankBearingAlwaysBears
|
||||
# 5.5 DestroyedItemStopsBeingAimPoint
|
||||
# 5.6 DeathRemovesShipFromSimulation
|
||||
# 5.7 PendingShotCarriesNoDeadFields
|
||||
# 8-11. Four of the five mutations that survived 197 green tests in Soul's own pass (the fifth,
|
||||
# freezing source.TargetItem.Value instead of ResolvedTargetItem, is Cut 7's):
|
||||
# FireControl.cs -- delete shield.Break() from Splash
|
||||
# Weapon.cs -- ArcAllowsFire hardcoded true
|
||||
# InstantWeapon.cs -- delete the player's own arc gate
|
||||
# FireControl.cs (Commit) -- elapsed pinned to the shot's own duration instead of tracking `now`
|
||||
# (the map's own named mutation for EvasionCountsUntilCommitAndNotAfter; post-5.7 the spelling
|
||||
# is `shot.ArrivalTime - shot.FireTime` rather than the deleted FlightTime field, same rule)
|
||||
#
|
||||
# A no-op control (a byte-identical rewrite of one target file through this script's own read/write path)
|
||||
# proves the mechanism itself is transparent before any real mutation is trusted.
|
||||
#
|
||||
# Usage:
|
||||
# tests/mutation_tests_fire_control_cut5.sh <path-to-CultLib-45c2f40-worktree>
|
||||
#
|
||||
# Every mutation is reversed (byte-exact, from an on-disk copy) before the script exits, including on failure
|
||||
# or interrupt, and every restore is verified against a sha256 recorded before any mutation ran: the tree-
|
||||
# clean verdict at the end states PASS/FAIL explicitly rather than leaving a reader to infer it. A SIGKILL of
|
||||
# the whole process group is the one termination bash cannot trap at all -- verify the tree independently
|
||||
# after a run that was killed that hard.
|
||||
|
||||
set -u
|
||||
|
||||
if [ $# -lt 1 ]; then
|
||||
echo "Usage: $0 <cultlib-root>" >&2
|
||||
exit 2
|
||||
fi
|
||||
CULTLIB_ROOT="$1"
|
||||
|
||||
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
|
||||
REPO_ROOT="$(cd "$SCRIPT_DIR/.." && pwd)"
|
||||
TEST_PROJECT="$REPO_ROOT/tests/Aetheria.Shared.Tests"
|
||||
|
||||
FIRE_CONTROL_CS="$REPO_ROOT/Assets/Scripts/ServerShared/FireControl.cs"
|
||||
WEAPON_CS="$REPO_ROOT/Assets/Scripts/ServerShared/Behaviors/Weapon.cs"
|
||||
INSTANT_WEAPON_CS="$REPO_ROOT/Assets/Scripts/ServerShared/Behaviors/InstantWeapon.cs"
|
||||
CONSTANT_WEAPON_CS="$REPO_ROOT/Assets/Scripts/ServerShared/Behaviors/ConstantWeapon.cs"
|
||||
ZONE_CS="$REPO_ROOT/Assets/Scripts/ServerShared/Zone.cs"
|
||||
|
||||
# --- byte-exact backup/restore: preserves line endings exactly, no text-mode translation. ---
|
||||
#
|
||||
# Cut 5b (5b.3): a restore that is not verified is a restore that can lie. Three runs of this script have
|
||||
# left mutated files behind (a trap cannot survive a hard kill of the process group, and nothing before this
|
||||
# cut ever checked that a `cp -p` restore actually reproduced the original bytes). Every target file's
|
||||
# pre-mutation sha256 is recorded once here, before any mutation touches it; every restore below --
|
||||
# per-mutation and at exit -- re-hashes and refuses to let a mismatch pass silently.
|
||||
declare -A ORIGINALS
|
||||
declare -A ORIGINAL_HASH
|
||||
FILES=("$FIRE_CONTROL_CS" "$WEAPON_CS" "$INSTANT_WEAPON_CS" "$CONSTANT_WEAPON_CS" "$ZONE_CS")
|
||||
BACKUP_DIR="$(mktemp -d)"
|
||||
|
||||
hash_file() {
|
||||
sha256sum "$1" 2>/dev/null | awk '{print $1}'
|
||||
}
|
||||
|
||||
for f in "${FILES[@]}"; do
|
||||
base="$(basename "$f")"
|
||||
cp -p "$f" "$BACKUP_DIR/$base.orig"
|
||||
ORIGINALS["$f"]="$BACKUP_DIR/$base.orig"
|
||||
ORIGINAL_HASH["$f"]="$(hash_file "$f")"
|
||||
done
|
||||
|
||||
# restore_and_verify FILE -- restores one file from its recorded backup, then re-hashes it and aborts the
|
||||
# whole script (not just this mutation) if the restore did not reproduce the pre-mutation hash exactly. A
|
||||
# mismatch here means continuing would judge every later mutation against an already-contaminated baseline,
|
||||
# which is strictly worse than stopping.
|
||||
restore_and_verify() {
|
||||
local file="$1"
|
||||
cp -p "${ORIGINALS[$file]}" "$file"
|
||||
local actual
|
||||
actual="$(hash_file "$file")"
|
||||
if [ "$actual" != "${ORIGINAL_HASH[$file]}" ]; then
|
||||
echo "" >&2
|
||||
echo "FATAL: restoring $file did not reproduce its pre-mutation hash -- aborting." >&2
|
||||
echo " expected sha256: ${ORIGINAL_HASH[$file]}" >&2
|
||||
echo " actual sha256: $actual" >&2
|
||||
exit 1
|
||||
fi
|
||||
}
|
||||
|
||||
TREE_CLEAN_OK=1
|
||||
TREE_VERDICT_DONE=0
|
||||
|
||||
# verify_tree_clean -- re-hashes every target file against its pre-mutation baseline and prints an explicit
|
||||
# PASS/FAIL verdict. Called once explicitly at the bottom of a normal run (so the verdict is part of that
|
||||
# run's own summary, not something a reader has to infer from silence) and again from the EXIT/INT/TERM trap
|
||||
# as the safety net for every abnormal exit -- interrupted, failed, or killed in a way bash can still trap.
|
||||
# (A SIGKILL of the whole process group is the one termination this cannot see at all; nothing running inside
|
||||
# the killed process can run afterward, trap or not -- that gap is closed by re-checking independently after
|
||||
# the fact, not by this function.)
|
||||
verify_tree_clean() {
|
||||
if [ "$TREE_VERDICT_DONE" = "1" ]; then return; fi
|
||||
local f actual
|
||||
for f in "${FILES[@]}"; do
|
||||
cp -p "${ORIGINALS[$f]}" "$f" 2>/dev/null
|
||||
actual="$(hash_file "$f")"
|
||||
if [ "$actual" != "${ORIGINAL_HASH[$f]}" ]; then
|
||||
echo "FATAL: $f is NOT byte-identical to how this script found it." >&2
|
||||
echo " expected sha256: ${ORIGINAL_HASH[$f]}" >&2
|
||||
echo " actual sha256: $actual" >&2
|
||||
TREE_CLEAN_OK=0
|
||||
fi
|
||||
done
|
||||
echo ""
|
||||
if [ "$TREE_CLEAN_OK" = "1" ]; then
|
||||
echo "=== tree-clean verdict: PASS -- every target file is byte-identical to how this script found it ==="
|
||||
else
|
||||
echo "=== tree-clean verdict: FAIL -- see FATAL lines above; the working tree is contaminated ==="
|
||||
fi
|
||||
TREE_VERDICT_DONE=1
|
||||
}
|
||||
trap verify_tree_clean EXIT INT TERM
|
||||
|
||||
FAILURES=()
|
||||
|
||||
# replace_unique FILE ANCHOR REPLACEMENT
|
||||
# Anchor must occur exactly once (byte-exact match via awk/perl, not sed's line-oriented rewriting, so a
|
||||
# multi-line anchor is matched as literal text). Writes the mutated content back byte-exact.
|
||||
replace_unique() {
|
||||
local file="$1" anchor="$2" replacement="$3"
|
||||
local count
|
||||
count=$(perl -0777 -e '
|
||||
my ($anchor, $file) = @ARGV;
|
||||
local $/; open(my $fh, "<:raw", $file) or die $!;
|
||||
my $text = <$fh>; close $fh;
|
||||
my $n = () = $text =~ /\Q$anchor\E/g;
|
||||
print $n;
|
||||
' "$anchor" "$file")
|
||||
if [ "$count" != "1" ]; then
|
||||
echo "ERROR: $file: anchor must occur exactly once, found $count" >&2
|
||||
echo "--- anchor ---" >&2
|
||||
echo "$anchor" >&2
|
||||
return 1
|
||||
fi
|
||||
# No -i: on this filesystem perl's in-place-edit extension parsing has mangled a literal ":raw" suffix
|
||||
# into stray sibling files before. Read, substitute once, write back byte-exact instead.
|
||||
perl -0777 -e '
|
||||
my ($file, $anchor, $replacement) = @ARGV;
|
||||
local $/; open(my $fh, "<:raw", $file) or die $!;
|
||||
my $text = <$fh>; close $fh;
|
||||
my $qa = quotemeta($anchor);
|
||||
$text =~ s/$qa/$replacement/;
|
||||
open(my $out, ">:raw", $file) or die $!;
|
||||
print $out $text; close $out;
|
||||
' "$file" "$anchor" "$replacement"
|
||||
}
|
||||
|
||||
# A dedicated, isolated artifacts path -- the default tests/Aetheria.Shared.Tests/bin,obj can carry stale
|
||||
# prebuilt CultLib assemblies from an unrelated earlier session's default-path build, and mixing those with a
|
||||
# freshly compiled Aetheria.Shared.Tests.dll produced one observed spurious failure of a control (no-op)
|
||||
# mutation. A private --artifacts-path sidesteps that shared, unowned build state entirely.
|
||||
ARTIFACTS_PATH="$(mktemp -d)"
|
||||
|
||||
# run_test TEST_FQN -> 0 (passed) or 1 (failed/errored)
|
||||
run_test() {
|
||||
local test="$1"
|
||||
local out
|
||||
out=$(dotnet test "$TEST_PROJECT" -p:CultLibRoot="$CULTLIB_ROOT" --artifacts-path "$ARTIFACTS_PATH" --filter "FullyQualifiedName~$test" 2>&1)
|
||||
if echo "$out" | grep -q "Passed!" && ! echo "$out" | grep -q "Failed: [1-9]"; then
|
||||
return 0
|
||||
fi
|
||||
LAST_OUTPUT="$out"
|
||||
return 1
|
||||
}
|
||||
|
||||
# check_mutation NAME FILE ANCHOR MUTATED TEST EXPECT("red"|"green")
|
||||
check_mutation() {
|
||||
local name="$1" file="$2" anchor="$3" mutated="$4" test="$5" expect="$6"
|
||||
echo ""
|
||||
echo "=== $name (expect $expect) ==="
|
||||
if ! replace_unique "$file" "$anchor" "$mutated"; then
|
||||
FAILURES+=("$name: anchor replacement failed")
|
||||
restore_and_verify "$file"
|
||||
return
|
||||
fi
|
||||
if run_test "$test"; then
|
||||
passed=1
|
||||
else
|
||||
passed=0
|
||||
fi
|
||||
restore_and_verify "$file" # restore this file immediately, before judging, so a crash still leaves a clean tree
|
||||
|
||||
if [ "$expect" = "green" ]; then
|
||||
if [ "$passed" = "1" ]; then
|
||||
echo "OK: $test passed under a no-op mutation, as expected"
|
||||
else
|
||||
FAILURES+=("$name: expected green (test passing), got failure")
|
||||
echo "$LAST_OUTPUT" | tail -40
|
||||
fi
|
||||
else
|
||||
if [ "$passed" = "0" ]; then
|
||||
echo "OK: $test went red under the mutation, as expected"
|
||||
else
|
||||
FAILURES+=("$name: expected red (mutant killed), but $test still passed")
|
||||
fi
|
||||
fi
|
||||
}
|
||||
|
||||
# --- No-op control: a byte-identical round trip through this script's own replace_unique path, proving the
|
||||
# anchor/replace/restore mechanism is transparent before any real mutation is trusted. ---
|
||||
check_mutation \
|
||||
"control (no-op round trip)" \
|
||||
"$FIRE_CONTROL_CS" \
|
||||
'var pDeviation = DeviationProbability(shot, now, out _);' \
|
||||
'var pDeviation = DeviationProbability(shot, now, out _);' \
|
||||
"FireControlCut5Tests.TrackingIsAFloorNotACliff" \
|
||||
"green"
|
||||
|
||||
# --- 5.1: Tracking is a floor, not a cliff. Mutation: the unaided fallback reverts to a literal 0f (division
|
||||
# by zero saturates the deviation term to 0 regardless of how forgivable the deviation actually is). ---
|
||||
check_mutation \
|
||||
"5.1 Tracking must fall back to UnaidedTracking, not 0" \
|
||||
"$FIRE_CONTROL_CS" \
|
||||
'return system != null && system.Item.Active.Value ? system.Tracking : entity.ItemManager.GameplaySettings.UnaidedTracking;' \
|
||||
'return system != null && system.Item.Active.Value ? system.Tracking : 0f;' \
|
||||
"FireControlCut5Tests.UnaidedTrackingFallsBackToSettingNotZero" \
|
||||
"red"
|
||||
|
||||
# --- 5.2: a shot the shield cannot absorb breaks it. Mutation: delete Apply's Break() call -- the hull still
|
||||
# takes the remainder, but the shield itself never registers the overwhelming hit. ---
|
||||
check_mutation \
|
||||
"5.2 Apply must Break() a shield the roll decided ShieldBroken" \
|
||||
"$FIRE_CONTROL_CS" \
|
||||
'if (shot.Outcome.ShieldBroken) shot.Target.Shield.Break();' \
|
||||
'// deleted' \
|
||||
"FireControlCut5Tests.UnabsorbableHitBreaksShield" \
|
||||
"red"
|
||||
|
||||
# --- 5.3: continuous weapons obey their arc. Mutation: ConstantWeapon's safety check drops ArcAllowsFire,
|
||||
# restoring StanceAllowsFire-only gating (a side-mounted beam fires forward). ---
|
||||
check_mutation \
|
||||
"5.3 ConstantWeapon.Execute must safe off on ArcAllowsFire too, not StanceAllowsFire alone" \
|
||||
"$CONSTANT_WEAPON_CS" \
|
||||
'if (_firing && !(StanceAllowsFire && ArcAllowsFire))' \
|
||||
'if (_firing && !StanceAllowsFire)' \
|
||||
"FireControlCut5Tests.ConstantWeaponObeysArc" \
|
||||
"red"
|
||||
|
||||
# --- 5.4: one bearing test, including at zero range. Mutation: drop InArc's point-blank guard, restoring the
|
||||
# NaN comparison a zero-length bearing used to fall through to. ---
|
||||
check_mutation \
|
||||
"5.4 InArc must treat a zero-length bearing as bearing true, not fall through to NaN" \
|
||||
"$FIRE_CONTROL_CS" \
|
||||
$' if (lengthsq(planarToTarget) < 1e-6f) return true;\r\n var planarTarget = normalize(planarToTarget);' \
|
||||
' var planarTarget = normalize(planarToTarget);' \
|
||||
"FireControlCut5Tests.PointBlankBearingAlwaysBears" \
|
||||
"red"
|
||||
|
||||
# --- 5.5: a destroyed subsystem stops being the aim point. Mutation: drop the Durability filter from
|
||||
# IsRevealed's ranking -- a destroyed item keeps its old tier and TargetItem keeps aiming at a corpse. ---
|
||||
check_mutation \
|
||||
"5.5 IsRevealed's ranking must exclude a destroyed item, not just fail its own tier" \
|
||||
"$FIRE_CONTROL_CS" \
|
||||
'.Where(e => e.Data.HardpointType != HardpointType.Hull && e.EquippableItem.Durability >= .01f)' \
|
||||
'.Where(e => e.Data.HardpointType != HardpointType.Hull)' \
|
||||
"FireControlCut5Tests.DestroyedItemStopsBeingAimPoint" \
|
||||
"red"
|
||||
|
||||
# --- 5.6: death removes the ship, in the simulation. Mutation: remove Zone's own Death subscription --
|
||||
# nothing outside Unity's own (untested here) loot-drop path would ever remove the corpse. Cut 8's own
|
||||
# re-anchor: 8.1 (docs/fire-control-cut.md) paired this subscription's Entities.Remove with a Deactivate()
|
||||
# call -- only the spelling grew, the rule under test (does Zone remove the corpse at all) is unchanged. ---
|
||||
check_mutation \
|
||||
"5.6 Zone must remove a dead entity itself, not rely on a presentation subscription" \
|
||||
"$ZONE_CS" \
|
||||
'Entities.ObserveAdd().Subscribe(add => add.Value.Death.Subscribe(_ => { Entities.Remove(add.Value); add.Value.Deactivate(); }));' \
|
||||
'// deleted -- nothing removes a dead entity' \
|
||||
"FireControlCut5Tests.DeathRemovesShipFromSimulation" \
|
||||
"red"
|
||||
|
||||
# --- 5.7: delete the two carried-and-unread fields. Mutation: re-add PredictedIntercept to the struct. ---
|
||||
check_mutation \
|
||||
"5.7 PendingShot must not carry PredictedIntercept back" \
|
||||
"$FIRE_CONTROL_CS" \
|
||||
$' public float3 FireTargetVelocity;\r\n public float FireTime;' \
|
||||
$' public float3 FireTargetVelocity;\r\n public float3 PredictedIntercept;\r\n public float FireTime;' \
|
||||
"FireControlCut5Tests.PendingShotCarriesNoDeadFields" \
|
||||
"red"
|
||||
|
||||
# --- Named survivor 1: FireControl.cs -- delete shield.Break() from Splash. ---
|
||||
check_mutation \
|
||||
"survivor: Splash must Break() a shield it does not absorb" \
|
||||
"$FIRE_CONTROL_CS" \
|
||||
'if (shieldActive && !shieldAbsorbs) shield.Break();' \
|
||||
'// deleted' \
|
||||
"FireControlCut5Tests.SplashBreaksUnabsorbedShield" \
|
||||
"red"
|
||||
|
||||
# --- Named survivor 2: Weapon.cs -- ArcAllowsFire hardcoded true. ---
|
||||
check_mutation \
|
||||
"survivor: Weapon.ArcAllowsFire must defer to InArc's real answer" \
|
||||
"$WEAPON_CS" \
|
||||
'return FireControl.InArc(Item, toTarget);' \
|
||||
'return true;' \
|
||||
"FireControlCut5Tests.WeaponArcAllowsFireReflectsRealBearing" \
|
||||
"red"
|
||||
|
||||
# --- Named survivor 3: InstantWeapon.cs -- delete the player's own arc gate (Q2, Cut 3's own deferred rule). ---
|
||||
check_mutation \
|
||||
"survivor: InstantWeapon.Trigger must refuse to queue a burst out of arc" \
|
||||
"$INSTANT_WEAPON_CS" \
|
||||
'if (!ArcAllowsFire) return;' \
|
||||
'' \
|
||||
"FireControlCut5Tests.PlayerTriggerObeysArcGate" \
|
||||
"red"
|
||||
|
||||
# --- Named survivor 4: FireControl.cs (Commit) -- elapsed pinned to the shot's own fixed duration instead of
|
||||
# tracking `now`, so deviation never grows past its value at the instant the shot was queued. This is the
|
||||
# map's own declared mutation for EvasionCountsUntilCommitAndNotAfter (FireAuthorityTests.cs); post-5.7 the
|
||||
# spelling is `shot.ArrivalTime - shot.FireTime` (constant) rather than the deleted FlightTime field. ---
|
||||
check_mutation \
|
||||
"survivor: Commit must measure elapsed against now, not a fixed duration" \
|
||||
"$FIRE_CONTROL_CS" \
|
||||
'var elapsed = now - shot.FireTime;' \
|
||||
'var elapsed = shot.ArrivalTime - shot.FireTime;' \
|
||||
"FireAuthorityTests.EvasionCountsUntilCommitAndNotAfter" \
|
||||
"red"
|
||||
|
||||
echo ""
|
||||
verify_tree_clean # explicit call: the tree-clean verdict is part of THIS run's own summary, not only
|
||||
# something printed later at exit (the trap's own call is a no-op after this one runs --
|
||||
# see TREE_VERDICT_DONE above -- and remains the safety net for an interrupted run).
|
||||
|
||||
if [ "${#FAILURES[@]}" -gt 0 ]; then
|
||||
echo ""
|
||||
echo "=== FAILURES ==="
|
||||
for f in "${FAILURES[@]}"; do
|
||||
echo "- $f"
|
||||
done
|
||||
fi
|
||||
|
||||
if [ "${#FAILURES[@]}" -gt 0 ] || [ "$TREE_CLEAN_OK" != "1" ]; then
|
||||
echo ""
|
||||
echo "RESULT: FAILED -- mutations: $([ "${#FAILURES[@]}" -gt 0 ] && echo "${#FAILURES[@]} failure(s)" || echo "all behaved as declared"), tree-clean: $([ "$TREE_CLEAN_OK" = "1" ] && echo PASS || echo FAIL)"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
echo "RESULT: All mutations behaved as declared. tree-clean: PASS"
|
||||
exit 0
|
||||
|
|
@ -1,289 +0,0 @@
|
|||
#!/usr/bin/env bash
|
||||
# Mutation tests for docs/fire-control-cut.md's Cut 6b ("the dice stop being shared, and the flak cannon works
|
||||
# again").
|
||||
#
|
||||
# Four mutations, each against tests/Aetheria.Shared.Tests/FireControlCut6Tests.cs, plus a no-op control and a
|
||||
# negative-grep step:
|
||||
#
|
||||
# 1. 6.1 SameFightRollsSameThroughUnrelatedDraws -- restore Commit's generator to the shared
|
||||
# ItemManager.Random stream (read AND write-back; CultMath.Random is a struct, so the old shared-stream
|
||||
# behaviour needs both halves, not just the read).
|
||||
# 2. 6.1 ShotIdDecidesTheDie -- drop ShotId from the local generator's seed.
|
||||
# 3. 6.2 AirburstAndDiscreteNeverDoubleUp -- call Apply as well as Splash at arrival.
|
||||
# 4. 6.2 NonAirburstNeverSplashes -- splash unconditionally instead of gating on BurstRadius.
|
||||
#
|
||||
# A no-op control (a byte-identical rewrite of the target file through this script's own read/write path)
|
||||
# proves the mechanism itself is transparent before any real mutation is trusted. Modelled on
|
||||
# tests/mutation_tests_fire_control_cut5.sh, the good example: byte-exact I/O, sha256-verified restore after
|
||||
# every single mutation, and an explicit tree-clean verdict line at exit.
|
||||
#
|
||||
# Usage:
|
||||
# tests/mutation_tests_fire_control_cut6.sh <path-to-CultLib-worktree>
|
||||
#
|
||||
# Every mutation is reversed (byte-exact, from an on-disk copy) before the script exits, including on failure
|
||||
# or interrupt, and every restore is verified against a sha256 recorded before any mutation ran: the tree-
|
||||
# clean verdict at the end states PASS/FAIL explicitly rather than leaving a reader to infer it. A SIGKILL of
|
||||
# the whole process group is the one termination bash cannot trap at all -- verify the tree independently
|
||||
# after a run that was killed that hard.
|
||||
|
||||
set -u
|
||||
|
||||
if [ $# -lt 1 ]; then
|
||||
echo "Usage: $0 <cultlib-root>" >&2
|
||||
exit 2
|
||||
fi
|
||||
CULTLIB_ROOT="$1"
|
||||
|
||||
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
|
||||
REPO_ROOT="$(cd "$SCRIPT_DIR/.." && pwd)"
|
||||
TEST_PROJECT="$REPO_ROOT/tests/Aetheria.Shared.Tests"
|
||||
|
||||
FIRE_CONTROL_CS="$REPO_ROOT/Assets/Scripts/ServerShared/FireControl.cs"
|
||||
|
||||
# --- byte-exact backup/restore: preserves line endings exactly, no text-mode translation. Every target file's
|
||||
# pre-mutation sha256 is recorded once here, before any mutation touches it; every restore below -- per-
|
||||
# mutation and at exit -- re-hashes and refuses to let a mismatch pass silently (docs/fire-control-cut.md Cut
|
||||
# 5b.3's own lesson: a harness that can leak an un-restored mutation into the tree produces fictional
|
||||
# verdicts). ---
|
||||
declare -A ORIGINALS
|
||||
declare -A ORIGINAL_HASH
|
||||
FILES=("$FIRE_CONTROL_CS")
|
||||
BACKUP_DIR="$(mktemp -d)"
|
||||
|
||||
hash_file() {
|
||||
sha256sum "$1" 2>/dev/null | awk '{print $1}'
|
||||
}
|
||||
|
||||
for f in "${FILES[@]}"; do
|
||||
base="$(basename "$f")"
|
||||
cp -p "$f" "$BACKUP_DIR/$base.orig"
|
||||
ORIGINALS["$f"]="$BACKUP_DIR/$base.orig"
|
||||
ORIGINAL_HASH["$f"]="$(hash_file "$f")"
|
||||
done
|
||||
|
||||
restore_and_verify() {
|
||||
local file="$1"
|
||||
cp -p "${ORIGINALS[$file]}" "$file"
|
||||
local actual
|
||||
actual="$(hash_file "$file")"
|
||||
if [ "$actual" != "${ORIGINAL_HASH[$file]}" ]; then
|
||||
echo "" >&2
|
||||
echo "FATAL: restoring $file did not reproduce its pre-mutation hash -- aborting." >&2
|
||||
echo " expected sha256: ${ORIGINAL_HASH[$file]}" >&2
|
||||
echo " actual sha256: $actual" >&2
|
||||
exit 1
|
||||
fi
|
||||
}
|
||||
|
||||
TREE_CLEAN_OK=1
|
||||
TREE_VERDICT_DONE=0
|
||||
|
||||
verify_tree_clean() {
|
||||
if [ "$TREE_VERDICT_DONE" = "1" ]; then return; fi
|
||||
local f actual
|
||||
for f in "${FILES[@]}"; do
|
||||
cp -p "${ORIGINALS[$f]}" "$f" 2>/dev/null
|
||||
actual="$(hash_file "$f")"
|
||||
if [ "$actual" != "${ORIGINAL_HASH[$f]}" ]; then
|
||||
echo "FATAL: $f is NOT byte-identical to how this script found it." >&2
|
||||
echo " expected sha256: ${ORIGINAL_HASH[$f]}" >&2
|
||||
echo " actual sha256: $actual" >&2
|
||||
TREE_CLEAN_OK=0
|
||||
fi
|
||||
done
|
||||
echo ""
|
||||
if [ "$TREE_CLEAN_OK" = "1" ]; then
|
||||
echo "=== tree-clean verdict: PASS -- every target file is byte-identical to how this script found it ==="
|
||||
else
|
||||
echo "=== tree-clean verdict: FAIL -- see FATAL lines above; the working tree is contaminated ==="
|
||||
fi
|
||||
TREE_VERDICT_DONE=1
|
||||
}
|
||||
trap verify_tree_clean EXIT INT TERM
|
||||
|
||||
FAILURES=()
|
||||
|
||||
# replace_unique FILE ANCHOR REPLACEMENT
|
||||
# Anchor must occur exactly once (byte-exact match via perl, not sed's line-oriented rewriting, so a
|
||||
# multi-line anchor is matched as literal text). Writes the mutated content back byte-exact.
|
||||
replace_unique() {
|
||||
local file="$1" anchor="$2" replacement="$3"
|
||||
local count
|
||||
count=$(perl -0777 -e '
|
||||
my ($anchor, $file) = @ARGV;
|
||||
local $/; open(my $fh, "<:raw", $file) or die $!;
|
||||
my $text = <$fh>; close $fh;
|
||||
my $n = () = $text =~ /\Q$anchor\E/g;
|
||||
print $n;
|
||||
' "$anchor" "$file")
|
||||
if [ "$count" != "1" ]; then
|
||||
echo "ERROR: $file: anchor must occur exactly once, found $count" >&2
|
||||
echo "--- anchor ---" >&2
|
||||
echo "$anchor" >&2
|
||||
return 1
|
||||
fi
|
||||
perl -0777 -e '
|
||||
my ($file, $anchor, $replacement) = @ARGV;
|
||||
local $/; open(my $fh, "<:raw", $file) or die $!;
|
||||
my $text = <$fh>; close $fh;
|
||||
my $qa = quotemeta($anchor);
|
||||
$text =~ s/$qa/$replacement/;
|
||||
open(my $out, ">:raw", $file) or die $!;
|
||||
print $out $text; close $out;
|
||||
' "$file" "$anchor" "$replacement"
|
||||
}
|
||||
|
||||
ARTIFACTS_PATH="$(mktemp -d)"
|
||||
|
||||
# run_test TEST_FQN -> 0 (passed) or 1 (failed/errored)
|
||||
run_test() {
|
||||
local test="$1"
|
||||
local out
|
||||
out=$(dotnet test "$TEST_PROJECT" -p:CultLibRoot="$CULTLIB_ROOT" --artifacts-path "$ARTIFACTS_PATH" --filter "FullyQualifiedName~$test" 2>&1)
|
||||
if echo "$out" | grep -q "Passed!" && ! echo "$out" | grep -q "Failed: [1-9]"; then
|
||||
return 0
|
||||
fi
|
||||
LAST_OUTPUT="$out"
|
||||
return 1
|
||||
}
|
||||
|
||||
# check_mutation NAME FILE TEST EXPECT("red"|"green") -- applies whatever mutation the caller already staged
|
||||
# via replace_unique calls made just before invoking this, runs TEST, restores FILE, and judges. Used by the
|
||||
# single-anchor mutations below directly; the one multi-edit mutation (restoring the shared-stream generator)
|
||||
# calls replace_unique twice itself before delegating here.
|
||||
judge_mutation() {
|
||||
local name="$1" file="$2" test="$3" expect="$4"
|
||||
if run_test "$test"; then
|
||||
passed=1
|
||||
else
|
||||
passed=0
|
||||
fi
|
||||
restore_and_verify "$file" # restore immediately, before judging, so a crash still leaves a clean tree
|
||||
|
||||
if [ "$expect" = "green" ]; then
|
||||
if [ "$passed" = "1" ]; then
|
||||
echo "OK: $test passed under a no-op mutation, as expected"
|
||||
else
|
||||
FAILURES+=("$name: expected green (test passing), got failure")
|
||||
echo "$LAST_OUTPUT" | tail -40
|
||||
fi
|
||||
else
|
||||
if [ "$passed" = "0" ]; then
|
||||
echo "OK: $test went red under the mutation, as expected"
|
||||
else
|
||||
FAILURES+=("$name: expected red (mutant killed), but $test still passed")
|
||||
fi
|
||||
fi
|
||||
}
|
||||
|
||||
# check_mutation NAME FILE ANCHOR MUTATED TEST EXPECT("red"|"green") -- single-anchor convenience wrapper.
|
||||
check_mutation() {
|
||||
local name="$1" file="$2" anchor="$3" mutated="$4" test="$5" expect="$6"
|
||||
echo ""
|
||||
echo "=== $name (expect $expect) ==="
|
||||
if ! replace_unique "$file" "$anchor" "$mutated"; then
|
||||
FAILURES+=("$name: anchor replacement failed")
|
||||
restore_and_verify "$file"
|
||||
return
|
||||
fi
|
||||
judge_mutation "$name" "$file" "$test" "$expect"
|
||||
}
|
||||
|
||||
# --- No-op control: a byte-identical round trip through this script's own replace_unique path, proving the
|
||||
# anchor/replace/restore mechanism is transparent before any real mutation is trusted. ---
|
||||
check_mutation \
|
||||
"control (no-op round trip)" \
|
||||
"$FIRE_CONTROL_CS" \
|
||||
'var random = new Random(MixSeed((zone.CombatSeed * 2654435761u) ^ (uint) shot.ShotId | 1u));' \
|
||||
'var random = new Random(MixSeed((zone.CombatSeed * 2654435761u) ^ (uint) shot.ShotId | 1u));' \
|
||||
"FireControlCut6Tests.SameFightRollsSameThroughUnrelatedDraws" \
|
||||
"green"
|
||||
|
||||
# --- 6.1: a shot's dice belong to the shot. Mutation: restore Commit's generator to the shared
|
||||
# ItemManager.Random stream -- both the read and the write-back, since CultMath.Random is a struct and the old
|
||||
# shared-stream behaviour depended on writing the advanced copy back for the next Commit to see. Unrelated
|
||||
# draws the test makes between shots then perturb the very stream Commit reads from. ---
|
||||
echo ""
|
||||
echo "=== 6.1 Commit must not read or write the shared ItemManager.Random stream (expect red) ==="
|
||||
if replace_unique "$FIRE_CONTROL_CS" \
|
||||
'var random = new Random(MixSeed((zone.CombatSeed * 2654435761u) ^ (uint) shot.ShotId | 1u));' \
|
||||
'var random = shot.Source.ItemManager.Random;' \
|
||||
&& replace_unique "$FIRE_CONTROL_CS" \
|
||||
'return MakeOutcome(shot, hit, shielded, shieldBroken, cell, aimed, now);' \
|
||||
'shot.Source.ItemManager.Random = random;
|
||||
return MakeOutcome(shot, hit, shielded, shieldBroken, cell, aimed, now);'; then
|
||||
judge_mutation "6.1 shared-stream generator" "$FIRE_CONTROL_CS" \
|
||||
"FireControlCut6Tests.SameFightRollsSameThroughUnrelatedDraws" "red"
|
||||
else
|
||||
FAILURES+=("6.1 shared-stream generator: anchor replacement failed")
|
||||
restore_and_verify "$FIRE_CONTROL_CS"
|
||||
fi
|
||||
|
||||
# --- 6.1: the roll is a function of the shot's own id. Mutation: drop ShotId from the seed -- every shot in a
|
||||
# zone then builds the exact same local generator every time. ---
|
||||
check_mutation \
|
||||
"6.1 ShotId must reach the seed" \
|
||||
"$FIRE_CONTROL_CS" \
|
||||
'var random = new Random(MixSeed((zone.CombatSeed * 2654435761u) ^ (uint) shot.ShotId | 1u));' \
|
||||
'var random = new Random(MixSeed((zone.CombatSeed * 2654435761u) | 1u));' \
|
||||
"FireControlCut6Tests.ShotIdDecidesTheDie" \
|
||||
"red"
|
||||
|
||||
# --- 6.2: airburst resolves via Splash instead of Apply, never both. Mutation: call Apply as well as Splash at
|
||||
# arrival -- the double-application Soul was told to hunt for. ---
|
||||
check_mutation \
|
||||
"6.2 an airburst shot must not also Apply a discrete hit" \
|
||||
"$FIRE_CONTROL_CS" \
|
||||
$' if (shot.BurstRadius > 0f) Splash(zone, shot.BurstPosition, shot.BurstRadius, shot.Damage, shot.DamageType);\r\n else Apply(shot);' \
|
||||
$' if (shot.BurstRadius > 0f) Splash(zone, shot.BurstPosition, shot.BurstRadius, shot.Damage, shot.DamageType);\r\n Apply(shot);' \
|
||||
"FireControlCut6Tests.AirburstAndDiscreteNeverDoubleUp" \
|
||||
"red"
|
||||
|
||||
# --- 6.2: the other side. Mutation: splash unconditionally instead of gating on BurstRadius -- Splash does not
|
||||
# check Hit, so a shot that never carried the Airburst flag would still deal area damage. ---
|
||||
check_mutation \
|
||||
"6.2 a non-airburst shot must never splash" \
|
||||
"$FIRE_CONTROL_CS" \
|
||||
$' if (shot.BurstRadius > 0f) Splash(zone, shot.BurstPosition, shot.BurstRadius, shot.Damage, shot.DamageType);\r\n else Apply(shot);' \
|
||||
' Splash(zone, shot.BurstPosition, shot.BurstRadius, shot.Damage, shot.DamageType);' \
|
||||
"FireControlCut6Tests.NonAirburstNeverSplashes" \
|
||||
"red"
|
||||
|
||||
echo ""
|
||||
echo "=== negative greps ==="
|
||||
if grep -q "ItemManager.Random" "$FIRE_CONTROL_CS"; then
|
||||
FAILURES+=("negative grep: ItemManager.Random still appears in FireControl.cs")
|
||||
echo "FAIL: ItemManager.Random appears in FireControl.cs"
|
||||
else
|
||||
echo "OK: ItemManager.Random does not appear in FireControl.cs"
|
||||
fi
|
||||
|
||||
if grep -rq "Airburst" "$REPO_ROOT/Assets/Scripts/Gameplay"; then
|
||||
FAILURES+=("negative grep: Airburst still appears under Assets/Scripts/Gameplay")
|
||||
echo "FAIL: Airburst appears under Assets/Scripts/Gameplay"
|
||||
grep -rn "Airburst" "$REPO_ROOT/Assets/Scripts/Gameplay"
|
||||
else
|
||||
echo "OK: Airburst does not appear anywhere under Assets/Scripts/Gameplay"
|
||||
fi
|
||||
|
||||
echo ""
|
||||
verify_tree_clean # explicit call: the tree-clean verdict is part of THIS run's own summary, not only
|
||||
# something printed later at exit (the trap's own call is a no-op after this one runs --
|
||||
# see TREE_VERDICT_DONE above -- and remains the safety net for an interrupted run).
|
||||
|
||||
if [ "${#FAILURES[@]}" -gt 0 ]; then
|
||||
echo ""
|
||||
echo "=== FAILURES ==="
|
||||
for f in "${FAILURES[@]}"; do
|
||||
echo "- $f"
|
||||
done
|
||||
fi
|
||||
|
||||
if [ "${#FAILURES[@]}" -gt 0 ] || [ "$TREE_CLEAN_OK" != "1" ]; then
|
||||
echo ""
|
||||
echo "RESULT: FAILED -- mutations: $([ "${#FAILURES[@]}" -gt 0 ] && echo "${#FAILURES[@]} failure(s)" || echo "all behaved as declared"), tree-clean: $([ "$TREE_CLEAN_OK" = "1" ] && echo PASS || echo FAIL)"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
echo "RESULT: All mutations behaved as declared. tree-clean: PASS"
|
||||
exit 0
|
||||
|
|
@ -1,241 +0,0 @@
|
|||
#!/usr/bin/env bash
|
||||
# Mutation tests for docs/fire-control-cut.md's Cut 6c ("the formula gets fixed, and the single point of
|
||||
# failure").
|
||||
#
|
||||
# Two mutations, each against tests/Aetheria.Shared.Tests/FireControlCut6cTests.cs:
|
||||
#
|
||||
# 1. 6c.1 HigherResolutionYieldsHigherOrEqualHitProbability -- FireControl.cs's HitProbability reads
|
||||
# Resolution as the sensor ceiling directly again (the shipped Cut 6a behaviour), instead of taking its
|
||||
# reciprocal. That inverts the direction the operator ruling fixed: a higher-Resolution design would
|
||||
# score lower than a cheaper one at low info, the exact defect 6c.1 exists to kill.
|
||||
# 2. 6c.2 NoSellerDegradesToUnaidedInsteadOfThrowing -- LoadoutGenerator.FillInterior's targeting-system
|
||||
# RandomProduct call goes back to `required: true` and the null case throws InvalidLoadoutException
|
||||
# again, instead of degrading to an unaided entity and logging the gap.
|
||||
#
|
||||
# A no-op control (a byte-identical rewrite of one target file through this script's own read/write path)
|
||||
# proves the mechanism itself is transparent before any real mutation is trusted.
|
||||
#
|
||||
# Usage:
|
||||
# tests/mutation_tests_fire_control_cut6c.sh <path-to-CultLib-45c2f40-worktree>
|
||||
#
|
||||
# Every mutation is reversed (byte-exact, from an on-disk copy) before the script exits, including on failure
|
||||
# or interrupt, and every restore is verified against a sha256 recorded before any mutation ran: the tree-
|
||||
# clean verdict at the end states PASS/FAIL explicitly rather than leaving a reader to infer it. A SIGKILL of
|
||||
# the whole process group is the one termination bash cannot trap at all -- verify the tree independently
|
||||
# after a run that was killed that hard.
|
||||
|
||||
set -u
|
||||
|
||||
if [ $# -lt 1 ]; then
|
||||
echo "Usage: $0 <cultlib-root>" >&2
|
||||
exit 2
|
||||
fi
|
||||
CULTLIB_ROOT="$1"
|
||||
|
||||
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
|
||||
REPO_ROOT="$(cd "$SCRIPT_DIR/.." && pwd)"
|
||||
TEST_PROJECT="$REPO_ROOT/tests/Aetheria.Shared.Tests"
|
||||
|
||||
FIRE_CONTROL_CS="$REPO_ROOT/Assets/Scripts/ServerShared/FireControl.cs"
|
||||
LOADOUT_GENERATOR_CS="$REPO_ROOT/Assets/Scripts/ServerShared/LoadoutGenerator.cs"
|
||||
|
||||
# --- byte-exact backup/restore: preserves line endings exactly, no text-mode translation. Same convention
|
||||
# Cut 5's harness established (5b.3): a restore that is not verified is a restore that can lie. ---
|
||||
declare -A ORIGINALS
|
||||
declare -A ORIGINAL_HASH
|
||||
FILES=("$FIRE_CONTROL_CS" "$LOADOUT_GENERATOR_CS")
|
||||
BACKUP_DIR="$(mktemp -d)"
|
||||
|
||||
hash_file() {
|
||||
sha256sum "$1" 2>/dev/null | awk '{print $1}'
|
||||
}
|
||||
|
||||
for f in "${FILES[@]}"; do
|
||||
base="$(basename "$f")"
|
||||
cp -p "$f" "$BACKUP_DIR/$base.orig"
|
||||
ORIGINALS["$f"]="$BACKUP_DIR/$base.orig"
|
||||
ORIGINAL_HASH["$f"]="$(hash_file "$f")"
|
||||
done
|
||||
|
||||
# restore_and_verify FILE -- restores one file from its recorded backup, then re-hashes it and aborts the
|
||||
# whole script (not just this mutation) if the restore did not reproduce the pre-mutation hash exactly.
|
||||
restore_and_verify() {
|
||||
local file="$1"
|
||||
cp -p "${ORIGINALS[$file]}" "$file"
|
||||
local actual
|
||||
actual="$(hash_file "$file")"
|
||||
if [ "$actual" != "${ORIGINAL_HASH[$file]}" ]; then
|
||||
echo "" >&2
|
||||
echo "FATAL: restoring $file did not reproduce its pre-mutation hash -- aborting." >&2
|
||||
echo " expected sha256: ${ORIGINAL_HASH[$file]}" >&2
|
||||
echo " actual sha256: $actual" >&2
|
||||
exit 1
|
||||
fi
|
||||
}
|
||||
|
||||
TREE_CLEAN_OK=1
|
||||
TREE_VERDICT_DONE=0
|
||||
|
||||
# verify_tree_clean -- re-hashes every target file against its pre-mutation baseline and prints an explicit
|
||||
# PASS/FAIL verdict. Called once explicitly at the bottom of a normal run, and again from the EXIT/INT/TERM
|
||||
# trap as the safety net for every abnormal exit. (A SIGKILL of the whole process group is the one
|
||||
# termination this cannot see at all -- that gap is closed by re-checking independently after the fact.)
|
||||
verify_tree_clean() {
|
||||
if [ "$TREE_VERDICT_DONE" = "1" ]; then return; fi
|
||||
local f actual
|
||||
for f in "${FILES[@]}"; do
|
||||
cp -p "${ORIGINALS[$f]}" "$f" 2>/dev/null
|
||||
actual="$(hash_file "$f")"
|
||||
if [ "$actual" != "${ORIGINAL_HASH[$f]}" ]; then
|
||||
echo "FATAL: $f is NOT byte-identical to how this script found it." >&2
|
||||
echo " expected sha256: ${ORIGINAL_HASH[$f]}" >&2
|
||||
echo " actual sha256: $actual" >&2
|
||||
TREE_CLEAN_OK=0
|
||||
fi
|
||||
done
|
||||
echo ""
|
||||
if [ "$TREE_CLEAN_OK" = "1" ]; then
|
||||
echo "=== tree-clean verdict: PASS -- every target file is byte-identical to how this script found it ==="
|
||||
else
|
||||
echo "=== tree-clean verdict: FAIL -- see FATAL lines above; the working tree is contaminated ==="
|
||||
fi
|
||||
TREE_VERDICT_DONE=1
|
||||
}
|
||||
trap verify_tree_clean EXIT INT TERM
|
||||
|
||||
FAILURES=()
|
||||
|
||||
# replace_unique FILE ANCHOR REPLACEMENT
|
||||
# Anchor must occur exactly once (byte-exact match via perl, not sed's line-oriented rewriting, so a
|
||||
# multi-line anchor is matched as literal text). Writes the mutated content back byte-exact.
|
||||
replace_unique() {
|
||||
local file="$1" anchor="$2" replacement="$3"
|
||||
local count
|
||||
count=$(perl -0777 -e '
|
||||
my ($anchor, $file) = @ARGV;
|
||||
local $/; open(my $fh, "<:raw", $file) or die $!;
|
||||
my $text = <$fh>; close $fh;
|
||||
my $n = () = $text =~ /\Q$anchor\E/g;
|
||||
print $n;
|
||||
' "$anchor" "$file")
|
||||
if [ "$count" != "1" ]; then
|
||||
echo "ERROR: $file: anchor must occur exactly once, found $count" >&2
|
||||
echo "--- anchor ---" >&2
|
||||
echo "$anchor" >&2
|
||||
return 1
|
||||
fi
|
||||
perl -0777 -e '
|
||||
my ($file, $anchor, $replacement) = @ARGV;
|
||||
local $/; open(my $fh, "<:raw", $file) or die $!;
|
||||
my $text = <$fh>; close $fh;
|
||||
my $qa = quotemeta($anchor);
|
||||
$text =~ s/$qa/$replacement/;
|
||||
open(my $out, ">:raw", $file) or die $!;
|
||||
print $out $text; close $out;
|
||||
' "$file" "$anchor" "$replacement"
|
||||
}
|
||||
|
||||
# A dedicated, isolated artifacts path -- see Cut 5's harness for why (stale prebuilt CultLib assemblies from
|
||||
# an unrelated earlier session's default-path build produced one observed spurious control failure).
|
||||
ARTIFACTS_PATH="$(mktemp -d)"
|
||||
|
||||
# run_test TEST_FQN -> 0 (passed) or 1 (failed/errored)
|
||||
run_test() {
|
||||
local test="$1"
|
||||
local out
|
||||
out=$(dotnet test "$TEST_PROJECT" -p:CultLibRoot="$CULTLIB_ROOT" --artifacts-path "$ARTIFACTS_PATH" --filter "FullyQualifiedName~$test" 2>&1)
|
||||
if echo "$out" | grep -q "Passed!" && ! echo "$out" | grep -q "Failed: [1-9]"; then
|
||||
return 0
|
||||
fi
|
||||
LAST_OUTPUT="$out"
|
||||
return 1
|
||||
}
|
||||
|
||||
# check_mutation NAME FILE ANCHOR MUTATED TEST EXPECT("red"|"green")
|
||||
check_mutation() {
|
||||
local name="$1" file="$2" anchor="$3" mutated="$4" test="$5" expect="$6"
|
||||
echo ""
|
||||
echo "=== $name (expect $expect) ==="
|
||||
if ! replace_unique "$file" "$anchor" "$mutated"; then
|
||||
FAILURES+=("$name: anchor replacement failed")
|
||||
restore_and_verify "$file"
|
||||
return
|
||||
fi
|
||||
if run_test "$test"; then
|
||||
passed=1
|
||||
else
|
||||
passed=0
|
||||
fi
|
||||
restore_and_verify "$file" # restore this file immediately, before judging, so a crash still leaves a clean tree
|
||||
|
||||
if [ "$expect" = "green" ]; then
|
||||
if [ "$passed" = "1" ]; then
|
||||
echo "OK: $test passed under a no-op mutation, as expected"
|
||||
else
|
||||
FAILURES+=("$name: expected green (test passing), got failure")
|
||||
echo "$LAST_OUTPUT" | tail -40
|
||||
fi
|
||||
else
|
||||
if [ "$passed" = "0" ]; then
|
||||
echo "OK: $test went red under the mutation, as expected"
|
||||
else
|
||||
FAILURES+=("$name: expected red (mutant killed), but $test still passed")
|
||||
fi
|
||||
fi
|
||||
}
|
||||
|
||||
# --- No-op control: a byte-identical round trip through this script's own replace_unique path, proving the
|
||||
# anchor/replace/restore mechanism is transparent before any real mutation is trusted. ---
|
||||
check_mutation \
|
||||
"control (no-op round trip)" \
|
||||
"$FIRE_CONTROL_CS" \
|
||||
'diagnostic.PSensor = saturate(unlerp(settings.TargetDetectionInfoThreshold, diagnostic.InfoDemandCeiling, info));' \
|
||||
'diagnostic.PSensor = saturate(unlerp(settings.TargetDetectionInfoThreshold, diagnostic.InfoDemandCeiling, info));' \
|
||||
"FireControlCut6cTests.HigherResolutionYieldsHigherOrEqualHitProbability" \
|
||||
"green"
|
||||
|
||||
# --- 6c.1: Resolution is a benefit, so HitProbability must take its reciprocal to derive the sensor ceiling,
|
||||
# not read Resolution as that ceiling directly. Mutation: restore the shipped Cut 6a reading. Cut 8's own
|
||||
# re-anchor: Cut 7 (docs/fire-control-cut.md, Cut 8 header) moved this calculation into Inspect, so the local
|
||||
# `demandCeiling`/`pSensor` variables and the `Resolution(source)` call became `diagnostic.InfoDemandCeiling`,
|
||||
# `diagnostic.PSensor` and `diagnostic.Resolution` (a field on the same diagnostic, set once at its top) --
|
||||
# only the spelling moved; the rule under test is unchanged. ---
|
||||
check_mutation \
|
||||
"6c.1 HitProbability must take Resolution's reciprocal, not read it as the ceiling directly" \
|
||||
"$FIRE_CONTROL_CS" \
|
||||
$' diagnostic.InfoDemandCeiling = settings.TargetDetectionInfoThreshold +\r\n (1f - settings.TargetDetectionInfoThreshold) / max(diagnostic.Resolution, 1e-3f);\r\n diagnostic.PSensor = saturate(unlerp(settings.TargetDetectionInfoThreshold, diagnostic.InfoDemandCeiling, info));' \
|
||||
' diagnostic.PSensor = saturate(unlerp(settings.TargetDetectionInfoThreshold, diagnostic.Resolution, info));' \
|
||||
"FireControlCut6cTests.HigherResolutionYieldsHigherOrEqualHitProbability" \
|
||||
"red"
|
||||
|
||||
# --- 6c.2: a galaxy with no available targeting-system seller must degrade the loadout, not throw. Mutation:
|
||||
# restore `required: true` on the RandomProduct call and the InvalidLoadoutException throw. ---
|
||||
check_mutation \
|
||||
"6c.2 FillInterior must degrade to unaided fire on no targeting seller, not throw" \
|
||||
"$LOADOUT_GENERATOR_CS" \
|
||||
$' var (targetingProduct, targetingData) = RandomProduct<GearData>(2, item =>\r\n item.Behaviors.Any(b => b is TargetingSystemData) &&\r\n item.Shape.FitsWithin(emptyShape, out _, out _));\r\n if (targetingData == null)\r\n {\r\n ItemManager.Log("No targeting system available for armed entity; it will fire unaided.");\r\n }\r\n else\r\n {' \
|
||||
$' var (targetingProduct, targetingData) = RandomProduct<GearData>(2, item =>\r\n item.Behaviors.Any(b => b is TargetingSystemData) &&\r\n item.Shape.FitsWithin(emptyShape, out _, out _), required: true);\r\n if (targetingData == null)\r\n {\r\n throw new InvalidLoadoutException("No compatible targeting system found for entity!");\r\n }\r\n else\r\n {' \
|
||||
"FireControlCut6cTests.NoSellerDegradesToUnaidedInsteadOfThrowing" \
|
||||
"red"
|
||||
|
||||
echo ""
|
||||
verify_tree_clean # explicit call: the tree-clean verdict is part of THIS run's own summary, not only
|
||||
# something printed later at exit (the trap's own call is a no-op after this one runs --
|
||||
# see TREE_VERDICT_DONE above -- and remains the safety net for an interrupted run).
|
||||
|
||||
if [ "${#FAILURES[@]}" -gt 0 ]; then
|
||||
echo ""
|
||||
echo "=== FAILURES ==="
|
||||
for f in "${FAILURES[@]}"; do
|
||||
echo "- $f"
|
||||
done
|
||||
fi
|
||||
|
||||
if [ "${#FAILURES[@]}" -gt 0 ] || [ "$TREE_CLEAN_OK" != "1" ]; then
|
||||
echo ""
|
||||
echo "RESULT: FAILED -- mutations: $([ "${#FAILURES[@]}" -gt 0 ] && echo "${#FAILURES[@]} failure(s)" || echo "all behaved as declared"), tree-clean: $([ "$TREE_CLEAN_OK" = "1" ] && echo PASS || echo FAIL)"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
echo "RESULT: All mutations behaved as declared. tree-clean: PASS"
|
||||
exit 0
|
||||
|
|
@ -1,282 +0,0 @@
|
|||
#!/usr/bin/env bash
|
||||
# Mutation tests for docs/fire-control-cut.md's Cut 6d ("where a hit lands is a dart throw, not a coin flip").
|
||||
#
|
||||
# Four mutations, each from the spec's own Verification block, against
|
||||
# tests/Aetheria.Shared.Tests/FireControlCut6dTests.cs:
|
||||
#
|
||||
# 1. AimingAtTheSternHitsTheStern -- restore the uniform-random branch (ignore the kernel's weights when
|
||||
# drawing a landing cell).
|
||||
# 2. ThinLimbCostsHitChance -- drop pOnHull from HitProbability's return.
|
||||
# 3. PlacementAndProbabilityShareOneKernel -- perturb sigma in one of the kernel's two call sites only
|
||||
# (HitProbability's, leaving Commit's placement call untouched).
|
||||
# 4. EveryHitLandsOnMetal -- draw the kernel's weights over the hull's full bounding box
|
||||
# (Shape.AllCoordinates) instead of its occupied cells (Shape.Coordinates). Not named in the spec's own
|
||||
# Verification block, but declared in this cut's own test file as the mutation that invariant is pinned
|
||||
# against, so it is included here rather than left unrun.
|
||||
#
|
||||
# A no-op control (a byte-identical rewrite of the target file through this script's own read/write path)
|
||||
# proves the mechanism itself is transparent before any real mutation is trusted.
|
||||
#
|
||||
# Usage:
|
||||
# tests/mutation_tests_fire_control_cut6d.sh <path-to-CultLib-45c2f40-worktree>
|
||||
#
|
||||
# Every mutation is reversed (byte-exact, from an on-disk copy) before the script exits, including on failure
|
||||
# or interrupt, and every restore is verified against a sha256 recorded before any mutation ran: the tree-
|
||||
# clean verdict at the end states PASS/FAIL explicitly rather than leaving a reader to infer it. A SIGKILL of
|
||||
# the whole process group is the one termination bash cannot trap at all -- verify the tree independently
|
||||
# after a run that was killed that hard.
|
||||
#
|
||||
# Run only one mutation harness at a time against this tree -- concurrent harnesses corrupt each other's
|
||||
# snapshots (docs/fire-control-cut.md's own campaign has hit this).
|
||||
|
||||
set -u
|
||||
|
||||
if [ $# -lt 1 ]; then
|
||||
echo "Usage: $0 <cultlib-root>" >&2
|
||||
exit 2
|
||||
fi
|
||||
CULTLIB_ROOT="$1"
|
||||
|
||||
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
|
||||
REPO_ROOT="$(cd "$SCRIPT_DIR/.." && pwd)"
|
||||
TEST_PROJECT="$REPO_ROOT/tests/Aetheria.Shared.Tests"
|
||||
|
||||
FIRE_CONTROL_CS="$REPO_ROOT/Assets/Scripts/ServerShared/FireControl.cs"
|
||||
|
||||
# --- byte-exact backup/restore: preserves line endings exactly, no text-mode translation. ---
|
||||
declare -A ORIGINALS
|
||||
declare -A ORIGINAL_HASH
|
||||
FILES=("$FIRE_CONTROL_CS")
|
||||
BACKUP_DIR="$(mktemp -d)"
|
||||
|
||||
hash_file() {
|
||||
sha256sum "$1" 2>/dev/null | awk '{print $1}'
|
||||
}
|
||||
|
||||
for f in "${FILES[@]}"; do
|
||||
base="$(basename "$f")"
|
||||
cp -p "$f" "$BACKUP_DIR/$base.orig"
|
||||
ORIGINALS["$f"]="$BACKUP_DIR/$base.orig"
|
||||
ORIGINAL_HASH["$f"]="$(hash_file "$f")"
|
||||
done
|
||||
|
||||
# restore_and_verify FILE -- restores one file from its recorded backup, then re-hashes it and aborts the
|
||||
# whole script (not just this mutation) if the restore did not reproduce the pre-mutation hash exactly. A
|
||||
# mismatch here means continuing would judge every later mutation against an already-contaminated baseline,
|
||||
# which is strictly worse than stopping.
|
||||
restore_and_verify() {
|
||||
local file="$1"
|
||||
cp -p "${ORIGINALS[$file]}" "$file"
|
||||
local actual
|
||||
actual="$(hash_file "$file")"
|
||||
if [ "$actual" != "${ORIGINAL_HASH[$file]}" ]; then
|
||||
echo "" >&2
|
||||
echo "FATAL: restoring $file did not reproduce its pre-mutation hash -- aborting." >&2
|
||||
echo " expected sha256: ${ORIGINAL_HASH[$file]}" >&2
|
||||
echo " actual sha256: $actual" >&2
|
||||
exit 1
|
||||
fi
|
||||
}
|
||||
|
||||
TREE_CLEAN_OK=1
|
||||
TREE_VERDICT_DONE=0
|
||||
|
||||
# verify_tree_clean -- re-hashes every target file against its pre-mutation baseline and prints an explicit
|
||||
# PASS/FAIL verdict. Called once explicitly at the bottom of a normal run (so the verdict is part of that
|
||||
# run's own summary, not something a reader has to infer from silence) and again from the EXIT/INT/TERM trap
|
||||
# as the safety net for every abnormal exit -- interrupted, failed, or killed in a way bash can still trap.
|
||||
# (A SIGKILL of the whole process group is the one termination this cannot see at all; nothing running inside
|
||||
# the killed process can run afterward, trap or not -- that gap is closed by re-checking independently after
|
||||
# the fact, not by this function.)
|
||||
verify_tree_clean() {
|
||||
if [ "$TREE_VERDICT_DONE" = "1" ]; then return; fi
|
||||
local f actual
|
||||
for f in "${FILES[@]}"; do
|
||||
cp -p "${ORIGINALS[$f]}" "$f" 2>/dev/null
|
||||
actual="$(hash_file "$f")"
|
||||
if [ "$actual" != "${ORIGINAL_HASH[$f]}" ]; then
|
||||
echo "FATAL: $f is NOT byte-identical to how this script found it." >&2
|
||||
echo " expected sha256: ${ORIGINAL_HASH[$f]}" >&2
|
||||
echo " actual sha256: $actual" >&2
|
||||
TREE_CLEAN_OK=0
|
||||
fi
|
||||
done
|
||||
echo ""
|
||||
if [ "$TREE_CLEAN_OK" = "1" ]; then
|
||||
echo "=== tree-clean verdict: PASS -- every target file is byte-identical to how this script found it ==="
|
||||
else
|
||||
echo "=== tree-clean verdict: FAIL -- see FATAL lines above; the working tree is contaminated ==="
|
||||
fi
|
||||
TREE_VERDICT_DONE=1
|
||||
}
|
||||
trap verify_tree_clean EXIT INT TERM
|
||||
|
||||
FAILURES=()
|
||||
|
||||
# replace_unique FILE ANCHOR REPLACEMENT
|
||||
# Anchor must occur exactly once (byte-exact match via awk/perl, not sed's line-oriented rewriting, so a
|
||||
# multi-line anchor is matched as literal text). Writes the mutated content back byte-exact.
|
||||
replace_unique() {
|
||||
local file="$1" anchor="$2" replacement="$3"
|
||||
local count
|
||||
count=$(perl -0777 -e '
|
||||
my ($anchor, $file) = @ARGV;
|
||||
local $/; open(my $fh, "<:raw", $file) or die $!;
|
||||
my $text = <$fh>; close $fh;
|
||||
my $n = () = $text =~ /\Q$anchor\E/g;
|
||||
print $n;
|
||||
' "$anchor" "$file")
|
||||
if [ "$count" != "1" ]; then
|
||||
echo "ERROR: $file: anchor must occur exactly once, found $count" >&2
|
||||
echo "--- anchor ---" >&2
|
||||
echo "$anchor" >&2
|
||||
return 1
|
||||
fi
|
||||
# No -i: on this filesystem perl's in-place-edit extension parsing has mangled a literal ":raw" suffix
|
||||
# into stray sibling files before. Read, substitute once, write back byte-exact instead.
|
||||
perl -0777 -e '
|
||||
my ($file, $anchor, $replacement) = @ARGV;
|
||||
local $/; open(my $fh, "<:raw", $file) or die $!;
|
||||
my $text = <$fh>; close $fh;
|
||||
my $qa = quotemeta($anchor);
|
||||
$text =~ s/$qa/$replacement/;
|
||||
open(my $out, ">:raw", $file) or die $!;
|
||||
print $out $text; close $out;
|
||||
' "$file" "$anchor" "$replacement"
|
||||
}
|
||||
|
||||
# A dedicated, isolated artifacts path -- the default tests/Aetheria.Shared.Tests/bin,obj can carry stale
|
||||
# prebuilt CultLib assemblies from an unrelated earlier session's default-path build, and mixing those with a
|
||||
# freshly compiled Aetheria.Shared.Tests.dll produced one observed spurious failure of a control (no-op)
|
||||
# mutation elsewhere in this campaign. A private --artifacts-path sidesteps that shared, unowned build state
|
||||
# entirely.
|
||||
ARTIFACTS_PATH="$(mktemp -d)"
|
||||
|
||||
# run_test TEST_FQN -> 0 (passed) or 1 (failed/errored)
|
||||
run_test() {
|
||||
local test="$1"
|
||||
local out
|
||||
out=$(dotnet test "$TEST_PROJECT" -p:CultLibRoot="$CULTLIB_ROOT" --artifacts-path "$ARTIFACTS_PATH" --filter "FullyQualifiedName~$test" 2>&1)
|
||||
if echo "$out" | grep -q "Passed!" && ! echo "$out" | grep -q "Failed: [1-9]"; then
|
||||
return 0
|
||||
fi
|
||||
LAST_OUTPUT="$out"
|
||||
return 1
|
||||
}
|
||||
|
||||
# check_mutation NAME FILE ANCHOR MUTATED TEST EXPECT("red"|"green")
|
||||
check_mutation() {
|
||||
local name="$1" file="$2" anchor="$3" mutated="$4" test="$5" expect="$6"
|
||||
echo ""
|
||||
echo "=== $name (expect $expect) ==="
|
||||
if ! replace_unique "$file" "$anchor" "$mutated"; then
|
||||
FAILURES+=("$name: anchor replacement failed")
|
||||
restore_and_verify "$file"
|
||||
return
|
||||
fi
|
||||
if run_test "$test"; then
|
||||
passed=1
|
||||
else
|
||||
passed=0
|
||||
fi
|
||||
restore_and_verify "$file" # restore this file immediately, before judging, so a crash still leaves a clean tree
|
||||
|
||||
if [ "$expect" = "green" ]; then
|
||||
if [ "$passed" = "1" ]; then
|
||||
echo "OK: $test passed under a no-op mutation, as expected"
|
||||
else
|
||||
FAILURES+=("$name: expected green (test passing), got failure")
|
||||
echo "$LAST_OUTPUT" | tail -40
|
||||
fi
|
||||
else
|
||||
if [ "$passed" = "0" ]; then
|
||||
echo "OK: $test went red under the mutation, as expected"
|
||||
else
|
||||
FAILURES+=("$name: expected red (mutant killed), but $test still passed")
|
||||
fi
|
||||
fi
|
||||
}
|
||||
|
||||
# --- No-op control: a byte-identical round trip through this script's own replace_unique path, proving the
|
||||
# anchor/replace/restore mechanism is transparent before any real mutation is trusted. ---
|
||||
check_mutation \
|
||||
"control (no-op round trip)" \
|
||||
"$FIRE_CONTROL_CS" \
|
||||
'var sigma = Sigma(precision);' \
|
||||
'var sigma = Sigma(precision);' \
|
||||
"FireControlCut6dTests.AimingAtTheSternHitsTheStern" \
|
||||
"green"
|
||||
|
||||
# --- AimingAtTheSternHitsTheStern: restore the uniform-random branch. Mutation: Commit's weighted draw
|
||||
# ignores the kernel's own weights and picks uniformly over the hull's occupied cells instead -- the shipped
|
||||
# coin flip's "miss the aimed item" behaviour, which spreads evenly over the whole hull regardless of aim. ---
|
||||
check_mutation \
|
||||
"AimingAtTheSternHitsTheStern: Commit must draw from the kernel's weights, not uniformly" \
|
||||
"$FIRE_CONTROL_CS" \
|
||||
'cell = WeightedPick(cells, weights, totalWeight, random);' \
|
||||
'cell = cells[random.NextInt(cells.Length)];' \
|
||||
"FireControlCut6dTests.AimingAtTheSternHitsTheStern" \
|
||||
"red"
|
||||
|
||||
# --- ThinLimbCostsHitChance: drop pOnHull from the probability. Mutation: HitProbability's return loses its
|
||||
# fourth factor -- aiming at a thin limb and aiming at the centre of mass then score identically, since
|
||||
# nothing left in the product carries a spatial term at all. Cut 8's own re-anchor: Cut 7 (docs/fire-control-
|
||||
# cut.md, Cut 8 header) moved this calculation into Inspect; only the spelling (diagnostic.PBase's product)
|
||||
# moved, the rule is unchanged. ---
|
||||
check_mutation \
|
||||
"ThinLimbCostsHitChance: HitProbability must carry pOnHull, not drop it" \
|
||||
"$FIRE_CONTROL_CS" \
|
||||
'diagnostic.PBase = diagnostic.Accuracy * diagnostic.PSensor * diagnostic.PSpread * diagnostic.POnHull;' \
|
||||
'diagnostic.PBase = diagnostic.Accuracy * diagnostic.PSensor * diagnostic.PSpread;' \
|
||||
"FireControlCut6dTests.ThinLimbCostsHitChance" \
|
||||
"red"
|
||||
|
||||
# --- PlacementAndProbabilityShareOneKernel: perturb sigma in one of the two call sites only. Mutation:
|
||||
# HitProbability's own call into the kernel reads a Precision inflated by 1.5x, while Commit's placement call
|
||||
# (WeightedPick's own kernel read, a few hundred lines below) is untouched -- the two functions answering
|
||||
# "where will this shot go" now disagree, which is exactly the named risk this cut's own comments call out --
|
||||
# "One kernel, computed by one function" no longer holds once a caller perturbs its own input. Cut 8's own
|
||||
# re-anchor: this call now lives in Inspect and reads diagnostic.Precision (set once at the diagnostic's own
|
||||
# top from Precision(source)) rather than calling Precision(source) a second time; only the spelling moved. ---
|
||||
check_mutation \
|
||||
"PlacementAndProbabilityShareOneKernel: HitProbability and Commit must read the same Precision into the kernel" \
|
||||
"$FIRE_CONTROL_CS" \
|
||||
$' diagnostic.POnHull = HullKernel(targetHull,\r\n ResolveAimPoint(target, targetHull, source.ResolvedTargetItem).AimPoint, diagnostic.Precision).POnHull;' \
|
||||
' diagnostic.POnHull = HullKernel(targetHull, ResolveAimPoint(target, targetHull, source.ResolvedTargetItem).AimPoint, diagnostic.Precision * 1.5f).POnHull;' \
|
||||
"FireControlCut6dTests.PlacementAndProbabilityShareOneKernel" \
|
||||
"red"
|
||||
|
||||
# --- EveryHitLandsOnMetal: draw the kernel's weights over the hull's full bounding box instead of its
|
||||
# occupied cells. Mutation: HullKernel reads Shape.AllCoordinates (every grid cell in the bounding box,
|
||||
# occupied or not) instead of Shape.Coordinates (only the cells actually part of the schematic) -- a hull with
|
||||
# a hole in it can then have a hit resolve onto the hole. Not one of the spec's own four named mutations, but
|
||||
# the one this cut's own test file declares against this invariant. ---
|
||||
check_mutation \
|
||||
"EveryHitLandsOnMetal: the kernel must draw from the hull's occupied cells, not its full bounding box" \
|
||||
"$FIRE_CONTROL_CS" \
|
||||
'var coords = hullData.Shape.Coordinates;' \
|
||||
'var coords = hullData.Shape.AllCoordinates;' \
|
||||
"FireControlCut6dTests.EveryHitLandsOnMetal" \
|
||||
"red"
|
||||
|
||||
echo ""
|
||||
verify_tree_clean # explicit call: the tree-clean verdict is part of THIS run's own summary, not only
|
||||
# something printed later at exit (the trap's own call is a no-op after this one runs --
|
||||
# see TREE_VERDICT_DONE above -- and remains the safety net for an interrupted run).
|
||||
|
||||
if [ "${#FAILURES[@]}" -gt 0 ]; then
|
||||
echo ""
|
||||
echo "=== FAILURES ==="
|
||||
for f in "${FAILURES[@]}"; do
|
||||
echo "- $f"
|
||||
done
|
||||
fi
|
||||
|
||||
if [ "${#FAILURES[@]}" -gt 0 ] || [ "$TREE_CLEAN_OK" != "1" ]; then
|
||||
echo ""
|
||||
echo "RESULT: FAILED -- mutations: $([ "${#FAILURES[@]}" -gt 0 ] && echo "${#FAILURES[@]} failure(s)" || echo "all behaved as declared"), tree-clean: $([ "$TREE_CLEAN_OK" = "1" ] && echo PASS || echo FAIL)"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
echo "RESULT: All mutations behaved as declared. tree-clean: PASS"
|
||||
exit 0
|
||||
|
|
@ -1,242 +0,0 @@
|
|||
#!/usr/bin/env bash
|
||||
# Mutation tests for docs/fire-control-cut.md's Cut 8 ("a removed entity is a dead entity, and the anchors the
|
||||
# diagnostics moved").
|
||||
#
|
||||
# Three mutations against tests/Aetheria.Shared.Tests/FireControlCut8Tests.cs, each from the spec's own
|
||||
# Verification block:
|
||||
#
|
||||
# 1. DeadEntityDoesNotUpdate -- drop the `_active` guard from Entity.Update (8.2). The always-runs prefix of
|
||||
# Update (TargetRange, temperature, visibility decay, equipment performance) then keeps executing on a
|
||||
# torn-down entity instead of returning immediately.
|
||||
# 2. DeathDeactivates -- remove the Deactivate() call from Zone's own Death subscription (8.1). The dead
|
||||
# entity then leaves Entities but stays active, holding live subscriptions and an uncleared
|
||||
# EntityInfoGathered/VisibleEntities.
|
||||
#
|
||||
# LootStillDropsOnDeath_EquipmentSurvivesDeactivate and LockWeaponOnDeactivatedEntityDoesNotThrow are not
|
||||
# mutation-targeted here: the first pins a fact about Deactivate's own body (it never touches Equipment) that
|
||||
# no single-line mutation isolates without duplicating the DeathDeactivates mutation above, and the second is
|
||||
# a regression pin already satisfied by Entity.Update's pre-existing inner `if (_active)` gate regardless of
|
||||
# this cut's own guard -- confirmed by hand: dropping the Cut 8.2 guard does not turn it red. Both still run as
|
||||
# part of the full test file on every invocation of run_test above and are reported in the final tally.
|
||||
#
|
||||
# A no-op control (a byte-identical rewrite of one target file through this script's own read/write path)
|
||||
# proves the mechanism itself is transparent before any real mutation is trusted.
|
||||
#
|
||||
# Usage:
|
||||
# tests/mutation_tests_fire_control_cut8.sh <path-to-CultLib-45c2f40-worktree>
|
||||
#
|
||||
# Every mutation is reversed (byte-exact, from an on-disk copy) before the script exits, including on failure
|
||||
# or interrupt, and every restore is verified against a sha256 recorded before any mutation ran: the tree-
|
||||
# clean verdict at the end states PASS/FAIL explicitly rather than leaving a reader to infer it. A SIGKILL of
|
||||
# the whole process group is the one termination bash cannot trap at all -- verify the tree independently
|
||||
# after a run that was killed that hard.
|
||||
#
|
||||
# Run only one mutation harness at a time against this tree -- concurrent harnesses corrupt each other's
|
||||
# snapshots (docs/fire-control-cut.md's own campaign has hit this).
|
||||
|
||||
set -u
|
||||
|
||||
if [ $# -lt 1 ]; then
|
||||
echo "Usage: $0 <cultlib-root>" >&2
|
||||
exit 2
|
||||
fi
|
||||
CULTLIB_ROOT="$1"
|
||||
|
||||
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
|
||||
REPO_ROOT="$(cd "$SCRIPT_DIR/.." && pwd)"
|
||||
TEST_PROJECT="$REPO_ROOT/tests/Aetheria.Shared.Tests"
|
||||
|
||||
ENTITY_CS="$REPO_ROOT/Assets/Scripts/ServerShared/Entity.cs"
|
||||
ZONE_CS="$REPO_ROOT/Assets/Scripts/ServerShared/Zone.cs"
|
||||
|
||||
# --- byte-exact backup/restore: preserves line endings exactly, no text-mode translation. ---
|
||||
declare -A ORIGINALS
|
||||
declare -A ORIGINAL_HASH
|
||||
FILES=("$ENTITY_CS" "$ZONE_CS")
|
||||
BACKUP_DIR="$(mktemp -d)"
|
||||
|
||||
hash_file() {
|
||||
sha256sum "$1" 2>/dev/null | awk '{print $1}'
|
||||
}
|
||||
|
||||
for f in "${FILES[@]}"; do
|
||||
base="$(basename "$f")"
|
||||
cp -p "$f" "$BACKUP_DIR/$base.orig"
|
||||
ORIGINALS["$f"]="$BACKUP_DIR/$base.orig"
|
||||
ORIGINAL_HASH["$f"]="$(hash_file "$f")"
|
||||
done
|
||||
|
||||
restore_and_verify() {
|
||||
local file="$1"
|
||||
cp -p "${ORIGINALS[$file]}" "$file"
|
||||
local actual
|
||||
actual="$(hash_file "$file")"
|
||||
if [ "$actual" != "${ORIGINAL_HASH[$file]}" ]; then
|
||||
echo "" >&2
|
||||
echo "FATAL: restoring $file did not reproduce its pre-mutation hash -- aborting." >&2
|
||||
echo " expected sha256: ${ORIGINAL_HASH[$file]}" >&2
|
||||
echo " actual sha256: $actual" >&2
|
||||
exit 1
|
||||
fi
|
||||
}
|
||||
|
||||
TREE_CLEAN_OK=1
|
||||
TREE_VERDICT_DONE=0
|
||||
|
||||
verify_tree_clean() {
|
||||
if [ "$TREE_VERDICT_DONE" = "1" ]; then return; fi
|
||||
local f actual
|
||||
for f in "${FILES[@]}"; do
|
||||
cp -p "${ORIGINALS[$f]}" "$f" 2>/dev/null
|
||||
actual="$(hash_file "$f")"
|
||||
if [ "$actual" != "${ORIGINAL_HASH[$f]}" ]; then
|
||||
echo "FATAL: $f is NOT byte-identical to how this script found it." >&2
|
||||
echo " expected sha256: ${ORIGINAL_HASH[$f]}" >&2
|
||||
echo " actual sha256: $actual" >&2
|
||||
TREE_CLEAN_OK=0
|
||||
fi
|
||||
done
|
||||
echo ""
|
||||
if [ "$TREE_CLEAN_OK" = "1" ]; then
|
||||
echo "=== tree-clean verdict: PASS -- every target file is byte-identical to how this script found it ==="
|
||||
else
|
||||
echo "=== tree-clean verdict: FAIL -- see FATAL lines above; the working tree is contaminated ==="
|
||||
fi
|
||||
TREE_VERDICT_DONE=1
|
||||
}
|
||||
trap verify_tree_clean EXIT INT TERM
|
||||
|
||||
FAILURES=()
|
||||
|
||||
# replace_unique FILE ANCHOR REPLACEMENT
|
||||
# Anchor must occur exactly once (byte-exact match via perl, not sed's line-oriented rewriting, so a
|
||||
# multi-line anchor is matched as literal text). Writes the mutated content back byte-exact.
|
||||
replace_unique() {
|
||||
local file="$1" anchor="$2" replacement="$3"
|
||||
local count
|
||||
count=$(perl -0777 -e '
|
||||
my ($anchor, $file) = @ARGV;
|
||||
local $/; open(my $fh, "<:raw", $file) or die $!;
|
||||
my $text = <$fh>; close $fh;
|
||||
my $n = () = $text =~ /\Q$anchor\E/g;
|
||||
print $n;
|
||||
' "$anchor" "$file")
|
||||
if [ "$count" != "1" ]; then
|
||||
echo "ERROR: $file: anchor must occur exactly once, found $count" >&2
|
||||
echo "--- anchor ---" >&2
|
||||
echo "$anchor" >&2
|
||||
return 1
|
||||
fi
|
||||
# No -i: on this filesystem perl's in-place-edit extension parsing has mangled a literal ":raw" suffix
|
||||
# into stray sibling files before. Read, substitute once, write back byte-exact instead.
|
||||
perl -0777 -e '
|
||||
my ($file, $anchor, $replacement) = @ARGV;
|
||||
local $/; open(my $fh, "<:raw", $file) or die $!;
|
||||
my $text = <$fh>; close $fh;
|
||||
my $qa = quotemeta($anchor);
|
||||
$text =~ s/$qa/$replacement/;
|
||||
open(my $out, ">:raw", $file) or die $!;
|
||||
print $out $text; close $out;
|
||||
' "$file" "$anchor" "$replacement"
|
||||
}
|
||||
|
||||
# A dedicated, isolated artifacts path -- see cut5.sh's own comment on this: mixing this run's build with a
|
||||
# stale default tests/Aetheria.Shared.Tests/bin,obj has produced spurious control failures before.
|
||||
ARTIFACTS_PATH="$(mktemp -d)"
|
||||
|
||||
# run_test TEST_FQN -> 0 (passed) or 1 (failed/errored)
|
||||
run_test() {
|
||||
local test="$1"
|
||||
local out
|
||||
out=$(dotnet test "$TEST_PROJECT" -p:CultLibRoot="$CULTLIB_ROOT" --artifacts-path "$ARTIFACTS_PATH" --filter "FullyQualifiedName~$test" 2>&1)
|
||||
if echo "$out" | grep -q "Passed!" && ! echo "$out" | grep -q "Failed: [1-9]"; then
|
||||
return 0
|
||||
fi
|
||||
LAST_OUTPUT="$out"
|
||||
return 1
|
||||
}
|
||||
|
||||
# check_mutation NAME FILE ANCHOR MUTATED TEST EXPECT("red"|"green")
|
||||
check_mutation() {
|
||||
local name="$1" file="$2" anchor="$3" mutated="$4" test="$5" expect="$6"
|
||||
echo ""
|
||||
echo "=== $name (expect $expect) ==="
|
||||
if ! replace_unique "$file" "$anchor" "$mutated"; then
|
||||
FAILURES+=("$name: anchor replacement failed")
|
||||
restore_and_verify "$file"
|
||||
return
|
||||
fi
|
||||
if run_test "$test"; then
|
||||
passed=1
|
||||
else
|
||||
passed=0
|
||||
fi
|
||||
restore_and_verify "$file" # restore this file immediately, before judging, so a crash still leaves a clean tree
|
||||
|
||||
if [ "$expect" = "green" ]; then
|
||||
if [ "$passed" = "1" ]; then
|
||||
echo "OK: $test passed under a no-op mutation, as expected"
|
||||
else
|
||||
FAILURES+=("$name: expected green (test passing), got failure")
|
||||
echo "$LAST_OUTPUT" | tail -40
|
||||
fi
|
||||
else
|
||||
if [ "$passed" = "0" ]; then
|
||||
echo "OK: $test went red under the mutation, as expected"
|
||||
else
|
||||
FAILURES+=("$name: expected red (mutant killed), but $test still passed")
|
||||
fi
|
||||
fi
|
||||
}
|
||||
|
||||
# --- No-op control: a byte-identical round trip through this script's own replace_unique path, proving the
|
||||
# anchor/replace/restore mechanism is transparent before any real mutation is trusted. ---
|
||||
check_mutation \
|
||||
"control (no-op round trip)" \
|
||||
"$ENTITY_CS" \
|
||||
$' public virtual void Update(float delta)\r\n {\r\n if (!_active) return;' \
|
||||
$' public virtual void Update(float delta)\r\n {\r\n if (!_active) return;' \
|
||||
"FireControlCut8Tests.DeadEntityDoesNotUpdate" \
|
||||
"green"
|
||||
|
||||
# --- 8.2: a deactivated entity does not update. Mutation: drop the `_active` guard -- the always-runs prefix
|
||||
# of Update (TargetRange included) keeps executing on a torn-down entity instead of returning immediately. ---
|
||||
check_mutation \
|
||||
"8.2 Entity.Update must return immediately when !_active, not run its prefix regardless" \
|
||||
"$ENTITY_CS" \
|
||||
$' public virtual void Update(float delta)\r\n {\r\n if (!_active) return;\r\n\r\n var hullData = ItemManager.GetData(Hull) as HullData;' \
|
||||
$' public virtual void Update(float delta)\r\n {\r\n var hullData = ItemManager.GetData(Hull) as HullData;' \
|
||||
"FireControlCut8Tests.DeadEntityDoesNotUpdate" \
|
||||
"red"
|
||||
|
||||
# --- 8.1: removal and deactivation are one transition. Mutation: drop the Deactivate() call from Zone's own
|
||||
# Death subscription, restoring Cut 5.6's half-fix -- the dead entity leaves Entities but stays active. ---
|
||||
check_mutation \
|
||||
"8.1 Zone's Death subscription must pair Entities.Remove with Deactivate, exactly like TryDock" \
|
||||
"$ZONE_CS" \
|
||||
'Entities.ObserveAdd().Subscribe(add => add.Value.Death.Subscribe(_ => { Entities.Remove(add.Value); add.Value.Deactivate(); }));' \
|
||||
'Entities.ObserveAdd().Subscribe(add => add.Value.Death.Subscribe(_ => { Entities.Remove(add.Value); }));' \
|
||||
"FireControlCut8Tests.DeathDeactivates" \
|
||||
"red"
|
||||
|
||||
echo ""
|
||||
verify_tree_clean # explicit call: the tree-clean verdict is part of THIS run's own summary, not only
|
||||
# something printed later at exit (the trap's own call is a no-op after this one runs --
|
||||
# see TREE_VERDICT_DONE above -- and remains the safety net for an interrupted run).
|
||||
|
||||
if [ "${#FAILURES[@]}" -gt 0 ]; then
|
||||
echo ""
|
||||
echo "=== FAILURES ==="
|
||||
for f in "${FAILURES[@]}"; do
|
||||
echo "- $f"
|
||||
done
|
||||
fi
|
||||
|
||||
if [ "${#FAILURES[@]}" -gt 0 ] || [ "$TREE_CLEAN_OK" != "1" ]; then
|
||||
echo ""
|
||||
echo "RESULT: FAILED -- mutations: $([ "${#FAILURES[@]}" -gt 0 ] && echo "${#FAILURES[@]} failure(s)" || echo "all behaved as declared"), tree-clean: $([ "$TREE_CLEAN_OK" = "1" ] && echo PASS || echo FAIL)"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
echo "RESULT: All mutations behaved as declared. tree-clean: PASS"
|
||||
exit 0
|
||||
|
|
@ -1,252 +0,0 @@
|
|||
#!/usr/bin/env bash
|
||||
# Mutation tests for docs/fire-control-cut.md's Cut 9 ("the die is not a die").
|
||||
#
|
||||
# Three mutations against tests/Aetheria.Shared.Tests/FireControlCut9Tests.cs, each from the spec's own
|
||||
# Verification block, one per named finding:
|
||||
#
|
||||
# 1. TheDieIsUniform (9.1, blocking) -- delete FireControl.Commit's fmix32 finalizer, restoring the bare
|
||||
# seed straight into `new Random(...)`. One xorshift round does not diffuse a seed that differs from its
|
||||
# neighbours only in ShotId's low bits, so the roll goes back to being a near-constant step function of
|
||||
# p, fixed per zone at zone creation.
|
||||
# 2. POnHullNeverCollapsesAboveThePrecisionCliff (9.2, blocking) -- delete the SigmaFloor clamp from
|
||||
# FireControl.Sigma, restoring sigma = 1/Precision unclamped. Past Precision ~4-5 (LonginusX-shaped
|
||||
# centres of mass), the discrete kernel sum undershoots the continuous 2*pi*sigma^2 denominator and
|
||||
# pOnHull collapses back to zero.
|
||||
# 3. TargetSurvivingDockDieUndockDoesNotCrashLockWeapon (9.3, high) -- delete Entity.Activate's Target
|
||||
# reconciliation. A Target that outlived a Zone.Entities removal while the entity was inactive (docked)
|
||||
# survives Activate unchanged, and the next LockWeapon tick throws indexing EntityInfoGathered with it.
|
||||
#
|
||||
# A no-op control (a byte-identical rewrite of one target file through this script's own read/write path)
|
||||
# proves the mechanism itself is transparent before any real mutation is trusted.
|
||||
#
|
||||
# Usage:
|
||||
# tests/mutation_tests_fire_control_cut9.sh <path-to-CultLib-45c2f40-worktree>
|
||||
#
|
||||
# Every mutation is reversed (byte-exact, from an on-disk copy) before the script exits, including on failure
|
||||
# or interrupt, and every restore is verified against a sha256 recorded before any mutation ran: the tree-
|
||||
# clean verdict at the end states PASS/FAIL explicitly rather than leaving a reader to infer it. A SIGKILL of
|
||||
# the whole process group is the one termination bash cannot trap at all -- verify the tree independently
|
||||
# after a run that was killed that hard.
|
||||
#
|
||||
# Run only one mutation harness at a time against this tree -- concurrent harnesses corrupt each other's
|
||||
# snapshots (docs/fire-control-cut.md's own campaign has hit this).
|
||||
|
||||
set -u
|
||||
|
||||
if [ $# -lt 1 ]; then
|
||||
echo "Usage: $0 <cultlib-root>" >&2
|
||||
exit 2
|
||||
fi
|
||||
CULTLIB_ROOT="$1"
|
||||
|
||||
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
|
||||
REPO_ROOT="$(cd "$SCRIPT_DIR/.." && pwd)"
|
||||
TEST_PROJECT="$REPO_ROOT/tests/Aetheria.Shared.Tests"
|
||||
|
||||
FIRE_CONTROL_CS="$REPO_ROOT/Assets/Scripts/ServerShared/FireControl.cs"
|
||||
ENTITY_CS="$REPO_ROOT/Assets/Scripts/ServerShared/Entity.cs"
|
||||
|
||||
# --- byte-exact backup/restore: preserves line endings exactly, no text-mode translation. ---
|
||||
declare -A ORIGINALS
|
||||
declare -A ORIGINAL_HASH
|
||||
FILES=("$FIRE_CONTROL_CS" "$ENTITY_CS")
|
||||
BACKUP_DIR="$(mktemp -d)"
|
||||
|
||||
hash_file() {
|
||||
sha256sum "$1" 2>/dev/null | awk '{print $1}'
|
||||
}
|
||||
|
||||
for f in "${FILES[@]}"; do
|
||||
base="$(basename "$f")"
|
||||
cp -p "$f" "$BACKUP_DIR/$base.orig"
|
||||
ORIGINALS["$f"]="$BACKUP_DIR/$base.orig"
|
||||
ORIGINAL_HASH["$f"]="$(hash_file "$f")"
|
||||
done
|
||||
|
||||
restore_and_verify() {
|
||||
local file="$1"
|
||||
cp -p "${ORIGINALS[$file]}" "$file"
|
||||
local actual
|
||||
actual="$(hash_file "$file")"
|
||||
if [ "$actual" != "${ORIGINAL_HASH[$file]}" ]; then
|
||||
echo "" >&2
|
||||
echo "FATAL: restoring $file did not reproduce its pre-mutation hash -- aborting." >&2
|
||||
echo " expected sha256: ${ORIGINAL_HASH[$file]}" >&2
|
||||
echo " actual sha256: $actual" >&2
|
||||
exit 1
|
||||
fi
|
||||
}
|
||||
|
||||
TREE_CLEAN_OK=1
|
||||
TREE_VERDICT_DONE=0
|
||||
|
||||
verify_tree_clean() {
|
||||
if [ "$TREE_VERDICT_DONE" = "1" ]; then return; fi
|
||||
local f actual
|
||||
for f in "${FILES[@]}"; do
|
||||
cp -p "${ORIGINALS[$f]}" "$f" 2>/dev/null
|
||||
actual="$(hash_file "$f")"
|
||||
if [ "$actual" != "${ORIGINAL_HASH[$f]}" ]; then
|
||||
echo "FATAL: $f is NOT byte-identical to how this script found it." >&2
|
||||
echo " expected sha256: ${ORIGINAL_HASH[$f]}" >&2
|
||||
echo " actual sha256: $actual" >&2
|
||||
TREE_CLEAN_OK=0
|
||||
fi
|
||||
done
|
||||
echo ""
|
||||
if [ "$TREE_CLEAN_OK" = "1" ]; then
|
||||
echo "=== tree-clean verdict: PASS -- every target file is byte-identical to how this script found it ==="
|
||||
else
|
||||
echo "=== tree-clean verdict: FAIL -- see FATAL lines above; the working tree is contaminated ==="
|
||||
fi
|
||||
TREE_VERDICT_DONE=1
|
||||
}
|
||||
trap verify_tree_clean EXIT INT TERM
|
||||
|
||||
FAILURES=()
|
||||
|
||||
# replace_unique FILE ANCHOR REPLACEMENT
|
||||
# Anchor must occur exactly once (byte-exact match via perl, not sed's line-oriented rewriting, so a
|
||||
# multi-line anchor is matched as literal text). Writes the mutated content back byte-exact.
|
||||
replace_unique() {
|
||||
local file="$1" anchor="$2" replacement="$3"
|
||||
local count
|
||||
count=$(perl -0777 -e '
|
||||
my ($anchor, $file) = @ARGV;
|
||||
local $/; open(my $fh, "<:raw", $file) or die $!;
|
||||
my $text = <$fh>; close $fh;
|
||||
my $n = () = $text =~ /\Q$anchor\E/g;
|
||||
print $n;
|
||||
' "$anchor" "$file")
|
||||
if [ "$count" != "1" ]; then
|
||||
echo "ERROR: $file: anchor must occur exactly once, found $count" >&2
|
||||
echo "--- anchor ---" >&2
|
||||
echo "$anchor" >&2
|
||||
return 1
|
||||
fi
|
||||
# No -i: on this filesystem perl's in-place-edit extension parsing has mangled a literal ":raw" suffix
|
||||
# into stray sibling files before. Read, substitute once, write back byte-exact instead.
|
||||
perl -0777 -e '
|
||||
my ($file, $anchor, $replacement) = @ARGV;
|
||||
local $/; open(my $fh, "<:raw", $file) or die $!;
|
||||
my $text = <$fh>; close $fh;
|
||||
my $qa = quotemeta($anchor);
|
||||
$text =~ s/$qa/$replacement/;
|
||||
open(my $out, ">:raw", $file) or die $!;
|
||||
print $out $text; close $out;
|
||||
' "$file" "$anchor" "$replacement"
|
||||
}
|
||||
|
||||
# A dedicated, isolated artifacts path -- see cut5.sh's own comment on this: mixing this run's build with a
|
||||
# stale default tests/Aetheria.Shared.Tests/bin,obj has produced spurious control failures before.
|
||||
ARTIFACTS_PATH="$(mktemp -d)"
|
||||
|
||||
# run_test TEST_FQN -> 0 (passed) or 1 (failed/errored)
|
||||
run_test() {
|
||||
local test="$1"
|
||||
local out
|
||||
out=$(dotnet test "$TEST_PROJECT" -p:CultLibRoot="$CULTLIB_ROOT" --artifacts-path "$ARTIFACTS_PATH" --filter "FullyQualifiedName~$test" 2>&1)
|
||||
if echo "$out" | grep -q "Passed!" && ! echo "$out" | grep -q "Failed: [1-9]"; then
|
||||
return 0
|
||||
fi
|
||||
LAST_OUTPUT="$out"
|
||||
return 1
|
||||
}
|
||||
|
||||
# check_mutation NAME FILE ANCHOR MUTATED TEST EXPECT("red"|"green")
|
||||
check_mutation() {
|
||||
local name="$1" file="$2" anchor="$3" mutated="$4" test="$5" expect="$6"
|
||||
echo ""
|
||||
echo "=== $name (expect $expect) ==="
|
||||
if ! replace_unique "$file" "$anchor" "$mutated"; then
|
||||
FAILURES+=("$name: anchor replacement failed")
|
||||
restore_and_verify "$file"
|
||||
return
|
||||
fi
|
||||
if run_test "$test"; then
|
||||
passed=1
|
||||
else
|
||||
passed=0
|
||||
fi
|
||||
restore_and_verify "$file" # restore this file immediately, before judging, so a crash still leaves a clean tree
|
||||
|
||||
if [ "$expect" = "green" ]; then
|
||||
if [ "$passed" = "1" ]; then
|
||||
echo "OK: $test passed under a no-op mutation, as expected"
|
||||
else
|
||||
FAILURES+=("$name: expected green (test passing), got failure")
|
||||
echo "$LAST_OUTPUT" | tail -40
|
||||
fi
|
||||
else
|
||||
if [ "$passed" = "0" ]; then
|
||||
echo "OK: $test went red under the mutation, as expected"
|
||||
else
|
||||
FAILURES+=("$name: expected red (mutant killed), but $test still passed")
|
||||
fi
|
||||
fi
|
||||
}
|
||||
|
||||
# --- No-op control: a byte-identical round trip through this script's own replace_unique path, proving the
|
||||
# anchor/replace/restore mechanism is transparent before any real mutation is trusted. ---
|
||||
check_mutation \
|
||||
"control (no-op round trip)" \
|
||||
"$FIRE_CONTROL_CS" \
|
||||
'var random = new Random(MixSeed((zone.CombatSeed * 2654435761u) ^ (uint) shot.ShotId | 1u));' \
|
||||
'var random = new Random(MixSeed((zone.CombatSeed * 2654435761u) ^ (uint) shot.ShotId | 1u));' \
|
||||
"FireControlCut9Tests.TheDieIsUniform" \
|
||||
"green"
|
||||
|
||||
# --- 9.1: the die must be uniform, not a step function. Mutation: delete the fmix32 finalizer -- the bare,
|
||||
# structured (zone, shot id) seed goes straight into Random again, and one xorshift round does not diffuse a
|
||||
# seed that differs from its neighbours only in ShotId's low bits. ---
|
||||
check_mutation \
|
||||
"9.1 Commit must mix the per-shot seed (fmix32) before drawing, not feed it to Random raw" \
|
||||
"$FIRE_CONTROL_CS" \
|
||||
'var random = new Random(MixSeed((zone.CombatSeed * 2654435761u) ^ (uint) shot.ShotId | 1u));' \
|
||||
'var random = new Random((zone.CombatSeed * 2654435761u) ^ (uint) shot.ShotId | 1u);' \
|
||||
"FireControlCut9Tests.TheDieIsUniform" \
|
||||
"red"
|
||||
|
||||
# --- 9.2: pOnHull must stay monotonic in Precision over the whole domain. Mutation: delete the SigmaFloor
|
||||
# clamp -- sigma is 1/Precision again, unclamped, so pOnHull collapses back to zero past the cliff every
|
||||
# shipped (non-integral-centred) hull sits close to. ---
|
||||
check_mutation \
|
||||
"9.2 Sigma must floor at half a cell, not shrink unbounded with Precision" \
|
||||
"$FIRE_CONTROL_CS" \
|
||||
'private static float Sigma(float precision) => max(1f / max(precision, 1e-3f), SigmaFloor);' \
|
||||
'private static float Sigma(float precision) => 1f / max(precision, 1e-3f);' \
|
||||
"FireControlCut9Tests.POnHullNeverCollapsesAboveThePrecisionCliff" \
|
||||
"red"
|
||||
|
||||
# --- 9.3: a Target surviving a dock/die/undock cycle must not crash LockWeapon. Mutation: delete Activate's
|
||||
# reconciliation -- a Target the zone removed while this entity was inactive survives Activate unchanged, and
|
||||
# the next LockWeapon tick indexes EntityInfoGathered with a dictionary key that is no longer there. ---
|
||||
check_mutation \
|
||||
"9.3 Activate must reconcile a stale Target against the reseeded EntityInfoGathered" \
|
||||
"$ENTITY_CS" \
|
||||
$' if (Target.Value != null && !EntityInfoGathered.ContainsKey(Target.Value))\r\n Target.Value = null;\r\n\r\n if(WeaponGroups.All(wg=>!wg.items.Any()))' \
|
||||
$' if(WeaponGroups.All(wg=>!wg.items.Any()))' \
|
||||
"FireControlCut9Tests.TargetSurvivingDockDieUndockDoesNotCrashLockWeapon" \
|
||||
"red"
|
||||
|
||||
echo ""
|
||||
verify_tree_clean # explicit call: the tree-clean verdict is part of THIS run's own summary, not only
|
||||
# something printed later at exit (the trap's own call is a no-op after this one runs --
|
||||
# see TREE_VERDICT_DONE above -- and remains the safety net for an interrupted run).
|
||||
|
||||
if [ "${#FAILURES[@]}" -gt 0 ]; then
|
||||
echo ""
|
||||
echo "=== FAILURES ==="
|
||||
for f in "${FAILURES[@]}"; do
|
||||
echo "- $f"
|
||||
done
|
||||
fi
|
||||
|
||||
if [ "${#FAILURES[@]}" -gt 0 ] || [ "$TREE_CLEAN_OK" != "1" ]; then
|
||||
echo ""
|
||||
echo "RESULT: FAILED -- mutations: $([ "${#FAILURES[@]}" -gt 0 ] && echo "${#FAILURES[@]} failure(s)" || echo "all behaved as declared"), tree-clean: $([ "$TREE_CLEAN_OK" = "1" ] && echo PASS || echo FAIL)"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
echo "RESULT: All mutations behaved as declared. tree-clean: PASS"
|
||||
exit 0
|
||||
|
|
@ -1,218 +0,0 @@
|
|||
#!/usr/bin/env python3
|
||||
"""Mutation tests for the shield reserve ruling (docs/stats-and-power-cut.md, operator ruling 2026-09-19).
|
||||
|
||||
Supersedes Cut 4's derived reserve sizing (mutation_tests_stats_power_cut4.py's own shield rule, still intact
|
||||
and still pinned there against InputCapacitorTests.cs -- a hit within the reserve may draw a partial amount).
|
||||
This script covers what changed on top of that: ShieldData now authors Capacity/RefillDuration/RestoreDuration
|
||||
directly, a hit the reserve cannot fully cover breaks the shield instead of a silent, charge-preserving refusal,
|
||||
the breaking hit itself passes through with no partial absorption, the reserve empties to zero on break (not
|
||||
left at its pre-hit charge), and a broken shield restores on RestoreDuration, never RefillDuration. Each rule
|
||||
mutates one exact, unique anchor of source text and asserts the named test in ShieldReserveTests.cs goes red for
|
||||
exactly that reason. A no-op control mutation proves the read/replace/restore path itself is transparent.
|
||||
|
||||
Usage:
|
||||
python tests/mutation_tests_shield_reserve.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"
|
||||
SHIELD_CS = "Assets/Scripts/ServerShared/Behaviors/Shield.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=SHIELD_CS,
|
||||
anchor=" public void TakeHit(DamageType type, float damage)",
|
||||
mutated=" public void TakeHit(DamageType type, float damage)",
|
||||
test="ShieldReserveTests.HitWithinReserveIsAbsorbedAndSpendsCharge",
|
||||
expect="green",
|
||||
),
|
||||
|
||||
# --- A hit within the reserve is absorbed and spends charge: TakeHit must actually draw the reserve down,
|
||||
# --- not just gate on CanTakeHit and leave Charge untouched. ---
|
||||
Mutation(
|
||||
rule="a hit within the reserve must spend charge, not just be permitted",
|
||||
file=SHIELD_CS,
|
||||
anchor=" if (Item != null) _reserve.TrySpend(damage * EnergyUsage);",
|
||||
mutated=" if (Item != null) { }",
|
||||
test="ShieldReserveTests.HitWithinReserveIsAbsorbedAndSpendsCharge",
|
||||
expect="red",
|
||||
),
|
||||
|
||||
# --- A hit beyond the reserve breaks the shield: refusing the hit is not enough on its own -- Broken must
|
||||
# --- actually flip, or the shield is just Cut 4's silent refusal again with new authored numbers. ---
|
||||
Mutation(
|
||||
rule="a hit beyond the reserve must set Broken, not just refuse",
|
||||
file=SHIELD_CS,
|
||||
anchor=(
|
||||
" if (_reserve.CanSpend(damage * EnergyUsage)) return true;\n"
|
||||
" Broken = true;"
|
||||
),
|
||||
mutated=" if (_reserve.CanSpend(damage * EnergyUsage)) return true;",
|
||||
test="ShieldReserveTests.HitBeyondReserveBreaksTheShield",
|
||||
expect="red",
|
||||
),
|
||||
|
||||
# --- Operator's second ruling: the breaking hit passes through in full, no partial absorption -- TakeHit
|
||||
# --- (and its TrySpend/AddHeat) must never run for the hit that broke the shield. Routing the breaking hit
|
||||
# --- through TakeHit instead of a direct break-and-empty absorbs/processes it, which the ruling forbids.
|
||||
# --- Caught by the restore-timing assertion: TrySpend's own atomicity refuses (cost > charge) and leaves
|
||||
# --- Charge at its pre-hit value instead of the emptied reserve.
|
||||
Mutation(
|
||||
rule="the breaking hit must not be absorbed via TakeHit -- it passes through untouched",
|
||||
file=SHIELD_CS,
|
||||
anchor=(
|
||||
" Broken = true;\n"
|
||||
" _reserve.AddCharge(-_reserve.Charge); // emptied by the break, not left at its pre-hit charge\n"
|
||||
" return false;"
|
||||
),
|
||||
mutated=(
|
||||
" Broken = true;\n"
|
||||
" TakeHit(type, damage);\n"
|
||||
" return false;"
|
||||
),
|
||||
test="ShieldReserveTests.BreakingHitIsNotPartiallyAbsorbed",
|
||||
expect="red",
|
||||
),
|
||||
|
||||
# --- A broken shield absorbs nothing -- gated on Broken itself, not on the reserve happening to read empty.
|
||||
# --- Deleting the Broken short-circuit lets a partially-restored (but still Broken) reserve answer purely
|
||||
# --- on its own charge arithmetic, absorbing a hit it should still be refusing outright.
|
||||
Mutation(
|
||||
rule="a broken shield must refuse every hit regardless of reserve arithmetic",
|
||||
file=SHIELD_CS,
|
||||
anchor=(
|
||||
" if (Item == null) return true;\n"
|
||||
" if (Broken) return false;"
|
||||
),
|
||||
mutated=" if (Item == null) return true;",
|
||||
test="ShieldReserveTests.BrokenShieldAbsorbsNothing",
|
||||
expect="red",
|
||||
),
|
||||
|
||||
# --- A break empties the reserve to zero, not left at its pre-hit charge -- the restore duration must mean
|
||||
# --- the same thing every time. Removing the drain leaves whatever charge the reserve held at the moment
|
||||
# --- it broke, letting a shield that broke nearly full restore far faster than one that broke empty.
|
||||
Mutation(
|
||||
rule="a break must empty the reserve, not leave its pre-hit charge intact",
|
||||
file=SHIELD_CS,
|
||||
anchor=" Broken = true;\n _reserve.AddCharge(-_reserve.Charge); // emptied by the break, not left at its pre-hit charge",
|
||||
mutated=" Broken = true;",
|
||||
test="ShieldReserveTests.BreakEmptiesTheReserveRegardlessOfChargeHeldAtTheMoment",
|
||||
expect="red",
|
||||
),
|
||||
|
||||
# --- A broken shield restores on RestoreDuration, not RefillDuration -- the two must stay genuinely
|
||||
# --- separate speeds through the one InputCapacitor mechanism. Feeding RefillDuration in both branches
|
||||
# --- collapses the punish window: a shield would come back exactly as fast broken as it refills while up.
|
||||
Mutation(
|
||||
rule="a broken shield must derive its rate from RestoreDuration, not RefillDuration",
|
||||
file=SHIELD_CS,
|
||||
anchor=" var duration = Broken ? Evaluate(_data.RestoreDuration) : Evaluate(_data.RefillDuration);",
|
||||
mutated=" var duration = Evaluate(_data.RefillDuration);",
|
||||
test="ShieldReserveTests.BrokenShieldRestoresOnRestoreDurationNotRefillDuration",
|
||||
expect="red",
|
||||
),
|
||||
|
||||
# --- A partial grant from the bus stretches both durations proportionally -- Shield must scale its banked
|
||||
# --- charge by Item.PowerSupply like every other IPowerConsumer, not assume it always receives the whole
|
||||
# --- request regardless of what the bus actually granted.
|
||||
Mutation(
|
||||
rule="the reserve must only bank the fraction PowerBus actually granted, not the whole request",
|
||||
file=SHIELD_CS,
|
||||
anchor=" _reserve.AddCharge(_reserve.RequestedFill(dt) * Item.PowerSupply);",
|
||||
mutated=" _reserve.AddCharge(_reserve.RequestedFill(dt));",
|
||||
test="ShieldReserveTests.PartialGrantStretchesTheRefillDurationProportionally",
|
||||
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())
|
||||
|
|
@ -1,151 +0,0 @@
|
|||
#!/usr/bin/env python3
|
||||
"""Mutation tests for docs/stats-and-power-cut.md Cut 0 (one evaluation path).
|
||||
|
||||
Cut 0 collapses three disagreeing PerformanceStat.Evaluate bodies (ItemManager's unequipped read,
|
||||
ConsumableItemEffect's, EquippedItem's) into one shared function, PerformanceStat.Evaluate(IStatContext),
|
||||
fed by three IStatContext implementations. This script mutates each context's *identity* factor -- the
|
||||
constant it returns instead of computing a term it structurally cannot have -- and asserts the named
|
||||
test goes red for exactly that reason. A no-op control mutation proves the read/replace/restore path is
|
||||
transparent.
|
||||
|
||||
Usage:
|
||||
python tests/mutation_tests_stats_power_cut0.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"
|
||||
|
||||
|
||||
@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="Assets/Scripts/ServerShared/ItemManager.cs",
|
||||
anchor=" public float HeatFactor(PerformanceStat stat) => 1f;",
|
||||
mutated=" public float HeatFactor(PerformanceStat stat) => 1f;",
|
||||
test="LoadoutTests.UnequippedIgnoresHeatRegardlessOfTemperature",
|
||||
expect="green",
|
||||
),
|
||||
# --- Unequipped has no heat: UnequippedStatContext.HeatFactor is the fixed identity, not a real read ---
|
||||
Mutation(
|
||||
rule="unequipped must ignore heat (HeatFactor is the identity 1, not a computed value)",
|
||||
file="Assets/Scripts/ServerShared/ItemManager.cs",
|
||||
anchor=" public float HeatFactor(PerformanceStat stat) => 1f;",
|
||||
mutated=" public float HeatFactor(PerformanceStat stat) => .5f;",
|
||||
test="LoadoutTests.UnequippedIgnoresHeatRegardlessOfTemperature",
|
||||
expect="red",
|
||||
),
|
||||
# --- Unequipped has no modifiers: ConstantModifier is the fixed identity 0 ---
|
||||
Mutation(
|
||||
rule="unequipped must ignore modifiers (ConstantModifier is the fixed identity 0)",
|
||||
file="Assets/Scripts/ServerShared/ItemManager.cs",
|
||||
anchor=" public float ConstantModifier(PerformanceStat stat) => 0f;",
|
||||
mutated=" public float ConstantModifier(PerformanceStat stat) => 0.1f;",
|
||||
test="LoadoutTests.UnequippedAgreesWithEquippedAtFullHealthAndOptimalTemperature",
|
||||
expect="red",
|
||||
),
|
||||
# --- Consumable substitutes progress for heat unconditionally, not exponentiated by the stat's field ---
|
||||
Mutation(
|
||||
rule="consumable heat must not be exponentiated by HeatExponentMultiplier (that is the equipped shape)",
|
||||
file="Assets/Scripts/ServerShared/Entity.cs",
|
||||
anchor=(
|
||||
" public float HeatFactor(PerformanceStat stat) =>\n"
|
||||
" Data.Effectiveness.Evaluate((Data.Duration - RemainingDuration) / Data.Duration);"
|
||||
),
|
||||
mutated=(
|
||||
" public float HeatFactor(PerformanceStat stat) =>\n"
|
||||
" pow(Data.Effectiveness.Evaluate((Data.Duration - RemainingDuration) / Data.Duration), stat.HeatExponentMultiplier);"
|
||||
),
|
||||
test="LoadoutTests.ConsumableEvaluateSubstitutesProgressForHeatUnconditionally",
|
||||
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:
|
||||
path = REPO_ROOT / m.file
|
||||
if m.file not in originals:
|
||||
originals[m.file] = path.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)
|
||||
return 1
|
||||
|
||||
print("\nAll mutation cases behaved as Cut 0's verification section demands.")
|
||||
return 0
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
sys.exit(main())
|
||||
|
|
@ -1,295 +0,0 @@
|
|||
#!/usr/bin/env python3
|
||||
"""Mutation tests for docs/stats-and-power-cut.md Cut 1 (terms replace the four fixed exponents; R-heat).
|
||||
|
||||
Cut 1 replaces PerformanceStat's four fixed exponent fields with a declared list of StatTerm (source +
|
||||
exponent + role), and replaces EquippableItemData.HeatPerformanceCurve with an authored minimum, maximum,
|
||||
optimum and plateau width. This script mutates each rule's own line of code and asserts the named test goes
|
||||
red for exactly that reason. A no-op control mutation proves the read/replace/restore path is transparent.
|
||||
|
||||
Usage:
|
||||
python tests/mutation_tests_stats_power_cut1.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"
|
||||
|
||||
|
||||
@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="Assets/Scripts/ServerShared/ItemData.cs",
|
||||
anchor=" StatSource.Quality => pow(context.Lot.QualityForRole(term.Role), term.Exponent),",
|
||||
mutated=" StatSource.Quality => pow(context.Lot.QualityForRole(term.Role), term.Exponent),",
|
||||
test="LoadoutTests.StatsReadTheLot",
|
||||
expect="green",
|
||||
),
|
||||
# --- A term's Role must actually route the quality read; Quality is not one universal reader ---
|
||||
Mutation(
|
||||
rule="a Quality term must read its own Role, not always the item's workmanship (unrolled)",
|
||||
file="Assets/Scripts/ServerShared/ItemData.cs",
|
||||
anchor=" StatSource.Quality => pow(context.Lot.QualityForRole(term.Role), term.Exponent),",
|
||||
mutated=" StatSource.Quality => pow(context.Lot.QualityForRole(null), term.Exponent),",
|
||||
test="LoadoutTests.StatsReadTheLot",
|
||||
expect="red",
|
||||
),
|
||||
# --- A Durability term must resolve through DurabilityFactor, not some other source's factor ---
|
||||
Mutation(
|
||||
rule="a Durability term must call DurabilityFactor, not HeatFactor",
|
||||
file="Assets/Scripts/ServerShared/ItemData.cs",
|
||||
anchor=" StatSource.Durability => context.DurabilityFactor(term.Exponent),",
|
||||
mutated=" StatSource.Durability => context.HeatFactor(term.Exponent),",
|
||||
test="LoadoutTests.EvaluateDurabilityExponentReadsLotQuality",
|
||||
expect="red",
|
||||
),
|
||||
# --- A stat with no declared terms is the identity (factor 1, i.e. Max) -- the zero-multiplier equivalence
|
||||
# --- the migration depends on (docs/stats-and-power-cut.md Cut 1's "a zero multiplier becomes no term") ---
|
||||
Mutation(
|
||||
rule="an undeclared term set must resolve to the identity factor (1), not 0",
|
||||
file="Assets/Scripts/ServerShared/ItemData.cs",
|
||||
anchor=" var factor = 1f;",
|
||||
mutated=" var factor = 0f;",
|
||||
test="LoadoutTests.ConsumableProgressAppliesOnlyWhenDeclared",
|
||||
expect="red",
|
||||
),
|
||||
# --- ConsumableProgress must apply the term's own exponent, not a fixed pass-through ---
|
||||
Mutation(
|
||||
rule="a ConsumableProgress term must apply its own exponent",
|
||||
file="Assets/Scripts/ServerShared/Entity.cs",
|
||||
anchor=(
|
||||
" public float ConsumableProgressFactor(float exponent) =>\n"
|
||||
" pow(Data.Effectiveness.Evaluate((Data.Duration - RemainingDuration) / Data.Duration), exponent);"
|
||||
),
|
||||
mutated=(
|
||||
" public float ConsumableProgressFactor(float exponent) =>\n"
|
||||
" Data.Effectiveness.Evaluate((Data.Duration - RemainingDuration) / Data.Duration);"
|
||||
),
|
||||
test="LoadoutTests.ConsumableProgressAppliesOnlyWhenDeclared",
|
||||
expect="red",
|
||||
),
|
||||
# --- R-heat validation: an optimum outside its own bounds must be refused at load, naming the item ---
|
||||
Mutation(
|
||||
rule="StatValidation must refuse an OptimalTemperature outside its bounds",
|
||||
file="Assets/Scripts/ServerShared/ItemData.cs",
|
||||
anchor=(
|
||||
" if (data.OptimalTemperature < data.MinimumTemperature || data.OptimalTemperature > data.MaximumTemperature)"
|
||||
),
|
||||
mutated=" if (false)",
|
||||
test="HeatResponseTests.LoadRefusesAnOptimumOutsideItsBounds",
|
||||
expect="red",
|
||||
),
|
||||
# --- R-heat validation: a negative plateau width must be refused at load, naming the item ---
|
||||
Mutation(
|
||||
rule="StatValidation must refuse a negative PlateauWidth",
|
||||
file="Assets/Scripts/ServerShared/ItemData.cs",
|
||||
anchor=" if (data.PlateauWidth < 0)",
|
||||
mutated=" if (false)",
|
||||
test="HeatResponseTests.LoadRefusesANegativePlateauWidth",
|
||||
expect="red",
|
||||
),
|
||||
# --- R-heat shape: performance must be full across the whole authored plateau, not only at the optimum ---
|
||||
Mutation(
|
||||
rule="Performance() must be 1 across the whole plateau, not only exactly at the optimum",
|
||||
file="Assets/Scripts/ServerShared/ItemData.cs",
|
||||
anchor=" var halfPlateau = PlateauWidth * 0.5f;",
|
||||
mutated=" var halfPlateau = 0f;",
|
||||
test="HeatResponseTests.PerformanceIsFullAcrossThePlateau",
|
||||
expect="red",
|
||||
),
|
||||
# --- The wear lever: inside the plateau, Wear's thermal term must be zero (Performance == 1 there) ---
|
||||
Mutation(
|
||||
rule="held inside the plateau must take no thermal wear (the operational-lifespan lever)",
|
||||
file="Assets/Scripts/ServerShared/Entity.cs",
|
||||
anchor=(
|
||||
" Wear = (1 - pow(ThermalPerformance,\n"
|
||||
" (1 - pow(Lot.Quality, ItemManager.GameplaySettings.QualityWearExponent)) *\n"
|
||||
" ItemManager.GameplaySettings.ThermalWearExponent) +\n"
|
||||
" deltaTemp * ItemManager.GameplaySettings.DeltaTempWearExponent \n"
|
||||
" ) * Data.Durability / Data.ThermalResilience;"
|
||||
),
|
||||
mutated=(
|
||||
" Wear = (0.05f - pow(ThermalPerformance,\n"
|
||||
" (1 - pow(Lot.Quality, ItemManager.GameplaySettings.QualityWearExponent)) *\n"
|
||||
" ItemManager.GameplaySettings.ThermalWearExponent) +\n"
|
||||
" deltaTemp * ItemManager.GameplaySettings.DeltaTempWearExponent \n"
|
||||
" ) * Data.Durability / Data.ThermalResilience;"
|
||||
),
|
||||
test="HeatResponseTests.HeldInsidePlateauTakesNoThermalWear",
|
||||
expect="red",
|
||||
),
|
||||
# --- The wear lever's other half: a fast swing across the plateau must still wear, from deltaTemp alone ---
|
||||
Mutation(
|
||||
rule="deltaTemp must still cost wear even when the swing lands back inside the plateau",
|
||||
file="Assets/Scripts/ServerShared/Entity.cs",
|
||||
anchor=" var deltaTemp = math.abs(temp - oldTemperature);",
|
||||
mutated=" var deltaTemp = 0f;",
|
||||
test="HeatResponseTests.AFastSwingAcrossThePlateauStillWears",
|
||||
expect="red",
|
||||
),
|
||||
|
||||
# --- Soul's Cut 1 findings (docs/stats-and-power-cut.md), S7: four mutations that survived the original
|
||||
# --- suite. Each gets a dedicated test here rather than reusing a coincidentally-sensitive one. ---
|
||||
|
||||
# S7: out-of-bounds semantics. The old curve returned 1 at any temperature it had no data for; R-heat's whole
|
||||
# point is that a design is dead at and beyond its bounds. Restoring the old "1" must fail loudly, at the
|
||||
# boundary itself and strictly beyond it.
|
||||
Mutation(
|
||||
rule="a design must be dead (0), not immune (1), at and beyond its bounds",
|
||||
file="Assets/Scripts/ServerShared/ItemData.cs",
|
||||
anchor=" if (temperature <= MinimumTemperature || temperature >= MaximumTemperature) return 0f;",
|
||||
mutated=" if (temperature <= MinimumTemperature || temperature >= MaximumTemperature) return 1f;",
|
||||
test="HeatResponseTests.PerformanceIsZeroAtAndBeyondEachBound",
|
||||
expect="red",
|
||||
),
|
||||
# S7: the low-side plateau clamp -- StatValidation must refuse a plateau whose low edge pokes past
|
||||
# MinimumTemperature, not just an optimum outside the bounds outright.
|
||||
Mutation(
|
||||
rule="StatValidation must refuse a plateau whose low edge pokes past MinimumTemperature",
|
||||
file="Assets/Scripts/ServerShared/ItemData.cs",
|
||||
anchor=" if (plateauLow < data.MinimumTemperature || plateauHigh > data.MaximumTemperature)",
|
||||
mutated=" if (false || plateauHigh > data.MaximumTemperature)",
|
||||
test="HeatResponseTests.UpsertRefusesAPlateauThatPokesPastItsBounds",
|
||||
expect="red",
|
||||
),
|
||||
# S7: the high-side plateau clamp, the other half of the same rule.
|
||||
Mutation(
|
||||
rule="StatValidation must refuse a plateau whose high edge pokes past MaximumTemperature",
|
||||
file="Assets/Scripts/ServerShared/ItemData.cs",
|
||||
anchor=" if (plateauLow < data.MinimumTemperature || plateauHigh > data.MaximumTemperature)",
|
||||
mutated=" if (plateauLow < data.MinimumTemperature || false)",
|
||||
test="HeatResponseTests.UpsertRefusesAPlateauThatPokesPastItsBounds",
|
||||
expect="red",
|
||||
),
|
||||
# S7: a Quality term must raise the lot's quality to its own declared Exponent. StatsReadTheLot uses
|
||||
# Exponent = 1 throughout (pow(x, 1) == x), which cannot distinguish "read the term's exponent" from "always
|
||||
# use 1" -- that is exactly how this mutation survived the original suite.
|
||||
Mutation(
|
||||
rule="a Quality term must apply its own exponent, not always 1",
|
||||
file="Assets/Scripts/ServerShared/ItemData.cs",
|
||||
anchor=" StatSource.Quality => pow(context.Lot.QualityForRole(term.Role), term.Exponent),",
|
||||
mutated=" StatSource.Quality => pow(context.Lot.QualityForRole(term.Role), 1f),",
|
||||
test="LoadoutTests.QualityTermAppliesItsOwnExponent",
|
||||
expect="red",
|
||||
),
|
||||
|
||||
# --- S5/S6: the validation gaps Soul found closed. Zero-span and NaN were silently accepted before. ---
|
||||
Mutation(
|
||||
rule="StatValidation must refuse a zero-span range (Tractor Beam's exact bug: Min == Max)",
|
||||
file="Assets/Scripts/ServerShared/ItemData.cs",
|
||||
anchor=" if (data.MinimumTemperature == data.MaximumTemperature)",
|
||||
mutated=" if (false)",
|
||||
test="HeatResponseTests.UpsertRefusesAZeroSpanRange",
|
||||
expect="red",
|
||||
),
|
||||
Mutation(
|
||||
rule="StatValidation must refuse NaN in any of the four heat-response fields",
|
||||
file="Assets/Scripts/ServerShared/ItemData.cs",
|
||||
anchor=(
|
||||
" if (float.IsNaN(data.MinimumTemperature) || float.IsNaN(data.MaximumTemperature) ||\n"
|
||||
" float.IsNaN(data.OptimalTemperature) || float.IsNaN(data.PlateauWidth))"
|
||||
),
|
||||
mutated=" if (false)",
|
||||
test="HeatResponseTests.UpsertRefusesNaNInAnyHeatResponseField",
|
||||
expect="red",
|
||||
),
|
||||
# S6: the writable path used to trust Open()'s one-time check; a session that wrote an invalid record and
|
||||
# committed it produced a catalog that only failed the *next* time somebody reopened it. Mutating away the
|
||||
# Upsert-time validation call must fail a test that checks the record never reaches disk in the first place.
|
||||
Mutation(
|
||||
rule="CultRecordRefs.Upsert must validate an EquippableItemData's heat response before it reaches disk",
|
||||
file="Assets/Scripts/ServerShared/AetheriaStores.cs",
|
||||
anchor=" if (document is EquippableItemData data) StatValidation.ValidateHeatResponse(data);",
|
||||
mutated=" if (false) { }",
|
||||
test="HeatResponseTests.UpsertRefusesAnOptimumOutsideItsBounds",
|
||||
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:
|
||||
path = REPO_ROOT / m.file
|
||||
if m.file not in originals:
|
||||
originals[m.file] = path.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)
|
||||
return 1
|
||||
|
||||
print("\nAll mutation cases behaved as Cut 1's verification section demands.")
|
||||
return 0
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
sys.exit(main())
|
||||
|
|
@ -1,270 +0,0 @@
|
|||
#!/usr/bin/env python3
|
||||
"""Mutation tests for docs/stats-and-power-cut.md Cut 2 (the resolver owns every value).
|
||||
|
||||
Cut 2 deletes PerformanceStat's two per-entity modifier dictionaries (the leak, §0.3) and moves every resolved
|
||||
(item, stat) value and every attached modifier into a new StatResolver owned by the Entity, one per entity. This
|
||||
script mutates each rule's own line of code and asserts the named test goes red for exactly that reason. A no-op
|
||||
control mutation proves the read/replace/restore path is transparent.
|
||||
|
||||
Usage:
|
||||
python tests/mutation_tests_stats_power_cut2.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"
|
||||
|
||||
|
||||
@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="Assets/Scripts/ServerShared/StatResolver.cs",
|
||||
anchor=" var value = stat.Evaluate(context);",
|
||||
mutated=" var value = stat.Evaluate(context);",
|
||||
test="StatResolverTests.ResolveRecomputesOnlyAfterItsDeclaredSourceIsInvalidated",
|
||||
expect="green",
|
||||
),
|
||||
|
||||
# --- The leak (§0.3): a resolver reachable from a static field is reachable from the catalog's assembly for
|
||||
# --- the life of the process, exactly like the two dictionaries it replaced. Both the leak test and the
|
||||
# --- one-resolver-per-entity structural test die under this same mutation. ---
|
||||
Mutation(
|
||||
rule="Entity.Resolver must be an instance field: one resolver per entity, not a shared/static one",
|
||||
file="Assets/Scripts/ServerShared/Entity.cs",
|
||||
anchor=" public readonly StatResolver Resolver = new StatResolver();",
|
||||
mutated=" public static readonly StatResolver Resolver = new StatResolver();",
|
||||
test="StatResolverTests.EachEntityOwnsItsOwnResolver",
|
||||
expect="red",
|
||||
),
|
||||
Mutation(
|
||||
rule="a static resolver keeps every entity that ever evaluated a stat reachable for the life of the process",
|
||||
file="Assets/Scripts/ServerShared/Entity.cs",
|
||||
anchor=" public readonly StatResolver Resolver = new StatResolver();",
|
||||
mutated=" public static readonly StatResolver Resolver = new StatResolver();",
|
||||
test="StatResolverTests.UnequippingAndDroppingAnEntityLeavesItCollectible",
|
||||
expect="red",
|
||||
),
|
||||
|
||||
# --- A resolved value is per (item, stat): a modifier must attach to the resolver entry it actually targets
|
||||
# --- (the target item's own owner key), not to the modifying behaviour's own item. This is the real defect
|
||||
# --- Hands found and fixed while wiring StatModifier to the resolver: attaching under the wrong owner leaves
|
||||
# --- the target item's resolved value untouched. ---
|
||||
Mutation(
|
||||
rule="a modifier must attach under the target item's own owner, not the modifying behaviour's owner",
|
||||
file="Assets/Scripts/ServerShared/Behaviors/StatModifier.cs",
|
||||
anchor=" foreach (var target in _targets)\n Entity.Resolver.AttachModifier(target.Owner, target.Stat, this, _data.Type, value);",
|
||||
# F8 (Soul pass over Cut 2): this mutant used to reach for a dedicated `StatOwner` property on Behavior
|
||||
# that had no production caller -- added only to make this one mutation expressible. Deleted; `Entity`
|
||||
# is already a real accessible symbol on Behavior and is just as wrong an owner key for this mutant
|
||||
# (neither the modifying item's own owner nor the target's), so it needs no dead code to stand in for it.
|
||||
mutated=" foreach (var target in _targets)\n Entity.Resolver.AttachModifier(Entity, target.Stat, this, _data.Type, value);",
|
||||
test="StatResolverTests.AModifierOnOneEntityDoesNotReachAnotherEntitysResolvedValue",
|
||||
expect="red",
|
||||
),
|
||||
|
||||
# --- Self-dependency / modifier-chain cycles (Cut 2's Adds): a modifier whose magnitude stat is one of its
|
||||
# --- own targets must be refused at equip, not silently accepted to resolve one tick behind itself. ---
|
||||
Mutation(
|
||||
rule="a modifier chain that reaches its own magnitude stat must be refused at equip",
|
||||
file="Assets/Scripts/ServerShared/Behaviors/StatModifier.cs",
|
||||
anchor=" if (ReferenceEquals(target, data.Modifier)) return true;",
|
||||
mutated=" if (false) return true;",
|
||||
test="StatResolverTests.AModifierThatTargetsItsOwnMagnitudeStatIsRefusedAtEquip",
|
||||
expect="red",
|
||||
),
|
||||
|
||||
# --- Recompute-on-change: a cached value must be reused while every source it declared is unchanged, and
|
||||
# --- must be recomputed once any of them is invalidated. Each half gets its own mutation. ---
|
||||
Mutation(
|
||||
rule="a resolved value must stay cached until its own declared source is invalidated (never on every read)",
|
||||
file="Assets/Scripts/ServerShared/StatResolver.cs",
|
||||
anchor=" if (_cache.TryGetValue(entry, out var cached) && IsCurrent(owner, cached))",
|
||||
mutated=" if (false)",
|
||||
test="StatResolverTests.ResolveDoesNotRecomputeWhenNoDeclaredSourceMoved",
|
||||
expect="red",
|
||||
),
|
||||
Mutation(
|
||||
rule="a resolved value must actually recompute once its declared source is invalidated (never stay cached forever)",
|
||||
file="Assets/Scripts/ServerShared/StatResolver.cs",
|
||||
anchor=" if (_cache.TryGetValue(entry, out var cached) && IsCurrent(owner, cached))",
|
||||
mutated=" if (_cache.TryGetValue(entry, out var cached))",
|
||||
test="StatResolverTests.ResolveRecomputesOnlyAfterItsDeclaredSourceIsInvalidated",
|
||||
expect="red",
|
||||
),
|
||||
# --- Invalidation must be scoped to the source that actually moved -- otherwise every stat recomputes on
|
||||
# --- every tick regardless of the Terms it declared, which defeats the whole point of caching by source. ---
|
||||
Mutation(
|
||||
rule="invalidating one source must not bump every source's generation",
|
||||
file="Assets/Scripts/ServerShared/StatResolver.cs",
|
||||
anchor=" perSource[source] = GenerationOf(owner, source) + 1;",
|
||||
mutated=" foreach (StatSource s in System.Enum.GetValues(typeof(StatSource))) perSource[s] = GenerationOf(owner, s) + 1;",
|
||||
test="StatResolverTests.InvalidatingAnUnrelatedSourceDoesNotRecomputeAStatThatDidNotDeclareIt",
|
||||
expect="red",
|
||||
),
|
||||
# --- Attaching/detaching a modifier must invalidate immediately: a modifier is not one of the stat's
|
||||
# --- declared Terms, so the source-generation scheme never sees it move on its own. ---
|
||||
Mutation(
|
||||
rule="attaching a modifier must invalidate its (owner, stat) entry immediately",
|
||||
file="Assets/Scripts/ServerShared/StatResolver.cs",
|
||||
anchor=(
|
||||
" (type == StatModifierType.Constant ? set.Constant : set.Scale)[modifierKey] = value;\n"
|
||||
" _cache.Remove(entry);"
|
||||
),
|
||||
mutated=" (type == StatModifierType.Constant ? set.Constant : set.Scale)[modifierKey] = value;",
|
||||
test="StatResolverTests.AttachingAModifierInvalidatesImmediately",
|
||||
expect="red",
|
||||
),
|
||||
|
||||
# --- Gate 1 (Soul pass over Cut 2), F1: nothing ever removed an owner from _cache/_modifiers/_generations,
|
||||
# --- so the resolver's retained set grew without bound across unequip and consumable-expiry cycles even
|
||||
# --- though §0b promises a resolver entry is "destroyed at unequip / entity teardown." Forget(owner) closes
|
||||
# --- it; these two mutations delete each of its two call sites in turn. ---
|
||||
Mutation(
|
||||
rule="unequipping an item must forget it in the resolver (Entity.TryUnequip)",
|
||||
file="Assets/Scripts/ServerShared/Entity.cs",
|
||||
anchor=(
|
||||
" // Cut 2 Gate 1 fix (docs/stats-and-power-cut.md): the resolver's \xa70b lifecycle promise -- a resolver\n"
|
||||
" // entry is \"destroyed at unequip\" -- was never wired up. Without this, every unequipped item stayed\n"
|
||||
" // reachable from the resolver's dictionaries for the entity's whole remaining life.\n"
|
||||
" Resolver.Forget(item);"
|
||||
),
|
||||
mutated=(
|
||||
" // Cut 2 Gate 1 fix (docs/stats-and-power-cut.md): the resolver's \xa70b lifecycle promise -- a resolver\n"
|
||||
" // entry is \"destroyed at unequip\" -- was never wired up. Without this, every unequipped item stayed\n"
|
||||
" // reachable from the resolver's dictionaries for the entity's whole remaining life."
|
||||
),
|
||||
test="StatResolverTests.UnequipReturnsTheResolversRetainedSetToBaseline",
|
||||
expect="red",
|
||||
),
|
||||
Mutation(
|
||||
rule="an expired consumable must forget itself in the resolver (Entity.Update)",
|
||||
file="Assets/Scripts/ServerShared/Entity.cs",
|
||||
anchor=(
|
||||
" // Cut 2 Gate 1 fix (docs/stats-and-power-cut.md): an expired consumable dropped out of this\n"
|
||||
" // list without ever telling the resolver, leaving its generation/cache/modifier entries\n"
|
||||
" // reachable (keyed by this ConsumableItemEffect instance) for the rest of the process.\n"
|
||||
" Resolver.Forget(_activeConsumables[i]);\n"
|
||||
" _activeConsumables.RemoveAt(i--);"
|
||||
),
|
||||
mutated=(
|
||||
" // Cut 2 Gate 1 fix (docs/stats-and-power-cut.md): an expired consumable dropped out of this\n"
|
||||
" // list without ever telling the resolver, leaving its generation/cache/modifier entries\n"
|
||||
" // reachable (keyed by this ConsumableItemEffect instance) for the rest of the process.\n"
|
||||
" _activeConsumables.RemoveAt(i--);"
|
||||
),
|
||||
test="StatResolverTests.ConsumableExpiryReturnsTheResolversRetainedSetToBaseline",
|
||||
expect="red",
|
||||
),
|
||||
|
||||
# --- Gate 2 (Soul pass over Cut 2), F2: _applied was never reset alongside the _targets recomputed on every
|
||||
# --- Initialize, so a modifier that had already applied once stayed permanently latched after its target was
|
||||
# --- unequipped, replaced and the entity reactivated -- Update saw `_executed && _applied` and neither branch
|
||||
# --- of its own latch ever fired again. Deleting the detach-on-reinitialize line restores that split
|
||||
# --- authority between Initialize and Update. ---
|
||||
Mutation(
|
||||
rule="Initialize must detach a stale attachment before new targets are computed, so Update's latch can reattach",
|
||||
file="Assets/Scripts/ServerShared/Behaviors/StatModifier.cs",
|
||||
anchor=(
|
||||
" public void Initialize()\n"
|
||||
" {\n"
|
||||
" if (_applied)\n"
|
||||
" RemoveModifier();\n"
|
||||
" _targets = TargetsOf(Entity, _data);"
|
||||
),
|
||||
mutated=(
|
||||
" public void Initialize()\n"
|
||||
" {\n"
|
||||
" _targets = TargetsOf(Entity, _data);"
|
||||
),
|
||||
test="StatResolverTests.ARefitTargetReattachesTheModifierAfterReactivation",
|
||||
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)
|
||||
return 1
|
||||
|
||||
print("\nAll mutation cases behaved as Cut 2's verification section demands.")
|
||||
return 0
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
sys.exit(main())
|
||||
|
|
@ -1,175 +0,0 @@
|
|||
#!/usr/bin/env python3
|
||||
"""Mutation tests for docs/stats-and-power-cut.md Cut 3 (the power bus replaces TryConsumeEnergy).
|
||||
|
||||
Cut 3 deletes Entity.CanConsumeEnergy/TryConsumeEnergy and Reactor's own capacitor handling, replacing them with
|
||||
PowerBus: a single per-Entity step that turns every IPowerConsumer's request plus the tick's reactor generation
|
||||
and stored capacitor charge into one shared grant ratio, refusing demand that supply cannot cover instead of
|
||||
taxing the shortfall onto the reactor as heat. This script mutates each rule's own line of code and asserts the
|
||||
named test in PowerBusTests.cs goes red for exactly that reason. A no-op control mutation proves the
|
||||
read/replace/restore path is transparent.
|
||||
|
||||
Usage:
|
||||
python tests/mutation_tests_stats_power_cut3.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"
|
||||
|
||||
|
||||
@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="Assets/Scripts/ServerShared/PowerBus.cs",
|
||||
anchor=" TotalGeneration = reactors.Sum(r => r.Generation(dt));",
|
||||
mutated=" TotalGeneration = reactors.Sum(r => r.Generation(dt));",
|
||||
test="PowerBusTests.GrantIsCappedAtGenerationPlusStoredCapacitorCharge",
|
||||
expect="green",
|
||||
),
|
||||
|
||||
# --- Cut 3 verification bullet 1 (§0.5): "a ship whose requests exceed generation gets a total grant equal
|
||||
# --- to generation plus available capacitor charge, and not more." Dropping the capacitor draw on a deficit
|
||||
# --- caps the grant at generation alone -- still capped, but at the wrong ceiling, which is exactly the kind
|
||||
# --- of silent under-delivery a ceiling test has to catch as sharply as the old unlimited-reactor bug. ---
|
||||
Mutation(
|
||||
rule="a deficit must draw stored capacitor charge, not just generation",
|
||||
file="Assets/Scripts/ServerShared/PowerBus.cs",
|
||||
anchor=" var chargeDrawn = min(preCapacitorNet, availableCharge);",
|
||||
mutated=" var chargeDrawn = 0f;",
|
||||
test="PowerBusTests.GrantIsCappedAtGenerationPlusStoredCapacitorCharge",
|
||||
expect="red",
|
||||
),
|
||||
|
||||
# --- Cut 3 verification bullet 2 (§0.5): "a refused request produces no effect at all -- not a partial one,
|
||||
# --- and not an overload-heat receipt." This is the map's own named mutation: "make the bus grant the full
|
||||
# --- request and heat the reactor; it goes red" -- forcing overload to 0 finances the whole deficit for
|
||||
# --- free, the exact HEAD bug ("every draw succeeds while one reactor is online") Cut 3 exists to remove. ---
|
||||
Mutation(
|
||||
rule="unmet demand after generation and stored charge must be refused (overload), never financed for free",
|
||||
file="Assets/Scripts/ServerShared/PowerBus.cs",
|
||||
anchor=" overload = preCapacitorNet - chargeDrawn;",
|
||||
mutated=" overload = 0f;",
|
||||
test="PowerBusTests.ZeroSupplyRefusesTheWholeRequest",
|
||||
expect="red",
|
||||
),
|
||||
|
||||
# --- Cut 3 verification bullet 3 (§0.5): "two ships with identical loadouts fitted in opposite equip order
|
||||
# --- get identical grants. That is the death of the hidden priority." The invariant that makes equip order
|
||||
# --- unable to matter is that every consuming item reads back its own tier's GrantRatio; restricting the
|
||||
# --- write to only the first draw found reintroduces exactly the "whoever equipped first" priority the cut
|
||||
# --- kills, just relocated from SortPosition into this loop.
|
||||
# ---
|
||||
# --- F8 (docs/stats-and-power-cut.md, Soul pass 2026-09-19) re-anchor: Cut 3's own flat "write GrantRatio to
|
||||
# --- every consumer" loop (Step) was replaced by Cut 5's tiered AllocateTiers, whose closing loop writes each
|
||||
# --- draw's own tier ratio instead -- the anchor below follows that move; the rule and the test it's pinned
|
||||
# --- against (EveryConsumingItemReadsTheSameGrantRatio, a single-tier fixture) are unchanged. ---
|
||||
Mutation(
|
||||
rule="every consuming item must be written its own tier's GrantRatio, not just the first draw found",
|
||||
file="Assets/Scripts/ServerShared/PowerBus.cs",
|
||||
anchor=(
|
||||
" foreach (var draw in draws)\n"
|
||||
" {\n"
|
||||
" draw.Item.PowerSupply = ratios[draw.Tier];"
|
||||
),
|
||||
mutated=(
|
||||
" foreach (var draw in draws.Take(1))\n"
|
||||
" {\n"
|
||||
" draw.Item.PowerSupply = ratios[draw.Tier];"
|
||||
),
|
||||
test="PowerBusTests.EveryConsumingItemReadsTheSameGrantRatio",
|
||||
expect="red",
|
||||
),
|
||||
|
||||
# Entity.TrySpendCapacitorCharge/CanSpendCapacitorCharge -- the four instant draws' named, temporary
|
||||
# exception this file used to pin -- died with their last caller in Cut 4
|
||||
# (docs/stats-and-power-cut.md, Cut 4). See mutation_tests_stats_power_cut4.py's InputCapacitor.TrySpend
|
||||
# case for the same atomicity rule, now scoped to each behaviour's own buffer.
|
||||
]
|
||||
|
||||
|
||||
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())
|
||||
|
|
@ -1,203 +0,0 @@
|
|||
#!/usr/bin/env python3
|
||||
"""Mutation tests for docs/stats-and-power-cut.md Cut 4 (all draw is continuous -- input capacitors).
|
||||
|
||||
Cut 4 closes Cut 3's named, temporary exception for the four instant draws (a burst, a shot, a ping, a hit
|
||||
taken): each now spends from its own InputCapacitor, fed continuously by PowerBus like every other consumer,
|
||||
instead of the entity's shared bus capacitors directly through the now-deleted
|
||||
Entity.TrySpendCapacitorCharge/CanSpendCapacitorCharge. This script mutates each rule's own line of code and
|
||||
asserts the named test in InputCapacitorTests.cs goes red for exactly that reason. A no-op control mutation
|
||||
proves the read/replace/restore path is transparent.
|
||||
|
||||
Usage:
|
||||
python tests/mutation_tests_stats_power_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"
|
||||
|
||||
|
||||
@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="Assets/Scripts/ServerShared/Behaviors/InputCapacitor.cs",
|
||||
anchor=" public void AddCharge(float amount) => Charge = clamp(Charge + amount, 0f, Capacity);",
|
||||
mutated=" public void AddCharge(float amount) => Charge = clamp(Charge + amount, 0f, Capacity);",
|
||||
test="InputCapacitorTests.AddChargeNeverExceedsCapacity",
|
||||
expect="green",
|
||||
),
|
||||
|
||||
# --- AddCharge must clamp to Capacity: an input capacitor is sized to exactly one activation, so nothing
|
||||
# --- (a rounding surplus, a Capacity that shrank after charge had already banked) may ever report more
|
||||
# --- charge than one activation could use. ---
|
||||
Mutation(
|
||||
rule="AddCharge must clamp to Capacity",
|
||||
file="Assets/Scripts/ServerShared/Behaviors/InputCapacitor.cs",
|
||||
anchor=" public void AddCharge(float amount) => Charge = clamp(Charge + amount, 0f, Capacity);",
|
||||
mutated=" public void AddCharge(float amount) => Charge = Charge + amount;",
|
||||
test="InputCapacitorTests.AddChargeNeverExceedsCapacity",
|
||||
expect="red",
|
||||
),
|
||||
|
||||
# --- TrySpend must refuse before moving anything it cannot fully cover -- the exact atomicity contract
|
||||
# --- Entity.TrySpendCapacitorCharge had, now scoped to one behaviour's own buffer. Deleting the upfront
|
||||
# --- check restores the old TryConsumeEnergy-class bug: a cost this buffer cannot fully pay would still
|
||||
# --- partially drain it. ---
|
||||
Mutation(
|
||||
rule="TrySpend must refuse before spending anything it cannot fully cover",
|
||||
file="Assets/Scripts/ServerShared/Behaviors/InputCapacitor.cs",
|
||||
anchor=(
|
||||
" if (cost < .01f) return true;\n"
|
||||
" if (!CanSpend(cost)) return false;"
|
||||
),
|
||||
mutated=" if (cost < .01f) return true;",
|
||||
test="InputCapacitorTests.TrySpendMovesNothingWhenTheCostCannotBeFullyCovered",
|
||||
expect="red",
|
||||
),
|
||||
|
||||
# --- Default rate derives from Capacity/cooldown (Q4 ruling: "derived from energy and cooldown"). Dropping
|
||||
# --- the division collapses the derived rate to Capacity itself (refill in 1 second regardless of the
|
||||
# --- weapon's own cooldown), which RateOverrideReplacesTheDerivedDefault pins directly. ---
|
||||
Mutation(
|
||||
rule="the default rate must derive from Capacity/cooldown, not just Capacity",
|
||||
file="Assets/Scripts/ServerShared/Behaviors/InputCapacitor.cs",
|
||||
anchor=" Rate = rateOverride > 0f ? rateOverride : (cooldown > 0f ? Capacity / cooldown : Capacity);",
|
||||
mutated=" Rate = rateOverride > 0f ? rateOverride : Capacity;",
|
||||
test="InputCapacitorTests.RateOverrideReplacesTheDerivedDefault",
|
||||
expect="red",
|
||||
),
|
||||
|
||||
# --- Cut 4's own verification bullet: "a weapon whose input capacitor is partly filled does not fire a
|
||||
# --- partial shot." A fire attempt must spend exactly Capacity -- never a lesser, possibly-partial amount
|
||||
# --- -- so it only ever succeeds at full charge. Spending 0 instead bypasses the whole-shot requirement:
|
||||
# --- every attempt would "succeed" regardless of charge. ---
|
||||
Mutation(
|
||||
rule="a fire attempt must spend exactly Capacity, never a lesser amount",
|
||||
file="Assets/Scripts/ServerShared/Behaviors/InstantWeapon.cs",
|
||||
anchor=" protected bool TrySpendActivationEnergy() => Item == null || _capacitor.TrySpend(_capacitor.Capacity);",
|
||||
mutated=" protected bool TrySpendActivationEnergy() => Item == null || _capacitor.TrySpend(0f);",
|
||||
test="InputCapacitorTests.WeaponDoesNotFireOnAPartialCharge",
|
||||
expect="red",
|
||||
),
|
||||
|
||||
# --- The other half of the same rule, at the fill site instead of the spend site: the capacitor must only
|
||||
# --- ever bank the fraction PowerBus actually granted (Item.PowerSupply), never the whole request
|
||||
# --- regardless of supply -- otherwise a weapon on half power would still reach full charge (and fire) on
|
||||
# --- the very first tick, exactly the "every draw succeeds" bug Cut 3 exists to remove, just relocated. ---
|
||||
Mutation(
|
||||
rule="the capacitor must only bank the fraction PowerBus actually granted, not the whole request",
|
||||
file="Assets/Scripts/ServerShared/Behaviors/InstantWeapon.cs",
|
||||
anchor=" _capacitor.AddCharge(_capacitor.RequestedFill(dt) * Item.PowerSupply);",
|
||||
mutated=" _capacitor.AddCharge(_capacitor.RequestedFill(dt));",
|
||||
test="InputCapacitorTests.WeaponDoesNotFireOnAPartialCharge",
|
||||
expect="red",
|
||||
),
|
||||
|
||||
# --- Shield is the map's named exception: a hit is not a chosen activation, so its reserve is a continuous
|
||||
# --- one that may absorb an arbitrary fraction of a hit's cost, unlike a weapon's shot (always spent
|
||||
# --- whole). Requiring a hit to cover the WHOLE reserve before absorbing anything collapses that
|
||||
# --- distinction back into an activation buffer, which the map explicitly says not to give it.
|
||||
# ---
|
||||
# --- F8 (docs/stats-and-power-cut.md, Soul pass 2026-09-19) re-anchor: the shield reserve ruling
|
||||
# --- (2026-09-19) gave Shield a Broken state, splitting CanTakeHit's old single-expression body
|
||||
# --- (`return Item == null || _reserve.CanSpend(...)`) into an Item-null check, a Broken check, and this
|
||||
# --- CanSpend call as its own line; F5 (the same Soul pass) then made the whole method a pure query. The
|
||||
# --- rule under test -- CanSpend reads the hit's own cost, not a full-reserve requirement -- is unchanged
|
||||
# --- and still reachable from this same line. ---
|
||||
Mutation(
|
||||
rule="the shield reserve must allow a partial draw, not require a full reserve before absorbing a hit",
|
||||
file="Assets/Scripts/ServerShared/Behaviors/Shield.cs",
|
||||
anchor=" return _reserve.CanSpend(damage * EnergyUsage);",
|
||||
mutated=" return _reserve.CanSpend(_reserve.Capacity);",
|
||||
test="InputCapacitorTests.ShieldReserveAllowsAPartialDrawButRefusesAHitItCannotFullyCover",
|
||||
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())
|
||||
|
|
@ -1,218 +0,0 @@
|
|||
#!/usr/bin/env python3
|
||||
"""Mutation tests for docs/stats-and-power-cut.md Cut 5 (priority tiers).
|
||||
|
||||
Cut 5 replaces PowerBus's single shared GrantRatio with priority tiers (PowerTiers.cs): critical is fed in full
|
||||
before the next tier sees anything, each following tier divides what remains, and a tier is a stored player
|
||||
choice (EquippableItem.PowerTier) rather than something the bus derives every tick. This script mutates each
|
||||
rule's own line of code and asserts the named test in PowerTierTests.cs goes red for exactly that reason. A
|
||||
no-op control mutation proves the read/replace/restore path is transparent.
|
||||
|
||||
Usage:
|
||||
python tests/mutation_tests_stats_power_cut5.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"
|
||||
|
||||
|
||||
@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="Assets/Scripts/ServerShared/PowerBus.cs",
|
||||
anchor=" if (demand <= 1e-4f) { ratios[tier] = 1f; continue; }",
|
||||
mutated=" if (demand <= 1e-4f) { ratios[tier] = 1f; continue; }",
|
||||
test="PowerTierTests.CriticalTierIsFedInFullWhileLowerTierStarves",
|
||||
expect="green",
|
||||
),
|
||||
|
||||
# --- Cut 5's headline rule: critical must be satisfied in full before the next tier receives anything.
|
||||
# --- Walking tiers in descending order instead of ascending feeds Utility first, so the fixed generation
|
||||
# --- that exactly covers Critical's own demand gets spent on Utility instead, starving Critical -- the
|
||||
# --- opposite of the ruling ("critical is fed first"). ---
|
||||
Mutation(
|
||||
rule="tiers must be walked lowest-number (highest priority) first",
|
||||
file="Assets/Scripts/ServerShared/PowerBus.cs",
|
||||
anchor=" for (var tier = 0; tier < PowerTiers.Count; tier++)",
|
||||
mutated=" for (var tier = PowerTiers.Count - 1; tier >= 0; tier--)",
|
||||
test="PowerTierTests.CriticalTierIsFedInFullWhileLowerTierStarves",
|
||||
expect="red",
|
||||
),
|
||||
|
||||
# --- A satisfied tier must commit what it took, so a lower tier can never see it again. Skipping the
|
||||
# --- subtraction lets every tier see the SAME remaining pool -- Utility would compute against the full 30,
|
||||
# --- not the 0 left after Critical, so it would stop reading as fully starved. ---
|
||||
Mutation(
|
||||
rule="a fed tier must commit its grant against remaining, or a lower tier could see it twice",
|
||||
file="Assets/Scripts/ServerShared/PowerBus.cs",
|
||||
anchor=" remaining -= grant;",
|
||||
mutated=" // remaining -= grant;",
|
||||
test="PowerTierTests.CriticalTierIsFedInFullWhileLowerTierStarves",
|
||||
expect="red",
|
||||
),
|
||||
|
||||
# --- Within a tier, every consumer must ration by the SAME fraction of its OWN request -- that is what
|
||||
# --- makes two unequal requests divide proportionally instead of splitting the tier's grant evenly per
|
||||
# --- consumer regardless of how much each asked for. ---
|
||||
Mutation(
|
||||
rule="within a tier, the ratio must be grant/demand (proportional), not an even per-consumer split",
|
||||
file="Assets/Scripts/ServerShared/PowerBus.cs",
|
||||
anchor=" ratios[tier] = saturate(grant / demand);",
|
||||
mutated=" ratios[tier] = saturate(grant / tierDemand.Length);",
|
||||
test="PowerTierTests.TwoConsumersInOneTierAreRationedProportionally",
|
||||
expect="red",
|
||||
),
|
||||
|
||||
# --- An empty tier must read as fully met (ratio 1, remaining untouched) so its share passes straight to
|
||||
# --- the next tier down. Forcing it to 0 instead makes an unused tier look starved and -- because nothing
|
||||
# --- reduces remaining for a 0-demand tier either way -- has no effect on the real consumer, but the
|
||||
# --- assertion on the empty tiers' own ratio catches the wrong value directly. ---
|
||||
Mutation(
|
||||
rule="a tier with no demand must read as fully met (1f), not starved (0f)",
|
||||
file="Assets/Scripts/ServerShared/PowerBus.cs",
|
||||
anchor=" if (demand <= 1e-4f) { ratios[tier] = 1f; continue; }",
|
||||
mutated=" if (demand <= 1e-4f) { ratios[tier] = 0f; continue; }",
|
||||
test="PowerTierTests.ATierWithNoDemandPassesItsShareDown",
|
||||
expect="red",
|
||||
),
|
||||
|
||||
# --- TotalGrant is the ceiling tiering allocates -- it must still reflect what generation and stored charge
|
||||
# --- can actually cover, not the raw total request. Feeding the allocator TotalDemand instead (pretending
|
||||
# --- supply always covers everything) hands Utility the untouched 80 instead of the 0 left after Critical's
|
||||
# --- own 30 is committed, so it reads as fully fed instead of starved. ---
|
||||
Mutation(
|
||||
rule="tiering must allocate against TotalGrant (what supply covers), not TotalDemand (what was asked)",
|
||||
file="Assets/Scripts/ServerShared/PowerBus.cs",
|
||||
anchor=" var remaining = TotalGrant;",
|
||||
mutated=" var remaining = TotalDemand;",
|
||||
test="PowerTierTests.CriticalTierIsFedInFullWhileLowerTierStarves",
|
||||
expect="red",
|
||||
),
|
||||
|
||||
# --- The boundary rule itself: once a higher tier's own shortfall has consumed all of remaining, a lower
|
||||
# --- tier must see 0, not some leftover computed from the higher tier's unmet demand. Handing every tier
|
||||
# --- the ORIGINAL TotalGrant instead of the shrinking remaining pool would let Utility compute its own
|
||||
# --- ratio against the full grant as if Critical had never been served first -- exactly the leak §1.3
|
||||
# --- forbids. ---
|
||||
Mutation(
|
||||
rule="each tier must divide what is actually left (remaining), not the original total grant again",
|
||||
file="Assets/Scripts/ServerShared/PowerBus.cs",
|
||||
anchor=" var grant = min(demand, remaining);",
|
||||
mutated=" var grant = min(demand, TotalGrant);",
|
||||
test="PowerTierTests.LowerTierGetsExactlyZeroWhenAHigherTierAloneStarves",
|
||||
expect="red",
|
||||
),
|
||||
|
||||
# --- The tier must be read fresh from the stored field every Step, not cached at equip time -- otherwise a
|
||||
# --- player's tier change on the schematic UI would need an unequip/re-equip round trip to take effect,
|
||||
# --- which the map's own verification bullet rules out. ---
|
||||
Mutation(
|
||||
rule="the bus must read the item's CURRENT stored PowerTier every Step, not one captured once at equip",
|
||||
file="Assets/Scripts/ServerShared/PowerBus.cs",
|
||||
anchor=" var tier = item.EquippableItem.PowerTier;",
|
||||
mutated=" var tier = consumer.DefaultPowerTier;",
|
||||
test="PowerTierTests.ATierChangeOnAnEquippedItemTakesEffectNextTickWithNoReEquip",
|
||||
expect="red",
|
||||
),
|
||||
|
||||
# --- EquippedItem's constructor must seed a kind default only once, the first time a unit is equipped
|
||||
# --- (PowerTiers.Unassigned) -- never re-seeding it on every equip is what makes the tier a genuinely stored
|
||||
# --- choice instead of one silently reset back to the kind default whenever a unit changes hands. Removing
|
||||
# --- the guard reassigns Utility's own default even after a mutating test has moved it to Critical.
|
||||
Mutation(
|
||||
rule="the kind default must only be seeded when PowerTier is still Unassigned, never re-seeded",
|
||||
file="Assets/Scripts/ServerShared/Entity.cs",
|
||||
anchor=" if (EquippableItem.PowerTier == PowerTiers.Unassigned)\n {\n var consumerTiers = Behaviors.OfType<IPowerConsumer>().Select(c => c.DefaultPowerTier).ToArray();\n if (consumerTiers.Length > 0)\n EquippableItem.PowerTier = consumerTiers.Min();\n }",
|
||||
mutated=" {\n var consumerTiers = Behaviors.OfType<IPowerConsumer>().Select(c => c.DefaultPowerTier).ToArray();\n if (consumerTiers.Length > 0)\n EquippableItem.PowerTier = consumerTiers.Min();\n }",
|
||||
test="PowerTierTests.ATierChangeOnAnEquippedItemTakesEffectNextTickWithNoReEquip",
|
||||
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())
|
||||
|
|
@ -1,201 +0,0 @@
|
|||
#!/usr/bin/env python3
|
||||
"""Mutation tests for docs/stats-and-power-cut.md Cut 6 (the power-supply term and the request-independence
|
||||
rule).
|
||||
|
||||
Cut 6 is purely additive: StatSource.PowerSupply already existed as an identity everywhere (Cut 2), and the bus
|
||||
already wrote EquippedItem.PowerSupply (Cut 3/5). This cut wires the identity into a real curve
|
||||
(EquippedItem.PowerSupplyFactor), wires the bus's own per-tick write to invalidate the resolver
|
||||
(PowerBus.AllocateTiers), and added the validation rule that a power request may not depend on power supply,
|
||||
directly or through a modifier chain. This script mutates each rule's own line of code and asserts the named
|
||||
test in PowerCurveTests.cs goes red for exactly that reason. A no-op control mutation proves the read/replace/
|
||||
restore path is transparent.
|
||||
|
||||
Nominal-request ruling (docs/stats-and-power-cut.md, operator ruling 2026-09-19) deleted the direct half of the
|
||||
request-independence rule (StatValidation.ValidateNoPowerSupplyOnRequest) -- PowerRequest now reads a request
|
||||
field through EquippedItem.EvaluateNominalPower, which pins that stat's own PowerSupplyFactor to 1, so a direct
|
||||
term can no longer make the request depend on its own answer. The three mutation cases that used to pin that
|
||||
static check (and the two PowerCurveTests it targeted, since renamed to OpenAccepts.../UpsertAccepts... in
|
||||
PowerCurveTests.cs) are removed below; see tests/mutation_tests_stats_power_nominal_request.py for that rule's
|
||||
own replacement check. The modifier-chain half (StatModifier.ValidateNoPowerSupplyChain) is unchanged and its
|
||||
two mutation cases below still apply.
|
||||
|
||||
Usage:
|
||||
python tests/mutation_tests_stats_power_cut6.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"
|
||||
|
||||
|
||||
@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="Assets/Scripts/ServerShared/Entity.cs",
|
||||
anchor=" public float PowerSupplyFactor(float exponent) => pow(PowerSupply, exponent);",
|
||||
mutated=" public float PowerSupplyFactor(float exponent) => pow(PowerSupply, exponent);",
|
||||
test="PowerCurveTests.APowerSupplyTermedStatAtHalfGrantDegradesByItsAuthoredExponent",
|
||||
expect="green",
|
||||
),
|
||||
|
||||
# --- The curve itself: a PowerSupply term must actually read this tick's grant raised to its own exponent,
|
||||
# --- not the identity Cut 3 left in place as a placeholder. Reverting to the pre-Cut-6 stub makes a half
|
||||
# --- grant read exactly like a full one. ---
|
||||
Mutation(
|
||||
rule="PowerSupplyFactor must read PowerSupply raised to the term's exponent, not the identity",
|
||||
file="Assets/Scripts/ServerShared/Entity.cs",
|
||||
anchor=" public float PowerSupplyFactor(float exponent) => pow(PowerSupply, exponent);",
|
||||
mutated=" public float PowerSupplyFactor(float exponent) => 1f;",
|
||||
test="PowerCurveTests.APowerSupplyTermedStatAtHalfGrantDegradesByItsAuthoredExponent",
|
||||
expect="red",
|
||||
),
|
||||
|
||||
# --- The exponent itself must be honoured, not discarded -- a stat authored with a steep curve (exponent 2)
|
||||
# --- must degrade faster than a linear one. Dropping the exponent from the pow() call collapses every
|
||||
# --- PowerSupply term to the same linear response regardless of what was authored. ---
|
||||
Mutation(
|
||||
rule="PowerSupplyFactor must raise PowerSupply to the term's own exponent, not always to the first power",
|
||||
file="Assets/Scripts/ServerShared/Entity.cs",
|
||||
anchor=" public float PowerSupplyFactor(float exponent) => pow(PowerSupply, exponent);",
|
||||
mutated=" public float PowerSupplyFactor(float exponent) => PowerSupply;",
|
||||
test="PowerCurveTests.APowerSupplyTermedStatAtHalfGrantDegradesByItsAuthoredExponent",
|
||||
expect="red",
|
||||
),
|
||||
|
||||
# --- The missing half of the wiring: the bus must invalidate StatSource.PowerSupply every time it writes a
|
||||
# --- grant, or a resolver entry that cached last tick's (or the pre-activation default) value never
|
||||
# --- recomputes at all. Deleting the invalidate call leaves PowerSupply written but never re-read. ---
|
||||
Mutation(
|
||||
rule="the bus must invalidate StatSource.PowerSupply every time it writes a grant",
|
||||
file="Assets/Scripts/ServerShared/PowerBus.cs",
|
||||
anchor=" draw.Item.PowerSupply = ratios[draw.Tier];\n // Cut 6 (docs/stats-and-power-cut.md): the missing half of the wiring -- a stat with a PowerSupply\n // term must recompute when the grant actually moves. Called unconditionally, once per draw per tick,\n // the same shape as EquippedItem.UpdatePerformance's own Heat/Durability invalidation: \"recomputes at\n // most once per tick per (item, stat),\" not \"only when the value moved.\" An item with no PowerSupply\n // term pays nothing extra -- the resolver's per-source generation bookkeeping (StatResolver.Resolve)\n // only ever looks at sources a stat's own Terms declared.\n _entity.Resolver.InvalidateSource(draw.Item, StatSource.PowerSupply);",
|
||||
mutated=" draw.Item.PowerSupply = ratios[draw.Tier];",
|
||||
test="PowerCurveTests.APowerSupplyTermedStatAtHalfGrantDegradesByItsAuthoredExponent",
|
||||
expect="red",
|
||||
),
|
||||
|
||||
# --- The resolver's own generation scheme must still be keyed per-source: invalidating PowerSupply must never
|
||||
# --- touch a stat that declared only Heat. Rerouting the bus's invalidate call to a different StatSource
|
||||
# --- proves the wrong thing gets bumped -- the power-termed stat then never recomputes even though the grant
|
||||
# --- moved, because nothing ever invalidates the source it actually declared. ---
|
||||
Mutation(
|
||||
rule="the bus must invalidate PowerSupply specifically, not some other source",
|
||||
file="Assets/Scripts/ServerShared/PowerBus.cs",
|
||||
anchor=" _entity.Resolver.InvalidateSource(draw.Item, StatSource.PowerSupply);",
|
||||
mutated=" _entity.Resolver.InvalidateSource(draw.Item, StatSource.Heat);",
|
||||
test="PowerCurveTests.APowerSupplyTermedStatAtHalfGrantDegradesByItsAuthoredExponent",
|
||||
expect="red",
|
||||
),
|
||||
|
||||
# --- The modifier-chain half: a modifier's own magnitude must be checked for a PowerSupply dependency before
|
||||
# --- it is allowed to attach onto a known request stat. Skipping the taint check lets a booster with a
|
||||
# --- power-dependent magnitude attach onto Drain's own EnergyDraw without complaint. ---
|
||||
Mutation(
|
||||
rule="a modifier chain reaching a request stat through a power-tainted magnitude must be refused",
|
||||
file="Assets/Scripts/ServerShared/Behaviors/StatModifier.cs",
|
||||
anchor=" if (!IsPowerTainted(data.Modifier, edges, new HashSet<PerformanceStat>()))\n return;",
|
||||
mutated=" if (true)\n return;",
|
||||
test="PowerCurveTests.ActivateRefusesAModifierChainThatWouldCorruptAPowerRequest",
|
||||
expect="red",
|
||||
),
|
||||
|
||||
# --- IsPowerTainted's own base case: a magnitude stat's Terms must actually be checked for PowerSupply, not
|
||||
# --- treated as always clean. Without this, no magnitude is ever tainted and the chain check above never
|
||||
# --- fires for any catalog. ---
|
||||
Mutation(
|
||||
rule="IsPowerTainted must check the stat's own Terms for a direct PowerSupply dependency",
|
||||
file="Assets/Scripts/ServerShared/Behaviors/StatModifier.cs",
|
||||
anchor=" foreach (var term in stat.Terms)\n if (term.Source == StatSource.PowerSupply)\n return true;",
|
||||
mutated=" foreach (var term in stat.Terms)\n if (false)\n return true;",
|
||||
test="PowerCurveTests.ActivateRefusesAModifierChainThatWouldCorruptAPowerRequest",
|
||||
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())
|
||||
|
|
@ -1,204 +0,0 @@
|
|||
#!/usr/bin/env python3
|
||||
"""Mutation tests for docs/stats-and-power-target.md's brownout ruling ("Continuous consumers brown out through
|
||||
a power supply curve on their performance stats. An exponent is enough.") -- Cut 7 in this repo's own numbering,
|
||||
following Cut 6 (mutation_tests_stats_power_cut6.py).
|
||||
|
||||
Cut 6 wired a real curve into EquippedItem.PowerSupplyFactor and the bus already wrote a real fractional grant,
|
||||
but every continuous consumer still gated its own effect on Item.PowerSupply >= 1f (Thruster.Execute,
|
||||
AetherDrive.Execute, Radiator.Execute, ConstantWeapon.Execute, EnergyDraw.Execute) -- so a partial grant read
|
||||
exactly like none, the flicking-off bug the ruling names. This cut removes each of those five gates (Radiator's
|
||||
outright; the other four narrowed to a true-zero floor) and lets the already-curved performance stat (or, for
|
||||
EnergyDraw, the plain pass/fail gate itself) carry the degradation instead. This script mutates each behaviour's
|
||||
own changed line back toward the old all-or-nothing shape (or removes the newly-added epsilon floor) and asserts
|
||||
the matching BrownoutTests.cs Fact goes red for exactly that reason. A no-op control mutation proves the
|
||||
read/replace/restore path is transparent.
|
||||
|
||||
Usage:
|
||||
python tests/mutation_tests_stats_power_cut7.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"
|
||||
|
||||
|
||||
@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="Assets/Scripts/ServerShared/Behaviors/Thruster.cs",
|
||||
anchor=" if(_input > .01f && Item.PowerSupply > 1e-4f)",
|
||||
mutated=" if(_input > .01f && Item.PowerSupply > 1e-4f)",
|
||||
test="BrownoutTests.ThrusterAtHalfGrantProducesReducedNotZeroNotFullThrust",
|
||||
expect="green",
|
||||
),
|
||||
|
||||
# --- Thruster: the gate must not demand a full grant any more. Reverting to the pre-cut ">= 1f" makes a
|
||||
# --- half grant produce exactly nothing (the flicking-off bug), instead of a reduced, nonzero thrust. ---
|
||||
Mutation(
|
||||
rule="Thruster.Execute must not gate its effect on a full grant",
|
||||
file="Assets/Scripts/ServerShared/Behaviors/Thruster.cs",
|
||||
anchor=" if(_input > .01f && Item.PowerSupply > 1e-4f)",
|
||||
mutated=" if(_input > .01f && Item.PowerSupply >= 1f)",
|
||||
test="BrownoutTests.ThrusterAtHalfGrantProducesReducedNotZeroNotFullThrust",
|
||||
expect="red",
|
||||
),
|
||||
|
||||
# --- AetherDrive: the same rule, the same shape of mutation, on the rotor's own spin-up gate. ---
|
||||
Mutation(
|
||||
rule="AetherDrive.Execute must not gate its effect on a full grant",
|
||||
file="Assets/Scripts/ServerShared/Behaviors/AetherDrive.cs",
|
||||
anchor=" if (Item.PowerSupply > 1e-4f)",
|
||||
mutated=" if (Item.PowerSupply >= 1f)",
|
||||
test="BrownoutTests.AetherDriveAtHalfGrantProducesReducedNotZeroNotFullSpinUp",
|
||||
expect="red",
|
||||
),
|
||||
|
||||
# --- Radiator: Cut 7 deletes the gate outright rather than narrowing it (PumpedHeat's own curve already
|
||||
# --- reduces the pump, and already zeroes it at true zero supply). Re-inserting the old gate line makes a
|
||||
# --- half grant refuse to pump at all, same as a full shortfall. ---
|
||||
Mutation(
|
||||
rule="Radiator.Execute must not gate its pump on a full grant",
|
||||
file="Assets/Scripts/ServerShared/Behaviors/Radiator.cs",
|
||||
anchor=" var pumpedHeat = PumpedHeat * max(itemTemperature - _data.TemperatureFloor, 0);",
|
||||
mutated=" if (Item != null && Item.PowerSupply < 1f) return false;\n\n var pumpedHeat = PumpedHeat * max(itemTemperature - _data.TemperatureFloor, 0);",
|
||||
test="BrownoutTests.RadiatorAtHalfGrantPumpsReducedNotZeroNotFullHeat",
|
||||
expect="red",
|
||||
),
|
||||
|
||||
# --- ConstantWeapon: the exact bug the ruling names -- a partial grant used to safe the weapon off entirely
|
||||
# --- (_firing = false), not merely fire it for less. Reverting to "< 1f" makes a half grant stop the weapon
|
||||
# --- outright instead of merely firing it for reduced damage. ---
|
||||
Mutation(
|
||||
rule="ConstantWeapon.Execute must not safe the weapon off on anything less than a full grant",
|
||||
file="Assets/Scripts/ServerShared/Behaviors/ConstantWeapon.cs",
|
||||
anchor=" if (Item != null && Item.PowerSupply <= 1e-4f)",
|
||||
mutated=" if (Item != null && Item.PowerSupply < 1f)",
|
||||
test="BrownoutTests.ConstantWeaponAtHalfGrantKeepsFiringWithReducedDamage",
|
||||
expect="red",
|
||||
),
|
||||
|
||||
# --- EnergyDraw: the one continuous consumer with no performance stat of its own to curve -- its Execute
|
||||
# --- return value IS its whole effect (Entity.cs's per-BehaviorGroup Execute chain). Reverting to ">= 1f"
|
||||
# --- makes a half grant close the gate exactly like a full shortfall would. ---
|
||||
Mutation(
|
||||
rule="EnergyDraw.Execute must not demand a full grant to pass its gate",
|
||||
file="Assets/Scripts/ServerShared/Behaviors/EnergyDraw.cs",
|
||||
anchor=" return Item == null || Item.PowerSupply > 1e-4f;",
|
||||
mutated=" return Item == null || Item.PowerSupply >= 1f;",
|
||||
test="BrownoutTests.EnergyDrawAtHalfGrantStillPasses",
|
||||
expect="red",
|
||||
),
|
||||
|
||||
# --- The degradation must follow the authored exponent end-to-end through a newly-ungated consumer, not
|
||||
# --- just in the abstract (mutation_tests_stats_power_cut6.py already pins this against a bare resolver
|
||||
# --- read) -- dropping the exponent from PowerSupplyFactor collapses ConstantWeapon's own Damage curve to
|
||||
# --- the identity-linear response regardless of what was authored. ---
|
||||
Mutation(
|
||||
rule="PowerSupplyFactor must raise PowerSupply to the term's own exponent for a real continuous consumer, not just a bare stat",
|
||||
file="Assets/Scripts/ServerShared/Entity.cs",
|
||||
anchor=" public float PowerSupplyFactor(float exponent) => pow(PowerSupply, exponent);",
|
||||
mutated=" public float PowerSupplyFactor(float exponent) => PowerSupply;",
|
||||
test="BrownoutTests.ConstantWeaponDamageDegradesByItsAuthoredExponentNotLinearly",
|
||||
expect="red",
|
||||
),
|
||||
|
||||
# --- Regression: Cut 4's InputCapacitor all-or-nothing rule for instant items must survive this cut
|
||||
# --- completely untouched. Neutering the refusal check proves the regression test actually catches a
|
||||
# --- violation, not merely that it happens to pass today. ---
|
||||
Mutation(
|
||||
rule="InputCapacitor.TrySpend must still refuse a cost it cannot cover",
|
||||
file="Assets/Scripts/ServerShared/Behaviors/InputCapacitor.cs",
|
||||
anchor=" if (!CanSpend(cost)) return false;",
|
||||
mutated=" if (false) return false;",
|
||||
test="BrownoutTests.AnInputCapacitorStillRefusesToSpendOnAPartialCharge",
|
||||
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())
|
||||
|
|
@ -1,182 +0,0 @@
|
|||
#!/usr/bin/env python3
|
||||
"""Mutation tests for docs/stats-and-power-cut.md's Cut 7 ("Roles authored") -- this repository's own Cut 8,
|
||||
following Cut 7's brownout work (mutation_tests_stats_power_cut7.py). This is the content cut: designs declare
|
||||
roles, stats name the role they read, and products author per-role quality so two products of one design differ
|
||||
by being good at different parts. Three rules are pinned here:
|
||||
|
||||
1. ValidateRoleUsage (ItemData.cs) must refuse a StatSource.Quality term naming a role its design does not
|
||||
declare -- and must not refuse one naming a role the design DOES declare (two mutations, opposite
|
||||
directions, so neither "always refuse" nor "never refuse" can pass both).
|
||||
2. Lot.QualityForRole (Provenance.cs) must resolve the matching RoleFill's own quality, not fall back to the
|
||||
lot's workmanship quality regardless of role.
|
||||
3. ItemManager.CreateLot (ItemManager.cs) must roll each role's quality from that product's own ProductRole
|
||||
mean/deviation, not a fixed default -- so two products of one design with different means mint lots that
|
||||
resolve a role-termed stat to different values.
|
||||
|
||||
A no-op control mutation proves the read/replace/restore path is transparent.
|
||||
|
||||
Usage:
|
||||
python tests/mutation_tests_stats_power_cut8.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"
|
||||
|
||||
|
||||
@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="Assets/Scripts/ServerShared/ItemData.cs",
|
||||
anchor=" if (!declared.Contains(term.Role))",
|
||||
mutated=" if (!declared.Contains(term.Role))",
|
||||
test="RoleAuthoringTests.UpsertRefusesAStatNamingARoleItsDesignLacks",
|
||||
expect="green",
|
||||
),
|
||||
|
||||
# --- ValidateRoleUsage must actually refuse an undeclared role, not merely appear to. Weakening the check to
|
||||
# --- "never refuse" is the failure the ruling names directly: "validation already refuses a stat naming a
|
||||
# --- role its design lacks." ---
|
||||
Mutation(
|
||||
rule="ValidateRoleUsage must refuse a StatSource.Quality term naming a role its design does not declare",
|
||||
file="Assets/Scripts/ServerShared/ItemData.cs",
|
||||
anchor=" if (!declared.Contains(term.Role))",
|
||||
mutated=" if (false)",
|
||||
test="RoleAuthoringTests.UpsertRefusesAStatNamingARoleItsDesignLacks",
|
||||
expect="red",
|
||||
),
|
||||
|
||||
# --- The opposite direction: a check that refuses every role-bearing stat, declared or not, would also pass
|
||||
# --- the mutation above. This mutant refuses everything, and must kill the positive control instead. ---
|
||||
Mutation(
|
||||
rule="ValidateRoleUsage must not refuse a StatSource.Quality term naming a role the design DOES declare",
|
||||
file="Assets/Scripts/ServerShared/ItemData.cs",
|
||||
anchor=" if (!declared.Contains(term.Role))",
|
||||
mutated=" if (true)",
|
||||
test="RoleAuthoringTests.UpsertAcceptsAStatNamingADeclaredRole",
|
||||
expect="red",
|
||||
),
|
||||
|
||||
# --- Lot.QualityForRole must resolve the named role's own RoleFill, not fall back to the lot's workmanship
|
||||
# --- regardless of which role (or none) a term names. Commenting out the match makes every role read the
|
||||
# --- same number StatsReadTheLot's "noRole" case reads, collapsing the very distinction Cut 7 exists to buy. ---
|
||||
Mutation(
|
||||
rule="QualityForRole must resolve the matching RoleFill, not fall back to workmanship for every role",
|
||||
file="Assets/Scripts/ServerShared/Provenance.cs",
|
||||
anchor=" foreach (var fill in Roles)\n if (fill.Role == role) return fill.Quality;",
|
||||
mutated=" foreach (var fill in Roles)\n if (false) return fill.Quality;",
|
||||
test="LoadoutTests.StatsReadTheLot",
|
||||
expect="red",
|
||||
),
|
||||
|
||||
# --- ItemManager.CreateLot must roll each role from that product's own declared mean, not a constant. This
|
||||
# --- is the mechanism "products author quality, designs author roles" depends on: without it, authoring a
|
||||
# --- ProductRole spread on a design's role would do nothing at mint time. ---
|
||||
Mutation(
|
||||
rule="CreateLot must roll a role's quality from the product's own ProductRole.Mean, not a fixed default",
|
||||
file="Assets/Scripts/ServerShared/ItemManager.cs",
|
||||
anchor=" Quality = clamp(Random.NextGaussian(build.Mean, build.StandardDeviation), .01f, 1)",
|
||||
mutated=" Quality = clamp(Random.NextGaussian(.5f, build.StandardDeviation), .01f, 1)",
|
||||
test="LoadoutTests.CreateLotFillsRolesFromProductSpread",
|
||||
expect="red",
|
||||
),
|
||||
|
||||
# --- The same rule, proved the way the operator's market-segmentation ruling states it: two products of one
|
||||
# --- design with different role means must mint lots that resolve a role-termed stat to different values. ---
|
||||
Mutation(
|
||||
rule="Two products of one design with different role means must resolve a role-termed stat differently",
|
||||
file="Assets/Scripts/ServerShared/ItemManager.cs",
|
||||
anchor=" Quality = clamp(Random.NextGaussian(build.Mean, build.StandardDeviation), .01f, 1)",
|
||||
mutated=" Quality = clamp(Random.NextGaussian(.5f, build.StandardDeviation), .01f, 1)",
|
||||
test="RoleAuthoringTests.TwoProductsOfOneDesignWithDifferentRoleMeansResolveDifferentValues",
|
||||
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())
|
||||
|
|
@ -1,161 +0,0 @@
|
|||
#!/usr/bin/env python3
|
||||
"""Mutation tests for docs/stats-and-power-cut.md's nominal-request ruling (operator ruling 2026-09-19).
|
||||
|
||||
Two landed rulings collided: a stat deciding a power request may not depend on power supply (Cut 6, widened by
|
||||
F6 to name every stat a PowerRequest actually reads), and continuous consumers brown out through a power term on
|
||||
their performance stats (Cut 7). For Radiator and AetherDrive those are the same stat (RadiatorData.PumpedHeat,
|
||||
AetherDriveData.Torque), which made six shipped catalog records illegal and left six BrownoutTests.cs cases
|
||||
skipped.
|
||||
|
||||
The resolution: a power request is evaluated NOMINALLY -- what the item wants at full supply, not what it is
|
||||
currently managing. EquippedItem.EvaluateNominalPower (Entity.cs) is the read every PowerRequest/RefreshReserve/
|
||||
RefreshInputCapacitor implementation now uses for a StatValidation.PowerRequestFields stat; it pins that stat's
|
||||
own PowerSupplyFactor to 1 regardless of what Terms it declares, removing the circularity at the root instead of
|
||||
forbidding the field (StatValidation.ValidateNoPowerSupplyOnRequest, the static half of Cut 6/F6's rule, is
|
||||
deleted -- see mutation_tests_stats_power_cut6.py's own updated header). This script pins that EvaluateNominalPower
|
||||
actually forces the pin (rather than silently falling back to the real, curved value) and that Radiator.PowerRequest
|
||||
actually calls it instead of the ordinary Evaluate. AetherDrive.PowerRequest's own Torque read has no equivalent
|
||||
black-box mutation here -- its torqueRatio term self-cancels Torque's own magnitude outside the rpm cap, so
|
||||
reverting that one call site produces byte-identical demand in every fixture tried (see the comment in
|
||||
BrownoutTests.cs, right after AetherDriveAtZeroGrantProducesNothing, for the empirical finding); its correctness
|
||||
rests on the identical pattern to Radiator's own call site instead.
|
||||
|
||||
Usage:
|
||||
python tests/mutation_tests_stats_power_nominal_request.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"
|
||||
|
||||
|
||||
@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="Assets/Scripts/ServerShared/Entity.cs",
|
||||
anchor=" public PowerRequestContext(EquippedItem item) => _item = item;\n public Lot Lot => _item.Lot;\n public float HeatFactor(float exponent) => _item.HeatFactor(exponent);\n public float DurabilityFactor(float exponent) => _item.DurabilityFactor(exponent);\n public float ConsumableProgressFactor(float exponent) => _item.ConsumableProgressFactor(exponent);\n public float PowerSupplyFactor(float exponent) => 1f;",
|
||||
mutated=" public PowerRequestContext(EquippedItem item) => _item = item;\n public Lot Lot => _item.Lot;\n public float HeatFactor(float exponent) => _item.HeatFactor(exponent);\n public float DurabilityFactor(float exponent) => _item.DurabilityFactor(exponent);\n public float ConsumableProgressFactor(float exponent) => _item.ConsumableProgressFactor(exponent);\n public float PowerSupplyFactor(float exponent) => 1f;",
|
||||
test="BrownoutTests.EvaluateNominalPowerIgnoresTheItemsCurrentPowerSupplyGrant",
|
||||
expect="green",
|
||||
),
|
||||
|
||||
# --- The pin itself: EvaluateNominalPower's own context must actually return the identity for PowerSupply,
|
||||
# --- not forward it to the real item -- otherwise EvaluateNominalPower is just Evaluate under another name,
|
||||
# --- and every PowerRequest/RefreshReserve/RefreshInputCapacitor call site built on it stays exactly as
|
||||
# --- circular as before this ruling (the oscillation Soul originally measured against the shipped catalog).
|
||||
Mutation(
|
||||
rule="PowerRequestContext.PowerSupplyFactor must return the identity (1), not forward to the real item",
|
||||
file="Assets/Scripts/ServerShared/Entity.cs",
|
||||
anchor=" public float ConsumableProgressFactor(float exponent) => _item.ConsumableProgressFactor(exponent);\n public float PowerSupplyFactor(float exponent) => 1f;\n public float ScaleModifier(PerformanceStat stat) => _item.ScaleModifier(stat);\n public float ConstantModifier(PerformanceStat stat) => _item.ConstantModifier(stat);\n }\n\n public float HeatFactor(float exponent)",
|
||||
mutated=" public float ConsumableProgressFactor(float exponent) => _item.ConsumableProgressFactor(exponent);\n public float PowerSupplyFactor(float exponent) => _item.PowerSupplyFactor(exponent);\n public float ScaleModifier(PerformanceStat stat) => _item.ScaleModifier(stat);\n public float ConstantModifier(PerformanceStat stat) => _item.ConstantModifier(stat);\n }\n\n public float HeatFactor(float exponent)",
|
||||
test="BrownoutTests.EvaluateNominalPowerIgnoresTheItemsCurrentPowerSupplyGrant",
|
||||
expect="red",
|
||||
),
|
||||
|
||||
# --- Radiator.PowerRequest must actually call the nominal read for PumpedHeat/WasteHeat/EnergyUsage, not the
|
||||
# --- ordinary curved Evaluate. A single-tick fixture cannot distinguish the two (Item.PowerSupply defaults to
|
||||
# --- 1 before the bus has ever run, so tick 1 reads the same value either way) -- this fixture runs two
|
||||
# --- ticks, so tick 2 sees whatever tick 1 actually granted. Tuned so nominal PumpedHeat/WasteHeat (10/5 = 2)
|
||||
# --- stays comfortably above tempRatio (pinned near 1) while the curved value at tick 1's partial grant
|
||||
# --- (pow(.4, 1)*10/5 = .8) falls below it -- reverting to Evaluate collapses tick 2's demand through
|
||||
# --- PowerRequest's own early-out gate instead of leaving it open. AetherDrive.PowerRequest's own Torque read
|
||||
# --- has no equivalent black-box mutation: see the comment in BrownoutTests.cs above
|
||||
# --- AetherDriveAtHalfGrantProducesReducedNotZeroNotFullSpinUp for why (torqueRatio self-cancels Torque's own
|
||||
# --- magnitude outside the rpm cap, verified empirically while building this script).
|
||||
Mutation(
|
||||
rule="Radiator.PowerRequest must read PumpedHeat/WasteHeat/EnergyUsage nominally, not via the curved Evaluate",
|
||||
file="Assets/Scripts/ServerShared/Behaviors/Radiator.cs",
|
||||
anchor=" var pumpedHeat = EvaluateNominalPower(_data.PumpedHeat);\n var wasteHeat = EvaluateNominalPower(_data.WasteHeat);\n var tempRatio = max(RadiatorTemperature / Temperature, 1);\n if (tempRatio > pumpedHeat / wasteHeat) return 0f;\n return EvaluateNominalPower(_data.EnergyUsage) * tempRatio * dt;",
|
||||
mutated=" var pumpedHeat = Evaluate(_data.PumpedHeat);\n var wasteHeat = Evaluate(_data.WasteHeat);\n var tempRatio = max(RadiatorTemperature / Temperature, 1);\n if (tempRatio > pumpedHeat / wasteHeat) return 0f;\n return Evaluate(_data.EnergyUsage) * tempRatio * dt;",
|
||||
test="BrownoutTests.RadiatorSecondTickDemandIsUnaffectedByTheFirstTicksPartialGrant",
|
||||
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())
|
||||
Loading…
Reference in a new issue