Back to City Breaker
SOURCE / PINNED RELEASE

Made of little things.

City Breaker

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

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

src/game/campaign.ts
// Strategic layer: time, fleet readiness, ROE, mission bookkeeping, recon,
// rescue, end conditions and the after-action report.
import { AIRCRAFT_TYPES, CONFIG, MUNITION_IDS, type AircraftType, type MunitionId } from './config';
import {
  adaptationLevel, checkDestroyed, cityDestructionFraction, cityTick, criticalNodesDown, killCivilians, news,
  recordRaidProfile, updateGrid, warOutput,
} from './city';
import { DISTRICT_BY_ID, HOUSING_IDS, type RouteId } from './map';
import { addAirframe, dayOf, fmtTime, isNight, rand, type Airframe, type GameState } from './state';

export type FlightRole = 'strike' | 'escort' | 'weasel' | 'jammer' | 'suppress' | 'recon';
export type AltBand = 'low' | 'med' | 'high';

export interface FlightPlan {
  id: string;
  callsign: string;
  type: AircraftType;
  role: FlightRole;
  airframeIds: number[];
  munition: MunitionId | null;
  target: string | null; // district id, `sam:<id>`, `farm:<id>`
  offsetSec: number; // arrival relative to the main bomber wave (negative = ahead)
  loads?: number[]; // weapons actually loaded per aircraft when stocks run short
}

export interface MissionPlan {
  flights: FlightPlan[];
  primary: string;
  alternates: string[];
  route: RouteId;
  ingressAlt: AltBand;
  attackAlt: AltBand;
  launchHour: number;
  chaffCorridor: boolean;
  drones: number;
  tanker: boolean;
  playerFlight: string;
  ignoreROE: boolean;
}

export interface Impact {
  x: number;
  y: number;
  kg: number;
  munition: MunitionId;
  flightId: string;
  hit: string | null; // 'd:<id>' | 'decoy:<district>:<decoyId>' | 'sam:<id>' | 'farm:<id>' | 'samdecoy:<id>'
  civ: number;
  player: boolean;
  aimed: string | null;
}

export interface AirframeOutcome {
  airframeId: number;
  damage: number;
  fate: 'ok' | 'killed' | 'captured' | 'crashlanded' | 'ejectedHome';
  bombsOnTarget: number;
}

export interface MissionResult {
  impacts: Impact[];
  outcomes: AirframeOutcome[];
  interceptorsKilled: number;
  samLaunches: number;
  samSitesHit: string[];
  guidedHits: number;
  playerFate: AirframeOutcome['fate'] | 'none';
  durationSec: number;
  usedJamming: boolean;
  firestorms: string[];
  dronesUsed: number;
  presUsed: number;
  autoResolved: boolean;
  destructionBefore: number;
}

export interface AftermathReport {
  hour: number;
  outputBefore: number;
  outputAfter: number;
  politicalBefore: number;
  politicalAfter: number;
  civilians: number;
  lost: { airframe: Airframe; fate: string }[];
  damaged: number;
  bda: BdaEntry[];
  tonnage: number;
  decoyTonnage: number;
  roeViolations: string[];
  messages: string[];
  playerFate: string;
}

export interface BdaEntry {
  key: string;
  name: string;
  kg: number;
  decoy: boolean;
  integrityAfter: number;
  hits: { x: number; y: number }[];
  cx: number;
  cy: number;
  kind: string;
}

const CALLSIGNS = ['GRANIT', 'SOKOL', 'BERKUT', 'KOMETA', 'TUMAN', 'URAGAN', 'ZARYA', 'VOLNA', 'SHTORM', 'YASTREB', 'KRECHET', 'ALMAZ'];

// ── Readiness ───────────────────────────────────────────────────────────────
export function refreshFleet(s: GameState, atHour = s.hour): void {
  for (const a of s.fleet) {
    if (a.status === 'maint' && a.readyAt <= atHour) a.status = 'ready';
  }
}

export function readyAt(s: GameState, type: AircraftType, hour: number): Airframe[] {
  return s.fleet
    .filter((a) => a.type === type && a.status !== 'lost' && a.readyAt <= hour)
    .sort((a, b) => b.xp - a.xp);
}

export function fleetCounts(s: GameState): Record<AircraftType, { ready: number; maint: number; lost: number }> {
  const out = {} as Record<AircraftType, { ready: number; maint: number; lost: number }>;
  for (const t of AIRCRAFT_TYPES) out[t] = { ready: 0, maint: 0, lost: 0 };
  for (const a of s.fleet) {
    if (a.status === 'lost') out[a.type].lost++;
    else if (a.readyAt <= s.hour) out[a.type].ready++;
    else out[a.type].maint++;
  }
  return out;
}

// ── ROE ─────────────────────────────────────────────────────────────────────
export function roeTier(s: GameState) {
  return CONFIG.politics.roeTiers.find((t) => s.political >= t.min) ?? CONFIG.politics.roeTiers.at(-1)!;
}

