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/city.ts
// Hafenstadt as a living organism: power, logistics, industry, people, repair,
// fire, morale and the adaptation AI. Advanced in 6-hour ticks.
import { CONFIG } from './config';
import {
  CRITICAL_NODES, DISTRICTS, DISTRICT_BY_ID, FACTORY_IDS, HOUSING_IDS, SATELLITE_IDS, SUBSTATIONS, SUPPLY_ROUTES,
  districtNeighbors,
} from './map';
import { newCampaign, rand, type GameState, type NewsItem, type RaidProfile } from './state';

const clamp = (v: number, lo = 0, hi = 1) => Math.max(lo, Math.min(hi, v));
const integ = (s: GameState, id: string) => s.districts[id]?.integrity ?? 1;

export function news(s: GameState, text: string, kind: NewsItem['kind'] = 'enemy'): void {
  s.news.unshift({ hour: s.hour, text, kind });
  if (s.news.length > 120) s.news.length = 120;
}

// ── Power grid ──────────────────────────────────────────────────────────────
export function plantFactor(s: GameState): number {
  const coal = s.city.stock.coal > 0 ? 1 : 0.45;
  const imp = CONFIG.output.regionalImport;
  return clamp(imp + (1 - imp) * clamp((integ(s, 'power') - 0.2) / 0.7) * coal);
}

export function updateGrid(s: GameState): void {
  const plant = plantFactor(s);
  for (const d of DISTRICTS) {
    const st = s.districts[d.id];
    if (!d.substation) {
      st.powered = d.kind === 'power' ? integ(s, 'power') > 0.25 : true;
      continue;
    }
    st.powered = plant > 0.25 && integ(s, d.substation) > 0.35 && st.integrity > 0.1;
  }
}

export function powerIndex(s: GameState): number {
  const plant = plantFactor(s);
  const subs = SUBSTATIONS.reduce((a, id) => a + (integ(s, id) > 0.35 ? 1 : integ(s, id) * 0.5), 0) / SUBSTATIONS.length;
  const gens = s.city.pools.generators * CONFIG.city.generatorPowerShare;
  return clamp(plant * subs + gens * 0.5);
}

/** Radar effectiveness of the SAM net: needs grid power or battery/generator reserves. */
export function samPower(s: GameState): number {
  if (plantFactor(s) > 0.25 && s.districts.military.powered) return 1;
  if (s.city.batteryHours > 0) return 0.9;
  return clamp(0.45 + s.city.pools.generators * 0.03, 0, 0.8);
}

// ── Logistics ───────────────────────────────────────────────────────────────
export function bridgeFactor(s: GameState): number {
  const b = integ(s, 'bridge');
  return Math.max(b > 0.35 ? b : b * 0.3, s.city.pontoon * CONFIG.city.pontoonCapacity);
}

export function routeStatus(s: GameState, routeId: string): number {
  const r = SUPPLY_ROUTES.find((x) => x.id === routeId)!;
  const floor = r.mode === 'rail' ? 0.07 * s.city.adaptLevels.railResilience : 0;
  let v = Math.max(floor, clamp((integ(s, r.node) - 0.15) / 0.75));
  if (r.viaBridge) v *= bridgeFactor(s);
  if (r.mode === 'rail') v *= clamp(0.15 + integ(s, 'yards') * 0.95 + 0.06 * s.city.adaptLevels.railResilience);
  if (r.mode === 'river') v *= clamp(0.2 + integ(s, 'port'));
  return clamp(v);
}

export function logisticsIndex(s: GameState): number {
  let tot = 0;
  let cap = 0;
  for (const r of SUPPLY_ROUTES) {
    tot += r.capacity * routeStatus(s, r.id);
    cap += r.capacity;
  }
  return clamp(tot / cap);
}

// ── Workforce & industry ───────────────────────────────────────────────────
export function workforceIndex(s: GameState): number {
  const c = s.city;
  const people = c.workers / CONFIG.city.workers;
  const moraleF = 0.6 + 0.4 * c.morale;
  return clamp(people * moraleF * (1 + c.resolve * 0.15));
}

