From 46912a20c83d0909428d354fe731767516ba6898 Mon Sep 17 00:00:00 2001 From: soul Date: Fri, 23 Oct 2026 01:47:52 +0200 Subject: [PATCH] A trigger is the weapon's own state: a tap fires one burst and a held trigger fires its cooldown's rounds at any tick; a step resolves its rounds in fire-time order; the idle-clamp test really idles InstantWeapon repeats while its trigger is down (Activate to Deactivate), not when it was pulled this step; AutoWeapon's override of that is gone. FireControl keeps the shot queue in fire-time order (ties by shot id) and Step walks it oldest first. Co-Authored-By: Claude Sonnet 5.5 --- .../ServerShared/Behaviors/AutoWeapon.cs | 3 - .../ServerShared/Behaviors/InstantWeapon.cs | 14 +- Assets/Scripts/ServerShared/FireControl.cs | 16 ++- tests/Aetheria.Shared.Tests/SimRateTests.cs | 129 ++++++++++++++++-- 4 files changed, 141 insertions(+), 21 deletions(-) diff --git a/Assets/Scripts/ServerShared/Behaviors/AutoWeapon.cs b/Assets/Scripts/ServerShared/Behaviors/AutoWeapon.cs index 088d4b5cf..b408f8079 100644 --- a/Assets/Scripts/ServerShared/Behaviors/AutoWeapon.cs +++ b/Assets/Scripts/ServerShared/Behaviors/AutoWeapon.cs @@ -20,8 +20,5 @@ public class AutoWeapon : InstantWeapon public AutoWeapon(InstantWeaponData data, EquippedItem item) : base(data, item) { } public AutoWeapon(InstantWeaponData data, ConsumableItemEffect item) : base(data, item) { } - - // 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/InstantWeapon.cs b/Assets/Scripts/ServerShared/Behaviors/InstantWeapon.cs index 49a0b7ea6..47b61ddf4 100644 --- a/Assets/Scripts/ServerShared/Behaviors/InstantWeapon.cs +++ b/Assets/Scripts/ServerShared/Behaviors/InstantWeapon.cs @@ -61,9 +61,6 @@ 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; @@ -95,9 +92,11 @@ 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; + // Whether the trigger is down, so a cooldown that ends inside a step starts the next burst inside it. The trigger is the + // weapon's own state, set by Activate and cleared by Deactivate (a player's press and let-go, an agent's pull and + // release), never inferred from how often it was pulled: a tap fires one burst, and a held trigger fires every round + // its cooldown allows, at any step length. + private bool Repeats => _firing; public virtual void ResetEvents() { @@ -269,7 +268,7 @@ public class InstantWeapon : Weapon, IProgressBehavior, IEventBehavior, IPowerCo public override bool Execute(float dt) { try { return Step(dt); } - finally { _pulled = false; _capacitor.Settle(); } + finally { _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 @@ -356,7 +355,6 @@ public class InstantWeapon : Weapon, IProgressBehavior, IEventBehavior, IPowerCo internal override void Activate() { - _pulled = true; if(CanFire) Trigger(); base.Activate(); diff --git a/Assets/Scripts/ServerShared/FireControl.cs b/Assets/Scripts/ServerShared/FireControl.cs index 3266cb2d3..3b3b78cf3 100644 --- a/Assets/Scripts/ServerShared/FireControl.cs +++ b/Assets/Scripts/ServerShared/FireControl.cs @@ -783,7 +783,12 @@ public static class FireControl // point is decided at its commit (CommitBurst), which reads it there. Each shot reads it once. if (!shot.DeliveredAtCommit) Deliver(ref shot, solution.BurstReach); - zone.PendingShots.Add(shot); + // The queue is in fire-time order, ties in shot-id order (ids rise as shots are made): Step walks it oldest first. Rounds + // of one step are born shooter by shooter, so a later shooter's earlier round goes in before an earlier shooter's later one. + var queue = zone.PendingShots; + var place = queue.Count; + while (place > 0 && queue[place - 1].FireTime > shot.FireTime) place--; + queue.Insert(place, shot); return shot.ShotId; } @@ -849,7 +854,10 @@ public static class FireControl if (shots.Count == 0) return; var now = zone.Time; - for (var i = shots.Count - 1; i >= 0; i--) + // In fire-time order, oldest first (the queue is kept in it by Fire), so which of a step's rounds breaks a shield or + // lands a kill follows sim time, not the order the shooters were stepped in. Shots queued while this walk runs are + // left to the next step. + for (int i = 0, end = shots.Count; i < end;) { var shot = shots[i]; @@ -863,6 +871,7 @@ public static class FireControl // target is gone. Republishing the committed outcome here used to put a hit marker on a corpse. var miss = MakeOutcome(shot, ShotResult.Miss, false, false, int2.zero, float2.zero, 0f, now); shots.RemoveAt(i); + end--; if (!shot.Committed) zone.ShotCommitted.OnNext(miss); zone.ShotResolved.OnNext(miss); continue; @@ -890,9 +899,12 @@ public static class FireControl // Removed first: Apply reaches damage observers (user code), and a shot left in the queue by one // that throws would resolve, and hurt, again on every later tick. shots.RemoveAt(i); + end--; Apply(zone, shot); zone.ShotResolved.OnNext(shot.Outcome); + continue; } + i++; } } diff --git a/tests/Aetheria.Shared.Tests/SimRateTests.cs b/tests/Aetheria.Shared.Tests/SimRateTests.cs index d8fe5ce57..9f2aab07f 100644 --- a/tests/Aetheria.Shared.Tests/SimRateTests.cs +++ b/tests/Aetheria.Shared.Tests/SimRateTests.cs @@ -3,6 +3,7 @@ using System.Collections.Generic; using System.Linq; using System.Reflection; using CultMath; +using UniRx; using Xunit; using static CultMath.math; using float3 = CultMath.float3; @@ -75,6 +76,7 @@ public sealed partial class ContactRevealTests gun.Activate(); Tick(dt); } + gun.Deactivate(); return rounds; } @@ -268,16 +270,18 @@ public sealed partial class ContactRevealTests _openCache.Upsert(Tool(name, new CooldownData { Cooldown = Const(.5f) })); var ship = Add(float3(0, 0, 0), null, new[] { name }); var clock = ship.Equipment.SelectMany(i => i.Behaviors).OfType().Single(); - for (var step = 0; step < (int) MathF.Round(10f / dt); step++) Tick(dt); - var times = new List(); - for (var step = 0; step < (int) MathF.Round(3f / dt); step++) + // Idle: the timer is updated but its chain never reaches Execute (an earlier behaviour gates it, or the item is + // offline). Zone ticks would not do: an Active tool reaches Execute every step and restarts the timer each time. + for (var step = 0; step < (int) MathF.Round(10f / dt); step++) clock.Update(dt); + var restarts = new List(); + for (var step = 0; step < (int) MathF.Round(2f / dt); step++) { - Tick(dt); - if (clock.Execute(dt)) times.Add(_zone.Time); + if (clock.Execute(dt)) restarts.Add(step); + clock.Update(dt); } - Assert.True(times.Count >= 4); - for (var i = 1; i < times.Count; i++) - Assert.True(times[i] - times[i - 1] >= .5f - dt - 1e-4f, $"dt {dt}: restarts at {times[i - 1]} and {times[i]}"); + Assert.True(restarts.Count >= 3, $"dt {dt}: {restarts.Count} restarts in 2 s"); + for (var i = 1; i < restarts.Count; i++) + Assert.True((restarts[i] - restarts[i - 1]) * dt >= .5f - dt - 1e-4f, $"dt {dt}: restarts at steps {restarts[i - 1]} and {restarts[i]}"); } } @@ -301,6 +305,114 @@ public sealed partial class ContactRevealTests } } + private InputCapacitor CapacitorOf(InstantWeapon gun) => (InputCapacitor) typeof(InstantWeapon).GetField("_capacitor", BindingFlags.Instance | BindingFlags.NonPublic).GetValue(gun); + + // A press that is let go before the step runs fires one burst, whatever the cooldown and the tick: the trigger is the + // weapon's state, not a pulse once per step. The press goes through the pilot's command (the player's action bar and the + // agents' Combat both do) and straight to the weapon (a turret does). + [Fact] + public void ATapFiresOneBurstAtEveryRate() + { + foreach (var cooldown in new[] { .01f, .025f, .1f }) + foreach (var viaCommand in new[] { false, true }) + foreach (var dt in new[] { 1f / 60f, 1f / 30f, 1f / 15f }) + { + var ship = Add(float3(0, 0, 0), new[] { GunWithCooldown(cooldown) }, new[] { "Targeting", "Reactor" }); + var gun = (InstantWeapon) Guns(ship)[0].Behaviors.Single(b => b is InstantWeapon); + var capacitor = CapacitorOf(gun); + var rounds = 0; + gun.OnFire += _ => rounds++; + ship.SetTarget(TargetRef.None); + capacitor.AddStepCharge(1000f, dt); + if (viaCommand) + { + ship.Command.Pull(gun); + ship.Command.Release(gun); + } + else + { + gun.Activate(); + gun.Deactivate(); + } + for (var step = 0; step < (int) MathF.Round(1f / dt); step++) + { + capacitor.AddStepCharge(1000f, dt); + Tick(dt); + } + Assert.True(rounds == 1, $"cooldown {cooldown} at dt {dt}, command {viaCommand}: {rounds} rounds from one tap, expected 1"); + } + } + + // A trigger pulled once and held, as the player's key and a turret's pull are, delivers the authored cadence: the rounds + // of ten seconds are the same at 15, 30 and 60 Hz, and the weapon stops when the trigger is let go. + [Fact] + public void AHeldTriggerFiresTheAuthoredCadenceAtEveryRate() + { + foreach (var cooldown in new[] { .01f, .1f }) + foreach (var dt in new[] { 1f / 60f, 1f / 30f, 1f / 15f }) + { + var ship = Add(float3(0, 0, 0), new[] { GunWithCooldown(cooldown) }, new[] { "Targeting", "Reactor" }); + var gun = (InstantWeapon) Guns(ship)[0].Behaviors.Single(b => b is InstantWeapon); + var capacitor = CapacitorOf(gun); + var rounds = 0; + gun.OnFire += _ => rounds++; + ship.SetTarget(TargetRef.None); + capacitor.AddStepCharge(1000f, dt); + gun.Activate(); + for (var step = 0; step < (int) MathF.Round(10f / dt); step++) + { + capacitor.AddStepCharge(1000f, dt); + Tick(dt); + } + Assert.True(abs(rounds - 10f / cooldown) <= 1.01f, $"cooldown {cooldown} at dt {dt}: {rounds} rounds held 10 s, expected {10f / cooldown} +-1"); + gun.Deactivate(); + var held = rounds; + for (var step = 0; step < (int) MathF.Round(1f / dt); step++) + { + capacitor.AddStepCharge(1000f, dt); + Tick(dt); + } + Assert.True(rounds - held <= 1, $"cooldown {cooldown} at dt {dt}: {rounds - held} rounds after the trigger was let go"); + } + } + + // Within a step, rounds resolve in the order they were fired across shooters, not one shooter's block after the other's + // and not newest first: which round breaks a shield or lands the kill follows sim time. Equal moments go by shot id. + [Fact] + public void RoundsOfOneStepResolveInFireTimeOrderAcrossShooters() + { + var dt = 1f / 30f; + var a = Add(float3(0, 0, 0), new[] { GunWithCooldown(.01f) }, new[] { "Targeting", "Reactor" }); + var b = Add(float3(5, 0, 0), new[] { GunWithCooldown(.01f) }, new[] { "Targeting", "Reactor" }); + var guns = new[] { a, b }.Select(s => (InstantWeapon) Guns(s)[0].Behaviors.Single(x => x is InstantWeapon)).ToArray(); + var fired = new Dictionary(); + foreach (var gun in guns) + gun.OnFire += id => { if (_zone.TryGetShot(id, out var shot)) fired[id] = shot.FireTime; }; + foreach (var s in new[] { a, b }) s.SetTarget(TargetRef.None); + var order = new List(); + var sources = new List(); + _zone.ShotResolved.Subscribe(outcome => { order.Add(outcome.ShotId); sources.Add(ReferenceEquals(outcome.Source, a)); }); + var checkedSteps = 0; + for (var step = 0; step < 8; step++) + { + foreach (var gun in guns) { CapacitorOf(gun).AddStepCharge(1000f, dt); gun.Activate(); } + order.Clear(); + sources.Clear(); + Tick(dt); + if (step < 3) continue; + checkedSteps++; + Assert.True(order.Count >= 4, $"step {step}: {order.Count} rounds resolved"); + for (var i = 1; i < order.Count; i++) + { + var before = (fired[order[i - 1]], order[i - 1]); + var after = (fired[order[i]], order[i]); + Assert.True(before.CompareTo(after) < 0, $"step {step}: shot {order[i - 1]} (fired {before.Item1:R}) resolved before shot {order[i]} (fired {after.Item1:R})"); + } + Assert.True(sources.Zip(sources.Skip(1), (x, y) => x != y).Count(changed => changed) > 1, $"step {step}: one shooter's rounds resolved as a block"); + } + Assert.True(checkedSteps >= 4); + } + // Rounds born in one step leave from where the shooter was at their own moment: the origins of two rounds of a step are // apart by the shooter's velocity times the time between them. [Fact] @@ -360,6 +472,7 @@ public sealed partial class ContactRevealTests Tick(dt); } for (var i = 0; i < 30; i++) Step(); // warm the JIT and the shot queue + foreach (var w in weapons) w.Deactivate(); // the triggers stay down until released for (var i = 0; i < 60; i++) Tick(dt); // let the in-flight rounds resolve var steps = 300; var idleBytes = GC.GetAllocatedBytesForCurrentThread();