Back to City Breaker
SOURCE / PINNED RELEASE

Made of little things.

City Breaker

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

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

src/ui/planner.ts
// The war table: plan a raid by touching the map and pictures, not filling forms.
// Tap a target, drag your route around the SAM rings, spin the clock, tap the sky
// for altitude, tap aircraft in the hangar to build the package, tap bomb pictures
// to load them.
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, planProblems, planWarnings, readyAt, roeTier, roeViolations,
  type AltBand, type FlightPlan, type FlightRole, type MissionPlan,
} from '../game/campaign';
import { BARRIER_RADARS, DISTRICT_BY_ID, ROUTES, type DistrictDef, type RouteId } from '../game/map';
import { dayOf, hourOfDay, isNight, type GameState } from '../game/state';
import { bind, esc, modal } from './dom';
import { planeCard } from '../art/sprites';
import { districtPicture } from '../art/terrain';
import { AIRCRAFT_HELP } from './hq';
import { ICONS, munIcon, timingIcon } from './icons';
import { StratMap, drawIcon } from './stratmap';

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

const MUNITION_HELP: Record<string, string> = {
  'FAB-250': 'Light bomb. Many small hits — soft targets and rail.',
  'FAB-500': 'Standard bomb. The tonnage backbone.',
  'FAB-1500': 'Block-buster. Smashes power halls, bridges, steel works.',
  'RBK-500': 'Cluster bomb. Shreds SAM sites, flak, rail wagons. Useless on concrete.',
  'ZAB-500': 'Incendiary. Starts spreading fires — and kills many civilians in housing.',
  'FAB-500-LD': 'Delayed fuzes. Stops repair crews working for hours.',
  'UPAB-1500': 'Guided bomb. You steer it in on a TV screen. Very scarce.',
  'S-24': 'Rockets. Kill flak guns and radars from low level.',
  'KSR-11': 'Anti-radar missile. Hunts SAM radars that are switched on.',
  'K-10S': 'Standoff missile. Fire 12–45 km out, outside the flak.',
  'Kh-22': 'Big standoff missile.',
};

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_BADGE: Record<FlightRole, string> = { strike: 'BOMB', escort: 'ESCORT', weasel: 'SAM HUNT', jammer: 'JAM', suppress: 'FLAK KILL', recon: 'PHOTO' };
const DEFAULT_COUNT: Partial<Record<AircraftType, number>> = { 'Tu-16': 4, 'Il-28': 4, 'Tu-22': 2, 'Tu-95': 2, 'MiG-21': 4, 'Su-7': 2, 'Yak-28': 2, 'Tu-16PP': 1, 'MiG-25R': 1 };
const OFFSETS = CONFIG.mission.waveOffsets;

const TARGET_GROUPS: [string, string[]][] = [
  ['Opening move', ['pickets']],
  ['Bottlenecks', ['power', 'subN', 'subE', 'subW', 'subS', 'yards', 'bridge', 'fueldepot', 'railE', 'railN', 'railS', 'lock', 'road']],
  ['Industry', ['steel', 'machine', 'chem', 'port', 'warehouse', 'satN', 'satW', 'satS']],
  ['Military', ['military', 'airbase', 'govt']],
  ['Civilian', ['housingN', 'housingW', 'housingS', 'altstadt', 'hospital', 'dam']],
];

const iconCache = new Map<string, string>();
function districtIcon(kind: DistrictDef['kind'] | 'radar', color = '#ffd9a0'): string {
  const key = `${kind}|${color}`;
  const hit = iconCache.get(key);
  if (hit) return hit;
  const c = document.createElement('canvas');
  c.width = c.height = 64;
  const ctx = c.getContext('2d')!;
  if (kind === 'radar') {
    ctx.strokeStyle = '#ff6a5a';
    ctx.lineWidth = 5;
    ctx.beginPath();
    ctx.moveTo(16, 56); ctx.lineTo(32, 12); ctx.lineTo(48, 56);
    ctx.moveTo(12, 14); ctx.lineTo(52, 14);
    ctx.stroke();
  } else drawIcon(ctx, kind, 32, 32, 50, color);
  const url = c.toDataURL();
  iconCache.set(key, url);
  return url;
}

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

export class PlannerView {
  private plan: MissionPlan;
  private map: StratMap;
  private el: HTMLElement;
  private selFlight: string | null = null;
  private showTargets = false;

