Fused rounds fire out of arc; safe fuse; proximity misses type as Burst

Operator ruling 2026-09-30: a fused weapon with a selected but out-of-arc
target fires. Its round flies along the arc-clamped aim and bursts at the
target's range. Non-fused weapons still refuse.

- FireControl.ArcPermitsFire is the one trigger arc gate (fused weapons are
  exempt); Fire reads the same InArc through PFire's new designated/inArc
  outs, which replace `bears`. No valid targeting data still bursts at Range.
- F3: a fused weapon whose effective Range does not exceed its BlastRadius
  stays safed at the trigger (FuseCanArm, read from the runtime Range). The
  catalog check on Range.Min is deleted; it read the authored floor, which
  Evaluate can scale below.
- F4: a targeted proximity burst is pulled back along its line to Range.
- F8: a proximity round that detonates commits as Burst (or Hit); Apply's
  Proximity case collapses into the Burst path via Outcome.BurstPoint.
- Tests: arc-only and LockWeapon gates, out-of-arc burst, trigger gate,
  runtime-Range refusal, Range clamp, Burst typing, commit horizon, and
  Soul's adopted probes (aim clamp edges, contact point, target leaves,
  shooter destroyed).

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
Meta Vens 2026-09-29 16:27:46 +02:00
commit 640fa96787
8 changed files with 609 additions and 145 deletions

View file