export function altKm(b: AltBand): number {
  const m = CONFIG.mission;
  return b === 'low' ? m.altLow : b === 'med' ? m.altMed : m.altHigh;
}

export function roeViolations(s: GameState, plan: MissionPlan): string[] {
  if (s.linebacker) return [];
  const t = roeTier(s);
  const v: string[] = [];
  const targets = new Set(plan.flights.map((f) => f.target).filter(Boolean) as string[]);
  targets.add(plan.primary);
  for (const id of targets) {
    const d = DISTRICT_BY_ID[id];
    if (!d) continue;
    if (t.noGo.includes(d.kind) || t.noGo.includes(d.id)) v.push(`${d.name} is a no-go target`);
  }
  if (t.minAlt && (altKm(plan.attackAlt) < t.minAlt || altKm(plan.ingressAlt) < t.minAlt)) v.push(`Minimum altitude ${t.minAlt} km`);
  if (t.dayOnly && isNight(plan.launchHour)) v.push('Daylight raids only (photo verification)');
  const n = plan.flights.reduce((a, f) => a + f.airframeIds.length, 0);
  if (n > t.maxAircraft) v.push(`Package limited to ${t.maxAircraft} aircraft`);
  if (s.hour < s.standDownUntil || plan.launchHour < s.standDownUntil) v.push(`Stand-down in force until ${fmtTime(s.standDownUntil)}`);
  return v;
}

export function earliestLaunch(s: GameState): number {
  return Math.max(s.hour, s.lastRaidHour + CONFIG.campaign.minHoursBetweenRaids);
}

// ── Time ────────────────────────────────────────────────────────────────────
export function advanceTo(s: GameState, targetHour: number): void {
  const step = CONFIG.campaign.turnHours;
  while (s.hour + step <= targetHour && !s.ended) {
    s.hour += step;
    cityTick(s);
    checkDestroyed(s);
    turnBookkeeping(s);
  }
  if (s.hour < targetHour && !s.ended) s.hour = targetHour; // partial turn: launch time inside the turn
  refreshFleet(s);
  updateGrid(s);
}

function turnBookkeeping(s: GameState): void {
  const P = CONFIG.politics;
  s.political = Math.min(P.max, s.political + P.regenPerDay * (CONFIG.campaign.turnHours / 24));
  if (s.linebacker) s.political -= P.linebackerDrainPerDay * (CONFIG.campaign.turnHours / 24);

  // Weekly resupply.
  if (s.hour - s.lastWeeklyHour >= 24 * 7) {
    s.lastWeeklyHour = s.hour;
    for (const m of MUNITION_IDS) s.munitions[m] += CONFIG.munitions[m].weekly;
    s.pres += CONFIG.pres.weekly;
    s.drones += CONFIG.drones.weekly;
    s.fuel += CONFIG.fuelTons.weekly;
    news(s, `Weekly resupply train: ${CONFIG.munitions['UPAB-1500'].weekly} UPAB-1500 guided bombs, ${CONFIG.munitions['FAB-500'].weekly} FAB-500, chaff and drones.`, 'hq');
    replacementsCheck(s);
  }
  if (!s.foxbatDelivered && dayOf(s.hour) >= CONFIG.foxbatArrivalDay) {
    s.foxbatDelivered = true;
    for (let i = 0; i < CONFIG.foxbatCount; i++) addAirframe(s, 'MiG-25R', 0.7);
    news(s, 'Two MiG-25R "Foxbat-B" reconnaissance aircraft arrive from Moscow. Nothing in the West can catch them.', 'hq');
  }
  const out = warOutput(s).output;
  s.outputHistory.push({ hour: s.hour, output: out });
  if (criticalNodesDown(s)) s.criticalDownHours += CONFIG.campaign.turnHours;
  else s.criticalDownHours = 0;
  checkEnd(s);
}

function replacementsCheck(s: GameState): void {
  const R = CONFIG.replacements;
  const out = warOutput(s).output;
  const progress = s.outputAtLastReplacement - out;
  if (progress >= R.progressNeeded && s.political >= R.politicalNeeded) {
    const bombers: AircraftType[] = ['Tu-16', 'Il-28', 'Tu-22'];
    const fighters: AircraftType[] = ['MiG-21', 'Su-7', 'Yak-28'];
    for (let i = 0; i < R.bombers; i++) addAirframe(s, bombers[i % bombers.length], CONFIG.crew.greenXp);
    for (let i = 0; i < R.fighters; i++) addAirframe(s, fighters[i % fighters.length], CONFIG.crew.greenXp);
    news(s, `High Command is satisfied: ${R.bombers} bombers and ${R.fighters} fighters with fresh crews are on the way.`, 'hq');
  } else {
    news(s, 'High Command withholds replacements: "Show results first."', 'hq');
  }
  s.outputAtLastReplacement = out;
}