function satelliteOut(s: GameState, L: number): number {
  const avg = SATELLITE_IDS.reduce((a, id) => a + integ(s, id), 0) / SATELLITE_IDS.length;
  return avg * (0.55 + 0.45 * L);
}

export interface OutputBreakdown {
  steel: number; machine: number; chem: number; workshops: number; satellites: number; port: number;
  industry: number; logistics: number; power: number; workforce: number; output: number; raw: number;
}

function computeOutput(s: GameState): OutputBreakdown {
  const W = workforceIndex(s);
  const L = logisticsIndex(s);
  const P = powerIndex(s);
  const pw = (id: string) => (s.districts[id].powered ? 1 : 0.15 + 0.03 * s.city.pools.generators);
  const sat = satelliteOut(s, L);
  const disp = (id: string, base: number) => {
    const d = s.districts[id].dispersal;
    return (1 - d) * base + d * sat;
  };
  const coal = s.city.stock.coal > 0 ? 1 : 0.25;
  const steelMat = s.city.stock.steel > 0 ? 1 : 0.45;
  const fuel = s.city.stock.fuel > 0 ? 1 : 0.4;
  const steel = disp('steel', integ(s, 'steel') * pw('steel') * coal) * W;
  const machine = disp('machine', integ(s, 'machine') * pw('machine') * steelMat) * W;
  const chem = disp('chem', integ(s, 'chem') * pw('chem') * fuel) * W;
  const housing = HOUSING_IDS.reduce((a, id) => a + integ(s, id) * (s.districts[id].powered ? 1 : 0.55), 0) / HOUSING_IDS.length;
  // Cottage industry survives in cellars and courtyards even when blocks are gutted.
  const workshops = (0.4 + 0.6 * housing) * W;
  const port = integ(s, 'port') * routeStatus(s, 'river') * pw('port');
  const satellites = sat * (1 + CONFIG.city.satelliteGrowthPerDispersal * (s.city.adaptLevels.dispersal ?? 0)) * W;
  const w = CONFIG.output.weights;
  const industry = clamp(
    (w.steel * steel + w.machine * machine + w.chem * chem + w.workshops * workshops + w.satellites * satellites + w.port * port) /
      (w.steel + w.machine + w.chem + w.workshops + w.satellites + w.port),
    0,
    1.05,
  );
  const o = CONFIG.output;
  const eff = (x: number, k: number) => 1 - k * (1 - x);
  let raw = industry * eff(L, o.logisticsBottleneck) * eff(P, o.powerBottleneck) * eff(W, o.workforceBottleneck);
  if (L < o.bottleneckThreshold) raw *= o.bottleneckPenalty;
  if (P < o.bottleneckThreshold) raw *= o.bottleneckPenalty;
  return { steel, machine, chem, workshops, satellites, port, industry, logistics: L, power: P, workforce: W, output: raw, raw };
}

let baseline = 0;
export function warOutput(s: GameState): OutputBreakdown {
  if (!baseline) {
    const fresh = newCampaign(1);
    updateGrid(fresh);
    baseline = computeOutput(fresh).raw || 1;
  }
  const b = computeOutput(s);
  b.output = Math.min(1, b.raw / baseline);
  return b;
}

export function criticalNodesDown(s: GameState): boolean {
  const t = CONFIG.campaign.criticalNodeDownThreshold;
  return CRITICAL_NODES.every((id) => {
    if (id === 'bridge') return bridgeFactor(s) < t;
    if (id === 'fueldepot') return integ(s, id) < t && s.city.fuelFarms.filter((f) => !f.destroyed).length === 0;
    return integ(s, id) < t;
  });
}

