Aetheria/Assets/Scripts/ServerShared/PowerBus.cs

265 lines
15 KiB
C#
Raw Permalink Normal View History

/* This Source Code Form is subject to the terms of the Mozilla Public
* License, v. 2.0. If a copy of the MPL was not distributed with this
* file, You can obtain one at https://mozilla.org/MPL/2.0/. */
using System.Collections.Generic;
using System.Linq;
using static CultMath.math;
// Cut 3 (docs/stats-and-power-cut.md §1.2): the sole owner of every power grant for one Entity, stepped once per
// tick from Entity.Update, before any equipped item's Behaviors execute. It replaces
// Entity.TryConsumeEnergy/CanConsumeEnergy: at HEAD every draw succeeded as long as one reactor was online, and
// any shortfall was silently taxed onto the reactor as heat instead of ever being refused (§0.5). The bus makes
// the ceiling real: demand beyond what this tick's generation and stored capacitor charge can cover goes unmet.
Cut 5: priority tiers replace PowerBus's single shared GrantRatio PowerTiers.cs names five tiers (Critical/High/Medium/Low/Utility) and their per-behaviour-kind defaults: Radiator is Critical (the closest thing this game has to life support -- a starved radiator cascades into wear and shutdown across the whole hull), Shield is High, Thruster/AetherDrive are Medium, ConstantWeapon/InstantWeapon are Low, and Sensor/EnergyDraw are Utility. IPowerConsumer.DefaultPowerTier lets each behaviour own its own default. EquippableItem.PowerTier (Key 12) is the stored, player-choosable tier on the unit rather than the design; EquippedItem's constructor seeds a kind default the first time a unit is equipped, only when the field is still PowerTiers.Unassigned, and never again -- a tier is a stored choice, not something the bus re-derives. PowerBus.AllocateTiers walks tiers lowest-number-first: a tier is granted in full before the next tier sees anything left, an empty tier passes its whole share down, and within a tier every consumer rations by the same grant/demand fraction so unequal requests still divide proportionally. A new TierGrantRatio[] exposes each tier's own ratio for a future UI (Cut 8) to read starvation from directly, instead of reconstructing it from every item's PowerSupply. Deletes EquippedItem.SortPosition, IOrderedBehavior and Reactor.Order (its only implementer): the bus already steps before any equipment's Execute, so equip/behaviour order never decided who got fed even before this cut, and nothing else needed the interface (subject to the map's O3 operator check). docs/stats-and-power-cut.md Cut 5 / §1.3. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-19 02:58:38 +02:00
//
// Cut 5 (docs/stats-and-power-cut.md §1.3) replaces the single shared fraction with priority tiers
// (PowerTiers.cs): the total this tick's supply can cover (TotalGrant, unchanged by tiers -- see below) is
// walked tier by tier, lowest number first. A tier is fed in full before the next tier sees anything left; a
// tier with no demand simply passes its whole share down; within a tier, every consumer's own request is
// rationed by the same fraction, so two requests of unequal size still divide proportionally. This is the death
// of the old "whoever equipped first drains the capacitors first" hidden priority (§0.5), replaced with an
// authored one instead of no priority at all.
//
// Bus capacitors are charged and drained only here (§0b): Reactor no longer touches them, and neither does any
// IPowerConsumer. The four instant draws the cut map names (a burst, a shot, a ping, a hit taken) are
// IPowerConsumers too as of Cut 4: each spends from its own InputCapacitor (InstantWeapon, Sensor, Shield),
// never from these bus capacitors directly. Entity.TrySpendCapacitorCharge/CanSpendCapacitorCharge, Cut 3's
// named, temporary exception, died with their last caller.
public class PowerBus
{
private readonly Entity _entity;
public PowerBus(Entity entity) => _entity = entity;
// Every figure below is this tick's, overwritten wholesale by the next Step -- nothing here is persisted
// (§0.6); a fresh Entity starts with the defaults below and they read as "fully supplied, nothing drawn"
// until the first Step runs.
public float TotalGeneration { get; private set; }
public float TotalDemand { get; private set; }
Cut 5: priority tiers replace PowerBus's single shared GrantRatio PowerTiers.cs names five tiers (Critical/High/Medium/Low/Utility) and their per-behaviour-kind defaults: Radiator is Critical (the closest thing this game has to life support -- a starved radiator cascades into wear and shutdown across the whole hull), Shield is High, Thruster/AetherDrive are Medium, ConstantWeapon/InstantWeapon are Low, and Sensor/EnergyDraw are Utility. IPowerConsumer.DefaultPowerTier lets each behaviour own its own default. EquippableItem.PowerTier (Key 12) is the stored, player-choosable tier on the unit rather than the design; EquippedItem's constructor seeds a kind default the first time a unit is equipped, only when the field is still PowerTiers.Unassigned, and never again -- a tier is a stored choice, not something the bus re-derives. PowerBus.AllocateTiers walks tiers lowest-number-first: a tier is granted in full before the next tier sees anything left, an empty tier passes its whole share down, and within a tier every consumer rations by the same grant/demand fraction so unequal requests still divide proportionally. A new TierGrantRatio[] exposes each tier's own ratio for a future UI (Cut 8) to read starvation from directly, instead of reconstructing it from every item's PowerSupply. Deletes EquippedItem.SortPosition, IOrderedBehavior and Reactor.Order (its only implementer): the bus already steps before any equipment's Execute, so equip/behaviour order never decided who got fed even before this cut, and nothing else needed the interface (subject to the map's O3 operator check). docs/stats-and-power-cut.md Cut 5 / §1.3. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-19 02:58:38 +02:00
// Energy actually delivered this tick, summed across every tier: generation first, then whatever stored
// capacitor charge covers the rest, capped at demand. Equal to TotalDemand whenever supply covers it; equal
// to (generation + available charge) otherwise -- never more, which TryConsumeEnergy could never promise (a
// reactor always paid the remainder as heat, so overdraw was invisible at this level). Which consumers that
// total actually reaches is what tiering (below) decides; this figure is unaffected by tiers -- the ceiling
// is a property of supply, not of priority.
public float TotalGrant { get; private set; }
Cut 5: priority tiers replace PowerBus's single shared GrantRatio PowerTiers.cs names five tiers (Critical/High/Medium/Low/Utility) and their per-behaviour-kind defaults: Radiator is Critical (the closest thing this game has to life support -- a starved radiator cascades into wear and shutdown across the whole hull), Shield is High, Thruster/AetherDrive are Medium, ConstantWeapon/InstantWeapon are Low, and Sensor/EnergyDraw are Utility. IPowerConsumer.DefaultPowerTier lets each behaviour own its own default. EquippableItem.PowerTier (Key 12) is the stored, player-choosable tier on the unit rather than the design; EquippedItem's constructor seeds a kind default the first time a unit is equipped, only when the field is still PowerTiers.Unassigned, and never again -- a tier is a stored choice, not something the bus re-derives. PowerBus.AllocateTiers walks tiers lowest-number-first: a tier is granted in full before the next tier sees anything left, an empty tier passes its whole share down, and within a tier every consumer rations by the same grant/demand fraction so unequal requests still divide proportionally. A new TierGrantRatio[] exposes each tier's own ratio for a future UI (Cut 8) to read starvation from directly, instead of reconstructing it from every item's PowerSupply. Deletes EquippedItem.SortPosition, IOrderedBehavior and Reactor.Order (its only implementer): the bus already steps before any equipment's Execute, so equip/behaviour order never decided who got fed even before this cut, and nothing else needed the interface (subject to the map's O3 operator check). docs/stats-and-power-cut.md Cut 5 / §1.3. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-19 02:58:38 +02:00
// Aggregate fraction of all demand granted this tick (TotalGrant / TotalDemand) -- a fleet-wide summary, not
// what any one consumer necessarily received. Still written onto every consuming EquippedItem.PowerSupply
// when every tier in play happens to land on the same ratio (single-tier loadouts, the common case in the
// Cut 3/4 test fixtures), but a multi-tier shortfall gives different tiers different ratios -- read
// TierGrantRatio or the item's own PowerSupply for that.
public float GrantRatio { get; private set; } = 1f;
F3: an empty tier is not a satisfied tier, and sub-epsilon demand pays its way AllocateTiers treated any tier whose summed demand was at or below 1e-4 as "fully met" (ratio 1) without ever subtracting it from `remaining`. Two bugs: a tier with no consumers equipped in it at all read as 1f, the exact "fully supplied" signal a UI is meant to trust, even on a fully blacked-out ship (measured: Critical 0 demand reads "fine" right next to a genuinely starved High); and a tier with a real but tiny demand (down to 5e-5) was granted out of nothing and never charged against the ship's actual generation, letting a lower tier get overfed by the sliver the higher one was never billed for. AllocateTiers now tracks which tiers actually have a consumer separately from their summed demand. A tier with no consumer reports the new PowerBus.NoDemand sentinel (NaN), never 1f. A tier with a consumer whose own request truly is zero this tick is trivially satisfied (ratio 1, nothing to be short of). Any other demand, however small, goes through the same grant/remaining arithmetic as every other tier. Pinned in tests/Aetheria.Shared.Tests/PowerTierTests.cs: SubEpsilonDemandGetsNoGrantAtTotalBlackout, HigherTierSubEpsilonDemandIsStillSubtractedFromRemaining, and EmptyTierAtBlackoutReportsNoDemandNotFullySupplied -- all fail under the reverted (<=1e-4 free-pass) mutation. Updated ATierWithNoDemandPassesItsShareDown's own empty-tier assertion, which had pinned the bug itself ("empty tiers read as fully met"). Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-19 05:15:30 +02:00
// 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: priority tiers replace PowerBus's single shared GrantRatio PowerTiers.cs names five tiers (Critical/High/Medium/Low/Utility) and their per-behaviour-kind defaults: Radiator is Critical (the closest thing this game has to life support -- a starved radiator cascades into wear and shutdown across the whole hull), Shield is High, Thruster/AetherDrive are Medium, ConstantWeapon/InstantWeapon are Low, and Sensor/EnergyDraw are Utility. IPowerConsumer.DefaultPowerTier lets each behaviour own its own default. EquippableItem.PowerTier (Key 12) is the stored, player-choosable tier on the unit rather than the design; EquippedItem's constructor seeds a kind default the first time a unit is equipped, only when the field is still PowerTiers.Unassigned, and never again -- a tier is a stored choice, not something the bus re-derives. PowerBus.AllocateTiers walks tiers lowest-number-first: a tier is granted in full before the next tier sees anything left, an empty tier passes its whole share down, and within a tier every consumer rations by the same grant/demand fraction so unequal requests still divide proportionally. A new TierGrantRatio[] exposes each tier's own ratio for a future UI (Cut 8) to read starvation from directly, instead of reconstructing it from every item's PowerSupply. Deletes EquippedItem.SortPosition, IOrderedBehavior and Reactor.Order (its only implementer): the bus already steps before any equipment's Execute, so equip/behaviour order never decided who got fed even before this cut, and nothing else needed the interface (subject to the map's O3 operator check). docs/stats-and-power-cut.md Cut 5 / §1.3. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-19 02:58:38 +02:00
// Cut 5: this tick's within-tier grant ratio, indexed by PowerTiers.Critical..Utility. 1f means that tier's
F3: an empty tier is not a satisfied tier, and sub-epsilon demand pays its way AllocateTiers treated any tier whose summed demand was at or below 1e-4 as "fully met" (ratio 1) without ever subtracting it from `remaining`. Two bugs: a tier with no consumers equipped in it at all read as 1f, the exact "fully supplied" signal a UI is meant to trust, even on a fully blacked-out ship (measured: Critical 0 demand reads "fine" right next to a genuinely starved High); and a tier with a real but tiny demand (down to 5e-5) was granted out of nothing and never charged against the ship's actual generation, letting a lower tier get overfed by the sliver the higher one was never billed for. AllocateTiers now tracks which tiers actually have a consumer separately from their summed demand. A tier with no consumer reports the new PowerBus.NoDemand sentinel (NaN), never 1f. A tier with a consumer whose own request truly is zero this tick is trivially satisfied (ratio 1, nothing to be short of). Any other demand, however small, goes through the same grant/remaining arithmetic as every other tier. Pinned in tests/Aetheria.Shared.Tests/PowerTierTests.cs: SubEpsilonDemandGetsNoGrantAtTotalBlackout, HigherTierSubEpsilonDemandIsStillSubtractedFromRemaining, and EmptyTierAtBlackoutReportsNoDemandNotFullySupplied -- all fail under the reverted (<=1e-4 free-pass) mutation. Updated ATierWithNoDemandPassesItsShareDown's own empty-tier assertion, which had pinned the bug itself ("empty tiers read as fully met"). Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-19 05:15:30 +02:00
// 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
Cut 5: priority tiers replace PowerBus's single shared GrantRatio PowerTiers.cs names five tiers (Critical/High/Medium/Low/Utility) and their per-behaviour-kind defaults: Radiator is Critical (the closest thing this game has to life support -- a starved radiator cascades into wear and shutdown across the whole hull), Shield is High, Thruster/AetherDrive are Medium, ConstantWeapon/InstantWeapon are Low, and Sensor/EnergyDraw are Utility. IPowerConsumer.DefaultPowerTier lets each behaviour own its own default. EquippableItem.PowerTier (Key 12) is the stored, player-choosable tier on the unit rather than the design; EquippedItem's constructor seeds a kind default the first time a unit is equipped, only when the field is still PowerTiers.Unassigned, and never again -- a tier is a stored choice, not something the bus re-derives. PowerBus.AllocateTiers walks tiers lowest-number-first: a tier is granted in full before the next tier sees anything left, an empty tier passes its whole share down, and within a tier every consumer rations by the same grant/demand fraction so unequal requests still divide proportionally. A new TierGrantRatio[] exposes each tier's own ratio for a future UI (Cut 8) to read starvation from directly, instead of reconstructing it from every item's PowerSupply. Deletes EquippedItem.SortPosition, IOrderedBehavior and Reactor.Order (its only implementer): the bus already steps before any equipment's Execute, so equip/behaviour order never decided who got fed even before this cut, and nothing else needed the interface (subject to the map's O3 operator check). docs/stats-and-power-cut.md Cut 5 / §1.3. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-19 02:58:38 +02:00
// 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();
F3: an empty tier is not a satisfied tier, and sub-epsilon demand pays its way AllocateTiers treated any tier whose summed demand was at or below 1e-4 as "fully met" (ratio 1) without ever subtracting it from `remaining`. Two bugs: a tier with no consumers equipped in it at all read as 1f, the exact "fully supplied" signal a UI is meant to trust, even on a fully blacked-out ship (measured: Critical 0 demand reads "fine" right next to a genuinely starved High); and a tier with a real but tiny demand (down to 5e-5) was granted out of nothing and never charged against the ship's actual generation, letting a lower tier get overfed by the sliver the higher one was never billed for. AllocateTiers now tracks which tiers actually have a consumer separately from their summed demand. A tier with no consumer reports the new PowerBus.NoDemand sentinel (NaN), never 1f. A tier with a consumer whose own request truly is zero this tick is trivially satisfied (ratio 1, nothing to be short of). Any other demand, however small, goes through the same grant/remaining arithmetic as every other tier. Pinned in tests/Aetheria.Shared.Tests/PowerTierTests.cs: SubEpsilonDemandGetsNoGrantAtTotalBlackout, HigherTierSubEpsilonDemandIsStillSubtractedFromRemaining, and EmptyTierAtBlackoutReportsNoDemandNotFullySupplied -- all fail under the reverted (<=1e-4 free-pass) mutation. Updated ATierWithNoDemandPassesItsShareDown's own empty-tier assertion, which had pinned the bug itself ("empty tiers read as fully met"). Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-19 05:15:30 +02:00
// 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.
Cut 5: priority tiers replace PowerBus's single shared GrantRatio PowerTiers.cs names five tiers (Critical/High/Medium/Low/Utility) and their per-behaviour-kind defaults: Radiator is Critical (the closest thing this game has to life support -- a starved radiator cascades into wear and shutdown across the whole hull), Shield is High, Thruster/AetherDrive are Medium, ConstantWeapon/InstantWeapon are Low, and Sensor/EnergyDraw are Utility. IPowerConsumer.DefaultPowerTier lets each behaviour own its own default. EquippableItem.PowerTier (Key 12) is the stored, player-choosable tier on the unit rather than the design; EquippedItem's constructor seeds a kind default the first time a unit is equipped, only when the field is still PowerTiers.Unassigned, and never again -- a tier is a stored choice, not something the bus re-derives. PowerBus.AllocateTiers walks tiers lowest-number-first: a tier is granted in full before the next tier sees anything left, an empty tier passes its whole share down, and within a tier every consumer rations by the same grant/demand fraction so unequal requests still divide proportionally. A new TierGrantRatio[] exposes each tier's own ratio for a future UI (Cut 8) to read starvation from directly, instead of reconstructing it from every item's PowerSupply. Deletes EquippedItem.SortPosition, IOrderedBehavior and Reactor.Order (its only implementer): the bus already steps before any equipment's Execute, so equip/behaviour order never decided who got fed even before this cut, and nothing else needed the interface (subject to the map's O3 operator check). docs/stats-and-power-cut.md Cut 5 / §1.3. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-19 02:58:38 +02:00
private static float[] InitFullTiers()
{
var ratios = new float[PowerTiers.Count];
F3: an empty tier is not a satisfied tier, and sub-epsilon demand pays its way AllocateTiers treated any tier whose summed demand was at or below 1e-4 as "fully met" (ratio 1) without ever subtracting it from `remaining`. Two bugs: a tier with no consumers equipped in it at all read as 1f, the exact "fully supplied" signal a UI is meant to trust, even on a fully blacked-out ship (measured: Critical 0 demand reads "fine" right next to a genuinely starved High); and a tier with a real but tiny demand (down to 5e-5) was granted out of nothing and never charged against the ship's actual generation, letting a lower tier get overfed by the sliver the higher one was never billed for. AllocateTiers now tracks which tiers actually have a consumer separately from their summed demand. A tier with no consumer reports the new PowerBus.NoDemand sentinel (NaN), never 1f. A tier with a consumer whose own request truly is zero this tick is trivially satisfied (ratio 1, nothing to be short of). Any other demand, however small, goes through the same grant/remaining arithmetic as every other tier. Pinned in tests/Aetheria.Shared.Tests/PowerTierTests.cs: SubEpsilonDemandGetsNoGrantAtTotalBlackout, HigherTierSubEpsilonDemandIsStillSubtractedFromRemaining, and EmptyTierAtBlackoutReportsNoDemandNotFullySupplied -- all fail under the reverted (<=1e-4 free-pass) mutation. Updated ATierWithNoDemandPassesItsShareDown's own empty-tier assertion, which had pinned the bug itself ("empty tiers read as fully met"). Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-19 05:15:30 +02:00
for (var i = 0; i < ratios.Length; i++) ratios[i] = NoDemand;
Cut 5: priority tiers replace PowerBus's single shared GrantRatio PowerTiers.cs names five tiers (Critical/High/Medium/Low/Utility) and their per-behaviour-kind defaults: Radiator is Critical (the closest thing this game has to life support -- a starved radiator cascades into wear and shutdown across the whole hull), Shield is High, Thruster/AetherDrive are Medium, ConstantWeapon/InstantWeapon are Low, and Sensor/EnergyDraw are Utility. IPowerConsumer.DefaultPowerTier lets each behaviour own its own default. EquippableItem.PowerTier (Key 12) is the stored, player-choosable tier on the unit rather than the design; EquippedItem's constructor seeds a kind default the first time a unit is equipped, only when the field is still PowerTiers.Unassigned, and never again -- a tier is a stored choice, not something the bus re-derives. PowerBus.AllocateTiers walks tiers lowest-number-first: a tier is granted in full before the next tier sees anything left, an empty tier passes its whole share down, and within a tier every consumer rations by the same grant/demand fraction so unequal requests still divide proportionally. A new TierGrantRatio[] exposes each tier's own ratio for a future UI (Cut 8) to read starvation from directly, instead of reconstructing it from every item's PowerSupply. Deletes EquippedItem.SortPosition, IOrderedBehavior and Reactor.Order (its only implementer): the bus already steps before any equipment's Execute, so equip/behaviour order never decided who got fed even before this cut, and nothing else needed the interface (subject to the map's O3 operator check). docs/stats-and-power-cut.md Cut 5 / §1.3. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-19 02:58:38 +02:00
return ratios;
}
// What Reactor.Execute reads to run its own heat/throttle arithmetic (Cut 3: "the arithmetic survives, moved
// under the bus's numbers"). Positive: demand left unmet after generation AND stored charge, split evenly
// across online reactors for overload heat. Negative: generation left over after demand and after topping up
// every capacitor, split evenly for the throttling arithmetic. Reactor.Draw no longer feeds this -- it is now
// a reported total, not a sink (§1.2).
public float NetDraw { get; private set; }
Cut 5: priority tiers replace PowerBus's single shared GrantRatio PowerTiers.cs names five tiers (Critical/High/Medium/Low/Utility) and their per-behaviour-kind defaults: Radiator is Critical (the closest thing this game has to life support -- a starved radiator cascades into wear and shutdown across the whole hull), Shield is High, Thruster/AetherDrive are Medium, ConstantWeapon/InstantWeapon are Low, and Sensor/EnergyDraw are Utility. IPowerConsumer.DefaultPowerTier lets each behaviour own its own default. EquippableItem.PowerTier (Key 12) is the stored, player-choosable tier on the unit rather than the design; EquippedItem's constructor seeds a kind default the first time a unit is equipped, only when the field is still PowerTiers.Unassigned, and never again -- a tier is a stored choice, not something the bus re-derives. PowerBus.AllocateTiers walks tiers lowest-number-first: a tier is granted in full before the next tier sees anything left, an empty tier passes its whole share down, and within a tier every consumer rations by the same grant/demand fraction so unequal requests still divide proportionally. A new TierGrantRatio[] exposes each tier's own ratio for a future UI (Cut 8) to read starvation from directly, instead of reconstructing it from every item's PowerSupply. Deletes EquippedItem.SortPosition, IOrderedBehavior and Reactor.Order (its only implementer): the bus already steps before any equipment's Execute, so equip/behaviour order never decided who got fed even before this cut, and nothing else needed the interface (subject to the map's O3 operator check). docs/stats-and-power-cut.md Cut 5 / §1.3. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-19 02:58:38 +02:00
// One equipped item's power request for this tick, captured once so PowerRequest -- which some behaviours
// (Shield, InstantWeapon, Sensor) implement by refreshing their own cached stats -- is never called twice in
// one Step.
private readonly struct Draw
{
public readonly EquippedItem Item;
public readonly int Tier;
public readonly float Request;
public Draw(EquippedItem item, int tier, float request) { Item = item; Tier = tier; Request = request; }
}
private static bool ClosedByThermostat(EquippedItem item, Behavior consumer)
{
foreach (var behavior in item.BehaviorGroups[consumer.Data.Group].Behaviors)
{
if (behavior == consumer) return false;
if (behavior is Thermotoggle { Open: false }) return true;
}
return false;
}
public void Step(float dt)
{
var reactors = new List<Reactor>();
var capacitors = new List<Capacitor>();
Cut 5: priority tiers replace PowerBus's single shared GrantRatio PowerTiers.cs names five tiers (Critical/High/Medium/Low/Utility) and their per-behaviour-kind defaults: Radiator is Critical (the closest thing this game has to life support -- a starved radiator cascades into wear and shutdown across the whole hull), Shield is High, Thruster/AetherDrive are Medium, ConstantWeapon/InstantWeapon are Low, and Sensor/EnergyDraw are Utility. IPowerConsumer.DefaultPowerTier lets each behaviour own its own default. EquippableItem.PowerTier (Key 12) is the stored, player-choosable tier on the unit rather than the design; EquippedItem's constructor seeds a kind default the first time a unit is equipped, only when the field is still PowerTiers.Unassigned, and never again -- a tier is a stored choice, not something the bus re-derives. PowerBus.AllocateTiers walks tiers lowest-number-first: a tier is granted in full before the next tier sees anything left, an empty tier passes its whole share down, and within a tier every consumer rations by the same grant/demand fraction so unequal requests still divide proportionally. A new TierGrantRatio[] exposes each tier's own ratio for a future UI (Cut 8) to read starvation from directly, instead of reconstructing it from every item's PowerSupply. Deletes EquippedItem.SortPosition, IOrderedBehavior and Reactor.Order (its only implementer): the bus already steps before any equipment's Execute, so equip/behaviour order never decided who got fed even before this cut, and nothing else needed the interface (subject to the map's O3 operator check). docs/stats-and-power-cut.md Cut 5 / §1.3. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-19 02:58:38 +02:00
var draws = new List<Draw>();
foreach (var item in _entity.Equipment)
foreach (var behavior in item.Behaviors)
{
switch (behavior)
{
// 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;
}
// 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.
// And a consumer whose group a thermostat has closed ahead of it does not run this tick either, so it is
// not billed: a heater above its target draws nothing.
if (behavior is IPowerConsumer consumer && item.Active.Value && !ClosedByThermostat(item, behavior))
Cut 5: priority tiers replace PowerBus's single shared GrantRatio PowerTiers.cs names five tiers (Critical/High/Medium/Low/Utility) and their per-behaviour-kind defaults: Radiator is Critical (the closest thing this game has to life support -- a starved radiator cascades into wear and shutdown across the whole hull), Shield is High, Thruster/AetherDrive are Medium, ConstantWeapon/InstantWeapon are Low, and Sensor/EnergyDraw are Utility. IPowerConsumer.DefaultPowerTier lets each behaviour own its own default. EquippableItem.PowerTier (Key 12) is the stored, player-choosable tier on the unit rather than the design; EquippedItem's constructor seeds a kind default the first time a unit is equipped, only when the field is still PowerTiers.Unassigned, and never again -- a tier is a stored choice, not something the bus re-derives. PowerBus.AllocateTiers walks tiers lowest-number-first: a tier is granted in full before the next tier sees anything left, an empty tier passes its whole share down, and within a tier every consumer rations by the same grant/demand fraction so unequal requests still divide proportionally. A new TierGrantRatio[] exposes each tier's own ratio for a future UI (Cut 8) to read starvation from directly, instead of reconstructing it from every item's PowerSupply. Deletes EquippedItem.SortPosition, IOrderedBehavior and Reactor.Order (its only implementer): the bus already steps before any equipment's Execute, so equip/behaviour order never decided who got fed even before this cut, and nothing else needed the interface (subject to the map's O3 operator check). docs/stats-and-power-cut.md Cut 5 / §1.3. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-19 02:58:38 +02:00
{
// 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
// the game after this unit was minted, which the constructor never saw -- fall back to the
// behaviour's own default rather than stranding it at an invalid tier.
var tier = item.EquippableItem.PowerTier;
if (tier == PowerTiers.Unassigned) tier = consumer.DefaultPowerTier;
tier = clamp(tier, 0, PowerTiers.Count - 1);
draws.Add(new Draw(item, tier, max(0f, consumer.PowerRequest(dt))));
}
}
TotalGeneration = reactors.Sum(r => r.Generation(dt));
Cut 5: priority tiers replace PowerBus's single shared GrantRatio PowerTiers.cs names five tiers (Critical/High/Medium/Low/Utility) and their per-behaviour-kind defaults: Radiator is Critical (the closest thing this game has to life support -- a starved radiator cascades into wear and shutdown across the whole hull), Shield is High, Thruster/AetherDrive are Medium, ConstantWeapon/InstantWeapon are Low, and Sensor/EnergyDraw are Utility. IPowerConsumer.DefaultPowerTier lets each behaviour own its own default. EquippableItem.PowerTier (Key 12) is the stored, player-choosable tier on the unit rather than the design; EquippedItem's constructor seeds a kind default the first time a unit is equipped, only when the field is still PowerTiers.Unassigned, and never again -- a tier is a stored choice, not something the bus re-derives. PowerBus.AllocateTiers walks tiers lowest-number-first: a tier is granted in full before the next tier sees anything left, an empty tier passes its whole share down, and within a tier every consumer rations by the same grant/demand fraction so unequal requests still divide proportionally. A new TierGrantRatio[] exposes each tier's own ratio for a future UI (Cut 8) to read starvation from directly, instead of reconstructing it from every item's PowerSupply. Deletes EquippedItem.SortPosition, IOrderedBehavior and Reactor.Order (its only implementer): the bus already steps before any equipment's Execute, so equip/behaviour order never decided who got fed even before this cut, and nothing else needed the interface (subject to the map's O3 operator check). docs/stats-and-power-cut.md Cut 5 / §1.3. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-19 02:58:38 +02:00
TotalDemand = draws.Sum(d => d.Request);
var availableCharge = capacitors.Sum(c => c.Charge);
var preCapacitorNet = TotalDemand - TotalGeneration;
float overload = 0f, unabsorbedSurplus = 0f;
if (preCapacitorNet > 0f)
{
var chargeDrawn = min(preCapacitorNet, availableCharge);
if (chargeDrawn > 0f) DrainEvenly(capacitors, chargeDrawn);
overload = preCapacitorNet - chargeDrawn;
}
else if (preCapacitorNet < 0f)
{
unabsorbedSurplus = FillEvenly(capacitors, -preCapacitorNet);
}
NetDraw = overload - unabsorbedSurplus; // exactly one of the two is ever nonzero
TotalGrant = TotalDemand - overload;
GrantRatio = TotalDemand <= 1e-4f ? 1f : saturate(TotalGrant / TotalDemand);
Cut 5: priority tiers replace PowerBus's single shared GrantRatio PowerTiers.cs names five tiers (Critical/High/Medium/Low/Utility) and their per-behaviour-kind defaults: Radiator is Critical (the closest thing this game has to life support -- a starved radiator cascades into wear and shutdown across the whole hull), Shield is High, Thruster/AetherDrive are Medium, ConstantWeapon/InstantWeapon are Low, and Sensor/EnergyDraw are Utility. IPowerConsumer.DefaultPowerTier lets each behaviour own its own default. EquippableItem.PowerTier (Key 12) is the stored, player-choosable tier on the unit rather than the design; EquippedItem's constructor seeds a kind default the first time a unit is equipped, only when the field is still PowerTiers.Unassigned, and never again -- a tier is a stored choice, not something the bus re-derives. PowerBus.AllocateTiers walks tiers lowest-number-first: a tier is granted in full before the next tier sees anything left, an empty tier passes its whole share down, and within a tier every consumer rations by the same grant/demand fraction so unequal requests still divide proportionally. A new TierGrantRatio[] exposes each tier's own ratio for a future UI (Cut 8) to read starvation from directly, instead of reconstructing it from every item's PowerSupply. Deletes EquippedItem.SortPosition, IOrderedBehavior and Reactor.Order (its only implementer): the bus already steps before any equipment's Execute, so equip/behaviour order never decided who got fed even before this cut, and nothing else needed the interface (subject to the map's O3 operator check). docs/stats-and-power-cut.md Cut 5 / §1.3. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-19 02:58:38 +02:00
AllocateTiers(draws);
}
// Cut 5 (docs/stats-and-power-cut.md §1.3): walks tiers lowest-number-first, feeding each in full before the
// next sees anything. remaining only ever shrinks, and once it hits 0 every later tier's ratio is 0 by the
// same division -- a lower tier can never take from a higher one (§1.3 "a tier boundary does not leak"),
// because a higher tier's share is committed (remaining -= grant) before a lower tier is even considered.
// Within a tier every consumer shares that tier's one ratio against its own request, so two unequal requests
// still divide proportionally -- the same property Cut 3 proved for the whole ship, now proved per tier.
private void AllocateTiers(List<Draw> draws)
{
var tierDemand = new float[PowerTiers.Count];
F3: an empty tier is not a satisfied tier, and sub-epsilon demand pays its way AllocateTiers treated any tier whose summed demand was at or below 1e-4 as "fully met" (ratio 1) without ever subtracting it from `remaining`. Two bugs: a tier with no consumers equipped in it at all read as 1f, the exact "fully supplied" signal a UI is meant to trust, even on a fully blacked-out ship (measured: Critical 0 demand reads "fine" right next to a genuinely starved High); and a tier with a real but tiny demand (down to 5e-5) was granted out of nothing and never charged against the ship's actual generation, letting a lower tier get overfed by the sliver the higher one was never billed for. AllocateTiers now tracks which tiers actually have a consumer separately from their summed demand. A tier with no consumer reports the new PowerBus.NoDemand sentinel (NaN), never 1f. A tier with a consumer whose own request truly is zero this tick is trivially satisfied (ratio 1, nothing to be short of). Any other demand, however small, goes through the same grant/remaining arithmetic as every other tier. Pinned in tests/Aetheria.Shared.Tests/PowerTierTests.cs: SubEpsilonDemandGetsNoGrantAtTotalBlackout, HigherTierSubEpsilonDemandIsStillSubtractedFromRemaining, and EmptyTierAtBlackoutReportsNoDemandNotFullySupplied -- all fail under the reverted (<=1e-4 free-pass) mutation. Updated ATierWithNoDemandPassesItsShareDown's own empty-tier assertion, which had pinned the bug itself ("empty tiers read as fully met"). Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-19 05:15:30 +02:00
// 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; }
Cut 5: priority tiers replace PowerBus's single shared GrantRatio PowerTiers.cs names five tiers (Critical/High/Medium/Low/Utility) and their per-behaviour-kind defaults: Radiator is Critical (the closest thing this game has to life support -- a starved radiator cascades into wear and shutdown across the whole hull), Shield is High, Thruster/AetherDrive are Medium, ConstantWeapon/InstantWeapon are Low, and Sensor/EnergyDraw are Utility. IPowerConsumer.DefaultPowerTier lets each behaviour own its own default. EquippableItem.PowerTier (Key 12) is the stored, player-choosable tier on the unit rather than the design; EquippedItem's constructor seeds a kind default the first time a unit is equipped, only when the field is still PowerTiers.Unassigned, and never again -- a tier is a stored choice, not something the bus re-derives. PowerBus.AllocateTiers walks tiers lowest-number-first: a tier is granted in full before the next tier sees anything left, an empty tier passes its whole share down, and within a tier every consumer rations by the same grant/demand fraction so unequal requests still divide proportionally. A new TierGrantRatio[] exposes each tier's own ratio for a future UI (Cut 8) to read starvation from directly, instead of reconstructing it from every item's PowerSupply. Deletes EquippedItem.SortPosition, IOrderedBehavior and Reactor.Order (its only implementer): the bus already steps before any equipment's Execute, so equip/behaviour order never decided who got fed even before this cut, and nothing else needed the interface (subject to the map's O3 operator check). docs/stats-and-power-cut.md Cut 5 / §1.3. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-19 02:58:38 +02:00
var remaining = TotalGrant;
var ratios = TierGrantRatio;
for (var tier = 0; tier < PowerTiers.Count; tier++)
{
F3: an empty tier is not a satisfied tier, and sub-epsilon demand pays its way AllocateTiers treated any tier whose summed demand was at or below 1e-4 as "fully met" (ratio 1) without ever subtracting it from `remaining`. Two bugs: a tier with no consumers equipped in it at all read as 1f, the exact "fully supplied" signal a UI is meant to trust, even on a fully blacked-out ship (measured: Critical 0 demand reads "fine" right next to a genuinely starved High); and a tier with a real but tiny demand (down to 5e-5) was granted out of nothing and never charged against the ship's actual generation, letting a lower tier get overfed by the sliver the higher one was never billed for. AllocateTiers now tracks which tiers actually have a consumer separately from their summed demand. A tier with no consumer reports the new PowerBus.NoDemand sentinel (NaN), never 1f. A tier with a consumer whose own request truly is zero this tick is trivially satisfied (ratio 1, nothing to be short of). Any other demand, however small, goes through the same grant/remaining arithmetic as every other tier. Pinned in tests/Aetheria.Shared.Tests/PowerTierTests.cs: SubEpsilonDemandGetsNoGrantAtTotalBlackout, HigherTierSubEpsilonDemandIsStillSubtractedFromRemaining, and EmptyTierAtBlackoutReportsNoDemandNotFullySupplied -- all fail under the reverted (<=1e-4 free-pass) mutation. Updated ATierWithNoDemandPassesItsShareDown's own empty-tier assertion, which had pinned the bug itself ("empty tiers read as fully met"). Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-19 05:15:30 +02:00
// 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; }
Cut 5: priority tiers replace PowerBus's single shared GrantRatio PowerTiers.cs names five tiers (Critical/High/Medium/Low/Utility) and their per-behaviour-kind defaults: Radiator is Critical (the closest thing this game has to life support -- a starved radiator cascades into wear and shutdown across the whole hull), Shield is High, Thruster/AetherDrive are Medium, ConstantWeapon/InstantWeapon are Low, and Sensor/EnergyDraw are Utility. IPowerConsumer.DefaultPowerTier lets each behaviour own its own default. EquippableItem.PowerTier (Key 12) is the stored, player-choosable tier on the unit rather than the design; EquippedItem's constructor seeds a kind default the first time a unit is equipped, only when the field is still PowerTiers.Unassigned, and never again -- a tier is a stored choice, not something the bus re-derives. PowerBus.AllocateTiers walks tiers lowest-number-first: a tier is granted in full before the next tier sees anything left, an empty tier passes its whole share down, and within a tier every consumer rations by the same grant/demand fraction so unequal requests still divide proportionally. A new TierGrantRatio[] exposes each tier's own ratio for a future UI (Cut 8) to read starvation from directly, instead of reconstructing it from every item's PowerSupply. Deletes EquippedItem.SortPosition, IOrderedBehavior and Reactor.Order (its only implementer): the bus already steps before any equipment's Execute, so equip/behaviour order never decided who got fed even before this cut, and nothing else needed the interface (subject to the map's O3 operator check). docs/stats-and-power-cut.md Cut 5 / §1.3. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-19 02:58:38 +02:00
var demand = tierDemand[tier];
F3: an empty tier is not a satisfied tier, and sub-epsilon demand pays its way AllocateTiers treated any tier whose summed demand was at or below 1e-4 as "fully met" (ratio 1) without ever subtracting it from `remaining`. Two bugs: a tier with no consumers equipped in it at all read as 1f, the exact "fully supplied" signal a UI is meant to trust, even on a fully blacked-out ship (measured: Critical 0 demand reads "fine" right next to a genuinely starved High); and a tier with a real but tiny demand (down to 5e-5) was granted out of nothing and never charged against the ship's actual generation, letting a lower tier get overfed by the sliver the higher one was never billed for. AllocateTiers now tracks which tiers actually have a consumer separately from their summed demand. A tier with no consumer reports the new PowerBus.NoDemand sentinel (NaN), never 1f. A tier with a consumer whose own request truly is zero this tick is trivially satisfied (ratio 1, nothing to be short of). Any other demand, however small, goes through the same grant/remaining arithmetic as every other tier. Pinned in tests/Aetheria.Shared.Tests/PowerTierTests.cs: SubEpsilonDemandGetsNoGrantAtTotalBlackout, HigherTierSubEpsilonDemandIsStillSubtractedFromRemaining, and EmptyTierAtBlackoutReportsNoDemandNotFullySupplied -- all fail under the reverted (<=1e-4 free-pass) mutation. Updated ATierWithNoDemandPassesItsShareDown's own empty-tier assertion, which had pinned the bug itself ("empty tiers read as fully met"). Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-19 05:15:30 +02:00
// 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; }
Cut 5: priority tiers replace PowerBus's single shared GrantRatio PowerTiers.cs names five tiers (Critical/High/Medium/Low/Utility) and their per-behaviour-kind defaults: Radiator is Critical (the closest thing this game has to life support -- a starved radiator cascades into wear and shutdown across the whole hull), Shield is High, Thruster/AetherDrive are Medium, ConstantWeapon/InstantWeapon are Low, and Sensor/EnergyDraw are Utility. IPowerConsumer.DefaultPowerTier lets each behaviour own its own default. EquippableItem.PowerTier (Key 12) is the stored, player-choosable tier on the unit rather than the design; EquippedItem's constructor seeds a kind default the first time a unit is equipped, only when the field is still PowerTiers.Unassigned, and never again -- a tier is a stored choice, not something the bus re-derives. PowerBus.AllocateTiers walks tiers lowest-number-first: a tier is granted in full before the next tier sees anything left, an empty tier passes its whole share down, and within a tier every consumer rations by the same grant/demand fraction so unequal requests still divide proportionally. A new TierGrantRatio[] exposes each tier's own ratio for a future UI (Cut 8) to read starvation from directly, instead of reconstructing it from every item's PowerSupply. Deletes EquippedItem.SortPosition, IOrderedBehavior and Reactor.Order (its only implementer): the bus already steps before any equipment's Execute, so equip/behaviour order never decided who got fed even before this cut, and nothing else needed the interface (subject to the map's O3 operator check). docs/stats-and-power-cut.md Cut 5 / §1.3. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-19 02:58:38 +02:00
var grant = min(demand, remaining);
ratios[tier] = saturate(grant / demand);
remaining -= grant;
}
foreach (var draw in draws)
Cut 6: wire StatSource.PowerSupply and refuse a power request that reads it docs/stats-and-power-cut.md Cut 6, purely additive per its own map: the enum member, the resolver's generation-cache and the bus's PowerSupply write already existed (Cuts 2/3/5) as an identity everywhere. This lands the two things Cut 6 actually adds. The curve: EquippedItem.PowerSupplyFactor now returns pow(PowerSupply, exponent) instead of the Cut-3 identity stub, so a stat that declares a PowerSupply term degrades as this tick's grant falls and reads unchanged at full supply (pow(1, n) == 1 regardless of n). PowerBus.AllocateTiers now invalidates StatSource.PowerSupply once per draw per tick -- the missing half of the wiring named in the cut map -- so the resolver actually recomputes when the grant moves, using the same per-source generation scheme Heat/Durability already use (unconditional, at most once per tick, never touching a stat that did not declare the source). The request-independence rule: a stat that decides a power request may not depend on power supply, directly or through a modifier chain. Direct dependency is checked statically (StatValidation.ValidateNoPowerSupplyOnRequest, ItemData.cs) against a fixed registry of (BehaviorData type, field) pairs -- the one PerformanceStat each IPowerConsumer's own PowerRequest evaluates as its request, a compile-time fact rather than something authored per instance -- and runs at catalog load (AetheriaStores.Open), at Upsert, and again at equip (EquippedItem's constructor) as defense against the two named bypasses that write a catalog document without going through Upsert. The modifier-chain half is dynamic (StatModifier.ValidateNoPowerSupplyChain): a modifier's own magnitude stat is walked through the same magnitude->target edges ValidateNoCycle already builds for the self-cycle check, and refused at Initialize (equip time) if it is power-tainted and targets a known request stat, naming both items. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-19 03:47:07 +02:00
{
Cut 5: priority tiers replace PowerBus's single shared GrantRatio PowerTiers.cs names five tiers (Critical/High/Medium/Low/Utility) and their per-behaviour-kind defaults: Radiator is Critical (the closest thing this game has to life support -- a starved radiator cascades into wear and shutdown across the whole hull), Shield is High, Thruster/AetherDrive are Medium, ConstantWeapon/InstantWeapon are Low, and Sensor/EnergyDraw are Utility. IPowerConsumer.DefaultPowerTier lets each behaviour own its own default. EquippableItem.PowerTier (Key 12) is the stored, player-choosable tier on the unit rather than the design; EquippedItem's constructor seeds a kind default the first time a unit is equipped, only when the field is still PowerTiers.Unassigned, and never again -- a tier is a stored choice, not something the bus re-derives. PowerBus.AllocateTiers walks tiers lowest-number-first: a tier is granted in full before the next tier sees anything left, an empty tier passes its whole share down, and within a tier every consumer rations by the same grant/demand fraction so unequal requests still divide proportionally. A new TierGrantRatio[] exposes each tier's own ratio for a future UI (Cut 8) to read starvation from directly, instead of reconstructing it from every item's PowerSupply. Deletes EquippedItem.SortPosition, IOrderedBehavior and Reactor.Order (its only implementer): the bus already steps before any equipment's Execute, so equip/behaviour order never decided who got fed even before this cut, and nothing else needed the interface (subject to the map's O3 operator check). docs/stats-and-power-cut.md Cut 5 / §1.3. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-19 02:58:38 +02:00
draw.Item.PowerSupply = ratios[draw.Tier];
Cut 6: wire StatSource.PowerSupply and refuse a power request that reads it docs/stats-and-power-cut.md Cut 6, purely additive per its own map: the enum member, the resolver's generation-cache and the bus's PowerSupply write already existed (Cuts 2/3/5) as an identity everywhere. This lands the two things Cut 6 actually adds. The curve: EquippedItem.PowerSupplyFactor now returns pow(PowerSupply, exponent) instead of the Cut-3 identity stub, so a stat that declares a PowerSupply term degrades as this tick's grant falls and reads unchanged at full supply (pow(1, n) == 1 regardless of n). PowerBus.AllocateTiers now invalidates StatSource.PowerSupply once per draw per tick -- the missing half of the wiring named in the cut map -- so the resolver actually recomputes when the grant moves, using the same per-source generation scheme Heat/Durability already use (unconditional, at most once per tick, never touching a stat that did not declare the source). The request-independence rule: a stat that decides a power request may not depend on power supply, directly or through a modifier chain. Direct dependency is checked statically (StatValidation.ValidateNoPowerSupplyOnRequest, ItemData.cs) against a fixed registry of (BehaviorData type, field) pairs -- the one PerformanceStat each IPowerConsumer's own PowerRequest evaluates as its request, a compile-time fact rather than something authored per instance -- and runs at catalog load (AetheriaStores.Open), at Upsert, and again at equip (EquippedItem's constructor) as defense against the two named bypasses that write a catalog document without going through Upsert. The modifier-chain half is dynamic (StatModifier.ValidateNoPowerSupplyChain): a modifier's own magnitude stat is walked through the same magnitude->target edges ValidateNoCycle already builds for the self-cycle check, and refused at Initialize (equip time) if it is power-tainted and targets a known request stat, naming both items. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-19 03:47:07 +02:00
// 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
// charge, repeating as capacitors empty out), just relocated: bus capacitors are drained only by the bus now.
private static void DrainEvenly(List<Capacitor> capacitors, float amount)
{
int chargedCount;
do
{
chargedCount = capacitors.Count(c => c.Charge > .01f);
if (chargedCount == 0) break;
var share = amount / chargedCount;
foreach (var cap in capacitors)
{
if (cap.Charge <= .01f) continue;
var drawn = min(share, cap.Charge);
cap.AddCharge(-drawn);
amount -= drawn;
}
} while (chargedCount > 0 && amount > .01f);
}
// Fills every capacitor evenly up to its own capacity and returns whatever would not fit, for Reactor to
// throttle away -- the same job Reactor.cs used to do to itself (surplus branch), moved here because bus
// capacitors are charged only by the bus (§0b). Resolves Capacity itself rather than reading each
// Capacitor's cached property: the bus steps before any behaviour's Execute runs this tick, so that property
// still holds last tick's value.
private static float FillEvenly(List<Capacitor> capacitors, float amount)
{
int nonFullCount;
do
{
nonFullCount = capacitors.Count(c => c.Charge < c.ResolveCapacity() - .01f);
if (nonFullCount == 0) break;
var share = amount / nonFullCount;
foreach (var cap in capacitors)
{
var capacity = cap.ResolveCapacity();
if (cap.Charge >= capacity - .01f) continue;
var added = min(share, capacity - cap.Charge);
cap.AddCharge(added);
amount -= added;
}
} while (nonFullCount > 0 && amount > .01f);
return amount;
}
}