@ -30,7 +30,7 @@ public static class AetheriaStores
if (missing != null)
throw new InvalidOperationException($"Catalog {catalogPath} has no {missing.SchemaName} record; catalog globals are authored, never invented.");
// Every catalog document is held to the one check Upsert applies on write: R-heat (docs/stats-and-power-
// cut.md), stat modifiers, role usage (Cut 7), and the fuse range rule. Fails loudly, naming the item.
// cut.md), stat modifiers, role usage (Cut 7). Fails loudly, naming the item.
foreach (var data in cache.GetAll<EquippableItemData>()) CultRecordRefs.Validate(data);
foreach (var data in cache.GetAll<ConsumableItemData>()) CultRecordRefs.Validate(data);
return cache;
@ -73,7 +73,6 @@ public static class CultRecordRefs
StatValidation.ValidateHeatResponse(data);
StatValidation.ValidateStatModifiers(data.Name, data.Behaviors);
StatValidation.ValidateRoleUsage(data.Name, data.Roles, data.Behaviors);
StatValidation.ValidateFuse(data);
}
if (document is ConsumableItemData consumable)
{

View file

@ -91,7 +91,7 @@ public class ConstantWeapon : Weapon, IProgressBehavior, IEventBehavior, IPowerC
// Cut 5 (docs/fire-control-cut.md, 5.3, Soul finding 9): StanceAllowsFire alone let a side-mounted beam
// fire forward -- ArcAllowsFire joins it here, matching InstantWeapon.cs's player arc gate (Q2: manual and
// programmatic are one truth, and a continuous weapon is no exception).
public float PowerRequest(float dt) => _firing && StanceAllowsFire && ArcAllowsFire ? EvaluateNominalPower(_data.Energy) * dt : 0f;
public float PowerRequest(float dt) => _firing && StanceAllowsFire && ArcAllowsFire && FuseAllowsFire ? EvaluateNominalPower(_data.Energy) * dt : 0f;
// Cut 5 (docs/stats-and-power-cut.md §1.3, PowerTiers.cs): Low -- offense, same as InstantWeapon.
public int DefaultPowerTier => PowerTiers.Low;
@ -99,7 +99,7 @@ public class ConstantWeapon : Weapon, IProgressBehavior, IEventBehavior, IPowerC
public override bool Execute(float dt)
{
base.Execute(dt);
if (_firing && !(StanceAllowsFire && ArcAllowsFire))
if (_firing && !(StanceAllowsFire && ArcAllowsFire && FuseAllowsFire))
{
// Safed: shooter has a target and hasn't declared hostility toward it, or the target no longer
// bears (5.3: a beam obeys its arc exactly as InstantWeapon's trigger does).

View file

@ -103,6 +103,8 @@ public class InstantWeapon : Weapon, IProgressBehavior, IEventBehavior, IPowerCo
// through -- a player's action-bar Activate() reaches this exactly the same way Combat.cs's and
// TurretController.cs's Activate() calls do.
if (!ArcAllowsFire) return;
// A fused weapon whose Range no longer clears its blast would burst on its shooter.
if (!FuseAllowsFire) 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

View file

@ -107,16 +107,11 @@ public abstract class Weapon : Behavior, IActivatedBehavior
// gate, not the gate itself. With no target set, behaviour is unchanged (nothing to bear on).
// Cut 5, 5.4 (Soul finding 11): the point-blank special case this used to carry moved into InArc itself --
// one bearing test, one owner, so every caller (not just this one) gets a point-blank shot that bears.
public bool ArcAllowsFire
{
get
{
var target = Entity.Target.Value;
if (target == null) return true;
var toTarget = target.Position - Entity.Position;
return FireControl.InArc(Item, toTarget);
}
}
// FireControl owns the gate, including its one exemption: a fused weapon fires out of arc.
public bool ArcAllowsFire => FireControl.ArcPermitsFire(this, Entity);
// A fused weapon whose current Range does not exceed its blast stays safed (FireControl.FuseCanArm).
public bool FuseAllowsFire => FireControl.FuseCanArm(this);
public Weapon(WeaponData data, EquippedItem item) : base(data, item)
{

View file

@ -57,6 +57,33 @@ public static class FireControl
return dot(MountDirection(weapon), planarTarget) >= cos(radians(arc / 2f));
}
// The one place a weapon's payload is read: its blast radius, and the fuse it detonates with. A weapon
// detonates only with both a fuse and a radius above zero; either alone is inert and resolves as a direct
// hit (nothing polices that), so a null return means "not fused".
public static WeaponFuse? FuseOf(EquippedItem item, out float blastRadius)
{
var data = item?.Data as WeaponItemData;
blastRadius = data?.BlastRadius > 0f ? data.BlastRadius.Value : 0f;
return blastRadius > 0f ? data.Fuse : null;
}
// The one arc gate on the trigger. A fused weapon fires whatever the arc says: a target it cannot bear on is
// no reason to keep it silent, the round flies along the arc-clamped aim and bursts at the target's range
// (Fire; operator ruling 2026-09-30, "the user is hoping that the arc gets the weapon close enough to still
// splash some damage"). A weapon that only hits what it fires at still refuses. Fire reads InArc itself to
// choose that flight, so the two share the one bearing test and differ only in this exemption.
public static bool ArcPermitsFire(Weapon weapon, Entity shooter)
{
var target = shooter.Target.Value;
if (target == null) return true;
return FuseOf(weapon.Item, out _) != null || InArc(weapon.Item, target.Position - shooter.Position);
}
// A fused weapon whose Range, as it stands now (power supply and modifiers included), does not exceed its
// blast would burst on its own shooter at max range, so its trigger stays safed. Judged on the effective
// Range rather than the catalog's authored floor, which Evaluate can scale below.
public static bool FuseCanArm(Weapon weapon) => FuseOf(weapon.Item, out var blastRadius) == null || weapon.Range > blastRadius;
// 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
// arc is InArc's own cone: an aim inside it is used as is, one outside it is turned to the nearest edge of
@ -172,23 +199,26 @@ public static class FireControl
// freezes (R10), gated exactly as HitProbability gates. Nothing about the target's facing or silhouette
// enters here; that is Silhouette's and PSpread's job, priced fresh by HitProbability below and again,
// live, by Commit at the commit tick. 0 the moment any gate closes.
private static float PFire(Weapon weapon, Entity source, Entity target) => PFire(weapon, source, target, out _, out _);
private static float PFire(Weapon weapon, Entity source, Entity target) => PFire(weapon, source, target, out _, out _, out _);
// The `out range` overload is HitProbability's own gate call -- one computation of `target.Position -
// source.Position`, not two. HitProbability used to recompute its own copy for PSpread, which is exactly
// the kind of split authority Cut 3 named as the risk (two places computing the same vector, free to
// drift or to have their subtraction order flipped in only one of them).
private static float PFire(Weapon weapon, Entity source, Entity target, out float range) =>
PFire(weapon, source, target, out range, out _);
PFire(weapon, source, target, out range, out _, out _);
// `bears` is the one answer to "is this target one the weapon can fire at": in range, visible, locked (for a
// LockWeapon) and in arc. PFire prices a shot at zero when it is false; Fire reads it to decide that a
// fused round has no target at all (operator ruling 2026-09-30: an invalid target counts as no lock).
// Inspect recomputes the four gates one by one, to show the HUD which is closed; it decides nothing.
private static float PFire(Weapon weapon, Entity source, Entity target, out float range, out bool bears)
// `designated` is the one answer to "does the shooter hold valid targeting data on this target": visible, in
// range and locked (for a LockWeapon). `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.
private static float PFire(Weapon weapon, Entity source, Entity target, out float range, out bool designated, out bool inArc)
{
range = 0f;
bears = false;
designated = false;
inArc = false;
if (target == null) return 0f;
// F8 correction (Soul's fix batch, 2026-09-24): range is computed for any non-null target, before any
@ -201,8 +231,9 @@ public static class FireControl
if (!source.VisibleEntities.Contains(target)) return 0f;
if (range < weapon.MinRange || range > weapon.Range) return 0f;
if (weapon is LockWeapon lockWeapon && !lockWeapon.IsLocked) return 0f;
if (!InArc(weapon.Item, toTarget)) return 0f;
bears = true;
designated = true;
inArc = InArc(weapon.Item, toTarget);
if (!inArc) return 0f;
var settings = source.ItemManager.GameplaySettings;
var info = source.EntityInfoGathered.TryGetValue(target, out var gathered) ? gathered : 0f;
@ -393,7 +424,7 @@ public static class FireControl
// F8 (Soul's fix batch, 2026-09-24): the `out range` overload is the one range HitProbability now
// reads too -- Fire used to recompute its own second copy of `target.Position - source.Position` for
// fireRange, right beside the copy PFire's gate already computed.
var pFire = PFire(weapon, source, target, out var fireRange, out var bears);
var pFire = PFire(weapon, source, target, out var fireRange, out var designated, out var inArc);
// Cut 12.4(a) (Q12-6, "WeaponModifiers are labels, not behaviour"): a blast is a property of the
// weapon's data fields alone, resolved and frozen here like every other payload field -- never decided
@ -401,17 +432,18 @@ public static class FireControl
// only when BlastRadius > 0, and null otherwise, so a radius without a fuse or a fuse without a radius
// is inert and resolves as a direct hit (nothing polices that). A zero-radius freeze is what Step below
// still reads as "resolve this shot with Apply."
var weaponItemData = item.Data as WeaponItemData;
var blastRadius = weaponItemData?.BlastRadius > 0f ? weaponItemData.BlastRadius.Value : 0f;
var fuse = blastRadius > 0f ? weaponItemData.Fuse : null;
var fuse = FuseOf(item, out var blastRadius);
// Operator rulings 2026-09-30: an invalid target counts as no lock -- a fused round whose selected
// target is out of range, out of arc, not visible or not locked (PFire's `bears`) is fired at nothing.
// A fused shot with no target bursts where its own aim line ends: the shooter's aim (AimDirection) at
// the weapon's Range stat, the same stat PFire's range gate reads, flown at the weapon's Velocity like
// a targeted shot flies to its intercept. The round carries no target from here on, so nothing at
// resolution asks whether one is still around. A shot without a fuse keeps its target as before.
var engaged = fuse != null && !bears ? null : target;
// Operator rulings 2026-09-30. A fused round flies along the shooter's aim (AimDirection, clamped to the
// mount's arc) and carries no target -- so nothing at resolution asks whether one is still around --
// unless its target is designated (visible, in range, locked) and in arc:
// * no valid targeting data (no target, or one out of range, not visible or not locked) counts as no
// 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 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.
var engaged = fuse != null && !(designated && inArc) ? null : target;
var origin = source.Position;
var travelDirection = engaged != null ? TravelDirection(weapon, source, engaged) : AimDirection(item, source);
@ -427,11 +459,20 @@ public static class FireControl
var commitHorizon = source.ItemManager.GameplaySettings.CommitHorizon;
var burstPosition = blastRadius > 0f && engaged != null ? PredictedIntercept(weapon, source, engaged) : targetPosition;
if (fuse != null && engaged == null)
var burstPosition = targetPosition;
if (fuse != null && engaged != null)
{
burstPosition = origin + float3(travelDirection.x, 0, travelDirection.y) * weapon.Range;
flightTime = weapon.Velocity > .01f ? weapon.Range / weapon.Velocity : 0f;
// Nothing bursts beyond max range: an intercept a receding target leads the round past Range is
// pulled back along its own line to Range.
var intercept = PredictedIntercept(weapon, source, engaged);
var reach = length((intercept - origin).xz);
burstPosition = reach > weapon.Range ? origin + (intercept - origin) * (weapon.Range / reach) : intercept;
}
else if (fuse != null)
{
var reach = designated ? fireRange : weapon.Range;
burstPosition = origin + float3(travelDirection.x, 0, travelDirection.y) * reach;
flightTime = weapon.Velocity > .01f ? reach / weapon.Velocity : 0f;
}
var shot = new PendingShot
@ -649,7 +690,14 @@ public static class FireControl
}
}
return MakeOutcome(shot, hit ? ShotResult.Hit : ShotResult.Miss, shielded, shieldBroken, cell, bearing, lateral, now);
// A proximity round detonates at its burst point whether or not the roll hit, so a roll that missed is a
// Burst, not a Miss: the outcome types what happened, and the HUD must not call a real explosion a miss. A
// contact or delayed round detonates only on a hit, so its miss stays a Miss.
var proximity = shot.Fuse == WeaponFuse.Proximity;
var outcome = MakeOutcome(shot, hit ? ShotResult.Hit : proximity ? ShotResult.Burst : ShotResult.Miss,
shielded, shieldBroken, cell, bearing, lateral, now);
if (proximity) outcome.BurstPoint = shot.BurstPosition.xz;
return outcome;
}
// Operator rulings 2026-09-30. A fused round with no target bursts at the max-range point Fire froze along
@ -770,9 +818,10 @@ public static class FireControl
// centre lane (see the fuse-point comment on the Contact/Delayed branch below).
private static void Apply(Zone zone, PendingShot shot)
{
// A fused shot with no target has nothing to hit or miss: its committed outcome is a Burst, and it
// detonates at the point Commit decided (Burst outcome's BurstPoint). Detonate is still the one blast path.
if (shot.Outcome.Result == ShotResult.Burst)
// A Burst outcome detonates at the point Commit decided (its BurstPoint): a fused round with no target,
// which has nothing to hit or miss, and a proximity round, which detonates whether its roll hit or not.
// Detonate is still the one blast path.
if (shot.Outcome.Result == ShotResult.Burst || shot.Fuse == WeaponFuse.Proximity)
{
Detonate(zone, shot.Outcome.BurstPoint, shot.BlastRadius, shot.Damage, shot.DamageType);
return;
@ -783,11 +832,6 @@ public static class FireControl
case null:
ApplyDirectHit(shot);
return;
case WeaponFuse.Proximity:
// Detonates at shot.BurstPosition.xz, which PredictedIntercept froze at Fire (today's airburst
// rule, unchanged) -- no roll, and no gate on shot.Outcome.Hit.
Detonate(zone, shot.BurstPosition.xz, shot.BlastRadius, shot.Damage, shot.DamageType);
return;
case WeaponFuse.Contact:
case WeaponFuse.Delayed:
ApplyBlastHit(shot);
@ -1737,10 +1781,12 @@ public sealed class ShotOutcome
public Entity Target;
public EquippedItem Weapon;
// What the commit decided, typed: a shot rolled and missed, rolled and hit, or was a fused round with no
// target and so burst without rolling (Burst, at BurstPoint). Hit is derived from it, not stored beside it.
// target and so burst without rolling, or a proximity round whose roll missed but which detonates anyway
// (Burst, at BurstPoint). Hit is derived from it, not stored beside it.
public ShotResult Result;
public bool Hit => Result == ShotResult.Hit;
// World planar point a Burst detonates at, frozen by the commit. Meaningless for any other Result.
// World planar point a proximity or no-target round detonates at, frozen by the commit. Meaningless for any
// other round.
public float2 BurstPoint;
public bool Shielded;
// Cut 5, 5.2 (Soul finding 4): frozen alongside the rest of the outcome (R4) -- a shield present, active,
@ -1757,8 +1803,9 @@ public sealed class ShotOutcome
public DamageType DamageType;
}
// What a committed shot came to. A Burst is a fused round that had no valid target: it rolled nothing, so it is
// neither a hit nor a miss, and the presentation must not call it one.
// What a committed shot came to. A Burst is a detonation that is not a hit on a target: a fused round that had no
// valid target and rolled nothing, or a proximity round whose roll missed but which bursts anyway. It is neither
// a hit nor a miss, and the presentation must not call it one.
public enum ShotResult
{
Miss,

View file

@ -752,21 +752,6 @@ public static class StatValidation
"not exceed them");
}
// Operator ruling 2026-09-30 (Soul): a round with a blast that could burst on its own shooter is refused at
// the catalog rather than policed at runtime. A weapon that detonates (a fuse and a blast radius above zero,
// the two FireControl.Fire freezes together) must reach farther than its blast, at the weakest value its Range
// stat can take: a no-lock round bursts at Range, so Range <= BlastRadius would put the shooter inside the
// burst, and Range <= 0 is a burst on the muzzle. A fuse without a radius is inert and needs no range.
public static void ValidateFuse(EquippableItemData data)
{
if (!(data is WeaponItemData weapon) || weapon.Fuse == null || !(weapon.BlastRadius > 0f)) return;
foreach (var behavior in weapon.Behaviors)
if (behavior is WeaponData weaponData && weaponData.Range.Min <= weapon.BlastRadius.Value)
throw new InvalidOperationException(
$"{weapon.Name}: fused weapon's Range {weaponData.Range.Min} does not exceed its BlastRadius " +
$"{weapon.BlastRadius.Value} -- a burst at max range would land on the shooter");
}
// Cut 2 (docs/stats-and-power-cut.md): a StatReference names its target by (type name, field name) so a
// modifier can point at any design or behaviour without a hard type reference -- but before this cut an
// unresolvable reference failed silently: StatModifier.Initialize left `_stats` null and the first

View file

@ -190,60 +190,6 @@ public sealed class AetheriaStoresTests : IDisposable
Assert.Null(record);
}
// Operator ruling 2026-09-30 (Soul): a weapon that detonates must reach farther than its blast, at the
// weakest value its Range stat can take, or a max-range burst lands on the shooter. Refused where every
// catalog write and every open already checks, not policed at runtime.
private static WeaponItemData FusedWeapon(string name, float rangeMin, float rangeMax, float? blastRadius, WeaponFuse? fuse) => new WeaponItemData
{
Name = name, Hardpoint = HardpointType.Sensors, Shape = new Shape(), Durability = 1,
MinimumTemperature = -1000, MaximumTemperature = 1000, OptimalTemperature = 0, PlateauWidth = 2000,
Fuse = fuse, BlastRadius = blastRadius,
Behaviors = { new InstantWeaponData { Range = new PerformanceStat { Min = rangeMin, Max = rangeMax } } }
};
// Kills: a comparison that admits equality (Range == BlastRadius bursts on the shooter's own edge); reading
// Range.Max instead of Min (the second row: Max 100 clears the blast, the weakest value does not); a
// zero range with a positive blast.
[Theory]
[InlineData(3f, 3f, 3f)]
[InlineData(2f, 2f, 3f)]
[InlineData(2f, 100f, 3f)]
[InlineData(0f, 0f, .5f)]
public void ValidateRefusesAFusedWeaponWhoseRangeDoesNotExceedItsBlast(float rangeMin, float rangeMax, float radius)
{
var error = Assert.Throws<InvalidOperationException>(() =>
CultRecordRefs.Validate(FusedWeapon("ShortFuse", rangeMin, rangeMax, radius, WeaponFuse.Proximity)));
Assert.Contains("ShortFuse", error.Message);
}
// A fuse without a radius, a radius without a fuse, and a range that clears the blast are all fine: only a
// weapon that will actually detonate is held to the rule.
[Theory]
[InlineData(1f, 1f, null, WeaponFuse.Contact)]
[InlineData(1f, 1f, 0f, WeaponFuse.Contact)]
[InlineData(1f, 1f, 30f, null)]
[InlineData(60f, 60f, 4f, WeaponFuse.Delayed)]
public void ValidateAcceptsAWeaponThatCannotBurstOnItsShooter(float rangeMin, float rangeMax, float? radius, WeaponFuse? fuse)
{
var record = Record.Exception(() => CultRecordRefs.Validate(FusedWeapon("Fine", rangeMin, rangeMax, radius, fuse)));
Assert.Null(record);
}
// Open holds a catalog already on disk to the same rule Upsert applies on write. The bad record is written
// past Upsert, straight through the cache, the way a hand-edited or migrated catalog would carry one.
[Fact]
public void OpenRefusesAFusedWeaponWhoseRangeDoesNotExceedItsBlast()
{
using (var cache = AetheriaStores.Open(Catalog, catalogWritable: true))
{
cache.UpsertAsync(FusedWeapon("OnDisk", 2f, 2f, 3f, WeaponFuse.Contact)).Wait();
cache.FlushAsync().Wait();
}
var error = Assert.Throws<InvalidOperationException>(() => AetheriaStores.Open(Catalog));
Assert.Contains("OnDisk", error.Message);
}
// A document type Validate has no opinion about (neither EquippableItemData nor ConsumableItemData) must be
// a pure no-op, not a crash -- the three tools' change lists carry `object`, and not every migration touches
// an equippable design (e.g. a future repair over FactionProductData).

