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 <noreply@anthropic.com>
This commit is contained in:
soul 2026-10-22 23:47:39 +02:00
commit 95b7aeb7a2
5 changed files with 114 additions and 68 deletions

View file

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

View file

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

View file

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

View file

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

View file

@ -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<float> HoldTrigger(Ship ship, EquippedItem item, float dt, float seconds)
private List<float> HoldTrigger(Ship ship, EquippedItem item, float dt, float seconds, List<float> 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<float>();
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<float>();
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<string>();
void Read(string item, IEnumerable<BehaviorData> 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<EquippableItemData>()) Read(item.Name, item.Behaviors);
foreach (var item in cache.GetAll<ConsumableItemData>()) 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)}");
}
}