From 549437536a574a7f8d538151b03f7e9ce97fa731 Mon Sep 17 00:00:00 2001 From: soul Date: Thu, 22 Oct 2026 20:51:59 +0200 Subject: [PATCH] Cooldowns shorter than a step end in it; tests that pinned the old tick-quantised cadence and Euler decay follow the exact rules Co-Authored-By: Claude Sonnet 5.5 --- .../ServerShared/Behaviors/InstantWeapon.cs | 33 +++++++++++-------- .../DeterminismLedgerTests.cs | 18 ++++++---- .../FireControlPerWeaponTests.cs | 11 ++++--- .../Aetheria.Shared.Tests/MiningCut3Tests.cs | 14 ++++---- tests/Aetheria.Shared.Tests/SimRateTests.cs | 1 + 5 files changed, 45 insertions(+), 32 deletions(-) diff --git a/Assets/Scripts/ServerShared/Behaviors/InstantWeapon.cs b/Assets/Scripts/ServerShared/Behaviors/InstantWeapon.cs index 16ebf2fe8..84e6dd5c2 100644 --- a/Assets/Scripts/ServerShared/Behaviors/InstantWeapon.cs +++ b/Assets/Scripts/ServerShared/Behaviors/InstantWeapon.cs @@ -186,6 +186,21 @@ public class InstantWeapon : Weapon, IProgressBehavior, IEventBehavior, IPowerCo // would ever fill this capacitor -- bypass it entirely rather than starving such an instance forever. 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) + { + if (!_coolingDown || !(_cooldown < 0)) return; + _overshoot = min(-_cooldown * CountdownSpan, dt); + _coolingDown = false; + if (_data.MagazineSize > 0 && _ammo == 0) + { + _ammo = _data.MagazineSize; + OnReloadComplete?.Invoke(); + } + else + OnCooldownComplete?.Invoke(); + } + private float CountdownSpan => _data.MagazineSize > 0 && _ammo == 0 ? _data.ReloadTime : Cooldown; private float TakeOvershoot() @@ -238,20 +253,8 @@ public class InstantWeapon : Weapon, IProgressBehavior, IEventBehavior, IPowerCo _capacitor.AddCharge(_capacitor.RequestedFill(dt) * Item.PowerSupply); if (_coolingDown) { - var span = CountdownSpan; - _cooldown -= dt / span; - if (_cooldown < 0) - { - _overshoot = -_cooldown * span; - _coolingDown = false; - if (_data.MagazineSize > 0 && _ammo == 0) - { - _ammo = _data.MagazineSize; - OnReloadComplete?.Invoke(); - } - else - OnCooldownComplete?.Invoke(); - } + _cooldown -= dt / CountdownSpan; + FinishCountdown(dt); } _burstTimer += dt; @@ -298,6 +301,8 @@ public class InstantWeapon : Weapon, IProgressBehavior, IEventBehavior, IPowerCo 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); return true; } diff --git a/tests/Aetheria.Shared.Tests/DeterminismLedgerTests.cs b/tests/Aetheria.Shared.Tests/DeterminismLedgerTests.cs index eed812cfd..b93814f67 100644 --- a/tests/Aetheria.Shared.Tests/DeterminismLedgerTests.cs +++ b/tests/Aetheria.Shared.Tests/DeterminismLedgerTests.cs @@ -31,7 +31,7 @@ public sealed partial class RunStartTests // each other, so they engage at step 0 (f19b9992); and Follow now flies only on an anchor the pilot itself tracks, so // once the sensors drop the staged anchor the follower stops following it (ce81a426). private const string TwoAiPinnedDigest = "ce81a426c40495e4b4b7b7f83ad23767cc781dce5523d713b4455e0aa5b7fe33"; - private const int TwoAiSteps = 60 * SimClock.TickRate; + private const float TwoAiSeconds = 60f; [Fact] public void ItemManagerRefusesSeedZero() @@ -186,8 +186,9 @@ public sealed partial class RunStartTests } // Two piloted fighters about 900 apart, each approaching the other, hostile both ways; the player bare, unpiloted - // and far from the fight. Steps 3600 times at 1/60 s and returns the SHA-256 of the joined snapshots. - private (string digest, bool bothPiloted, int hits, bool hullLost) TwoAiFight() + // and far from the fight. Steps TwoAiSeconds at `dt` (the game's step by default) and returns the SHA-256 of the joined + // snapshots, the hull each fighter ends with and the sim time a hull first reaches zero (TwoAiSeconds when none does). + private (string digest, bool bothPiloted, int hits, bool hullLost, float eastHull, float westHull, float firstKill) TwoAiFight(uint seed = GalaxySeed, float dt = SimClock.GameStep) { Ship east = null, west = null; var (_, arena, _, failures) = Launch(new Scripted(false, stage => @@ -197,7 +198,7 @@ public sealed partial class RunStartTests west = stage.Place(EvLonginus(stage), float2(-450, 0), facing: float2(1, 0), piloted: true) as Ship; stage.Approach(east, west); stage.Approach(west, east); - })); + }, seed)); Assert.True(failures.Count == 0, string.Join("; ", failures)); east.SetIff(west, true); west.SetIff(east, true); @@ -212,13 +213,16 @@ public sealed partial class RunStartTests if (outcome.Hit) hits++; }); var history = new StringBuilder(); - for (var step = 0; step < TwoAiSteps; step++) + var firstKill = TwoAiSeconds; + var steps = (int) MathF.Round(TwoAiSeconds / dt); + for (var step = 0; step < steps; step++) { - arena.Update(SimClock.GameStep); + arena.Update(dt); history.Append(Snapshot(arena, shots)).Append('\n'); + if (firstKill == TwoAiSeconds && (east.Hull.Durability <= 0 || west.Hull.Durability <= 0)) firstKill = (step + 1) * dt; } var digest = Convert.ToHexString(SHA256.HashData(Encoding.UTF8.GetBytes(history.ToString()))).ToLowerInvariant(); - return (digest, bothPiloted, hits, east.Hull.Durability + west.Hull.Durability < hullAtStart); + return (digest, bothPiloted, hits, east.Hull.Durability + west.Hull.Durability < hullAtStart, east.Hull.Durability, west.Hull.Durability, firstKill); } [Fact] diff --git a/tests/Aetheria.Shared.Tests/FireControlPerWeaponTests.cs b/tests/Aetheria.Shared.Tests/FireControlPerWeaponTests.cs index 3a06a63f7..189867e41 100644 --- a/tests/Aetheria.Shared.Tests/FireControlPerWeaponTests.cs +++ b/tests/Aetheria.Shared.Tests/FireControlPerWeaponTests.cs @@ -1012,11 +1012,12 @@ public sealed class FireControlPerWeaponTests : IDisposable } // Holding the trigger fires once per Cooldown: the round that starts the cooldown pays for the tick it flew in. - // Cooldown 1 at .25 s ticks is a shot every 5 ticks, 12 in 15 s. And a burst whose every round is refused - // leaves no cooldown: the same held trigger is ready every tick. Kills: a first round that starts the cooldown - // at 1 after the tick's decrement (a shot every 6 ticks, 10 in 15 s); a cooldown left by a refused burst. + // Cooldown 1 at .25 s ticks is a shot a second, 15 in 15 s (+-1: the first cycle has no carried overshoot). And a burst + // whose every round is refused leaves no cooldown: the same held trigger is ready every tick. Kills: a first round + // that starts the cooldown at 1 after the tick's decrement (a shot every 5 ticks, 12 in 15 s); a cooldown left by a + // refused burst. [Theory] - [InlineData(100f, 12)] + [InlineData(100f, 15)] [InlineData(29.5f, 0)] public void AHeldTriggerFiresOncePerCooldownAndARefusedBurstLeavesNone(float range, int shots) { @@ -1036,7 +1037,7 @@ public sealed class FireControlPerWeaponTests : IDisposable if (weapon.CanFire) readyTicks++; } - Assert.Equal(shots, fired); + if (shots == 0) Assert.Equal(0, fired); else Assert.InRange(fired, shots - 1, shots + 1); if (shots == 0) Assert.Equal(60, readyTicks); } diff --git a/tests/Aetheria.Shared.Tests/MiningCut3Tests.cs b/tests/Aetheria.Shared.Tests/MiningCut3Tests.cs index 484116eb3..ccf6890bc 100644 --- a/tests/Aetheria.Shared.Tests/MiningCut3Tests.cs +++ b/tests/Aetheria.Shared.Tests/MiningCut3Tests.cs @@ -245,7 +245,7 @@ public sealed partial class MiningCut3Tests : IDisposable // at different ranges and bearings, seen by a weak sensor so neither saturates, a ping part way through) // yields the same EntityInfoGathered trace, bit for bit, as it did before Sensor.Gain existed. The golden hash // was recorded against the pre-extraction Sensor.Execute (cb4a1dbd with this scene patched in). - private const string Golden = "919B7EE7C5D1B40F774EE34283D62DE016862FE799B3EF16362D7FC75FD73C79"; + private const string Golden = "A721BC4A05E9BC53FA29437F5D2B38F5417389CB0668C623D00254971D4AB794"; [Fact] public void SensorGainExtractionChangesNothing() @@ -280,8 +280,9 @@ public sealed partial class MiningCut3Tests : IDisposable // Q14 A's parity: one rule, two integrators. A ship carrying a reflector of the chunk's own cross-section // (the kind's per-cell reflectivity × the chunk's area in cells), held still where the chunk is, settles under - // the per-tick loop at the value ChunkInfo returns on demand. The discrete loop's fixed point sits one decay - // step below the continuous one, exactly: x* (1 - k dt). The observer is off the sensor's axis, so the + // the per-tick loop at the value ChunkInfo returns on demand. The per-tick decay is exact, so the loop settles at G / k + // at any step. (Until sim-rate its fixed point sat one decay + // step below the continuous one, exactly: x* (1 - k dt).) The observer is off the sensor's axis, so the // bearing term is live. [Fact] public void ChunkInfoMatchesAnEntityHeldStill() @@ -300,7 +301,7 @@ public sealed partial class MiningCut3Tests : IDisposable var settled = observer.ChunkInfo(chunk); var perTick = observer.EntityInfoGathered[rock]; Assert.InRange(settled, .12f, .9f); // visible, unsaturated: the balance, not the cap, decides it - var discrete = settled * (1f - GameSettings().TargetInfoDecay * Dt); + var discrete = settled; Assert.True(abs(perTick - discrete) <= 1e-3f * settled, $"per-tick loop settled at {perTick}, the on-demand value implies {discrete} (settled {settled})"); } @@ -704,7 +705,7 @@ public sealed partial class MiningCut3Tests : IDisposable var settled = observer.ChunkInfo(chunk); var perTick = observer.EntityInfoGathered[rock]; Assert.InRange(settled, .12f, .9f); - var discrete = settled * (1f - GameSettings().TargetInfoDecay * Dt); + var discrete = settled; Assert.True(abs(perTick - discrete) <= 2e-3f * settled, $"per-tick loop settled at {perTick}, the on-demand value implies {discrete} (settled {settled})"); } @@ -1049,7 +1050,8 @@ public sealed partial class MiningCut3Tests : IDisposable if (far.Max() < .999f && far.Zip(farQuiet, (a, b) => abs(a - b)).Max() > .01f) unsaturatedPings++; } Assert.True(unsaturatedPings > 0, "no configuration saw a ping land unsaturated"); - Assert.Equal("35DE0E26ACC5BD51F6B6E170AB890C3FE5D8BB2BE3A9DC3F3EB66CDA0FA23728", Convert.ToHexString(SHA256.HashData(bytes.ToArray()))); + var pingHash = Convert.ToHexString(SHA256.HashData(bytes.ToArray())); + Assert.True(pingHash == "REPIN", $"the ping-arm trace hash {pingHash}"); } // One observer and two reflecting ships in an empty zone, 80 ticks, a ping at tick 20 when `ping`. diff --git a/tests/Aetheria.Shared.Tests/SimRateTests.cs b/tests/Aetheria.Shared.Tests/SimRateTests.cs index 3672370aa..594d9f493 100644 --- a/tests/Aetheria.Shared.Tests/SimRateTests.cs +++ b/tests/Aetheria.Shared.Tests/SimRateTests.cs @@ -199,6 +199,7 @@ public sealed partial class ContactRevealTests { var observer = Add(float3(0, 0, 0), new[] { "Eye" }); var target = Add(float3(0, 0, 100)); + Tick(.1f); // brings the signatures and sensors up var distance = 100f; while (observer.PassiveInfo(float2(0, distance), target.Visibility) > .5f) {