Fuse fixes: arming holds for every burst, no-lock rounds stop on the first hull, settle before user code
Operator ruling 2026-09-30: a no-lock round of any fuse stops at the first hull on its aim line and detonates there (bomb fishing still bursts at max range on a clear line). CommitBurst no longer special-cases Contact. Soul's findings on 14f66634..7e620ff9: - Arming (HIGH): a contact or delayed blast on a hull nearer the muzzle than the blast radius detonated at the hull. Armed is now the one owner of the arming rule; Solve plans its burst point through it and ApplyBlastHit pushes the hull point through it. - A refused beam never starts (ConstantWeapon.Activate asks Refuses), so OnStartFiring no longer flashes visuals and audio for a beam that fires nothing. - Step removes a shot from the queue before Apply runs damage observers, so a throwing observer cannot make the blast repeat every tick. - AgentFires consults Solve for a fused weapon: a refused weapon is never fired and Combat's group pick no longer counts its damage. - SingleAmmoBurst pays at its first unrefused round in Execute, judged with the Range that round flies with, instead of at the trigger on the previous tick's. - Inspect reads Designated from Solve (FireSolution.Designated) instead of recomputing visible, in-range and locked; FireControlDiagnostic drops those three fields and the debug HUD shows the one. Combat's group range test is FireControl.InReach, the range half of Designated (a LockWeapon must be activated to acquire its lock, so Combat cannot ask Designated itself). Tests pin each rule, plus the two surviving gaps: a pushed-out targeted round's arrival at the arming distance, and the AgentMinHitProbability boundary. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
parent
63a2fcdaa4
commit
57c2560fc0
8 changed files with 325 additions and 69 deletions
|
|
@ -1335,7 +1335,7 @@ public class ActionGameManager : MonoBehaviour
|
|||
: "last: none";
|
||||
var gates = target == null
|
||||
? "target: none"
|
||||
: $"gates vis {d.Visible} range {d.InRange} arc {d.InArc} lock {d.Locked}";
|
||||
: $"gates designated {d.Designated} arc {d.InArc}";
|
||||
|
||||
// A fused weapon's forecast is its outcome, not a hit chance: the burst point or the refusal.
|
||||
var outcomeLine = d.Outcome == FireOutcome.Burst ? $"burst at {d.BurstReach:F0}"
|
||||
|
|
|
|||
|
|
@ -57,10 +57,12 @@ public class CombatState : BaseState
|
|||
var group = _agent.Ship.WeaponGroups[i];
|
||||
var dps = 0f;
|
||||
foreach (var weapon in group.weapons)
|
||||
if (weapon.Item.Online.Value &&
|
||||
weapon.MinRange < targetDistance &&
|
||||
targetDistance < weapon.Range &&
|
||||
(weapon is ConstantWeapon ||
|
||||
// The range test is FireControl's (InReach, the part of Designated before the lock, which this
|
||||
// loop is what acquires), and a weapon Solve refuses adds nothing to a group's damage.
|
||||
if (weapon.Item.Online.Value &&
|
||||
FireControl.InReach(weapon, _agent.Ship, target, out _) &&
|
||||
!FireControl.Refuses(weapon, _agent.Ship) &&
|
||||
(weapon is ConstantWeapon ||
|
||||
weapon is InstantWeapon instantWeapon && instantWeapon.CanFire))
|
||||
{
|
||||
if(weapon is LockWeapon lockWeapon)
|
||||
|
|
|
|||
|
|
@ -194,7 +194,9 @@ public class ConstantWeapon : Weapon, IProgressBehavior, IEventBehavior, IPowerC
|
|||
|
||||
public override void Activate()
|
||||
{
|
||||
if(!_firing && !_reloading)
|
||||
// A refused beam never starts (operator ruling 2026-09-30): OnStartFiring drives the beam's visuals and
|
||||
// audio, so starting it only for Execute to stop it a tick later would flash a beam that fires nothing.
|
||||
if(!_firing && !_reloading && !FireControl.Refuses(this, Entity))
|
||||
{
|
||||
_firing = true;
|
||||
OnStartFiring?.Invoke();
|
||||
|
|
|
|||
|
|
@ -41,6 +41,7 @@ public class InstantWeapon : Weapon, IProgressBehavior, IEventBehavior, IPowerCo
|
|||
protected readonly InputCapacitor _capacitor = new InputCapacitor();
|
||||
|
||||
protected int _burstRemaining;
|
||||
private bool _burstPaid;
|
||||
private float _burstTimer;
|
||||
private float _burstInterval;
|
||||
protected float _cooldown; // Normalized
|
||||
|
|
@ -107,10 +108,10 @@ public class InstantWeapon : Weapon, IProgressBehavior, IEventBehavior, IPowerCo
|
|||
// whose first round FireControl would refuse.
|
||||
if (FireControl.Refuses(this, Entity)) return;
|
||||
|
||||
// If 1 ammo is consumed per burst, perform ammo and energy consumption here
|
||||
// UseAmmo returns false when triggering reload; cancel firing if that is the case
|
||||
if(_data.SingleAmmoBurst && (!TrySpendActivationEnergy() || !UseAmmo())) return;
|
||||
|
||||
// A burst that pays once (SingleAmmoBurst) pays at its first round that is not refused, in Execute, judged
|
||||
// with the Range that round flies with: paying here would judge with the Range of the previous tick, and a
|
||||
// Range that fell in between would spend the ammo on rounds Fire then refuses.
|
||||
_burstPaid = false;
|
||||
_burstRemaining = (int) BurstCount;
|
||||
_burstInterval = BurstTime / _burstRemaining;
|
||||
_burstTimer = 0;
|
||||
|
|
@ -234,13 +235,14 @@ public class InstantWeapon : Weapon, IProgressBehavior, IEventBehavior, IPowerCo
|
|||
// spent (operator ruling 2026-09-30): it costs no ammo, energy, sound, heat, wear or visibility.
|
||||
// The decision is FireControl's; Fire makes the same one.
|
||||
var refused = FireControl.Refuses(this, Entity);
|
||||
// If multiple ammo is consumed per burst, perform ammo and energy consumption here
|
||||
// UseAmmo returns false when triggering reload; cancel firing if that is the case
|
||||
if (!refused && !_data.SingleAmmoBurst && (!TrySpendActivationEnergy() || !UseAmmo()))
|
||||
// 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()))
|
||||
{
|
||||
_burstRemaining = 0;
|
||||
return false;
|
||||
}
|
||||
if (!refused) _burstPaid = _data.SingleAmmoBurst;
|
||||
|
||||
_burstRemaining--;
|
||||
_burstTimer -= _burstInterval;
|
||||
|
|
|
|||
|
|
@ -88,11 +88,15 @@ public static class FireControl
|
|||
// The agents' trigger decision (Combat, TurretController), one weapon at a time (operator ruling 2026-09-30).
|
||||
// A weapon that fires at what it hits fires when the shot is worth taking. A fused weapon fires whenever its
|
||||
// target is designated, whatever the arc: the round is sent out of arc on purpose (Solve). An agent never
|
||||
// fires a fused weapon at nothing -- no target, or one it holds no valid data on, is no reason to fish.
|
||||
public static bool AgentFires(Weapon weapon, Entity shooter, Entity target) =>
|
||||
FuseOf(weapon.Item, out _) != null
|
||||
? Designated(weapon, shooter, target, out _)
|
||||
: HitProbability(weapon, shooter, target) >= shooter.ItemManager.GameplaySettings.AgentMinHitProbability;
|
||||
// fires a fused weapon at nothing -- no target, or one it holds no valid data on, is no reason to fish -- and
|
||||
// never one Solve refuses: a refused weapon is not a weapon it can use.
|
||||
public static bool AgentFires(Weapon weapon, Entity shooter, Entity target)
|
||||
{
|
||||
if (FuseOf(weapon.Item, out _) == null)
|
||||
return HitProbability(weapon, shooter, target) >= shooter.ItemManager.GameplaySettings.AgentMinHitProbability;
|
||||
var solution = Solve(weapon, weapon.Item, shooter, target);
|
||||
return solution.Designated && solution.Outcome != FireOutcome.Refused;
|
||||
}
|
||||
|
||||
// Operator ruling 2026-09-30: a shot with nothing to bear on flies along where its shooter is aiming
|
||||
// (Entity.LookDirection -- the player's mouse or a turret's tracking), restricted by the mount's arc. The
|
||||
|
|
@ -225,21 +229,25 @@ public static class FireControl
|
|||
// visible, out of range) already denies the target. Planar (R7: the simulation is 2D): the range gate, the
|
||||
// burst reach and the range clamp all measure the same distance, so a target's height never moves one of
|
||||
// them without the others.
|
||||
public static bool Designated(Weapon weapon, Entity source, Entity target, out float range)
|
||||
public static bool Designated(Weapon weapon, Entity source, Entity target, out float range) =>
|
||||
InReach(weapon, source, target, out range) && !(weapon is LockWeapon lockWeapon && !lockWeapon.IsLocked);
|
||||
|
||||
// Designated before the lock: the target is seen and within the weapon's planar range. A LockWeapon acquires
|
||||
// its lock only while its shooter holds the trigger, so an agent asks this to know when to start locking;
|
||||
// every other reader wants Designated. The one range test in the file.
|
||||
public static bool InReach(Weapon weapon, Entity source, Entity target, out float range)
|
||||
{
|
||||
range = 0f;
|
||||
if (target == null) return false;
|
||||
range = length((target.Position - source.Position).xz);
|
||||
return source.VisibleEntities.Contains(target)
|
||||
&& range >= weapon.MinRange && range <= weapon.Range
|
||||
&& !(weapon is LockWeapon lockWeapon && !lockWeapon.IsLocked);
|
||||
return source.VisibleEntities.Contains(target) && range >= weapon.MinRange && range <= weapon.Range;
|
||||
}
|
||||
|
||||
// `designated` is the answer above. `inArc` is the bearing test, asked only once a target is designated.
|
||||
// PFire prices a shot at zero unless both hold. Fire reads them to decide what a fused round flies at
|
||||
// (operator rulings 2026-09-30): without valid data it has no target and bursts at max range; with data but
|
||||
// out of arc it bursts at the target's range, along the aim. Inspect recomputes the four gates one by one,
|
||||
// to show the HUD which is closed; it decides nothing.
|
||||
// out of arc it bursts at the target's range, along the aim. The HUD reads Designated and the
|
||||
// arc from Solve and Inspect and decides nothing.
|
||||
private static float PFire(Weapon weapon, Entity source, Entity target, out float range, out bool designated, out bool inArc)
|
||||
{
|
||||
range = 0f;
|
||||
|
|
@ -337,9 +345,7 @@ public static class FireControl
|
|||
|
||||
var toTarget = target.Position - source.Position;
|
||||
diagnostic.Range = length(toTarget.xz);
|
||||
diagnostic.Visible = source.VisibleEntities.Contains(target);
|
||||
diagnostic.InRange = diagnostic.Range >= weapon.MinRange && diagnostic.Range <= weapon.Range;
|
||||
diagnostic.Locked = !(weapon is LockWeapon lockWeapon) || lockWeapon.IsLocked;
|
||||
diagnostic.Designated = solution.Designated;
|
||||
diagnostic.InArc = InArc(weapon.Item, toTarget);
|
||||
|
||||
var targetHull = source.ItemManager.GetData(target.Hull) as HullData;
|
||||
|
|
@ -447,6 +453,9 @@ public static class FireControl
|
|||
// lock and bursts at the weapon's Range, the stat PFire's range gate reads: bomb fishing;
|
||||
// * valid data but out of arc bursts at the target's range, which the range gate has already held to
|
||||
// Range, along that same clamped aim: the arc is hoped to land the blast near enough to splash.
|
||||
// Either way the point frozen here is the farthest the round can burst: whatever its fuse, it detonates at the
|
||||
// first hull on its aim line short of it (CommitBurst; operator ruling 2026-09-30, "a no-lock round stops at
|
||||
// the first hull"), and bomb fishing bursts at this point only when the line is clear.
|
||||
// Either way the round flies at the weapon's Velocity like a targeted shot flies to its intercept. A shot
|
||||
// without a fuse keeps its target as before.
|
||||
// Arming distance (operator ruling 2026-09-30): the blast radius, measured from the shooter's position and
|
||||
|
|
@ -469,15 +478,15 @@ public static class FireControl
|
|||
// pulled back along its own line to Range.
|
||||
var intercept = PredictedIntercept(weapon, source, engaged);
|
||||
var interceptReach = length((intercept - origin).xz);
|
||||
if (min(interceptReach, weapon.Range) < arming) burstPosition = origin + float3(travelDirection.x, 0, travelDirection.y) * arming;
|
||||
else burstPosition = interceptReach > weapon.Range ? origin + (intercept - origin) * (weapon.Range / interceptReach) : intercept;
|
||||
var point = interceptReach > weapon.Range ? origin + (intercept - origin) * (weapon.Range / interceptReach) : intercept;
|
||||
burstPosition = Armed(origin, travelDirection, arming, point);
|
||||
flightDistance = max(fireRange, arming);
|
||||
}
|
||||
else if (fuse != null)
|
||||
{
|
||||
var reach = max(designated ? fireRange : weapon.Range, arming);
|
||||
burstPosition = origin + float3(travelDirection.x, 0, travelDirection.y) * reach;
|
||||
flightDistance = reach;
|
||||
var reach = designated ? fireRange : weapon.Range;
|
||||
burstPosition = Armed(origin, travelDirection, arming, origin + float3(travelDirection.x, 0, travelDirection.y) * reach);
|
||||
flightDistance = max(reach, arming);
|
||||
}
|
||||
else if (engaged != null) flightDistance = fireRange;
|
||||
|
||||
|
|
@ -486,6 +495,7 @@ public static class FireControl
|
|||
Outcome = weapon.Range < arming ? FireOutcome.Refused : fuse != null ? FireOutcome.Burst : FireOutcome.Direct,
|
||||
PFire = pFire,
|
||||
FireRange = fireRange,
|
||||
Designated = designated,
|
||||
Engaged = engaged,
|
||||
TravelDirection = travelDirection,
|
||||
BurstPosition = burstPosition,
|
||||
|
|
@ -497,6 +507,12 @@ public static class FireControl
|
|||
return solution;
|
||||
}
|
||||
|
||||
// The arming rule, once: a burst point nearer to `origin` than `arming` (planar, blind to hull size) is pushed
|
||||
// out along the unit planar `direction` to it. Solve applies it to the point it plans; the detonation of a
|
||||
// contact or delayed blast, whose point is found on the hull only at resolution, applies it to that point.
|
||||
private static float3 Armed(float3 origin, float2 direction, float arming, float3 point) =>
|
||||
length((point - origin).xz) < arming ? origin + float3(direction.x, 0, direction.y) * arming : point;
|
||||
|
||||
// Cut 3, R1: called once per burst step from InstantWeapon.Execute. Computes flight time, freezes the
|
||||
// payload snapshot (R10, Q6 -- the gun that fired it, not a re-read later), and queues a PendingShot for
|
||||
// Zone.Step to age and eventually resolve. Returns the ShotId so the caller's OnFire event can carry it to
|
||||
|
|
@ -582,9 +598,10 @@ public static class FireControl
|
|||
// its shooter (operator ruling 2026-09-30): the shooter is only an identity a shot carries, never asked
|
||||
// for at resolution, so a shooter that died or left the zone in flight changes nothing about the shot.
|
||||
// Resolution reads the zone and the target; there is no credit or loot for a shooter to receive.
|
||||
// A shot's queue state is settled before anything is published about it: it is committed in the list before
|
||||
// ShotCommitted fires and removed before ShotResolved fires, so a presentation observer that throws costs
|
||||
// that one notification and cannot leave the shot to be committed or resolved again on every later tick.
|
||||
// A shot's queue state is settled before anything that can run user code: it is committed in the list before
|
||||
// ShotCommitted fires and removed before its damage is applied (damage observers) or ShotResolved fires, so an
|
||||
// observer that throws costs that one notification, or the rest of that blast, and cannot leave the shot to be
|
||||
// committed, resolved or applied again on every later tick.
|
||||
public static void Step(Zone zone, float dt)
|
||||
{
|
||||
var shots = zone.PendingShots;
|
||||
|
|
@ -627,8 +644,10 @@ public static class FireControl
|
|||
// which model a shot uses, through its own switch on the frozen Fuse -- Step has no damage
|
||||
// branch left. A direct hit, a proximity burst and a contact or delayed blast all arrive here
|
||||
// the same way; only Apply decides what that means.
|
||||
Apply(zone, shot);
|
||||
// Removed first: Apply reaches damage observers (user code), and a shot left in the queue by one
|
||||
// that throws would resolve, and hurt, again on every later tick.
|
||||
shots.RemoveAt(i);
|
||||
Apply(zone, shot);
|
||||
zone.ShotResolved.OnNext(shot.Outcome);
|
||||
}
|
||||
}
|
||||
|
|
@ -772,9 +791,9 @@ public static class FireControl
|
|||
return outcome;
|
||||
}
|
||||
|
||||
// Operator rulings 2026-09-30. A fused round with no target bursts at the max-range point Fire froze along
|
||||
// the aim line, whatever its fuse -- except a Contact round, which bursts at the first hull that line crosses
|
||||
// before that point (a Proximity or Delayed fuse does not trip on a hull with nothing locked). The point is
|
||||
// Operator rulings 2026-09-30. A fused round with no target bursts at the first hull its aim line crosses
|
||||
// before the max-range point Fire froze, whatever its fuse (contact, proximity and delayed alike: a round with
|
||||
// nothing locked stops on what is in its way), and at the max-range point when the line is clear. The point is
|
||||
// read from this tick's poses and frozen into the outcome (R4, the same timing Commit reads facing at).
|
||||
// Nothing is rolled or drawn, so determinism needs nothing beyond the frozen inputs.
|
||||
private static ShotOutcome CommitBurst(Zone zone, PendingShot shot, float now)
|
||||
|
|
@ -782,8 +801,7 @@ public static class FireControl
|
|||
var outcome = MakeOutcome(shot, ShotResult.Burst, false, false, int2.zero, float2.zero, 0f, now);
|
||||
outcome.HasBurstPoint = true;
|
||||
var reach = length(shot.BurstPosition.xz - shot.FireOrigin.xz);
|
||||
var travelled = shot.Fuse == WeaponFuse.Contact ? ContactDistance(zone, shot, reach) : reach;
|
||||
travelled = min(reach, max(travelled, shot.BlastRadius));
|
||||
var travelled = min(reach, max(HullDistance(zone, shot, reach), shot.BlastRadius));
|
||||
if (travelled < reach)
|
||||
{
|
||||
outcome.BurstPoint = shot.FireOrigin.xz + shot.TravelDirection * travelled;
|
||||
|
|
@ -794,11 +812,11 @@ public static class FireControl
|
|||
return outcome;
|
||||
}
|
||||
|
||||
// The distance along a no-lock round's aim line to the first thing a contact fuse trips on, or `reach` (the
|
||||
// The distance along a no-lock round's aim line to the first hull it crosses, or `reach` (the
|
||||
// max-range point) when it meets nothing first. The shooter's own hull is never a candidate.
|
||||
// SEAM FOR THE MINING CAMPAIGN: mining chunks are not Entities and are not on this branch. When they land,
|
||||
// whatever they are contributes its own entry distance to this minimum, and nothing else in the file changes.
|
||||
private static float ContactDistance(Zone zone, PendingShot shot, float reach)
|
||||
private static float HullDistance(Zone zone, PendingShot shot, float reach)
|
||||
{
|
||||
var nearest = reach;
|
||||
foreach (var entity in zone.Entities)
|
||||
|
|
@ -813,7 +831,7 @@ public static class FireControl
|
|||
// `origin` to the first metal cell ahead, +infinity when the line meets none, and negative when the origin is
|
||||
// already inside the hull (the blast radius floors every caller's use of it). Read through Lane's own cell
|
||||
// admission (CellsOnLine), in the entity's schematic frame: the line's lateral offset is its own, and cells
|
||||
// wholly behind the origin are skipped. Only a contact fuse asks.
|
||||
// wholly behind the origin are skipped. Only a no-lock round asks.
|
||||
private static float HullEntry(Entity entity, float2 origin, float2 direction)
|
||||
{
|
||||
var hullData = entity.ItemManager.GetData(entity.Hull) as HullData;
|
||||
|
|
@ -1025,7 +1043,10 @@ public static class FireControl
|
|||
// cancels the host's pose, so the host's own damage is fixed by the commit (R4 holds for it), while
|
||||
// every bystander is judged against where it actually is at arrival -- the same live rule a proximity
|
||||
// burst already applies to everyone.
|
||||
var worldPoint = target.ToWorldPoint(schematicPoint);
|
||||
// The arming rule applies to this point as it does to every other burst (Solve's Armed): a hull met nearer
|
||||
// the muzzle than the blast radius is detonated on at the radius, along the round's own flight line.
|
||||
var hullPoint = target.ToWorldPoint(schematicPoint);
|
||||
var worldPoint = Armed(shot.FireOrigin, shot.TravelDirection, shot.BlastRadius, float3(hullPoint.x, shot.FireOrigin.y, hullPoint.y)).xz;
|
||||
Detonate(target.Zone, worldPoint, shot.BlastRadius, shot.Damage, shot.DamageType);
|
||||
}
|
||||
|
||||
|
|
@ -1789,6 +1810,8 @@ public struct FireSolution
|
|||
public float FlightDistance;
|
||||
public float PFire;
|
||||
public float FireRange;
|
||||
// Whether the shooter holds valid targeting data on the target (Designated); the HUD and the agents read it here.
|
||||
public bool Designated;
|
||||
public Entity Engaged;
|
||||
public float2 TravelDirection;
|
||||
public float3 BurstPosition;
|
||||
|
|
@ -1799,9 +1822,7 @@ public struct FireSolution
|
|||
public struct FireControlDiagnostic
|
||||
{
|
||||
public bool HasTarget;
|
||||
public bool Visible;
|
||||
public bool InRange;
|
||||
public bool Locked;
|
||||
public bool Designated;
|
||||
public bool InArc;
|
||||
public float Range;
|
||||
public float MinRange;
|
||||
|
|
|
|||
|
|
@ -1520,6 +1520,103 @@ public sealed class FireControlCut124Tests : IDisposable
|
|||
SafeAssert.NoShots(e.Zone);
|
||||
}
|
||||
|
||||
public enum Observer { HullDamage, ArmorDamage, IncomingHit }
|
||||
|
||||
// Nothing the queue does is left to a user callback that may throw: a shot is out of the queue before its
|
||||
// damage is applied, so a damage observer that throws costs the rest of that blast, once, and the shot is not
|
||||
// applied again on every later tick (its blast, and the observer, used to repeat forever). The observer throws
|
||||
// on its first call; the following tick is clean and the observer has been called exactly once. Contact for
|
||||
// the hit report (a roll can miss, so it retries), Proximity for the damage events.
|
||||
// Kills: applying the shot's damage before removing it from the queue.
|
||||
[Theory]
|
||||
[InlineData(Observer.HullDamage)]
|
||||
[InlineData(Observer.ArmorDamage)]
|
||||
[InlineData(Observer.IncomingHit)]
|
||||
public void AThrowingDamageObserverIsCalledOnceAndCannotWedgeTheShotQueue(Observer observer)
|
||||
{
|
||||
var e = Build(TestSettings(), SolidShape(5, 4), velocity: 0, fuse: observer == Observer.IncomingHit ? WeaponFuse.Contact : WeaponFuse.Proximity,
|
||||
blastRadius: 4f, penetration: 1f, damage: 100f);
|
||||
if (observer == Observer.ArmorDamage) ArmourEverything(e);
|
||||
var calls = 0;
|
||||
void Throw() { calls++; throw new InvalidOperationException("observer"); }
|
||||
using var a = observer == Observer.ArmorDamage ? e.Target.ArmorDamage.Subscribe(_ => Throw()) : Disposable.Empty;
|
||||
using var h = observer == Observer.HullDamage ? e.Target.HullDamage.Subscribe(_ => Throw()) : Disposable.Empty;
|
||||
using var i = observer == Observer.IncomingHit ? e.Target.IncomingHit.Subscribe(_ => Throw()) : Disposable.Empty;
|
||||
|
||||
for (var attempt = 0; attempt < 60 && calls == 0; attempt++)
|
||||
{
|
||||
FireControl.Fire(e.Weapon, e.WeaponItem, e.Shooter);
|
||||
Record.Exception(() => e.Zone.Update(.01f));
|
||||
}
|
||||
Assert.Equal(1, calls);
|
||||
var later = Record.Exception(() => e.Zone.Update(.01f));
|
||||
|
||||
Assert.Null(later);
|
||||
Assert.Equal(1, calls);
|
||||
SafeAssert.NoShots(e.Zone);
|
||||
}
|
||||
|
||||
// A round pushed out to its arming distance flies the arming distance: its arrival is that far off, not the
|
||||
// target's own (5 units, a quarter second) range. 30 units at 20 units/s is 1.5 seconds. Kills: a flight
|
||||
// distance of the target's range for a pushed-out round.
|
||||
[Fact]
|
||||
public void APushedOutRoundDoesNotArriveBeforeItHasFlownTheArmingDistance()
|
||||
{
|
||||
var e = Build(TestSettings(), SolidShape(5, 4), velocity: 20f, fuse: WeaponFuse.Proximity, blastRadius: 30f, damage: 100f,
|
||||
weaponRange: 60f, targetRange: 5f);
|
||||
Aim(e, float2(0, 1));
|
||||
|
||||
FireControl.Fire(e.Weapon, e.WeaponItem, e.Shooter);
|
||||
|
||||
var shot = SafeAssert.OnlyShot(e.Zone);
|
||||
Assert.True(shot.Target == e.Target, "fixture: the pushed-out round keeps its target");
|
||||
Assert.Equal(1.5f, shot.ArrivalTime - shot.FireTime, 3);
|
||||
}
|
||||
|
||||
// What the shooter's own hull takes from one round at `targetRange`, counting only rounds that hit when the
|
||||
// fuse needs a hit (a roll can miss, so it retries).
|
||||
private static float ShooterSplash(Engagement e, bool needsHit)
|
||||
{
|
||||
var taken = 0f;
|
||||
using var a = e.Shooter.ArmorDamage.Subscribe(x => taken += x.damage);
|
||||
using var h = e.Shooter.HullDamage.Subscribe(x => taken += x);
|
||||
using var i = e.Shooter.ItemDamage.Subscribe(x => taken += x.damage);
|
||||
ShotOutcome outcome = null;
|
||||
using var r = e.Zone.ShotResolved.Subscribe(o => outcome = o);
|
||||
for (var attempt = 0; attempt < 60; attempt++)
|
||||
{
|
||||
taken = 0f;
|
||||
outcome = null;
|
||||
FireControl.Fire(e.Weapon, e.WeaponItem, e.Shooter);
|
||||
e.Zone.Update(.01f);
|
||||
if (!needsHit || outcome != null && outcome.Hit) return taken;
|
||||
}
|
||||
throw new InvalidOperationException("no round hit in 60 attempts");
|
||||
}
|
||||
|
||||
// The arming ruling holds for every fuse, at the point a contact or delayed round detonates on a hull: no
|
||||
// burst nearer to the shooter than the blast radius. A round that hits a target inside the radius (5 out), or
|
||||
// one whose near face is inside it (a target 32 out has its face at 28), is detonated on at the radius (30),
|
||||
// so the shooter takes what any burst 30 out on its line deals: the amount a proximity round at exactly 30
|
||||
// deals. Kills: a hull-point detonation that ignores the arming distance (the shooter then took 7.07 from a
|
||||
// blast 30 at 5 out, more than the armed burst).
|
||||
[Theory]
|
||||
[InlineData(WeaponFuse.Contact, 5f)]
|
||||
[InlineData(WeaponFuse.Delayed, 5f)]
|
||||
[InlineData(WeaponFuse.Contact, 32f)]
|
||||
public void AContactOrDelayedBlastNeverDetonatesNearerThanTheArmingDistance(WeaponFuse fuse, float targetRange)
|
||||
{
|
||||
var armed = ShooterSplash(Build(TestSettings(), SolidShape(5, 4), velocity: 0, fuse: WeaponFuse.Proximity, blastRadius: 30f, penetration: 1f,
|
||||
damage: 100f, weaponRange: 60f, targetRange: 30f), needsHit: false);
|
||||
Assert.True(armed > 0f, "fixture: the shooter's own hull takes splash from a burst at the radius");
|
||||
|
||||
var e = Build(TestSettings(), SolidShape(5, 4), velocity: 0, fuse: fuse, blastRadius: 30f, penetration: 1f,
|
||||
damage: 100f, weaponRange: 60f, targetRange: targetRange);
|
||||
var splash = ShooterSplash(e, needsHit: true);
|
||||
|
||||
Assert.Equal(armed, splash, 3);
|
||||
}
|
||||
|
||||
// A target beyond the arming distance is not pushed anywhere: the round still rides it and bursts at its
|
||||
// intercept. Kills: demoting every fused round to the aim.
|
||||
[Fact]
|
||||
|
|
@ -1661,7 +1758,7 @@ public sealed class FireControlCut124Tests : IDisposable
|
|||
Assert.True(shot.Target == e.Target, "the target is inside Range in the plane, so it is valid data");
|
||||
Assert.Equal(40f, Planar(e.Shooter.Position, shot.BurstPosition), 2);
|
||||
var diagnostic = FireControl.Inspect(e.Weapon, e.Shooter, e.Target);
|
||||
Assert.True(diagnostic.InRange, "the HUD's range gate is planar too");
|
||||
Assert.True(diagnostic.Designated, "the HUD's designation is planar too");
|
||||
Assert.Equal(40f, diagnostic.Range, 2);
|
||||
}
|
||||
|
||||
|
|
@ -1790,15 +1887,15 @@ public sealed class FireControlCut124Tests : IDisposable
|
|||
Assert.True(e.Zone.PendingShots[0].Committed, "and commits once its horizon is reached, with the round still in flight");
|
||||
}
|
||||
|
||||
// Ruling 3: a Contact round with no lock stops on the first hull its aim line crosses before max range; a
|
||||
// Proximity or Delayed round keeps the max-range burst. The blocker's near face sits 21 units out (a 5x4-cell
|
||||
// hull of 2-unit cells centred 25 out), the witness at max range (60). Kills: a Contact fuse that ignores
|
||||
// hulls; a Proximity or Delayed fuse that trips on one.
|
||||
// Ruling 3 (operator, 2026-09-30): a no-lock round of any fuse stops on the first hull its aim line crosses
|
||||
// before max range and detonates there; with the line clear it bursts at max range. The blocker's near face
|
||||
// sits 21 units out (a 5x4-cell hull of 2-unit cells centred 25 out), the witness at max range (60). Kills: a
|
||||
// fuse that ignores hulls (a Proximity or Delayed round used to fly on to max range).
|
||||
[Theory]
|
||||
[InlineData(WeaponFuse.Contact, true)]
|
||||
[InlineData(WeaponFuse.Proximity, false)]
|
||||
[InlineData(WeaponFuse.Delayed, false)]
|
||||
public void OnlyAContactRoundWithNoLockStopsOnTheFirstHullItCrosses(WeaponFuse fuse, bool stopsOnTheBlocker)
|
||||
[InlineData(WeaponFuse.Contact)]
|
||||
[InlineData(WeaponFuse.Proximity)]
|
||||
[InlineData(WeaponFuse.Delayed)]
|
||||
public void ANoLockRoundOfAnyFuseStopsOnTheFirstHullItCrosses(WeaponFuse fuse)
|
||||
{
|
||||
var e = Build(TestSettings(), SolidShape(5, 4), velocity: 0, fuse: fuse, blastRadius: 4f, penetration: 1f, damage: 100f,
|
||||
weaponRange: NoLockRange);
|
||||
|
|
@ -1813,11 +1910,11 @@ public sealed class FireControlCut124Tests : IDisposable
|
|||
e.Zone.Update(.01f);
|
||||
|
||||
Assert.NotNull(outcome);
|
||||
var expectedReach = stopsOnTheBlocker ? 21f : NoLockRange;
|
||||
const float expectedReach = 21f;
|
||||
Assert.Equal(e.Shooter.Position.z + expectedReach, outcome.BurstPoint.y, 2);
|
||||
Assert.Equal(e.Shooter.Position.x, outcome.BurstPoint.x, 2);
|
||||
Assert.Equal(!stopsOnTheBlocker, Alive(e, blocker));
|
||||
Assert.Equal(stopsOnTheBlocker, Alive(e, atMaxRange));
|
||||
Assert.False(Alive(e, blocker));
|
||||
Assert.True(Alive(e, atMaxRange), "the witness at max range is beyond the stop");
|
||||
}
|
||||
|
||||
// Ruling 3: a hull beyond max range is not on the round's way, and the shooter's own hull, which the line
|
||||
|
|
|
|||
|
|
@ -428,7 +428,7 @@ public sealed class FireControlCut9Tests : IDisposable
|
|||
e.Shooter.EntityInfoGathered[e.Target] = info;
|
||||
var hud = FireControl.Inspect(e.Weapon, e.Shooter, e.Target);
|
||||
var hot = FireControl.HitProbability(e.Weapon, e.Shooter, e.Target);
|
||||
Assert.True(hud.Visible && hud.InRange && hud.Locked && hud.InArc, $"info {info}: precondition, every gate open");
|
||||
Assert.True(hud.Designated && hud.InArc, $"info {info}: precondition, every gate open");
|
||||
Assert.True(hot > 0f, $"info {info}, Precision {precision}: precondition, a shot that can land");
|
||||
Assert.Equal(hot, hud.Accuracy * hud.PSensor * hud.PSpread * hud.POnHull, 5);
|
||||
sawPartialSpread |= hud.PSpread < .99f;
|
||||
|
|
@ -445,7 +445,7 @@ public sealed class FireControlCut9Tests : IDisposable
|
|||
}
|
||||
|
||||
e.Target.Position = origin + float3(0, 0, 5000);
|
||||
Closed("out of range", d => d.InRange);
|
||||
Closed("out of range", d => d.Designated);
|
||||
e.Target.Position = origin + float3(0, 0, -100);
|
||||
Closed("behind the mount", d => d.InArc);
|
||||
|
||||
|
|
@ -455,13 +455,13 @@ public sealed class FireControlCut9Tests : IDisposable
|
|||
e.Target.Position = float3(600, 0, 500);
|
||||
var far = FireControl.Inspect(e.Weapon, e.Shooter, e.Target);
|
||||
Assert.Equal(500f, far.Range, 3);
|
||||
Assert.True(far.InRange && FireControl.HitProbability(e.Weapon, e.Shooter, e.Target) > 0f, "shooter far from the origin");
|
||||
Assert.True(far.Designated && FireControl.HitProbability(e.Weapon, e.Shooter, e.Target) > 0f, "shooter far from the origin");
|
||||
checkedConfigurations++;
|
||||
e.Shooter.Position = origin;
|
||||
e.Target.Position = origin + float3(0, 0, 100);
|
||||
|
||||
e.Shooter.VisibleEntities.Remove(e.Target);
|
||||
Closed("not visible", d => d.Visible);
|
||||
Closed("not visible", d => d.Designated);
|
||||
|
||||
Assert.False(FireControl.Inspect(e.Weapon, e.Shooter, null).HasTarget);
|
||||
Assert.Equal(0f, FireControl.HitProbability(e.Weapon, e.Shooter, null));
|
||||
|
|
|
|||
|
|
@ -75,6 +75,7 @@ public sealed class FireControlPerWeaponTests : IDisposable
|
|||
public Ship Target;
|
||||
public List<Gun> Guns = new List<Gun>();
|
||||
public Gun Gun => Guns[0];
|
||||
public Gun Decoy;
|
||||
}
|
||||
|
||||
// Mount 0 (None, arc 0) bears on a target dead ahead; (Clockwise, arc 0) is a side mount the default 170
|
||||
|
|
@ -83,7 +84,7 @@ public sealed class FireControlPerWeaponTests : IDisposable
|
|||
private Rig Build(
|
||||
(ItemRotation Rotation, float Arc)[] mounts = null, bool beam = false, WeaponFuse? fuse = null, float blast = 0f,
|
||||
float range = 100f, float targetRange = 60f, float accuracy = 1f, float minHit = .2f, bool lockWeapon = false,
|
||||
bool turret = false, float energy = 0f, float heat = 0f, float visibility = 0f, int magazine = 0, bool singleAmmoBurst = false)
|
||||
bool turret = false, float energy = 0f, float heat = 0f, float visibility = 0f, int magazine = 0, bool singleAmmoBurst = false, bool decoy = false)
|
||||
{
|
||||
mounts ??= new[] { (ItemRotation.None, 0f) };
|
||||
var hullData = new HullData
|
||||
|
|
@ -101,6 +102,14 @@ public sealed class FireControlPerWeaponTests : IDisposable
|
|||
Type = HardpointType.Sensors, Position = new int2(i, 0), Shape = new Shape(),
|
||||
Rotation = mounts[i].Rotation, FiringArc = mounts[i].Arc
|
||||
});
|
||||
// A second weapon type on its own mount (a group of its own, since groups are per item type), dead ahead
|
||||
// and unfused, weaker than the gun: what a group pick falls back to when the gun's group is refused.
|
||||
if (decoy)
|
||||
shooterHullData.Hardpoints.Add(new HardpointData
|
||||
{
|
||||
Type = HardpointType.Sensors, Position = new int2(mounts.Length, 0), Shape = new Shape(),
|
||||
Rotation = ItemRotation.None, FiringArc = 0f
|
||||
});
|
||||
|
||||
WeaponData behavior;
|
||||
if (beam)
|
||||
|
|
@ -138,6 +147,21 @@ public sealed class FireControlPerWeaponTests : IDisposable
|
|||
Fuse = fuse, BlastRadius = blast,
|
||||
Behaviors = { behavior }
|
||||
});
|
||||
cache.Upsert(new WeaponItemData
|
||||
{
|
||||
Name = "Decoy", Hardpoint = HardpointType.Sensors, Shape = new Shape(), Durability = 1,
|
||||
MinimumTemperature = -1000, MaximumTemperature = 1000, OptimalTemperature = 0, PlateauWidth = 2000,
|
||||
Behaviors =
|
||||
{
|
||||
new InstantWeaponData
|
||||
{
|
||||
Count = Constant(1), BurstTime = Constant(0), Cooldown = Constant(1), Damage = Constant(50), Range = Constant(100),
|
||||
MinRange = Constant(0), Velocity = Constant(0), Spread = Constant(0), DamageSpread = Constant(0), Penetration = Constant(0),
|
||||
Energy = Constant(0), Heat = Constant(0), Visibility = Constant(0),
|
||||
DamageCurve = new BezierCurve { Keys = new[] { float4(0, 1, 0, 0), float4(1, 1, 0, 0) } }
|
||||
}
|
||||
}
|
||||
});
|
||||
cache.Upsert(new GearData
|
||||
{
|
||||
Name = "Targeting", Hardpoint = HardpointType.Tool, Shape = new Shape(), Durability = 1,
|
||||
|
|
@ -180,6 +204,13 @@ public sealed class FireControlPerWeaponTests : IDisposable
|
|||
var item = shooter.Equipment.Single(x => x.EquippableItem == gun);
|
||||
rig.Guns.Add(new Gun { Item = item, Weapon = (Weapon) item.Behaviors.Single(b => b is Weapon) });
|
||||
}
|
||||
if (decoy)
|
||||
{
|
||||
var d = Make("Decoy", lot++, 1);
|
||||
Assert.True(shooter.TryEquip(d, new int2(mounts.Length, 0)));
|
||||
var item = shooter.Equipment.Single(x => x.EquippableItem == d);
|
||||
rig.Decoy = new Gun { Item = item, Weapon = (Weapon) item.Behaviors.Single(b => b is Weapon) };
|
||||
}
|
||||
var targeting = Make("Targeting", lot++, 1);
|
||||
Assert.True(shooter.TryFindSpace(targeting, out var tpos));
|
||||
Assert.True(shooter.TryEquip(targeting, tpos));
|
||||
|
|
@ -374,7 +405,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, bool viaTrigger)
|
||||
private Costs Pull(float range, bool single, bool viaTrigger, bool staleRange = false)
|
||||
{
|
||||
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,6 +424,9 @@ public sealed class FireControlPerWeaponTests : IDisposable
|
|||
var ammoBefore = weapon.Ammo;
|
||||
Assert.True(chargeBefore > 0f && ammoBefore == 5, "fixture: a full buffer and magazine");
|
||||
|
||||
// The Range the previous tick's Execute left, before this tick refreshes it: a power-dependent Range that
|
||||
// has since fallen. Set through the property's own setter, so the trigger sees a Range the round will not.
|
||||
if (staleRange) typeof(Weapon).GetProperty("Range").GetSetMethod(true).Invoke(weapon, new object[] { 100f });
|
||||
if (viaTrigger) weapon.Activate();
|
||||
else typeof(InstantWeapon).GetField("_burstRemaining", BindingFlags.Instance | BindingFlags.NonPublic).SetValue(weapon, 1);
|
||||
r.Zone.Update(.01f);
|
||||
|
|
@ -406,7 +440,7 @@ 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. `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 its first unrefused round rather than at each;
|
||||
// `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
|
||||
|
|
@ -440,7 +474,7 @@ public sealed class FireControlPerWeaponTests : IDisposable
|
|||
|
||||
// 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.
|
||||
// that, asks the bus for nothing, and never starts (ARefusedBeamNeverStarts).
|
||||
// Kills: ConstantWeapon spending (power request, wear, heat, visibility) before the refusal, or not
|
||||
// consulting FireControl at all.
|
||||
private (int Shots, int Wears, float Request, bool Visible, float Heat, int Stops, bool Firing) Burn(float range)
|
||||
|
|
@ -481,6 +515,104 @@ public sealed class FireControlPerWeaponTests : IDisposable
|
|||
Assert.Equal(0f, refused.Request);
|
||||
Assert.False(refused.Visible);
|
||||
Assert.True(fired.Heat - refused.Heat > 5f, $"a refused beam adds no heat ({refused.Heat} vs {fired.Heat})");
|
||||
Assert.True(refused.Stops > 0 && !refused.Firing, "a refused beam stops itself, as an out-of-arc beam does");
|
||||
Assert.True(refused.Stops == 0 && !refused.Firing, "a refused beam never starts, so it never has to stop");
|
||||
}
|
||||
|
||||
// A beam is a stream of rounds, so a refused beam never starts: OnStartFiring drives the beam's visuals and
|
||||
// audio, and starting it for Execute to stop a tick later would flash a beam that fires nothing. The control
|
||||
// (Range 40) starts once and keeps firing. Kills: Activate that starts a beam without asking FireControl.
|
||||
[Theory]
|
||||
[InlineData(40f, 1)]
|
||||
[InlineData(29.5f, 0)]
|
||||
public void ARefusedBeamNeverStarts(float range, int starts)
|
||||
{
|
||||
var r = Build(beam: true, fuse: WeaponFuse.Proximity, blast: 30f, range: range);
|
||||
r.Shooter.Target.Value = null;
|
||||
Aim(r, float2(0, 1));
|
||||
var weapon = (ConstantWeapon) r.Gun.Weapon;
|
||||
var started = 0;
|
||||
weapon.OnStartFiring += () => started++;
|
||||
|
||||
weapon.Activate();
|
||||
for (var i = 0; i < 5; i++) r.Zone.Update(.1f);
|
||||
|
||||
Assert.Equal(starts, started);
|
||||
Assert.Equal(starts == 1, weapon.Firing);
|
||||
}
|
||||
|
||||
// A refused weapon is not one an agent can use: a fused weapon whose arming distance (30) exceeds its Range
|
||||
// (29.5) is not fired at a target inside that Range, where the same weapon with Range to spare is.
|
||||
// Kills: AgentFires answering "designated" without asking whether Solve refuses.
|
||||
[Theory]
|
||||
[InlineData(29.5f, false)]
|
||||
[InlineData(40f, true)]
|
||||
public void AnAgentNeverFiresARefusedFusedWeapon(float range, bool fires)
|
||||
{
|
||||
var r = Build(fuse: WeaponFuse.Proximity, blast: 30f, range: range, targetRange: 20f);
|
||||
|
||||
Assert.Equal(fires, FireControl.AgentFires(r.Gun.Weapon, r.Shooter, r.Target));
|
||||
}
|
||||
|
||||
// Combat's group pick counts only weapons that can fire: the refused gun's group out-damages the decoy's, so a
|
||||
// pick that counted it would select a group whose weapons are then never fired, and the decoy would stay silent.
|
||||
// Kills: Combat counting a refused weapon's damage toward its group.
|
||||
[Fact]
|
||||
public void CombatDoesNotPickAGroupWhoseWeaponIsRefused()
|
||||
{
|
||||
var r = Build(fuse: WeaponFuse.Proximity, blast: 30f, range: 29.5f, targetRange: 20f, decoy: true);
|
||||
Assert.True(r.Gun.Weapon.RangeDamagePerSecond(20f) > r.Decoy.Weapon.RangeDamagePerSecond(20f), "fixture: the refused gun is the stronger group");
|
||||
r.Zone.Agents.Add(new Minion(r.Shooter));
|
||||
|
||||
r.Zone.Update(1f);
|
||||
r.Zone.Update(1f);
|
||||
|
||||
Assert.False(r.Gun.Weapon.Firing, "a refused weapon is never activated");
|
||||
Assert.True(r.Decoy.Weapon.Firing, "the group that can fire is the one picked");
|
||||
}
|
||||
|
||||
// Combat's range test is Designated's, not a second one: a target exactly at max range is in reach (the old
|
||||
// private test was strict). Kills: a range comparison of Combat's own.
|
||||
[Fact]
|
||||
public void CombatCountsATargetExactlyAtMaxRangeAsInReach()
|
||||
{
|
||||
var r = Build(range: 60f, targetRange: 60f);
|
||||
// The agent is driven directly, not through the zone, so the shooter stays exactly at max range: the first
|
||||
// update leaves the root state for combat, the second is combat deciding.
|
||||
var agent = new Minion(r.Shooter);
|
||||
agent.Update(.01f);
|
||||
agent.Update(.01f);
|
||||
|
||||
Assert.True(r.Gun.Weapon.Firing);
|
||||
}
|
||||
|
||||
// The trigger's threshold is inclusive: a shot priced exactly at AgentMinHitProbability is worth taking, and
|
||||
// one priced a hair under it is not. Kills: `>` for `>=`.
|
||||
[Fact]
|
||||
public void AnAgentFiresAtExactlyTheHitProbabilityThreshold()
|
||||
{
|
||||
var probe = Build(accuracy: .5f);
|
||||
var price = FireControl.HitProbability(probe.Gun.Weapon, probe.Shooter, probe.Target);
|
||||
Assert.True(price > 0f);
|
||||
|
||||
var atThreshold = Build(accuracy: .5f, minHit: price);
|
||||
var overThreshold = Build(accuracy: .5f, minHit: MathF.BitIncrement(price));
|
||||
|
||||
Assert.True(FireControl.AgentFires(atThreshold.Gun.Weapon, atThreshold.Shooter, atThreshold.Target));
|
||||
Assert.False(FireControl.AgentFires(overThreshold.Gun.Weapon, overThreshold.Shooter, overThreshold.Target));
|
||||
}
|
||||
|
||||
// A round refused on a Range that fell between the trigger and the round is still free: the burst is paid
|
||||
// (SingleAmmoBurst) at its first round that is not refused, judged with the Range that round flies with.
|
||||
// The trigger saw a stale Range of 100; the round's Range is 29.5, under the arming distance.
|
||||
// Kills: paying for the burst at the trigger.
|
||||
[Fact]
|
||||
public void ARefusedBurstThatPassedTheTriggerOnAStaleRangeCostsNothing()
|
||||
{
|
||||
var refused = Pull(29.5f, single: true, viaTrigger: true, staleRange: true);
|
||||
|
||||
Assert.Equal(0, refused.Shots);
|
||||
Assert.Equal(0, refused.Ammo);
|
||||
Assert.Equal(0f, refused.ChargeSpent);
|
||||
Assert.Equal(0, refused.Announced);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
Loading…
Reference in a new issue