Pin Cut 6's power-curve and request-independence rules: PowerCurveTests.cs
Seven tests: a PowerSupply-termed stat degrades by its own authored exponent when the grant drops and reads unchanged at full grant (the second read is deliberately taken after a real cached value exists, so the missing-invalidate mutation is actually reachable rather than hidden behind a fixture that only ever reads once); a stat with no PowerSupply term never asks the context for one, cache or no cache; a power-termed stat recomputes at most once per tick, not once per read; a thruster-style EnergyUsage field carrying a PowerSupply term directly is refused at AetheriaStores.Open and at Upsert, naming the item and the field; and a modifier whose magnitude carries a PowerSupply term is refused at equip when its target is a known power-request stat, naming both items. 123 tests total (116 baseline + 7). tests/mutation_tests_stats_power_cut6.py: 10 mutations (1 no-op control, 9 rule-killing), all behave as declared. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
parent
1c1249e700
commit
d975e1d2cc
2 changed files with 542 additions and 0 deletions
313
tests/Aetheria.Shared.Tests/PowerCurveTests.cs
Normal file
313
tests/Aetheria.Shared.Tests/PowerCurveTests.cs
Normal file
|
|
@ -0,0 +1,313 @@
|
|||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.IO;
|
||||
using System.Linq;
|
||||
using CultMath;
|
||||
using GameCult.Caching;
|
||||
using Xunit;
|
||||
|
||||
// Cut 6 (docs/stats-and-power-cut.md): "the power-supply term and the request-independence rule." Purely
|
||||
// additive -- StatSource.PowerSupply already existed as an identity everywhere (Cut 2), and the bus already wrote
|
||||
// EquippedItem.PowerSupply (Cut 3/5). This file pins the two things Cut 6 actually adds: a stat that declares a
|
||||
// PowerSupply term now really degrades as the grant falls, and a stat that decides a power request can never read
|
||||
// that same term back, directly or through a modifier chain. Each rule has a matching mutation in
|
||||
// tests/mutation_tests_stats_power_cut6.py.
|
||||
public sealed class PowerCurveTests : IDisposable
|
||||
{
|
||||
private readonly string _root = Path.Combine(Path.GetTempPath(), "aetheria-powercurve-" + Guid.NewGuid().ToString("N"));
|
||||
private string Catalog => Path.Combine(_root, "Aetheria.cc");
|
||||
private static readonly int2 HardpointCell = new int2(0, 0);
|
||||
private static readonly int2 SecondHardpointCell = new int2(1, 0);
|
||||
|
||||
public PowerCurveTests() => Directory.CreateDirectory(_root);
|
||||
public void Dispose() => Directory.Delete(_root, true);
|
||||
|
||||
private static GameplaySettings Settings() => new GameplaySettings
|
||||
{
|
||||
DefaultEntitySettings = new EntitySettings(),
|
||||
Tiers = new[] { new RarityTier { Name = "Common", Quality = .5f, Rarity = 0, Color = new float3(1, 1, 1) } },
|
||||
QualityPriceModifier = new ExponentialLerp()
|
||||
};
|
||||
|
||||
private static PerformanceStat Constant(float v) => new PerformanceStat { Min = v, Max = v };
|
||||
|
||||
private static EquippableItem Mint(CultCache cache, ItemManager items, ItemData design) => new EquippableItem
|
||||
{
|
||||
Data = cache.RefOf<ItemData>(design),
|
||||
Durability = design is EquippableItemData equippable ? equippable.Durability : 0,
|
||||
Lot = items.Lots.Add(new Lot { Design = cache.RefOf<ItemData>(design), Origin = new Attributed(), Quality = .5f, Roles = new List<RoleFill>() })
|
||||
};
|
||||
|
||||
// Mirrors PowerBusTests/PowerTierTests' own fixture: a 5x5 hull (a real interior for Tool-hardpoint items),
|
||||
// wide heat bounds around the default start temperature so ThermalOnline never has to be driven separately,
|
||||
// and a Reactor whose Charge each test sets directly (dt=1 in every Update call here, so reactorCharge reads
|
||||
// straight as this tick's generation, same as PowerBusTests). The consumer is an EnergyDrawData -- exactly
|
||||
// PowerBusTests' own "Drain" -- rather than Thruster: Thruster's own request is gated on its Axis, which Ship
|
||||
// itself recomputes from MovementDirection every tick (Ship.cs, its own thruster-axis pass), so an axis set
|
||||
// directly on the behaviour is clobbered before PowerBus.Step ever reads it. EnergyDraw's request is a plain
|
||||
// authored constant, with nothing else deciding it -- deterministic demand is the whole point of this
|
||||
// fixture, not thruster flight. curveStat is a second PerformanceStat on the same item (CapacitorData's own
|
||||
// Capacity, which no IPowerConsumer ever requests through) declaring the PowerSupply term under test: reading
|
||||
// it through the item's Evaluate proves the resolver's own curve, independent of what any particular
|
||||
// behaviour does with a partial grant.
|
||||
private CultCache OpenCatalog(PerformanceStat curveStat)
|
||||
{
|
||||
var cache = AetheriaStores.Open(Catalog, catalogWritable: true);
|
||||
cache.Upsert(new TestCatalogGlobal { Name = "Temperament" });
|
||||
var hullShape = new Shape(5, 5);
|
||||
foreach (var cell in hullShape.AllCoordinates) hullShape[cell] = true;
|
||||
cache.Upsert(new HullData { Name = "Skiff", HullType = HullType.Ship, Shape = hullShape, Durability = 10, Mass = 1000 });
|
||||
cache.Upsert(new GearData
|
||||
{
|
||||
Name = "Reactor", Hardpoint = HardpointType.Tool, Shape = new Shape(), Durability = 10,
|
||||
MinimumTemperature = 0, MaximumTemperature = 1000, OptimalTemperature = 280, PlateauWidth = 400,
|
||||
Behaviors = { new ReactorData
|
||||
{
|
||||
Charge = Constant(0), Efficiency = Constant(1), OverloadEfficiency = Constant(1), ThrottlingFactor = Constant(2)
|
||||
} }
|
||||
});
|
||||
cache.Upsert(new GearData
|
||||
{
|
||||
Name = "Engine", Hardpoint = HardpointType.Tool, Shape = new Shape(), Durability = 10,
|
||||
MinimumTemperature = 0, MaximumTemperature = 1000, OptimalTemperature = 280, PlateauWidth = 400,
|
||||
Behaviors =
|
||||
{
|
||||
new EnergyDrawData { EnergyDraw = Constant(100), PerSecond = true },
|
||||
new CapacitorData { Capacity = curveStat, Efficiency = Constant(1) }
|
||||
}
|
||||
});
|
||||
cache.FlushAsync().Wait();
|
||||
return cache;
|
||||
}
|
||||
|
||||
private Ship BuildShip(CultCache cache, float reactorCharge)
|
||||
{
|
||||
var items = new ItemManager(cache, new ProvenanceLedger(), Settings(), _ => { });
|
||||
var zone = new Zone(items, new PlanetSettings(), new ZonePack(), new GalaxyZone { Name = "Test Zone", Owner = null }, null);
|
||||
var hull = Mint(cache, items, cache.GetByName<HullData>("Skiff"));
|
||||
var ship = new Ship(items, zone, hull, new EntitySettings());
|
||||
|
||||
var reactorData = cache.GetByName<GearData>("Reactor");
|
||||
((ReactorData) reactorData.Behaviors[0]).Charge = Constant(reactorCharge);
|
||||
Assert.True(ship.TryEquip(Mint(cache, items, reactorData)));
|
||||
Assert.True(ship.TryEquip(Mint(cache, items, cache.GetByName<GearData>("Engine"))));
|
||||
|
||||
zone.Entities.Add(ship);
|
||||
ship.Activate();
|
||||
return ship;
|
||||
}
|
||||
|
||||
// --- Cut 6 verification bullet 1: "a thruster at half grant produces less thrust, by the authored exponent" --
|
||||
// --- generalised to any PowerSupply-termed stat, since the resolver does not care which behaviour declared
|
||||
// --- it. curveStat's exponent-2 term must degrade to pow(.5, 2) == .25 of its range, not the linear .5 a
|
||||
// --- careless implementation (or a mutation that drops the exponent) would produce.
|
||||
// ---
|
||||
// --- Reads the stat once at full grant BEFORE dropping the reactor's own charge, so the resolver has a real
|
||||
// --- cached (generation-stamped) value in hand when the grant actually changes -- a fixture that only ever
|
||||
// --- reads once, after the fact, can never tell "recomputed correctly" from "never cached, so it happened to
|
||||
// --- read fresh": the missing invalidate half of this cut's own wiring (PowerBus.AllocateTiers) only shows up
|
||||
// --- against a stat that was already cached under the old grant. ---
|
||||
[Fact]
|
||||
public void APowerSupplyTermedStatAtHalfGrantDegradesByItsAuthoredExponent()
|
||||
{
|
||||
var curve = new PerformanceStat { Min = 0, Max = 100, Terms = { new StatTerm { Source = StatSource.PowerSupply, Exponent = 2 } } };
|
||||
using var cache = OpenCatalog(curve);
|
||||
var ship = BuildShip(cache, reactorCharge: 1000); // over-supplied: ratio 1, establishes a real cache entry
|
||||
|
||||
ship.Update(1f);
|
||||
var engine = ship.Equipment.Single(e => e.Data.Name == "Engine");
|
||||
Assert.Equal(1f, engine.PowerSupply, 3);
|
||||
Assert.Equal(100f, engine.Evaluate(curve), 3); // cached at full grant
|
||||
|
||||
var reactor = (ReactorData) ship.Equipment.Single(e => e.Data.Name == "Reactor").Data.Behaviors[0];
|
||||
reactor.Charge = Constant(50); // request 100, generation 50 -> ratio .5
|
||||
ship.Update(1f);
|
||||
|
||||
Assert.Equal(100f, ship.PowerBus.TotalDemand, 3);
|
||||
Assert.Equal(.5f, engine.PowerSupply, 3);
|
||||
Assert.Equal(25f, engine.Evaluate(curve), 2); // lerp(0, 100, pow(.5, 2)) == 25, not the stale 100
|
||||
}
|
||||
|
||||
// --- The other half of the same rule, named explicitly in the ruling ("unchanged at full supply"): a
|
||||
// --- PowerSupply term is the identity at grant 1 regardless of its exponent -- pow(1, n) == 1 -- so an item
|
||||
// --- that is never starved reads exactly as if it had never declared the term. ---
|
||||
[Fact]
|
||||
public void APowerSupplyTermedStatAtFullGrantIsUnaffectedByItsExponent()
|
||||
{
|
||||
var curve = new PerformanceStat { Min = 0, Max = 100, Terms = { new StatTerm { Source = StatSource.PowerSupply, Exponent = 5 } } };
|
||||
using var cache = OpenCatalog(curve);
|
||||
var ship = BuildShip(cache, reactorCharge: 1000); // generation far exceeds the 100 request
|
||||
|
||||
ship.Update(1f);
|
||||
|
||||
var engine = ship.Equipment.Single(e => e.Data.Name == "Engine");
|
||||
Assert.Equal(1f, engine.PowerSupply, 3);
|
||||
Assert.Equal(100f, engine.Evaluate(curve), 3);
|
||||
}
|
||||
|
||||
// A small, deliberately fake context (StatResolverTests' own pattern) so the "no cost when undeclared" rule
|
||||
// is provable without any catalog, entity or item plumbing -- and so it can count exactly how many times the
|
||||
// resolver ever asked for the power-supply factor.
|
||||
private sealed class CountingContext : IStatContext
|
||||
{
|
||||
public Lot Lot { get; set; }
|
||||
public int PowerSupplyFactorCalls;
|
||||
public int HeatFactorCalls;
|
||||
public float HeatFactor(float exponent) { HeatFactorCalls++; return 1f; }
|
||||
public float DurabilityFactor(float exponent) => 1f;
|
||||
public float ConsumableProgressFactor(float exponent) => 1f;
|
||||
public float PowerSupplyFactor(float exponent) { PowerSupplyFactorCalls++; return 1f; }
|
||||
public float ScaleModifier(PerformanceStat stat) => 1f;
|
||||
public float ConstantModifier(PerformanceStat stat) => 0f;
|
||||
}
|
||||
|
||||
// --- Cut 6 verification bullet 4 / the operator's own "don't pay that cost for every stat evaluation" as an
|
||||
// --- assertion: a stat with no PowerSupply term never asks the context for one, at all, cache or no cache --
|
||||
// --- proving the term list genuinely is not walked for a source the stat never declared, not merely that the
|
||||
// --- answer happens to be 1. ---
|
||||
[Fact]
|
||||
public void AStatWithNoPowerSupplyTermNeverAsksForOne()
|
||||
{
|
||||
var resolver = new StatResolver();
|
||||
var owner = new object();
|
||||
var context = new CountingContext();
|
||||
var stat = new PerformanceStat { Min = 0, Max = 10, Terms = { new StatTerm { Source = StatSource.Heat, Exponent = 1 } } };
|
||||
|
||||
resolver.Resolve(owner, stat, context);
|
||||
resolver.InvalidateSource(owner, StatSource.PowerSupply); // the bus's own per-tick call, unconditional
|
||||
resolver.Resolve(owner, stat, context);
|
||||
|
||||
Assert.Equal(1, context.HeatFactorCalls); // recomputed once, for its own declared source
|
||||
Assert.Equal(0, context.PowerSupplyFactorCalls); // never once asked -- this stat has no PowerSupply term
|
||||
}
|
||||
|
||||
// --- The same bullet's other half: a stat that DOES declare the term recomputes "at most once per tick," not
|
||||
// --- once per read and not once per grant change. PowerBus invalidates PowerSupply unconditionally, exactly
|
||||
// --- once per draw per Step (mirroring Heat/Durability's own "every tick, not only when the value moved"
|
||||
// --- scheme) -- this pins that shape directly against the resolver, independent of PowerBus's own plumbing.
|
||||
// --- ---
|
||||
[Fact]
|
||||
public void APowerTermedStatRecomputesAtMostOncePerTick()
|
||||
{
|
||||
var resolver = new StatResolver();
|
||||
var owner = new object();
|
||||
var context = new CountingContext();
|
||||
var stat = new PerformanceStat { Min = 0, Max = 10, Terms = { new StatTerm { Source = StatSource.PowerSupply, Exponent = 1 } } };
|
||||
|
||||
// Tick 1: the bus invalidates once; three reads inside the tick must share one evaluation.
|
||||
resolver.InvalidateSource(owner, StatSource.PowerSupply);
|
||||
resolver.Resolve(owner, stat, context);
|
||||
resolver.Resolve(owner, stat, context);
|
||||
resolver.Resolve(owner, stat, context);
|
||||
Assert.Equal(1, context.PowerSupplyFactorCalls);
|
||||
|
||||
// Tick 2: the bus invalidates again (PowerBus.AllocateTiers does this every tick, moved or not).
|
||||
resolver.InvalidateSource(owner, StatSource.PowerSupply);
|
||||
resolver.Resolve(owner, stat, context);
|
||||
Assert.Equal(2, context.PowerSupplyFactorCalls); // exactly one more -- not zero, not three again
|
||||
}
|
||||
|
||||
// --- Cut 6 verification bullet 2: "a catalog holding a thruster whose EnergyUsage carries a PowerSupply term
|
||||
// --- is refused at load, naming the item and the stat." Written through the raw UpsertAsync (bypassing
|
||||
// --- CultRecordRefs.Upsert's own validation, AetheriaStoresTests.WeaponWrittenThroughGearHandleReloadsAsWeapon's
|
||||
// --- own pattern for reaching disk with something Upsert would refuse) so this pins AetheriaStores.Open's own
|
||||
// --- half of the rule, not Upsert's. ---
|
||||
[Fact]
|
||||
public void OpenRefusesAThrusterWhoseEnergyUsageCarriesAPowerSupplyTerm()
|
||||
{
|
||||
var badEnergy = new PerformanceStat { Min = 1, Max = 1, Terms = { new StatTerm { Source = StatSource.PowerSupply, Exponent = 1 } } };
|
||||
using (var cache = AetheriaStores.Open(Catalog, catalogWritable: true))
|
||||
{
|
||||
cache.Upsert(new TestCatalogGlobal { Name = "Temperament" });
|
||||
cache.UpsertAsync(new GearData
|
||||
{
|
||||
Name = "BadThruster", Hardpoint = HardpointType.Tool, Shape = new Shape(), Durability = 10,
|
||||
MinimumTemperature = 0, MaximumTemperature = 1000, OptimalTemperature = 280, PlateauWidth = 400,
|
||||
Behaviors = { new ThrusterData { EnergyUsage = badEnergy } }
|
||||
}).Wait();
|
||||
cache.FlushAsync().Wait();
|
||||
}
|
||||
|
||||
var error = Assert.Throws<InvalidOperationException>(() => AetheriaStores.Open(Catalog));
|
||||
Assert.Contains("BadThruster", error.Message);
|
||||
Assert.Contains(nameof(ThrusterData), error.Message);
|
||||
Assert.Contains(nameof(ThrusterData.EnergyUsage), error.Message);
|
||||
}
|
||||
|
||||
// The same rule at Upsert (the write path every tool, test and migration script actually goes through), so a
|
||||
// bad record is refused before it ever reaches disk -- not just the next time someone reopens the catalog.
|
||||
[Fact]
|
||||
public void UpsertRefusesAThrusterWhoseEnergyUsageCarriesAPowerSupplyTerm()
|
||||
{
|
||||
var badEnergy = new PerformanceStat { Min = 1, Max = 1, Terms = { new StatTerm { Source = StatSource.PowerSupply, Exponent = 1 } } };
|
||||
using var cache = AetheriaStores.Open(Catalog, catalogWritable: true);
|
||||
cache.Upsert(new TestCatalogGlobal { Name = "Temperament" });
|
||||
|
||||
var error = Assert.Throws<InvalidOperationException>(() => cache.Upsert(new GearData
|
||||
{
|
||||
Name = "BadThruster", Hardpoint = HardpointType.Tool, Shape = new Shape(), Durability = 10,
|
||||
MinimumTemperature = 0, MaximumTemperature = 1000, OptimalTemperature = 280, PlateauWidth = 400,
|
||||
Behaviors = { new ThrusterData { EnergyUsage = badEnergy } }
|
||||
}));
|
||||
Assert.Contains("BadThruster", error.Message);
|
||||
Assert.Contains(nameof(ThrusterData.EnergyUsage), error.Message);
|
||||
}
|
||||
|
||||
// --- Cut 6 verification bullet 3: "the same refusal through a modifier chain, at equip time, naming both
|
||||
// --- items." Drain's own EnergyDraw declares no PowerSupply term (so Upsert/Open both accept it on its own),
|
||||
// --- but Booster's StatModifier targets it with a magnitude that DOES carry one -- the request would inherit
|
||||
// --- the dependency the moment the modifier attaches. This is dynamic (StatValidation's static check has no
|
||||
// --- entity to resolve the modifier's target against), so it is refused at Activate, mirroring
|
||||
// --- StatResolverTests.AModifierThatTargetsItsOwnMagnitudeStatIsRefusedAtEquip's own equip-time pattern. ---
|
||||
[Fact]
|
||||
public void ActivateRefusesAModifierChainThatWouldCorruptAPowerRequest()
|
||||
{
|
||||
var request = new PerformanceStat { Min = 10, Max = 10 }; // Drain's own request: no PowerSupply term
|
||||
var magnitude = new PerformanceStat { Min = 1, Max = 1, Terms = { new StatTerm { Source = StatSource.PowerSupply, Exponent = 1 } } };
|
||||
var modifier = new StatModifierData
|
||||
{
|
||||
Stat = new StatReference { Target = nameof(EnergyDrawData), Stat = nameof(EnergyDrawData.EnergyDraw) },
|
||||
Modifier = magnitude,
|
||||
Type = StatModifierType.Constant
|
||||
};
|
||||
|
||||
using var cache = AetheriaStores.Open(Catalog, catalogWritable: true);
|
||||
cache.Upsert(new TestCatalogGlobal { Name = "Temperament" });
|
||||
var hullShape = new Shape(3, 3);
|
||||
foreach (var cell in hullShape.AllCoordinates) hullShape[cell] = true;
|
||||
cache.Upsert(new HullData
|
||||
{
|
||||
Name = "Skiff", HullType = HullType.Ship, Shape = hullShape, Durability = 10,
|
||||
Hardpoints =
|
||||
{
|
||||
new HardpointData { Type = HardpointType.Sensors, Position = HardpointCell, Shape = new Shape() },
|
||||
new HardpointData { Type = HardpointType.Sensors, Position = SecondHardpointCell, Shape = new Shape() }
|
||||
}
|
||||
});
|
||||
cache.Upsert(new GearData
|
||||
{
|
||||
Name = "Drain", Hardpoint = HardpointType.Sensors, Shape = new Shape(), Durability = 10,
|
||||
MinimumTemperature = 0, MaximumTemperature = 1000, OptimalTemperature = 280, PlateauWidth = 400,
|
||||
Behaviors = { new EnergyDrawData { EnergyDraw = request, PerSecond = true } }
|
||||
});
|
||||
cache.Upsert(new GearData
|
||||
{
|
||||
Name = "Booster", Hardpoint = HardpointType.Sensors, Shape = new Shape(), Durability = 10,
|
||||
MinimumTemperature = 0, MaximumTemperature = 1000, OptimalTemperature = 280, PlateauWidth = 400,
|
||||
Behaviors = { modifier }
|
||||
});
|
||||
cache.FlushAsync().Wait();
|
||||
|
||||
var items = new ItemManager(cache, new ProvenanceLedger(), Settings(), _ => { });
|
||||
var zone = new Zone(items, new PlanetSettings(), new ZonePack(), new GalaxyZone { Name = "Test Zone", Owner = null }, null);
|
||||
var hullItem = Mint(cache, items, cache.GetByName<HullData>("Skiff"));
|
||||
var ship = new Ship(items, zone, hullItem, new EntitySettings());
|
||||
Assert.True(ship.TryEquip(Mint(cache, items, cache.GetByName<GearData>("Drain")), HardpointCell));
|
||||
Assert.True(ship.TryEquip(Mint(cache, items, cache.GetByName<GearData>("Booster")), SecondHardpointCell));
|
||||
zone.Entities.Add(ship);
|
||||
|
||||
var error = Assert.Throws<InvalidOperationException>(() => ship.Activate());
|
||||
Assert.Contains("Drain", error.Message); // the item whose request would be corrupted
|
||||
Assert.Contains("Booster", error.Message); // the item whose modifier would corrupt it
|
||||
}
|
||||
}
|
||||
229
tests/mutation_tests_stats_power_cut6.py
Normal file
229
tests/mutation_tests_stats_power_cut6.py
Normal file
|
|
@ -0,0 +1,229 @@
|
|||
#!/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 adds the validation rule that a power request may not depend on power supply,
|
||||
directly (StatValidation.ValidateNoPowerSupplyOnRequest) or through a modifier chain
|
||||
(StatModifier.ValidateNoPowerSupplyChain). 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.
|
||||
|
||||
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 direct half of the request-independence rule: a request stat's own declared Terms must be checked
|
||||
# --- for PowerSupply. Neutering the check into a no-op accepts the exact catalog record the rule exists to
|
||||
# --- refuse. ---
|
||||
Mutation(
|
||||
rule="a request stat's own Terms must be checked for a PowerSupply term",
|
||||
file="Assets/Scripts/ServerShared/ItemData.cs",
|
||||
anchor=" foreach (var term in stat.Terms)\n if (term.Source == StatSource.PowerSupply)\n throw new InvalidOperationException(\n $\"{ownerName}: {behavior.GetType().Name}.{field} is a power request -- its own Terms may not \" +\n \"declare a PowerSupply term, or the request would depend on how much power it receives to \" +\n \"decide how much power it asks for\");",
|
||||
mutated=" foreach (var term in stat.Terms)\n if (false)\n throw new InvalidOperationException(\n $\"{ownerName}: {behavior.GetType().Name}.{field} is a power request -- its own Terms may not \" +\n \"declare a PowerSupply term, or the request would depend on how much power it receives to \" +\n \"decide how much power it asks for\");",
|
||||
test="PowerCurveTests.UpsertRefusesAThrusterWhoseEnergyUsageCarriesAPowerSupplyTerm",
|
||||
expect="red",
|
||||
),
|
||||
|
||||
# --- The registry itself must actually name the field the mutant fixture exercises (ThrusterData.EnergyUsage)
|
||||
# --- -- removing that one entry lets a catalog write straight past the check because nothing on the whole
|
||||
# --- registry recognises it as a request stat any more. ---
|
||||
Mutation(
|
||||
rule="the request-field registry must include ThrusterData.EnergyUsage",
|
||||
file="Assets/Scripts/ServerShared/ItemData.cs",
|
||||
anchor=" (typeof(ThrusterData), nameof(ThrusterData.EnergyUsage)),\n",
|
||||
mutated="",
|
||||
test="PowerCurveTests.UpsertRefusesAThrusterWhoseEnergyUsageCarriesAPowerSupplyTerm",
|
||||
expect="red",
|
||||
),
|
||||
|
||||
# --- AetheriaStores.Open must run the same check at catalog load, not only at Upsert -- otherwise a record
|
||||
# --- written through the raw CultCache API (bypassing Upsert, exactly like AetherDb's own named dangling-ref
|
||||
# --- hole) reopens silently invalid. ---
|
||||
Mutation(
|
||||
rule="AetheriaStores.Open must validate every EquippableItemData against the power-request rule",
|
||||
file="Assets/Scripts/ServerShared/AetheriaStores.cs",
|
||||
anchor=" StatValidation.ValidateStatModifiers(data.Name, data.Behaviors);\n // Cut 6 (docs/stats-and-power-cut.md): a power request may not depend on power supply.\n StatValidation.ValidateNoPowerSupplyOnRequest(data.Name, data.Behaviors);",
|
||||
mutated=" StatValidation.ValidateStatModifiers(data.Name, data.Behaviors);",
|
||||
test="PowerCurveTests.OpenRefusesAThrusterWhoseEnergyUsageCarriesAPowerSupplyTerm",
|
||||
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())
|
||||
Loading…
Reference in a new issue