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:
parent
6133a051a6
commit
640fa96787
8 changed files with 609 additions and 145 deletions
|
|
@ -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)
|
||||
{
|
||||
|
|
|
|||
|
|
@ -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).
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
{
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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).
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
|
|
|
|||
Loading…
Reference in a new issue