Back to City Breaker
SOURCE / PINNED RELEASE

Made of little things.

City Breaker

Release
94d2b8664b10…
Author-recorded commit
df23103cbcc1…
License
LICENSE
Author’s source reference
nostr://npub1sj2vketepqw9v6fmge5fdzuj7atj9sv6qv2jss05n2z6rl64lcfqumdsnt/wss%3A%2F%2Fgit.napplet.soy%2F/n-cf8b858653e

Archive hash verified: eeeed725668c1ece…. The source-to-build association is the author’s claim; it has not been independently rebuilt.

src/game/sim.ts
// The raid itself: a real-time world simulation shared by the flown mission and
// by auto-resolve. It mutates the campaign state (districts burn, the grid dies)
// as bombs land, and emits events for effects, audio and radio.
import { CONFIG, type AircraftType, type InterceptorType, type MunitionId } from './config';
import { cityDestructionFraction, samPower, updateGrid } from './city';
import {
  altKm, bombsPerAircraft, type FlightPlan, type Impact, type MissionPlan, type MissionResult, type AirframeOutcome,
} from './campaign';
import { BARRIER_RADARS, BARRIER_X, DISTRICTS, DISTRICT_BY_ID, HOUSING_IDS, ROUTES, inRect, terrainHeight, type DistrictDef } from './map';
import { isNight, lightLevel, type GameState, type SamSiteState } from './state';

const TAU = Math.PI * 2;
const clamp = (v: number, lo = 0, hi = 1) => Math.max(lo, Math.min(hi, v));
const wrap = (a: number) => {
  while (a > Math.PI) a -= TAU;
  while (a < -Math.PI) a += TAU;
  return a;
};
const gauss = () => {
  let u = 0;
  let v = 0;
  while (!u) u = Math.random();
  while (!v) v = Math.random();
  return Math.sqrt(-2 * Math.log(u)) * Math.cos(TAU * v);
};
const dist = (ax: number, ay: number, bx: number, by: number) => Math.hypot(ax - bx, ay - by);

export type PlaneKind = AircraftType | InterceptorType | 'drone';

export interface Plane {
  id: number;
  side: 'red' | 'blue';
  kind: PlaneKind;
  flight: FlightRT | null;
  slot: number;
  airframeId: number;
  callsign: string;
  x: number;
  y: number;
  alt: number;
  tAlt: number;
  hdg: number;
  spd: number;
  baseSpd: number;
  turnRate: number;
  hp: number;
  maxHp: number;
  alive: boolean;
  done: boolean;
  isPlayer: boolean;
  xp: number;
  role: string;
  sticks: number;
  stickKg: number;
  munition: MunitionId | null;
  missiles: number;
  aim9: number;
  aim7: number;
  chaff: number;
  wp: number;
  path: [number, number][];
  state: 'wait' | 'fly' | 'run' | 'egress' | 'engage' | 'cap' | 'rtb';
  spawnAt: number;
  targetId: number;
  cool: number;
  gunCool: number;
  released: boolean;
  releaseT: number;
  jinkUntil: number;
  engineFire: number;
  hydraulics: number;
  controlBias: number;
  instruments: number;
  lastHit: number;
  fate: AirframeOutcome['fate'];
  bombsOnTarget: number;
  jamming: boolean;
  lastHdg: number;
  bingo: number;
  fuelT: number;
  trail: number;
  tracked: number; // seconds since a guidance radar last locked it
  searchlit: number;
  crossedBarrier: boolean;
  isWingmanOfPlayer: boolean;
  chuteCount: number;
  lastSrc: string;
  evadeUntil: number;
  evadeDir: number;
  bank: number; // visual roll, -1 … 1 (right wing down positive)
}

export interface FlightRT {
  plan: FlightPlan;
  planes: Plane[];
  aimX: number;
  aimY: number;
  aimKey: string | null;
  delay: number;
  released: boolean;
  calledFeetDry: boolean;
  retargeted: boolean;
}

export interface Missile {
  id: number;
  kind: 'nike' | 'bloodhound' | 'aim9' | 'aim7' | 'r3s' | 'ksr11' | 'standoff';
  side: 'red' | 'blue';
  x: number;
  y: number;
  alt: number;
  hdg: number;
  spd: number;
  turn: number;
  targetId: number;
  tx: number;
  ty: number;
  life: number;
  guided: boolean;
  site: SiteRT | null;
  lethal: number;
  dmg: number;
  pk: number;
  minD: number;
  alive: boolean;
  munition: MunitionId | null;
  owner: Plane | null;
  flightId: string;
  aimKey: string | null;
  seduced: boolean;
  age: number;
  evaded: boolean;
  missX: number;
  missY: number;
}

export interface SiteRT {
  st: SamSiteState;
  state: 'idle' | 'search' | 'track' | 'guide' | 'reload' | 'dead' | 'silent';
  timer: number;
  targetId: number;
  ready: number;
  silentUntil: number;
  emitting: boolean;
  inFlight: number;
}

export interface Gun {
  x: number;
  y: number;
  kind: 'heavy' | 'bofors' | 'vulcan';
  district: string;
  cool: number;
  err: number;
  targetId: number;
  lastHdg: number;
  alive: boolean;
  suppressed: number;
}

export interface Bomb {
  x: number;
  y: number;
  vx: number;
  vy: number;
  alt0: number;
  t: number;
  ft: number;
  kg: number;
  munition: MunitionId;
  flightId: string;
  player: boolean;
  guided: boolean;
  steerX: number;
  steerY: number;
  aimKey: string | null;
  owner: Plane | null;
  alive: boolean;
}

export interface Searchlight {
  x: number;
  y: number;
  ang: number;
  len: number;
  speed: number;
  targetId: number;
  hold: number;
}

export interface ChaffCloud {
  x: number;
  y: number;
  alt: number;
  r: number;
  life: number;
  max: number;
}

export type SimEvent =
  | { t: 'flak'; x: number; y: number; alt: number; kind: Gun['kind']; near: number }
  | { t: 'tracer'; x: number; y: number; tx: number; ty: number; alt: number; onPlayer: boolean; gun?: Gun['kind'] }
  | { t: 'samLaunch'; x: number; y: number; m: Missile }
  | { t: 'missileDet'; x: number; y: number; alt: number; near: number; kind: Missile['kind'] }
  | { t: 'impact'; x: number; y: number; kg: number; munition: MunitionId; player: boolean; hit: string | null }
  | { t: 'secondary'; x: number; y: number; big: boolean }
  | { t: 'hit'; plane: Plane; dmg: number }
  | { t: 'down'; plane: Plane }
  | { t: 'chaff'; x: number; y: number; alt: number; player: boolean }
  | { t: 'release'; plane: Plane }
  | { t: 'gun'; x: number; y: number; hdg: number; player: boolean }
  | { t: 'blackout'; district: string }
  | { t: 'radio'; from: string; text: string; urgent: boolean }
  | { t: 'barrage'; x: number; y: number; tx: number; ty: number; alt: number; hue: number }
  | { t: 'picketDown'; x: number; y: number }
  | { t: 'sys'; what: 'doors' | 'clunk' | 'hung' | 'unhung' | 'extinguish' | 'engineBoom' | 'hydBackup' | 'isolate' | 'leak' | 'marker' | 'beamLost' | 'flameout' | 'crash' | 'tailgun' | 'formation'; x: number; y: number; v?: number };

export interface Controls {
  turn: number;
  climb: number;
  throttle: number;
  fire: boolean;
  secondary: boolean;
  chaff: boolean;
  steerX: number;
  steerY: number;
  // One-shot crew actions (set true by input, cleared by the sim).
  doors: boolean;
  mode: boolean;
  marker: boolean;
  extinguish: boolean;
  hydraulics: boolean;
  isolate: boolean;
  formation: 'tight' | 'normal' | 'loose' | null;
  tailGun: boolean; // held
  firePressed: boolean; // edge for single-release mode
}

/** The player's own aircraft systems and crew stations. */
export interface Crew {
  doors: number; // 0 closed … 1 open
  doorsWant: boolean;
  mode: 'train' | 'salvo' | 'single';
  markers: number;
  extinguishers: number;
  fireT: number;
  hydBackup: boolean;
  fuel: number; // 0 … 1
  fuelRate: number; // fraction per game-second at cruise
  fuelLeak: number;
  leakIsolated: boolean;
  hung: number;
  doorCycle: number;
  formation: 'tight' | 'normal' | 'loose';
  beam: number;
  beamTurn: number;
  agl: number;
  pullUp: boolean;
  tailTarget: number;
  flamedOut: boolean;
}

export interface World {
  s: GameState;
  plan: MissionPlan;
  t: number;
  planes: Plane[];
  flights: FlightRT[];
  missiles: Missile[];
  bombs: Bomb[];
  sites: SiteRT[];
  guns: Gun[];
  lights: Searchlight[];
  chaff: ChaffCloud[];
  events: SimEvent[];
  impacts: Impact[];
  player: Plane | null;
  playerLost: boolean;
  windX: number;
  windY: number;
  night: boolean;
  light: number;
  detected: boolean;
  detectT: number;
  scrambled: boolean;
  interceptorsKilled: number;
  samLaunches: number;
  samSitesHit: Set<string>;
  guidedHits: number;
  usedJamming: boolean;
  firestorms: string[];
  fire: FireGrid;
  nextId: number;
  ended: boolean;
  radioCooldown: Record<string, number>;
  ctl: Controls;
  playerReleaseHeld: boolean;
  guidedBomb: Bomb | null;
  powerWas: Record<string, boolean>;
  bandits: number;
  autoResolved: boolean;
  dronesUsed: number;
  photoTaken: Set<string>;
  silence: { at: number; from: string; to: string }[];
  destructionBefore: number;
  pickets: { x: number; y: number; alive: boolean }[];
  markers: { x: number; y: number; life: number }[];
  crew: Crew;
  droneBait: boolean;
  brevity: Record<string, boolean>;
}

// ── Fire grid (spread physics over the city) ────────────────────────────────
export interface FireGrid {
  x0: number;
  y0: number;
  cell: number;
  w: number;
  h: number;
  heat: Float32Array;
  fuel: Float32Array;
  flam: Float32Array;
  district: Int16Array;
  acc: number;
}

function makeFireGrid(s: GameState): FireGrid {
  const cell = 0.5;
  const x0 = -42;
  const y0 = -26;
  const w = Math.ceil(72 / cell);
  const h = Math.ceil(48 / cell);
  const g: FireGrid = {
    x0, y0, cell, w, h, heat: new Float32Array(w * h), fuel: new Float32Array(w * h), flam: new Float32Array(w * h),
    district: new Int16Array(w * h).fill(-1), acc: 0,
  };
  for (let j = 0; j < h; j++) {
    for (let i = 0; i < w; i++) {
      const x = x0 + (i + 0.5) * cell;
      const y = y0 + (j + 0.5) * cell;
      const k = j * w + i;
      const r = Math.hypot(x + 2, y);
      g.flam[k] = r < 17 ? 0.12 : r < 24 ? 0.05 : 0;
      g.fuel[k] = 1;
      DISTRICTS.forEach((d, di) => {
        if (inRect(d, x, y)) {
          g.district[k] = di;
          g.flam[k] = d.flammable * (1 - s.districts[d.id].firebreak * 0.7);
          g.heat[k] = s.districts[d.id].fire * (0.5 + Math.random() * 0.8);
          g.fuel[k] = 0.3 + 0.7 * s.districts[d.id].integrity;
        }
      });
    }
  }
  return g;
}

export function igniteAt(g: FireGrid, x: number, y: number, amount: number, radius: number): void {
  const r = Math.max(1, Math.ceil(radius / g.cell));
  const ci = Math.floor((x - g.x0) / g.cell);
  const cj = Math.floor((y - g.y0) / g.cell);
  for (let j = cj - r; j <= cj + r; j++) {
    for (let i = ci - r; i <= ci + r; i++) {
      if (i < 0 || j < 0 || i >= g.w || j >= g.h) continue;
      const k = j * g.w + i;
      if (g.flam[k] <= 0) continue;
      g.heat[k] = clamp(g.heat[k] + amount * g.flam[k] * (0.6 + Math.random() * 0.6));
    }
  }
}

function stepFire(w: World, dt: number): void {
  const g = w.fire;
  g.acc += dt;
  if (g.acc < 0.25) return;
  const step = g.acc;
  g.acc = 0;
  const wx = Math.sign(w.windX);
  const wy = Math.sign(w.windY);
  const next = g.heat.slice();
  for (let j = 1; j < g.h - 1; j++) {
    for (let i = 1; i < g.w - 1; i++) {
      const k = j * g.w + i;
      const h = g.heat[k];
      if (h <= 0.02) continue;
      // burn fuel, grow toward flashover
      g.fuel[k] = Math.max(0, g.fuel[k] - h * 0.012 * step);
      next[k] = clamp(h + (g.fuel[k] > 0.05 ? 0.05 * g.flam[k] : -0.08) * step);
      const spread = h * CONFIG.city.fireSpread * step * 0.7;
      for (const [di, dj] of [[1, 0], [-1, 0], [0, 1], [0, -1]]) {
        const n = (j + dj) * g.w + (i + di);
        if (g.flam[n] <= 0 || g.fuel[n] <= 0.05) continue;
        const windBoost = 1 + 0.8 * (di * wx + dj * wy);
        if (Math.random() < spread * g.flam[n] * windBoost) next[n] = clamp(next[n] + 0.2 * h);
      }
    }
  }
  g.heat.set(next);
}

/** Aggregate fire per district back into the campaign state. */
function settleFire(w: World): void {
  const g = w.fire;
  const sum = new Float32Array(DISTRICTS.length);
  const n = new Float32Array(DISTRICTS.length);
  for (let k = 0; k < g.heat.length; k++) {
    const d = g.district[k];
    if (d < 0) continue;
    sum[d] += g.heat[k];
    n[d]++;
  }
  DISTRICTS.forEach((d, i) => {
    if (!n[i]) return;
    const mean = sum[i] / n[i];
    const st = w.s.districts[d.id];
    st.fire = clamp(Math.max(st.fire * 0.8, mean * 1.2));
    st.integrity = clamp(st.integrity - mean * 0.1);
  });
}

// ── Setup ──────────────────────────────────────────────────────────────────
function flightSpeed(type: AircraftType): number {
  return CONFIG.aircraft[type].speed;
}