// ── Repair priority & tick ─────────────────────────────────────────────────
const REPAIR_ORDER = [
  'subN', 'subE', 'subW', 'subS', 'yards', 'railE', 'railN', 'railS', 'lock', 'road', 'power', 'fueldepot', 'bridge',
  'airbase', 'military', 'govt', 'steel', 'machine', 'chem', 'port', 'satN', 'satW', 'satS', 'warehouse', 'hospital',
  'housingN', 'housingW', 'housingS', 'altstadt', 'dam',
];

function repairMult(id: string, s: GameState): number {
  const kind = DISTRICT_BY_ID[id].kind;
  const rr = 1 + 0.35 * s.city.adaptLevels.railResilience;
  if (kind === 'substation') return CONFIG.city.substationRepairMult;
  if (kind === 'rail' || kind === 'interdiction') return CONFIG.city.railRepairMult * rr;
  if (kind === 'bridge') return CONFIG.city.bridgeRepairMult;
  if (kind === 'dam') return 0.1;
  return 1 - DISTRICT_BY_ID[id].hardness * 0.4;
}

function onDestroyed(s: GameState, id: string): void {
  const st = s.districts[id];
  if (st.everDestroyed) return;
  st.everDestroyed = true;
  const d = DISTRICT_BY_ID[id];
  if (['steel', 'machine', 'chem', 'port', 'warehouse'].includes(d.kind)) {
    st.dispersal = clamp(st.dispersal + CONFIG.city.rebuildDispersalGain, 0, 0.7);
    news(s, `${d.name} will not be rebuilt as before: lathes are moving to cellars and village halls. Production goes on!`);
  }
  if (d.flammable > 0.5) st.firebreak = clamp(st.firebreak + 0.3, 0, 0.8);
  if (d.kind === 'fuel') {
    spawnFuelFarms(s, 2);
    news(s, 'Fuel stocks removed to secret dumps in the hills. The enemy bombs empty tanks.');
  }
  if (d.kind === 'rail') news(s, 'Rolling stock dispersed along every siding in the province. Railmen stand ready!');
}

export function spawnFuelFarms(s: GameState, n: number): void {
  for (let i = 0; i < n; i++) {
    const a = rand(s) * Math.PI * 2;
    const r = 14 + rand(s) * 22;
    s.city.fuelFarms.push({ id: `F${s.city.fuelFarms.length + 1}`, x: Math.cos(a) * r - 5, y: Math.sin(a) * r, fuel: 3 + rand(s) * 4, known: false, destroyed: false });
  }
}

