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/ui/planner.ts
// Mission planning: target, timing, route, package, loadouts, wave timeline.
import { audio } from '../game/audio';
import { decoyRisk } from '../game/city';
import { CONFIG, AIRCRAFT_TYPES, type AircraftType, type MunitionId } from '../game/config';
import {
  allocateLoads, bombsPerAircraft, earliestLaunch, fuelNeed, makeFlight, munitionLoaded, munitionNeed, planProblems, planWarnings, readyAt, roeTier, roeViolations,
  type AltBand, type FlightPlan, type FlightRole, type MissionPlan,
} from '../game/campaign';
import { BARRIER_RADARS, DISTRICTS, DISTRICT_BY_ID, ROUTES, type RouteId } from '../game/map';
import { dayOf, fmtTime, hourOfDay, isNight, type GameState } from '../game/state';
import { bind, esc, pct } from './dom';
import { planeCard } from '../art/sprites';
import { districtPicture } from '../art/terrain';
import { AIRCRAFT_HELP } from './hq';

const MUNITION_HELP: Record<string, string> = {
  'FAB-250': 'Light general-purpose bomb. Many small hits — good on soft targets and rail.',
  'FAB-500': 'Standard general-purpose bomb. The tonnage backbone.',
  'FAB-1500': 'Heavy "block-buster". Smashes hardened buildings — power halls, bridges, steel works.',
  'RBK-500': 'Cluster bomb. Shreds SAM sites, flak guns, rail wagons and anything in the open. Useless on concrete.',
  'ZAB-500': 'Incendiary. Starts fires that spread. On the old town it can raise a firestorm — and enormous civilian deaths.',
  'FAB-500-LD': 'Long-delay fuzes. Bombs lie buried for hours and stop repair crews from working there.',
  'UPAB-1500': 'TV/radio-guided bomb. Near-pinpoint — but you must steer it in on the TV screen while loitering under fire. ~4 per week.',
  'S-24': 'Heavy rockets for Su-7s. Fired at guns, radars and vehicles from low level.',
  'KSR-11': 'Anti-radar missile (AS-5 Kelt). Homes on SAM radars that are switched on.',
  'K-10S': 'Standoff missile, launched 12–45 km from the target, outside the flak.',
  'Kh-22': 'Big supersonic standoff missile for the Tu-22.',
};
const ROLE_HELP: Record<string, string> = {
  strike: 'Bombs the target.',
  escort: 'Stays with the bombers and fights interceptors.',
  weasel: 'Goes in first and kills SAM radars with anti-radiation missiles.',
  jammer: 'Blinds SAM and warning radars near it; lays chaff corridors.',
  suppress: 'Attacks flak guns around the target (or the barrier radar) with rockets.',
  recon: 'Photographs the target and SAM sites — refreshes your intel.',
};
import { StratMap } from './stratmap';

export interface PlannerHost {
  s: GameState;
  launch(plan: MissionPlan, auto: boolean): void;
  back(): void;
}

const ROLE_FOR: Record<AircraftType, FlightRole> = {
  'Tu-16': 'strike', 'Tu-22': 'strike', 'Tu-95': 'strike', 'Il-28': 'strike', 'MiG-21': 'escort', 'Su-7': 'suppress',
  'Yak-28': 'weasel', 'Tu-16PP': 'jammer', 'MiG-25R': 'recon',
};
const ROLE_LABEL: Record<FlightRole, string> = { strike: 'Strike', escort: 'Escort', weasel: 'Weasel', jammer: 'EW / chaff', suppress: 'Flak suppression', recon: 'Recon' };

let lastPlan: Partial<MissionPlan> | null = null;

export class PlannerView {
  private plan: MissionPlan;
  private map: StratMap;
  private el: HTMLElement;

  constructor(private root: HTMLElement, private host: PlannerHost, target?: string) {
    const s = host.s;
    this.root.replaceChildren();
    this.root.className = 'screen planner';
    const launch = Math.ceil(earliestLaunch(s));
    const primary = target ?? lastPlan?.primary ?? 'yards';
    this.plan = {
      flights: [], primary, alternates: [], route: lastPlan?.route ?? 'center', ingressAlt: lastPlan?.ingressAlt ?? 'med',
      attackAlt: lastPlan?.attackAlt ?? 'med', launchHour: launch, chaffCorridor: false, drones: 0, tanker: false, playerFlight: '', ignoreROE: false,
    };
    this.defaultPackage();
    this.el = document.createElement('div');
    this.el.className = 'plan-grid';
    this.root.append(this.el);
    this.map = new StratMap(s);
    this.map.onSelect = (id) => {
      if (!id) return;
      this.setPrimary(id);
    };
    this.el.innerHTML = `<div class="plan-map"></div><div class="plan-form"></div>`;
    this.el.querySelector('.plan-map')!.append(this.map.canvas);
    this.map.start();
    bind(this.el, {
      back: () => this.host.back(),
      fly: () => this.go(false),
      auto: () => this.go(true),
      addFlight: (t) => this.addFlight(t.dataset.type as AircraftType),
      rm: (t) => { this.plan.flights = this.plan.flights.filter((f) => f.id !== t.dataset.id); this.fixPlayer(); this.render(); },
      inc: (t) => this.resize(t.dataset.id!, 1),
      dec: (t) => this.resize(t.dataset.id!, -1),
      me: (t) => { this.plan.playerFlight = t.dataset.id!; this.render(); },
      auto3: () => { this.defaultPackage(); this.render(); },
    });
    this.el.addEventListener('change', (e) => this.onChange(e.target as HTMLInputElement | HTMLSelectElement));
    this.render();
  }

