diff --git a/Assets/Scripts/ServerShared/Behaviors/Shield.cs b/Assets/Scripts/ServerShared/Behaviors/Shield.cs index 6b2797d76..8960f11a1 100644 --- a/Assets/Scripts/ServerShared/Behaviors/Shield.cs +++ b/Assets/Scripts/ServerShared/Behaviors/Shield.cs @@ -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) diff --git a/tests/Aetheria.Shared.Tests/InputCapacitorTests.cs b/tests/Aetheria.Shared.Tests/InputCapacitorTests.cs index dacccc6ab..f9a05459e 100644 --- a/tests/Aetheria.Shared.Tests/InputCapacitorTests.cs +++ b/tests/Aetheria.Shared.Tests/InputCapacitorTests.cs @@ -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;