From 640fa96787a5fea3ad1dcb7e7a3cdfc7d7c2e700 Mon Sep 17 00:00:00 2001 From: Meta Vens Date: Tue, 29 Sep 2026 16:27:46 +0200 Subject: [PATCH] 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 --- Assets/Scripts/ServerShared/AetheriaStores.cs | 3 +- .../ServerShared/Behaviors/ConstantWeapon.cs | 4 +- .../ServerShared/Behaviors/InstantWeapon.cs | 2 + .../Scripts/ServerShared/Behaviors/Weapon.cs | 15 +- Assets/Scripts/ServerShared/FireControl.cs | 123 ++-- Assets/Scripts/ServerShared/ItemData.cs | 15 - .../AetheriaStoresTests.cs | 54 -- .../FireControlCut124Tests.cs | 534 +++++++++++++++++- 8 files changed, 607 insertions(+), 143 deletions(-) diff --git a/Assets/Scripts/ServerShared/AetheriaStores.cs b/Assets/Scripts/ServerShared/AetheriaStores.cs index b16a33af8..bee8a4c9f 100644 --- a/Assets/Scripts/ServerShared/AetheriaStores.cs +++ b/Assets/Scripts/ServerShared/AetheriaStores.cs @@ -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()) CultRecordRefs.Validate(data); foreach (var data in cache.GetAll()) 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) { diff --git a/Assets/Scripts/ServerShared/Behaviors/ConstantWeapon.cs b/Assets/Scripts/ServerShared/Behaviors/ConstantWeapon.cs index 37da4fe06..8a93c2a82 100644 --- a/Assets/Scripts/ServerShared/Behaviors/ConstantWeapon.cs +++ b/Assets/Scripts/ServerShared/Behaviors/ConstantWeapon.cs @@ -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). diff --git a/Assets/Scripts/ServerShared/Behaviors/InstantWeapon.cs b/Assets/Scripts/ServerShared/Behaviors/InstantWeapon.cs index 6b9b14fc8..1cf398539 100644 --- a/Assets/Scripts/ServerShared/Behaviors/InstantWeapon.cs +++ b/Assets/Scripts/ServerShared/Behaviors/InstantWeapon.cs @@ -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 diff --git a/Assets/Scripts/ServerShared/Behaviors/Weapon.cs b/Assets/Scripts/ServerShared/Behaviors/Weapon.cs index 350d63500..57340f8d9 100644 --- a/Assets/Scripts/ServerShared/Behaviors/Weapon.cs +++ b/Assets/Scripts/ServerShared/Behaviors/Weapon.cs @@ -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) { diff --git a/Assets/Scripts/ServerShared/FireControl.cs b/Assets/Scripts/ServerShared/FireControl.cs index 3d08172d8..51fab98af 100644 --- a/Assets/Scripts/ServerShared/FireControl.cs +++ b/Assets/Scripts/ServerShared/FireControl.cs @@ -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, diff --git a/Assets/Scripts/ServerShared/ItemData.cs b/Assets/Scripts/ServerShared/ItemData.cs index 40e2beb83..59d93c1ba 100644 --- a/Assets/Scripts/ServerShared/ItemData.cs +++ b/Assets/Scripts/ServerShared/ItemData.cs @@ -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 diff --git a/tests/Aetheria.Shared.Tests/AetheriaStoresTests.cs b/tests/Aetheria.Shared.Tests/AetheriaStoresTests.cs index e23953192..e0fec193d 100644 --- a/tests/Aetheria.Shared.Tests/AetheriaStoresTests.cs +++ b/tests/Aetheria.Shared.Tests/AetheriaStoresTests.cs @@ -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(() => - 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(() => 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). diff --git a/tests/Aetheria.Shared.Tests/FireControlCut124Tests.cs b/tests/Aetheria.Shared.Tests/FireControlCut124Tests.cs index 943475789..3c8f38db3 100644 --- a/tests/Aetheria.Shared.Tests/FireControlCut124Tests.cs +++ b/tests/Aetheria.Shared.Tests/FireControlCut124Tests.cs @@ -83,6 +83,32 @@ public sealed class FireControlCut124Tests : IDisposable public Dictionary Custom = new Dictionary(); } + // 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(); + 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(); + 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(); + 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(); + 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.