Merge the power fixes: multiplier, active gating, honest tiers, shield repair

F1 power supply multiplies the resolved value (operator ruling), so zero
supply means zero and a flat stat still responds. F2 the bus agrees with
Active in both directions. F3 an empty tier reports no demand instead of
full supply, and sub-epsilon demand is rationed and subtracted. F4/F5 the
shield restores while inactive and CanTakeHit no longer mutates. F6/F7 the
validator names every stat a request reads and the tools validate before
writing. F8 re-anchors two dead mutation harnesses.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
Meta Vens 2026-09-19 06:04:12 +02:00
commit cd47eefe9e
21 changed files with 667 additions and 108 deletions

View file

@ -587,9 +587,9 @@ public interface IStatContext
// Progress through a consumable effect's duration, standing in for "condition" on a consumable the way heat
// does for equipped gear. Only ConsumableItemEffect has one; every other context is the identity (1).
float ConsumableProgressFactor(float exponent);
// Cut 6 (the power bus) wires this to a real brownout curve. Until then no catalog stat declares a
// PowerSupply term, and every context answers the identity so the enum member can exist now without a
// resolver to back it.
// Cut 6 (the power bus) wires this to the bus's grant ratio. F1 (docs/stats-and-power-cut.md, operator
// ruling 2026-09-19): this is not one of the terms blended into the Min/Max interpolation -- PerformanceStat
// applies it as a separate multiplier on the fully-resolved value, so zero supply always resolves to zero.
float PowerSupplyFactor(float exponent);
float ScaleModifier(PerformanceStat stat);
float ConstantModifier(PerformanceStat stat);
@ -643,11 +643,28 @@ public class PerformanceStat
// this cut, because pow(x, 0) == 1 regardless of x. Callers keep their own NaN handling: an unequipped read
// throws with diagnostic detail, an equipped or consumable read falls back to Min. That disagreement is not
// named as a defect, so it is not touched here.
//
// F1 (docs/stats-and-power-cut.md, operator ruling 2026-09-19): PowerSupply is not folded into the
// interpolation factor with every other source. Heat, Durability and Quality all describe "how worn/hot/well
// made is this part" -- a part that is still there, just less good at its job, so blending toward Min is
// right for them. Power is not a degradation of the part; it is whether the part is receiving anything to
// work with at all, so it multiplies the fully-interpolated, fully-modified value instead: a PowerSupply term
// still lives in this stat's own Terms list (it is still authored per stat, still censused, still refused on
// a request stat by ValidateNoPowerSupplyOnRequest below), it is just applied as a separate multiplier rather
// than contributing to `factor`. Consequences named in the ruling: PowerSupply == 0 makes the whole
// expression 0 regardless of Min (pow(0, exponent) == 0 for any exponent > 0), and a Min == Max stat still
// responds to a PowerSupply term because the multiplier no longer has to move a degenerate interpolation.
public float Evaluate(IStatContext context)
{
var factor = 1f;
var powerMultiplier = 1f;
foreach (var term in Terms)
{
if (term.Source == StatSource.PowerSupply)
{
powerMultiplier *= context.PowerSupplyFactor(term.Exponent);
continue;
}
factor *= term.Source switch
{
// F6 (docs/stats-and-power-cut.md Cut 2 Soul pass): nothing ever calls Resolver.InvalidateSource
@ -662,27 +679,32 @@ public class PerformanceStat
StatSource.Heat => context.HeatFactor(term.Exponent),
StatSource.Durability => context.DurabilityFactor(term.Exponent),
StatSource.ConsumableProgress => context.ConsumableProgressFactor(term.Exponent),
StatSource.PowerSupply => context.PowerSupplyFactor(term.Exponent),
_ => throw new ArgumentOutOfRangeException(nameof(term.Source), term.Source, $"Unknown StatSource on {Min}-{Max} stat")
};
}
return lerp(Min, Max, factor) * context.ScaleModifier(this) + context.ConstantModifier(this);
return (lerp(Min, Max, factor) * context.ScaleModifier(this) + context.ConstantModifier(this)) * powerMultiplier;
}
}
// Cut 1's loud refusal: the heat-response shape (min, max, optimum, plateau width) must describe a coherent
// range. Runs at catalog load (AetheriaStores.Open) and at every catalog write that goes through
// CultRecordRefs.Upsert (AetheriaStores.cs). That is not, in fact, every catalog write in this repository: two
// named holes exist, not one.
// CultRecordRefs.Upsert (AetheriaStores.cs). That is not, in fact, every catalog write in this repository. F7
// (docs/stats-and-power-cut.md, Soul pass 2026-09-19) found four holes where the two named here left off, not
// one:
// - CultCache Studio's generic document editor writes straight through CultCache, bypassing Upsert, and Studio
// exposes no per-document validation hook to attach to yet.
// - tools/AetherDb/Program.cs's Dangling command (:271-274) lands its repaired records through
// `db.Cache.Commit(batch => batch.Upsert(document.GetType(), document, key))` -- CultCache's own batch API,
// keyed explicitly to preserve the record's existing identity, not the validating extension method above. A
// dangling-ref fixup can therefore land an EquippableItemData or ConsumableItemData with an invalid heat
// response or an unresolvable stat modifier reference, and `Open` would refuse it only the next time someone
// reopens the file, not at the moment `apply` writes it. Both gaps are named here rather than silently assumed
// closed.
// exposes no per-document validation hook to attach to yet. Still open -- Studio is not this repository's to
// patch from here.
// - tools/AetherDb/Program.cs's Dangling, ShieldMigrate and BrownoutMigrate commands each repair existing
// records in place and so cannot go through Upsert (its cache.UpsertAsync(document) does not accept a
// caller-supplied key -- see AetheriaStores.cs's own Validate comment); each lands its changes through
// `db.Cache.Commit(batch => batch.Upsert(document.GetType(), document, key))`, CultCache's own batch API,
// keyed explicitly to preserve the record's existing identity. `brownout-migrate apply` is how the shipped
// catalog's PowerSupply terms were actually written (Cut 7), and all three now call
// `AetheriaStores.Validate(document)` immediately before that commit -- the same checks Upsert runs, just
// invoked directly since the identity-preserving write path cannot call Upsert itself. Closed: a dangling-ref
// fixup, a shield migration or a brownout migration can no longer land an EquippableItemData or
// ConsumableItemData with an invalid heat response, an unresolvable stat modifier reference, or a power
// request that depends on its own supply.
public static class StatValidation
{
public static void ValidateHeatResponse(EquippableItemData data)
@ -785,24 +807,47 @@ public static class StatValidation
// 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.
// never hands the bus a number it invented on the spot -- each implementation evaluates a fixed set of
// PerformanceStat fields on its own BehaviorData. Those fields are a compile-time fact about the C# type, not
// something authored per catalog instance, so they are 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.
//
// F6 (docs/stats-and-power-cut.md, Soul pass 2026-09-19): this used to name only each consumer's top-level
// request field ("Thruster reads EnergyUsage, EnergyDraw reads EnergyDraw, and so on") on the premise that
// PowerRequest reads exactly one PerformanceStat. That premise was false for five of the eight consumers --
// Radiator.PowerRequest also reads PumpedHeat and WasteHeat (to decide whether the pump can even run this
// tick), AetherDrive.PowerRequest also reads Torque, LambdaMultiplier, MaximumRpm and PassiveCoupling (its
// whole rotor spin-up arithmetic), Shield.PowerRequest (via RefreshReserve) also reads RefillDuration and
// RestoreDuration, InstantWeapon.PowerRequest (via RefreshInputCapacitor) also reads Cooldown and Count, and
// Sensor.PowerRequest (via RefreshInputCapacitor) also reads PingCooldown. Every one of those was free to
// carry a PowerSupply term and sail straight past ValidateNoPowerSupplyOnRequest below, corrupting the
// request it feeds exactly the way the rule exists to forbid. Audited directly against each PowerRequest
// method's own body (and RefreshReserve/RefreshInputCapacitor, which PowerRequest calls into) rather than
// guessed from the behaviour's public surface.
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(InstantWeaponData), nameof(InstantWeaponData.Cooldown)),
(typeof(InstantWeaponData), nameof(InstantWeaponData.Count)),
(typeof(SensorData), nameof(SensorData.PingEnergy)),
(typeof(SensorData), nameof(SensorData.PingCooldown)),
(typeof(ShieldData), nameof(ShieldData.Capacity)),
(typeof(ShieldData), nameof(ShieldData.RefillDuration)),
(typeof(ShieldData), nameof(ShieldData.RestoreDuration)),
(typeof(RadiatorData), nameof(RadiatorData.EnergyUsage)),
(typeof(RadiatorData), nameof(RadiatorData.PumpedHeat)),
(typeof(RadiatorData), nameof(RadiatorData.WasteHeat)),
(typeof(AetherDriveData), nameof(AetherDriveData.EnergyDraw)),
(typeof(AetherDriveData), nameof(AetherDriveData.Torque)),
(typeof(AetherDriveData), nameof(AetherDriveData.LambdaMultiplier)),
(typeof(AetherDriveData), nameof(AetherDriveData.MaximumRpm)),
(typeof(AetherDriveData), nameof(AetherDriveData.PassiveCoupling)),
};
// The direct half of the rule: a request stat's own declared Terms must not name PowerSupply. Runs wherever