export function cityTick(s: GameState): void {
  const c = s.city;
  const C = CONFIG.city;
  const dtDays = CONFIG.campaign.turnHours / 24;
  updateGrid(s);
  const L = logisticsIndex(s);
  const gridUp = plantFactor(s) > 0.25;

  // Batteries & generators.
  if (!gridUp) c.batteryHours = Math.max(0, c.batteryHours - CONFIG.campaign.turnHours);
  else c.batteryHours = Math.min(C.batteryReserveHours, c.batteryHours + 3);

  // Stockpiles (in days of consumption).
  const fuelHold = clamp(integ(s, 'fueldepot') + c.fuelFarms.filter((f) => !f.destroyed).length * 0.25);
  c.stock.coal = clamp(c.stock.coal + dtDays * (L * 1.15 - integ(s, 'steel') * 0.9 - 0.2), 0, C.stockpileDays.coal * 1.5);
  c.stock.fuel = clamp(c.stock.fuel + dtDays * (L * 1.1 * fuelHold - 0.9), 0, C.stockpileDays.fuel * 1.5);
  c.stock.steel = clamp(c.stock.steel + dtDays * (integ(s, 'steel') * (c.stock.coal > 0 ? 1 : 0.3) * 0.7 + L * 0.5 - 0.95), 0, C.stockpileDays.steel * 1.5);
  c.stock.samReloads = clamp(c.stock.samReloads + dtDays * (L * 1.2 - 0.3), 0, C.stockpileDays.samReloads * 2);

  // SAM reloads delivered to batteries.
  let reloads = Math.round(C.samReloadsPerTick * (c.stock.samReloads > 0 ? 1 : 0.2) * (0.3 + L));
  for (const site of s.sams) {
    if (site.isDecoy) continue;
    const spec = CONFIG.sam[site.kind];
    while (reloads > 0 && site.missiles < spec.stock) {
      site.missiles++;
      reloads--;
    }
    if (site.disabledUntil && site.disabledUntil <= s.hour) {
      site.radar = Math.max(site.radar, 0.8);
      site.launchers = Math.max(site.launchers, 0.7);
      site.disabledUntil = 0;
    }
  }

  // Interceptor replacements by rail.
  const inter = c.interceptors;
  if (rand(s) < C.interceptorReplacePerTick * L) {
    const k = (['F-104G', 'F-4', 'Lightning'] as const)[Math.floor(rand(s) * 3)];
    if (inter[k] < CONFIG.interceptors[k].count) inter[k]++;
  }

  // ── Fire ──
  const water = gridUp ? 1 : 0.4;
  let brigades = c.pools.fireBrigades * (0.5 + 0.5 * c.morale);
  const burning = DISTRICTS.filter((d) => s.districts[d.id].fire > 0.02).sort(
    (a, b) => s.districts[b.id].fire * (b.militaryValue + b.popDensity) - s.districts[a.id].fire * (a.militaryValue + a.popDensity),
  );
  const spread: Record<string, number> = {};
  for (const d of burning) {
    const st = s.districts[d.id];
    for (const n of districtNeighbors(d.id, 3)) {
      const nd = DISTRICT_BY_ID[n];
      spread[n] = (spread[n] ?? 0) + st.fire * C.fireSpread * nd.flammable * (1 - s.districts[n].firebreak);
    }
  }
  for (const d of burning) {
    const st = s.districts[d.id];
    const assigned = Math.min(brigades, 6 + st.fire * 6);
    brigades -= assigned;
    st.fire = clamp(st.fire * (1 - C.fireBurnoutPerTick * (1 - d.flammable * 0.5)) + st.fire * 0.25 * d.flammable - assigned * C.fireSuppressPerBrigade * water);
    st.integrity = clamp(st.integrity - st.fire * C.fireDamagePerTick);
    const deaths = Math.round(st.fire * d.popDensity * C.fireCasualtiesPerTick * (1 - c.evacuated / c.population * 0.5));
    killCivilians(s, deaths, `fire:${d.id}`);
  }
  for (const [id, v] of Object.entries(spread)) s.districts[id].fire = clamp(s.districts[id].fire + v);

  // Firestorm.
  // Firestorms need dense, timber-built, populated blocks: in Hafenstadt only the Altstadt qualifies.
  for (const d of DISTRICTS) {
    if (d.flammable < 0.8 || d.popDensity < 0.5) continue;
    const ns = districtNeighbors(d.id, 3);
    const total = s.districts[d.id].fire + ns.reduce((a, n) => a + s.districts[n].fire, 0);
    if (total * (1 - s.districts[d.id].firebreak) > C.firestormThreshold && s.districts[d.id].fire > 0.55 && s.districts[d.id].integrity > 0.15) triggerFirestorm(s, d.id);
  }

  // ── Long-delay bombs ──
  for (const st of Object.values(s.districts)) {
    if (st.ldbHours > 0) st.ldbHours = Math.max(0, st.ldbHours - CONFIG.campaign.turnHours - C.ldbClearPerTick * (c.pools.labor / 60));
  }

  // ── Repair ──
  const mobil = 1 + c.resolve * C.resolveMobilizationBonus;
  const steelF = c.stock.steel > 0 ? 1 : 0.5;
  let crews = (c.pools.railBattalions + c.pools.labor * 0.5 + c.aidCrews) * mobil * (0.4 + 0.6 * workforceIndex(s)) * steelF;
  for (const id of REPAIR_ORDER) {
    const st = s.districts[id];
    if (!st || st.integrity >= 1 || st.ldbHours > 0 || st.fire > 0.5) continue;
    if (crews <= 0) break;
    const assign = Math.min(crews, 10);
    crews -= assign;
    const before = st.integrity;
    st.integrity = clamp(st.integrity + assign * C.repairPerCrewTick * repairMult(id, s));
    if (before < 0.5 && st.integrity >= 0.5 && ['subN', 'subE', 'subW', 'subS', 'yards', 'railE', 'railN', 'railS'].includes(id)) {
      const hours = Math.max(6, Math.round((s.hour - (s.stats.raidHours.at(-1) ?? s.hour)) / 6) * 6);
      news(s, `${DISTRICT_BY_ID[id].name} restored in ${hours} hours. Vigilance!`);
    }
  }
  // Pontoon ferry.
  if (integ(s, 'bridge') < 0.4 && c.pontoon < 1) {
    const prev = c.pontoon;
    c.pontoon = clamp(c.pontoon + (1 / C.pontoonTicks) * (1 + 0.3 * c.adaptLevels.railResilience));
    if (prev < 1 && c.pontoon >= 1) news(s, 'Pioneers complete the pontoon ferry below the Elbbrücke. Trains roll again by night!');
  }
  if (integ(s, 'bridge') >= 0.6) c.pontoon = Math.max(0, c.pontoon - 0.05);

  // Mutual aid from other towns along the road.
  if (s.hour - c.lastAidHour >= C.mutualAidEveryDays * 24) {
    c.lastAidHour = s.hour;
    const roadOk = routeStatus(s, 'road');
    if (roadOk > 0.3) {
      c.aidCrews += Math.round(C.mutualAidCrews * roadOk);
      news(s, `Brigades from Westwald and Lindenau arrive to help their sister city. ${Math.round(C.mutualAidCrews * roadOk) * 40} volunteers.`);
    } else if (c.aidCrews > 0) {
      news(s, 'Relief columns stopped at the Westwald pass by enemy air action.', 'intel');
    }
  }
  c.aidCrews = Math.max(0, c.aidCrews - 0.25);

  // ── Morale & evacuation ──
  c.morale = clamp(c.morale + (0.7 - c.morale) * 0.04);
  c.resolve = clamp(c.resolve - 0.004);
  const windowStart = s.hour - C.evacuationWindowDays * 24;
  c.destructionLog = c.destructionLog.filter((e) => e.hour >= windowStart);
  const windowFrac = c.destructionLog.reduce((a, e) => a + e.frac, 0);
  const homesLost = HOUSING_IDS.reduce((a, id) => a + (1 - integ(s, id)), 0) / HOUSING_IDS.length;
  if (!c.evacuationTriggered && windowFrac > C.evacuationThreshold && homesLost > C.evacuationHousingLoss) {
    c.evacuationTriggered = true;
    const leaving = Math.round(c.population * 0.38);
    c.evacuated += leaving;
    c.population -= leaving;
    c.workers = Math.round(c.workers * (1 - C.evacuationWorkforceLoss));
    c.morale = 0.25;
    c.resolve = 0.1;
    c.pools.labor = Math.round(c.pools.labor * 0.5);
    news(s, `MASS EVACUATION. ${leaving.toLocaleString('en')} citizens leave the burning city on foot and by barge. The factories stand empty.`);
  }

  // Political news & adaptation.
  adaptTick(s);
  relocateDefenses(s);
  s.tick++;
}

