Back to Big Rig Rush
SOURCE / PINNED RELEASE

Made of little things.

Big Rig Rush

Release
ec9b6549f752…
Author-recorded commit
74b729258b1a…
License
LICENSE
Author’s source reference
nostr://npub1sj2vketepqw9v6fmge5fdzuj7atj9sv6qv2jss05n2z6rl64lcfqumdsnt/wss%3A%2F%2Fgit.napplet.soy%2F/n-0a99f0cae40

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

src/game/civil.ts
import { FLIGHT_ALT } from './a10.js';
import type { Lift } from './airstrike.js';
import { LOOK, type City, type District, type Station, type Structure } from './city.js';
import type { Effects } from './effects.js';
import type { InfraSpec } from './expansion.js';
import type { Vec } from './geometry.js';
import type { Pedestrians } from './peds.js';
import type { ServiceKind, Services } from './services.js';
import { inView, type View } from './traffic.js';
import { drawHelicopter } from './vehicles.js';

/**
 * The city fighting to stay alive. It runs like a real emergency organisation:
 *
 *  - Utilities: power stations feed a substation in every district; when generating
 *    capacity falls short it sheds load, keeping hospitals and City Hall lit first.
 *    Waterworks need power to pump, and the water towers drain without them, so hydrant
 *    pressure falls and fires burn longer (as after the Napa and Loma Prieta quakes).
 *    The masts and City Hall's emergency operations centre set how quickly the city
 *    can coordinate anything at all.
 *  - People: every district has its population; collapsing buildings kill and trap the
 *    people inside, search-and-rescue teams dig the trapped out for the ambulances, and
 *    districts under attack are evacuated to the shelters. Life safety first.
 *  - Escalation: as the destruction mounts the alert goes up, the air-raid sirens wail,
 *    a state of emergency brings in mutual aid from the county, water-bombing
 *    helicopters fly from the reservoir and the bay, and the National Guard rolls out
 *    mobile air defences.
 *  - Air defence: anti-aircraft guns and surface-to-air missiles round the base and the
 *    vital sites, weapons free once the city knows it's under attack. Flares decoy the
 *    missiles; the guns just have to be survived.
 */

export interface CivilWorld {
  hour(): number;
  /** Structures on fire. */
  burning(): Structure[];
  douse(x: number, y: number, r: number): void;
  toast(text: string, tone: 'good' | 'info' | 'bad'): void;
  /** The A-10, if one's up there (position in map units, velocity in units/s). */
  jet(): { x: number; y: number; vx: number; vy: number } | null;
  /** Hits on the jet (damage points). */
  hitJet(damage: number): void;
  /** Explosions in the air or on the ground that the game should play out. */
  explode(x: number, y: number, size: number, air: boolean): void;
  sound(kind: 'siren' | 'launch' | 'flak' | 'guns' | 'heli', x: number, y: number): void;
  /** Score the player for something that matters. */
  score(points: number, x: number, y: number, label: string): void;
}

interface InfraState extends InfraSpec {
  district: District;
  /** Water towers: how full (0..1). */
  level: number;
  online: boolean;
}

interface Rescue {
  x: number;
  y: number;
  trapped: number;
  born: number;
  st: Structure;
}

interface AASite {
  x: number;
  y: number;
  kind: 'gun' | 'sam';
  hp: number;
  cooldown: number;
  burst: number;
  angle: number;
  mobile: boolean;
}

interface AARound {
  x0: number;
  y0: number;
  x1: number;
  y1: number;
  t: number;
  dur: number;
}

interface SAM {
  x: number;
  y: number;
  z: number;
  angle: number;
  speed: number;
  age: number;
  /** Chasing a flare instead of the jet. */
  flare: Flare | null;
  trail: number;
}

interface Flare {
  x: number;
  y: number;
  vx: number;
  vy: number;
  life: number;
}

interface Bomber {
  x: number;
  y: number;
  z: number;
  vx: number;
  vy: number;
  angle: number;
  rotor: number;
  water: number;
  state: 'base' | 'fill' | 'fight' | 'home';
  timer: number;
  target: Vec | null;
  base: Vec;
}

/** Everyone who lives (or works) in each district. */
const POPULATION: Record<District, number> = {
  downtown: 42000,
  uptown: 12000,
  hood: 26000,
  port: 3000,
  village: 5000,
  industrial: 4000,
  country: 2500,
  docks: 4500,
  factory: 6000,
  rail: 1500,
  suburb: 38000,
  commercial: 9000,
  military: 7000,
  rural: 3500,
  town: 6500,
  highway: 0,
};

