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.
// 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);
}
}
