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