diff --git a/docs/BACKLOG.md b/docs/BACKLOG.md index 43f05a5..875b0a5 100644 --- a/docs/BACKLOG.md +++ b/docs/BACKLOG.md @@ -2,18 +2,27 @@ Curated from the 2026-06-09 full-codebase audit (12-agent sweep, 199 findings) plus live playtest. Wave 1+2 fixes covered the gameplay-killing bugs; these are the deliberate deferrals. +Fog of war + war termination shipped in game-loop v2; the intel suite + combat-on-contacts +shipped in v3 (docs/plans/intel-suite-v3.md) — v3 deferrals listed below. + +## Intel suite v3 deferrals (design doc §7 + build-wave notes) + +- Missions/doctrine cascade (patrol boxes, recon orbits, per-zone weapon release authority). +- HVT person-tracking chains, underground facility model, WAMI rewind, Staff Summary panel. +- Counter-OSINT levers: shutter control, disinfo plants, Iranian internet blackout. +- Live-data upgrades: OpenSky via a small Vercel proxy, aisstream AIS relay, Windy webcams, + GIBS fires MVT layer, USGS seismic ticker as MASINT flavor. +- STRIKE_LEAKED currently scoots the target + degrades the contact; the designed Iranian + point-defense readiness bump is not implemented. +- FLASH salvo-warning intercept path (getNextSalvoEstimate) has no test (couples to AI phase). +- ADS-B polling has no document-visibility pause; LiveFeeds drag is title-bar only. +- Destroying Iranian ISR assets (Mohajer orbit, picket boats, Noor) has no kill path. +- OSINT regime-mouthpiece nation detection is a name-regex heuristic; new Iranian names need + the patterns extended (osint-feed.ts IRAN_WEAPON_RE/IRAN_UNIT_RE). ## Big features (design needed before code) -- **Fog of war.** The snapshot ships full enemy state to the UI; detection/espionage/satellite - systems compute results that gate nothing. Decide the visibility model (per-unit detected - state with decay?), filter the snapshot, and wire HUMINT reveals + SIGINT range multiplier + - `satelliteDetectedUnitIds` into it. Until then the whole intel layer is cosmetic - (IntelBudgetPanel is a placebo). -- **Victory / end conditions.** Wars currently never end: no objectives evaluated, no - victory/defeat screen, CEASE_FIRE command handled by the engine but has no UI. The war just - goes silent. Define win conditions per scenario (e.g. Hormuz kept open N days, % enemy - strategic assets destroyed) and an end-of-war screen. +- **Logistics depth** (see below), **aircraft/sortie system**, **more scenarios**. - **Logistics depth.** logistics-v2 (national stockpiles, shipments, production) was deleted as dead code in the cleanup; the live v1 ignores `SupplyLine.capacity`/`distance_km`. If supply is to matter strategically, re-design from the v1 base (git history has v2 for reference). @@ -26,26 +35,39 @@ Wave 1+2 fixes covered the gameplay-killing bugs; these are the deliberate defer ## UX debt -- Panels overlap each other (SITREP opens on top of the strike dialog) and have no - drag/z-order management; Escape does not close panels. - Mobile: LOG tab empty, IntelPanel X needs two taps, direct-fire flow yanks to UNIT panel. -- Strike timing modes (staggered/sequential) run on wall-clock setTimeout (0.3s/3s) instead of - game time, and fire while paused. Honest fix = engine-side scheduled salvos. - Shipping lane name/throughput labels are built but never rendered; lanes/minefields have no hover/click interactivity. -- Intel estimate markers are pickable but `moveEstimate`/`confirmEstimate` have no UI path. -- "BAB EL_MANDEB" event copy; review event vocabulary for raw enum leakage generally. -- No path back to the main menu from a running game. -- StartScreen shows a hardcoded stale version/date banner; project naming is split between - REALPOLITIK (UI/package/IndexedDB) and Ashfall Command (repo/Vercel). +- OFFER CEASEFIRE two-step confirm still uses silent blur-disarm — reuse TopBar's + useArmedCountdown for consistency with CONFIRM WAR. +- exitToMainMenu() in TopBar duplicates DebriefScreen's handleMainMenu — extract a shared + helper; consider SET_SPEED 0 on exit (worker keeps ticking behind the start screen). +- Package/IndexedDB internals still named realpolitik (package.json name, save-load DB_NAME, + map style ids) — cosmetic, renaming the DB key breaks saves. + +## Engine scale (from the Wave A stress guard, 2026-06-11) + +- Measured: ~60-unit shipped scenarios are comfortably real-time; at 500 units the + interceptor-saturation phase (~2100 live missiles) runs 80-190 ms/tick — missile + count dominates, units are cheap, terrain/LOS adds only ~9%. If bigger scenarios + ever become a target: spatial index / per-missile detection culling in + detection/visibility loops. NOT WASM-for-LOS. Guard: src/engine/__tests__/scale-stress.test.ts. ## Smaller deferred fixes - DIRECT FIRE cluster popup z-order vs strike panel (replaces it on screen). -- DECLARE WAR confirm silently times out (by design?) — consider a visible countdown. - visualTimestamp interpolation assumes nominal worker speed; visual clock can snap back. -- Multiple radars on one unit: max range paired with first radar's antenna/sector. -- detectThreats runs twice per radar unit per tick (perf, harmless at current scale). -- Sensor types sonar/irst/ew have no mechanics; `Sensor.detection_prob` unread. +- Sensor types sonar/irst/ew have no mechanics; `Sensor.detection_prob` data-only (noted in code). - `GameTime.tickIntervalMs` set everywhere, read nowhere (visual interpolation hardcodes 100). -- Cursor elevation readout tracks mousemove even with ELV overlay off. + +## Done 2026-06-11 (backlog wave 1) + +- Panels: drag by title bar, click-to-raise z-order, Escape closes topmost, per-title + position memory (Panel.tsx + ui-store panels slice). +- Strike timing engine-side: LAUNCH_SALVO spacingTicks + state.scheduledLaunches queue — + pause-safe, save/load-safe, one leak roll per salvo. Staggered/sequential now space + rounds within the salvo (2 s/8 s); old 30 s/10 min between-tier delays removed. +- MAIN MENU in the ··· menu (two-step confirm), CONFIRM WAR visible 5 s countdown, + lane/supply-line names prettified (Bab el-Mandeb). +- Multi-radar pairing fixed (per-sensor range/antenna/sector evaluation), detectThreats + per-tick memo (3× → 1× per unit), ELV cursor listener gated on the toggle. diff --git a/docs/plans/air-war-v5.md b/docs/plans/air-war-v5.md new file mode 100644 index 0000000..2081ddb --- /dev/null +++ b/docs/plans/air-war-v5.md @@ -0,0 +1,138 @@ +# Air war v5 — squadrons, flights, missions + +Binding design for Roadmap Wave C. Research basis: CMO/Falcon/DCS Liberation +convergent design + real 2026 orbat (roadmap-v4.md Wave C). Core principle: +squadrons are pools, flights are transient map tracks, orders exist ONLY at +mission level. No per-airframe anything. + +## 1. Data model + +```ts +// types/game.ts +export interface SquadronState { + id: string // 'vfa14' + name: string // 'VFA-14 Tophatters' + airframe: AirframeId // 'fa18e' | 'f35c' | 'ea18g' | 'e2d' | 'f14' | 'mig29' | 'su24' | 'su35' + total: number // airframes on the books + available: number // ready now (total − airborne − turnaround − maintenance − lost) + readyAt: number[] // ticks when turning-around airframes rejoin `available` +} +// Unit gains: airWing?: SquadronState[] (carrier_group + airbase only) + +export interface AirframeSpec { + id: AirframeId + name: string + speed_kts: number // cruise + combat_radius_km: number + sensors: Sensor[] // radar per airframe (E-2D huge + datalink) + weapons: WeaponLoadout[] // per-airframe loadout template by mission + datalink_range_km?: number // E-2D/E-3 are hubs + rcs_class: 'stealth' | 'fighter' | 'large' +} + +export type AirMissionKind = 'cap' | 'strike' | 'aew' +export interface AirMission { + id: string + kind: AirMissionKind + squadronId: string + fromUnitId: UnitId + flightSize: number // 2-4 + station?: Position // cap/aew orbit point + targetId?: UnitId // strike + escortSead?: boolean // EA-18G pair attached (USA only) + extendedRange?: boolean // tanker tax: +35% radius, −2 fa18e sorties + status: 'planning' | 'active' | 'complete' | 'aborted' + planningCompleteTick?: number + flightUnitId?: UnitId // spawned Flight unit +} +// GameState gains: airMissions?: AirMission[] +``` + +Flights are real `Unit`s, category `'aircraft'`, id `flight_`, name +"2× F/A-18E (VFA-14)" — movement, visibility contacts, SAM engagement, +war-support, datalink all work day one. Flight unit carries the airframe's +sensors/weapons (scaled by flightSize) and `flightMeta` fields on Unit: +`{ missionId, bingoTick, rtbTo: UnitId }` (optional, plain data). + +## 2. Sortie economy (CMO numbers) + +- CAP quick-turn 90 game-min; strike turnaround 6 h (surge) / 20 h (sustained). +- Carrier ceiling ~100 sorties/day enforced implicitly by pools + turnarounds. +- 30% of each squadron's `total` starts in maintenance (deduct from available + at init; returns at scenario start + 12-24 h via readyAt). +- SURGE OPS: one global toggle (command). 96 h of halved ready times, then + ×1.5 sustained for the rest of the war. TopBar chip while active. +- `extendedRange` deducts 2 extra fa18e sorties from the carrier (buddy + tanking; MQ-25 slipped to 2029). +- Strike planning delay: 2-6 h (rng seeded by mission id) between order and + launch window — `status: 'planning'`. CAP/AEW launch next tick. + +## 3. Mission behavior (engine/systems/air-ops.ts) + +Per game-minute evaluation + per-tick movement (movement.ts already moves +aircraft units; air-ops only sets waypoints/decisions): + +- **Launch**: pop `available`, spawn Flight at the host position, waypoints to + station/target. bingoTick = now + 2×(transit time) + on-station allowance + derived from combat_radius vs distance (never fuel liters). +- **CAP**: orbit station (small 4-waypoint racetrack, re-issued when reached). + Auto-intercept: nearest enemy `aircraft` contact (tracked+, live) within + commit range 120 km → engage via AUTO_ENGAGEMENT path with A2A pK table + (new weapon type 'aam', simple pK per rcs_class; one engagement roll per + 30 game-s within 40 km). Iranian flights symmetric. +- **Strike**: transit to release range of targetId (per loadout weapon), + spawn EXISTING missiles via launchMissile (trackQuality from the nation + picture like friendly-ai), then RTB. SEAD escort: while within 80 km of the + flight, enemy SAM detection_prob×0.6 and pk×0.6 vs this flight's missiles + AND emitting SAMs within 150 km become 'detected' contacts (ELINT hook). +- **AEW**: orbit station; the Flight has E-2D sensors + datalink_range_km → + 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). +- **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 + scrambles to defend Hamadan/Tehran. Parked airframes: each Iranian airbase + exposes `airWing` totals to BDA — airbase damage destroys parked airframes + proportionally (UNIT damage events → pool reduction; satellite pass caption + mentions counted airframes). + +## 4. Commands + +`LAUNCH_AIR_MISSION { kind, squadronId, fromUnitId, flightSize, station?, targetId?, escortSead?, extendedRange? }`, +`CANCEL_AIR_MISSION { missionId }` (RTB immediately), `SET_SURGE_OPS { enabled }`. + +## 5. UI — Air Plan board + +- New AIR button in TopBar → AirOpsPanel (Panel, draggable like the rest): + per host (carrier/airbase): squadron rows (name, airframe, available/total, + next-ready countdown), active missions list (kind, flight, status, BINGO + countdown, CANCEL), mission composer: kind → squadron → flight size → + (station: click-map capture like satellite tasking / target: enemy contact + picker) → SEAD/extended-range checkboxes → LAUNCH. SURGE OPS toggle with + the 96 h consequence spelled out. +- Map: Flight units render with existing aircraft icon; CAP/AEW stations get + a small racetrack glyph (IntelLayers-style); strike flights show a target + line when selected. +- ViewState: `airMissions` slice (player nation only) + squadron pools on + ViewUnit (own units only, scrubbed for enemies — enemy pools NEVER leak; + BDA estimates come via imagery product captions only). + +## 6. Out of scope (→ BACKLOG) + +Individual airframes, fuel-liter modeling, tanker tracks, weather aborts, +A2A missile entities (pK roll abstraction instead), runway cratering state +(airbase health already gates), carrier deck cycles visualization. + +## 7. Build split + +Scaffold (orchestrator): types + commands + airframe/squadron data files + +air-ops.ts skeleton + game-engine wiring + view slice. +- C1: air-ops.ts full implementation + ai scramble hook + tests. +- C2: AirOpsPanel + TopBar AIR + map glyphs + tests. +- C3: data verification pass (CVW-9 + Iranian orbat numbers vs research), + BDA/pool wiring on airbase damage + imagery captions, pilot-fate events + + AlertFeed formatters + OSINT feed coverage, tests. +- T: smoke extension (launch CAP, see flight on map, RTB). diff --git a/docs/plans/intel-suite-v3.md b/docs/plans/intel-suite-v3.md new file mode 100644 index 0000000..420496a --- /dev/null +++ b/docs/plans/intel-suite-v3.md @@ -0,0 +1,336 @@ +# Intel Suite v3 — SIGINT, IMINT, HUMINT, OSINT, counterespionage + +Binding design for the v3 build wave. Builds on game-loop v2 fog of war +(`docs/plans/game-loop-v2.md`) and the v3 combat-on-contacts commit (radar +horizon, fire-control quality, auto-engagement). Research basis: 5-agent web +research sweep 2026-06-10 (US ISR architecture, OSINT ecosystem, verified free +data feeds, game design references, Iran counterintelligence). + +Design goals, in priority order: + +1. The USA player CAN reach near-total knowledge of Iran — but only by tasking + assets well (tip-and-cue: wide-area sensors tip, expensive collection confirms). +2. Intel is load-bearing: TEL hunting, decoy discrimination and strike warning + are unsolvable without collection. +3. Counterespionage cuts both ways: Iran hunts the player's sources and leaks + the player's operations; the player has OPSEC verbs to fight back. +4. Presentation mirrors real intel products using REAL data where free and + verified: real satellite imagery of the real bases, a genuinely live + geostationary weather satellite, real air traffic, real cloud cover. +5. No espionage minigames. Every intel action is 1–2 clicks, resolved by + readable odds. Products (imagery, intercepts, reports) are presentation + rewards that show exactly the knowledge earned — never knowledge the sensor + network does not have. + +## 0. Verified real-data stack (all keyless + CORS unless noted) + +Central config module `src/data/feeds.ts` — every URL lives there with a +comment naming what was verified 2026-06-10. Degrade gracefully: every consumer +must handle fetch failure by falling back to simulated/synthetic content. + +| Source | Use | Notes | +|---|---|---| +| Esri World Imagery XYZ `https://server.arcgisonline.com/ArcGIS/rest/services/World_Imagery/MapServer/tile/{z}/{y}/{x}` | High-res IMINT product backdrops + FMV feed scenery (z up to 16) | path order is z/y/x. Attribution required. Free app only. | +| NASA Worldview Snapshot `https://wvs.earthdata.nasa.gov/api/v1/snapshot?REQUEST=GetSnapshot&LAYERS=VIIRS_SNPP_CorrectedReflectance_TrueColor&CRS=EPSG:4326&TIME={YYYY-MM-DD}&BBOX={s},{w},{n},{e}&WIDTH=768&HEIGHT=512&FORMAT=image/jpeg` | One-fetch date-stamped recon JPEG for "daily pass" products | Pin explicit date; default yesterday UTC, try today, 404 = "not downlinked yet" | +| NASA GIBS WMTS `https://gibs.earthdata.nasa.gov/wmts/epsg3857/best/VIIRS_SNPP_CorrectedReflectance_TrueColor/default/{time}/GoogleMapsCompatible_Level9/{z}/{y}/{x}.jpg` | Optional map base-layer toggle "DAILY RECON MOSAIC" | maxzoom 9, time pinned yesterday UTC. Never TIME=default (resolves to tomorrow → 404). | +| EUMETSAT EUMETView WMS `https://view.eumetsat.int/geoserver/wms?service=WMS&request=GetMap&version=1.3.0&layers=msg_iodc:{layer}&styles=&format=image/jpeg&crs=EPSG:4326&bbox={s},{w},{n},{e}&width=640&height=480` layers `rgb_naturalenhncd` (day) / `ir108` (night-capable) | "GEOSAT IODC LIVE" window — genuinely live Meteosat-9 over the Gulf, 15-min cadence | Refresh every 15 min of REAL time | +| airplanes.live `https://api.airplanes.live/v2/point/{lat}/{lon}/{radius_nm}` | "LIVE ADS-B" map layer — real civilian/military aircraft over the Gulf | ~1 req/s limit → poll every 45 s, radius ≤ 250 nm | +| Open-Meteo `https://api.open-meteo.com/v1/forecast?latitude={lat}&longitude={lon}¤t=cloud_cover` | Real cloud cover gates optical satellite tasking | One fetch per tasking, UI-side; fallback = seeded rng | +| YouTube live embed, video id `osUeQTR91Ig` (Reuters "Vessel traffic in Strait of Hormuz") | OSINT ambience cam in the LIVE FEEDS window | iframe embed verified via oEmbed; if unavailable show SIGNAL LOST card | +| NOT in v3 | OpenSky (CORS-locked, needs proxy), live AIS (key+relay), Windy webcams (key+token), GIBS fires MVT, USGS quakes, RainViewer | BACKLOG.md | + +Compliance: game stays free/non-revenue. LIVE FEEDS window footer gets an +"INTEL SOURCES" credits panel styled as an agency acknowledgment page: Esri · +Maxar, NASA GIBS, EUMETSAT 2026, airplanes.live, Open-Meteo, Reuters. + +Engine purity rule: the worker NEVER fetches. UI fetches real data and passes +results into commands (e.g. cloud cover) or renders it presentation-side only. +Engine outcomes must stay deterministic given commands + seed. + +## 1. Engine: intel assets + tasking (`src/engine/systems/intel.ts`) + +New module-level system, `resetIntelState()` + save/load like war-support. +State lives in `state.intel` (new `IntelState` in types/game.ts): + +```ts +interface IntelState { + assets: Record // per-nation ISR assets + agents: Record // player HUMINT sources + products: IntelProduct[] // last 30 imagery/report products (metadata only) + taskings: SatTasking[] // queued satellite taskings + paranoia: number // 0-100 Iranian counterintel alert + encryptionUpgradedUntilTick?: number // SIGINT blackout window + leakLevel: number // 0-100 how compromised the player's ops are + lastSweepTick?: number // player OPSEC sweep cooldown anchor +} +``` + +### 1.1 ISR assets (fixed roster, data in `src/data/intel/assets.ts`) + +USA: `kh11` (LEO optical, revisit 4h base — existing satellite entries get +names/types), `commercial` (revisit 90min, lower quality, products marked +UNCLASSIFIED//COMMERCIAL), `rc135` (SIGINT standoff — drives intercept cadence +while alive), `mq4c_triton` (wide-area maritime — coarse 'detected' refresh of +ships in the Gulf box every 30 game-min), `sbirs` (always-on launch detection — +already exists as launch-plume reveals; now also emits a FLASH intercept card). +Iran: `noor` (coarse optical, revisit 8h), `mohajer10` (drone orbit over the +strait — refreshes carrier contact), `fastboats` (IRGC shadowing — carrier +coarse-tracked while inside the Hormuz approaches box). + +Assets are abstract (no map unit) except where a unit already exists. Each has +`status: 'active' | 'lost'`, cooldowns, and for Iran assets a kill-path noted +in BACKLOG (out of scope to destroy them in v3 except via events). + +### 1.2 Satellite tasking — the IMINT verb + +Command `TASK_SATELLITE_PASS { assetId: 'kh11' | 'commercial', target: Position, cloudPct?: number }`. +UI flow: INTEL → ISR tab → TASK PASS → click map (or "TASK PASS" button on a +contact). Queues a `SatTasking`; resolves at the asset's next pass window +(kh11: next tick where `(tick - lastPassTick) >= revisit`, max 1 queued per asset). + +Resolution: +- cloudPct ≥ 70 → pass FAILED (`SATELLITE_PASS_FAILED` event, asset cooldown + halved so retry is cheap). cloudPct comes from the command (UI fetched real + weather); if absent, seeded rng 0–100. +- Success: all enemy units within `swathKm = 60` of target get contact refresh: + fixed sites + units already tracked → `identified`; others → `tracked`. + Decoys within swath: kh11 pass REVEALS them (NIIRS 7+); commercial does not. +- Emits `SATELLITE_PASS_COMPLETE { assetId, target, found: number, revealedDecoys: number }` + and pushes an `IntelProduct { kind: 'imint', assetId, target, tick, niirs, caption, classification }`. + The UI renders the product with REAL imagery fetched at view time (engine + stores metadata only). +- Each pass over Iranian soil: `paranoia += 4` (kh11) / `+2` (commercial). + +### 1.3 SIGINT intercepts + +While `rc135` active and not in an encryption-upgrade window: every +`INTERCEPT_INTERVAL = 20 game-min ± jitter`, scaled by `sigint_pct` budget, +emit `INTERCEPT_DECRYPTED { precedence, text, aboutUnitId?, leakKind }` choosing +from true-state leaks (priority order): + +1. Pending Iran AI salvo in the next 30 game-min → `FLASH` warning naming the + target region ("FLASH: missile brigade ordered to combat readiness — + expect fires vs PRINCE SULTAN AB within the hour"). +2. A hidden (unseen/detected) Iranian `missile_battery` or `sam_site` → + reveal at `detected` + `IMMEDIATE` card with a location ellipse reference. +3. War-support state ("PRIORITY: Tehran leadership cohesion failing") when + Iran support < 45. +4. Filler chatter (`ROUTINE`, no game effect) otherwise. + +Every intercept: `paranoia += 2`. When `paranoia ≥ 70` and at war: Iran rolls +encryption upgrade — `ENCRYPTION_UPGRADED` event, no intercepts for 6 game-h, +paranoia resets to 40. SIGINT cards live in `IntelState.products` as +`kind: 'sigint'`. + +### 1.4 HUMINT — named sources (the burn-the-source loop) + +Exactly 4 named sources in `src/data/intel/agents.ts`, USA-side only in v3: + +| id | codename | placement | product | +|---|---|---|---| +| `amber` | AMBER | Bandar Abbas port clerk | naval base activity; reveals ships in Bandar Abbas/Jask boxes at `tracked`; sortie warnings | +| `opal` | OPAL | IRGC logistics officer | TEL hunt: reveals 1-2 hidden `missile_battery` at `identified` per tasking | +| `saffron` | SAFFRON | Tehran ministry aide | political: Iran war-support exact value + ceasefire intent for 2 game-h | +| `garnet` | GARNET | Strait observer w/ camera | enables the LIVE OBSERVER feed on the Hormuz box; passive +tracked refresh of ships transiting the strait every 30 game-min while active | + +`AgentSource { id, codename, placement, product, status: 'active'|'resting'|'exfiltrated'|'arrested', exposure: 0-100, lastTaskedTick }`. + +Verbs (commands): `TASK_AGENT { agentId }` (immediate report + effect, +`exposure += 15 + paranoia/5`, 1 game-h cooldown), `REST_AGENT` (status +resting: no products, exposure decays 1/game-h instead of rising), +`EXFILTRATE_AGENT` (after 6 game-h delay → safely removed, product lane lost). + +Iranian spy sweeps: when `paranoia ≥ 50`, every 4 game-h Iran sweeps: each +active/resting source rolls `chance(exposure/200 + paranoia/400)` → ARRESTED: +`AGENT_ARRESTED` event, source lost, `leakLevel += 10` (interrogation), USA +war-support −3, Iran war-support +2. Feed + INTEL panel show it loudly. + +### 1.5 Counterespionage — Iran spies on the player + +`leakLevel` 0–100 (starts 25): Iran's insight into player operations. +- Rises: +1/game-h while carrier inside Hormuz approaches box (fast boats), + +5 per player strike launched (pattern analysis), +10 per arrested agent. +- Effects: while `leakLevel ≥ 60`, player `LAUNCH_MISSILE`/`LAUNCH_SALVO` + commands have a `leakLevel/200` chance to emit `STRIKE_LEAKED` — Iran AI + gets +1 defensive readiness: targeted unit (if mobile, undamaged) relocates + ~15 km and its contact for USA degrades to `detected` (it moved), and Iran + point-defense readiness event fires. Player sees "STRIKE COMPROMISED — + target displaced before impact" only via OSINT/feed after the miss. +- Iran's coarse carrier picture (asymmetry rule): every 30 game-min, if the + player carrier_group is within the Gulf/strait OSINT box, Iran's contact on + it refreshes at `detected` (at `tracked` while leakLevel ≥ 60 or mohajer10 + active). EMCON does NOT hide the carrier from this (you can't hide a CSG + from port spotters) — it only denies ELINT/precision (see 1.6). + +Player verbs: `OPSEC_SWEEP` (cooldown 6 game-h: `leakLevel -= 25`, event +`OPSEC_SWEEP_COMPLETE`), and EMCON (1.6). + +### 1.6 EMCON + +`SET_EMCON { unitId, emcon: boolean }` — unit radar stops radiating: +- visibility.ts: unit excluded from `ownRadars` (no contributions to own picture) + and `radarSeesUnit` returns unseen for it (fire-control 'own' lost). +- sensor-network ELINT: emcon units are NOT ELINT-detectable (both the per-tick + network and visibility's `isElintDetected`). +- detection.ts missile defense: emcon unit cannot detect threats with own radar + (datalink network picture still applies — that's the CEC trade-off). +- UnitInfoPanel gets an EMCON toggle next to ROE. + +### 1.7 Decoys + +At war start (or when Iran enters DEFENSIVE), spawn `DECOY_COUNT = 4` decoy +units near real Iranian missile_battery clusters: real `Unit`s with +`isDecoy: true`, category `missile_battery`, no weapons, health 40, name +"Missile TEL group" — indistinguishable at detected/tracked. Revealed by: +kh11 pass over them (1.2), HUMINT OPAL report, or destroying one (BDA shows +no secondaries). Revealed → `DECOY_REVEALED` event; ViewUnit gets +`decoyRevealed: true` → rendered desaturated with DECOY tag. Striking an +unrevealed decoy: normal impact, but war-support loss for Iran is 0 and Iran +gains +1 support (propaganda); the player wasted missiles — that's the lesson. +war-support.ts: losses ignore `isDecoy` units. toViewUnit: `isDecoy` NEVER +leaks for unrevealed decoys (snapshot scrubbing test required). + +### 1.8 Tick order + +`processIntel(state, rng, grid)` runs after `processVisibility` (consumes fresh +contacts) and before `processWarSupport`. All reveals route through the same +contact bookkeeping as visibility.ts (export small helpers there rather than +duplicating decay logic). + +## 2. OSINT feed (UI-side, `src/intel/osint-feed.ts`) + +Pure consumer of the snapshot event stream — no engine state. Generator keyed +on `(event, account)` with per-account delay/noise/false-rate; posts surface in +the INTEL → OSINT tab and the 3 most recent as a collapsed ticker above the +AlertFeed. Roster (`src/data/intel/osint-accounts.ts`), all fictional handles: + +| handle | archetype | delay | reliability | +|---|---|---|---| +| `@GulfPlaneWatch` | base plane-spotter | 1-3 min | high — launches/sorties near bases | +| `@CENTCOM_Watch` | aggregator | 2-6 min | 85% — BREAKING style, occasionally wrong target names | +| `@TankerTrackerz` | oil-flow analyst | 12-24 game-h | high — Hormuz status, oil price commentary | +| `@OrbitalRecon` | imagery analyst | 6-12 game-h | high — BDA after strikes ("crater analysis suggests...") | +| `@IRGC_Media` | regime mouthpiece | 5-15 min | inflated claims, true readiness chatter | +| `@StraitSpotter` | webcam watcher | 2-8 min | ship transits, mine sightings | +| `@SignalDesk` | leak channel | minutes BEFORE Iran salvos (when leakLevel < 40) | jittered warnings | +| `@PizzaIndexGulf` | joke indicator | ~1 game-h before player-visible AI escalation | 60% | + +False posts: aggregator/mouthpiece occasionally emit posts about events that +did not happen (recycled-footage flavor); cross-checking against sensors is the +intended skill. Posts referencing player operations (carrier transit, big +salvos) appear too — visible reminder that OSINT cuts both ways. + +## 3. UI — INTEL command center + product viewers + +### 3.1 `IntelCommandCenter.tsx` (replaces IntelPanel content; keep budget sliders in ISR tab footer) + +Tabs: **ISR · SIGINT · HUMINT · OSINT · OPSEC** + +- ISR: asset cards (status, next-pass countdown), TASK PASS flow (click-map + capture like the estimate-placement flow), product gallery (thumbnail grid, + click → IMINT viewer). Budget sliders move here. +- SIGINT: intercept cards newest-first, precedence-tagged (FLASH red pulse, + IMMEDIATE amber, PRIORITY white, ROUTINE muted), encryption-upgrade banner + with countdown when active. +- HUMINT: one card per source: codename, placement, product line, exposure bar + (green→red), TASK / REST / EXFILTRATE buttons with readable risk ("Tasking + raises exposure ~18%"). Arrested sources stay as tombstone cards. +- OSINT: the feed (2), with account filter chips. +- OPSEC: leakLevel gauge ("OPERATIONS SECURITY"), what's driving it (list), + OPSEC SWEEP button + cooldown, EMCON quick-toggles for radar ships, Iranian + paranoia estimate (fuzzy: LOW/ELEVATED/HIGH/SEVERE). + +### 3.2 IMINT product viewer (`ImintViewer.tsx`) + +Full-screen modal, classified-product dress: black frame, +`TOP SECRET//TK//NOFORN` banner (or `UNCLASSIFIED//COMMERCIAL`), real Esri +imagery crop centered on product.target (simple `` tile mosaic 3×2 at the +zoom where swath ≈ frame, no canvas), crosshair + AOI bracket overlays, NIIRS +rating, sensor + acquisition Z-time stamps, auto-caption ("2× probable TEL +group, 1× SA-15 type emitter"), grain/scanline CSS overlay. SAVE TO BOARD +pins it to the product gallery. + +### 3.3 LIVE FEEDS window (`LiveFeeds.tsx`) + +Dockable window (toggle in TopBar: LIVE) with a 2×2 grid: +1. **GEOSAT IODC LIVE** — EUMETSAT WMS img, refreshed every 15 real-min, + day layer by local day/night at the Gulf (rgb day / ir108 night), timestamp. +2. **HORMUZ TRAFFIC CAM** — Reuters YouTube live iframe; SIGNAL LOST card on error. +3. **ISR FMV** — synthetic drone soda-straw: Esri z15-16 imagery of a selected + contact/AOI, slow Ken-Burns drift, IR-style filter (invert+contrast) at + night, noise/scanline shader, REC dot, corner telemetry (coords, ALT, SLANT), + crosshair. Source select: any tracked+ contact (or GARNET's strait box). +4. **ADS-B LIVE** — toggle that also enables the map layer; in-window list of + the 10 nearest real aircraft (callsign, alt, speed) from airplanes.live. +Footer: INTEL SOURCES credits (compliance, styled as agency acknowledgments). + +### 3.4 Map layers (`IntelLayers.ts` + UnitLayer touches) + +- AOU ellipses: stale contacts get a growing dashed circle + `radius = min(60, 4 + minutesSinceSeen × speedFactor)` km. +- Sensor rings: selected own unit shows radar ring capped at the horizon vs + surface targets (teaches the new physics); dim second ring = nominal range. +- Satellite swath preview during TASK PASS placement + 60 km AOI circle on + queued taskings with next-pass countdown label. +- Decoy styling: revealed decoys desaturated + DECOY tag. +- ADS-B layer: real aircraft as small neutral-gray tracks w/ heading, callsign + on hover. Clearly non-interactive (flavor), toggle in MapToggle. +- DAILY RECON MOSAIC: GIBS VIIRS raster source toggle (maxzoom 9, yesterday). + +### 3.5 Time slider (TopBar) + +Replace/augment the fixed speed buttons with a continuous slider (user +request): log-scale drag 0 → 3600 (PAUSE · 1× · 8× · 60× · 10m/s · 1h/s +detents with snap), current multiplier label ("×480"), keyboard +/- steps +between detents, pause button stays separate. Engine already accepts any +number via SET_SPEED; `GameLoop` bursts `round(speed)` ticks per 100 ms with +an 80 ms budget, so the slider needs no engine change. Presets remain as +click-targets under the slider. TopBar is owned by U2 in this wave (slider + +LIVE toggle) to avoid file collisions. + +### 3.6 View-state additions (types/view.ts) + +`GameViewState` gains `intel: { assets, agents, products(latest 30), taskings, leakLevel, paranoiaBand, encryptionUpgradedUntilTick }`, +`ViewUnit` gains `emcon?: boolean`, `decoyRevealed?: boolean`. Snapshot +scrubbing: products/agents are player-nation only; decoy truth only when revealed. + +## 4. Events (types/game.ts) + +`SATELLITE_PASS_COMPLETE`, `SATELLITE_PASS_FAILED`, `INTERCEPT_DECRYPTED`, +`AGENT_REPORT`, `AGENT_ARRESTED`, `AGENT_EXFILTRATED`, `SPY_SWEEP`, +`ENCRYPTION_UPGRADED`, `DECOY_REVEALED`, `STRIKE_LEAKED`, `OPSEC_SWEEP_COMPLETE`. +All get AlertFeed formatters + colors; FLASH intercepts and AGENT_ARRESTED join +the auto-pause options. + +## 5. Commands (types/commands.ts) + +`TASK_SATELLITE_PASS`, `TASK_AGENT`, `REST_AGENT`, `EXFILTRATE_AGENT`, +`OPSEC_SWEEP`, `SET_EMCON`. + +## 6. Build plan (parallel agents, disjoint files) + +Scaffold (me, first commit): types/game.ts + commands.ts + view.ts deltas, +`intel.ts` skeleton (exported signatures + reset + save/load wiring), +`feeds.ts`, data files (assets/agents/osint-accounts) with full content, +game-engine wiring (tick order, command cases, snapshot slice), EMCON hooks in +visibility/sensor-network/detection (small, central). + +- **E1**: intel.ts full implementation + war-support decoy exception + tests + (taskings, intercepts incl. encryption window, sweeps/exposure, leakLevel, + decoy spawn/reveal, EMCON visibility effects, save/load round-trip). +- **U1**: IntelCommandCenter + ui-store + UnitInfoPanel EMCON toggle + panel tests. +- **U2**: ImintViewer + LiveFeeds + credits + osint-feed.ts generator + ticker + + TopBar (time slider §3.5 + LIVE toggle). +- **U3**: IntelLayers (AOU, sensor rings, swaths, ADS-B, GIBS toggle) + decoy + styling + MapToggle entries. +- **T**: extend `scripts/e2e-smoke.mjs`: open INTEL, task a pass, verify a + product appears; check OSINT tab renders posts; toggle EMCON; verify LIVE + window opens with all four quadrants (network feeds may show fallback cards). + +## 7. Out of scope (→ BACKLOG.md) + +Missions/doctrine cascade, WAMI rewind, HVT person-tracking chains, underground +facility model, shutter control, disinfo plants, internet blackout, OpenSky +proxy, aisstream relay, Windy webcams, USGS seismic ticker, RainViewer, GIBS +fires layer, Iranian asset destruction, Staff Summary panel, NIIRS-gated +bunker-buster folders. diff --git a/docs/plans/roadmap-v4.md b/docs/plans/roadmap-v4.md new file mode 100644 index 0000000..bc4cb19 --- /dev/null +++ b/docs/plans/roadmap-v4.md @@ -0,0 +1,147 @@ +# Roadmap v4 — toward the best browser military sim + +Synthesized from a 5-agent research sweep 2026-06-11 (3D/terrain, air ops, TOT + +elevation, presentation, longevity). Every external source named here was +live-verified (curl) on 2026-06-11. Hard constraints unchanged: free/non-revenue, +purely client-side, Vercel static hosting. + +Waves are ordered by recommendation. Effort is calendar-honest for this codebase. + +## Wave A — Feel (each item ~hours-to-2-days, all prerequisites already shipped) + +1. **Hillshade relief, always on.** Add a `raster-dem` source to both map styles — + verified keyless+CORS sources: AWS Terrain Tiles + `https://s3.amazonaws.com/elevation-tiles-prod/terrarium/{z}/{x}/{y}.png` + (encoding `terrarium`, tileSize 256, native detail to z12 = SRTM 30 m) and + `tiles.mapterhorn.com` (webp, far lighter, used by MapLibre's official 3D + example). Dark-tuned hillshade layer below `overlay-anchor`; retire the static + elevation overlay image. Attribution line: Mapzen/Tilezen + providers. +2. **TOT strikes (time-on-target).** `launch_i = TOT − flightTime_i` on top of the + just-shipped `state.scheduledLaunches`. One master TOT control in the strike + panel (default "earliest feasible"), read-only derived launch schedule, never + per-launcher inputs (CMO/DCS pattern; CMO players used Excel before they got + this). Plan summary shows estimated leakers all-at-TOT vs spread (fire-channel + saturation math already exists) and a "first launch at T−Xh may be detected" + warning when slow weapons are in the package — April vs October 2024 Iran + strikes as emergent gameplay. Staggered/sequential become impact-window + spreads on the same queue. +3. **Unit images in tooltips + pinned card.** 22-unit verified image list + (research doc): US units all public-domain DoD photos on Wikimedia Commons; + Iranian split PD-by-US-Navy + Tasnim/Fars CC BY 4.0 (credit line required). + Hotlinking rejected (Commons 400s non-standard widths, discouraged) → build + script downloads via `Special:FilePath?width=500`, re-encodes 250 px WebP into + `public/unit-images/` (~400 KB total) + generates `src/data/unit-images.ts` + with license metadata. Image shows only at `identified` level (intel reward). + Credits panel entry mandatory incl. DoD non-endorsement disclaimer. +4. **Minimal sound kit.** 8 CC0 sounds (Kenney Interface Sounds + freesound CC0: + klaxon, sonar ping #493162, radio squelch, launch whoosh, distant impact, CIC + room tone, UI blips, error). AudioManager: master/sfx/ambient GainNodes, + `ctx.resume()` on first gesture (autoplay rule), localStorage mute/volume, + one pure event→sound table. DEFCON precedent: sound is the cheapest immersion + multiplier in the genre. No camera shake — ops rooms flash and sound, never shake. +5. **Combat legibility:** DEFCON-style kill markers (expanding blot → persistent + faint X + tally), contact track trails via `TripsLayer` (~30-point ring buffer, + ~10 game-min trail, fadeTrail), 200-300 ms opacity transitions on range rings, + 300 ms tooltip hover delay + click-to-pin. + +## Wave A addendum + +6. **Engine scale stress test.** Synthetic scenario (~500 units, missiles in + flight) driven 1000+ ticks in a vitest perf guard: assert average tick stays + inside budget, log p95. Answers "do we need a faster engine" with numbers + instead of vibes (decision 2026-06-11: stay TS, harden, WASM only if a + measured hot path ever demands it). + +## Wave B — TILT 3D — CUT (player decision 2026-06-11: no 3D) + +Kept for the record only. Hillshade (Wave A.1) is 2D shading and stays. +Do not add pitch/terrain/sky features. + +Everything needed ships in installed maplibre-gl 4.7.1 (terrain + sky + hillshade; +v5/globe explicitly unnecessary). Add `terrain {source, exaggeration ~1.1}` + `sky` +as style-root properties in BOTH styles (survives MAP/SAT swaps), maxPitch 70, +TILT toggle flies pitch 0 ↔ ~55. Default OFF (protect 30 fps baseline). +- deck.gl interop truth: keep `interleaved: false` (interleaved+terrain is broken, + deck.gl#8091). Overlay mode = icons draw over ridges — correct for a command UI. + Naval z=0 IS sea level; land units get z from the main-thread elevation grid; + IconLayer billboards stay readable under pitch. +- Missile arcs: carry altitude into `Missile.path` as [lng,lat,alt], PathLayer with + z + follow-cam at pitch ~60 for ballistic arcs — the visual payoff. +- Optional: maplibre-contour (BSD-3, same terrarium tiles) contour toggle for the + tactical-chart look at pitch 0. +- Do NOT: interleaved mode, deck.gl TerrainLayer basemap, maplibre v5 migration, + Cesium-style full 3D. Watch deck.gl#10173 (fractional browser zoom drift in + installed 9.2.11). + +## Wave C — Air war (the big gameplay feature, ~1-2 weeks) + +Convergent design from CMO/Falcon/DCS Liberation: squadrons as pools, flights as +transient tracks, orders at mission level only. Slots into shipped systems +(contacts, datalink hubs, SAM envelopes, war support, satellite BDA products). +1. `airWing: SquadronState[]` on carrier + airbases. CVW-9 real 2026 composition + (3×12 F/A-18E/F, 10 F-35C, 6 EA-18G, 5 E-2D — Lincoln really is in CENTCOM); + Iran: F-14 Isfahan, MiG-29 Mehrabad/Tabriz, Su-24 Bushehr, Su-35 Hamadan. + 25-33% maintenance unavailability at start. +2. One Flight entity (2-4 airframes folded into one `aircraft`-category unit so + movement/visibility/SAM/war-support work day one) + three verbs: CAP station + (auto-intercept via combat-on-contacts), Strike package (transit → release → + spawn existing missiles → RTB), AEW station (E-2D/E-3 pattern, datalink hub). +3. Sortie economy = CMO's published numbers: 90-min CAP quick-turn, 6 h surge / + 20 h sustained strike ready times, ~90-120 sorties/day carrier ceiling, one + global SURGE OPS lever (96 h half ready-times, then ×1.5 sustained). +4. SEAD as EA-18G escort modifier (×0.6 SAM detect/pk in radius + emitting SAMs + become ELINT contacts). Tanker tax not tanker tracks (+30-40% radius costs 2 + Super Hornet sorties; MQ-25 slipped to 2029). Strike-package planning delay + 2-6 h paces the war and rewards the intel workflow. +5. Iran plays a different air game: scramble-only interceptors vs approaching + packages (ai.ts escalation hook), hoarded Su-35 HVT, parked airframes as + strikeable airbase sub-targets whose loss permanently drains pools (BDA via + the shipped imagery products — June 2025 ramp strikes). Pilot stakes: KIA / + rescued / POW rolls feed war support + OSINT. +6. UI = Air Plan board (Task Force Admiral pattern): squadrons × launch-cycle + windows, drop missions into cycles, engine air boss resolves. Read + dcs-retribution's flight-plan builder for auto-escort suggestion logic. + +## Wave D — Elevation fidelity (~2-4 days) + +Two tiers (current grid ~5.5 km aliases whole Zagros ridges): +1. Build-time: regenerate static grid at 0.01° (~1.1 km) for the Gulf core box + (lat 22-34, lng 44-64) as Int16 (~4.8 MB raw, ~2 MB brotli) from Copernicus + GLO-30 COGs (verified keyless, HTTP Range OK, NO CORS → build-time only). + Header gains a version field; ElevationGrid gains bilinear sampling. +2. Runtime: z11 terrarium tile LRU (128-256 tiles, 17-34 MB) decoded in the + worker via createImageBitmap + OffscreenCanvas for hot paths (LOS viewsheds, + TEL hide checks, cruise terrain-following) — ~68 m/px = DTED Level 1, the + real military LOS standard. Coarse grid stays as fallback. + +## Wave E — Longevity (pick per appetite) + +1. **Command-log replay foundation** (FIRST — unlocks everything below): record + (tick, Command) in executeCommand, seed+appVersion+scenarioHash in the save + envelope, periodic syncHash. Watch-replay mode = re-simulate in a second + worker. Caveat: JS trig isn't cross-engine deterministic — same-browser safe, + pin versions (OpenRA sync-hash pattern). +2. **Daily Flashpoint**: date-hashed seed + fixed objectives + score + Wordle-style + emoji share block with `#c=base64url(gzip())` challenge URL (CompressionStream, + zero backend). +3. **Scenario import/export + URL loading**: .json export/drag-drop import; load + from raw.githubusercontent/gist (CORS verified `*`). The JSON IS the editor. +4. **Community gallery**: ashfall-scenarios repo + index.json via jsDelivr — + Steam Workshop with zero infrastructure, PR-based curation. +5. **Persistent theater week**: dawn-planning/dusk-debrief day cycle, persistent + losses/ammo, sortie fatigue, procurement, one IndexedDB campaign slot (DCS + Liberation loop compressed to 7 browser days). Do after Wave C. +6. **Rival commander persona**: templated communiqués from GameEvents into the + OSINT feed, seeded-deterministic. WebLLM paraphrase = optional toggle only + (multi-GB download is hostile onboarding) — never a dependency. +7. **ADVISOR onboarding**: interactive checklist completing by observing real + Commands + diegetic Watch Officer's Handbook (Highfleet charm, not CMO videos). +8. **Async 1v1 PBEM** LAST: WEGO command-packet exchange reusing replay machinery. + Realtime PeerJS lockstep deferred until trig determinism is solved. + +## Recommended order + +A (feel, incl. TOT) → C (air war) → B (TILT 3D) → D (elevation) → E1-E4 +(replay/daily/sharing) → E5 (campaign) → rest. Wave A first because every minute +of play gets better immediately; C is the biggest pure-gameplay hole; B and D are +spectacle and fidelity on top of a game that by then deserves them. diff --git a/public/unit-images/arleigh_burke.jpg b/public/unit-images/arleigh_burke.jpg new file mode 100644 index 0000000..72b0aa3 Binary files /dev/null and b/public/unit-images/arleigh_burke.jpg differ diff --git a/public/unit-images/bavar373.jpg b/public/unit-images/bavar373.jpg new file mode 100644 index 0000000..c92fd4e Binary files /dev/null and b/public/unit-images/bavar373.jpg differ diff --git a/public/unit-images/e3_sentry.jpg b/public/unit-images/e3_sentry.jpg new file mode 100644 index 0000000..17c9b04 Binary files /dev/null and b/public/unit-images/e3_sentry.jpg differ diff --git a/public/unit-images/fateh110.jpg b/public/unit-images/fateh110.jpg new file mode 100644 index 0000000..25f3873 Binary files /dev/null and b/public/unit-images/fateh110.jpg differ diff --git a/public/unit-images/ghadir_sub.jpg b/public/unit-images/ghadir_sub.jpg new file mode 100644 index 0000000..10589bd Binary files /dev/null and b/public/unit-images/ghadir_sub.jpg differ diff --git a/public/unit-images/iran_airbase.jpg b/public/unit-images/iran_airbase.jpg new file mode 100644 index 0000000..c73748c Binary files /dev/null and b/public/unit-images/iran_airbase.jpg differ diff --git a/public/unit-images/irgc_fac.jpg b/public/unit-images/irgc_fac.jpg new file mode 100644 index 0000000..31f9b27 Binary files /dev/null and b/public/unit-images/irgc_fac.jpg differ diff --git a/public/unit-images/khordad3.jpg b/public/unit-images/khordad3.jpg new file mode 100644 index 0000000..2fb114d Binary files /dev/null and b/public/unit-images/khordad3.jpg differ diff --git a/public/unit-images/nimitz_cvn.jpg b/public/unit-images/nimitz_cvn.jpg new file mode 100644 index 0000000..dcbeaad Binary files /dev/null and b/public/unit-images/nimitz_cvn.jpg differ diff --git a/public/unit-images/noor_coastal.jpg b/public/unit-images/noor_coastal.jpg new file mode 100644 index 0000000..a898c04 Binary files /dev/null and b/public/unit-images/noor_coastal.jpg differ diff --git a/public/unit-images/nsa_bahrain.jpg b/public/unit-images/nsa_bahrain.jpg new file mode 100644 index 0000000..67a5c82 Binary files /dev/null and b/public/unit-images/nsa_bahrain.jpg differ diff --git a/public/unit-images/patriot.jpg b/public/unit-images/patriot.jpg new file mode 100644 index 0000000..0f668f8 Binary files /dev/null and b/public/unit-images/patriot.jpg differ diff --git a/public/unit-images/s300pmu2.jpg b/public/unit-images/s300pmu2.jpg new file mode 100644 index 0000000..457090b Binary files /dev/null and b/public/unit-images/s300pmu2.jpg differ diff --git a/public/unit-images/sejjil2.jpg b/public/unit-images/sejjil2.jpg new file mode 100644 index 0000000..449dff1 Binary files /dev/null and b/public/unit-images/sejjil2.jpg differ diff --git a/public/unit-images/shahab3.jpg b/public/unit-images/shahab3.jpg new file mode 100644 index 0000000..7507fac Binary files /dev/null and b/public/unit-images/shahab3.jpg differ diff --git a/public/unit-images/shahed136.jpg b/public/unit-images/shahed136.jpg new file mode 100644 index 0000000..2c7a756 Binary files /dev/null and b/public/unit-images/shahed136.jpg differ diff --git a/public/unit-images/soumar.jpg b/public/unit-images/soumar.jpg new file mode 100644 index 0000000..036b62f Binary files /dev/null and b/public/unit-images/soumar.jpg differ diff --git a/public/unit-images/thaad.jpg b/public/unit-images/thaad.jpg new file mode 100644 index 0000000..85b7e5c Binary files /dev/null and b/public/unit-images/thaad.jpg differ diff --git a/public/unit-images/tor_m1.jpg b/public/unit-images/tor_m1.jpg new file mode 100644 index 0000000..6f1f490 Binary files /dev/null and b/public/unit-images/tor_m1.jpg differ diff --git a/public/unit-images/us_airbase.jpg b/public/unit-images/us_airbase.jpg new file mode 100644 index 0000000..cef6c29 Binary files /dev/null and b/public/unit-images/us_airbase.jpg differ diff --git a/public/unit-images/virginia_ssn.jpg b/public/unit-images/virginia_ssn.jpg new file mode 100644 index 0000000..ecd9227 Binary files /dev/null and b/public/unit-images/virginia_ssn.jpg differ diff --git a/public/unit-images/zolfaghar.jpg b/public/unit-images/zolfaghar.jpg new file mode 100644 index 0000000..1b6e6e9 Binary files /dev/null and b/public/unit-images/zolfaghar.jpg differ diff --git a/scripts/e2e-smoke.mjs b/scripts/e2e-smoke.mjs index 4d913be..89a8afa 100644 --- a/scripts/e2e-smoke.mjs +++ b/scripts/e2e-smoke.mjs @@ -49,14 +49,49 @@ await step('fog of war: SITREP shows contacts, not full enemy orbat', async () = await clickText('SITREP', { exact: true }) }) +await step('intel command center: tabs, assets, agents, opsec', async () => { + await clickText('INTEL', { exact: true }) + await page.getByText('KH-11 CRYSTAL').first().waitFor({ timeout: 8000 }) + await page.getByText('TASK PASS').first().waitFor({ timeout: 4000 }) + await clickText('HUMINT', { exact: true }) + await page.getByText('AMBER').first().waitFor({ timeout: 4000 }) + await page.getByText('OPAL').first().waitFor({ timeout: 2000 }) + await clickText('OSINT', { exact: true }) + await clickText('OPSEC', { exact: true }) + await page.getByText(/OPSEC SWEEP/i).first().waitFor({ timeout: 4000 }) + await clickText('SIGINT', { exact: true }) + await clickText('INTEL', { exact: true }) +}) + +await step('live feeds window opens with all four quadrants', async () => { + await clickText('LIVE', { exact: true }) + await page.getByText('GEOSAT IODC LIVE').first().waitFor({ timeout: 8000 }) + await page.getByText(/HORMUZ TRAFFIC CAM/i).first().waitFor({ timeout: 4000 }) + await page.getByText(/ISR FMV/i).first().waitFor({ timeout: 4000 }) + await page.getByText(/ADS-B/i).first().waitFor({ timeout: 4000 }) + await page.getByText(/INTEL SOURCES/i).first().waitFor({ timeout: 4000 }) + await clickText('LIVE', { exact: true }) +}) + +await step('time slider present in top bar', async () => { + const slider = page.locator('input[type="range"]').first() + await slider.waitFor({ timeout: 4000 }) +}) + await step('declare war', async () => { await clickText('DECLARE WAR') await clickText('CONFIRM WAR') await page.getByText('WAR: IRAN').first().waitFor({ timeout: 8000 }) }) -await step('run the war at speed', async () => { - await clickText('1h', { exact: true }) +await step('run the war at speed via the time slider', async () => { + await page.locator('input[type="range"]').first().evaluate(el => { + const setter = Object.getOwnPropertyDescriptor(window.HTMLInputElement.prototype, 'value').set + setter.call(el, el.max) + el.dispatchEvent(new Event('input', { bubbles: true })) + el.dispatchEvent(new Event('change', { bubbles: true })) + }) + await page.getByText('1h/s').first().waitFor({ timeout: 4000 }) await page.waitForTimeout(8000) }) diff --git a/scripts/fetch-unit-images.mjs b/scripts/fetch-unit-images.mjs new file mode 100644 index 0000000..9c70627 --- /dev/null +++ b/scripts/fetch-unit-images.mjs @@ -0,0 +1,230 @@ +/** + * Downloads one Wikimedia Commons recognition photo per unit class into + * public/unit-images/ and generates src/data/unit-images.ts with license + * metadata. Re-runnable; classes that fail (license, HTTP, not-a-JPEG) are + * dropped from the generated map and reported. + * + * node scripts/fetch-unit-images.mjs + * + * Hotlinking is rejected by design (Commons 400s non-standard widths and + * discourages it) — width 500/250 are allowed thumb widths. + */ +import fs from 'node:fs/promises' +import path from 'node:path' +import { fileURLToPath } from 'node:url' + +const root = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..') +const outDir = path.join(root, 'public', 'unit-images') +const tsOut = path.join(root, 'src', 'data', 'unit-images.ts') + +const UA = 'AshfallCommand/1.0 (browser wargame; one-time build script fetching unit recognition photos)' +const MAX_BYTES = 80 * 1024 +const WIDTHS = [500, 250] + +/** classKey → Commons file title (verified to exist + acceptable license, 2026-06-11) */ +const MANIFEST = [ + // — USA (public-domain DoD photography) — + ['nimitz_cvn', 'File:USS Abraham Lincoln (CVN-72) underway during sea trials on 11 May 2017.jpg'], + ['arleigh_burke', 'File:US Navy 050413-N-5526M-016 The Arleigh Burke-class guided missile destroyer USS Mustin (DDG 89) underway in the Northern Persian Gulf while conducting Maritime Security Operations.jpg'], + ['virginia_ssn', 'File:US Navy 110909-N-OV802-222 The Virginia-class submarine Pre-Commissioning Unit (PCU) California (SSN 781) gets underway from Naval Station Norfolk.jpg'], + ['patriot', 'File:MIM-104 Patriot surface-to-air missile system launcher.jpg'], + ['thaad', 'File:THAAD missile launch in 2005 -2.jpg'], + ['e3_sentry', 'File:Boeing E-3 Sentry 090512-F-7550B-902.jpg'], + ['us_airbase', 'File:Al Udeid Air Base.jpg'], + ['nsa_bahrain', 'File:Navy Day at Naval Support Activity Bahrain 140416-N-RJ323-385.jpg'], + // — Iran (Tasnim/Fars/Mehr press photography, CC BY 4.0 — credit required) — + ['s300pmu2', 'File:S-300 Tehran 2017.jpg'], + ['bavar373', 'File:Bavar-373 upgrade ceremony (23).jpg'], + ['khordad3', 'File:3rd Khordad TELAR w Taer-2 missiles.jpg'], + ['tor_m1', 'File:Tor M1.jpg'], + ['shahab3', 'File:Shahab missile launch photographed by Satyar Emami.jpg'], + ['sejjil2', 'File:Sejjil missile launch - November 2008 (09).jpg'], + ['fateh110', 'File:Firing Fateh-110 belong to AFAGIR.jpg'], + ['zolfaghar', 'File:Two IRGC AF Missiles.jpg'], + ['soumar', 'File:Unveiling ceremony of Soumar cruise missile and other missiles (13).jpg'], + ['ghadir_sub', 'File:2012 Bandar Abbas new equipment induction ceremony - Ghadir-class submarine (10).jpg'], + ['noor_coastal', 'File:شلیک موشک کروز نور توسط یگان ساحلی نداجا از سواحل مکران (1).jpg'], + ['irgc_fac', 'File:Commissioning ceremony of IRGC naval vessels in March 2023 (10).jpg'], + ['iran_airbase', 'File:F-14 Tomcat at Mehrabad preparing for Iranian Army Day 2013 (1).jpg'], + ['shahed136', 'File:Military equipment displayed for the 44th Iranian revolution anniversary rally - Shahed 136.jpg'], +] + +// PD/CC0/CC BY/CC BY-SA only — Commons' bare "Attribution" license is deliberately excluded +const LICENSE_OK = /^(public domain|pd[\s-]|pd$|cc0|cc[\s-]by(-sa)?(\s\d\.\d)?$)/i + +const sleep = (ms) => new Promise((r) => setTimeout(r, ms)) + +async function fetchRetry(url, attempt = 0) { + const res = await fetch(url, { headers: { 'User-Agent': UA } }) + if (res.status === 429 && attempt < 4) { + await sleep(8000 * (attempt + 1)) + return fetchRetry(url, attempt + 1) + } + return res +} + +const stripHtml = (s) => + (s ?? '').replace(/<[^>]*>/g, '').replace(/\s+/g, ' ').trim() + +function shorten(s, max = 70) { + return s.length > max ? `${s.slice(0, max - 1).trimEnd()}…` : s +} + +/** One batched imageinfo query for every title (cheap on the API rate limit) */ +async function fetchMetadata(titles) { + const url = 'https://commons.wikimedia.org/w/api.php?' + new URLSearchParams({ + format: 'json', + action: 'query', + titles: titles.join('|'), + prop: 'imageinfo', + iiprop: 'url|mime|extmetadata', + }) + const res = await fetchRetry(url) + if (!res.ok) throw new Error(`metadata query failed: HTTP ${res.status}`) + const json = await res.json() + const byTitle = new Map() + for (const page of Object.values(json.query?.pages ?? {})) { + if (page.imageinfo?.[0]) byTitle.set(page.title, page.imageinfo[0]) + } + // map normalized titles back to requested ones + for (const { from, to } of json.query?.normalized ?? []) { + if (byTitle.has(to)) byTitle.set(from, byTitle.get(to)) + } + return byTitle +} + +async function downloadJpeg(title, width) { + const name = encodeURIComponent(title.replace(/^File:/, '')) + const url = `https://commons.wikimedia.org/wiki/Special:FilePath/${name}?width=${width}` + const res = await fetchRetry(url) + if (!res.ok) throw new Error(`HTTP ${res.status} at width ${width}`) + const buf = Buffer.from(await res.arrayBuffer()) + if (buf.length < 4 || buf[0] !== 0xff || buf[1] !== 0xd8 || buf[2] !== 0xff) { + throw new Error(`not a JPEG (first bytes ${buf.subarray(0, 3).toString('hex')}) at width ${width}`) + } + return buf +} + +const entries = {} +const failures = [] + +await fs.mkdir(outDir, { recursive: true }) + +console.log('Fetching metadata for', MANIFEST.length, 'titles...') +const meta = await fetchMetadata(MANIFEST.map(([, t]) => t)) + +for (const [key, title] of MANIFEST) { + const info = meta.get(title) + if (!info) { + failures.push([key, 'no imageinfo (title missing on Commons?)']) + continue + } + const md = info.extmetadata ?? {} + const license = stripHtml(md.LicenseShortName?.value) + const author = shorten(stripHtml(md.Artist?.value) || 'Unknown') + if (!LICENSE_OK.test(license)) { + failures.push([key, `license not in allowlist: "${license}"`]) + continue + } + if (info.mime !== 'image/jpeg') { + failures.push([key, `not a JPEG source: ${info.mime}`]) + continue + } + try { + let buf = await downloadJpeg(title, WIDTHS[0]) + if (buf.length > MAX_BYTES) { + await sleep(1500) + buf = await downloadJpeg(title, WIDTHS[1]) + } + if (buf.length > MAX_BYTES) { + console.warn(` WARN ${key}: still ${(buf.length / 1024).toFixed(0)} KB at width ${WIDTHS[1]}, keeping it`) + } + const file = `${key}.jpg` + await fs.writeFile(path.join(outDir, file), buf) + entries[key] = { + file, + title: title.replace(/^File:/, ''), + author, + license, + sourceUrl: info.descriptionurl ?? `https://commons.wikimedia.org/wiki/${encodeURIComponent(title)}`, + } + console.log(` OK ${key} ${(buf.length / 1024).toFixed(0)} KB [${license}] ${author}`) + } catch (e) { + failures.push([key, e.message]) + } + await sleep(1500) +} + +for (const [key, why] of failures) console.warn(` DROP ${key}: ${why}`) + +const count = Object.keys(entries).length +console.log(`\n${count}/${MANIFEST.length} classes downloaded.`) +if (count < 12) { + console.error('Fewer than 12 classes succeeded — generated map is too thin, aborting without writing TS.') + process.exit(1) +} + +const entryLines = Object.entries(entries) + .map(([key, e]) => + ` ${key}: { file: ${JSON.stringify(e.file)}, title: ${JSON.stringify(e.title)}, author: ${JSON.stringify(e.author)}, license: ${JSON.stringify(e.license)}, sourceUrl: ${JSON.stringify(e.sourceUrl)} },`) + .join('\n') + +const ts = `/** + * GENERATED by scripts/fetch-unit-images.mjs — re-run the script instead of + * editing image entries by hand. Recognition photos live in public/unit-images/. + * Licenses: US imagery is public-domain DoD photography; Iranian imagery is + * CC BY press photography — author + license MUST be shown wherever an image + * renders (in-game credit caption + LIVE FEEDS credits panel). + */ + +export interface UnitImageInfo { + /** filename under public/unit-images/ */ + file: string + /** Commons file title (without the File: prefix) */ + title: string + author: string + license: string + sourceUrl: string +} + +export const UNIT_IMAGES: Record = { +${entryLines} +} + +const US_AIRBASE_HINTS = ['udeid', 'dhafra', 'prince sultan', 'al salem', 'incirlik', 'diego garcia'] + +/** + * Best-effort class match from unit name/category. May return a key with no + * UNIT_IMAGES entry (download dropped) — callers must check the map. + */ +export function unitImageKey(unit: { name: string; category: string }): string | null { + const n = unit.name.toLowerCase() + if (unit.category === 'carrier_group' || n.includes('cvn')) return 'nimitz_cvn' + if (n.includes('ddg') || n.includes('arleigh')) return 'arleigh_burke' + if (n.includes('ssn')) return 'virginia_ssn' + if (n.includes('patriot')) return 'patriot' + if (n.includes('thaad')) return 'thaad' + if (n.includes('e-3') || n.includes('awacs')) return 'e3_sentry' + if (n.includes('nsa bahrain') || n.includes('5th fleet')) return 'nsa_bahrain' + if (n.includes('s-300')) return 's300pmu2' + if (n.includes('bavar')) return 'bavar373' + if (n.includes('khordad')) return 'khordad3' + if (n.includes('tor-m1')) return 'tor_m1' + if (n.includes('shahab')) return 'shahab3' + if (n.includes('sejjil')) return 'sejjil2' + if (n.includes('fateh')) return 'fateh110' + if (n.includes('zolfaghar')) return 'zolfaghar' + if (n.includes('soumar')) return 'soumar' + if (n.includes('ghadir')) return 'ghadir_sub' + if (n.includes('coastal') || n.includes('qeshm')) return 'noor_coastal' + if (/\\bfac\\b/.test(n)) return 'irgc_fac' + if (n.includes('shahed')) return 'shahed136' + if (unit.category === 'airbase') { + return US_AIRBASE_HINTS.some((h) => n.includes(h)) ? 'us_airbase' : 'iran_airbase' + } + return null +} +` + +await fs.writeFile(tsOut, ts, 'utf8') +console.log('Wrote', tsOut) diff --git a/src/App.tsx b/src/App.tsx index 689da25..7a26758 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -9,6 +9,9 @@ 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 ImintViewer from '@/components/intel/ImintViewer' +import LiveFeeds from '@/components/intel/LiveFeeds' +import OsintTicker from '@/components/intel/OsintTicker' import StartScreen from '@/components/menu/StartScreen' import ScenarioSelect from '@/components/menu/ScenarioSelect' import FreeModeLobby from '@/components/menu/FreeModeLobby' @@ -19,6 +22,7 @@ import { useMenuStore } from '@/store/menu-store' import { useDeploymentStore } from '@/store/deployment-store' import { useStrikeStore } from '@/store/strike-store' import { useIsMobile } from '@/hooks/useIsMobile' +import { useSoundEffects } from '@/audio/useSoundEffects' import { initBridge, initFromData, sendCommand } from '@/store/bridge' import { getScenario } from '@/data/scenarios/index' @@ -29,6 +33,8 @@ export default function App() { const [mobilePanel, setMobilePanel] = useState(null) const screen = useMenuStore(s => s.screen) + useSoundEffects() + useEffect(() => { initBridge() }, []) @@ -133,6 +139,8 @@ export default function App() { break } 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) { ui.setLeftPanel(null) useUIStore.setState({ showIntel: false }) @@ -168,6 +176,7 @@ export default function App() { {mobilePanel === 'econ' && } {mobilePanel === 'events' && } {mobilePanel === 'intel' && setMobilePanel(null)} />} + {showDebrief && } @@ -179,12 +188,15 @@ export default function App() { + {showOrbat && } {showStats && } {showEconomy && } {showIntel && } + + {showDebrief && } ) diff --git a/src/audio/__tests__/audio-manager.test.ts b/src/audio/__tests__/audio-manager.test.ts new file mode 100644 index 0000000..42dc4cc --- /dev/null +++ b/src/audio/__tests__/audio-manager.test.ts @@ -0,0 +1,255 @@ +import { describe, it, expect, vi } from 'vitest' +import { + AudioManager, + AUDIO_STORAGE_KEYS, + DEFAULT_DEBOUNCE_MS, + DEFAULT_VOLUME, + SOUND_DEBOUNCE_MS, +} from '../audio-manager' +import type { AudioManagerDeps, SoundKit, SoundName } from '../audio-manager' + +const SOUND_NAMES: SoundName[] = [ + 'klaxon', 'sonar-ping', 'radio-squelch', 'launch-whoosh', + 'distant-impact', 'ui-blip', 'ui-error', 'ambient-cic', +] + +function makeFakeGain() { + return { + gain: { value: 1 }, + connect: vi.fn(), + disconnect: vi.fn(), + } +} + +function makeFakeSource() { + return { + buffer: null as unknown, + loop: false, + connect: vi.fn(), + disconnect: vi.fn(), + start: vi.fn(), + stop: vi.fn(), + } +} + +function makeFakeContext() { + const gains: ReturnType[] = [] + const sources: ReturnType[] = [] + const ctx = { + state: 'suspended', + sampleRate: 44100, + destination: {}, + resume: vi.fn(async () => { ctx.state = 'running' }), + createGain: vi.fn(() => { + const g = makeFakeGain() + gains.push(g) + return g + }), + createBufferSource: vi.fn(() => { + const s = makeFakeSource() + sources.push(s) + return s + }), + } + return { ctx, gains, sources } +} + +function makeMapStorage() { + const map = new Map() + return { + map, + getItem: (key: string) => map.get(key) ?? null, + setItem: (key: string, value: string) => { map.set(key, value) }, + } +} + +function makeKit(): SoundKit { + const kit: SoundKit = {} + for (const name of SOUND_NAMES) kit[name] = { soundName: name } as unknown as AudioBuffer + return kit +} + +function makeManager(over: Partial = {}) { + const fake = makeFakeContext() + const storage = makeMapStorage() + const clock = { nowMs: 0 } + const manager = new AudioManager({ + createContext: () => fake.ctx as unknown as AudioContext, + loadKit: async () => makeKit(), + storage, + now: () => clock.nowMs, + ...over, + }) + return { manager, fake, storage, clock } +} + +describe('AudioManager init', () => { + it('resumes a suspended context and builds master/sfx/ambient buses', async () => { + const { manager, fake } = makeManager() + await manager.init() + expect(fake.ctx.resume).toHaveBeenCalledTimes(1) + expect(fake.ctx.createGain).toHaveBeenCalledTimes(3) + expect(manager.isInitialized()).toBe(true) + }) + + it('initializes once on the first gesture, then ignores further gestures', async () => { + const { manager, fake } = makeManager() + const target = new EventTarget() + manager.attachGestureListeners(target) + expect(manager.isInitialized()).toBe(false) + + target.dispatchEvent(new Event('pointerdown')) + await manager.init() + expect(fake.ctx.resume).toHaveBeenCalledTimes(1) + + target.dispatchEvent(new Event('keydown')) + target.dispatchEvent(new Event('pointerdown')) + await manager.init() + expect(fake.ctx.createGain).toHaveBeenCalledTimes(3) + }) + + it('degrades gracefully when no AudioContext is available', async () => { + const { manager } = makeManager({ createContext: () => null }) + await manager.init() + expect(manager.isInitialized()).toBe(false) + expect(manager.play('ui-blip')).toBe(false) + expect(() => manager.startAmbient()).not.toThrow() + expect(() => manager.stopAmbient()).not.toThrow() + }) +}) + +describe('AudioManager play + debounce', () => { + it('does not play before init, and the failed attempt does not burn the debounce window', async () => { + const { manager, fake } = makeManager() + expect(manager.play('ui-blip')).toBe(false) + expect(fake.sources.length).toBe(0) + await manager.init() + expect(manager.play('ui-blip')).toBe(true) + expect(fake.sources.length).toBe(1) + expect(fake.sources[0].start).toHaveBeenCalledTimes(1) + }) + + it('debounces repeats inside the default window and allows them after', async () => { + const { manager, clock } = makeManager() + await manager.init() + expect(manager.play('ui-blip')).toBe(true) + expect(manager.play('ui-blip')).toBe(false) + clock.nowMs = DEFAULT_DEBOUNCE_MS - 1 + expect(manager.play('ui-blip')).toBe(false) + clock.nowMs = DEFAULT_DEBOUNCE_MS + expect(manager.play('ui-blip')).toBe(true) + }) + + it('debounces klaxon with its heavy per-sound window', async () => { + const { manager, clock } = makeManager() + await manager.init() + const gap = SOUND_DEBOUNCE_MS['klaxon']! + expect(manager.play('klaxon')).toBe(true) + clock.nowMs = gap - 1 + expect(manager.play('klaxon')).toBe(false) + clock.nowMs = gap + expect(manager.play('klaxon')).toBe(true) + }) + + it('tracks debounce per sound name, not globally', async () => { + const { manager, fake } = makeManager() + await manager.init() + expect(manager.play('ui-blip')).toBe(true) + expect(manager.play('klaxon')).toBe(true) + expect(fake.sources.length).toBe(2) + }) + + it('applies per-play volume through an extra gain node', async () => { + const { manager, fake } = makeManager() + await manager.init() + expect(manager.play('ui-blip', { volume: 0.3 })).toBe(true) + const perPlayGain = fake.gains[3] // 0-2 are master/sfx/ambient + expect(perPlayGain.gain.value).toBe(0.3) + expect(fake.sources[0].connect).toHaveBeenCalledWith(perPlayGain) + }) + + it('returns false for sounds missing from the kit', async () => { + const { manager } = makeManager({ loadKit: async () => ({}) }) + await manager.init() + expect(manager.play('ui-blip')).toBe(false) + }) +}) + +describe('AudioManager mute/volume persistence', () => { + it('defaults to unmuted at default volume', () => { + const { manager } = makeManager() + expect(manager.isMuted()).toBe(false) + expect(manager.getVolume()).toBe(DEFAULT_VOLUME) + }) + + it('drives the master gain: volume when unmuted, 0 when muted', async () => { + const { manager, fake } = makeManager() + await manager.init() + const master = fake.gains[0] + expect(master.gain.value).toBe(DEFAULT_VOLUME) + + manager.setMuted(true) + expect(master.gain.value).toBe(0) + + manager.setVolume(0.8) + expect(master.gain.value).toBe(0) + + manager.setMuted(false) + expect(master.gain.value).toBe(0.8) + }) + + it('persists prefs and restores them in a fresh manager', () => { + const { manager, storage } = makeManager() + manager.setMuted(true) + manager.setVolume(0.8) + expect(storage.map.get(AUDIO_STORAGE_KEYS.muted)).toBe('1') + expect(storage.map.get(AUDIO_STORAGE_KEYS.volume)).toBe('0.8') + + const { manager: restored } = makeManager({ storage }) + expect(restored.isMuted()).toBe(true) + expect(restored.getVolume()).toBe(0.8) + }) + + it('clamps volume to 0..1', () => { + const { manager } = makeManager() + manager.setVolume(1.7) + expect(manager.getVolume()).toBe(1) + manager.setVolume(-2) + expect(manager.getVolume()).toBe(0) + }) + + it('works without storage', () => { + const { manager } = makeManager({ storage: null }) + expect(() => manager.setMuted(true)).not.toThrow() + expect(manager.isMuted()).toBe(true) + }) +}) + +describe('AudioManager ambient loop', () => { + it('starts the loop after init even when requested before, and stops cleanly', async () => { + const { manager, fake } = makeManager() + manager.startAmbient() + expect(fake.sources.length).toBe(0) + + await manager.init() + expect(fake.sources.length).toBe(1) + expect(fake.sources[0].loop).toBe(true) + expect(fake.sources[0].start).toHaveBeenCalledTimes(1) + + manager.startAmbient() + expect(fake.sources.length).toBe(1) + + manager.stopAmbient() + expect(fake.sources[0].stop).toHaveBeenCalledTimes(1) + + manager.startAmbient() + expect(fake.sources.length).toBe(2) + }) + + it('does not start ambient when the kit lacks the loop buffer', async () => { + const { manager, fake } = makeManager({ loadKit: async () => ({}) }) + await manager.init() + manager.startAmbient() + expect(fake.sources.length).toBe(0) + }) +}) diff --git a/src/audio/__tests__/event-sounds.test.ts b/src/audio/__tests__/event-sounds.test.ts new file mode 100644 index 0000000..75a4188 --- /dev/null +++ b/src/audio/__tests__/event-sounds.test.ts @@ -0,0 +1,55 @@ +import { describe, it, expect } from 'vitest' +import { soundForEvent } from '../event-sounds' +import type { SoundName } from '../audio-manager' +import type { GameEvent } from '@/types/game' + +const tick = 42 +const pos = { lng: 52, lat: 27 } + +const cases: [GameEvent, SoundName | null][] = [ + [{ type: 'WAR_DECLARED', attacker: 'usa', defender: 'iran', tick }, 'klaxon'], + [{ type: 'MISSILE_LAUNCHED', missileId: 'm1', launcherId: 'u1', targetId: 'u2', weaponName: 'Tomahawk', tick }, 'launch-whoosh'], + [{ type: 'MISSILE_IMPACT', missileId: 'm1', targetId: 'u2', damage: 40, tick }, 'distant-impact'], + [{ type: 'UNIT_DESTROYED', unitId: 'u2', tick }, 'distant-impact'], + [{ type: 'MISSILE_INTERCEPTED', missileId: 'm1', interceptorId: 'u3', position: pos, tick }, 'ui-blip'], + [{ type: 'POINT_DEFENSE_KILL', unitId: 'u3', missileId: 'm1', specId: 'ciws', tick }, 'ui-blip'], + [{ type: 'AGENT_ARRESTED', agentId: 'a1', codename: 'CYRUS', tick }, 'ui-error'], + [{ type: 'STRIKE_LEAKED', targetId: 'u2', tick }, 'ui-error'], + [{ type: 'INTERCEPT_DECRYPTED', precedence: 'FLASH', text: 'launch order intercepted', tick }, 'radio-squelch'], + [{ type: 'SATELLITE_PASS_COMPLETE', assetId: 's1', target: pos, found: 2, revealedDecoys: 0, tick }, 'sonar-ping'], + [{ type: 'CEASEFIRE_OFFERED', by: 'iran', tick }, 'radio-squelch'], + [{ type: 'WAR_ENDED', outcome: 'ceasefire', tick }, 'radio-squelch'], +] + +const silentCases: GameEvent[] = [ + { type: 'INTERCEPT_DECRYPTED', precedence: 'ROUTINE', text: 'routine traffic', tick }, + { type: 'INTERCEPT_DECRYPTED', precedence: 'IMMEDIATE', text: 'immediate traffic', tick }, + { type: 'RESUPPLIED', unitId: 'u1', weaponId: 'w1', count: 4, fromBaseId: 'b1', tick }, + { type: 'OIL_PRICE_CHANGE', newPrice: 95, oldPrice: 80, tick }, + { type: 'AGENT_REPORT', agentId: 'a1', codename: 'CYRUS', text: 'report', tick }, + { type: 'SATELLITE_PASS_FAILED', assetId: 's1', target: pos, cloudPct: 80, tick }, + { type: 'ORDER_REJECTED', unitId: 'u1', reason: 'no fuel', tick }, +] + +describe('soundForEvent', () => { + it.each(cases.map(([e, expected]) => [e.type, e, expected] as const))( + 'maps %s', + (_type, e, expected) => { + expect(soundForEvent(e)).toBe(expected) + }, + ) + + it.each(silentCases.map((e) => [e.type, e] as const))( + 'stays silent for %s', + (_type, e) => { + expect(soundForEvent(e)).toBeNull() + }, + ) + + it('only FLASH intercepts squelch', () => { + const flash: GameEvent = { type: 'INTERCEPT_DECRYPTED', precedence: 'FLASH', text: 'x', tick } + const priority: GameEvent = { type: 'INTERCEPT_DECRYPTED', precedence: 'PRIORITY', text: 'x', tick } + expect(soundForEvent(flash)).toBe('radio-squelch') + expect(soundForEvent(priority)).toBeNull() + }) +}) diff --git a/src/audio/audio-manager.ts b/src/audio/audio-manager.ts new file mode 100644 index 0000000..13ad693 --- /dev/null +++ b/src/audio/audio-manager.ts @@ -0,0 +1,271 @@ +import { synthesizeKit } from './synth' + +export type SoundName = + | 'klaxon' + | 'sonar-ping' + | 'radio-squelch' + | 'launch-whoosh' + | 'distant-impact' + | 'ui-blip' + | 'ui-error' + | 'ambient-cic' + +export type BusName = 'sfx' | 'ambient' + +export interface PlayOptions { + bus?: BusName + volume?: number +} + +export const AUDIO_STORAGE_KEYS = { + muted: 'ashfall.audio.muted', + volume: 'ashfall.audio.volume', +} as const + +export const DEFAULT_VOLUME = 0.5 +/** Ambient bus sits well under sfx — CIC room tone, not a soundtrack */ +const AMBIENT_BUS_LEVEL = 0.4 + +export const DEFAULT_DEBOUNCE_MS = 80 +/** Long sounds get long debounces so a 30-missile salvo doesn't stack 30 klaxons */ +export const SOUND_DEBOUNCE_MS: Partial> = { + 'klaxon': 2500, + 'launch-whoosh': 1500, + 'distant-impact': 300, + 'sonar-ping': 500, + 'radio-squelch': 400, + 'ui-error': 200, +} + +export type SoundKit = Partial> + +export interface AudioManagerDeps { + createContext?: () => AudioContext | null + loadKit?: (ctx: AudioContext) => Promise + /** null = no persistence (e.g. storage blocked) */ + storage?: Pick | null + now?: () => number +} + +function clamp01(v: number): number { + return Math.max(0, Math.min(1, v)) +} + +function defaultCreateContext(): AudioContext | null { + try { + const Ctor = typeof AudioContext !== 'undefined' + ? AudioContext + : (globalThis as { webkitAudioContext?: typeof AudioContext }).webkitAudioContext + return Ctor ? new Ctor() : null + } catch { + return null + } +} + +function defaultStorage(): Pick | null { + try { + return typeof localStorage !== 'undefined' ? localStorage : null + } catch { + return null + } +} + +export class AudioManager { + private createContext: () => AudioContext | null + private loadKit: (ctx: AudioContext) => Promise + private storage: Pick | null + private now: () => number + + private ctx: AudioContext | null = null + private master: GainNode | null = null + private buses: Record | null = null + private buffers: SoundKit = {} + private lastPlayedMs = new Map() + private ambientSource: AudioBufferSourceNode | null = null + private ambientWanted = false + private muted: boolean + private volume: number + private initPromise: Promise | null = null + private detachGesture: (() => void) | null = null + + constructor(deps: AudioManagerDeps = {}) { + this.createContext = deps.createContext ?? defaultCreateContext + this.loadKit = deps.loadKit ?? ((ctx) => synthesizeKit(ctx.sampleRate)) + this.storage = deps.storage === undefined ? defaultStorage() : deps.storage + this.now = deps.now + ?? (() => (typeof performance !== 'undefined' ? performance.now() : Date.now())) + + this.muted = this.readStored(AUDIO_STORAGE_KEYS.muted) === '1' + const storedVolume = this.readStored(AUDIO_STORAGE_KEYS.volume) + const parsed = storedVolume === null ? NaN : Number(storedVolume) + this.volume = Number.isFinite(parsed) ? clamp01(parsed) : DEFAULT_VOLUME + } + + /** Browsers block AudioContext until a user gesture — init on the first one, then detach */ + attachGestureListeners(target: EventTarget): void { + if (this.detachGesture) return + const onGesture = () => { + this.detachGesture?.() + void this.init() + } + target.addEventListener('pointerdown', onGesture, { once: true }) + target.addEventListener('keydown', onGesture, { once: true }) + this.detachGesture = () => { + target.removeEventListener('pointerdown', onGesture) + target.removeEventListener('keydown', onGesture) + this.detachGesture = null + } + } + + detachGestureListeners(): void { + this.detachGesture?.() + } + + /** Idempotent; a failed init leaves the manager permanently (and silently) inert */ + init(): Promise { + if (!this.initPromise) { + this.initPromise = this.doInit().catch(() => undefined) + } + return this.initPromise + } + + isInitialized(): boolean { + return this.ctx !== null + } + + private async doInit(): Promise { + const ctx = this.createContext() + if (!ctx) return + this.ctx = ctx + if (ctx.state === 'suspended') { + try { + await ctx.resume() + } catch { + // stays suspended — play() calls are harmless no-audio ops + } + } + + const master = ctx.createGain() + master.connect(ctx.destination) + const sfx = ctx.createGain() + sfx.connect(master) + const ambient = ctx.createGain() + ambient.gain.value = AMBIENT_BUS_LEVEL + ambient.connect(master) + this.master = master + this.buses = { sfx, ambient } + this.applyMasterGain() + + try { + this.buffers = await this.loadKit(ctx) + } catch { + this.buffers = {} + } + if (this.ambientWanted) this.startAmbientSource() + } + + /** Returns true only when a source actually started */ + play(name: SoundName, opts: PlayOptions = {}): boolean { + const minGapMs = SOUND_DEBOUNCE_MS[name] ?? DEFAULT_DEBOUNCE_MS + const t = this.now() + const last = this.lastPlayedMs.get(name) + if (last !== undefined && t - last < minGapMs) return false + + const ctx = this.ctx + const buses = this.buses + const buffer = this.buffers[name] + if (!ctx || !buses || !buffer) return false + + this.lastPlayedMs.set(name, t) + const source = ctx.createBufferSource() + source.buffer = buffer + const bus = buses[opts.bus ?? 'sfx'] + if (opts.volume !== undefined) { + const gain = ctx.createGain() + gain.gain.value = clamp01(opts.volume) + source.connect(gain) + gain.connect(bus) + } else { + source.connect(bus) + } + source.start() + return true + } + + startAmbient(): void { + this.ambientWanted = true + this.startAmbientSource() + } + + stopAmbient(): void { + this.ambientWanted = false + if (!this.ambientSource) return + try { + this.ambientSource.stop() + } catch { + // already stopped + } + try { + this.ambientSource.disconnect() + } catch { + // already disconnected + } + this.ambientSource = null + } + + private startAmbientSource(): void { + if (!this.ambientWanted || this.ambientSource) return + const ctx = this.ctx + const buses = this.buses + const buffer = this.buffers['ambient-cic'] + if (!ctx || !buses || !buffer) return + const source = ctx.createBufferSource() + source.buffer = buffer + source.loop = true + source.connect(buses.ambient) + source.start() + this.ambientSource = source + } + + isMuted(): boolean { + return this.muted + } + + getVolume(): number { + return this.volume + } + + setMuted(muted: boolean): void { + this.muted = muted + this.writeStored(AUDIO_STORAGE_KEYS.muted, muted ? '1' : '0') + this.applyMasterGain() + } + + setVolume(volume: number): void { + this.volume = clamp01(volume) + this.writeStored(AUDIO_STORAGE_KEYS.volume, String(this.volume)) + this.applyMasterGain() + } + + private applyMasterGain(): void { + if (this.master) this.master.gain.value = this.muted ? 0 : this.volume + } + + private readStored(key: string): string | null { + try { + return this.storage?.getItem(key) ?? null + } catch { + return null + } + } + + private writeStored(key: string, value: string): void { + try { + this.storage?.setItem(key, value) + } catch { + // persistence unavailable — session-only prefs + } + } +} + +export const audioManager = new AudioManager() diff --git a/src/audio/event-sounds.ts b/src/audio/event-sounds.ts new file mode 100644 index 0000000..bb8dfa4 --- /dev/null +++ b/src/audio/event-sounds.ts @@ -0,0 +1,33 @@ +import type { GameEvent } from '@/types/game' +import type { SoundName } from './audio-manager' + +/** + * Pure event→sound table. The own-unit-destroyed klaxon overlay lives in + * useSoundEffects — it needs viewState to resolve the unit's nation. + */ +export function soundForEvent(e: GameEvent): SoundName | null { + switch (e.type) { + case 'WAR_DECLARED': + return 'klaxon' + case 'MISSILE_LAUNCHED': + return 'launch-whoosh' + case 'MISSILE_IMPACT': + case 'UNIT_DESTROYED': + return 'distant-impact' + case 'MISSILE_INTERCEPTED': + case 'POINT_DEFENSE_KILL': + return 'ui-blip' + case 'AGENT_ARRESTED': + case 'STRIKE_LEAKED': + return 'ui-error' + case 'INTERCEPT_DECRYPTED': + return e.precedence === 'FLASH' ? 'radio-squelch' : null + case 'SATELLITE_PASS_COMPLETE': + return 'sonar-ping' + case 'CEASEFIRE_OFFERED': + case 'WAR_ENDED': + return 'radio-squelch' + default: + return null + } +} diff --git a/src/audio/synth.ts b/src/audio/synth.ts new file mode 100644 index 0000000..9e7968a --- /dev/null +++ b/src/audio/synth.ts @@ -0,0 +1,290 @@ +import type { SoundKit } from './audio-manager' + +// Procedural DEFCON-style ops-room kit — no audio assets shipped, every sound is +// rendered once at init via OfflineAudioContext and cached as an AudioBuffer. + +const DEFAULT_SAMPLE_RATE = 44100 + +type OfflineCtor = new (numberOfChannels: number, length: number, sampleRate: number) => OfflineAudioContext + +function getOfflineCtor(): OfflineCtor | null { + if (typeof OfflineAudioContext !== 'undefined') return OfflineAudioContext + const webkit = (globalThis as { webkitOfflineAudioContext?: OfflineCtor }).webkitOfflineAudioContext + return webkit ?? null +} + +function whiteNoiseBuffer(ctx: OfflineAudioContext, seconds: number): AudioBuffer { + const n = Math.ceil(seconds * ctx.sampleRate) + const buffer = ctx.createBuffer(1, n, ctx.sampleRate) + const data = buffer.getChannelData(0) + for (let i = 0; i < n; i++) data[i] = Math.random() * 2 - 1 + return buffer +} + +function noiseSource(ctx: OfflineAudioContext, seconds: number): AudioBufferSourceNode { + const source = ctx.createBufferSource() + source.buffer = whiteNoiseBuffer(ctx, seconds) + return source +} + +type Builder = (ctx: OfflineAudioContext, master: GainNode) => void + +async function render(Offline: OfflineCtor, sampleRate: number, seconds: number, build: Builder): Promise { + const ctx = new Offline(1, Math.ceil(seconds * sampleRate), sampleRate) + const master = ctx.createGain() + master.connect(ctx.destination) + build(ctx, master) + return ctx.startRendering() +} + +/** Short scope blip — track updates, intercepts, PD kills */ +function buildUiBlip(ctx: OfflineAudioContext, master: GainNode): void { + const osc = ctx.createOscillator() + osc.type = 'triangle' + osc.frequency.value = 1240 + const gain = ctx.createGain() + gain.gain.setValueAtTime(0, 0) + gain.gain.linearRampToValueAtTime(0.5, 0.005) + gain.gain.exponentialRampToValueAtTime(0.001, 0.08) + osc.connect(gain) + gain.connect(master) + osc.start(0) + osc.stop(0.09) +} + +/** Two falling buzzer notes — denied / source lost */ +function buildUiError(ctx: OfflineAudioContext, master: GainNode): void { + const lp = ctx.createBiquadFilter() + lp.type = 'lowpass' + lp.frequency.value = 1200 + lp.connect(master) + const note = (freq: number, start: number, dur: number) => { + const osc = ctx.createOscillator() + osc.type = 'square' + osc.frequency.value = freq + const gain = ctx.createGain() + gain.gain.setValueAtTime(0, start) + gain.gain.linearRampToValueAtTime(0.3, start + 0.008) + gain.gain.setValueAtTime(0.3, start + dur - 0.02) + gain.gain.exponentialRampToValueAtTime(0.001, start + dur) + osc.connect(gain) + gain.connect(lp) + osc.start(start) + osc.stop(start + dur) + } + note(440, 0, 0.1) + note(294, 0.11, 0.13) +} + +/** Two-tone alternating klaxon — war declared, own unit lost */ +function buildKlaxon(ctx: OfflineAudioContext, master: GainNode): void { + const osc = ctx.createOscillator() + osc.type = 'sawtooth' + const lp = ctx.createBiquadFilter() + lp.type = 'lowpass' + lp.frequency.value = 1500 + const gain = ctx.createGain() + for (let i = 0; i < 6; i++) { + osc.frequency.setValueAtTime(i % 2 === 0 ? 620 : 466, i * 0.3) + } + gain.gain.setValueAtTime(0, 0) + gain.gain.linearRampToValueAtTime(0.4, 0.015) + gain.gain.setValueAtTime(0.4, 1.5) + gain.gain.exponentialRampToValueAtTime(0.001, 1.78) + osc.connect(lp) + lp.connect(gain) + gain.connect(master) + osc.start(0) + osc.stop(1.8) +} + +/** Sine ping with long decay and one faint echo — satellite pass complete */ +function buildSonarPing(ctx: OfflineAudioContext, master: GainNode): void { + const ping = (start: number, level: number) => { + const osc = ctx.createOscillator() + osc.type = 'sine' + osc.frequency.setValueAtTime(1050, start) + osc.frequency.exponentialRampToValueAtTime(980, start + 0.9) + const gain = ctx.createGain() + gain.gain.setValueAtTime(0, start) + gain.gain.linearRampToValueAtTime(level, start + 0.006) + gain.gain.exponentialRampToValueAtTime(0.0005, start + 1.0) + osc.connect(gain) + gain.connect(master) + osc.start(start) + osc.stop(start + 1.0) + } + ping(0, 0.55) + ping(0.35, 0.16) +} + +/** Bandpassed static burst ending in a click — comms traffic */ +function buildRadioSquelch(ctx: OfflineAudioContext, master: GainNode): void { + const bp = ctx.createBiquadFilter() + bp.type = 'bandpass' + bp.frequency.value = 1800 + bp.Q.value = 0.8 + bp.connect(master) + const burst = (start: number, dur: number, level: number) => { + const src = noiseSource(ctx, dur) + const gain = ctx.createGain() + gain.gain.setValueAtTime(0, start) + gain.gain.linearRampToValueAtTime(level, start + 0.01) + gain.gain.setValueAtTime(level, start + dur - 0.015) + gain.gain.linearRampToValueAtTime(0, start + dur) + src.connect(gain) + gain.connect(bp) + src.start(start) + src.stop(start + dur) + } + burst(0, 0.18, 0.4) + burst(0.22, 0.06, 0.25) +} + +/** Filtered-noise sweep with a sub-bass rumble — missile away */ +function buildLaunchWhoosh(ctx: OfflineAudioContext, master: GainNode): void { + const src = noiseSource(ctx, 1.6) + const lp = ctx.createBiquadFilter() + lp.type = 'lowpass' + lp.frequency.setValueAtTime(150, 0) + lp.frequency.exponentialRampToValueAtTime(2800, 0.5) + lp.frequency.exponentialRampToValueAtTime(500, 1.55) + const gain = ctx.createGain() + gain.gain.setValueAtTime(0, 0) + gain.gain.linearRampToValueAtTime(0.5, 0.4) + gain.gain.exponentialRampToValueAtTime(0.002, 1.55) + src.connect(lp) + lp.connect(gain) + gain.connect(master) + src.start(0) + src.stop(1.6) + + const sub = ctx.createOscillator() + sub.type = 'sawtooth' + sub.frequency.setValueAtTime(60, 0) + sub.frequency.exponentialRampToValueAtTime(28, 1.4) + const subGain = ctx.createGain() + subGain.gain.setValueAtTime(0, 0) + subGain.gain.linearRampToValueAtTime(0.15, 0.3) + subGain.gain.exponentialRampToValueAtTime(0.001, 1.5) + sub.connect(subGain) + subGain.connect(master) + sub.start(0) + sub.stop(1.6) +} + +/** Muffled low thud — impacts and unit losses heard from the CIC, not the front line */ +function buildDistantImpact(ctx: OfflineAudioContext, master: GainNode): void { + const thump = ctx.createOscillator() + thump.type = 'sine' + thump.frequency.setValueAtTime(90, 0) + thump.frequency.exponentialRampToValueAtTime(38, 0.5) + const thumpGain = ctx.createGain() + thumpGain.gain.setValueAtTime(0, 0) + thumpGain.gain.linearRampToValueAtTime(0.7, 0.012) + thumpGain.gain.exponentialRampToValueAtTime(0.001, 1.0) + thump.connect(thumpGain) + thumpGain.connect(master) + thump.start(0) + thump.stop(1.05) + + const debris = noiseSource(ctx, 0.35) + const lp = ctx.createBiquadFilter() + lp.type = 'lowpass' + lp.frequency.value = 400 + const debrisGain = ctx.createGain() + debrisGain.gain.setValueAtTime(0, 0) + debrisGain.gain.linearRampToValueAtTime(0.35, 0.01) + debrisGain.gain.exponentialRampToValueAtTime(0.001, 0.32) + debris.connect(lp) + lp.connect(debrisGain) + debrisGain.connect(master) + debris.start(0) + debris.stop(0.35) +} + +const AMBIENT_LOOP_S = 8 +const AMBIENT_FADE_S = 0.4 + +/** Very quiet CIC room tone: mains hum + air handling, rendered loop-seamless */ +async function renderAmbient(Offline: OfflineCtor, sampleRate: number): Promise { + const totalS = AMBIENT_LOOP_S + AMBIENT_FADE_S + const ctx = new Offline(1, Math.ceil(totalS * sampleRate), sampleRate) + const master = ctx.createGain() + master.connect(ctx.destination) + + const hum = (freq: number, level: number) => { + const osc = ctx.createOscillator() + osc.type = 'sine' + osc.frequency.value = freq + const gain = ctx.createGain() + gain.gain.value = level + osc.connect(gain) + gain.connect(master) + osc.start(0) + osc.stop(totalS) + } + hum(60, 0.05) + hum(120, 0.028) + + const air = noiseSource(ctx, totalS) + const lp = ctx.createBiquadFilter() + lp.type = 'lowpass' + lp.frequency.value = 450 + const airGain = ctx.createGain() + airGain.gain.value = 0.06 + air.connect(lp) + lp.connect(airGain) + airGain.connect(master) + air.start(0) + air.stop(totalS) + + const rendered = await ctx.startRendering() + + // Crossfade the extra tail into the head, then trim — plain loop=true plays seamlessly + const loopN = Math.floor(AMBIENT_LOOP_S * sampleRate) + const fadeN = Math.floor(AMBIENT_FADE_S * sampleRate) + const src = rendered.getChannelData(0) + const out = ctx.createBuffer(1, loopN, sampleRate) + const dst = out.getChannelData(0) + dst.set(src.subarray(0, loopN)) + for (let i = 0; i < fadeN; i++) { + const w = i / fadeN + dst[i] = dst[i] * w + src[loopN + i] * (1 - w) + } + return out +} + +export async function synthesizeKit(sampleRate?: number): Promise { + const Offline = getOfflineCtor() + if (!Offline) return {} + const sr = sampleRate !== undefined && Number.isFinite(sampleRate) && sampleRate >= 8000 + ? sampleRate + : DEFAULT_SAMPLE_RATE + + try { + const [klaxon, sonarPing, radioSquelch, launchWhoosh, distantImpact, uiBlip, uiError, ambientCic] + = await Promise.all([ + render(Offline, sr, 1.8, buildKlaxon), + render(Offline, sr, 1.4, buildSonarPing), + render(Offline, sr, 0.32, buildRadioSquelch), + render(Offline, sr, 1.6, buildLaunchWhoosh), + render(Offline, sr, 1.1, buildDistantImpact), + render(Offline, sr, 0.09, buildUiBlip), + render(Offline, sr, 0.26, buildUiError), + renderAmbient(Offline, sr), + ]) + return { + 'klaxon': klaxon, + 'sonar-ping': sonarPing, + 'radio-squelch': radioSquelch, + 'launch-whoosh': launchWhoosh, + 'distant-impact': distantImpact, + 'ui-blip': uiBlip, + 'ui-error': uiError, + 'ambient-cic': ambientCic, + } + } catch { + return {} + } +} diff --git a/src/audio/useSoundEffects.ts b/src/audio/useSoundEffects.ts new file mode 100644 index 0000000..afa4737 --- /dev/null +++ b/src/audio/useSoundEffects.ts @@ -0,0 +1,41 @@ +import { useEffect, useRef } from 'react' +import { useGameStore } from '@/store/game-store' +import { useMenuStore } from '@/store/menu-store' +import { audioManager } from './audio-manager' +import { soundForEvent } from './event-sounds' +import type { GameEvent } from '@/types/game' + +export function useSoundEffects(): void { + useEffect(() => { + audioManager.attachGestureListeners(window) + return () => audioManager.detachGestureListeners() + }, []) + + const events = useGameStore((s) => s.viewState.events) + // Events are one-shot batches from the worker — reference-guarded like + // AlertFeed so StrictMode/unrelated re-renders don't replay a batch + const lastBatchRef = useRef(null) + useEffect(() => { + if (events.length === 0 || lastBatchRef.current === events) return + lastBatchRef.current = events + + const { viewState } = useGameStore.getState() + for (const e of events) { + const name = soundForEvent(e) + if (name) audioManager.play(name) + if (e.type === 'UNIT_DESTROYED' + && viewState.units.find((u) => u.id === e.unitId)?.nation === viewState.playerNation) { + audioManager.play('klaxon') + } + } + }, [events]) + + const screen = useMenuStore((s) => s.screen) + const initialized = useGameStore((s) => s.viewState.initialized) + useEffect(() => { + if (screen === 'playing' && initialized) audioManager.startAmbient() + else audioManager.stopAmbient() + }, [screen, initialized]) + + useEffect(() => () => audioManager.stopAmbient(), []) +} diff --git a/src/components/common/Panel.tsx b/src/components/common/Panel.tsx index c1b9776..066659b 100644 --- a/src/components/common/Panel.tsx +++ b/src/components/common/Panel.tsx @@ -1,5 +1,10 @@ -import { useRef, useState, useCallback, type CSSProperties, type ReactNode, type PointerEvent } from 'react' +import { useRef, useState, useEffect, useCallback, type CSSProperties, type ReactNode, type PointerEvent as ReactPointerEvent } from 'react' import { useIsMobile } from '@/hooks/useIsMobile' +import { useUIStore } from '@/store/ui-store' + +const Z_BASE = 20 +/** Min px of the title bar that must remain inside the viewport after a drag */ +const EDGE_MARGIN = 40 interface PanelProps { title: string @@ -19,42 +24,81 @@ function stripPosition(s: CSSProperties | undefined): CSSProperties { export default function Panel({ title, children, style, onClose, defaultMinimized = false }: PanelProps) { const isMobile = useIsMobile() const panelRef = useRef(null) - const [offset, setOffset] = useState<{ x: number; y: number } | null>(null) const [minimized, setMinimized] = useState(defaultMinimized) - const dragState = useRef<{ startX: number; startY: number; origLeft: number; origTop: number } | null>(null) + const [dragging, setDragging] = useState(false) + + const offset = useUIStore((s) => s.panelOffsets[title]) + const focusPanel = useUIStore((s) => s.focusPanel) + const zIndex = useUIStore((s) => { + const reg = s.panelRegistry[title] + if (!reg) return Z_BASE + let below = 0 + for (const other of Object.values(s.panelRegistry)) { + if (other.lastFocus < reg.lastFocus) below++ + } + return Z_BASE + below + }) + + // Live ref so consumer re-renders can swap onClose without re-registering (which would reset focus order) + const onCloseRef = useRef(onClose) + useEffect(() => { + onCloseRef.current = onClose + }, [onClose]) + + useEffect(() => { + useUIStore.getState().registerPanel(title, onCloseRef) + return () => useUIStore.getState().unregisterPanel(title) + }, [title]) + + // Detach window drag listeners if unmounted mid-drag + const dragCleanupRef = useRef<(() => void) | null>(null) + useEffect(() => () => dragCleanupRef.current?.(), []) - const onPointerDown = useCallback((e: PointerEvent) => { - if (isMobile) return // no drag on mobile + const onTitlePointerDown = useCallback((e: ReactPointerEvent) => { + if (isMobile || e.button !== 0) return + if ((e.target as HTMLElement).closest('button, a')) return const panel = panelRef.current if (!panel) return e.preventDefault() - ;(e.target as HTMLElement).setPointerCapture(e.pointerId) - const rect = panel.getBoundingClientRect() - dragState.current = { - startX: e.clientX, - startY: e.clientY, - origLeft: rect.left, - origTop: rect.top, - } - }, [isMobile]) - - const onPointerMove = useCallback((e: PointerEvent) => { - if (!dragState.current) return - const dx = e.clientX - dragState.current.startX - const dy = e.clientY - dragState.current.startY - setOffset({ - x: dragState.current.origLeft + dx, - y: dragState.current.origTop + dy, - }) - }, []) + const start = useUIStore.getState().panelOffsets[title] ?? { dx: 0, dy: 0 } + const startX = e.clientX + const startY = e.clientY + setDragging(true) - const onPointerUp = useCallback(() => { - dragState.current = null - }, []) + const onMove = (ev: PointerEvent) => { + useUIStore.getState().setPanelOffset(title, { + dx: start.dx + ev.clientX - startX, + dy: start.dy + ev.clientY - startY, + }) + } + const cleanup = () => { + window.removeEventListener('pointermove', onMove) + window.removeEventListener('pointerup', onUp) + dragCleanupRef.current = null + setDragging(false) + } + const onUp = () => { + cleanup() + const rect = panel.getBoundingClientRect() + const cur = useUIStore.getState().panelOffsets[title] ?? { dx: 0, dy: 0 } + let { dx, dy } = cur + if (rect.left > window.innerWidth - EDGE_MARGIN) dx -= rect.left - (window.innerWidth - EDGE_MARGIN) + if (rect.right < EDGE_MARGIN) dx += EDGE_MARGIN - rect.right + if (rect.top < 0) dy -= rect.top + if (rect.top > window.innerHeight - EDGE_MARGIN) dy -= rect.top - (window.innerHeight - EDGE_MARGIN) + if (dx !== cur.dx || dy !== cur.dy) useUIStore.getState().setPanelOffset(title, { dx, dy }) + } + dragCleanupRef.current = cleanup + window.addEventListener('pointermove', onMove) + window.addEventListener('pointerup', onUp) + }, [isMobile, title]) - const positionStyle: CSSProperties = (!isMobile && offset) - ? { position: 'fixed', left: offset.x, top: offset.y, right: 'auto', bottom: 'auto' } + const dx = offset?.dx ?? 0 + const dy = offset?.dy ?? 0 + // Drag offset rides on top of the consumer's own transform (e.g. translateX(-50%) centering) + const dragStyle: CSSProperties = (!isMobile && (dx !== 0 || dy !== 0)) + ? { transform: `${typeof style?.transform === 'string' ? `${style.transform} ` : ''}translate(${dx}px, ${dy}px)` } : {} const mobileStyle: CSSProperties = isMobile ? { @@ -75,6 +119,7 @@ export default function Panel({ title, children, style, onClose, defaultMinimize return (
focusPanel(title)} style={{ background: 'var(--bg-panel)', border: '1px solid var(--border-default)', @@ -86,15 +131,13 @@ export default function Panel({ title, children, style, onClose, defaultMinimize minWidth: minimized ? 120 : 260, maxHeight: minimized ? 'auto' : '80vh', overflowY: minimized ? 'hidden' : 'auto', - zIndex: 10, + zIndex, ...(isMobile ? stripPosition(style) : style), - ...(isMobile ? mobileStyle : positionStyle), + ...(isMobile ? mobileStyle : dragStyle), }} >
= {}) { + el.getBoundingClientRect = () => ({ + x: 100, y: 100, top: 100, left: 100, right: 400, bottom: 300, width: 300, height: 200, + toJSON: () => ({}), + ...rect, + }) as DOMRect +} + +function titleBar(title: string): HTMLElement { + return screen.getByText(title).parentElement! +} + +function panelEl(title: string): HTMLElement { + return titleBar(title).parentElement! +} + +beforeEach(() => { + useUIStore.setState({ panelOffsets: {}, panelRegistry: {}, panelFocusCounter: 0 }) +}) + +describe('Panel dragging', () => { + it('drags via the title bar, applies a translate, and persists the offset across remount', () => { + const first = render(body) + stubRect(first.container.firstChild as HTMLElement) + + expect(titleBar('TEST PANEL').style.cursor).toBe('grab') + fireEvent.pointerDown(titleBar('TEST PANEL'), { clientX: 200, clientY: 100 }) + expect(titleBar('TEST PANEL').style.cursor).toBe('grabbing') + + fireEvent.pointerMove(window, { clientX: 250, clientY: 130 }) + expect((first.container.firstChild as HTMLElement).style.transform).toBe('translate(50px, 30px)') + + fireEvent.pointerUp(window, { clientX: 250, clientY: 130 }) + expect(titleBar('TEST PANEL').style.cursor).toBe('grab') + expect(useUIStore.getState().panelOffsets['TEST PANEL']).toEqual({ dx: 50, dy: 30 }) + + first.unmount() + const second = render(body) + expect((second.container.firstChild as HTMLElement).style.transform).toBe('translate(50px, 30px)') + }) + + it('composes the drag translate with the consumer transform', () => { + const { container } = render( + body, + ) + stubRect(container.firstChild as HTMLElement) + + fireEvent.pointerDown(titleBar('CENTERED'), { clientX: 0, clientY: 0 }) + fireEvent.pointerMove(window, { clientX: 10, clientY: 5 }) + fireEvent.pointerUp(window) + + expect((container.firstChild as HTMLElement).style.transform).toBe('translateX(-50%) translate(10px, 5px)') + }) + + it('ignores drags starting on title-bar buttons', () => { + const onClose = vi.fn() + render(body) + + fireEvent.pointerDown(screen.getByText('x'), { clientX: 0, clientY: 0 }) + fireEvent.pointerMove(window, { clientX: 80, clientY: 80 }) + fireEvent.pointerUp(window) + + expect(useUIStore.getState().panelOffsets['BTN PANEL']).toBeUndefined() + }) + + it('clamps the offset on drag end so the title bar stays reachable', () => { + const { container } = render(body) + const el = container.firstChild as HTMLElement + + fireEvent.pointerDown(titleBar('CLAMP'), { clientX: 0, clientY: 0 }) + fireEvent.pointerMove(window, { clientX: 1100, clientY: 0 }) + // panel now hangs past the right viewport edge (jsdom viewport is 1024px wide) + stubRect(el, { left: 1100, right: 1400 }) + fireEvent.pointerUp(window) + + expect(useUIStore.getState().panelOffsets['CLAMP']).toEqual({ dx: 1024 - 40, dy: 0 }) + }) +}) + +describe('Panel z-order', () => { + it('raises the clicked panel above siblings', () => { + render( + <> + alpha body + bravo body + , + ) + const z = (t: string) => Number(panelEl(t).style.zIndex) + + // last mounted starts on top + expect(z('BRAVO')).toBeGreaterThan(z('ALPHA')) + + fireEvent.pointerDown(screen.getByText('alpha body')) + expect(z('ALPHA')).toBeGreaterThan(z('BRAVO')) + + fireEvent.pointerDown(screen.getByText('bravo body')) + expect(z('BRAVO')).toBeGreaterThan(z('ALPHA')) + }) +}) + +describe('Escape close ordering (closeTopPanel)', () => { + it('closes only the topmost panel with an onClose', () => { + const closeA = vi.fn() + const closeB = vi.fn() + render( + <> + alpha body + bravo body + , + ) + fireEvent.pointerDown(screen.getByText('alpha body')) + + expect(useUIStore.getState().closeTopPanel()).toBe(true) + expect(closeA).toHaveBeenCalledTimes(1) + expect(closeB).not.toHaveBeenCalled() + }) + + it('skips panels without an onClose', () => { + const closeB = vi.fn() + render( + <> + plain body + bravo body + , + ) + fireEvent.pointerDown(screen.getByText('plain body')) + + expect(useUIStore.getState().closeTopPanel()).toBe(true) + expect(closeB).toHaveBeenCalledTimes(1) + }) + + it('returns false when no closable panel is registered', () => { + render(plain body) + expect(useUIStore.getState().closeTopPanel()).toBe(false) + }) + + it('unregisters on unmount so closed panels cannot be re-closed', () => { + const closeA = vi.fn() + const { unmount } = render(alpha body) + unmount() + + expect(useUIStore.getState().closeTopPanel()).toBe(false) + expect(closeA).not.toHaveBeenCalled() + }) +}) diff --git a/src/components/hud/AlertFeed.tsx b/src/components/hud/AlertFeed.tsx index b4a67ce..5a39f0c 100644 --- a/src/components/hud/AlertFeed.tsx +++ b/src/components/hud/AlertFeed.tsx @@ -436,6 +436,29 @@ function eventColor(e: GameEvent): string { case 'CEASEFIRE_OFFERED': return 'var(--status-ready)' case 'CEASEFIRE_REJECTED': return 'var(--text-muted)' case 'WAR_ENDED': return 'var(--status-ready)' + case 'AIR_MISSION_LAUNCHED': return 'var(--status-moving)' + case 'FLIGHT_ON_STATION': return 'var(--status-ready)' + case 'FLIGHT_RTB': return 'var(--text-secondary)' + case 'FLIGHT_LOST': return 'var(--status-damaged)' + case 'AIR_INTERCEPT': return 'var(--status-engaged)' + case 'AUTO_ENGAGEMENT': return 'var(--status-engaged)' + case 'MISSILE_MISSED': return 'var(--text-muted)' + case 'MISSILE_CRASHED': return 'var(--text-muted)' + case 'ORDER_REJECTED': return 'var(--text-muted)' + case 'SATELLITE_PASS_COMPLETE': return 'var(--status-ready)' + case 'SATELLITE_PASS_FAILED': return 'var(--text-muted)' + case 'INTERCEPT_DECRYPTED': + return e.precedence === 'FLASH' ? '#ff4444' + : e.precedence === 'IMMEDIATE' ? 'var(--status-engaged)' + : 'var(--text-secondary)' + case 'AGENT_REPORT': return 'var(--status-ready)' + case 'AGENT_ARRESTED': return 'var(--status-damaged)' + case 'AGENT_EXFILTRATED': return 'var(--status-moving)' + case 'SPY_SWEEP': return 'var(--status-engaged)' + case 'ENCRYPTION_UPGRADED': return 'var(--status-engaged)' + case 'DECOY_REVEALED': return 'var(--status-moving)' + case 'STRIKE_LEAKED': return 'var(--status-damaged)' + case 'OPSEC_SWEEP_COMPLETE': return 'var(--status-ready)' default: return 'var(--text-secondary)' } } @@ -448,12 +471,24 @@ function weaponName(id: string): string { return weaponSpecs[id]?.name ?? id } +const NAME_PARTICLES = new Set(['of', 'the', 'el', 'al']) + +// 'bab_el_mandeb' → 'Bab el-Mandeb', 'strait_of_hormuz' → 'Strait of Hormuz' function lineName(id: string): string { - return id.toUpperCase().replace(/_/g, ' ') + return id + .split('_') + .filter(Boolean) + .map((word, i) => { + const lower = word.toLowerCase() + if (i > 0 && NAME_PARTICLES.has(lower)) return lower + return lower.charAt(0).toUpperCase() + lower.slice(1) + }) + .join(' ') + .replace(/ (el|al) /g, ' $1-') } function laneName(id: string, lanes: Map): string { - return (lanes.get(id) ?? lineName(id)).toUpperCase() + return lanes.get(id) ?? lineName(id) } function eventPosition( @@ -475,6 +510,21 @@ function eventPosition( case 'UNIT_REPAIRED': case 'POINT_DEFENSE_KILL': case 'RESUPPLIED': + case 'ORDER_REJECTED': + return unitPositions.get(e.unitId) ?? null + case 'AUTO_ENGAGEMENT': + return unitPositions.get(e.targetId) ?? unitPositions.get(e.unitId) ?? null + case 'MISSILE_MISSED': + case 'STRIKE_LEAKED': + return unitPositions.get(e.targetId) ?? null + case 'SATELLITE_PASS_COMPLETE': + case 'SATELLITE_PASS_FAILED': + return e.target + case 'MISSILE_CRASHED': + return e.position ?? null + case 'INTERCEPT_DECRYPTED': + return e.aboutUnitId ? (unitPositions.get(e.aboutUnitId) ?? null) : null + case 'DECOY_REVEALED': return unitPositions.get(e.unitId) ?? null case 'SUPPLY_LINE_INTERDICTED': return unitPositions.get(e.threatUnitId) ?? null @@ -525,6 +575,46 @@ function formatEvent(e: GameEvent, names: Map, lanes: Map 0 ? ` — ${e.kills} kill(s)` : ' — no kill'}` + case 'AUTO_ENGAGEMENT': + return `T+${e.tick} ENGAGING ${unitName(e.targetId, names)}: ${e.weaponName} x${e.count}${e.quality === 'datalink' ? ' [LINK]' : ''}` + case 'MISSILE_MISSED': + return `T+${e.tick} MISS: ${unitName(e.targetId, names)} evaded (stale track)` + case 'MISSILE_CRASHED': + return `T+${e.tick} MISSILE DOWN: fuel exhausted en route` + case 'ORDER_REJECTED': + return `T+${e.tick} ORDER REJECTED: ${unitName(e.unitId, names)} — ${e.reason}` + case 'SATELLITE_PASS_COMPLETE': + return `T+${e.tick} IMINT: pass complete — ${e.found} object(s) imaged${e.revealedDecoys > 0 ? `, ${e.revealedDecoys} DECOY` : ''}` + case 'SATELLITE_PASS_FAILED': + return `T+${e.tick} IMINT: pass failed — cloud cover ${e.cloudPct}%` + case 'INTERCEPT_DECRYPTED': + return `T+${e.tick} ${e.precedence} SIGINT: ${e.text}` + case 'AGENT_REPORT': + return `T+${e.tick} HUMINT ${e.codename}: ${e.text}` + case 'AGENT_ARRESTED': + return `T+${e.tick} SOURCE LOST: ${e.codename} arrested by counterintelligence` + case 'AGENT_EXFILTRATED': + return `T+${e.tick} ${e.codename} exfiltrated safely` + case 'SPY_SWEEP': + return `T+${e.tick} IRANIAN SPY SWEEP${e.arrests > 0 ? ` — ${e.arrests} source(s) lost` : ' — network intact'}` + case 'ENCRYPTION_UPGRADED': + return `T+${e.tick} SIGINT BLACKOUT: enemy upgraded encryption` + case 'DECOY_REVEALED': + return `T+${e.tick} DECOY IDENTIFIED: ${unitName(e.unitId, names)} is a dummy` + case 'STRIKE_LEAKED': + return `T+${e.tick} STRIKE COMPROMISED: enemy had foreknowledge` + case 'OPSEC_SWEEP_COMPLETE': + return `T+${e.tick} OPSEC SWEEP COMPLETE: leak level ${e.newLeakLevel}%` default: return `T+${(e as GameEvent & { tick: number }).tick} ${(e as GameEvent & { type: string }).type}` } diff --git a/src/components/hud/MapToggle.tsx b/src/components/hud/MapToggle.tsx index 8f9a292..7d25507 100644 --- a/src/components/hud/MapToggle.tsx +++ b/src/components/hud/MapToggle.tsx @@ -1,5 +1,6 @@ import { useState } from 'react' import { useUIStore } from '@/store/ui-store' +import { useMapIntelStore } from '@/store/map-intel-store' import { useIsMobile } from '@/hooks/useIsMobile' import type { CSSProperties } from 'react' @@ -74,6 +75,12 @@ export default function MapToggle() { const cycleMapMode = useUIStore(s => s.cycleMapMode) const toggleElevation = useUIStore(s => s.toggleElevation) const toggleIntelCoverage = useUIStore(s => s.toggleIntelCoverage) + const reconMosaic = useMapIntelStore(s => s.reconMosaic) + const adsbLive = useMapIntelStore(s => s.adsbLive) + const intelOverlays = useMapIntelStore(s => s.intelOverlays) + const toggleReconMosaic = useMapIntelStore(s => s.toggleReconMosaic) + const toggleAdsbLive = useMapIntelStore(s => s.toggleAdsbLive) + const toggleIntelOverlays = useMapIntelStore(s => s.toggleIntelOverlays) const [openSub, setOpenSub] = useState(null) @@ -98,7 +105,7 @@ export default function MapToggle() { - + + +
{/* Sub-menu panels */} diff --git a/src/components/hud/TopBar.tsx b/src/components/hud/TopBar.tsx index b2400da..8c5e6db 100644 --- a/src/components/hud/TopBar.tsx +++ b/src/components/hud/TopBar.tsx @@ -1,8 +1,9 @@ -import { useState, useCallback, useMemo } from 'react' +import { useState, useCallback, useEffect, useMemo, useRef } from 'react' import { useUIStore } from '@/store/ui-store' import { useGameStore } from '@/store/game-store' import { useStrikeStore } from '@/store/strike-store' import { useIntelStore } from '@/store/intel-store' +import { useMenuStore } from '@/store/menu-store' import { sendCommand, getFullState, loadState } from '@/store/bridge' import { saveToSlot, loadFromSlot } from '@/store/save-load' import { useIsMobile } from '@/hooks/useIsMobile' @@ -32,15 +33,70 @@ const INLINE_LABELS: Record = { 360: '1h', } -const ALL_SPEEDS = [0, 0.1, 1, 6, 60, 360, 3600] as const -const ALL_SPEED_LABELS: Record = { - 0: '||', - 0.1: '1s/s', - 1: '10s/s', - 6: '1m/s', - 60: '10m/s', - 360: '1h/s', - 3600: '10h/s', +// Time slider works in game-time multipliers (game-seconds per real second); +// engine speed (ticks per 100ms) = multiplier / 10 +const SLIDER_MAX_MULT = 3600 +const SLIDER_STEPS = 1000 +const SNAP_PCT = 0.08 +const DETENTS: { mult: number; label: string }[] = [ + { mult: 0, label: 'PAUSED' }, + { mult: 1, label: '1×' }, + { mult: 8, label: '8×' }, + { mult: 60, label: '60×' }, + { mult: 600, label: '10m/s' }, + { mult: 3600, label: '1h/s' }, +] + +function multToPos(mult: number): number { + if (mult <= 0) return 0 + return Math.max(1, Math.min(SLIDER_STEPS, Math.round(1 + ((SLIDER_STEPS - 1) * Math.log(mult)) / Math.log(SLIDER_MAX_MULT)))) +} + +function posToMult(pos: number): number { + if (pos <= 0) return 0 + const raw = Math.pow(SLIDER_MAX_MULT, (pos - 1) / (SLIDER_STEPS - 1)) + for (const d of DETENTS) { + if (d.mult > 0 && Math.abs(raw - d.mult) <= d.mult * SNAP_PCT) return d.mult + } + return Math.round(raw) +} + +function multLabel(mult: number): string { + const detent = DETENTS.find((d) => Math.abs(d.mult - mult) < 0.001) + if (detent) return detent.label + return mult >= 1 ? `×${Math.round(mult)}` : `×${mult.toFixed(1)}` +} + +const WAR_CONFIRM_MS = 5_000 +const EXIT_CONFIRM_MS = 4_000 + +// Two-step confirm state with a wall-clock auto-disarm (UI-side only, never engine time) +function useArmedCountdown(durationMs: number) { + const [remainingMs, setRemainingMs] = useState(null) + const deadlineRef = useRef(0) + const armed = remainingMs !== null + + useEffect(() => { + if (!armed) return + const id = setInterval(() => { + const left = deadlineRef.current - Date.now() + setRemainingMs(left > 0 ? left : null) + }, 100) + return () => clearInterval(id) + }, [armed]) + + const arm = useCallback(() => { + deadlineRef.current = Date.now() + durationMs + setRemainingMs(durationMs) + }, [durationMs]) + const disarm = useCallback(() => setRemainingMs(null), []) + + return { + armed, + secondsLeft: remainingMs === null ? 0 : Math.ceil(remainingMs / 1000), + arm, + disarm, + } } export default function TopBar() { @@ -55,6 +111,8 @@ export default function TopBar() { const showEconomy = useUIStore((s) => s.showEconomy) const showIntel = useUIStore((s) => s.showIntel) const toggleIntel = useUIStore((s) => s.toggleIntel) + const liveFeedsOpen = useUIStore((s) => s.liveFeedsOpen) + const toggleLiveFeeds = useUIStore((s) => s.toggleLiveFeeds) const placingCatalogId = useIntelStore((s) => s.placingCatalogId) const units = useGameStore((s) => s.viewState.units) @@ -76,10 +134,9 @@ export default function TopBar() { const hasUnits = units.length > 0 const [showHelp, setShowHelp] = useState(false) - const [warClickPending, setWarClickPending] = useState(false) + const warConfirm = useArmedCountdown(WAR_CONFIRM_MS) const [offerClickPending, setOfferClickPending] = useState(false) const [roeOpen, setRoeOpen] = useState(false) - const [speedDropdownOpen, setSpeedDropdownOpen] = useState(false) const [overflowOpen, setOverflowOpen] = useState(false) const [objectivesOpen, setObjectivesOpen] = useState(false) @@ -123,12 +180,12 @@ export default function TopBar() { const handleDeclareWar = () => { if (!primaryEnemyNation) return - if (!warClickPending) { - setWarClickPending(true) + if (!warConfirm.armed) { + warConfirm.arm() return } + warConfirm.disarm() sendCommand({ type: 'DECLARE_WAR', target: primaryEnemyNation.id }) - setWarClickPending(false) } const handleOfferCeasefire = () => { @@ -224,7 +281,7 @@ export default function TopBar() { }}> WAR - ) : !warClickPending ? ( + ) : !warConfirm.armed ? ( )} @@ -373,114 +430,7 @@ export default function TopBar() { - {INLINE_SPEEDS.map((s) => ( - - ))} - - {/* Speed chevron dropdown */} -
- - - {speedDropdownOpen && ( -
- - {/* Fine speed slider */} -
- { - const val = Number(e.target.value) - const speed = val === 0 ? 0 : 0.1 * Math.pow(36000, (val - 1) / 99) - sendCommand({ type: 'SET_SPEED', speed }) - }} - style={{ flex: 1, height: 4, cursor: 'pointer', accentColor: 'var(--text-accent)' }} - /> - - {time.speed <= 0 ? '||' : time.speed < 6 ? `${Math.round(time.speed * 10)}s/s` : time.speed < 300 ? `${Math.round(time.speed / 6)}m/s` : `${(time.speed / 360).toFixed(1)}h/s`} - -
-
- - {ALL_SPEEDS.map((s) => ( - - ))} -
- )} -
+ @@ -507,6 +457,12 @@ export default function TopBar() { {/* Intel panel toggle */} + {/* Live feeds window toggle */} + + + {/* Sound mute toggle */} + + {/* Hormuz status badge */} @@ -715,10 +671,10 @@ export default function TopBar() { {!atWarWithPrimaryEnemy && primaryEnemyNation && ( )}
@@ -794,6 +750,7 @@ export default function TopBar() { {atWarWithPrimaryEnemy && ( setOverflowOpen(false)} /> )} + setOverflowOpen(false)} />
)} @@ -802,9 +759,9 @@ export default function TopBar() { {/* Close dropdowns on outside click */} - {(roeOpen || speedDropdownOpen || overflowOpen || objectivesOpen) && ( + {(roeOpen || overflowOpen || objectivesOpen) && (
{ setRoeOpen(false); setSpeedDropdownOpen(false); setOverflowOpen(false); setObjectivesOpen(false) }} + onClick={() => { setRoeOpen(false); setOverflowOpen(false); setObjectivesOpen(false) }} style={{ position: 'fixed', inset: 0, @@ -860,6 +817,8 @@ export default function TopBar() {
Top Bar
+ + @@ -1004,6 +963,51 @@ function OverflowResign({ onDone }: { onDone: () => void }) { ) } +// Same return-to-menu flow as DebriefScreen's MAIN MENU button +function exitToMainMenu() { + useStrikeStore.getState().reset() + useIntelStore.getState().reset() + const ui = useUIStore.getState() + ui.clearSelection() + ui.setLeftPanel(null) + useUIStore.setState({ showIntel: false }) + useMenuStore.getState().setScreen('start') +} + +function OverflowMainMenu({ onDone }: { onDone: () => void }) { + const confirm = useArmedCountdown(EXIT_CONFIRM_MS) + return ( + + ) +} + function OverflowSaveLoad({ onDone }: { onDone: () => void }) { const [feedback, setFeedback] = useState(null) @@ -1079,6 +1083,164 @@ function IntelBtn({ active, onClick, compact }: { active: boolean; onClick: () = ) } +function TimeSlider({ speed }: { speed: number }) { + const [dragMult, setDragMult] = useState(null) + const lastSentRef = useRef(0) + const pendingRef = useRef(null) + const timerRef = useRef | null>(null) + const lastNonzeroRef = useRef(0.1) + + useEffect(() => { + if (speed > 0) lastNonzeroRef.current = speed + }, [speed]) + + useEffect(() => () => { + if (timerRef.current) clearTimeout(timerRef.current) + }, []) + + // ~10 sends/s max while dragging; trailing send keeps the final position + const sendSpeed = useCallback((s: number) => { + const since = performance.now() - lastSentRef.current + if (since >= 100) { + lastSentRef.current = performance.now() + sendCommand({ type: 'SET_SPEED', speed: s }) + } else { + pendingRef.current = s + if (!timerRef.current) { + timerRef.current = setTimeout(() => { + timerRef.current = null + lastSentRef.current = performance.now() + if (pendingRef.current !== null) { + sendCommand({ type: 'SET_SPEED', speed: pendingRef.current }) + pendingRef.current = null + } + }, 100 - since) + } + } + }, []) + + const mult = dragMult ?? speed * 10 + const paused = mult <= 0 + + return ( +
+ + { + const m = posToMult(Number(e.target.value)) + setDragMult(m) + sendSpeed(m / 10) + }} + onPointerUp={() => setDragMult(null)} + onKeyUp={() => setDragMult(null)} + onBlur={() => setDragMult(null)} + style={{ width: 92, height: 4, cursor: 'pointer', accentColor: 'var(--text-accent)' }} + /> + + {multLabel(mult)} + +
+ ) +} + +function LiveBtn({ active, onClick }: { active: boolean; onClick: () => void }) { + return ( + + ) +} + +function MuteBtn() { + const muted = useUIStore((s) => s.audioMuted) + const toggleAudioMuted = useUIStore((s) => s.toggleAudioMuted) + return ( + + ) +} + function StrikeBtn({ compact }: { compact: boolean }) { const { open, openStrike, closeStrike } = useStrikeStore() return ( diff --git a/src/components/hud/__tests__/AlertFeed.test.tsx b/src/components/hud/__tests__/AlertFeed.test.tsx index 2498b85..a340ad5 100644 --- a/src/components/hud/__tests__/AlertFeed.test.tsx +++ b/src/components/hud/__tests__/AlertFeed.test.tsx @@ -6,7 +6,7 @@ import { useUIStore } from '@/store/ui-store' import { sendCommand } from '@/store/bridge' import { shippingLanes } from '@/data/shipping/shipping-lanes' import type { GameViewState, ViewUnit } from '@/types/view' -import type { GameEvent } from '@/types/game' +import type { GameEvent, ShippingLane } from '@/types/game' vi.mock('@/store/bridge', () => ({ sendCommand: vi.fn(), @@ -50,6 +50,7 @@ const jamaran: ViewUnit = { interface ViewOpts { speed?: number units?: ViewUnit[] + lanes?: ShippingLane[] } function makeViewState(events: GameEvent[], opts: ViewOpts = {}): GameViewState { @@ -61,13 +62,14 @@ function makeViewState(events: GameEvent[], opts: ViewOpts = {}): GameViewState units: opts.units ?? [lincoln, jamaran], missiles: [], supplyLines: [], - shippingLanes, + shippingLanes: opts.lanes ?? shippingLanes, events, pendingEventCount: 0, satelliteDetectedUnitIds: [], warSupport: {}, gameOver: null, objectives: [], + intel: { assets: [], agents: [], products: [], taskings: [], leakLevel: 0, paranoiaBand: 'LOW' as const, encryptionUpgradedUntilTick: null }, } } @@ -150,7 +152,7 @@ describe('AlertFeed', () => { const cut: GameEvent = { type: 'SUPPLY_LINE_CUT', lineId: 'bandar_supply', tick: 7 } setStore([cut]) render() - expect(screen.getByText(/SUPPLY CUT: BANDAR SUPPLY/)).toBeTruthy() + expect(screen.getByText(/SUPPLY CUT: Bandar Supply/)).toBeTruthy() }) it('renders lane events with the lane display name, not the underscored id', () => { @@ -160,8 +162,18 @@ describe('AlertFeed', () => { } setStore([laneEvent]) render() - expect(screen.getByText(/BAB EL-MANDEB: REDUCED/)).toBeTruthy() - expect(screen.queryByText(/EL_MANDEB/)).toBeNull() + expect(screen.getByText(/Bab el-Mandeb: REDUCED/)).toBeTruthy() + expect(screen.queryByText(/EL_MANDEB/i)).toBeNull() + }) + + it('prettifies lane ids missing from view state instead of leaking the raw enum', () => { + const laneEvent: GameEvent = { + type: 'SHIPPING_LANE_STATUS_CHANGE', laneId: 'bab_el_mandeb', + newStatus: 'blocked', suppressionFactor: 1, tick: 9, + } + setStore([laneEvent], [], { lanes: [] }) + render() + expect(screen.getByText(/Bab el-Mandeb: BLOCKED/)).toBeTruthy() }) }) @@ -193,7 +205,7 @@ describe('AlertFeed click-to-zoom', () => { render() expandFeed() - fireEvent.click(screen.getByText(/STRAIT OF HORMUZ: BLOCKED/)) + fireEvent.click(screen.getByText(/Strait of Hormuz: BLOCKED/)) expect(useUIStore.getState().mapFocus).toMatchObject({ lng: 56.3, lat: 26.5 }) }) diff --git a/src/components/hud/__tests__/DebriefScreen.test.tsx b/src/components/hud/__tests__/DebriefScreen.test.tsx index e676e46..d762074 100644 --- a/src/components/hud/__tests__/DebriefScreen.test.tsx +++ b/src/components/hud/__tests__/DebriefScreen.test.tsx @@ -75,6 +75,7 @@ function makeViewState(over: Partial): GameViewState { warSupport: { usa: 64, iran: 0 }, gameOver: victoryReport, objectives, + intel: { assets: [], agents: [], products: [], taskings: [], leakLevel: 0, paranoiaBand: 'LOW' as const, encryptionUpgradedUntilTick: null }, ...over, } } diff --git a/src/components/hud/__tests__/MapToggle.test.tsx b/src/components/hud/__tests__/MapToggle.test.tsx index 0f3c8a7..81c2510 100644 --- a/src/components/hud/__tests__/MapToggle.test.tsx +++ b/src/components/hud/__tests__/MapToggle.test.tsx @@ -1,5 +1,7 @@ -import { describe, it, expect, beforeEach } from 'vitest' +import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest' import { useUIStore } from '@/store/ui-store' +import { useMapIntelStore } from '@/store/map-intel-store' +import type { ViewUnit } from '@/types/view' /** * Tests for map toggle behavior — written BEFORE implementation (TDD). @@ -31,3 +33,149 @@ describe('MapToggle sub-menu behavior', () => { expect(useUIStore.getState().losFilter).toBe('off') }) }) + +function mkUnit(id: string, nation: string, stale: boolean, status = 'ready'): ViewUnit { + return { id, nation, stale, status } as unknown as ViewUnit +} + +describe('map-intel-store toggles', () => { + beforeEach(() => { + useMapIntelStore.getState().setAdsbLive(false) + useMapIntelStore.setState({ intelOverlays: false, reconMosaic: false, staleSince: {}, adsbAircraft: [] }) + }) + + afterEach(() => { + useMapIntelStore.getState().setAdsbLive(false) + vi.unstubAllGlobals() + }) + + it('defaults: all layers off', () => { + const s = useMapIntelStore.getState() + expect(s.intelOverlays).toBe(false) + expect(s.reconMosaic).toBe(false) + expect(s.adsbLive).toBe(false) + expect(s.adsbAircraft).toEqual([]) + }) + + it('intel overlays and recon mosaic toggle independently', () => { + useMapIntelStore.getState().toggleIntelOverlays() + expect(useMapIntelStore.getState().intelOverlays).toBe(true) + expect(useMapIntelStore.getState().reconMosaic).toBe(false) + + useMapIntelStore.getState().toggleReconMosaic() + expect(useMapIntelStore.getState().reconMosaic).toBe(true) + + useMapIntelStore.getState().toggleIntelOverlays() + expect(useMapIntelStore.getState().intelOverlays).toBe(false) + expect(useMapIntelStore.getState().reconMosaic).toBe(true) + }) + + it('ADS-B on: fetches aircraft, caps at 80, trims callsigns', async () => { + const ac = Array.from({ length: 100 }, (_, i) => ({ + hex: `hex${i}`, + flight: `GULF${i} `, + lat: 26 + i * 0.01, + lon: 54, + track: 90, + })) + vi.stubGlobal('fetch', vi.fn(async () => ({ + ok: true, + json: async () => ({ ac }), + }))) + + useMapIntelStore.getState().setAdsbLive(true) + await vi.waitFor(() => { + expect(useMapIntelStore.getState().adsbAircraft.length).toBe(80) + }) + expect(useMapIntelStore.getState().adsbAircraft[0]).toEqual({ + id: 'hex0', + callsign: 'GULF0', + lat: 26, + lon: 54, + track: 90, + }) + }) + + it('ADS-B skips aircraft without a usable position', async () => { + vi.stubGlobal('fetch', vi.fn(async () => ({ + ok: true, + json: async () => ({ + ac: [ + { hex: 'nopos', flight: 'GHOST1' }, + { hex: 'good', flight: 'REAL1', lat: 26.5, lon: 54.2 }, + { hex: 'lastpos', lastPosition: { lat: 27.1, lon: 53.9 } }, + ], + }), + }))) + + useMapIntelStore.getState().setAdsbLive(true) + await vi.waitFor(() => { + expect(useMapIntelStore.getState().adsbAircraft.length).toBe(2) + }) + const ids = useMapIntelStore.getState().adsbAircraft.map(a => a.id) + expect(ids).toEqual(['good', 'lastpos']) + expect(useMapIntelStore.getState().adsbAircraft[1].track).toBeNull() + }) + + it('ADS-B fetch failure is silent and leaves no aircraft', async () => { + vi.stubGlobal('fetch', vi.fn(async () => { throw new Error('network down') })) + useMapIntelStore.getState().setAdsbLive(true) + await new Promise(r => setTimeout(r, 10)) + expect(useMapIntelStore.getState().adsbLive).toBe(true) + expect(useMapIntelStore.getState().adsbAircraft).toEqual([]) + }) + + it('ADS-B off clears aircraft', async () => { + vi.stubGlobal('fetch', vi.fn(async () => ({ + ok: true, + json: async () => ({ ac: [{ hex: 'a1', flight: 'X', lat: 26, lon: 54, track: 10 }] }), + }))) + useMapIntelStore.getState().setAdsbLive(true) + await vi.waitFor(() => { + expect(useMapIntelStore.getState().adsbAircraft.length).toBe(1) + }) + useMapIntelStore.getState().setAdsbLive(false) + expect(useMapIntelStore.getState().adsbAircraft).toEqual([]) + expect(useMapIntelStore.getState().adsbLive).toBe(false) + }) +}) + +describe('map-intel-store stale-since bookkeeping', () => { + beforeEach(() => { + useMapIntelStore.setState({ staleSince: {} }) + }) + + it('anchors a stale enemy contact at the tick it first went stale', () => { + const sync = useMapIntelStore.getState().syncStaleContacts + sync([mkUnit('e1', 'iran', true)], 100, 'usa') + expect(useMapIntelStore.getState().staleSince).toEqual({ e1: 100 }) + + // Still stale later → anchor preserved, not re-stamped + sync([mkUnit('e1', 'iran', true)], 500, 'usa') + expect(useMapIntelStore.getState().staleSince).toEqual({ e1: 100 }) + }) + + it('clears the anchor when the contact refreshes or disappears', () => { + const sync = useMapIntelStore.getState().syncStaleContacts + sync([mkUnit('e1', 'iran', true), mkUnit('e2', 'iran', true)], 100, 'usa') + expect(Object.keys(useMapIntelStore.getState().staleSince)).toHaveLength(2) + + // e1 refreshes (stale=false), e2 disappears entirely + sync([mkUnit('e1', 'iran', false)], 200, 'usa') + expect(useMapIntelStore.getState().staleSince).toEqual({}) + + // Going stale again re-anchors at the new tick + sync([mkUnit('e1', 'iran', true)], 300, 'usa') + expect(useMapIntelStore.getState().staleSince).toEqual({ e1: 300 }) + }) + + it('ignores own-nation and destroyed units', () => { + const sync = useMapIntelStore.getState().syncStaleContacts + sync([ + mkUnit('own1', 'usa', true), + mkUnit('dead1', 'iran', true, 'destroyed'), + mkUnit('e1', 'iran', true), + ], 50, 'usa') + expect(useMapIntelStore.getState().staleSince).toEqual({ e1: 50 }) + }) +}) diff --git a/src/components/hud/__tests__/TopBar.test.tsx b/src/components/hud/__tests__/TopBar.test.tsx index a53f286..86d133d 100644 --- a/src/components/hud/__tests__/TopBar.test.tsx +++ b/src/components/hud/__tests__/TopBar.test.tsx @@ -1,8 +1,11 @@ import { describe, it, expect, beforeEach, vi } from 'vitest' -import { render, screen, fireEvent } from '@testing-library/react' +import { render, screen, fireEvent, act } from '@testing-library/react' import TopBar from '../TopBar' import { useGameStore } from '@/store/game-store' +import { useUIStore } from '@/store/ui-store' +import { useMenuStore } from '@/store/menu-store' import { sendCommand } from '@/store/bridge' +import { audioManager, AUDIO_STORAGE_KEYS } from '@/audio/audio-manager' import type { GameViewState } from '@/types/view' import type { GameEvent, Nation } from '@/types/game' @@ -50,6 +53,15 @@ function makeViewState(over: Partial & { atWar?: boolean }): Game warSupport: { usa: 72, iran: 41 }, gameOver: null, objectives: [], + intel: { + assets: [], + agents: [], + products: [], + taskings: [], + leakLevel: 0, + paranoiaBand: 'LOW', + encryptionUpgradedUntilTick: null, + }, ...rest, } } @@ -86,6 +98,34 @@ describe('TopBar war controls', () => { expect(screen.getByText('41%')).toBeTruthy() }) + it('sends DECLARE_WAR when confirmed inside the countdown window', () => { + render() + + fireEvent.click(screen.getByText('DECLARE WAR')) + expect(sendCommand).not.toHaveBeenCalled() + + fireEvent.click(screen.getByText(/CONFIRM WAR/)) + expect(sendCommand).toHaveBeenCalledWith({ type: 'DECLARE_WAR', target: 'iran' }) + }) + + it('shows a live countdown on CONFIRM WAR and disarms when it expires', () => { + vi.useFakeTimers() + try { + render() + fireEvent.click(screen.getByText('DECLARE WAR')) + expect(screen.getByText('CONFIRM WAR 5')).toBeTruthy() + + act(() => { vi.advanceTimersByTime(1100) }) + expect(screen.getByText('CONFIRM WAR 4')).toBeTruthy() + + act(() => { vi.advanceTimersByTime(4000) }) + expect(screen.getByText('DECLARE WAR')).toBeTruthy() + expect(sendCommand).not.toHaveBeenCalled() + } finally { + vi.useRealTimers() + } + }) + it('sends OFFER_CEASEFIRE only after the two-step confirm', () => { setStore(makeViewState({ atWar: true })) render() @@ -168,3 +208,116 @@ describe('TopBar war controls', () => { expect(screen.queryByText('RESIGN')).toBeNull() }) }) + +describe('TopBar main menu exit', () => { + beforeEach(() => { + useMenuStore.getState().setScreen('playing') + }) + + it('returns to the main menu only after the two-step confirm', () => { + useUIStore.setState({ showIntel: true }) + render() + + fireEvent.click(screen.getByText('···')) + fireEvent.click(screen.getByText('MAIN MENU')) + expect(useMenuStore.getState().screen).toBe('playing') + + fireEvent.click(screen.getByText('CONFIRM EXIT?')) + expect(useMenuStore.getState().screen).toBe('start') + expect(useUIStore.getState().showIntel).toBe(false) + }) + + it('disarms CONFIRM EXIT? after 4 seconds without confirming', () => { + vi.useFakeTimers() + try { + render() + fireEvent.click(screen.getByText('···')) + fireEvent.click(screen.getByText('MAIN MENU')) + expect(screen.getByText('CONFIRM EXIT?')).toBeTruthy() + + act(() => { vi.advanceTimersByTime(4100) }) + expect(screen.getByText('MAIN MENU')).toBeTruthy() + expect(useMenuStore.getState().screen).toBe('playing') + } finally { + vi.useRealTimers() + } + }) +}) + +describe('TopBar sound toggle', () => { + beforeEach(() => { + audioManager.setMuted(false) + useUIStore.setState({ audioMuted: false }) + }) + + it('toggles mute in the ui-store and persists it', () => { + render() + const btn = screen.getByLabelText('Sound on/off') + expect(btn.getAttribute('title')).toBe('Sound on/off') + + fireEvent.click(btn) + expect(useUIStore.getState().audioMuted).toBe(true) + expect(localStorage.getItem(AUDIO_STORAGE_KEYS.muted)).toBe('1') + expect(audioManager.isMuted()).toBe(true) + + fireEvent.click(btn) + expect(useUIStore.getState().audioMuted).toBe(false) + expect(localStorage.getItem(AUDIO_STORAGE_KEYS.muted)).toBe('0') + }) +}) + +describe('TopBar time controls', () => { + function setSpeed(speed: number) { + setStore(makeViewState({ time: { tick: 100, timestamp: 1_000_000, speed, tickIntervalMs: 100 } })) + } + + it('slider at max sends 1h/s (engine speed 360)', () => { + render() + fireEvent.change(screen.getByRole('slider'), { target: { value: '1000' } }) + expect(sendCommand).toHaveBeenCalledWith({ type: 'SET_SPEED', speed: 360 }) + }) + + it('slider snaps near-detent positions to the detent', () => { + render() + // pos 500 ≈ multiplier 59.8 → snaps to the 60× detent → engine speed 6 + fireEvent.change(screen.getByRole('slider'), { target: { value: '500' } }) + expect(sendCommand).toHaveBeenCalledWith({ type: 'SET_SPEED', speed: 6 }) + }) + + it('slider at zero pauses', () => { + render() + fireEvent.change(screen.getByRole('slider'), { target: { value: '0' } }) + expect(sendCommand).toHaveBeenCalledWith({ type: 'SET_SPEED', speed: 0 }) + }) + + it('shows PAUSED when speed is 0 and ×N otherwise', () => { + setSpeed(0) + const { unmount } = render() + expect(screen.getByText('PAUSED')).toBeTruthy() + unmount() + + setSpeed(1) // multiplier 10 + render() + expect(screen.getByText('×10')).toBeTruthy() + }) + + it('pause button stops the clock and resumes to the last nonzero speed', () => { + setSpeed(1) + const { unmount } = render() + fireEvent.click(screen.getByLabelText('Pause')) + expect(sendCommand).toHaveBeenCalledWith({ type: 'SET_SPEED', speed: 0 }) + unmount() + + setSpeed(0) + render() + fireEvent.click(screen.getByLabelText('Resume')) + expect(sendCommand).toHaveBeenCalledWith({ type: 'SET_SPEED', speed: 0.1 }) + }) + + it('toggles the LIVE feeds window', () => { + useUIStore.setState({ liveFeedsOpen: false }) + render() + fireEvent.click(screen.getByText('LIVE')) + expect(useUIStore.getState().liveFeedsOpen).toBe(true) + }) +}) diff --git a/src/components/intel/ImintViewer.tsx b/src/components/intel/ImintViewer.tsx new file mode 100644 index 0000000..e81be30 --- /dev/null +++ b/src/components/intel/ImintViewer.tsx @@ -0,0 +1,258 @@ +import { useEffect, useRef, useState } from 'react' +import { useGameStore } from '@/store/game-store' +import { useUIStore } from '@/store/ui-store' +import { esriImageryTileUrl } from '@/data/feeds' +import { formatDtg, tileGrid, NOISE_BG, SCANLINE_OVERLAY } from './imagery' +import type { GameEvent, IntelProduct } from '@/types/game' + +const TOAST_MS = 10_000 + +export default function ImintViewer() { + const viewedProductId = useUIStore((s) => s.viewedProductId) + const setViewedProduct = useUIStore((s) => s.setViewedProduct) + const intel = useGameStore((s) => s.viewState.intel) + const events = useGameStore((s) => s.viewState.events) + const products = intel?.products ?? [] + + // Toast on fresh imagery — event batches are one-shot, guard by reference + const [toastUntil, setToastUntil] = useState(0) + const toastBatchRef = useRef(null) + useEffect(() => { + if (events.length === 0 || toastBatchRef.current === events) return + toastBatchRef.current = events + if (events.some((e) => e.type === 'SATELLITE_PASS_COMPLETE')) { + setToastUntil(Date.now() + TOAST_MS) + } + }, [events]) + useEffect(() => { + if (toastUntil <= Date.now()) return + const t = setTimeout(() => setToastUntil(0), toastUntil - Date.now()) + return () => clearTimeout(t) + }, [toastUntil]) + + const product = viewedProductId + ? products.find((p) => p.id === viewedProductId && p.kind === 'imint') ?? null + : null + + // Esc closes the viewer; capture+stop so App's global Escape doesn't also fire + useEffect(() => { + if (!product) return + const onKey = (e: KeyboardEvent) => { + if (e.key === 'Escape') { + e.stopPropagation() + setViewedProduct(null) + } + } + window.addEventListener('keydown', onKey, true) + return () => window.removeEventListener('keydown', onKey, true) + }, [product, setViewedProduct]) + + const openNewest = () => { + const newest = [...products].filter((p) => p.kind === 'imint').sort((a, b) => b.tick - a.tick)[0] + if (newest) setViewedProduct(newest.id) + setToastUntil(0) + } + + const sensorName = product?.assetId + ? intel?.assets.find((a) => a.id === product.assetId)?.name ?? product.assetId.toUpperCase() + : 'UNKNOWN SENSOR' + + return ( + <> + {toastUntil > Date.now() && !product && ( + + )} + + {product && ( +
setViewedProduct(null)} + style={{ + position: 'fixed', + inset: 0, + zIndex: 100, + background: 'rgba(0, 0, 0, 0.85)', + display: 'flex', + alignItems: 'center', + justifyContent: 'center', + fontFamily: 'var(--font-mono)', + }} + > + setViewedProduct(null)} + /> +
+ )} + + ) +} + +function ProductFrame({ product, sensorName, onClose }: { product: IntelProduct; sensorName: string; onClose: () => void }) { + const topSecret = product.classification.toUpperCase().includes('TOP SECRET') + const zoom = product.assetId === 'commercial' ? 14 : 15 + const target = product.target ?? { lng: 56.27, lat: 27.18 } + const { xs, ys } = tileGrid(target.lng, target.lat, zoom, 3, 2) + const [failed, setFailed] = useState>({}) + + return ( +
e.stopPropagation()} + style={{ + background: '#000', + border: '1px solid #2a2f36', + width: 'min(780px, 94vw)', + boxShadow: '0 0 60px rgba(0,0,0,0.9)', + }} + > + + + {/* Image area */} +
+
+ {ys.map((ty) => + xs.map((tx) => { + const k = `${tx}/${ty}` + return failed[k] ? ( +
+ COLLECTION ARTIFACT — IMAGE DATA UNAVAILABLE +
+ ) : ( + setFailed((f) => ({ ...f, [k]: true }))} + style={{ width: '100%', aspectRatio: '1', display: 'block', objectFit: 'cover' }} + draggable={false} + /> + ) + }), + )} +
+ + {/* Crosshair */} +
+
+
+
+
+ + {/* Corner brackets */} + {([ + { left: 6, top: 6, borderLeft: '2px solid', borderTop: '2px solid' }, + { right: 6, top: 6, borderRight: '2px solid', borderTop: '2px solid' }, + { left: 6, bottom: 6, borderLeft: '2px solid', borderBottom: '2px solid' }, + { right: 6, bottom: 6, borderRight: '2px solid', borderBottom: '2px solid' }, + ] as const).map((pos, i) => ( +
+ ))} + + {/* Burn-ins */} +
+ {sensorName.toUpperCase()} +
+
+ {product.niirs !== undefined && ( + + NIIRS {product.niirs} + + )} +
+
+ {formatDtg(product.tick)} +
+
+ {`${Math.abs(target.lat).toFixed(4)}${target.lat >= 0 ? 'N' : 'S'} ${Math.abs(target.lng).toFixed(4)}${target.lng >= 0 ? 'E' : 'W'}`} +
+ +
+
+ + {/* Caption bar */} +
+ {product.caption} + +
+ + +
+ ) +} + +function ClassificationBanner({ text, topSecret }: { text: string; topSecret: boolean }) { + return ( +
+ {text} +
+ ) +} diff --git a/src/components/intel/LiveFeeds.tsx b/src/components/intel/LiveFeeds.tsx new file mode 100644 index 0000000..19db596 --- /dev/null +++ b/src/components/intel/LiveFeeds.tsx @@ -0,0 +1,381 @@ +import { useCallback, useEffect, useRef, useState, type CSSProperties, type PointerEvent, type ReactNode } from 'react' +import { useGameStore } from '@/store/game-store' +import { useUIStore } from '@/store/ui-store' +import { + ADSB_POLL_INTERVAL_MS, + GULF_BBOX, + HORMUZ_LIVE_YOUTUBE_ID, + INTEL_SOURCES, + adsbLiveUrl, + esriImageryTileUrl, + eumetsatLiveUrl, + youtubeEmbedUrl, +} from '@/data/feeds' +import { formatDtg, isGulfDaylight, tileGrid, NOISE_BG, SCANLINE_OVERLAY } from './imagery' + +const GEOSAT_REFRESH_MS = 15 * 60 * 1000 +const ADSB_CENTER = { lat: 26.5, lon: 54.0, radiusNm: 220 } + +export default function LiveFeeds() { + const open = useUIStore((s) => s.liveFeedsOpen) + const toggleLiveFeeds = useUIStore((s) => s.toggleLiveFeeds) + const [pos, setPos] = useState<{ x: number; y: number } | null>(null) + const panelRef = useRef(null) + const dragRef = useRef<{ startX: number; startY: number; origLeft: number; origTop: number } | null>(null) + + const onPointerDown = useCallback((e: PointerEvent) => { + const panel = panelRef.current + if (!panel) return + e.preventDefault() + ;(e.target as HTMLElement).setPointerCapture(e.pointerId) + const rect = panel.getBoundingClientRect() + dragRef.current = { startX: e.clientX, startY: e.clientY, origLeft: rect.left, origTop: rect.top } + }, []) + const onPointerMove = useCallback((e: PointerEvent) => { + if (!dragRef.current) return + setPos({ + x: dragRef.current.origLeft + e.clientX - dragRef.current.startX, + y: dragRef.current.origTop + e.clientY - dragRef.current.startY, + }) + }, []) + const onPointerUp = useCallback(() => { dragRef.current = null }, []) + + if (!open) return null + + return ( +
+ + + {/* Title bar */} +
+ + LIVE FEEDS — THEATER ISR + + +
+ + {/* 2x2 grid */} +
+ + + + +
+ + {/* Credits — required by source terms */} +
+
+ INTEL SOURCES +
+
+ {INTEL_SOURCES.map((s) => ( + + {s.name} · {s.role} + + ))} +
+
+
+ ) +} + +function FeedCell({ title, live = true, caption, children }: { title: string; live?: boolean; caption?: ReactNode; children: ReactNode }) { + return ( +
+
+ {live && ( + + )} + + {title} + +
+
{children}
+ {caption !== undefined && ( +
+ {caption} +
+ )} +
+ ) +} + +function OfflineCard({ label }: { label: string }) { + return ( +
+ {label} +
+ ) +} + +// ── 1. EUMETSAT Meteosat-9 IODC, genuinely live, 15-min cadence ───────────── + +function GeosatCell() { + const [refreshedAt, setRefreshedAt] = useState(() => Date.now()) + const [failed, setFailed] = useState(false) + + useEffect(() => { + const iv = setInterval(() => { + setFailed(false) + setRefreshedAt(Date.now()) + }, GEOSAT_REFRESH_MS) + return () => clearInterval(iv) + }, []) + + const layer = isGulfDaylight(refreshedAt) ? 'rgb_naturalenhncd' : 'ir108' + const src = `${eumetsatLiveUrl({ layer, ...GULF_BBOX, width: 640, height: 360 })}&_cb=${refreshedAt}` + const iso = new Date(refreshedAt).toISOString() + + return ( + + {failed ? ( + + ) : ( + setFailed(true)} + style={{ position: 'absolute', inset: 0, width: '100%', height: '100%', objectFit: 'cover', display: 'block' }} + draggable={false} + /> + )} + + ) +} + +// ── 2. Reuters Hormuz vessel-traffic live stream ──────────────────────────── + +function HormuzCamCell() { + return ( + +