/** People per 100 square units of floor, by the look of the district. */
const DENSITY: Partial<Record<District, number>> = { downtown: 0.9, hood: 0.8, uptown: 0.25, village: 0.4, country: 0.15, port: 0.25, industrial: 0.25, military: 0.5 };

const ALERTS = ['NORMAL', 'ELEVATED', 'EMERGENCY', 'DISASTER', 'CATASTROPHE'];
const WATER_SOURCES: Vec[] = [
  { x: 6300, y: 2600 },
  { x: 720, y: 860 },
  { x: 3300, y: 5300 },
  { x: 12000, y: 21300 },
];

const range = (a: number, b: number) => a + Math.random() * (b - a);

export class Civil {
  readonly infra: InfraState[];
  private readonly powered = new Map<District, boolean>();
  private readonly districts: District[] = Object.keys(POPULATION).filter((d) => d !== 'highway') as District[];
  readonly evac = new Map<District, number>();
  private readonly threat = new Map<District, number>();
  private readonly orderedEvac = new Set<District>();
  dead = 0;
  rescued = 0;
  injured = 0;
  sheltered = 0;
  alert = 0;
  private destroyedBuildings = 0;
  private infraLost = 0;
  coordination = 1;
  capacity = 1;
  private readonly rescues: Rescue[] = [];
  private readonly sites: AASite[] = [];
  private readonly rounds: AARound[] = [];
  private readonly sams: SAM[] = [];
  private readonly flares: Flare[] = [];
  flareCount = 30;
  private flareCooldown = 0;
  private readonly bombers: Bomber[] = [];
  private readonly breaks: { x: number; y: number; until: number }[] = [];
  private time = 0;
  private tick = 0;
  /** A missile is tracking the jet (for the HUD and the warning tone). */
  missileWarning = false;
  private aidWaves = 0;
  private lastAid = -999;
  private attacked = false;
  private readonly lostNames = new Set<string>();

  constructor(
    private readonly city: City,
    private readonly effects: Effects,
    private readonly services: Services,
    private readonly peds: Pedestrians,
    private readonly world: CivilWorld,
  ) {
    this.infra = city.infra.map((spec) => ({ ...spec, district: city.districtAt(spec.st.x, spec.st.y), level: 1, online: true }));
    this.reset();
  }

  reset(): void {
    for (const d of this.districts) {
      this.powered.set(d, true);
      this.evac.set(d, 0);
      this.threat.set(d, 0);
    }
    for (const i of this.infra) {
      i.level = 1;
      i.online = true;
    }
    this.orderedEvac.clear();
    this.lostNames.clear();
    this.dead = this.rescued = this.injured = this.sheltered = 0;
    this.alert = 0;
    this.destroyedBuildings = this.infraLost = 0;
    this.coordination = 1;
    this.capacity = 1;
    this.rescues.length = 0;
    this.rounds.length = 0;
    this.sams.length = 0;
    this.flares.length = 0;
    this.flareCount = 30;
    this.bombers.length = 0;
    this.breaks.length = 0;
    this.aidWaves = 0;
    this.attacked = false;
    this.missileWarning = false;
    this.sites.length = 0;
    // Fixed air defences: round the air base, and on the city's vital sites.
    const fixed: [number, number, 'gun' | 'sam'][] = [
      [10400, 13250, 'gun'],
      [14800, 13250, 'gun'],
      [12600, 15900, 'gun'],
      [11000, 15300, 'sam'],
      [14200, 15300, 'sam'],
      [14800, 1200, 'gun'],
      [2600, 2280, 'gun'],
      [9000, 2600, 'sam'],
      [3600, 900, 'gun'],
      [7500, 12000, 'sam'],
      [1800, 7600, 'gun'],
    ];
    for (const [x, y, kind] of fixed) this.sites.push({ x, y, kind, hp: kind === 'sam' ? 160 : 120, cooldown: range(0, 3), burst: 0, angle: 0, mobile: false });
  }

  // --- Queries for the rest of the game.

  /** Is there power at a spot? */
  hasPower(x: number, y: number): boolean {
    return this.powered.get(this.city.districtAt(x, y)) ?? true;
  }

  /** Hydrant pressure at a spot (0 dry .. 1 full). */
  pressure(x: number, y: number): number {
    let best = -1;
    for (const t of this.infra) {
      if (t.kind !== 'watertower') continue;
      const d = Math.hypot(t.st.x - x, t.st.y - y);
      if (d < 3500) best = Math.max(best, t.online ? t.level : 0);
    }
    let p = best < 0 ? (this.supply() > 0 ? 0.7 : 0.25) : best;
    // A burst main nearby: the pressure goes out of the hole in the road.
    for (const b of this.breaks) if (b.until > this.time && Math.hypot(b.x - x, b.y - y) < 320) p *= 0.3;
    return Math.max(0.05, p);
  }