  destroy(): void {
    this.map.stop();
  }

  private get s(): GameState {
    return this.host.s;
  }

  private free(type: AircraftType, except?: FlightPlan): number[] {
    const used = new Set(this.plan.flights.filter((f) => f !== except).flatMap((f) => f.airframeIds));
    return readyAt(this.s, type, this.plan.launchHour).map((a) => a.id).filter((id) => !used.has(id));
  }

  private defaultMunition(type: AircraftType): MunitionId | null {
    const l = CONFIG.loadouts[type];
    if (!l?.length) return null;
    if (l.includes('FAB-500')) return 'FAB-500';
    return l[0] as MunitionId;
  }

  private addFlight(type: AircraftType, count = 2, offset?: number): void {
    const fr = makeFlight(this.plan.flights.length, type, ROLE_FOR[type], [], this.defaultMunition(type), this.plan.primary, offset ?? (type === 'Yak-28' ? -24 : type === 'Tu-16PP' ? -12 : type === 'Su-7' ? -12 : 0));
    fr.airframeIds = this.free(type).slice(0, count);
    if (!fr.airframeIds.length) return;
    this.plan.flights.push(fr);
    this.fixPlayer();
    this.render();
  }

  private defaultPackage(): void {
    this.plan.flights = [];
    const add = (t: AircraftType, n: number) => {
      const ids = this.free(t).slice(0, n);
      if (!ids.length) return;
      const f = makeFlight(this.plan.flights.length, t, ROLE_FOR[t], ids, this.defaultMunition(t), this.plan.primary, t === 'Yak-28' ? -24 : t === 'Tu-16PP' ? -12 : 0);
      this.plan.flights.push(f);
    };
    add('Tu-16', 6);
    if (!this.plan.flights.length) add('Il-28', 6);
    add('MiG-21', 4);
    if (this.s.munitions['KSR-11'] > 0) add('Yak-28', 2);
    add('Tu-16PP', 1);
    this.plan.chaffCorridor = this.plan.flights.some((f) => f.role === 'jammer');
    this.fixPlayer();
  }

  private fixPlayer(): void {
    if (!this.plan.flights.some((f) => f.id === this.plan.playerFlight)) this.plan.playerFlight = this.plan.flights[0]?.id ?? '';
  }

  private resize(id: string, d: number): void {
    const f = this.plan.flights.find((q) => q.id === id);
    if (!f) return;
    if (d > 0) {
      const extra = this.free(f.type, f).filter((x) => !f.airframeIds.includes(x))[0];
      if (extra !== undefined) f.airframeIds.push(extra);
    } else if (f.airframeIds.length > 1) f.airframeIds.pop();
    this.render();
  }

  private setPrimary(id: string): void {
    const old = this.plan.primary;
    this.plan.primary = id;
    for (const f of this.plan.flights) if (f.target === old || !f.target) f.target = id;
    audio.uiClick();
    this.render();
  }

  private reassignForTime(): void {
    for (const f of this.plan.flights) {
      const n = f.airframeIds.length;
      f.airframeIds = [];
      f.airframeIds = this.free(f.type, f).slice(0, n);
    }
    this.plan.flights = this.plan.flights.filter((f) => f.airframeIds.length);
    this.fixPlayer();
  }

  private onChange(t: HTMLInputElement | HTMLSelectElement): void {
    const p = this.plan;
    const k = t.name;
    const v = t.value;
    if (k === 'primary') return this.setPrimary(v);
    if (k === 'alt1') p.alternates = [v, ...p.alternates.slice(1)].filter(Boolean);
    if (k === 'route') p.route = v as RouteId;
    if (k === 'ingressAlt') p.ingressAlt = v as AltBand;
    if (k === 'attackAlt') p.attackAlt = v as AltBand;
    if (k === 'day' || k === 'hour') {
      const day = Number((this.el.querySelector('[name=day]') as HTMLSelectElement).value);
      const hour = Number((this.el.querySelector('[name=hour]') as HTMLSelectElement).value);
      p.launchHour = (day - 1) * 24 + hour;
      this.reassignForTime();
    }
    if (k === 'chaff') p.chaffCorridor = (t as HTMLInputElement).checked;
    if (k === 'tanker') p.tanker = (t as HTMLInputElement).checked;
    if (k === 'roe') p.ignoreROE = (t as HTMLInputElement).checked;
    if (k === 'drones') p.drones = Number(v);
    const fid = t.dataset.flight;
    if (fid) {
      const f = p.flights.find((q) => q.id === fid);
      if (f) {
        if (k === 'mun') f.munition = (v || null) as MunitionId | null;
        if (k === 'ftarget') f.target = v;
        if (k === 'wave') f.offsetSec = Number(v);
        if (k === 'role') f.role = v as FlightRole;
      }
    }
    this.render();
  }