function chooseAim(s: GameState, target: string | null, route?: string): { x: number; y: number; key: string | null } {
  if (!target) return { x: 0, y: 0, key: null };
  if (target === 'pickets') {
    // The early-warning radar nearest the chosen ingress corridor.
    const ref = route === 'north' ? -55 : route === 'south' ? 50 : 0;
    const [x, y] = BARRIER_RADARS.reduce((a, b) => (Math.abs(b[1] - ref) < Math.abs(a[1] - ref) ? b : a));
    return { x, y, key: 'pickets' };
  }
  if (target.startsWith('sam:')) {
    const site = s.sams.find((x) => x.id === target.slice(4));
    if (site) return { x: site.knownX, y: site.knownY, key: target };
  }
  if (target.startsWith('farm:')) {
    const f = s.city.fuelFarms.find((x) => x.id === target.slice(5));
    if (f) return { x: f.x, y: f.y, key: target };
  }
  const d = DISTRICT_BY_ID[target];
  if (!d) return { x: 0, y: 0, key: null };
  const st = s.districts[d.id];
  // Decoys: known unrevealed dummies and unknown ones (stale intel, night) compete for the aim.
  const stale = Math.max(0, (s.hour - st.intelHour) / 24 - CONFIG.adaptation.intelFreshDays);
  const candidates = st.decoys.filter((x) => !x.revealed && !x.destroyed);
  for (const dec of candidates) {
    const w = 0.5 + stale * 0.12 + (isNight(s.hour) ? 0.3 : 0);
    if (Math.random() < w / (1 + w + candidates.length * 0.3)) return { x: dec.x, y: dec.y, key: target };
  }
  return { x: d.x, y: d.y, key: target };
}

function makePlane(w: World, init: Partial<Plane> & { kind: PlaneKind; side: 'red' | 'blue'; x: number; y: number }): Plane {
  const red = init.side === 'red' && init.kind !== 'drone';
  const spec = red ? CONFIG.aircraft[init.kind as AircraftType] : null;
  const blue = init.side === 'blue' ? CONFIG.interceptors[init.kind as InterceptorType] : null;
  const hp = spec ? spec.hp : blue ? blue.hp : 12;
  const spd = spec ? spec.speed : blue ? blue.speed : 1.3;
  const p: Plane = {
    id: w.nextId++, flight: null, slot: 0, airframeId: -1, callsign: '', alt: 5, tAlt: 5, hdg: Math.PI, spd, baseSpd: spd,
    turnRate: spec ? spec.turn : blue ? blue.turn : 0.6, hp, maxHp: hp, alive: true, done: false, isPlayer: false, xp: 0.5,
    role: spec?.role ?? 'interceptor', sticks: 0, stickKg: 0, munition: null, missiles: 0, aim9: blue?.aim9 ?? 0,
    aim7: blue?.aim7 ?? 0, chaff: CONFIG.pres.perAircraft, wp: 0, path: [], state: 'fly', spawnAt: 0, targetId: -1, cool: 0,
    gunCool: 0, released: false, releaseT: 0, jinkUntil: 0, engineFire: 0, hydraulics: 0, controlBias: 0, instruments: 1,
    lastHit: -99, fate: 'ok', bombsOnTarget: 0, jamming: false, lastHdg: 0, bingo: 9999, fuelT: 0, trail: 0, tracked: 99,
    searchlit: 0, crossedBarrier: false, isWingmanOfPlayer: false, chuteCount: 0, lastSrc: '', evadeUntil: 0, evadeDir: 0, bank: 0,
    ...init,
  };
  if (p.kind === 'Lightning') p.aim9 = 2; // Red Top / Firestreak as IR missiles
  p.lastHdg = p.hdg;
  w.planes.push(p);
  return p;
}

export function createWorld(s: GameState, plan: MissionPlan): World {
  updateGrid(s);
  const night = isNight(plan.launchHour);
  const ang = Math.random() * TAU;
  const w: World = {
    s, plan, t: 0, planes: [], flights: [], missiles: [], bombs: [], sites: [], guns: [], lights: [], chaff: [], events: [],
    impacts: [], player: null, playerLost: false, windX: Math.cos(ang) * CONFIG.mission.windKmS, windY: Math.sin(ang) * CONFIG.mission.windKmS,
    night, light: lightLevel(plan.launchHour), detected: false, detectT: 0, scrambled: false, interceptorsKilled: 0, samLaunches: 0,
    samSitesHit: new Set(), guidedHits: 0, usedJamming: false, firestorms: [], fire: makeFireGrid(s), nextId: 1, ended: false,
    radioCooldown: {}, ctl: { turn: 0, climb: 0, throttle: 0, fire: false, secondary: false, chaff: false, steerX: 0, steerY: 0, doors: false, mode: false, marker: false, extinguish: false, hydraulics: false, isolate: false, formation: null, tailGun: false, firePressed: false },
    playerReleaseHeld: false, guidedBomb: null, powerWas: Object.fromEntries(DISTRICTS.map((d) => [d.id, s.districts[d.id].powered])),
    bandits: 0, autoResolved: false, dronesUsed: 0, photoTaken: new Set(), silence: [], destructionBefore: cityDestructionFraction(s), pickets: BARRIER_RADARS.map(([x, y]) => ({ x, y, alive: true })), droneBait: false, brevity: {}, markers: [],
    crew: {
      doors: 0, doorsWant: false, mode: 'train', markers: CONFIG.mission.crew.markers, extinguishers: CONFIG.mission.crew.extinguishers, fireT: 0,
      hydBackup: true, fuel: 1, fuelRate: 0.002, fuelLeak: 0, leakIsolated: false, hung: 0, doorCycle: 0, formation: 'normal', beam: 0, beamTurn: 0,
      agl: 5, pullUp: false, tailTarget: -1, flamedOut: false,
    },
  };
  const route = ROUTES[plan.route];
  const egress = route.egress;
  const ingress = route.ingress;

  // Timing: all strike flights on target together, others offset by their wave.
  const pathLen = (pts: [number, number][]) => pts.slice(1).reduce((a, p, i) => a + dist(p[0], p[1], pts[i][0], pts[i][1]), 0);
  const rts: { f: FlightPlan; path: [number, number][]; aim: { x: number; y: number; key: string | null }; travel: number }[] = [];
  for (const f of plan.flights) {
    const aim = chooseAim(s, f.target ?? plan.primary, plan.route);
    let pts: [number, number][];
    if (f.role === 'escort' || f.role === 'jammer') pts = [...ingress, [aim.x + 4, aim.y], ...egress];
    else if (f.role === 'recon') pts = [...ingress, [aim.x, aim.y], ...egress];
    else pts = [...ingress, [aim.x, aim.y], ...egress];
    const toTarget = pathLen([...ingress, [aim.x, aim.y]]);
    rts.push({ f, path: pts, aim, travel: toTarget / flightSpeed(f.type) });
  }
  // Escorts and jammers launch with the bombers they protect.
  const strikeTravel = Math.max(0, ...rts.filter((r) => r.f.role === 'strike').map((r) => r.travel));
  for (const r of rts) if ((r.f.role === 'escort' || r.f.role === 'jammer') && strikeTravel) r.travel = strikeTravel;
  const T = Math.max(...rts.map((r) => r.travel), 0);
  let minDelay = Infinity;
  const delays = rts.map((r) => {
    const d = T - r.travel + r.f.offsetSec;
    minDelay = Math.min(minDelay, d);
    return d;
  });
  rts.forEach((r, i) => {
    const delay = delays[i] - minDelay;
    const fr: FlightRT = { plan: r.f, planes: [], aimX: r.aim.x, aimY: r.aim.y, aimKey: r.aim.key, delay, released: false, calledFeetDry: false, retargeted: false };
    w.flights.push(fr);
    const spec = CONFIG.aircraft[r.f.type];
    const [sx, sy] = r.path[0];
    const [nx, ny] = r.path[1];
    const hdg = Math.atan2(ny - sy, nx - sx);
    r.f.airframeIds.forEach((afId, slot) => {
      const af = s.fleet.find((a) => a.id === afId);
      const per = r.f.loads?.[slot] ?? bombsPerAircraft(r.f.type, r.f.munition);
      const kg = r.f.munition ? per * CONFIG.munitions[r.f.munition].kg : 0;
      const isMissile = r.f.munition ? CONFIG.munitions[r.f.munition].missile || CONFIG.munitions[r.f.munition].guided : false;
      const sticks = isMissile ? per : r.f.munition ? Math.min(CONFIG.mission.sticks, per) : 0;
      const back = Math.floor((slot + 1) / 2) * 0.9;
      const side = slot === 0 ? 0 : slot % 2 ? -1 : 1;
      const p = makePlane(w, {
        kind: r.f.type, side: 'red', x: sx - Math.cos(hdg) * back + Math.cos(hdg + Math.PI / 2) * side * back * 0.7,
        y: sy - Math.sin(hdg) * back + Math.sin(hdg + Math.PI / 2) * side * back * 0.7, hdg, alt: altKm(plan.ingressAlt),
        tAlt: altKm(plan.ingressAlt), flight: fr, slot, airframeId: afId, callsign: `${r.f.callsign}-${slot + 1}`, xp: af?.xp ?? 0.4,
        role: r.f.role, munition: r.f.munition, sticks, stickKg: sticks ? kg / sticks : 0, missiles: isMissile ? per : r.f.role === 'escort' ? 2 : 0,
        path: r.path.map(([x, y]) => [x + Math.cos(hdg + Math.PI / 2) * side * back * 0.7, y + Math.sin(hdg + Math.PI / 2) * side * back * 0.7] as [number, number]),
        wp: 1, state: 'wait', spawnAt: delay, jamming: r.f.role === 'jammer', chaff: r.f.role === 'jammer' ? CONFIG.pres.perJammerSortie : CONFIG.pres.perAircraft,
        bingo: spec.fighter ? (plan.route === 'center' ? 9999 : (plan.tanker ? CONFIG.mission.tanker.fighterBingoTanker : CONFIG.mission.tanker.fighterBingoNoTanker) * 260) : 9999,
      });
      if (r.f.id === plan.playerFlight && slot === 0) {
        p.isPlayer = true;
        w.player = p;
      }
      if (r.f.id === plan.playerFlight && slot > 0) p.isWingmanOfPlayer = true;
      fr.planes.push(p);
    });
  });

  // Fuel for the planned route plus a reserve.
  if (w.player) {
    const path = w.player.path;
    const len = path.slice(1).reduce((acc, q, i) => acc + dist(q[0], q[1], path[i][0], path[i][1]), 0);
    w.crew.fuelRate = 1 / ((len / w.player.baseSpd) * CONFIG.mission.crew.fuelReserve);
  }

  // Tu-123 drones run ahead to draw missiles.
  for (let i = 0; i < plan.drones; i++) {
    const [sx, sy] = ingress[0];
    const p = makePlane(w, { kind: 'drone', side: 'red', x: sx, y: sy + (i - plan.drones / 2) * 3, alt: 7, tAlt: 7, hdg: Math.PI, path: [...ingress.map(([x, y]) => [x, y + (i - plan.drones / 2) * 4] as [number, number]), [(Math.random() - 0.5) * 20, (Math.random() - 0.5) * 20], [-70, (Math.random() - 0.5) * 60]], wp: 1, state: 'wait', spawnAt: Math.max(0, T - minDelay - CONFIG.ew.droneLaunchLead - 40 + i * 4), callsign: `DRONE-${i + 1}`, role: 'drone' });
    p.spd = 1.3;
    w.dronesUsed++;
  }

  // SAM sites.
  for (const st of s.sams) {
    if (st.launchers <= 0.05 && !st.isDecoy) continue;
    w.sites.push({ st, state: 'idle', timer: Math.random() * 3, targetId: -1, ready: st.isDecoy ? 0 : Math.min(CONFIG.sam[st.kind].ready, st.missiles), silentUntil: 0, emitting: false, inFlight: 0 });
  }

  // AAA belts.
  const aaaMult = (1 + 0.22 * (s.city.adaptLevels.aaaDensity ?? 0)) * (0.55 + 0.45 * Math.min(1, s.city.workers / CONFIG.city.workers + 0.1));
  const lowMult = 1 + 0.25 * (s.city.adaptLevels.lowAltRadar ?? 0);
  for (const d of DISTRICTS) {
    const add = (kind: Gun['kind'], n: number) => {
      const count = Math.round(n * aaaMult * (kind === 'heavy' ? 1 : lowMult));
      for (let i = 0; i < count; i++) {
        const a = Math.random() * TAU;
        const r = Math.max(d.w, d.h) / 2 + 0.4 + Math.random() * (kind === 'heavy' ? 2.5 : 1.2);
        w.guns.push({ x: d.x + Math.cos(a) * r, y: d.y + Math.sin(a) * r, kind, district: d.id, cool: Math.random() * 2, err: CONFIG.aaa[kind].err0, targetId: -1, lastHdg: 0, alive: true, suppressed: 0 });
      }
    };
    add('heavy', d.aaa.heavy);
    add('bofors', d.aaa.bofors);
    add('vulcan', d.aaa.vulcan);
  }
  for (const site of w.sites) {
    if (site.st.isDecoy) continue;
    for (let i = 0; i < 2; i++) w.guns.push({ x: site.st.x + gauss() * 0.6, y: site.st.y + gauss() * 0.6, kind: 'vulcan', district: 'military', cool: 1, err: 0.6, targetId: -1, lastHdg: 0, alive: true, suppressed: 0 });
  }

  // Searchlight-radar net at night.
  if (night || w.light < 0.4) {
    const n = 10 + 5 * (s.city.adaptLevels.nightNet ?? 0);
    for (let i = 0; i < n; i++) {
      const a = Math.random() * TAU;
      const r = 3 + Math.random() * 14;
      w.lights.push({ x: Math.cos(a) * r, y: Math.sin(a) * r, ang: Math.random() * TAU, len: 5 + Math.random() * 3, speed: (Math.random() - 0.5) * 0.6, targetId: -1, hold: 0 });
    }
  }

  // Barrier combat air patrol.
  const cap = Math.round((CONFIG.interceptors.barrierCap + (s.city.adaptLevels.barrierPatrol ?? 0) * 1.5) * (night ? CONFIG.interceptors.nightEffectiveness : 1));
  const fuelOk = s.city.stock.fuel > 0 ? 1 : 0.5;
  for (let i = 0; i < Math.round(cap * fuelOk); i++) {
    const kind: InterceptorType = i % 2 ? 'F-104G' : 'F-4';
    if (s.city.interceptors[kind] <= 0) continue;
    const y = [-30, 28, 0, -55, 52][i % 5];
    makePlane(w, { kind, side: 'blue', x: BARRIER_X - 10 + Math.random() * 6, y: y + Math.random() * 5, alt: 7, tAlt: 7, hdg: Math.random() * TAU, state: 'cap', path: [[BARRIER_X - 8, y]] });
  }
  w.events.length = 0;
  return w;
}