// ── Mission bookkeeping ────────────────────────────────────────────────────
export function makeFlight(index: number, type: AircraftType, role: FlightRole, ids: number[], munition: MunitionId | null, target: string | null, offsetSec = 0): FlightPlan {
  return { id: `f${index}-${Math.floor(Math.random() * 1e6)}`, callsign: CALLSIGNS[index % CALLSIGNS.length], type, role, airframeIds: ids, munition, target, offsetSec };
}

export function bombsPerAircraft(type: AircraftType, m: MunitionId | null): number {
  if (!m) return 0;
  const spec = CONFIG.munitions[m];
  const lim = CONFIG.missileLimit[m];
  const byWeight = Math.floor(CONFIG.aircraft[type].loadKg / spec.kg);
  return lim ? Math.min(lim, Math.max(1, byWeight)) : byWeight;
}

/** Weapons wanted for a full load (before stock limits). */
export function munitionNeed(plan: MissionPlan): Record<string, number> {
  const need: Record<string, number> = {};
  for (const f of plan.flights) {
    if (!f.munition) continue;
    need[f.munition] = (need[f.munition] ?? 0) + bombsPerAircraft(f.type, f.munition) * f.airframeIds.length;
  }
  return need;
}

/**
 * Load what the armory actually has. Your own flight is armed first, then the
 * rest in package order; when a weapon runs out, aircraft fly with partial or
 * empty racks instead of the raid being scrubbed.
 */
export function allocateLoads(s: GameState, plan: MissionPlan): void {
  const left: Record<string, number> = { ...s.munitions };
  const order = [...plan.flights].sort((a, b) => Number(b.id === plan.playerFlight) - Number(a.id === plan.playerFlight));
  for (const f of order) {
    if (!f.munition) {
      f.loads = undefined;
      continue;
    }
    const per = bombsPerAircraft(f.type, f.munition);
    f.loads = f.airframeIds.map(() => {
      const n = Math.max(0, Math.min(per, left[f.munition!] ?? 0));
      left[f.munition!] = (left[f.munition!] ?? 0) - n;
      return n;
    });
  }
}

/** Weapons that will really be loaded (after allocateLoads). */
export function munitionLoaded(plan: MissionPlan): Record<string, number> {
  const out: Record<string, number> = {};
  for (const f of plan.flights) {
    if (!f.munition) continue;
    const n = f.loads ? f.loads.reduce((a, b) => a + b, 0) : bombsPerAircraft(f.type, f.munition) * f.airframeIds.length;
    out[f.munition] = (out[f.munition] ?? 0) + n;
  }
  return out;
}

/** Shortage notes: not blockers, but the player should know. */
export function planWarnings(s: GameState, plan: MissionPlan): string[] {
  const w: string[] = [];
  const need = munitionNeed(plan);
  for (const [m, n] of Object.entries(need)) {
    const have = s.munitions[m as MunitionId];
    if (have < n) w.push(`Only ${have} ${m} in stock (a full load is ${n}) — some aircraft fly with partial racks. ${CONFIG.munitions[m as MunitionId].weekly} more arrive each week.`);
  }
  for (const f of plan.flights) {
    if (f.munition && f.loads && f.loads.every((n) => n === 0)) w.push(`${f.callsign} has no ${f.munition} left — it will fly unarmed. Change its weapon or remove it.`);
  }
  return w;
}

export function fuelNeed(plan: MissionPlan): number {
  const F = CONFIG.fuelTons;
  const k = CONFIG.mission.routeLength[plan.route];
  return Math.round(
    plan.flights.reduce((a, f) => {
      const per = f.type === 'Tu-95' ? F.perBearTon : CONFIG.aircraft[f.type].fighter ? F.perFighterTon : F.perSortieTon;
      return a + per * f.airframeIds.length * k;
    }, 0),
  );
}

export function planProblems(s: GameState, plan: MissionPlan): string[] {
  const p: string[] = [];
  if (!plan.flights.length) p.push('No flights assigned');
  if (!plan.flights.some((f) => f.id === plan.playerFlight)) p.push('Choose the flight you will fly');
  if (fuelNeed(plan) > s.fuel) p.push(`Fuel: need ${fuelNeed(plan)} t, have ${Math.round(s.fuel)} t`);
  if (plan.drones > s.drones) p.push('Not enough Tu-123 drones');
  if (plan.chaffCorridor && !plan.flights.some((f) => f.role === 'jammer')) p.push('A chaff corridor needs a Tu-16PP flight');
  if (plan.launchHour < earliestLaunch(s)) p.push(`Crews not rested: earliest launch ${fmtTime(earliestLaunch(s))}`);
  if (plan.launchHour >= CONFIG.campaign.days * 24) p.push('Launch is after the campaign deadline');
  const used = new Set<number>();
  for (const f of plan.flights) {
    for (const id of f.airframeIds) {
      const a = s.fleet.find((x) => x.id === id);
      if (!a || a.status === 'lost') p.push(`${f.callsign}: aircraft missing`);
      else if (a.readyAt > plan.launchHour) p.push(`${f.callsign}: ${a.tail} in maintenance until ${fmtTime(a.readyAt)}`);
      if (used.has(id)) p.push(`${f.callsign}: aircraft assigned twice`);
      used.add(id);
    }
    if (!f.airframeIds.length) p.push(`${f.callsign}: no aircraft`);
  }
  return p;
}

