diff --git a/Assets/Scripts/Gameplay/EntityInstance.cs b/Assets/Scripts/Gameplay/EntityInstance.cs index 0c3c35368..4034c2e55 100644 --- a/Assets/Scripts/Gameplay/EntityInstance.cs +++ b/Assets/Scripts/Gameplay/EntityInstance.cs @@ -277,10 +277,11 @@ public class EntityInstance : MonoBehaviour } } - // Cut 3 (docs/fire-control-cut.md): DamageSchematic and both HullCollider subscriptions (Splash, Hit) - // are deleted -- FireControl decides hits now and applies them through Entity.Absorb/DamageHull - // (ServerShared, Cut 12.3; DamageSchematic and ApplyHit are gone), the single owner in place of what - // used to exist once per Unity effect. HullColliders no longer publish Hit/Splash at all (R8). + // Cut 3 (docs/fire-control-cut.md): DamageSchematic and both HullCollider subscriptions (blast, Hit) + // are deleted -- FireControl decides hits now and applies them through Entity.ArmorAbsorb/ItemAbsorb/ + // DamageHull (ServerShared, Cut 12.3/12.4(b); DamageSchematic, the old per-cell single-lane absorb path + // and ApplyHit are all gone), the single owner in place of what used to exist once per Unity effect. + // HullColliders no longer publish Hit or a blast event at all (R8). LookAtPoint = new GameObject($"{entity.Name} Look Point").transform; diff --git a/Assets/Scripts/Gameplay/HullCollider.cs b/Assets/Scripts/Gameplay/HullCollider.cs index bdd417e8e..e5dc4ad14 100644 --- a/Assets/Scripts/Gameplay/HullCollider.cs +++ b/Assets/Scripts/Gameplay/HullCollider.cs @@ -3,7 +3,8 @@ using CultMath.UnityBridge; // Cut 4 (docs/fire-control-cut.md): Hit, Splash, SendHit and SendSplash are deleted -- their last callers // (ConstantLaser, ConstantLightning, ConstantParticleWeapon, HitscanEffect, Mine) are gone too (see those -// files and FireControl.Splash). Nothing decides or applies damage through a Unity collider any more (R8): +// files and FireControl.Detonate, 12.4(b)'s renamed Splash). Nothing decides or applies damage through a Unity +// collider any more (R8): // this component is now purely a physics surface for ship-on-ship collision impulse and Mine's own proximity // arming query (Q7), neither of which is hit detection. public class HullCollider : MonoBehaviour diff --git a/Assets/Scripts/Gameplay/Weapons/Mine.cs b/Assets/Scripts/Gameplay/Weapons/Mine.cs index db0f82141..57d56f68e 100644 --- a/Assets/Scripts/Gameplay/Weapons/Mine.cs +++ b/Assets/Scripts/Gameplay/Weapons/Mine.cs @@ -88,15 +88,16 @@ public class Mine : MonoBehaviour } // Cut 4 (docs/fire-control-cut.md, Q7): the arming OverlapSphere above stays -- that is contact detection, - // not hit detection. Blast damage is not: it goes through the same FireControl.Splash every splash-shaped - // weapon uses now, over the zone's own planar entities, not a Unity collider query. Presentation (ShowHit) - // goes with the query it depended on -- nothing here decides who was hit any more, so nothing here can - // point a hit effect at them; a future pass can rebuild that off FireControl-published state if the mine - // ever ships (R9 keeps it regardless). + // not hit detection. Blast damage is not: it goes through the same FireControl.Detonate every blast-shaped + // weapon uses now (12.4(b) renamed Splash), over the zone's own planar entities, not a Unity collider + // query. Presentation (ShowHit) goes with the query it depended on -- nothing here decides who was hit any + // more, so nothing here can point a hit effect at them; a future pass can rebuild that off + // FireControl-published state if the mine ever ships (R9 keeps it regardless). F12-6: BlastRange stays a + // presentation-side input -- Mine has no catalog weapon record carrying Fuse/BlastRadius. public void Explode() { var position = transform.position; - FireControl.Splash(Source.Entity.Zone, position.ToCultMath(), BlastRange, Damage, DamageType); + FireControl.Detonate(Source.Entity.Zone, position.ToCultMath().xz, BlastRange, Damage, DamageType); var ht = HitEffect.Instantiate(); ht.position = position; diff --git a/Assets/Scripts/Gameplay/Weapons/Projectile.cs b/Assets/Scripts/Gameplay/Weapons/Projectile.cs index dd83d0228..70b591649 100644 --- a/Assets/Scripts/Gameplay/Weapons/Projectile.cs +++ b/Assets/Scripts/Gameplay/Weapons/Projectile.cs @@ -5,10 +5,10 @@ using static CultMath.math; // Cut 3 (docs/fire-control-cut.md): the raycast, shield branch and SendHit are deleted -- FireControl already // decided this shot's fate before this object was ever spawned (R8). What is left is pure flight-path // presentation: fly, and disappear at Range or ShotId's resolution, whichever comes first. -// Cut 6b, 6.2: the flak-round blast now resolves in the simulation (FireControl.Step calls Splash instead of -// Apply at arrival) -- a MonoBehaviour calling Splash would be exactly the authority Cut 3 spent itself -// deleting, so the blast-distance/blast-radius fields this object used to carry for that are gone. This -// object's job stays fly-and-disappear. +// Cut 6b, 6.2 (12.4(b): Apply's own switch on the frozen Fuse decides this now, not Step): the flak-round +// blast resolves in the simulation, through FireControl.Detonate -- a MonoBehaviour calling it would be +// exactly the authority Cut 3 spent itself deleting, so the blast-distance/blast-radius fields this object +// used to carry for that are gone. This object's job stays fly-and-disappear. public class Projectile : MonoBehaviour { public TrailRenderer Trail; diff --git a/Assets/Scripts/ServerShared/Entity.cs b/Assets/Scripts/ServerShared/Entity.cs index f7e5ac14c..bccc990a3 100644 --- a/Assets/Scripts/ServerShared/Entity.cs +++ b/Assets/Scripts/ServerShared/Entity.cs @@ -364,8 +364,9 @@ public abstract class Entity // own INCOMING share -- not the post-clamp amount, the same convention ArmorAbsorb already uses -- and only // when that lane's own share is itself > 0, so a zero-deposit contributor (a lane whose armour ate its // whole share before reaching this item) never fires a phantom event. `item` is never null here: both - // callers (`Absorb`, below, and `FireControl.ApplyPooled`'s `Resolve`) already guarantee it -- a pool is - // only ever opened under a non-null `GearOccupancy` cell. + // callers (`FireControl.ApplyPooled`'s `Resolve`, one lane's worth of contributions included, and + // `FireControl.Detonate`'s own per-entity item-pool resolution, one covered cell's worth included) already + // guarantee it -- a pool is only ever opened under a non-null `GearOccupancy` cell. public void ItemAbsorb(EquippedItem item, Span incoming) { var total = 0f; @@ -383,22 +384,6 @@ public abstract class Entity } } - // Cut 12.3 (docs/fire-control-cut.md): "armour absorbs first" -- armour up to its own value, then the - // occupying item, then whatever is left over is returned to the caller for the hull's own share (or the - // next cell down the lane; a lane that keeps carrying a remainder is not this function's concern -- - // FireControl.Apply's own pooled walk does that). This is the single-lane path: it feeds ItemAbsorb a - // pool of exactly one contribution, the same degenerate case Apply's own multi-lane pools reduce to when - // only one lane ever reaches an item. Splash is Absorb's only remaining caller (12.4 deletes Splash). - public float Absorb(int2 cell, float damage) - { - var d = ArmorAbsorb(cell, damage); - var item = GearOccupancy[cell.x, cell.y]; - if (item == null) return d; - Span incoming = stackalloc float[1] { d }; - ItemAbsorb(item, incoming); - return incoming[0]; - } - // Cut 12.3: moved verbatim from DamageSchematic's own tail (Cut 3) -- the >.1f threshold and the one // HullDamage event are unchanged. FireControl.Apply calls this once per resolved hit, with the summed // remainder every lane's own walk left over (Q12-3 = A: a lane's remainder goes to the hull wherever the @@ -414,8 +399,8 @@ public abstract class Entity // Cut 12.1 (docs/fire-control-cut.md): the one schematic-frame owner. Maps a world-planar vector into this // entity's own schematic frame -- x = starboard, y = bow -- so FireControl.Apply's lane walk and - // FireControl.Splash's directional half both read the same transform instead of each carrying their own - // copy of forward/right. + // FireControl.Detonate's per-entity point conversion both read the same transform instead of each carrying + // their own copy of forward/right. public float2 ToSchematic(float2 worldPlanar) { var forward = normalize(Direction); @@ -423,6 +408,31 @@ public abstract class Entity return float2(dot(worldPlanar, right), dot(worldPlanar, forward)); } + // Cut 12.4(b) (docs/fire-control-cut.md, "Area, per entity"): the world<->schematic POINT owners, built on + // ToSchematic's frame -- the entity's own position is the schematic's centre of mass. FireControl.Detonate + // uses ToSchematicPoint to find where a blast sits in each candidate's own schematic (the host of a contact + // or delayed blast included: it is treated like any other entity in the radius, its cells found again + // here, not carried over from the commit). ToWorldPoint is the inverse, used once, to convert a contact or + // delayed fuse point (found on the committed lane, in the host's schematic frame at commit) into the one + // world point Detonate's single input shape takes -- the round trip through the host's own pose at arrival + // is what fixes the host's damage to the commit (R4) while every bystander is judged live. + public float2 ToSchematicPoint(float2 worldPlanar) + { + var hullData = ItemManager.GetData(Hull) as HullData; + var cellSize = ItemManager.GameplaySettings.SchematicCellSize; + return ToSchematic(worldPlanar - Position.xz) / cellSize + hullData.Shape.CenterOfMass; + } + + public float2 ToWorldPoint(float2 schematicPoint) + { + var hullData = ItemManager.GetData(Hull) as HullData; + var cellSize = ItemManager.GameplaySettings.SchematicCellSize; + var local = (schematicPoint - hullData.Shape.CenterOfMass) * cellSize; + var forward = normalize(Direction); + var right = float2(forward.y, -forward.x); + return Position.xz + local.x * right + local.y * forward; + } + // Another entity's stance toward THIS one, as far as this entity can perceive it: unknown (null) // until this entity detects them, mirroring the existing detection model (VisibleEntities, driven // by EntityInfoGathered crossing TargetDetectionInfoThreshold). Detected but not yet hostility-rated diff --git a/Assets/Scripts/ServerShared/FireControl.cs b/Assets/Scripts/ServerShared/FireControl.cs index 2c7fcbdba..c76ffcc19 100644 --- a/Assets/Scripts/ServerShared/FireControl.cs +++ b/Assets/Scripts/ServerShared/FireControl.cs @@ -461,11 +461,11 @@ public static class FireControl if (shot.Committed && now >= shot.ArrivalTime) { - // Cut 6b, 6.2 (renamed 12.4(a)): a shot with a frozen BlastRadius > 0 resolves as an area effect - // instead of a discrete hit -- Splash instead of Apply, never both, which is exactly the - // double-application Soul was told to hunt for. The fuse switch itself is 12.4(b)'s. - if (shot.BlastRadius > 0f) Splash(zone, shot.BurstPosition, shot.BlastRadius, shot.Damage, shot.DamageType); - else Apply(shot); + // Cut 12.4(b) (docs/fire-control-cut.md, "Which model a shot uses"): Apply is the one owner of + // which model a shot uses, through its own switch on the frozen Fuse -- Step has no damage + // branch left. A direct hit, a proximity burst and a contact or delayed blast all arrive here + // the same way; only Apply decides what that means. + Apply(shot); zone.ShotResolved.OnNext(shot.Outcome); shots.RemoveAt(i); } @@ -579,10 +579,17 @@ public static class FireControl ArrayPool.Shared.Return(buffer); } - var shield = shot.Target.Shield; - var shieldActive = shield != null && shield.Item.Active.Value; - if (shieldActive && shield.CanTakeHit(shot.DamageType, shot.Damage)) shielded = true; - else if (shieldActive) shieldBroken = true; + // Q12-9 = A (docs/fire-control-cut.md): Commit decides no shield for a shot that carries a fuse -- + // Detonate decides every blast's shields live, at detonation, the host included, charged the + // entity's own covered share. A direct hit (Fuse == null) is unchanged: Commit still decides it here, + // on the whole frozen Damage. + if (shot.Fuse == null) + { + var shield = shot.Target.Shield; + var shieldActive = shield != null && shield.Item.Active.Value; + if (shieldActive && shield.CanTakeHit(shot.DamageType, shot.Damage)) shielded = true; + else if (shieldActive) shieldBroken = true; + } } return MakeOutcome(shot, hit, shielded, shieldBroken, cell, bearing, lateral, now); @@ -632,7 +639,45 @@ public static class FireControl // membership rule, the identical shape of failure as 12.2's empty lane and 12.3's own double strike. There // is now one walk, `ApplyPooled`, in which every item pools the deposits of whatever lanes reach it, a // pool of one lane included -- a single-cell item is this rule's own degenerate case, not a fork. + // + // Cut 12.4(b) (docs/fire-control-cut.md, "Which model a shot uses"): Apply is the one owner of which model + // a shot uses, through the switch on the frozen shot.Fuse below. Step has no damage branch left -- every + // arrived shot, direct hit or blast, comes through here. A null fuse keeps 12.3's lane path unchanged; a + // proximity fuse detonates at arrival with no roll, whether the shot hit or missed + // (ProximityDetonatesEvenOnAGuaranteedMiss keeps today's AirburstSplashesEvenOnAGuaranteedMiss rule); a + // contact or delayed fuse detonates only on a committed hit, at a point 12.3's own Lane/Reach machinery + // locates. This is a default, not a ruling: a blast shot's DamageSpread is not read, and P sits on the + // centre lane (see the fuse-point comment on the Contact/Delayed branch below). private static void Apply(PendingShot shot) + { + switch (shot.Fuse) + { + 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(shot.Source.Zone, shot.BurstPosition.xz, shot.BlastRadius, shot.Damage, shot.DamageType); + return; + case WeaponFuse.Contact: + case WeaponFuse.Delayed: + ApplyBlastHit(shot); + return; + } + } + + // Cut 12.3 (docs/fire-control-cut.md, "How a direct hit travels"): replaces Entity.ApplyHit's 0.5-step + // march and Entity.DamageSchematic's even split over the whole hit shape. Spread is width (Q12-2 = A): + // 2n+1 parallel lanes, sharing the shot's damage evenly over whichever lanes actually meet metal -- the + // centre lane, at the committed Lateral, always does (Commit already proved it, R3). Each lane is Lane's + // own contiguous cell run from its own facing cell, clipped to the penetration depth by Reach (the impact + // cell is always reached, replacing the deleted `> .5f` threshold at zero cost). Whatever a lane does not + // spend -- penetration exhausted, a gap, or the far side -- goes to the hull where the lane ends (Q12-3 = + // A), summed across every lane into one DamageHull call. + // Cut 12.4(b): unchanged from 12.3 except that the inline penetration clip is now Reach, the shared owner + // the delayed fuse point also reads (see "The penetrator"). + private static void ApplyDirectHit(PendingShot shot) { if (!shot.Outcome.Hit) return; @@ -648,7 +693,8 @@ public static class FireControl var target = shot.Target; // Cut 12.3: moved here from Entity.ApplyHit's own first line -- the shield branches above still return - // before this runs, unchanged from 12.2. + // before this runs, unchanged from 12.2. Cut 12.4(b): fires only for a direct hit; a proximity burst + // and every bystander in any blast never fire it (AContactBlastReportsTheHit covers the blast half). target.IncomingHit.OnNext(shot.Source); // Cut 12.2 (R4): the committed geometry, frozen. Apply reads no position or facing of its own -- @@ -667,19 +713,10 @@ public static class FireControl buffers[li] = ArrayPool.Shared.Rent(hullData.Shape.Coordinates.Length); var walked = Lanes(hullData, bearing, shot.Outcome.Lateral, n, laneSpacing, buffers); - // Cut 12.3 fix batch ("one code path"): the ONLY penetration derivation in this whole walk -- clip each - // lane's own cell run to its penetration depth right here, once, immediately after Lanes returns. - // Everything downstream reads `walked` as the lane's entire reach; nothing below ever re-tests a cell's - // entry against penetration again, and there is no separate survey of which cells or items a lane can - // reach -- ApplyPooled's own walk discovers that live, from `walked` alone. + // Cut 12.4(b) ("The penetrator", Q12-8): Reach is now the one penetration-reach owner, shared with the + // delayed fuse point below -- no second reach derivation exists anywhere in this file. for (var li = 0; li < laneCount; li++) - { - if (walked[li] == 0) continue; - var impactEntry = buffers[li][0].Entry; - var clipped = 1; - while (clipped < walked[li] && buffers[li][clipped].Entry - impactEntry < shot.Penetration) clipped++; - walked[li] = clipped; - } + walked[li] = Reach(buffers[li], walked[li], shot.Penetration); var metalLanes = 0; for (var li = 0; li < laneCount; li++) @@ -696,6 +733,66 @@ public static class FireControl target.DamageHull(hullDamage); } + // Cut 12.4(b) (docs/fire-control-cut.md, "Where P is"): a contact or delayed blast needs a committed hit -- + // on a miss nothing happens, exactly like a direct hit's own gate. IncomingHit fires once, for the host, + // before the blast is handed to Detonate (which then treats the host like any other entity in the radius: + // Detonate never reads a host parameter, and finds the host's own cells again by ToSchematicPoint). + private static void ApplyBlastHit(PendingShot shot) + { + if (!shot.Outcome.Hit) return; + + var target = shot.Target; + target.IncomingHit.OnNext(shot.Source); + + // "Where P is": the lane's own parametrisation gives the point directly, point(t) = t*b + s*ell, on the + // committed lane (the committed Bearing and Lateral, which Commit's own guard already proved non-empty, + // R3). Contact: t is the impact cell's own entry, lane[0].Entry. Delayed: t_fuse is where the frozen + // Penetration runs out or the metal does, whichever comes first -- Reach (the same owner the direct-hit + // walk reads) decides how deep the lane reaches, and t_fuse clamps to the last reached cell's exit so + // the disc is always centred on metal. + var hullData = target.ItemManager.GetData(target.Hull) as HullData; + var bearing = shot.Outcome.Bearing; + var buffer = ArrayPool.Shared.Rent(hullData.Shape.Coordinates.Length); + float2 schematicPoint; + try + { + var walked = Lane(hullData, bearing, shot.Outcome.Lateral, buffer); + var t = buffer[0].Entry; + if (shot.Fuse == WeaponFuse.Delayed) + { + var reach = Reach(buffer, walked, shot.Penetration); + t = min(buffer[0].Entry + shot.Penetration, buffer[reach - 1].Exit); + } + schematicPoint = t * bearing + shot.Outcome.Lateral * Lateral(bearing); + } + finally + { + ArrayPool.Shared.Return(buffer); + } + + // "Conversion to the world": the round trip through the host's own ToWorldPoint/ToSchematicPoint + // cancels the host's pose, so the host's own damage is fixed by the commit (R4 holds for it), while + // every bystander is judged against where it actually is at arrival -- the same live rule a proximity + // burst already applies to everyone. + var worldPoint = target.ToWorldPoint(schematicPoint); + Detonate(target.Zone, worldPoint, shot.BlastRadius, shot.Damage, shot.DamageType); + } + + // Cut 12.4(b) (docs/fire-control-cut.md, "The penetrator", Q12-8): the one penetration-reach owner, moved + // verbatim from Apply's own inline clip (Cut 12.3 fix batch) so the delayed fuse point above can read the + // identical rule the direct-hit walk already used. The rule is unchanged: the impact cell is always + // reached, and a later cell is reached while entry - impact.entry < penetration. Two callers, and no third: + // a null-fuse shot spends damage along the walk (ApplyDirectHit), a contact-or-delayed shot only locates P + // (ApplyBlastHit) -- no shot ever takes both branches of Apply's switch. + private static int Reach(LaneCell[] cells, int walked, float penetration) + { + if (walked == 0) return 0; + var impactEntry = cells[0].Entry; + var reach = 1; + while (reach < walked && cells[reach].Entry - impactEntry < penetration) reach++; + return reach; + } + // Cut 12.3 fix batch (2026-09-25, operator rulings "proportional absorption; go" and "one code path" -- // docs/fire-control-cut.md, Cut 12.3 status): every lane walks its own already-clipped cells near to far. // Armour is never shared between lanes (Lanes' own disjointness rule), so Entity.ArmorAbsorb runs the @@ -947,56 +1044,201 @@ public static class FireControl }; } - // Cut 4 (docs/fire-control-cut.md): the one splash rule -- an unconditional area effect, not a rolled - // shot. Nothing here draws: a mine or an airburst round always damages everything it catches, the same - // as the Physics.OverlapSphere queries this replaces always did. Applies the shield-absorb-or-hull-takes- - // it branch (F5, docs/stats-and-power-cut.md) per target, then -- for a target the shield didn't fully - // absorb -- an even split (still today's rule; 12.3 only retired the direct-hit lane's even split, not - // Splash's) over the directional half of that target's own hull that faces the blast, the rule moved - // verbatim from the Splash subscription EntityInstance.cs carried before Cut 3 deleted it (git show - // b7743789^:Assets/Scripts/Gameplay/EntityInstance.cs), made planar (R7): the blast-to-target direction is - // measured in the zone's (x,z) plane and rotated into each target's own facing by its Direction, the same - // rotation Entity's own schematic frame (ToSchematic) uses. - // Cut 12.3: Entity.DamageSchematic is deleted with the direct-hit march it shared a file with -- Splash - // now calls Entity.Absorb per footprint cell directly and sums the leftover into one DamageHull call, - // arithmetically identical to DamageSchematic's own body (same per-cell split, same order, same - // thresholds). Untouched otherwise; deleted in 12.4 along with the rest of the ray model. - public static void Splash(Zone zone, float3 position, float radius, float damage, DamageType damageType) + // Cut 12.4(b) (docs/fire-control-cut.md, "Area, per entity"): the one owner of blast damage -- a blast is + // an area, not a bundle of rays (Q12-7 dissolved the ray model's remainder question along with the rays + // themselves). Splash's signature with the point made planar (R7). Callers: Apply's three fuse branches and + // Mine.Explode. Nothing draws: every candidate entity in radius takes its share, unconditionally. + // + // For each candidate (an entity whose centre lies within radius plus half its own hull diagonal -- + // CandidatesIncludeHullsWhoseCentreIsOutsideTheRadius, which Splash's centre-distance cull could not pass): + // take Ps = ToSchematicPoint(P) and rCells = radius / SchematicCellSize once, then for every occupied hull + // cell whose unit square meets the disc, share_c = damage * overlap(c) / (pi * rCells^2) -- the exact area + // of the disc inside that cell's unit square, over the disc's own area. Every share that falls on an + // unoccupied cell, or outside the schematic, is lost (TheShareOffTheHullIsLost); nothing renormalises over + // the covered cells. + // + // Every shield decision for a blast is made here, live, charged the entity's summed shares under today's + // CanTakeHit/Break/TakeHit rules (Q12-9 = A: the host of a contact or delayed blast decides its shield here + // too, never at Commit). If the shield absorbs, no cell of that entity takes anything; if it cannot, it + // breaks and the cells take their shares. + // + // Armour protects only its own cell's share (ArmorProtectsOnlyItsOwnCellsShare) -- "armour absorbs first" + // runs per cell, in hull Coordinates order. Only once every covered cell has deposited does an item resolve + // its own pool, once (AMultiCellItemAbsorbsItsCoveredCellsAsOnePool): no per-cell item threshold survives + // anywhere, because a disc's schedule has no walk -- no cell's share depends on another + // cell's leftover, so every deposit is known before any item resolves and there is nothing for a + // reachability test or a cycle rule to decide (unlike ApplyPooled's lane walk, which this function does not + // call: doing so would buy no behaviour, per ProbeC in the cut doc). Leftovers -- unoccupied-cell shares, + // and whatever an item's pool does not absorb -- join one entity-wide hull total, applied through one + // DamageHull call. + public static void Detonate(Zone zone, float2 worldPlanar, float radius, float damage, DamageType damageType) { - foreach (var target in zone.Entities) - { - var toTarget = (target.Position - position).xz; - if (length(toTarget) > radius) continue; + // F3 (Soul, Cut 12.3 fold-in): Detonate cannot be reached with a nonpositive radius. Fire only ever + // freezes a Fuse when BlastRadius > 0 (12.4(a)), so this guard matters only for a direct caller (a + // test, or Mine.Explode with a zero BlastRange). + if (radius <= 0f) return; - var shield = target.Shield; - var shieldActive = shield != null && shield.Item.Active.Value; - var shieldAbsorbs = shieldActive && shield.CanTakeHit(damageType, damage); - if (shieldActive && !shieldAbsorbs) shield.Break(); - if (shieldAbsorbs) + foreach (var entity in zone.Entities) + { + var cellSize = entity.ItemManager.GameplaySettings.SchematicCellSize; + var hullData = entity.ItemManager.GetData(entity.Hull) as HullData; + var shape = hullData.Shape; + + // Candidates: |Position.xz - P| <= radius + half the hull's own diagonal, in world units -- catches + // a long hull whose end lies inside the blast even though its centre does not. + var toEntity = length(entity.Position.xz - worldPlanar); + var halfDiagonal = .5f * length(float2(shape.Width, shape.Height)) * cellSize; + if (toEntity > radius + halfDiagonal) continue; + + var centre = entity.ToSchematicPoint(worldPlanar); + var rCells = radius / cellSize; + var normaliser = PI * rCells * rCells; + + // Shares, computed once, in hull Coordinates order -- the same order armour absorbs in below and + // an item's pool deposits in. + var coords = shape.Coordinates; + var covered = new List<(int2 Cell, float Share)>(); + var totalShare = 0f; + for (var i = 0; i < coords.Length; i++) { - shield.TakeHit(damageType, damage); - continue; + var overlap = CircleSquareOverlap(centre, rCells, coords[i]); + if (overlap <= 0f) continue; + var share = damage * overlap / normaliser; + covered.Add((coords[i], share)); + totalShare += share; + } + if (covered.Count == 0) continue; + + var shield = entity.Shield; + var shieldActive = shield != null && shield.Item.Active.Value; + if (shieldActive) + { + if (shield.CanTakeHit(damageType, totalShare)) + { + shield.TakeHit(damageType, totalShare); + continue; + } + shield.Break(); } - var hullData = target.ItemManager.GetData(target.Hull) as HullData; - var localDirection = lengthsq(toTarget) > 1e-6f - ? normalize(target.ToSchematic(toTarget)) - : float2(0, 1); + // Armour first, per cell; an item's covered cells pool their post-armour deposits and resolve once, + // from the pooled sum -- ItemAbsorb's own degenerate case for a pool of one covers a single-cell + // item, exactly as it does in ApplyPooled. + Dictionary> itemPools = null; + var hullTotal = 0f; + foreach (var (cell, share) in covered) + { + var postArmor = entity.ArmorAbsorb(cell, share); + var item = entity.GearOccupancy[cell.x, cell.y]; + if (item == null) { hullTotal += postArmor; continue; } + itemPools ??= new Dictionary>(); + if (!itemPools.TryGetValue(item, out var pool)) itemPools[item] = pool = new List(); + pool.Add(postArmor); + } - var hitShape = new Shape(hullData.Shape.Width, hullData.Shape.Height); - foreach (var v in hullData.Shape.Coordinates) - if (dot(normalize((float2) v - hullData.Shape.CenterOfMass), localDirection) < 0) - hitShape[v] = true; + if (itemPools != null) + foreach (var (item, pool) in itemPools) + { + var amounts = pool.ToArray(); + entity.ItemAbsorb(item, amounts); + for (var i = 0; i < amounts.Length; i++) hullTotal += amounts[i]; + } - var cells = hitShape.Coordinates; - var damagePerCell = damage / cells.Length; - var hullDamage = 0f; - foreach (var v in cells) - hullDamage += target.Absorb(v, damagePerCell); - target.DamageHull(hullDamage); + entity.DamageHull(hullTotal); } } + // Cut 12.4(b) (docs/fire-control-cut.md, "Exact overlap, in float"): the exact area of a unit square, + // centred on `cell` (the unit square centred on its integer coordinate, the same convention every other + // frame rule uses), that lies inside a disc of radius `r` centred at `diskCentre` -- both in the same + // (schematic-cell) units. Allocates nothing. + private static float CircleSquareOverlap(float2 diskCentre, float r, int2 cell) + { + var xlo = cell.x - .5f - diskCentre.x; + var xhi = cell.x + .5f - diskCentre.x; + var ylo = cell.y - .5f - diskCentre.y; + var yhi = cell.y + .5f - diskCentre.y; + return RectDiskOverlap(xlo, xhi, ylo, yhi, r); + } + + // The area of intersection of a disc of radius r centred at the origin with the axis-aligned rectangle + // [xlo,xhi] x [ylo,yhi]. Integrates the disc's chord height over the rectangle's x-span: for a fixed x, the + // disc spans y in [-h,h] with h = sqrt(r^2 - x^2), and the covered length there is + // min(yhi,h) - max(ylo,-h), clamped to zero. That covered length changes formula only where h(x) crosses + // ylo or yhi (or at the rectangle's own edges), so the span is cut at those breakpoints and each piece is + // integrated in closed form with AntiderivSqrt, the antiderivative of sqrt(r^2 - x^2). + private static float RectDiskOverlap(float xlo, float xhi, float ylo, float yhi, float r) + { + if (r <= 0f) return 0f; + xlo = max(xlo, -r); + xhi = min(xhi, r); + if (xlo >= xhi) return 0f; + + Span bp = stackalloc float[6]; + var n = 0; + bp[n++] = xlo; + bp[n++] = xhi; + + // The breakpoints where h(x) = sqrt(r^2 - x^2) crosses ylo or yhi -- the only places the covered + // length's formula (below) can change. No local function: a stackalloc Span cannot be captured by one. + Span ys = stackalloc float[2] { ylo, yhi }; + for (var k = 0; k < 2; k++) + { + var y = ys[k]; + if (abs(y) >= r) continue; + var xc = sqrt(r * r - y * y); + if (-xc > xlo && -xc < xhi) bp[n++] = -xc; + if (xc > xlo && xc < xhi) bp[n++] = xc; + } + + // Insertion sort: n is at most 6. + for (var i = 1; i < n; i++) + { + var key = bp[i]; + var j = i - 1; + while (j >= 0 && bp[j] > key) { bp[j + 1] = bp[j]; j--; } + bp[j + 1] = key; + } + + var area = 0f; + for (var i = 0; i < n - 1; i++) + { + var a = bp[i]; + var b = bp[i + 1]; + if (b - a < 1e-9f) continue; + + var mid = .5f * (a + b); + var h = sqrt(max(0f, r * r - mid * mid)); + var upperIsDisc = h < yhi; + var lowerIsDisc = -h > ylo; + var upper = upperIsDisc ? h : yhi; + var lower = lowerIsDisc ? -h : ylo; + if (upper <= lower) continue; + + float piece; + if (upperIsDisc && lowerIsDisc) + piece = 2f * (AntiderivSqrt(b, r) - AntiderivSqrt(a, r)); + else if (upperIsDisc) + piece = (AntiderivSqrt(b, r) - AntiderivSqrt(a, r)) - ylo * (b - a); + else if (lowerIsDisc) + piece = yhi * (b - a) + (AntiderivSqrt(b, r) - AntiderivSqrt(a, r)); + else + piece = (yhi - ylo) * (b - a); + + area += piece; + } + return max(0f, area); + } + + // The antiderivative of sqrt(r^2 - x^2): x/2 * sqrt(r^2 - x^2) + r^2/2 * asin(x/r). Clamped into [-r,r] + // first -- every caller here already clips its integration bounds into that range, so this is a guard + // against float rounding at the boundary, not a second clipping rule. + private static float AntiderivSqrt(float x, float r) + { + var xc = clamp(x, -r, r); + return .5f * (xc * sqrt(max(0f, r * r - xc * xc)) + r * r * asin(xc / r)); + } + // Cut 12.2 (docs/fire-control-cut.md): Sigma keeps its Cut 6d/9.2 rule unchanged -- the frozen Precision's // reciprocal, in hull-schematic cell units, floored at half a cell. The 9.2 floor's old rationale ("the // discrete sum undershoots the continuous integral") is retired: Silhouette below integrates the Gaussian @@ -1202,7 +1444,7 @@ public static class FireControl } // The cells of the target's hull schematic actually occupied by `item` -- GearOccupancy is the one source - // of truth for where an equipped item's footprint lands, the same table Entity.Absorb reads. + // of truth for where an equipped item's footprint lands, the same table Detonate and ApplyPooled read. private static int2[] CellsOf(Entity target, EquippedItem item) { var hullData = target.ItemManager.GetData(target.Hull) as HullData; diff --git a/Assets/Scripts/ServerShared/ItemData.cs b/Assets/Scripts/ServerShared/ItemData.cs index 27c647d83..59d93c1ba 100644 --- a/Assets/Scripts/ServerShared/ItemData.cs +++ b/Assets/Scripts/ServerShared/ItemData.cs @@ -491,9 +491,9 @@ public class WeaponItemData : GearData public WeaponModifiers WeaponModifiers; // Cut 12.4(a) (docs/fire-control-cut.md): the authored blast radius a weapon's shot detonates with, in - // world units. Renamed from AirburstRange -- the slot and type are unchanged, so CultCache opens the - // catalog as compatible drift. Null (every shipped record) means no blast; WeaponModifiers no longer - // decides this (Q12-6, "WeaponModifiers are labels, not behaviour"). + // world units. Renamed from slot 29's old airburst-range field -- the slot and type are unchanged, so + // CultCache opens the catalog as compatible drift. Null (every shipped record) means no blast; WeaponModifiers + // no longer decides this (Q12-6, "WeaponModifiers are labels, not behaviour"). [Inspectable, JsonProperty("blastRadius"), Key(29)] public float? BlastRadius; diff --git a/tests/Aetheria.Shared.Tests/FireControlCut11Tests.cs b/tests/Aetheria.Shared.Tests/FireControlCut11Tests.cs index e46c1a86e..2a65fda64 100644 --- a/tests/Aetheria.Shared.Tests/FireControlCut11Tests.cs +++ b/tests/Aetheria.Shared.Tests/FireControlCut11Tests.cs @@ -231,10 +231,14 @@ public sealed class FireControlCut11Tests : IDisposable Assert.Equal(hull, e.Target.Hull.Durability); // and every hit was absorbed } - // The same rule on Splash's path, whose absorb branch had no test coverage at all. Mutation: delete - // TakeHit from Splash's absorb branch. + // The same rule on a blast's path (12.4(b) rewrites this to Detonate, docs/fire-control-cut.md + // "ShieldPaysForWhatReachesIt": "the reserve drops by the entity's covered share, not by the whole + // blast"), whose absorb branch had no test coverage at all. The radius sits well inside the hull's own + // solid interior (the 5x3 shape's centre of mass, at least 1 cell from every edge) so the disc never spills + // off the hull -- the covered share equals the whole damage exactly, the same amount Splash always charged, + // so this expectation is not loosened. Mutation: delete TakeHit from Detonate's absorb branch. [Fact] - public void SplashShieldAbsorptionDrainsTheReserve() + public void ABlastShieldAbsorptionDrainsTheReserve() { var e = Build(equipShield: true, shieldCapacity: 30); Charge(e); @@ -244,7 +248,7 @@ public sealed class FireControlCut11Tests : IDisposable var blasts = 0; while (e.Target.Shield.CanTakeHit(DamageType.Kinetic, 5) && blasts < 200) { - FireControl.Splash(e.Zone, e.Target.Position, radius: 20, damage: 5, damageType: DamageType.Kinetic); + FireControl.Detonate(e.Zone, e.Target.Position.xz, radius: 1, damage: 5, damageType: DamageType.Kinetic); blasts++; } @@ -354,40 +358,11 @@ public sealed class FireControlCut11Tests : IDisposable } // ---- Splash damages the side the blast came from. ---- - - // The operator's "damage on the wrong side of the ship" was a legacy Unity bug that Splash never shipped, - // but nothing defended against it. Flipping the sign in Splash's - // `right = float2(forward.y, -forward.x)` survived every earlier suite, and it survives this test too at - // the default facing (0, 1): the flip only changes which side is hit when a ship faces more along world x - // than z. So the target is spun through facings where the flip bites as well as ones where it does not, - // and blasted from each side in turn. The blast side is computed here independently of FireControl -- - // starboard is the facing rotated clockwise -- so this is not the code checking itself. - [Theory] - [InlineData(0f, 1f)] - [InlineData(1f, 0f)] - [InlineData(.8f, .6f)] - [InlineData(-.8f, -.6f)] - [InlineData(-.6f, .8f)] - public void SplashDamagesTheSideTheBlastCameFrom(float fx, float fy) - { - foreach (var fromStarboard in new[] { true, false }) - { - var e = Build(markers: true); - var facing = normalize(float2(fx, fy)); - e.Target.Direction = facing; - var starboard = float2(facing.y, -facing.x); - var side = fromStarboard ? starboard : -starboard; - var blast = e.Target.Position + float3(side.x, 0, side.y) * 10f; - - FireControl.Splash(e.Zone, blast, radius: 20, damage: 10, damageType: DamageType.Kinetic); - - var nearMarker = fromStarboard ? e.StarboardMarker : e.PortMarker; - var farMarker = fromStarboard ? e.PortMarker : e.StarboardMarker; - var label = $"facing ({fx}, {fy}), blast from {(fromStarboard ? "starboard" : "port")}"; - Assert.True(nearMarker.EquippableItem.Durability < 100, $"{label}: the marker facing the blast took no damage"); - Assert.Equal(100f, farMarker.EquippableItem.Durability); - } - } + // + // Superseded (docs/fire-control-cut.md, "Symmetry and frame"): ABlastDamagesTheCellsNearestIt in + // FireControlCut124Tests.cs succeeds this test and Cut4Tests.SplashIsDirectional -- a blast is now an area + // (12.4(b)), not a directional half-hull split, so "the side the blast came from" is pinned by which cells + // the disc actually covers, not by a facing-rotated half-shape. // ---- pSpread is the share of the spread cone the target's silhouette covers. ---- diff --git a/tests/Aetheria.Shared.Tests/FireControlCut123Tests.cs b/tests/Aetheria.Shared.Tests/FireControlCut123Tests.cs index 90f5891c1..b01d04b76 100644 --- a/tests/Aetheria.Shared.Tests/FireControlCut123Tests.cs +++ b/tests/Aetheria.Shared.Tests/FireControlCut123Tests.cs @@ -493,48 +493,45 @@ public sealed class FireControlCut123Tests : IDisposable Assert.Equal(hullBefore - 40f, e.Target.Hull.Durability, 2); } - // Stryker survivor triage (this cut's own changed lines): Entity.Absorb's `d > 0f` guard boundary-flipped to - // `d >= 0f` survived -- at exactly 0 incoming, real code emits nothing ("only for incoming > 0"), the mutant - // would additionally fire ArmorDamage(cell, 0). Not a rare float coincidence: a spent lane (rem already 0) - // walks past every remaining cell in FireControl.Apply's own loop, so this is the ordinary "nothing left" - // case, not an edge case. + // Stryker survivor triage (this cut's own changed lines), retargeted to the public owner after 12.4(b) + // deletes Entity.Absorb (docs/fire-control-cut.md, "Retargeted, because Absorb is deleted"): + // ArmorAbsorb's `d > 0f` guard boundary-flipped to `d >= 0f` survived -- at exactly 0 incoming, real code + // emits nothing ("only for incoming > 0"), the mutant would additionally fire ArmorDamage(cell, 0). Not a + // rare float coincidence: a spent lane (rem already 0) walks past every remaining cell in + // FireControl.Apply's own loop, so this is the ordinary "nothing left" case, not an edge case. [Fact] - public void AbsorbEmitsNothingAtExactlyZeroIncomingDamage() + public void ArmorAbsorbEmitsNothingAtExactlyZero() { var e = Build(TestSettings(), SolidShape(3, 3), precision: 1f, markers: new[] { (new int2(1, 1), 50f) }); e.Target.Armor[1, 1] = 5f; e.Target.MaxArmor[1, 1] = 5f; - var marker = e.Markers[0]; var armorEvents = 0; - var itemEvents = 0; using var a = e.Target.ArmorDamage.Subscribe(_ => armorEvents++); - using var i = e.Target.ItemDamage.Subscribe(_ => itemEvents++); - var remainder = e.Target.Absorb(new int2(1, 1), 0f); + var remainder = e.Target.ArmorAbsorb(new int2(1, 1), 0f); Assert.Equal(0f, remainder); Assert.Equal(0, armorEvents); - Assert.Equal(0, itemEvents); Assert.Equal(5f, e.Target.Armor[1, 1]); - Assert.Equal(50f, marker.EquippableItem.Durability); } - // Stryker survivor: Entity.Absorb's `d > 0.1f` item-phase guard boundary-flipped to `d >= 0.1f` survived -- - // at exactly 0.1f remaining after armor, real code leaves the item untouched (the remainder returns to the - // caller instead), the mutant would additionally spend it on the item. + // Stryker survivor, retargeted to the public owner after 12.4(b) deletes Entity.Absorb: ItemAbsorb's + // `total <= 0.1f` guard survived at exactly 0.1f -- real code leaves the item untouched, the mutant would + // additionally spend it on the item. A spent lane still walks cells with a remainder of 0, so this is + // pinned as a pool of exactly one contribution, ItemAbsorb's own degenerate case. [Fact] - public void AbsorbLeavesTheItemUntouchedAtExactlyPointOneRemaining() + public void ItemAbsorbLeavesTheItemUntouchedAtExactlyPointOneRemaining() { var e = Build(TestSettings(), SolidShape(3, 3), precision: 1f, markers: new[] { (new int2(1, 1), 50f) }); - e.Target.Armor[1, 1] = 0f; e.Target.MaxArmor[1, 1] = 0f; // bare, so the whole .1f reaches the item check var marker = e.Markers[0]; var itemEvents = 0; using var i = e.Target.ItemDamage.Subscribe(_ => itemEvents++); - var remainder = e.Target.Absorb(new int2(1, 1), 0.1f); + Span incoming = stackalloc float[1] { .1f }; + e.Target.ItemAbsorb(marker, incoming); - Assert.Equal(0.1f, remainder, 3); + Assert.Equal(0.1f, incoming[0], 3); Assert.Equal(0, itemEvents); Assert.Equal(50f, marker.EquippableItem.Durability); } @@ -742,35 +739,10 @@ public sealed class FireControlCut123Tests : IDisposable Assert.Equal(12f, hullThisAttempt, 2); } - // F6: armour and an occupying item on the SAME cell -- no existing fixture combines them, so "armour - // absorbs first" (Entity.Absorb) was only ever exercised with one or the other. Direct calls to Absorb, - // matching this file's own existing style for pinning its per-cell arithmetic (AbsorbEmitsNothingAt..., - // AbsorbLeavesTheItemUntouchedAt...). Kills a swap of the armour and item blocks in Absorb. - [Fact] - public void AbsorbSpendsArmourBeforeTheItemOnTheSameCell() - { - var e = Build(TestSettings(), SolidShape(3, 3), precision: 1f, markers: new[] { (new int2(1, 1), 50f) }); - var marker = e.Markers[0]; - void Reset() { e.Target.Armor[1, 1] = 10f; e.Target.MaxArmor[1, 1] = 10f; marker.EquippableItem.Durability = 50f; } - - Reset(); - var lessThanArmour = e.Target.Absorb(new int2(1, 1), 5f); - Assert.Equal(0f, lessThanArmour); - Assert.Equal(5f, e.Target.Armor[1, 1]); // 5 of the 10 spent - Assert.Equal(50f, marker.EquippableItem.Durability); // item untouched -- armour alone covered it - - Reset(); - var moreThanArmourLessThanBoth = e.Target.Absorb(new int2(1, 1), 25f); - Assert.Equal(0f, moreThanArmourLessThanBoth); - Assert.Equal(0f, e.Target.Armor[1, 1]); // the whole 10 spent - Assert.Equal(35f, marker.EquippableItem.Durability, 2); // exactly the 15 excess - - Reset(); - var moreThanBoth = e.Target.Absorb(new int2(1, 1), 80f); - Assert.Equal(20f, moreThanBoth, 2); // exactly 80 - 10 - 50 - Assert.Equal(0f, e.Target.Armor[1, 1]); - Assert.Equal(0f, marker.EquippableItem.Durability); - } + // F6/12.4(b): "armour absorbs first" on one cell shared by armour and an occupying item is now pinned on + // the public owner, Detonate, by ABlastIsAbsorbedThroughArmourFirstPerCell in FireControlCut124Tests.cs -- + // this superseded AbsorbSpendsArmourBeforeTheItemOnTheSameCell, which called the now-deleted Entity.Absorb + // directly (docs/fire-control-cut.md, "Absorption order"). // F7: direct-hit frame handedness at |fx| > |fy| is otherwise only guarded by Cut 11's Splash tests, which // 12.4 deletes. A target facing (2,1) normalized (|fx| > |fy|), shot from its own starboard side (computed diff --git a/tests/Aetheria.Shared.Tests/FireControlCut4Tests.cs b/tests/Aetheria.Shared.Tests/FireControlCut4Tests.cs index 2fbc5e52d..5ee04751a 100644 --- a/tests/Aetheria.Shared.Tests/FireControlCut4Tests.cs +++ b/tests/Aetheria.Shared.Tests/FireControlCut4Tests.cs @@ -247,9 +247,10 @@ public sealed class FireControlCut4Tests : IDisposable Assert.Equal(1, resolvedCount); } - // ---- Cut 4's splash rule: FireControl.Splash is an unconditional area effect (no roll), directional per - // target's own hull facing. Tested directly against the function, no weapon/gear catalog needed -- Entity. - // MapEntity (called from the Entity constructor) is enough to give a Ship its Armor/GearOccupancy/Hull. ---- + // ---- Cut 4's blast rule (12.4(b) renames FireControl.Splash to Detonate, and areas replace the + // directional half-hull split): FireControl.Detonate is an unconditional area effect, no roll. Tested + // directly against the function, no weapon/gear catalog needed -- Entity.MapEntity (called from the Entity + // constructor) is enough to give a Ship its Armor/GearOccupancy/Hull. ---- private ProvenanceLedger _splashLedger; @@ -279,10 +280,10 @@ public sealed class FireControlCut4Tests : IDisposable return ship; } - // Splash is one rule, not per-effect: every entity within radius takes damage, one outside radius takes + // A blast is one rule, not per-effect: every entity within radius takes damage, one outside radius takes // none. Mutation: damage only the nearest entity in range instead of every one. [Fact] - public void SplashHitsEveryEntityInRadius() + public void ABlastHitsEveryEntityItCovers() { var (items, zone) = BuildSplashZone(TestSettings()); var near = AddShip(items, zone, float3(10, 0, 0), float2(0, 1), 1); @@ -293,39 +294,15 @@ public sealed class FireControlCut4Tests : IDisposable var farBefore = far.Hull.Durability; var outBefore = outOfRange.Hull.Durability; - FireControl.Splash(zone, float3.zero, radius: 30, damage: 50, damageType: DamageType.Kinetic); + FireControl.Detonate(zone, float2.zero, radius: 30, damage: 50, damageType: DamageType.Kinetic); Assert.True(near.Hull.Durability < nearBefore); Assert.True(far.Hull.Durability < farBefore); Assert.Equal(outBefore, outOfRange.Hull.Durability); } - // Splash is directional over each target's own hull: cells facing the blast take it, cells facing away do - // not. Mutation: damage the whole shape regardless of direction. - [Fact] - public void SplashIsDirectional() - { - // Nonzero armor so an undamaged cell is distinguishable from a damaged one -- with armor 0 every cell - // starts and stays at 0 regardless of which half took the schematic hit. - var (items, zone) = BuildSplashZone(TestSettings(), armor: 10); - // Facing +Z (the default); the blast sits behind the ship along -Z, so the near half of the hull - // (negative-Z cells, closest to the blast) should take damage and the far half (positive-Z) should not. - var ship = AddShip(items, zone, float3(0, 0, 10), float2(0, 1), 1); - - FireControl.Splash(zone, float3(0, 0, 0), radius: 50, damage: 1000, damageType: DamageType.Kinetic); - - var hullData = (HullData) items.GetData(ship.Hull); - var center = hullData.Shape.CenterOfMass; - var nearCellDamaged = false; - var farCellUntouched = false; - foreach (var v in hullData.Shape.Coordinates) - { - var offset = (float2) v - center; - if (offset.y < -.5f && ship.Armor[v.x, v.y] < ship.MaxArmor[v.x, v.y]) nearCellDamaged = true; - if (offset.y > .5f && ship.Armor[v.x, v.y] >= ship.MaxArmor[v.x, v.y]) farCellUntouched = true; - } - - Assert.True(nearCellDamaged); - Assert.True(farCellUntouched); - } + // Superseded (docs/fire-control-cut.md, "Symmetry and frame"): ABlastDamagesTheCellsNearestIt in + // FireControlCut124Tests.cs succeeds this test (SplashIsDirectional) and Cut11Tests' + // SplashDamagesTheSideTheBlastCameFrom -- a blast is now an area (12.4(b)), not a directional half-hull + // split, so "which cells take it" is pinned by the disc's own coverage, not a facing-rotated half-shape. } diff --git a/tests/Aetheria.Shared.Tests/FireControlCut5Tests.cs b/tests/Aetheria.Shared.Tests/FireControlCut5Tests.cs index 48dccca21..525a24ce6 100644 --- a/tests/Aetheria.Shared.Tests/FireControlCut5Tests.cs +++ b/tests/Aetheria.Shared.Tests/FireControlCut5Tests.cs @@ -499,12 +499,16 @@ public sealed class FireControlCut5Tests : IDisposable Assert.Equal(0, resolvedCount); } - // A named Soul-pass survivor (docs/fire-control-cut.md Cut 5 Verification): FireControl.Splash's shield - // branch must actually break an unabsorbed shield, not merely route the remainder to the hull. Mutation: - // delete `shield.Break()` from Splash -- the target's hull still takes the blast (SplashHitsEveryEntityInRadius - // stays green), but the shield itself never registers the overwhelming hit and keeps absorbing on schedule. + // A named Soul-pass survivor (docs/fire-control-cut.md Cut 5 Verification), rewritten to Detonate (12.4(b), + // "Retargeted, because Absorb is deleted" / Q12-9 = A): the blast's shield branch must actually break an + // unabsorbed shield, not merely route the remainder to the hull. A radius this much larger than the hull + // covers essentially the whole disc's damage on the target's own cells, overwhelming a 5-capacity reserve + // however the exact per-cell shares fall -- the expected amount is not loosened, it was never asserted + // precisely here (only that the shield breaks). Mutation: delete `shield.Break()` from Detonate -- the + // target's hull still takes the blast (ABlastHitsEveryEntityItCovers stays green), but the shield itself + // never registers the overwhelming hit and keeps absorbing on schedule. [Fact] - public void SplashBreaksUnabsorbedShield() + public void ABlastBreaksUnabsorbedShield() { var e = Build(TestSettings(), damage: 10, velocity: 0, accuracy: 1, resolution: 1, spread: 0, armor: 0, equipShield: true, shieldCapacity: 5); @@ -512,7 +516,10 @@ public sealed class FireControlCut5Tests : IDisposable Assert.False(e.Target.Shield.Broken); Assert.False(e.Target.Shield.CanTakeHit(DamageType.Kinetic, 500)); - FireControl.Splash(e.Zone, e.Target.Position, radius: 50, damage: 500, damageType: DamageType.Kinetic); + // radius 1, centred on the target's own position (its hull's centre of mass): well inside the 5x5 + // solid hull's interior, so the disc never spills off the hull and the covered share equals the whole + // damage exactly, the same amount Splash always charged. + FireControl.Detonate(e.Zone, e.Target.Position.xz, radius: 1, damage: 500, damageType: DamageType.Kinetic); Assert.True(e.Target.Shield.Broken); } diff --git a/tests/Aetheria.Shared.Tests/FireControlCut6Tests.cs b/tests/Aetheria.Shared.Tests/FireControlCut6Tests.cs index 1f85e2381..788655802 100644 --- a/tests/Aetheria.Shared.Tests/FireControlCut6Tests.cs +++ b/tests/Aetheria.Shared.Tests/FireControlCut6Tests.cs @@ -235,15 +235,16 @@ public sealed class FireControlCut6Tests : IDisposable // ---- 6.2: airburst resolves in the simulation (Soul finding 5). ---- - // An airburst shot resolves via Splash, and Splash does not gate on the discrete hit roll at all -- it - // damages everything in radius unconditionally (Cut 4's rule). Accuracy 0 pins PBase (and so Commit's hit) - // to false with no dependence on the random draw, so a normal discrete shot would apply no damage - // whatsoever (Apply's own `if (!shot.Outcome.Hit) return;`). An airburst shot must still deal its area - // damage. Mutation: call Apply as well as Splash -- harmless here (Apply still no-ops on a miss), so this - // test alone does not catch that mutation; AirburstAndDiscreteNeverDoubleUp below does, with a guaranteed - // hit where an extra Apply call is not a no-op. + // A proximity-fuse shot detonates unconditionally, at arrival, with no roll (12.4(b)'s "Which model a shot + // uses") -- it damages everything in radius regardless of the discrete hit roll (Cut 4's rule, kept). + // Accuracy 0 pins PBase (and so Commit's hit) to false with no dependence on the random draw, so a direct + // hit would apply no damage whatsoever (Apply's own gate on shot.Outcome.Hit). A proximity shot must still + // deal its area damage. Mutation: route a proximity fuse through the direct-hit branch instead -- harmless + // here (that branch still no-ops on a miss), so this test alone does not catch that mutation; + // AirburstAndDiscreteNeverDoubleUp below does, with a guaranteed hit where an extra direct-hit application + // is not a no-op. [Fact] - public void AirburstSplashesEvenOnAGuaranteedMiss() + public void ProximityDetonatesEvenOnAGuaranteedMiss() { var settings = TestSettings(); var e = Build(settings, damage: 500, velocity: 0, accuracy: 0, resolution: 1, tracking: 100000, @@ -256,45 +257,48 @@ public sealed class FireControlCut6Tests : IDisposable Assert.True(e.Target.Hull.Durability < before, "An airburst shot must splash regardless of the discrete hit roll."); } - // The double-application Soul was told to hunt for: with a guaranteed hit (accuracy 1), an airburst shot's - // total damage must equal a bare Splash call's own damage -- not more. Mutation: call Apply as well as - // Splash at arrival -- Apply's own discrete cell hit would land on top of Splash's area damage, so the - // airburst shot's total damage exceeds a lone Splash call's. + // The double-application Soul was told to hunt for: with a guaranteed hit (accuracy 1), a proximity shot's + // total damage must equal a bare Detonate call's own damage -- not more. Mutation: route the shot through + // the direct-hit branch as well as Detonate at arrival -- the direct-hit cell would land on top of + // Detonate's area damage, so the shot's total damage exceeds a lone Detonate call's. [Fact] public void AirburstAndDiscreteNeverDoubleUp() { var settings = TestSettings(); - var e = Build(settings, damage: 10, velocity: 0, accuracy: 1, resolution: 1, tracking: 100000, + // 12.4(b)'s area model spreads damage over pi*r^2, so a radius this much larger than the 5x5 target + // needs enough damage that the covered share still clears DamageHull's .1f threshold (matching + // ProximityDetonatesEvenOnAGuaranteedMiss's own damage, above). + var e = Build(settings, damage: 500, velocity: 0, accuracy: 1, resolution: 1, tracking: 100000, targetRange: 50, fuse: WeaponFuse.Proximity, blastRadius: 100); var before = e.Target.Hull.Durability; FireControl.Fire(e.Weapon, e.WeaponItem, e.Shooter); e.Zone.Update(.01f); var actualDelta = before - e.Target.Hull.Durability; - // A fresh, identically-shaped target, damaged by one bare Splash call with the same parameters + // A fresh, identically-shaped target, damaged by one bare Detonate call with the same parameters // FireControl.Fire freezes for this weapon (BurstPosition, for a stationary velocity-0 target, is // exactly its own position -- PredictedIntercept falls back to target.Position below weapon.Velocity's // .01f floor). - var e2 = Build(settings, damage: 10, velocity: 0, accuracy: 1, resolution: 1, tracking: 100000, + var e2 = Build(settings, damage: 500, velocity: 0, accuracy: 1, resolution: 1, tracking: 100000, targetRange: 50, fuse: WeaponFuse.Proximity, blastRadius: 100); var before2 = e2.Target.Hull.Durability; - FireControl.Splash(e2.Zone, e2.Target.Position, 100, 10, DamageType.Kinetic); + FireControl.Detonate(e2.Zone, e2.Target.Position.xz, 100, 500, DamageType.Kinetic); var expectedDelta = before2 - e2.Target.Hull.Durability; Assert.True(expectedDelta > 0); Assert.Equal(expectedDelta, actualDelta, 3); } - // The other side: a shot with no blast must never splash, even one that lands exactly on its own frozen - // burst position at zero range (a stationary target, so BurstPosition == the target's own position at - // arrival) -- the zero-radius edge case a careless "splash unconditionally" mutant would still pass - // through, since distance 0 is never greater than radius 0. Accuracy 0 pins the discrete roll to a - // guaranteed miss, so the correct path (Apply, gated on Outcome.Hit) deals no damage at all. Mutation: - // splash unconditionally at arrival instead of gating on BlastRadius -- Splash does not check Hit, so it - // would still deal damage even though this shot never froze a blast. + // The other side: a shot with no fuse must never detonate, even one whose frozen burst position (unread, + // for a null-fuse shot) would land exactly at zero range on a stationary target -- the zero-radius edge + // case a careless "detonate unconditionally" mutant would still pass through, since distance 0 is never + // greater than radius 0. Accuracy 0 pins the discrete roll to a guaranteed miss, so the correct path + // (Apply's null-fuse branch, gated on Outcome.Hit) deals no damage at all. Mutation: detonate unconditionally + // at arrival instead of switching on the frozen Fuse -- Detonate does not check Hit, so it would still deal + // damage even though this shot never froze a fuse. [Fact] - public void NonAirburstNeverSplashes() + public void NoFuseNeverDetonates() { var settings = TestSettings(); var e = Build(settings, damage: 1000, velocity: 0, accuracy: 0, resolution: 1, tracking: 100000,