Combat releases a held AutoWeapon it no longer pulls: no target, no track, other group, exit

The weapon holds a down trigger; Combat's early returns issued no release and had no exit hook, so a held
Slug Rifle, Autocannon or Flak Gun fired on at nothing. Combat is the one owner of the AI's trigger level:
every held AutoWeapon it does not pull this step is released. A held gun stays a group candidate between
rounds so cooling does not drop it. Tests run through Minion/Combat.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
This commit is contained in:
soul 2026-10-23 13:27:52 +02:00
commit 848ff83cd7
2 changed files with 113 additions and 3 deletions

View file

@ -47,9 +47,9 @@ public class CombatState : BaseState
// only a contact a member tracks has a track to fly on, so an untracked target is no target here.
var view = _agent.View;
var target = view.TargetOf(_agent.Ship);
if (target == null) return;
if (target == null) { ReleaseUnwanted(); return; }
var track = view.TrackOf(_agent.Ship, target.Value);
if (track == null) return;
if (track == null) { ReleaseUnwanted(); return; }
_availableGroups.Clear();
_availableLockingWeapons.Clear();
@ -88,7 +88,9 @@ public class CombatState : BaseState
view.Designated(_agent.Ship, weapon, target.Value) &&
!view.Refuses(_agent.Ship, weapon, target.Value) &&
(weapon is ConstantWeapon ||
weapon is InstantWeapon instantWeapon && instantWeapon.CanFire))
// A held AutoWeapon stays a candidate between its rounds (it is cooling because it is firing), so the
// group it belongs to is judged by the same Fires verdict below, not dropped for cooling down.
weapon is InstantWeapon instantWeapon && (instantWeapon.CanFire || weapon is AutoWeapon && weapon.Firing)))
{
if(weapon is LockWeapon lockWeapon)
_availableLockingWeapons.Add(lockWeapon);
@ -151,6 +153,7 @@ public class CombatState : BaseState
_agent.Ship.Command.Release(weapon);
}
}
ReleaseUnwanted();
_agent.Ship.Command.Aim = toTarget;
_agent.Ship.Command.Turn = TurnCommand.Toward(toTarget.xz);
_agent.Accelerate(movementDirection * _agent.TopSpeed, true);//selectedGroup >= 0);
@ -158,6 +161,18 @@ public class CombatState : BaseState
// Fire charged guns!
}
public override void OnExitState() => ReleaseUnwanted();
// The AI's trigger level for an AutoWeapon is what it pulls this step: the weapon holds a down trigger until it is
// released, so every held one this step does not pull is let go, whether the target is gone, untracked, out of reach,
// its group is not the pick, or Combat has ended. A plain gun is tapped and lets go of itself.
private void ReleaseUnwanted()
{
var command = _agent.Ship.Command;
foreach (var weapon in _agent.Ship.Weapons)
if (weapon is AutoWeapon && weapon.Firing && !command.Pulls(weapon)) command.Release(weapon);
}
// Overdrive to close a distant target ahead, through the same consumable gate as the vent (Agent.VentOnTrack).
private void UseConsumables(float3 toTarget, float targetDistance)
{

View file

@ -1041,6 +1041,101 @@ public sealed class FireControlPerWeaponTests : IDisposable
if (shots == 0) Assert.Equal(60, readyTicks);
}
// ==== The AI's trigger level for an AutoWeapon ====
public enum Loss { NoTarget, Untracked }
private static void LoseTheTarget(Rig r, Loss loss)
{
if (loss == Loss.NoTarget) r.Shooter.SetTarget(TargetRef.None);
else r.Shooter.VisibleEntities.Remove(r.Target);
}
// A pilot that pulled an AutoWeapon and then loses its target, or its track of it, lets the trigger go: the weapon holds
// a down trigger until told otherwise, and an early return that issued no release left it firing on at nothing. Through
// the whole agent (Combat, then the transition out of it), and through Combat's Update alone. Kills: Combat never
// releasing, releasing only when the target is gone (not when it is untracked), releasing a step late.
[Theory]
[InlineData(Loss.NoTarget)]
[InlineData(Loss.Untracked)]
public void ACombatPilotThatLosesItsTargetReleasesItsHeldAutoWeaponAndItFiresNoMore(Loss loss)
{
var r = Build(auto: true);
var gun = (InstantWeapon) r.Gun.Weapon;
var rounds = 0;
gun.OnFire += _ => rounds++;
r.Zone.Agents.Add(new Minion(r.Shooter, FactionDoctrine.Default(r.Shooter.ItemManager.GameplaySettings).Combatant));
const float dt = 1f / 30f;
for (var i = 0; i < 90; i++) r.Zone.Update(dt);
Assert.True(gun.Firing, "control: the pilot holds the gun while it has a target");
Assert.True(rounds >= 2, $"control: {rounds} rounds in 3 s at a 1 s cooldown");
LoseTheTarget(r, loss);
r.Zone.Update(dt);
var after = rounds;
Assert.False(gun.Firing, "the trigger is let go the step the target is lost");
for (var i = 0; i < 90; i++) r.Zone.Update(dt);
Assert.Equal(after, rounds);
}
[Theory]
[InlineData(Loss.NoTarget)]
[InlineData(Loss.Untracked)]
public void CombatUpdateReleasesAHeldAutoWeaponWhenItHasNoTargetOrNoTrack(Loss loss)
{
var r = Build(auto: true);
var gun = (InstantWeapon) r.Gun.Weapon;
var combat = new CombatState(new Minion(r.Shooter, FactionDoctrine.Default(r.Shooter.ItemManager.GameplaySettings).Combatant));
gun.Activate();
Assert.True(gun.Firing, "control: the trigger is down");
LoseTheTarget(r, loss);
combat.Update(.1f);
r.Zone.Update(.1f);
Assert.False(gun.Firing);
}
// Combat's end lets go of what it held, wherever the end comes from.
[Fact]
public void CombatExitReleasesAHeldAutoWeapon()
{
var r = Build(auto: true);
var gun = (InstantWeapon) r.Gun.Weapon;
var combat = new CombatState(new Minion(r.Shooter, FactionDoctrine.Default(r.Shooter.ItemManager.GameplaySettings).Combatant));
gun.Activate();
Assert.True(gun.Firing, "control: the trigger is down");
combat.OnExitState();
r.Zone.Update(.1f);
Assert.False(gun.Firing);
}
// A held AutoWeapon is cooling between its rounds because it is firing: its group is not dropped for cooling down, or
// the trigger would be let go and pressed again at step granularity. Kills: releasing a held gun whose cooldown is
// running while its target stands.
[Fact]
public void CombatKeepsAnAutoWeaponHeldThroughItsCooldownWhileTheTargetStands()
{
var r = Build(auto: true);
var gun = (InstantWeapon) r.Gun.Weapon;
var rounds = 0;
gun.OnFire += _ => rounds++;
r.Zone.Agents.Add(new Minion(r.Shooter, FactionDoctrine.Default(r.Shooter.ItemManager.GameplaySettings).Combatant));
const float dt = 1f / 30f;
for (var i = 0; i < 60; i++) r.Zone.Update(dt);
var cooling = 0;
for (var i = 0; i < 240; i++)
{
r.Zone.Update(dt);
Assert.True(gun.Firing, $"step {i}: the held gun was let go with its target standing");
if (!gun.CanFire) cooling++;
}
Assert.True(cooling > 100, $"{cooling} of 240 steps spent cooling");
Assert.InRange(rounds, 8, 10);
}
// 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]