export function consumeForMission(s: GameState, plan: MissionPlan): void {
  allocateLoads(s, plan);
  for (const [m, n] of Object.entries(munitionLoaded(plan))) s.munitions[m as MunitionId] = Math.max(0, s.munitions[m as MunitionId] - n);
  s.fuel -= fuelNeed(plan);
  s.drones -= plan.drones;
  if (plan.chaffCorridor) s.pres = Math.max(0, s.pres - CONFIG.pres.corridorCost);
}

export function applyMission(s: GameState, plan: MissionPlan, r: MissionResult, outputBefore: number, politicalBefore: number): AftermathReport {
  const P = CONFIG.politics;
  const report: AftermathReport = {
    hour: s.hour, outputBefore, outputAfter: 0, politicalBefore, politicalAfter: 0, civilians: 0, lost: [], damaged: 0,
    bda: [], tonnage: 0, decoyTonnage: 0, roeViolations: [], messages: [], playerFate: r.playerFate,
  };
  const violations = plan.ignoreROE ? roeViolations(s, plan) : [];
  report.roeViolations = violations;
  if (violations.length) {
    s.political -= P.roeViolation * violations.length;
    s.stats.roeViolations += violations.length;
    news(s, 'Moscow cables: the regiment ignored political restrictions. "You will answer for this."', 'hq');
  }

  // Fleet outcomes.
  const flightOf = (id: number) => plan.flights.find((f) => f.airframeIds.includes(id));
  for (const o of r.outcomes) {
    const a = s.fleet.find((x) => x.id === o.airframeId);
    if (!a) continue;
    a.sorties++;
    s.stats.sorties++;
    const maint = CONFIG.aircraft[a.type].maintHours;
    if (o.fate === 'killed' || o.fate === 'captured' || o.fate === 'crashlanded') {
      a.status = 'lost';
      s.stats.aircraftLost++;
      s.political -= P.aircraftLost + (a.type === 'Tu-95' ? P.bearLostExtra : 0);
      if (o.fate === 'killed') s.stats.crewsKilled++;
      if (o.fate === 'captured') {
        s.stats.crewsCaptured++;
        s.political -= P.capturedCrewParade;
        s.captives.push({ crew: a.crew, type: a.type, hour: s.hour, resolved: 'pending' });
      }
      report.lost.push({ airframe: a, fate: o.fate });
    } else {
      a.damage = o.damage;
      if (o.damage > 0.05) report.damaged++;
      a.xp = Math.min(CONFIG.crew.xpCap, a.xp + CONFIG.crew.xpPerSortie);
      a.readyAt = s.hour + maint * (1 + o.damage * 3);
      a.status = 'maint';
      a.damage = 0;
    }
    if (o.bombsOnTarget && flightOf(o.airframeId)?.id === plan.playerFlight) s.stats.playerBombsOnTarget += o.bombsOnTarget;
  }
  // Green replacements for dead crews on surviving types come later via replacements.

  // Impacts → BDA & stats.
  const groups = new Map<string, BdaEntry>();
  let civ = 0;
  for (const im of r.impacts) {
    report.tonnage += im.kg / 1000;
    civ += im.civ;
    if (!im.hit) continue;
    const parts = im.hit.split(':');
    let entry = groups.get(im.hit);
    if (!entry) {
      let name = im.hit;
      let decoy = false;
      let integrityAfter = 0;
      let cx = im.x;
      let cy = im.y;
      let kind = 'site';
      if (parts[0] === 'd') {
        const d = DISTRICT_BY_ID[parts[1]];
        name = d.name; integrityAfter = s.districts[d.id].integrity; cx = d.x; cy = d.y; kind = d.kind;
        s.districts[d.id].knownIntegrity = integrityAfter; // strike photos
      } else if (parts[0] === 'decoy') {
        const d = DISTRICT_BY_ID[parts[1]];
        name = `${d.name}?`; decoy = true; kind = d.kind;
        const dec = s.districts[d.id].decoys.find((x) => x.id === parts[2]);
        if (dec) { cx = dec.x; cy = dec.y; dec.revealed = true; dec.seen = true; }
      } else if (parts[0] === 'sam' || parts[0] === 'samdecoy') {
        const site = s.sams.find((x) => x.id === parts[1]);
        name = `SAM site ${parts[1]} (${site?.kind === 'nike' ? 'Nike Hercules' : 'Bloodhound'})`;
        decoy = parts[0] === 'samdecoy';
        if (site) { cx = site.x; cy = site.y; integrityAfter = site.launchers; if (decoy) site.revealed = true; }
        kind = 'sam';
      } else if (parts[0] === 'picket') {
        name = 'Barrier picket radar'; kind = 'sam';
        integrityAfter = 0;
      } else if (parts[0] === 'farm') {
        name = `Fuel drum farm ${parts[1]}`; kind = 'fuel';
        const f = s.city.fuelFarms.find((x) => x.id === parts[1]);
        if (f) { cx = f.x; cy = f.y; integrityAfter = f.destroyed ? 0 : 1; }
      }
      entry = { key: im.hit, name, kg: 0, decoy, integrityAfter, hits: [], cx, cy, kind };
      groups.set(im.hit, entry);
    }
    entry.kg += im.kg;
    entry.hits.push({ x: im.x, y: im.y });
    const kindKey = entry.decoy ? 'decoy' : entry.kind;
    s.stats.tonnageByKind[kindKey] = (s.stats.tonnageByKind[kindKey] ?? 0) + im.kg / 1000;
    if (entry.decoy) report.decoyTonnage += im.kg / 1000;
    const isCivilian = parts[0] === 'd' && (HOUSING_IDS.includes(parts[1]) || parts[1] === 'hospital');
    if (isCivilian) s.stats.civilianDamage += im.kg / 1000;
    else if (!entry.decoy) s.stats.militaryDamage += im.kg / 1000;
  }
  report.bda = [...groups.values()].sort((a, b) => b.kg - a.kg).slice(0, 8);
  s.stats.tonnage += report.tonnage;
  s.stats.decoyTonnage += report.decoyTonnage;
  s.stats.interceptorsKilled += r.interceptorsKilled;
  s.stats.samsKilled += r.samSitesHit.length;
  s.stats.missions++;
  s.stats.raidHours.push(s.hour);
  if (plan.flights.some((f) => f.id === plan.playerFlight)) s.stats.playerSorties++;
  killCivilians(s, Math.round(civ));
  report.civilians = Math.round(civ);
  if (civ > P.highProfileThreshold) {
    s.political -= P.highProfileCivilianEvent;
    news(s, `Western newsreels show the funerals. ${Math.round(civ).toLocaleString('en')} civilians killed in one night of "precision".`, 'moral');
  }
  for (const id of ['hospital', 'dam', 'govt']) {
    if (r.impacts.some((im) => im.hit === `d:${id}`)) {
      const pen = id === 'hospital' ? P.restrictedHospital : id === 'dam' ? P.restrictedDam : P.restrictedGovt;
      s.political -= pen;
      if (id === 'hospital') news(s, 'BOMBS ON ST. GEORG HOSPITAL. Doctors operate by candlelight in the cellars.', 'moral');
      if (id === 'dam') news(s, 'The Talsperre is breached. Flood water in the eastern suburbs. The world is horrified.', 'moral');
    }
  }
  if (s.city.shieldsActive && r.guidedHits > 0) {
    const dead = Math.round(60 + rand(s) * 140) * r.guidedHits;
    killCivilians(s, dead);
    report.civilians += dead;
    news(s, `A guided bomb found the reload trucks in the Lessing school courtyard. ${dead} dead, many of them children.`, 'moral');
  }

  // City response: hardening, destruction log, profile.
  const destruction = cityDestructionFraction(s);
  const prevFrac = s.city.destructionLog.reduce((a, e) => a + e.frac, 0);
  const raidFrac = Math.max(0, destruction - r.destructionBefore);
  s.city.destructionLog.push({ hour: s.hour, frac: raidFrac });
  if (prevFrac + raidFrac < CONFIG.city.evacuationThreshold) {
    s.city.resolve = Math.min(1, s.city.resolve + CONFIG.city.moraleHardenPerRaid * (1 + destruction));
    s.city.morale = Math.min(1, s.city.morale + 0.02);
  }
  const attackAlt = plan.attackAlt;
  const strikeMunitions = [...new Set(plan.flights.map((f) => f.munition).filter(Boolean) as string[])];
  const targetKinds = [...new Set(plan.flights.map((f) => (f.target ? DISTRICT_BY_ID[f.target]?.kind ?? 'sam' : '')).filter(Boolean))];
  recordRaidProfile(s, {
    hour: s.hour, route: plan.route, alt: attackAlt, night: isNight(plan.launchHour), munitions: strikeMunitions,
    targetKinds, guidedSuccess: r.guidedHits > 0, usedJamming: r.usedJamming,
  });
  for (const im of r.impacts) s.raidHeat.push({ x: im.x, y: im.y, kg: im.kg, hour: s.hour });
  if (s.raidHeat.length > 1500) s.raidHeat.splice(0, s.raidHeat.length - 1500);

  s.lastRaidHour = s.hour;
  const tier = roeTier(s);
  if (tier.standDownHours && !s.linebacker) s.standDownUntil = s.hour + tier.standDownHours;

  // Mission takes time.
  advanceTo(s, s.hour + CONFIG.campaign.missionDurationHours);
  const outAfter = warOutput(s).output;
  report.outputAfter = outAfter;
  const drop = outputBefore - outAfter;
  // Only clean military results buy political credit.
  if (drop > 0 && report.civilians < P.highProfileThreshold * 0.4) s.political = Math.min(P.max, s.political + drop * P.militarySuccessBonus);
  report.politicalAfter = s.political;
  report.messages.push(enemyHeadline(report));
  checkEnd(s);
  return report;
}