// ── Helpers ────────────────────────────────────────────────────────────────
function planeById(w: World, id: number): Plane | undefined {
  return w.planes.find((p) => p.id === id);
}

function clock(from: Plane, x: number, y: number): number {
  const rel = wrap(Math.atan2(y - from.y, x - from.x) - from.hdg);
  let c = Math.round((rel / TAU) * 12);
  if (c <= 0) c += 12;
  return c;
}

export function radio(w: World, from: string, text: string, urgent = false, key?: string, cooldown = 6): void {
  if (key) {
    if ((w.radioCooldown[key] ?? -99) > w.t) return;
    w.radioCooldown[key] = w.t + cooldown;
  }
  w.events.push({ t: 'radio', from, text, urgent });
}

export function ccip(w: World, p: Plane, useActualWind = false): { x: number; y: number; ft: number } {
  const M = CONFIG.mission;
  const ft = M.gravityFall * Math.sqrt(Math.max(0.05, p.alt - terrainHeight(p.x, p.y)));
  const fwd = p.spd * ft * M.throwFactor;
  const known = useActualWind ? 1 : 1 - M.windForecastError * clamp(p.alt / M.altHigh);
  return {
    x: p.x + Math.cos(p.hdg) * fwd + w.windX * ft * M.windDriftFactor * known,
    y: p.y + Math.sin(p.hdg) * fwd + w.windY * ft * M.windDriftFactor * known,
    ft,
  };
}

function jammedAt(w: World, x: number, y: number, siteX: number, siteY: number): number {
  let best = 0;
  for (const p of w.planes) {
    if (!p.alive || p.done || !p.jamming || p.state === 'wait') continue;
    if (dist(p.x, p.y, x, y) > CONFIG.ew.jammerRadius) continue;
    if (dist(siteX, siteY, x, y) < CONFIG.sam.burnThroughKm) continue;
    best = Math.max(best, CONFIG.ew.jammerStrength);
  }
  if (best > 0) w.usedJamming = true;
  return clamp(best - CONFIG.ew.freqHopPerLevel * (w.s.city.adaptLevels.freqHop ?? 0));
}

function chaffNear(w: World, x: number, y: number, alt: number): boolean {
  return w.chaff.some((c) => dist(c.x, c.y, x, y) < c.r && Math.abs(c.alt - alt) < 2);
}

export const DAMAGE_LOG: Record<string, number> = {};

function damagePlane(w: World, p: Plane, dmg: number, src = 'other'): void {
  if (!p.alive || p.done) return;
  if (p.side === 'red') DAMAGE_LOG[src] = (DAMAGE_LOG[src] ?? 0) + dmg;
  p.lastSrc = src;
  const armor = p.side === 'red' ? 1 - p.xp * 0.15 : 1;
  const d = dmg * armor;
  p.hp -= d;
  p.lastHit = w.t;
  w.events.push({ t: 'hit', plane: p, dmg: d });
  if (p.side === 'red' && d > 4) {
    const r = Math.random();
    if (r < 0.18) p.engineFire = Math.max(p.engineFire, 0.4 + Math.random() * 0.6);
    else if (r < 0.36) p.hydraulics = clamp(p.hydraulics + 0.35);
    else if (r < 0.52) p.controlBias = clamp(p.controlBias + (Math.random() - 0.5) * 0.5, -0.6, 0.6);
    else if (r < 0.7) p.instruments = clamp(p.instruments - 0.3);
    if (p.isPlayer && Math.random() < CONFIG.mission.crew.fuelLeakChance && w.crew.fuelLeak === 0) {
      w.crew.fuelLeak = 0.002 + Math.random() * 0.004;
      w.events.push({ t: 'sys', what: 'leak', x: p.x, y: p.y });
      radio(w, 'FLIGHT ENGINEER', 'Fuel leak — number two tank is streaming!', true);
    }
  }
  if (p.hp <= 0) killPlane(w, p);
}

function killPlane(w: World, p: Plane): void {
  if (!p.alive) return;
  p.alive = false;
  p.hp = 0;
  w.events.push({ t: 'down', plane: p });
  if (p.side === 'blue') {
    w.interceptorsKilled++;
    const shooter = w.planes.find((q) => q.side === 'red' && q.alive && !q.done && CONFIG.aircraft[q.kind as AircraftType]?.fighter && dist(q.x, q.y, p.x, p.y) < 8);
    radio(w, shooter?.callsign ?? 'TAIL GUNNER', `Splash one ${String(p.kind)}!`, false, `splash${p.id}`, 1);
    const k = p.kind as InterceptorType;
    w.s.city.interceptors[k] = Math.max(0, w.s.city.interceptors[k] - 1);
    return;
  }
  if (p.kind === 'drone') return;
  const home = p.x > BARRIER_X + 5;
  const crew = CONFIG.aircraft[p.kind as AircraftType].crew;
  const chutes = Math.random() < 0.7 ? Math.max(1, Math.round(crew * (0.3 + Math.random() * 0.7))) : 0;
  p.chuteCount = chutes;
  p.fate = home ? 'ejectedHome' : chutes && Math.random() < CONFIG.crew.captureChance + 0.3 ? 'captured' : 'killed';
  if (p.isPlayer) w.playerLost = true;
  const fr = p.flight;
  if (fr) {
    const msg = chutes ? `${p.callsign} is hit — going down! I count ${chutes} chute${chutes > 1 ? 's' : ''}.` : `${p.callsign} is down. No chutes. No chutes.`;
    radio(w, fr.planes.find((x) => x.alive && x !== p)?.callsign ?? 'TOWER', msg, true);
    if (p.isWingmanOfPlayer && w.player?.alive) {
      w.silence.push({ at: w.t + 5, from: w.player.callsign, to: p.callsign });
    }
  }
}


// ── Damage to the city ─────────────────────────────────────────────────────
function hitTest(w: World, x: number, y: number, radius: number): { key: string | null; def: DistrictDef | null; decoyOf?: string } {
  const s = w.s;
  for (let i = 0; i < w.pickets.length; i++) {
    const pk = w.pickets[i];
    if (pk.alive && dist(pk.x, pk.y, x, y) < 0.6 + radius) return { key: `picket:${i}`, def: null };
  }
  for (const site of s.sams) {
    if (dist(site.x, site.y, x, y) < 0.45 + radius) return { key: `${site.isDecoy ? 'samdecoy' : 'sam'}:${site.id}`, def: null };
  }
  for (const f of s.city.fuelFarms) {
    if (!f.destroyed && dist(f.x, f.y, x, y) < 0.5 + radius) return { key: `farm:${f.id}`, def: null };
  }
  for (const d of DISTRICTS) {
    for (const dec of s.districts[d.id].decoys) {
      if (!dec.destroyed && inRect({ x: dec.x, y: dec.y, w: d.w * 0.8, h: d.h * 0.8 }, x, y, radius * 0.5)) return { key: `decoy:${d.id}:${dec.id}`, def: d, decoyOf: d.id };
    }
  }
  for (const d of DISTRICTS) {
    if (inRect(d, x, y, radius * 0.5)) return { key: `d:${d.id}`, def: d };
  }
  return { key: null, def: null };
}

export function impact(w: World, x: number, y: number, kg: number, m: MunitionId, flightId: string, player: boolean, aimKey: string | null, owner: Plane | null): void {
  const s = w.s;
  const spec = CONFIG.munitions[m];
  const h = hitTest(w, x, y, spec.radius);
  let civ = 0;
  const pts = (kg / 100) * spec.blast * CONFIG.mission.damageScale;
  if (h.key?.startsWith('d:') && h.def) {
    const d = h.def;
    const st = s.districts[d.id];
    const rail = d.kind === 'rail' || d.kind === 'interdiction' || d.kind === 'bridge';
    const eff = rail ? spec.vsRail : spec.vsHard * d.hardness + spec.vsSoft * (1 - d.hardness);
    const before = st.integrity;
    st.integrity = clamp(st.integrity - (pts * eff) / d.hp);
    st.hitsKg += kg;
    if (spec.delay) st.ldbHours += CONFIG.city.ldbHoursPerBomb * (kg / 500);
    const fireAdd = (kg / 1000) * (spec.fire + 0.05) * d.flammable * (1 - st.firebreak * 0.6);
    st.fire = clamp(st.fire + fireAdd);
    igniteAt(w.fire, x, y, spec.fire * 1.6 + 0.1, spec.radius + (spec.fire > 0.5 ? 0.6 : 0.1));
    const housing = HOUSING_IDS.includes(d.id) || d.kind === 'hospital';
    civ = kg * (housing ? CONFIG.city.civiliansPerKgInHousing : CONFIG.city.civiliansPerKgElsewhere) * d.popDensity;
    if (s.city.shieldsActive && ['military', 'fuel', 'airbase'].includes(d.kind)) civ *= 3;
    if (['fuel', 'chem', 'port', 'military', 'airbase'].includes(d.kind) && Math.random() < 0.25 + (d.kind === 'fuel' ? 0.4 : 0)) {
      w.events.push({ t: 'secondary', x: x + gauss() * 0.3, y: y + gauss() * 0.3, big: d.kind === 'fuel' || d.kind === 'chem' });
      st.integrity = clamp(st.integrity - 0.04);
      if (d.kind === 'fuel') s.city.stock.fuel = Math.max(0, s.city.stock.fuel - 0.6);
    }
    if (d.kind === 'airbase') {
      for (const k of ['F-104G', 'F-4', 'Lightning'] as InterceptorType[]) if (Math.random() < pts * 0.03) s.city.interceptors[k] = Math.max(0, s.city.interceptors[k] - 1);
    }
    if (d.kind === 'military') {
      for (const site of s.sams) if (!site.isDecoy && Math.random() < 0.05) site.missiles = Math.max(0, site.missiles - 2);
    }
    if (before > 0.25 && st.integrity <= 0.25) radio(w, 'RECON', `${d.name} — heavy damage observed.`, false, `dmg${d.id}`, 30);
    // Guns near the impact are knocked out.
    for (const g of w.guns) if (g.alive && dist(g.x, g.y, x, y) < spec.radius * (spec.cluster ? 2.2 : 1.2)) g.alive = Math.random() > 0.6;
  } else if (h.key?.startsWith('decoy:') && h.decoyOf) {
    const dec = s.districts[h.decoyOf].decoys.find((q) => h.key!.endsWith(q.id));
    if (dec && pts > 2) dec.destroyed = Math.random() < 0.5;
  } else if (h.key?.startsWith('sam') ) {
    const site = s.sams.find((q) => h.key!.endsWith(`:${q.id}`));
    if (site) {
      const k = spec.cluster ? 2.5 : 1;
      site.launchers = clamp(site.launchers - (pts * k) / 18);
      site.radar = clamp(site.radar - (pts * k) / 22);
      if (!site.isDecoy && site.launchers < 0.25) {
        site.disabledUntil = s.hour + CONFIG.sam.siteRebuildDays * 24;
        w.samSitesHit.add(site.id);
        const rt = w.sites.find((r) => r.st === site);
        if (rt) rt.state = 'dead';
      }
      if (s.city.shieldsActive && !site.isDecoy) civ += kg * 0.01;
    }
  } else if (h.key?.startsWith('picket:')) {
    const pk = w.pickets[Number(h.key.slice(7))];
    if (pk && pts > 1.2) {
      pk.alive = false;
      w.events.push({ t: 'picketDown', x: pk.x, y: pk.y });
      w.events.push({ t: 'secondary', x: pk.x, y: pk.y, big: false });
      radio(w, owner?.callsign ?? 'STRIKE', 'Picket radar is down. The door is open — the barrier is blind here.', false);
    }
  } else if (h.key?.startsWith('farm:')) {
    const f = s.city.fuelFarms.find((q) => h.key!.endsWith(q.id));
    if (f) {
      f.destroyed = true;
      f.known = true;
      s.city.stock.fuel = Math.max(0, s.city.stock.fuel - f.fuel);
      w.events.push({ t: 'secondary', x, y, big: true });
    }
  } else {
    const r = Math.hypot(x + 2, y);
    if (r < 18) civ = kg * CONFIG.city.civiliansPerKgElsewhere * 0.6 * (1 - r / 18);
    igniteAt(w.fire, x, y, spec.fire, spec.radius);
  }
  civ *= 1 - s.city.evacuated / (s.city.evacuated + s.city.population + 1);
  const imp: Impact = { x, y, kg, munition: m, flightId, hit: h.key, civ, player, aimed: aimKey };
  w.impacts.push(imp);
  w.events.push({ t: 'impact', x, y, kg, munition: m, player, hit: h.key });
  if (owner && h.key && aimKey && (h.key === `d:${aimKey}` || h.key === aimKey)) owner.bombsOnTarget++;
  // Lights die district by district.
  updateGrid(s);
  for (const d of DISTRICTS) {
    const now = s.districts[d.id].powered;
    if (w.powerWas[d.id] && !now) w.events.push({ t: 'blackout', district: d.id });
    w.powerWas[d.id] = now;
  }
}

// ── Weapons ────────────────────────────────────────────────────────────────
function launchMissile(w: World, m: Partial<Missile> & { kind: Missile['kind']; side: 'red' | 'blue'; x: number; y: number; alt: number; hdg: number }): Missile {
  const ms: Missile = {
    id: w.nextId++, spd: 2, turn: 2, targetId: -1, tx: 0, ty: 0, life: 10, guided: true, site: null, lethal: 0.4, dmg: 50, pk: 1,
    minD: 999, alive: true, munition: null, owner: null, flightId: '', aimKey: null, seduced: false, age: 0, evaded: false, missX: 0, missY: 0, ...m,
  };
  w.missiles.push(ms);
  return ms;
}