/** Running tally of civilian deaths by cause, for balancing. */
export const CASUALTY_LOG: Record<string, number> = {};

export function killCivilians(s: GameState, n: number, cause = 'bombs'): void {
  if (n <= 0) return;
  CASUALTY_LOG[cause] = (CASUALTY_LOG[cause] ?? 0) + n;
  const c = s.city;
  c.casualties += n;
  c.injured += n * 2;
  c.population = Math.max(0, c.population - n);
  c.workers = Math.max(0, c.workers - Math.round(n * 0.33));
  s.stats.civiliansKilled += n;
  s.political -= (n / 1000) * CONFIG.politics.civilianDeathsPer1000;
}

export function triggerFirestorm(s: GameState, id: string): void {
  const C = CONFIG.city;
  const d = DISTRICT_BY_ID[id];
  const affected = [id, ...districtNeighbors(id, 3)];
  let deaths = 0;
  for (const n of affected) {
    const st = s.districts[n];
    const nd = DISTRICT_BY_ID[n];
    st.integrity = clamp(st.integrity - C.firestormDamage * (0.4 + nd.flammable));
    st.fire = clamp(st.fire + 0.5 * nd.flammable);
    deaths += Math.round(nd.popDensity * 9000 * (0.5 + nd.flammable));
    if (st.integrity < 0.25) onDestroyed(s, n);
  }
  // Burned out: little is left to burn.
  s.districts[id].fire = 0.25;
  s.districts[id].firebreak = clamp(s.districts[id].firebreak + 0.6, 0, 0.9);
  killCivilians(s, deaths, 'firestorm');
  s.stats.firestorms++;
  s.political -= CONFIG.politics.firestormPenalty;
  s.city.destructionLog.push({ hour: s.hour, frac: 0.08 });
  news(s, `FIRESTORM over ${d.name}. Hurricane winds drag the air into the fire. ${deaths.toLocaleString('en')} dead. The world press calls it a crime.`, 'moral');
}