  private targetOptions(selected: string, allowEmpty = false): string {
    const s = this.s;
    const tier = roeTier(s);
    const opt = (id: string, label: string) => {
      const d = DISTRICT_BY_ID[id];
      const nogo = d && !s.linebacker && (tier.noGo.includes(d.kind) || tier.noGo.includes(d.id));
      return `<option value="${esc(id)}" ${id === selected ? 'selected' : ''}>${esc(label)}${nogo ? ' ⛔' : ''}${d?.restricted ? ' ⚠' : ''}</option>`;
    };
    const grp = (name: string, ids: string[]) => `<optgroup label="${name}">${ids.map((id) => opt(id, DISTRICT_BY_ID[id].name)).join('')}</optgroup>`;
    const sams = s.sams.filter((q) => q.knownHour > -1000 && (q.launchers > 0.05 || q.isDecoy) && !q.revealed).map((q) => opt(`sam:${q.id}`, `SAM ${q.id} (${q.kind === 'nike' ? 'Nike' : 'Bloodhound'}, seen ${Math.round((s.hour - q.knownHour) / 24)}d ago)`)).join('');
    const farms = s.city.fuelFarms.filter((f) => f.known && !f.destroyed).map((f) => opt(`farm:${f.id}`, `Fuel drum farm ${f.id}`)).join('');
    return `${allowEmpty ? '<option value="">—</option>' : ''}
      <optgroup label="Opening strike"><option value="pickets" ${selected === 'pickets' ? 'selected' : ''}>Barrier early-warning radar (blind the barrier)</option></optgroup>
      ${grp('Bottlenecks', ['power', 'subN', 'subE', 'subW', 'subS', 'yards', 'bridge', 'fueldepot', 'railE', 'railN', 'railS', 'lock', 'road'])}
      ${grp('Industry', ['steel', 'machine', 'chem', 'port', 'warehouse', 'satN', 'satW', 'satS'])}
      ${grp('Military', ['military', 'airbase', 'govt'])}
      ${sams || farms ? `<optgroup label="Sites">${sams}${farms}</optgroup>` : ''}
      ${grp('Civilian', ['housingN', 'housingW', 'housingS', 'altstadt'])}
      ${grp('Restricted', ['hospital', 'dam'])}`;
  }