function releaseBombs(w: World, p: Plane, count: number, forced = false): void {
  if (!p.munition || p.sticks <= 0) return;
  const M = CONFIG.mission;
  const spec = CONFIG.munitions[p.munition];
  const fr = p.flight!;
  if (spec.missile) {
    // Standoff / anti-radar missiles.
    if (p.munition === 'KSR-11') fireARM(w, p);
    else fireStandoff(w, p);
    return;
  }
  const n = Math.min(count, p.sticks);
  const K = M.crew;
  for (let i = 0; i < n; i++) {
    if (p.isPlayer && !forced && Math.random() < (p.hp < p.maxHp * 0.7 ? K.hungChanceDamaged : K.hungChance)) {
      // A shackle jams: the rest stay aboard until the doors are cycled.
      w.crew.hung += n - i;
      w.events.push({ t: 'sys', what: 'hung', x: p.x, y: p.y, v: n - i });
      radio(w, 'NAVIGATOR', 'Hung bombs! The release is jammed — cycle the doors!', true);
      break;
    }
    p.sticks--;
    if (p.isPlayer) w.events.push({ t: 'sys', what: 'clunk', x: p.x, y: p.y });
    const agl = Math.max(0.05, p.alt - terrainHeight(p.x, p.y));
    const ft = M.gravityFall * Math.sqrt(agl) * (spec.guided ? 1.7 : 1);
    const sigmaBase = M.scatterBase + M.scatterPerKmAlt * p.alt + (w.light < 0.3 ? M.scatterNight : 0) + (w.t < p.jinkUntil ? M.scatterJink : 0);
    const spread = p.isWingmanOfPlayer ? (w.crew.formation === 'tight' ? 0.8 : w.crew.formation === 'loose' ? 1.3 : 1) : 1;
    const sigma = sigmaBase * (1 - p.xp * M.scatterXpReduction) * (p.kind === 'Tu-22' && p.alt < 1 ? 1.4 : 1) * spread;
    const ex = (gauss() * sigma) / ft;
    const ey = (gauss() * sigma) / ft;
    const throwV = p.spd * M.throwFactor;
    const b: Bomb = {
      x: p.x, y: p.y, vx: Math.cos(p.hdg) * throwV + w.windX * M.windDriftFactor + ex, vy: Math.sin(p.hdg) * throwV + w.windY * M.windDriftFactor + ey,
      alt0: p.alt, t: 0, ft, kg: p.stickKg, munition: p.munition, flightId: fr.plan.id, player: p.isPlayer || p.isWingmanOfPlayer,
      guided: spec.guided, steerX: 0, steerY: 0, aimKey: fr.aimKey, owner: p, alive: true,
    };
    if (spec.guided) {
      const pred = ccip(w, p, true);
      b.steerX = p.isPlayer ? pred.x : fr.aimX + gauss() * 0.08;
      b.steerY = p.isPlayer ? pred.y : fr.aimY + gauss() * 0.08;
      if (p.isPlayer) w.guidedBomb = b;
    }
    w.bombs.push(b);
  }
  if (!fr.released) {
    fr.released = true;
    radio(w, fr.planes[0].callsign, spec.guided ? 'Pickle — bomb away, guiding.' : 'Pickle, pickle — bombs away!', false);
  }
  w.events.push({ t: 'release', plane: p });
}

function fireARM(w: World, p: Plane): boolean {
  if (p.sticks <= 0) return false;
  let best: SiteRT | null = null;
  let bestScore = -1;
  for (const s of w.sites) {
    if (!s.emitting || s.state === 'dead') continue;
    const d = dist(p.x, p.y, s.st.x, s.st.y);
    if (d > CONFIG.mission.armRange) continue;
    const off = Math.abs(wrap(Math.atan2(s.st.y - p.y, s.st.x - p.x) - p.hdg));
    if (off > CONFIG.mission.armCone) continue;
    const score = (s.state === 'guide' ? 3 : s.state === 'track' ? 2 : 1) - d / 40;
    if (score > bestScore) { best = s; bestScore = score; }
  }
  if (!best) {
    if (p.isPlayer) radio(w, p.callsign, 'No emitter in the seeker cone.', false, 'noarm', 3);
    return false;
  }
  p.sticks--;
  const ms = launchMissile(w, { kind: 'ksr11', side: 'red', x: p.x, y: p.y, alt: p.alt, hdg: p.hdg, spd: 2.4, turn: 0.9, life: 30, tx: best.st.x, ty: best.st.y, site: best, munition: 'KSR-11', owner: p, flightId: p.flight!.plan.id, aimKey: `sam:${best.st.id}`, lethal: 0.5 });
  ms.guided = true;
  radio(w, p.callsign, `Magnum! Kelt away on emitter ${best.st.id}!`, false);
  if (Math.random() < CONFIG.sam.emconChance + 0.1 * (w.s.city.adaptLevels.emcon ?? 0) && !best.st.isDecoy) {
    best.silentUntil = w.t + 12 + Math.random() * 12;
    best.state = 'silent';
    best.emitting = false;
  }
  return true;
}

function fireStandoff(w: World, p: Plane): boolean {
  if (p.sticks <= 0) return false;
  const fr = p.flight!;
  const d = dist(p.x, p.y, fr.aimX, fr.aimY);
  if (d > CONFIG.mission.standoffRange || d < CONFIG.mission.standoffMin) return false;
  p.sticks--;
  const hdg = Math.atan2(fr.aimY - p.y, fr.aimX - p.x);
  launchMissile(w, { kind: 'standoff', side: 'red', x: p.x, y: p.y, alt: p.alt, hdg, spd: 3.2, turn: 0.5, life: 40, tx: fr.aimX + gauss() * 0.15, ty: fr.aimY + gauss() * 0.15, munition: p.munition, owner: p, flightId: fr.plan.id, aimKey: fr.aimKey, lethal: 0.3 });
  radio(w, p.callsign, `${p.munition} away. Turning out.`, false);
  if (!fr.released) fr.released = true;
  return true;
}

function fireAAM(w: World, p: Plane, kind: 'aim9' | 'aim7' | 'r3s', target: Plane): void {
  const spec = CONFIG.interceptors[kind];
  launchMissile(w, { kind, side: p.side, x: p.x, y: p.y, alt: p.alt, hdg: p.hdg, spd: spec.speed + p.spd * 0.5, turn: spec.turn, life: spec.life, targetId: target.id, pk: spec.pk, dmg: spec.dmg, lethal: 0.25, owner: p });
}

function canShoot(p: Plane, t: Plane, range: number, minRange: number, cone: number, rear: boolean): boolean {
  const d = dist(p.x, p.y, t.x, t.y);
  if (d > range || d < minRange || Math.abs(p.alt - t.alt) > 3) return false;
  const off = Math.abs(wrap(Math.atan2(t.y - p.y, t.x - p.x) - p.hdg));
  if (off > cone) return false;
  if (rear) {
    const aspect = Math.abs(wrap(Math.atan2(p.y - t.y, p.x - t.x) - t.hdg));
    if (aspect < Math.PI * 0.55) return false;
  }
  return true;
}

function gunsOn(w: World, p: Plane, dt: number): void {
  const I = CONFIG.interceptors;
  for (const t of w.planes) {
    if (!t.alive || t.done || t.side === p.side || t.state === 'wait') continue;
    if (canShoot(p, t, I.gunRange, 0, I.gunCone, false) && Math.random() < 0.5 + p.xp * 0.3) damagePlane(w, t, I.gunDmgPerSec * dt * (0.5 + Math.random()), 'cannon');
  }
}

// ── Plane AI & control ─────────────────────────────────────────────────────
function steerTo(p: Plane, x: number, y: number, dt: number, rateMul = 1): void {
  const want = Math.atan2(y - p.y, x - p.x);
  const d = wrap(want - p.hdg);
  const rate = p.turnRate * rateMul * (1 - p.hydraulics * 0.5);
  p.hdg = wrap(p.hdg + clamp(d, -rate * dt, rate * dt) + p.controlBias * 0.25 * dt);
}

function nearestEnemy(w: World, p: Plane, maxD: number, pred?: (t: Plane) => boolean): Plane | null {
  let best: Plane | null = null;
  let bd = maxD;
  for (const t of w.planes) {
    if (!t.alive || t.done || t.side === p.side || t.state === 'wait' || (pred && !pred(t))) continue;
    const d = dist(p.x, p.y, t.x, t.y);
    if (d < bd) { bd = d; best = t; }
  }
  return best;
}

function redAI(w: World, p: Plane, dt: number): void {
  const fr = p.flight;
  if (w.t < p.evadeUntil) {
    const d = wrap(p.evadeDir - p.hdg);
    p.hdg = wrap(p.hdg + clamp(d, -p.turnRate * 1.4 * dt, p.turnRate * 1.4 * dt));
    p.tAlt = Math.max(CONFIG.mission.altLow, p.alt - 1.5);
    p.jinkUntil = w.t + 1;
    return;
  }
  // Wingmen of the player hold formation and drop on lead.
  if (p.isWingmanOfPlayer && w.player?.alive && !w.player.done) {
    const lead = w.player;
    const back = Math.floor((p.slot + 1) / 2) * 0.8 * CONFIG.mission.crew.formation[w.crew.formation];
    const side = p.slot % 2 ? -1 : 1;
    const fx = lead.x - Math.cos(lead.hdg) * back + Math.cos(lead.hdg + Math.PI / 2) * side * back * 0.7;
    const fy = lead.y - Math.sin(lead.hdg) * back + Math.sin(lead.hdg + Math.PI / 2) * side * back * 0.7;
    steerTo(p, fx + Math.cos(lead.hdg) * 2, fy + Math.sin(lead.hdg) * 2, dt, 1.3);
    const dd = dist(p.x, p.y, fx, fy);
    p.spd = lead.spd * (dd > 0.3 ? 1.08 : 1);
    p.tAlt = lead.alt;
    if (p.role === 'escort') escortFight(w, p, dt);
    return;
  }
  if (p.role === 'drone') {
    followPath(w, p, dt);
    return;
  }
  if (p.role === 'escort' && escortFight(w, p, dt)) return;
  if (p.role === 'weasel') weaselAI(w, p);
  if (p.role === 'suppress') suppressAI(w, p);
  if (p.role === 'jammer' && w.plan.chaffCorridor && p.chaff > 0 && p.x < BARRIER_X + 20 && Math.random() < dt * 1.2) {
    p.chaff--;
    w.chaff.push({ x: p.x, y: p.y, alt: p.alt, r: CONFIG.sam.chaffRadius * 1.6, life: CONFIG.ew.chaffCorridorDuration, max: CONFIG.ew.chaffCorridorDuration });
    w.events.push({ t: 'chaff', x: p.x, y: p.y, alt: p.alt, player: false });
  }
  // Fighters out of fuel head home.
  if (p.fuelT > p.bingo && p.state !== 'egress') {
    p.state = 'egress';
    p.wp = p.path.length - 2;
    if (fr && p.slot === 0) radio(w, p.callsign, 'Bingo fuel — escort turning for home!', true);
  }
  // Primary already wrecked by an earlier wave: divert to the alternate.
  if (fr && p.slot === 0 && p.role === 'strike' && !fr.retargeted && !fr.released && w.plan.alternates[0] && fr.aimKey === w.plan.primary) {
    const st = w.s.districts[w.plan.primary];
    if (st && st.integrity < 0.15 && dist(p.x, p.y, fr.aimX, fr.aimY) < 30) {
      fr.retargeted = true;
      const alt = chooseAim(w.s, w.plan.alternates[0]);
      fr.aimX = alt.x;
      fr.aimY = alt.y;
      fr.aimKey = alt.key;
      // Path layout is [...ingress, aim, ...egress].
      const k = ROUTES[w.plan.route].ingress.length;
      for (const q of fr.planes) q.path[k] = [alt.x, alt.y];
      radio(w, p.callsign, `Primary is burning out. Diverting to the alternate: ${DISTRICT_BY_ID[w.plan.alternates[0]]?.name ?? w.plan.alternates[0]}.`, false);
    }
  }
  // Bombing run.
  if (fr && (p.role === 'strike' || (p.role === 'suppress' && p.munition !== 'S-24')) && p.sticks > 0 && p.munition) {
    const spec = CONFIG.munitions[p.munition];
    if (spec.missile) {
      if (dist(p.x, p.y, fr.aimX, fr.aimY) < CONFIG.mission.standoffRange * 0.8) fireStandoff(w, p);
    } else {
      const imp = ccip(w, p);
      const toAim = dist(imp.x, imp.y, fr.aimX, fr.aimY);
      const along = Math.cos(p.hdg) * (fr.aimX - imp.x) + Math.sin(p.hdg) * (fr.aimY - imp.y);
      if ((toAim < 0.6 || (along < 0 && toAim < 2.5)) && !p.released) {
        p.released = true;
        p.releaseT = w.t;
      }
    }
  }
  if (p.released && p.sticks > 0 && p.munition && !CONFIG.munitions[p.munition].missile) {
    const perSec = Math.max(1, Math.ceil(p.sticks / CONFIG.mission.ripple));
    if (Math.random() < dt * 6) releaseBombs(w, p, Math.min(perSec, 2));
  }
  followPath(w, p, dt);
}

function followPath(w: World, p: Plane, dt: number): void {
  const fr = p.flight;
  if (p.wp >= p.path.length) {
    p.done = p.x > CONFIG.mission.egressX - 6 || p.role === 'drone';
    if (!p.done) steerTo(p, CONFIG.mission.egressX + 10, p.y, dt);
    return;
  }
  const [tx, ty] = p.path[p.wp];
  steerTo(p, tx, ty, dt);
  const ingressLen = ROUTES[w.plan.route].ingress.length;
  const dWp = dist(p.x, p.y, tx, ty);
  const behind = Math.cos(p.hdg) * (tx - p.x) + Math.sin(p.hdg) * (ty - p.y) < 0;
  if (dWp < (p.role === 'drone' ? 2 : 2.4) || (behind && dWp < 7)) {
    p.wp++;
    if (fr && p.wp === ingressLen && p.slot === 0 && p.role === 'strike') radio(w, p.callsign, 'IP inbound. Doors open. Hold her steady.', false);
    if (fr && p.wp === ingressLen + 1) p.state = 'egress';
  }
  const ingressDone = p.wp >= ingressLen;
  if (p.role !== 'drone') {
    if (p.state === 'egress') p.tAlt = altKm(w.plan.ingressAlt === 'low' ? 'low' : 'med');
    else if (ingressDone || dist(p.x, p.y, fr?.aimX ?? 0, fr?.aimY ?? 0) < 25) p.tAlt = altKm(w.plan.attackAlt);
    else p.tAlt = altKm(w.plan.ingressAlt);
    if (p.role === 'weasel' || p.role === 'suppress') p.tAlt = Math.min(p.tAlt, altKm('med'));
    if (p.role === 'suppress' && fr?.aimKey === 'pickets') p.tAlt = altKm('low');
    if (p.role === 'recon' && p.kind === 'MiG-25R') p.tAlt = 12;
  }
  // Dash on the run-in for Blinders.
  if (p.kind === 'Tu-22' && ingressDone && p.state !== 'egress') p.spd = Math.min(CONFIG.aircraft['Tu-22'].dash, p.spd + dt * 0.1);
  else if (p.kind !== 'drone') p.spd += (p.baseSpd - p.spd) * dt * 0.5;
  // Jink under fire.
  if (w.t - p.lastHit < 2 || p.tracked < 1) {
    p.jinkUntil = w.t + 2;
    p.hdg += Math.sin(w.t * 3 + p.id) * dt * 0.4;
  }
  // Recon photographs.
  if (p.role === 'recon') photograph(w, p);
}

