The squad stopped dropping mortars on itself

In the Mountains · call-for-fire realism pass · 2026-06-06 · issue 013

SOLO → metricize → fix → adversarial-verifyengine-only change

"A squad called a [mortar] mission much too close to them, nowhere near the enemy."
— the player report that started this.

Two complaints in one sentence, and both were real and measurable. Under the game's hands-off combat model the AI raises a call-for-fire and the commander approves or denies it — so an AI that proposes a bad grid is a bug in the squad leader's brain. This pass turned the fuzzy report into hard numbers, found the two mechanisms, fixed them as one doctrine-shaped gate, and proved the win on seeds it was never tuned on.

1 · Metricize first (no fix without a number)

A new probe, scripts/fire-mission-probe.ts, drives 40 deployments into contact, intercepts every AI call-for-fire at the instant it is raised, and scores the proposed aimpoint against independent ground truth: distance to the nearest friendly, distance to the nearest observed/living enemy, and the weapon's danger-close radius. In approve mode it also lets the rounds fall and counts real fratricide — friendlies wounded or killed with casualtyByFaction === "us" (the blast in detonate() is faction-blind, so this is genuine, not cosmetic).

metric (40 seeds, HEAD)deny modeapprove (rounds land)what it means
d_friendly min23 m10 mclosest a round was called to a friendly (81 mm lethal radius = 24 m)
danger-close %3%4%aimpoint inside blast×2.5 of a friendly — "much too close"
on-friendlies %0%1%aimpoint inside the lethal blast of a friendly
projection %9%32%fired on a grid with no enemy observed at all
off-target % (vs seen enemy)18%52%aimpoint >60 m from the nearest enemy the squad can see — "nowhere near the enemy"
US fratricide / 40 deps1a friendly hit by an AI-called mortar

2 · The two mechanisms

"Nowhere near the enemy"

The aimpoint was threatCentroid — the average of every visible enemy. On a two-sided / L-shaped ambush (fire coming from two directions) the average of the two groups lands between them — which is right where the squad is. And the only guard (threatIsReal) let a "shot-at-from-a-direction" contact through with no PID, so it would fire on a projected 120 m guess.

"Much too close"

Nothing stopped the AI from proposing a grid inside its own danger-close radius. isDangerClose() existed but only labelled the round "DANGER CLOSE" — it never prevented the call. So the squad could, and did, drop high explosive on itself.

3 · The fix — one doctrine-shaped gate

A real forward observer obeys two hard rules; the AI now enforces both, so it never proposes a grid a commander would refuse (FM 3-09 Call for Fire, ATP 3-21.8):

Before/after aimpoint on a two-sided contact
The same two-sided contact (squad blue, enemy red). Before: the all-enemies centroid lands on the squad — 19 m from a friendly (inside the 24 m lethal ring) and 154 m from either enemy group. After: the densest-cluster aimpoint sits on the 4-man group with 183 m of standoff. Dashed ring = 60 m danger-close, solid = 24 m lethal blast.

4 · The result (same probe, same seeds)

metricHEAD → After (deny)HEAD → After (approve)
d_friendly min23 → 40 m10 → 69 m
danger-close %3% → 0%4% → 0%
on-friendlies %0% → 0%1% → 0%
off-target vs living enemy (tick-robust)0%0%
US fratricide / 40 deps1 → 0

The aimpoint now sits on a living enemy in every case (d_enemy_live median ~2 m). A residual ~5% "projection" in the probe is a measurement artifact — PID existed when the AI decided (mid-tick) but lapsed one tick later when the probe samples — proven by the tick-robust metric offLive% = 0%. The AI never fires on a guess.

Held-out proof (no curve-fit)

Re-run on an entirely different family of valleys (hold-0..29 — the prefix seeds the whole procedural generator, so different mountains, rivers, villages, spawns): 88 requests → danger-close 0%, on-friendlies 0%, off-living-enemy 0%, min standoff 55 m. The two constants (cluster radius 35 m, blast×2.5) are doctrinal, not fit to the tuning seeds — which is why they transfer.

5 · Verification (don't trust yourself)

Live game with the fire-support HUD
The live build at a combat zoom — the approve/deny fire-support toolkit the player uses.

6 · Restraint logged

I deliberately did not add an "in-extremis danger-close override" that would let the AI call fire on itself when about to be overrun. That reintroduces exactly the reported failure mode, and the automatic break-contact safety already pulls a combat-ineffective squad off the X. The AI's job is to never propose a grid a commander would refuse; choosing to accept danger-close risk stays a human decision.

Files: lib/sim/ai/squad-combat.ts (fireAimpoint + danger-close gate), lib/sim/combat.ts (FDC check-fire). Probe: scripts/fire-mission-probe.ts. Verbatim numbers: baseline-{deny,approve}.txt, after-{deny,approve}.txt in this folder. — Could an AI really have done this? It found a one-line averaging mistake and a missing safety the way a soldier would: by measuring where the rounds actually fell, not by guessing.