  /** Waterworks pumping (online and powered). */
  private supply(): number {
    return this.infra.filter((w) => w.kind === 'waterworks' && w.online && this.hasPower(w.st.x, w.st.y)).length;
  }

  get alertName(): string {
    return ALERTS[this.alert];
  }

  /** A share of the city's population living with power. */
  powerShare(): number {
    let on = 0;
    let all = 0;
    for (const d of this.districts) {
      all += POPULATION[d];
      if (this.powered.get(d)) on += POPULATION[d];
    }
    return all ? on / all : 1;
  }

  waterShare(): number {
    const towers = this.infra.filter((t) => t.kind === 'watertower');
    if (!towers.length) return this.supply() > 0 ? 1 : 0.3;
    return towers.reduce((s, t) => s + (t.online ? t.level : 0), 0) / towers.length;
  }

  count(kind: InfraSpec['kind']): [number, number] {
    const all = this.infra.filter((i) => i.kind === kind);
    return [all.filter((i) => i.online).length, all.length];
  }

  evacuated(): number {
    let n = 0;
    for (const d of this.districts) n += POPULATION[d] * (this.evac.get(d) ?? 0);
    return Math.round(n);
  }

  trapped(): number {
    return this.rescues.reduce((s, r) => s + r.trapped, 0);
  }

  /** Collapse sites with people still under the rubble, for the rescue teams. */
  rescueSites(): Rescue[] {
    return this.rescues.filter((r) => r.trapped > 0);
  }

  /** A rescue team pulls people out at a site: they come out hurt, for the ambulances. */
  dig(site: Rescue): void {
    const n = Math.min(site.trapped, 1 + Math.floor(Math.random() * 3));
    site.trapped -= n;
    this.rescued += n;
    this.injured += n;
    for (let i = 0; i < Math.min(2, n); i++) this.peds.launch(site.x + range(-8, 8), site.y + range(-8, 8), range(-10, 10), range(-10, 10), '#8d877b', '#4a3222');
  }

  // --- Things happening to the city.

  /** Something came down. Who was inside, and was it something the city needs? */
  onCollapse(st: Structure): void {
    const d = st.district;
    const info = this.infra.find((i) => i.st === st);
    if (info) {
      info.online = false;
      this.infraLost++;
      if (!this.lostNames.has(info.name)) {
        this.lostNames.add(info.name);
        const effect =
          info.kind === 'power'
            ? `generation down ${Math.round((info.capacity ?? 0) * 100)}%`
            : info.kind === 'substation'
              ? 'district power cut'
              : info.kind === 'waterworks'
                ? 'water pumping lost'
                : info.kind === 'watertower'
                  ? 'water pressure falling'
                  : info.kind === 'comms'
                    ? 'radio and phones failing'
                    : info.kind === 'eoc'
                      ? 'the emergency command is gone'
                      : 'mass casualties';
        this.world.toast(`${info.name} destroyed: ${effect}`, 'bad');
        this.world.score(info.kind === 'shelter' ? 300 : 500, st.x, st.y, info.kind === 'power' ? 'POWER STATION!' : 'INFRASTRUCTURE!');
      }
      if (info.kind === 'substation' || info.kind === 'power') this.effects.arcs(st.x, st.y, info.kind === 'power' ? 30 : 18);
      if (info.kind === 'shelter') {
        const lost = Math.round(this.sheltered * range(0.05, 0.12));
        this.dead += lost;
        this.sheltered -= lost;
      }
    }
    this.attacked = true;
    this.bump(d, 1.2);
    if (!st.building && !st.station) return;
    this.destroyedBuildings++;
    // The people inside: fewer the more of the district has been evacuated.
    const look = LOOK[d];
    const residential = look === 'uptown' || look === 'hood' || look === 'village' || look === 'country';
    const h = this.world.hour();
    const night = h < 7 || h > 19;
    const occupancy = residential ? (night ? 1 : 0.45) : night ? 0.25 : 1;
    const people = ((st.w * st.d) / 100) * (DENSITY[look] ?? 0.3) * occupancy * (1 - (this.evac.get(d) ?? 0));
    const dead = Math.round(people * range(0.12, 0.3));
    const trapped = Math.round(people * range(0.15, 0.35));
    this.dead += dead;
    if (trapped > 0) this.rescues.push({ x: st.x, y: st.y, trapped, born: this.time, st });
    // Gas mains rupture in the ruins: a roaring jet of flame that burns for a while.
    if (Math.random() < (look === 'downtown' || look === 'hood' ? 0.3 : 0.15)) this.effects.gasFire(st.x + range(-st.w, st.w) * 0.3, st.y + range(-st.d, st.d) * 0.3, range(20, 45));
  }