function photograph(w: World, p: Plane): void {
  for (const d of DISTRICTS) {
    if (w.photoTaken.has(d.id)) continue;
    if (dist(p.x, p.y, d.x, d.y) < 4) {
      w.photoTaken.add(d.id);
      const st = w.s.districts[d.id];
      st.intelHour = w.s.hour;
      st.knownIntegrity = st.integrity;
      for (const dec of st.decoys) {
        dec.seen = true;
        if (Math.random() < CONFIG.adaptation.reconRevealMiG25) dec.revealed = true;
      }
    }
  }
  for (const site of w.s.sams) {
    if (dist(p.x, p.y, site.x, site.y) < 6) {
      site.knownX = site.x;
      site.knownY = site.y;
      site.knownHour = w.s.hour;
      if (site.isDecoy && Math.random() < 0.7) site.revealed = true;
    }
  }
}

function escortFight(w: World, p: Plane, dt: number): boolean {
  const I = CONFIG.interceptors;
  const anchor = w.flights.find((f) => f.plan.role === 'strike')?.planes.find((x) => x.alive && !x.done) ?? null;
  const t = nearestEnemy(w, p, 14);
  if (!t || (anchor && dist(anchor.x, anchor.y, t.x, t.y) > 18)) {
    if (anchor && !p.isWingmanOfPlayer) {
      p.wp = Math.min(anchor.wp, p.path.length - 1);
      steerTo(p, anchor.x + Math.cos(anchor.hdg) * 2.5 + Math.cos(anchor.hdg + 1.2) * (p.slot % 2 ? 2 : -2), anchor.y + Math.sin(anchor.hdg) * 2.5 + Math.sin(anchor.hdg + 1.2) * (p.slot % 2 ? 2 : -2), dt);
      p.spd = Math.max(anchor.spd * 1.05, p.baseSpd * 0.8);
      p.tAlt = anchor.alt + 0.8;
      return true;
    }
    if (!anchor && p.state !== 'egress') {
      p.state = 'egress';
      p.wp = Math.max(p.wp, p.path.length - 3);
    }
    return false;
  }
  if (!w.radioCooldown.tallyho || w.radioCooldown.tallyho < w.t) radio(w, p.callsign, `Tally-ho — ${String(t.kind)}s! Engaging!`, false, 'tallyho', 20);
  steerTo(p, t.x + Math.cos(t.hdg) * 0.4, t.y + Math.sin(t.hdg) * 0.4, dt);
  p.tAlt = t.alt;
  p.spd = p.baseSpd * 1.3;
  p.cool -= dt;
  if (p.missiles > 0 && p.cool <= 0 && canShoot(p, t, I.r3s.range, I.r3s.minRange, I.r3s.cone, true)) {
    p.missiles--;
    p.cool = 4;
    fireAAM(w, p, 'r3s', t);
    radio(w, p.callsign, 'Fox two!', false, `aam${p.id}`, 3);
  }
  gunsOn(w, p, dt);
  return true;
}

function weaselAI(w: World, p: Plane): void {
  if (p.sticks <= 0) return;
  p.cool -= 0.016;
  if (p.cool > 0) return;
  if (w.sites.some((s) => s.emitting && dist(p.x, p.y, s.st.x, s.st.y) < CONFIG.mission.armRange * 0.85)) {
    if (fireARM(w, p)) p.cool = 6;
  }
}

function suppressAI(w: World, p: Plane): void {
  if (p.munition !== 'S-24' || p.sticks <= 0) return;
  const fr = p.flight!;
  if (fr.aimKey === 'pickets') {
    // H-hour: kill the early-warning radar before the main force arrives.
    const pk = w.pickets.find((q) => q.alive && dist(p.x + Math.cos(p.hdg) * 1.5, p.y + Math.sin(p.hdg) * 1.5, q.x, q.y) < 2.5);
    if (pk && Math.random() < 0.12) rocketSalvo(w, p, pk.x, pk.y);
    return;
  }
  if (dist(p.x, p.y, fr.aimX, fr.aimY) > 9) return;
  let best: Gun | null = null;
  let bd = 3;
  for (const g of w.guns) {
    if (!g.alive) continue;
    const d = dist(p.x + Math.cos(p.hdg) * 1.4, p.y + Math.sin(p.hdg) * 1.4, g.x, g.y);
    if (d < bd) { bd = d; best = g; }
  }
  if (best && Math.random() < 0.08) rocketSalvo(w, p, best.x, best.y);
}

function rocketSalvo(w: World, p: Plane, tx: number, ty: number): void {
  if (p.sticks <= 0 || !p.munition) return;
  p.sticks--;
  const spec = CONFIG.munitions[p.munition];
  const e = 0.25 * (1 - p.xp * 0.5);
  w.bombs.push({ x: p.x, y: p.y, vx: (tx - p.x) / 0.9 + gauss() * e, vy: (ty - p.y) / 0.9 + gauss() * e, alt0: p.alt, t: 0, ft: 0.9, kg: p.stickKg, munition: p.munition, flightId: p.flight!.plan.id, player: p.isPlayer, guided: false, steerX: 0, steerY: 0, aimKey: p.flight!.aimKey, owner: p, alive: true });
  for (const g of w.guns) if (g.alive && dist(g.x, g.y, tx, ty) < 1.5) g.suppressed = 6;
  w.events.push({ t: 'gun', x: p.x, y: p.y, hdg: p.hdg, player: p.isPlayer });
  void spec;
}

function playerControl(w: World, p: Plane, dt: number): void {
  const c = w.ctl;
  const cr = w.crew;
  const K = CONFIG.mission.crew;
  const spec = CONFIG.aircraft[p.kind as AircraftType];
  const lowSpeed = spec.lowSpeedPenalty && p.alt < 1.5 ? 1 - spec.lowSpeedPenalty : 1;
  const rate = p.turnRate * (1 - p.hydraulics * 0.5) * lowSpeed;
  p.hdg = wrap(p.hdg + (c.turn * rate + p.controlBias * 0.35) * dt);
  const doorsDrag = 1 - K.doorsDrag * cr.doors;
  if (cr.flamedOut) {
    // Dead engines: glide down.
    p.spd = Math.max(p.baseSpd * 0.45, p.spd - dt * 0.02);
  } else if (spec.fighter) {
    // Energy: hard turns bleed speed, dives build it.
    p.spd -= Math.abs(c.turn) * 0.22 * dt;
    p.spd += c.throttle * 0.25 * dt + (p.baseSpd - p.spd) * 0.15 * dt;
    p.spd = clamp(p.spd, p.baseSpd * 0.5, spec.dash);
  } else {
    p.spd += ((p.baseSpd + c.throttle * (spec.dash - p.baseSpd)) * doorsDrag - p.spd) * dt * 0.6;
  }
  const ground = terrainHeight(p.x, p.y);
  p.tAlt = cr.flamedOut ? p.alt - 0.25 : clamp(p.alt + c.climb * 3, ground + 0.03, CONFIG.mission.altMax);
  p.cool -= dt;
  p.gunCool -= dt;

  // ── Fuel ──
  if (!cr.flamedOut) {
    cr.fuel -= dt * (cr.fuelRate * (1 + Math.max(0, c.throttle) * 0.8 + cr.doors * 0.1) + cr.fuelLeak);
    if (cr.fuel <= 0) {
      cr.fuel = 0;
      cr.flamedOut = true;
      w.events.push({ t: 'sys', what: 'flameout', x: p.x, y: p.y });
      radio(w, p.callsign, 'Both engines flamed out — tanks are dry! We are gliding!', true);
    } else if (cr.fuel < K.bingo) radio(w, 'NAVIGATOR', 'Bingo fuel, commander. Turn for home now.', true, 'bingo', 40);
  }

  // ── Crew actions ──
  if (c.doors) {
    c.doors = false;
    cr.doorsWant = !cr.doorsWant;
    w.events.push({ t: 'sys', what: 'doors', x: p.x, y: p.y, v: cr.doorsWant ? 1 : 0 });
    if (cr.doorsWant && cr.hung > 0) cr.doorCycle++;
  }
  const doorsBefore = cr.doors;
  cr.doors = clamp(cr.doors + (cr.doorsWant ? dt : -dt) / K.doorsOpenSec);
  if (doorsBefore < 1 && cr.doors >= 1 && cr.hung > 0 && cr.doorCycle > 0) {
    // Shaking the doors frees the hung stores — wherever you are.
    const n = cr.hung;
    cr.hung = 0;
    cr.doorCycle = 0;
    w.events.push({ t: 'sys', what: 'unhung', x: p.x, y: p.y, v: n });
    radio(w, 'NAVIGATOR', `Hung bombs released — ${n} stick${n > 1 ? 's' : ''} gone!`, true);
    releaseBombs(w, p, n, true);
  }
  if (c.mode) {
    c.mode = false;
    cr.mode = cr.mode === 'train' ? 'salvo' : cr.mode === 'salvo' ? 'single' : 'train';
  }
  if (c.formation) {
    cr.formation = c.formation;
    c.formation = null;
    w.events.push({ t: 'sys', what: 'formation', x: p.x, y: p.y });
    radio(w, p.callsign, cr.formation === 'tight' ? 'All Granit, close it up — tight box, guns covering each other.' : cr.formation === 'loose' ? 'Spread out! Open the formation — don\'t give the flak a group.' : 'Back to normal spacing.', false);
  }
  if (c.marker) {
    c.marker = false;
    if (cr.markers > 0) {
      cr.markers--;
      const imp = ccip(w, p, true);
      w.markers.push({ x: imp.x, y: imp.y, life: K.markerLife });
      w.events.push({ t: 'sys', what: 'marker', x: imp.x, y: imp.y });
      radio(w, p.callsign, 'Marker down! All flights — bomb on my marker!', true);
    }
  }
  if (c.extinguish) {
    c.extinguish = false;
    if (p.engineFire > 0 && cr.extinguishers > 0) {
      cr.extinguishers--;
      p.engineFire = 0;
      cr.fireT = 0;
      w.events.push({ t: 'sys', what: 'extinguish', x: p.x, y: p.y });
      radio(w, 'FLIGHT ENGINEER', 'Bottle fired — fire is out.', false);
    } else if (p.engineFire > 0) radio(w, 'FLIGHT ENGINEER', 'No bottles left! Dive — try to blow it out!', true, 'nobottle', 4);
  }
  if (c.hydraulics) {
    c.hydraulics = false;
    if (p.hydraulics > 0.15 && cr.hydBackup) {
      cr.hydBackup = false;
      p.hydraulics *= 0.3;
      w.events.push({ t: 'sys', what: 'hydBackup', x: p.x, y: p.y });
      radio(w, 'CO-PILOT', 'Backup hydraulic pump on. Controls are lighter.', false);
    }
  }
  if (c.isolate) {
    c.isolate = false;
    if (cr.fuelLeak > 0 && !cr.leakIsolated) {
      cr.leakIsolated = true;
      cr.fuelLeak = 0;
      w.events.push({ t: 'sys', what: 'isolate', x: p.x, y: p.y });
      radio(w, 'FLIGHT ENGINEER', 'Leaking tank isolated. We keep what we have.', false);
    }
  }
  // Engine fire burns until the crew acts.
  if (p.engineFire > 0) {
    cr.fireT += dt;
    if (p.spd > p.baseSpd * 1.1 && Math.random() < dt * 0.1) p.engineFire = 0; // blown out in a dive
    if (cr.fireT > K.engineFireExplodeSec) {
      cr.fireT = 0;
      p.engineFire = 0;
      w.events.push({ t: 'sys', what: 'engineBoom', x: p.x, y: p.y });
      radio(w, 'FLIGHT ENGINEER', 'Engine has blown apart!', true);
      damagePlane(w, p, K.engineExplodeDmg, 'fire');
    }
  }

  // ── Weapons ──
  const mspec = p.munition ? CONFIG.munitions[p.munition] : null;
  const pressed = c.fire && !c.firePressed;
  c.firePressed = c.fire;
  if (c.fire) {
    if (p.role === 'escort' || (p.role === 'suppress' && c.secondary)) {
      if (p.gunCool <= 0) {
        p.gunCool = 0.08;
        gunsOn(w, p, 0.08);
        w.events.push({ t: 'gun', x: p.x, y: p.y, hdg: p.hdg, player: true });
      }
    } else if (p.role === 'jammer') {
      if (p.cool <= 0) { p.jamming = !p.jamming; p.cool = 0.5; radio(w, p.callsign, p.jamming ? 'Music on — jammers radiating.' : 'Jammers off — we are naked.', false); }
    } else if (mspec?.missile) {
      if (p.cool <= 0) {
        p.cool = 1.2;
        const ok = p.munition === 'KSR-11' ? fireARM(w, p) : fireStandoff(w, p);
        if (!ok && p.munition !== 'KSR-11') radio(w, p.callsign, `Out of launch envelope (${CONFIG.mission.standoffMin}–${CONFIG.mission.standoffRange} km).`, false, 'env', 3);
      }
    } else if (p.munition === 'S-24') {
      if (p.cool <= 0) {
        p.cool = 0.5;
        rocketSalvo(w, p, p.x + Math.cos(p.hdg) * 1.6, p.y + Math.sin(p.hdg) * 1.6);
      }
    } else if (p.sticks > 0 && p.cool <= 0) {
      if (cr.doors < 1) {
        if (pressed) radio(w, 'NAVIGATOR', cr.doorsWant ? 'Doors still opening!' : 'Bomb doors are CLOSED — open them (B)!', true, 'doorsclosed', 2);
      } else if (cr.hung > 0) {
        if (pressed) radio(w, 'NAVIGATOR', 'Release is jammed — hung bombs! Cycle the doors!', true, 'hungmsg', 3);
      } else if (cr.mode === 'salvo') {
        if (pressed) {
          const n = p.sticks;
          releaseBombs(w, p, n);
          for (const wm of p.flight?.planes ?? []) if (wm !== p && wm.alive && !wm.done && wm.sticks > 0) releaseBombs(w, wm, wm.sticks);
          p.cool = 1;
          void n;
        }
      } else if (cr.mode === 'single') {
        if (pressed) {
          releaseBombs(w, p, 1);
          for (const wm of p.flight?.planes ?? []) if (wm !== p && wm.alive && !wm.done && wm.sticks > 0) releaseBombs(w, wm, 1);
          p.cool = 0.3;
        }
      } else {
        p.cool = CONFIG.mission.ripple / CONFIG.mission.sticks;
        releaseBombs(w, p, 1);
        // Wingmen toggle on lead.
        for (const wm of p.flight?.planes ?? []) if (wm !== p && wm.alive && !wm.done && wm.sticks > 0) releaseBombs(w, wm, 1);
      }
    }
  }
  if (c.secondary && p.cool <= 0) {
    p.cool = 0.6;
    if (p.role === 'escort' && p.missiles > 0) {
      const t = nearestEnemy(w, p, CONFIG.interceptors.r3s.range, (q) => canShoot(p, q, CONFIG.interceptors.r3s.range, CONFIG.interceptors.r3s.minRange, CONFIG.interceptors.r3s.cone, true));
      if (t) {
        p.missiles--;
        fireAAM(w, p, 'r3s', t);
        radio(w, p.callsign, 'Fox two — Atoll away!', false);
      } else radio(w, p.callsign, 'No tone.', false, 'notone', 2);
    }
  }
  if (c.chaff && p.chaff > 0 && p.cool <= 0.3) {
    p.chaff--;
    w.chaff.push({ x: p.x, y: p.y, alt: p.alt, r: CONFIG.sam.chaffRadius, life: CONFIG.sam.chaffCloudLife, max: CONFIG.sam.chaffCloudLife });
    w.events.push({ t: 'chaff', x: p.x, y: p.y, alt: p.alt, player: true });
    c.chaff = false;
  }
  // ── Tail gun: the gunner fires on his own; you can take the turret (hold G). ──
  if (spec.tailGun) {
    cr.tailTarget = -1;
    let best: Plane | null = null;
    let bd = K.tailGunRange;
    for (const q of w.planes) {
      if (q.side !== 'blue' || !q.alive || q.done) continue;
      const d = dist(p.x, p.y, q.x, q.y);
      if (d > bd || Math.abs(q.alt - p.alt) > 1.5) continue;
      const aspect = Math.abs(wrap(Math.atan2(q.y - p.y, q.x - p.x) - p.hdg));
      if (aspect < Math.PI * 0.6) continue;
      best = q;
      bd = d;
    }
    if (best) {
      cr.tailTarget = best.id;
      const mult = c.tailGun ? K.tailGunManualMult : K.tailGunAutoMult;
      damagePlane(w, best, CONFIG.interceptors.tailGunDmgPerSec * mult * dt * (0.5 + p.xp), 'tailgun');
      if (Math.random() < dt * (c.tailGun ? 22 : 6)) {
        w.events.push({ t: 'tracer', x: p.x - Math.cos(p.hdg) * 0.2, y: p.y - Math.sin(p.hdg) * 0.2, tx: best.x + gauss() * 0.08, ty: best.y + gauss() * 0.08, alt: p.alt, onPlayer: false });
        if (c.tailGun) w.events.push({ t: 'sys', what: 'tailgun', x: p.x, y: p.y });
      }
    }
  }
  if (w.guidedBomb?.alive) {
    w.guidedBomb.steerX += c.steerX * CONFIG.mission.guidedSteerRate * dt;
    w.guidedBomb.steerY += c.steerY * CONFIG.mission.guidedSteerRate * dt;
  }
  if (p.role === 'recon') photograph(w, p);
  if (p.crossedBarrier && p.x > CONFIG.mission.egressX) p.done = true;
}