function enemyHeadline(r: AftermathReport): string {
  if (r.decoyTonnage > r.tonnage * 0.3) return `"The Russians bombed our theatre sets for an hour. ${Math.round(r.decoyTonnage)} tons on plywood!"`;
  if (r.lost.length >= 3) return `"${r.lost.length} enemy bombers burn on our fields. The sky over Hafenstadt belongs to us!"`;
  if (r.civilians > 500) return `"Murderers! ${r.civilians.toLocaleString('en')} dead in the housing blocks. Hafenstadt will never forget."`;
  if (r.outputBefore - r.outputAfter > 0.05) return '"Heavy damage. Every hand to the rubble. We will rebuild by morning."';
  return '"Enemy raid repelled with light damage. Production continues."';
}

// ── Recon ───────────────────────────────────────────────────────────────────
export function runRecon(s: GameState, type: 'MiG-25R' | 'Il-28', area: 'city' | 'wide'): string[] {
  const out: string[] = [];
  const pool = readyAt(s, type, s.hour);
  if (!pool.length) return [`No ${type} ready.`];
  const plane = pool[0];
  const A = CONFIG.adaptation;
  const reveal = type === 'MiG-25R' ? A.reconRevealMiG25 : A.reconRevealIl28;
  s.stats.reconSorties++;
  plane.readyAt = s.hour + CONFIG.aircraft[type].maintHours;
  plane.status = 'maint';
  plane.sorties++;
  // Il-28 recon is exposed.
  if (type === 'Il-28') {
    const risk = 0.12 + 0.03 * (s.city.adaptLevels.samDensity + s.city.adaptLevels.lowAltRadar);
    if (rand(s) < risk) {
      plane.status = 'lost';
      s.stats.aircraftLost++;
      s.stats.crewsKilled++;
      s.political -= CONFIG.politics.aircraftLost;
      out.push(`${plane.tail} (${plane.crew}) did not return from the photo run. Partial film recovered from the wingman.`);
    }
  }
  const radius = area === 'city' ? 26 : 70;
  for (const d of Object.values(s.districts)) {
    const def = DISTRICT_BY_ID[d.id];
    if (Math.hypot(def.x, def.y) > radius) continue;
    d.intelHour = s.hour;
    d.knownIntegrity = d.integrity;
    for (const dec of d.decoys) dec.seen = true;
    for (const dec of d.decoys) if (!dec.revealed && rand(s) < reveal) { dec.revealed = true; out.push(`Photo interpreters flag a dummy ${def.name.toLowerCase()} near grid ${Math.round(dec.x)},${Math.round(dec.y)}.`); }
  }
  for (const site of s.sams) {
    if (Math.hypot(site.x, site.y) > radius + 10) continue;
    const moved = Math.hypot(site.x - site.knownX, site.y - site.knownY) > 0.5;
    site.knownX = site.x;
    site.knownY = site.y;
    site.knownHour = s.hour;
    if (site.isDecoy && rand(s) < reveal) { site.revealed = true; out.push(`SAM site ${site.id} is a decoy — wooden launchers, tire tracks painted on.`); }
    else if (moved && !site.isDecoy) out.push(`SAM battery ${site.id} has relocated.`);
  }
  for (const f of s.city.fuelFarms) {
    if (!f.known && !f.destroyed && rand(s) < reveal * 0.8) { f.known = true; out.push(`Fuel drum farm ${f.id} located in the hills.`); }
  }
  if (!out.length) out.push('Imagery shows no new deception. Targets verified.');
  out.unshift(`Recon by ${type} complete. Intel refreshed ${area === 'city' ? 'over the city' : 'across the theater'}.`);
  advanceTo(s, s.hour + CONFIG.campaign.turnHours);
  return out;
}

