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