export function checkDestroyed(s: GameState): void {
  for (const d of DISTRICTS) {
    if (s.districts[d.id].integrity < 0.25) onDestroyed(s, d.id);
  }
}

// ── Adaptation AI ──────────────────────────────────────────────────────────
const COUNTER_NEWS: Record<string, string[]> = {
  samDensity: ['New missile batteries take position around the city. The sky is closed!', 'Allied Nike crews arrive from the coast to guard the high air.'],
  aaaDensity: ['Hundreds of new guns ring the factories. Every rooftop answers!', 'Flak regiment from the coast redeployed into the city rings.'],
  lowAltRadar: ['Low-level radar pickets on every hill. The tree-top bandits will be seen.', 'Gap-filler radars installed in the river valley.'],
  barrierPatrol: ['Starfighter barrier patrols now fly around the clock.', 'Phantoms orbit the eastern frontier day and night.'],
  nightNet: ['The searchlight-radar net lights the night. The night belongs to the defenders.', 'Night fighter crews retrained: the darkness will not hide them.'],
  railResilience: ['Railway battalions stand by day and night: no line will stay cut.', 'Spare rails and bridging girders stockpiled at every junction.'],
  firebreaks: ['Firebreaks cut through the Altstadt. Rooftop fire-watchers on every block.', 'Water bombers and fireboats readied on the river.'],
  dispersal: ['Workshops spread into the villages. The enemy will find empty halls.', 'Production of precision parts moved to a hundred neighborhood workshops.'],
  decoys: ['Our carpenters build cities of plywood. Let them bomb the theatre sets!', 'Smoke generators and dummy works laid out along the river.'],
  hiddenFuel: ['Fuel buried in drum farms across the hills. Let them search!', 'Every barn in the district hides a fuel drum.'],
  humanShields: ['Radar vans and fuel trucks parked in school courtyards for protection.', 'Missile reloads stored between apartment blocks.'],
  freqHop: ['Our radar men change frequency faster than the enemy can jam!', 'New home-on-jam modes fitted to every missile battery.'],
  emcon: ['Missile radars now go silent at the first sign of the radar-killers.', 'Batteries learn to hide behind their own silence.'],
};