  /** A blast somewhere: the district's alarm goes up; air defences in it take the damage. */
  onBlast(x: number, y: number, r: number, power: number, onRoad: boolean): void {
    this.attacked = true;
    this.bump(this.city.districtAt(x, y), 0.6 + power * 0.002);
    for (const s of this.sites) {
      if (s.hp <= 0) continue;
      const d = Math.hypot(s.x - x, s.y - y);
      if (d > r + 14) continue;
      s.hp -= power * (1 - d / (r + 14));
      if (s.hp <= 0) {
        this.world.explode(s.x, s.y, s.kind === 'sam' ? 2.4 : 1.6, false);
        this.world.score(250, s.x, s.y, 'AIR DEFENCE DOWN!');
      }
    }
    // A bomb in the road bursts the water main under it.
    if (onRoad && power > 300 && Math.random() < 0.6) {
      this.breaks.push({ x, y, until: this.time + 120 });
      this.effects.geyser(x + range(-6, 6), y + range(-6, 6), 60);
    }
  }

  private bump(d: District, amount: number): void {
    this.threat.set(d, (this.threat.get(d) ?? 0) + amount);
  }

  /** The pilot pops flares: every missile tracking the jet may go for one instead. */
  popFlares(): boolean {
    const jet = this.world.jet();
    if (!jet || this.flareCount <= 0 || this.flareCooldown > 0) return false;
    this.flareCount -= 2;
    this.flareCooldown = 0.8;
    for (const side of [-1, 1]) {
      const a = Math.atan2(jet.vy, jet.vx) + Math.PI + side * 0.6;
      this.flares.push({ x: jet.x, y: jet.y, vx: jet.vx * 0.4 + Math.cos(a) * 160, vy: jet.vy * 0.4 + Math.sin(a) * 160, life: 3.2 });
    }
    for (const m of this.sams) if (!m.flare && Math.random() < 0.8) m.flare = this.flares[this.flares.length - 1 - Math.floor(Math.random() * 2)];
    return true;
  }

  // --- The city's own turn.

  update(dt: number): void {
    this.time += dt;
    this.flareCooldown -= dt;
    this.tick -= dt;
    if (this.tick <= 0) {
      this.tick = 1;
      this.think();
    }
    this.defend(dt);
    this.fly(dt);
  }

