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:
commit
cd47eefe9e
21 changed files with 667 additions and 108 deletions
|
|
@ -76,10 +76,19 @@ public class ConstantLaser : MonoBehaviour
|
|||
var shield = hit.collider.GetComponent<ShieldManager>();
|
||||
if (shield)
|
||||
{
|
||||
if (!(shield.Entity.Shield != null && shield.Entity.Shield.Item.Active.Value && shield.Entity.Shield.CanTakeHit(DamageType, Damage))) continue;
|
||||
var shieldBehavior = shield.Entity.Shield;
|
||||
var shieldActive = shieldBehavior != null && shieldBehavior.Item.Active.Value;
|
||||
var shieldAbsorbs = shieldActive && shieldBehavior.CanTakeHit(DamageType, Damage);
|
||||
// F5 (docs/stats-and-power-cut.md, Soul pass 2026-09-19): CanTakeHit is a pure query now -- read
|
||||
// once into shieldAbsorbs, and break the shield exactly once, right here, at the point this hit
|
||||
// is actually decided to route past it (to the hull collider RaycastAll returns further down
|
||||
// this same loop) instead of being absorbed. The hull branch below re-queries CanTakeHit safely
|
||||
// -- it is side-effect-free -- and will see Broken already true.
|
||||
if (shieldActive && !shieldAbsorbs) shieldBehavior.Break();
|
||||
if (!shieldAbsorbs) continue;
|
||||
if (shield.Entity != SourceEntity)
|
||||
{
|
||||
shield.Entity.Shield.TakeHit(DamageType, Damage);
|
||||
shieldBehavior.TakeHit(DamageType, Damage);
|
||||
shield.ShowHit(hit.point, sqrt(Damage));
|
||||
LineRenderer.SetPosition(1, hit.point);
|
||||
hitFound = true;
|
||||
|
|
|
|||
|
|
@ -155,10 +155,19 @@ public class GuidedProjectile : MonoBehaviour
|
|||
var shield = hit.collider.GetComponent<ShieldManager>();
|
||||
if (shield)
|
||||
{
|
||||
if (!(shield.Entity.Shield != null && shield.Entity.Shield.Item.Active.Value && shield.Entity.Shield.CanTakeHit(DamageType, Damage))) continue;
|
||||
var shieldBehavior = shield.Entity.Shield;
|
||||
var shieldActive = shieldBehavior != null && shieldBehavior.Item.Active.Value;
|
||||
var shieldAbsorbs = shieldActive && shieldBehavior.CanTakeHit(DamageType, Damage);
|
||||
// F5 (docs/stats-and-power-cut.md, Soul pass 2026-09-19): CanTakeHit is a pure query now --
|
||||
// read once into shieldAbsorbs, and break the shield exactly once, right here, at the point
|
||||
// this hit is actually decided to route past it (to the hull collider RaycastAll returns
|
||||
// further down this same loop) instead of being absorbed. The hull branch below re-queries
|
||||
// CanTakeHit safely -- it is side-effect-free -- and will see Broken already true.
|
||||
if (shieldActive && !shieldAbsorbs) shieldBehavior.Break();
|
||||
if (!shieldAbsorbs) continue;
|
||||
if (shield.Entity != SourceEntity)
|
||||
{
|
||||
shield.Entity.Shield.TakeHit(DamageType, Damage);
|
||||
shieldBehavior.TakeHit(DamageType, Damage);
|
||||
shield.ShowHit(hit.point, sqrt(Damage));
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -42,10 +42,19 @@ public class Laser : MonoBehaviour
|
|||
var shield = hit.collider.GetComponent<ShieldManager>();
|
||||
if (shield)
|
||||
{
|
||||
if (!(shield.Entity.Shield != null && shield.Entity.Shield.Item.Active.Value && shield.Entity.Shield.CanTakeHit(DamageType, Damage))) continue;
|
||||
var shieldBehavior = shield.Entity.Shield;
|
||||
var shieldActive = shieldBehavior != null && shieldBehavior.Item.Active.Value;
|
||||
var shieldAbsorbs = shieldActive && shieldBehavior.CanTakeHit(DamageType, Damage);
|
||||
// F5 (docs/stats-and-power-cut.md, Soul pass 2026-09-19): CanTakeHit is a pure query now -- read
|
||||
// once into shieldAbsorbs, and break the shield exactly once, right here, at the point this hit
|
||||
// is actually decided to route past it (to the hull collider RaycastAll returns further down
|
||||
// this same loop) instead of being absorbed. The hull branch below re-queries CanTakeHit safely
|
||||
// -- it is side-effect-free -- and will see Broken already true.
|
||||
if (shieldActive && !shieldAbsorbs) shieldBehavior.Break();
|
||||
if (!shieldAbsorbs) continue;
|
||||
if (shield.Entity != SourceEntity)
|
||||
{
|
||||
shield.Entity.Shield.TakeHit(DamageType, Damage);
|
||||
shieldBehavior.TakeHit(DamageType, Damage);
|
||||
shield.ShowHit(hit.point, sqrt(Damage));
|
||||
LineRenderer.SetPosition(1, hit.point);
|
||||
hitFound = true;
|
||||
|
|
|
|||
|
|
@ -35,14 +35,26 @@ public class Lightning : MonoBehaviour
|
|||
foreach (var hit in hits)
|
||||
{
|
||||
var shield = hit.collider.GetComponent<ShieldManager>();
|
||||
if (shield && (shield.Entity.Shield != null && shield.Entity.Shield.Item.Active.Value && shield.Entity.Shield.CanTakeHit(DamageType, Damage)))
|
||||
if (shield)
|
||||
{
|
||||
if (shield.Entity == Source.Entity) continue;
|
||||
LightningCompute.OnLeaderComplete = () =>
|
||||
var shieldBehavior = shield.Entity.Shield;
|
||||
var shieldActive = shieldBehavior != null && shieldBehavior.Item.Active.Value;
|
||||
var shieldAbsorbs = shieldActive && shieldBehavior.CanTakeHit(DamageType, Damage);
|
||||
// F5 (docs/stats-and-power-cut.md, Soul pass 2026-09-19): CanTakeHit is a pure query now -- read
|
||||
// once into shieldAbsorbs, and break the shield exactly once, right here, at the point this hit
|
||||
// is actually decided to route past it (to the hull collider SphereCastAll returns as a
|
||||
// separate entry further down this same loop) instead of being absorbed. The hull branch below
|
||||
// re-queries CanTakeHit safely -- it is side-effect-free -- and will see Broken already true.
|
||||
if (shieldActive && !shieldAbsorbs) shieldBehavior.Break();
|
||||
if (shieldAbsorbs)
|
||||
{
|
||||
shield.Entity.Shield.TakeHit(DamageType, Damage);
|
||||
shield.ShowHit(hit.point, sqrt(Damage));
|
||||
};
|
||||
if (shield.Entity == Source.Entity) continue;
|
||||
LightningCompute.OnLeaderComplete = () =>
|
||||
{
|
||||
shieldBehavior.TakeHit(DamageType, Damage);
|
||||
shield.ShowHit(hit.point, sqrt(Damage));
|
||||
};
|
||||
}
|
||||
}
|
||||
var hull = hit.collider.GetComponent<HullCollider>();
|
||||
if (hull && !(hull.Entity.Shield != null && hull.Entity.Shield.Item.Active.Value && hull.Entity.Shield.CanTakeHit(DamageType, Damage)))
|
||||
|
|
|
|||
|
|
@ -92,10 +92,22 @@ public class Mine : MonoBehaviour
|
|||
foreach (var collider in Physics.OverlapSphere(position, BlastRange, 1 | (1 << 17)))
|
||||
{
|
||||
var shield = collider.GetComponent<ShieldManager>();
|
||||
if (shield && (shield.Entity.Shield != null && shield.Entity.Shield.Item.Active.Value && shield.Entity.Shield.CanTakeHit(DamageType, Damage)))
|
||||
if (shield)
|
||||
{
|
||||
shield.Entity.Shield.TakeHit(DamageType, Damage);
|
||||
shield.ShowHit(position, sqrt(Damage));
|
||||
var shieldBehavior = shield.Entity.Shield;
|
||||
var shieldActive = shieldBehavior != null && shieldBehavior.Item.Active.Value;
|
||||
var shieldAbsorbs = shieldActive && shieldBehavior.CanTakeHit(DamageType, Damage);
|
||||
// F5 (docs/stats-and-power-cut.md, Soul pass 2026-09-19): CanTakeHit is a pure query now -- read
|
||||
// once into shieldAbsorbs, and break the shield exactly once, right here, at the point this hit
|
||||
// is actually decided to route past it (to the hull collider OverlapSphere returns as a
|
||||
// separate entry in this same loop) instead of being absorbed. The hull branch below re-queries
|
||||
// CanTakeHit safely -- it is side-effect-free -- and will see Broken already true.
|
||||
if (shieldActive && !shieldAbsorbs) shieldBehavior.Break();
|
||||
if (shieldAbsorbs)
|
||||
{
|
||||
shieldBehavior.TakeHit(DamageType, Damage);
|
||||
shield.ShowHit(position, sqrt(Damage));
|
||||
}
|
||||
}
|
||||
var hull = collider.GetComponent<HullCollider>();
|
||||
if (hull && !(hull.Entity.Shield != null && hull.Entity.Shield.Item.Active.Value && hull.Entity.Shield.CanTakeHit(DamageType, Damage)))
|
||||
|
|
|
|||
|
|
@ -61,10 +61,20 @@ public class Projectile : MonoBehaviour
|
|||
var hull = hit.collider.GetComponent<HullCollider>();
|
||||
if (shield)
|
||||
{
|
||||
if (!(shield.Entity.Shield != null && shield.Entity.Shield.Item.Active.Value && shield.Entity.Shield.CanTakeHit(DamageType, Damage))) continue;
|
||||
var shieldBehavior = shield.Entity.Shield;
|
||||
var shieldActive = shieldBehavior != null && shieldBehavior.Item.Active.Value;
|
||||
var shieldAbsorbs = shieldActive && shieldBehavior.CanTakeHit(DamageType, Damage);
|
||||
// F5 (docs/stats-and-power-cut.md, Soul pass 2026-09-19): CanTakeHit is a pure query now --
|
||||
// read once into shieldAbsorbs, and break the shield exactly once, right here, at the point
|
||||
// this hit is actually decided to route past it (to the hull collider RaycastAll returns
|
||||
// as a separate entry further down this same loop) instead of being absorbed. The hull
|
||||
// branch below re-queries CanTakeHit safely -- it is side-effect-free -- and will see Broken
|
||||
// already true.
|
||||
if (shieldActive && !shieldAbsorbs) shieldBehavior.Break();
|
||||
if (!shieldAbsorbs) continue;
|
||||
if (shield.Entity != SourceEntity)
|
||||
{
|
||||
shield.Entity.Shield.TakeHit(DamageType, Damage*DirectHitDamageMultiplier);
|
||||
shieldBehavior.TakeHit(DamageType, Damage*DirectHitDamageMultiplier);
|
||||
shield.ShowHit(hit.point, sqrt(Damage * DirectHitDamageMultiplier));
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -72,12 +72,16 @@ public static class CultRecordRefs
|
|||
new CultRecordRef<T>(cache.TryGetHandle(document)?.Key ??
|
||||
throw new InvalidOperationException($"This cache holds no such {document.GetType().Name}."));
|
||||
|
||||
// Writes a document to its home store; the cache mints its key the first time. Every catalog write in this
|
||||
// repository (tools, tests, migration scripts) goes through this one function, so it is where R-heat's
|
||||
// validation closes the gap AetheriaStores.Open leaves: Open only checks what is already on disk when a
|
||||
// session starts, not what a session then writes. Validating here means an invalid heat response is refused
|
||||
// before the write ever reaches disk, not just the next time someone reopens the file.
|
||||
public static CultRecordRef<T> Upsert<T>(this CultCache cache, T document) where T : class
|
||||
// F7 (docs/stats-and-power-cut.md, Soul pass 2026-09-19): the validation body itself, pulled out of Upsert
|
||||
// below so it has exactly one owner. Every catalog write in this repository was supposed to go through
|
||||
// Upsert, but three of tools/AetherDb/Program.cs's own migration commands (Dangling, ShieldMigrate,
|
||||
// BrownoutMigrate) cannot: they repair an EXISTING record in place and must preserve its key, which Upsert's
|
||||
// own cache.UpsertAsync(document) does not support (Upsert mints or resolves a key from the document itself,
|
||||
// not from a caller-supplied one). Rather than leave those three writing raw and unvalidated -- as they did
|
||||
// before this fix -- they call this method directly, immediately before their own identity-preserving
|
||||
// batch.Upsert(document.GetType(), document, key) commit, so the same rules run either way. Takes `object`
|
||||
// (not a generic T) because that is what those three tools' own (Document, Key) change lists are typed as.
|
||||
public static void Validate(object document)
|
||||
{
|
||||
if (document is EquippableItemData data)
|
||||
{
|
||||
|
|
@ -92,6 +96,17 @@ public static class CultRecordRefs
|
|||
StatValidation.ValidateNoPowerSupplyOnRequest(consumable.Name, consumable.Behaviors);
|
||||
StatValidation.ValidateRoleUsage(consumable.Name, consumable.Roles, consumable.Behaviors);
|
||||
}
|
||||
}
|
||||
|
||||
// Writes a document to its home store; the cache mints its key the first time. Every catalog write in this
|
||||
// repository (tools, tests, migration scripts) goes through this one function, or through Validate above
|
||||
// directly when the caller must preserve an existing key (see Validate's own comment) -- so this is where
|
||||
// R-heat's validation closes the gap AetheriaStores.Open leaves: Open only checks what is already on disk
|
||||
// when a session starts, not what a session then writes. Validating here means an invalid heat response is
|
||||
// refused before the write ever reaches disk, not just the next time someone reopens the file.
|
||||
public static CultRecordRef<T> Upsert<T>(this CultCache cache, T document) where T : class
|
||||
{
|
||||
Validate(document);
|
||||
return new CultRecordRef<T>(cache.UpsertAsync(document).Result.Key);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -90,8 +90,10 @@ public class Radiator : Behavior, IAlwaysUpdatedBehavior, IInitializableBehavior
|
|||
// plain Evaluate() read, so a PumpedHeat stat carrying a PowerSupply term already pumps less under a
|
||||
// partial grant -- waste heat below is unaffected by the curve, so a starved radiator falls behind and
|
||||
// the ship heats up, which is the reduced-performance failure the ruling asks for instead of the pump
|
||||
// simply refusing to run. At true zero supply PumpedHeat itself resolves to 0 (pow(0, exponent) == 0),
|
||||
// so "produces nothing" already falls out of the curve below without a special case here.
|
||||
// simply refusing to run. F1 (docs/stats-and-power-cut.md, operator ruling 2026-09-19): PowerSupply is a
|
||||
// multiplier PerformanceStat.Evaluate applies to the whole resolved value, not a term blended into the
|
||||
// Min/Max interpolation, so at true zero supply PumpedHeat resolves to exactly 0 regardless of Min -- so
|
||||
// "produces nothing" already falls out of Evaluate() without a special case here.
|
||||
var pumpedHeat = PumpedHeat * max(itemTemperature - _data.TemperatureFloor, 0);
|
||||
|
||||
// Radiator temperature is below temperature floor, stop executing
|
||||
|
|
|
|||
|
|
@ -41,7 +41,7 @@ public class ShieldData : BehaviorData
|
|||
}
|
||||
}
|
||||
|
||||
public class Shield : Behavior, IProgressBehavior, IPowerConsumer
|
||||
public class Shield : Behavior, IProgressBehavior, IPowerConsumer, IAlwaysUpdatedBehavior
|
||||
{
|
||||
public float Efficiency { get; private set; }
|
||||
public float EnergyUsage { get; private set; }
|
||||
|
|
@ -56,9 +56,11 @@ public class Shield : Behavior, IProgressBehavior, IPowerConsumer
|
|||
//
|
||||
// Operator's second ruling, same date, on the fork the first ruling left open: the breaking hit passes
|
||||
// through in full -- no partial absorption. The reserve does not spend the portion it could have covered;
|
||||
// TakeHit is never called for this hit at all (CanTakeHit's false branch below is the caller's sole signal
|
||||
// to route the whole hit to the hull instead -- see every caller under Assets/Scripts/Gameplay/Weapons).
|
||||
// The break is a distinct event the player feels, not a discount on an overkill hit.
|
||||
// TakeHit is never called for this hit at all -- CanTakeHit's false return is the caller's signal to route
|
||||
// the whole hit to the hull instead, and Break() below (F5, Soul pass 2026-09-19) is what the caller invokes
|
||||
// at that same point to actually break the shield, since CanTakeHit itself is now a pure query (see every
|
||||
// caller under Assets/Scripts/Gameplay/Weapons). The break is a distinct event the player feels, not a
|
||||
// discount on an overkill hit.
|
||||
//
|
||||
// A second question the operator asked me to settle and state: what happens to whatever charge the reserve
|
||||
// held at the moment it broke. Chosen: emptied by the break, not left at its pre-hit charge. A reserve that
|
||||
|
|
@ -116,36 +118,62 @@ public class Shield : Behavior, IProgressBehavior, IPowerConsumer
|
|||
Capacity = Evaluate(_data.Capacity);
|
||||
RefillDuration = Evaluate(_data.RefillDuration);
|
||||
RestoreDuration = Evaluate(_data.RestoreDuration);
|
||||
// This tick's grant, read back via Item.PowerSupply -- Item is non-null here because a null-Item
|
||||
// instance never reaches PowerBus.Step (see CanTakeHit/TakeHit) and so never accrues charge.
|
||||
if (Item != null)
|
||||
{
|
||||
_reserve.AddCharge(_reserve.RequestedFill(dt) * Item.PowerSupply);
|
||||
// Restored to full on the restore duration: back up.
|
||||
if (Broken && _reserve.Charge >= _reserve.Capacity - .01f) Broken = false;
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
// F4 (docs/stats-and-power-cut.md, Soul pass 2026-09-19): the reserve's own recharge, and clearing Broken
|
||||
// once it is full, used to live in Execute above -- which Entity.Update only calls while Item.Active is
|
||||
// true. A hit that breaks the shield usually also cooks it (AddHeat in TakeHit below), which can knock the
|
||||
// item thermally offline in the same moment it breaks -- Execute then stops running for as long as the item
|
||||
// stays offline, so the shield could never come back no matter how long RestoreDuration allows for. Restoring
|
||||
// must not depend on whether the item is currently Active, so it moved to IAlwaysUpdatedBehavior.Update,
|
||||
// which Entity.Update calls every tick unconditionally (Cooldown.cs already relies on the same interface for
|
||||
// exactly this reason -- see Entity.cs's own alwaysUpdatedBehavior loop, outside the `if (Active.Value)`
|
||||
// block Execute lives in). PowerBus (F2) only bills and refreshes Item.PowerSupply while the item is Active,
|
||||
// so while offline this keeps recharging at the last grant it actually received rather than freezing dead or
|
||||
// fabricating a fresh one -- the punish window still runs down on its own clock instead of being held
|
||||
// hostage by an unrelated shutdown.
|
||||
public void Update(float dt)
|
||||
{
|
||||
// Item is non-null-checked the same way Execute's old version was: a null-Item (consumable-hosted)
|
||||
// instance never reaches PowerBus.Step and so never accrues charge -- see CanTakeHit's own comment.
|
||||
if (Item == null) return;
|
||||
RefreshReserve();
|
||||
_reserve.AddCharge(_reserve.RequestedFill(dt) * Item.PowerSupply);
|
||||
// Restored to full on the restore duration: back up.
|
||||
if (Broken && _reserve.Charge >= _reserve.Capacity - .01f) Broken = false;
|
||||
}
|
||||
|
||||
// Cut 4 (docs/stats-and-power-cut.md, Cut 4): a hit taken, not a chosen activation, drawing from the
|
||||
// shield's own continuous reserve instead of the entity's shared bus capacitors -- closing Cut 3's named,
|
||||
// temporary exception. A consumable-hosted instance (Item == null) has no PowerBus entry, so nothing would
|
||||
// ever fill this reserve; bypass it rather than starving such an instance forever.
|
||||
//
|
||||
// Operator ruling, 2026-09-19: a broken shield absorbs nothing (Broken short-circuits to false). A hit the
|
||||
// reserve cannot fully cover breaks the shield as a side effect of this same check -- see the Broken field
|
||||
// comment above for why that mutation lives here rather than in TakeHit. Every caller queries CanTakeHit
|
||||
// exactly once per hit and only calls TakeHit when it returns true (Assets/Scripts/Gameplay/Weapons/*), so
|
||||
// this is not a repeated-query hazard in practice. Draining the reserve on break (rather than leaving its
|
||||
// pre-hit charge, see the Broken field comment) happens here too, in the same atomic decision.
|
||||
// F5 (docs/stats-and-power-cut.md, Soul pass 2026-09-19): this used to mutate -- breaking the shield and
|
||||
// draining the reserve -- as a side effect of the query itself, on the premise that "every caller queries
|
||||
// CanTakeHit exactly once per hit." That premise was false: RaycastAll returns both the shield collider and
|
||||
// the hull collider for one shot, and every caller under Assets/Scripts/Gameplay/Weapons queries this twice
|
||||
// per hit (once deciding whether the shield collider absorbs it, once deciding whether the hull collider is
|
||||
// exposed). A caller must be able to ask without consequence, so this is now a pure read: Broken and the
|
||||
// reserve's own CanSpend, nothing more. See Break() below for where the mutation moved.
|
||||
public bool CanTakeHit(DamageType type, float damage)
|
||||
{
|
||||
if (Item == null) return true;
|
||||
if (Broken) return false;
|
||||
if (_reserve.CanSpend(damage * EnergyUsage)) return true;
|
||||
return _reserve.CanSpend(damage * EnergyUsage);
|
||||
}
|
||||
|
||||
// F5: the mutation CanTakeHit used to perform when it returned false, now a caller invokes explicitly at the
|
||||
// one point damage is actually applied -- specifically, the point a caller decides to route a hit past this
|
||||
// shield (to the hull) instead of absorbing it, which is also the only point that decision is made at all,
|
||||
// so this is naturally called at most once per hit despite the double CanTakeHit query above. Idempotent
|
||||
// (a no-op once Broken is already true) as a defense-in-depth against a caller structure that cannot
|
||||
// guarantee single-call discipline, not as licence to call it more than once on purpose.
|
||||
public void Break()
|
||||
{
|
||||
if (Item == null || Broken) return;
|
||||
Broken = true;
|
||||
_reserve.AddCharge(-_reserve.Charge); // emptied by the break, not left at its pre-hit charge
|
||||
return false;
|
||||
}
|
||||
|
||||
public void TakeHit(DamageType type, float damage)
|
||||
|
|
|
|||
|
|
@ -1371,9 +1371,11 @@ 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;
|
||||
// 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
|
||||
// Cut 6 (docs/stats-and-power-cut.md), applied per F1 (operator ruling 2026-09-19): PerformanceStat.Evaluate
|
||||
// multiplies this straight onto the resolved value rather than blending it into the Min/Max interpolation, so
|
||||
// a stat with a PowerSupply term is exactly unchanged at full supply (pow(1, exponent) == 1 regardless of
|
||||
// exponent) and exactly zero at zero supply (pow(0, exponent) == 0 for any exponent > 0), no matter what Min
|
||||
// is -- "a continuous consumer that degrades instead of stopping" down to genuinely nothing. 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);
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -52,16 +52,25 @@ public class PowerBus
|
|||
// TierGrantRatio or the item's own PowerSupply for that.
|
||||
public float GrantRatio { get; private set; } = 1f;
|
||||
|
||||
// F3 (docs/stats-and-power-cut.md, Soul pass 2026-09-19): the sentinel AllocateTiers writes for a tier with no
|
||||
// consumers equipped in it at all. Distinct from 1f -- an empty tier has not been "fully supplied," there was
|
||||
// nothing to supply, and reporting it as 1f is exactly the fully-satisfied signal a UI is meant to read as
|
||||
// "this tier is fine," on a ship that may be fully blacked out. A reader must treat NaN as "nothing to report
|
||||
// here," not as satisfied or starved.
|
||||
public const float NoDemand = float.NaN;
|
||||
|
||||
// Cut 5: this tick's within-tier grant ratio, indexed by PowerTiers.Critical..Utility. 1f means that tier's
|
||||
// demand was fully met (including a tier with no demand at all, per the array's own default-init); anything
|
||||
// below 1f is exactly the "starved" state a future UI (Cut 8) reads instead of reconstructing it from
|
||||
// demand was fully met; NoDemand (NaN) means the tier has no consumers at all (see NoDemand above); anything
|
||||
// else below 1f is exactly the "starved" state a future UI (Cut 8) reads instead of reconstructing it from
|
||||
// per-item PowerSupply. A tier below a starved one is always 0f -- §1.3's "a tier boundary does not leak".
|
||||
public float[] TierGrantRatio { get; } = InitFullTiers();
|
||||
|
||||
// Before the bus has ever run a Step, there is no grant information at all yet -- not "fully supplied,"
|
||||
// "nothing to report" (NoDemand), same as an empty tier reads once Step does run.
|
||||
private static float[] InitFullTiers()
|
||||
{
|
||||
var ratios = new float[PowerTiers.Count];
|
||||
for (var i = 0; i < ratios.Length; i++) ratios[i] = 1f;
|
||||
for (var i = 0; i < ratios.Length; i++) ratios[i] = NoDemand;
|
||||
return ratios;
|
||||
}
|
||||
|
||||
|
|
@ -93,14 +102,24 @@ public class PowerBus
|
|||
{
|
||||
switch (behavior)
|
||||
{
|
||||
case Reactor reactor when reactor.Item.Online.Value:
|
||||
// F2 (docs/stats-and-power-cut.md, Soul pass 2026-09-19): this must agree with the one predicate
|
||||
// Entity.Update actually gates Execute on (Active.Value, Entity.cs -- Enabled && Online), in both
|
||||
// directions. Gating on Online alone let a reactor the player switched off (Enabled = false, so
|
||||
// Active is false but Online stays true) keep generating here even though Reactor.Execute -- the
|
||||
// behaviour that produces the heat receipt -- never runs for it: free, heat-free power. Capacitor
|
||||
// has no IPowerConsumer/generation role and is unconditionally a store regardless of its own
|
||||
// item's state (mirrors pre-existing behaviour; nothing in the ruling asks for that to change).
|
||||
case Reactor reactor when item.Active.Value:
|
||||
reactors.Add(reactor);
|
||||
break;
|
||||
case Capacitor capacitor:
|
||||
capacitors.Add(capacitor);
|
||||
break;
|
||||
}
|
||||
if (behavior is IPowerConsumer consumer)
|
||||
// The other direction of the same rule: a consumer switched off or shut down (Active.Value false)
|
||||
// never runs its Execute, so it must never be billed either -- otherwise a disabled item's demand
|
||||
// starves every live consumer and drains the capacitors for energy nobody spends.
|
||||
if (behavior is IPowerConsumer consumer && item.Active.Value)
|
||||
{
|
||||
// Cut 5 (docs/stats-and-power-cut.md §1.3): the item's own stored choice wins once it has one;
|
||||
// PowerTiers.Unassigned only survives past EquippedItem's constructor for a consumer added to
|
||||
|
|
@ -147,14 +166,28 @@ public class PowerBus
|
|||
private void AllocateTiers(List<Draw> draws)
|
||||
{
|
||||
var tierDemand = new float[PowerTiers.Count];
|
||||
foreach (var draw in draws) tierDemand[draw.Tier] += draw.Request;
|
||||
// F3 (docs/stats-and-power-cut.md, Soul pass 2026-09-19): demand alone cannot tell "no consumer is
|
||||
// equipped in this tier" from "a real consumer is equipped here and its own request happens to be 0 (or
|
||||
// effectively 0) this tick" -- both sum to the same tierDemand. Only the first is actually empty; the
|
||||
// second still has an item reading this tier's ratio back as its own PowerSupply, and that item is owed
|
||||
// a real answer (trivially 1: it asked for nothing, so nothing it could be short of), not NoDemand.
|
||||
var tierHasConsumer = new bool[PowerTiers.Count];
|
||||
foreach (var draw in draws) { tierDemand[draw.Tier] += draw.Request; tierHasConsumer[draw.Tier] = true; }
|
||||
|
||||
var remaining = TotalGrant;
|
||||
var ratios = TierGrantRatio;
|
||||
for (var tier = 0; tier < PowerTiers.Count; tier++)
|
||||
{
|
||||
// A tier with genuinely no consumers is not satisfied, it is empty: report NoDemand, not the "fully
|
||||
// supplied" signal 1f. Nothing to subtract from `remaining` either way.
|
||||
if (!tierHasConsumer[tier]) { ratios[tier] = NoDemand; continue; }
|
||||
var demand = tierDemand[tier];
|
||||
if (demand <= 1e-4f) { ratios[tier] = 1f; continue; }
|
||||
// This used to short-circuit ANY demand at or below 1e-4 the same way as a genuinely empty tier,
|
||||
// which both reported a grant nobody made (an item reading ratio 1 while the ship generates nothing,
|
||||
// down to Soul's own probe at 5e-5) and leaked that tier's unsubtracted share to every lower one. A
|
||||
// consumer requesting truly nothing (demand <= 0f exactly) is trivially fully met; anything above
|
||||
// that, however tiny, is rationed like any other tier.
|
||||
if (demand <= 0f) { ratios[tier] = 1f; continue; }
|
||||
var grant = min(demand, remaining);
|
||||
ratios[tier] = saturate(grant / demand);
|
||||
remaining -= grant;
|
||||
|
|
|
|||
Loading…
Reference in a new issue