Aetheria/Assets/Scripts/ServerShared/PowerBus.cs
Meta Vens 8dacabb3d8 Cut 4: input capacitors close the last direct capacitor spend
Deletes Entity.CanSpendCapacitorCharge/TrySpendCapacitorCharge (and the
now-dead _capacitors bookkeeping list that only fed them) -- Cut 3's
named, temporary exception for the four instant draws. InstantWeapon
(both fire sites), Sensor.Ping, and Shield.CanTakeHit/TakeHit now spend
from their own InputCapacitor, filled continuously by PowerBus like
every other IPowerConsumer instead of touching the entity's shared bus
capacitors directly.

InputCapacitor's Capacity/Rate derive from the owning item's Energy and
Cooldown, with an authored-override hook (ChargedWeapon repurposes its
previously-unused ChargeEnergy stat as that override, per the map's own
instruction, rather than adding a new field). A weapon/sensor fire
attempt only ever spends exactly Capacity, so it only ever succeeds at
full charge -- no fractional shot. Shield is the map's named exception:
its reserve is continuous, not an activation buffer, so it allows a
partial draw; its size (no Energy/Cooldown pair exists on Shield) is a
flagged judgment call, not a spec'd formula.

Adds InputCapacitorTests.cs (8 tests) and
mutation_tests_stats_power_cut4.py (7 mutations, all behave as
declared); removes the one PowerBusTests.cs test and Cut 3 mutation
case that exercised the now-deleted Entity methods.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-18 22:52:15 +02:00

144 lines
6.9 KiB
C#

/* 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.
// There are no tiers yet (Cut 5 adds them), so a shortfall rations the whole pool by the same fraction for every
// consumer alike -- the death of the old "whoever equipped first drains the capacitors first" hidden priority
// (§0.5), not a replacement for it.
//
// 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; }
// Energy actually delivered this tick: 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).
public float TotalGrant { get; private set; }
// Fraction of every consumer's own request actually granted this tick, shared by every consumer alike (no
// tiers yet). Also written onto every consuming EquippedItem.PowerSupply.
public float GrantRatio { get; private set; } = 1f;
// 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; }
public void Step(float dt)
{
var reactors = new List<Reactor>();
var capacitors = new List<Capacitor>();
var consumers = new List<IPowerConsumer>();
foreach (var item in _entity.Equipment)
foreach (var behavior in item.Behaviors)
{
switch (behavior)
{
case Reactor reactor when reactor.Item.Online.Value:
reactors.Add(reactor);
break;
case Capacitor capacitor:
capacitors.Add(capacitor);
break;
}
if (behavior is IPowerConsumer consumer)
consumers.Add(consumer);
}
TotalGeneration = reactors.Sum(r => r.Generation(dt));
TotalDemand = consumers.Sum(c => max(0f, c.PowerRequest(dt)));
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);
foreach (var item in _entity.Equipment)
if (item.Behaviors.Any(b => b is IPowerConsumer))
item.PowerSupply = GrantRatio;
}
// 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;
}
}