function counterScores(history: RaidProfile[]): Record<string, number> {
  const sc: Record<string, number> = { decoys: 0.6, dispersal: 0.4 };
  const add = (k: string, v: number) => (sc[k] = (sc[k] ?? 0) + v);
  for (const r of history) {
    if (r.alt === 'high' || r.alt === 'med') { add('samDensity', 1); add('aaaDensity', 0.6); }
    if (r.alt === 'low') { add('lowAltRadar', 1.1); add('barrierPatrol', 0.8); add('aaaDensity', 0.6); }
    if (r.night) add('nightNet', 1.2);
    if (r.route === 'center') add('barrierPatrol', 0.5);
    if (r.targetKinds.some((k) => ['rail', 'interdiction', 'bridge'].includes(k))) add('railResilience', 1.2);
    if (r.targetKinds.some((k) => ['steel', 'machine', 'chem'].includes(k))) add('dispersal', 0.8);
    if (r.targetKinds.includes('fuel')) add('hiddenFuel', 1);
    if (r.munitions.includes('ZAB-500')) add('firebreaks', 1.3);
    if (r.guidedSuccess) { add('humanShields', 1.2); add('decoys', 1); add('hiddenFuel', 0.6); }
    if (r.usedJamming) add('freqHop', 0.9);
    if (r.munitions.includes('KSR-11')) add('emcon', 1);
    add('decoys', 0.25);
  }
  return sc;
}

function applyCounter(s: GameState, key: string): void {
  const c = s.city;
  c.adaptLevels[key] = (c.adaptLevels[key] ?? 0) + 1;
  const pick = COUNTER_NEWS[key];
  if (pick) news(s, pick[Math.floor(rand(s) * pick.length)]);
  if (key === 'samDensity') {
    const recent = s.raidHeat.at(-1) ?? { x: 5, y: -5 };
    const a = rand(s) * Math.PI * 2;
    const kind = rand(s) < 0.5 ? 'nike' : 'bloodhound';
    const x = recent.x + Math.cos(a) * (8 + rand(s) * 8);
    const y = recent.y + Math.sin(a) * (8 + rand(s) * 8);
    s.sams.push({
      id: `${kind === 'nike' ? 'N' : 'B'}${s.sams.length + 1}`, kind, x, y, knownX: x, knownY: y, knownHour: -9999,
      radar: 1, launchers: 1, isDecoy: false, revealed: false, disabledUntil: 0, missiles: CONFIG.sam[kind].stock,
    });
  }
  if (key === 'firebreaks') {
    for (const d of DISTRICTS) if (d.flammable > 0.5) s.districts[d.id].firebreak = clamp(s.districts[d.id].firebreak + 0.15, 0, 0.85);
  }
  if (key === 'dispersal') {
    for (const id of FACTORY_IDS) s.districts[id].dispersal = clamp(s.districts[id].dispersal + 0.08, 0, 0.7);
  }
  if (key === 'decoys') buildDecoys(s, 2);
  if (key === 'hiddenFuel') spawnFuelFarms(s, 2);
  if (key === 'humanShields') {
    c.shieldsActive = true;
    news(s, 'HQ INTEL: enemy radar vans and reload trucks are now parked inside school yards and between apartment blocks. Precise strikes on them will kill children.', 'moral');
  }
}

export function buildDecoys(s: GameState, n: number): void {
  // Dummies of the most-struck districts, plus false SAM sites and emitters.
  const struck = Object.values(s.districts).filter((d) => d.hitsKg > 0 && DISTRICT_BY_ID[d.id].kind !== 'housing').sort((a, b) => b.hitsKg - a.hitsKg);
  const pool = struck.length ? struck : [s.districts.yards, s.districts.power, s.districts.steel];
  for (let i = 0; i < n; i++) {
    const st = pool[i % pool.length];
    const d = DISTRICT_BY_ID[st.id];
    const a = rand(s) * Math.PI * 2;
    const r = 3 + rand(s) * 5;
    st.decoys.push({ id: `${st.id}-D${st.decoys.length + 1}`, x: d.x + Math.cos(a) * r, y: d.y + Math.sin(a) * r, revealed: false, destroyed: false, seen: false });
  }
  const a = rand(s) * Math.PI * 2;
  const r = 8 + rand(s) * 14;
  const kind = rand(s) < 0.5 ? 'nike' : 'bloodhound';
  s.sams.push({
    id: `${kind === 'nike' ? 'N' : 'B'}${s.sams.length + 1}`, kind, x: Math.cos(a) * r, y: Math.sin(a) * r,
    knownX: Math.cos(a) * r, knownY: Math.sin(a) * r, knownHour: s.hour, radar: 1, launchers: 1,
    isDecoy: true, revealed: false, disabledUntil: 0, missiles: 0,
  });
}