  /** Once a second: utilities, evacuation, rescues, escalation. */
  private think(): void {
    for (const i of this.infra) i.online = !i.st.destroyed;
    // Coordination: the masts and City Hall.
    const [masts, mastsAll] = this.count('comms');
    const eoc = this.infra.find((i) => i.kind === 'eoc');
    this.coordination = Math.max(0.3, 0.35 + 0.35 * (mastsAll ? masts / mastsAll : 1) + (eoc && !eoc.online ? 0 : 0.3));

    // Power: share out what's still generating, essentials first.
    this.capacity = this.infra.filter((i) => i.kind === 'power' && i.online).reduce((s, i) => s + (i.capacity ?? 0), 0);
    const hasStation = (d: District, kind: Station['kind']) => this.city.stations.some((s) => s.kind === kind && s.district === d && !s.structure?.destroyed);
    const priority = (d: District) => (hasStation(d, 'hospital') ? 3 : 0) + (eoc && eoc.district === d ? 2 : 0) + (this.infra.some((w) => w.kind === 'waterworks' && w.district === d) ? 2 : 0) + POPULATION[d] / 50000;
    const total = this.districts.reduce((s, d) => s + POPULATION[d], 0);
    let left = this.capacity * total * 1.15;
    const before = new Map(this.powered);
    for (const d of [...this.districts].sort((a, b) => priority(b) - priority(a))) {
      const subs = this.infra.filter((i) => i.kind === 'substation' && i.district === d);
      const sub = !subs.length || subs.some((s) => s.online);
      const need = POPULATION[d];
      const on = sub && left >= need * 0.6;
      if (on) left -= need;
      this.powered.set(d, on);
    }
    const dark = this.districts.filter((d) => before.get(d) && !this.powered.get(d));
    if (dark.length) this.world.toast(`Blackout: ${dark.length > 2 ? `${dark.length} districts` : dark.join(' and ')}`, 'bad');

    // Water: towers fill while the works pump, and drain (faster when fighting fires) when they don't.
    const pumping = this.supply() > 0;
    const fires = this.world.burning();
    for (const t of this.infra) {
      if (t.kind !== 'watertower' || !t.online) continue;
      const nearFires = fires.filter((f) => Math.hypot(f.x - t.st.x, f.y - t.st.y) < 3500).length;
      t.level = Math.max(0, Math.min(1, t.level + (pumping ? 0.05 : 0) - 0.006 - nearFires * 0.0015));
    }

    // Threat cools off; districts that stay under attack are evacuated to the shelters.
    for (const d of this.districts) {
      const threat = (this.threat.get(d) ?? 0) * 0.97;
      this.threat.set(d, threat);
      const e = this.evac.get(d) ?? 0;
      if (threat > 3 && e < 0.92 && this.alert >= 1) {
        if (!this.orderedEvac.has(d)) {
          this.orderedEvac.add(d);
          this.world.toast(`Evacuation ordered: ${d.toUpperCase()}. Buses to the shelters`, 'info');
        }
        const step = Math.min(0.92 - e, 0.012 * this.coordination);
        this.evac.set(d, e + step);
        this.sheltered += POPULATION[d] * step;
      }
    }

    // The trapped who aren't reached in time don't make it.
    for (let i = this.rescues.length - 1; i >= 0; i--) {
      const r = this.rescues[i];
      if (this.time - r.born > 240 || r.trapped <= 0) {
        this.dead += r.trapped;
        this.rescues.splice(i, 1);
      }
    }
    if (this.rescues.length > 40) this.rescues.splice(0, this.rescues.length - 40);

    // Escalation.
    const level = !this.attacked ? 0 : this.destroyedBuildings + this.infraLost * 6 + fires.length * 0.5 > 160 ? 4 : this.destroyedBuildings + this.infraLost * 6 > 70 ? 3 : this.destroyedBuildings + this.infraLost * 6 > 18 ? 2 : 1;
    if (level > this.alert) {
      this.alert = level;
      const jet = this.world.jet();
      this.world.sound('siren', jet?.x ?? 0, jet?.y ?? 0);
      this.world.toast(
        [
          '',
          'AIR RAID: sirens sounding, air defences weapons free',
          'STATE OF EMERGENCY: evacuations, mutual aid requested, water bombers launching',
          'DISASTER: National Guard deploying mobile air defence',
          'CATASTROPHE: federal emergency, every unit in the state on its way',
        ][level],
        'bad',
      );
      if (level >= 3 && jet) this.deployMobile(jet, level === 3 ? 2 : 3);
    }
    // Mutual aid: when the city's own units are run ragged, the county and state send more.
    if (this.alert >= 2 && this.time - this.lastAid > 50 && this.aidWaves < 2 + this.alert) {
      const stretched = this.services.available('fire') + this.services.available('ambulance') < 4;
      if (stretched || this.aidWaves === 0) {
        this.lastAid = this.time;
        this.aidWaves++;
        this.reinforce('fire', 2);
        this.reinforce('ambulance', 2);
        this.reinforce('rescue', 1);
        this.reinforce('police', 1);
        this.addBombers(this.aidWaves === 1 ? 2 : 1);
        this.world.toast(`Mutual aid wave ${this.aidWaves}: engines, ambulances and a rescue team from the county${this.aidWaves === 1 ? ', and water bombers' : ''}`, 'info');
      }
    }
  }

  /** More units at the city's surviving stations (or a staging area, if they're all gone). */
  private reinforce(kind: ServiceKind, n: number): void {
    const depots = this.services.depots.filter((d) => d.kind === kind && !d.station.structure?.destroyed);
    if (depots.length) {
      for (let i = 0; i < n; i++) depots[i % depots.length].units++;
      return;
    }
    const staging: Station = { kind: kind === 'ambulance' ? 'hospital' : kind === 'police' ? 'police' : 'fire', x: 5900, y: 7700, angle: 0, w: 40, d: 40, units: n, district: 'rail' };
    this.services.addDepot(staging, kind, n);
  }

  /** National Guard air-defence vehicles, set up round where the jet's been working. */
  private deployMobile(jet: Vec, n: number): void {
    for (let i = 0; i < n; i++) {
      const a = Math.random() * Math.PI * 2;
      const d = range(900, 1500);
      const x = Math.max(200, Math.min(16200, jet.x + Math.cos(a) * d));
      const y = Math.max(200, Math.min(29000, jet.y + Math.sin(a) * d));
      this.sites.push({ x, y, kind: i % 2 ? 'sam' : 'gun', hp: 90, cooldown: 3, burst: 0, angle: 0, mobile: true });
    }
  }

  private addBombers(n: number): void {
    for (let i = 0; i < n; i++) {
      const base = { x: 12600 + (this.bombers.length % 4) * 120, y: 13780 };
      this.bombers.push({ x: base.x, y: base.y, z: 0, vx: 0, vy: 0, angle: -Math.PI / 2, rotor: 0, water: 0, state: 'base', timer: 0, target: null, base });
    }
  }

