From 7224a92a59ba4aff85e700b95813092f62d608d2 Mon Sep 17 00:00:00 2001 From: CarlBarl <145713155+CarlBarl@users.noreply.github.com> Date: Wed, 10 Jun 2026 22:56:50 +0200 Subject: [PATCH 01/12] Combat on contacts: radar physics, datalink fire control, ROE doctrine MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Units now fight on their intel picture instead of ground truth. Radar realism (visibility): unit detection respects the radar horizon (4/3-earth model, per-category target heights) and the antenna's sector arc — a ship mast sees ~35-45 km against surface targets no matter its nominal range, which makes AWACS/E-2D the long-range eyes they are. Fire control: getFireControlQuality gates every shot — 'own' (shooter's radar holds the target), 'datalink' (live nation track + shooter on a datalink hub's network), or no shot. Fixed installations (airbases, naval bases) are public-knowledge coordinates, always strikable, and both sides start with them identified on the map. Datalink shots carry a 12% miss chance against moving targets; misses surface in the feed. Auto-engagement doctrine (friendly-ai, both nations): weapons_free engages any valid track in weapon range, weapons_tight only inside a 75 km self-defense bubble, 90 s cooldown (was 5 min), salvo sized by target class, nation-level re-engage guard against overkill, and an AUTO_ENGAGEMENT feed event with the firing quality. Iran's strategic AI uses the same fire-control gate, so hiding the carrier now works. Movement: naval MOVE orders route around land per player waypoint leg (drawn waypoints were silently discarded before); land units ordered into water get an ORDER_REJECTED event instead of waypoints silently vanishing mid-route. 434 tests green (8 new), tsc clean. Co-Authored-By: Claude Fable 5 --- src/components/hud/AlertFeed.tsx | 14 ++ src/engine/game-engine.ts | 41 ++++-- .../systems/__tests__/friendly-ai.test.ts | 110 +++++++++++++- .../systems/__tests__/visibility.test.ts | 41 +++++- src/engine/systems/ai.ts | 26 ++-- src/engine/systems/combat.ts | 25 +++- src/engine/systems/detection.ts | 2 +- src/engine/systems/friendly-ai.ts | 137 +++++++++++++----- src/engine/systems/movement.ts | 15 +- src/engine/systems/sensor-network.ts | 15 ++ src/engine/systems/visibility.ts | 132 +++++++++++++++-- src/types/commands.ts | 4 +- src/types/game.ts | 6 + 13 files changed, 481 insertions(+), 87 deletions(-) diff --git a/src/components/hud/AlertFeed.tsx b/src/components/hud/AlertFeed.tsx index b4a67ce..78f7add 100644 --- a/src/components/hud/AlertFeed.tsx +++ b/src/components/hud/AlertFeed.tsx @@ -436,6 +436,9 @@ 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 'AUTO_ENGAGEMENT': return 'var(--status-engaged)' + case 'MISSILE_MISSED': return 'var(--text-muted)' + case 'ORDER_REJECTED': return 'var(--text-muted)' default: return 'var(--text-secondary)' } } @@ -475,7 +478,12 @@ 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': + return unitPositions.get(e.targetId) ?? null case 'SUPPLY_LINE_INTERDICTED': return unitPositions.get(e.threatUnitId) ?? null case 'SHIPPING_LANE_STATUS_CHANGE': @@ -525,6 +533,12 @@ function formatEvent(e: GameEvent, names: Map, lanes: Map 0) { - // Auto-route naval units around land - const finalDest = cmd.waypoints[cmd.waypoints.length - 1] - const route = findNavalRoute(unit.position, finalDest, this.elevationGrid) - if (route) { - unit.waypoints = [...route, finalDest] - } else { - unit.waypoints = cmd.waypoints // fallback to direct if no route + // Auto-route naval units around land, honoring every player waypoint as a leg + const routed: typeof cmd.waypoints = [] + let from = unit.position + for (const wp of cmd.waypoints) { + const leg = findNavalRoute(from, wp, this.elevationGrid) + if (leg) routed.push(...leg) + routed.push(wp) + from = wp } + unit.waypoints = routed } else { unit.waypoints = cmd.waypoints } @@ -237,7 +254,7 @@ export class GameEngine { break } case 'LAUNCH_MISSILE': { - const event = launchMissile(state, cmd.launcherId, cmd.weaponId, cmd.targetId, cmd.waypoints) + const event = launchMissile(state, cmd.launcherId, cmd.weaponId, cmd.targetId, cmd.waypoints, cmd.trackQuality) if (event) { const launcher = state.units.get(cmd.launcherId) const target = state.units.get(cmd.targetId) diff --git a/src/engine/systems/__tests__/friendly-ai.test.ts b/src/engine/systems/__tests__/friendly-ai.test.ts index aded62d..d6a7712 100644 --- a/src/engine/systems/__tests__/friendly-ai.test.ts +++ b/src/engine/systems/__tests__/friendly-ai.test.ts @@ -1,7 +1,7 @@ import { describe, it, expect, beforeEach } from 'vitest' import { processFriendlyAI, resetFriendlyAIState } from '../friendly-ai' import { SeededRNG } from '../../utils/rng' -import type { GameState, Unit, NationId, WeaponLoadout } from '@/types/game' +import type { GameState, Sensor, Unit, NationId, WeaponLoadout } from '@/types/game' // ── Helpers ───────────────────────────────────────────────────── @@ -33,6 +33,11 @@ function loadout(weaponId: string, count: number): WeaponLoadout { return { weaponId, count, maxCount: count, reloadTimeSec: 0 } } +// Tall mast keeps the radar horizon out of these doctrine tests +function radar(range_km: number): Sensor { + return { type: 'radar', range_km, detection_prob: 0.9, antenna_height_m: 2000 } +} + function makeState(units: Unit[]): GameState { return { playerNation: 'usa', @@ -60,7 +65,18 @@ function makeState(units: Unit[]): GameState { } } -// Iranian ship ~55km away — inside harpoon (130km) and tomahawk (1600km) range +/** Seed a live nation-level contact so datalink engagement is possible */ +function seedContact(state: GameState, observer: NationId, target: Unit): void { + state.visibility ??= {} + const contacts = (state.visibility[observer as string] ??= {}) + contacts[target.id] = { + level: 'tracked', + lastSeenTick: state.time.tick, + lastKnownPosition: { ...target.position }, + } +} + +// Iranian ship ~55km away — inside harpoon (130km) range const enemyShip = () => makeUnit({ id: 'ir_ship', nation: 'iran', position: { lat: 26.5, lng: 52 } }) // ── Tests ─────────────────────────────────────────────────────── @@ -74,6 +90,7 @@ describe('processFriendlyAI', () => { const ship = makeUnit({ id: 'us_ship', nation: 'usa', + sensors: [radar(200)], weapons: [loadout('tomahawk', 30), loadout('sm6', 12), loadout('harpoon', 8)], }) const state = makeState([ship, enemyShip()]) @@ -99,6 +116,7 @@ describe('processFriendlyAI', () => { nation: 'iran', category: 'missile_battery', position: { lat: 26.5, lng: 52 }, + sensors: [radar(200)], weapons: [loadout('fateh110', 30), loadout('shahed_136', 40)], }) const usTarget = makeUnit({ id: 'us_ship', nation: 'usa', position: { lat: 26, lng: 52 } }) @@ -115,6 +133,7 @@ describe('processFriendlyAI', () => { readiness: 'packing', readinessTimer: 300, deploy_time_sec: 600, + sensors: [radar(200)], weapons: [loadout('harpoon', 8)], }) const state = makeState([battery, enemyShip()]) @@ -128,4 +147,91 @@ describe('processFriendlyAI', () => { const cmds = processFriendlyAI(state, new SeededRNG(42)) expect(cmds.length).toBeGreaterThanOrEqual(1) }) + + it('holds fire with no track: blind ships cannot engage targets in weapon range', () => { + const ship = makeUnit({ + id: 'us_ship', + nation: 'usa', + weapons: [loadout('harpoon', 8)], + }) + const state = makeState([ship, enemyShip()]) + + expect(processFriendlyAI(state, new SeededRNG(42))).toHaveLength(0) + }) + + it('engages on own radar with own quality and emits AUTO_ENGAGEMENT', () => { + const ship = makeUnit({ + id: 'us_ship', + nation: 'usa', + sensors: [radar(200)], + weapons: [loadout('harpoon', 8)], + }) + const state = makeState([ship, enemyShip()]) + + const cmds = processFriendlyAI(state, new SeededRNG(42)) + expect(cmds.length).toBeGreaterThanOrEqual(1) + expect(cmds.every(c => c.type === 'LAUNCH_MISSILE' && c.trackQuality === 'own')).toBe(true) + + const engagement = state.events.find(e => e.type === 'AUTO_ENGAGEMENT') + expect(engagement).toBeDefined() + if (engagement?.type === 'AUTO_ENGAGEMENT') { + expect(engagement.targetId).toBe('ir_ship') + expect(engagement.quality).toBe('own') + } + }) + + it('engages on a datalink track when the nation holds a live contact', () => { + const ship = makeUnit({ + id: 'us_ship', + nation: 'usa', + datalink_range_km: 150, // hub itself → on the network + weapons: [loadout('harpoon', 8)], + }) + const target = enemyShip() + const state = makeState([ship, target]) + seedContact(state, 'usa', target) + + const cmds = processFriendlyAI(state, new SeededRNG(42)) + expect(cmds.length).toBeGreaterThanOrEqual(1) + expect(cmds.every(c => c.type === 'LAUNCH_MISSILE' && c.trackQuality === 'datalink')).toBe(true) + }) + + it('weapons_tight only engages inside the self-defense bubble', () => { + const tightShip = makeUnit({ + id: 'us_tight', + nation: 'usa', + roe: 'weapons_tight', + sensors: [radar(200)], + weapons: [loadout('harpoon', 8)], + }) + // ~111km away: in harpoon range, outside the 75km self-defense bubble + const farTarget = makeUnit({ id: 'ir_far', nation: 'iran', position: { lat: 27, lng: 52 } }) + const state = makeState([tightShip, farTarget]) + + expect(processFriendlyAI(state, new SeededRNG(42))).toHaveLength(0) + + // Same geometry under weapons_free fires + tightShip.roe = 'weapons_free' + resetFriendlyAIState() + expect(processFriendlyAI(state, new SeededRNG(42)).length).toBeGreaterThanOrEqual(1) + }) + + it('does not pile a second salvo onto a freshly engaged target', () => { + const a = makeUnit({ + id: 'us_a', nation: 'usa', sensors: [radar(200)], weapons: [loadout('harpoon', 8)], + }) + const b = makeUnit({ + id: 'us_b', nation: 'usa', position: { lat: 26.1, lng: 52 }, sensors: [radar(200)], weapons: [loadout('harpoon', 8)], + }) + const state = makeState([a, b, enemyShip()]) + + const cmds = processFriendlyAI(state, new SeededRNG(42)) + const shooters = new Set(cmds.map(c => c.type === 'LAUNCH_MISSILE' ? c.launcherId : '')) + expect(shooters.size).toBe(1) + + // After the re-engage window the second ship may add its own salvo + state.time.tick += 200 + const later = processFriendlyAI(state, new SeededRNG(43)) + expect(later.length).toBeGreaterThanOrEqual(1) + }) }) diff --git a/src/engine/systems/__tests__/visibility.test.ts b/src/engine/systems/__tests__/visibility.test.ts index 3cf89a2..0536541 100644 --- a/src/engine/systems/__tests__/visibility.test.ts +++ b/src/engine/systems/__tests__/visibility.test.ts @@ -31,8 +31,10 @@ function makeUnit(overrides: Partial & { id: string; nation: NationId }): } as Unit } -function radar(range_km: number, antenna_height_m = 15): Sensor { - return { type: 'radar', range_km, detection_prob: 0.9, antenna_height_m } +// Default test radar sits on a tall mast so the radar horizon never caps these +// scenarios — acquisition/decay tests probe contact logic, not curvature physics. +function radar(range_km: number, antenna_height_m = 2000, sector_deg?: number): Sensor { + return { type: 'radar', range_km, detection_prob: 0.9, antenna_height_m, sector_deg } } function makeState(units: Unit[], tick = 60): GameState { @@ -150,6 +152,41 @@ describe('radar acquisition', () => { expect(contact(state, 'iran', 'ir_ship')).toBeUndefined() }) + it('radar horizon caps surface-to-surface detection regardless of nominal range', () => { + // Ship-mast radar (25 m) vs ship (20 m): horizon ≈ 4.12·(√25+√20) ≈ 39 km + const usShip = makeUnit({ id: 'us_ship', nation: 'usa', sensors: [radar(400, 25)] }) + const irBeyond = makeUnit({ id: 'ir_beyond', nation: 'iran', position: { lat: 27, lng: 53 } }) // ~99 km + const irInside = makeUnit({ id: 'ir_inside', nation: 'iran', position: { lat: 27, lng: 52.3 } }) // ~30 km + const state = makeState([usShip, irBeyond, irInside]) + + runEval(state, 60) + + expect(contact(state, 'usa', 'ir_beyond')).toBeUndefined() + expect(contact(state, 'usa', 'ir_inside')?.level).toBe('identified') + }) + + it('airborne radar (AWACS) sees surface ships far beyond a ship-mast horizon', () => { + const awacs = makeUnit({ id: 'us_awacs', nation: 'usa', category: 'aircraft', sensors: [radar(400, 10000)] }) + const irFar = makeUnit({ id: 'ir_far', nation: 'iran', position: { lat: 27, lng: 55.5 } }) // ~347 km + const state = makeState([awacs, irFar]) + + runEval(state, 60) + + expect(contact(state, 'usa', 'ir_far')?.level).toBe('tracked') + }) + + it('sector-limited radar only acquires inside its arc', () => { + const usSam = makeUnit({ id: 'us_sam', nation: 'usa', heading: 90, sensors: [radar(100, 2000, 120)] }) + const irEast = makeUnit({ id: 'ir_east', nation: 'iran', position: { lat: 27, lng: 52.5 } }) // bearing ~90°, ~50 km + const irWest = makeUnit({ id: 'ir_west', nation: 'iran', position: { lat: 27, lng: 51.3 } }) // bearing ~270° + const state = makeState([usSam, irEast, irWest]) + + runEval(state, 60) + + expect(contact(state, 'usa', 'ir_east')?.level).toBe('identified') + expect(contact(state, 'usa', 'ir_west')).toBeUndefined() + }) + it('terrain blocks line of sight', () => { // 3000 m ridge at lng 52.4-52.6, flat elsewhere const elevations = Array.from({ length: 20 }, () => diff --git a/src/engine/systems/ai.ts b/src/engine/systems/ai.ts index c37be3f..f253062 100644 --- a/src/engine/systems/ai.ts +++ b/src/engine/systems/ai.ts @@ -1,10 +1,12 @@ -import type { GameState, NationId, Position, Unit } from '@/types/game' +import type { GameState, NationId, Position, TrackQuality, Unit } from '@/types/game' import type { Command } from '@/types/commands' +import type { ElevationGrid } from './elevation' import type { SeededRNG } from '../utils/rng' import { weaponSpecs } from '@/data/weapons/missiles' import { haversine, bearing } from '../utils/geo' import { processDroneSwarm, getDroneAmmo } from './drone-ai' import { WAR_SUPPORT_CRITICAL_THRESHOLD } from './war-support' +import { getFireControlQuality } from './visibility' type AIPhase = 'PEACETIME' | 'ALERT' | 'DEFENSIVE' | 'OFFENSIVE' | 'ATTRITION' @@ -83,7 +85,7 @@ export function orientSAMRadars(state: GameState, excludeNation?: NationId): voi } /** Process AI for all non-player nations. Returns commands to execute. */ -export function processAI(state: GameState, rng: SeededRNG): Command[] { +export function processAI(state: GameState, rng: SeededRNG, grid?: ElevationGrid | null): Command[] { const commands: Command[] = [] // Re-orient enemy SAMs periodically (initial orient done in game-engine init) @@ -160,7 +162,7 @@ export function processAI(state: GameState, rng: SeededRNG): Command[] { case 'DEFENSIVE': // Retaliate within 5 minutes of being attacked if (ai.attacksReceived > 0 && state.time.tick - ai.lastRetaliationTick > 300) { - const salvoCommands = generateRetaliatorySalvo(state, nation.id, enemyNation, rng, 'defensive') + const salvoCommands = generateRetaliatorySalvo(state, nation.id, enemyNation, rng, 'defensive', grid) commands.push(...salvoCommands) // Accompany with drone swarm for saturation effect if (getDroneAmmo(state, nation.id) > 10) { @@ -177,7 +179,7 @@ export function processAI(state: GameState, rng: SeededRNG): Command[] { case 'OFFENSIVE': // Launch salvos every 15 minutes if (state.time.tick - ai.lastRetaliationTick > 900) { - const salvoCommands = generateRetaliatorySalvo(state, nation.id, enemyNation, rng, 'offensive') + const salvoCommands = generateRetaliatorySalvo(state, nation.id, enemyNation, rng, 'offensive', grid) commands.push(...salvoCommands) ai.lastRetaliationTick = state.time.tick ai.salvosLaunched++ @@ -192,7 +194,7 @@ export function processAI(state: GameState, rng: SeededRNG): Command[] { case 'ATTRITION': // Conserve ballistic ammo — launch only for saturation if (state.time.tick - ai.lastRetaliationTick > 3600) { - const salvoCommands = generateRetaliatorySalvo(state, nation.id, enemyNation, rng, 'saturation') + const salvoCommands = generateRetaliatorySalvo(state, nation.id, enemyNation, rng, 'saturation', grid) commands.push(...salvoCommands) ai.lastRetaliationTick = state.time.tick } @@ -246,6 +248,7 @@ function generateRetaliatorySalvo( enemyNation: NationId, rng: SeededRNG, mode: 'defensive' | 'offensive' | 'saturation', + grid?: ElevationGrid | null, ): Command[] { const commands: Command[] = [] @@ -259,7 +262,8 @@ function generateRetaliatorySalvo( }), ) - // Find enemy targets, prioritized + // Find enemy targets, prioritized — the AI fights on its own intel picture, + // so it can only target units it holds a fire-quality track on const targets = Array.from(state.units.values()) .filter(u => u.nation === enemyNation && u.status !== 'destroyed') .sort((a, b) => targetPriority(b) - targetPriority(a)) @@ -284,13 +288,16 @@ function generateRetaliatorySalvo( const spec = weaponSpecs[loadout.weaponId] if (!spec || spec.type === 'sam' || loadout.count <= 0) continue - // Pick a target in range + // Pick a target in range that this launcher has fire-control quality on + let quality: TrackQuality | null = null const target = targets.find(t => { const dist = haversine(launcher.position, t.position) - return dist <= spec.range_km + if (dist > spec.range_km) return false + quality = getFireControlQuality(state, launcher, t, grid ?? null) + return quality !== null }) - if (!target) continue + if (!target || !quality) continue // Launch 1-3 missiles at this target const count = Math.min(loadout.count, rng.int(1, 3), maxLaunches - launched) @@ -300,6 +307,7 @@ function generateRetaliatorySalvo( launcherId: launcher.id, weaponId: loadout.weaponId, targetId: target.id, + trackQuality: quality, }) launched++ } diff --git a/src/engine/systems/combat.ts b/src/engine/systems/combat.ts index f571757..23f3a81 100644 --- a/src/engine/systems/combat.ts +++ b/src/engine/systems/combat.ts @@ -1,4 +1,4 @@ -import type { GameState, GameEvent, Missile, NationId, Unit, WeaponSpec, ADSystemSpec, Position } from '@/types/game' +import type { GameState, GameEvent, Missile, NationId, TrackQuality, Unit, WeaponSpec, ADSystemSpec, Position } from '@/types/game' import type { ElevationGrid } from './elevation' import type { SeededRNG } from '../utils/rng' import { weaponSpecs } from '@/data/weapons/missiles' @@ -131,7 +131,7 @@ export function processCombat(state: GameState, rng: SeededRNG, elevationGrid?: updateMissilePositions(state) runADEngagement(state, rng, elevationGrid, sensorNetwork) updateInterceptors(state, rng) - resolveImpacts(state) + resolveImpacts(state, rng) updateReloads(state) } @@ -840,6 +840,7 @@ export function launchMissile( weaponId: string, targetId: string, waypoints?: Position[], + trackQuality?: TrackQuality, ): GameEvent | null { const launcher = state.units.get(launcherId) const target = state.units.get(targetId) @@ -940,6 +941,7 @@ export function launchMissile( speed_current_mach: spec.type === 'ballistic_missile' ? 0 : spec.speed_mach, fuel_remaining_sec: fuelSec, is_interceptor: false, + networkQuality: trackQuality === 'datalink' ? 'tracked' : 'own', } state.missiles.set(id, missile) @@ -1105,7 +1107,11 @@ function isAlreadyEngagedByUnit(unitId: string, missileId: string): boolean { // IMPACT RESOLUTION // =============================================== -function resolveImpacts(state: GameState): void { +/** Categories that can move between launch and impact — datalink shots may miss them */ +const MOBILE_TARGET_CATEGORIES = new Set(['ship', 'carrier_group', 'submarine', 'aircraft']) +const DATALINK_MISS_CHANCE = 0.12 + +function resolveImpacts(state: GameState, rng: SeededRNG): void { const events: GameEvent[] = [] for (const missile of state.missiles.values()) { @@ -1120,6 +1126,19 @@ function resolveImpacts(state: GameState): void { const target = state.units.get(missile.targetId) const spec = weaponSpecs[missile.weaponId] + // Shots on relayed tracks lack terminal-quality data — moving targets can evade + if (target && spec && missile.networkQuality === 'tracked' && + MOBILE_TARGET_CATEGORIES.has(target.category) && rng.chance(DATALINK_MISS_CHANCE)) { + events.push({ + type: 'MISSILE_MISSED', + missileId: missile.id, + targetId: missile.targetId, + tick: state.time.tick, + }) + state.missiles.delete(missile.id) + continue + } + if (target && target.status !== 'destroyed' && spec) { const damage = computeDamage(spec, target.hardness) const healthBefore = target.health diff --git a/src/engine/systems/detection.ts b/src/engine/systems/detection.ts index 889a97c..67ae559 100644 --- a/src/engine/systems/detection.ts +++ b/src/engine/systems/detection.ts @@ -10,7 +10,7 @@ export interface DetectedThreat { } /** Radar horizon distance in km using 4/3 earth refraction model */ -function radarHorizon(antennaHeightM: number, targetHeightM: number): number { +export function radarHorizon(antennaHeightM: number, targetHeightM: number): number { return 4.12 * (Math.sqrt(Math.max(0, antennaHeightM)) + Math.sqrt(Math.max(0, targetHeightM))) } diff --git a/src/engine/systems/friendly-ai.ts b/src/engine/systems/friendly-ai.ts index ae8dc2b..e6f4d81 100644 --- a/src/engine/systems/friendly-ai.ts +++ b/src/engine/systems/friendly-ai.ts @@ -1,22 +1,35 @@ /** - * Autonomous offensive AI for units with weapons_free ROE. - * Generic — works for any nation, any unit type with offensive weapons. - * When a unit is set to weapons_free and its nation is at war, - * it autonomously selects and fires at high-priority enemy targets in range. + * Auto-engagement doctrine — units of any nation fire on enemy CONTACTS, not ground truth. + * + * A unit may engage a target only with fire-control quality from getFireControlQuality: + * its own radar holds the target ('own'), or a live nation-level track exists and the + * unit is datalink-connected ('datalink' — AWACS/hub relays the picture). Datalink shots + * carry a miss chance against moving targets (combat.ts). + * + * ROE: weapons_free engages any valid track in weapon range; weapons_tight only targets + * within the self-defense bubble around the unit or a nearby friendly; hold_fire never. */ -import type { GameState, NationId, UnitId } from '@/types/game' +import type { GameState, NationId, Unit, UnitCategory, UnitId } from '@/types/game' import type { Command } from '@/types/commands' +import type { ElevationGrid } from './elevation' import type { SeededRNG } from '../utils/rng' import { weaponSpecs } from '@/data/weapons/missiles' import { haversine } from '../utils/geo' +import { getFireControlQuality } from './visibility' + +const FIRE_COOLDOWN_TICKS = 90 +const SELF_DEFENSE_RADIUS_KM = 75 +/** A target already salvoed by the nation gets a grace period before the next salvo */ +const TARGET_REENGAGE_TICKS = 120 -const FIRE_COOLDOWN_TICKS = 300 // 5 minutes between autonomous salvos const lastFireTick = new Map() +const lastTargetSalvoTick = new Map() // `${nation}:${targetId}` /** Reset module-level state — must be called on save/load */ export function resetFriendlyAIState(): void { lastFireTick.clear() + lastTargetSalvoTick.clear() } const CATEGORY_PRIORITY: Record = { @@ -35,73 +48,117 @@ const CATEGORY_PRIORITY: Record = { // belong to drone-ai's swarm logic (its cooldowns + never-empty rules). const EXCLUDED_WEAPON_TYPES = new Set(['sam', 'cruise_missile', 'ballistic_missile', 'loitering_munition']) -/** Process autonomous offensive fire for all weapons_free units at war */ -export function processFriendlyAI(state: GameState, rng: SeededRNG): Command[] { +/** Anti-ship missiles only work against things that float */ +const ASHM_TARGETS = new Set(['ship', 'carrier_group', 'submarine']) + +function validTargetForWeapon(weaponType: string, category: UnitCategory): boolean { + if (weaponType === 'ashm') return ASHM_TARGETS.has(category) + return category !== 'aircraft' // generic tactical weapons can't hit fast movers +} + +function salvoSizeFor(category: UnitCategory): number { + switch (category) { + case 'carrier_group': return 4 + case 'ship': return 2 + case 'submarine': return 1 + default: return 2 + } +} + +/** Process autonomous engagement for all units at war (weapons_free or weapons_tight) */ +export function processFriendlyAI(state: GameState, _rng: SeededRNG, grid?: ElevationGrid | null): Command[] { const commands: Command[] = [] + const tick = state.time.tick for (const unit of state.units.values()) { if (unit.status === 'destroyed') continue - if (unit.roe !== 'weapons_free') continue + if (unit.roe === 'hold_fire') continue // launchMissile rejects non-deployed launchers — skip them so the cooldown isn't burned for nothing if (unit.readiness && unit.readiness !== 'deployed') continue - // Must be at war const nation = state.nations[unit.nation] if (!nation || nation.atWar.length === 0) continue - // Cooldown check const lastFire = lastFireTick.get(unit.id) ?? -FIRE_COOLDOWN_TICKS - if (state.time.tick - lastFire < FIRE_COOLDOWN_TICKS) continue + if (tick - lastFire < FIRE_COOLDOWN_TICKS) continue - // Find tactical offensive weapons with ammo const offensiveWeapons = unit.weapons.filter(w => { const spec = weaponSpecs[w.weaponId] return spec && !EXCLUDED_WEAPON_TYPES.has(spec.type) && w.count > 0 }) - if (offensiveWeapons.length === 0) continue - // Find enemy nations const enemyNations = new Set(nation.atWar as NationId[]) - // Find all enemy targets, sorted by priority - const enemies = Array.from(state.units.values()) + const candidates = Array.from(state.units.values()) .filter(u => enemyNations.has(u.nation) && u.status !== 'destroyed') .sort((a, b) => (CATEGORY_PRIORITY[b.category] ?? 0) - (CATEGORY_PRIORITY[a.category] ?? 0)) + if (candidates.length === 0) continue - if (enemies.length === 0) continue - - // For each offensive weapon, find a target in range let fired = false for (const loadout of offensiveWeapons) { - if (fired) break // one salvo per cooldown cycle - + if (fired) break const spec = weaponSpecs[loadout.weaponId] if (!spec) continue - // Find best target in range - const target = enemies.find(e => { - const dist = haversine(unit.position, e.position) - return dist <= spec.range_km - }) - - if (!target) continue - - // Fire 1-2 missiles - const salvoSize = Math.min(loadout.count, rng.int(1, 2)) - for (let i = 0; i < salvoSize; i++) { - commands.push({ - type: 'LAUNCH_MISSILE', - launcherId: unit.id, - weaponId: loadout.weaponId, + for (const target of candidates) { + if (!validTargetForWeapon(spec.type, target.category)) continue + + const dist = haversine(unit.position, target.position) + if (dist > spec.range_km) continue + + if (unit.roe === 'weapons_tight' && !isSelfDefense(state, unit, target)) continue + + // Nation-level overkill guard — don't have every ship dump at the same contact + const salvoKey = `${unit.nation}:${target.id}` + const lastSalvo = lastTargetSalvoTick.get(salvoKey) ?? -TARGET_REENGAGE_TICKS + if (tick - lastSalvo < TARGET_REENGAGE_TICKS) continue + + const quality = getFireControlQuality(state, unit, target, grid ?? null) + if (!quality) continue + + const salvoSize = Math.min(loadout.count, salvoSizeFor(target.category)) + for (let i = 0; i < salvoSize; i++) { + commands.push({ + type: 'LAUNCH_MISSILE', + launcherId: unit.id, + weaponId: loadout.weaponId, + targetId: target.id, + trackQuality: quality, + }) + } + + state.events.push({ + type: 'AUTO_ENGAGEMENT', + unitId: unit.id, targetId: target.id, + weaponName: spec.name, + count: salvoSize, + quality, + tick, }) - } + state.pendingEvents.push(state.events[state.events.length - 1]) - lastFireTick.set(unit.id, state.time.tick) - fired = true + lastFireTick.set(unit.id, tick) + lastTargetSalvoTick.set(salvoKey, tick) + fired = true + break + } } } return commands } + +/** weapons_tight: only engage targets near the unit itself or a nearby friendly */ +function isSelfDefense(state: GameState, unit: Unit, target: Unit): boolean { + if (haversine(unit.position, target.position) <= SELF_DEFENSE_RADIUS_KM) return true + for (const friendly of state.units.values()) { + if (friendly.nation !== unit.nation || friendly.status === 'destroyed') continue + if (haversine(friendly.position, target.position) <= SELF_DEFENSE_RADIUS_KM && + haversine(unit.position, friendly.position) <= SELF_DEFENSE_RADIUS_KM * 2) { + return true + } + } + return false +} diff --git a/src/engine/systems/movement.ts b/src/engine/systems/movement.ts index 3f7360e..282ce71 100644 --- a/src/engine/systems/movement.ts +++ b/src/engine/systems/movement.ts @@ -67,9 +67,18 @@ export function processMovement(state: GameState, elevationGrid?: ElevationGrid continue } if (!isNaval && nextIsWater) { - // Land unit hitting water — skip this waypoint - unit.waypoints.shift() - if (unit.waypoints.length === 0) finishMovement(unit) + // Land unit hitting water — halt and tell the player instead of silently + // skipping waypoints (which read as "my order vanished") + unit.waypoints = [] + finishMovement(unit) + const event = { + type: 'ORDER_REJECTED' as const, + unitId: unit.id, + reason: 'route blocked by water', + tick: state.time.tick, + } + state.events.push(event) + state.pendingEvents.push(event) continue } } diff --git a/src/engine/systems/sensor-network.ts b/src/engine/systems/sensor-network.ts index b9bb3c6..3e4c590 100644 --- a/src/engine/systems/sensor-network.ts +++ b/src/engine/systems/sensor-network.ts @@ -255,3 +255,18 @@ export function isDetectedByELINT( ): boolean { return network.elintDetections.get(nation)?.has(unitId) ?? false } + +// --------------------------------------------------------------------------- +// Datalink connectivity — can this unit receive fire-quality tracks from the net? +// --------------------------------------------------------------------------- + +/** A unit is on the datalink if it is a hub itself or within range of any friendly hub */ +export function isDatalinkConnected(state: GameState, unit: Unit): boolean { + if (unit.datalink_range_km && unit.datalink_range_km > 0) return true + for (const hub of state.units.values()) { + if (hub.nation !== unit.nation || hub.status === 'destroyed') continue + if (!hub.datalink_range_km || hub.datalink_range_km <= 0) continue + if (haversine(unit.position, hub.position) <= hub.datalink_range_km) return true + } + return false +} diff --git a/src/engine/systems/visibility.ts b/src/engine/systems/visibility.ts index e1cdfe2..a0c1575 100644 --- a/src/engine/systems/visibility.ts +++ b/src/engine/systems/visibility.ts @@ -3,6 +3,7 @@ import type { Nation, NationId, Position, + TrackQuality, Unit, UnitCategory, UnitId, @@ -12,9 +13,10 @@ import type { import type { ElevationGrid } from './elevation' import type { SensorNetwork } from './sensor-network' import type { EspionageResult } from './espionage' -import { hasLineOfSight } from './detection' +import { hasLineOfSight, radarHorizon } from './detection' +import { isDatalinkConnected } from './sensor-network' import { getSatelliteDetections, pointToLineDistKm, DETECTION_FADE_TICKS } from './satellites' -import { haversine } from '../utils/geo' +import { haversine, bearing } from '../utils/geo' /** * Fog of war. Maintains state.visibility — per observing nation, a contact map over @@ -34,6 +36,22 @@ const DEFAULT_SIGINT_MULTIPLIER = 1.5 const DEFAULT_ANTENNA_HEIGHT_M = 15 const TARGET_HEIGHT_M = 10 +/** Effective radar target height per category — drives the radar-horizon cap */ +const TARGET_HEIGHT_BY_CATEGORY: Partial> = { + aircraft: 8000, + carrier_group: 40, + ship: 20, + naval_base: 30, + airbase: 25, + submarine: 2, + missile_battery: 5, + sam_site: 8, +} + +function targetHeightM(category: UnitCategory): number { + return TARGET_HEIGHT_BY_CATEGORY[category] ?? TARGET_HEIGHT_M +} + const LEVEL_RANK: Record = { unseen: 0, detected: 1, tracked: 2, identified: 3 } interface ContactMeta { @@ -70,6 +88,24 @@ export function resetVisibilityState(): void { metaByObserver.clear() } +/** + * Seed scenario-start contacts: fixed military installations (airbases, naval bases) + * are public knowledge — both sides start with them identified and pinned. + */ +export function seedInitialVisibility(state: GameState): void { + state.visibility ??= {} + for (const nation of Object.values(state.nations)) { + const contacts = (state.visibility[nation.id as string] ??= {}) + const meta = metaFor(nation.id as string) + for (const unit of state.units.values()) { + if (unit.nation === nation.id || unit.status === 'destroyed') continue + if (unit.category !== 'airbase' && unit.category !== 'naval_base') continue + if (contacts[unit.id]) continue + contacts[unit.id] = newContact(meta, unit, 'identified', state.time.tick) + } + } +} + // --------------------------------------------------------------------------- // Per-minute source evaluation // --------------------------------------------------------------------------- @@ -145,24 +181,52 @@ function evaluateSources(state: GameState, espionage: EspionageResult | null, gr function radarContactLevel(ownRadars: Unit[], target: Unit, grid: ElevationGrid | null): VisibilityLevel { let best: VisibilityLevel = 'unseen' for (const radar of ownRadars) { - let range = 0 - let antennaHeight = DEFAULT_ANTENNA_HEIGHT_M - for (const s of radar.sensors) { - if (s.type === 'radar' && s.range_km > range) { - range = s.range_km - antennaHeight = s.antenna_height_m ?? DEFAULT_ANTENNA_HEIGHT_M - } + const level = radarSeesUnit(radar, target, grid) + if (level === 'identified') return 'identified' + if (level === 'tracked') best = 'tracked' + } + return best +} + +/** + * Can a single unit's radar see a target unit right now? + * Models nominal range, the radar horizon (earth curvature), the antenna's + * sector arc relative to the unit's heading, and terrain line-of-sight. + */ +export function radarSeesUnit(radar: Unit, target: Unit, grid: ElevationGrid | null): VisibilityLevel { + if (radar.status === 'destroyed') return 'unseen' + const dist = haversine(radar.position, target.position) + const targetAltAglM = targetHeightM(target.category) + + let best: VisibilityLevel = 'unseen' + for (const s of radar.sensors) { + if (s.type !== 'radar' || s.range_km <= 0) continue + if (dist > s.range_km) continue + + const antennaHeight = s.antenna_height_m ?? DEFAULT_ANTENNA_HEIGHT_M + + // Earth curvature: low antennas can't see surface targets far away no matter + // the radar's nominal range. This is what makes AWACS the long-range eyes. + const horizonKm = radarHorizon(antennaHeight, targetAltAglM) + if (dist > horizonKm) continue + + // Sector arc relative to the unit's heading + const sectorDeg = s.sector_deg ?? 360 + if (sectorDeg < 360) { + const brg = bearing(radar.position, target.position) + const diff = ((brg - radar.heading) % 360 + 540) % 360 - 180 + if (Math.abs(diff) > sectorDeg / 2) continue } - const dist = haversine(radar.position, target.position) - if (dist > range) continue + if (grid) { const radarAltM = grid.getElevation(radar.position.lat, radar.position.lng) + antennaHeight - const targetAltM = grid.getElevation(target.position.lat, target.position.lng) + TARGET_HEIGHT_M + const targetAltM = grid.getElevation(target.position.lat, target.position.lng) + targetAltAglM if (!hasLineOfSight(radar.position, radarAltM, target.position.lat, target.position.lng, targetAltM, grid)) { continue } } - if (dist <= range * RADAR_IDENTIFY_FRACTION) return 'identified' + + if (dist <= s.range_km * RADAR_IDENTIFY_FRACTION) return 'identified' best = 'tracked' } return best @@ -370,3 +434,45 @@ const CONTACT_NAMES: Record = { export function contactDisplayName(category: UnitCategory): string { return CONTACT_NAMES[category] ?? 'Unknown contact' } + +// --------------------------------------------------------------------------- +// Fire control — what may a unit shoot at, and on whose data? +// --------------------------------------------------------------------------- + +const FIXED_SITE_CATEGORIES = new Set(['airbase', 'naval_base']) + +/** + * Fire-control quality for shooter → target: + * 'own' — the shooter's own radar holds the target right now + * 'datalink' — a live nation-level track exists and the shooter is on the network, + * or the target is a fixed site with known coordinates + * null — no engageable track; the shooter may not fire at this target + */ +export function getFireControlQuality( + state: GameState, + shooter: Unit, + target: Unit, + grid: ElevationGrid | null, +): TrackQuality | null { + if (target.nation === shooter.nation || target.status === 'destroyed') return null + + // Fixed installations are public knowledge — surveyed coordinates, no track needed + if (FIXED_SITE_CATEGORIES.has(target.category)) return 'datalink' + + if (radarSeesUnit(shooter, target, grid) !== 'unseen') return 'own' + + const contact = state.visibility?.[shooter.nation as string]?.[target.id] + if (!contact || contact.level === 'unseen') return null + + // Unmoved SAM sites pin at their last fix — strikable on coordinates + if (target.category === 'sam_site' && contact.pinned && + target.position.lat === contact.lastKnownPosition.lat && + target.position.lng === contact.lastKnownPosition.lng) { + return 'datalink' + } + + const live = contact.level === 'tracked' || contact.level === 'identified' + if (live && isDatalinkConnected(state, shooter)) return 'datalink' + + return null +} diff --git a/src/types/commands.ts b/src/types/commands.ts index 35a982a..662df46 100644 --- a/src/types/commands.ts +++ b/src/types/commands.ts @@ -1,8 +1,8 @@ -import type { GameTime, IntelBudget, NationId, Position, ROE, UnitId, WeaponId } from './game' +import type { GameTime, IntelBudget, NationId, Position, ROE, TrackQuality, UnitId, WeaponId } from './game' export type Command = | { type: 'MOVE_UNIT'; unitId: UnitId; waypoints: Position[] } - | { type: 'LAUNCH_MISSILE'; launcherId: UnitId; weaponId: WeaponId; targetId: UnitId; waypoints?: Position[] } + | { type: 'LAUNCH_MISSILE'; launcherId: UnitId; weaponId: WeaponId; targetId: UnitId; waypoints?: Position[]; trackQuality?: TrackQuality } | { type: 'LAUNCH_SALVO'; launcherId: UnitId; weaponId: WeaponId; targetId: UnitId; count: number; waypoints?: Position[] } | { type: 'SET_ROE'; unitId: UnitId; roe: ROE } | { type: 'SET_SPEED'; speed: GameTime['speed'] } diff --git a/src/types/game.ts b/src/types/game.ts index cf98ba1..b791cf9 100644 --- a/src/types/game.ts +++ b/src/types/game.ts @@ -360,3 +360,9 @@ export type GameEvent = | { type: 'CEASEFIRE_OFFERED'; by: NationId; tick: number } | { type: 'CEASEFIRE_REJECTED'; by: NationId; tick: number } | { type: 'WAR_ENDED'; outcome: 'ceasefire' | 'capitulation'; loser?: NationId; tick: number } + | { type: 'AUTO_ENGAGEMENT'; unitId: UnitId; targetId: UnitId; weaponName: string; count: number; quality: TrackQuality; tick: number } + | { type: 'MISSILE_MISSED'; missileId: string; targetId: UnitId; tick: number } + | { type: 'ORDER_REJECTED'; unitId: UnitId; reason: string; tick: number } + +/** Fire-control source for a shot: the shooter's own sensors, or a track relayed over datalink */ +export type TrackQuality = 'own' | 'datalink' From 19cfd0ade55e4590414a9370ccca1472b147b10e Mon Sep 17 00:00:00 2001 From: CarlBarl <145713155+CarlBarl@users.noreply.github.com> Date: Wed, 10 Jun 2026 23:17:58 +0200 Subject: [PATCH 02/12] Scaffold intel suite v3: engine core, contracts, verified feed config MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Design: docs/plans/intel-suite-v3.md (from a 5-agent research sweep of real US ISR architecture, the OSINT ecosystem, Iran counterintelligence, intel game design, and live-verified free data feeds). Engine (complete): src/engine/systems/intel.ts — taskable satellite passes with cloud-cover gating and NIIRS-based decoy reveal, SIGINT intercept cards that leak true AI state (salvo warnings, hidden emitter geolocation, leadership reads), four named HUMINT sources with the exposure/rest/exfiltrate loop, Iranian counterintel (paranoia meter, spy sweeps, encryption blackouts), player leak level with compromised strikes (35% miss + TEL scoot), Iranian decoy TELs, wide-area Triton sweeps and Iran's coarse carrier picture, EMCON (radar-silent units drop out of ELINT and their own radar picture but keep the network). Contracts: IntelState/IntelAsset/AgentSource/IntelProduct types, six new commands, eleven new events with feed formatters, IntelViewState snapshot slice, save/load + backfill for older saves. Data: src/data/feeds.ts — central config of verified keyless+CORS real data sources (Esri imagery, NASA Worldview/GIBS, EUMETSAT live WMS, airplanes.live ADS-B, Open-Meteo, Reuters Hormuz stream) with the attribution roster; ISR asset/agent/OSINT-account rosters. 434 tests green, tsc clean. Co-Authored-By: Claude Fable 5 --- docs/plans/intel-suite-v3.md | 336 ++++++++++++++ src/components/hud/AlertFeed.tsx | 44 ++ src/data/feeds.ts | 117 +++++ src/data/intel/agents.ts | 60 +++ src/data/intel/assets.ts | 94 ++++ src/data/intel/osint-accounts.ts | 93 ++++ src/engine/game-engine.ts | 89 +++- src/engine/systems/ai.ts | 19 + src/engine/systems/combat.ts | 15 + src/engine/systems/detection.ts | 1 + src/engine/systems/intel.ts | 637 +++++++++++++++++++++++++++ src/engine/systems/sensor-network.ts | 1 + src/engine/systems/visibility.ts | 9 +- src/engine/systems/war-support.ts | 6 + src/types/commands.ts | 6 + src/types/game.ts | 107 +++++ src/types/view.ts | 22 + 17 files changed, 1649 insertions(+), 7 deletions(-) create mode 100644 docs/plans/intel-suite-v3.md create mode 100644 src/data/feeds.ts create mode 100644 src/data/intel/agents.ts create mode 100644 src/data/intel/assets.ts create mode 100644 src/data/intel/osint-accounts.ts create mode 100644 src/engine/systems/intel.ts 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/src/components/hud/AlertFeed.tsx b/src/components/hud/AlertFeed.tsx index 78f7add..57ca3ea 100644 --- a/src/components/hud/AlertFeed.tsx +++ b/src/components/hud/AlertFeed.tsx @@ -439,6 +439,20 @@ function eventColor(e: GameEvent): string { case 'AUTO_ENGAGEMENT': return 'var(--status-engaged)' case 'MISSILE_MISSED': 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)' } } @@ -483,7 +497,15 @@ function eventPosition( 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 '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 case 'SHIPPING_LANE_STATUS_CHANGE': @@ -539,6 +561,28 @@ function formatEvent(e: GameEvent, names: Map, lanes: Map 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/data/feeds.ts b/src/data/feeds.ts new file mode 100644 index 0000000..dd34a38 --- /dev/null +++ b/src/data/feeds.ts @@ -0,0 +1,117 @@ +/** + * Central config for every REAL external data source the game uses. + * All endpoints verified keyless + CORS-enabled 2026-06-10 (research sweep). + * Every consumer MUST degrade gracefully on fetch failure — these are + * community/agency services and they drift. + * + * Compliance: the game must stay free/non-revenue (Esri free-use, EOX CC-BY-NC-SA). + * Attribution lives in the LIVE FEEDS window credits panel (INTEL_SOURCES below). + */ + +/** Slippy-map tile coordinates from lat/lon (verified against Bandar Abbas fixtures) */ +export function lonLatToTile(lon: number, lat: number, z: number): { x: number; y: number } { + const x = Math.floor(((lon + 180) / 360) * 2 ** z) + const latRad = (lat * Math.PI) / 180 + const y = Math.floor(((1 - Math.log(Math.tan(latRad) + 1 / Math.cos(latRad)) / Math.PI) / 2) * 2 ** z) + return { x, y } +} + +/** Esri World Imagery — high-res IMINT backdrops + FMV scenery. NOTE: path is z/y/x. */ +export function esriImageryTileUrl(z: number, x: number, y: number): string { + return `https://server.arcgisonline.com/ArcGIS/rest/services/World_Imagery/MapServer/tile/${z}/${y}/${x}` +} + +/** Yesterday UTC as YYYY-MM-DD — the safe GIBS/Worldview date (today may not be downlinked) */ +export function safeGibsDate(now: Date = new Date()): string { + const d = new Date(now.getTime() - 24 * 3600 * 1000) + return d.toISOString().slice(0, 10) +} + +/** + * NASA Worldview Snapshot — one keyless fetch returns a date-stamped recon JPEG + * of any bbox. The IMINT product generator. 404 on a date = "pass not downlinked yet". + */ +export function worldviewSnapshotUrl(opts: { + date: string + south: number + west: number + north: number + east: number + width?: number + height?: number +}): string { + const { date, south, west, north, east, width = 768, height = 512 } = opts + return ( + 'https://wvs.earthdata.nasa.gov/api/v1/snapshot?REQUEST=GetSnapshot' + + '&LAYERS=VIIRS_SNPP_CorrectedReflectance_TrueColor&CRS=EPSG:4326' + + `&TIME=${date}&BBOX=${south},${west},${north},${east}` + + `&WIDTH=${width}&HEIGHT=${height}&FORMAT=image/jpeg` + ) +} + +/** + * NASA GIBS WMTS — daily VIIRS true color as a maplibre raster source. + * maxzoom 9. NEVER use TIME=default (resolves to tomorrow UTC → 404). + */ +export function gibsDailyTrueColorTiles(date: string): { tiles: string[]; maxzoom: number; attribution: string } { + return { + tiles: [ + `https://gibs.earthdata.nasa.gov/wmts/epsg3857/best/VIIRS_SNPP_CorrectedReflectance_TrueColor/default/${date}/GoogleMapsCompatible_Level9/{z}/{y}/{x}.jpg`, + ], + maxzoom: 9, + attribution: 'NASA GIBS', + } +} + +/** + * EUMETSAT EUMETView WMS — Meteosat-9 IODC, 15-minute cadence over the Gulf. + * The one genuinely LIVE satellite source. ir108 works at night. + */ +export function eumetsatLiveUrl(opts: { + layer: 'rgb_naturalenhncd' | 'ir108' + south: number + west: number + north: number + east: number + width?: number + height?: number +}): string { + const { layer, south, west, north, east, width = 640, height = 480 } = opts + return ( + '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=${south},${west},${north},${east}&width=${width}&height=${height}` + ) +} + +/** airplanes.live — real live ADS-B over the Gulf. ~1 req/s limit: poll every 45 s, radius ≤ 250 nm. */ +export function adsbLiveUrl(lat: number, lon: number, radiusNm: number): string { + return `https://api.airplanes.live/v2/point/${lat.toFixed(3)}/${lon.toFixed(3)}/${Math.min(250, radiusNm)}` +} + +export const ADSB_POLL_INTERVAL_MS = 45_000 + +/** Open-Meteo — real current cloud cover, gates optical satellite tasking */ +export function cloudCoverUrl(lat: number, lon: number): string { + return `https://api.open-meteo.com/v1/forecast?latitude=${lat.toFixed(3)}&longitude=${lon.toFixed(3)}¤t=cloud_cover` +} + +/** Reuters "Vessel traffic in Strait of Hormuz" live stream (verified embeddable via oEmbed) */ +export const HORMUZ_LIVE_YOUTUBE_ID = 'osUeQTR91Ig' + +export function youtubeEmbedUrl(videoId: string): string { + return `https://www.youtube-nocookie.com/embed/${videoId}?autoplay=1&mute=1` +} + +/** EUMETSAT live window default bbox — the Gulf theater */ +export const GULF_BBOX = { south: 22, west: 44, north: 32, east: 62 } + +/** Attribution for the LIVE FEEDS credits panel — required by source terms */ +export const INTEL_SOURCES: { name: string; role: string }[] = [ + { name: 'Esri · Maxar · Earthstar Geographics', role: 'World Imagery basemap & IMINT products' }, + { name: 'NASA Global Imagery Browse Services (GIBS)', role: 'Daily VIIRS reconnaissance mosaics' }, + { name: 'EUMETSAT © 2026', role: 'Meteosat-9 IODC live geostationary imagery' }, + { name: 'airplanes.live', role: 'Live ADS-B air traffic' }, + { name: 'Open-Meteo', role: 'Real-time weather (collection gating)' }, + { name: 'Reuters', role: 'Strait of Hormuz live vessel traffic' }, +] diff --git a/src/data/intel/agents.ts b/src/data/intel/agents.ts new file mode 100644 index 0000000..265a490 --- /dev/null +++ b/src/data/intel/agents.ts @@ -0,0 +1,60 @@ +import type { AgentSource } from '@/types/game' +import type { Position } from '@/types/game' + +/** + * Named HUMINT sources — design: docs/plans/intel-suite-v3.md §1.4. + * Few sources, each a character with a distinct product. Never interchangeable. + */ +export function buildAgentRoster(): Record { + const agents: AgentSource[] = [ + { + id: 'amber', + codename: 'AMBER', + placement: 'Port logistics clerk, Bandar Abbas', + product: 'Naval activity: reveals ships near Bandar Abbas & Jask, sortie warnings', + status: 'active', + exposure: 10, + lastTaskedTick: -999_999, + }, + { + id: 'opal', + codename: 'OPAL', + placement: 'IRGC missile-force logistics officer', + product: 'TEL hunt: pinpoints hidden missile batteries (identified-level)', + status: 'active', + exposure: 20, + lastTaskedTick: -999_999, + }, + { + id: 'saffron', + codename: 'SAFFRON', + placement: 'Ministry aide, Tehran', + product: 'Political: exact war support + ceasefire intent readout', + status: 'active', + exposure: 15, + lastTaskedTick: -999_999, + }, + { + id: 'garnet', + codename: 'GARNET', + placement: 'Coastal observer with camera, Strait of Hormuz', + product: 'Live observer feed: tracks ships transiting the strait while active', + status: 'active', + exposure: 5, + lastTaskedTick: -999_999, + }, + ] + return Object.fromEntries(agents.map(a => [a.id, { ...a }])) +} + +/** Coverage boxes per agent (lat/lng bounds) */ +export const AGENT_COVERAGE: Record = { + amber: { south: 25.5, west: 55.0, north: 27.8, east: 58.2 }, // Bandar Abbas + Jask + garnet: { south: 25.8, west: 55.5, north: 27.2, east: 57.5 }, // Strait of Hormuz +} + +/** AMBER/GARNET refresh cadence while active (game-minutes) */ +export const AGENT_PASSIVE_INTERVAL_MIN = 30 + +/** Where arrested-agent fallout is centered for feed click-to-zoom flavor */ +export const TEHRAN: Position = { lat: 35.69, lng: 51.39 } diff --git a/src/data/intel/assets.ts b/src/data/intel/assets.ts new file mode 100644 index 0000000..0b9d955 --- /dev/null +++ b/src/data/intel/assets.ts @@ -0,0 +1,94 @@ +import type { IntelAsset } from '@/types/game' + +/** + * Fixed ISR asset roster — design: docs/plans/intel-suite-v3.md §1.1. + * revisit_min is in GAME minutes. niirs >= 7 reveals decoys on a pass. + */ +export function buildIntelAssets(): Record { + const assets: IntelAsset[] = [ + // ── USA ── + { + id: 'kh11', + nation: 'usa', + name: 'KH-11 CRYSTAL', + kind: 'optical_sat', + status: 'active', + revisit_min: 240, + lastCollectionTick: 0, + niirs: 8, + }, + { + id: 'commercial', + nation: 'usa', + name: 'Commercial EO layer', + kind: 'commercial_sat', + status: 'active', + revisit_min: 90, + lastCollectionTick: 0, + niirs: 5, + }, + { + id: 'rc135', + nation: 'usa', + name: 'RC-135 RIVET JOINT', + kind: 'sigint_air', + status: 'active', + revisit_min: 0, + lastCollectionTick: 0, + }, + { + id: 'mq4c', + nation: 'usa', + name: 'MQ-4C TRITON', + kind: 'maritime_patrol', + status: 'active', + revisit_min: 30, + lastCollectionTick: 0, + }, + { + id: 'sbirs', + nation: 'usa', + name: 'SBIRS OPIR', + kind: 'launch_detection', + status: 'active', + revisit_min: 0, + lastCollectionTick: 0, + }, + // ── Iran ── + { + id: 'noor', + nation: 'iran', + name: 'Noor-3', + kind: 'optical_sat', + status: 'active', + revisit_min: 480, + lastCollectionTick: 0, + niirs: 2, + }, + { + id: 'mohajer10', + nation: 'iran', + name: 'Mohajer-10 orbit', + kind: 'recon_drone', + status: 'active', + revisit_min: 60, + lastCollectionTick: 0, + }, + { + id: 'fastboats', + nation: 'iran', + name: 'IRGCN picket boats', + kind: 'fast_boats', + status: 'active', + revisit_min: 30, + lastCollectionTick: 0, + }, + ] + return Object.fromEntries(assets.map(a => [a.id, { ...a }])) +} + +/** Hormuz approaches box — IRGC picket/OSINT coverage of the carrier (design §1.5) */ +export const HORMUZ_OSINT_BOX = { south: 24.5, west: 53.5, north: 27.8, east: 58.5 } + +/** Satellite pass footprint half-width (km) */ +export const PASS_SWATH_KM = 60 diff --git a/src/data/intel/osint-accounts.ts b/src/data/intel/osint-accounts.ts new file mode 100644 index 0000000..6507d13 --- /dev/null +++ b/src/data/intel/osint-accounts.ts @@ -0,0 +1,93 @@ +/** + * Diegetic OSINT account roster — design: docs/plans/intel-suite-v3.md §2. + * The feed generator (src/intel/osint-feed.ts) is a pure consumer of snapshot + * events; each account transforms events it "covers" with its own delay, + * precision and error rate. All handles fictional. + */ + +export interface OsintAccount { + handle: string + displayName: string + archetype: + | 'plane_spotter' + | 'aggregator' + | 'oil_analyst' + | 'imagery_analyst' + | 'regime_mouthpiece' + | 'webcam_watcher' + | 'leak_channel' + | 'joke_indicator' + /** Game-seconds delay range from true event to post */ + delayRangeSec: [number, number] + /** 0-1 chance a given post is wrong/garbled (wrong name, inflated claim) */ + errorRate: number + /** Feed accent color */ + color: string +} + +export const OSINT_ACCOUNTS: OsintAccount[] = [ + { + handle: '@GulfPlaneWatch', + displayName: 'Gulf Plane Watch', + archetype: 'plane_spotter', + delayRangeSec: [60, 180], + errorRate: 0.02, + color: '#7fb3d5', + }, + { + handle: '@CENTCOM_Watch', + displayName: 'CENTCOM Watch', + archetype: 'aggregator', + delayRangeSec: [120, 360], + errorRate: 0.15, + color: '#e8d27a', + }, + { + handle: '@TankerTrackerz', + displayName: 'Tanker Trackerz', + archetype: 'oil_analyst', + delayRangeSec: [43_200, 86_400], + errorRate: 0.05, + color: '#8fbf8f', + }, + { + handle: '@OrbitalRecon', + displayName: 'Orbital Recon', + archetype: 'imagery_analyst', + delayRangeSec: [21_600, 43_200], + errorRate: 0.05, + color: '#b39ddb', + }, + { + handle: '@IRGC_Media', + displayName: 'IRGC Media Desk', + archetype: 'regime_mouthpiece', + delayRangeSec: [300, 900], + errorRate: 0.5, + color: '#d98880', + }, + { + handle: '@StraitSpotter', + displayName: 'Strait Spotter', + archetype: 'webcam_watcher', + delayRangeSec: [120, 480], + errorRate: 0.08, + color: '#76c7c0', + }, + { + handle: '@SignalDesk', + displayName: 'Signal Desk', + archetype: 'leak_channel', + delayRangeSec: [0, 0], // posts BEFORE the event when it fires (warning channel) + errorRate: 0.2, + color: '#f0a35e', + }, + { + handle: '@PizzaIndexGulf', + displayName: 'Gulf Pizza Index', + archetype: 'joke_indicator', + delayRangeSec: [0, 0], + errorRate: 0.4, + color: '#c8a2c8', + }, +] diff --git a/src/engine/game-engine.ts b/src/engine/game-engine.ts index c3a84e4..bc75518 100644 --- a/src/engine/game-engine.ts +++ b/src/engine/game-engine.ts @@ -26,6 +26,8 @@ import { processShipping, resetShippingState } from './systems/shipping' import { shippingLanes as defaultShippingLanes } from '@/data/shipping/shipping-lanes' import { processVisibility, resetVisibilityState, seedInitialVisibility, getViewVisibility, contactDisplayName, type ViewVisibility } from './systems/visibility' import { processWarSupport, resetWarSupportState, offerCeasefire, acceptCeasefire, resign, getWarSupport, getObjectives } from './systems/war-support' +import { processIntel, initIntelState, resetIntelState, taskSatellitePass, taskAgent, restAgent, exfiltrateAgent, opsecSweep, maybeLeakStrike, paranoiaBand } from './systems/intel' +import type { IntelViewState } from '@/types/view' const TICK_MS = 1_000 // 1 tick = 1 game second (real-time at 1x) const SCENARIO_START = new Date('2026-06-15T06:00:00Z').getTime() @@ -132,6 +134,9 @@ export class GameEngine { // Fixed installations are public knowledge — both sides start with them on the map seedInitialVisibility(this.state) + // Intel suite: ISR assets, HUMINT roster, counterintel meters + initIntelState(this.state) + // Initialize satellite constellations (only for modern scenarios with USA/Iran) if (this.state.nations.usa && this.state.nations.iran) { this.initSatellites() @@ -178,6 +183,9 @@ export class GameEngine { // Fog of war: fold radar/satellite/HUMINT/ELINT pictures into per-nation contacts processVisibility(state, this.sensorNetwork, this.lastEspionageResult, this.elevationGrid) + // Intel suite: satellite taskings, SIGINT intercepts, HUMINT, counterintel + processIntel(state, this.rng, this.elevationGrid) + // Political will: war-support drains, ceasefire logic, capitulation, objectives processWarSupport(state) @@ -254,7 +262,8 @@ export class GameEngine { break } case 'LAUNCH_MISSILE': { - const event = launchMissile(state, cmd.launcherId, cmd.weaponId, cmd.targetId, cmd.waypoints, cmd.trackQuality) + const compromised = this.applyStrikeLeak(cmd.launcherId, cmd.targetId, cmd.trackQuality) + const event = launchMissile(state, cmd.launcherId, cmd.weaponId, cmd.targetId, cmd.waypoints, cmd.trackQuality, compromised) if (event) { const launcher = state.units.get(cmd.launcherId) const target = state.units.get(cmd.targetId) @@ -269,9 +278,10 @@ export class GameEngine { case 'LAUNCH_SALVO': { if (cmd.count <= 0) break + const compromised = this.applyStrikeLeak(cmd.launcherId, cmd.targetId, undefined) let declaredWar = false for (let i = 0; i < cmd.count; i++) { - const event = launchMissile(state, cmd.launcherId, cmd.weaponId, cmd.targetId, cmd.waypoints) + const event = launchMissile(state, cmd.launcherId, cmd.weaponId, cmd.targetId, cmd.waypoints, undefined, compromised) if (!event) break if (!declaredWar) { @@ -319,9 +329,50 @@ export class GameEngine { if (unit) unit.droneMission = cmd.mission break } + case 'TASK_SATELLITE_PASS': { + taskSatellitePass(state, cmd.assetId, cmd.target, cmd.cloudPct) + break + } + case 'TASK_AGENT': { + taskAgent(state, this.rng, cmd.agentId) + break + } + case 'REST_AGENT': { + restAgent(state, cmd.agentId) + break + } + case 'EXFILTRATE_AGENT': { + exfiltrateAgent(state, cmd.agentId) + break + } + case 'OPSEC_SWEEP': { + opsecSweep(state) + break + } + case 'SET_EMCON': { + const unit = state.units.get(cmd.unitId) + if (unit) unit.emcon = cmd.emcon + break + } } } + /** + * Deliberate player strikes (strike panel, no auto-fire trackQuality) feed Iranian + * pattern analysis: bump the leak level and possibly compromise this launch. + */ + private applyStrikeLeak(launcherId: UnitId, targetId: UnitId, trackQuality?: import('@/types/game').TrackQuality): boolean { + const { state } = this + if (trackQuality) return false // reactive auto-engagement, not a planned strike + const intel = state.intel + const launcher = state.units.get(launcherId) + const target = state.units.get(targetId) + if (!intel || !launcher || !target) return false + if (launcher.nation !== state.playerNation || target.nation === state.playerNation) return false + intel.leakLevel = Math.min(100, intel.leakLevel + 5) + return maybeLeakStrike(state, this.rng, targetId) + } + /** Get serializable snapshot for the main thread */ getViewState(): GameViewState { const { state } = this @@ -331,7 +382,7 @@ export class GameEngine { const units: ViewUnit[] = [] for (const u of state.units.values()) { const vis = getViewVisibility(state, state.playerNation, u) - if (vis) units.push(toViewUnit(u, vis)) + if (vis) units.push(toViewUnit(u, vis, state.playerNation)) } return { @@ -349,6 +400,27 @@ export class GameEngine { warSupport: getWarSupport(state), gameOver: state.gameOver ?? null, objectives: getObjectives(state), + intel: this.getIntelViewState(), + } + } + + /** Player-nation slice of the intel suite — exact paranoia stays engine-side */ + private getIntelViewState(): IntelViewState { + const intel = this.state.intel + if (!intel) { + return { assets: [], agents: [], products: [], taskings: [], leakLevel: 0, paranoiaBand: 'LOW', encryptionUpgradedUntilTick: null } + } + const player = this.state.playerNation + return { + assets: Object.values(intel.assets).filter(a => a.nation === player).map(a => ({ ...a })), + agents: player === 'usa' ? Object.values(intel.agents).map(a => ({ ...a })) : [], + products: player === 'usa' ? intel.products.map(p => ({ ...p })) : [], + taskings: intel.taskings + .filter(t => intel.assets[t.assetId]?.nation === player) + .map(t => ({ ...t, target: { ...t.target } })), + leakLevel: intel.leakLevel, + paranoiaBand: paranoiaBand(intel.paranoia), + encryptionUpgradedUntilTick: intel.encryptionUpgradedUntilTick ?? null, } } @@ -364,6 +436,7 @@ export class GameEngine { visibility: s.visibility ?? {}, warStatus: s.warStatus ?? {}, gameOver: s.gameOver ?? null, + intel: s.intel ?? null, units: Array.from(s.units.entries()), missiles: Array.from(s.missiles.entries()), supplyLines: Array.from(s.supplyLines.entries()), @@ -406,7 +479,10 @@ export class GameEngine { visibility: raw.visibility ?? {}, warStatus: raw.warStatus ?? {}, gameOver: raw.gameOver ?? undefined, + intel: raw.intel ?? undefined, } + // Saves from before the intel suite get a fresh roster + if (!this.state.intel) initIntelState(this.state) // Backfill shipping lanes for old saves that didn't have them if (!raw.shippingLanes || raw.shippingLanes.length === 0) { for (const lane of defaultShippingLanes) { @@ -452,6 +528,7 @@ export class GameEngine { resetShippingState() resetVisibilityState() resetWarSupportState() + resetIntelState() } /** Set up satellite constellations for each nation */ @@ -548,11 +625,12 @@ export class GameEngine { } } -function toViewUnit(u: Unit, vis: ViewVisibility): ViewUnit { +function toViewUnit(u: Unit, vis: ViewVisibility, playerNation: NationId): ViewUnit { // Scrub by contact quality: 'detected' hides everything but the contact itself, // 'tracked' shows identity and condition but not loadout. Own units are 'identified'. const identified = vis.level === 'identified' const trackedPlus = identified || vis.level === 'tracked' + const isOwn = u.nation === playerNation return { id: u.id, name: trackedPlus ? u.name : contactDisplayName(u.category), @@ -580,5 +658,8 @@ function toViewUnit(u: Unit, vis: ViewVisibility): ViewUnit { droneMission: identified ? u.droneMission : undefined, visibility: vis.level, stale: vis.stale, + emcon: isOwn ? u.emcon : undefined, + // isDecoy itself NEVER leaks — only the revealed verdict (own decoys are always known) + decoyRevealed: (isOwn ? u.isDecoy : u.decoyRevealed) || undefined, } } diff --git a/src/engine/systems/ai.ts b/src/engine/systems/ai.ts index f253062..23e28e5 100644 --- a/src/engine/systems/ai.ts +++ b/src/engine/systems/ai.ts @@ -57,6 +57,25 @@ function getAIState(nation: NationId, state: GameState): AIState { return s } +/** + * SIGINT hook: best estimate of the nation's next salvo tick, or null when no + * salvo is on the clock. Reads the same AI state the salvo logic uses. + */ +export function getNextSalvoEstimate(nation: NationId): number | null { + const ai = aiStates.get(nation) + if (!ai) return null + switch (ai.phase) { + case 'DEFENSIVE': + return ai.attacksReceived > 0 ? ai.lastRetaliationTick + 300 : null + case 'OFFENSIVE': + return ai.lastRetaliationTick + 900 + case 'ATTRITION': + return ai.lastRetaliationTick + 3600 + default: + return null + } +} + /** Orient sector-limited SAM radars toward the nearest enemy concentration */ export function orientSAMRadars(state: GameState, excludeNation?: NationId): void { for (const unit of state.units.values()) { diff --git a/src/engine/systems/combat.ts b/src/engine/systems/combat.ts index 23f3a81..e787640 100644 --- a/src/engine/systems/combat.ts +++ b/src/engine/systems/combat.ts @@ -841,6 +841,7 @@ export function launchMissile( targetId: string, waypoints?: Position[], trackQuality?: TrackQuality, + compromised?: boolean, ): GameEvent | null { const launcher = state.units.get(launcherId) const target = state.units.get(targetId) @@ -942,6 +943,7 @@ export function launchMissile( fuel_remaining_sec: fuelSec, is_interceptor: false, networkQuality: trackQuality === 'datalink' ? 'tracked' : 'own', + compromised: compromised || undefined, } state.missiles.set(id, missile) @@ -1110,6 +1112,8 @@ function isAlreadyEngagedByUnit(unitId: string, missileId: string): boolean { /** Categories that can move between launch and impact — datalink shots may miss them */ const MOBILE_TARGET_CATEGORIES = new Set(['ship', 'carrier_group', 'submarine', 'aircraft']) const DATALINK_MISS_CHANCE = 0.12 +/** Strike leaked before launch — target was warned and displaced/hardened */ +const COMPROMISED_MISS_CHANCE = 0.35 function resolveImpacts(state: GameState, rng: SeededRNG): void { const events: GameEvent[] = [] @@ -1126,6 +1130,17 @@ function resolveImpacts(state: GameState, rng: SeededRNG): void { const target = state.units.get(missile.targetId) const spec = weaponSpecs[missile.weaponId] + if (target && spec && missile.compromised && rng.chance(COMPROMISED_MISS_CHANCE)) { + events.push({ + type: 'MISSILE_MISSED', + missileId: missile.id, + targetId: missile.targetId, + tick: state.time.tick, + }) + state.missiles.delete(missile.id) + continue + } + // Shots on relayed tracks lack terminal-quality data — moving targets can evade if (target && spec && missile.networkQuality === 'tracked' && MOBILE_TARGET_CATEGORIES.has(target.category) && rng.chance(DATALINK_MISS_CHANCE)) { diff --git a/src/engine/systems/detection.ts b/src/engine/systems/detection.ts index 67ae559..8192ea7 100644 --- a/src/engine/systems/detection.ts +++ b/src/engine/systems/detection.ts @@ -38,6 +38,7 @@ export function detectThreats(state: GameState, adUnit: Unit, grid?: ElevationGr const threats: DetectedThreat[] = [] if (adUnit.sensors.length === 0) return threats + if (adUnit.emcon) return threats // radar silent — own detection off, network picture still applies const radarRange = Math.max(...adUnit.sensors .filter(s => s.type === 'radar') diff --git a/src/engine/systems/intel.ts b/src/engine/systems/intel.ts new file mode 100644 index 0000000..5a67420 --- /dev/null +++ b/src/engine/systems/intel.ts @@ -0,0 +1,637 @@ +import type { + GameEvent, + GameState, + IntelProduct, + IntelState, + InterceptPrecedence, + NationId, + Position, + Unit, +} from '@/types/game' +import type { ElevationGrid } from './elevation' +import type { SeededRNG } from '../utils/rng' +import { buildIntelAssets, HORMUZ_OSINT_BOX, PASS_SWATH_KM } from '@/data/intel/assets' +import { buildAgentRoster, AGENT_COVERAGE, AGENT_PASSIVE_INTERVAL_MIN } from '@/data/intel/agents' +import { revealContact } from './visibility' +import { getNextSalvoEstimate } from './ai' +import { haversine, destination } from '../utils/geo' + +/** + * Intel suite v3 — ISR tasking, SIGINT intercepts, HUMINT sources, counterintel. + * Design: docs/plans/intel-suite-v3.md. All state lives in state.intel (plain + * data → saves/loads for free). Heavy evaluation runs once per game-minute. + */ + +const MINUTE = 60 +const HOUR = 3600 + +const INTERCEPT_INTERVAL_TICKS = 20 * MINUTE +const SWEEP_INTERVAL_TICKS = 4 * HOUR +const SWEEP_PARANOIA_THRESHOLD = 50 +const ENCRYPTION_PARANOIA_THRESHOLD = 70 +const ENCRYPTION_BLACKOUT_TICKS = 6 * HOUR +const AGENT_TASK_COOLDOWN_TICKS = 1 * HOUR +const EXFIL_DURATION_TICKS = 6 * HOUR +const OPSEC_SWEEP_COOLDOWN_TICKS = 6 * HOUR +const CLOUD_FAIL_THRESHOLD = 70 +const DECOY_COUNT = 4 +const PRODUCT_CAP = 30 +const GULF_PATROL_BOX = { south: 23.5, west: 47.5, north: 30.5, east: 59.5 } + +export function initIntelState(state: GameState): void { + state.intel = { + assets: buildIntelAssets(), + agents: buildAgentRoster(), + products: [], + taskings: [], + paranoia: 10, + leakLevel: 25, + productCounter: 0, + } +} + +/** No module-level state — everything lives in state.intel. Kept for reset symmetry. */ +export function resetIntelState(): void {} + +// --------------------------------------------------------------------------- +// Tick processing +// --------------------------------------------------------------------------- + +export function processIntel(state: GameState, rng: SeededRNG, grid: ElevationGrid | null): void { + const intel = state.intel + if (!intel) return + const tick = state.time.tick + + // SBIRS FLASH cards ride on this tick's launch events (always-on OPIR) + emitLaunchDetectionCards(state, intel) + + if (tick % MINUTE !== 0) return + + resolveSatelliteTaskings(state, intel, rng) + generateIntercept(state, intel, rng) + runAgentClock(state, intel, rng) + runCounterintel(state, intel, rng) + runWideAreaSensors(state, intel) + spawnDecoysAtWar(state, intel, rng, grid) +} + +// --------------------------------------------------------------------------- +// Satellite tasking (design §1.2) +// --------------------------------------------------------------------------- + +export function taskSatellitePass( + state: GameState, + assetId: string, + target: Position, + cloudPct?: number, +): void { + const intel = state.intel + if (!intel) return + const asset = intel.assets[assetId] + if (!asset || asset.status !== 'active' || asset.kind === 'sigint_air') return + + // One queued tasking per asset — re-tasking replaces it + intel.taskings = intel.taskings.filter(t => t.assetId !== assetId) + intel.taskings.push({ + id: `task_${(intel.productCounter = (intel.productCounter ?? 0) + 1)}`, + assetId, + target: { ...target }, + queuedTick: state.time.tick, + cloudPct, + }) +} + +function resolveSatelliteTaskings(state: GameState, intel: IntelState, rng: SeededRNG): void { + const tick = state.time.tick + const done: string[] = [] + + for (const tasking of intel.taskings) { + const asset = intel.assets[tasking.assetId] + if (!asset || asset.status !== 'active') { + done.push(tasking.id) + continue + } + const revisitTicks = asset.revisit_min * MINUTE + if (tick - asset.lastCollectionTick < revisitTicks) continue + + asset.lastCollectionTick = tick + done.push(tasking.id) + + const cloudPct = tasking.cloudPct ?? rng.int(0, 100) + if (cloudPct >= CLOUD_FAIL_THRESHOLD) { + // Failed pass: cheap retry — half the revisit clock + asset.lastCollectionTick = tick - Math.floor(revisitTicks / 2) + emit(state, { + type: 'SATELLITE_PASS_FAILED', + assetId: asset.id, + target: tasking.target, + cloudPct, + tick, + }) + continue + } + + // Sweep the footprint + let found = 0 + let revealedDecoys = 0 + const byCategory = new Map() + for (const unit of state.units.values()) { + if (unit.nation === asset.nation || unit.status === 'destroyed') continue + if (haversine(unit.position, tasking.target) > PASS_SWATH_KM) continue + + const existing = state.visibility?.[asset.nation as string]?.[unit.id] + const level = + unit.category === 'airbase' || unit.category === 'naval_base' || + existing?.level === 'tracked' || existing?.level === 'identified' + ? 'identified' + : 'tracked' + revealContact(state, asset.nation as string, unit, level) + found++ + byCategory.set(unit.category, (byCategory.get(unit.category) ?? 0) + 1) + + if (unit.isDecoy && !unit.decoyRevealed && (asset.niirs ?? 0) >= 7) { + unit.decoyRevealed = true + revealedDecoys++ + emit(state, { type: 'DECOY_REVEALED', unitId: unit.id, tick }) + } + } + + pushProduct(intel, { + kind: 'imint', + tick, + assetId: asset.id, + target: tasking.target, + niirs: asset.niirs, + classification: asset.kind === 'commercial_sat' ? 'UNCLASSIFIED//COMMERCIAL' : 'TOP SECRET//TK//NOFORN', + caption: imintCaption(byCategory, revealedDecoys), + }) + + emit(state, { + type: 'SATELLITE_PASS_COMPLETE', + assetId: asset.id, + target: tasking.target, + found, + revealedDecoys, + tick, + }) + + if (asset.nation === 'usa') { + intel.paranoia = clamp(intel.paranoia + (asset.kind === 'commercial_sat' ? 2 : 4)) + } + } + + if (done.length > 0) { + intel.taskings = intel.taskings.filter(t => !done.includes(t.id)) + } +} + +function imintCaption(byCategory: Map, revealedDecoys: number): string { + if (byCategory.size === 0) return 'No significant activity observed in AOI.' + const labels: Record = { + missile_battery: 'probable TEL group', + sam_site: 'SAM emitter site', + ship: 'surface combatant', + carrier_group: 'capital surface group', + submarine: 'submarine (surfaced)', + airbase: 'air operations facility', + naval_base: 'naval facility', + aircraft: 'aircraft on apron', + minefield: 'suspected mine line', + } + const parts = Array.from(byCategory.entries()).map(([cat, n]) => `${n}× ${labels[cat] ?? cat}`) + const decoyNote = revealedDecoys > 0 ? `; ${revealedDecoys}× assessed DECOY (no thermal signature)` : '' + return parts.join(', ') + decoyNote + '.' +} + +// --------------------------------------------------------------------------- +// SIGINT (design §1.3) +// --------------------------------------------------------------------------- + +const ROUTINE_CHATTER = [ + 'Logistics net: fuel convoy scheduling between Shiraz and coastal sites.', + 'IRGCN harbor net: routine patrol rotation, nothing significant.', + 'Air-defense net: calibration chatter, sectors quiet.', + 'Provincial command net: leave rotations and ration complaints.', +] + +function generateIntercept(state: GameState, intel: IntelState, rng: SeededRNG): void { + const tick = state.time.tick + const rc135 = intel.assets['rc135'] + if (!rc135 || rc135.status !== 'active') return + if ((intel.encryptionUpgradedUntilTick ?? 0) > tick) return + + const sigintPct = state.nations['usa']?.intelBudget?.sigint_pct ?? 30 + const interval = Math.round(INTERCEPT_INTERVAL_TICKS * (1.5 - sigintPct / 100)) + if (tick - (intel.lastIntInterceptTick ?? -interval) < interval) return + intel.lastIntInterceptTick = tick + + let precedence: InterceptPrecedence = 'ROUTINE' + let text = ROUTINE_CHATTER[rng.int(0, ROUTINE_CHATTER.length - 1)] + let aboutUnitId: string | undefined + + const salvoTick = getNextSalvoEstimate('iran') + const iranSupport = state.warStatus?.['iran']?.warSupport + + if (salvoTick !== null && salvoTick - tick <= 30 * MINUTE && salvoTick >= tick) { + precedence = 'FLASH' + text = 'Missile brigade ordered to combat readiness — expect fires against US installations within the hour.' + } else { + const hidden = findHiddenEmitter(state) + if (hidden) { + precedence = 'IMMEDIATE' + aboutUnitId = hidden.id + revealContact(state, 'usa', hidden, 'detected') + text = `Geolocated C2 transmission: ${hidden.category === 'sam_site' ? 'air-defense battery' : 'missile unit'} operating vicinity ${hidden.position.lat.toFixed(1)}N ${hidden.position.lng.toFixed(1)}E.` + } else if (iranSupport !== undefined && iranSupport < 45) { + precedence = 'PRIORITY' + text = 'Leadership net: cohesion failing — open argument over continuing the war.' + } + } + + emit(state, { type: 'INTERCEPT_DECRYPTED', precedence, text, aboutUnitId, tick }) + pushProduct(intel, { + kind: 'sigint', + tick, + precedence, + classification: 'TOP SECRET//SI', + caption: text, + }) + intel.paranoia = clamp(intel.paranoia + 2) +} + +function findHiddenEmitter(state: GameState): Unit | null { + for (const unit of state.units.values()) { + if (unit.nation !== 'iran' || unit.status === 'destroyed') continue + if (unit.category !== 'missile_battery' && unit.category !== 'sam_site') continue + const contact = state.visibility?.['usa']?.[unit.id] + if (!contact || contact.level === 'unseen') return unit + } + return null +} + +/** SBIRS: every Iranian launch this tick gets a FLASH OPIR card (cheap flavor + product) */ +function emitLaunchDetectionCards(state: GameState, intel: IntelState): void { + const tick = state.time.tick + const sbirs = intel.assets['sbirs'] + if (!sbirs || sbirs.status !== 'active') return + for (let i = state.events.length - 1; i >= 0; i--) { + const e = state.events[i] + if (e.tick !== tick) break + if (e.type !== 'MISSILE_LAUNCHED') continue + const launcher = state.units.get(e.launcherId) + if (!launcher || launcher.nation !== 'iran') continue + pushProduct(intel, { + kind: 'sigint', + tick, + precedence: 'FLASH', + classification: 'TOP SECRET//TK', + caption: `OPIR LAUNCH DETECTION: booster plume ${launcher.position.lat.toFixed(2)}N ${launcher.position.lng.toFixed(2)}E — ${e.weaponName}. Launch point passed to targeting.`, + }) + } +} + +// --------------------------------------------------------------------------- +// HUMINT (design §1.4) +// --------------------------------------------------------------------------- + +export function taskAgent(state: GameState, rng: SeededRNG, agentId: string): void { + const intel = state.intel + if (!intel) return + const agent = intel.agents[agentId] + if (!agent) return + if (agent.status === 'arrested' || agent.status === 'exfiltrated' || agent.status === 'exfiltrating') return + const tick = state.time.tick + if (tick - agent.lastTaskedTick < AGENT_TASK_COOLDOWN_TICKS) return + + agent.status = 'active' + agent.lastTaskedTick = tick + agent.exposure = clamp(agent.exposure + 15 + intel.paranoia / 5) + intel.paranoia = clamp(intel.paranoia + 1) + + let text = '' + switch (agent.id) { + case 'amber': { + const n = revealBox(state, 'usa', AGENT_COVERAGE['amber'], 'tracked', ['ship', 'submarine']) + text = n > 0 + ? `Port movement log copied: ${n} hulls active in the Bandar Abbas–Jask complex. Berths and sortie states attached.` + : 'Port quiet — no significant sorties on the log.' + break + } + case 'opal': { + let revealed = 0 + let decoys = 0 + for (const unit of state.units.values()) { + if (revealed >= 2) break + if (unit.nation !== 'iran' || unit.status === 'destroyed') continue + if (unit.category !== 'missile_battery') continue + const contact = state.visibility?.['usa']?.[unit.id] + if (contact && (contact.level === 'tracked' || contact.level === 'identified')) continue + if (unit.isDecoy) { + if (!unit.decoyRevealed) { + unit.decoyRevealed = true + decoys++ + emit(state, { type: 'DECOY_REVEALED', unitId: unit.id, tick }) + } + continue + } + revealContact(state, 'usa', unit, 'identified') + revealed++ + } + text = revealed > 0 + ? `Dispersal annex photographed: ${revealed} launcher group(s) located with grid coordinates.` + : 'No new launcher movement in the annex this cycle.' + if (decoys > 0) text += ` Flags ${decoys} site(s) as inflatable decoys.` + break + } + case 'saffron': { + const support = state.warStatus?.['iran']?.warSupport + const offered = state.warStatus?.['iran']?.ceasefireOffered + text = support !== undefined + ? `Cabinet read: war support at ${Math.round(support)}%. ${offered ? 'Ceasefire feelers ALREADY authorized.' : support < 45 ? 'Ceasefire faction gaining ground.' : 'Leadership committed to continuing.'}` + : 'Cabinet read: leadership posture stable, no war council convened.' + break + } + case 'garnet': { + const n = revealBox(state, 'usa', AGENT_COVERAGE['garnet'], 'tracked', ['ship', 'submarine']) + text = n > 0 + ? `Strait watch: ${n} contacts logged transiting the narrows, photos timestamped.` + : 'Strait watch: channel quiet this cycle.' + break + } + } + + emit(state, { type: 'AGENT_REPORT', agentId: agent.id, codename: agent.codename, text, tick }) + pushProduct(intel, { + kind: 'humint', + tick, + agentId: agent.id, + classification: 'SECRET//HCS', + caption: `${agent.codename}: ${text}`, + }) +} + +export function restAgent(state: GameState, agentId: string): void { + const agent = state.intel?.agents[agentId] + if (!agent) return + if (agent.status === 'active') agent.status = 'resting' +} + +export function exfiltrateAgent(state: GameState, agentId: string): void { + const agent = state.intel?.agents[agentId] + if (!agent) return + if (agent.status !== 'active' && agent.status !== 'resting') return + agent.status = 'exfiltrating' + agent.exfilCompleteTick = state.time.tick + EXFIL_DURATION_TICKS +} + +function runAgentClock(state: GameState, intel: IntelState, _rng: SeededRNG): void { + const tick = state.time.tick + + for (const agent of Object.values(intel.agents)) { + // Exfil completion + if (agent.status === 'exfiltrating' && (agent.exfilCompleteTick ?? 0) <= tick) { + agent.status = 'exfiltrated' + emit(state, { type: 'AGENT_EXFILTRATED', agentId: agent.id, codename: agent.codename, tick }) + } + // Resting exposure decay (1 per game-hour) + if (agent.status === 'resting' && tick % HOUR === 0) { + agent.exposure = Math.max(0, agent.exposure - 1) + } + } + + // Passive coverage: AMBER + GARNET keep a coarse eye on their boxes + if (tick % (AGENT_PASSIVE_INTERVAL_MIN * MINUTE) === 0) { + for (const id of ['amber', 'garnet'] as const) { + const agent = intel.agents[id] + if (agent?.status === 'active') { + revealBox(state, 'usa', AGENT_COVERAGE[id], 'detected', ['ship', 'submarine']) + } + } + } +} + +// --------------------------------------------------------------------------- +// Counterintel — Iranian sweeps, encryption, the player's leak level (design §1.5) +// --------------------------------------------------------------------------- + +function runCounterintel(state: GameState, intel: IntelState, rng: SeededRNG): void { + const tick = state.time.tick + const atWar = (state.nations['iran']?.atWar.length ?? 0) > 0 + + // Encryption upgrade at high paranoia (war only) + if (atWar && intel.paranoia >= ENCRYPTION_PARANOIA_THRESHOLD && (intel.encryptionUpgradedUntilTick ?? 0) <= tick) { + intel.encryptionUpgradedUntilTick = tick + ENCRYPTION_BLACKOUT_TICKS + intel.paranoia = 40 + emit(state, { type: 'ENCRYPTION_UPGRADED', untilTick: intel.encryptionUpgradedUntilTick, tick }) + } + + // Spy sweeps + if (intel.paranoia >= SWEEP_PARANOIA_THRESHOLD && tick - (intel.lastSweepTick ?? -SWEEP_INTERVAL_TICKS) >= SWEEP_INTERVAL_TICKS) { + intel.lastSweepTick = tick + let arrests = 0 + for (const agent of Object.values(intel.agents)) { + if (agent.status !== 'active' && agent.status !== 'resting' && agent.status !== 'exfiltrating') continue + let chance = agent.exposure / 200 + intel.paranoia / 400 + if (agent.status === 'exfiltrating') chance /= 2 + if (rng.chance(chance)) { + agent.status = 'arrested' + arrests++ + intel.leakLevel = clamp(intel.leakLevel + 10) + adjustWarSupport(state, 'usa', -3) + adjustWarSupport(state, 'iran', +2) + emit(state, { type: 'AGENT_ARRESTED', agentId: agent.id, codename: agent.codename, tick }) + } + } + emit(state, { type: 'SPY_SWEEP', arrests, tick }) + } + + // Leak level drift + if (tick % HOUR === 0) { + const carrier = findPlayerCarrier(state) + if (carrier && inBox(carrier.position, HORMUZ_OSINT_BOX)) { + intel.leakLevel = clamp(intel.leakLevel + 1) + } else if (tick % (2 * HOUR) === 0) { + intel.leakLevel = Math.max(10, intel.leakLevel - 1) + } + } +} + +/** Player strike → possible Iranian foreknowledge. Roll once per launch command. */ +export function maybeLeakStrike(state: GameState, rng: SeededRNG, targetId: string): boolean { + const intel = state.intel + if (!intel || intel.leakLevel < 60) return false + if (!rng.chance(intel.leakLevel / 200)) return false + + const tick = state.time.tick + intel.paranoia = clamp(intel.paranoia + 2) + emit(state, { type: 'STRIKE_LEAKED', targetId, tick }) + + // Mobile land targets scoot — the contact the player fired on goes stale + const target = state.units.get(targetId) + if (target && target.status !== 'destroyed' && + (target.category === 'missile_battery' || target.category === 'sam_site') && + target.maxSpeed_kts > 0) { + const brng = rng.int(0, 359) + target.position = destination(target.position, brng, 15) + const contact = state.visibility?.['usa']?.[targetId] + if (contact) contact.level = 'detected' + } + return true +} + +export function opsecSweep(state: GameState): void { + const intel = state.intel + if (!intel) return + const tick = state.time.tick + if (tick - (intel.lastOpsecSweepTick ?? -OPSEC_SWEEP_COOLDOWN_TICKS) < OPSEC_SWEEP_COOLDOWN_TICKS) return + intel.lastOpsecSweepTick = tick + intel.leakLevel = Math.max(10, intel.leakLevel - 25) + emit(state, { type: 'OPSEC_SWEEP_COMPLETE', newLeakLevel: intel.leakLevel, tick }) +} + +// --------------------------------------------------------------------------- +// Wide-area sensors + Iran's coarse carrier picture (design §1.1, §1.5) +// --------------------------------------------------------------------------- + +function runWideAreaSensors(state: GameState, intel: IntelState): void { + const tick = state.time.tick + + // MQ-4C Triton: coarse maritime sweep of the Gulf box + const triton = intel.assets['mq4c'] + if (triton?.status === 'active' && tick - triton.lastCollectionTick >= triton.revisit_min * MINUTE) { + triton.lastCollectionTick = tick + revealBox(state, 'usa', GULF_PATROL_BOX, 'detected', ['ship', 'submarine', 'carrier_group']) + } + + // Iran's eyes on the carrier: picket boats (detected) / Mohajer-10 (tracked) + if (tick - (intel.lastCarrierOsintTick ?? 0) >= 30 * MINUTE) { + intel.lastCarrierOsintTick = tick + const carrier = findPlayerCarrier(state) + if (carrier && inBox(carrier.position, HORMUZ_OSINT_BOX)) { + const mohajer = intel.assets['mohajer10'] + const level = (mohajer?.status === 'active' || intel.leakLevel >= 60) ? 'tracked' : 'detected' + revealContact(state, 'iran', carrier, level) + } + } +} + +// --------------------------------------------------------------------------- +// Decoys (design §1.7) +// --------------------------------------------------------------------------- + +function spawnDecoysAtWar(state: GameState, intel: IntelState, rng: SeededRNG, grid: ElevationGrid | null): void { + if (intel.decoysSpawned) return + if ((state.nations['iran']?.atWar.length ?? 0) === 0) return + intel.decoysSpawned = true + + const batteries = Array.from(state.units.values()).filter( + u => u.nation === 'iran' && u.category === 'missile_battery' && u.status !== 'destroyed' && !u.isDecoy, + ) + if (batteries.length === 0) return + + for (let i = 0; i < DECOY_COUNT; i++) { + const anchor = batteries[rng.int(0, batteries.length - 1)] + let pos: Position | null = null + for (let attempt = 0; attempt < 8; attempt++) { + const candidate = destination(anchor.position, rng.int(0, 359), 5 + rng.int(0, 10)) + if (!grid || !grid.isWater(candidate.lat, candidate.lng)) { + pos = candidate + break + } + } + if (!pos) continue + + const decoy: Unit = { + id: `decoy_${i + 1}`, + name: 'Missile TEL group', + nation: 'iran', + category: 'missile_battery', + position: pos, + heading: rng.int(0, 359), + speed_kts: 0, + maxSpeed_kts: 30, + status: 'ready', + health: 40, + maxHealth: 40, + hardness: 80, + logistics: 0, + supplyStocks: [], + weapons: [], + pointDefense: [], + sensors: [], + waypoints: [], + roe: 'hold_fire', + subordinateIds: [], + isDecoy: true, + } + state.units.set(decoy.id, decoy) + } +} + +// --------------------------------------------------------------------------- +// Snapshot helper +// --------------------------------------------------------------------------- + +export function paranoiaBand(paranoia: number): 'LOW' | 'ELEVATED' | 'HIGH' | 'SEVERE' { + if (paranoia < 30) return 'LOW' + if (paranoia < 55) return 'ELEVATED' + if (paranoia < 75) return 'HIGH' + return 'SEVERE' +} + +// --------------------------------------------------------------------------- +// Internals +// --------------------------------------------------------------------------- + +function revealBox( + state: GameState, + observer: NationId, + box: { south: number; west: number; north: number; east: number }, + level: 'detected' | 'tracked' | 'identified', + categories: string[], +): number { + let n = 0 + for (const unit of state.units.values()) { + if (unit.nation === observer || unit.status === 'destroyed') continue + if (!categories.includes(unit.category)) continue + if (!inBox(unit.position, box)) continue + revealContact(state, observer as string, unit, level) + n++ + } + return n +} + +function inBox(p: Position, box: { south: number; west: number; north: number; east: number }): boolean { + return p.lat >= box.south && p.lat <= box.north && p.lng >= box.west && p.lng <= box.east +} + +function findPlayerCarrier(state: GameState): Unit | null { + for (const unit of state.units.values()) { + if (unit.nation === state.playerNation && unit.category === 'carrier_group' && unit.status !== 'destroyed') { + return unit + } + } + return null +} + +function pushProduct(intel: IntelState, p: Omit): void { + intel.productCounter = (intel.productCounter ?? 0) + 1 + intel.products.unshift({ ...p, id: `prod_${intel.productCounter}` }) + if (intel.products.length > PRODUCT_CAP) intel.products.length = PRODUCT_CAP +} + +function adjustWarSupport(state: GameState, nation: NationId, delta: number): void { + if (state.gameOver) return + const ws = state.warStatus?.[nation] + if (ws) ws.warSupport = Math.max(0, Math.min(100, ws.warSupport + delta)) +} + +function clamp(v: number): number { + return Math.max(0, Math.min(100, v)) +} + +function emit(state: GameState, event: GameEvent): void { + state.events.push(event) + if (state.events.length > 2000) state.events.splice(0, state.events.length - 2000) + state.pendingEvents.push(event) +} diff --git a/src/engine/systems/sensor-network.ts b/src/engine/systems/sensor-network.ts index 3e4c590..107f056 100644 --- a/src/engine/systems/sensor-network.ts +++ b/src/engine/systems/sensor-network.ts @@ -149,6 +149,7 @@ export function buildSensorNetwork( for (const enemy of state.units.values()) { if (enemy.status === 'destroyed') continue if (enemy.nation === unit.nation) continue // same nation — skip + if (enemy.emcon) continue // radar silent — no emissions to intercept // Check each enemy radar sensor for (const sensor of enemy.sensors) { diff --git a/src/engine/systems/visibility.ts b/src/engine/systems/visibility.ts index a0c1575..2742129 100644 --- a/src/engine/systems/visibility.ts +++ b/src/engine/systems/visibility.ts @@ -123,7 +123,8 @@ function evaluateSources(state: GameState, espionage: EspionageResult | null, gr for (const u of state.units.values()) { if (u.nation !== nation.id || u.status === 'destroyed' || u.sensors.length === 0) continue ownSensorUnits.push(u) - if (u.sensors.some(s => s.type === 'radar' && s.range_km > 0)) ownRadars.push(u) + // EMCON units don't radiate — passive ELINT antennas still listen + if (!u.emcon && u.sensors.some(s => s.type === 'radar' && s.range_km > 0)) ownRadars.push(u) } const humint = espionage?.humintRevealed.get(nation.id) @@ -194,7 +195,7 @@ function radarContactLevel(ownRadars: Unit[], target: Unit, grid: ElevationGrid * sector arc relative to the unit's heading, and terrain line-of-sight. */ export function radarSeesUnit(radar: Unit, target: Unit, grid: ElevationGrid | null): VisibilityLevel { - if (radar.status === 'destroyed') return 'unseen' + if (radar.status === 'destroyed' || radar.emcon) return 'unseen' const dist = haversine(radar.position, target.position) const targetAltAglM = targetHeightM(target.category) @@ -244,6 +245,7 @@ function satelliteContactLevel(nation: Nation, unit: Unit, tick: number): Visibi } function isElintDetected(ownSensorUnits: Unit[], emitter: Unit, sigintMultiplier: number): boolean { + if (emitter.emcon) return false // radar silent — nothing to intercept let radarRange = 0 for (const s of emitter.sensors) { if (s.type === 'radar' && s.range_km > radarRange) radarRange = s.range_km @@ -284,7 +286,8 @@ function applyEventReveals(state: GameState): void { } } -function revealContact(state: GameState, observer: string, unit: Unit, level: VisibilityLevel): void { +/** External reveal entry point — used by event reveals and the intel suite (satellites, HUMINT, SIGINT) */ +export function revealContact(state: GameState, observer: string, unit: Unit, level: VisibilityLevel): void { const tick = state.time.tick state.visibility ??= {} const contacts = (state.visibility[observer] ??= {}) diff --git a/src/engine/systems/war-support.ts b/src/engine/systems/war-support.ts index 3113842..08a7b44 100644 --- a/src/engine/systems/war-support.ts +++ b/src/engine/systems/war-support.ts @@ -212,6 +212,12 @@ function evaluate(state: GameState): void { case 'UNIT_DESTROYED': { const unit = state.units.get(e.unitId) if (!unit) break + if (unit.isDecoy) { + // Killing an inflatable costs the victim nothing and hands them a propaganda win + const owner = (ws[unit.nation] ??= { warSupport: 100 }) + owner.warSupport = clampSupport(owner.warSupport + 1) + break + } stats.unitsLost[unit.nation] = (stats.unitsLost[unit.nation] ?? 0) + 1 const victim = state.nations[unit.nation] if (!victim || victim.atWar.length === 0) break diff --git a/src/types/commands.ts b/src/types/commands.ts index 662df46..8e1e54f 100644 --- a/src/types/commands.ts +++ b/src/types/commands.ts @@ -14,3 +14,9 @@ export type Command = | { type: 'SET_DRONE_MISSION'; unitId: UnitId; mission: 'military' | 'shipping_interdiction' } | { type: 'OFFER_CEASEFIRE'; target: NationId } | { type: 'RESIGN' } + | { type: 'TASK_SATELLITE_PASS'; assetId: string; target: Position; cloudPct?: number } + | { type: 'TASK_AGENT'; agentId: string } + | { type: 'REST_AGENT'; agentId: string } + | { type: 'EXFILTRATE_AGENT'; agentId: string } + | { type: 'OPSEC_SWEEP' } + | { type: 'SET_EMCON'; unitId: UnitId; emcon: boolean } diff --git a/src/types/game.ts b/src/types/game.ts index b791cf9..bc9a7e4 100644 --- a/src/types/game.ts +++ b/src/types/game.ts @@ -150,6 +150,12 @@ export interface Unit { damage_per_contact?: number /** For drone launcher units — current tactical mission */ droneMission?: 'military' | 'shipping_interdiction' + /** EMCON: radar silent — invisible to ELINT, blind on own radar (network picture still applies) */ + emcon?: boolean + /** Decoy unit (Iranian dummy TELs) — engine truth, scrubbed from snapshots until revealed */ + isDecoy?: boolean + /** Set once the enemy has positively identified this decoy (NIIRS 7+ pass, HUMINT, or BDA) */ + decoyRevealed?: boolean } export interface WeaponStock { @@ -301,6 +307,8 @@ export interface Missile { interceptTargetMissileId?: string /** Detection quality that led to this intercept (for accuracy modifier) */ networkQuality?: 'own' | 'tracked' | 'detected' + /** Strike was leaked to the enemy before launch — heavy miss chance at impact */ + compromised?: boolean } export interface ShippingLane { @@ -339,6 +347,8 @@ export interface GameState { warStatus?: Record /** Set once the war has been resolved — the world keeps ticking but the game is decided */ gameOver?: GameOverReport + /** Intel suite v3: ISR assets, HUMINT sources, products, counterintel meters */ + intel?: IntelState } export type GameEvent = @@ -363,6 +373,103 @@ export type GameEvent = | { type: 'AUTO_ENGAGEMENT'; unitId: UnitId; targetId: UnitId; weaponName: string; count: number; quality: TrackQuality; tick: number } | { type: 'MISSILE_MISSED'; missileId: string; targetId: UnitId; tick: number } | { type: 'ORDER_REJECTED'; unitId: UnitId; reason: string; tick: number } + | { type: 'SATELLITE_PASS_COMPLETE'; assetId: string; target: Position; found: number; revealedDecoys: number; tick: number } + | { type: 'SATELLITE_PASS_FAILED'; assetId: string; target: Position; cloudPct: number; tick: number } + | { type: 'INTERCEPT_DECRYPTED'; precedence: InterceptPrecedence; text: string; aboutUnitId?: UnitId; tick: number } + | { type: 'AGENT_REPORT'; agentId: string; codename: string; text: string; tick: number } + | { type: 'AGENT_ARRESTED'; agentId: string; codename: string; tick: number } + | { type: 'AGENT_EXFILTRATED'; agentId: string; codename: string; tick: number } + | { type: 'SPY_SWEEP'; arrests: number; tick: number } + | { type: 'ENCRYPTION_UPGRADED'; untilTick: number; tick: number } + | { type: 'DECOY_REVEALED'; unitId: UnitId; tick: number } + | { type: 'STRIKE_LEAKED'; targetId: UnitId; tick: number } + | { type: 'OPSEC_SWEEP_COMPLETE'; newLeakLevel: number; tick: number } /** Fire-control source for a shot: the shooter's own sensors, or a track relayed over datalink */ export type TrackQuality = 'own' | 'datalink' + +// --------------------------------------------------------------------------- +// Intel suite (v3) — design: docs/plans/intel-suite-v3.md +// --------------------------------------------------------------------------- + +export type IntelAssetKind = + | 'optical_sat' // KH-11 / Noor — taskable imagery passes + | 'commercial_sat' // commercial layer — frequent, lower quality + | 'sigint_air' // RC-135 — drives intercept cadence + | 'maritime_patrol' // MQ-4C Triton — coarse wide-area ship refresh + | 'launch_detection' // SBIRS — always-on launch plume FLASH cards + | 'recon_drone' // Mohajer-10 — Iran's carrier watcher + | 'fast_boats' // IRGC shadowing — Iran's coarse carrier track + +export interface IntelAsset { + id: string + nation: NationId + name: string + kind: IntelAssetKind + status: 'active' | 'lost' + /** Game-minutes between collections (0 = continuous) */ + revisit_min: number + lastCollectionTick: number + /** Imagery quality for products (NIIRS 0-9); >= 7 reveals decoys */ + niirs?: number +} + +export interface SatTasking { + id: string + assetId: string + target: Position + queuedTick: number + /** Real-world cloud cover 0-100 captured at tasking time (UI-fetched); undefined = roll it */ + cloudPct?: number +} + +export type InterceptPrecedence = 'FLASH' | 'IMMEDIATE' | 'PRIORITY' | 'ROUTINE' + +export type IntelProductKind = 'imint' | 'sigint' | 'humint' + +/** Metadata only — the UI fetches real imagery at view time */ +export interface IntelProduct { + id: string + kind: IntelProductKind + tick: number + classification: string + caption: string + assetId?: string + target?: Position + niirs?: number + precedence?: InterceptPrecedence + agentId?: string +} + +export type AgentStatus = 'active' | 'resting' | 'exfiltrating' | 'exfiltrated' | 'arrested' + +export interface AgentSource { + id: string + codename: string + placement: string + product: string + status: AgentStatus + /** 0-100 — arrest risk during Iranian spy sweeps */ + exposure: number + lastTaskedTick: number + exfilCompleteTick?: number +} + +export interface IntelState { + assets: Record + agents: Record + /** Newest first, capped at 30 */ + products: IntelProduct[] + taskings: SatTasking[] + /** 0-100 Iranian counterintel alert — drives sweeps, encryption upgrades */ + paranoia: number + /** 0-100 how compromised the player's operations are */ + leakLevel: number + encryptionUpgradedUntilTick?: number + lastSweepTick?: number + lastOpsecSweepTick?: number + lastIntInterceptTick?: number + lastCarrierOsintTick?: number + decoysSpawned?: boolean + productCounter?: number +} diff --git a/src/types/view.ts b/src/types/view.ts index 502d631..aa77990 100644 --- a/src/types/view.ts +++ b/src/types/view.ts @@ -1,13 +1,17 @@ import type { + AgentSource, GameEvent, GameOverReport, GameTime, + IntelAsset, + IntelProduct, Missile, Nation, NationId, PointDefenseSystem, Position, ROE, + SatTasking, Sensor, ShippingLane, SupplyLine, @@ -19,6 +23,18 @@ import type { WeaponStock, } from './game' +/** Intel slice of the snapshot — player-nation data only */ +export interface IntelViewState { + assets: IntelAsset[] + agents: AgentSource[] + products: IntelProduct[] + taskings: SatTasking[] + leakLevel: number + /** Fuzzy read of Iranian counterintel posture — exact paranoia stays hidden */ + paranoiaBand: 'LOW' | 'ELEVATED' | 'HIGH' | 'SEVERE' + encryptionUpgradedUntilTick: number | null +} + /** Live status of one scenario objective, computed engine-side for the player's nation */ export interface ObjectiveStatus { id: string @@ -50,6 +66,8 @@ export interface GameViewState { gameOver: GameOverReport | null /** Scenario objectives for the player's side (empty at peace) */ objectives: ObjectiveStatus[] + /** Intel suite: assets, sources, products, counterintel meters (player nation only) */ + intel: IntelViewState } export interface ViewUnit { @@ -81,4 +99,8 @@ export interface ViewUnit { visibility: VisibilityLevel /** True when position is a last-known fix rather than a live track */ stale: boolean + /** Own units: radar silent (EMCON) */ + emcon?: boolean + /** Enemy contacts: positively identified as a decoy */ + decoyRevealed?: boolean } From 00728188780dc924d2d3f995ef847f3df3c59820 Mon Sep 17 00:00:00 2001 From: CarlBarl <145713155+CarlBarl@users.noreply.github.com> Date: Wed, 10 Jun 2026 23:46:24 +0200 Subject: [PATCH 03/12] Build intel suite v3: command center, real-imagery products, live feeds, time slider MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit UI for the v3 engine (docs/plans/intel-suite-v3.md), built by four parallel agents on the frozen scaffold contracts, integrated and verified. INTEL command center (IntelPanel rewritten): five tabs. ISR — asset cards with next-pass countdowns, TASK PASS with an AOI picker that fetches real cloud cover (Open-Meteo) before queueing; SIGINT — precedence-tagged intercept cards + encryption-blackout banner; HUMINT — source cards with exposure bars and TASK/REST/EXFILTRATE; OSINT — the diegetic feed; OPSEC — leak-level gauge, sweep button, paranoia band, EMCON quick-toggles. UnitInfoPanel gains an EMCON toggle. Products and feeds: ImintViewer renders pass products as classified imagery (real Esri tiles of the actual AOI, banners, NIIRS, Z-times, scanlines) with a new-product toast. LiveFeeds window: EUMETSAT Meteosat-9 IODC live (15-min cadence, IR at night), Reuters Hormuz traffic stream, synthetic drone FMV over any tracked contact (real imagery + Ken-Burns + IR mode), live ADS-B table of real Gulf aircraft, agency-style source credits. OSINT feed generator: 8 fictional accounts with per-archetype delay, error rate and templates, consuming the true event stream; ticker above the feed. Map: AOU rings that grow on stale contacts, radar-horizon sensor rings on selection, queued-pass swaths, revealed-decoy styling, live ADS-B layer, GIBS daily VIIRS recon-mosaic basemap toggle (RCN/ADS-B/OVL in MapToggle). TopBar: log-scale time slider 0 to 1h/s with snap detents and a pause/play toggle (user request), plus the LIVE window button. 515 tests green (81 new: 38 intel engine, panel/feed/TopBar suites), tsc clean, Playwright smoke extended (intel tabs, live feeds, slider-driven war) and passing end to end. Co-Authored-By: Claude Fable 5 --- docs/BACKLOG.md | 27 +- scripts/e2e-smoke.mjs | 39 +- src/App.tsx | 7 + src/components/hud/MapToggle.tsx | 16 + src/components/hud/TopBar.tsx | 289 +++--- .../hud/__tests__/AlertFeed.test.tsx | 1 + .../hud/__tests__/DebriefScreen.test.tsx | 1 + .../hud/__tests__/MapToggle.test.tsx | 150 ++- src/components/hud/__tests__/TopBar.test.tsx | 66 ++ src/components/intel/ImintViewer.tsx | 258 +++++ src/components/intel/LiveFeeds.tsx | 381 +++++++ src/components/intel/OsintTicker.tsx | 60 ++ src/components/intel/imagery.ts | 59 ++ src/components/map/DeckOverlay.tsx | 68 +- src/components/map/GameMap.tsx | 69 ++ .../map/__tests__/ContextMenu.test.tsx | 1 + .../map/__tests__/InfoTooltip.test.tsx | 1 + src/components/map/layers/IntelLayers.ts | 274 +++++ src/components/map/layers/UnitLayer.ts | 42 +- src/components/panels/IntelPanel.tsx | 980 ++++++++++++------ src/components/panels/UnitInfoPanel.tsx | 59 ++ .../panels/__tests__/IntelPanel.test.tsx | 303 ++++++ .../panels/__tests__/StatsPanel.test.tsx | 1 + .../panels/__tests__/UnitInfoPanel.test.tsx | 1 + src/engine/systems/__tests__/intel.test.ts | 870 ++++++++++++++++ src/engine/systems/intel.ts | 2 +- src/intel/__tests__/osint-feed.test.ts | 149 +++ src/intel/osint-feed.ts | 312 ++++++ src/store/__tests__/game-store.test.ts | 1 + src/store/game-store.ts | 9 + src/store/intel-store.ts | 7 + src/store/map-intel-store.ts | 107 ++ src/store/ui-store.ts | 19 + 33 files changed, 4146 insertions(+), 483 deletions(-) create mode 100644 src/components/intel/ImintViewer.tsx create mode 100644 src/components/intel/LiveFeeds.tsx create mode 100644 src/components/intel/OsintTicker.tsx create mode 100644 src/components/intel/imagery.ts create mode 100644 src/components/map/layers/IntelLayers.ts create mode 100644 src/components/panels/__tests__/IntelPanel.test.tsx create mode 100644 src/engine/systems/__tests__/intel.test.ts create mode 100644 src/intel/__tests__/osint-feed.test.ts create mode 100644 src/intel/osint-feed.ts create mode 100644 src/store/map-intel-store.ts diff --git a/docs/BACKLOG.md b/docs/BACKLOG.md index 43f05a5..c73f2a7 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). 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/src/App.tsx b/src/App.tsx index 689da25..f475c6f 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' @@ -168,6 +171,7 @@ export default function App() { {mobilePanel === 'econ' && } {mobilePanel === 'events' && } {mobilePanel === 'intel' && setMobilePanel(null)} />} + {showDebrief && } @@ -179,12 +183,15 @@ export default function App() { + {showOrbat && } {showStats && } {showEconomy && } {showIntel && } + + {showDebrief && } ) diff --git a/src/components/hud/MapToggle.tsx b/src/components/hud/MapToggle.tsx index 8f9a292..6761c94 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) @@ -110,6 +117,15 @@ export default function MapToggle() { + + + {/* Sub-menu panels */} diff --git a/src/components/hud/TopBar.tsx b/src/components/hud/TopBar.tsx index b2400da..7bdd5a3 100644 --- a/src/components/hud/TopBar.tsx +++ b/src/components/hud/TopBar.tsx @@ -1,4 +1,4 @@ -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' @@ -32,15 +32,38 @@ 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)}` } export default function TopBar() { @@ -55,6 +78,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) @@ -79,7 +104,6 @@ export default function TopBar() { const [warClickPending, setWarClickPending] = useState(false) 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) @@ -373,114 +397,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 +424,9 @@ export default function TopBar() { {/* Intel panel toggle */} + {/* Live feeds window toggle */} + + {/* Hormuz status badge */} @@ -802,9 +722,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 +780,8 @@ export default function TopBar() {
Top Bar
+ + @@ -1079,6 +1001,131 @@ 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 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..6c1d87e 100644 --- a/src/components/hud/__tests__/AlertFeed.test.tsx +++ b/src/components/hud/__tests__/AlertFeed.test.tsx @@ -68,6 +68,7 @@ function makeViewState(events: GameEvent[], opts: ViewOpts = {}): GameViewState warSupport: {}, gameOver: null, objectives: [], + intel: { assets: [], agents: [], products: [], taskings: [], leakLevel: 0, paranoiaBand: 'LOW' as const, encryptionUpgradedUntilTick: null }, } } 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..4028f92 100644 --- a/src/components/hud/__tests__/TopBar.test.tsx +++ b/src/components/hud/__tests__/TopBar.test.tsx @@ -2,6 +2,7 @@ import { describe, it, expect, beforeEach, vi } from 'vitest' import { render, screen, fireEvent } from '@testing-library/react' import TopBar from '../TopBar' import { useGameStore } from '@/store/game-store' +import { useUIStore } from '@/store/ui-store' import { sendCommand } from '@/store/bridge' import type { GameViewState } from '@/types/view' import type { GameEvent, Nation } from '@/types/game' @@ -50,6 +51,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, } } @@ -168,3 +178,59 @@ describe('TopBar war controls', () => { expect(screen.queryByText('RESIGN')).toBeNull() }) }) + +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 ( + +