View file

@ -83,6 +83,32 @@ public sealed class FireControlCut124Tests : IDisposable
public Dictionary<string, EquippedItem> Custom = new Dictionary<string, EquippedItem>();
}
// The gun's behaviour data: a plain InstantWeaponData, or a LockWeaponData whose lock either grows to full
// (LockAngle 180 admits any bearing) or never grows (LockAngle 0 admits none, so it only decays). A stat
// term on Range lets a test scale it below its authored value through the power supply.
private static InstantWeaponData WeaponBehavior(float damage, float range, StatSource? rangeTerm, float velocity, float penetration,
bool lockWeapon, bool lockAcquires)
{
var data = lockWeapon ? new LockWeaponData
{
LockSpeed = Constant(1000), SensorImpact = Constant(1), LockAngle = Constant(lockAcquires ? 180 : 0),
DirectionImpact = Constant(1), Decay = Constant(1)
} : new InstantWeaponData();
data.Damage = Constant(damage);
data.Range = rangeTerm == null ? Constant(range)
: new PerformanceStat { Min = range, Max = range, Terms = { new StatTerm { Source = rangeTerm.Value, Exponent = 1f } } };
data.MinRange = Constant(0);
data.Velocity = Constant(velocity);
data.Spread = Constant(0);
data.DamageSpread = Constant(0);
data.Penetration = Constant(penetration);
data.Count = Constant(1);
data.BurstTime = Constant(0);
data.Cooldown = Constant(1000);
data.DamageCurve = new BezierCurve { Keys = new[] { float4(0, 1, 0, 0), float4(1, 1, 0, 0) } };
return data;
}
private Engagement Build(
GameplaySettings settings, Shape hullShape,
float2? targetFacing = null, float targetRange = 100f, float velocity = 0f,
@ -91,7 +117,8 @@ public sealed class FireControlCut124Tests : IDisposable
bool equipShield = false, float shieldCapacity = 1000f, bool shieldActive = true,
int2? cockpitCell = null, float cockpitDurability = 1000f,
(string Name, Shape Shape, int2 Cell, float Durability)[] custom = null,
float accuracy = 1f, int2? gunCell = null, float weaponRange = 100000f)
float accuracy = 1f, int2? gunCell = null, float weaponRange = 100000f,
float hardpointArc = 0f, StatSource? rangeTerm = null, bool lockWeapon = false, bool lockAcquires = true)
{
var hullData = new HullData
{
@ -101,7 +128,7 @@ public sealed class FireControlCut124Tests : IDisposable
var shooterHullData = new HullData
{
Name = "ShooterHull", HullType = HullType.Ship, Shape = SolidShape(5, 5), Durability = 1000000, Mass = 1000,
Hardpoints = { new HardpointData { Type = HardpointType.Sensors, Position = new int2(0, 0), Shape = new Shape() } }
Hardpoints = { new HardpointData { Type = HardpointType.Sensors, Position = new int2(0, 0), Shape = new Shape(), FiringArc = hardpointArc } }
};
var cache = AetheriaStores.Open(Catalog + Guid.NewGuid().ToString("N"), catalogWritable: true);
@ -114,13 +141,7 @@ public sealed class FireControlCut124Tests : IDisposable
Name = "Gun", Hardpoint = HardpointType.Sensors, Shape = new Shape(), Durability = 1,
MinimumTemperature = -1000, MaximumTemperature = 1000, OptimalTemperature = 0, PlateauWidth = 2000,
Fuse = fuse, BlastRadius = blastRadius, WeaponModifiers = modifiers,
Behaviors = { new InstantWeaponData
{
Damage = Constant(damage), Range = Constant(weaponRange), MinRange = Constant(0),
Velocity = Constant(velocity), Spread = Constant(0), DamageSpread = Constant(0),
Penetration = Constant(penetration), Count = Constant(1), BurstTime = Constant(0), Cooldown = Constant(1000),
DamageCurve = new BezierCurve { Keys = new[] { float4(0, 1, 0, 0), float4(1, 1, 0, 0) } }
} }
Behaviors = { WeaponBehavior(damage, weaponRange, rangeTerm, velocity, penetration, lockWeapon, lockAcquires) }
});
cache.Upsert(new GearData
{
@ -1270,28 +1291,25 @@ public sealed class FireControlCut124Tests : IDisposable
Assert.False(Alive(e, onMount), "with nothing aimed the blast follows the mount");
}
public enum Invalid { OutOfRange, NotVisible, OutOfArc }
public enum Invalid { OutOfRange, NotVisible, Unlocked }
// Ruling 2: a selected target that is out of range, not visible or out of arc counts as no lock -- the round
// bursts at max range along the aim, and the target is left alone. The aim is (2,1) except when the target is
// behind the mount, where the mount is flipped and the aim follows it. Kills: reading "no lock" as
// Target == null alone (the round would burst at the target's intercept, 100 / 40 / 100 units out).
// (An unlocked LockWeapon shares the same predicate; the fixture has no LockWeapon.)
// Ruling 2: a selected target that is out of range, not visible or not locked is not valid targeting data,
// and counts as no lock -- the round bursts at max range along the aim, and the target is left alone. Kills:
// reading "no lock" as Target == null alone (the round would burst at the target's intercept, 100 / 40 / 40
// units out); dropping the range, visibility or lock gate from PFire's `designated`. The lock row needs a
// LockWeapon whose lock never grows; its control, below, is the same gun with the lock acquired. (A closed
// arc is not an invalid target: see AFusedShotAtATargetOutsideTheArc...)
[Theory]
[InlineData(Invalid.OutOfRange)]
[InlineData(Invalid.NotVisible)]
[InlineData(Invalid.OutOfArc)]
[InlineData(Invalid.Unlocked)]
public void AnInvalidTargetCountsAsNoLock(Invalid reason)
{
var e = Build(TestSettings(), SolidShape(5, 4), velocity: 0, fuse: WeaponFuse.Proximity, blastRadius: 4f, damage: 100f,
weaponRange: NoLockRange, targetRange: reason == Invalid.NotVisible ? 40f : 100f);
weaponRange: NoLockRange, targetRange: reason == Invalid.OutOfRange ? 100f : 40f,
lockWeapon: reason == Invalid.Unlocked, lockAcquires: false);
var aim = normalize(float2(2, 1));
if (reason == Invalid.NotVisible) e.Shooter.VisibleEntities.Remove(e.Target);
if (reason == Invalid.OutOfArc)
{
e.Shooter.Direction = float2(0, -1);
aim = float2(0, -1);
}
Aim(e, aim);
var burst = PointAlong(e, aim, NoLockRange);
var onAim = AddShip(e, burst, 1f, 300);
@ -1308,6 +1326,228 @@ public sealed class FireControlCut124Tests : IDisposable
Assert.Equal(targetBefore, e.Target.Hull.Durability);
}
// The lock row's control: the same LockWeapon once its lock has grown carries its target to the intercept.
// Kills: a fixture whose lock could never grow (which would make the unlocked row above pass for nothing).
[Fact]
public void ALockedTargetRidesWithTheRound()
{
var e = Build(TestSettings(), SolidShape(5, 4), velocity: 0, fuse: WeaponFuse.Proximity, blastRadius: 4f, damage: 100f,
weaponRange: NoLockRange, targetRange: 40f, lockWeapon: true, lockAcquires: true);
Aim(e, float2(0, 1));
e.Zone.Update(.1f);
Assert.True(((LockWeapon) e.Weapon).IsLocked, "fixture: the lock must have grown");
FireControl.Fire(e.Weapon, e.WeaponItem, e.Shooter);
var shot = Assert.Single(e.Zone.PendingShots);
Assert.True(shot.Target == e.Target, "a locked, visible, in-range, in-arc target rides with the round");
Assert.Equal(e.Target.Position.z, shot.BurstPosition.z, 3);
}
// The out-of-arc ruling (operator, 2026-09-30): a fused round at a target it holds valid data on but cannot
// bear on flies along the aim clamped to the mount's arc and bursts at the TARGET'S range, not at max range.
// The mount is flipped to (0,-1) so the target 40 ahead of the shooter is 180 degrees off it, in range,
// visible and unlocked by nothing: the arc is the only closed gate. The aim (1,1) is 135 degrees off the
// mount, so it clamps to the 85-degree edge on its own side. Three witnesses: on the edge at 40 (dies), on
// the edge at max range 60 (lives: the burst is at target range), under the unclamped aim at 40 (lives: the
// clamp holds). Kills: dropping `inArc` from PFire (the target would ride and burst at 40 along the target
// line); bursting at Range instead of the target's range; skipping the clamp. Contact is left out: its round
// stops on the first hull it meets, and these witnesses are hulls.
[Theory]
[InlineData(WeaponFuse.Proximity)]
[InlineData(WeaponFuse.Delayed)]
public void AFusedShotAtATargetOutsideTheArcBurstsAtTheTargetsRangeAlongTheClampedAim(WeaponFuse fuse)
{
var e = Build(TestSettings(), SolidShape(5, 4), velocity: 0, fuse: fuse, blastRadius: 4f, penetration: 1f, damage: 100f,
weaponRange: NoLockRange, targetRange: 40f);
e.Shooter.Direction = float2(0, -1);
var look = normalize(float2(1, 1));
Aim(e, look);
var mount = float2(0, -1);
var edge = float2(mount.x * cos(radians(85f)) - mount.y * sin(radians(85f)), mount.x * sin(radians(85f)) + mount.y * cos(radians(85f)));
var atTargetRange = AddShip(e, PointAlong(e, edge, 40f), 1f, 300);
var atMaxRange = AddShip(e, PointAlong(e, edge, NoLockRange), 1f, 302);
var underAim = AddShip(e, PointAlong(e, look, 40f), 1f, 304);
var targetBefore = e.Target.Hull.Durability;
FireControl.Fire(e.Weapon, e.WeaponItem, e.Shooter);
var shot = Assert.Single(e.Zone.PendingShots);
Assert.True(shot.Target == null, "a target the weapon cannot bear on must not ride with the round");
var burst = PointAlong(e, edge, 40f);
Assert.Equal(burst.x, shot.BurstPosition.x, 3);
Assert.Equal(burst.z, shot.BurstPosition.z, 3);
e.Zone.Update(.01f);
Assert.False(Alive(e, atTargetRange), "the blast must land at the target's range along the clamped aim");
Assert.True(Alive(e, atMaxRange), "the round bursts at the target's range, not at max range");
Assert.True(Alive(e, underAim), "the aim is clamped to the arc's edge");
Assert.Equal(targetBefore, e.Target.Hull.Durability);
}
// The trigger and Fire share one bearing test (InArc) and differ only in the fused weapon's exemption, so
// this pins the pair at the layer a player uses: Activate. With the target out of arc and the weapon
// otherwise clear, a fused weapon fires a round; the same weapon without a fuse stays silent. Kills:
// removing the exemption from FireControl.ArcPermitsFire (the fused row goes silent); removing the arc gate
// itself (the plain row fires); widening the exemption to a weapon with a blast radius but no fuse.
[Theory]
[InlineData(WeaponFuse.Proximity, true)]
[InlineData(null, false)]
public void OnlyAFusedWeaponFiresAtATargetOutsideItsArc(WeaponFuse? fuse, bool fires)
{
var e = Build(TestSettings(), SolidShape(5, 4), velocity: 0, fuse: fuse, blastRadius: 4f, damage: 100f,
weaponRange: NoLockRange, targetRange: 40f);
e.Shooter.Direction = float2(0, -1);
Aim(e, float2(0, -1));
Assert.Equal(fires, e.Weapon.ArcAllowsFire);
var committed = 0;
using var c = e.Zone.ShotCommitted.Subscribe(_ => committed++);
e.Weapon.Activate();
for (var i = 0; i < 5; i++) e.Zone.Update(.01f);
Assert.Equal(fires ? 1 : 0, committed);
}
// A fused weapon safes itself while its effective Range does not exceed its blast, because a round bursting at
// max range would land on its own shooter. The gate reads the Range the weapon has NOW: the third row is
// Soul's probe (authored Range 100 clears a blast of 30, but at a power supply of 0.1 Range is 10), and the
// first two pin the boundary itself (equal refuses). Kills: reading the authored figure instead of the
// runtime Range; `>=` for `>`; dropping the gate from InstantWeapon.Trigger.
[Theory]
[InlineData(30f, 30f, false, false)]
[InlineData(31f, 30f, false, true)]
[InlineData(100f, 30f, true, false)]
public void AFusedWeaponWhoseRangeDoesNotExceedItsBlastStaysSafed(float range, float blast, bool starvedOfPower, bool fires)
{
var e = Build(TestSettings(), SolidShape(5, 4), velocity: 0, fuse: WeaponFuse.Proximity, blastRadius: blast, damage: 100f,
weaponRange: range, rangeTerm: starvedOfPower ? StatSource.PowerSupply : (StatSource?) null);
e.Shooter.Target.Value = null;
Aim(e, float2(0, 1));
if (starvedOfPower)
{
typeof(EquippedItem).GetProperty("PowerSupply").GetSetMethod(true).Invoke(e.WeaponItem, new object[] { .1f });
e.Shooter.Resolver.InvalidateSource(e.WeaponItem, StatSource.PowerSupply);
e.Weapon.GetType().GetMethod("UpdateStats", BindingFlags.Instance | BindingFlags.NonPublic).Invoke(e.Weapon, null);
Assert.True(e.Weapon.Range < blast, "fixture: the runtime Range must have fallen below the blast");
}
var committed = 0;
using var c = e.Zone.ShotCommitted.Subscribe(_ => committed++);
Assert.Equal(fires, e.Weapon.FuseAllowsFire);
e.Weapon.Activate();
// The starved row must not run a full Update first: the power bus would restore the supply we forced.
// Activate queues the burst inside Trigger, so one short tick is enough for a commit if it fires.
for (var i = 0; i < 5; i++) e.Zone.Update(.01f);
Assert.Equal(fires ? 1 : 0, committed);
}
// Nothing bursts beyond max range. A target receding faster than the round would be led past Range by the
// intercept; the burst is pulled back along the line to the intercept, to exactly Range. The target moves
// (6,8), so the direction is off-axis and a clamp that moved the point along the wrong line shows.
// Kills: no clamp (the probe burst 180 units out on Range 100); a clamp on one axis only.
[Fact]
public void ATargetedProximityBurstIsPulledBackToMaxRange()
{
var e = Build(TestSettings(), SolidShape(5, 4), velocity: 20f, fuse: WeaponFuse.Proximity, blastRadius: 4f, damage: 100f,
weaponRange: 100f, targetRange: 90f);
e.Target.Velocity = float2(6f, 8f);
var intercept = FireControl.PredictedIntercept(e.Weapon, e.Shooter, e.Target);
var origin = e.Shooter.Position;
var toIntercept = normalize((intercept - origin).xz);
Assert.True(length((intercept - origin).xz) > 100f, "fixture: the intercept must lie beyond Range");
FireControl.Fire(e.Weapon, e.WeaponItem, e.Shooter);
var shot = Assert.Single(e.Zone.PendingShots);
Assert.Equal(origin.x + toIntercept.x * 100f, shot.BurstPosition.x, 2);
Assert.Equal(origin.z + toIntercept.y * 100f, shot.BurstPosition.z, 2);
}
// What a proximity round's commit says. A round whose roll missed still detonates, so it commits as a Burst
// at the intercept, and only a roll that landed is a Hit; a contact or delayed round detonates only on a hit,
// so its guaranteed miss (accuracy 0) stays a Miss and explodes nowhere. Kills: typing a proximity miss as a
// Miss (the HUD said MISS over a real explosion); typing every proximity round a Burst (no Hit among the
// twelve); dropping the burst point from a Hit (it would detonate at the origin).
[Fact]
public void AProximityRoundThatMissesCommitsAsABurstAtTheIntercept()
{
var e = Build(TestSettings(), SolidShape(5, 4), velocity: 0, fuse: WeaponFuse.Proximity, blastRadius: 30f, damage: 100f, accuracy: 0f);
var damage = 0f;
using var a = e.Target.ArmorDamage.Subscribe(x => damage += x.damage);
using var h = e.Target.HullDamage.Subscribe(x => damage += x);
using var i = e.Target.ItemDamage.Subscribe(x => damage += x.damage);
ShotOutcome committed = null;
using var c = e.Zone.ShotCommitted.Subscribe(o => committed = o);
FireControl.Fire(e.Weapon, e.WeaponItem, e.Shooter);
e.Zone.Update(.01f);
Assert.NotNull(committed);
Assert.Equal(ShotResult.Burst, committed.Result);
Assert.Equal(e.Target.Position.x, committed.BurstPoint.x, 3);
Assert.Equal(e.Target.Position.z, committed.BurstPoint.y, 3);
Assert.Equal(5.65884256f, damage, 3);
}
[Fact]
public void AProximityRoundThatLandsCommitsAsAHitAndStillDetonates()
{
var e = Build(TestSettings(), SolidShape(5, 4), velocity: 0, fuse: WeaponFuse.Proximity, blastRadius: 30f, damage: 100f);
var results = new List<ShotOutcome>();
using var r = e.Zone.ShotResolved.Subscribe(o => results.Add(o));
var damage = 0f;
using var a = e.Target.ArmorDamage.Subscribe(x => damage += x.damage);
using var h = e.Target.HullDamage.Subscribe(x => damage += x);
using var i = e.Target.ItemDamage.Subscribe(x => damage += x.damage);
for (var n = 0; n < 12; n++) FireControl.Fire(e.Weapon, e.WeaponItem, e.Shooter);
e.Zone.Update(.01f);
Assert.Equal(12, results.Count);
Assert.True(results.All(o => o.Result != ShotResult.Miss), "a proximity round always detonates, so it is never a Miss");
Assert.True(results.Any(o => o.Result == ShotResult.Hit), "the stationary, fully-accurate target must be hit at least once");
Assert.True(results.All(o => abs(o.BurstPoint.x - e.Target.Position.x) < 1e-3f && abs(o.BurstPoint.y - e.Target.Position.z) < 1e-3f),
"a hit detonates at the intercept too, not at the origin");
Assert.True(damage > 0f, "the blasts must have landed");
}
[Theory]
[InlineData(WeaponFuse.Contact)]
[InlineData(WeaponFuse.Delayed)]
public void AContactOrDelayedRoundThatMissesIsAMissAndDoesNotDetonate(WeaponFuse fuse)
{
var e = Build(TestSettings(), SolidShape(5, 4), velocity: 0, fuse: fuse, blastRadius: 30f, penetration: 1f, damage: 100f, accuracy: 0f);
var bystander = AddShip(e, e.Target.Position + float3(8, 0, 0), 1f, 300);
ShotOutcome resolved = null;
using var r = e.Zone.ShotResolved.Subscribe(o => resolved = o);
FireControl.Fire(e.Weapon, e.WeaponItem, e.Shooter);
e.Zone.Update(.01f);
Assert.NotNull(resolved);
Assert.Equal(ShotResult.Miss, resolved.Result);
Assert.True(Alive(e, bystander), "a missed contact or delayed round detonates nowhere");
}
// A targeted fused round commits at its horizon, not at Fire: the target's facing is read late (Bearing
// timing), and only a round with no target commits at once. 100 units at 20/s is a 5 s flight, so with the
// 0.5 s horizon it commits at 4.5 s. Kills: CommitTime = now for every fused shot (the round would be
// committed after the first tick).
[Fact]
public void ATargetedFusedShotCommitsAtItsHorizonNotAtFire()
{
var e = Build(TestSettings(), SolidShape(5, 4), velocity: 20f, fuse: WeaponFuse.Proximity, blastRadius: 4f, damage: 100f);
FireControl.Fire(e.Weapon, e.WeaponItem, e.Shooter);
var shot = Assert.Single(e.Zone.PendingShots);
Assert.Equal(shot.FireTime + 4.5f, shot.CommitTime, 3);
e.Zone.Update(.01f);
Assert.False(e.Zone.PendingShots[0].Committed, "a targeted round reads the target's facing late");
e.Zone.Update(4.5f);
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
@ -1481,6 +1721,256 @@ public sealed class FireControlCut124Tests : IDisposable
Assert.True(ReferenceEquals(committed, resolved), "the resolution republishes the commit unchanged");
}
// ==== Soul's probes on hands/fuse-fix2 (2026-09-30), adopted as tests ====
private static float2 Dir(float degreesFromMount) => float2(sin(radians(degreesFromMount)), cos(radians(degreesFromMount)));
private static float2 Clamped(Engagement e, float2 look)
{
Aim(e, look);
return FireControl.AimDirection(e.WeaponItem, e.Shooter);
}
private static void Near(float2 expected, float2 actual, int digits = 3)
{
Assert.Equal(expected.x, actual.x, digits);
Assert.Equal(expected.y, actual.y, digits);
}
// AimDirection at the arc's edges: the mount is (0,1) and the arc 170 degrees, so the edge is 85. On the edge
// and inside are untouched, just outside turns to the edge on its own side, straight behind turns to the
// positive edge, a zero aim (and one too short to normalise) is the mount, and a NaN aim never poisons the
// result. Kills: a clamp that turns the wrong way; an edge off by a degree; a missing zero-aim fallback.
[Fact]
public void TheAimClampHoldsAtTheEdgesOfTheArc()
{
var e = Build(TestSettings(), SolidShape(5, 4), fuse: WeaponFuse.Proximity, blastRadius: 4f, weaponRange: 60f);
Near(Dir(85), Clamped(e, Dir(85)));
Near(Dir(85), Clamped(e, Dir(85.5f)));
Near(Dir(-85), Clamped(e, Dir(-86)));
Near(Dir(84), Clamped(e, Dir(84)));
Near(Dir(0), Clamped(e, Dir(0)));
Near(Dir(85), Clamped(e, Dir(180 - 1e-3f)), 2);
Near(float2(0, 1), Clamped(e, float2(0, 0)));
Near(float2(0, 1), Clamped(e, float2(5e-4f, 0)));
Near(Dir(85), Clamped(e, float2(2e-3f, 0)));
e.Shooter.LookDirection = float3(0, 1, 0);
Near(float2(0, 1), FireControl.AimDirection(e.WeaponItem, e.Shooter));
e.Shooter.LookDirection = float3(float.NaN, 0, float.NaN);
var nan = FireControl.AimDirection(e.WeaponItem, e.Shooter);
Assert.False(float.IsNaN(nan.x) || float.IsNaN(nan.y), "a NaN aim must not produce a NaN flight direction");
}
[Fact]
public void AFullCircleMountPassesEveryAim()
{
var settings = TestSettings();
settings.FiringArc = 360;
var e = Build(settings, SolidShape(5, 4), fuse: WeaponFuse.Proximity, blastRadius: 4f, weaponRange: 60f);
Near(float2(0, -1), Clamped(e, float2(0, -3)));
Near(normalize(float2(-1, -1)), Clamped(e, float2(-5, -5)));
}
[Fact]
public void AZeroWidthArcIsTheMount()
{
var settings = TestSettings();
settings.FiringArc = 0;
var e = Build(settings, SolidShape(5, 4), fuse: WeaponFuse.Proximity, blastRadius: 4f, weaponRange: 60f);
foreach (var d in new[] { 0f, 1e-3f, 30f, 90f, 179f, -120f })
Near(float2(0, 1), Clamped(e, Dir(d)), 4);
}
// The hardpoint's authored arc overrides the default (90 here, so the edge is 45), and the arc turns with the
// mount: a shooter facing (1,1) has its edge 45 degrees counter-clockwise of that when aimed along -x.
[Fact]
public void TheHardpointsOwnArcAndTheRotatedMountSetTheEdge()
{
var e = Build(TestSettings(), SolidShape(5, 4), fuse: WeaponFuse.Proximity, blastRadius: 4f, weaponRange: 60f, hardpointArc: 90f);
Near(Dir(45), Clamped(e, float2(1, 0)));
Near(Dir(-45), Clamped(e, Dir(-100)));
e.Shooter.Direction = normalize(float2(1, 1));
var mount = normalize(float2(1, 1));
var edgeCcw = float2(mount.x * cos(radians(45)) - mount.y * sin(radians(45)), mount.x * sin(radians(45)) + mount.y * cos(radians(45)));
Near(edgeCcw, Clamped(e, float2(-1, 0)));
}
// A contact round with no lock commits at Fire, against the poses of that tick: a hull that sails onto the
// aim line afterwards is not in its way, and one that leaves it afterwards does not un-stop it. Kills:
// committing later than Fire (the blocker that arrives would stop the round); re-deciding the burst point
// from live poses.
[Fact]
public void AContactRoundIgnoresAHullThatCrossesItsLineAfterFire()
{
var e = Build(TestSettings(), SolidShape(5, 4), velocity: 20f, fuse: WeaponFuse.Contact, blastRadius: 4f, damage: 100f, weaponRange: 60f);
e.Shooter.Target.Value = null;
Aim(e, float2(0, 1));
var blocker = AddShip(e, PointAlong(e, float2(0, 1), 25f) + float3(300, 0, 0), 1f, 300);
ShotOutcome committed = null;
using var c = e.Zone.ShotCommitted.Subscribe(o => committed = o);
FireControl.Fire(e.Weapon, e.WeaponItem, e.Shooter);
e.Zone.Update(.01f);
Assert.Equal(ShotResult.Burst, committed.Result);
Assert.Equal(e.Shooter.Position.z + 60f, committed.BurstPoint.y, 2);
blocker.Position = PointAlong(e, float2(0, 1), 25f);
e.Zone.Update(4f);
Assert.Empty(e.Zone.PendingShots);
Assert.True(Alive(e, blocker), "the round flew through the hull that arrived after Fire and burst at max range");
}
[Fact]
public void AContactRoundStillBurstsAtAHullThatLeavesItsLineAfterFire()
{
var e = Build(TestSettings(), SolidShape(5, 4), velocity: 20f, fuse: WeaponFuse.Contact, blastRadius: 4f, damage: 100f, weaponRange: 60f);
e.Shooter.Target.Value = null;
Aim(e, float2(0, 1));
var blocker = AddShip(e, PointAlong(e, float2(0, 1), 25f), 1f, 300);
var atMaxRange = AddShip(e, PointAlong(e, float2(0, 1), 60f), 1f, 302);
ShotOutcome committed = null;
using var c = e.Zone.ShotCommitted.Subscribe(o => committed = o);
FireControl.Fire(e.Weapon, e.WeaponItem, e.Shooter);
e.Zone.Update(.01f);
Assert.Equal(e.Shooter.Position.z + 21f, committed.BurstPoint.y, 2);
blocker.Position += float3(300, 0, 0);
e.Zone.Update(4f);
Assert.True(Alive(e, blocker), "the hull moved away; the round still burst at its old face");
Assert.True(Alive(e, atMaxRange), "and did not continue to max range");
}
// The contact point is measured from where the round left (FireOrigin), not from where its shooter is when
// the commit tick runs. Unblocked, a max-range burst point is frozen at Fire either way; with a blocker 21
// units out the origin matters. Kills: Commit reading the shooter's live position (Soul's M6).
[Theory]
[InlineData(false)]
[InlineData(true)]
public void AShooterMovingAfterFireDoesNotMoveTheContactPoint(bool withBlocker)
{
var e = Build(TestSettings(), SolidShape(5, 4), velocity: 20f, fuse: WeaponFuse.Contact, blastRadius: 4f, damage: 100f, weaponRange: 60f);
e.Shooter.Target.Value = null;
Aim(e, float2(0, 1));
var origin = e.Shooter.Position;
if (withBlocker) AddShip(e, PointAlong(e, float2(0, 1), 25f), 1f, 300);
ShotOutcome committed = null;
using var c = e.Zone.ShotCommitted.Subscribe(o => committed = o);
FireControl.Fire(e.Weapon, e.WeaponItem, e.Shooter);
e.Shooter.Position += float3(25f, 0, 10f);
e.Zone.Update(.01f);
Assert.Equal(origin.x, committed.BurstPoint.x, 2);
Assert.Equal(origin.z + (withBlocker ? 21f : 60f), committed.BurstPoint.y, 2);
}
// A round fired from inside another hull bursts at the muzzle: the origin is already in metal, a distance of
// zero. Kills: reading a cell's Entry for its Exit (Soul's M7: a cell the origin sits in is skipped and the
// round flies on); dropping the clamp at zero (Soul's M8: the negative distance puts the burst behind the
// muzzle).
[Fact]
public void AContactRoundFiredFromInsideAnotherHullBurstsAtTheMuzzle()
{
var e = Build(TestSettings(), SolidShape(5, 4), velocity: 20f, fuse: WeaponFuse.Contact, blastRadius: 4f, damage: 100f, weaponRange: 60f);
e.Shooter.Target.Value = null;
Aim(e, float2(0, 1));
AddShip(e, e.Shooter.Position + float3(0.5f, 0, 0.5f), 1f, 300);
ShotOutcome committed = null;
using var c = e.Zone.ShotCommitted.Subscribe(o => committed = o);
FireControl.Fire(e.Weapon, e.WeaponItem, e.Shooter);
e.Zone.Update(.01f);
Assert.Equal(e.Shooter.Position.x, committed.BurstPoint.x, 2);
Assert.Equal(e.Shooter.Position.z, committed.BurstPoint.y, 2);
}
// A hull wholly behind the muzzle is not on the round's way. Kills: admitting cells whose exit lies behind
// the origin (the round would burst at the hull behind it, at distance zero).
[Fact]
public void AContactRoundIgnoresAHullBehindTheMuzzle()
{
var e = Build(TestSettings(), SolidShape(5, 4), velocity: 20f, fuse: WeaponFuse.Contact, blastRadius: 4f, damage: 100f, weaponRange: 60f);
e.Shooter.Target.Value = null;
Aim(e, float2(0, 1));
AddShip(e, PointAlong(e, float2(0, -1), 25f), 1f, 300);
ShotOutcome committed = null;
using var c = e.Zone.ShotCommitted.Subscribe(o => committed = o);
FireControl.Fire(e.Weapon, e.WeaponItem, e.Shooter);
e.Zone.Update(.01f);
Assert.Equal(e.Shooter.Position.z + 60f, committed.BurstPoint.y, 2);
}
// The intent ruling: a round fired at a valid target that has left the zone does not burst, at either stage
// (before or after its commit), whatever its fuse -- it resolves as a Miss, and nothing standing beside where
// the target was, nor the shooter, takes blast damage. Kills: a round that detonates at its frozen burst point
// after its target is gone.
[Theory]
[InlineData(WeaponFuse.Proximity, false)]
[InlineData(WeaponFuse.Contact, false)]
[InlineData(WeaponFuse.Delayed, false)]
[InlineData(WeaponFuse.Proximity, true)]
[InlineData(WeaponFuse.Contact, true)]
[InlineData(WeaponFuse.Delayed, true)]
public void ARoundWhoseTargetLeavesTheZoneDoesNotBurst(WeaponFuse fuse, bool committedFirst)
{
var e = Build(TestSettings(), SolidShape(5, 4), velocity: 20f, fuse: fuse, blastRadius: 30f, penetration: 1f, damage: 500f);
var bystanders = new Entity[]
{
AddShip(e, e.Target.Position + float3(8, 0, 0), 1f, 300),
AddShip(e, e.Target.Position + float3(-8, 0, 12), 1f, 302)
};
var damage = 0f;
var subscriptions = new List<IDisposable>();
foreach (var o in bystanders.Append(e.Shooter))
{
subscriptions.Add(o.ArmorDamage.Subscribe(x => damage += x.damage));
subscriptions.Add(o.HullDamage.Subscribe(x => damage += x));
subscriptions.Add(o.ItemDamage.Subscribe(x => damage += x.damage));
}
var resolved = new List<ShotOutcome>();
subscriptions.Add(e.Zone.ShotResolved.Subscribe(o => resolved.Add(o)));
FireControl.Fire(e.Weapon, e.WeaponItem, e.Shooter);
e.Zone.Update(committedFirst ? 4.6f : .01f);
Assert.Equal(committedFirst, e.Zone.PendingShots[0].Committed);
e.Zone.Entities.Remove(e.Target);
e.Zone.Update(6f);
foreach (var s in subscriptions) s.Dispose();
Assert.Empty(e.Zone.PendingShots);
Assert.True(bystanders.All(b => Alive(e, b)), "no bystander may take blast damage");
Assert.Equal(0f, damage);
Assert.Equal(ShotResult.Miss, Assert.Single(resolved).Result);
}
// A no-lock round outlives a shooter destroyed outright in flight, for every fuse: Death removes the shooter
// and the round still bursts at max range and resolves once as a Burst.
[Theory]
[InlineData(WeaponFuse.Proximity)]
[InlineData(WeaponFuse.Contact)]
[InlineData(WeaponFuse.Delayed)]
public void ANoLockRoundStillBurstsAfterItsShooterIsDestroyed(WeaponFuse fuse)
{
var e = Build(TestSettings(), SolidShape(5, 4), velocity: 20f, fuse: fuse, blastRadius: 4f, damage: 100f, weaponRange: 60f);
e.Shooter.Target.Value = null;
Aim(e, float2(0, 1));
var witness = AddShip(e, PointAlong(e, float2(0, 1), 60f), 1f, 300);
var resolved = new List<ShotOutcome>();
using var r = e.Zone.ShotResolved.Subscribe(o => resolved.Add(o));
FireControl.Fire(e.Weapon, e.WeaponItem, e.Shooter);
e.Shooter.DamageHull(1e9f);
e.Zone.Update(4f);
Assert.False(Alive(e, e.Shooter));
Assert.False(Alive(e, witness), "the round must still burst at max range");
Assert.Equal(ShotResult.Burst, Assert.Single(resolved).Result);
}
// ToWorldPoint and ToSchematicPoint are inverses, and ToWorldPoint puts one cell to starboard of the centre
// of mass two world units along the starboard axis (right = (forward.y, -forward.x), spelled out here). At
// facing (0,1) a flipped right axis is still masked by a centre-lane point; (2,1) is not.