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.
// Strategic layer: the regiment's command post.
import { audio } from '../game/audio';
import {
adaptationLevel, bridgeFactor, criticalNodesDown, decoyRisk, logisticsIndex, powerIndex, routeStatus, samPower, warOutput,
} from '../game/city';
import { CONFIG, AIRCRAFT_TYPES, MUNITION_IDS } from '../game/config';
import {
abandonCaptive, advanceTo, attemptRescue, declareLinebacker, earliestLaunch, fleetCounts, readyAt, roeTier, runRecon,
} from '../game/campaign';
import { CRITICAL_NODES, DISTRICT_BY_ID, SUPPLY_ROUTES } from '../game/map';
import { dayOf, fmtTime, isNight, type GameState } from '../game/state';
import { bar, bind, esc, modal, pct } from './dom';
import { planeCard } from '../art/sprites';
import { districtPicture } from '../art/terrain';
const picCache = new Map<string, string>();
function pictureFor(id: string, integrity: number): string {
const key = `${id}|${Math.round(integrity * 10)}`;
let url = picCache.get(key);
if (!url) {
url = districtPicture(DISTRICT_BY_ID[id], integrity, 300, 170, 'color');
picCache.set(key, url);
}
return url;
}
export const AIRCRAFT_HELP: Record<string, string> = {
'Tu-16': 'Medium jet bomber, 9 t of bombs, tail guns. The workhorse.',
'Tu-22': 'Supersonic dash on the run-in, big standoff missile. Clumsy when slow and low.',
'Tu-95': 'Turboprop giant: 15 t of bombs, slow and loud. Needs heavy jamming to survive.',
'Il-28': 'Light jet bomber: cheap, nimble down low, 3 t load. Good night raider.',
'MiG-21': 'Fast escort fighter: cannon and 2 R-3S Atoll missiles. Short legs.',
'Su-7': 'Fighter-bomber: S-24 rockets to kill flak guns and radars at low level.',
'Yak-28': 'Radar hunter ("Wild Weasel"): KSR-11 Kelt anti-radiation missiles.',
'Tu-16PP': 'Jammer: blinds SAM radars near it and lays chaff corridors.',
'MiG-25R': 'Mach 3 reconnaissance. Untouchable. Brings back fresh photos.',
};
import { StratMap } from './stratmap';
export interface HqHost {
s: GameState;
plan(target?: string): void;
persist(): void;
title(): void;
newCampaign(): void;
end(): void;
canSave: boolean;
}
type Tab = 'city' | 'intel' | 'fleet' | 'news' | 'crews';
export class HqView {
private map: StratMap;
private tab: Tab = 'city';
private el: HTMLElement;
private side!: HTMLElement;
private sideOpen = false;
constructor(private root: HTMLElement, private host: HqHost) {
this.root.replaceChildren();
this.root.className = 'screen hq';
this.el = document.createElement('div');
this.el.className = 'hq-grid';
this.root.append(this.el);
this.map = new StratMap(host.s);
this.map.onSelect = (id) => {
if (id) {
this.tab = 'intel';
this.sideOpen = true;
audio.uiClick();
}
this.renderSide();
};
this.render();
this.map.start();
}
destroy(): void {
this.map.stop();
}
private get s(): GameState {
return this.host.s;
}
private render(): void {
const s = this.s;
this.el.innerHTML = `
<header class="hq-bar"></header>
<div class="hq-main">
<div class="map-wrap">
<div class="map-ctl" role="group" aria-label="Map zoom">
<button type="button" class="chip" data-act="zoomCity" data-tip="Zoom to the city: every district, its icon and condition.">City</button>
<button type="button" class="chip" data-act="zoomTheater" data-tip="Zoom out to the whole theater: your bases are east (right), the barrier line, the supply lines and the sea to the west.">Theater</button>
<button type="button" class="chip" data-act="legend" data-tip="What the symbols on this map mean.">Legend</button>
<button type="button" class="chip side-toggle" data-act="toggleSide" aria-expanded="false" data-tip="Show the staff reports panel.">Info</button>
</div>
<div class="map-legend" hidden>
<b>MAP LEGEND</b>
<ul>
<li><span class="sw amber"></span>Amber blocks — industry & infrastructure. Bright = has power, dim = dark</li>
<li><span class="sw pale"></span>Pale blue blocks — where people live</li>
<li><span class="sw hatch"></span>Hatching — damage seen on the last photos</li>
<li><span class="sw sam"></span>Red hexagon + ring — SAM battery and its reach (dashed = old photo)</li>
<li><span class="sw radar"></span>Red tower — early-warning radar on the barrier (dashed red line)</li>
<li><span class="sw flow"></span>Moving dots — supply trains, barges and convoys. Red dot = line cut</li>
<li><span class="sw fire"></span>Orange glow + smoke — fires</li>
<li><span class="sw heat"></span>Orange specks — where your bombs fell</li>
<li><span class="sw decoy"></span>Yellow X — a revealed decoy</li>
</ul>
<p>Hover anything for details. Click to select it, then <b>Plan raid</b>.</p>
</div>
</div>
<aside class="hq-side" aria-label="Staff reports"></aside>
</div>
<footer class="hq-actions">
<button type="button" class="primary" data-act="plan" data-tip="Open the raid order: pick the target, launch time, route and the aircraft in the package.">Plan<span class="long"> raid</span></button>
<button type="button" data-act="recon" data-tip="Send a photo-recon flight (6 h). Fresh photos reveal decoys, moved SAM sites and real damage.">Recon</button>
<button type="button" data-act="wait6" aria-label="Wait 6 hours" data-tip="Let 6 hours pass. Your crews rest and repair — and so does the city.">6h<span class="long"> wait</span></button>
<button type="button" data-act="wait24" aria-label="Wait 24 hours" data-tip="Let a full day pass.">24h<span class="long"> wait</span></button>
<button type="button" data-act="menu" aria-label="Menu" data-tip="Save, title screen, new campaign.">≡</button>
</footer>
<div class="ticker" aria-label="Enemy press"><div class="ticker-in"></div></div>`;
this.el.querySelector('.map-wrap')!.prepend(this.map.canvas);
this.side = this.el.querySelector<HTMLElement>('.hq-side')!;
bind(this.el, {
plan: () => this.host.plan(this.map.selected ?? undefined),
recon: () => this.recon(),
wait6: () => this.wait(6),
wait24: () => this.wait(24),
menu: () => this.menu(),
zoomCity: () => this.map.setSpan(62),
zoomTheater: () => this.map.setSpan(210, 30, 0),
legend: () => { const l = this.el.querySelector<HTMLElement>('.map-legend')!; l.hidden = !l.hidden; },
toggleSide: () => { this.sideOpen = !this.sideOpen; this.renderSide(); },
tab: (t) => { this.tab = t.dataset.tab as Tab; this.renderSide(); },
strike: (t) => this.host.plan(t.dataset.id),
rescue: (t) => this.rescue(Number(t.dataset.i)),
abandon: (t) => { abandonCaptive(this.s, Number(t.dataset.i)); this.refresh(); },
linebacker: () => this.linebacker(),
});
this.refresh();
void s;
}
refresh(): void {
this.map.setState(this.s);
this.renderBar();
this.renderSide();
this.renderTicker();
}
private renderBar(): void {
const s = this.s;
const out = warOutput(s);
const tier = roeTier(s);
const daysLeft = Math.max(0, CONFIG.campaign.days - (dayOf(s.hour) - 1));
const hist = s.outputHistory.slice(-60).map((h) => h.output);
const spark = hist.length > 1 ? hist.map((v, i) => `${(i / (hist.length - 1)) * 60},${16 - Math.min(1, v) * 16}`).join(' ') : '';
this.el.querySelector('.hq-bar')!.innerHTML = `
<div class="clock" data-tip="Campaign clock. The summer offensive begins on day ${CONFIG.campaign.days}; Hafenstadt's war output must be broken by then."><b>${fmtTime(s.hour)}</b><span>${isNight(s.hour) ? 'NIGHT' : 'DAY'} · ${daysLeft} days to the offensive</span></div>
<div class="meter" data-tip="How much war material the city still produces (100% = untouched). Win: below ${Math.round(CONFIG.campaign.winOutput * 100)}% on day ${CONFIG.campaign.days}. The line on the chart is the goal."><span class="k">War output</span><b class="${out.output < CONFIG.campaign.winOutput ? 'good' : out.output > 0.6 ? 'bad' : ''}">${pct(out.output)}</b>
<svg class="spark" viewBox="0 0 60 16" aria-hidden="true"><line x1="0" x2="60" y1="${16 - CONFIG.campaign.winOutput * 16}" y2="${16 - CONFIG.campaign.winOutput * 16}" class="goal"/><polyline points="${spark}"/></svg></div>
<div class="meter" data-tip="Moscow's patience. Civilian deaths, restricted targets and lost aircraft drain it. Low capital brings restrictions; zero ends your command."><span class="k">Political capital</span><b class="${s.political < 30 ? 'bad' : ''}">${Math.round(s.political)}</b>${bar(s.political / 100, s.political < 30 ? 'red' : 'amber')}<span class="roe ${s.linebacker ? 'lb' : ''}">${s.linebacker ? 'ALL-OUT' : `ROE ${tier.name}`}</span></div>
<div class="meter small" data-tip="How many counter-measures the city has built against your habits: new SAMs, flak, decoys, firebreaks…"><span class="k">City adaptation</span><b>${adaptationLevel(s)}</b></div>`;
}
private renderTicker(): void {
const items = this.s.news.filter((n) => n.kind === 'enemy' || n.kind === 'moral').slice(0, 8);
const t = this.el.querySelector<HTMLElement>('.ticker-in')!;
t.textContent = items.length ? items.map((n) => `HAFENSTÄDTER ABENDBLATT — ${n.text}`).join(' ✦ ') : 'HAFENSTÄDTER ABENDBLATT — "The city works. The city watches the sky."';
}
private renderSide(): void {
const s = this.s;
this.side.classList.toggle('open', this.sideOpen);
this.el.querySelector('.side-toggle')?.setAttribute('aria-expanded', String(this.sideOpen));
const pending = s.captives.filter((c) => c.resolved === 'pending').length;
const tabs: [Tab, string, string][] = [
['city', 'City', 'How Hafenstadt is doing: output, power, supply lines, people, stockpiles.'],
['intel', 'Intel', 'Details and a photo of whatever you selected on the map.'],
['fleet', 'Fleet', 'Your aircraft, crews and bomb stocks.'],
['news', 'Wire', 'Enemy newspaper and regimental messages.'],
['crews', `Crews${pending ? ` (${pending})` : ''}`, 'Crews shot down behind the lines — rescue them or not.'],
];
let body = '';
if (this.tab === 'city') body = this.cityTab();
if (this.tab === 'intel') body = this.intelTab();
if (this.tab === 'fleet') body = this.fleetTab();
if (this.tab === 'news') body = this.newsTab();
if (this.tab === 'crews') body = this.crewsTab();
this.side.innerHTML = `<div class="tabs" role="tablist">${tabs.map(([k, l, tip]) => `<button type="button" role="tab" aria-selected="${this.tab === k}" class="tab ${this.tab === k ? 'on' : ''}" data-act="tab" data-tab="${k}" data-tip="${tip}">${l}</button>`).join('')}</div><div class="side-body">${body}</div>`;
}
private cityTab(): string {
const s = this.s;
const o = warOutput(s);
const c = s.city;
const crit = CRITICAL_NODES.map((id) => {
const v = id === 'bridge' ? bridgeFactor(s) : s.districts[id].knownIntegrity;
return `<li><span>${esc(DISTRICT_BY_ID[id].name)}</span>${bar(v, v < CONFIG.campaign.criticalNodeDownThreshold ? 'red' : '')}</li>`;
}).join('');
const routes = SUPPLY_ROUTES.map((r) => `<li><span>${esc(r.name)}</span>${bar(routeStatus(s, r.id))}</li>`).join('');
return `
<h3>War output ${pct(o.output)}</h3>
<ul class="kv bars">
<li><span>Industry</span>${bar(o.industry)}</li>
<li><span>Logistics</span>${bar(o.logistics)}</li>
<li><span>Power</span>${bar(o.power)}</li>
<li><span>Workforce</span>${bar(o.workforce)}</li>
</ul>
<ul class="kv bars small">
<li><span>Steel</span>${bar(o.steel)}</li><li><span>Machine works</span>${bar(o.machine)}</li>
<li><span>Chemicals</span>${bar(o.chem)}</li><li><span>Neighborhood workshops</span>${bar(o.workshops)}</li>
<li><span>Satellite towns</span>${bar(o.satellites)}</li><li><span>River port</span>${bar(o.port)}</li>
</ul>
<h3>Critical nodes ${criticalNodesDown(s) ? `<em class="good">ALL DOWN · ${Math.round(s.criticalDownHours / 24 * 10) / 10} d</em>` : ''}</h3>
<ul class="kv bars">${crit}</ul>
<p class="note">Hold power, yards, the river crossing and fuel down together for ${CONFIG.campaign.criticalNodesDaysForWin} days to win outright.</p>
<h3>Supply lines</h3>
<ul class="kv bars">${routes}</ul>
<p class="note">Pontoon ferry ${pct(c.pontoon)} · SAM radar power ${pct(samPower(s))} · grid ${pct(powerIndex(s))} · logistics ${pct(logisticsIndex(s))}</p>
<h3>People</h3>
<ul class="kv">
<li><span>Population</span><b>${c.population.toLocaleString('en')}</b></li>
<li><span>Workers</span><b>${c.workers.toLocaleString('en')}</b></li>
<li><span>Civilian dead</span><b class="bad">${c.casualties.toLocaleString('en')}</b></li>
<li><span>Evacuated</span><b>${c.evacuated.toLocaleString('en')}</b></li>
<li><span>Morale</span>${bar(c.morale)}</li>
<li><span>Resolve (repair mobilization)</span>${bar(c.resolve, 'amber')}</li>
</ul>
<h3>Stockpiles (days)</h3>
<ul class="kv">
<li><span>Coal & ore</span><b>${c.stock.coal.toFixed(1)}</b></li><li><span>Fuel</span><b>${c.stock.fuel.toFixed(1)}</b></li>
<li><span>Repair steel</span><b>${c.stock.steel.toFixed(1)}</b></li><li><span>SAM reloads</span><b>${c.stock.samReloads.toFixed(1)}</b></li>
</ul>
<p class="note">Interceptors (est.): ${Object.entries(c.interceptors).map(([k, v]) => `${k} ${v}`).join(' · ')}</p>`;
}
private intelTab(): string {
const s = this.s;
const id = this.map.selected;
if (!id) return '<p class="note">Tap a district, SAM site or fuel farm on the map.</p>' + this.samList();
if (id.startsWith('sam:')) {
const site = s.sams.find((q) => q.id === id.slice(4));
if (!site) return '';
const age = Math.round((s.hour - site.knownHour) / 24 * 10) / 10;
return `<h3>SAM site ${esc(site.id)}</h3>
<ul class="kv"><li><span>Type</span><b>${site.kind === 'nike' ? 'Nike Hercules' : 'Bloodhound'}</b></li>
<li><span>Range</span><b>${CONFIG.sam[site.kind].range} km</b></li>
<li><span>Photographed</span><b>${site.knownHour < -1000 ? 'never' : `${age} d ago`}</b></li>
<li><span>Status</span><b>${site.revealed ? 'DECOY' : site.disabledUntil > s.hour ? `knocked out until ${fmtTime(site.disabledUntil)}` : 'believed active'}</b></li></ul>
<p class="note">Batteries relocate after raids. Strike stale positions and you may hit an empty field — or plywood.</p>
<button type="button" class="primary" data-act="strike" data-id="${esc(id)}" data-tip="Open the raid planner with this SAM site as the target. RBK cluster bombs and Kelts work best.">Plan raid on this site</button>`;
}
if (id.startsWith('farm:')) {
return `<h3>Fuel drum farm ${esc(id.slice(5))}</h3><p class="note">Hidden fuel reserves dispersed in the hills. Tedious, but every farm destroyed starves the SAM trucks and interceptors.</p>
<button type="button" class="primary" data-act="strike" data-id="${esc(id)}" data-tip="Open the raid planner with this fuel farm as the target.">Plan raid on this farm</button>`;
}
const d = DISTRICT_BY_ID[id];
const st = s.districts[id];
const age = Math.round(((s.hour - st.intelHour) / 24) * 10) / 10;
const tier = roeTier(s);
const nogo = !s.linebacker && (tier.noGo.includes(d.kind) || tier.noGo.includes(d.id));
const risk = decoyRisk(s, id);
return `<h3>${esc(d.name)}</h3>
<figure class="intel-photo"><img src="${pictureFor(d.id, st.knownIntegrity)}" alt="Reconnaissance photo of ${esc(d.name)}"><figcaption>Recon photo · ${age} d old · ${Math.round(st.knownIntegrity * 100)}% intact</figcaption></figure>
${d.restricted ? '<p class="warn">Restricted target. Striking it costs heavy political capital.</p>' : ''}
${nogo ? `<p class="warn">No-go under ROE ${tier.name}.</p>` : ''}
<ul class="kv bars">
<li><span>Integrity (as of recon)</span>${bar(st.knownIntegrity)}</li>
<li><span>Fire</span>${bar(st.fire, 'red')}</li>
</ul>
<ul class="kv">
<li><span>Intel age</span><b class="${age > CONFIG.adaptation.intelFreshDays ? 'bad' : ''}">${age} d</b></li>
<li><span>Decoy risk</span><b class="${risk > 0.25 ? 'bad' : ''}">${pct(risk)}</b></li>
<li><span>Power</span><b>${st.powered ? 'ON' : 'DARK'}</b></li>
<li><span>Hardness</span><b>${pct(d.hardness)}</b></li>
<li><span>Flammability</span><b>${pct(d.flammable * (1 - st.firebreak))}</b></li>
<li><span>Output dispersed</span><b>${pct(st.dispersal)}</b></li>
<li><span>Delayed bombs</span><b>${st.ldbHours > 0 ? `${Math.round(st.ldbHours)} h` : '—'}</b></li>
<li><span>Flak</span><b>${d.aaa.heavy}×90mm ${d.aaa.bofors}×40mm ${d.aaa.vulcan}×20mm</b></li>
</ul>
<p class="note">${districtNote(d.kind)}</p>
<button type="button" class="primary" data-act="strike" data-id="${esc(id)}" data-tip="Open the raid planner with this as the primary target.">Plan raid on this</button>`;
}
private samList(): string {
const s = this.s;
const rows = s.sams.filter((q) => q.knownHour > -1000 && (q.launchers > 0.05 || q.isDecoy)).map((q) => {
const age = Math.round((s.hour - q.knownHour) / 24);
return `<li><span>${esc(q.id)} ${q.kind === 'nike' ? 'Nike' : 'Bloodhound'}</span><b>${q.revealed ? 'decoy' : q.disabledUntil > s.hour ? 'down' : `seen ${age}d ago`}</b></li>`;
}).join('');
return `<h3>Known SAM sites</h3><ul class="kv">${rows}</ul>`;
}
private fleetTab(): string {
const s = this.s;
const fc = fleetCounts(s);
const rows = AIRCRAFT_TYPES.filter((t) => fc[t].ready + fc[t].maint + fc[t].lost > 0).map((t) => {
const alive = s.fleet.filter((a) => a.type === t && a.status !== 'lost');
const xp = alive.length ? alive.reduce((a, b) => a + b.xp, 0) / alive.length : 0;
return `<tr data-tip="${esc(AIRCRAFT_HELP[t] ?? '')}" data-tip-title="${esc(CONFIG.aircraft[t].name)}"><th><img class="mini-plane" src="${planeCard(t, 64, 44)}" alt="">${esc(CONFIG.aircraft[t].name)}</th><td>${fc[t].ready}</td><td>${fc[t].maint}</td><td>${fc[t].lost}</td><td>${bar(xp, 'amber')}</td></tr>`;
}).join('');
const mun = MUNITION_IDS.map((m) => `<li><span>${esc(CONFIG.munitions[m].name)}</span><b>${s.munitions[m]}</b></li>`).join('');
return `<table class="fleet"><thead><tr><th>Type</th><th>Rdy</th><th>Mnt</th><th>Lost</th><th>Crew XP</th></tr></thead><tbody>${rows}</tbody></table>
<p class="note">Losses hurt twice: replacements come only when High Command sees results. Dead crews are replaced by green ones.</p>
<h3>Stores</h3><ul class="kv">${mun}
<li><span>Pres chaff bundles</span><b>${s.pres}</b></li><li><span>Tu-123 drones</span><b>${s.drones}</b></li><li><span>Fuel</span><b>${Math.round(s.fuel)} t</b></li></ul>
<h3>Politics</h3>
<p class="note">${s.linebacker ? 'All-out offensive declared. Restrictions are suspended and capital drains daily.' : 'Low capital brings restrictions: no-go districts, minimum altitudes, daylight-only and stand-downs.'}</p>
${s.linebacker ? '' : '<button type="button" class="danger" data-act="linebacker" data-tip="Lift every political restriction for the rest of the campaign — like Linebacker II in 1972. Capital drains every day.">Go all-out (Linebacker)…</button>'}`;
}
private newsTab(): string {
const items = this.s.news.slice(0, 40).map((n) => `<li class="n-${n.kind}"><time>${fmtTime(n.hour)}</time> ${esc(n.text)}</li>`).join('');
return `<div class="masthead">HAFENSTÄDTER ABENDBLATT <small>· & regimental wire</small></div><ul class="news">${items || '<li>No reports yet.</li>'}</ul>`;
}
private crewsTab(): string {
const s = this.s;
const rows = s.captives.map((c, i) => `<li><b>${esc(c.crew)}</b> (${esc(c.type)}) · down ${fmtTime(c.hour)} · ${c.resolved}
${c.resolved === 'pending' ? `<div class="row"><button type="button" data-act="rescue" data-i="${i}" data-tip="A helicopter goes in low. Roughly even odds; it may be shot down. Too late after 48 hours.">Send Mi-8 rescue (6h)</button><button type="button" data-act="abandon" data-i="${i}">Leave them</button></div>` : ''}</li>`).join('');
const dead = s.stats.crewsKilled;
return `<h3>Missing crews</h3><ul class="crews">${rows || '<li>No crews missing.</li>'}</ul><p class="note">${dead} crews killed in action.</p>`;
}
// ── Actions ──
private wait(hours: number): void {
audio.start();
audio.teletype();
const before = this.s.news.length;
advanceTo(this.s, this.s.hour + hours);
this.host.persist();
if (this.s.ended) return this.host.end();
this.refresh();
if (this.s.news.length > before) this.tab = this.tab === 'intel' ? 'intel' : 'news';
this.renderSide();
}
private recon(): void {
const s = this.s;
const fox = readyAt(s, 'MiG-25R', s.hour).length;
const il = readyAt(s, 'Il-28', s.hour).length;
modal(this.root, `<h2>Photo reconnaissance</h2>
<p>Imagery goes stale. The city builds decoys and moves its batteries; only fresh photos tell plywood from steel. A sortie costs 6 hours.</p>
<div class="col">
<button type="button" data-act="fox" ${fox ? '' : 'disabled'} data-tip="Mach 3 at 20 km: nothing can touch it. Refreshes all city photos and very likely reveals decoys.">MiG-25R over the city — untouchable (${fox} ready)${s.foxbatDelivered ? '' : ' · arrives day ' + CONFIG.foxbatArrivalDay}</button>
<button type="button" data-act="foxw" ${fox ? '' : 'disabled'} data-tip="Wide sweep: finds relocated SAM batteries, decoy sites and hidden fuel farms across the theater.">MiG-25R theater sweep — SAM sites, fuel farms (${fox} ready)</button>
<button type="button" data-act="il" ${il ? '' : 'disabled'} data-tip="A Beagle with cameras. Less likely to spot decoys — and it can be shot down.">Il-28 photo run over the city — exposed (${il} ready)</button>
<button type="button" data-act="close">Cancel</button>
</div>`, {
fox: (_t, close) => { close(); this.showRecon(runRecon(s, 'MiG-25R', 'city')); },
foxw: (_t, close) => { close(); this.showRecon(runRecon(s, 'MiG-25R', 'wide')); },
il: (_t, close) => { close(); this.showRecon(runRecon(s, 'Il-28', 'city')); },
});
}
private showRecon(lines: string[]): void {
this.host.persist();
if (this.s.ended) return this.host.end();
this.refresh();
modal(this.root, `<h2>Photo interpretation</h2><ul class="news">${lines.map((l) => `<li>${esc(l)}</li>`).join('')}</ul><button type="button" class="primary" data-act="close">Understood</button>`, {});
}
private rescue(i: number): void {
const txt = attemptRescue(this.s, i);
this.host.persist();
this.refresh();
modal(this.root, `<h2>Rescue</h2><p>${esc(txt)}</p><button type="button" class="primary" data-act="close">Continue</button>`, {});
}
private linebacker(): void {
modal(this.root, `<h2>Go nuclear or go home</h2>
<p>Suspend every political restriction for the rest of the campaign. No-go districts, altitude floors and stand-downs are lifted. Capital drains ${CONFIG.politics.linebackerDrainPerDay}/day on top of every civilian death.</p>
<p>If capital reaches zero, Moscow ends the campaign — and you are judged on what the city looks like at that moment.</p>
<div class="row"><button type="button" class="danger" data-act="yes" data-tip="No going back.">Declare all-out offensive</button><button type="button" data-act="close">Not yet</button></div>`, {
yes: (_t, close) => { declareLinebacker(this.s); close(); this.host.persist(); this.refresh(); },
});
}
private menu(): void {
modal(this.root, `<h2>Regimental HQ</h2>
<p class="note">${this.host.canSave ? 'Progress saves automatically to this napplet\'s shell storage after every turn.' : 'Shell storage is not available here: this campaign cannot be resumed after closing.'}</p>
<p class="note">Earliest next launch: ${fmtTime(earliestLaunch(this.s))}${this.s.standDownUntil > this.s.hour ? ` · stand-down until ${fmtTime(this.s.standDownUntil)}` : ''}</p>
<div class="col">
<button type="button" data-act="save" ${this.host.canSave ? '' : 'disabled'} data-tip="Save to the napplet's shell storage (it also saves automatically).">Save now</button>
<button type="button" data-act="title">Title screen</button>
<button type="button" class="danger" data-act="newc" data-tip="Erase this campaign and start over.">Abandon and start a new campaign</button>
<button type="button" data-act="close">Back</button>
</div>`, {
save: (_t, close) => { this.host.persist(); close(); },
title: (_t, close) => { close(); this.host.title(); },
newc: (_t, close) => { close(); this.host.newCampaign(); },
});
}
}
function districtNote(kind: string): string {
switch (kind) {
case 'power': return 'Feeds every substation. Without it, factories slow, SAM radars run on batteries for hours, searchlights die and water pumps stop — firefighting collapses.';
case 'substation': return 'High value, quick to repair. The regional grid can still feed the city through intact substations even with the plant down.';
case 'rail': return 'Coal, ore, SAM reloads and interceptor parts all pass through here. Repair battalions restore yards fast — re-strike before they finish.';
case 'bridge': return 'Drop it and the south line and convoy road reroute over a pontoon ferry the city builds in about three days.';
case 'fuel': return 'Fuel for SAM trucks and interceptor sorties. Once burned, the city will disperse fuel into hidden drum farms.';
case 'housing': return 'Concrete blocks, fire-resistant — and 40% of factory output happens in workshops inside them. Bombing them kills civilians and costs capital.';
case 'altstadt': return 'Timber-framed old town. Incendiaries here can start a firestorm. The world will be watching.';
case 'interdiction': return 'Cutting supply lines starves the defense of reloads and replacements, not just industry. The city stockpiled; it takes patience.';
case 'satellite': return 'Dispersed works in the hills. They grow as the city moves production out of the bombed districts.';
case 'airbase': return 'Interceptor base. Cratering it reduces scrambles; the aircraft are dispersed in revetments.';
case 'military': return 'SAM garrison and reload depot. Hits here drain missile stocks.';
case 'hospital': return 'Protected. Hitting it is a political catastrophe.';
case 'dam': return 'Protected. Breaching it floods the eastern suburbs — and the world.';
default: return '';
}
}