  // --- Air defence.

  private defend(dt: number): void {
    const jet = this.world.jet();
    this.missileWarning = false;
    for (const s of this.sites) {
      if (s.hp <= 0 || !jet || this.alert < 1) continue;
      const dx = jet.x - s.x;
      const dy = jet.y - s.y;
      const d = Math.hypot(dx, dy);
      s.angle = Math.atan2(dy, dx);
      s.cooldown -= dt;
      if (s.kind === 'gun') {
        if (d > 1250) continue;
        if (s.burst > 0) {
          s.burst -= dt;
          // A stream of tracer, led on the jet, walking about it.
          if (Math.random() < dt * 14) {
            const flight = Math.hypot(d, FLIGHT_ALT) / 1700;
            const spread = 26 + d * 0.05;
            const ax = jet.x + jet.vx * flight + range(-spread, spread);
            const ay = jet.y + jet.vy * flight + range(-spread, spread);
            this.rounds.push({ x0: s.x, y0: s.y, x1: ax, y1: ay, t: 0, dur: flight });
            if (Math.random() < 0.08) this.world.sound('guns', s.x, s.y);
          }
        } else if (s.cooldown <= 0) {
          s.burst = range(1, 1.8);
          s.cooldown = range(2.2, 3.4);
        }
      } else if (d < 1900 && s.cooldown <= 0 && this.sams.length < 3) {
        s.cooldown = range(10, 15);
        this.sams.push({ x: s.x, y: s.y, z: 4, angle: s.angle, speed: 200, age: 0, flare: null, trail: 0 });
        this.world.sound('launch', s.x, s.y);
        this.world.toast('MISSILE LAUNCH! Flares: F', 'bad');
      }
    }
    // Flak and tracer: a hit if it bursts close to where the jet actually is.
    for (const r of this.rounds) {
      r.t += dt;
      if (r.t < r.dur || r.t - dt >= r.dur) continue;
      if (jet && Math.hypot(r.x1 - jet.x, r.y1 - jet.y) < 13) this.world.hitJet(range(1, 2));
      if (Math.random() < 0.18) this.effects.flak(r.x1, r.y1);
    }
    for (let i = this.rounds.length - 1; i >= 0; i--) if (this.rounds[i].t > this.rounds[i].dur + 0.05) this.rounds.splice(i, 1);
    // Missiles: boost, climb to the jet's height, and pull lead on it (or a flare).
    for (const m of this.sams) {
      m.age += dt;
      m.speed = Math.min(780, m.speed + 900 * dt);
      m.z = Math.min(FLIGHT_ALT, m.z + dt * 140);
      const target = m.flare ?? jet;
      if (target) {
        const tx = target.x + ('vx' in target ? target.vx * 0.15 : 0);
        const ty = target.y + ('vy' in target ? target.vy * 0.15 : 0);
        const want = Math.atan2(ty - m.y, tx - m.x);
        let da = want - m.angle;
        while (da > Math.PI) da -= Math.PI * 2;
        while (da < -Math.PI) da += Math.PI * 2;
        m.angle += Math.max(-2.6 * dt, Math.min(2.6 * dt, da));
      }
      m.x += Math.cos(m.angle) * m.speed * dt;
      m.y += Math.sin(m.angle) * m.speed * dt;
      m.trail -= dt;
      if (m.trail <= 0) {
        m.trail = 0.02;
        this.effects.missileTrail(m.x, m.y, m.z);
      }
      if (!m.flare && jet) this.missileWarning = true;
      if (m.flare && m.flare.life > 0 && Math.hypot(m.flare.x - m.x, m.flare.y - m.y) < 18) m.age = 99;
      if (!m.flare && jet && m.z >= FLIGHT_ALT * 0.8 && Math.hypot(jet.x - m.x, jet.y - m.y) < 22) {
        this.world.hitJet(range(24, 34));
        m.age = 99;
      }
    }
    for (let i = this.sams.length - 1; i >= 0; i--) {
      const m = this.sams[i];
      if (m.age > 7) {
        this.world.explode(m.x, m.y, 1, true);
        this.sams.splice(i, 1);
      }
    }
    for (const f of this.flares) {
      f.life -= dt;
      f.x += f.vx * dt;
      f.y += f.vy * dt;
      f.vx *= Math.exp(-dt * 1.2);
      f.vy *= Math.exp(-dt * 1.2);
    }
    for (let i = this.flares.length - 1; i >= 0; i--) if (this.flares[i].life <= 0) this.flares.splice(i, 1);
  }

