From 1870e23bfd4e075a8f57ef846a9d4c4bbaee73bc Mon Sep 17 00:00:00 2001 From: soul Date: Fri, 23 Oct 2026 00:02:49 +0200 Subject: [PATCH] Rounds per step: a gun's capacitor holds a step's inflow so every round of the step is paid; the mine-laying test's cooldown no longer pins the one-lay-per-step cap Co-Authored-By: Claude Sonnet 5.5 --- .../ServerShared/Behaviors/InputCapacitor.cs | 12 ++++++++++++ .../ServerShared/Behaviors/InstantWeapon.cs | 6 +++--- tests/Aetheria.Shared.Tests/MineTests.cs | 4 ++-- tests/Aetheria.Shared.Tests/SimRateTests.cs | 14 ++++++++++---- 4 files changed, 27 insertions(+), 9 deletions(-) diff --git a/Assets/Scripts/ServerShared/Behaviors/InputCapacitor.cs b/Assets/Scripts/ServerShared/Behaviors/InputCapacitor.cs index c20b2b8b5..4198801bd 100644 --- a/Assets/Scripts/ServerShared/Behaviors/InputCapacitor.cs +++ b/Assets/Scripts/ServerShared/Behaviors/InputCapacitor.cs @@ -42,6 +42,18 @@ public class InputCapacitor public void AddCharge(float amount) => Charge = clamp(Charge + amount, 0f, Capacity); + // A weapon may fire several rounds in one step (ruling rounds-per-step), and what flows in during that step is what pays + // for them, so its buffer is Capacity or the step's inflow, whichever is more; Settle trims the surplus a step did not spend. + // The fill rate is unchanged, so the energy a gun draws is still exactly its rounds' cost. A sensor or a shield keeps the + // one-activation buffer above. + private float StepReach(float dt) => max(Capacity, Rate * dt); + + public float RequestedStepFill(float dt) => min(Rate * dt, max(0f, StepReach(dt) - Charge)); + + public void AddStepCharge(float amount, float dt) => Charge = clamp(Charge + amount, 0f, StepReach(dt)); + + public void Settle() => Charge = min(Charge, Capacity); + public bool CanSpend(float cost) => Charge >= cost; // Atomic and whole-or-nothing, mirroring Entity.TrySpendCapacitorCharge's old contract exactly (down to the diff --git a/Assets/Scripts/ServerShared/Behaviors/InstantWeapon.cs b/Assets/Scripts/ServerShared/Behaviors/InstantWeapon.cs index c143251c9..88f7942e3 100644 --- a/Assets/Scripts/ServerShared/Behaviors/InstantWeapon.cs +++ b/Assets/Scripts/ServerShared/Behaviors/InstantWeapon.cs @@ -181,7 +181,7 @@ public class InstantWeapon : Weapon, IProgressBehavior, IEventBehavior, IPowerCo public float PowerRequest(float dt) { RefreshInputCapacitor(); - return _capacitor.RequestedFill(dt); + return _capacitor.RequestedStepFill(dt); } // Cut 5 (docs/stats-and-power-cut.md ยง1.3, PowerTiers.cs): Low -- offense. The ruling's own example of what @@ -265,7 +265,7 @@ public class InstantWeapon : Weapon, IProgressBehavior, IEventBehavior, IPowerCo public override bool Execute(float dt) { try { return Step(dt); } - finally { _pulled = false; } + finally { _pulled = false; _capacitor.Settle(); } } // One step of rounds. A round is due at a moment inside the step (_burstTimer is how long ago, at the step's end), and a @@ -277,7 +277,7 @@ public class InstantWeapon : Weapon, IProgressBehavior, IEventBehavior, IPowerCo // Cut 4: this tick's grant, read back via Item.PowerSupply -- Item is non-null here because a null-Item // instance never reaches PowerBus.Step (see TrySpendActivationEnergy) and so never accrues charge. if (Item != null) - _capacitor.AddCharge(_capacitor.RequestedFill(dt) * Item.PowerSupply); + _capacitor.AddStepCharge(_capacitor.RequestedStepFill(dt) * Item.PowerSupply, dt); var ended = false; var retriggered = false; if (_coolingDown) diff --git a/tests/Aetheria.Shared.Tests/MineTests.cs b/tests/Aetheria.Shared.Tests/MineTests.cs index 8ddf775a7..880fee858 100644 --- a/tests/Aetheria.Shared.Tests/MineTests.cs +++ b/tests/Aetheria.Shared.Tests/MineTests.cs @@ -515,8 +515,8 @@ public sealed class MineTests : IDisposable [Fact] public void LayingIsDeterministicAndFillsItsSpread() { - var a = Build(spread: 40f, velocity: 5f, cooldown: .01f, layerFaction: A); - var b = Build(spread: 40f, velocity: 5f, cooldown: .01f, layerFaction: A); + var a = Build(spread: 40f, velocity: 5f, cooldown: .15f, layerFaction: A); + var b = Build(spread: 40f, velocity: 5f, cooldown: .15f, layerFaction: A); var angles = new List(); using var sub = a.Zone.Mines.ObserveAdd().Subscribe(e => { diff --git a/tests/Aetheria.Shared.Tests/SimRateTests.cs b/tests/Aetheria.Shared.Tests/SimRateTests.cs index 766cbc444..96a51f97b 100644 --- a/tests/Aetheria.Shared.Tests/SimRateTests.cs +++ b/tests/Aetheria.Shared.Tests/SimRateTests.cs @@ -70,7 +70,7 @@ public sealed partial class ContactRevealTests ship.SetTarget(TargetRef.None); for (var step = 0; step < (int) MathF.Round(seconds / dt); step++) { - capacitor.AddCharge(1000f); + capacitor.AddStepCharge(1000f, dt); gun.Activate(); Tick(dt); } @@ -313,18 +313,24 @@ public sealed partial class ContactRevealTests foreach (var s in ships) s.SetTarget(TargetRef.None); void Step() { - for (var i = 0; i < guns; i++) { capacitors[i].AddCharge(1000f); weapons[i].Activate(); } + for (var i = 0; i < guns; i++) { capacitors[i].AddStepCharge(1000f, dt); weapons[i].Activate(); } Tick(dt); } for (var i = 0; i < 30; i++) Step(); // warm the JIT and the shot queue - rounds = 0; + for (var i = 0; i < 60; i++) Tick(dt); // let the in-flight rounds resolve var steps = 300; + var idleBytes = GC.GetAllocatedBytesForCurrentThread(); + var idle = System.Diagnostics.Stopwatch.StartNew(); + for (var i = 0; i < steps; i++) Tick(dt); + idle.Stop(); + idleBytes = GC.GetAllocatedBytesForCurrentThread() - idleBytes; + rounds = 0; var bytes = GC.GetAllocatedBytesForCurrentThread(); var watch = System.Diagnostics.Stopwatch.StartNew(); for (var i = 0; i < steps; i++) Step(); watch.Stop(); bytes = GC.GetAllocatedBytesForCurrentThread() - bytes; - var line = $"{guns} guns at .005 s, {SimClock.TickRate} Hz: {rounds / (double) steps:F1} rounds per step, {watch.Elapsed.TotalMilliseconds / steps:F3} ms per step, {bytes / (double) rounds:F0} heap bytes per round"; + var line = $"{guns} guns at .005 s, {SimClock.TickRate} Hz: {rounds / (double) steps:F1} rounds per step, {watch.Elapsed.TotalMilliseconds / steps:F3} ms per step, {(bytes - idleBytes) / (double) rounds:F0} heap bytes per round beyond the idle step (idle step {idle.Elapsed.TotalMilliseconds / steps:F3} ms, {idleBytes / (double) steps:F0} bytes)"; System.IO.File.WriteAllText(System.IO.Path.Combine(System.IO.Path.GetTempPath(), "simrate-stepcost.txt"), line); Assert.True(Math.Abs(rounds / (double) steps - guns * dt / .005) < 1.0 * guns, line); }