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.
// The raid itself: a real-time world simulation shared by the flown mission and
// by auto-resolve. It mutates the campaign state (districts burn, the grid dies)
// as bombs land, and emits events for effects, audio and radio.
import { CONFIG, type AircraftType, type InterceptorType, type MunitionId } from './config';
import { cityDestructionFraction, samPower, updateGrid } from './city';
import {
altKm, bombsPerAircraft, type FlightPlan, type Impact, type MissionPlan, type MissionResult, type AirframeOutcome,
} from './campaign';
import { BARRIER_RADARS, BARRIER_X, DISTRICTS, DISTRICT_BY_ID, HOUSING_IDS, ROUTES, inRect, terrainHeight, type DistrictDef } from './map';
import { isNight, lightLevel, type GameState, type SamSiteState } from './state';
const TAU = Math.PI * 2;
const clamp = (v: number, lo = 0, hi = 1) => Math.max(lo, Math.min(hi, v));
const wrap = (a: number) => {
while (a > Math.PI) a -= TAU;
while (a < -Math.PI) a += TAU;
return a;
};
const gauss = () => {
let u = 0;
let v = 0;
while (!u) u = Math.random();
while (!v) v = Math.random();
return Math.sqrt(-2 * Math.log(u)) * Math.cos(TAU * v);
};
const dist = (ax: number, ay: number, bx: number, by: number) => Math.hypot(ax - bx, ay - by);
export type PlaneKind = AircraftType | InterceptorType | 'drone';
export interface Plane {
id: number;
side: 'red' | 'blue';
kind: PlaneKind;
flight: FlightRT | null;
slot: number;
airframeId: number;
callsign: string;
x: number;
y: number;
alt: number;
tAlt: number;
hdg: number;
spd: number;
baseSpd: number;
turnRate: number;
hp: number;
maxHp: number;
alive: boolean;
done: boolean;
isPlayer: boolean;
xp: number;
role: string;
sticks: number;
stickKg: number;
munition: MunitionId | null;
missiles: number;
aim9: number;
aim7: number;
chaff: number;
wp: number;
path: [number, number][];
state: 'wait' | 'fly' | 'run' | 'egress' | 'engage' | 'cap' | 'rtb';
spawnAt: number;
targetId: number;
cool: number;
gunCool: number;
released: boolean;
releaseT: number;
jinkUntil: number;
engineFire: number;
hydraulics: number;
controlBias: number;
instruments: number;
lastHit: number;
fate: AirframeOutcome['fate'];
bombsOnTarget: number;
jamming: boolean;
lastHdg: number;
bingo: number;
fuelT: number;
trail: number;
tracked: number; // seconds since a guidance radar last locked it
searchlit: number;
crossedBarrier: boolean;
isWingmanOfPlayer: boolean;
chuteCount: number;
lastSrc: string;
evadeUntil: number;
evadeDir: number;
bank: number; // visual roll, -1 … 1 (right wing down positive)
}
export interface FlightRT {
plan: FlightPlan;
planes: Plane[];
aimX: number;
aimY: number;
aimKey: string | null;
delay: number;
released: boolean;
calledFeetDry: boolean;
retargeted: boolean;
}
export interface Missile {
id: number;
kind: 'nike' | 'bloodhound' | 'aim9' | 'aim7' | 'r3s' | 'ksr11' | 'standoff';
side: 'red' | 'blue';
x: number;
y: number;
alt: number;
hdg: number;
spd: number;
turn: number;
targetId: number;
tx: number;
ty: number;
life: number;
guided: boolean;
site: SiteRT | null;
lethal: number;
dmg: number;
pk: number;
minD: number;
alive: boolean;
munition: MunitionId | null;
owner: Plane | null;
flightId: string;
aimKey: string | null;
seduced: boolean;
age: number;
evaded: boolean;
missX: number;
missY: number;
}
export interface SiteRT {
st: SamSiteState;
state: 'idle' | 'search' | 'track' | 'guide' | 'reload' | 'dead' | 'silent';
timer: number;
targetId: number;
ready: number;
silentUntil: number;
emitting: boolean;
inFlight: number;
}
export interface Gun {
x: number;
y: number;
kind: 'heavy' | 'bofors' | 'vulcan';
district: string;
cool: number;
err: number;
targetId: number;
lastHdg: number;
alive: boolean;
suppressed: number;
}
export interface Bomb {
x: number;
y: number;
vx: number;
vy: number;
alt0: number;
t: number;
ft: number;
kg: number;
munition: MunitionId;
flightId: string;
player: boolean;
guided: boolean;
steerX: number;
steerY: number;
aimKey: string | null;
owner: Plane | null;
alive: boolean;
}
export interface Searchlight {
x: number;
y: number;
ang: number;
len: number;
speed: number;
targetId: number;
hold: number;
}
export interface ChaffCloud {
x: number;
y: number;
alt: number;
r: number;
life: number;
max: number;
}
export type SimEvent =
| { t: 'flak'; x: number; y: number; alt: number; kind: Gun['kind']; near: number }
| { t: 'tracer'; x: number; y: number; tx: number; ty: number; alt: number; onPlayer: boolean; gun?: Gun['kind'] }
| { t: 'samLaunch'; x: number; y: number; m: Missile }
| { t: 'missileDet'; x: number; y: number; alt: number; near: number; kind: Missile['kind'] }
| { t: 'impact'; x: number; y: number; kg: number; munition: MunitionId; player: boolean; hit: string | null }
| { t: 'secondary'; x: number; y: number; big: boolean }
| { t: 'hit'; plane: Plane; dmg: number }
| { t: 'down'; plane: Plane }
| { t: 'chaff'; x: number; y: number; alt: number; player: boolean }
| { t: 'release'; plane: Plane }
| { t: 'gun'; x: number; y: number; hdg: number; player: boolean }
| { t: 'blackout'; district: string }
| { t: 'radio'; from: string; text: string; urgent: boolean }
| { t: 'barrage'; x: number; y: number; tx: number; ty: number; alt: number; hue: number }
| { t: 'picketDown'; x: number; y: number }
| { t: 'sys'; what: 'doors' | 'clunk' | 'hung' | 'unhung' | 'extinguish' | 'engineBoom' | 'hydBackup' | 'isolate' | 'leak' | 'marker' | 'beamLost' | 'flameout' | 'crash' | 'tailgun' | 'formation'; x: number; y: number; v?: number };
export interface Controls {
turn: number;
climb: number;
throttle: number;
fire: boolean;
secondary: boolean;
chaff: boolean;
steerX: number;
steerY: number;
// One-shot crew actions (set true by input, cleared by the sim).
doors: boolean;
mode: boolean;
marker: boolean;
extinguish: boolean;
hydraulics: boolean;
isolate: boolean;
formation: 'tight' | 'normal' | 'loose' | null;
tailGun: boolean; // held
firePressed: boolean; // edge for single-release mode
}
/** The player's own aircraft systems and crew stations. */
export interface Crew {
doors: number; // 0 closed … 1 open
doorsWant: boolean;
mode: 'train' | 'salvo' | 'single';
markers: number;
extinguishers: number;
fireT: number;
hydBackup: boolean;
fuel: number; // 0 … 1
fuelRate: number; // fraction per game-second at cruise
fuelLeak: number;
leakIsolated: boolean;
hung: number;
doorCycle: number;
formation: 'tight' | 'normal' | 'loose';
beam: number;
beamTurn: number;
agl: number;
pullUp: boolean;
tailTarget: number;
flamedOut: boolean;
}
export interface World {
s: GameState;
plan: MissionPlan;
t: number;
planes: Plane[];
flights: FlightRT[];
missiles: Missile[];
bombs: Bomb[];
sites: SiteRT[];
guns: Gun[];
lights: Searchlight[];
chaff: ChaffCloud[];
events: SimEvent[];
impacts: Impact[];
player: Plane | null;
playerLost: boolean;
windX: number;
windY: number;
night: boolean;
light: number;
detected: boolean;
detectT: number;
scrambled: boolean;
interceptorsKilled: number;
samLaunches: number;
samSitesHit: Set<string>;
guidedHits: number;
usedJamming: boolean;
firestorms: string[];
fire: FireGrid;
nextId: number;
ended: boolean;
radioCooldown: Record<string, number>;
ctl: Controls;
playerReleaseHeld: boolean;
guidedBomb: Bomb | null;
powerWas: Record<string, boolean>;
bandits: number;
autoResolved: boolean;
dronesUsed: number;
photoTaken: Set<string>;
silence: { at: number; from: string; to: string }[];
destructionBefore: number;
pickets: { x: number; y: number; alive: boolean }[];
markers: { x: number; y: number; life: number }[];
crew: Crew;
droneBait: boolean;
brevity: Record<string, boolean>;
}
// ── Fire grid (spread physics over the city) ────────────────────────────────
export interface FireGrid {
x0: number;
y0: number;
cell: number;
w: number;
h: number;
heat: Float32Array;
fuel: Float32Array;
flam: Float32Array;
district: Int16Array;
acc: number;
}
function makeFireGrid(s: GameState): FireGrid {
const cell = 0.5;
const x0 = -42;
const y0 = -26;
const w = Math.ceil(72 / cell);
const h = Math.ceil(48 / cell);
const g: FireGrid = {
x0, y0, cell, w, h, heat: new Float32Array(w * h), fuel: new Float32Array(w * h), flam: new Float32Array(w * h),
district: new Int16Array(w * h).fill(-1), acc: 0,
};
for (let j = 0; j < h; j++) {
for (let i = 0; i < w; i++) {
const x = x0 + (i + 0.5) * cell;
const y = y0 + (j + 0.5) * cell;
const k = j * w + i;
const r = Math.hypot(x + 2, y);
g.flam[k] = r < 17 ? 0.12 : r < 24 ? 0.05 : 0;
g.fuel[k] = 1;
DISTRICTS.forEach((d, di) => {
if (inRect(d, x, y)) {
g.district[k] = di;
g.flam[k] = d.flammable * (1 - s.districts[d.id].firebreak * 0.7);
g.heat[k] = s.districts[d.id].fire * (0.5 + Math.random() * 0.8);
g.fuel[k] = 0.3 + 0.7 * s.districts[d.id].integrity;
}
});
}
}
return g;
}
export function igniteAt(g: FireGrid, x: number, y: number, amount: number, radius: number): void {
const r = Math.max(1, Math.ceil(radius / g.cell));
const ci = Math.floor((x - g.x0) / g.cell);
const cj = Math.floor((y - g.y0) / g.cell);
for (let j = cj - r; j <= cj + r; j++) {
for (let i = ci - r; i <= ci + r; i++) {
if (i < 0 || j < 0 || i >= g.w || j >= g.h) continue;
const k = j * g.w + i;
if (g.flam[k] <= 0) continue;
g.heat[k] = clamp(g.heat[k] + amount * g.flam[k] * (0.6 + Math.random() * 0.6));
}
}
}
function stepFire(w: World, dt: number): void {
const g = w.fire;
g.acc += dt;
if (g.acc < 0.25) return;
const step = g.acc;
g.acc = 0;
const wx = Math.sign(w.windX);
const wy = Math.sign(w.windY);
const next = g.heat.slice();
for (let j = 1; j < g.h - 1; j++) {
for (let i = 1; i < g.w - 1; i++) {
const k = j * g.w + i;
const h = g.heat[k];
if (h <= 0.02) continue;
// burn fuel, grow toward flashover
g.fuel[k] = Math.max(0, g.fuel[k] - h * 0.012 * step);
next[k] = clamp(h + (g.fuel[k] > 0.05 ? 0.05 * g.flam[k] : -0.08) * step);
const spread = h * CONFIG.city.fireSpread * step * 0.7;
for (const [di, dj] of [[1, 0], [-1, 0], [0, 1], [0, -1]]) {
const n = (j + dj) * g.w + (i + di);
if (g.flam[n] <= 0 || g.fuel[n] <= 0.05) continue;
const windBoost = 1 + 0.8 * (di * wx + dj * wy);
if (Math.random() < spread * g.flam[n] * windBoost) next[n] = clamp(next[n] + 0.2 * h);
}
}
}
g.heat.set(next);
}
/** Aggregate fire per district back into the campaign state. */
function settleFire(w: World): void {
const g = w.fire;
const sum = new Float32Array(DISTRICTS.length);
const n = new Float32Array(DISTRICTS.length);
for (let k = 0; k < g.heat.length; k++) {
const d = g.district[k];
if (d < 0) continue;
sum[d] += g.heat[k];
n[d]++;
}
DISTRICTS.forEach((d, i) => {
if (!n[i]) return;
const mean = sum[i] / n[i];
const st = w.s.districts[d.id];
st.fire = clamp(Math.max(st.fire * 0.8, mean * 1.2));
st.integrity = clamp(st.integrity - mean * 0.1);
});
}
// ── Setup ──────────────────────────────────────────────────────────────────
function flightSpeed(type: AircraftType): number {
return CONFIG.aircraft[type].speed;
}
function chooseAim(s: GameState, target: string | null, route?: string): { x: number; y: number; key: string | null } {
if (!target) return { x: 0, y: 0, key: null };
if (target === 'pickets') {
// The early-warning radar nearest the chosen ingress corridor.
const ref = route === 'north' ? -55 : 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, key: 'pickets' };
}
if (target.startsWith('sam:')) {
const site = s.sams.find((x) => x.id === target.slice(4));
if (site) return { x: site.knownX, y: site.knownY, key: target };
}
if (target.startsWith('farm:')) {
const f = s.city.fuelFarms.find((x) => x.id === target.slice(5));
if (f) return { x: f.x, y: f.y, key: target };
}
const d = DISTRICT_BY_ID[target];
if (!d) return { x: 0, y: 0, key: null };
const st = s.districts[d.id];
// Decoys: known unrevealed dummies and unknown ones (stale intel, night) compete for the aim.
const stale = Math.max(0, (s.hour - st.intelHour) / 24 - CONFIG.adaptation.intelFreshDays);
const candidates = st.decoys.filter((x) => !x.revealed && !x.destroyed);
for (const dec of candidates) {
const w = 0.5 + stale * 0.12 + (isNight(s.hour) ? 0.3 : 0);
if (Math.random() < w / (1 + w + candidates.length * 0.3)) return { x: dec.x, y: dec.y, key: target };
}
return { x: d.x, y: d.y, key: target };
}
function makePlane(w: World, init: Partial<Plane> & { kind: PlaneKind; side: 'red' | 'blue'; x: number; y: number }): Plane {
const red = init.side === 'red' && init.kind !== 'drone';
const spec = red ? CONFIG.aircraft[init.kind as AircraftType] : null;
const blue = init.side === 'blue' ? CONFIG.interceptors[init.kind as InterceptorType] : null;
const hp = spec ? spec.hp : blue ? blue.hp : 12;
const spd = spec ? spec.speed : blue ? blue.speed : 1.3;
const p: Plane = {
id: w.nextId++, flight: null, slot: 0, airframeId: -1, callsign: '', alt: 5, tAlt: 5, hdg: Math.PI, spd, baseSpd: spd,
turnRate: spec ? spec.turn : blue ? blue.turn : 0.6, hp, maxHp: hp, alive: true, done: false, isPlayer: false, xp: 0.5,
role: spec?.role ?? 'interceptor', sticks: 0, stickKg: 0, munition: null, missiles: 0, aim9: blue?.aim9 ?? 0,
aim7: blue?.aim7 ?? 0, chaff: CONFIG.pres.perAircraft, wp: 0, path: [], state: 'fly', spawnAt: 0, targetId: -1, cool: 0,
gunCool: 0, released: false, releaseT: 0, jinkUntil: 0, engineFire: 0, hydraulics: 0, controlBias: 0, instruments: 1,
lastHit: -99, fate: 'ok', bombsOnTarget: 0, jamming: false, lastHdg: 0, bingo: 9999, fuelT: 0, trail: 0, tracked: 99,
searchlit: 0, crossedBarrier: false, isWingmanOfPlayer: false, chuteCount: 0, lastSrc: '', evadeUntil: 0, evadeDir: 0, bank: 0,
...init,
};
if (p.kind === 'Lightning') p.aim9 = 2; // Red Top / Firestreak as IR missiles
p.lastHdg = p.hdg;
w.planes.push(p);
return p;
}
export function createWorld(s: GameState, plan: MissionPlan): World {
updateGrid(s);
const night = isNight(plan.launchHour);
const ang = Math.random() * TAU;
const w: World = {
s, plan, t: 0, planes: [], flights: [], missiles: [], bombs: [], sites: [], guns: [], lights: [], chaff: [], events: [],
impacts: [], player: null, playerLost: false, windX: Math.cos(ang) * CONFIG.mission.windKmS, windY: Math.sin(ang) * CONFIG.mission.windKmS,
night, light: lightLevel(plan.launchHour), detected: false, detectT: 0, scrambled: false, interceptorsKilled: 0, samLaunches: 0,
samSitesHit: new Set(), guidedHits: 0, usedJamming: false, firestorms: [], fire: makeFireGrid(s), nextId: 1, ended: false,
radioCooldown: {}, ctl: { turn: 0, climb: 0, throttle: 0, fire: false, secondary: false, chaff: false, steerX: 0, steerY: 0, doors: false, mode: false, marker: false, extinguish: false, hydraulics: false, isolate: false, formation: null, tailGun: false, firePressed: false },
playerReleaseHeld: false, guidedBomb: null, powerWas: Object.fromEntries(DISTRICTS.map((d) => [d.id, s.districts[d.id].powered])),
bandits: 0, autoResolved: false, dronesUsed: 0, photoTaken: new Set(), silence: [], destructionBefore: cityDestructionFraction(s), pickets: BARRIER_RADARS.map(([x, y]) => ({ x, y, alive: true })), droneBait: false, brevity: {}, markers: [],
crew: {
doors: 0, doorsWant: false, mode: 'train', markers: CONFIG.mission.crew.markers, extinguishers: CONFIG.mission.crew.extinguishers, fireT: 0,
hydBackup: true, fuel: 1, fuelRate: 0.002, fuelLeak: 0, leakIsolated: false, hung: 0, doorCycle: 0, formation: 'normal', beam: 0, beamTurn: 0,
agl: 5, pullUp: false, tailTarget: -1, flamedOut: false,
},
};
const route = ROUTES[plan.route];
const egress = route.egress;
const ingress = route.ingress;
// Timing: all strike flights on target together, others offset by their wave.
const pathLen = (pts: [number, number][]) => pts.slice(1).reduce((a, p, i) => a + dist(p[0], p[1], pts[i][0], pts[i][1]), 0);
const rts: { f: FlightPlan; path: [number, number][]; aim: { x: number; y: number; key: string | null }; travel: number }[] = [];
for (const f of plan.flights) {
const aim = chooseAim(s, f.target ?? plan.primary, plan.route);
let pts: [number, number][];
if (f.role === 'escort' || f.role === 'jammer') pts = [...ingress, [aim.x + 4, aim.y], ...egress];
else if (f.role === 'recon') pts = [...ingress, [aim.x, aim.y], ...egress];
else pts = [...ingress, [aim.x, aim.y], ...egress];
const toTarget = pathLen([...ingress, [aim.x, aim.y]]);
rts.push({ f, path: pts, aim, travel: toTarget / flightSpeed(f.type) });
}
// Escorts and jammers launch with the bombers they protect.
const strikeTravel = Math.max(0, ...rts.filter((r) => r.f.role === 'strike').map((r) => r.travel));
for (const r of rts) if ((r.f.role === 'escort' || r.f.role === 'jammer') && strikeTravel) r.travel = strikeTravel;
const T = Math.max(...rts.map((r) => r.travel), 0);
let minDelay = Infinity;
const delays = rts.map((r) => {
const d = T - r.travel + r.f.offsetSec;
minDelay = Math.min(minDelay, d);
return d;
});
rts.forEach((r, i) => {
const delay = delays[i] - minDelay;
const fr: FlightRT = { plan: r.f, planes: [], aimX: r.aim.x, aimY: r.aim.y, aimKey: r.aim.key, delay, released: false, calledFeetDry: false, retargeted: false };
w.flights.push(fr);
const spec = CONFIG.aircraft[r.f.type];
const [sx, sy] = r.path[0];
const [nx, ny] = r.path[1];
const hdg = Math.atan2(ny - sy, nx - sx);
r.f.airframeIds.forEach((afId, slot) => {
const af = s.fleet.find((a) => a.id === afId);
const per = r.f.loads?.[slot] ?? bombsPerAircraft(r.f.type, r.f.munition);
const kg = r.f.munition ? per * CONFIG.munitions[r.f.munition].kg : 0;
const isMissile = r.f.munition ? CONFIG.munitions[r.f.munition].missile || CONFIG.munitions[r.f.munition].guided : false;
const sticks = isMissile ? per : r.f.munition ? Math.min(CONFIG.mission.sticks, per) : 0;
const back = Math.floor((slot + 1) / 2) * 0.9;
const side = slot === 0 ? 0 : slot % 2 ? -1 : 1;
const p = makePlane(w, {
kind: r.f.type, side: 'red', x: sx - Math.cos(hdg) * back + Math.cos(hdg + Math.PI / 2) * side * back * 0.7,
y: sy - Math.sin(hdg) * back + Math.sin(hdg + Math.PI / 2) * side * back * 0.7, hdg, alt: altKm(plan.ingressAlt),
tAlt: altKm(plan.ingressAlt), flight: fr, slot, airframeId: afId, callsign: `${r.f.callsign}-${slot + 1}`, xp: af?.xp ?? 0.4,
role: r.f.role, munition: r.f.munition, sticks, stickKg: sticks ? kg / sticks : 0, missiles: isMissile ? per : r.f.role === 'escort' ? 2 : 0,
path: r.path.map(([x, y]) => [x + Math.cos(hdg + Math.PI / 2) * side * back * 0.7, y + Math.sin(hdg + Math.PI / 2) * side * back * 0.7] as [number, number]),
wp: 1, state: 'wait', spawnAt: delay, jamming: r.f.role === 'jammer', chaff: r.f.role === 'jammer' ? CONFIG.pres.perJammerSortie : CONFIG.pres.perAircraft,
bingo: spec.fighter ? (plan.route === 'center' ? 9999 : (plan.tanker ? CONFIG.mission.tanker.fighterBingoTanker : CONFIG.mission.tanker.fighterBingoNoTanker) * 260) : 9999,
});
if (r.f.id === plan.playerFlight && slot === 0) {
p.isPlayer = true;
w.player = p;
}
if (r.f.id === plan.playerFlight && slot > 0) p.isWingmanOfPlayer = true;
fr.planes.push(p);
});
});
// Fuel for the planned route plus a reserve.
if (w.player) {
const path = w.player.path;
const len = path.slice(1).reduce((acc, q, i) => acc + dist(q[0], q[1], path[i][0], path[i][1]), 0);
w.crew.fuelRate = 1 / ((len / w.player.baseSpd) * CONFIG.mission.crew.fuelReserve);
}
// Tu-123 drones run ahead to draw missiles.
for (let i = 0; i < plan.drones; i++) {
const [sx, sy] = ingress[0];
const p = makePlane(w, { kind: 'drone', side: 'red', x: sx, y: sy + (i - plan.drones / 2) * 3, alt: 7, tAlt: 7, hdg: Math.PI, path: [...ingress.map(([x, y]) => [x, y + (i - plan.drones / 2) * 4] as [number, number]), [(Math.random() - 0.5) * 20, (Math.random() - 0.5) * 20], [-70, (Math.random() - 0.5) * 60]], wp: 1, state: 'wait', spawnAt: Math.max(0, T - minDelay - CONFIG.ew.droneLaunchLead - 40 + i * 4), callsign: `DRONE-${i + 1}`, role: 'drone' });
p.spd = 1.3;
w.dronesUsed++;
}
// SAM sites.
for (const st of s.sams) {
if (st.launchers <= 0.05 && !st.isDecoy) continue;
w.sites.push({ st, state: 'idle', timer: Math.random() * 3, targetId: -1, ready: st.isDecoy ? 0 : Math.min(CONFIG.sam[st.kind].ready, st.missiles), silentUntil: 0, emitting: false, inFlight: 0 });
}
// AAA belts.
const aaaMult = (1 + 0.22 * (s.city.adaptLevels.aaaDensity ?? 0)) * (0.55 + 0.45 * Math.min(1, s.city.workers / CONFIG.city.workers + 0.1));
const lowMult = 1 + 0.25 * (s.city.adaptLevels.lowAltRadar ?? 0);
for (const d of DISTRICTS) {
const add = (kind: Gun['kind'], n: number) => {
const count = Math.round(n * aaaMult * (kind === 'heavy' ? 1 : lowMult));
for (let i = 0; i < count; i++) {
const a = Math.random() * TAU;
const r = Math.max(d.w, d.h) / 2 + 0.4 + Math.random() * (kind === 'heavy' ? 2.5 : 1.2);
w.guns.push({ x: d.x + Math.cos(a) * r, y: d.y + Math.sin(a) * r, kind, district: d.id, cool: Math.random() * 2, err: CONFIG.aaa[kind].err0, targetId: -1, lastHdg: 0, alive: true, suppressed: 0 });
}
};
add('heavy', d.aaa.heavy);
add('bofors', d.aaa.bofors);
add('vulcan', d.aaa.vulcan);
}
for (const site of w.sites) {
if (site.st.isDecoy) continue;
for (let i = 0; i < 2; i++) w.guns.push({ x: site.st.x + gauss() * 0.6, y: site.st.y + gauss() * 0.6, kind: 'vulcan', district: 'military', cool: 1, err: 0.6, targetId: -1, lastHdg: 0, alive: true, suppressed: 0 });
}
// Searchlight-radar net at night.
if (night || w.light < 0.4) {
const n = 10 + 5 * (s.city.adaptLevels.nightNet ?? 0);
for (let i = 0; i < n; i++) {
const a = Math.random() * TAU;
const r = 3 + Math.random() * 14;
w.lights.push({ x: Math.cos(a) * r, y: Math.sin(a) * r, ang: Math.random() * TAU, len: 5 + Math.random() * 3, speed: (Math.random() - 0.5) * 0.6, targetId: -1, hold: 0 });
}
}
// Barrier combat air patrol.
const cap = Math.round((CONFIG.interceptors.barrierCap + (s.city.adaptLevels.barrierPatrol ?? 0) * 1.5) * (night ? CONFIG.interceptors.nightEffectiveness : 1));
const fuelOk = s.city.stock.fuel > 0 ? 1 : 0.5;
for (let i = 0; i < Math.round(cap * fuelOk); i++) {
const kind: InterceptorType = i % 2 ? 'F-104G' : 'F-4';
if (s.city.interceptors[kind] <= 0) continue;
const y = [-30, 28, 0, -55, 52][i % 5];
makePlane(w, { kind, side: 'blue', x: BARRIER_X - 10 + Math.random() * 6, y: y + Math.random() * 5, alt: 7, tAlt: 7, hdg: Math.random() * TAU, state: 'cap', path: [[BARRIER_X - 8, y]] });
}
w.events.length = 0;
return w;
}
// ── Helpers ────────────────────────────────────────────────────────────────
function planeById(w: World, id: number): Plane | undefined {
return w.planes.find((p) => p.id === id);
}
function clock(from: Plane, x: number, y: number): number {
const rel = wrap(Math.atan2(y - from.y, x - from.x) - from.hdg);
let c = Math.round((rel / TAU) * 12);
if (c <= 0) c += 12;
return c;
}
export function radio(w: World, from: string, text: string, urgent = false, key?: string, cooldown = 6): void {
if (key) {
if ((w.radioCooldown[key] ?? -99) > w.t) return;
w.radioCooldown[key] = w.t + cooldown;
}
w.events.push({ t: 'radio', from, text, urgent });
}
export function ccip(w: World, p: Plane, useActualWind = false): { x: number; y: number; ft: number } {
const M = CONFIG.mission;
const ft = M.gravityFall * Math.sqrt(Math.max(0.05, p.alt - terrainHeight(p.x, p.y)));
const fwd = p.spd * ft * M.throwFactor;
const known = useActualWind ? 1 : 1 - M.windForecastError * clamp(p.alt / M.altHigh);
return {
x: p.x + Math.cos(p.hdg) * fwd + w.windX * ft * M.windDriftFactor * known,
y: p.y + Math.sin(p.hdg) * fwd + w.windY * ft * M.windDriftFactor * known,
ft,
};
}
function jammedAt(w: World, x: number, y: number, siteX: number, siteY: number): number {
let best = 0;
for (const p of w.planes) {
if (!p.alive || p.done || !p.jamming || p.state === 'wait') continue;
if (dist(p.x, p.y, x, y) > CONFIG.ew.jammerRadius) continue;
if (dist(siteX, siteY, x, y) < CONFIG.sam.burnThroughKm) continue;
best = Math.max(best, CONFIG.ew.jammerStrength);
}
if (best > 0) w.usedJamming = true;
return clamp(best - CONFIG.ew.freqHopPerLevel * (w.s.city.adaptLevels.freqHop ?? 0));
}
function chaffNear(w: World, x: number, y: number, alt: number): boolean {
return w.chaff.some((c) => dist(c.x, c.y, x, y) < c.r && Math.abs(c.alt - alt) < 2);
}
export const DAMAGE_LOG: Record<string, number> = {};
function damagePlane(w: World, p: Plane, dmg: number, src = 'other'): void {
if (!p.alive || p.done) return;
if (p.side === 'red') DAMAGE_LOG[src] = (DAMAGE_LOG[src] ?? 0) + dmg;
p.lastSrc = src;
const armor = p.side === 'red' ? 1 - p.xp * 0.15 : 1;
const d = dmg * armor;
p.hp -= d;
p.lastHit = w.t;
w.events.push({ t: 'hit', plane: p, dmg: d });
if (p.side === 'red' && d > 4) {
const r = Math.random();
if (r < 0.18) p.engineFire = Math.max(p.engineFire, 0.4 + Math.random() * 0.6);
else if (r < 0.36) p.hydraulics = clamp(p.hydraulics + 0.35);
else if (r < 0.52) p.controlBias = clamp(p.controlBias + (Math.random() - 0.5) * 0.5, -0.6, 0.6);
else if (r < 0.7) p.instruments = clamp(p.instruments - 0.3);
if (p.isPlayer && Math.random() < CONFIG.mission.crew.fuelLeakChance && w.crew.fuelLeak === 0) {
w.crew.fuelLeak = 0.002 + Math.random() * 0.004;
w.events.push({ t: 'sys', what: 'leak', x: p.x, y: p.y });
radio(w, 'FLIGHT ENGINEER', 'Fuel leak — number two tank is streaming!', true);
}
}
if (p.hp <= 0) killPlane(w, p);
}
function killPlane(w: World, p: Plane): void {
if (!p.alive) return;
p.alive = false;
p.hp = 0;
w.events.push({ t: 'down', plane: p });
if (p.side === 'blue') {
w.interceptorsKilled++;
const shooter = w.planes.find((q) => q.side === 'red' && q.alive && !q.done && CONFIG.aircraft[q.kind as AircraftType]?.fighter && dist(q.x, q.y, p.x, p.y) < 8);
radio(w, shooter?.callsign ?? 'TAIL GUNNER', `Splash one ${String(p.kind)}!`, false, `splash${p.id}`, 1);
const k = p.kind as InterceptorType;
w.s.city.interceptors[k] = Math.max(0, w.s.city.interceptors[k] - 1);
return;
}
if (p.kind === 'drone') return;
const home = p.x > BARRIER_X + 5;
const crew = CONFIG.aircraft[p.kind as AircraftType].crew;
const chutes = Math.random() < 0.7 ? Math.max(1, Math.round(crew * (0.3 + Math.random() * 0.7))) : 0;
p.chuteCount = chutes;
p.fate = home ? 'ejectedHome' : chutes && Math.random() < CONFIG.crew.captureChance + 0.3 ? 'captured' : 'killed';
if (p.isPlayer) w.playerLost = true;
const fr = p.flight;
if (fr) {
const msg = chutes ? `${p.callsign} is hit — going down! I count ${chutes} chute${chutes > 1 ? 's' : ''}.` : `${p.callsign} is down. No chutes. No chutes.`;
radio(w, fr.planes.find((x) => x.alive && x !== p)?.callsign ?? 'TOWER', msg, true);
if (p.isWingmanOfPlayer && w.player?.alive) {
w.silence.push({ at: w.t + 5, from: w.player.callsign, to: p.callsign });
}
}
}
// ── Damage to the city ─────────────────────────────────────────────────────
function hitTest(w: World, x: number, y: number, radius: number): { key: string | null; def: DistrictDef | null; decoyOf?: string } {
const s = w.s;
for (let i = 0; i < w.pickets.length; i++) {
const pk = w.pickets[i];
if (pk.alive && dist(pk.x, pk.y, x, y) < 0.6 + radius) return { key: `picket:${i}`, def: null };
}
for (const site of s.sams) {
if (dist(site.x, site.y, x, y) < 0.45 + radius) return { key: `${site.isDecoy ? 'samdecoy' : 'sam'}:${site.id}`, def: null };
}
for (const f of s.city.fuelFarms) {
if (!f.destroyed && dist(f.x, f.y, x, y) < 0.5 + radius) return { key: `farm:${f.id}`, def: null };
}
for (const d of DISTRICTS) {
for (const dec of s.districts[d.id].decoys) {
if (!dec.destroyed && inRect({ x: dec.x, y: dec.y, w: d.w * 0.8, h: d.h * 0.8 }, x, y, radius * 0.5)) return { key: `decoy:${d.id}:${dec.id}`, def: d, decoyOf: d.id };
}
}
for (const d of DISTRICTS) {
if (inRect(d, x, y, radius * 0.5)) return { key: `d:${d.id}`, def: d };
}
return { key: null, def: null };
}
export function impact(w: World, x: number, y: number, kg: number, m: MunitionId, flightId: string, player: boolean, aimKey: string | null, owner: Plane | null): void {
const s = w.s;
const spec = CONFIG.munitions[m];
const h = hitTest(w, x, y, spec.radius);
let civ = 0;
const pts = (kg / 100) * spec.blast * CONFIG.mission.damageScale;
if (h.key?.startsWith('d:') && h.def) {
const d = h.def;
const st = s.districts[d.id];
const rail = d.kind === 'rail' || d.kind === 'interdiction' || d.kind === 'bridge';
const eff = rail ? spec.vsRail : spec.vsHard * d.hardness + spec.vsSoft * (1 - d.hardness);
const before = st.integrity;
st.integrity = clamp(st.integrity - (pts * eff) / d.hp);
st.hitsKg += kg;
if (spec.delay) st.ldbHours += CONFIG.city.ldbHoursPerBomb * (kg / 500);
const fireAdd = (kg / 1000) * (spec.fire + 0.05) * d.flammable * (1 - st.firebreak * 0.6);
st.fire = clamp(st.fire + fireAdd);
igniteAt(w.fire, x, y, spec.fire * 1.6 + 0.1, spec.radius + (spec.fire > 0.5 ? 0.6 : 0.1));
const housing = HOUSING_IDS.includes(d.id) || d.kind === 'hospital';
civ = kg * (housing ? CONFIG.city.civiliansPerKgInHousing : CONFIG.city.civiliansPerKgElsewhere) * d.popDensity;
if (s.city.shieldsActive && ['military', 'fuel', 'airbase'].includes(d.kind)) civ *= 3;
if (['fuel', 'chem', 'port', 'military', 'airbase'].includes(d.kind) && Math.random() < 0.25 + (d.kind === 'fuel' ? 0.4 : 0)) {
w.events.push({ t: 'secondary', x: x + gauss() * 0.3, y: y + gauss() * 0.3, big: d.kind === 'fuel' || d.kind === 'chem' });
st.integrity = clamp(st.integrity - 0.04);
if (d.kind === 'fuel') s.city.stock.fuel = Math.max(0, s.city.stock.fuel - 0.6);
}
if (d.kind === 'airbase') {
for (const k of ['F-104G', 'F-4', 'Lightning'] as InterceptorType[]) if (Math.random() < pts * 0.03) s.city.interceptors[k] = Math.max(0, s.city.interceptors[k] - 1);
}
if (d.kind === 'military') {
for (const site of s.sams) if (!site.isDecoy && Math.random() < 0.05) site.missiles = Math.max(0, site.missiles - 2);
}
if (before > 0.25 && st.integrity <= 0.25) radio(w, 'RECON', `${d.name} — heavy damage observed.`, false, `dmg${d.id}`, 30);
// Guns near the impact are knocked out.
for (const g of w.guns) if (g.alive && dist(g.x, g.y, x, y) < spec.radius * (spec.cluster ? 2.2 : 1.2)) g.alive = Math.random() > 0.6;
} else if (h.key?.startsWith('decoy:') && h.decoyOf) {
const dec = s.districts[h.decoyOf].decoys.find((q) => h.key!.endsWith(q.id));
if (dec && pts > 2) dec.destroyed = Math.random() < 0.5;
} else if (h.key?.startsWith('sam') ) {
const site = s.sams.find((q) => h.key!.endsWith(`:${q.id}`));
if (site) {
const k = spec.cluster ? 2.5 : 1;
site.launchers = clamp(site.launchers - (pts * k) / 18);
site.radar = clamp(site.radar - (pts * k) / 22);
if (!site.isDecoy && site.launchers < 0.25) {
site.disabledUntil = s.hour + CONFIG.sam.siteRebuildDays * 24;
w.samSitesHit.add(site.id);
const rt = w.sites.find((r) => r.st === site);
if (rt) rt.state = 'dead';
}
if (s.city.shieldsActive && !site.isDecoy) civ += kg * 0.01;
}
} else if (h.key?.startsWith('picket:')) {
const pk = w.pickets[Number(h.key.slice(7))];
if (pk && pts > 1.2) {
pk.alive = false;
w.events.push({ t: 'picketDown', x: pk.x, y: pk.y });
w.events.push({ t: 'secondary', x: pk.x, y: pk.y, big: false });
radio(w, owner?.callsign ?? 'STRIKE', 'Picket radar is down. The door is open — the barrier is blind here.', false);
}
} else if (h.key?.startsWith('farm:')) {
const f = s.city.fuelFarms.find((q) => h.key!.endsWith(q.id));
if (f) {
f.destroyed = true;
f.known = true;
s.city.stock.fuel = Math.max(0, s.city.stock.fuel - f.fuel);
w.events.push({ t: 'secondary', x, y, big: true });
}
} else {
const r = Math.hypot(x + 2, y);
if (r < 18) civ = kg * CONFIG.city.civiliansPerKgElsewhere * 0.6 * (1 - r / 18);
igniteAt(w.fire, x, y, spec.fire, spec.radius);
}
civ *= 1 - s.city.evacuated / (s.city.evacuated + s.city.population + 1);
const imp: Impact = { x, y, kg, munition: m, flightId, hit: h.key, civ, player, aimed: aimKey };
w.impacts.push(imp);
w.events.push({ t: 'impact', x, y, kg, munition: m, player, hit: h.key });
if (owner && h.key && aimKey && (h.key === `d:${aimKey}` || h.key === aimKey)) owner.bombsOnTarget++;
// Lights die district by district.
updateGrid(s);
for (const d of DISTRICTS) {
const now = s.districts[d.id].powered;
if (w.powerWas[d.id] && !now) w.events.push({ t: 'blackout', district: d.id });
w.powerWas[d.id] = now;
}
}
// ── Weapons ────────────────────────────────────────────────────────────────
function launchMissile(w: World, m: Partial<Missile> & { kind: Missile['kind']; side: 'red' | 'blue'; x: number; y: number; alt: number; hdg: number }): Missile {
const ms: Missile = {
id: w.nextId++, spd: 2, turn: 2, targetId: -1, tx: 0, ty: 0, life: 10, guided: true, site: null, lethal: 0.4, dmg: 50, pk: 1,
minD: 999, alive: true, munition: null, owner: null, flightId: '', aimKey: null, seduced: false, age: 0, evaded: false, missX: 0, missY: 0, ...m,
};
w.missiles.push(ms);
return ms;
}
function releaseBombs(w: World, p: Plane, count: number, forced = false): void {
if (!p.munition || p.sticks <= 0) return;
const M = CONFIG.mission;
const spec = CONFIG.munitions[p.munition];
const fr = p.flight!;
if (spec.missile) {
// Standoff / anti-radar missiles.
if (p.munition === 'KSR-11') fireARM(w, p);
else fireStandoff(w, p);
return;
}
const n = Math.min(count, p.sticks);
const K = M.crew;
for (let i = 0; i < n; i++) {
if (p.isPlayer && !forced && Math.random() < (p.hp < p.maxHp * 0.7 ? K.hungChanceDamaged : K.hungChance)) {
// A shackle jams: the rest stay aboard until the doors are cycled.
w.crew.hung += n - i;
w.events.push({ t: 'sys', what: 'hung', x: p.x, y: p.y, v: n - i });
radio(w, 'NAVIGATOR', 'Hung bombs! The release is jammed — cycle the doors!', true);
break;
}
p.sticks--;
if (p.isPlayer) w.events.push({ t: 'sys', what: 'clunk', x: p.x, y: p.y });
const agl = Math.max(0.05, p.alt - terrainHeight(p.x, p.y));
const ft = M.gravityFall * Math.sqrt(agl) * (spec.guided ? 1.7 : 1);
const sigmaBase = M.scatterBase + M.scatterPerKmAlt * p.alt + (w.light < 0.3 ? M.scatterNight : 0) + (w.t < p.jinkUntil ? M.scatterJink : 0);
const spread = p.isWingmanOfPlayer ? (w.crew.formation === 'tight' ? 0.8 : w.crew.formation === 'loose' ? 1.3 : 1) : 1;
const sigma = sigmaBase * (1 - p.xp * M.scatterXpReduction) * (p.kind === 'Tu-22' && p.alt < 1 ? 1.4 : 1) * spread;
const ex = (gauss() * sigma) / ft;
const ey = (gauss() * sigma) / ft;
const throwV = p.spd * M.throwFactor;
const b: Bomb = {
x: p.x, y: p.y, vx: Math.cos(p.hdg) * throwV + w.windX * M.windDriftFactor + ex, vy: Math.sin(p.hdg) * throwV + w.windY * M.windDriftFactor + ey,
alt0: p.alt, t: 0, ft, kg: p.stickKg, munition: p.munition, flightId: fr.plan.id, player: p.isPlayer || p.isWingmanOfPlayer,
guided: spec.guided, steerX: 0, steerY: 0, aimKey: fr.aimKey, owner: p, alive: true,
};
if (spec.guided) {
const pred = ccip(w, p, true);
b.steerX = p.isPlayer ? pred.x : fr.aimX + gauss() * 0.08;
b.steerY = p.isPlayer ? pred.y : fr.aimY + gauss() * 0.08;
if (p.isPlayer) w.guidedBomb = b;
}
w.bombs.push(b);
}
if (!fr.released) {
fr.released = true;
radio(w, fr.planes[0].callsign, spec.guided ? 'Pickle — bomb away, guiding.' : 'Pickle, pickle — bombs away!', false);
}
w.events.push({ t: 'release', plane: p });
}
function fireARM(w: World, p: Plane): boolean {
if (p.sticks <= 0) return false;
let best: SiteRT | null = null;
let bestScore = -1;
for (const s of w.sites) {
if (!s.emitting || s.state === 'dead') continue;
const d = dist(p.x, p.y, s.st.x, s.st.y);
if (d > CONFIG.mission.armRange) continue;
const off = Math.abs(wrap(Math.atan2(s.st.y - p.y, s.st.x - p.x) - p.hdg));
if (off > CONFIG.mission.armCone) continue;
const score = (s.state === 'guide' ? 3 : s.state === 'track' ? 2 : 1) - d / 40;
if (score > bestScore) { best = s; bestScore = score; }
}
if (!best) {
if (p.isPlayer) radio(w, p.callsign, 'No emitter in the seeker cone.', false, 'noarm', 3);
return false;
}
p.sticks--;
const ms = launchMissile(w, { kind: 'ksr11', side: 'red', x: p.x, y: p.y, alt: p.alt, hdg: p.hdg, spd: 2.4, turn: 0.9, life: 30, tx: best.st.x, ty: best.st.y, site: best, munition: 'KSR-11', owner: p, flightId: p.flight!.plan.id, aimKey: `sam:${best.st.id}`, lethal: 0.5 });
ms.guided = true;
radio(w, p.callsign, `Magnum! Kelt away on emitter ${best.st.id}!`, false);
if (Math.random() < CONFIG.sam.emconChance + 0.1 * (w.s.city.adaptLevels.emcon ?? 0) && !best.st.isDecoy) {
best.silentUntil = w.t + 12 + Math.random() * 12;
best.state = 'silent';
best.emitting = false;
}
return true;
}
function fireStandoff(w: World, p: Plane): boolean {
if (p.sticks <= 0) return false;
const fr = p.flight!;
const d = dist(p.x, p.y, fr.aimX, fr.aimY);
if (d > CONFIG.mission.standoffRange || d < CONFIG.mission.standoffMin) return false;
p.sticks--;
const hdg = Math.atan2(fr.aimY - p.y, fr.aimX - p.x);
launchMissile(w, { kind: 'standoff', side: 'red', x: p.x, y: p.y, alt: p.alt, hdg, spd: 3.2, turn: 0.5, life: 40, tx: fr.aimX + gauss() * 0.15, ty: fr.aimY + gauss() * 0.15, munition: p.munition, owner: p, flightId: fr.plan.id, aimKey: fr.aimKey, lethal: 0.3 });
radio(w, p.callsign, `${p.munition} away. Turning out.`, false);
if (!fr.released) fr.released = true;
return true;
}
function fireAAM(w: World, p: Plane, kind: 'aim9' | 'aim7' | 'r3s', target: Plane): void {
const spec = CONFIG.interceptors[kind];
launchMissile(w, { kind, side: p.side, x: p.x, y: p.y, alt: p.alt, hdg: p.hdg, spd: spec.speed + p.spd * 0.5, turn: spec.turn, life: spec.life, targetId: target.id, pk: spec.pk, dmg: spec.dmg, lethal: 0.25, owner: p });
}
function canShoot(p: Plane, t: Plane, range: number, minRange: number, cone: number, rear: boolean): boolean {
const d = dist(p.x, p.y, t.x, t.y);
if (d > range || d < minRange || Math.abs(p.alt - t.alt) > 3) return false;
const off = Math.abs(wrap(Math.atan2(t.y - p.y, t.x - p.x) - p.hdg));
if (off > cone) return false;
if (rear) {
const aspect = Math.abs(wrap(Math.atan2(p.y - t.y, p.x - t.x) - t.hdg));
if (aspect < Math.PI * 0.55) return false;
}
return true;
}
function gunsOn(w: World, p: Plane, dt: number): void {
const I = CONFIG.interceptors;
for (const t of w.planes) {
if (!t.alive || t.done || t.side === p.side || t.state === 'wait') continue;
if (canShoot(p, t, I.gunRange, 0, I.gunCone, false) && Math.random() < 0.5 + p.xp * 0.3) damagePlane(w, t, I.gunDmgPerSec * dt * (0.5 + Math.random()), 'cannon');
}
}
// ── Plane AI & control ─────────────────────────────────────────────────────
function steerTo(p: Plane, x: number, y: number, dt: number, rateMul = 1): void {
const want = Math.atan2(y - p.y, x - p.x);
const d = wrap(want - p.hdg);
const rate = p.turnRate * rateMul * (1 - p.hydraulics * 0.5);
p.hdg = wrap(p.hdg + clamp(d, -rate * dt, rate * dt) + p.controlBias * 0.25 * dt);
}
function nearestEnemy(w: World, p: Plane, maxD: number, pred?: (t: Plane) => boolean): Plane | null {
let best: Plane | null = null;
let bd = maxD;
for (const t of w.planes) {
if (!t.alive || t.done || t.side === p.side || t.state === 'wait' || (pred && !pred(t))) continue;
const d = dist(p.x, p.y, t.x, t.y);
if (d < bd) { bd = d; best = t; }
}
return best;
}
function redAI(w: World, p: Plane, dt: number): void {
const fr = p.flight;
if (w.t < p.evadeUntil) {
const d = wrap(p.evadeDir - p.hdg);
p.hdg = wrap(p.hdg + clamp(d, -p.turnRate * 1.4 * dt, p.turnRate * 1.4 * dt));
p.tAlt = Math.max(CONFIG.mission.altLow, p.alt - 1.5);
p.jinkUntil = w.t + 1;
return;
}
// Wingmen of the player hold formation and drop on lead.
if (p.isWingmanOfPlayer && w.player?.alive && !w.player.done) {
const lead = w.player;
const back = Math.floor((p.slot + 1) / 2) * 0.8 * CONFIG.mission.crew.formation[w.crew.formation];
const side = p.slot % 2 ? -1 : 1;
const fx = lead.x - Math.cos(lead.hdg) * back + Math.cos(lead.hdg + Math.PI / 2) * side * back * 0.7;
const fy = lead.y - Math.sin(lead.hdg) * back + Math.sin(lead.hdg + Math.PI / 2) * side * back * 0.7;
steerTo(p, fx + Math.cos(lead.hdg) * 2, fy + Math.sin(lead.hdg) * 2, dt, 1.3);
const dd = dist(p.x, p.y, fx, fy);
p.spd = lead.spd * (dd > 0.3 ? 1.08 : 1);
p.tAlt = lead.alt;
if (p.role === 'escort') escortFight(w, p, dt);
return;
}
if (p.role === 'drone') {
followPath(w, p, dt);
return;
}
if (p.role === 'escort' && escortFight(w, p, dt)) return;
if (p.role === 'weasel') weaselAI(w, p);
if (p.role === 'suppress') suppressAI(w, p);
if (p.role === 'jammer' && w.plan.chaffCorridor && p.chaff > 0 && p.x < BARRIER_X + 20 && Math.random() < dt * 1.2) {
p.chaff--;
w.chaff.push({ x: p.x, y: p.y, alt: p.alt, r: CONFIG.sam.chaffRadius * 1.6, life: CONFIG.ew.chaffCorridorDuration, max: CONFIG.ew.chaffCorridorDuration });
w.events.push({ t: 'chaff', x: p.x, y: p.y, alt: p.alt, player: false });
}
// Fighters out of fuel head home.
if (p.fuelT > p.bingo && p.state !== 'egress') {
p.state = 'egress';
p.wp = p.path.length - 2;
if (fr && p.slot === 0) radio(w, p.callsign, 'Bingo fuel — escort turning for home!', true);
}
// Primary already wrecked by an earlier wave: divert to the alternate.
if (fr && p.slot === 0 && p.role === 'strike' && !fr.retargeted && !fr.released && w.plan.alternates[0] && fr.aimKey === w.plan.primary) {
const st = w.s.districts[w.plan.primary];
if (st && st.integrity < 0.15 && dist(p.x, p.y, fr.aimX, fr.aimY) < 30) {
fr.retargeted = true;
const alt = chooseAim(w.s, w.plan.alternates[0]);
fr.aimX = alt.x;
fr.aimY = alt.y;
fr.aimKey = alt.key;
// Path layout is [...ingress, aim, ...egress].
const k = ROUTES[w.plan.route].ingress.length;
for (const q of fr.planes) q.path[k] = [alt.x, alt.y];
radio(w, p.callsign, `Primary is burning out. Diverting to the alternate: ${DISTRICT_BY_ID[w.plan.alternates[0]]?.name ?? w.plan.alternates[0]}.`, false);
}
}
// Bombing run.
if (fr && (p.role === 'strike' || (p.role === 'suppress' && p.munition !== 'S-24')) && p.sticks > 0 && p.munition) {
const spec = CONFIG.munitions[p.munition];
if (spec.missile) {
if (dist(p.x, p.y, fr.aimX, fr.aimY) < CONFIG.mission.standoffRange * 0.8) fireStandoff(w, p);
} else {
const imp = ccip(w, p);
const toAim = dist(imp.x, imp.y, fr.aimX, fr.aimY);
const along = Math.cos(p.hdg) * (fr.aimX - imp.x) + Math.sin(p.hdg) * (fr.aimY - imp.y);
if ((toAim < 0.6 || (along < 0 && toAim < 2.5)) && !p.released) {
p.released = true;
p.releaseT = w.t;
}
}
}
if (p.released && p.sticks > 0 && p.munition && !CONFIG.munitions[p.munition].missile) {
const perSec = Math.max(1, Math.ceil(p.sticks / CONFIG.mission.ripple));
if (Math.random() < dt * 6) releaseBombs(w, p, Math.min(perSec, 2));
}
followPath(w, p, dt);
}
function followPath(w: World, p: Plane, dt: number): void {
const fr = p.flight;
if (p.wp >= p.path.length) {
p.done = p.x > CONFIG.mission.egressX - 6 || p.role === 'drone';
if (!p.done) steerTo(p, CONFIG.mission.egressX + 10, p.y, dt);
return;
}
const [tx, ty] = p.path[p.wp];
steerTo(p, tx, ty, dt);
const ingressLen = ROUTES[w.plan.route].ingress.length;
const dWp = dist(p.x, p.y, tx, ty);
const behind = Math.cos(p.hdg) * (tx - p.x) + Math.sin(p.hdg) * (ty - p.y) < 0;
if (dWp < (p.role === 'drone' ? 2 : 2.4) || (behind && dWp < 7)) {
p.wp++;
if (fr && p.wp === ingressLen && p.slot === 0 && p.role === 'strike') radio(w, p.callsign, 'IP inbound. Doors open. Hold her steady.', false);
if (fr && p.wp === ingressLen + 1) p.state = 'egress';
}
const ingressDone = p.wp >= ingressLen;
if (p.role !== 'drone') {
if (p.state === 'egress') p.tAlt = altKm(w.plan.ingressAlt === 'low' ? 'low' : 'med');
else if (ingressDone || dist(p.x, p.y, fr?.aimX ?? 0, fr?.aimY ?? 0) < 25) p.tAlt = altKm(w.plan.attackAlt);
else p.tAlt = altKm(w.plan.ingressAlt);
if (p.role === 'weasel' || p.role === 'suppress') p.tAlt = Math.min(p.tAlt, altKm('med'));
if (p.role === 'suppress' && fr?.aimKey === 'pickets') p.tAlt = altKm('low');
if (p.role === 'recon' && p.kind === 'MiG-25R') p.tAlt = 12;
}
// Dash on the run-in for Blinders.
if (p.kind === 'Tu-22' && ingressDone && p.state !== 'egress') p.spd = Math.min(CONFIG.aircraft['Tu-22'].dash, p.spd + dt * 0.1);
else if (p.kind !== 'drone') p.spd += (p.baseSpd - p.spd) * dt * 0.5;
// Jink under fire.
if (w.t - p.lastHit < 2 || p.tracked < 1) {
p.jinkUntil = w.t + 2;
p.hdg += Math.sin(w.t * 3 + p.id) * dt * 0.4;
}
// Recon photographs.
if (p.role === 'recon') photograph(w, p);
}
function photograph(w: World, p: Plane): void {
for (const d of DISTRICTS) {
if (w.photoTaken.has(d.id)) continue;
if (dist(p.x, p.y, d.x, d.y) < 4) {
w.photoTaken.add(d.id);
const st = w.s.districts[d.id];
st.intelHour = w.s.hour;
st.knownIntegrity = st.integrity;
for (const dec of st.decoys) {
dec.seen = true;
if (Math.random() < CONFIG.adaptation.reconRevealMiG25) dec.revealed = true;
}
}
}
for (const site of w.s.sams) {
if (dist(p.x, p.y, site.x, site.y) < 6) {
site.knownX = site.x;
site.knownY = site.y;
site.knownHour = w.s.hour;
if (site.isDecoy && Math.random() < 0.7) site.revealed = true;
}
}
}
function escortFight(w: World, p: Plane, dt: number): boolean {
const I = CONFIG.interceptors;
const anchor = w.flights.find((f) => f.plan.role === 'strike')?.planes.find((x) => x.alive && !x.done) ?? null;
const t = nearestEnemy(w, p, 14);
if (!t || (anchor && dist(anchor.x, anchor.y, t.x, t.y) > 18)) {
if (anchor && !p.isWingmanOfPlayer) {
p.wp = Math.min(anchor.wp, p.path.length - 1);
steerTo(p, anchor.x + Math.cos(anchor.hdg) * 2.5 + Math.cos(anchor.hdg + 1.2) * (p.slot % 2 ? 2 : -2), anchor.y + Math.sin(anchor.hdg) * 2.5 + Math.sin(anchor.hdg + 1.2) * (p.slot % 2 ? 2 : -2), dt);
p.spd = Math.max(anchor.spd * 1.05, p.baseSpd * 0.8);
p.tAlt = anchor.alt + 0.8;
return true;
}
if (!anchor && p.state !== 'egress') {
p.state = 'egress';
p.wp = Math.max(p.wp, p.path.length - 3);
}
return false;
}
if (!w.radioCooldown.tallyho || w.radioCooldown.tallyho < w.t) radio(w, p.callsign, `Tally-ho — ${String(t.kind)}s! Engaging!`, false, 'tallyho', 20);
steerTo(p, t.x + Math.cos(t.hdg) * 0.4, t.y + Math.sin(t.hdg) * 0.4, dt);
p.tAlt = t.alt;
p.spd = p.baseSpd * 1.3;
p.cool -= dt;
if (p.missiles > 0 && p.cool <= 0 && canShoot(p, t, I.r3s.range, I.r3s.minRange, I.r3s.cone, true)) {
p.missiles--;
p.cool = 4;
fireAAM(w, p, 'r3s', t);
radio(w, p.callsign, 'Fox two!', false, `aam${p.id}`, 3);
}
gunsOn(w, p, dt);
return true;
}
function weaselAI(w: World, p: Plane): void {
if (p.sticks <= 0) return;
p.cool -= 0.016;
if (p.cool > 0) return;
if (w.sites.some((s) => s.emitting && dist(p.x, p.y, s.st.x, s.st.y) < CONFIG.mission.armRange * 0.85)) {
if (fireARM(w, p)) p.cool = 6;
}
}
function suppressAI(w: World, p: Plane): void {
if (p.munition !== 'S-24' || p.sticks <= 0) return;
const fr = p.flight!;
if (fr.aimKey === 'pickets') {
// H-hour: kill the early-warning radar before the main force arrives.
const pk = w.pickets.find((q) => q.alive && dist(p.x + Math.cos(p.hdg) * 1.5, p.y + Math.sin(p.hdg) * 1.5, q.x, q.y) < 2.5);
if (pk && Math.random() < 0.12) rocketSalvo(w, p, pk.x, pk.y);
return;
}
if (dist(p.x, p.y, fr.aimX, fr.aimY) > 9) return;
let best: Gun | null = null;
let bd = 3;
for (const g of w.guns) {
if (!g.alive) continue;
const d = dist(p.x + Math.cos(p.hdg) * 1.4, p.y + Math.sin(p.hdg) * 1.4, g.x, g.y);
if (d < bd) { bd = d; best = g; }
}
if (best && Math.random() < 0.08) rocketSalvo(w, p, best.x, best.y);
}
function rocketSalvo(w: World, p: Plane, tx: number, ty: number): void {
if (p.sticks <= 0 || !p.munition) return;
p.sticks--;
const spec = CONFIG.munitions[p.munition];
const e = 0.25 * (1 - p.xp * 0.5);
w.bombs.push({ x: p.x, y: p.y, vx: (tx - p.x) / 0.9 + gauss() * e, vy: (ty - p.y) / 0.9 + gauss() * e, alt0: p.alt, t: 0, ft: 0.9, kg: p.stickKg, munition: p.munition, flightId: p.flight!.plan.id, player: p.isPlayer, guided: false, steerX: 0, steerY: 0, aimKey: p.flight!.aimKey, owner: p, alive: true });
for (const g of w.guns) if (g.alive && dist(g.x, g.y, tx, ty) < 1.5) g.suppressed = 6;
w.events.push({ t: 'gun', x: p.x, y: p.y, hdg: p.hdg, player: p.isPlayer });
void spec;
}
function playerControl(w: World, p: Plane, dt: number): void {
const c = w.ctl;
const cr = w.crew;
const K = CONFIG.mission.crew;
const spec = CONFIG.aircraft[p.kind as AircraftType];
const lowSpeed = spec.lowSpeedPenalty && p.alt < 1.5 ? 1 - spec.lowSpeedPenalty : 1;
const rate = p.turnRate * (1 - p.hydraulics * 0.5) * lowSpeed;
p.hdg = wrap(p.hdg + (c.turn * rate + p.controlBias * 0.35) * dt);
const doorsDrag = 1 - K.doorsDrag * cr.doors;
if (cr.flamedOut) {
// Dead engines: glide down.
p.spd = Math.max(p.baseSpd * 0.45, p.spd - dt * 0.02);
} else if (spec.fighter) {
// Energy: hard turns bleed speed, dives build it.
p.spd -= Math.abs(c.turn) * 0.22 * dt;
p.spd += c.throttle * 0.25 * dt + (p.baseSpd - p.spd) * 0.15 * dt;
p.spd = clamp(p.spd, p.baseSpd * 0.5, spec.dash);
} else {
p.spd += ((p.baseSpd + c.throttle * (spec.dash - p.baseSpd)) * doorsDrag - p.spd) * dt * 0.6;
}
const ground = terrainHeight(p.x, p.y);
p.tAlt = cr.flamedOut ? p.alt - 0.25 : clamp(p.alt + c.climb * 3, ground + 0.03, CONFIG.mission.altMax);
p.cool -= dt;
p.gunCool -= dt;
// ── Fuel ──
if (!cr.flamedOut) {
cr.fuel -= dt * (cr.fuelRate * (1 + Math.max(0, c.throttle) * 0.8 + cr.doors * 0.1) + cr.fuelLeak);
if (cr.fuel <= 0) {
cr.fuel = 0;
cr.flamedOut = true;
w.events.push({ t: 'sys', what: 'flameout', x: p.x, y: p.y });
radio(w, p.callsign, 'Both engines flamed out — tanks are dry! We are gliding!', true);
} else if (cr.fuel < K.bingo) radio(w, 'NAVIGATOR', 'Bingo fuel, commander. Turn for home now.', true, 'bingo', 40);
}
// ── Crew actions ──
if (c.doors) {
c.doors = false;
cr.doorsWant = !cr.doorsWant;
w.events.push({ t: 'sys', what: 'doors', x: p.x, y: p.y, v: cr.doorsWant ? 1 : 0 });
if (cr.doorsWant && cr.hung > 0) cr.doorCycle++;
}
const doorsBefore = cr.doors;
cr.doors = clamp(cr.doors + (cr.doorsWant ? dt : -dt) / K.doorsOpenSec);
if (doorsBefore < 1 && cr.doors >= 1 && cr.hung > 0 && cr.doorCycle > 0) {
// Shaking the doors frees the hung stores — wherever you are.
const n = cr.hung;
cr.hung = 0;
cr.doorCycle = 0;
w.events.push({ t: 'sys', what: 'unhung', x: p.x, y: p.y, v: n });
radio(w, 'NAVIGATOR', `Hung bombs released — ${n} stick${n > 1 ? 's' : ''} gone!`, true);
releaseBombs(w, p, n, true);
}
if (c.mode) {
c.mode = false;
cr.mode = cr.mode === 'train' ? 'salvo' : cr.mode === 'salvo' ? 'single' : 'train';
}
if (c.formation) {
cr.formation = c.formation;
c.formation = null;
w.events.push({ t: 'sys', what: 'formation', x: p.x, y: p.y });
radio(w, p.callsign, cr.formation === 'tight' ? 'All Granit, close it up — tight box, guns covering each other.' : cr.formation === 'loose' ? 'Spread out! Open the formation — don\'t give the flak a group.' : 'Back to normal spacing.', false);
}
if (c.marker) {
c.marker = false;
if (cr.markers > 0) {
cr.markers--;
const imp = ccip(w, p, true);
w.markers.push({ x: imp.x, y: imp.y, life: K.markerLife });
w.events.push({ t: 'sys', what: 'marker', x: imp.x, y: imp.y });
radio(w, p.callsign, 'Marker down! All flights — bomb on my marker!', true);
}
}
if (c.extinguish) {
c.extinguish = false;
if (p.engineFire > 0 && cr.extinguishers > 0) {
cr.extinguishers--;
p.engineFire = 0;
cr.fireT = 0;
w.events.push({ t: 'sys', what: 'extinguish', x: p.x, y: p.y });
radio(w, 'FLIGHT ENGINEER', 'Bottle fired — fire is out.', false);
} else if (p.engineFire > 0) radio(w, 'FLIGHT ENGINEER', 'No bottles left! Dive — try to blow it out!', true, 'nobottle', 4);
}
if (c.hydraulics) {
c.hydraulics = false;
if (p.hydraulics > 0.15 && cr.hydBackup) {
cr.hydBackup = false;
p.hydraulics *= 0.3;
w.events.push({ t: 'sys', what: 'hydBackup', x: p.x, y: p.y });
radio(w, 'CO-PILOT', 'Backup hydraulic pump on. Controls are lighter.', false);
}
}
if (c.isolate) {
c.isolate = false;
if (cr.fuelLeak > 0 && !cr.leakIsolated) {
cr.leakIsolated = true;
cr.fuelLeak = 0;
w.events.push({ t: 'sys', what: 'isolate', x: p.x, y: p.y });
radio(w, 'FLIGHT ENGINEER', 'Leaking tank isolated. We keep what we have.', false);
}
}
// Engine fire burns until the crew acts.
if (p.engineFire > 0) {
cr.fireT += dt;
if (p.spd > p.baseSpd * 1.1 && Math.random() < dt * 0.1) p.engineFire = 0; // blown out in a dive
if (cr.fireT > K.engineFireExplodeSec) {
cr.fireT = 0;
p.engineFire = 0;
w.events.push({ t: 'sys', what: 'engineBoom', x: p.x, y: p.y });
radio(w, 'FLIGHT ENGINEER', 'Engine has blown apart!', true);
damagePlane(w, p, K.engineExplodeDmg, 'fire');
}
}
// ── Weapons ──
const mspec = p.munition ? CONFIG.munitions[p.munition] : null;
const pressed = c.fire && !c.firePressed;
c.firePressed = c.fire;
if (c.fire) {
if (p.role === 'escort' || (p.role === 'suppress' && c.secondary)) {
if (p.gunCool <= 0) {
p.gunCool = 0.08;
gunsOn(w, p, 0.08);
w.events.push({ t: 'gun', x: p.x, y: p.y, hdg: p.hdg, player: true });
}
} else if (p.role === 'jammer') {
if (p.cool <= 0) { p.jamming = !p.jamming; p.cool = 0.5; radio(w, p.callsign, p.jamming ? 'Music on — jammers radiating.' : 'Jammers off — we are naked.', false); }
} else if (mspec?.missile) {
if (p.cool <= 0) {
p.cool = 1.2;
const ok = p.munition === 'KSR-11' ? fireARM(w, p) : fireStandoff(w, p);
if (!ok && p.munition !== 'KSR-11') radio(w, p.callsign, `Out of launch envelope (${CONFIG.mission.standoffMin}–${CONFIG.mission.standoffRange} km).`, false, 'env', 3);
}
} else if (p.munition === 'S-24') {
if (p.cool <= 0) {
p.cool = 0.5;
rocketSalvo(w, p, p.x + Math.cos(p.hdg) * 1.6, p.y + Math.sin(p.hdg) * 1.6);
}
} else if (p.sticks > 0 && p.cool <= 0) {
if (cr.doors < 1) {
if (pressed) radio(w, 'NAVIGATOR', cr.doorsWant ? 'Doors still opening!' : 'Bomb doors are CLOSED — open them (B)!', true, 'doorsclosed', 2);
} else if (cr.hung > 0) {
if (pressed) radio(w, 'NAVIGATOR', 'Release is jammed — hung bombs! Cycle the doors!', true, 'hungmsg', 3);
} else if (cr.mode === 'salvo') {
if (pressed) {
const n = p.sticks;
releaseBombs(w, p, n);
for (const wm of p.flight?.planes ?? []) if (wm !== p && wm.alive && !wm.done && wm.sticks > 0) releaseBombs(w, wm, wm.sticks);
p.cool = 1;
void n;
}
} else if (cr.mode === 'single') {
if (pressed) {
releaseBombs(w, p, 1);
for (const wm of p.flight?.planes ?? []) if (wm !== p && wm.alive && !wm.done && wm.sticks > 0) releaseBombs(w, wm, 1);
p.cool = 0.3;
}
} else {
p.cool = CONFIG.mission.ripple / CONFIG.mission.sticks;
releaseBombs(w, p, 1);
// Wingmen toggle on lead.
for (const wm of p.flight?.planes ?? []) if (wm !== p && wm.alive && !wm.done && wm.sticks > 0) releaseBombs(w, wm, 1);
}
}
}
if (c.secondary && p.cool <= 0) {
p.cool = 0.6;
if (p.role === 'escort' && p.missiles > 0) {
const t = nearestEnemy(w, p, CONFIG.interceptors.r3s.range, (q) => canShoot(p, q, CONFIG.interceptors.r3s.range, CONFIG.interceptors.r3s.minRange, CONFIG.interceptors.r3s.cone, true));
if (t) {
p.missiles--;
fireAAM(w, p, 'r3s', t);
radio(w, p.callsign, 'Fox two — Atoll away!', false);
} else radio(w, p.callsign, 'No tone.', false, 'notone', 2);
}
}
if (c.chaff && p.chaff > 0 && p.cool <= 0.3) {
p.chaff--;
w.chaff.push({ x: p.x, y: p.y, alt: p.alt, r: CONFIG.sam.chaffRadius, life: CONFIG.sam.chaffCloudLife, max: CONFIG.sam.chaffCloudLife });
w.events.push({ t: 'chaff', x: p.x, y: p.y, alt: p.alt, player: true });
c.chaff = false;
}
// ── Tail gun: the gunner fires on his own; you can take the turret (hold G). ──
if (spec.tailGun) {
cr.tailTarget = -1;
let best: Plane | null = null;
let bd = K.tailGunRange;
for (const q of w.planes) {
if (q.side !== 'blue' || !q.alive || q.done) continue;
const d = dist(p.x, p.y, q.x, q.y);
if (d > bd || Math.abs(q.alt - p.alt) > 1.5) continue;
const aspect = Math.abs(wrap(Math.atan2(q.y - p.y, q.x - p.x) - p.hdg));
if (aspect < Math.PI * 0.6) continue;
best = q;
bd = d;
}
if (best) {
cr.tailTarget = best.id;
const mult = c.tailGun ? K.tailGunManualMult : K.tailGunAutoMult;
damagePlane(w, best, CONFIG.interceptors.tailGunDmgPerSec * mult * dt * (0.5 + p.xp), 'tailgun');
if (Math.random() < dt * (c.tailGun ? 22 : 6)) {
w.events.push({ t: 'tracer', x: p.x - Math.cos(p.hdg) * 0.2, y: p.y - Math.sin(p.hdg) * 0.2, tx: best.x + gauss() * 0.08, ty: best.y + gauss() * 0.08, alt: p.alt, onPlayer: false });
if (c.tailGun) w.events.push({ t: 'sys', what: 'tailgun', x: p.x, y: p.y });
}
}
}
if (w.guidedBomb?.alive) {
w.guidedBomb.steerX += c.steerX * CONFIG.mission.guidedSteerRate * dt;
w.guidedBomb.steerY += c.steerY * CONFIG.mission.guidedSteerRate * dt;
}
if (p.role === 'recon') photograph(w, p);
if (p.crossedBarrier && p.x > CONFIG.mission.egressX) p.done = true;
}
function blueAI(w: World, p: Plane, dt: number): void {
const I = CONFIG.interceptors;
p.fuelT += dt;
if (p.fuelT > 200) p.state = 'rtb';
if (p.state === 'rtb') {
steerTo(p, -33, 6, dt);
if (dist(p.x, p.y, -33, 6) < 3) p.done = true;
return;
}
const detectR = p.state === 'cap' ? 22 : 45;
const prefer = (t: Plane) => t.kind !== 'MiG-25R' || t.alt < 8;
let t = p.targetId >= 0 ? planeById(w, p.targetId) : undefined;
if (!t || !t.alive || t.done || dist(p.x, p.y, t.x, t.y) > detectR * 1.3) {
t = nearestEnemy(w, p, detectR, (q) => prefer(q) && (q.alt > 0.5 || (w.s.city.adaptLevels.lowAltRadar ?? 0) > 1 || dist(p.x, p.y, q.x, q.y) < 6)) ?? undefined;
p.targetId = t?.id ?? -1;
}
if (!t) {
if (p.state === 'cap') {
const [cx, cy] = p.path[0];
steerTo(p, cx + Math.cos(w.t * 0.15 + p.id) * 8, cy + Math.sin(w.t * 0.15 + p.id) * 8, dt);
} else {
// Head toward the detected raid.
const r = w.planes.find((q) => q.side === 'red' && q.alive && !q.done && q.state !== 'wait');
if (r) steerTo(p, r.x, r.y, dt);
}
return;
}
if (p.state === 'cap') p.state = 'engage';
if (w.bandits === 0 && w.player?.alive) {
const c = clock(w.player, p.x, p.y);
radio(w, w.player.flight?.planes[1]?.callsign ?? 'LEAD', `Bandits, bandits — ${c} o'clock ${p.alt > w.player.alt ? 'high' : 'low'}, ${String(p.kind)}s!`, true, 'bandits', 25);
}
w.bandits++;
// Lead pursuit with a little lag; fly to the six on fighters.
const lead = 0.6;
steerTo(p, t.x + Math.cos(t.hdg) * t.spd * lead, t.y + Math.sin(t.hdg) * t.spd * lead, dt);
p.tAlt = t.alt + 0.2;
const dd = dist(p.x, p.y, t.x, t.y);
p.spd = p.baseSpd * (dd < 2 ? 0.85 : 1.15);
p.cool -= dt;
const nightF = w.light < 0.3 ? I.nightEffectiveness : 1;
if (p.cool <= 0) {
if (p.aim7 > 0 && canShoot(p, t, I.aim7.range, I.aim7.minRange, I.aim7.cone, false) && Math.random() < 0.5 * nightF) {
p.aim7--;
p.cool = 5;
fireAAM(w, p, 'aim7', t);
} else if (p.aim9 > 0 && canShoot(p, t, I.aim9.range, I.aim9.minRange, I.aim9.cone, true) && Math.random() < 0.7 * nightF) {
p.aim9--;
p.cool = 4;
fireAAM(w, p, 'aim9', t);
}
}
const spec = CONFIG.interceptors[p.kind as InterceptorType];
if (spec.gun && canShoot(p, t, I.gunRange, 0, I.gunCone, false)) {
gunsOn(w, p, dt);
if (Math.random() < dt * 8) w.events.push({ t: 'gun', x: p.x, y: p.y, hdg: p.hdg, player: false });
}
// Tail guns answer.
if (t.side === 'red' && CONFIG.aircraft[t.kind as AircraftType]?.tailGun && dd < CONFIG.aircraft[t.kind as AircraftType].tailGunRange) {
const aspect = Math.abs(wrap(Math.atan2(p.y - t.y, p.x - t.x) - t.hdg));
if (aspect > Math.PI * 0.6) {
const box = t.isWingmanOfPlayer && w.crew.formation === 'tight' ? 1.6 : t.isWingmanOfPlayer && w.crew.formation === 'loose' ? 0.7 : 1;
if (!t.isPlayer) damagePlane(w, p, I.tailGunDmgPerSec * dt * (0.4 + t.xp) * box, 'tailgun');
if (Math.random() < dt * 10) w.events.push({ t: 'tracer', x: t.x, y: t.y, tx: p.x + gauss() * 0.15, ty: p.y + gauss() * 0.15, alt: t.alt, onPlayer: false });
}
}
}
// ── SAM state machine: detect → track → launch → guide ─────────────────────
function samStep(w: World, site: SiteRT, dt: number): void {
const S = CONFIG.sam;
const spec = S[site.st.kind];
const st = site.st;
if (site.state === 'dead' || st.launchers <= 0.05) {
site.state = 'dead';
site.emitting = false;
return;
}
if (site.state === 'silent') {
site.emitting = false;
if (w.t > site.silentUntil) site.state = 'search';
return;
}
site.timer -= dt;
const power = samPower(w.s) * st.radar;
if (!w.detected) {
site.emitting = false;
return;
}
site.emitting = true;
if (site.state === 'idle') site.state = 'search';
if (site.state === 'search' && site.timer <= 0) {
site.timer = 1;
// Look for a target.
let best: Plane | null = null;
let bestScore = -1;
for (const p of w.planes) {
if (!p.alive || p.done || p.side !== 'red' || p.state === 'wait') continue;
const d = dist(st.x, st.y, p.x, p.y);
let range = spec.searchRange * power;
const lowMask = S.lowAltDetectFactor + 0.12 * (w.s.city.adaptLevels.lowAltRadar ?? 0);
if (p.alt - terrainHeight(p.x, p.y) < S.lowAltBelow) range *= Math.min(1, lowMask);
if (p.isPlayer) range *= 1 + (CONFIG.mission.crew.doorsRcs - 1) * w.crew.doors;
const jam = jammedAt(w, p.x, p.y, st.x, st.y);
range *= 1 - jam * S.jamRangeFactor;
if (d > range) continue;
if (d > spec.range || p.alt < spec.minAlt * (p.alt < 0.5 ? 1 : 0.5)) continue;
if (chaffNear(w, p.x, p.y, p.alt) && Math.random() < 0.5) continue;
const rcs = p.kind === 'drone' ? 1.2 : CONFIG.aircraft[p.kind as AircraftType]?.rcs ?? 1;
const score = rcs / (d + 3) + (p.isPlayer ? 0.02 : 0);
if (score > bestScore) { best = p; bestScore = score; }
}
if (best) {
site.targetId = best.id;
site.state = 'track';
site.timer = spec.trackTime * (1 + jammedAt(w, best.x, best.y, st.x, st.y));
}
} else if (site.state === 'track') {
const t = planeById(w, site.targetId);
if (!t || !t.alive || t.done) { site.state = 'search'; return; }
t.tracked = 0;
if (site.timer <= 0) {
if (st.isDecoy || site.ready <= 0 || st.missiles <= 0) {
// Decoy emitters only pretend.
site.state = st.isDecoy ? 'search' : 'reload';
site.timer = st.isDecoy ? 6 : spec.reloadSec;
return;
}
const active = w.missiles.filter((m) => m.alive && (m.kind === 'nike' || m.kind === 'bloodhound')).length;
if (active >= S.maxSimultaneousSam) { site.timer = 1; return; }
site.ready--;
st.missiles--;
w.samLaunches++;
if (t.kind === 'drone' && !w.droneBait) {
w.droneBait = true;
radio(w, 'CONTROL', 'The drones are drawing fire — SAM radars are lighting up! Weasels, Magnum when ready!', true);
}
const hdg = Math.atan2(t.y - st.y, t.x - st.x);
const m = launchMissile(w, { kind: st.kind, side: 'blue', x: st.x, y: st.y, alt: 0.05, hdg, spd: spec.speed, turn: spec.turn, life: spec.guidanceSec, targetId: t.id, site, lethal: spec.lethal, dmg: spec.kill, pk: spec.pk });
const sig = CONFIG.sam.missSigma + CONFIG.sam.missSigmaJammed * jammedAt(w, t.x, t.y, st.x, st.y);
m.missX = gauss() * sig;
m.missY = gauss() * sig;
site.inFlight++;
site.state = 'guide';
w.events.push({ t: 'samLaunch', x: st.x, y: st.y, m });
if (w.player?.alive && !w.player.done && dist(w.player.x, w.player.y, st.x, st.y) < 50) {
const c = clock(w.player, st.x, st.y);
const brk = c >= 1 && c <= 5 ? 'break left' : c >= 7 && c <= 11 ? 'break right' : 'dive, dive';
radio(w, w.player.flight?.planes.find((q) => q !== w.player && q.alive)?.callsign ?? 'CONTROL', t.isPlayer ? `Mud spike! SAM launch ${c} o'clock — it's on YOU, ${brk}!` : `SAM launch, ${c} o'clock!`, true, `sam${site.st.id}`, 4);
}
}
} else if (site.state === 'guide') {
if (site.inFlight <= 0) {
site.state = site.ready > 0 ? 'search' : 'reload';
site.timer = site.ready > 0 ? 1 : spec.reloadSec;
}
} else if (site.state === 'reload' && site.timer <= 0) {
site.ready = Math.min(spec.ready, st.missiles);
site.state = 'search';
}
}
function missileStep(w: World, m: Missile, dt: number): void {
m.age += dt;
m.life -= dt;
const S = CONFIG.sam;
let tx = m.tx;
let ty = m.ty;
let talt = 0;
const target = m.targetId >= 0 ? planeById(w, m.targetId) : undefined;
if (m.kind === 'nike' || m.kind === 'bloodhound') {
// Semi-active: the site radar must keep the lock.
const site = m.site;
if (m.guided && target && target.alive && site) {
const jam = jammedAt(w, target.x, target.y, site.st.x, site.st.y);
if (site.state === 'silent' || site.state === 'dead') m.guided = false;
else if (jam > 0 && Math.random() < S.trackLossPerSecJammed * jam * dt * 10) {
if (Math.random() < S.homeOnJamChance * (w.s.city.adaptLevels.freqHop ?? 0) * 0.5) {
// home-on-jam keeps guiding
} else m.guided = false;
}
if (!m.seduced && chaffNear(w, target.x, target.y, target.alt) && Math.random() < S.chaffBreakChance * dt * 1.5) {
m.seduced = true;
const c = w.chaff.find((q) => dist(q.x, q.y, target.x, target.y) < q.r);
if (c) { m.tx = c.x; m.ty = c.y; }
}
target.tracked = 0;
}
// Beaming: a semi-active seeker loses a target crossing at right angles (Doppler notch).
if (m.guided && target?.isPlayer && target.alive) {
const K = CONFIG.mission.crew;
const d = dist(m.x, m.y, target.x, target.y);
const aspect = Math.abs(wrap(Math.atan2(target.y - m.y, target.x - m.x) - target.hdg));
const beam = clamp(1 - Math.abs(aspect - Math.PI / 2) / ((K.beamWindowDeg * Math.PI) / 180));
if (d < K.beamRangeKm && beam > 0 && Math.random() < K.beamBreakPerSec * beam * dt) {
m.guided = false;
w.events.push({ t: 'sys', what: 'beamLost', x: m.x, y: m.y });
radio(w, 'NAVIGATOR', 'It lost us — you beamed it! It\'s going ballistic!', false);
}
}
if (m.guided && !m.seduced && target && target.alive && !target.done) {
// Lead pursuit
const tt = dist(m.x, m.y, target.x, target.y) / m.spd;
tx = target.x + Math.cos(target.hdg) * target.spd * tt * 0.7 + m.missX;
ty = target.y + Math.sin(target.hdg) * target.spd * tt * 0.7 + m.missY;
talt = target.alt;
m.tx = tx; m.ty = ty;
} else {
talt = Math.max(0.5, m.alt);
}
} else if (m.kind === 'aim9' || m.kind === 'aim7' || m.kind === 'r3s') {
if (target && target.alive && !target.done) {
tx = target.x + Math.cos(target.hdg) * target.spd * 0.4;
ty = target.y + Math.sin(target.hdg) * target.spd * 0.4;
talt = target.alt;
if (m.kind === 'aim7' && chaffNear(w, target.x, target.y, target.alt) && Math.random() < dt) m.targetId = -1;
} else talt = m.alt;
} else if (m.kind === 'ksr11') {
const site = m.site;
if (site && site.emitting) { m.tx = site.st.x; m.ty = site.st.y; }
else if (site && !m.seduced) { m.seduced = true; m.tx += gauss() * 1.2; m.ty += gauss() * 1.2; }
tx = m.tx; ty = m.ty; talt = 0;
} else {
tx = m.tx; ty = m.ty; talt = 0;
}
const want = Math.atan2(ty - m.y, tx - m.x);
const off = wrap(want - m.hdg);
const air = m.kind !== 'ksr11' && m.kind !== 'standoff';
// An air-to-air or SAM round that has lost its guidance, or overshot, flies on ballistic.
if (air && Math.abs(off) > 1.75) m.guided = false;
const steer = !air || m.seduced || (m.kind === 'nike' || m.kind === 'bloodhound' ? m.guided : !!target?.alive && m.guided);
const turn = m.turn * (m.kind === 'nike' && m.age < 2 ? 0.3 : 1);
if (steer) m.hdg = wrap(m.hdg + clamp(off, -turn * dt, turn * dt));
m.x += Math.cos(m.hdg) * m.spd * dt;
m.y += Math.sin(m.hdg) * m.spd * dt;
const climb = m.kind === 'nike' || m.kind === 'bloodhound' ? 1.6 : 1.2;
m.alt += clamp(talt - m.alt, -climb * dt * 2, climb * dt);
// Ground attack weapons.
if (m.kind === 'ksr11' || m.kind === 'standoff') {
const dd = dist(m.x, m.y, m.tx, m.ty);
if (dd < m.spd * dt * 1.5 || m.life <= 0) {
m.alive = false;
const munition = m.munition ?? 'KSR-11';
const kg = CONFIG.munitions[munition].kg;
if (m.kind === 'ksr11' && m.site && dd < 0.6 && !m.seduced) {
m.site.st.radar = 0;
m.site.st.launchers = clamp(m.site.st.launchers - 0.5);
if (!m.site.st.isDecoy) {
m.site.st.disabledUntil = w.s.hour + CONFIG.sam.radarRepairDays * 24;
m.site.state = 'dead';
w.samSitesHit.add(m.site.st.id);
radio(w, m.owner?.callsign ?? 'WEASEL', `Emitter ${m.site.st.id} is off the air. Good kill.`, false);
} else {
m.site.st.revealed = true;
radio(w, m.owner?.callsign ?? 'WEASEL', `Emitter ${m.site.st.id} was a dummy transmitter. Wasted a Kelt.`, false);
}
}
if (m.kind === 'standoff') w.guidedHits++;
impact(w, m.x, m.y, kg * 0.6, munition, m.flightId, !!m.owner?.isPlayer, m.aimKey, m.owner);
}
return;
}
// Crews see the smoke trail coming: break, dive and pop chaff.
if (target && target.alive && !target.done && m.side === 'blue' && !m.evaded) {
const dd = dist(m.x, m.y, target.x, target.y);
if (dd < 6 + target.xp * 3) {
m.evaded = true;
// AI pilots break; the player's own crew only works the chaff dispenser.
if (!target.isPlayer) {
target.evadeUntil = w.t + 3;
target.evadeDir = wrap(m.hdg + (Math.random() < 0.5 ? 1 : -1) * Math.PI / 2);
} else if (target.chaff > 0) radio(w, 'NAVIGATOR', 'Chaff, chaff! Break!', true, 'crewchaff', 3);
if (target.chaff > 0 && Math.random() < 0.4 + target.xp * 0.5) {
target.chaff--;
w.chaff.push({ x: target.x, y: target.y, alt: target.alt, r: CONFIG.sam.chaffRadius, life: CONFIG.sam.chaffCloudLife, max: CONFIG.sam.chaffCloudLife });
w.events.push({ t: 'chaff', x: target.x, y: target.y, alt: target.alt, player: false });
}
}
}
// Air targets: proximity fuze.
if (target && target.alive && !target.done) {
const d3 = Math.hypot(m.x - target.x, m.y - target.y, (m.alt - target.alt) * 0.6);
if (d3 < m.minD) m.minD = d3;
else if (m.minD < m.lethal * 3 && d3 > m.minD + 0.05) {
detonate(w, m, target, m.minD);
return;
}
if (d3 < m.lethal * 0.4) {
detonate(w, m, target, d3);
return;
}
}
// Seduced missiles burst in the chaff.
if (m.seduced && dist(m.x, m.y, m.tx, m.ty) < 0.5) {
detonate(w, m, null, 9);
return;
}
if (m.life <= 0) detonate(w, m, null, 9);
}
function detonate(w: World, m: Missile, target: Plane | null, d: number): void {
m.alive = false;
if (m.site) m.site.inFlight = Math.max(0, m.site.inFlight - 1);
const nearPlayer = w.player ? Math.hypot(m.x - w.player.x, m.y - w.player.y, (m.alt - w.player.alt) * 0.6) : 99;
w.events.push({ t: 'missileDet', x: m.x, y: m.y, alt: m.alt, near: nearPlayer, kind: m.kind });
// Fragments hit everything in the ring.
for (const p of w.planes) {
if (!p.alive || p.done || p.side === m.side || p.state === 'wait') continue;
const dd = Math.hypot(m.x - p.x, m.y - p.y, (m.alt - p.alt) * 0.6);
if (dd > m.lethal) continue;
const fuze = Math.random() < m.pk + (p === target ? 0.35 : 0) - p.xp * 0.1;
if (!fuze && p === target && d > m.lethal * 0.3) continue;
damagePlane(w, p, m.dmg * (1 - dd / m.lethal) * (0.6 + Math.random() * 0.8), m.kind);
}
}
// ── AAA ────────────────────────────────────────────────────────────────────
function aaaStep(w: World, dt: number): void {
const A = CONFIG.aaa;
const staff = 0.5 + 0.5 * Math.min(1, w.s.city.morale + w.s.city.resolve * 0.4);
const radarPower = samPower(w.s);
if (!w.detected) return;
barrage(w, dt);
for (const g of w.guns) {
if (!g.alive) continue;
if (g.suppressed > 0) { g.suppressed -= dt; continue; }
g.cool -= dt;
if (g.cool > 0) continue;
const spec = A[g.kind];
g.cool = (1 / spec.rof) * (0.7 + Math.random() * 0.6) / staff;
let t = g.targetId >= 0 ? planeById(w, g.targetId) : undefined;
const valid = (p: Plane) => p.alive && !p.done && p.side === 'red' && p.state !== 'wait' && p.alt <= spec.maxAlt && p.alt >= spec.minAlt && dist(g.x, g.y, p.x, p.y) < spec.range;
if (!t || !valid(t)) {
t = undefined;
let bd = spec.range;
for (const p of w.planes) {
if (!valid(p)) continue;
const d = dist(g.x, g.y, p.x, p.y);
if (d < bd) { bd = d; t = p; }
}
if (t) { g.targetId = t.id; g.err = spec.err0; g.lastHdg = t.hdg; }
else {
g.targetId = -1;
// Barrage fire into the sky over the target area at night / without radar.
if (g.kind === 'heavy' && Math.random() < 0.15 && w.planes.some((p) => p.side === 'red' && p.alive && !p.done && dist(g.x, g.y, p.x, p.y) < spec.range * 1.8)) {
w.events.push({ t: 'flak', x: g.x + gauss() * 3, y: g.y + gauss() * 3, alt: 2 + Math.random() * 7, kind: g.kind, near: 99 });
}
continue;
}
}
// Walk the bursts onto a steady target; jinking resets the solution.
const turned = Math.abs(wrap(t.hdg - g.lastHdg));
g.lastHdg = t.hdg;
const radarAimed = g.kind === 'heavy' ? radarPower : 1;
let acc = (w.light < 0.3 ? A.nightPenalty : 1) * (t.searchlit > 0 ? A.searchlightBonus : 1) * radarAimed;
acc *= t.alt < 0.6 && g.kind === 'heavy' ? 0.3 : 1;
if (turned > 0.08 || w.t < t.jinkUntil) g.err = Math.min(spec.err0 * 1.3, g.err * 1.25 + 0.05);
else g.err = Math.max(spec.errMin, g.err * spec.walk);
const err = g.err / Math.max(0.2, acc);
const d = dist(g.x, g.y, t.x, t.y);
const tof = d / 1.4;
const bx = t.x + Math.cos(t.hdg) * t.spd * tof * 0.5 + gauss() * err;
const by = t.y + Math.sin(t.hdg) * t.spd * tof * 0.5 + gauss() * err;
const balt = t.alt + gauss() * err * 0.5;
if (g.kind === 'heavy') {
const near = w.player ? Math.hypot(bx - w.player.x, by - w.player.y, (balt - w.player.alt) * 0.7) : 99;
w.events.push({ t: 'flak', x: bx, y: by, alt: balt, kind: g.kind, near });
for (const p of w.planes) {
if (!p.alive || p.done || p.side !== 'red') continue;
const dd = Math.hypot(bx - p.x, by - p.y, (balt - p.alt) * 0.7);
if (dd < spec.burstR) damagePlane(w, p, spec.dmg * (1 - dd / spec.burstR), 'flak90');
}
} else {
w.events.push({ t: 'tracer', x: g.x, y: g.y, tx: bx, ty: by, alt: balt, onPlayer: t.isPlayer, gun: g.kind });
if (Math.random() < 0.35) {
const near = w.player ? Math.hypot(bx - w.player.x, by - w.player.y) : 99;
w.events.push({ t: 'flak', x: bx, y: by, alt: balt, kind: g.kind, near });
}
const dd = Math.hypot(bx - t.x, by - t.y);
if (dd < spec.burstR) damagePlane(w, t, spec.dmg * (0.5 + Math.random()), g.kind);
}
}
}
/** Unaimed tracer curtains thrown up over the city once the raid is overhead. */
function barrage(w: World, dt: number): void {
const near = w.planes.some((p) => p.side === 'red' && p.alive && !p.done && p.state !== 'wait' && Math.hypot(p.x + 2, p.y) < 28);
if (!near) return;
const M = CONFIG.mission;
const alive = w.guns.filter((g) => g.alive).length / Math.max(1, w.guns.length);
const rate = (w.light < 0.35 ? M.barragePerSecNight : M.barragePerSecDay) * (0.4 + alive) * (1 + 0.15 * (w.s.city.adaptLevels.aaaDensity ?? 0));
let n = rate * dt;
while (n > 0) {
if (Math.random() > n) break;
n -= 1;
const a = Math.random() * TAU;
const r = Math.sqrt(Math.random()) * 15;
const x = Math.cos(a) * r - 2;
const y = Math.sin(a) * r * 0.8;
const d = Math.random() * TAU;
const len = 1.5 + Math.random() * 4;
w.events.push({ t: 'barrage', x, y, tx: x + Math.cos(d) * len, ty: y + Math.sin(d) * len, alt: 1.5 + Math.random() * 6, hue: Math.floor(Math.random() * 3) });
// Blind fire still kills now and then.
const p = w.player;
if (p && p.alive && !p.done && Math.random() < 0.08 && dist(x, y, p.x, p.y) < 6 && p.alt < 7) {
const bx = p.x + gauss() * 0.9;
const by = p.y + gauss() * 0.9;
const near2 = Math.hypot(bx - p.x, by - p.y);
w.events.push({ t: 'flak', x: bx, y: by, alt: p.alt + gauss() * 0.3, kind: 'bofors', near: near2 });
if (near2 < 0.2) damagePlane(w, p, 6, 'barrage');
}
}
}
function searchlightStep(w: World, dt: number): void {
for (const p of w.planes) p.searchlit = Math.max(0, p.searchlit - dt);
for (const l of w.lights) {
if (l.targetId >= 0) {
const t = planeById(w, l.targetId);
l.hold -= dt;
if (!t || !t.alive || t.done || l.hold <= 0 || dist(l.x, l.y, t.x, t.y) > l.len + 1) { l.targetId = -1; continue; }
l.ang = Math.atan2(t.y - l.y, t.x - l.x);
l.len = dist(l.x, l.y, t.x, t.y);
t.searchlit = 0.6;
continue;
}
l.ang += l.speed * dt;
l.len = 6 + Math.sin(w.t * 0.3 + l.x) * 2;
const ex = l.x + Math.cos(l.ang) * l.len;
const ey = l.y + Math.sin(l.ang) * l.len;
for (const p of w.planes) {
if (!p.alive || p.done || p.side !== 'red' || p.state === 'wait' || p.alt > 9) continue;
if (dist(ex, ey, p.x, p.y) < 0.9) {
l.targetId = p.id;
l.hold = 4 + Math.random() * 5;
if (p.isPlayer) radio(w, p.callsign, 'Coned! Searchlights have us!', true, 'coned', 8);
}
}
}
}
// ── Detection & scramble ───────────────────────────────────────────────────
function detection(w: World): void {
if (w.detected) return;
for (const p of w.planes) {
if (p.side !== 'red' || !p.alive || p.state === 'wait') continue;
for (const pk of w.pickets) {
if (!pk.alive) continue;
const rx = pk.x;
const ry = pk.y;
let r = 55;
if (p.alt - terrainHeight(p.x, p.y) < CONFIG.sam.lowAltBelow) r *= CONFIG.sam.lowAltDetectFactor + 0.12 * (w.s.city.adaptLevels.lowAltRadar ?? 0);
r *= 1 - jammedAt(w, p.x, p.y, rx, ry) * 0.4;
if (dist(p.x, p.y, rx, ry) < r) {
w.detected = true;
w.detectT = w.t;
radio(w, 'CONTROL', 'Picket radars are painting us. They know we are coming. Picture: fighters scrambling west of the city.', true);
return;
}
}
if (Math.hypot(p.x, p.y) < 45) {
w.detected = true;
w.detectT = w.t;
radio(w, 'CONTROL', 'Ground radars active. The city is awake.', false);
return;
}
}
}
function scramble(w: World): void {
if (w.scrambled || !w.detected || w.t - w.detectT < CONFIG.interceptors.scrambleDelaySec) return;
w.scrambled = true;
const s = w.s;
const raid = w.planes.filter((p) => p.side === 'red' && p.alive && p.kind !== 'drone').length;
const base = s.districts.airbase.integrity;
const fuel = s.city.stock.fuel > 0 ? 1 : 0.4;
const nightF = w.light < 0.3 ? CONFIG.interceptors.nightEffectiveness + 0.1 * (s.city.adaptLevels.nightNet ?? 0) : 1;
let n = Math.round(raid * CONFIG.interceptors.scramblePerRaidSize * base * fuel * nightF) + 1;
const kinds: InterceptorType[] = ['F-4', 'F-104G', 'Lightning'];
let launched = 0;
for (let i = 0; n > 0 && i < 40; i++) {
const k = kinds[i % 3];
if (s.city.interceptors[k] <= 0) continue;
makePlane(w, { kind: k, side: 'blue', x: -33 + Math.random() * 2, y: 6 + Math.random(), alt: 1, tAlt: 6, hdg: 0, state: 'engage' });
n--;
launched++;
}
if (launched) radio(w, 'CONTROL', `Lindenfeld is scrambling — ${launched} interceptors airborne.`, true);
}
// ── Main step ──────────────────────────────────────────────────────────────
export function step(w: World, dt: number): void {
if (w.ended) return;
w.t += dt;
detection(w);
scramble(w);
for (const p of w.planes) {
if (!p.alive || p.done) continue;
if (p.state === 'wait') {
if (w.t >= p.spawnAt) p.state = 'fly';
else continue;
}
p.tracked += dt;
p.fuelT += dt;
if (p.side === 'red') {
if (p.isPlayer) playerControl(w, p, dt);
else redAI(w, p, dt);
} else blueAI(w, p, dt);
// Kinematics.
// Roll into turns: bank follows the turn rate, rolls out when straight.
if (dt > 0) {
const rate = wrap(p.hdg - p.lastHdg) / dt;
const want = clamp(rate / (p.turnRate * 0.9 || 1), -1, 1);
p.bank += (want - p.bank) * Math.min(1, dt * 2.5);
p.lastHdg = p.hdg;
}
p.x += Math.cos(p.hdg) * p.spd * dt;
p.y += Math.sin(p.hdg) * p.spd * dt;
if (!p.isPlayer) p.tAlt = Math.max(p.tAlt, terrainHeight(p.x + Math.cos(p.hdg) * 3, p.y + Math.sin(p.hdg) * 3) + 0.12);
const climb = p.kind === 'drone' ? 0.4 : (CONFIG.aircraft[p.kind as AircraftType]?.climb ?? 0.8);
p.alt += clamp(p.tAlt - p.alt, -climb * 2 * dt, climb * dt);
if (p.engineFire > 0) {
damagePlane(w, p, p.engineFire * 1.4 * dt, 'fire');
if (!p.isPlayer) {
p.engineFire = Math.max(0, p.engineFire - dt * 0.03);
if (Math.random() < dt * 0.08) p.engineFire = 0; // the AI crew's extinguisher bottle
}
}
if (p.side === 'red' && !p.crossedBarrier && p.x < BARRIER_X) {
p.crossedBarrier = true;
if (p.flight && !p.flight.calledFeetDry && p.slot === 0) {
p.flight.calledFeetDry = true;
if (p.flight.plan.id === w.plan.playerFlight || Math.random() < 0.3) radio(w, p.callsign, 'Feet dry.', false);
}
}
if (p.x < CONFIG.mission.worldMinX - 10 || p.y < CONFIG.mission.worldMinY - 20 || p.y > CONFIG.mission.worldMaxY + 20) p.done = true;
if (p.side === 'red' && p.x > CONFIG.mission.egressX && p.crossedBarrier && (p.isPlayer || p.wp > 2 || p.state === 'egress')) {
if (!p.done && p.isPlayer) radio(w, p.callsign, 'Crossing the line. Heading home.', false);
p.done = true;
}
}
for (const site of w.sites) samStep(w, site, dt);
for (const m of w.missiles) if (m.alive) missileStep(w, m, dt);
aaaStep(w, dt);
searchlightStep(w, dt);
// Bombs fall.
for (const b of w.bombs) {
if (!b.alive) continue;
b.t += dt;
if (b.guided) {
const owner = b.owner;
const steering = owner && owner.alive && !owner.done && dist(owner.x, owner.y, b.x, b.y) < 14;
const remain = Math.max(0.05, b.ft - b.t);
if (steering) {
b.vx += ((b.steerX - b.x) / remain - b.vx) * Math.min(1, dt * 2);
b.vy += ((b.steerY - b.y) / remain - b.vy) * Math.min(1, dt * 2);
}
}
b.x += b.vx * dt;
b.y += b.vy * dt;
// A higher wave's bombs falling through your formation.
const pl = w.player;
if (pl && pl.alive && !pl.done && b.owner && b.owner.flight !== pl.flight) {
const f = b.t / b.ft;
const balt = b.alt0 * (1 - f * f);
if (Math.abs(balt - pl.alt) < 0.12 && dist(b.x, b.y, pl.x, pl.y) < 0.09) {
damagePlane(w, pl, 35, 'friendly bomb');
radio(w, 'TAIL GUNNER', 'We were hit by a bomb from above! From our own wave!', true, 'fbomb', 5);
b.alive = false;
continue;
}
}
if (b.t >= b.ft) {
b.alive = false;
if (b.guided) {
const h = hitTest(w, b.x, b.y, 0.2);
if (h.key && b.aimKey && (h.key === `d:${b.aimKey}` || h.key === b.aimKey)) w.guidedHits++;
if (w.guidedBomb === b) w.guidedBomb = null;
}
impact(w, b.x, b.y, b.kg, b.munition, b.flightId, b.player, b.aimKey, b.owner);
}
}
w.bombs = w.bombs.filter((b) => b.alive);
w.missiles = w.missiles.filter((m) => m.alive);
for (const mk of w.markers) mk.life -= dt;
w.markers = w.markers.filter((mk) => mk.life > 0);
crewStep(w);
for (const c of w.chaff) {
c.life -= dt;
c.r += dt * 0.04;
c.alt -= dt * 0.05;
}
w.chaff = w.chaff.filter((c) => c.life > 0);
stepFire(w, dt);
// Wingman silence.
for (let i = w.silence.length - 1; i >= 0; i--) {
const ps = w.silence[i];
if (w.t >= ps.at) {
w.silence.splice(i, 1);
radio(w, ps.from, `${ps.to}, report. ${ps.to}… come in.`, false);
}
}
w.bandits = 0;
// End condition.
const reds = w.planes.filter((p) => p.side === 'red' && p.kind !== 'drone');
if (reds.every((p) => !p.alive || p.done) && !w.bombs.length && !w.missiles.some((m) => m.side === 'red')) w.ended = true;
if (w.t > CONFIG.mission.maxSeconds) w.ended = true;
}
/** Player-side bookkeeping: beam advice, terrain clearance, marker following. */
function crewStep(w: World): void {
const p = w.player;
const cr = w.crew;
cr.beam = 0;
cr.beamTurn = 0;
if (p && p.alive && !p.done) {
// The navigator calls the turn that puts the closest missile on the beam.
let best = 99;
for (const m of w.missiles) {
if (m.side !== 'blue' || m.targetId !== p.id || !m.guided) continue;
const d = dist(m.x, m.y, p.x, p.y);
if (d > best) continue;
best = d;
const rel = wrap(Math.atan2(m.y - p.y, m.x - p.x) - p.hdg);
const a = wrap(rel - Math.PI / 2);
const b = wrap(rel + Math.PI / 2);
const pick = Math.abs(a) < Math.abs(b) ? a : b;
const aspect = Math.abs(wrap(Math.atan2(p.y - m.y, p.x - m.x) - p.hdg));
cr.beam = clamp(1 - Math.abs(aspect - Math.PI / 2) / ((CONFIG.mission.crew.beamWindowDeg * Math.PI) / 180));
cr.beamTurn = Math.abs(pick) < 0.12 ? 0 : Math.sign(pick);
}
// Terrain.
const ground = terrainHeight(p.x, p.y);
const ahead = terrainHeight(p.x + Math.cos(p.hdg) * p.spd * 6, p.y + Math.sin(p.hdg) * p.spd * 6);
cr.agl = p.alt - ground;
cr.pullUp = cr.agl < CONFIG.mission.crew.pullUpAglKm && (ahead > ground + 0.02 || cr.agl < 0.08);
if (cr.agl < CONFIG.mission.crew.crashAglKm) {
w.events.push({ t: 'sys', what: 'crash', x: p.x, y: p.y });
radio(w, p.flight?.planes.find((q) => q !== p && q.alive)?.callsign ?? 'CONTROL', `${p.callsign} flew into the ground!`, true);
p.chuteCount = 0;
damagePlane(w, p, 9999, 'terrain');
}
}
// Following flights bomb on the pathfinder's marker.
if (w.markers.length) {
for (const fr of w.flights) {
if (fr.released || fr.plan.id === w.plan.playerFlight || fr.plan.role !== 'strike') continue;
const mk = w.markers.find((q) => dist(q.x, q.y, fr.aimX, fr.aimY) < CONFIG.mission.crew.markerPullKm);
if (!mk || (fr.aimX === mk.x && fr.aimY === mk.y)) continue;
fr.aimX = mk.x;
fr.aimY = mk.y;
const k = ROUTES[w.plan.route].ingress.length;
for (const q of fr.planes) q.path[k] = [mk.x, mk.y];
radio(w, fr.planes[0]?.callsign ?? fr.plan.callsign, 'Marker in sight — bombing on the marker.', false, `mk${fr.plan.id}`, 30);
}
}
}
/** Run the rest of the raid without rendering. */
export function autoResolve(w: World, maxSteps = 20000): void {
w.autoResolved = true;
for (let i = 0; i < maxSteps && !w.ended; i++) {
if (w.player?.alive && !w.player.done) {
// The AI takes the controls.
w.player.isPlayer = false;
w.player.isWingmanOfPlayer = false;
}
step(w, 0.1);
w.events.length = 0;
}
}
export function finishWorld(w: World): MissionResult {
settleFire(w);
const outcomes: AirframeOutcome[] = [];
for (const p of w.planes) {
if (p.side !== 'red' || p.kind === 'drone' || p.airframeId < 0) continue;
let fate = p.fate;
const dmg = clamp(1 - p.hp / p.maxHp);
if (p.alive && dmg > 0.8 && !p.isPlayer && Math.random() < 0.35) fate = 'crashlanded';
if (p.isPlayer && p.alive && p.fate !== 'ok') fate = p.fate;
outcomes.push({ airframeId: p.airframeId, damage: dmg, fate: p.alive ? fate : p.fate, bombsOnTarget: p.bombsOnTarget });
}
const pl = w.planes.find((p) => p.airframeId >= 0 && p.flight?.plan.id === w.plan.playerFlight && p.slot === 0);
return {
impacts: w.impacts, outcomes, interceptorsKilled: w.interceptorsKilled, samLaunches: w.samLaunches, samSitesHit: [...w.samSitesHit],
guidedHits: w.guidedHits, playerFate: pl ? (pl.alive ? pl.fate : pl.fate) : 'none', durationSec: w.t, usedJamming: w.usedJamming,
firestorms: w.firestorms, dronesUsed: w.dronesUsed, presUsed: 0, autoResolved: w.autoResolved, destructionBefore: w.destructionBefore,
};
}
export function samRangeFor(kind: 'nike' | 'bloodhound'): number {
return CONFIG.sam[kind].range;
}