  // --- Water bombers.

  private fly(dt: number): void {
    const fires = this.world.burning();
    for (const b of this.bombers) {
      b.rotor += dt * 30;
      const claimed = this.bombers.filter((o) => o !== b && o.target).map((o) => o.target!);
      if (b.state === 'base' || b.state === 'home') {
        // The biggest blaze no one else is on.
        if (this.alert >= 2 && fires.length) {
          let best: Vec | null = null;
          let score = 0;
          for (const f of fires) {
            if (claimed.some((c) => Math.hypot(c.x - f.x, c.y - f.y) < 300)) continue;
            const n = fires.filter((g) => Math.hypot(g.x - f.x, g.y - f.y) < 150).length;
            if (n > score) {
              score = n;
              best = { x: f.x, y: f.y };
            }
          }
          if (best) {
            b.target = best;
            b.state = b.water > 0 ? 'fight' : 'fill';
          }
        }
        if (b.state === 'home') {
          if (this.flyTo(b, b.base, dt) < 10) {
            b.state = 'base';
            b.target = null;
          }
        } else b.z = Math.max(0, b.z - dt * 12);
        continue;
      }
      b.z = Math.min(46, b.z + dt * 16);
      if (b.state === 'fill') {
        const src = WATER_SOURCES.reduce((p, q) => (Math.hypot(q.x - b.x, q.y - b.y) < Math.hypot(p.x - b.x, p.y - b.y) ? q : p));
        if (this.flyTo(b, src, dt) < 20) {
          b.timer += dt;
          b.z = Math.max(14, b.z - dt * 30);
          if (Math.random() < dt * 10) this.effects.water(b.x, b.y, b.x + range(-8, 8), b.y + range(-8, 8));
          if (b.timer > 2.5) {
            b.water = 1;
            b.timer = 0;
            b.state = 'fight';
          }
        }
      } else if (b.state === 'fight' && b.target) {
        if (this.flyTo(b, b.target, dt) < 25) {
          // Release over the fire: a falling sheet of water that knocks the flames down.
          for (let k = 0; k < 30; k++) this.effects.water(b.x, b.y, b.x + range(-50, 50), b.y + range(-50, 50));
          this.world.douse(b.target.x, b.target.y, 75);
          this.effects.extinguish(b.target.x, b.target.y, 75);
          b.water = 0;
          b.target = null;
          b.state = fires.length > 1 ? 'fill' : 'home';
          this.world.sound('heli', b.x, b.y);
        }
      }
    }
  }

  private flyTo(b: Bomber, p: Vec, dt: number): number {
    const dx = p.x - b.x;
    const dy = p.y - b.y;
    const d = Math.hypot(dx, dy);
    const want = Math.min(160, d * 0.8);
    const k = Math.min(1, dt * 1.2);
    b.vx += ((d > 0.1 ? (dx / d) * want : 0) - b.vx) * k;
    b.vy += ((d > 0.1 ? (dy / d) * want : 0) - b.vy) * k;
    b.x += b.vx * dt;
    b.y += b.vy * dt;
    if (Math.hypot(b.vx, b.vy) > 8) b.angle = Math.atan2(b.vy, b.vx);
    return d;
  }

  // --- Drawing.

  /** Air-defence sites and Guard vehicles on the ground. */
  drawGround(ctx: CanvasRenderingContext2D, view: View, time: number): void {
    for (const s of this.sites) {
      if (!inView(s.x, s.y, view, 40)) continue;
      const dead = s.hp <= 0;
      ctx.save();
      ctx.translate(s.x, s.y);
      // Sandbag ring (or the Guard truck).
      ctx.fillStyle = 'rgba(0, 0, 0, 0.3)';
      ctx.beginPath();
      ctx.arc(3, 4, 15, 0, Math.PI * 2);
      ctx.fill();
      ctx.fillStyle = dead ? '#3a3530' : s.mobile ? '#5a5f45' : '#b6a77d';
      ctx.beginPath();
      ctx.arc(0, 0, 15, 0, Math.PI * 2);
      ctx.fill();
      ctx.fillStyle = dead ? '#2b2b2e' : '#4b5320';
      ctx.beginPath();
      ctx.arc(0, 0, 9, 0, Math.PI * 2);
      ctx.fill();
      if (!dead) {
        ctx.rotate(s.angle);
        ctx.fillStyle = '#2b2b2e';
        if (s.kind === 'gun') {
          ctx.fillRect(4, -2.2, 16, 1.6);
          ctx.fillRect(4, 0.6, 16, 1.6);
          if (s.burst > 0 && Math.sin(time * 60) > 0) {
            ctx.fillStyle = '#ffd56a';
            ctx.beginPath();
            ctx.arc(21, 0, 3, 0, Math.PI * 2);
            ctx.fill();
          }
        } else {
          ctx.fillStyle = '#d5d8dc';
          for (const oy of [-4, 0, 4]) ctx.fillRect(2, oy - 1.2, 14, 2.4);
        }
      }
      ctx.restore();
    }
  }

