diff --git a/Assets/Scripts/ServerShared/AetheriaStores.cs b/Assets/Scripts/ServerShared/AetheriaStores.cs index f823b0514..47e935999 100644 --- a/Assets/Scripts/ServerShared/AetheriaStores.cs +++ b/Assets/Scripts/ServerShared/AetheriaStores.cs @@ -35,11 +35,16 @@ public static class AetheriaStores { StatValidation.ValidateHeatResponse(data); StatValidation.ValidateStatModifiers(data.Name, data.Behaviors); + // Cut 6 (docs/stats-and-power-cut.md): a power request may not depend on power supply. + StatValidation.ValidateNoPowerSupplyOnRequest(data.Name, data.Behaviors); } // Cut 2 (docs/stats-and-power-cut.md): a consumable's own StatModifierData needs the same check; // consumables carry no heat response, so this is not folded into the loop above. foreach (var data in cache.GetAll()) + { StatValidation.ValidateStatModifiers(data.Name, data.Behaviors); + StatValidation.ValidateNoPowerSupplyOnRequest(data.Name, data.Behaviors); + } return cache; } catch @@ -75,9 +80,13 @@ public static class CultRecordRefs { StatValidation.ValidateHeatResponse(data); StatValidation.ValidateStatModifiers(data.Name, data.Behaviors); + StatValidation.ValidateNoPowerSupplyOnRequest(data.Name, data.Behaviors); } if (document is ConsumableItemData consumable) + { StatValidation.ValidateStatModifiers(consumable.Name, consumable.Behaviors); + StatValidation.ValidateNoPowerSupplyOnRequest(consumable.Name, consumable.Behaviors); + } return new CultRecordRef(cache.UpsertAsync(document).Result.Key); } } diff --git a/Assets/Scripts/ServerShared/Behaviors/StatModifier.cs b/Assets/Scripts/ServerShared/Behaviors/StatModifier.cs index 71164bfcb..975911321 100644 --- a/Assets/Scripts/ServerShared/Behaviors/StatModifier.cs +++ b/Assets/Scripts/ServerShared/Behaviors/StatModifier.cs @@ -85,6 +85,7 @@ public class StatModifier : Behavior, IInitializableBehavior, IDisposable, IAlwa RemoveModifier(); _targets = TargetsOf(Entity, _data); ValidateNoCycle(Entity, _data, _targets); + ValidateNoPowerSupplyChain(Entity, _data, _targets, Item); } private static Target[] TargetsOf(Entity entity, StatModifierData data) @@ -106,17 +107,14 @@ public class StatModifier : Behavior, IInitializableBehavior, IDisposable, IAlwa .ToArray(); } - // A modifier's magnitude (_data.Modifier) is itself a PerformanceStat, evaluated through the same resolver as - // everything else it modifies. If that magnitude stat is, directly or through another modifier on this - // entity, one of the stats this modifier writes, resolving it would depend on a value computed from itself - // one tick late -- an authoring error, not a feature. Refused at equip, naming the entity and the reference. - // Built fresh from Entity.Equipment every time (never a static or catalog-side map), so it costs nothing to - // discard and cannot leak: by the time Activate() calls any StatModifier's Initialize, every EquippedItem - // (and so every StatModifier) on the entity already exists (Entity.cs Activate). - private static void ValidateNoCycle(Entity entity, StatModifierData data, Target[] targets) + // Shared by ValidateNoCycle and ValidateNoPowerSupplyChain below: every StatModifier currently on this + // entity, as an edge from the magnitude stat it reads to the stat(s) it writes. Built fresh from + // Entity.Equipment every time (never a static or catalog-side map), so it costs nothing to discard and cannot + // leak: by the time Activate() calls any StatModifier's Initialize, every EquippedItem (and so every + // StatModifier) on the entity already exists (Entity.cs Activate). Both checks see exactly what is actually + // equipped, not what the catalog merely allows. + private static Dictionary> BuildModifierEdges(Entity entity, StatModifierData data, Target[] targets) { - if (data.Modifier == null) return; - var edges = new Dictionary>(); foreach (var behavior in entity.Equipment.SelectMany(e => e.Behaviors).OfType()) { @@ -135,6 +133,18 @@ public class StatModifier : Behavior, IInitializableBehavior, IDisposable, IAlwa foreach (var target in behaviorTargets) if (target.Stat != null) list.Add(target.Stat); } + return edges; + } + + // A modifier's magnitude (_data.Modifier) is itself a PerformanceStat, evaluated through the same resolver as + // everything else it modifies. If that magnitude stat is, directly or through another modifier on this + // entity, one of the stats this modifier writes, resolving it would depend on a value computed from itself + // one tick late -- an authoring error, not a feature. Refused at equip, naming the entity and the reference. + private static void ValidateNoCycle(Entity entity, StatModifierData data, Target[] targets) + { + if (data.Modifier == null) return; + + var edges = BuildModifierEdges(entity, data, targets); var visited = new HashSet(); bool ReachesSelf(PerformanceStat node) @@ -153,6 +163,52 @@ public class StatModifier : Behavior, IInitializableBehavior, IDisposable, IAlwa "modifier chain reaches back to itself -- a stat cannot (even transitively) modify its own magnitude"); } + // Cut 6 (docs/stats-and-power-cut.md): the dynamic half of "a stat that decides a power request may not + // depend on power supply, directly or through a modifier chain." StatValidation.ValidateNoPowerSupplyOnRequest + // already refuses a request stat's own declared Terms, at catalog load, at Upsert and at equip -- that check + // needs no entity. This one does: whether a given modifier's target is "a power request stat" is static (the + // (BehaviorData type, field) registry in StatValidation), but which items are actually feeding a modifier + // chain into it is a fact about this concrete ship's loadout. Walks the same magnitude->target edges + // ValidateNoCycle builds, in the other direction: is this modifier's own magnitude stat "power-tainted" -- + // does it, or something feeding it through another modifier on this entity, carry a PowerSupply term -- and + // does it write onto a known request stat. Refused at equip, naming both the modifying item and the item + // whose request it would corrupt. + private static void ValidateNoPowerSupplyChain(Entity entity, StatModifierData data, Target[] targets, EquippedItem modifyingItem) + { + if (data.Modifier == null) return; + var edges = BuildModifierEdges(entity, data, targets); + if (!IsPowerTainted(data.Modifier, edges, new HashSet())) + return; + + foreach (var target in targets) + { + if (!(target.Owner is EquippedItem targetItem)) continue; + if (!StatValidation.TryGetPowerRequestBehaviorName(targetItem.Data, target.Stat, out var behaviorName)) + continue; + throw new InvalidOperationException( + $"{entity.Name}: stat modifier on \"{modifyingItem?.Data.Name ?? "?"}\" would let " + + $"\"{targetItem.Data.Name}\".{behaviorName}'s power request depend on power supply through a " + + "modifier chain -- a stat that decides how much power a behaviour asks for may not depend, even " + + "transitively, on how much it receives"); + } + } + + // A magnitude stat is power-tainted if its own Terms name PowerSupply directly, or if it is itself the + // target of some other modifier on this entity whose magnitude is (transitively) power-tainted. `visited` + // guards the search against a real cycle -- ValidateNoCycle already refuses those outright, but this walk + // must not stack-overflow while that refusal is still in flight for a different modifier on the same entity. + private static bool IsPowerTainted(PerformanceStat stat, Dictionary> edges, HashSet visited) + { + if (stat == null || !visited.Add(stat)) return false; + foreach (var term in stat.Terms) + if (term.Source == StatSource.PowerSupply) + return true; + foreach (var pair in edges) + if (pair.Value.Contains(stat) && IsPowerTainted(pair.Key, edges, visited)) + return true; + return false; + } + // Cut 2: the modifier value used to be written straight into the catalog stat's own per-entity dictionary // (the leak, §0.3). It now attaches to the resolver entry it targets, keyed by (that item, that stat); it is // discarded with the entity, never with the catalog. diff --git a/Assets/Scripts/ServerShared/Entity.cs b/Assets/Scripts/ServerShared/Entity.cs index 40513f9c6..8a051bc67 100644 --- a/Assets/Scripts/ServerShared/Entity.cs +++ b/Assets/Scripts/ServerShared/Entity.cs @@ -1354,6 +1354,14 @@ public class EquippedItem : IStatContext if (consumerTiers.Length > 0) EquippableItem.PowerTier = consumerTiers.Min(); } + + // Cut 6 (docs/stats-and-power-cut.md): "the validator runs at catalog load, at Studio save, and at + // equip." AetheriaStores.Open/CultRecordRefs.Upsert already refuse this at catalog write time; re-running + // the same static check here is what closes the gap for the two named bypasses (CultCache Studio's + // generic editor, AetherDb's dangling-ref apply) rather than silently trusting that every catalog on disk + // went through Upsert. The modifier-chain half of the rule is dynamic (it depends on what is actually + // equipped alongside this item) and lives in StatModifier.Initialize instead. + StatValidation.ValidateNoPowerSupplyOnRequest(Data.Name, Data.Behaviors); } // Cut 2 (docs/stats-and-power-cut.md): the resolver owns the value; this is the caller's read of it, keyed by @@ -1363,13 +1371,18 @@ public class EquippedItem : IStatContext public float HeatFactor(float exponent) => pow(ThermalPerformance, ThermalExponent * exponent); public float DurabilityFactor(float exponent) => pow(DurabilityPerformance, DurabilityExponent * exponent); public float ConsumableProgressFactor(float exponent) => 1f; - public float PowerSupplyFactor(float exponent) => 1f; + // Cut 6 (docs/stats-and-power-cut.md): the brownout curve. A stat with a PowerSupply term degrades as this + // tick's grant falls below 1 and is exactly unchanged at full supply (pow(1, exponent) == 1 regardless of + // exponent) -- the operator's "a continuous consumer that degrades instead of stopping." PowerBus writes + // PowerSupply below; the resolver invalidates this source once per tick from the same write (PowerBus.cs + // AllocateTiers), so this read is always this tick's own grant, never a stale one. + public float PowerSupplyFactor(float exponent) => pow(PowerSupply, exponent); // Cut 3 (docs/stats-and-power-cut.md §1.2): PowerBus's grant ratio for this item, in [0,1], from whichever - // tick last ran its Step. Display-only and (once Cut 6 wires StatSource.PowerSupply into the resolver) - // stat-source-only: the only other reader is the very IPowerConsumer behaviour that declared the request this - // reflects. Forbidden writers: nothing but PowerBus sets this. Defaults to 1 (fully supplied) for an item - // that draws no power, or before the bus has run once. + // tick last ran its Step. Display-only and, as of Cut 6, stat-source-only: the only other reader is the + // resolver, through PowerSupplyFactor above, on behalf of whichever IPowerConsumer behaviour declared a + // PowerSupply term. Forbidden writers: nothing but PowerBus sets this. Defaults to 1 (fully supplied) for an + // item that draws no power, or before the bus has run once. public float PowerSupply { get; internal set; } = 1f; // Cut 2: these used to read the catalog stat's own per-entity dictionary (the leak, §0.3). A modifier now diff --git a/Assets/Scripts/ServerShared/ItemData.cs b/Assets/Scripts/ServerShared/ItemData.cs index 3df548feb..8f318d9d0 100644 --- a/Assets/Scripts/ServerShared/ItemData.cs +++ b/Assets/Scripts/ServerShared/ItemData.cs @@ -754,6 +754,70 @@ public static class StatValidation throw new InvalidOperationException($"{ownerName}: {ex.Message}"); } } + + // Cut 6 (docs/stats-and-power-cut.md): "a stat that decides a power request may not depend on power supply, + // directly or through a modifier chain, and no stat may depend on itself." IPowerConsumer.PowerRequest(dt) + // never hands the bus a number it invented on the spot -- each implementation evaluates exactly one fixed + // PerformanceStat field on its own BehaviorData (Thruster reads EnergyUsage, EnergyDraw reads EnergyDraw, and + // so on). That field is a compile-time fact about the C# type, not something authored per catalog instance, + // so it is named here once -- the same (type, field) address ResolveStatField already uses for + // StatReference -- rather than adding a second reflection idiom or a per-instance flag. Naming it here (not a + // new interface member on IPowerConsumer) means the check needs no Behavior instance, no EquippedItem and no + // Entity: it can run over the catalog's own Data objects, at load and at Upsert, before anything is ever + // equipped. + public static readonly (Type Type, string Field)[] PowerRequestFields = + { + (typeof(EnergyDrawData), nameof(EnergyDrawData.EnergyDraw)), + (typeof(ThrusterData), nameof(ThrusterData.EnergyUsage)), + (typeof(ConstantWeaponData), nameof(WeaponData.Energy)), + (typeof(InstantWeaponData), nameof(WeaponData.Energy)), + (typeof(SensorData), nameof(SensorData.PingEnergy)), + (typeof(ShieldData), nameof(ShieldData.Capacity)), + (typeof(RadiatorData), nameof(RadiatorData.EnergyUsage)), + (typeof(AetherDriveData), nameof(AetherDriveData.EnergyDraw)), + }; + + // The direct half of the rule: a request stat's own declared Terms must not name PowerSupply. Runs wherever + // ValidateHeatResponse/ValidateStatModifiers run (AetheriaStores.Open, CultRecordRefs.Upsert) plus once more + // at equip (EquippedItem's constructor, Entity.cs) as defense against the two named bypasses that write a + // catalog document without going through Upsert (CultCache Studio's generic editor, AetherDb's dangling-ref + // apply -- see the comment above ValidateHeatResponse). The modifier-chain half of the rule is dynamic and + // lives in StatModifier.Initialize (StatModifier.cs), because a modifier's target only resolves against a + // concrete entity's actual equipment. + public static void ValidateNoPowerSupplyOnRequest(string ownerName, IEnumerable behaviors) + { + foreach (var behavior in behaviors) + foreach (var (type, field) in PowerRequestFields) + { + if (!type.IsInstanceOfType(behavior)) continue; + if (!(type.GetField(field)?.GetValue(behavior) is PerformanceStat stat)) continue; + foreach (var term in stat.Terms) + if (term.Source == StatSource.PowerSupply) + throw new InvalidOperationException( + $"{ownerName}: {behavior.GetType().Name}.{field} is a power request -- its own Terms may not " + + "declare a PowerSupply term, or the request would depend on how much power it receives to " + + "decide how much power it asks for"); + } + } + + // Whether `stat` is the request stat of some IPowerConsumer behaviour on `data` -- used by StatModifier's + // equip-time chain check to tell "an ordinary modifier target" from "a modifier target that would corrupt a + // power request." Returns the owning behaviour's type name for the error message. + public static bool TryGetPowerRequestBehaviorName(EquippableItemData data, PerformanceStat stat, out string behaviorName) + { + foreach (var behavior in data.Behaviors) + foreach (var (type, field) in PowerRequestFields) + { + if (!type.IsInstanceOfType(behavior)) continue; + if (ReferenceEquals(type.GetField(field)?.GetValue(behavior), stat)) + { + behaviorName = type.Name; + return true; + } + } + behaviorName = null; + return false; + } } [CultDocument("aetheria.personalityattribute", "1"), Inspectable, MessagePackObject, JsonObject(MemberSerialization.OptIn)] diff --git a/Assets/Scripts/ServerShared/PowerBus.cs b/Assets/Scripts/ServerShared/PowerBus.cs index 53c1b9d01..6942688ae 100644 --- a/Assets/Scripts/ServerShared/PowerBus.cs +++ b/Assets/Scripts/ServerShared/PowerBus.cs @@ -161,7 +161,16 @@ public class PowerBus } foreach (var draw in draws) + { draw.Item.PowerSupply = ratios[draw.Tier]; + // Cut 6 (docs/stats-and-power-cut.md): the missing half of the wiring -- a stat with a PowerSupply + // term must recompute when the grant actually moves. Called unconditionally, once per draw per tick, + // the same shape as EquippedItem.UpdatePerformance's own Heat/Durability invalidation: "recomputes at + // most once per tick per (item, stat)," not "only when the value moved." An item with no PowerSupply + // term pays nothing extra -- the resolver's per-source generation bookkeeping (StatResolver.Resolve) + // only ever looks at sources a stat's own Terms declared. + _entity.Resolver.InvalidateSource(draw.Item, StatSource.PowerSupply); + } } // Mirrors Entity.TryConsumeEnergy's old do/while exactly (draw evenly across every capacitor that still has diff --git a/tests/Aetheria.Shared.Tests/PowerCurveTests.cs b/tests/Aetheria.Shared.Tests/PowerCurveTests.cs new file mode 100644 index 000000000..5141291a6 --- /dev/null +++ b/tests/Aetheria.Shared.Tests/PowerCurveTests.cs @@ -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(design), + Durability = design is EquippableItemData equippable ? equippable.Durability : 0, + Lot = items.Lots.Add(new Lot { Design = cache.RefOf(design), Origin = new Attributed(), Quality = .5f, Roles = new List() }) + }; + + // 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("Skiff")); + var ship = new Ship(items, zone, hull, new EntitySettings()); + + var reactorData = cache.GetByName("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("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(() => 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(() => 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("Skiff")); + var ship = new Ship(items, zone, hullItem, new EntitySettings()); + Assert.True(ship.TryEquip(Mint(cache, items, cache.GetByName("Drain")), HardpointCell)); + Assert.True(ship.TryEquip(Mint(cache, items, cache.GetByName("Booster")), SecondHardpointCell)); + zone.Entities.Add(ship); + + var error = Assert.Throws(() => 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 + } +} diff --git a/tests/mutation_tests_stats_power_cut6.py b/tests/mutation_tests_stats_power_cut6.py new file mode 100644 index 000000000..04ecdfabd --- /dev/null +++ b/tests/mutation_tests_stats_power_cut6.py @@ -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 + +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()))\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())