  private aimOf(target: string): { x: number; y: number } {
    if (target === 'pickets') {
      const ref = this.plan.route === 'north' ? -55 : this.plan.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 };
    }
    if (target.startsWith('sam:')) {
      const q = this.s.sams.find((x) => x.id === target.slice(4));
      return q ? { x: q.knownX, y: q.knownY } : { x: 0, y: 0 };
    }
    if (target.startsWith('farm:')) {
      const f = this.s.city.fuelFarms.find((x) => x.id === target.slice(5));
      return f ? { x: f.x, y: f.y } : { x: 0, y: 0 };
    }
    const d = DISTRICT_BY_ID[target];
    return d ? { x: d.x, y: d.y } : { x: 0, y: 0 };
  }

  private timeline(): string {
    // Every flight is timed to reach its target at TOT plus its wave offset.
    const arrivals = this.plan.flights.map((f) => ({ f, at: f.offsetSec }));
    const min = Math.min(-40, ...arrivals.map((a) => a.at));
    const max = Math.max(30, ...arrivals.map((a) => a.at));
    const X = (t: number) => ((t - min) / (max - min)) * 100;
    const fmt = (sec: number) => {
      const m = (sec * CONFIG.mission.timeCompression) / 60;
      return `${m >= 0 ? '+' : '−'}${Math.abs(m).toFixed(0)} min`;
    };
    return `<div class="timeline" aria-label="Time over target">
      <div class="tl-zero" style="left:${X(0)}%"><span>TOT</span></div>
      ${arrivals.map((a) => `<div class="tl-row"><span class="tl-l">${esc(a.f.callsign)} ${esc(a.f.type)}</span><span class="tl-dot r-${a.f.role}" style="left:${X(a.at)}%" title="${fmt(a.at)}"></span><span class="tl-t">${fmt(a.at)}</span></div>`).join('')}
    </div>`;
  }

  private render(): void {
    const s = this.s;
    const p = this.plan;
    lastPlan = { primary: p.primary, route: p.route, ingressAlt: p.ingressAlt, attackAlt: p.attackAlt };
    this.map.route = p.route;
    this.map.aim = this.aimOf(p.primary);
    this.map.selected = p.primary;
    const earliest = earliestLaunch(s);
    const lastDay = CONFIG.campaign.days;
    const dayNow = dayOf(Math.ceil(earliest));
    const days = Array.from({ length: Math.min(5, lastDay - dayNow + 1) }, (_, i) => dayNow + i);
    const ld = dayOf(p.launchHour);
    const lh = hourOfDay(p.launchHour);
    const turns = Math.max(0, Math.floor((p.launchHour - s.hour) / CONFIG.campaign.turnHours));
    const tier = roeTier(s);
    const viol = roeViolations(s, p);
    const problems = planProblems(s, p);
    const blocked = problems.length > 0 || (viol.length > 0 && !p.ignoreROE);
    allocateLoads(s, p);
    const need = munitionNeed(p);
    const loaded = munitionLoaded(p);
    const warnings = planWarnings(s, p);
    const risk = DISTRICT_BY_ID[p.primary] ? decoyRisk(s, p.primary) : 0;
    const anyFighter = p.flights.some((f) => CONFIG.aircraft[f.type].fighter);
    const flights = p.flights.map((f) => {
      const muns = CONFIG.loadouts[f.type] ?? [];
      const per = bombsPerAircraft(f.type, f.munition);
      const roles: FlightRole[] = f.type === 'Su-7' ? ['suppress', 'strike', 'escort'] : f.type === 'MiG-21' ? ['escort'] : [ROLE_FOR[f.type]];
      return `<div class="flight ${f.id === p.playerFlight ? 'me' : ''}">
        <div class="f-head">
          <img class="flight-pic" src="${planeCard(f.type, 96, 64)}" alt="" data-tip="${esc(AIRCRAFT_HELP[f.type] ?? '')}" data-tip-title="${esc(CONFIG.aircraft[f.type].name)}">
          <label class="me-pick" data-tip="Fly this flight yourself. You will lead it; the others are flown by the AI."><input type="radio" name="me" ${f.id === p.playerFlight ? 'checked' : ''} data-act="me" data-id="${f.id}"> <b>${esc(f.callsign)}</b>${f.id === p.playerFlight ? ' <em class="you-tag">YOU</em>' : ''}</label>
          <span>${esc(CONFIG.aircraft[f.type].name)}</span>
          <button type="button" class="icon" data-act="rm" data-id="${f.id}" aria-label="Remove flight" data-tip="Remove this flight from the package.">×</button>
        </div>
        <div class="f-row">
          <span class="stepper" data-tip="Number of aircraft in this flight (best crews are picked first)."><button type="button" data-act="dec" data-id="${f.id}" aria-label="Fewer">−</button><b>${f.airframeIds.length}</b><button type="button" data-act="inc" data-id="${f.id}" aria-label="More">+</button></span>
          <select name="role" data-flight="${f.id}" aria-label="Role" data-tip="${esc(ROLE_HELP[f.role] ?? '')}">${roles.map((r) => `<option value="${r}" ${r === f.role ? 'selected' : ''}>${ROLE_LABEL[r]}</option>`).join('')}</select>
          ${muns.length ? `<select name="mun" data-flight="${f.id}" aria-label="Munition" data-tip="${esc(MUNITION_HELP[f.munition ?? ''] ?? 'Weapon load.')}">${muns.map((m) => `<option value="${m}" ${m === f.munition ? 'selected' : ''}>${esc(m)} (${s.munitions[m as MunitionId]})</option>`).join('')}</select>` : ''}
          <select name="wave" data-flight="${f.id}" aria-label="Wave timing" data-tip="When this flight reaches its target, relative to the main bomber wave (TOT). Weasels and jammers should go a few minutes early.">${CONFIG.mission.waveOffsets.map((o) => `<option value="${o}" ${o === f.offsetSec ? 'selected' : ''}>${o === 0 ? 'On TOT' : `${o < 0 ? '' : '+'}${Math.round((o * CONFIG.mission.timeCompression) / 60)} min`}</option>`).join('')}</select>
        </div>
        ${f.role !== 'escort' && f.role !== 'jammer' ? `<div class="f-row"><select name="ftarget" data-flight="${f.id}" aria-label="Flight target" data-tip="This flight's own target. Defaults to the primary.">${this.targetOptions(f.target ?? p.primary)}</select></div>` : ''}
        <div class="f-note">${f.munition && f.loads && f.loads.some((n) => n < per) ? `<span class="warn-txt">Short: loaded ${f.loads.join(' / ')} of ${per} each</span> · ` : ''}${f.munition ? `${per} × ${esc(f.munition)} each · ${((per * CONFIG.munitions[f.munition].kg * f.airframeIds.length) / 1000).toFixed(1)} t` : ROLE_LABEL[f.role]} · avg XP ${pct(f.airframeIds.reduce((a, id) => a + (s.fleet.find((x) => x.id === id)?.xp ?? 0), 0) / Math.max(1, f.airframeIds.length))}</div>
      </div>`;
    }).join('');
    const addable = AIRCRAFT_TYPES.map((t) => ({ t, n: this.free(t).length })).filter((q) => q.n > 0);
    this.el.querySelector('.plan-form')!.innerHTML = `
      <div class="plan-head">
        <button type="button" data-act="back" data-tip="Back to the command post without launching.">← HQ</button>
        <b>Raid order</b>
        <span class="note" data-tip="Current rules of engagement set by Moscow.">ROE ${s.linebacker ? 'ALL-OUT' : tier.name}</span>
        <button type="button" class="primary" data-act="fly" ${blocked ? 'disabled' : ''} data-tip="${blocked ? 'Fix the problems listed at the bottom of the order first.' : 'Take off and fly your aircraft through the raid.'}">Fly ▶</button>
      </div>
      ${this.resources()}
      <section>
        <h3>1 · Target</h3>
        ${DISTRICT_BY_ID[p.primary] ? `<figure class="target-photo"><img src="${districtPicture(DISTRICT_BY_ID[p.primary], s.districts[p.primary].knownIntegrity, 300, 150, 'photo')}" alt=""><figcaption>${esc(DISTRICT_BY_ID[p.primary].name)} — last recon photo</figcaption></figure>` : ''}
        <label data-tip="The main target. You can also click it on the map.">Primary <select name="primary">${this.targetOptions(p.primary)}</select></label>
        <label data-tip="If an earlier wave has already wrecked the primary, the bombers divert here.">Alternate <select name="alt1">${this.targetOptions(p.alternates[0] ?? '', true)}</select></label>
        <p class="note">${DISTRICT_BY_ID[p.primary] ? `Intel age ${Math.round(((s.hour - s.districts[p.primary].intelHour) / 24) * 10) / 10} d · decoy risk <b class="${risk > 0.25 ? 'bad' : ''}">${pct(risk)}</b> · known integrity ${pct(s.districts[p.primary].knownIntegrity)}` : 'Site positions move between raids.'} · tap the map to pick.</p>
        ${this.analysis()}
      </section>
      <section>
        <h3>2 · When</h3>
        <div class="row">
          <label data-tip="Launch day. Waiting lets your aircraft come out of maintenance — and lets the city repair.">Day <select name="day">${days.map((d) => `<option value="${d}" ${d === ld ? 'selected' : ''}>D${d}</option>`).join('')}</select></label>
          <label data-tip="Time over the target. ☾ = night: searchlights and scattered bombs, but fewer fighters and the city's lights to aim at.">Hour <select name="hour">${Array.from({ length: 24 }, (_, h) => `<option value="${h}" ${h === lh ? 'selected' : ''}>${String(h).padStart(2, '0')}:00${h < 5 || h >= 20 ? ' ☾' : ''}</option>`).join('')}</select></label>
        </div>
        <div class="chips-row">
          <span class="pill ${isNight(p.launchHour) ? 'night' : 'day'}" data-tip="${isNight(p.launchHour) ? 'Night: searchlights, scattered bombs, fewer fighters.' : 'Day: accurate bombing — and every gun can see you.'}">${isNight(p.launchHour) ? '☾ NIGHT' : '☀ DAY'} · ${fmtTime(p.launchHour)}</span>
          <span class="pill ${turns ? 'warn' : ''}" data-tip="Every 6 hours you wait, the city repairs and adapts. Earliest launch: ${fmtTime(earliest)}.">🛠 city repairs ${turns * 6} h</span>
        </div>
      </section>
      <section>
        <h3>3 · Route & profile</h3>
        <div class="seg" role="radiogroup" aria-label="Route">${(Object.keys(ROUTES) as RouteId[]).map((r) => `<label class="${p.route === r ? 'on' : ''}" data-tip="${esc(ROUTES[r].desc)}"><input type="radio" name="route" value="${r}" ${p.route === r ? 'checked' : ''}>${r === 'center' ? 'Through the barrier' : r === 'north' ? 'Around north' : 'Around south'}</label>`).join('')}</div>
        <div class="row">
          <label data-tip="Altitude on the way in. LOW sneaks under the early-warning radars but every gun can reach you.">Ingress <select name="ingressAlt">${(['low', 'med', 'high'] as AltBand[]).map((a) => `<option value="${a}" ${a === p.ingressAlt ? 'selected' : ''}>${a.toUpperCase()}</option>`).join('')}</select></label>
          <label data-tip="Altitude over the target. LOW = accurate but inside the light flak. MED = balanced. HIGH = above most flak, bombs scatter, Nike SAMs love it.">Attack <select name="attackAlt">${(['low', 'med', 'high'] as AltBand[]).map((a) => `<option value="${a}" ${a === p.attackAlt ? 'selected' : ''}>${a.toUpperCase()}</option>`).join('')}</select></label>
        </div>
        ${altitudeDiagram(p.attackAlt)}
        <div class="row">
          <label class="check" data-tip="The Tu-16PP drops a trail of chaff along the route: SAM radars see a wall of false echoes for a couple of minutes."><input type="checkbox" name="chaff" ${p.chaffCorridor ? 'checked' : ''}> Chaff corridor (${CONFIG.pres.corridorCost} Pres, needs Tu-16PP)</label>
          <label class="check" data-tip="Short-legged MiG-21s and Su-7s refuel on the way. Without it, on the long north/south routes they must turn home early (bingo fuel)."><input type="checkbox" name="tanker" ${p.tanker ? 'checked' : ''} ${anyFighter ? '' : 'disabled'}> Tanker rendezvous for fighters</label>
          <label data-tip="Decoy drones fly ahead of the raid. SAM crews waste missiles on them and switch their radars on — where your weasels can find them (Desert Storm's opening trick).">Tu-123 drones <select name="drones">${[0, 2, 4, 6].map((n) => `<option value="${n}" ${n === p.drones ? 'selected' : ''} ${n > s.drones ? 'disabled' : ''}>${n}</option>`).join('')}</select></label>
        </div>
      </section>
      <section>
        <h3>4 · Package <button type="button" class="chip" data-act="auto3" data-tip="Reset to a balanced package: bombers, escorts, weasels and a jammer.">Standard package</button></h3>
        ${flights || '<p class="note">No flights.</p>'}
        <div class="add-row">${addable.map((q) => `<button type="button" class="chip add-plane" data-act="addFlight" data-type="${q.t}" data-tip="${esc(AIRCRAFT_HELP[q.t] ?? '')} (${q.n} ready)" data-tip-title="Add ${esc(CONFIG.aircraft[q.t].name)}"><img src="${planeCard(q.t, 48, 32)}" alt="">+ ${esc(q.t)} (${q.n})</button>`).join('')}</div>
      </section>
      <section>
        <h3>5 · Waves</h3>
        ${this.timeline()}
      </section>
      <section class="summary">
        <h3>6 · Order check</h3>
        <ul class="kv">
          ${Object.entries(need).map(([m, n]) => `<li data-tip="Loaded for this raid / a full load. Stock left in the armory: ${s.munitions[m as MunitionId]}."><span>${esc(m)}</span><b class="${(loaded[m] ?? 0) < n ? 'warn-txt' : ''}">${loaded[m] ?? 0} loaded${(loaded[m] ?? 0) < n ? ` of ${n} (short)` : ''} · stock ${s.munitions[m as MunitionId]}</b></li>`).join('')}
          <li><span>Fuel</span><b>${fuelNeed(p)} / ${Math.round(s.fuel)} t</b></li>
          <li><span>Aircraft</span><b>${p.flights.reduce((a, f) => a + f.airframeIds.length, 0)}</b></li>
        </ul>
        ${problems.map((x) => `<p class="warn">${esc(x)}</p>`).join('')}
        ${warnings.map((x) => `<p class="caution">⚠ ${esc(x)}</p>`).join('')}
        ${viol.length ? `<div class="roe-box">${viol.map((x) => `<p class="warn">ROE: ${esc(x)}</p>`).join('')}<label class="check" data-tip="Fly it anyway. Each violated restriction costs political capital."><input type="checkbox" name="roe" ${p.ignoreROE ? 'checked' : ''}> Ignore restrictions (−${CONFIG.politics.roeViolation} capital each)</label></div>` : ''}
        <div class="row launch">
          <button type="button" class="primary big" data-act="fly" ${blocked ? 'disabled' : ''} data-tip="Take off and fly your chosen aircraft through the raid.">Fly the mission</button>
          <button type="button" data-act="auto" ${blocked ? 'disabled' : ''} data-tip="Let the AI fly the whole raid and jump straight to the results.">Command from the bunker</button>
        </div>
      </section>`;
  }

  /** Everything the regiment has, and what each thing is for. */
  private resources(): string {
    const s = this.s;
    const p = this.plan;
    const used = new Set(p.flights.flatMap((f) => f.airframeIds));
    const types = AIRCRAFT_TYPES.filter((t) => s.fleet.some((a) => a.type === t && a.status !== 'lost'));
    const cards = types.map((t) => {
      const all = s.fleet.filter((a) => a.type === t && a.status !== 'lost');
      const readyAtLaunch = all.filter((a) => a.readyAt <= p.launchHour);
      const free = readyAtLaunch.filter((a) => !used.has(a.id)).length;
      const maint = all.filter((a) => a.readyAt > p.launchHour).sort((a, b) => a.readyAt - b.readyAt);
      const lost = s.fleet.filter((a) => a.type === t && a.status === 'lost').length;
      const spec = CONFIG.aircraft[t];
      const xp = all.length ? all.reduce((a, b) => a + b.xp, 0) / all.length : 0;
      return `<div class="res-plane" data-tip="${esc(AIRCRAFT_HELP[t] ?? '')}" data-tip-title="${esc(spec.name)}">
        <img src="${planeCard(t, 120, 72)}" alt="">
        <div class="res-info">
          <b>${esc(spec.name)}</b>
          <span class="res-role">${ROLE_LABEL[ROLE_FOR[t]]}</span>
          <span><b class="${free ? 'good' : 'bad'}">${free}</b> free at launch · ${readyAtLaunch.length - free} in this package${maint.length ? ` · ${maint.length} in repair (next ${fmtTime(maint[0].readyAt)})` : ''}${lost ? ` · <span class="bad">${lost} lost</span>` : ''}</span>
          <span class="note">${spec.loadKg ? `${(spec.loadKg / 1000).toFixed(0)} t bombs · ` : ''}${Math.round(spec.speed * 1000)} km/h · ${spec.tailGun ? 'tail guns · ' : ''}crew ${spec.crew} · XP ${pct(xp)}</span>
        </div>
        ${free ? `<button type="button" class="chip" data-act="addFlight" data-type="${t}" data-tip="Add a flight of ${esc(t)} to the package.">+ Add</button>` : ''}
      </div>`;
    }).join('');
    const target = DISTRICT_BY_ID[p.primary];
    const best = target ? this.bestWeapons(target.id).slice(0, 3).map((b) => b.m) : [];
    const bar = (v: number) => `<span class="eff" style="--v:${Math.min(1, v / 2.6)}"></span>`;
    const muns = (Object.keys(CONFIG.munitions) as MunitionId[]).map((m) => {
      const M = CONFIG.munitions[m];
      const carriers = AIRCRAFT_TYPES.filter((t) => (CONFIG.loadouts[t] ?? []).includes(m)).join(', ');
      return `<tr data-tip="${esc(MUNITION_HELP[m] ?? '')} Carried by: ${esc(carriers)}." data-tip-title="${esc(M.name)}" class="${best.includes(m) ? 'rec' : ''}">
        <th>${best.includes(m) ? '★ ' : ''}${esc(M.name)}</th><td class="${s.munitions[m] ? '' : 'bad'}">${s.munitions[m]}</td><td>+${M.weekly}/wk</td>
        <td>${bar(M.vsHard * M.blast)}</td><td>${bar(M.vsSoft * M.blast)}</td><td>${bar(M.vsRail * M.blast)}</td><td>${bar(M.fire * 2.6)}</td></tr>`;
    }).join('');
    return `<section>
      <details class="res" open>
        <summary data-tip="Everything the regiment can put in the air tonight — click to fold away.">0 · Your resources</summary>
        <div class="res-planes">${cards}</div>
        <h4>Bombs & missiles</h4>
        <table class="mun-table">
          <thead><tr><th>Weapon</th><th>Stock</th><th>Resupply</th><th data-tip="Concrete, steel halls, bridges">Hard</th><th data-tip="Factories, warehouses, sites in the open">Soft</th><th data-tip="Rail yards and lines">Rail</th><th data-tip="Starts fires">Fire</th></tr></thead>
          <tbody>${muns}</tbody>
        </table>
        ${best.length ? '<p class="note">★ = best against the selected target.</p>' : ''}
        <ul class="kv res-misc">
          <li data-tip="Jet fuel for this raid and the ones after it. Resupplied weekly."><span>Fuel</span><b>${Math.round(s.fuel)} t (this raid ~${fuelNeed(p)} t)</b></li>
          <li data-tip="Pres chaff bundles. A chaff corridor uses ${CONFIG.pres.corridorCost}; each aircraft also carries ${CONFIG.pres.perAircraft} for self-defence."><span>Chaff (Pres)</span><b>${s.pres}</b></li>
          <li data-tip="Decoy drones that draw SAM fire and make radars switch on."><span>Tu-123 drones</span><b>${s.drones}</b></li>
          <li data-tip="Guided bombs arrive ~${CONFIG.munitions['UPAB-1500'].weekly} a week. Precious."><span>Guided UPAB-1500</span><b>${s.munitions['UPAB-1500']}</b></li>
          <li data-tip="Each lead aircraft carries marker flares for pathfinding, and every bomber has 2 extinguisher bottles."><span>Per aircraft</span><b>${CONFIG.mission.crew.markers} markers · ${CONFIG.mission.crew.extinguishers} extinguishers</b></li>
        </ul>
      </details>
    </section>`;
  }

  private bestWeapons(id: string): { m: MunitionId; v: number }[] {
    const d = DISTRICT_BY_ID[id];
    const rail = d.kind === 'rail' || d.kind === 'interdiction' || d.kind === 'bridge';
    return (Object.keys(CONFIG.munitions) as MunitionId[])
      .filter((m) => this.s.munitions[m] > 0 && !['KSR-11', 'S-24'].includes(m))
      .map((m) => {
        const M = CONFIG.munitions[m];
        const v = (rail ? M.vsRail : M.vsHard * d.hardness + M.vsSoft * (1 - d.hardness)) * M.blast + M.fire * d.flammable * 0.8;
        return { m, v };
      })
      .sort((a, b) => b.v - a.v);
  }

  /** What the defenses look like and what this package should achieve. */
  private analysis(): string {
    const s = this.s;
    const p = this.plan;
    const d = DISTRICT_BY_ID[p.primary];
    if (!d) return '';
    const sams = s.sams.filter((q) => !q.revealed && (q.launchers > 0.05 || q.isDecoy) && q.knownHour > -1000 && Math.hypot(q.knownX - d.x, q.knownY - d.y) < CONFIG.sam[q.kind].range);
    const flak = d.aaa.heavy * 3 + d.aaa.bofors * 2 + d.aaa.vulcan;
    const strike = p.flights.filter((f) => f.role === 'strike' && f.munition && (f.target ?? p.primary) === p.primary);
    // Expected hits: altitude, light, crew skill and the decoy gamble.
    const altHit = p.attackAlt === 'low' ? 0.55 : p.attackAlt === 'med' ? 0.42 : 0.2;
    const lightHit = isNight(p.launchHour) ? 0.7 : 1;
    const risk = decoyRisk(s, d.id);
    let pts = 0;
    let tons = 0;
    for (const f of strike) {
      const M = CONFIG.munitions[f.munition!];
      const kg = bombsPerAircraft(f.type, f.munition) * M.kg * f.airframeIds.length;
      tons += kg / 1000;
      const xp = f.airframeIds.reduce((a, id) => a + (s.fleet.find((x) => x.id === id)?.xp ?? 0.4), 0) / Math.max(1, f.airframeIds.length);
      const rail = d.kind === 'rail' || d.kind === 'interdiction' || d.kind === 'bridge';
      const eff = rail ? M.vsRail : M.vsHard * d.hardness + M.vsSoft * (1 - d.hardness);
      const hit = M.guided ? 0.85 : altHit * lightHit * (0.75 + xp * 0.5);
      pts += (kg / 100) * M.blast * eff * CONFIG.mission.damageScale * hit * (1 - risk);
    }
    const dmg = Math.min(1, pts / d.hp);
    const fullTons = (d.hp / Math.max(0.01, pts)) * tons;
    const fighters = Object.values(s.city.interceptors).reduce((a, b) => a + b, 0);
    const threat = sams.length * 2 + flak / 6 + (p.route === 'center' ? 2 : 0) + (isNight(p.launchHour) ? -1 : 1);
    const level = threat > 9 ? 'EXTREME' : threat > 6 ? 'HIGH' : threat > 3 ? 'MEDIUM' : 'LOW';
    const best = this.bestWeapons(d.id).slice(0, 3).map((b) => CONFIG.munitions[b.m].name).join(', ');
    return `<div class="analysis">
      <div class="an-row" data-tip="How hard the target is to break, and what breaks it best.">
        <span>Target</span><b>${d.hardness > 0.6 ? 'Hardened' : d.hardness > 0.35 ? 'Medium' : 'Soft'}${d.flammable > 0.6 ? ', burns well' : ''}</b>
      </div>
      <div class="an-row"><span>Best weapons</span><b>${esc(best)}</b></div>
      <div class="an-row" data-tip="Known SAM batteries whose missiles reach the target, and the flak around it."><span>Defenses</span><b>${sams.length} SAM site${sams.length === 1 ? '' : 's'} in range · flak ${'▮'.repeat(Math.min(10, Math.round(flak / 2)))}${'▯'.repeat(Math.max(0, 10 - Math.round(flak / 2)))} · ~${fighters} fighters</b></div>
      <div class="an-row" data-tip="A rough forecast from your bomb load, altitude, light, crew skill and the decoy risk. The real raid depends on how you fly."><span>Forecast</span><b class="${dmg > 0.6 ? 'good' : dmg < 0.25 ? 'bad' : ''}">${tons.toFixed(0)} t aimed here → ~${Math.round(dmg * 100)}% destroyed</b></div>
      ${dmg < 1 && tons > 0 ? `<div class="an-row"><span></span><span class="note">≈${Math.round(fullTons)} t needed to flatten it at this altitude.</span></div>` : ''}
      ${!tons ? '<div class="an-row"><span></span><span class="warn">No bombers are aimed at this target.</span></div>' : ''}
      <div class="an-row" data-tip="Based on SAM coverage, flak, the route and time of day."><span>Threat</span><b class="${level === 'EXTREME' || level === 'HIGH' ? 'bad' : ''}">${level}</b></div>
    </div>`;
  }

  private go(auto: boolean): void {
    audio.start();
    this.host.launch(structuredClone(this.plan), auto);
  }
}