  /** Tracer, flak, missiles, flares and the water bombers, up in the air. */
  drawAir(ctx: CanvasRenderingContext2D, view: View, lift: Lift, pixel: number, time: number): void {
    ctx.globalCompositeOperation = 'lighter';
    ctx.lineCap = 'round';
    for (const r of this.rounds) {
      const k1 = Math.min(1, r.t / r.dur);
      const k0 = Math.max(0, k1 - 0.25);
      const a = lift(r.x0 + (r.x1 - r.x0) * k0, r.y0 + (r.y1 - r.y0) * k0, FLIGHT_ALT * k0);
      const b = lift(r.x0 + (r.x1 - r.x0) * k1, r.y0 + (r.y1 - r.y0) * k1, FLIGHT_ALT * k1);
      if (!a || !b || !inView(b.x, b.y, view, 60)) continue;
      ctx.strokeStyle = 'rgba(255, 120, 60, 0.85)';
      ctx.lineWidth = 1.6 * pixel;
      ctx.beginPath();
      ctx.moveTo(a.x, a.y);
      ctx.lineTo(b.x, b.y);
      ctx.stroke();
    }
    for (const f of this.flares) {
      const at = lift(f.x, f.y, FLIGHT_ALT);
      if (!at) continue;
      const g = ctx.createRadialGradient(at.x, at.y, 0, at.x, at.y, 9 * at.s);
      g.addColorStop(0, `rgba(255, 255, 230, ${Math.min(1, f.life)})`);
      g.addColorStop(1, 'rgba(255, 200, 80, 0)');
      ctx.fillStyle = g;
      ctx.beginPath();
      ctx.arc(at.x, at.y, 9 * at.s, 0, Math.PI * 2);
      ctx.fill();
    }
    ctx.lineCap = 'butt';
    ctx.globalCompositeOperation = 'source-over';
    for (const m of this.sams) {
      const at = lift(m.x, m.y, m.z);
      if (!at || !inView(at.x, at.y, view, 60)) continue;
      ctx.save();
      ctx.translate(at.x, at.y);
      ctx.rotate(m.angle);
      ctx.scale(at.s, at.s);
      ctx.fillStyle = 'rgba(255, 236, 170, 0.95)';
      ctx.beginPath();
      ctx.moveTo(-5, -0.8);
      ctx.lineTo(-10 - Math.sin(time * 70) * 2, 0);
      ctx.lineTo(-5, 0.8);
      ctx.fill();
      ctx.fillStyle = '#ecf0f1';
      ctx.fillRect(-5, -0.7, 10, 1.4);
      ctx.fillStyle = '#c0392b';
      ctx.fillRect(4, -0.7, 1.5, 1.4);
      ctx.restore();
    }
    for (const b of this.bombers) {
      if (!inView(b.x, b.y, view, 80)) continue;
      // The bucket swinging under it, full or empty.
      if (b.z > 4) {
        ctx.strokeStyle = 'rgba(30, 30, 30, 0.6)';
        ctx.lineWidth = 0.6;
        ctx.beginPath();
        ctx.moveTo(b.x, b.y);
        ctx.lineTo(b.x - Math.cos(b.angle) * 10, b.y - Math.sin(b.angle) * 10 + 6);
        ctx.stroke();
        ctx.fillStyle = b.water > 0 ? '#3f86ad' : '#d35400';
        ctx.beginPath();
        ctx.arc(b.x - Math.cos(b.angle) * 10, b.y - Math.sin(b.angle) * 10 + 6, 2.6, 0, Math.PI * 2);
        ctx.fill();
      }
      drawHelicopter(ctx, b.x, b.y, b.angle, b.z, b.rotor, '#c0392b', '#f2f2f2', false);
    }
  }

  /** Glows for the night: tracer, rocket motors, flares. */
  lights(lift: Lift): { x: number; y: number; r: number }[] {
    const out: { x: number; y: number; r: number }[] = [];
    for (const f of this.flares) {
      const at = lift(f.x, f.y, FLIGHT_ALT);
      if (at) out.push({ x: at.x, y: at.y, r: 40 });
    }
    for (const m of this.sams) {
      const at = lift(m.x, m.y, m.z);
      if (at) out.push({ x: at.x, y: at.y, r: 14 });
    }
    return out;
  }
}