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 <noreply@anthropic.com>
This commit is contained in:
soul 2026-10-22 20:51:59 +02:00
commit 549437536a
5 changed files with 45 additions and 32 deletions

View file

@ -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;
}

View file

@ -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]

View file

@ -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);
}

View file

@ -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`.

View file

@ -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)
{