function blueAI(w: World, p: Plane, dt: number): void {
  const I = CONFIG.interceptors;
  p.fuelT += dt;
  if (p.fuelT > 200) p.state = 'rtb';
  if (p.state === 'rtb') {
    steerTo(p, -33, 6, dt);
    if (dist(p.x, p.y, -33, 6) < 3) p.done = true;
    return;
  }
  const detectR = p.state === 'cap' ? 22 : 45;
  const prefer = (t: Plane) => t.kind !== 'MiG-25R' || t.alt < 8;
  let t = p.targetId >= 0 ? planeById(w, p.targetId) : undefined;
  if (!t || !t.alive || t.done || dist(p.x, p.y, t.x, t.y) > detectR * 1.3) {
    t = nearestEnemy(w, p, detectR, (q) => prefer(q) && (q.alt > 0.5 || (w.s.city.adaptLevels.lowAltRadar ?? 0) > 1 || dist(p.x, p.y, q.x, q.y) < 6)) ?? undefined;
    p.targetId = t?.id ?? -1;
  }
  if (!t) {
    if (p.state === 'cap') {
      const [cx, cy] = p.path[0];
      steerTo(p, cx + Math.cos(w.t * 0.15 + p.id) * 8, cy + Math.sin(w.t * 0.15 + p.id) * 8, dt);
    } else {
      // Head toward the detected raid.
      const r = w.planes.find((q) => q.side === 'red' && q.alive && !q.done && q.state !== 'wait');
      if (r) steerTo(p, r.x, r.y, dt);
    }
    return;
  }
  if (p.state === 'cap') p.state = 'engage';
  if (w.bandits === 0 && w.player?.alive) {
    const c = clock(w.player, p.x, p.y);
    radio(w, w.player.flight?.planes[1]?.callsign ?? 'LEAD', `Bandits, bandits — ${c} o'clock ${p.alt > w.player.alt ? 'high' : 'low'}, ${String(p.kind)}s!`, true, 'bandits', 25);
  }
  w.bandits++;
  // Lead pursuit with a little lag; fly to the six on fighters.
  const lead = 0.6;
  steerTo(p, t.x + Math.cos(t.hdg) * t.spd * lead, t.y + Math.sin(t.hdg) * t.spd * lead, dt);
  p.tAlt = t.alt + 0.2;
  const dd = dist(p.x, p.y, t.x, t.y);
  p.spd = p.baseSpd * (dd < 2 ? 0.85 : 1.15);
  p.cool -= dt;
  const nightF = w.light < 0.3 ? I.nightEffectiveness : 1;
  if (p.cool <= 0) {
    if (p.aim7 > 0 && canShoot(p, t, I.aim7.range, I.aim7.minRange, I.aim7.cone, false) && Math.random() < 0.5 * nightF) {
      p.aim7--;
      p.cool = 5;
      fireAAM(w, p, 'aim7', t);
    } else if (p.aim9 > 0 && canShoot(p, t, I.aim9.range, I.aim9.minRange, I.aim9.cone, true) && Math.random() < 0.7 * nightF) {
      p.aim9--;
      p.cool = 4;
      fireAAM(w, p, 'aim9', t);
    }
  }
  const spec = CONFIG.interceptors[p.kind as InterceptorType];
  if (spec.gun && canShoot(p, t, I.gunRange, 0, I.gunCone, false)) {
    gunsOn(w, p, dt);
    if (Math.random() < dt * 8) w.events.push({ t: 'gun', x: p.x, y: p.y, hdg: p.hdg, player: false });
  }
  // Tail guns answer.
  if (t.side === 'red' && CONFIG.aircraft[t.kind as AircraftType]?.tailGun && dd < CONFIG.aircraft[t.kind as AircraftType].tailGunRange) {
    const aspect = Math.abs(wrap(Math.atan2(p.y - t.y, p.x - t.x) - t.hdg));
    if (aspect > Math.PI * 0.6) {
      const box = t.isWingmanOfPlayer && w.crew.formation === 'tight' ? 1.6 : t.isWingmanOfPlayer && w.crew.formation === 'loose' ? 0.7 : 1;
      if (!t.isPlayer) damagePlane(w, p, I.tailGunDmgPerSec * dt * (0.4 + t.xp) * box, 'tailgun');
      if (Math.random() < dt * 10) w.events.push({ t: 'tracer', x: t.x, y: t.y, tx: p.x + gauss() * 0.15, ty: p.y + gauss() * 0.15, alt: t.alt, onPlayer: false });
    }
  }
}

// ── SAM state machine: detect → track → launch → guide ─────────────────────
function samStep(w: World, site: SiteRT, dt: number): void {
  const S = CONFIG.sam;
  const spec = S[site.st.kind];
  const st = site.st;
  if (site.state === 'dead' || st.launchers <= 0.05) {
    site.state = 'dead';
    site.emitting = false;
    return;
  }
  if (site.state === 'silent') {
    site.emitting = false;
    if (w.t > site.silentUntil) site.state = 'search';
    return;
  }
  site.timer -= dt;
  const power = samPower(w.s) * st.radar;
  if (!w.detected) {
    site.emitting = false;
    return;
  }
  site.emitting = true;
  if (site.state === 'idle') site.state = 'search';
  if (site.state === 'search' && site.timer <= 0) {
    site.timer = 1;
    // Look for a target.
    let best: Plane | null = null;
    let bestScore = -1;
    for (const p of w.planes) {
      if (!p.alive || p.done || p.side !== 'red' || p.state === 'wait') continue;
      const d = dist(st.x, st.y, p.x, p.y);
      let range = spec.searchRange * power;
      const lowMask = S.lowAltDetectFactor + 0.12 * (w.s.city.adaptLevels.lowAltRadar ?? 0);
      if (p.alt - terrainHeight(p.x, p.y) < S.lowAltBelow) range *= Math.min(1, lowMask);
      if (p.isPlayer) range *= 1 + (CONFIG.mission.crew.doorsRcs - 1) * w.crew.doors;
      const jam = jammedAt(w, p.x, p.y, st.x, st.y);
      range *= 1 - jam * S.jamRangeFactor;
      if (d > range) continue;
      if (d > spec.range || p.alt < spec.minAlt * (p.alt < 0.5 ? 1 : 0.5)) continue;
      if (chaffNear(w, p.x, p.y, p.alt) && Math.random() < 0.5) continue;
      const rcs = p.kind === 'drone' ? 1.2 : CONFIG.aircraft[p.kind as AircraftType]?.rcs ?? 1;
      const score = rcs / (d + 3) + (p.isPlayer ? 0.02 : 0);
      if (score > bestScore) { best = p; bestScore = score; }
    }
    if (best) {
      site.targetId = best.id;
      site.state = 'track';
      site.timer = spec.trackTime * (1 + jammedAt(w, best.x, best.y, st.x, st.y));
    }
  } else if (site.state === 'track') {
    const t = planeById(w, site.targetId);
    if (!t || !t.alive || t.done) { site.state = 'search'; return; }
    t.tracked = 0;
    if (site.timer <= 0) {
      if (st.isDecoy || site.ready <= 0 || st.missiles <= 0) {
        // Decoy emitters only pretend.
        site.state = st.isDecoy ? 'search' : 'reload';
        site.timer = st.isDecoy ? 6 : spec.reloadSec;
        return;
      }
      const active = w.missiles.filter((m) => m.alive && (m.kind === 'nike' || m.kind === 'bloodhound')).length;
      if (active >= S.maxSimultaneousSam) { site.timer = 1; return; }
      site.ready--;
      st.missiles--;
      w.samLaunches++;
      if (t.kind === 'drone' && !w.droneBait) {
        w.droneBait = true;
        radio(w, 'CONTROL', 'The drones are drawing fire — SAM radars are lighting up! Weasels, Magnum when ready!', true);
      }
      const hdg = Math.atan2(t.y - st.y, t.x - st.x);
      const m = launchMissile(w, { kind: st.kind, side: 'blue', x: st.x, y: st.y, alt: 0.05, hdg, spd: spec.speed, turn: spec.turn, life: spec.guidanceSec, targetId: t.id, site, lethal: spec.lethal, dmg: spec.kill, pk: spec.pk });
      const sig = CONFIG.sam.missSigma + CONFIG.sam.missSigmaJammed * jammedAt(w, t.x, t.y, st.x, st.y);
      m.missX = gauss() * sig;
      m.missY = gauss() * sig;
      site.inFlight++;
      site.state = 'guide';
      w.events.push({ t: 'samLaunch', x: st.x, y: st.y, m });
      if (w.player?.alive && !w.player.done && dist(w.player.x, w.player.y, st.x, st.y) < 50) {
        const c = clock(w.player, st.x, st.y);
        const brk = c >= 1 && c <= 5 ? 'break left' : c >= 7 && c <= 11 ? 'break right' : 'dive, dive';
        radio(w, w.player.flight?.planes.find((q) => q !== w.player && q.alive)?.callsign ?? 'CONTROL', t.isPlayer ? `Mud spike! SAM launch ${c} o'clock — it's on YOU, ${brk}!` : `SAM launch, ${c} o'clock!`, true, `sam${site.st.id}`, 4);
      }
    }
  } else if (site.state === 'guide') {
    if (site.inFlight <= 0) {
      site.state = site.ready > 0 ? 'search' : 'reload';
      site.timer = site.ready > 0 ? 1 : spec.reloadSec;
    }
  } else if (site.state === 'reload' && site.timer <= 0) {
    site.ready = Math.min(spec.ready, st.missiles);
    site.state = 'search';
  }
}