// ── Rescue ──────────────────────────────────────────────────────────────────
export function attemptRescue(s: GameState, index: number): string {
  const c = s.captives[index];
  if (!c || c.resolved !== 'pending') return 'Nothing to do.';
  if (s.hour - c.hour > 48) {
    c.resolved = 'abandoned';
    return `${c.crew} has been moved to a prison camp in the West. Too late.`;
  }
  const odds = CONFIG.crew.rescueSuccess - 0.04 * s.city.adaptLevels.barrierPatrol;
  if (rand(s) < odds) {
    c.resolved = 'rescued';
    s.stats.crewsRescued++;
    s.stats.crewsCaptured--;
    const a = addAirframe(s, c.type, 0.6);
    a.crew = c.crew;
    a.readyAt = s.hour + 72;
    a.status = 'maint';
    s.political += 3;
    advanceTo(s, s.hour + CONFIG.campaign.turnHours);
    return `The Mi-8 came in low at dusk and found the signal mirror. ${c.crew} is home. Their aircraft will be replaced.`;
  }
  c.resolved = 'failed';
  let txt = `The rescue Mi-8 found only a burned parachute and a patrol waiting.`;
  if (rand(s) < CONFIG.crew.rescueHeloLoss) {
    s.political -= 2;
    txt += ' The helicopter was shot down. Four more names for the wall.';
  }
  advanceTo(s, s.hour + CONFIG.campaign.turnHours);
  return txt;
}

