diff --git a/docs/plans/air-war-v5.md b/docs/plans/air-war-v5.md index 2081ddb..5bab21a 100644 --- a/docs/plans/air-war-v5.md +++ b/docs/plans/air-war-v5.md @@ -89,8 +89,9 @@ aircraft units; air-ops only sets waypoints/decisions): the existing visibility + fire-control network does the rest. - **RTB**: at bingo or out of weapons → waypoints home, on arrival the Flight unit is removed, squadron readyAt += turnaround, `available` restored minus - losses. Flight destroyed → airframes lost, pilot roll per airframe: - KIA (−2 war support) / rescued (+1) / POW (−4, OSINT event). + losses. Flight destroyed → airframes lost, one pilot-fate roll per flight: + KIA (−2 war support) / rescued (−1, a rescue softens the loss) / POW (−4, + OSINT event). - **Iran AI**: scramble-only — when a detected USA package/flight approaches a defended box, spawn an interceptor CAP from the nearest airbase with available fighters (ai.ts hook, max 2 concurrent). Su-35 squadron only diff --git a/src/App.tsx b/src/App.tsx index 7a26758..666bb6f 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -9,6 +9,7 @@ import EconomyPanel from '@/components/panels/EconomyPanel' import OrbatPanel from '@/components/panels/OrbatPanel' import StatsPanel from '@/components/panels/StatsPanel' import IntelPanel from '@/components/panels/IntelPanel' +import AirOpsPanel from '@/components/panels/AirOpsPanel' import ImintViewer from '@/components/intel/ImintViewer' import LiveFeeds from '@/components/intel/LiveFeeds' import OsintTicker from '@/components/intel/OsintTicker' @@ -72,6 +73,7 @@ export default function App() { const showStats = useUIStore((s) => s.showStats) const showEconomy = useUIStore((s) => s.showEconomy) const showIntel = useUIStore((s) => s.showIntel) + const showAirOps = useUIStore((s) => s.showAirOps) // StrikePanel manages its own visibility via useStrikeStore // Debrief overlay: shown when the war is decided, until dismissed (keyed by @@ -141,9 +143,9 @@ export default function App() { const ui = useUIStore.getState() // Close the topmost registered panel first (ImintViewer capture-stops its own Escape before this runs) if (ui.closeTopPanel()) break - if (ui.leftPanel !== null || ui.showIntel || strike.open) { + if (ui.leftPanel !== null || ui.showIntel || ui.showAirOps || strike.open) { ui.setLeftPanel(null) - useUIStore.setState({ showIntel: false }) + useUIStore.setState({ showIntel: false, showAirOps: false }) strike.closeStrike() break } @@ -195,6 +197,7 @@ export default function App() { {showStats && } {showEconomy && } {showIntel && } + {showAirOps && } {showDebrief && } diff --git a/src/components/hud/TopBar.tsx b/src/components/hud/TopBar.tsx index 8c5e6db..9b1f166 100644 --- a/src/components/hud/TopBar.tsx +++ b/src/components/hud/TopBar.tsx @@ -113,6 +113,8 @@ export default function TopBar() { const toggleIntel = useUIStore((s) => s.toggleIntel) const liveFeedsOpen = useUIStore((s) => s.liveFeedsOpen) const toggleLiveFeeds = useUIStore((s) => s.toggleLiveFeeds) + const showAirOps = useUIStore((s) => s.showAirOps) + const toggleAirOps = useUIStore((s) => s.toggleAirOps) const placingCatalogId = useIntelStore((s) => s.placingCatalogId) const units = useGameStore((s) => s.viewState.units) @@ -122,6 +124,7 @@ export default function TopBar() { const shippingLanes = useGameStore((s) => s.viewState.shippingLanes) const warSupport = useGameStore((s) => s.viewState.warSupport) const objectives = useGameStore((s) => s.viewState.objectives) + const surgeOps = useGameStore((s) => s.viewState.surgeOps) const eventLog = useGameStore((s) => s.eventLog) const hormuzLane = shippingLanes.find((l) => l.id === 'hormuz') @@ -454,6 +457,10 @@ export default function TopBar() { + {/* Air ops panel toggle + surge indicator */} + + {surgeOps && } + {/* Intel panel toggle */} @@ -970,7 +977,7 @@ function exitToMainMenu() { const ui = useUIStore.getState() ui.clearSelection() ui.setLeftPanel(null) - useUIStore.setState({ showIntel: false }) + useUIStore.setState({ showIntel: false, showAirOps: false }) useMenuStore.getState().setScreen('start') } @@ -1208,6 +1215,27 @@ function LiveBtn({ active, onClick }: { active: boolean; onClick: () => void }) ) } +function SurgeChip() { + return ( + <> + + + SURGE + + + ) +} + function MuteBtn() { const muted = useUIStore((s) => s.audioMuted) const toggleAudioMuted = useUIStore((s) => s.toggleAudioMuted) diff --git a/src/components/hud/__tests__/TopBar.test.tsx b/src/components/hud/__tests__/TopBar.test.tsx index 86d133d..b1b8c9e 100644 --- a/src/components/hud/__tests__/TopBar.test.tsx +++ b/src/components/hud/__tests__/TopBar.test.tsx @@ -321,3 +321,25 @@ describe('TopBar time controls', () => { expect(useUIStore.getState().liveFeedsOpen).toBe(true) }) }) + +describe('TopBar air ops', () => { + it('toggles the air ops panel with the AIR button', () => { + useUIStore.setState({ showAirOps: false }) + render() + fireEvent.click(screen.getByText('AIR')) + expect(useUIStore.getState().showAirOps).toBe(true) + fireEvent.click(screen.getByText('AIR')) + expect(useUIStore.getState().showAirOps).toBe(false) + }) + + it('shows the SURGE chip only while surge ops is active', () => { + setStore(makeViewState({ surgeOps: true })) + const { unmount } = render() + expect(screen.getByText('SURGE')).toBeTruthy() + unmount() + + setStore(makeViewState({})) + render() + expect(screen.queryByText('SURGE')).toBeNull() + }) +}) diff --git a/src/components/map/DeckOverlay.tsx b/src/components/map/DeckOverlay.tsx index 27be577..575efd6 100644 --- a/src/components/map/DeckOverlay.tsx +++ b/src/components/map/DeckOverlay.tsx @@ -4,6 +4,7 @@ import { MapboxOverlay } from '@deck.gl/mapbox' import type { MapboxOverlayProps } from '@deck.gl/mapbox' import { createIntelMapLayers } from './layers/IntelLayers' import { createEffectsLayers, KILL_ANIM_MS } from './layers/EffectsLayers' +import { createAirMapLayers } from './layers/AirLayers' import { useGameStore } from '@/store/game-store' import { useUIStore } from '@/store/ui-store' import { useMapIntelStore } from '@/store/map-intel-store' @@ -15,6 +16,7 @@ export default function DeckOverlay(props: MapboxOverlayProps) { const tick = useGameStore((s) => s.viewState.time.tick) const playerNation = useGameStore((s) => s.viewState.playerNation) const intel = useGameStore((s) => s.viewState.intel) + const airMissions = useGameStore((s) => s.viewState.airMissions) const eventLog = useGameStore((s) => s.eventLog) const selectedUnitId = useUIStore((s) => s.selectedUnitId) const intelOverlays = useMapIntelStore((s) => s.intelOverlays) @@ -101,11 +103,16 @@ export default function DeckOverlay(props: MapboxOverlayProps) { [killMarkers, trackHistory, units, playerNation, effectsNowMs], ) + const airLayers = useMemo( + () => createAirMapLayers({ missions: airMissions ?? [], units, selectedUnitId }), + [airMissions, units, selectedUnitId], + ) + // Intel layers prepend (draw beneath) the game layers passed by GameMap; - // effects sit between — above rings/overlays, below unit icons + // effects and air-war glyphs sit between — above rings/overlays, below unit icons const mergedProps = useMemo( - () => ({ ...props, layers: [...intelLayers, ...effectsLayers, ...(props.layers ?? [])] }), - [props, intelLayers, effectsLayers], + () => ({ ...props, layers: [...intelLayers, ...effectsLayers, ...airLayers, ...(props.layers ?? [])] }), + [props, intelLayers, effectsLayers, airLayers], ) const overlay = useControl( diff --git a/src/components/map/layers/AirLayers.ts b/src/components/map/layers/AirLayers.ts new file mode 100644 index 0000000..7f5f6c2 --- /dev/null +++ b/src/components/map/layers/AirLayers.ts @@ -0,0 +1,132 @@ +import { LineLayer, PathLayer } from '@deck.gl/layers' +import type { Layer } from '@deck.gl/core' +import type { AirMission } from '@/types/game' +import type { ViewUnit } from '@/types/view' + +type LngLat = [number, number] + +const KM_PER_DEG_LAT = 110.574 +const KM_PER_DEG_LON_EQ = 111.32 + +const CAP_CYAN: [number, number, number, number] = [64, 224, 240, 170] +const AEW_WHITE: [number, number, number, number] = [235, 235, 235, 150] +const STRIKE_AMBER: [number, number, number, number] = [212, 168, 84, 230] +const STRIKE_AMBER_DIM: [number, number, number, number] = [212, 168, 84, 102] + +const RACETRACK_LEG_KM = 18 +const RACETRACK_RADIUS_KM = 7 + +function offsetKm(lng: number, lat: number, eastKm: number, northKm: number): LngLat { + const dLat = northKm / KM_PER_DEG_LAT + const dLng = eastKm / (KM_PER_DEG_LON_EQ * Math.cos((lat * Math.PI) / 180)) + return [lng + dLng, lat + dLat] +} + +/** Point on a stadium (racetrack) perimeter at arc-distance d, in local km offsets */ +function stadiumPointKm(d: number, legKm: number, rKm: number): [number, number] { + const arc = Math.PI * rKm + if (d < legKm) return [-legKm / 2 + d, rKm] + d -= legKm + if (d < arc) { + const a = d / rKm + return [legKm / 2 + Math.sin(a) * rKm, Math.cos(a) * rKm] + } + d -= arc + if (d < legKm) return [legKm / 2 - d, -rKm] + d -= legKm + const a = d / rKm + return [-legKm / 2 - Math.sin(a) * rKm, -Math.cos(a) * rKm] +} + +// Dashed oval as separate short arcs — PathStyleExtension is not a dependency +function dashedRacetrackPaths(lng: number, lat: number, dashes = 28): LngLat[][] { + const total = 2 * RACETRACK_LEG_KM + 2 * Math.PI * RACETRACK_RADIUS_KM + const slot = total / dashes + const paths: LngLat[][] = [] + for (let i = 0; i < dashes; i++) { + const arc: LngLat[] = [] + for (let j = 0; j <= 3; j++) { + const [e, n] = stadiumPointKm((i * slot + (j / 3) * slot * 0.55) % total, RACETRACK_LEG_KM, RACETRACK_RADIUS_KM) + arc.push(offsetKm(lng, lat, e, n)) + } + paths.push(arc) + } + return paths +} + +interface StationDashDatum { + path: LngLat[] + kind: AirMission['kind'] +} + +interface StrikeLineDatum { + id: string + source: LngLat + target: LngLat + selected: boolean +} + +export interface AirMapLayerInputs { + missions: AirMission[] + units: ViewUnit[] + selectedUnitId: string | null +} + +/** CAP/AEW station racetracks + strike flight→target lines. Missions arrive pre-filtered to the player nation. */ +export function createAirMapLayers(opts: AirMapLayerInputs): Layer[] { + const { missions, units, selectedUnitId } = opts + const unitById = new Map(units.map((u) => [u.id, u])) + + const dashData: StationDashDatum[] = [] + const lineData: StrikeLineDatum[] = [] + for (const m of missions) { + if ((m.kind === 'cap' || m.kind === 'aew') && m.station && (m.status === 'active' || m.status === 'planning')) { + for (const path of dashedRacetrackPaths(m.station.lng, m.station.lat)) { + dashData.push({ path, kind: m.kind }) + } + } + if (m.kind === 'strike' && m.status === 'active' && m.flightUnitId !== undefined && m.targetId !== undefined) { + const flight = unitById.get(m.flightUnitId) + const target = unitById.get(m.targetId) + if (!flight || !target || flight.status === 'destroyed') continue + lineData.push({ + id: m.id, + source: [flight.position.lng, flight.position.lat], + target: [target.position.lng, target.position.lat], + selected: selectedUnitId === flight.id, + }) + } + } + + const layers: Layer[] = [] + if (dashData.length > 0) { + layers.push( + new PathLayer({ + id: 'air-station-racetracks', + data: dashData, + getPath: (d) => d.path, + getColor: (d) => (d.kind === 'aew' ? AEW_WHITE : CAP_CYAN), + widthUnits: 'pixels', + getWidth: 1, + widthMinPixels: 1, + pickable: false, + }), + ) + } + if (lineData.length > 0) { + layers.push( + new LineLayer({ + id: 'air-strike-lines', + data: lineData, + getSourcePosition: (d) => d.source, + getTargetPosition: (d) => d.target, + // Unselected flights keep a faint intent line (40%); selection brings it up + getColor: (d) => (d.selected ? STRIKE_AMBER : STRIKE_AMBER_DIM), + getWidth: 1, + widthUnits: 'pixels', + pickable: false, + }), + ) + } + return layers +} diff --git a/src/components/panels/AirOpsPanel.tsx b/src/components/panels/AirOpsPanel.tsx new file mode 100644 index 0000000..4c4cf54 --- /dev/null +++ b/src/components/panels/AirOpsPanel.tsx @@ -0,0 +1,506 @@ +import { useMemo, useState, type CSSProperties } from 'react' +import Panel from '@/components/common/Panel' +import { useUIStore } from '@/store/ui-store' +import { useGameStore } from '@/store/game-store' +import { sendCommand } from '@/store/bridge' +import { AIRFRAMES } from '@/data/air/airframes' +import type { AirMission, AirMissionKind, Position, SquadronState } from '@/types/game' +import type { ViewUnit } from '@/types/view' + +const KINDS: { id: AirMissionKind; label: string; color: string }[] = [ + { id: 'cap', label: 'CAP', color: 'var(--text-accent)' }, + { id: 'strike', label: 'STRIKE', color: 'var(--status-damaged)' }, + { id: 'aew', label: 'AEW', color: 'var(--text-primary)' }, +] + +const STRAIT_STATION: Position = { lat: 26.6, lng: 56.5 } + +const CARD: CSSProperties = { + border: '1px solid var(--border-default)', + borderRadius: 4, + padding: '6px 8px', + marginBottom: 6, + background: 'var(--bar-bg)', +} + +const SECTION_HEADER: CSSProperties = { + color: 'var(--text-accent)', + fontWeight: 700, + fontSize: 'var(--font-size-xs)', + letterSpacing: '0.06em', + textTransform: 'uppercase', + padding: '4px 0', + borderBottom: '1px solid var(--border-default)', + marginBottom: 6, + userSelect: 'none', +} + +const BTN: CSSProperties = { + background: 'var(--bg-hover)', + border: '1px solid var(--border-default)', + borderRadius: 3, + color: 'var(--text-secondary)', + cursor: 'pointer', + fontFamily: 'var(--font-mono)', + fontSize: '0.55rem', + padding: '3px 8px', + fontWeight: 600, + textTransform: 'uppercase', + letterSpacing: '0.04em', + whiteSpace: 'nowrap', +} + +const SELECT: CSSProperties = { + background: 'var(--bg-hover)', + border: '1px solid var(--border-default)', + borderRadius: 3, + color: 'var(--text-primary)', + fontFamily: 'var(--font-mono)', + fontSize: '0.6rem', + padding: '3px 4px', + width: '100%', +} + +const INPUT: CSSProperties = { ...SELECT, width: 80 } + +const LABEL: CSSProperties = { + color: 'var(--text-muted)', + fontSize: '0.5rem', + fontWeight: 700, + letterSpacing: '0.06em', + textTransform: 'uppercase', + whiteSpace: 'nowrap', +} + +const MUTED_XS: CSSProperties = { + color: 'var(--text-muted)', + fontSize: '0.55rem', + lineHeight: 1.4, +} + +const HINT: CSSProperties = { ...MUTED_XS, fontStyle: 'italic', padding: '2px 0' } + +function fmtTicks(ticks: number): string { + const t = Math.max(0, Math.round(ticks)) + const h = Math.floor(t / 3600) + const m = Math.floor((t % 3600) / 60) + const s = t % 60 + if (h > 0) return `${h}h ${String(m).padStart(2, '0')}m` + if (m > 0) return `${m}m ${String(s).padStart(2, '0')}s` + return `${s}s` +} + +function availColor(available: number, total: number): string { + if (available < 2) return 'var(--status-damaged)' + if (total > 0 && available / total < 0.5) return 'var(--status-engaged)' + return 'var(--status-ready)' +} + +function airframeShortName(squadron: SquadronState | undefined): string { + if (!squadron) return '?' + const spec = AIRFRAMES[squadron.airframe] + return spec ? spec.name.split(' ')[0] : squadron.airframe +} + +export default function AirOpsPanel({ onClose }: { onClose?: () => void }) { + const toggleAirOps = useUIStore((s) => s.toggleAirOps) + const units = useGameStore((s) => s.viewState.units) + const playerNation = useGameStore((s) => s.viewState.playerNation) + const tick = useGameStore((s) => s.viewState.time.tick) + const missions = useGameStore((s) => s.viewState.airMissions) + const surgeOps = useGameStore((s) => s.viewState.surgeOps) ?? false + + const [kind, setKind] = useState('cap') + const [squadronSel, setSquadronSel] = useState('') + const [flightSize, setFlightSize] = useState(2) + const [targetSel, setTargetSel] = useState('') + const [stationSel, setStationSel] = useState('strait') + const [customLat, setCustomLat] = useState('26.6') + const [customLng, setCustomLng] = useState('56.5') + const [sead, setSead] = useState(false) + const [extRange, setExtRange] = useState(false) + + const hosts = useMemo( + () => units.filter((u) => u.nation === playerNation && u.status !== 'destroyed' && (u.airWing?.length ?? 0) > 0), + [units, playerNation], + ) + + const squadronIndex = useMemo(() => { + const map = new Map() + for (const host of hosts) { + for (const squadron of host.airWing ?? []) map.set(squadron.id, { host, squadron }) + } + return map + }, [hosts]) + + // Aborted missions stay listed while their flight is still airborne (RTB) + const visibleMissions = useMemo( + () => (missions ?? []).filter((m) => + m.status === 'planning' || m.status === 'active' + || (m.status === 'aborted' && m.flightUnitId !== undefined && units.some((u) => u.id === m.flightUnitId && u.status !== 'destroyed'))), + [missions, units], + ) + + const eligible = useMemo(() => hosts.flatMap((host) => (host.airWing ?? []) + .filter((sq) => { + const spec = AIRFRAMES[sq.airframe] + if (!spec) return false + if (kind === 'strike') return spec.strikeWeapons.length > 0 + if (kind === 'aew') return spec.datalink_range_km !== undefined + return true + }) + .map((squadron) => ({ host, squadron }))), [hosts, kind]) + + const squadronValue = eligible.some((e) => `${e.host.id}|${e.squadron.id}` === squadronSel) ? squadronSel : '' + const selected = squadronValue === '' + ? undefined + : eligible.find((e) => `${e.host.id}|${e.squadron.id}` === squadronValue) + + const strikeTargets = useMemo(() => units.filter((u) => + u.nation !== playerNation && u.status !== 'destroyed' + && (u.visibility === 'tracked' || u.visibility === 'identified' || u.category === 'airbase' || u.category === 'naval_base')), + [units, playerNation]) + + const targetValue = strikeTargets.some((t) => t.id === targetSel) ? targetSel : '' + + const station: Position | null = (() => { + if (stationSel === 'strait') return STRAIT_STATION + if (stationSel.startsWith('unit|')) { + const u = units.find((x) => x.id === stationSel.slice(5)) + return u ? { lat: u.position.lat, lng: u.position.lng } : null + } + const lat = Number(customLat) + const lng = Number(customLng) + return customLat.trim() !== '' && customLng.trim() !== '' && Number.isFinite(lat) && Number.isFinite(lng) + ? { lat, lng } + : null + })() + + const isUsa = playerNation === 'usa' + const canLaunch = selected !== undefined + && selected.squadron.available >= flightSize + && (kind === 'strike' ? targetValue !== '' : station !== null) + + const handleLaunch = () => { + if (!selected) return + const base = { + type: 'LAUNCH_AIR_MISSION' as const, + kind, + squadronId: selected.squadron.id, + fromUnitId: selected.host.id, + flightSize, + ...(isUsa && sead ? { escortSead: true } : {}), + ...(isUsa && extRange ? { extendedRange: true } : {}), + } + if (kind === 'strike') { + if (targetValue === '') return + sendCommand({ ...base, targetId: targetValue }) + } else { + if (!station) return + sendCommand({ ...base, station }) + } + } + + return ( + + {/* Squadron pools per host */} +
SQUADRONS
+ {hosts.length === 0 &&
NO AIR WINGS IN THEATER
} + {hosts.map((host) => ( +
+
+ {host.name} +
+ {(host.airWing ?? []).map((sq) => ( + + ))} +
+ ))} + + {/* Live mission board */} +
ACTIVE MISSIONS
+ {visibleMissions.length === 0 &&
NO ACTIVE MISSIONS
} + {visibleMissions.map((m) => ( + + ))} + + {/* Composer */} +
MISSION COMPOSER
+
+
+ {KINDS.map((k) => ( + + ))} +
+ +
+
SQUADRON
+ +
+ +
+ FLIGHT SIZE + {[2, 3, 4].map((n) => ( + + ))} +
+ + {kind === 'strike' ? ( +
+
TARGET
+ + {strikeTargets.length === 0 &&
NO TRACKED CONTACTS OR FIXED SITES
} +
+ ) : ( +
+
STATION
+ + {stationSel === 'custom' && ( +
+ LAT + setCustomLat(e.target.value)} + style={INPUT} + /> + LNG + setCustomLng(e.target.value)} + style={INPUT} + /> +
+ )} +
+ )} + + {isUsa && ( +
+ + +
+ )} + + + {selected !== undefined && selected.squadron.available < flightSize && ( +
INSUFFICIENT READY AIRFRAMES
+ )} +
+ + {/* Surge lever */} +
SURGE OPS
+
+ + 96h halved ready times, then ×1.5 sustained +
+
+ ) +} + +function SquadronRow({ squadron, tick }: { squadron: SquadronState; tick: number }) { + const spec = AIRFRAMES[squadron.airframe] + const future = squadron.readyAt.filter((t) => t > tick) + const nextIn = future.length > 0 ? Math.min(...future) - tick : null + return ( +
+
+
+ {squadron.name} +
+
+ {spec?.name ?? squadron.airframe} +
+
+
+
+ {squadron.available}/{squadron.total} +
+ {nextIn !== null && ( +
+ NEXT +{fmtTicks(nextIn)} +
+ )} +
+
+ ) +} + +function MissionRow({ + mission, + tick, + squadronIndex, +}: { + mission: AirMission + tick: number + squadronIndex: Map +}) { + const entry = squadronIndex.get(mission.squadronId) + const kindMeta = KINDS.find((k) => k.id === mission.kind) + const cancellable = mission.status === 'planning' || mission.status === 'active' + const statusText = mission.status === 'planning' && mission.planningCompleteTick !== undefined + ? `PLANNING T-${fmtTicks(mission.planningCompleteTick - tick)}` + : mission.status.toUpperCase() + const statusColor = mission.status === 'active' + ? 'var(--status-ready)' + : mission.status === 'planning' ? 'var(--status-engaged)' : 'var(--text-muted)' + + return ( +
+ + {mission.kind.toUpperCase()} + +
+
+ {mission.flightSize}× {airframeShortName(entry?.squadron)} +
+
+ {entry?.squadron.name ?? mission.squadronId} +
+
+ + {statusText} + + {cancellable && ( + + )} +
+ ) +} diff --git a/src/components/panels/__tests__/AirOpsPanel.test.tsx b/src/components/panels/__tests__/AirOpsPanel.test.tsx new file mode 100644 index 0000000..aca8b5c --- /dev/null +++ b/src/components/panels/__tests__/AirOpsPanel.test.tsx @@ -0,0 +1,262 @@ +import { describe, it, expect, beforeEach, vi } from 'vitest' +import { render, screen, fireEvent } from '@testing-library/react' +import AirOpsPanel from '../AirOpsPanel' +import { useGameStore } from '@/store/game-store' +import { sendCommand } from '@/store/bridge' +import type { AirMission } from '@/types/game' +import type { GameViewState, ViewUnit } from '@/types/view' + +vi.mock('@/store/bridge', () => ({ + sendCommand: vi.fn(), + getFullState: vi.fn(), + loadState: vi.fn(), +})) + +const TICK = 7200 + +function makeUnit(id: string, nation: string, overrides: Partial = {}): ViewUnit { + return { + id, + name: id, + nation, + category: 'ship', + position: { lat: 26.2, lng: 56.3 }, + heading: 0, + speed_kts: 0, + status: 'ready', + health: 100, + maxHealth: 100, + logistics: 100, + supplyStocks: [], + weapons: [], + pointDefense: [], + sensors: [], + roe: 'weapons_free', + waypoints: [], + subordinateIds: [], + visibility: 'identified', + stale: false, + ...overrides, + } as ViewUnit +} + +function makeCarrier(): ViewUnit { + return makeUnit('cvn72', 'usa', { + name: 'CVN-72 Abraham Lincoln', + category: 'carrier_group', + airWing: [ + { id: 'vfa14', name: 'VFA-14 Tophatters', airframe: 'fa18e', total: 12, available: 8, readyAt: [TICK + 5400] }, + { id: 'vaw116', name: 'VAW-116 Sun Kings', airframe: 'e2d', total: 5, available: 3, readyAt: [] }, + ], + }) +} + +function setStore(over: Partial = {}) { + const viewState: GameViewState = { + playerNation: 'usa', + initialized: true, + time: { tick: TICK, timestamp: 0, speed: 0, tickIntervalMs: 100 }, + nations: [], + units: [makeCarrier()], + missiles: [], + supplyLines: [], + shippingLanes: [], + events: [], + pendingEventCount: 0, + satelliteDetectedUnitIds: [], + warSupport: {}, + gameOver: null, + objectives: [], + intel: { assets: [], agents: [], products: [], taskings: [], leakLevel: 0, paranoiaBand: 'LOW', encryptionUpgradedUntilTick: null }, + airMissions: [], + surgeOps: false, + ...over, + } + useGameStore.setState({ viewState, eventLog: [] }) +} + +function capMission(over: Partial = {}): AirMission { + return { + id: 'am_1_10', + kind: 'cap', + nation: 'usa', + squadronId: 'vfa14', + fromUnitId: 'cvn72', + flightSize: 2, + station: { lat: 26.6, lng: 56.5 }, + status: 'active', + createdTick: 10, + ...over, + } +} + +beforeEach(() => { + vi.mocked(sendCommand).mockClear() + setStore() +}) + +describe('AirOpsPanel squadron board', () => { + it('renders squadron rows with airframe label, pool and next-ready countdown', () => { + render() + expect(screen.getByText('CVN-72 Abraham Lincoln')).toBeTruthy() + expect(screen.getByText('VFA-14 Tophatters')).toBeTruthy() + expect(screen.getByText('F/A-18E Super Hornet')).toBeTruthy() + expect(screen.getByText('8/12')).toBeTruthy() + // 5400 ticks = 1h 30m until the next turnaround bird rejoins + expect(screen.getByText('NEXT +1h 30m')).toBeTruthy() + expect(screen.getByText('NO ACTIVE MISSIONS')).toBeTruthy() + }) + + it('shows the empty state without air wings', () => { + setStore({ units: [] }) + render() + expect(screen.getByText('NO AIR WINGS IN THEATER')).toBeTruthy() + }) +}) + +describe('AirOpsPanel mission composer', () => { + it('launches a CAP at the strait preset with the composed payload', () => { + render() + fireEvent.change(screen.getByLabelText('Squadron'), { target: { value: 'cvn72|vfa14' } }) + fireEvent.click(screen.getByLabelText('Flight size 3')) + fireEvent.click(screen.getByText('LAUNCH MISSION')) + expect(sendCommand).toHaveBeenCalledWith({ + type: 'LAUNCH_AIR_MISSION', + kind: 'cap', + squadronId: 'vfa14', + fromUnitId: 'cvn72', + flightSize: 3, + station: { lat: 26.6, lng: 56.5 }, + }) + }) + + it('filters squadrons by mission kind (strike needs strike weapons, AEW needs datalink)', () => { + render() + fireEvent.click(screen.getByRole('button', { name: 'STRIKE' })) + expect(screen.queryByRole('option', { name: /VAW-116/ })).toBeNull() + expect(screen.getByRole('option', { name: /VFA-14/ })).toBeTruthy() + + fireEvent.click(screen.getByRole('button', { name: 'AEW' })) + expect(screen.queryByRole('option', { name: /VFA-14/ })).toBeNull() + expect(screen.getByRole('option', { name: /VAW-116/ })).toBeTruthy() + }) + + it('offers only tracked/identified contacts or fixed enemy bases as strike targets and launches with SEAD', () => { + setStore({ + units: [ + makeCarrier(), + makeUnit('iran_ddg', 'iran', { name: 'Jamaran', visibility: 'tracked' }), + makeUnit('bushehr_ab', 'iran', { name: 'Bushehr AB', category: 'airbase', visibility: 'detected' }), + makeUnit('iran_tel', 'iran', { name: 'TEL Group', category: 'missile_battery', visibility: 'detected' }), + ], + }) + render() + fireEvent.click(screen.getByRole('button', { name: 'STRIKE' })) + + expect(screen.getByRole('option', { name: /Jamaran/ })).toBeTruthy() + expect(screen.getByRole('option', { name: /Bushehr AB/ })).toBeTruthy() + expect(screen.queryByRole('option', { name: /TEL Group/ })).toBeNull() + + fireEvent.change(screen.getByLabelText('Squadron'), { target: { value: 'cvn72|vfa14' } }) + fireEvent.change(screen.getByLabelText('Target'), { target: { value: 'iran_ddg' } }) + fireEvent.click(screen.getByLabelText('SEAD escort')) + fireEvent.click(screen.getByText('LAUNCH MISSION')) + expect(sendCommand).toHaveBeenCalledWith({ + type: 'LAUNCH_AIR_MISSION', + kind: 'strike', + squadronId: 'vfa14', + fromUnitId: 'cvn72', + flightSize: 2, + targetId: 'iran_ddg', + escortSead: true, + }) + }) + + it('launches a CAP at custom coordinates', () => { + render() + fireEvent.change(screen.getByLabelText('Squadron'), { target: { value: 'cvn72|vfa14' } }) + fireEvent.change(screen.getByLabelText('Station'), { target: { value: 'custom' } }) + fireEvent.change(screen.getByLabelText('Station latitude'), { target: { value: '27.1' } }) + fireEvent.change(screen.getByLabelText('Station longitude'), { target: { value: '55.9' } }) + fireEvent.click(screen.getByText('LAUNCH MISSION')) + expect(sendCommand).toHaveBeenCalledWith({ + type: 'LAUNCH_AIR_MISSION', + kind: 'cap', + squadronId: 'vfa14', + fromUnitId: 'cvn72', + flightSize: 2, + station: { lat: 27.1, lng: 55.9 }, + }) + }) + + it('disables LAUNCH when the squadron lacks ready airframes for the flight size', () => { + render() + fireEvent.click(screen.getByRole('button', { name: 'AEW' })) + fireEvent.change(screen.getByLabelText('Squadron'), { target: { value: 'cvn72|vaw116' } }) + fireEvent.click(screen.getByLabelText('Flight size 4')) + + const launch = screen.getByText('LAUNCH MISSION') as HTMLButtonElement + expect(launch.disabled).toBe(true) + expect(screen.getByText('INSUFFICIENT READY AIRFRAMES')).toBeTruthy() + fireEvent.click(launch) + expect(sendCommand).not.toHaveBeenCalled() + }) + + it('hides the USA-only SEAD/extended-range options for other nations', () => { + setStore({ playerNation: 'iran', units: [] }) + render() + expect(screen.queryByLabelText('SEAD escort')).toBeNull() + expect(screen.queryByLabelText('Extended range')).toBeNull() + }) +}) + +describe('AirOpsPanel mission board', () => { + it('lists an active mission and cancels it', () => { + setStore({ airMissions: [capMission()] }) + render() + expect(screen.getByText('2× F/A-18E')).toBeTruthy() + // Squadron board + mission row both name the squadron + expect(screen.getAllByText('VFA-14 Tophatters').length).toBe(2) + expect(screen.getByText('ACTIVE')).toBeTruthy() + + fireEvent.click(screen.getByText('CANCEL')) + expect(sendCommand).toHaveBeenCalledWith({ type: 'CANCEL_AIR_MISSION', missionId: 'am_1_10' }) + }) + + it('shows a planning countdown to the launch window', () => { + setStore({ + airMissions: [capMission({ + id: 'am_2_10', + kind: 'strike', + station: undefined, + targetId: 'iran_ddg', + status: 'planning', + planningCompleteTick: TICK + 3600, + })], + }) + render() + expect(screen.getByText('PLANNING T-1h 00m')).toBeTruthy() + }) + + it('hides completed missions', () => { + setStore({ airMissions: [capMission({ status: 'complete' })] }) + render() + expect(screen.getByText('NO ACTIVE MISSIONS')).toBeTruthy() + }) +}) + +describe('AirOpsPanel surge ops', () => { + it('dispatches SET_SURGE_OPS with the consequence copy visible', () => { + render() + expect(screen.getByText('96h halved ready times, then ×1.5 sustained')).toBeTruthy() + fireEvent.click(screen.getByText('SURGE OPS: OFF')) + expect(sendCommand).toHaveBeenCalledWith({ type: 'SET_SURGE_OPS', enabled: true }) + }) + + it('reflects an active surge and toggles it off', () => { + setStore({ surgeOps: true }) + render() + fireEvent.click(screen.getByText('SURGE OPS: ON')) + expect(sendCommand).toHaveBeenCalledWith({ type: 'SET_SURGE_OPS', enabled: false }) + }) +}) diff --git a/src/engine/game-engine.ts b/src/engine/game-engine.ts index 4fcfbd4..0b0617d 100644 --- a/src/engine/game-engine.ts +++ b/src/engine/game-engine.ts @@ -30,6 +30,7 @@ import { processVisibility, resetVisibilityState, seedInitialVisibility, getView import { processWarSupport, resetWarSupportState, offerCeasefire, acceptCeasefire, resign, getWarSupport, getObjectives } from './systems/war-support' import { processIntel, initIntelState, resetIntelState, taskSatellitePass, taskAgent, restAgent, exfiltrateAgent, opsecSweep, maybeLeakStrike, paranoiaBand } from './systems/intel' import { processAirOps, initAirWings, resetAirOpsState, setAirMissionCounter, launchAirMission, cancelAirMission, setSurgeOps, surgeActive, getAirMissionsView } from './systems/air-ops' +import { processAirBda } from './systems/air-bda' import type { IntelViewState } from '@/types/view' const TICK_MS = 1_000 // 1 tick = 1 game second (real-time at 1x) @@ -171,6 +172,9 @@ export class GameEngine { processCombat(state, this.rng, this.elevationGrid, this.sensorNetwork) processPointDefense(state, this.rng) processShipping(state, this.rng) + // After shipping, not just combat — mine hits on a carrier must wipe its deck too. + // Exactly once per tick: it rescans this tick's events and would double-apply. + processAirBda(state) processEconomy(state) processLogistics(state) processRepair(state) diff --git a/src/engine/systems/__tests__/air-bda.test.ts b/src/engine/systems/__tests__/air-bda.test.ts new file mode 100644 index 0000000..0148888 --- /dev/null +++ b/src/engine/systems/__tests__/air-bda.test.ts @@ -0,0 +1,180 @@ +import { describe, it, expect } from 'vitest' +import { processAirBda, parkedAirframes, SHELTER_FACTOR } from '../air-bda' +import type { GameState, NationId, SquadronState, Unit } from '@/types/game' + +function makeSquadron(overrides: Partial = {}): SquadronState { + return { + id: 'vfa14', + name: 'VFA-14 Tophatters', + airframe: 'fa18e', + total: 12, + available: 6, + readyAt: [100, 200], + ...overrides, + } +} + +function makeUnit(overrides: Partial & { id: string; nation: NationId }): Unit { + return { + name: overrides.id, + category: 'airbase', + position: { lat: 27, lng: 52 }, + heading: 0, + speed_kts: 0, + maxSpeed_kts: 0, + health: 100, + maxHealth: 100, + hardness: 200, + logistics: 0, + supplyStocks: [], + weapons: [], + pointDefense: [], + sensors: [], + roe: 'weapons_tight' as const, + status: 'ready' as const, + waypoints: [], + subordinateIds: [], + ...overrides, + } as Unit +} + +function makeState(units: Unit[], tick = 10): GameState { + return { + playerNation: 'usa', + initialized: true, + time: { tick, timestamp: 0, speed: 1, tickIntervalMs: 100 }, + nations: {}, + units: new Map(units.map(u => [u.id, u])), + missiles: new Map(), + supplyLines: new Map(), + shippingLanes: new Map(), + events: [], + pendingEvents: [], + } +} + +function impact(state: GameState, targetId: string, damage: number, tick = state.time.tick): void { + state.events.push({ type: 'MISSILE_IMPACT', missileId: `m${state.events.length}`, targetId, damage, tick }) +} + +function destroyed(state: GameState, unitId: string, tick = state.time.tick): void { + state.events.push({ type: 'UNIT_DESTROYED', unitId, tick }) +} + +describe('processAirBda — ramp damage', () => { + it('destroys floor(damage/100 × parked × shelter) airframes from total and available', () => { + const squadron = makeSquadron() // parked = 6 + 2 = 8, airborne = 4 + const base = makeUnit({ id: 'isfahan_ab', nation: 'iran', airWing: [squadron] }) + const state = makeState([base]) + impact(state, 'isfahan_ab', 50) // floor(0.5 × 8 × 0.5) = 2 + + processAirBda(state) + + expect(squadron.total).toBe(10) + expect(squadron.available).toBe(4) + expect(squadron.readyAt).toHaveLength(2) + // Airborne count untouched: total − parked stays 4 + expect(squadron.total - parkedAirframes(squadron)).toBe(4) + }) + + it('overflows losses from available into the turnaround queue', () => { + const squadron = makeSquadron({ total: 10, available: 1, readyAt: [10, 20, 30, 40, 50] }) // parked 6 + const base = makeUnit({ id: 'isfahan_ab', nation: 'iran', airWing: [squadron] }) + const state = makeState([base]) + impact(state, 'isfahan_ab', 100) // floor(1 × 6 × 0.5) = 3 + + processAirBda(state) + + expect(squadron.total).toBe(7) + expect(squadron.available).toBe(0) + expect(squadron.readyAt).toEqual([10, 20, 30]) + expect(squadron.total - parkedAirframes(squadron)).toBe(4) + }) + + it('rounds small damage down to zero losses', () => { + const squadron = makeSquadron() + const base = makeUnit({ id: 'isfahan_ab', nation: 'iran', airWing: [squadron] }) + const state = makeState([base]) + impact(state, 'isfahan_ab', 10) // floor(0.1 × 8 × 0.5) = 0 + + processAirBda(state) + + expect(squadron.total).toBe(12) + expect(squadron.available).toBe(6) + expect(squadron.readyAt).toHaveLength(2) + }) + + it('never destroys more than the parked count on outsized damage', () => { + const squadron = makeSquadron() // parked 8, airborne 4 + const base = makeUnit({ id: 'isfahan_ab', nation: 'iran', airWing: [squadron] }) + const state = makeState([base]) + impact(state, 'isfahan_ab', 300) // floor(3 × 8 × 0.5) = 12 → clamped to 8 + + processAirBda(state) + + expect(squadron.total).toBe(4) + expect(squadron.available).toBe(0) + expect(squadron.readyAt).toHaveLength(0) + }) + + it('uses the documented shelter factor', () => { + expect(SHELTER_FACTOR).toBe(0.5) + }) +}) + +describe('processAirBda — destroyed host', () => { + it('wipes all parked airframes across every squadron, keeping only airborne on the books', () => { + const sqA = makeSquadron({ id: 'tfb1_su35', total: 8, available: 5, readyAt: [400] }) // parked 6, airborne 2 + const sqB = makeSquadron({ id: 'tfb1_mig29', total: 10, available: 10, readyAt: [] }) // parked 10, airborne 0 + const base = makeUnit({ id: 'mehrabad', nation: 'iran', airWing: [sqA, sqB], status: 'destroyed' }) + const state = makeState([base]) + destroyed(state, 'mehrabad') + + processAirBda(state) + + expect(sqA.total).toBe(2) + expect(sqA.available).toBe(0) + expect(sqA.readyAt).toEqual([]) + expect(sqB.total).toBe(0) + expect(sqB.available).toBe(0) + }) + + it('does not double-count when impact and destruction land on the same tick', () => { + const squadron = makeSquadron() // total 12, parked 8, airborne 4 + const base = makeUnit({ id: 'isfahan_ab', nation: 'iran', airWing: [squadron] }) + const state = makeState([base]) + impact(state, 'isfahan_ab', 80) + destroyed(state, 'isfahan_ab') + + processAirBda(state) + + expect(squadron.total).toBe(4) + expect(squadron.available).toBe(0) + expect(squadron.readyAt).toEqual([]) + }) +}) + +describe('processAirBda — event filtering', () => { + it('ignores events from earlier ticks', () => { + const squadron = makeSquadron() + const base = makeUnit({ id: 'isfahan_ab', nation: 'iran', airWing: [squadron] }) + const state = makeState([base], 10) + impact(state, 'isfahan_ab', 100, 9) + destroyed(state, 'isfahan_ab', 9) + + processAirBda(state) + + expect(squadron.total).toBe(12) + expect(squadron.available).toBe(6) + }) + + it('ignores hits on units without an air wing and on unknown unit ids', () => { + const plain = makeUnit({ id: 'sam_site_1', nation: 'iran', category: 'sam_site' }) + const state = makeState([plain]) + impact(state, 'sam_site_1', 100) + impact(state, 'ghost', 100) + destroyed(state, 'ghost') + + expect(() => processAirBda(state)).not.toThrow() + }) +}) diff --git a/src/engine/systems/__tests__/air-ops.test.ts b/src/engine/systems/__tests__/air-ops.test.ts new file mode 100644 index 0000000..dc3b814 --- /dev/null +++ b/src/engine/systems/__tests__/air-ops.test.ts @@ -0,0 +1,698 @@ +import { describe, it, expect, beforeEach } from 'vitest' +import { + processAirOps, + launchAirMission, + cancelAirMission, + setSurgeOps, + resetAirOpsState, +} from '../air-ops' +import { processMovement } from '../movement' +import { SeededRNG } from '../../utils/rng' +import { GameEngine } from '../../game-engine' +import { haversine, destination } from '../../utils/geo' +import { + CAP_TURNAROUND_TICKS, + STRIKE_TURNAROUND_SURGE_TICKS, + STRIKE_TURNAROUND_SUSTAINED_TICKS, + STRIKE_PLANNING_MIN_TICKS, + STRIKE_PLANNING_MAX_TICKS, +} from '@/data/air/airframes' +import type { GameState, Nation, NationId, Position, SquadronState, Unit, VisibilityLevel } from '@/types/game' + +// ── Helpers ───────────────────────────────────────────────────── + +const CARRIER_POS: Position = { lat: 25, lng: 56 } + +function eco(): Nation['economy'] { + return { gdp_billions: 0, military_budget_billions: 0, military_budget_pct_gdp: 0, oil_revenue_billions: 0, sanctions_impact: 0, war_cost_per_day_millions: 0, reserves_billions: 0 } +} + +function makeUnit(overrides: Partial & { id: string; nation: NationId }): Unit { + return { + name: overrides.id, + category: 'ship', + position: { ...CARRIER_POS }, + heading: 0, + speed_kts: 0, + maxSpeed_kts: 0, + health: 100, + maxHealth: 100, + hardness: 150, + logistics: 0, + supplyStocks: [], + weapons: [], + pointDefense: [], + sensors: [], + roe: 'weapons_free' as const, + status: 'ready' as const, + waypoints: [], + subordinateIds: [], + ...overrides, + } as Unit +} + +function sq(id: string, name: string, airframe: SquadronState['airframe'], total: number, available = total): SquadronState { + return { id, name, airframe, total, available, readyAt: [] } +} + +function carrier(squadrons: SquadronState[]): Unit { + return makeUnit({ id: 'cvn', nation: 'usa', category: 'carrier_group', airWing: squadrons }) +} + +function makeState(units: Unit[], opts: { atWar?: boolean } = {}): GameState { + const atWar = opts.atWar ?? true + return { + playerNation: 'usa', + initialized: true, + time: { tick: 0, timestamp: 1_000_000, speed: 1, tickIntervalMs: 100 }, + nations: { + usa: { id: 'usa', name: 'USA', economy: eco(), relations: {}, atWar: atWar ? ['iran'] : [] }, + iran: { id: 'iran', name: 'Iran', economy: eco(), relations: {}, atWar: atWar ? ['usa'] : [] }, + }, + units: new Map(units.map(u => [u.id, u])), + missiles: new Map(), + supplyLines: new Map(), + shippingLanes: new Map(), + events: [], + pendingEvents: [], + attackCounters: {}, + airMissions: [], + surgeOps: { enabled: false }, + } +} + +/** Mirror the engine tick order for the systems under test: move, then air ops */ +function step(state: GameState, rng: SeededRNG, ticks = 1): void { + for (let i = 0; i < ticks; i++) { + state.time.tick++ + state.time.timestamp += 1000 + processMovement(state, null) + processAirOps(state, rng, null) + } +} + +function seedContact(state: GameState, observer: string, target: Unit, level: VisibilityLevel = 'tracked'): void { + state.visibility ??= {} + const contacts = (state.visibility[observer] ??= {}) + contacts[target.id] = { level, lastSeenTick: state.time.tick, lastKnownPosition: { ...target.position } } +} + +function events(state: GameState, type: string) { + return state.events.filter(e => e.type === type) +} + +function flightOf(state: GameState, idx = 0): Unit { + return state.units.get(state.airMissions![idx].flightUnitId!)! +} + +// ── Tests ─────────────────────────────────────────────────────── + +describe('air-ops launch', () => { + beforeEach(() => resetAirOpsState()) + + it('CAP launch deducts the pool and spawns a correctly shaped Flight unit', () => { + const cvn = carrier([sq('vfa14', 'VFA-14 Tophatters', 'fa18e', 12)]) + const state = makeState([cvn]) + const rng = new SeededRNG(42) + const station = destination(CARRIER_POS, 0, 50) + + launchAirMission(state, rng, { type: 'LAUNCH_AIR_MISSION', kind: 'cap', squadronId: 'vfa14', fromUnitId: 'cvn', flightSize: 2, station }) + expect(state.airMissions).toHaveLength(1) + expect(state.airMissions![0].status).toBe('active') + + step(state, rng, 1) + const mission = state.airMissions![0] + expect(mission.flightUnitId).toBe(`flight_${mission.id}`) + expect(cvn.airWing![0].available).toBe(10) + + const flight = flightOf(state) + expect(flight.name).toBe('2× F/A-18E Super Hornet (VFA-14 Tophatters)') + expect(flight.category).toBe('aircraft') + expect(flight.nation).toBe('usa') + expect(flight.maxSpeed_kts).toBe(480) + expect(flight.roe).toBe('weapons_free') + expect(flight.hardness).toBe(60) + expect(flight.sensors).toHaveLength(1) + expect(flight.weapons).toHaveLength(0) // CAP carries no strike weapons + expect(flight.flightMeta).toMatchObject({ missionId: mission.id, rtbTo: 'cvn', a2aShots: 12 }) + expect(flight.flightMeta!.bingoTick).toBeGreaterThan(state.time.tick) + expect(flight.waypoints[0]).toEqual(station) + expect(events(state, 'AIR_MISSION_LAUNCHED')).toHaveLength(1) + }) + + it('strike planning delay gates the launch until planningCompleteTick', () => { + const cvn = carrier([sq('vfa14', 'VFA-14 Tophatters', 'fa18e', 12)]) + // 920 km out — beyond the 900 km release ring, so the racks stay loaded here + const irBase = makeUnit({ id: 'ir_ab', nation: 'iran', category: 'airbase', position: destination(CARRIER_POS, 90, 920) }) + const state = makeState([cvn, irBase]) + const rng = new SeededRNG(42) + + launchAirMission(state, rng, { type: 'LAUNCH_AIR_MISSION', kind: 'strike', squadronId: 'vfa14', fromUnitId: 'cvn', flightSize: 2, targetId: 'ir_ab' }) + const mission = state.airMissions![0] + expect(mission.status).toBe('planning') + expect(mission.planningCompleteTick).toBeGreaterThanOrEqual(STRIKE_PLANNING_MIN_TICKS) + expect(mission.planningCompleteTick).toBeLessThanOrEqual(STRIKE_PLANNING_MAX_TICKS) + + step(state, rng, 1) + expect(mission.flightUnitId).toBeUndefined() + expect(cvn.airWing![0].available).toBe(12) + + mission.planningCompleteTick = 3 + step(state, rng, 1) // tick 2 — still planning + expect(mission.flightUnitId).toBeUndefined() + step(state, rng, 1) // tick 3 — window opens + expect(mission.status).toBe('active') + expect(mission.flightUnitId).toBeDefined() + expect(flightOf(state).weapons).toEqual([ + { weaponId: 'jassm_er', count: 4, maxCount: 4, reloadTimeSec: 0 }, + ]) + }) + + it('aborts at launch time when the pool was drained after ordering', () => { + const cvn = carrier([sq('vfa14', 'VFA-14 Tophatters', 'fa18e', 12, 2)]) + const state = makeState([cvn]) + const rng = new SeededRNG(42) + launchAirMission(state, rng, { type: 'LAUNCH_AIR_MISSION', kind: 'cap', squadronId: 'vfa14', fromUnitId: 'cvn', flightSize: 2, station: destination(CARRIER_POS, 0, 50) }) + cvn.airWing![0].available = 1 + + step(state, rng, 1) + expect(state.airMissions![0].status).toBe('aborted') + expect(state.airMissions![0].flightUnitId).toBeUndefined() + expect(cvn.airWing![0].available).toBe(1) + }) + + it('extendedRange taxes the first fa18e squadron 2 quick-turn sorties and extends bingo', () => { + const launchStrike = (extendedRange: boolean) => { + resetAirOpsState() + const cvn = carrier([ + sq('vfa14', 'VFA-14 Tophatters', 'fa18e', 12), + sq('vfa97', 'VFA-97 Warhawks', 'f35c', 10), + ]) + const irBase = makeUnit({ id: 'ir_ab', nation: 'iran', category: 'airbase', position: destination(CARRIER_POS, 90, 400) }) + const state = makeState([cvn, irBase]) + const rng = new SeededRNG(42) + launchAirMission(state, rng, { type: 'LAUNCH_AIR_MISSION', kind: 'strike', squadronId: 'vfa97', fromUnitId: 'cvn', flightSize: 2, targetId: 'ir_ab', extendedRange }) + state.airMissions![0].planningCompleteTick = 1 + step(state, rng, 1) + return { state, cvn } + } + + const ext = launchStrike(true) + expect(ext.cvn.airWing![1].available).toBe(8) // the flight itself + expect(ext.cvn.airWing![0].available).toBe(10) // tanker tax + expect(ext.cvn.airWing![0].readyAt).toEqual([1 + CAP_TURNAROUND_TICKS, 1 + CAP_TURNAROUND_TICKS]) + + const base = launchStrike(false) + expect(base.cvn.airWing![0].available).toBe(12) + expect(base.cvn.airWing![0].readyAt).toEqual([]) + expect(flightOf(ext.state).flightMeta!.bingoTick).toBeGreaterThan(flightOf(base.state).flightMeta!.bingoTick) + }) +}) + +describe('air-ops CAP', () => { + beforeEach(() => resetAirOpsState()) + + it('reaches station, emits FLIGHT_ON_STATION exactly once, and keeps an orbit going', () => { + const cvn = carrier([sq('vfa14', 'VFA-14 Tophatters', 'fa18e', 12)]) + const state = makeState([cvn]) + const rng = new SeededRNG(42) + const station = destination(CARRIER_POS, 0, 20) + launchAirMission(state, rng, { type: 'LAUNCH_AIR_MISSION', kind: 'cap', squadronId: 'vfa14', fromUnitId: 'cvn', flightSize: 2, station }) + + step(state, rng, 150) + expect(events(state, 'FLIGHT_ON_STATION')).toHaveLength(1) + const flight = flightOf(state) + expect(flight.waypoints.length).toBeGreaterThan(0) + expect(haversine(flight.position, station)).toBeLessThan(20) + + step(state, rng, 400) + expect(events(state, 'FLIGHT_ON_STATION')).toHaveLength(1) // never re-emitted + expect(haversine(flightOf(state).position, station)).toBeLessThan(20) // still orbiting + }) + + it('intercepts a hostile flight: chase, A2A rolls, kills, FLIGHT_LOST with pilot fate', () => { + const cvn = carrier([sq('vfa97', 'VFA-97 Warhawks', 'f35c', 10)]) + // Naval base host so the Iranian scramble AI (airbase-only) stays out of this test + const irHost = makeUnit({ + id: 'ir_port', nation: 'iran', category: 'naval_base', + position: destination(CARRIER_POS, 0, 67), + airWing: [sq('tfb1_mig29', '11th TFS Fulcrums', 'mig29', 10)], + }) + const state = makeState([cvn, irHost]) + const rng = new SeededRNG(42) + launchAirMission(state, rng, { type: 'LAUNCH_AIR_MISSION', kind: 'cap', squadronId: 'vfa97', fromUnitId: 'cvn', flightSize: 2, station: destination(CARRIER_POS, 0, 30) }) + launchAirMission(state, rng, { type: 'LAUNCH_AIR_MISSION', kind: 'cap', squadronId: 'tfb1_mig29', fromUnitId: 'ir_port', flightSize: 2, station: destination(CARRIER_POS, 0, 40) }) + step(state, rng, 1) + + const usaFlight = flightOf(state, 0) + const iranFlight = flightOf(state, 1) + seedContact(state, 'usa', iranFlight) + seedContact(state, 'iran', usaFlight) + + step(state, rng, 600) + + const intercepts = events(state, 'AIR_INTERCEPT') + expect(intercepts.length).toBeGreaterThan(0) + // Both sides rolled (defender shoots back) + expect(intercepts.some(e => e.type === 'AIR_INTERCEPT' && e.attackerName.includes('F-35C'))).toBe(true) + expect(intercepts.some(e => e.type === 'AIR_INTERCEPT' && e.attackerName.includes('MiG-29'))).toBe(true) + + const losses = events(state, 'FLIGHT_LOST') + expect(losses.length).toBeGreaterThanOrEqual(1) + const loss = losses[0] + if (loss.type !== 'FLIGHT_LOST') throw new Error('unreachable') + expect(loss.airframesLost).toBe(2) + expect(['kia', 'rescued', 'pow']).toContain(loss.pilotFate) + + const lostMission = state.airMissions!.find(m => m.id === loss.missionId)! + expect(lostMission.status).toBe('complete') + const victim = state.units.get(lostMission.flightUnitId!)! + expect(victim.status).toBe('destroyed') + expect(victim.flightMeta!.a2aShots).toBeLessThan(8) + expect(events(state, 'UNIT_DESTROYED').some(e => e.type === 'UNIT_DESTROYED' && e.unitId === victim.id)).toBe(true) + // Airframes never return: squadron loses them off the books + const loserHost = victim.nation === 'usa' ? cvn : irHost + expect(loserHost.airWing![0].total).toBe(8) + expect(loserHost.airWing![0].readyAt).toEqual([]) + }) + + it('never auto-engages non-aircraft units', () => { + const cvn = carrier([sq('vfa14', 'VFA-14 Tophatters', 'fa18e', 12)]) + const station = destination(CARRIER_POS, 0, 20) + const irShip = makeUnit({ id: 'ir_ship', nation: 'iran', category: 'ship', position: destination(station, 0, 10) }) + const state = makeState([cvn, irShip]) + const rng = new SeededRNG(42) + launchAirMission(state, rng, { type: 'LAUNCH_AIR_MISSION', kind: 'cap', squadronId: 'vfa14', fromUnitId: 'cvn', flightSize: 2, station }) + step(state, rng, 1) + seedContact(state, 'usa', irShip) + + step(state, rng, 300) + expect(events(state, 'AIR_INTERCEPT')).toHaveLength(0) + expect(irShip.health).toBe(100) + expect(flightOf(state).flightMeta!.a2aShots).toBe(12) + }) + + it('forces RTB at bingo', () => { + const cvn = carrier([sq('vfa14', 'VFA-14 Tophatters', 'fa18e', 12)]) + const state = makeState([cvn]) + const rng = new SeededRNG(42) + launchAirMission(state, rng, { type: 'LAUNCH_AIR_MISSION', kind: 'cap', squadronId: 'vfa14', fromUnitId: 'cvn', flightSize: 2, station: destination(CARRIER_POS, 0, 50) }) + step(state, rng, 1) + flightOf(state).flightMeta!.bingoTick = state.time.tick + 30 + + step(state, rng, 45) + const rtb = events(state, 'FLIGHT_RTB') + expect(rtb).toHaveLength(1) + expect(rtb[0].type === 'FLIGHT_RTB' && rtb[0].reason).toBe('bingo fuel') + }) +}) + +describe('air-ops strike', () => { + beforeEach(() => resetAirOpsState()) + + it('transits, releases the full magazine inside release range, then RTBs', () => { + const cvn = carrier([sq('vfa14', 'VFA-14 Tophatters', 'fa18e', 12)]) + const irBase = makeUnit({ id: 'ir_ab', nation: 'iran', category: 'airbase', position: destination(CARRIER_POS, 90, 920) }) + const state = makeState([cvn, irBase]) + const rng = new SeededRNG(42) + launchAirMission(state, rng, { type: 'LAUNCH_AIR_MISSION', kind: 'strike', squadronId: 'vfa14', fromUnitId: 'cvn', flightSize: 2, targetId: 'ir_ab' }) + state.airMissions![0].planningCompleteTick = 1 + step(state, rng, 1) + expect(state.missiles.size).toBe(0) // 920 km out — beyond the 900 km release ring + + // Release falls ~tick 82 (20 km closure); stop before the return leg completes + step(state, rng, 100) + expect(state.missiles.size).toBe(4) // 2 jassm_er per airframe × 2 + expect(events(state, 'MISSILE_LAUNCHED')).toHaveLength(4) + const flight = flightOf(state) + expect(flight).toBeDefined() + expect(flight.weapons[0].count).toBe(0) + const rtb = events(state, 'FLIGHT_RTB') + expect(rtb).toHaveLength(1) + expect(rtb[0].type === 'FLIGHT_RTB' && rtb[0].reason).toBe('weapons released') + expect(haversine(flight.waypoints[0], CARRIER_POS)).toBeLessThan(1) + for (const m of state.missiles.values()) { + expect(m.launcherId).toBe(flight.id) + expect(m.targetId).toBe('ir_ab') + } + }) + + it('a pre-war air strike puts both nations at war', () => { + const cvn = carrier([sq('vfa14', 'VFA-14 Tophatters', 'fa18e', 12)]) + const irBase = makeUnit({ id: 'ir_ab', nation: 'iran', category: 'airbase', position: destination(CARRIER_POS, 90, 100) }) + const state = makeState([cvn, irBase], { atWar: false }) + const rng = new SeededRNG(42) + launchAirMission(state, rng, { type: 'LAUNCH_AIR_MISSION', kind: 'strike', squadronId: 'vfa14', fromUnitId: 'cvn', flightSize: 2, targetId: 'ir_ab' }) + state.airMissions![0].planningCompleteTick = 1 + + step(state, rng, 2) // launch + immediate release (already in range) + expect(state.missiles.size).toBe(4) + expect(state.nations.usa.atWar).toContain('iran') + expect(state.nations.iran.atWar).toContain('usa') + expect(events(state, 'WAR_DECLARED')).toHaveLength(1) + }) + + it('RTBs "target down" when the target dies before release', () => { + const cvn = carrier([sq('vfa14', 'VFA-14 Tophatters', 'fa18e', 12)]) + const irBase = makeUnit({ id: 'ir_ab', nation: 'iran', category: 'airbase', position: destination(CARRIER_POS, 90, 920) }) + const state = makeState([cvn, irBase]) + const rng = new SeededRNG(42) + launchAirMission(state, rng, { type: 'LAUNCH_AIR_MISSION', kind: 'strike', squadronId: 'vfa14', fromUnitId: 'cvn', flightSize: 2, targetId: 'ir_ab' }) + state.airMissions![0].planningCompleteTick = 1 + step(state, rng, 5) + + irBase.status = 'destroyed' + step(state, rng, 2) + const rtb = events(state, 'FLIGHT_RTB') + expect(rtb).toHaveLength(1) + expect(rtb[0].type === 'FLIGHT_RTB' && rtb[0].reason).toBe('target down') + expect(state.missiles.size).toBe(0) + }) + + it('SEAD escort reveals emitting SAMs within 150 km as detected contacts', () => { + const runStrike = (escortSead: boolean) => { + resetAirOpsState() + const cvn = carrier([sq('vfa14', 'VFA-14 Tophatters', 'fa18e', 12)]) + // Far target keeps the flight airborne through the first minute boundary + const irBase = makeUnit({ id: 'ir_ab', nation: 'iran', category: 'airbase', position: destination(CARRIER_POS, 90, 920) }) + const sam = makeUnit({ + id: 'sam1', nation: 'iran', category: 'sam_site', + position: destination(CARRIER_POS, 90, 100), + sensors: [{ type: 'radar', range_km: 120, detection_prob: 0.9 }], + }) + const state = makeState([cvn, irBase, sam]) + const rng = new SeededRNG(42) + launchAirMission(state, rng, { type: 'LAUNCH_AIR_MISSION', kind: 'strike', squadronId: 'vfa14', fromUnitId: 'cvn', flightSize: 2, targetId: 'ir_ab', escortSead }) + state.airMissions![0].planningCompleteTick = 1 + step(state, rng, 60) // through the first game-minute boundary + return state + } + + expect(runStrike(true).visibility?.usa?.sam1?.level).toBe('detected') + expect(runStrike(false).visibility?.usa?.sam1).toBeUndefined() + }) +}) + +describe('air-ops RTB + recovery', () => { + beforeEach(() => resetAirOpsState()) + + it('cancel aborts to RTB; recovery restores the pool on the CAP quick-turn clock', () => { + const cvn = carrier([sq('vfa14', 'VFA-14 Tophatters', 'fa18e', 12)]) + const state = makeState([cvn]) + const rng = new SeededRNG(42) + launchAirMission(state, rng, { type: 'LAUNCH_AIR_MISSION', kind: 'cap', squadronId: 'vfa14', fromUnitId: 'cvn', flightSize: 2, station: destination(CARRIER_POS, 0, 20) }) + step(state, rng, 100) // on station + const mission = state.airMissions![0] + const flightId = mission.flightUnitId! + + cancelAirMission(state, mission.id) + step(state, rng, 1) + const rtb = events(state, 'FLIGHT_RTB') + expect(rtb).toHaveLength(1) + expect(rtb[0].type === 'FLIGHT_RTB' && rtb[0].reason).toBe('mission aborted') + + let recoveredTick = -1 + for (let i = 0; i < 400 && recoveredTick < 0; i++) { + step(state, rng, 1) + if (!state.units.has(flightId)) recoveredTick = state.time.tick + } + expect(recoveredTick).toBeGreaterThan(0) + expect(mission.status).toBe('complete') + const squadron = cvn.airWing![0] + expect(squadron.available).toBe(10) // not back yet + expect(squadron.readyAt).toEqual([recoveredTick + CAP_TURNAROUND_TICKS, recoveredTick + CAP_TURNAROUND_TICKS]) + + // Ready clock pops them back on a game-minute boundary + const due = squadron.readyAt[0] + state.time.tick = Math.ceil(due / 60) * 60 - 1 + step(state, rng, 1) + expect(squadron.available).toBe(12) + expect(squadron.readyAt).toEqual([]) + }) + + it('strike turnaround honors SURGE OPS vs sustained', () => { + const runStrike = (surge: boolean) => { + resetAirOpsState() + const cvn = carrier([sq('vfa14', 'VFA-14 Tophatters', 'fa18e', 12)]) + const irBase = makeUnit({ id: 'ir_ab', nation: 'iran', category: 'airbase', position: destination(CARRIER_POS, 90, 100) }) + const state = makeState([cvn, irBase]) + const rng = new SeededRNG(42) + if (surge) setSurgeOps(state, true) + launchAirMission(state, rng, { type: 'LAUNCH_AIR_MISSION', kind: 'strike', squadronId: 'vfa14', fromUnitId: 'cvn', flightSize: 2, targetId: 'ir_ab' }) + state.airMissions![0].planningCompleteTick = 1 + const flightId = `flight_${state.airMissions![0].id}` + let recoveredTick = -1 + for (let i = 0; i < 50 && recoveredTick < 0; i++) { + step(state, rng, 1) + if (state.airMissions![0].flightUnitId && !state.units.has(flightId)) recoveredTick = state.time.tick + } + return { state, cvn, recoveredTick } + } + + const surge = runStrike(true) + expect(surge.recoveredTick).toBeGreaterThan(0) + expect(surge.cvn.airWing![0].readyAt).toEqual([ + surge.recoveredTick + STRIKE_TURNAROUND_SURGE_TICKS, + surge.recoveredTick + STRIKE_TURNAROUND_SURGE_TICKS, + ]) + + const sustained = runStrike(false) + expect(sustained.cvn.airWing![0].readyAt).toEqual([ + sustained.recoveredTick + STRIKE_TURNAROUND_SUSTAINED_TICKS, + sustained.recoveredTick + STRIKE_TURNAROUND_SUSTAINED_TICKS, + ]) + }) + + it('diverts to the nearest friendly field with an air wing when the host dies', () => { + const cvn = carrier([sq('vfa14', 'VFA-14 Tophatters', 'fa18e', 12)]) + const diego = makeUnit({ + id: 'diego', nation: 'usa', category: 'airbase', + position: destination(CARRIER_POS, 180, 80), + airWing: [sq('det1', 'Detachment', 'fa18e', 4)], + }) + const state = makeState([cvn, diego]) + const rng = new SeededRNG(42) + launchAirMission(state, rng, { type: 'LAUNCH_AIR_MISSION', kind: 'cap', squadronId: 'vfa14', fromUnitId: 'cvn', flightSize: 2, station: destination(CARRIER_POS, 0, 20) }) + step(state, rng, 100) + const mission = state.airMissions![0] + const flight = flightOf(state) + + cvn.status = 'destroyed' + cancelAirMission(state, mission.id) + step(state, rng, 1) + expect(flight.flightMeta!.rtbTo).toBe('diego') + + for (let i = 0; i < 600 && state.units.has(flight.id); i++) step(state, rng, 1) + expect(state.units.has(flight.id)).toBe(false) + expect(mission.status).toBe('complete') + expect(cvn.airWing![0].readyAt).toHaveLength(2) // squadron bookkeeping still applies + expect(events(state, 'FLIGHT_LOST')).toHaveLength(0) + }) + + it('ditches with fate "rescued" when no divert field exists', () => { + const cvn = carrier([sq('vfa14', 'VFA-14 Tophatters', 'fa18e', 12)]) + const state = makeState([cvn]) + const rng = new SeededRNG(42) + launchAirMission(state, rng, { type: 'LAUNCH_AIR_MISSION', kind: 'cap', squadronId: 'vfa14', fromUnitId: 'cvn', flightSize: 2, station: destination(CARRIER_POS, 0, 20) }) + step(state, rng, 30) + const mission = state.airMissions![0] + const flightId = mission.flightUnitId! + + cvn.status = 'destroyed' + cancelAirMission(state, mission.id) + step(state, rng, 1) + + const losses = events(state, 'FLIGHT_LOST') + expect(losses).toHaveLength(1) + expect(losses[0].type === 'FLIGHT_LOST' && losses[0].pilotFate).toBe('rescued') + expect(losses[0].type === 'FLIGHT_LOST' && losses[0].airframesLost).toBe(2) + expect(state.units.has(flightId)).toBe(false) + expect(mission.status).toBe('complete') + }) + + it('a flight destroyed by outside combat is reported lost and never returns airframes', () => { + const cvn = carrier([sq('vfa14', 'VFA-14 Tophatters', 'fa18e', 12)]) + const state = makeState([cvn]) + const rng = new SeededRNG(42) + launchAirMission(state, rng, { type: 'LAUNCH_AIR_MISSION', kind: 'cap', squadronId: 'vfa14', fromUnitId: 'cvn', flightSize: 2, station: destination(CARRIER_POS, 0, 50) }) + step(state, rng, 5) + const mission = state.airMissions![0] + const flight = flightOf(state) + + flight.health = 0 + flight.status = 'destroyed' + step(state, rng, 1) + + const losses = events(state, 'FLIGHT_LOST') + expect(losses).toHaveLength(1) + expect(losses[0].type === 'FLIGHT_LOST' && losses[0].missionId).toBe(mission.id) + expect(losses[0].type === 'FLIGHT_LOST' && losses[0].airframesLost).toBe(2) + expect(mission.status).toBe('complete') + const squadron = cvn.airWing![0] + expect(squadron.total).toBe(10) + expect(squadron.available).toBe(10) + expect(squadron.readyAt).toEqual([]) + }) + + it('ready clock pops due airframes each game-minute, capped at total', () => { + const squadron = sq('vfa14', 'VFA-14 Tophatters', 'fa18e', 10, 9) + squadron.readyAt = [30, 30, 90] + const cvn = carrier([squadron]) + const state = makeState([cvn]) + const rng = new SeededRNG(42) + + step(state, rng, 60) + expect(squadron.available).toBe(10) // 9 + 2 due, capped at total 10 + expect(squadron.readyAt).toEqual([90]) + + step(state, rng, 60) + expect(squadron.available).toBe(10) + expect(squadron.readyAt).toEqual([]) + }) +}) + +describe('air-ops AEW', () => { + beforeEach(() => resetAirOpsState()) + + it('orbits station with datalink up and never RTBs for winchester', () => { + const cvn = carrier([sq('vaw116', 'VAW-116 Sun Kings', 'e2d', 5)]) + const state = makeState([cvn]) + const rng = new SeededRNG(42) + const station = destination(CARRIER_POS, 0, 20) + launchAirMission(state, rng, { type: 'LAUNCH_AIR_MISSION', kind: 'aew', squadronId: 'vaw116', fromUnitId: 'cvn', flightSize: 2, station }) + step(state, rng, 1) + + const flight = flightOf(state) + expect(flight.datalink_range_km).toBe(600) + expect(flight.weapons).toHaveLength(0) + expect(flight.flightMeta!.a2aShots).toBe(0) + + step(state, rng, 250) + expect(events(state, 'FLIGHT_ON_STATION')).toHaveLength(1) + expect(events(state, 'FLIGHT_RTB')).toHaveLength(0) + expect(state.units.has(flight.id)).toBe(true) + }) +}) + +describe('air-ops Iranian scramble', () => { + beforeEach(() => resetAirOpsState()) + + it('scrambles a 2-ship CAP at the midpoint against a detected enemy flight, max 2 live', () => { + const base = makeUnit({ + id: 'tabriz_ab', nation: 'iran', category: 'airbase', + position: { lat: 32, lng: 50 }, + airWing: [sq('tfb2_mig29', '23rd TFS Fulcrums', 'mig29', 8)], + }) + const intruders = [0, 1, 2].map(i => + makeUnit({ id: `us_jet_${i}`, nation: 'usa', category: 'aircraft', position: destination({ lat: 32, lng: 50 }, 90, 150 + i * 10) })) + const state = makeState([base, ...intruders]) + const rng = new SeededRNG(42) + for (const u of intruders) seedContact(state, 'iran', u, 'detected') + + step(state, rng, 59) + expect(state.airMissions).toHaveLength(0) // scans only on minute boundaries + + step(state, rng, 1) // tick 60 + const caps = state.airMissions!.filter(m => m.nation === 'iran' && m.kind === 'cap') + expect(caps).toHaveLength(2) // 3 threats, capped at 2 live CAPs + const cap = caps[0] + expect(cap.fromUnitId).toBe('tabriz_ab') + expect(cap.flightSize).toBe(2) + const expected = { lat: (32 + intruders[0].position.lat) / 2, lng: (50 + intruders[0].position.lng) / 2 } + expect(cap.station!.lat).toBeCloseTo(expected.lat, 5) + expect(cap.station!.lng).toBeCloseTo(expected.lng, 5) + + step(state, rng, 1) + expect(state.units.has(`flight_${cap.id}`)).toBe(true) + }) + + it('ignores threats beyond 250 km and stands down at peace', () => { + const base = makeUnit({ + id: 'tabriz_ab', nation: 'iran', category: 'airbase', + position: { lat: 32, lng: 50 }, + airWing: [sq('tfb2_mig29', '23rd TFS Fulcrums', 'mig29', 8)], + }) + const farJet = makeUnit({ id: 'us_far', nation: 'usa', category: 'aircraft', position: destination({ lat: 32, lng: 50 }, 90, 300) }) + const state = makeState([base, farJet]) + const rng = new SeededRNG(42) + seedContact(state, 'iran', farJet, 'tracked') + step(state, rng, 60) + expect(state.airMissions).toHaveLength(0) + + // Same geometry in range but at peace: still nothing + const nearJet = makeUnit({ id: 'us_near', nation: 'usa', category: 'aircraft', position: destination({ lat: 32, lng: 50 }, 90, 100) }) + const peace = makeState([base, nearJet], { atWar: false }) + resetAirOpsState() + seedContact(peace, 'iran', nearJet, 'tracked') + step(peace, new SeededRNG(42), 60) + expect(peace.airMissions).toHaveLength(0) + }) + + it('Su-35s only scramble for threats within 250 km of Mehrabad', () => { + const runScramble = (baseId: string) => { + resetAirOpsState() + const base = makeUnit({ + id: baseId, nation: 'iran', category: 'airbase', + position: { lat: 35.7, lng: 51.3 }, + airWing: [sq('tfb1_su35', 'Su-35SE Group', 'su35', 8)], + }) + const jet = makeUnit({ id: 'us_jet', nation: 'usa', category: 'aircraft', position: destination(base.position, 90, 200) }) + const units = [base, jet] + if (baseId !== 'mehrabad') { + // A far-away Mehrabad: the threat is near the Su-35 base but not the capital axis + units.push(makeUnit({ id: 'mehrabad', nation: 'iran', category: 'airbase', position: { lat: 27, lng: 60 } })) + } + const state = makeState(units) + seedContact(state, 'iran', jet, 'detected') + step(state, new SeededRNG(42), 60) + return state + } + + expect(runScramble('mehrabad').airMissions).toHaveLength(1) // defends the capital + expect(runScramble('bandar_ab').airMissions).toHaveLength(0) // refuses elsewhere + }) +}) + +describe('air-ops save/load (GameEngine)', () => { + beforeEach(() => resetAirOpsState()) + + function makeNation(id: NationId, name: string): Nation { + return { id, name, economy: eco(), relations: { usa: 0, iran: 0 }, atWar: [] } + } + + it('round-trips a mid-mission flight and finishes the mission after load', () => { + const engine = new GameEngine() + engine.initFromData('usa', { usa: makeNation('usa', 'USA'), iran: makeNation('iran', 'Iran') }, [ + makeUnit({ id: 'cvn72_lincoln', nation: 'usa', category: 'carrier_group' }), + makeUnit({ id: 'ir_base', nation: 'iran', category: 'airbase', position: { lat: 27.5, lng: 52 } }), + ], [], {}) + + const station = destination(CARRIER_POS, 0, 30) + engine.executeCommand({ type: 'LAUNCH_AIR_MISSION', kind: 'cap', squadronId: 'vfa14', fromUnitId: 'cvn72_lincoln', flightSize: 2, station }) + for (let i = 0; i < 120; i++) engine.tick() + + const mission = engine.state.airMissions![0] + expect(mission.status).toBe('active') + const flightId = mission.flightUnitId! + const flightBefore = engine.state.units.get(flightId)! + expect(flightBefore.flightMeta?.missionId).toBe(mission.id) + const posBefore = { ...flightBefore.position } + + const loaded = new GameEngine() + loaded.loadState(engine.getFullStateJson()) + const flightAfter = loaded.state.units.get(flightId)! + expect(flightAfter.flightMeta).toEqual(flightBefore.flightMeta) + expect(flightAfter.position).toEqual(posBefore) + + for (let i = 0; i < 120; i++) loaded.tick() + expect(loaded.state.units.has(flightId)).toBe(true) // still flying after load + + loaded.executeCommand({ type: 'CANCEL_AIR_MISSION', missionId: mission.id }) + for (let i = 0; i < 600 && loaded.state.units.has(flightId); i++) loaded.tick() + expect(loaded.state.units.has(flightId)).toBe(false) + expect(loaded.state.airMissions![0].status).toBe('complete') + + const squadron = loaded.state.units.get('cvn72_lincoln')!.airWing!.find(s => s.id === 'vfa14')! + expect(squadron.readyAt).toHaveLength(5) // 3 maintenance birds + 2 recovered + }) +}) diff --git a/src/engine/systems/__tests__/war-support.test.ts b/src/engine/systems/__tests__/war-support.test.ts index 0641c5e..fbc38eb 100644 --- a/src/engine/systems/__tests__/war-support.test.ts +++ b/src/engine/systems/__tests__/war-support.test.ts @@ -8,7 +8,7 @@ import { getWarSupport, getObjectives, } from '../war-support' -import type { GameEvent, GameState, NationId, ShippingLane, Unit, UnitCategory } from '@/types/game' +import type { AirMission, GameEvent, GameState, NationId, PilotFate, ShippingLane, Unit, UnitCategory } from '@/types/game' function makeUnit(overrides: Partial & { id: string; nation: NationId }): Unit { return { @@ -233,6 +233,69 @@ describe('ceasefire', () => { }) }) +describe('pilot-fate war-support drains', () => { + function mission(nation: NationId, id = 'am_1_0'): AirMission { + return { + id, kind: 'cap', nation, squadronId: 'vfa14', fromUnitId: 'cvn', + flightSize: 2, status: 'complete', createdTick: 0, + } + } + + function flightLost(state: GameState, missionId: string | undefined, pilotFate: PilotFate): void { + const event: GameEvent = { + type: 'FLIGHT_LOST', missionId, flightName: '2× F/A-18E (VFA-14)', + airframesLost: 2, pilotFate, tick: state.time.tick, + } + state.events.push(event) + state.pendingEvents.push(event) + } + + it.each([ + ['kia', 2], + ['pow', 4], + ['rescued', 1], + ] as [PilotFate, number][])('%s drains %d from the owning nation', (fate, drain) => { + const state = makeState([lossUnit('carrier_group', 'cvn', 'usa')]) + state.airMissions = [mission('usa')] + evalAt(state, 0) + flightLost(state, 'am_1_0', fate) + evalAt(state, 60) + const support = getWarSupport(state) + // Both sides take the same duration drain — the gap is the pilot-fate drain + expect(support.iran - support.usa).toBeCloseTo(drain, 5) + }) + + it('attributes the drain via the mission record, including Iranian losses', () => { + const state = makeState([lossUnit('airbase', 'mehrabad', 'iran')]) + state.airMissions = [mission('iran', 'am_2_0')] + evalAt(state, 0) + flightLost(state, 'am_2_0', 'pow') + evalAt(state, 60) + const support = getWarSupport(state) + expect(support.usa - support.iran).toBeCloseTo(4, 5) + }) + + it('ignores FLIGHT_LOST without a resolvable mission', () => { + const state = makeState([lossUnit('carrier_group', 'cvn', 'usa')]) + state.airMissions = [mission('usa')] + evalAt(state, 0) + flightLost(state, undefined, 'kia') + flightLost(state, 'am_unknown_99', 'kia') + evalAt(state, 60) + const support = getWarSupport(state) + expect(support.iran - support.usa).toBeCloseTo(0, 5) + }) + + it('does not drain at peace', () => { + const state = makeState([lossUnit('carrier_group', 'cvn', 'usa')], { atWar: false }) + state.airMissions = [mission('usa')] + evalAt(state, 0) + flightLost(state, 'am_1_0', 'kia') + evalAt(state, 60) + expect(getWarSupport(state).usa).toBe(100) + }) +}) + describe('objectives', () => { it('returns empty at peace', () => { const state = makeState([lossUnit('ship', 'us1', 'usa')], { atWar: false }) diff --git a/src/engine/systems/air-bda.ts b/src/engine/systems/air-bda.ts new file mode 100644 index 0000000..c493173 --- /dev/null +++ b/src/engine/systems/air-bda.ts @@ -0,0 +1,55 @@ +import type { GameState, SquadronState, Unit } from '@/types/game' + +/** + * BDA on parked airframes — design: docs/plans/air-war-v5.md §3 (Iran AI): + * airbase/carrier damage destroys parked airframes proportionally. Event-driven: + * scans this tick's MISSILE_IMPACT / UNIT_DESTROYED events against airWing + * hosts. Wire processAirBda(state) into game-engine tick() after every system + * that can emit those events this tick (combat AND shipping mine kills). + */ + +/** Hardened shelters absorb half the proportional ramp loss */ +export const SHELTER_FACTOR = 0.5 + +/** Airframes on the ground right now: ready + in turnaround (rest are airborne) */ +export function parkedAirframes(squadron: SquadronState): number { + return squadron.available + squadron.readyAt.length +} + +export function processAirBda(state: GameState): void { + const tick = state.time.tick + const events = state.events + for (let i = events.length - 1; i >= 0; i--) { + const e = events[i] + if (e.tick !== tick) break + if (e.type === 'MISSILE_IMPACT') { + const unit = state.units.get(e.targetId) + if (unit?.airWing) applyRampDamage(unit, e.damage) + } else if (e.type === 'UNIT_DESTROYED') { + const unit = state.units.get(e.unitId) + if (unit?.airWing) destroyParked(unit) + } + } +} + +function applyRampDamage(host: Unit, damage: number): void { + for (const squadron of host.airWing ?? []) { + const parked = parkedAirframes(squadron) + const lost = Math.min(parked, Math.floor((damage / 100) * parked * SHELTER_FACTOR)) + if (lost <= 0) continue + squadron.total = Math.max(0, squadron.total - lost) + const fromAvailable = Math.min(squadron.available, lost) + squadron.available -= fromAvailable + const fromQueue = lost - fromAvailable + if (fromQueue > 0) squadron.readyAt.splice(squadron.readyAt.length - fromQueue, fromQueue) + } +} + +/** Host destroyed: everything on the deck/ramp is gone — only airborne airframes remain on the books */ +function destroyParked(host: Unit): void { + for (const squadron of host.airWing ?? []) { + squadron.total = Math.max(0, squadron.total - parkedAirframes(squadron)) + squadron.available = 0 + squadron.readyAt = [] + } +} diff --git a/src/engine/systems/air-ops.ts b/src/engine/systems/air-ops.ts index ed924a0..e821aa1 100644 --- a/src/engine/systems/air-ops.ts +++ b/src/engine/systems/air-ops.ts @@ -3,8 +3,11 @@ import type { GameEvent, GameState, NationId, + PilotFate, + Position, SquadronState, Unit, + UnitId, } from '@/types/game' import type { Command } from '@/types/commands' import type { ElevationGrid } from './elevation' @@ -12,21 +15,61 @@ import type { SeededRNG } from '../utils/rng' import { AIR_WINGS } from '@/data/air/airwings' import { AIRFRAMES, + A2A_COMMIT_RANGE_KM, + A2A_ENGAGE_RANGE_KM, + A2A_ROLL_INTERVAL_TICKS, + CAP_TURNAROUND_TICKS, + EXTENDED_RANGE_BONUS, + EXTENDED_RANGE_SORTIE_COST, MAINTENANCE_FRACTION, STRIKE_PLANNING_MIN_TICKS, STRIKE_PLANNING_MAX_TICKS, + STRIKE_TURNAROUND_SURGE_TICKS, + STRIKE_TURNAROUND_SUSTAINED_TICKS, SURGE_OPS_DURATION_TICKS, + type AirframeSpec, } from '@/data/air/airframes' +import { weaponSpecs } from '@/data/weapons/missiles' +import { launchMissile } from './combat' +import { getFireControlQuality, revealContact } from './visibility' +import { bearing, destination, haversine, ktsToKmh } from '../utils/geo' /** * Air operations — squadrons as pools, flights as transient units, orders at * mission level only. Design: docs/plans/air-war-v5.md. All state lives on * GameState (airMissions, surgeOps, Unit.airWing, Unit.flightMeta) so * save/load is free. + * + * The Iranian scramble AI also lives here (scrambleInterceptors), called from + * processAirOps instead of ai.ts — it reads only plain GameState (contacts, + * wings, missions), so ai.ts needs no coupling to the air war. */ +const EVAL_INTERVAL_TICKS = 60 +const STATION_ARRIVE_KM = 5 +const RECOVERY_RANGE_KM = 5 +/** 4-point racetrack ring radius — legs come out ~20 km (design §3) */ +const ORBIT_RING_KM = 14 +const SEAD_ELINT_REVEAL_KM = 150 +const STEALTH_DEFENSE_MULTIPLIER = 0.5 +const SCRAMBLE_NATION: NationId = 'iran' +const SCRAMBLE_RADIUS_KM = 250 +const MAX_SCRAMBLE_CAPS = 2 +const SCRAMBLE_FLIGHT_SIZE = 2 +const SU35_HOME_BASE_ID = 'mehrabad' + +// Event/chase dedup only, never behavior-critical: the lifecycle re-derives +// RTB/orbit/intercept from GameState every pass, so after save/load (maps +// empty) the worst case is one duplicate FLIGHT_RTB feed item. +const onStationEmitted = new Set() +const rtbEmitted = new Set() +const capTargets = new Map() + export function resetAirOpsState(): void { resetAirMissionCounter() + onStationEmitted.clear() + rtbEmitted.clear() + capTargets.clear() } /** Scenario init: attach air wings to hosts and stand down the maintenance fraction */ @@ -125,20 +168,611 @@ export function surgeActive(state: GameState): boolean { * Per-tick mission lifecycle — implemented per docs/plans/air-war-v5.md §3: * launch due missions (spawn Flight units), CAP orbit + auto-intercept with the * A2A pK model, strike transit/release via launchMissile, AEW station-keeping, - * SEAD escort effects, bingo/RTB, recovery + turnaround bookkeeping, losses - * with pilot-fate rolls, and the Iranian scramble AI hook. + * SEAD escort contact reveals, bingo/RTB, recovery + turnaround bookkeeping, + * losses with pilot-fate rolls, and the Iranian scramble AI. + * + * Cheap arrival/release checks run every tick; expensive scans (intercept + * search, SEAD reveal, scramble, ready clock) gate to game-minute boundaries. */ export function processAirOps( state: GameState, rng: SeededRNG, grid: ElevationGrid | null, ): void { - // C1 implements — contracts above are frozen. - void state - void rng - void grid + const tick = state.time.tick + const minuteBoundary = tick % EVAL_INTERVAL_TICKS === 0 + + if (minuteBoundary) tickReadyClock(state) + launchDueMissions(state) + sweepDestroyedFlights(state, rng) + + const rolledPairs = new Set() + for (const mission of state.airMissions ?? []) { + if (mission.status === 'complete' || !mission.flightUnitId) continue + const flight = state.units.get(mission.flightUnitId) + if (!flight || flight.status === 'destroyed' || !flight.flightMeta) continue + + const reason = rtbReason(state, mission, flight) + if (reason) { + handleRtb(state, mission, flight, reason, minuteBoundary) + continue + } + if (mission.kind === 'strike') { + handleStrikeTransit(state, mission, flight, grid, minuteBoundary) + continue + } + const chasing = mission.kind === 'cap' && + updateCapIntercept(state, rng, mission, flight, minuteBoundary, rolledPairs) + if (!chasing) keepStation(state, mission, flight) + } + + if (minuteBoundary) { + revealSeadContacts(state) + scrambleInterceptors(state, rng) + } +} + +// --------------------------------------------------------------------------- +// Launch + ready clock +// --------------------------------------------------------------------------- + +function launchDueMissions(state: GameState): void { + const tick = state.time.tick + for (const mission of state.airMissions ?? []) { + if (mission.status === 'planning' && + mission.planningCompleteTick !== undefined && tick >= mission.planningCompleteTick) { + mission.status = 'active' + } + if (mission.status !== 'active' || mission.flightUnitId) continue + launchFlight(state, mission) + } +} + +function launchFlight(state: GameState, mission: AirMission): void { + const tick = state.time.tick + const found = findSquadron(state, mission.squadronId) + const host = state.units.get(mission.fromUnitId) + const spec = found ? AIRFRAMES[found.squadron.airframe] : undefined + const target = mission.targetId ? state.units.get(mission.targetId) : undefined + const dest = mission.kind === 'strike' ? target?.position : mission.station + if (!found || !spec || !host || host.status === 'destroyed' || !dest || + (mission.kind === 'strike' && (!target || target.status === 'destroyed')) || + found.squadron.available < mission.flightSize) { + mission.status = 'aborted' + return + } + + found.squadron.available -= mission.flightSize + if (mission.extendedRange && host.nation === 'usa') { + const tanker = host.airWing?.find(s => s.airframe === 'fa18e') + if (tanker) { + const cost = Math.min(EXTENDED_RANGE_SORTIE_COST, tanker.available) + tanker.available -= cost + // Buddy tankers fly a quick-turn cycle, not a combat sortie + for (let i = 0; i < cost; i++) tanker.readyAt.push(tick + CAP_TURNAROUND_TICKS) + } + } + + const distKm = haversine(host.position, dest) + const kmPerTick = ktsToKmh(spec.speed_kts) / 3600 + const radiusKm = spec.combat_radius_km * (mission.extendedRange ? EXTENDED_RANGE_BONUS : 1) + // Bingo = out + back + loiter on whatever radius the transit didn't spend + const bingoTick = tick + Math.ceil((2 * distKm + Math.max(0, 2 * (radiusKm - distKm))) / kmPerTick) + const perLoadout = (countPerAirframe: number) => countPerAirframe * mission.flightSize + + const flight: Unit = { + id: `flight_${mission.id}`, + name: `${mission.flightSize}× ${spec.name} (${found.squadron.name})`, + nation: mission.nation, + category: 'aircraft', + position: { ...host.position }, + heading: bearing(host.position, dest), + speed_kts: 0, + maxSpeed_kts: spec.speed_kts, + status: 'moving', + health: 100, + maxHealth: 100, + hardness: 60, + logistics: 0, + supplyStocks: [], + weapons: mission.kind === 'strike' + ? spec.strikeWeapons.map(w => ({ + weaponId: w.weaponId, + count: perLoadout(w.countPerAirframe), + maxCount: perLoadout(w.countPerAirframe), + reloadTimeSec: 0, + })) + : [], + pointDefense: [], + sensors: spec.sensors.map(s => ({ ...s })), + waypoints: [{ ...dest }], + roe: 'weapons_free', + subordinateIds: [], + datalink_range_km: spec.datalink_range_km, + flightMeta: { + missionId: mission.id, + bingoTick, + rtbTo: host.id, + a2aShots: (spec.a2a?.shots ?? 0) * mission.flightSize, + }, + } + state.units.set(flight.id, flight) + mission.flightUnitId = flight.id + emitAirEvent(state, { + type: 'AIR_MISSION_LAUNCHED', + missionId: mission.id, + kind: mission.kind, + flightName: flight.name, + tick, + }) +} + +/** Pop turnaround/maintenance airframes back into `available` (capped at total) */ +function tickReadyClock(state: GameState): void { + const tick = state.time.tick + for (const unit of state.units.values()) { + if (!unit.airWing) continue + for (const s of unit.airWing) { + if (s.readyAt.length === 0) continue + const due = s.readyAt.filter(t => t <= tick).length + if (due === 0) continue + s.readyAt = s.readyAt.filter(t => t > tick) + s.available = Math.min(s.total, s.available + due) + } + } +} + +// --------------------------------------------------------------------------- +// RTB + recovery + losses +// --------------------------------------------------------------------------- + +/** + * RTB is derived, not flagged: every condition (abort, bingo, empty racks, + * dead target, winchester) is recomputed from GameState so a save mid-RTB + * resumes correctly without extra mission fields. + */ +function rtbReason(state: GameState, mission: AirMission, flight: Unit): string | null { + const meta = flight.flightMeta + if (!meta) return null + if (mission.status === 'aborted') return 'mission aborted' + if (state.time.tick >= meta.bingoTick) return 'bingo fuel' + if (mission.kind === 'strike') { + const target = mission.targetId ? state.units.get(mission.targetId) : undefined + if (!target || target.status === 'destroyed') return 'target down' + if (flight.weapons.length > 0 && flight.weapons.every(w => w.count <= 0)) return 'weapons released' + } + if (mission.kind === 'cap' && meta.a2aShots <= 0) return 'winchester' + return null +} + +function handleRtb( + state: GameState, + mission: AirMission, + flight: Unit, + reason: string, + minuteBoundary: boolean, +): void { + const meta = flight.flightMeta + if (!meta) return + + let host = state.units.get(meta.rtbTo) + if (!host || host.status === 'destroyed') { + const divert = findDivertField(state, flight) + if (!divert) { + loseFlight(state, mission, flight, 'rescued', true) + return + } + meta.rtbTo = divert.id + host = divert + } + + if (!rtbEmitted.has(mission.id)) { + rtbEmitted.add(mission.id) + emitAirEvent(state, { + type: 'FLIGHT_RTB', + missionId: mission.id, + flightName: flight.name, + reason, + tick: state.time.tick, + }) + flight.waypoints = [{ ...host.position }] + } else if (minuteBoundary || flight.waypoints.length === 0) { + // Carriers move — re-steer at the host's live position + flight.waypoints = [{ ...host.position }] + } + + if (haversine(flight.position, host.position) <= RECOVERY_RANGE_KM) { + recoverFlight(state, mission, flight) + } +} + +function recoverFlight(state: GameState, mission: AirMission, flight: Unit): void { + state.units.delete(flight.id) + const found = findSquadron(state, mission.squadronId) + if (found) { + const turnaround = mission.kind === 'cap' + ? CAP_TURNAROUND_TICKS + : surgeActive(state) ? STRIKE_TURNAROUND_SURGE_TICKS : STRIKE_TURNAROUND_SUSTAINED_TICKS + for (let i = 0; i < mission.flightSize; i++) { + found.squadron.readyAt.push(state.time.tick + turnaround) + } + } + completeMission(mission) +} + +function findDivertField(state: GameState, flight: Unit): Unit | null { + let best: Unit | null = null + let bestDist = Infinity + for (const u of state.units.values()) { + if (u.nation !== flight.nation || u.status === 'destroyed' || !u.airWing) continue + if (u.category !== 'airbase' && u.category !== 'carrier_group') continue + const d = haversine(flight.position, u.position) + if (d < bestDist) { + best = u + bestDist = d + } + } + return best +} + +/** Flights destroyed by SAMs/combat: report the loss; airframes never come back */ +function sweepDestroyedFlights(state: GameState, rng: SeededRNG): void { + for (const mission of state.airMissions ?? []) { + if (mission.status === 'complete' || !mission.flightUnitId) continue + const flight = state.units.get(mission.flightUnitId) + if (!flight) { + completeMission(mission) + continue + } + if (flight.status !== 'destroyed') continue + loseFlight(state, mission, flight, rollPilotFate(rng), false) + } +} + +function loseFlight( + state: GameState, + mission: AirMission, + flight: Unit, + pilotFate: PilotFate, + removeUnit: boolean, +): void { + emitAirEvent(state, { + type: 'FLIGHT_LOST', + missionId: mission.id, + flightName: flight.name, + airframesLost: mission.flightSize, + pilotFate, + tick: state.time.tick, + }) + const found = findSquadron(state, mission.squadronId) + if (found) { + found.squadron.total = Math.max(0, found.squadron.total - mission.flightSize) + found.squadron.available = Math.min(found.squadron.available, found.squadron.total) + } + if (removeUnit) state.units.delete(flight.id) + completeMission(mission) +} + +function rollPilotFate(rng: SeededRNG): PilotFate { + const r = rng.next() + return r < 0.4 ? 'kia' : r < 0.8 ? 'rescued' : 'pow' +} + +function completeMission(mission: AirMission): void { + mission.status = 'complete' + onStationEmitted.delete(mission.id) + rtbEmitted.delete(mission.id) + capTargets.delete(mission.id) +} + +// --------------------------------------------------------------------------- +// CAP / AEW station keeping + A2A intercept +// --------------------------------------------------------------------------- + +function keepStation(state: GameState, mission: AirMission, flight: Unit): void { + const station = mission.station + if (!station) return + const dist = haversine(flight.position, station) + if (dist <= STATION_ARRIVE_KM && !onStationEmitted.has(mission.id)) { + onStationEmitted.add(mission.id) + emitAirEvent(state, { + type: 'FLIGHT_ON_STATION', + missionId: mission.id, + flightName: flight.name, + tick: state.time.tick, + }) + } + if (flight.waypoints.length === 0) { + flight.waypoints = dist <= ORBIT_RING_KM + STATION_ARRIVE_KM + ? [0, 90, 180, 270].map(b => destination(station, b, ORBIT_RING_KM)) + : [{ ...station }] + } +} + +/** Returns true while the CAP is committed on an intercept (skips station keeping) */ +function updateCapIntercept( + state: GameState, + rng: SeededRNG, + mission: AirMission, + flight: Unit, + minuteBoundary: boolean, + rolledPairs: Set, +): boolean { + if (minuteBoundary) { + const target = findInterceptTarget(state, flight) + if (target) capTargets.set(mission.id, target.id) + else capTargets.delete(mission.id) + } + const targetId = capTargets.get(mission.id) + if (!targetId) return false + const target = state.units.get(targetId) + if (!target || target.status === 'destroyed') { + capTargets.delete(mission.id) + return false + } + + if (minuteBoundary || flight.waypoints.length === 0) { + flight.waypoints = [{ ...target.position }] + } + + if (state.time.tick % A2A_ROLL_INTERVAL_TICKS === 0 && + haversine(flight.position, target.position) <= A2A_ENGAGE_RANGE_KM) { + const pairKey = [flight.id, target.id].sort().join('|') + if (!rolledPairs.has(pairKey)) { + rolledPairs.add(pairKey) + resolveA2AExchange(state, rng, flight, target) + } + } + return true +} + +/** Nearest live enemy aircraft with a tracked+ contact inside commit range. Never non-aircraft. */ +function findInterceptTarget(state: GameState, flight: Unit): Unit | null { + if (flight.roe !== 'weapons_free') return null + const nation = state.nations[flight.nation] + if (!nation || nation.atWar.length === 0) return null + const enemies = new Set(nation.atWar) + const contacts = state.visibility?.[flight.nation as string] + if (!contacts) return null + + let best: Unit | null = null + let bestDist = Infinity + for (const u of state.units.values()) { + if (!enemies.has(u.nation) || u.category !== 'aircraft' || u.status === 'destroyed') continue + const c = contacts[u.id] + if (!c || (c.level !== 'tracked' && c.level !== 'identified')) continue + const d = haversine(flight.position, u.position) + if (d <= A2A_COMMIT_RANGE_KM && d < bestDist) { + best = u + bestDist = d + } + } + return best +} + +/** One exchange per pair per interval: attacker shoots, surviving armed defender shoots back */ +function resolveA2AExchange(state: GameState, rng: SeededRNG, attacker: Unit, defender: Unit): void { + rollA2AShot(state, rng, attacker, defender) + if (defender.status !== 'destroyed' && defender.flightMeta && defender.roe !== 'hold_fire') { + rollA2AShot(state, rng, defender, attacker) + } +} + +function rollA2AShot(state: GameState, rng: SeededRNG, shooter: Unit, target: Unit): void { + const meta = shooter.flightMeta + const a2a = flightAirframe(state, shooter)?.a2a + if (!meta || !a2a || meta.a2aShots <= 0) return + meta.a2aShots-- + + const rcs = flightAirframe(state, target)?.rcsClass ?? 'fighter' + let pk = rcs === 'large' ? a2a.pkLarge : a2a.pkFighter + if (rcs === 'stealth') pk *= STEALTH_DEFENSE_MULTIPLIER + + const killed = rng.chance(pk) + emitAirEvent(state, { + type: 'AIR_INTERCEPT', + attackerName: shooter.name, + defenderName: target.name, + kills: killed ? 1 : 0, + tick: state.time.tick, + }) + if (killed) applyA2AKill(state, rng, target) +} + +/** Each kill downs one airframe: 100/flightSize damage; at 0 the flight is gone */ +function applyA2AKill(state: GameState, rng: SeededRNG, victim: Unit): void { + const mission = missionOfFlight(state, victim) + const damage = Math.ceil(100 / (mission?.flightSize ?? 1)) + victim.health = Math.max(0, victim.health - damage) + if (victim.health > 0) return + + victim.status = 'destroyed' + victim.speed_kts = 0 + victim.waypoints = [] + emitAirEvent(state, { type: 'UNIT_DESTROYED', unitId: victim.id, tick: state.time.tick }) + state.attackCounters ??= {} + state.attackCounters[victim.nation] = (state.attackCounters[victim.nation] ?? 0) + 1 + if (!mission) { + // Plain aircraft unit (no mission) — the destroyed-flight sweep won't report it + emitAirEvent(state, { + type: 'FLIGHT_LOST', + flightName: victim.name, + airframesLost: 1, + pilotFate: rollPilotFate(rng), + tick: state.time.tick, + }) + } + // Mission flights get FLIGHT_LOST + bookkeeping from sweepDestroyedFlights next tick +} + +function missionOfFlight(state: GameState, unit: Unit): AirMission | null { + if (!unit.flightMeta) return null + return state.airMissions?.find(m => m.id === unit.flightMeta?.missionId) ?? null +} + +function flightAirframe(state: GameState, unit: Unit): AirframeSpec | null { + const mission = missionOfFlight(state, unit) + if (!mission) return null + const found = findSquadron(state, mission.squadronId) + return found ? AIRFRAMES[found.squadron.airframe] : null +} + +// --------------------------------------------------------------------------- +// Strike transit + weapons release +// --------------------------------------------------------------------------- + +function handleStrikeTransit( + state: GameState, + mission: AirMission, + flight: Unit, + grid: ElevationGrid | null, + minuteBoundary: boolean, +): void { + const target = mission.targetId ? state.units.get(mission.targetId) : undefined + if (!target || target.status === 'destroyed') return // rtbReason turns them home next pass + + const releaseKm = releaseRangeKm(flight) + if (releaseKm > 0 && haversine(flight.position, target.position) <= releaseKm) { + releaseWeapons(state, flight, target, grid) + return + } + if (minuteBoundary || flight.waypoints.length === 0) { + flight.waypoints = [{ ...target.position }] + } +} + +/** Release at 90% of the shortest-legged weapon so every rack can fire */ +function releaseRangeKm(flight: Unit): number { + let min = Infinity + for (const w of flight.weapons) { + if (w.count <= 0) continue + const spec = weaponSpecs[w.weaponId] + if (spec && spec.range_km < min) min = spec.range_km + } + return Number.isFinite(min) ? min * 0.9 : 0 +} + +function releaseWeapons(state: GameState, flight: Unit, target: Unit, grid: ElevationGrid | null): void { + const quality = getFireControlQuality(state, flight, target, grid) ?? 'datalink' + let fired = 0 + for (const loadout of flight.weapons) { + while (loadout.count > 0) { + const event = launchMissile(state, flight.id, loadout.weaponId, target.id, undefined, quality) + if (!event) break + emitAirEvent(state, event) + fired++ + } + } + // Empty racks flip rtbReason to 'weapons released' on the next pass + if (fired > 0) declareAirWar(state, flight.nation, target.nation) +} + +/** Air strikes are hostile acts — mirror the LAUNCH_MISSILE command's war transition */ +function declareAirWar(state: GameState, attacker: NationId, defender: NationId): void { + if (attacker === defender) return + const a = state.nations[attacker] + const d = state.nations[defender] + if (!a || !d || a.atWar.includes(defender)) return + a.atWar.push(defender) + if (!d.atWar.includes(attacker)) d.atWar.push(attacker) + emitAirEvent(state, { type: 'WAR_DECLARED', attacker, defender, tick: state.time.tick }) } +// --------------------------------------------------------------------------- +// SEAD escort — ELINT reveal only +// --------------------------------------------------------------------------- + +/** + * EA-18G escort: emitting enemy SAMs near the escorted flight become 'detected' + * contacts each minute. The design's SAM detect/pk ×0.6 vs the escorted flight + * needs combat.ts coupling — BACKLOG, not implemented here. + */ +function revealSeadContacts(state: GameState): void { + for (const mission of state.airMissions ?? []) { + if (!mission.escortSead || mission.status === 'complete' || !mission.flightUnitId) continue + const flight = state.units.get(mission.flightUnitId) + if (!flight || flight.status === 'destroyed') continue + for (const sam of state.units.values()) { + if (sam.nation === flight.nation || sam.category !== 'sam_site' || sam.status === 'destroyed') continue + if (sam.emcon || !sam.sensors.some(s => s.type === 'radar' && s.range_km > 0)) continue + if (haversine(flight.position, sam.position) <= SEAD_ELINT_REVEAL_KM) { + revealContact(state, flight.nation as string, sam, 'detected') + } + } + } +} + +// --------------------------------------------------------------------------- +// Iranian scramble AI — reactive CAP only (design §3 "Iran AI") +// --------------------------------------------------------------------------- + +function scrambleInterceptors(state: GameState, rng: SeededRNG): void { + if (state.playerNation === SCRAMBLE_NATION) return + const iran = state.nations[SCRAMBLE_NATION] + if (!iran || iran.atWar.length === 0) return + const contacts = state.visibility?.[SCRAMBLE_NATION] + if (!contacts) return + + let liveCaps = 0 + for (const m of state.airMissions ?? []) { + if (m.nation === SCRAMBLE_NATION && m.kind === 'cap' && + m.status !== 'complete' && m.status !== 'aborted') liveCaps++ + } + if (liveCaps >= MAX_SCRAMBLE_CAPS) return + + const enemies = new Set(iran.atWar) + const su35Home = state.units.get(SU35_HOME_BASE_ID) + + for (const threat of state.units.values()) { + if (liveCaps >= MAX_SCRAMBLE_CAPS) break + if (!enemies.has(threat.nation) || threat.category !== 'aircraft' || threat.status === 'destroyed') continue + if (!contacts[threat.id]) continue + + let bestBase: Unit | null = null + let bestSquadron: SquadronState | null = null + let bestDist = Infinity + for (const base of state.units.values()) { + if (base.nation !== SCRAMBLE_NATION || base.category !== 'airbase' || + base.status === 'destroyed' || !base.airWing) continue + const dist = haversine(base.position, threat.position) + if (dist > SCRAMBLE_RADIUS_KM || dist >= bestDist) continue + const squadron = base.airWing.find(s => { + if (s.available < SCRAMBLE_FLIGHT_SIZE || !AIRFRAMES[s.airframe]?.a2a) return false + if (s.airframe === 'su35') { + // Su-35s defend the capital axis only + return su35Home !== undefined && haversine(su35Home.position, threat.position) <= SCRAMBLE_RADIUS_KM + } + return true + }) + if (squadron) { + bestBase = base + bestSquadron = squadron + bestDist = dist + } + } + if (!bestBase || !bestSquadron) continue + + const before = state.airMissions?.length ?? 0 + launchAirMission(state, rng, { + type: 'LAUNCH_AIR_MISSION', + kind: 'cap', + squadronId: bestSquadron.id, + fromUnitId: bestBase.id, + flightSize: SCRAMBLE_FLIGHT_SIZE, + station: midpoint(bestBase.position, threat.position), + }) + if ((state.airMissions?.length ?? 0) > before) liveCaps++ + } +} + +function midpoint(a: Position, b: Position): Position { + return { lat: (a.lat + b.lat) / 2, lng: (a.lng + b.lng) / 2 } +} + +// --------------------------------------------------------------------------- +// View / events / counters +// --------------------------------------------------------------------------- + /** Player-nation mission slice for the snapshot */ export function getAirMissionsView(state: GameState, nation: NationId): AirMission[] { return (state.airMissions ?? []) diff --git a/src/engine/systems/intel.ts b/src/engine/systems/intel.ts index b98962f..37dbf6e 100644 --- a/src/engine/systems/intel.ts +++ b/src/engine/systems/intel.ts @@ -134,6 +134,7 @@ function resolveSatelliteTaskings(state: GameState, intel: IntelState, rng: Seed // Sweep the footprint let found = 0 let revealedDecoys = 0 + let rampAirframes = 0 const byCategory = new Map() for (const unit of state.units.values()) { if (unit.nation === asset.nation || unit.status === 'destroyed') continue @@ -149,6 +150,11 @@ function resolveSatelliteTaskings(state: GameState, intel: IntelState, rng: Seed found++ byCategory.set(unit.category, (byCategory.get(unit.category) ?? 0) + 1) + // BDA reward: ramp counts are the only legal channel for enemy squadron pools + if (unit.airWing) { + rampAirframes += unit.airWing.reduce((n, s) => n + s.available + s.readyAt.length, 0) + } + if (unit.isDecoy && !unit.decoyRevealed && (asset.niirs ?? 0) >= 7) { unit.decoyRevealed = true revealedDecoys++ @@ -163,7 +169,7 @@ function resolveSatelliteTaskings(state: GameState, intel: IntelState, rng: Seed target: tasking.target, niirs: asset.niirs, classification: asset.kind === 'commercial_sat' ? 'UNCLASSIFIED//COMMERCIAL' : 'TOP SECRET//TK//NOFORN', - caption: imintCaption(byCategory, revealedDecoys), + caption: imintCaption(byCategory, revealedDecoys, rampAirframes), }) emit(state, { @@ -185,7 +191,7 @@ function resolveSatelliteTaskings(state: GameState, intel: IntelState, rng: Seed } } -function imintCaption(byCategory: Map, revealedDecoys: number): string { +function imintCaption(byCategory: Map, revealedDecoys: number, rampAirframes: number): string { if (byCategory.size === 0) return 'No significant activity observed in AOI.' const labels: Record = { missile_battery: 'probable TEL group', @@ -199,8 +205,9 @@ function imintCaption(byCategory: Map, revealedDecoys: number): minefield: 'suspected mine line', } const parts = Array.from(byCategory.entries()).map(([cat, n]) => `${n}× ${labels[cat] ?? cat}`) + const rampNote = rampAirframes > 0 ? `; ~${rampAirframes} airframes on ramp` : '' const decoyNote = revealedDecoys > 0 ? `; ${revealedDecoys}× assessed DECOY (no thermal signature)` : '' - return parts.join(', ') + decoyNote + '.' + return parts.join(', ') + rampNote + decoyNote + '.' } // --------------------------------------------------------------------------- diff --git a/src/engine/systems/war-support.ts b/src/engine/systems/war-support.ts index 08a7b44..7da397f 100644 --- a/src/engine/systems/war-support.ts +++ b/src/engine/systems/war-support.ts @@ -1,4 +1,4 @@ -import type { GameEvent, GameState, NationId, UnitCategory, WarStats } from '@/types/game' +import type { GameEvent, GameState, NationId, PilotFate, UnitCategory, WarStats } from '@/types/game' import type { ObjectiveStatus } from '@/types/view' import { weaponSpecs } from '@/data/weapons/missiles' @@ -26,6 +26,7 @@ const UNIT_LOSS_DRAIN: Record = { aircraft: 1, minefield: 0.5, } +const PILOT_FATE_DRAIN: Record = { kia: 2, pow: 4, rescued: 1 } const WAR_DURATION_DRAIN_PER_HOUR = 0.15 const LOW_RESERVES_FRACTION = 0.25 const LOW_RESERVES_DRAIN_PER_HOUR = 0.3 @@ -233,6 +234,16 @@ function evaluate(state: GameState): void { } break } + case 'FLIGHT_LOST': { + // Event carries no nation — the owning side comes from the mission record + const mission = e.missionId ? state.airMissions?.find(m => m.id === e.missionId) : undefined + if (!mission) break + const owner = state.nations[mission.nation] + if (!owner || owner.atWar.length === 0) break + const status = (ws[mission.nation] ??= { warSupport: 100 }) + status.warSupport = clampSupport(status.warSupport - PILOT_FATE_DRAIN[e.pilotFate]) + break + } } } diff --git a/src/intel/__tests__/osint-feed.test.ts b/src/intel/__tests__/osint-feed.test.ts index f4434cf..0631402 100644 --- a/src/intel/__tests__/osint-feed.test.ts +++ b/src/intel/__tests__/osint-feed.test.ts @@ -27,6 +27,18 @@ const flashIntercept: GameEvent = { } const routineIntercept: GameEvent = { type: 'INTERCEPT_DECRYPTED', precedence: 'ROUTINE', text: 'logistics chatter', tick: 61 } const supportCritical: GameEvent = { type: 'WAR_SUPPORT_CRITICAL', nation: 'usa', support: 28, tick: 5000 } +const usaMission: GameEvent = { + type: 'AIR_MISSION_LAUNCHED', missionId: 'am_1_1500', kind: 'strike', flightName: '2× F/A-18E (VFA-14)', tick: 1500, +} +const iranMission: GameEvent = { + type: 'AIR_MISSION_LAUNCHED', missionId: 'am_2_1600', kind: 'cap', flightName: '2× MiG-29A (11th TFS Fulcrums)', tick: 1600, +} +const flightLost: GameEvent = { + type: 'FLIGHT_LOST', missionId: 'am_1_1500', flightName: '2× F/A-18E (VFA-14)', airframesLost: 2, pilotFate: 'pow', tick: 2000, +} +const airIntercept: GameEvent = { + type: 'AIR_INTERCEPT', attackerName: '2× Su-35SE (Su-35SE Group)', defenderName: '2× F/A-18E (VFA-14)', kills: 1, tick: 2100, +} function account(handle: string) { return OSINT_ACCOUNTS.find((a) => a.handle === handle)! @@ -98,6 +110,25 @@ describe('generatePostsForEvent — archetype coverage', () => { expect(postsBy(generatePostsForEvent(supportCritical, names, 5000), '@PizzaIndexGulf')).toHaveLength(1) }) + it('plane spotter covers USA air-mission launches but not Iranian ones', () => { + expect(postsBy(generatePostsForEvent(usaMission, names, 1500), '@GulfPlaneWatch')).toHaveLength(1) + expect(postsBy(generatePostsForEvent(iranMission, names, 1600), '@GulfPlaneWatch')).toHaveLength(0) + }) + + it('plane spotter covers air-to-air intercepts', () => { + expect(postsBy(generatePostsForEvent(airIntercept, names, 2100), '@GulfPlaneWatch')).toHaveLength(1) + const noKill: GameEvent = { ...airIntercept, kills: 0, tick: 2101 } + expect(postsBy(generatePostsForEvent(noKill, names, 2101), '@GulfPlaneWatch')).toHaveLength(1) + }) + + it('aggregator covers downed flights', () => { + const posts = postsBy(generatePostsForEvent(flightLost, names, 2000), '@CENTCOM_Watch') + expect(posts).toHaveLength(1) + expect(posts[0].text).toMatch(/down over the Gulf|reported lost|failed to return/) + const kia: GameEvent = { type: 'FLIGHT_LOST', flightName: '2× F-14AM (81st TFS Tomcats)', airframesLost: 1, pilotFate: 'kia', tick: 2200 } + expect(postsBy(generatePostsForEvent(kia, names, 2200), '@CENTCOM_Watch')).toHaveLength(1) + }) + it('uncovered events generate nothing', () => { const repair: GameEvent = { type: 'UNIT_REPAIRED', unitId: 'al_udeid', healthRestored: 10, tick: 42 } expect(generatePostsForEvent(repair, names, 42)).toHaveLength(0) @@ -106,7 +137,7 @@ describe('generatePostsForEvent — archetype coverage', () => { describe('generatePostsForEvent — delay semantics', () => { it('every post surfaces no earlier than event tick + the account min delay', () => { - const allEvents = [launched, impact, destroyedIranian, mineHit, laneChange, oilSpike, flashIntercept, supportCritical] + const allEvents = [launched, impact, destroyedIranian, mineHit, laneChange, oilSpike, flashIntercept, supportCritical, usaMission, flightLost, airIntercept] for (const e of allEvents) { for (const post of generatePostsForEvent(e, names, e.tick)) { const [min, max] = account(post.handle).delayRangeSec diff --git a/src/intel/osint-feed.ts b/src/intel/osint-feed.ts index 8f33e74..3ad48cf 100644 --- a/src/intel/osint-feed.ts +++ b/src/intel/osint-feed.ts @@ -49,6 +49,7 @@ function pick(rng: () => number, arr: T[]): T { // ── Nation heuristics (events carry no nation field; the feed is flavor) ──── const IRAN_WEAPON_RE = /shahab|sejjil|fateh|zolfaghar|khalij|noor|soumar|hoveyzeh|shahed|sayyad|bavar|khordad|48n6|9m331/i +const USA_FLIGHT_RE = /f\/a-18|f-35|ea-18|e-2|hornet|lightning|growler|hawkeye|vfa|vaq|vaw|efs/i const IRAN_UNIT_RE = /irgc|irin|bandar|bushehr|qeshm|jask|chabahar|khordad|bavar|s-300|tor-m1|shahab|sejjil|fateh|zolfaghar|shahed|soumar|ghadir|mehrabad|nebo|isfahan|tabriz|dezful|semnan|natanz|kermanshah|khorramabad|shiraz|tehran|\btel\b/i const WRONG_NAMES = [ @@ -88,6 +89,22 @@ function buildText( `that sound is live ${event.weaponName} fire. engagement underway`, ]) } + if (event.type === 'AIR_MISSION_LAUNCHED' && USA_FLIGHT_RE.test(event.flightName)) { + return pick(rng, [ + `${event.flightName} just launched — climbing out fast and heading seaward`, + 'flight ops surging right now. multiple fast movers up in the last few minutes', + `caught it on the long lens: ${event.flightName}. that loadout is not a training fit`, + ]) + } + if (event.type === 'AIR_INTERCEPT') { + if (event.kills > 0) { + return pick(rng, [ + 'contrails merging high over the water, then a fireball. aircraft down — air-to-air, has to be', + 'just watched something fall burning out of the sky offshore. multiple watchers confirm', + ]) + } + return 'fast jets merging high over the gulf, missile trails visible. everyone still flying as far as I can tell' + } return null } @@ -115,6 +132,18 @@ function buildText( `it's happening — state of war: ${a} vs ${d}. live coverage thread below`, ]) } + if (event.type === 'FLIGHT_LOST') { + if (event.pilotFate === 'pow') { + return pick(rng, [ + 'reports of an aircraft down over the Gulf — state TV claims aircrew in custody. developing', + `BREAKING: ${event.flightName} reported lost. unverified footage shows a parachute and a capture crowd`, + ]) + } + return pick(rng, [ + 'reports of an aircraft down over the Gulf — SAR traffic spiking on open frequencies. developing', + `multiple sources: ${event.flightName} failed to return. no official confirmation yet`, + ]) + } return null } diff --git a/src/store/ui-store.ts b/src/store/ui-store.ts index 5d2c4a9..0f4a042 100644 --- a/src/store/ui-store.ts +++ b/src/store/ui-store.ts @@ -56,6 +56,7 @@ interface UIState { // Right-side panels (independent toggles) showIntel: boolean + showAirOps: boolean // Intel suite v3 liveFeedsOpen: boolean @@ -100,6 +101,7 @@ interface UIState { // Right-side panels toggleIntel: () => void + toggleAirOps: () => void // Intel suite v3 toggleLiveFeeds: () => void @@ -140,6 +142,7 @@ export const useUIStore = create((set, get) => ({ showStats: false, showEconomy: false, showIntel: false, + showAirOps: false, liveFeedsOpen: false, viewedProductId: null, fmvTargetId: null, @@ -184,6 +187,8 @@ export const useUIStore = create((set, get) => ({ toggleIntel: () => set((s) => ({ showIntel: !s.showIntel })), + toggleAirOps: () => set((s) => ({ showAirOps: !s.showAirOps })), + toggleLiveFeeds: () => set((s) => ({ liveFeedsOpen: !s.liveFeedsOpen })), setViewedProduct: (id) => set({ viewedProductId: id }), setFmvTarget: (id) => set({ fmvTargetId: id }),