  constructor(private root: HTMLElement, private host: PlannerHost, target?: string) {
    const s = host.s;
    this.root.replaceChildren();
    this.root.className = 'screen planner wt';
    const launch = Math.ceil(earliestLaunch(s));
    const route = (lastPlan?.route ?? 'center') as RouteId;
    this.plan = {
      flights: [], primary: target ?? lastPlan?.primary ?? 'yards', alternates: [], route, ingressAlt: lastPlan?.ingressAlt ?? 'med',
      attackAlt: lastPlan?.attackAlt ?? 'med', launchHour: launch, chaffCorridor: false, drones: 0, tanker: false, playerFlight: '', ignoreROE: false,
      customIngress: (lastPlan?.customIngress ?? ROUTES[route].ingress).map(([x, y]) => [x, y] as [number, number]),
    };
    this.defaultPackage();
    this.el = document.createElement('div');
    this.el.className = 'wt-grid';
    this.root.append(this.el);
    this.map = new StratMap(s);
    this.map.setSpan(150, 45, 0);
    this.map.onSelect = (id) => {
      if (id?.startsWith('picket')) this.setPrimary('pickets');
      else if (id) this.setPrimary(id);
    };
    this.map.editable = { pts: this.plan.customIngress!, egress: ROUTES[route].egress, onChange: (pts) => { this.plan.customIngress = pts; this.render(); } };
    this.el.innerHTML = `<div class="wt-map"></div><div class="wt-ui"></div>`;
    this.el.querySelector('.wt-map')!.append(this.map.canvas);
    this.map.start();
    bind(this.el, {
      back: () => this.host.back(),
      fly: () => this.go(false),
      auto: () => this.go(true),
      add: (t) => this.addFlight(t.dataset.type as AircraftType),
      sel: (t) => { this.selFlight = this.selFlight === t.dataset.id ? null : t.dataset.id!; audio.uiClick(); this.render(); },
      inc: () => this.resize(1),
      dec: () => this.resize(-1),
      mun: (t) => { const f = this.flight(); if (f) { f.munition = t.dataset.m as MunitionId; f.role = this.roleFor(f); } this.render(); },
      wave: (t) => { const f = this.flight(); if (f) f.offsetSec = Number(t.dataset.o); this.render(); },
      me: () => { const f = this.flight(); if (f) this.plan.playerFlight = f.id; this.render(); },
      rm: () => { this.plan.flights = this.plan.flights.filter((f) => f.id !== this.selFlight); this.selFlight = null; this.fixPlayer(); this.render(); },
      ftarget: (t) => { const f = this.flight(); if (f) f.target = t.dataset.t === 'main' ? this.plan.primary : 'pickets'; this.render(); },
      route: (t) => this.setRoute(t.dataset.r as RouteId),
      alt: (t) => { if (t.dataset.col === 'in') this.plan.ingressAlt = t.dataset.band as AltBand; else this.plan.attackAlt = t.dataset.band as AltBand; this.render(); },
      day: (t) => { this.plan.launchHour = (Number(t.dataset.d) - 1) * 24 + hourOfDay(this.plan.launchHour); this.reassign(); this.render(); },
      chaff: () => { this.plan.chaffCorridor = !this.plan.chaffCorridor; this.render(); },
      drones: () => { const opts = [0, 2, 4, 6].filter((n) => n <= this.s.drones); this.plan.drones = opts[(opts.indexOf(this.plan.drones) + 1) % opts.length] ?? 0; this.render(); },
      tanker: () => { this.plan.tanker = !this.plan.tanker; this.render(); },
      targets: () => { this.showTargets = !this.showTargets; this.render(); },
      pick: (t) => { this.showTargets = false; this.setPrimary(t.dataset.id!); },
      standard: () => { this.defaultPackage(); this.selFlight = null; this.render(); },
      roe: () => this.roeDialog(),
    });
    this.render();
  }

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

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

  private flight(): FlightPlan | undefined {
    return this.plan.flights.find((f) => f.id === this.selFlight);
  }