export function abandonCaptive(s: GameState, index: number): void {
  const c = s.captives[index];
  if (c) c.resolved = 'abandoned';
}

// ── End ─────────────────────────────────────────────────────────────────────
export function checkEnd(s: GameState): void {
  if (s.ended) return;
  const out = warOutput(s).output;
  if (s.criticalDownHours >= CONFIG.campaign.criticalNodesDaysForWin * 24) {
    s.ended = { outcome: 'win', reason: 'All critical nodes — power, marshalling yards, the river crossing and fuel — were held down together for five days. Hafenstadt stopped functioning as a war city.', hour: s.hour };
    return;
  }
  if (s.political <= 0) {
    if (s.linebacker) {
      s.ended = out <= CONFIG.campaign.winOutput
        ? { outcome: 'win', reason: 'Moscow ended the all-out offensive, but the city was already broken.', hour: s.hour }
        : { outcome: 'lose', reason: 'Political capital exhausted during the all-out offensive. Negotiations begin; you are relieved of command.', hour: s.hour };
    } else {
      s.ended = { outcome: 'lose', reason: 'Political capital exhausted. A ceasefire is negotiated over your head and you are relieved of command.', hour: s.hour };
    }
    return;
  }
  const flyable = s.fleet.filter((a) => a.status !== 'lost' && CONFIG.aircraft[a.type].loadKg > 0).length;
  if (flyable < 3) {
    s.ended = { outcome: 'lose', reason: 'The bomber force is gone. Aircraft and crew pools exhausted.', hour: s.hour };
    return;
  }
  if (s.hour >= CONFIG.campaign.days * 24) {
    if (out <= CONFIG.campaign.winOutput) s.ended = { outcome: 'win', reason: `War output stands at ${Math.round(out * 100)}% on day 60. The summer offensive rolls past a dead city.`, hour: s.hour };
    else if (out <= CONFIG.campaign.partialOutput) s.ended = { outcome: 'partial', reason: `War output at ${Math.round(out * 100)}%. The offensive goes in anyway, against a wounded but working city.`, hour: s.hour };
    else s.ended = { outcome: 'lose', reason: `War output still at ${Math.round(out * 100)}% on day 60. Hafenstadt out-built the bomber.`, hour: s.hour };
  }
}

export function declareLinebacker(s: GameState): void {
  s.linebacker = true;
  s.standDownUntil = 0;
  news(s, 'ORDER: political restrictions suspended. Maximum effort until the city breaks or Moscow loses its nerve.', 'hq');
  news(s, '"The Russians have unleashed their bombers without limit. The free world watches Hafenstadt."');
}