function missileStep(w: World, m: Missile, dt: number): void {
  m.age += dt;
  m.life -= dt;
  const S = CONFIG.sam;
  let tx = m.tx;
  let ty = m.ty;
  let talt = 0;
  const target = m.targetId >= 0 ? planeById(w, m.targetId) : undefined;
  if (m.kind === 'nike' || m.kind === 'bloodhound') {
    // Semi-active: the site radar must keep the lock.
    const site = m.site;
    if (m.guided && target && target.alive && site) {
      const jam = jammedAt(w, target.x, target.y, site.st.x, site.st.y);
      if (site.state === 'silent' || site.state === 'dead') m.guided = false;
      else if (jam > 0 && Math.random() < S.trackLossPerSecJammed * jam * dt * 10) {
        if (Math.random() < S.homeOnJamChance * (w.s.city.adaptLevels.freqHop ?? 0) * 0.5) {
          // home-on-jam keeps guiding
        } else m.guided = false;
      }
      if (!m.seduced && chaffNear(w, target.x, target.y, target.alt) && Math.random() < S.chaffBreakChance * dt * 1.5) {
        m.seduced = true;
        const c = w.chaff.find((q) => dist(q.x, q.y, target.x, target.y) < q.r);
        if (c) { m.tx = c.x; m.ty = c.y; }
      }
      target.tracked = 0;
    }
    // Beaming: a semi-active seeker loses a target crossing at right angles (Doppler notch).
    if (m.guided && target?.isPlayer && target.alive) {
      const K = CONFIG.mission.crew;
      const d = dist(m.x, m.y, target.x, target.y);
      const aspect = Math.abs(wrap(Math.atan2(target.y - m.y, target.x - m.x) - target.hdg));
      const beam = clamp(1 - Math.abs(aspect - Math.PI / 2) / ((K.beamWindowDeg * Math.PI) / 180));
      if (d < K.beamRangeKm && beam > 0 && Math.random() < K.beamBreakPerSec * beam * dt) {
        m.guided = false;
        w.events.push({ t: 'sys', what: 'beamLost', x: m.x, y: m.y });
        radio(w, 'NAVIGATOR', 'It lost us — you beamed it! It\'s going ballistic!', false);
      }
    }
    if (m.guided && !m.seduced && target && target.alive && !target.done) {
      // Lead pursuit
      const tt = dist(m.x, m.y, target.x, target.y) / m.spd;
      tx = target.x + Math.cos(target.hdg) * target.spd * tt * 0.7 + m.missX;
      ty = target.y + Math.sin(target.hdg) * target.spd * tt * 0.7 + m.missY;
      talt = target.alt;
      m.tx = tx; m.ty = ty;
    } else {
      talt = Math.max(0.5, m.alt);
    }
  } else if (m.kind === 'aim9' || m.kind === 'aim7' || m.kind === 'r3s') {
    if (target && target.alive && !target.done) {
      tx = target.x + Math.cos(target.hdg) * target.spd * 0.4;
      ty = target.y + Math.sin(target.hdg) * target.spd * 0.4;
      talt = target.alt;
      if (m.kind === 'aim7' && chaffNear(w, target.x, target.y, target.alt) && Math.random() < dt) m.targetId = -1;
    } else talt = m.alt;
  } else if (m.kind === 'ksr11') {
    const site = m.site;
    if (site && site.emitting) { m.tx = site.st.x; m.ty = site.st.y; }
    else if (site && !m.seduced) { m.seduced = true; m.tx += gauss() * 1.2; m.ty += gauss() * 1.2; }
    tx = m.tx; ty = m.ty; talt = 0;
  } else {
    tx = m.tx; ty = m.ty; talt = 0;
  }
  const want = Math.atan2(ty - m.y, tx - m.x);
  const off = wrap(want - m.hdg);
  const air = m.kind !== 'ksr11' && m.kind !== 'standoff';
  // An air-to-air or SAM round that has lost its guidance, or overshot, flies on ballistic.
  if (air && Math.abs(off) > 1.75) m.guided = false;
  const steer = !air || m.seduced || (m.kind === 'nike' || m.kind === 'bloodhound' ? m.guided : !!target?.alive && m.guided);
  const turn = m.turn * (m.kind === 'nike' && m.age < 2 ? 0.3 : 1);
  if (steer) m.hdg = wrap(m.hdg + clamp(off, -turn * dt, turn * dt));
  m.x += Math.cos(m.hdg) * m.spd * dt;
  m.y += Math.sin(m.hdg) * m.spd * dt;
  const climb = m.kind === 'nike' || m.kind === 'bloodhound' ? 1.6 : 1.2;
  m.alt += clamp(talt - m.alt, -climb * dt * 2, climb * dt);
  // Ground attack weapons.
  if (m.kind === 'ksr11' || m.kind === 'standoff') {
    const dd = dist(m.x, m.y, m.tx, m.ty);
    if (dd < m.spd * dt * 1.5 || m.life <= 0) {
      m.alive = false;
      const munition = m.munition ?? 'KSR-11';
      const kg = CONFIG.munitions[munition].kg;
      if (m.kind === 'ksr11' && m.site && dd < 0.6 && !m.seduced) {
        m.site.st.radar = 0;
        m.site.st.launchers = clamp(m.site.st.launchers - 0.5);
        if (!m.site.st.isDecoy) {
          m.site.st.disabledUntil = w.s.hour + CONFIG.sam.radarRepairDays * 24;
          m.site.state = 'dead';
          w.samSitesHit.add(m.site.st.id);
          radio(w, m.owner?.callsign ?? 'WEASEL', `Emitter ${m.site.st.id} is off the air. Good kill.`, false);
        } else {
          m.site.st.revealed = true;
          radio(w, m.owner?.callsign ?? 'WEASEL', `Emitter ${m.site.st.id} was a dummy transmitter. Wasted a Kelt.`, false);
        }
      }
      if (m.kind === 'standoff') w.guidedHits++;
      impact(w, m.x, m.y, kg * 0.6, munition, m.flightId, !!m.owner?.isPlayer, m.aimKey, m.owner);
    }
    return;
  }
  // Crews see the smoke trail coming: break, dive and pop chaff.
  if (target && target.alive && !target.done && m.side === 'blue' && !m.evaded) {
    const dd = dist(m.x, m.y, target.x, target.y);
    if (dd < 6 + target.xp * 3) {
      m.evaded = true;
      // AI pilots break; the player's own crew only works the chaff dispenser.
      if (!target.isPlayer) {
        target.evadeUntil = w.t + 3;
        target.evadeDir = wrap(m.hdg + (Math.random() < 0.5 ? 1 : -1) * Math.PI / 2);
      } else if (target.chaff > 0) radio(w, 'NAVIGATOR', 'Chaff, chaff! Break!', true, 'crewchaff', 3);
      if (target.chaff > 0 && Math.random() < 0.4 + target.xp * 0.5) {
        target.chaff--;
        w.chaff.push({ x: target.x, y: target.y, alt: target.alt, r: CONFIG.sam.chaffRadius, life: CONFIG.sam.chaffCloudLife, max: CONFIG.sam.chaffCloudLife });
        w.events.push({ t: 'chaff', x: target.x, y: target.y, alt: target.alt, player: false });
      }
    }
  }
  // Air targets: proximity fuze.
  if (target && target.alive && !target.done) {
    const d3 = Math.hypot(m.x - target.x, m.y - target.y, (m.alt - target.alt) * 0.6);
    if (d3 < m.minD) m.minD = d3;
    else if (m.minD < m.lethal * 3 && d3 > m.minD + 0.05) {
      detonate(w, m, target, m.minD);
      return;
    }
    if (d3 < m.lethal * 0.4) {
      detonate(w, m, target, d3);
      return;
    }
  }
  // Seduced missiles burst in the chaff.
  if (m.seduced && dist(m.x, m.y, m.tx, m.ty) < 0.5) {
    detonate(w, m, null, 9);
    return;
  }
  if (m.life <= 0) detonate(w, m, null, 9);
}

function detonate(w: World, m: Missile, target: Plane | null, d: number): void {
  m.alive = false;
  if (m.site) m.site.inFlight = Math.max(0, m.site.inFlight - 1);
  const nearPlayer = w.player ? Math.hypot(m.x - w.player.x, m.y - w.player.y, (m.alt - w.player.alt) * 0.6) : 99;
  w.events.push({ t: 'missileDet', x: m.x, y: m.y, alt: m.alt, near: nearPlayer, kind: m.kind });
  // Fragments hit everything in the ring.
  for (const p of w.planes) {
    if (!p.alive || p.done || p.side === m.side || p.state === 'wait') continue;
    const dd = Math.hypot(m.x - p.x, m.y - p.y, (m.alt - p.alt) * 0.6);
    if (dd > m.lethal) continue;
    const fuze = Math.random() < m.pk + (p === target ? 0.35 : 0) - p.xp * 0.1;
    if (!fuze && p === target && d > m.lethal * 0.3) continue;
    damagePlane(w, p, m.dmg * (1 - dd / m.lethal) * (0.6 + Math.random() * 0.8), m.kind);
  }
}

// ── AAA ────────────────────────────────────────────────────────────────────
function aaaStep(w: World, dt: number): void {
  const A = CONFIG.aaa;
  const staff = 0.5 + 0.5 * Math.min(1, w.s.city.morale + w.s.city.resolve * 0.4);
  const radarPower = samPower(w.s);
  if (!w.detected) return;
  barrage(w, dt);
  for (const g of w.guns) {
    if (!g.alive) continue;
    if (g.suppressed > 0) { g.suppressed -= dt; continue; }
    g.cool -= dt;
    if (g.cool > 0) continue;
    const spec = A[g.kind];
    g.cool = (1 / spec.rof) * (0.7 + Math.random() * 0.6) / staff;
    let t = g.targetId >= 0 ? planeById(w, g.targetId) : undefined;
    const valid = (p: Plane) => p.alive && !p.done && p.side === 'red' && p.state !== 'wait' && p.alt <= spec.maxAlt && p.alt >= spec.minAlt && dist(g.x, g.y, p.x, p.y) < spec.range;
    if (!t || !valid(t)) {
      t = undefined;
      let bd = spec.range;
      for (const p of w.planes) {
        if (!valid(p)) continue;
        const d = dist(g.x, g.y, p.x, p.y);
        if (d < bd) { bd = d; t = p; }
      }
      if (t) { g.targetId = t.id; g.err = spec.err0; g.lastHdg = t.hdg; }
      else {
        g.targetId = -1;
        // Barrage fire into the sky over the target area at night / without radar.
        if (g.kind === 'heavy' && Math.random() < 0.15 && w.planes.some((p) => p.side === 'red' && p.alive && !p.done && dist(g.x, g.y, p.x, p.y) < spec.range * 1.8)) {
          w.events.push({ t: 'flak', x: g.x + gauss() * 3, y: g.y + gauss() * 3, alt: 2 + Math.random() * 7, kind: g.kind, near: 99 });
        }
        continue;
      }
    }
    // Walk the bursts onto a steady target; jinking resets the solution.
    const turned = Math.abs(wrap(t.hdg - g.lastHdg));
    g.lastHdg = t.hdg;
    const radarAimed = g.kind === 'heavy' ? radarPower : 1;
    let acc = (w.light < 0.3 ? A.nightPenalty : 1) * (t.searchlit > 0 ? A.searchlightBonus : 1) * radarAimed;
    acc *= t.alt < 0.6 && g.kind === 'heavy' ? 0.3 : 1;
    if (turned > 0.08 || w.t < t.jinkUntil) g.err = Math.min(spec.err0 * 1.3, g.err * 1.25 + 0.05);
    else g.err = Math.max(spec.errMin, g.err * spec.walk);
    const err = g.err / Math.max(0.2, acc);
    const d = dist(g.x, g.y, t.x, t.y);
    const tof = d / 1.4;
    const bx = t.x + Math.cos(t.hdg) * t.spd * tof * 0.5 + gauss() * err;
    const by = t.y + Math.sin(t.hdg) * t.spd * tof * 0.5 + gauss() * err;
    const balt = t.alt + gauss() * err * 0.5;
    if (g.kind === 'heavy') {
      const near = w.player ? Math.hypot(bx - w.player.x, by - w.player.y, (balt - w.player.alt) * 0.7) : 99;
      w.events.push({ t: 'flak', x: bx, y: by, alt: balt, kind: g.kind, near });
      for (const p of w.planes) {
        if (!p.alive || p.done || p.side !== 'red') continue;
        const dd = Math.hypot(bx - p.x, by - p.y, (balt - p.alt) * 0.7);
        if (dd < spec.burstR) damagePlane(w, p, spec.dmg * (1 - dd / spec.burstR), 'flak90');
      }
    } else {
      w.events.push({ t: 'tracer', x: g.x, y: g.y, tx: bx, ty: by, alt: balt, onPlayer: t.isPlayer, gun: g.kind });
      if (Math.random() < 0.35) {
        const near = w.player ? Math.hypot(bx - w.player.x, by - w.player.y) : 99;
        w.events.push({ t: 'flak', x: bx, y: by, alt: balt, kind: g.kind, near });
      }
      const dd = Math.hypot(bx - t.x, by - t.y);
      if (dd < spec.burstR) damagePlane(w, t, spec.dmg * (0.5 + Math.random()), g.kind);
    }
  }
}

/** Unaimed tracer curtains thrown up over the city once the raid is overhead. */
function barrage(w: World, dt: number): void {
  const near = w.planes.some((p) => p.side === 'red' && p.alive && !p.done && p.state !== 'wait' && Math.hypot(p.x + 2, p.y) < 28);
  if (!near) return;
  const M = CONFIG.mission;
  const alive = w.guns.filter((g) => g.alive).length / Math.max(1, w.guns.length);
  const rate = (w.light < 0.35 ? M.barragePerSecNight : M.barragePerSecDay) * (0.4 + alive) * (1 + 0.15 * (w.s.city.adaptLevels.aaaDensity ?? 0));
  let n = rate * dt;
  while (n > 0) {
    if (Math.random() > n) break;
    n -= 1;
    const a = Math.random() * TAU;
    const r = Math.sqrt(Math.random()) * 15;
    const x = Math.cos(a) * r - 2;
    const y = Math.sin(a) * r * 0.8;
    const d = Math.random() * TAU;
    const len = 1.5 + Math.random() * 4;
    w.events.push({ t: 'barrage', x, y, tx: x + Math.cos(d) * len, ty: y + Math.sin(d) * len, alt: 1.5 + Math.random() * 6, hue: Math.floor(Math.random() * 3) });
    // Blind fire still kills now and then.
    const p = w.player;
    if (p && p.alive && !p.done && Math.random() < 0.08 && dist(x, y, p.x, p.y) < 6 && p.alt < 7) {
      const bx = p.x + gauss() * 0.9;
      const by = p.y + gauss() * 0.9;
      const near2 = Math.hypot(bx - p.x, by - p.y);
      w.events.push({ t: 'flak', x: bx, y: by, alt: p.alt + gauss() * 0.3, kind: 'bofors', near: near2 });
      if (near2 < 0.2) damagePlane(w, p, 6, 'barrage');
    }
  }
}