function adaptTick(s: GameState): void {
  const c = s.city;
  const A = CONFIG.adaptation;
  c.adaptPoints += A.pointsPerTick * (1 + c.resolve * A.resolveBonus) * (0.5 + 0.5 * workforceIndex(s));
  // Matured counters take effect.
  const ready = c.adaptPending.filter((p) => p.readyTick <= s.tick);
  c.adaptPending = c.adaptPending.filter((p) => p.readyTick > s.tick);
  for (const p of ready) applyCounter(s, p.key);
  // Spend on the best-scoring affordable counter.
  const scores = counterScores(c.history);
  const options = Object.entries(scores)
    .filter(([k]) => (c.adaptLevels[k] ?? 0) + c.adaptPending.filter((p) => p.key === k).length < A.maxLevel)
    .sort((a, b) => b[1] - a[1]);
  for (const [key, score] of options) {
    const cost = A.costs[key] * (1 + 0.4 * (c.adaptLevels[key] ?? 0));
    if (score <= 0.3) break;
    if (c.adaptPoints >= cost) {
      c.adaptPoints -= cost;
      c.adaptPending.push({ key, readyTick: s.tick + A.lagTicks });
    }
    break; // save up for the top choice
  }
}

export function adaptationLevel(s: GameState): number {
  return Object.values(s.city.adaptLevels).reduce((a, b) => a + b, 0);
}

function relocateDefenses(s: GameState): void {
  // SAM batteries move after they have been raided (at most once per tick).
  if (!s.stats.raidHours.length || s.hour - (s.stats.raidHours.at(-1) ?? 0) > 6) return;
  for (const site of s.sams) {
    if (site.isDecoy || site.launchers <= 0 || rand(s) > CONFIG.sam.relocateChance) continue;
    const oldX = site.x;
    const oldY = site.y;
    const a = rand(s) * Math.PI * 2;
    const r = 3 + rand(s) * 6;
    site.x += Math.cos(a) * r;
    site.y += Math.sin(a) * r;
    if ((s.city.adaptLevels.decoys ?? 0) > 0 && rand(s) < 0.7) {
      s.sams.push({
        id: `${site.id}d`, kind: site.kind, x: oldX, y: oldY, knownX: site.knownX, knownY: site.knownY, knownHour: site.knownHour,
        radar: 1, launchers: 1, isDecoy: true, revealed: false, disabledUntil: 0, missiles: 0,
      });
    }
  }
}

export function recordRaidProfile(s: GameState, p: RaidProfile): void {
  s.city.history.push(p);
  if (s.city.history.length > CONFIG.adaptation.historyLength) s.city.history.shift();
}

/** Probability that a strike aimed with current intel hits a dummy rather than the target. */
export function decoyRisk(s: GameState, id: string): number {
  const st = s.districts[id];
  const stale = Math.max(0, (s.hour - st.intelHour) / 24 - CONFIG.adaptation.intelFreshDays);
  const lvl = s.city.adaptLevels.decoys ?? 0;
  const known = st.decoys.filter((d) => !d.revealed && !d.destroyed).length;
  return clamp(lvl * CONFIG.adaptation.decoyBasePerLevel * (0.4 + stale * CONFIG.adaptation.intelStaleDecayPerDay * 4) + known * 0.12, 0, 0.85);
}

export function cityDestructionFraction(s: GameState): number {
  const ids = [...HOUSING_IDS, ...FACTORY_IDS, 'warehouse', 'port', 'yards', 'power', 'govt', 'military'];
  return ids.reduce((a, id) => a + (1 - integ(s, id)), 0) / ids.length;
}