Run the refused-round cost test through Execute as well as the trigger

The trigger's own refusal check stopped a short-Range round before Execute
ever reached its refusal, so wear, heat, visibility and sound left ahead of
Execute's `continue` survived every mutation. The cost theory now also
authorises a burst directly, the way a trigger that passed leaves it, and the
separate ammo-only test folds into it.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
Meta Vens 2026-09-29 18:51:57 +02:00
commit 7e620ff9f2

View file

@ -374,7 +374,7 @@ public sealed class FireControlPerWeaponTests : IDisposable
// One discrete weapon, triggered once at no target, with every cost observable: a magazine, an energy charge,
// a sound bank, wear events, heat and visibility. Range 29.5 refuses (arming 30); Range 40 fires.
private Costs Pull(float range, bool single)
private Costs Pull(float range, bool single, bool viaTrigger)
{
var r = Build(fuse: WeaponFuse.Proximity, blast: 30f, range: range, energy: 50f, heat: 5000f, visibility: 100f, magazine: 5, singleAmmoBurst: single);
r.Shooter.Target.Value = null;
@ -393,7 +393,8 @@ public sealed class FireControlPerWeaponTests : IDisposable
var ammoBefore = weapon.Ammo;
Assert.True(chargeBefore > 0f && ammoBefore == 5, "fixture: a full buffer and magazine");
weapon.Activate();
if (viaTrigger) weapon.Activate();
else typeof(InstantWeapon).GetField("_burstRemaining", BindingFlags.Instance | BindingFlags.NonPublic).SetValue(weapon, 1);
r.Zone.Update(.01f);
costs.Ammo = ammoBefore - weapon.Ammo;
@ -405,15 +406,18 @@ public sealed class FireControlPerWeaponTests : IDisposable
// Control: the same weapon with Range to spare pays every cost, so a free refusal below is not a fixture that
// never spends. Then the refusal costs nothing: no ammo, no energy, no sound, no wear, no heat, no
// visibility, no shot. Kills, one per row of the assertions: a spend that precedes the refusal (Execute or
// Trigger), an announcement, a sound, wear, heat or visibility left ahead of the `continue`.
// `single` is a weapon that pays once per burst, at the trigger rather than at the round.
// visibility, no shot. `single` is a weapon that pays once per burst, at the trigger rather than at the round;
// `viaTrigger` false authorises the burst directly (the way a trigger that passed leaves it) on a weapon whose
// Range is short, so the Trigger check cannot be what stops the round and Execute must refuse it itself.
// Kills, one per row of the assertions: a spend that precedes the refusal (Execute or Trigger), an
// announcement, a sound, wear, heat or visibility left ahead of the `continue`.
[Theory]
[InlineData(false)]
[InlineData(true)]
public void ARefusedDiscreteRoundCostsNothing(bool single)
[InlineData(false, true)]
[InlineData(true, true)]
[InlineData(false, false)]
public void ARefusedDiscreteRoundCostsNothing(bool single, bool viaTrigger)
{
var fired = Pull(40f, single);
var fired = Pull(40f, single, viaTrigger);
Assert.True(fired.Shots == 1, $"shots {fired.Shots}");
Assert.True(fired.Ammo == 1, $"ammo spent {fired.Ammo}");
Assert.True(fired.ChargeSpent > 1f, "a fired round spends its energy");
@ -423,7 +427,7 @@ public sealed class FireControlPerWeaponTests : IDisposable
Assert.True(fired.Visible);
Assert.Equal(1, fired.Announced);
var refused = Pull(29.5f, single);
var refused = Pull(29.5f, single, viaTrigger);
Assert.Equal(0, refused.Shots);
Assert.Equal(0, refused.Ammo);
Assert.Equal(0f, refused.ChargeSpent);
@ -434,27 +438,6 @@ public sealed class FireControlPerWeaponTests : IDisposable
Assert.Equal(0, refused.Announced);
}
// The trigger and the round are separate decisions: a burst authorised at the trigger runs its rounds later,
// at whatever Range Execute has refreshed by then, and a round that Range no longer reaches is refused before
// its spend. The authorisation is set directly (the way a trigger that passed leaves it) on a weapon whose
// Range is short of its blast radius, so the Trigger check cannot be what stops it. Kills: Execute spending
// before it asks.
[Fact]
public void ARoundRefusedAfterTheTriggerPassedIsRefusedBeforeItsSpend()
{
var r = Build(fuse: WeaponFuse.Proximity, blast: 30f, range: 29.5f, magazine: 5);
r.Shooter.Target.Value = null;
Aim(r, float2(0, 1));
var weapon = (InstantWeapon) r.Gun.Weapon;
typeof(InstantWeapon).GetField("_burstRemaining", BindingFlags.Instance | BindingFlags.NonPublic).SetValue(weapon, 1);
r.Zone.Update(.01f);
Assert.Equal(5, weapon.Ammo);
SafeAssert.NoShots(r.Zone);
Assert.Equal(0, (int) typeof(InstantWeapon).GetField("_burstRemaining", BindingFlags.Instance | BindingFlags.NonPublic).GetValue(weapon));
}
// The same rule for a beam: a fused beam whose arming distance exceeds Range fires nothing and draws
// nothing. The control (Range 40) rolls shots, wears, draws power and shows; the refused beam does none of
// that, asks the bus for nothing, and stops itself the way an out-of-arc beam does.