export function allDistrictIds(): string[] {
  return DISTRICTS.map((d) => d.id);
}

/** The altitude triangle as a picture: what can reach you at each height. */
function altitudeDiagram(sel: AltBand): string {
  const y = { high: 18, med: 52, low: 86 }[sel];
  return `<svg class="alt-diagram" viewBox="0 0 300 110" role="img" aria-label="Altitude trade-offs" data-tip="HIGH: above most guns, but bombs scatter and Nike missiles reach you. MED: a balance. LOW: accurate and under the radar, but inside every gun.">
    <rect x="0" y="2" width="300" height="32" fill="rgba(255,74,58,0.10)"/><rect x="0" y="36" width="300" height="32" fill="rgba(255,179,71,0.08)"/><rect x="0" y="70" width="300" height="34" fill="rgba(255,74,58,0.10)"/>
    <text x="6" y="22" class="t">HIGH</text><text x="6" y="56" class="t">MED</text><text x="6" y="90" class="t">LOW</text>
    <g class="sam"><path d="M70 30 l8 -18 l8 18 z"/><text x="94" y="24">SAMs</text></g>
    <g class="sc"><circle cx="160" cy="12" r="2"/><circle cx="174" cy="24" r="2"/><circle cx="188" cy="10" r="2"/><circle cx="200" cy="26" r="2"/><text x="210" y="22">bombs scatter</text></g>
    <g class="flak"><circle cx="78" cy="84" r="6"/><circle cx="96" cy="78" r="5"/><circle cx="88" cy="96" r="5"/><text x="108" y="90">flak</text></g>
    <g class="hit"><circle cx="190" cy="88" r="9"/><circle cx="190" cy="88" r="2"/><text x="206" y="92">accurate</text></g>
    <path class="plane" d="M260 ${y} l18 -5 l0 10 z" />
  </svg>`;
}