// ── After-action (USSBS style) ─────────────────────────────────────────────
export function afterAction(s: GameState): { stats: [string, string][]; paragraphs: string[] } {
  const st = s.stats;
  const out = warOutput(s);
  const t = st.tonnageByKind;
  const total = Math.max(1, st.tonnage);
  const bottleneck = ['power', 'substation', 'rail', 'interdiction', 'bridge', 'fuel'].reduce((a, k) => a + (t[k] ?? 0), 0);
  const area = ['housing', 'altstadt'].reduce((a, k) => a + (t[k] ?? 0), 0);
  const factories = ['steel', 'machine', 'chem'].reduce((a, k) => a + (t[k] ?? 0), 0);
  const decoy = t.decoy ?? 0;
  const gaps = st.raidHours.slice(1).map((h, i) => h - st.raidHours[i]);
  const avgGap = gaps.length ? gaps.reduce((a, b) => a + b, 0) / gaps.length : 0;
  const ratio = st.civilianDamage > 0 ? st.militaryDamage / st.civilianDamage : Infinity;
  const stats: [string, string][] = [
    ['Missions flown', String(st.missions)],
    ['Sorties', String(st.sorties)],
    ['Tonnage dropped', `${Math.round(st.tonnage).toLocaleString('en')} t`],
    ['On bottlenecks (power/rail/bridge/fuel)', `${Math.round((bottleneck / total) * 100)}%`],
    ['On factories', `${Math.round((factories / total) * 100)}%`],
    ['On housing / old town', `${Math.round((area / total) * 100)}%`],
    ['On decoys', `${Math.round((decoy / total) * 100)}%`],
    ['Military : civilian tonnage', ratio === Infinity ? 'no civilian hits' : `${ratio.toFixed(1)} : 1`],
    ['Civilians killed', st.civiliansKilled.toLocaleString('en')],
    ['Aircraft lost', String(st.aircraftLost)],
    ['Crews killed / captured / rescued', `${st.crewsKilled} / ${st.crewsCaptured} / ${st.crewsRescued}`],
    ['Interceptors / SAM sites destroyed', `${st.interceptorsKilled} / ${st.samsKilled}`],
    ['Firestorms', String(st.firestorms)],
    ['Mean interval between raids', gaps.length ? `${Math.round(avgGap)} h` : '—'],
    ['City adaptation level', String(adaptationLevel(s))],
    ['Final war output', `${Math.round(out.output * 100)}%`],
  ];
  const p: string[] = [];
  if (st.tonnage < 1) {
    p.push(`SUMMARY. The regiment never pressed an attack. For ${dayOf(s.hour) - 1} days Hafenstadt worked undisturbed, stockpiled, and still prepared ${adaptationLevel(s)} defensive measures against a blow that never came.`);
    p.push('VERDICT. The historical record says: hit the bottlenecks (power, rail, fuel), hit them together, re-strike before repair completes, verify targets before spending tonnage, and leave the population out of it.');
    return { stats, paragraphs: p };
  }
  p.push(`SUMMARY. Over ${dayOf(s.hour) - 1} days the regiment dropped ${Math.round(st.tonnage).toLocaleString('en')} tons on Hafenstadt and its approaches. The city's war output ended at ${Math.round(out.output * 100)}% of its pre-campaign level (industry ${Math.round(Math.min(1, out.industry) * 100)}%, logistics ${Math.round(out.logistics * 100)}%, power ${Math.round(out.power * 100)}%, workforce ${Math.round(out.workforce * 100)}%).`);
  if (bottleneck / total > 0.45) p.push('BOTTLENECKS. The weight of effort fell on the systems everything else depends on — power, transport and fuel. The record of 1944–45 is unambiguous that this was the decisive choice: when the German railways and synthetic oil plants were attacked persistently, factories that were still standing could no longer produce or deliver.');
  else p.push(`BOTTLENECKS. Only ${Math.round((bottleneck / total) * 100)}% of the tonnage went to power, rail, the river crossing and fuel. The Strategic Bombing Survey found that attacks on these thin systems collapsed output far faster than the same tonnage spread over factories, which proved remarkably easy to repair and disperse.`);
  if (area / total > 0.25 || st.firestorms) p.push(`AREA BOMBING. ${Math.round((area / total) * 100)}% of the bombs fell on housing and the old town${st.firestorms ? `, and ${st.firestorms} firestorm${st.firestorms > 1 ? 's' : ''} burned through the city` : ''}. As in London and Hamburg, the population did not break below the catastrophic threshold — it hardened, and its labor went into repair. The cost in lives and political capital bought little production loss per ton.`);
  else p.push('AREA BOMBING. The regiment largely refused to make the population the target. The record supports this: morale bombing hardened resolve far more often than it collapsed it.');
  if (avgGap && avgGap <= 18) p.push('COMPRESSION. Raids came fast enough that repair crews could not finish one job before the next. Linebacker II in December 1972 showed what compressed, sustained pressure does that a slow gradual campaign never achieves.');
  else if (avgGap) p.push(`COMPRESSION. With ${Math.round(avgGap)} hours between raids on average, the city's repair battalions and pontoon builders kept pace. Rolling Thunder's long pauses taught the same lesson: gradual pressure gives the defender time to adapt.`);
  if (decoy / total > 0.1) p.push(`DECEPTION. ${Math.round((decoy / total) * 100)}% of the tonnage hit dummies. As in Kosovo in 1999, unverified targets were increasingly plywood. Fresh reconnaissance before each strike — paying for the MiG-25R sortie — would have bought back that tonnage.`);
  else p.push('DECEPTION. Recon discipline kept the decoy economy at bay. Patience on unverified targets paid.');
  p.push(`ADAPTATION. The city's defense adapted to the regiment's habits ${adaptationLevel(s)} times. ${adaptationLevel(s) > 12 ? 'A predictable pattern let it optimize against you; varying altitude, timing and approach would have kept it guessing.' : 'Varied tactics kept the defense from optimizing against a single pattern.'}`);
  p.push('VERDICT. The historical record says: hit the bottlenecks (power, rail, fuel), hit them together, re-strike before repair completes, verify targets before spending tonnage, and leave the population out of it.');
  return { stats, paragraphs: p };
}

export function nightWord(hour: number): string {
  return isNight(hour) ? 'night' : 'day';
}