Gate a charged weapon's Activate on CanFire, and start a burst's cooldown with the tick it flew in

A released charge has a burst pending and no cooldown until its first round, so a second Activate in that
window started a second charge; Activate now asks CanFire. The round that starts a burst's cooldown does so
after the tick's decrement, which made every cooldown one tick long; it now charges that tick to the cooldown.
Pins both, and that a burst triggered after a fired one is not ready before its first round.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
Meta Vens 2026-09-29 22:15:51 +02:00
commit 742cb23f5b
3 changed files with 80 additions and 2 deletions

View file

@ -160,8 +160,10 @@ public class ChargedWeapon : InstantWeapon
public override void Activate()
{
// CanFire also holds a weapon whose released charge has a burst pending: its cooldown starts at its first round,
// so a charge started before then would overlap it.
// A refused weapon never starts charging: it would pay charge heat and audio for a shot Trigger drops.
if(!_charging && !_coolingDown && !FireControl.Refuses(this, Entity))
if(!_charging && CanFire && !FireControl.Refuses(this, Entity))
{
OnStartCharging?.Invoke();
Item.FireAudioEvent(ChargedWeaponAudioEvent.Start);

View file

@ -251,7 +251,8 @@ public class InstantWeapon : Weapon, IProgressBehavior, IEventBehavior, IPowerCo
if (!_burstStarted)
{
_burstStarted = true;
_cooldown = 1;
_cooldown = 1 - dt / Cooldown;
// The round starts the cooldown after this tick's decrement above: charge the tick to it, so the cadence stays Cooldown
_coolingDown = true;
}
}

View file

@ -711,4 +711,79 @@ public sealed class FireControlPerWeaponTests : IDisposable
Assert.False(weapon.CanFire);
}
// A charge released with a burst pending has not started its cooldown, but it is not ready either: pressed
// again before that burst's first round it would start a second charge over it. Kills: ChargedWeapon.Activate
// that reads the cooldown alone instead of CanFire.
[Fact]
public void AChargedWeaponPressedAgainBeforeItsReleasedRoundFliesStartsNoSecondCharge()
{
var r = Build(charged: true, range: 100f);
r.Shooter.Target.Value = null;
Aim(r, float2(0, 1));
var weapon = (ChargedWeapon) r.Gun.Weapon;
var starts = 0;
weapon.OnStartCharging += () => starts++;
r.Zone.Update(.01f); // resolves the stats
weapon.Activate();
for (var i = 0; i < 15; i++) r.Zone.Update(.1f);
Assert.Equal(1, starts);
weapon.Deactivate();
weapon.Activate();
r.Zone.Update(.1f);
Assert.Equal(1, starts);
Assert.False((bool) typeof(ChargedWeapon).GetField("_charging", BindingFlags.Instance | BindingFlags.NonPublic).GetValue(weapon));
}
// 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.
[Theory]
[InlineData(100f, 12)]
[InlineData(29.5f, 0)]
public void AHeldTriggerFiresOncePerCooldownAndARefusedBurstLeavesNone(float range, int shots)
{
var r = Build(fuse: WeaponFuse.Proximity, blast: 30f, range: range);
r.Shooter.Target.Value = null;
Aim(r, float2(0, 1));
var weapon = (InstantWeapon) r.Gun.Weapon;
var fired = 0;
weapon.OnFire += _ => fired++;
r.Zone.Update(.25f); // resolves the stats
var readyTicks = 0;
for (var i = 0; i < 60; i++)
{
weapon.Activate();
r.Zone.Update(.25f);
if (weapon.CanFire) readyTicks++;
}
Assert.Equal(shots, fired);
if (shots == 0) Assert.Equal(60, readyTicks);
}
// Kills: Trigger that leaves _burstStarted true from the previous burst, so the next burst reads as started
// (and ready) before its first round has flown.
[Fact]
public void ABurstTriggeredAfterAFiredOneIsNotReadyBeforeItsFirstRound()
{
var r = Build(range: 100f);
r.Shooter.Target.Value = null;
Aim(r, float2(0, 1));
var weapon = (InstantWeapon) r.Gun.Weapon;
r.Zone.Update(.25f);
weapon.Activate();
r.Zone.Update(.25f);
Assert.False(weapon.CanFire, "control: the first burst fired and is cooling");
for (var i = 0; i < 10 && !weapon.CanFire; i++) r.Zone.Update(.25f);
Assert.True(weapon.CanFire, "fixture: the cooldown expired");
weapon.Activate();
Assert.False(weapon.CanFire);
}
}