  private roleFor(f: FlightPlan): FlightRole {
    if (f.type === 'Su-7') return f.munition === 'S-24' ? 'suppress' : 'strike';
    return ROLE_FOR[f.type];
  }

  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] ?? []) as MunitionId[];
    if (!l.length) return null;
    const pref = (['FAB-500', 'S-24', 'KSR-11', 'FAB-250', 'FAB-1500'] as MunitionId[]).find((m) => l.includes(m) && this.s.munitions[m] > 0);
    return pref ?? l.find((m) => this.s.munitions[m] > 0) ?? l[0];
  }

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

  private defaultPackage(): void {
    this.plan.flights = [];
    const add = (t: AircraftType, n: number, offset = 0) => {
      const ids = this.free(t).slice(0, n);
      if (!ids.length) return;
      this.plan.flights.push(makeFlight(this.plan.flights.length, t, ROLE_FOR[t], ids, this.defaultMunition(t), this.plan.primary, offset));
    };
    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, -24);
    add('Tu-16PP', 1, -12);
    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(d: number): void {
    const f = this.flight();
    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 setRoute(r: RouteId): void {
    this.plan.route = r;
    this.plan.customIngress = ROUTES[r].ingress.map(([x, y]) => [x, y] as [number, number]);
    this.map.editable = { pts: this.plan.customIngress, egress: ROUTES[r].egress, onChange: (pts) => { this.plan.customIngress = pts; this.render(); } };
    audio.uiClick();
    this.render();
  }

  private reassign(): 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 aimOf(target: string): { x: number; y: number } {
    if (target === 'pickets') {
      const pts = this.plan.customIngress ?? ROUTES[this.plan.route].ingress;
      const refY = pts[Math.min(1, pts.length - 1)][1];
      const [x, y] = BARRIER_RADARS.reduce((a, b) => (Math.abs(b[1] - refY) < Math.abs(a[1] - refY) ? 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 targetName(id: string): string {
    if (id === 'pickets') return 'Early-warning radar';
    if (id.startsWith('sam:')) return `SAM site ${id.slice(4)}`;
    if (id.startsWith('farm:')) return `Fuel farm ${id.slice(5)}`;
    return DISTRICT_BY_ID[id]?.name ?? id;
  }

  /** Expected result and danger for the current plan. */
  private forecast(): { dmg: number; tons: number; threat: number; best: MunitionId[]; risk: number } {
    const s = this.s;
    const p = this.plan;
    const d = DISTRICT_BY_ID[p.primary];
    const tgt = this.aimOf(p.primary);
    const sams = s.sams.filter((q) => !q.revealed && (q.launchers > 0.05 || q.isDecoy) && q.knownHour > -1000 && Math.hypot(q.knownX - tgt.x, q.knownY - tgt.y) < CONFIG.sam[q.kind].range).length;
    let routeDanger = 0;
    const pts = [...(p.customIngress ?? ROUTES[p.route].ingress), [tgt.x, tgt.y] as [number, number]];
    for (let i = 0; i < pts.length - 1; i++) {
      for (let k = 0; k < 10; k++) {
        const t = k / 10;
        if (this.map.dangerAt(pts[i][0] + (pts[i + 1][0] - pts[i][0]) * t, pts[i][1] + (pts[i + 1][1] - pts[i][1]) * t) === 2) routeDanger++;
      }
    }
    const flak = d ? d.aaa.heavy * 3 + d.aaa.bofors * 2 + d.aaa.vulcan : 4;
    const escorts = p.flights.filter((f) => f.role === 'escort').reduce((a, f) => a + f.airframeIds.length, 0);
    const hunters = p.flights.filter((f) => f.role === 'weasel' || f.role === 'jammer').reduce((a, f) => a + f.airframeIds.length, 0);
    const raw = routeDanger * 0.35 + sams * 1.2 + flak / 6 + (isNight(p.launchHour) ? -1 : 1) - escorts * 0.25 - hunters * 0.5 + (p.attackAlt === 'low' ? 1 : 0);
    const threat = Math.max(1, Math.min(5, Math.round(raw / 2.2)));
    if (!d) return { dmg: 0, tons: 0, threat, best: [], risk: 0 };
    const risk = decoyRisk(s, d.id);
    const altHit = p.attackAlt === 'low' ? 0.55 : p.attackAlt === 'med' ? 0.42 : 0.2;
    const lightHit = isNight(p.launchHour) ? 0.7 : 1;
    const rail = d.kind === 'rail' || d.kind === 'interdiction' || d.kind === 'bridge';
    let pts2 = 0;
    let tons = 0;
    for (const f of p.flights) {
      if (f.role !== 'strike' || !f.munition || (f.target ?? p.primary) !== p.primary) continue;
      const M = CONFIG.munitions[f.munition];
      const n = f.loads ? f.loads.reduce((a, b) => a + b, 0) : bombsPerAircraft(f.type, f.munition) * f.airframeIds.length;
      const kg = n * M.kg;
      tons += kg / 1000;
      const eff = rail ? M.vsRail : M.vsHard * d.hardness + M.vsSoft * (1 - d.hardness);
      const hit = M.guided ? 0.85 : altHit * lightHit;
      pts2 += (kg / 100) * M.blast * eff * CONFIG.mission.damageScale * hit * (1 - risk);
    }
    const best = (Object.keys(CONFIG.munitions) as MunitionId[])
      .filter((m) => !['KSR-11', 'S-24'].includes(m))
      .map((m) => ({ m, v: (rail ? CONFIG.munitions[m].vsRail : CONFIG.munitions[m].vsHard * d.hardness + CONFIG.munitions[m].vsSoft * (1 - d.hardness)) * CONFIG.munitions[m].blast }))
      .sort((a, b) => b.v - a.v)
      .slice(0, 2)
      .map((x) => x.m);
    return { dmg: Math.min(1, pts2 / d.hp), tons, threat, best, risk };
  }

  // ── Rendering ──
  private render(): void {
    const s = this.s;
    const p = this.plan;
    lastPlan = { primary: p.primary, route: p.route, ingressAlt: p.ingressAlt, attackAlt: p.attackAlt, customIngress: p.customIngress };
    this.map.aim = this.aimOf(p.primary);
    this.map.selected = DISTRICT_BY_ID[p.primary] ? p.primary : null;
    allocateLoads(s, p);
    const problems = planProblems(s, p);
    const warnings = planWarnings(s, p);
    const viol = roeViolations(s, p);
    const blocked = problems.length > 0 || (viol.length > 0 && !p.ignoreROE);
    const fc = this.forecast();
    const ui = this.el.querySelector('.wt-ui')!;
    ui.innerHTML = `
      ${this.topBar(fc, problems, warnings, viol, blocked)}
      ${this.leftRail()}
      ${this.bottom()}
      ${this.showTargets ? this.targetSheet() : ''}`;
    // The clock is a dial: click anywhere on it.
    const clock = ui.querySelector<SVGSVGElement>('.wt-clock svg');
    clock?.addEventListener('click', (e) => {
      const r = clock.getBoundingClientRect();
      const a = Math.atan2(e.clientY - (r.top + r.height / 2), e.clientX - (r.left + r.width / 2));
      const hour = Math.round((((a + Math.PI / 2 + Math.PI * 2) % (Math.PI * 2)) / (Math.PI * 2)) * 24) % 24;
      let launch = (dayOf(p.launchHour) - 1) * 24 + hour;
      if (launch < Math.ceil(earliestLaunch(s))) launch += 24;
      p.launchHour = launch;
      this.reassign();
      audio.uiClick();
      this.render();
    });
  }

  private topBar(fc: ReturnType<PlannerView['forecast']>, problems: string[], warnings: string[], viol: string[], blocked: boolean): string {
    const s = this.s;
    const p = this.plan;
    const d = DISTRICT_BY_ID[p.primary];
    const pic = d ? districtPicture(d, s.districts[d.id].knownIntegrity, 160, 90, 'photo') : '';
    const skulls = Array.from({ length: 5 }, (_, i) => `<span class="sk ${i < fc.threat ? 'on' : ''}">${ICONS.skull}</span>`).join('');
    const earliest = Math.ceil(earliestLaunch(s));
    const days = Array.from({ length: Math.max(1, Math.min(5, CONFIG.campaign.days - dayOf(earliest) + 1)) }, (_, i) => dayOf(earliest) + i);
    const fuel = fuelNeed(p);
    const civ = d && (d.kind === 'housing' || d.kind === 'altstadt' || d.kind === 'hospital');
    return `<div class="wt-top">
      <button type="button" class="wt-back" data-act="back" data-tip="Back to the command post.">←</button>
      <button type="button" class="wt-target" data-act="targets" data-tip="<b>Target</b>Tap a target on the map, or tap here for the list.">
        ${pic ? `<img src="${pic}" alt="">` : `<span class="wt-tico">${ICONS.radar}</span>`}
        <span class="wt-tname">${esc(this.targetName(p.primary))}</span>
        <span class="wt-meter" data-tip="<b>Expected damage</b>${Math.round(fc.dmg * 100)}% from ${fc.tons.toFixed(0)} t aimed here, at this height and light."><i style="width:${Math.round(fc.dmg * 100)}%"></i><b>${Math.round(fc.dmg * 100)}%</b></span>
        <span class="wt-skulls" data-tip="<b>Danger ${fc.threat}/5</b>SAMs, flak and your route.">${skulls}</span>
        ${fc.risk > 0.15 ? `<span class="wt-flag" data-tip="<b>Decoy risk ${Math.round(fc.risk * 100)}%</b>Old photos. Send recon first.">${ICONS.eye}</span>` : ''}
        ${civ ? `<span class="wt-flag" data-tip="<b>Civilians</b>People live here. Deaths cost political capital.">${ICONS.people}</span>` : ''}
        ${fc.best.length ? `<span class="wt-best" data-tip="<b>Best weapons here</b>${fc.best.map((m) => CONFIG.munitions[m].name).join(', ')}">${fc.best.map((m) => munIcon(m)).join('')}</span>` : ''}
      </button>
      <div class="wt-days" role="group" aria-label="Day">${days.map((dd) => `<button type="button" class="wt-day ${dd === dayOf(p.launchHour) ? 'on' : ''}" data-act="day" data-d="${dd}" data-tip="Launch on day ${dd}. Waiting repairs your aircraft — and the city.">${dd}</button>`).join('')}</div>
      <div class="wt-clock" data-tip="<b>Launch hour</b>Tap the dial. Blue = night: searchlights, fewer fighters, scattered bombs.">${this.clockSvg()}</div>
      <span class="wt-fuel" data-tip="<b>Fuel</b>This raid ${fuel} t of ${Math.round(s.fuel)} t.">${ICONS.fuel}<i><b style="width:${Math.min(100, (fuel / Math.max(1, s.fuel)) * 100)}%"></b></i></span>
      <span class="wt-spacer"></span>
      ${viol.length ? `<button type="button" class="wt-alert stop ${p.ignoreROE ? 'ignored' : ''}" data-act="roe" data-tip="<b>Moscow forbids this</b>${viol.map(esc).join('<br>')}<br><i>Tap to fly it anyway.</i>">${ICONS.stop}<span>ROE</span></button>` : ''}
      ${problems.length ? `<span class="wt-alert stop" data-tip="<b>Can't launch yet</b>${problems.map(esc).join('<br>')}">${ICONS.stop}<span>${problems.length}</span></span>` : ''}
      ${warnings.length ? `<span class="wt-alert warn" data-tip="<b>Heads up</b>${warnings.map(esc).join('<br>')}">${ICONS.warn}<span>${warnings.length}</span></span>` : ''}
      <button type="button" class="wt-go" data-act="auto" ${blocked ? 'disabled' : ''} data-tip="Let the AI fly the raid and skip to the results.">AI ⏩</button>
      <button type="button" class="wt-go primary" data-act="fly" ${blocked ? 'disabled' : ''} data-tip="${blocked ? 'Fix the red alerts first.' : 'Take off!'}">FLY ▶</button>
    </div>`;
  }

  private clockSvg(): string {
    const h = hourOfDay(this.plan.launchHour);
    const R = 30;
    let arcs = '';
    for (let i = 0; i < 24; i++) {
      const a0 = (i / 24) * Math.PI * 2 - Math.PI / 2;
      const a1 = ((i + 1) / 24) * Math.PI * 2 - Math.PI / 2;
      const night = i < 5 || i >= 20;
      const dusk = (i >= 5 && i < 7) || (i >= 18 && i < 20);
      const col = night ? '#2a3a8a' : dusk ? '#c0662e' : '#e3b04a';
      arcs += `<path d="M${(40 + Math.cos(a0) * R).toFixed(1)} ${(40 + Math.sin(a0) * R).toFixed(1)} A${R} ${R} 0 0 1 ${(40 + Math.cos(a1) * R).toFixed(1)} ${(40 + Math.sin(a1) * R).toFixed(1)}" stroke="${col}" stroke-width="10" fill="none"/>`;
    }
    const a = (h / 24) * Math.PI * 2 - Math.PI / 2;
    const kx = 40 + Math.cos(a) * R;
    const ky = 40 + Math.sin(a) * R;
    const knob = isNight(this.plan.launchHour)
      ? `<circle cx="${kx}" cy="${ky}" r="9" fill="#dfe6ff"/><circle cx="${kx + 4}" cy="${ky - 3}" r="7" fill="#2a3a8a"/>`
      : `<circle cx="${kx}" cy="${ky}" r="8" fill="#fff2b0" stroke="#ffb347" stroke-width="3"/>`;
    return `<svg viewBox="0 0 80 80" role="img" aria-label="Launch hour ${h}">${arcs}<line x1="40" y1="40" x2="${kx}" y2="${ky}" stroke="#e8f8fc" stroke-width="2"/>${knob}<text x="40" y="45" text-anchor="middle">${String(h).padStart(2, '0')}</text></svg>`;
  }

  private leftRail(): string {
    const p = this.plan;
    const anyFighter = p.flights.some((f) => CONFIG.aircraft[f.type].fighter);
    const hasJammer = p.flights.some((f) => f.role === 'jammer');
    const route = (r: RouteId, tip: string) => `<button type="button" class="wt-ibtn ${p.route === r ? 'on' : ''}" data-act="route" data-r="${r}" data-tip="${tip}">${ICONS[r === 'center' ? 'direct' : r]}</button>`;
    const band = (col: 'in' | 'atk', b: AltBand, y: number) => {
      const on = (col === 'in' ? p.ingressAlt : p.attackAlt) === b;
      const x = col === 'in' ? 22 : 72;
      return `<g class="band ${on ? 'on' : ''}" data-act="alt" data-col="${col}" data-band="${b}"><rect x="${x}" y="${y}" width="46" height="26" rx="4"/>${on ? `<path d="M${x + 32} ${y + 13} l-14 -6 v12z" class="pl"/>` : ''}</g>`;
    };
    return `<div class="wt-left">
      <div class="wt-box" data-tip="<b>Route</b>Pick a way in, then drag the yellow points on the map. Green = quiet, orange = early-warning radar, red = inside SAM range.">
        ${route('center', 'Straight through the barrier. Short.')}${route('north', 'Around the north. Long.')}${route('south', 'Around the south. Long.')}
      </div>
      <div class="wt-box wt-alt" data-tip="<b>Altitude</b>Left: on the way in. Right: over the target. High = SAMs, scattered bombs. Low = flak, accurate, under radar.">
        <svg viewBox="0 0 120 96">
          <text x="45" y="8" class="h">✈</text><text x="95" y="8" class="h">◆</text>
          ${band('in', 'high', 10)}${band('atk', 'high', 10)}${band('in', 'med', 40)}${band('atk', 'med', 40)}${band('in', 'low', 70)}${band('atk', 'low', 70)}
          <path d="M5 30 l7 -14 7 14z" class="sam"/><g class="flak"><circle cx="9" cy="84" r="4"/><circle cx="16" cy="78" r="3"/></g>
        </svg>
      </div>
      <div class="wt-box">
        <button type="button" class="wt-ibtn ${p.chaffCorridor ? 'on' : ''}" data-act="chaff" ${hasJammer ? '' : 'disabled'} data-tip="<b>Chaff corridor</b>The jammer lays a wall of chaff along your route. Needs a Tu-16PP.">${ICONS.chaff}</button>
        <button type="button" class="wt-ibtn ${p.drones ? 'on' : ''}" data-act="drones" data-tip="<b>Decoy drones: ${p.drones}</b>Tap for more. They draw SAM fire and wake the radars. ${this.s.drones} left.">${ICONS.drone}${p.drones ? `<span class="badge">${p.drones}</span>` : ''}</button>
        <button type="button" class="wt-ibtn ${p.tanker ? 'on' : ''}" data-act="tanker" ${anyFighter ? '' : 'disabled'} data-tip="<b>Tanker</b>Lets fighters fly the long way round without turning back.">${ICONS.tanker}</button>
      </div>
    </div>`;
  }

  private bottom(): string {
    const p = this.plan;
    const tokens = [...p.flights].sort((a, b) => a.offsetSec - b.offsetSec).map((f) => {
      const per = bombsPerAircraft(f.type, f.munition);
      const short = f.munition && f.loads && f.loads.some((n) => n < per);
      return `<button type="button" class="wt-token ${f.id === this.selFlight ? 'sel' : ''} ${f.id === p.playerFlight ? 'me' : ''}" data-act="sel" data-id="${f.id}" data-tip="<b>${esc(CONFIG.aircraft[f.type].name)}</b>${esc(ROLE_BADGE[f.role])} · ${f.airframeIds.length} aircraft${f.munition ? ` · ${esc(f.munition)}` : ''}${short ? ' · <em>short of weapons</em>' : ''}${f.id === p.playerFlight ? '<br>★ You fly this one.' : ''}<br><i>Tap to change.</i>">
        <img src="${planeCard(f.type, 96, 64)}" alt="">
        <span class="cnt">×${f.airframeIds.length}</span>
        ${f.munition ? `<span class="mun ${short ? 'short' : ''}">${munIcon(f.munition)}</span>` : ''}
        <span class="tim">${timingIcon(f.offsetSec)}</span>
        ${f.id === p.playerFlight ? `<span class="me-star">${ICONS.star}</span>` : ''}
        <span class="role">${ROLE_BADGE[f.role]}</span>
      </button>`;
    }).join('');
    const sel = this.flight();
    return `<div class="wt-bottom">
      <div class="wt-pack">
        <span class="wt-axis" data-tip="Flights line up by when they reach the target: early on the left, late on the right.">⏪</span>
        ${tokens || '<span class="wt-empty">Tap an aircraft below ↓</span>'}
        <span class="wt-axis">⏩</span>
        <button type="button" class="wt-ibtn small" data-act="standard" data-tip="Reset to a balanced package.">↺</button>
      </div>
      ${sel ? this.editor(sel) : this.hangar()}
    </div>`;
  }

  private hangar(): string {
    const items = AIRCRAFT_TYPES.filter((t) => this.s.fleet.some((a) => a.type === t && a.status !== 'lost')).map((t) => {
      const n = this.free(t).length;
      const spec = CONFIG.aircraft[t];
      return `<button type="button" class="wt-hangar" data-act="add" data-type="${t}" ${n ? '' : 'disabled'} data-tip="<b>${esc(spec.name)}</b>${esc(AIRCRAFT_HELP[t] ?? '')}<br>${n} ready — tap to add.">
        <img src="${planeCard(t, 96, 64)}" alt=""><span class="cnt ${n ? '' : 'zero'}">${n}</span><span class="role">${ROLE_BADGE[ROLE_FOR[t]]}</span>
      </button>`;
    }).join('');
    return `<div class="wt-hangar-row">${items}</div>`;
  }

  private editor(f: FlightPlan): string {
    const s = this.s;
    const muns = (CONFIG.loadouts[f.type] ?? []) as MunitionId[];
    const per = bombsPerAircraft(f.type, f.munition);
    const loaded = f.loads ? f.loads.reduce((a, b) => a + b, 0) : per * f.airframeIds.length;
    const canTargetRadar = f.role === 'suppress' || f.role === 'strike';
    return `<div class="wt-editor">
      <img class="big" src="${planeCard(f.type, 144, 96)}" alt="" data-tip="${esc(AIRCRAFT_HELP[f.type] ?? '')}">
      <div class="grp" data-tip="How many aircraft in this flight.">
        <button type="button" class="wt-ibtn" data-act="dec" aria-label="Fewer">${ICONS.minus}</button>
        <span class="big-n">${f.airframeIds.length}</span>
        <button type="button" class="wt-ibtn" data-act="inc" aria-label="More" ${this.free(f.type, f).length > f.airframeIds.length ? '' : 'disabled'}>${ICONS.plus}</button>
      </div>
      ${muns.length ? `<div class="grp muns">${muns.map((m) => `<button type="button" class="wt-mun ${m === f.munition ? 'on' : ''}" data-act="mun" data-m="${m}" ${s.munitions[m] ? '' : 'disabled'} data-tip="<b>${esc(CONFIG.munitions[m].name)}</b>${esc(MUNITION_HELP[m] ?? '')}<br>${s.munitions[m]} in stock.">${munIcon(m)}<span>${s.munitions[m]}</span></button>`).join('')}</div>` : ''}
      ${f.munition ? `<div class="grp load" data-tip="Weapons loaded on this flight.">${Array.from({ length: Math.min(12, loaded) }, () => munIcon(f.munition!)).join('')}${loaded > 12 ? `<span>+${loaded - 12}</span>` : ''}${loaded === 0 ? '<span class="bad">EMPTY</span>' : ''}</div>` : ''}
      <div class="grp waves" data-tip="When this flight reaches the target. Blue = early, yellow = with the bombers, orange = late.">${OFFSETS.map((o) => `<button type="button" class="wt-wave ${o === f.offsetSec ? 'on' : ''}" data-act="wave" data-o="${o}" aria-label="Arrival ${o}">${timingIcon(o)}</button>`).join('')}</div>
      ${canTargetRadar ? `<div class="grp" data-tip="This flight's target: the main target, or the barrier radar (blind the barrier first).">
        <button type="button" class="wt-ibtn ${f.target !== 'pickets' ? 'on' : ''}" data-act="ftarget" data-t="main">${ICONS.target}</button>
        <button type="button" class="wt-ibtn ${f.target === 'pickets' ? 'on' : ''}" data-act="ftarget" data-t="radar">${ICONS.radar}</button>
      </div>` : ''}
      <div class="grp">
        <button type="button" class="wt-ibtn ${f.id === this.plan.playerFlight ? 'on' : ''}" data-act="me" data-tip="<b>Fly this one</b>You lead this flight.">${ICONS.star}</button>
        <button type="button" class="wt-ibtn" data-act="rm" data-tip="Remove this flight.">${ICONS.trash}</button>
        <button type="button" class="wt-ibtn" data-act="sel" data-id="${f.id}" data-tip="Done — back to the hangar.">✓</button>
      </div>
    </div>`;
  }

  private targetSheet(): string {
    const s = this.s;
    const tier = roeTier(s);
    const groups = TARGET_GROUPS.map(([name, ids]) => `<div class="tg"><span class="tg-h">${esc(name)}</span><div class="tg-row">${ids.map((id) => {
      const d = DISTRICT_BY_ID[id];
      const nogo = d && !s.linebacker && (tier.noGo.includes(d.kind) || tier.noGo.includes(d.id));
      const integ = d ? s.districts[id].knownIntegrity : 1;
      const icon = id === 'pickets' ? districtIcon('radar') : districtIcon(d.kind, d.kind === 'housing' || d.kind === 'altstadt' ? '#cfe6ee' : d.restricted ? '#9fd0ff' : '#ffd9a0');
      return `<button type="button" class="tg-btn ${id === this.plan.primary ? 'on' : ''} ${nogo ? 'nogo' : ''}" data-act="pick" data-id="${id}" data-tip="<b>${esc(this.targetName(id))}</b>${Math.round(integ * 100)}% intact${nogo ? '<br><em>Forbidden by Moscow</em>' : ''}">
        <img src="${icon}" alt=""><i style="width:${Math.round(integ * 100)}%"></i>${nogo ? `<span class="x">${ICONS.stop}</span>` : ''}</button>`;
    }).join('')}</div></div>`).join('');
    const sams = s.sams.filter((q) => q.knownHour > -1000 && (q.launchers > 0.05 || q.isDecoy) && !q.revealed);
    return `<div class="wt-sheet">
      ${groups}
      ${sams.length ? `<div class="tg"><span class="tg-h">SAM sites</span><div class="tg-row">${sams.map((q) => `<button type="button" class="tg-btn sam ${`sam:${q.id}` === this.plan.primary ? 'on' : ''}" data-act="pick" data-id="sam:${q.id}" data-tip="<b>SAM ${q.id}</b>${q.kind === 'nike' ? 'Nike Hercules' : 'Bloodhound'}">${ICONS.radar}<b>${q.id}</b></button>`).join('')}</div></div>` : ''}
    </div>`;
  }

  private roeDialog(): void {
    const viol = roeViolations(this.s, this.plan);
    modal(this.root, `<h2>Moscow forbids this</h2><ul class="news">${viol.map((v) => `<li>${esc(v)}</li>`).join('')}</ul>
      <p>Fly it anyway and lose ${CONFIG.politics.roeViolation} capital for each.</p>
      <div class="row"><button type="button" class="danger" data-act="yes">Fly it anyway</button><button type="button" data-act="no">Obey the rules</button></div>`, {
      yes: (_t, close) => { this.plan.ignoreROE = true; close(); this.render(); },
      no: (_t, close) => { this.plan.ignoreROE = false; close(); this.render(); },
    });
  }

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