function searchlightStep(w: World, dt: number): void {
  for (const p of w.planes) p.searchlit = Math.max(0, p.searchlit - dt);
  for (const l of w.lights) {
    if (l.targetId >= 0) {
      const t = planeById(w, l.targetId);
      l.hold -= dt;
      if (!t || !t.alive || t.done || l.hold <= 0 || dist(l.x, l.y, t.x, t.y) > l.len + 1) { l.targetId = -1; continue; }
      l.ang = Math.atan2(t.y - l.y, t.x - l.x);
      l.len = dist(l.x, l.y, t.x, t.y);
      t.searchlit = 0.6;
      continue;
    }
    l.ang += l.speed * dt;
    l.len = 6 + Math.sin(w.t * 0.3 + l.x) * 2;
    const ex = l.x + Math.cos(l.ang) * l.len;
    const ey = l.y + Math.sin(l.ang) * l.len;
    for (const p of w.planes) {
      if (!p.alive || p.done || p.side !== 'red' || p.state === 'wait' || p.alt > 9) continue;
      if (dist(ex, ey, p.x, p.y) < 0.9) {
        l.targetId = p.id;
        l.hold = 4 + Math.random() * 5;
        if (p.isPlayer) radio(w, p.callsign, 'Coned! Searchlights have us!', true, 'coned', 8);
      }
    }
  }
}

// ── Detection & scramble ───────────────────────────────────────────────────
function detection(w: World): void {
  if (w.detected) return;
  for (const p of w.planes) {
    if (p.side !== 'red' || !p.alive || p.state === 'wait') continue;
    for (const pk of w.pickets) {
      if (!pk.alive) continue;
      const rx = pk.x;
      const ry = pk.y;
      let r = 55;
      if (p.alt - terrainHeight(p.x, p.y) < CONFIG.sam.lowAltBelow) r *= CONFIG.sam.lowAltDetectFactor + 0.12 * (w.s.city.adaptLevels.lowAltRadar ?? 0);
      r *= 1 - jammedAt(w, p.x, p.y, rx, ry) * 0.4;
      if (dist(p.x, p.y, rx, ry) < r) {
        w.detected = true;
        w.detectT = w.t;
        radio(w, 'CONTROL', 'Picket radars are painting us. They know we are coming. Picture: fighters scrambling west of the city.', true);
        return;
      }
    }
    if (Math.hypot(p.x, p.y) < 45) {
      w.detected = true;
      w.detectT = w.t;
      radio(w, 'CONTROL', 'Ground radars active. The city is awake.', false);
      return;
    }
  }
}

function scramble(w: World): void {
  if (w.scrambled || !w.detected || w.t - w.detectT < CONFIG.interceptors.scrambleDelaySec) return;
  w.scrambled = true;
  const s = w.s;
  const raid = w.planes.filter((p) => p.side === 'red' && p.alive && p.kind !== 'drone').length;
  const base = s.districts.airbase.integrity;
  const fuel = s.city.stock.fuel > 0 ? 1 : 0.4;
  const nightF = w.light < 0.3 ? CONFIG.interceptors.nightEffectiveness + 0.1 * (s.city.adaptLevels.nightNet ?? 0) : 1;
  let n = Math.round(raid * CONFIG.interceptors.scramblePerRaidSize * base * fuel * nightF) + 1;
  const kinds: InterceptorType[] = ['F-4', 'F-104G', 'Lightning'];
  let launched = 0;
  for (let i = 0; n > 0 && i < 40; i++) {
    const k = kinds[i % 3];
    if (s.city.interceptors[k] <= 0) continue;
    makePlane(w, { kind: k, side: 'blue', x: -33 + Math.random() * 2, y: 6 + Math.random(), alt: 1, tAlt: 6, hdg: 0, state: 'engage' });
    n--;
    launched++;
  }
  if (launched) radio(w, 'CONTROL', `Lindenfeld is scrambling — ${launched} interceptors airborne.`, true);
}

// ── Main step ──────────────────────────────────────────────────────────────
export function step(w: World, dt: number): void {
  if (w.ended) return;
  w.t += dt;
  detection(w);
  scramble(w);
  for (const p of w.planes) {
    if (!p.alive || p.done) continue;
    if (p.state === 'wait') {
      if (w.t >= p.spawnAt) p.state = 'fly';
      else continue;
    }
    p.tracked += dt;
    p.fuelT += dt;
    if (p.side === 'red') {
      if (p.isPlayer) playerControl(w, p, dt);
      else redAI(w, p, dt);
    } else blueAI(w, p, dt);
    // Kinematics.
    // Roll into turns: bank follows the turn rate, rolls out when straight.
    if (dt > 0) {
      const rate = wrap(p.hdg - p.lastHdg) / dt;
      const want = clamp(rate / (p.turnRate * 0.9 || 1), -1, 1);
      p.bank += (want - p.bank) * Math.min(1, dt * 2.5);
      p.lastHdg = p.hdg;
    }
    p.x += Math.cos(p.hdg) * p.spd * dt;
    p.y += Math.sin(p.hdg) * p.spd * dt;
    if (!p.isPlayer) p.tAlt = Math.max(p.tAlt, terrainHeight(p.x + Math.cos(p.hdg) * 3, p.y + Math.sin(p.hdg) * 3) + 0.12);
    const climb = p.kind === 'drone' ? 0.4 : (CONFIG.aircraft[p.kind as AircraftType]?.climb ?? 0.8);
    p.alt += clamp(p.tAlt - p.alt, -climb * 2 * dt, climb * dt);
    if (p.engineFire > 0) {
      damagePlane(w, p, p.engineFire * 1.4 * dt, 'fire');
      if (!p.isPlayer) {
        p.engineFire = Math.max(0, p.engineFire - dt * 0.03);
        if (Math.random() < dt * 0.08) p.engineFire = 0; // the AI crew's extinguisher bottle
      }
    }
    if (p.side === 'red' && !p.crossedBarrier && p.x < BARRIER_X) {
      p.crossedBarrier = true;
      if (p.flight && !p.flight.calledFeetDry && p.slot === 0) {
        p.flight.calledFeetDry = true;
        if (p.flight.plan.id === w.plan.playerFlight || Math.random() < 0.3) radio(w, p.callsign, 'Feet dry.', false);
      }
    }
    if (p.x < CONFIG.mission.worldMinX - 10 || p.y < CONFIG.mission.worldMinY - 20 || p.y > CONFIG.mission.worldMaxY + 20) p.done = true;
    if (p.side === 'red' && p.x > CONFIG.mission.egressX && p.crossedBarrier && (p.isPlayer || p.wp > 2 || p.state === 'egress')) {
      if (!p.done && p.isPlayer) radio(w, p.callsign, 'Crossing the line. Heading home.', false);
      p.done = true;
    }
  }
  for (const site of w.sites) samStep(w, site, dt);
  for (const m of w.missiles) if (m.alive) missileStep(w, m, dt);
  aaaStep(w, dt);
  searchlightStep(w, dt);
  // Bombs fall.
  for (const b of w.bombs) {
    if (!b.alive) continue;
    b.t += dt;
    if (b.guided) {
      const owner = b.owner;
      const steering = owner && owner.alive && !owner.done && dist(owner.x, owner.y, b.x, b.y) < 14;
      const remain = Math.max(0.05, b.ft - b.t);
      if (steering) {
        b.vx += ((b.steerX - b.x) / remain - b.vx) * Math.min(1, dt * 2);
        b.vy += ((b.steerY - b.y) / remain - b.vy) * Math.min(1, dt * 2);
      }
    }
    b.x += b.vx * dt;
    b.y += b.vy * dt;
    // A higher wave's bombs falling through your formation.
    const pl = w.player;
    if (pl && pl.alive && !pl.done && b.owner && b.owner.flight !== pl.flight) {
      const f = b.t / b.ft;
      const balt = b.alt0 * (1 - f * f);
      if (Math.abs(balt - pl.alt) < 0.12 && dist(b.x, b.y, pl.x, pl.y) < 0.09) {
        damagePlane(w, pl, 35, 'friendly bomb');
        radio(w, 'TAIL GUNNER', 'We were hit by a bomb from above! From our own wave!', true, 'fbomb', 5);
        b.alive = false;
        continue;
      }
    }
    if (b.t >= b.ft) {
      b.alive = false;
      if (b.guided) {
        const h = hitTest(w, b.x, b.y, 0.2);
        if (h.key && b.aimKey && (h.key === `d:${b.aimKey}` || h.key === b.aimKey)) w.guidedHits++;
        if (w.guidedBomb === b) w.guidedBomb = null;
      }
      impact(w, b.x, b.y, b.kg, b.munition, b.flightId, b.player, b.aimKey, b.owner);
    }
  }
  w.bombs = w.bombs.filter((b) => b.alive);
  w.missiles = w.missiles.filter((m) => m.alive);
  for (const mk of w.markers) mk.life -= dt;
  w.markers = w.markers.filter((mk) => mk.life > 0);
  crewStep(w);
  for (const c of w.chaff) {
    c.life -= dt;
    c.r += dt * 0.04;
    c.alt -= dt * 0.05;
  }
  w.chaff = w.chaff.filter((c) => c.life > 0);
  stepFire(w, dt);
  // Wingman silence.
  for (let i = w.silence.length - 1; i >= 0; i--) {
    const ps = w.silence[i];
    if (w.t >= ps.at) {
      w.silence.splice(i, 1);
      radio(w, ps.from, `${ps.to}, report. ${ps.to}… come in.`, false);
    }
  }
  w.bandits = 0;
  // End condition.
  const reds = w.planes.filter((p) => p.side === 'red' && p.kind !== 'drone');
  if (reds.every((p) => !p.alive || p.done) && !w.bombs.length && !w.missiles.some((m) => m.side === 'red')) w.ended = true;
  if (w.t > CONFIG.mission.maxSeconds) w.ended = true;
}

/** Player-side bookkeeping: beam advice, terrain clearance, marker following. */
function crewStep(w: World): void {
  const p = w.player;
  const cr = w.crew;
  cr.beam = 0;
  cr.beamTurn = 0;
  if (p && p.alive && !p.done) {
    // The navigator calls the turn that puts the closest missile on the beam.
    let best = 99;
    for (const m of w.missiles) {
      if (m.side !== 'blue' || m.targetId !== p.id || !m.guided) continue;
      const d = dist(m.x, m.y, p.x, p.y);
      if (d > best) continue;
      best = d;
      const rel = wrap(Math.atan2(m.y - p.y, m.x - p.x) - p.hdg);
      const a = wrap(rel - Math.PI / 2);
      const b = wrap(rel + Math.PI / 2);
      const pick = Math.abs(a) < Math.abs(b) ? a : b;
      const aspect = Math.abs(wrap(Math.atan2(p.y - m.y, p.x - m.x) - p.hdg));
      cr.beam = clamp(1 - Math.abs(aspect - Math.PI / 2) / ((CONFIG.mission.crew.beamWindowDeg * Math.PI) / 180));
      cr.beamTurn = Math.abs(pick) < 0.12 ? 0 : Math.sign(pick);
    }
    // Terrain.
    const ground = terrainHeight(p.x, p.y);
    const ahead = terrainHeight(p.x + Math.cos(p.hdg) * p.spd * 6, p.y + Math.sin(p.hdg) * p.spd * 6);
    cr.agl = p.alt - ground;
    cr.pullUp = cr.agl < CONFIG.mission.crew.pullUpAglKm && (ahead > ground + 0.02 || cr.agl < 0.08);
    if (cr.agl < CONFIG.mission.crew.crashAglKm) {
      w.events.push({ t: 'sys', what: 'crash', x: p.x, y: p.y });
      radio(w, p.flight?.planes.find((q) => q !== p && q.alive)?.callsign ?? 'CONTROL', `${p.callsign} flew into the ground!`, true);
      p.chuteCount = 0;
      damagePlane(w, p, 9999, 'terrain');
    }
  }
  // Following flights bomb on the pathfinder's marker.
  if (w.markers.length) {
    for (const fr of w.flights) {
      if (fr.released || fr.plan.id === w.plan.playerFlight || fr.plan.role !== 'strike') continue;
      const mk = w.markers.find((q) => dist(q.x, q.y, fr.aimX, fr.aimY) < CONFIG.mission.crew.markerPullKm);
      if (!mk || (fr.aimX === mk.x && fr.aimY === mk.y)) continue;
      fr.aimX = mk.x;
      fr.aimY = mk.y;
      const k = ROUTES[w.plan.route].ingress.length;
      for (const q of fr.planes) q.path[k] = [mk.x, mk.y];
      radio(w, fr.planes[0]?.callsign ?? fr.plan.callsign, 'Marker in sight — bombing on the marker.', false, `mk${fr.plan.id}`, 30);
    }
  }
}

/** Run the rest of the raid without rendering. */
export function autoResolve(w: World, maxSteps = 20000): void {
  w.autoResolved = true;
  for (let i = 0; i < maxSteps && !w.ended; i++) {
    if (w.player?.alive && !w.player.done) {
      // The AI takes the controls.
      w.player.isPlayer = false;
      w.player.isWingmanOfPlayer = false;
    }
    step(w, 0.1);
    w.events.length = 0;
  }
}

export function finishWorld(w: World): MissionResult {
  settleFire(w);
  const outcomes: AirframeOutcome[] = [];
  for (const p of w.planes) {
    if (p.side !== 'red' || p.kind === 'drone' || p.airframeId < 0) continue;
    let fate = p.fate;
    const dmg = clamp(1 - p.hp / p.maxHp);
    if (p.alive && dmg > 0.8 && !p.isPlayer && Math.random() < 0.35) fate = 'crashlanded';
    if (p.isPlayer && p.alive && p.fate !== 'ok') fate = p.fate;
    outcomes.push({ airframeId: p.airframeId, damage: dmg, fate: p.alive ? fate : p.fate, bombsOnTarget: p.bombsOnTarget });
  }
  const pl = w.planes.find((p) => p.airframeId >= 0 && p.flight?.plan.id === w.plan.playerFlight && p.slot === 0);
  return {
    impacts: w.impacts, outcomes, interceptorsKilled: w.interceptorsKilled, samLaunches: w.samLaunches, samSitesHit: [...w.samSitesHit],
    guidedHits: w.guidedHits, playerFate: pl ? (pl.alive ? pl.fate : pl.fate) : 'none', durationSec: w.t, usedJamming: w.usedJamming,
    firestorms: w.firestorms, dronesUsed: w.dronesUsed, presUsed: 0, autoResolved: w.autoResolved, destructionBefore: w.destructionBefore,
  };
}

export function samRangeFor(kind: 'nike' | 'bloodhound'): number {
  return CONFIG.sam[kind].range;
}