Fire control 12.4 fix batch: tangent disc, candidate cull, lethal blast

F1: RectDiskOverlap counted a whole square when a cell edge sat exactly r from
the disc centre (`h < yhi`, `-h > ylo`). The comparisons are inclusive now, so
the chord bounds the piece. A proximity burst on an integer centre of mass
delivered 127.32 of 100 at BlastRadius 1 and 100.80 at 3.

F2: delete Detonate's distance prefilter and the radius <= 0 guard. The
prefilter measured from the centre of mass with the bounding box's
half-diagonal, which is not a bound on how far a hull's cells reach; it culled
real overlap on an L-shaped hull and on the shipped Longinus corners.
RectDiskOverlap is the single decider. An untouched entity is still skipped
(its covered list is never allocated).

F3: Detonate iterates a snapshot of zone.Entities, as Zone.Update does. A
lethal blast removed the entity from the live collection and the next MoveNext
threw, leaving the shot pending so it detonated again the next tick.

Tests: tangent discs through Fire and Detonate, an L-shaped hull, grazing
blasts at the farthest corner of Longinus and LonginusX (expected values from
a numeric overlap), and a lethal blast that must resolve once.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
Meta Vens 2026-09-29 03:38:14 +02:00
commit 8b4bf01eac
2 changed files with 190 additions and 22 deletions

View file

@ -1073,23 +1073,20 @@ public static class FireControl
// DamageHull call.
public static void Detonate(Zone zone, float2 worldPlanar, float radius, float damage, DamageType damageType)
{
// 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;
foreach (var entity in zone.Entities)
// No distance prefilter: RectDiskOverlap is the one decider of whether the disc touches a hull. A
// prefilter measured from Position (the centre of mass) with the bounding box's half-diagonal is not a
// bound on how far a hull's cells reach from it -- it culled real overlap on an L-shaped hull and on the
// shipped Longinus corners. A nonpositive radius needs no guard either: the overlap of a disc with no
// area is zero for every cell, so nothing is covered and nothing is delivered.
//
// The pass runs over a snapshot: a lethal blast fires an entity's Death, which removes it from
// Zone.Entities, and the live enumeration threw on the next MoveNext (the same idiom Zone.Update uses).
foreach (var entity in zone.Entities.ToArray())
{
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;
@ -1097,17 +1094,18 @@ public static class FireControl
// 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)>();
List<(int2 Cell, float Share)> covered = null;
var totalShare = 0f;
for (var i = 0; i < coords.Length; i++)
{
var overlap = CircleSquareOverlap(centre, rCells, coords[i]);
if (overlap <= 0f) continue;
var share = damage * overlap / normaliser;
covered.Add((coords[i], share));
(covered ??= new List<(int2 Cell, float Share)>()).Add((coords[i], share));
totalShare += share;
}
if (covered.Count == 0) continue;
// An entity the disc does not touch has nothing to decide: no shield is consulted, nothing is dealt.
if (covered == null) continue;
var shield = entity.Shield;
var shieldActive = shield != null && shield.Item.Active.Value;
@ -1209,8 +1207,11 @@ public static class FireControl
var mid = .5f * (a + b);
var h = sqrt(max(0f, r * r - mid * mid));
var upperIsDisc = h < yhi;
var lowerIsDisc = -h > ylo;
// Inclusive: a rectangle edge exactly tangent to the disc (h == yhi at a piece whose midpoint is 0)
// is the disc's own boundary, so the chord is the true bound there. A strict test sent that piece to
// the whole-rectangle branch and counted the square in full -- a boundary flip that creates damage.
var upperIsDisc = h <= yhi;
var lowerIsDisc = -h >= ylo;
var upper = upperIsDisc ? h : yhi;
var lower = lowerIsDisc ? -h : ylo;
if (upper <= lower) continue;