From 95b7aeb7a234c317db6d7fa91111a0007b0f93f3 Mon Sep 17 00:00:00 2001 From: soul Date: Thu, 22 Oct 2026 23:47:39 +0200 Subject: [PATCH] Rounds per step: a held gun fires every round its cooldown allows in a step, each born at its own moment inside it; the shortest-timer pin is gone Co-Authored-By: Claude Sonnet 5.5 --- .../ServerShared/Behaviors/AutoWeapon.cs | 8 +- .../ServerShared/Behaviors/ConstantWeapon.cs | 3 +- .../ServerShared/Behaviors/InstantWeapon.cs | 59 +++++++++-- Assets/Scripts/ServerShared/FireControl.cs | 12 ++- tests/Aetheria.Shared.Tests/SimRateTests.cs | 100 ++++++++++-------- 5 files changed, 114 insertions(+), 68 deletions(-) diff --git a/Assets/Scripts/ServerShared/Behaviors/AutoWeapon.cs b/Assets/Scripts/ServerShared/Behaviors/AutoWeapon.cs index 814b2875d..088d4b5cf 100644 --- a/Assets/Scripts/ServerShared/Behaviors/AutoWeapon.cs +++ b/Assets/Scripts/ServerShared/Behaviors/AutoWeapon.cs @@ -21,11 +21,7 @@ public class AutoWeapon : InstantWeapon public AutoWeapon(InstantWeaponData data, EquippedItem item) : base(data, item) { } public AutoWeapon(InstantWeaponData data, ConsumableItemEffect item) : base(data, item) { } - public override bool Execute(float dt) - { - if(_firing && _burstRemaining == 0 && _cooldown < 0) - Trigger(); - return base.Execute(dt); - } + // The trigger is held while it is down: InstantWeapon starts the next burst the moment the cooldown ends. + protected override bool Repeats => _firing; } diff --git a/Assets/Scripts/ServerShared/Behaviors/ConstantWeapon.cs b/Assets/Scripts/ServerShared/Behaviors/ConstantWeapon.cs index fd630f37d..b90a124da 100644 --- a/Assets/Scripts/ServerShared/Behaviors/ConstantWeapon.cs +++ b/Assets/Scripts/ServerShared/Behaviors/ConstantWeapon.cs @@ -146,7 +146,8 @@ public class ConstantWeapon : Weapon, IProgressBehavior, IEventBehavior, IPowerC } _ammoInterval -= dt / _data.AmmoInterval; - if (_ammoInterval < 0) + // An interval shorter than the step spends every round it spans. + while (_ammoInterval < 0) { _ammoInterval += 1; if (_data.MagazineSize > 1 && _ammo > 0) _ammo--; diff --git a/Assets/Scripts/ServerShared/Behaviors/InstantWeapon.cs b/Assets/Scripts/ServerShared/Behaviors/InstantWeapon.cs index eaf739ce0..52a2f3330 100644 --- a/Assets/Scripts/ServerShared/Behaviors/InstantWeapon.cs +++ b/Assets/Scripts/ServerShared/Behaviors/InstantWeapon.cs @@ -61,6 +61,11 @@ public class InstantWeapon : Weapon, IProgressBehavior, IEventBehavior, IPowerCo // Seconds the last countdown ran past zero (less than one step). The next countdown starts that far along, so a held // trigger fires Cooldown apart on average at any step, never floor(Cooldown/dt)+1 steps apart. private float _overshoot; + // The shooter asked for fire during this step (Activate): a weapon asked of for the whole step fires every round its + // cooldown allows inside it, rather than one per step. Cleared when the step ends. + private bool _pulled; + // The shortest cycle a countdown divides by: a cooldown of zero would otherwise fire without end in one step. + private const float ShortestCycle = .0005f; public float BurstCount { get; protected set; } public float BurstTime { get; protected set; } @@ -90,6 +95,10 @@ public class InstantWeapon : Weapon, IProgressBehavior, IEventBehavior, IPowerCo // manager can bind its presentation to the one shot it belongs to instead of applying damage itself. public event Action OnFire; + // Whether the trigger is held through this step, so a cooldown that ends inside it starts the next burst inside it. A + // tap or a press fires once per pull (a pull is held for its step); an automatic weapon holds while its trigger is down. + protected virtual bool Repeats => _pulled; + public virtual void ResetEvents() { OnReloadBegin = null; @@ -187,9 +196,10 @@ public class InstantWeapon : Weapon, IProgressBehavior, IEventBehavior, IPowerCo protected bool TrySpendActivationEnergy() => Item == null || _capacitor.TrySpend(_capacitor.Capacity); // Ends the countdown once it is below zero, keeping what it ran past zero (at most one step) for the next one. - private void FinishCountdown(float dt) + // True when it ended the countdown now. + private bool FinishCountdown(float dt) { - if (!_coolingDown || !(_cooldown < 0)) return; + if (!_coolingDown || !(_cooldown < 0)) return false; _overshoot = min(-_cooldown * CountdownSpan, dt); _coolingDown = false; if (_data.MagazineSize > 0 && _ammo == 0) @@ -199,9 +209,10 @@ public class InstantWeapon : Weapon, IProgressBehavior, IEventBehavior, IPowerCo } else OnCooldownComplete?.Invoke(); + return true; } - private float CountdownSpan => _data.MagazineSize > 0 && _ammo == 0 ? _data.ReloadTime : Cooldown; + private float CountdownSpan => max(ShortestCycle, _data.MagazineSize > 0 && _ammo == 0 ? _data.ReloadTime : Cooldown); // A countdown that starts outside a round (a charge failing) carries the last one's overshoot like every restart. protected void StartCountdown() @@ -217,7 +228,7 @@ public class InstantWeapon : Weapon, IProgressBehavior, IEventBehavior, IPowerCo return overshoot; } - private bool UseAmmo() + private bool UseAmmo(float dt) { if (_data.MagazineSize <= 1) return true; @@ -242,7 +253,7 @@ public class InstantWeapon : Weapon, IProgressBehavior, IEventBehavior, IPowerCo if(hasAmmo) { OnReloadBegin?.Invoke(); - _cooldown = 1 - TakeOvershoot() / _data.ReloadTime; + _cooldown = 1 - (_burstTimer + TakeOvershoot()) / _data.ReloadTime; _coolingDown = true; _firing = false; } @@ -252,19 +263,32 @@ public class InstantWeapon : Weapon, IProgressBehavior, IEventBehavior, IPowerCo } public override bool Execute(float dt) + { + try { return Step(dt); } + finally { _pulled = false; } + } + + // 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 + // cooldown that ends inside the step while the trigger is held starts the next burst at that moment, so a gun fires + // every round its cooldown allows in a step, each with its own roll, however short the cooldown or long the step. + private bool Step(float dt) { base.Execute(dt); // 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); + var ended = false; + var retriggered = false; if (_coolingDown) { _cooldown -= dt / CountdownSpan; - FinishCountdown(dt); + ended = FinishCountdown(dt); } _burstTimer += dt; + while (true) + { while (_burstRemaining > 0 && _burstTimer > 0) { // A fused round whose arming distance Range cannot reach is refused, per round, before anything is @@ -273,7 +297,7 @@ public class InstantWeapon : Weapon, IProgressBehavior, IEventBehavior, IPowerCo var refused = FireControl.Refuses(this, Entity); // Ammo and energy are consumed here, per round, or once per burst at its first unrefused round // (SingleAmmoBurst). UseAmmo returns false when triggering reload; cancel firing if that is the case - if (!refused && !_burstPaid && (!TrySpendActivationEnergy() || !UseAmmo())) + if (!refused && !_burstPaid && (!TrySpendActivationEnergy() || !UseAmmo(dt))) { _burstRemaining = 0; return false; @@ -284,7 +308,7 @@ public class InstantWeapon : Weapon, IProgressBehavior, IEventBehavior, IPowerCo if (!_burstStarted) { _burstStarted = true; - _cooldown = 1 - (dt + TakeOvershoot()) / CountdownSpan; + _cooldown = 1 - (_burstTimer + TakeOvershoot()) / CountdownSpan; // The round starts the cooldown after this tick's decrement above: charge the tick and the last // countdown's overshoot to it, so the cadence stays Cooldown _coolingDown = true; @@ -302,14 +326,27 @@ public class InstantWeapon : Weapon, IProgressBehavior, IEventBehavior, IPowerCo // whole expression, argument included, whenever OnFire has no subscriber (a headless run with no // Unity EntityInstance wiring, docs/headless-playground-cut.md), so a shot silently never fired. // Evaluate it into a local first. - var shotId = FireControl.Fire(this, Item, Entity); + var shotId = FireControl.Fire(this, Item, Entity, age: _burstTimer + _burstInterval); OnFire?.Invoke(shotId); CauseWearDamage(1); AddHeat(Heat); Entity.SetSignature(this, SignatureChannel.LaunchFlash, Visibility, Item); } - // A cooldown shorter than the step it started in is over in that step, so the next trigger can fire on the next one. - FinishCountdown(dt); + // A cooldown shorter than the step it started in is over in that step. + ended |= FinishCountdown(dt); + // The trigger is held and the weapon is ready: the next burst starts now, at the moment the cooldown ended (what it + // ran past zero is how long ago, at the step's end), or at the step's start when the weapon was already ready. + if (!Repeats || _burstRemaining > 0 || !CanFire || (retriggered && !_burstStarted)) break; + retriggered = true; + Trigger(); + if (_burstRemaining == 0) break; + if (ended) + { + _burstTimer = TakeOvershoot(); + ended = false; + } + else _burstTimer = dt; + } return true; } diff --git a/Assets/Scripts/ServerShared/FireControl.cs b/Assets/Scripts/ServerShared/FireControl.cs index 8f3cc5018..3266cb2d3 100644 --- a/Assets/Scripts/ServerShared/FireControl.cs +++ b/Assets/Scripts/ServerShared/FireControl.cs @@ -698,7 +698,11 @@ public static class FireControl // Damage * interval, through this exact same freeze-and-queue path (a flight time of zero, since a beam's // authored Velocity is 0, commits and resolves in the tick it fires -- R4's short-flight degradation, // unchanged). Every other caller keeps reading the frozen weapon.Damage it always did. - public static int Fire(Weapon weapon, EquippedItem item, Entity source, float? damageOverride = null) + // Rounds per step (ruling rounds-per-step): a gun fires every round its cooldown allows in a step, so a round is born + // `age` seconds before the step's end, where the shooter and its target were then (carried back along their velocities). + // FireTime, the origin, the target's frozen pose and the arrival and commit times all follow, so a presenter draws the round + // from where it left and the commit measures the target's stray from the line it was fired along. + public static int Fire(Weapon weapon, EquippedItem item, Entity source, float? damageOverride = null, float age = 0f) { var zone = source.Zone; if (IsMineLayer(weapon)) @@ -708,7 +712,7 @@ public static class FireControl } // Mining Cut 3: a chunk target fires as an untargeted round until Cut 4 routes shots at chunks. var target = source.Target.Value.Entity; - var now = zone.Time; + var now = zone.Time - age; // R1's engage gate and the shooter-side probability, evaluated now and frozen: nothing at commit time // re-reads a stat, an info level or a range. Cut 12.2: PFire no longer carries the spread/hull share -- @@ -724,14 +728,14 @@ public static class FireControl var burstPosition = solution.BurstPosition; var fuse = solution.Fuse; var blastRadius = solution.BlastRadius; - var origin = source.Position; + var origin = source.Position - float3(source.Velocity.x, 0, source.Velocity.y) * age; var targetVelocity = float3.zero; var targetPosition = origin; if (engaged != null) { targetVelocity = float3(engaged.Velocity.x, 0, engaged.Velocity.y); - targetPosition = engaged.Position; + targetPosition = engaged.Position - targetVelocity * age; } var flightTime = weapon.Velocity > .01f ? solution.FlightDistance / weapon.Velocity : 0f; diff --git a/tests/Aetheria.Shared.Tests/SimRateTests.cs b/tests/Aetheria.Shared.Tests/SimRateTests.cs index 9765757d3..dc8b1e03d 100644 --- a/tests/Aetheria.Shared.Tests/SimRateTests.cs +++ b/tests/Aetheria.Shared.Tests/SimRateTests.cs @@ -57,12 +57,16 @@ public sealed partial class ContactRevealTests // Holds the trigger for `seconds`, topping up the capacitor so only the cooldown paces the gun. Returns the zone // time of each round. - private List HoldTrigger(Ship ship, EquippedItem item, float dt, float seconds) + private List HoldTrigger(Ship ship, EquippedItem item, float dt, float seconds, List fireTimes = null) { var gun = (InstantWeapon) item.Behaviors.Single(b => b is InstantWeapon); var capacitor = (InputCapacitor) typeof(InstantWeapon).GetField("_capacitor", BindingFlags.Instance | BindingFlags.NonPublic).GetValue(gun); var rounds = new List(); - gun.OnFire += _ => rounds.Add(_zone.Time); + gun.OnFire += id => + { + rounds.Add(_zone.Time); + if (fireTimes != null && _zone.TryGetShot(id, out var shot)) fireTimes.Add(shot.FireTime); + }; ship.SetTarget(TargetRef.None); for (var step = 0; step < (int) MathF.Round(seconds / dt); step++) { @@ -277,6 +281,54 @@ public sealed partial class ContactRevealTests } // A magazine of three, cooldown .1 s and a .9 s reload is three rounds a 1.1 s cycle, at any step. + // Ruling rounds-per-step: cadence is authored in seconds at any tick rate. A gun of 0.01 s fires 100 rounds a second at 15, 30 + // and 60 Hz (several in one step), each born at its own moment inside the step, one cooldown after the one before. + [Fact] + public void AFastGunDeliversItsAuthoredRoundsAtEveryRate() + { + foreach (var dt in new[] { 1f / 60f, 1f / 30f, 1f / 15f }) + { + var ship = Add(float3(0, 0, 0), new[] { GunWithCooldown(.01f) }, new[] { "Targeting", "Reactor" }); + var fireTimes = new List(); + var rounds = HoldTrigger(ship, Guns(ship)[0], dt, 10f, fireTimes); + Assert.True(abs(rounds.Count - 1000f) <= 1.01f, $"dt {dt}: {rounds.Count} rounds in 10 s, expected 1000 +-1"); + Assert.Equal(rounds.Count, fireTimes.Count); + for (var i = 1; i < fireTimes.Count; i++) + Assert.True(abs(fireTimes[i] - fireTimes[i - 1] - .01f) < 2e-4f, $"dt {dt}: rounds {i - 1} and {i} born {fireTimes[i - 1]:R} and {fireTimes[i]:R}"); + } + } + + // The cluster's cost: a point-defence battery of 0.005 s guns holding its trigger, every round a queued shot with its own + // roll. The step wall time and the heap bytes per round are written beside the test for the report. + [Fact, Trait("Category", "Slow")] + public void ADenseClusterStepsAtAffordableCost() + { + const int guns = 16; + var dt = SimClock.GameStep; + var ships = Enumerable.Range(0, guns).Select(i => Add(float3(i * 3, 0, 0), new[] { GunWithCooldown(.005f) }, new[] { "Targeting", "Reactor" })).ToList(); + var weapons = ships.Select(s => (InstantWeapon) Guns(s)[0].Behaviors.Single(b => b is InstantWeapon)).ToList(); + var capacitors = weapons.Select(w => (InputCapacitor) typeof(InstantWeapon).GetField("_capacitor", BindingFlags.Instance | BindingFlags.NonPublic).GetValue(w)).ToList(); + var rounds = 0; + foreach (var w in weapons) w.OnFire += _ => rounds++; + 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(); } + Tick(dt); + } + for (var i = 0; i < 30; i++) Step(); // warm the JIT and the shot queue + rounds = 0; + var steps = 300; + 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"; + System.IO.File.WriteAllText(System.IO.Path.Combine(System.IO.Path.GetTempPath(), "simrate-stepcost.txt"), line); + Assert.True(abs(rounds / (double) steps - guns * dt / .005) < 1.0 * guns, line); + } + [Fact] public void MagazineCadenceIsPerSecond() { @@ -341,48 +393,4 @@ public sealed partial class RunStartTests Assert.True(Math.Abs(now.survivor - old.survivor) <= .1 * old.survivor, table.ToString()); Assert.True(Math.Abs(now.firstKill - old.firstKill) <= .1 * old.firstKill, table.ToString()); } - - // Every shipped cooldown, ping cooldown and ammo interval spans at least two steps, but the four guns below are - // authored faster (a restarted cooldown ends within its step, so they fire at most once per step: the step caps their - // rate). That is a content decision awaiting a ruling, so the offenders are pinned: a new one fails here. A burst - // interval under one step fires several rounds in one step (carry makes that exact on average) and is not a fault. - private static readonly string[] KnownFastCooldowns = - { - "Light Machine Gun.Cooldown", "Long Slug Rifle.Cooldown", "Point Defense Gun.Cooldown", "Slug Rifle.Cooldown" - }; - [Fact] - public void ShortestTimerIsSeveralSteps() - { - using var cache = RestoredHullsTests.OpenCatalog(); - var timers = new List<(string item, string timer, float seconds)>(); - var bursts = new List(); - void Read(string item, IEnumerable behaviors) - { - foreach (var behavior in behaviors.Where(b => b != null)) - switch (behavior) - { - case InstantWeaponData gun: - timers.Add((item, "Cooldown", Math.Min(gun.Cooldown.Min, gun.Cooldown.Max))); - var count = Math.Max(1f, Math.Min(gun.Count.Min, gun.Count.Max)); - var interval = Math.Min(gun.BurstTime.Min, gun.BurstTime.Max) / count; - if (count > 1 && interval < SimClock.GameStep) bursts.Add($"{item}: burst interval {interval:R} s"); - break; - case SensorData sensor: - timers.Add((item, "PingCooldown", Math.Min(sensor.PingCooldown.Min, sensor.PingCooldown.Max))); - break; - case ConstantWeaponData beam when beam.AmmoType.IsSet(): - timers.Add((item, "AmmoInterval", beam.AmmoInterval)); - break; - case CooldownData cooldown: - timers.Add((item, "Cooldown", Math.Min(cooldown.Cooldown.Min, cooldown.Cooldown.Max))); - break; - } - } - foreach (var item in cache.GetAll()) Read(item.Name, item.Behaviors); - foreach (var item in cache.GetAll()) Read(item.Name, item.Behaviors); - - var offenders = timers.Where(t => t.seconds > 0 && t.seconds < 2 * SimClock.GameStep) - .Select(t => $"{t.item}.{t.timer}").OrderBy(name => name, StringComparer.Ordinal).ToList(); - Assert.True(offenders.SequenceEqual(KnownFastCooldowns), $"timers under two steps of {SimClock.GameStep:R} s: {string.Join("; ", offenders)}. Burst intervals under a step: {string.Join("; ", bursts)}"); - } }