Shield reserve is authored: Capacity/RefillDuration/RestoreDuration, and a broken state
Operator ruling, 2026-09-19, supersedes Cut 4's derived reserve (EnergyUsage refilling every second, flagged as a guess). ShieldData now authors the reserve size and two recharge speeds directly; EnergyUsage stays the per-damage-point cost multiplier. A hit the reserve cannot fully cover now breaks the shield instead of a silent, charge-preserving refusal. Two follow-up rulings settle the fork the first left open: the breaking hit passes through in full (no partial absorption -- TakeHit never runs for it), and the reserve empties to zero on break so RestoreDuration means the same thing every time regardless of how full the shield was. InputCapacitorTests' shield fixture now authors the three new fields explicitly -- without them the reserve defaults to zero capacity and breaks on every hit. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
parent
08c3bb8c64
commit
b0e67cb36f
2 changed files with 82 additions and 20 deletions
|
|
@ -10,12 +10,27 @@ using static CultMath.math;
|
|||
[Inspectable, MessagePackObject, JsonObject(MemberSerialization.OptIn), RuntimeInspectable]
|
||||
public class ShieldData : BehaviorData
|
||||
{
|
||||
[Inspectable, JsonProperty("efficiency"), Key(1), RuntimeInspectable]
|
||||
[Inspectable, JsonProperty("efficiency"), Key(1), RuntimeInspectable]
|
||||
public PerformanceStat Efficiency = new PerformanceStat();
|
||||
|
||||
[Inspectable, JsonProperty("energy"), Key(2), RuntimeInspectable]
|
||||
[Inspectable, JsonProperty("energy"), Key(2), RuntimeInspectable]
|
||||
public PerformanceStat EnergyUsage = new PerformanceStat();
|
||||
|
||||
|
||||
// Operator ruling, 2026-09-19 (docs/stats-and-power-cut.md, shield reserve ruling block): supersedes Cut
|
||||
// 4's derived sizing (reserve = EnergyUsage, refilling every second). The reserve is now an authored energy
|
||||
// pool, sized independently of EnergyUsage (which stays the per-damage-point cost multiplier).
|
||||
[Inspectable, JsonProperty("capacity"), Key(3), RuntimeInspectable]
|
||||
public PerformanceStat Capacity = new PerformanceStat();
|
||||
|
||||
// Seconds to go from empty to full while the shield is up (holding, not broken).
|
||||
[Inspectable, JsonProperty("refillDuration"), Key(4), RuntimeInspectable]
|
||||
public PerformanceStat RefillDuration = new PerformanceStat();
|
||||
|
||||
// Seconds to go from empty to full while the shield is broken. This is the punish window: how long a hit
|
||||
// that overwhelms the reserve leaves the hull exposed.
|
||||
[Inspectable, JsonProperty("restoreDuration"), Key(5), RuntimeInspectable]
|
||||
public PerformanceStat RestoreDuration = new PerformanceStat();
|
||||
|
||||
public override Behavior CreateInstance(EquippedItem item)
|
||||
{
|
||||
return new Shield(this, item);
|
||||
|
|
@ -30,18 +45,39 @@ public class Shield : Behavior, IProgressBehavior, IPowerConsumer
|
|||
{
|
||||
public float Efficiency { get; private set; }
|
||||
public float EnergyUsage { get; private set; }
|
||||
public float Capacity { get; private set; }
|
||||
public float RefillDuration { get; private set; }
|
||||
public float RestoreDuration { get; private set; }
|
||||
|
||||
// Operator ruling, 2026-09-19 (docs/stats-and-power-cut.md, shield reserve ruling block): the state Cut 4's
|
||||
// reserve never had. A hit the reserve cannot fully cover breaks the shield instead of being silently
|
||||
// refused with its charge intact. Persists tick to tick (unlike Efficiency/EnergyUsage/Capacity above,
|
||||
// which are re-evaluated fresh every Execute): it is the shield's own history, not a derived stat.
|
||||
//
|
||||
// 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.
|
||||
//
|
||||
// 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
|
||||
// broke at 9/10 and kept that charge would refill to full in a heartbeat under RestoreDuration (which is
|
||||
// sized for a full recharge, not a top-up) -- the punish window would only ever bite on a shield broken
|
||||
// from near-empty. Zeroing it makes RestoreDuration mean the same thing every time a shield breaks,
|
||||
// regardless of how close to full it was when the hit that broke it landed.
|
||||
public bool Broken { get; private set; }
|
||||
|
||||
private ShieldData _data;
|
||||
|
||||
// Cut 4 (docs/stats-and-power-cut.md, Cut 4): "a shield hit is not an activation the player chose, so its
|
||||
// 'instant' draw is really a continuous reserve. Name it as such rather than giving it an activation
|
||||
// buffer." Unlike InstantWeapon/Sensor above, TrySpend below is never called with exactly Capacity -- a hit
|
||||
// can partially draw the reserve, same as the old direct capacitor spend could. Shield has no Energy/
|
||||
// Cooldown pair to derive a size from (only EnergyUsage, a per-damage-point multiplier), so this is a
|
||||
// judgment call, not a formula the map states: the reserve's Capacity and Rate both default to EnergyUsage
|
||||
// itself, refilling in full every second (Cooldown's implicit identity value -- the same "absent means
|
||||
// identity" convention InputCapacitor and PerformanceStat itself already use). Flagged for operator review
|
||||
// against real catalog EnergyUsage magnitudes rather than presented as spec'd.
|
||||
// Operator ruling, 2026-09-19: reserve capacity is now an authored number (Capacity), independent of
|
||||
// EnergyUsage (which stays the per-damage-point cost multiplier applied in CanTakeHit/TakeHit below). Rate
|
||||
// is derived the same way InputCapacitor already derives every other consumer's rate (Capacity/duration,
|
||||
// UpdateStats' own cooldown-to-rate formula) -- just fed RefillDuration while up and RestoreDuration while
|
||||
// Broken, so refilling from partial and restoring from broken are two different speeds through the one
|
||||
// mechanism every other IPowerConsumer already uses. A partial bus grant (Item.PowerSupply < 1) stretches
|
||||
// either duration proportionally for free: AddCharge below is scaled by PowerSupply same as every other
|
||||
// consumer, so a half grant simply takes twice as long to fill regardless of which duration is active.
|
||||
private readonly InputCapacitor _reserve = new InputCapacitor();
|
||||
|
||||
public Shield(ShieldData data, EquippedItem item) : base(data, item)
|
||||
|
|
@ -53,15 +89,16 @@ public class Shield : Behavior, IProgressBehavior, IPowerConsumer
|
|||
_data = data;
|
||||
}
|
||||
|
||||
// Cut 4: capacity/rate, resolved fresh (Capacitor.ResolveCapacity's precedent -- PowerBus.Step calls
|
||||
// PowerRequest below before this behaviour's own Execute runs this tick).
|
||||
// Resolved fresh (Capacitor.ResolveCapacity's precedent -- PowerBus.Step calls PowerRequest below before
|
||||
// this behaviour's own Execute runs this tick), reading Broken as it stood at the end of the previous tick.
|
||||
private void RefreshReserve()
|
||||
{
|
||||
var energyUsage = Evaluate(_data.EnergyUsage);
|
||||
_reserve.UpdateStats(energyUsage, cooldown: 1f);
|
||||
var capacity = Evaluate(_data.Capacity);
|
||||
var duration = Broken ? Evaluate(_data.RestoreDuration) : Evaluate(_data.RefillDuration);
|
||||
_reserve.UpdateStats(capacity, cooldown: duration, capacityOverride: capacity);
|
||||
}
|
||||
|
||||
// Cut 4: the request PowerBus needs before Execute runs.
|
||||
// The request PowerBus needs before Execute runs.
|
||||
public float PowerRequest(float dt)
|
||||
{
|
||||
RefreshReserve();
|
||||
|
|
@ -72,10 +109,17 @@ public class Shield : Behavior, IProgressBehavior, IPowerConsumer
|
|||
{
|
||||
Efficiency = Evaluate(_data.Efficiency);
|
||||
EnergyUsage = Evaluate(_data.EnergyUsage);
|
||||
// Cut 4: this tick's grant, read back via Item.PowerSupply -- Item is non-null here because a null-Item
|
||||
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;
|
||||
}
|
||||
|
||||
|
|
@ -83,9 +127,21 @@ public class Shield : Behavior, IProgressBehavior, IPowerConsumer
|
|||
// 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.
|
||||
public bool CanTakeHit(DamageType type, float damage)
|
||||
{
|
||||
return Item == null || _reserve.CanSpend(damage * EnergyUsage);
|
||||
if (Item == null) return true;
|
||||
if (Broken) return false;
|
||||
if (_reserve.CanSpend(damage * EnergyUsage)) return true;
|
||||
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)
|
||||
|
|
|
|||
|
|
@ -65,7 +65,13 @@ public sealed class InputCapacitorTests : IDisposable
|
|||
{
|
||||
Name = "Shield", Hardpoint = HardpointType.Tool, Shape = new Shape(), Durability = 10,
|
||||
MinimumTemperature = 0, MaximumTemperature = 1000, OptimalTemperature = 280, PlateauWidth = 400,
|
||||
Behaviors = { new ShieldData { Efficiency = Constant(1), EnergyUsage = Constant(1) } }
|
||||
// Capacity/RefillDuration/RestoreDuration authored explicitly (shield reserve ruling, 2026-09-19):
|
||||
// ShieldData no longer derives its reserve size from EnergyUsage, so a fixture that only authors
|
||||
// Efficiency/EnergyUsage now gets a zero-capacity reserve that breaks on every hit. Capacity = 1
|
||||
// reproduces this fixture's pre-ruling reserve size exactly (EnergyUsage was 1); durations are
|
||||
// unused by the tests below (they never provoke a break here) but must be nonzero so RefreshReserve
|
||||
// never divides by a zero duration.
|
||||
Behaviors = { new ShieldData { Efficiency = Constant(1), EnergyUsage = Constant(1), Capacity = Constant(1), RefillDuration = Constant(1), RestoreDuration = Constant(1) } }
|
||||
});
|
||||
cache.FlushAsync().Wait();
|
||||
return cache;
|
||||
|
|
|
|||
Loading…
Reference in a new issue