diff --git a/docs/plans/game-loop-v2.md b/docs/plans/game-loop-v2.md
new file mode 100644
index 0000000..7a1db04
--- /dev/null
+++ b/docs/plans/game-loop-v2.md
@@ -0,0 +1,122 @@
+# Game Loop v2 — fog of war, war termination, product polish
+
+Branch `feature/game-loop-v2`. Goal: turn the sim into a game. Wars must be winnable and
+losable, and the intel layer must actually gate what the player sees. Everything here is
+designed around contracts scaffolded in `types/game.ts`, `types/view.ts`,
+`types/commands.ts` and stubs in `engine/systems/visibility.ts` / `war-support.ts` —
+implementers fill the stubs, they do not reshape the contracts.
+
+## 1. Fog of war (visibility)
+
+Per observing nation, per enemy unit, a `VisibilityContact { level, lastSeenTick,
+lastKnownPosition }` with levels `unseen → detected → tracked → identified`.
+
+Sources, evaluated in `processVisibility` each tick (all inputs already computed by the
+engine — consume, don't reinvent):
+
+| Source | Result | Notes |
+|---|---|---|
+| Own radar / sensor network coverage | `tracked`; `identified` if also within 60% of radar range | use `buildSensorNetwork` output + `detection.ts` LOS logic |
+| Satellite pass (`getSatelliteDetections`) | `detected` (optical: `tracked`) | already per-nation |
+| HUMINT (`lastEspionageResult.humintRevealed`) | `identified` | sticky 30 game-min |
+| ELINT (`sensor-network` `isDetectedByELINT` × `sigintMultiplier`) | `detected` of EMITTING units (radar on) | this finally wires the dead ELINT path + SIGINT slider |
+| This-tick `MISSILE_LAUNCHED` events (scan `state.events` tail by `tick`) | launcher → `tracked` | launch plume |
+| `MINE_CONTACT` | minefield → `identified` | you found it the hard way |
+
+Decay when not refreshed: `identified→tracked` after 10 game-min, `tracked→detected`
+after 10, `detected→unseen` after 30. Exception: FIXED categories (airbase, naval_base,
+minefield once identified, sam_site that has not moved since last seen) never decay below
+`detected`, and airbase/naval_base are permanently `identified` once identified — bases
+don't walk away. Mobile categories (ship, submarine, carrier_group, missile_battery with
+readiness, aircraft) decay normally; on decay below `tracked`, freeze
+`lastKnownPosition`.
+
+Snapshot rules (`getViewState`, already wired to call the visibility module):
+- Own units: always `visibility: 'identified'`, `stale: false`, full data.
+- Enemy `unseen`: excluded from `units[]` entirely.
+- Enemy `detected`: included with `stale: true` when the live track is gone — position =
+ `lastKnownPosition`, and SCRUB weapons/supplyStocks/pointDefense to `[]`, sensors to
+ `[]`, health to 100 (you don't know). Name: generic per category ("Unknown contact").
+- Enemy `tracked`: live position, real name, health visible, weapons scrubbed.
+- Enemy `identified`: everything.
+- Missiles: always visible (radar-bright, both sides). AI keeps using full state (the AI
+ may cheat in v1).
+- SITREP/StatsPanel enemy "Active" count becomes known contacts; strike-panel target
+ lists inherit filtering for free since they read `viewState.units`.
+
+Save/load: `state.visibility` serializes; absent in old saves → starts empty (fair: you
+re-acquire the picture).
+
+## 2. War termination (war support)
+
+Per nation `WarStatus { warSupport 0-100, warStartTick, ceasefireOffered }`. Computed in
+`processWarSupport` once per game-minute (tick % 60) by scanning this-tick events via a
+module watermark over `state.events` (same pattern as attackCounters — do NOT read
+pendingEvents).
+
+Drains (tuned so a fought war resolves in 1-3 game-weeks at typical intensity):
+- Own unit destroyed: carrier 12, naval_base/airbase 6, ship/submarine 4, sam_site 2,
+ missile_battery 1.5, minefield 0.5.
+- War duration: 0.15 per game-hour at war.
+- Economy: reserves below 25% of start: extra 0.3/h. USA only: oil price above $110
+ drains 0.2/h (political pressure). Iran only: Hormuz lane status `blocked` drains Iran
+ 0.2/h too (their own exports die — closing Hormuz is a sword with two edges).
+- Gains: enemy unit destroyed gives the killer +0.5 (capped contribution +10 total).
+
+Thresholds:
+- ≤ 35: `WAR_SUPPORT_CRITICAL` event once per crossing; AI nation at ≤ 35 offers
+ ceasefire (`CEASEFIRE_OFFERED` event, `ceasefireOffered = true`) and its ai.ts phase
+ drops back to DEFENSIVE (stand down offensive salvos).
+- 0: capitulation → `WAR_ENDED { outcome: 'capitulation', loser }`, `state.gameOver`
+ set with stats, all units `hold_fire`, atWar cleared.
+
+Ceasefire mechanics:
+- Player → `OFFER_CEASEFIRE` command. AI accepts iff its warSupport <
+ playerSupport + 10 OR its offensive missile stock < 25% of start. Accept →
+ `WAR_ENDED { outcome: 'ceasefire' }` + gameOver report; reject →
+ `CEASEFIRE_REJECTED` event (no state change, 6h cooldown before re-offer).
+- AI offer pending → player accepts via existing `CEASE_FIRE` command (now =
+ "accept standing offer"), or ignores it (offer stands).
+- `RESIGN` command → immediate `WAR_ENDED`, outcome defeat for player.
+
+Outcome mapping for the player: enemy capitulates = victory; own capitulation/resign =
+defeat; ceasefire = scored draw — debrief shows who held the upper hand (higher
+warSupport).
+
+`GameOverReport.stats` (`WarStats`): duration, units lost per nation, missiles fired /
+intercepted per nation, peak oil price, ticks Hormuz spent blocked/reduced. Track inside
+war-support.ts from events + lane state; do not add engine-wide counters.
+
+Objectives (computed in war-support.ts, shipped as `GameViewState.objectives`, both for
+the player's side):
+- USA: "Keep Hormuz open" (share of war time lane ≠ blocked), "Destroy Iran's strategic
+ missile force" (fraction of initial missile_battery+TEL units killed), "Preserve the
+ carrier group" (binary).
+- Iran: "Close the Strait" (share of war time lane ≠ open), "Attrit the US fleet"
+ (fraction of initial US naval units killed), "Preserve strategic forces" (fraction of
+ own batteries surviving).
+Status: good ≥ 0.66 progress, contested ≥ 0.33, else bad. These are drivers shown to the
+player; warSupport is the actual win meter.
+
+## 3. UI
+
+- TopBar: two compact war-support bars (player blue / enemy red) visible when at war;
+ DECLARE WAR swaps to OFFER CEASEFIRE while at war; banner chip when the enemy has
+ offered (click = accept). Objectives chip opens a mini panel listing
+ `ObjectiveStatus` rows.
+- DebriefScreen: full-screen overlay when `gameOver` arrives — outcome headline, stats
+ table (losses, missiles, interceptions, oil peak, duration), objectives final state,
+ buttons: "Return to command" (keep watching the world) and "Main menu" (menu-store
+ back to start; engine re-init already safe).
+- Fog rendering (UnitLayer): `identified` full icon; `tracked` full icon at 80% alpha;
+ `detected` generic diamond contact marker with "?" badge, dashed when `stale`;
+ tooltips/panels show only the data the level grants.
+- AlertFeed: clicking an event with a position flies the camera there (ui-store
+ `mapFocus` consumed by GameMap); gear popover with auto-pause toggles (on war
+ declared / own unit destroyed / ceasefire offered) — client-side, sends SET_SPEED 0.
+- Branding unified to ASHFALL COMMAND (StartScreen, index.html title, README).
+
+## 4. Explicitly out of scope (stay in BACKLOG.md)
+
+Aircraft/sortie system, additional scenarios, multi-slot saves, strike timing modes
+engine-side, mobile layout, mission editor.
diff --git a/index.html b/index.html
index 679382e..24ba04a 100644
--- a/index.html
+++ b/index.html
@@ -4,7 +4,7 @@
-
REALPOLITIK
+ ASHFALL COMMAND
diff --git a/scripts/e2e-smoke.mjs b/scripts/e2e-smoke.mjs
new file mode 100644
index 0000000..4d913be
--- /dev/null
+++ b/scripts/e2e-smoke.mjs
@@ -0,0 +1,98 @@
+import { chromium } from 'playwright'
+
+const BASE = process.env.SMOKE_URL ?? 'http://localhost:4173'
+const browser = await chromium.launch({ channel: 'chrome', headless: process.env.SMOKE_HEADED ? false : true })
+const page = await browser.newPage({ viewport: { width: 1400, height: 900 } })
+
+const consoleErrors = []
+page.on('pageerror', err => consoleErrors.push(`pageerror: ${err.message}`))
+page.on('console', msg => {
+ if (msg.type() === 'error' && !msg.text().includes('borderColor')) {
+ consoleErrors.push(`console.error: ${msg.text()}`)
+ }
+})
+
+const step = async (name, fn) => {
+ try {
+ await fn()
+ console.log(`ok ${name}`)
+ } catch (err) {
+ console.log(`FAIL ${name}: ${err.message}`)
+ const text = (await page.locator('body').innerText()).replace(/\n/g, ' | ').slice(0, 1500)
+ console.log(`body: ${text}`)
+ await browser.close()
+ process.exit(1)
+ }
+}
+
+const clickText = async (text, opts = {}) => {
+ await page.getByText(text, { exact: opts.exact ?? false }).first().click({ timeout: 8000 })
+}
+
+await step('load start screen', async () => {
+ await page.goto(BASE)
+ await page.getByText('ASHFALL COMMAND').first().waitFor({ timeout: 10000 })
+})
+
+await step('launch scenario', async () => {
+ await clickText('SCENARIO', { exact: true })
+ await clickText('CONTINUE')
+ await clickText('Persian Gulf Crisis 2026')
+ await clickText('LAUNCH')
+ await page.getByText('Jun 15').first().waitFor({ timeout: 15000 })
+})
+
+await step('fog of war: SITREP shows contacts, not full enemy orbat', async () => {
+ await clickText('SITREP', { exact: true })
+ await page.getByText('Contacts').first().waitFor({ timeout: 8000 })
+ await page.getByText(/EST\. ORBAT/).first().waitFor({ timeout: 4000 })
+ await clickText('SITREP', { exact: true })
+})
+
+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 page.waitForTimeout(8000)
+})
+
+await step('war UI: support bars + ceasefire + objectives appear', async () => {
+ await page.getByText('OFFER CEASEFIRE').first().waitFor({ timeout: 8000 })
+ await page.getByText('OBJECTIVES').first().waitFor({ timeout: 4000 })
+ await clickText('OBJECTIVES')
+ await page.getByText('Keep Hormuz open').first().waitFor({ timeout: 4000 })
+ await clickText('OBJECTIVES')
+})
+
+await step('fog of war: war reveals enemy contacts on the map', async () => {
+ const text = await page.locator('body').innerText()
+ if (!/WAR: IRAN/.test(text)) throw new Error('war state lost')
+})
+
+await step('resign → debrief shows defeat', async () => {
+ await clickText('···')
+ await clickText('RESIGN')
+ const confirm = page.getByText(/CONFIRM/i).first()
+ if (await confirm.isVisible({ timeout: 2000 }).catch(() => false)) await confirm.click()
+ await page.getByText('DEFEAT').first().waitFor({ timeout: 8000 })
+ await page.getByText(/CAPITULATED|War support/i).first().waitFor({ timeout: 4000 })
+})
+
+await step('main menu return', async () => {
+ await clickText('MAIN MENU')
+ await page.getByText('SELECT MODE').first().waitFor({ timeout: 8000 })
+})
+
+if (consoleErrors.length > 0) {
+ console.log('CONSOLE ERRORS:')
+ for (const e of consoleErrors.slice(0, 10)) console.log(' ' + e)
+ await browser.close()
+ process.exit(1)
+}
+
+console.log('SMOKE PASSED')
+await browser.close()
diff --git a/src/App.tsx b/src/App.tsx
index d50828f..689da25 100644
--- a/src/App.tsx
+++ b/src/App.tsx
@@ -2,6 +2,7 @@ import { useCallback, useEffect, useState } from 'react'
import GameMap from '@/components/map/GameMap'
import TopBar from '@/components/hud/TopBar'
import AlertFeed from '@/components/hud/AlertFeed'
+import DebriefScreen from '@/components/hud/DebriefScreen'
import StrikePanel from '@/components/panels/StrikePanel'
import UnitInfoPanel from '@/components/panels/UnitInfoPanel'
import EconomyPanel from '@/components/panels/EconomyPanel'
@@ -59,6 +60,7 @@ export default function App() {
}, [screen])
const units = useGameStore((s) => s.viewState.units)
+ const gameOver = useGameStore((s) => s.viewState.gameOver)
const selectedUnitId = useUIStore((s) => s.selectedUnitId)
const showOrbat = useUIStore((s) => s.showOrbat)
const showStats = useUIStore((s) => s.showStats)
@@ -66,6 +68,18 @@ export default function App() {
const showIntel = useUIStore((s) => s.showIntel)
// StrikePanel manages its own visibility via useStrikeStore
+ // Debrief overlay: shown when the war is decided, until dismissed (keyed by
+ // endTick so a fresh game's report shows again)
+ const [dismissedDebriefTick, setDismissedDebriefTick] = useState(null)
+ useEffect(() => {
+ if (screen === 'playing') setDismissedDebriefTick(null)
+ }, [screen])
+ const showDebrief = gameOver !== null && gameOver.endTick !== dismissedDebriefTick
+ const dismissDebrief = useCallback(() => {
+ const report = useGameStore.getState().viewState.gameOver
+ if (report) setDismissedDebriefTick(report.endTick)
+ }, [])
+
// On mobile: auto-open UNIT panel on select, close all on deselect (map tap)
useEffect(() => {
if (!isMobile) return
@@ -155,6 +169,7 @@ export default function App() {
{mobilePanel === 'events' && }
{mobilePanel === 'intel' && setMobilePanel(null)} />}
+ {showDebrief && }
)
}
@@ -170,6 +185,7 @@ export default function App() {
{showStats && }
{showEconomy && }
{showIntel && }
+ {showDebrief && }
)
}
diff --git a/src/components/hud/AlertFeed.tsx b/src/components/hud/AlertFeed.tsx
index b303f32..b4a67ce 100644
--- a/src/components/hud/AlertFeed.tsx
+++ b/src/components/hud/AlertFeed.tsx
@@ -1,8 +1,11 @@
import { useCallback, useEffect, useMemo, useRef, useState } from 'react'
import { useGameStore } from '@/store/game-store'
+import { useUIStore } from '@/store/ui-store'
+import { sendCommand } from '@/store/bridge'
import { useIsMobile } from '@/hooks/useIsMobile'
import { weaponSpecs } from '@/data/weapons/missiles'
-import type { GameEvent } from '@/types/game'
+import type { GameEvent, Position } from '@/types/game'
+import type { AutoPauseSettings } from '@/store/ui-store'
/** How long (ms) after the last new event before auto-collapsing */
const AUTO_COLLAPSE_MS = 10_000
@@ -10,9 +13,18 @@ const AUTO_COLLAPSE_MS = 10_000
/** Logistics churn — one RESUPPLIED per weapon per unit per minute would flood the feed */
const HIDDEN_EVENT_TYPES = new Set(['RESUPPLIED'])
+const EVENT_FOCUS_ZOOM = 7
+
+const AUTO_PAUSE_OPTIONS: { key: keyof AutoPauseSettings; label: string }[] = [
+ { key: 'warDeclared', label: 'War declared' },
+ { key: 'ownUnitDestroyed', label: 'Own unit destroyed' },
+ { key: 'ceasefireOffered', label: 'Ceasefire offered' },
+]
+
export default function AlertFeed() {
const isMobile = useIsMobile()
const [expanded, setExpanded] = useState(false)
+ const [gearOpen, setGearOpen] = useState(false)
// Last log entry the user had on screen — the unread badge derives from it
const [lastSeen, setLastSeen] = useState(() => {
const initial = useGameStore.getState().eventLog
@@ -23,6 +35,10 @@ export default function AlertFeed() {
const events = useGameStore((s) => s.viewState.events)
const eventLog = useGameStore((s) => s.eventLog)
const units = useGameStore((s) => s.viewState.units)
+ const shippingLanes = useGameStore((s) => s.viewState.shippingLanes)
+ const focusMap = useUIStore((s) => s.focusMap)
+ const autoPause = useUIStore((s) => s.autoPause)
+ const toggleAutoPause = useUIStore((s) => s.toggleAutoPause)
// Render from the store-level event log so history survives unmount (mobile LOG tab)
const log = useMemo(
@@ -36,6 +52,27 @@ export default function AlertFeed() {
return names
}, [units])
+ const unitPositions = useMemo(() => {
+ const positions = new Map()
+ for (const u of units) positions.set(u.id, u.position)
+ return positions
+ }, [units])
+
+ const laneNames = useMemo(() => {
+ const names = new Map()
+ for (const l of shippingLanes) names.set(l.id, l.name)
+ return names
+ }, [shippingLanes])
+
+ const laneMidpoints = useMemo(() => {
+ const mids = new Map()
+ for (const l of shippingLanes) {
+ const [lng, lat] = l.path[Math.floor(l.path.length / 2)]
+ mids.set(l.id, { lng, lat })
+ }
+ return mids
+ }, [shippingLanes])
+
const unreadCount = useMemo(() => {
if (lastSeen === null) return log.length
const idx = log.lastIndexOf(lastSeen)
@@ -73,6 +110,24 @@ export default function AlertFeed() {
}, AUTO_COLLAPSE_MS)
}, [events, markSeen])
+ // Auto-pause on enabled triggers — same one-shot batch guard as above
+ const pauseBatchRef = useRef(null)
+ useEffect(() => {
+ if (events.length === 0 || pauseBatchRef.current === events) return
+ pauseBatchRef.current = events
+
+ const { viewState } = useGameStore.getState()
+ if (viewState.time.speed <= 0) return
+ const settings = useUIStore.getState().autoPause
+ const shouldPause = events.some((e) =>
+ (settings.warDeclared && e.type === 'WAR_DECLARED')
+ || (settings.ceasefireOffered && e.type === 'CEASEFIRE_OFFERED')
+ || (settings.ownUnitDestroyed && e.type === 'UNIT_DESTROYED'
+ && viewState.units.find((u) => u.id === e.unitId)?.nation === viewState.playerNation),
+ )
+ if (shouldPause) sendCommand({ type: 'SET_SPEED', speed: 0 })
+ }, [events])
+
// Auto-scroll when expanded and new events arrive
useEffect(() => {
if (expanded) {
@@ -82,10 +137,12 @@ export default function AlertFeed() {
const handleExpand = useCallback(() => {
setExpanded(true)
+ setGearOpen(false)
}, [])
const handleCollapse = useCallback(() => {
setExpanded(false)
+ setGearOpen(false)
markSeen()
}, [markSeen])
@@ -177,7 +234,7 @@ export default function AlertFeed() {
whiteSpace: 'nowrap',
flex: 1,
}}>
- {formatEvent(lastEvent, unitNames)}
+ {formatEvent(lastEvent, unitNames, laneNames)}
{/* Expand hint */}
@@ -241,20 +298,85 @@ export default function AlertFeed() {
}}>
Events ({log.length})
-
- {'\u25B2'}
-
+
+
+
setGearOpen(!gearOpen)}
+ aria-label="Auto-pause settings"
+ style={{
+ background: 'none',
+ border: 'none',
+ color: gearOpen ? 'var(--text-accent)' : 'var(--text-muted)',
+ cursor: 'pointer',
+ fontFamily: 'var(--font-mono)',
+ fontSize: 'var(--font-size-xs)',
+ padding: '0 2px',
+ }}
+ >
+ {'\u2699'}
+
+ {gearOpen && (
+
+
+ Pause on
+
+ {AUTO_PAUSE_OPTIONS.map(({ key, label }) => (
+
+ toggleAutoPause(key)}
+ style={{ accentColor: 'var(--text-accent)', cursor: 'pointer' }}
+ />
+ {label}
+
+ ))}
+
+ )}
+
+
+ {'\u25B2'}
+
+
{/* Scrolling log */}
@@ -267,11 +389,23 @@ export default function AlertFeed() {
...(isMobile ? { WebkitOverflowScrolling: 'touch' as const } : {}),
}}
>
- {log.map((e, i) => (
-
- {formatEvent(e, unitNames)}
-
- ))}
+ {log.map((e, i) => {
+ const pos = eventPosition(e, unitPositions, laneMidpoints)
+ return (
+ focusMap(pos.lng, pos.lat, EVENT_FOCUS_ZOOM) : undefined}
+ title={pos ? 'Show on map' : undefined}
+ style={{
+ padding: '1px 0',
+ color: eventColor(e),
+ cursor: pos ? 'pointer' : 'default',
+ }}
+ >
+ {formatEvent(e, unitNames, laneNames)}
+
+ )
+ })}
)
@@ -298,6 +432,10 @@ function eventColor(e: GameEvent): string {
case 'SUPPLY_LINE_INTERDICTED': return 'var(--status-engaged)'
case 'SUPPLY_LINE_CUT': return 'var(--status-damaged)'
case 'RESUPPLIED': return 'var(--status-ready)'
+ case 'WAR_SUPPORT_CRITICAL': return 'var(--status-engaged)'
+ case 'CEASEFIRE_OFFERED': return 'var(--status-ready)'
+ case 'CEASEFIRE_REJECTED': return 'var(--text-muted)'
+ case 'WAR_ENDED': return 'var(--status-ready)'
default: return 'var(--text-secondary)'
}
}
@@ -314,7 +452,40 @@ function lineName(id: string): string {
return id.toUpperCase().replace(/_/g, ' ')
}
-function formatEvent(e: GameEvent, names: Map): string {
+function laneName(id: string, lanes: Map): string {
+ return (lanes.get(id) ?? lineName(id)).toUpperCase()
+}
+
+function eventPosition(
+ e: GameEvent,
+ unitPositions: Map,
+ laneMidpoints: Map,
+): Position | null {
+ switch (e.type) {
+ case 'MISSILE_INTERCEPTED':
+ return e.position
+ case 'MISSILE_IMPACT':
+ return unitPositions.get(e.targetId) ?? null
+ case 'MISSILE_LAUNCHED':
+ return unitPositions.get(e.targetId) ?? unitPositions.get(e.launcherId) ?? null
+ case 'MINE_CONTACT':
+ return unitPositions.get(e.targetId) ?? unitPositions.get(e.minefieldId) ?? null
+ case 'UNIT_DESTROYED':
+ case 'AMMO_DEPLETED':
+ case 'UNIT_REPAIRED':
+ case 'POINT_DEFENSE_KILL':
+ case 'RESUPPLIED':
+ return unitPositions.get(e.unitId) ?? null
+ case 'SUPPLY_LINE_INTERDICTED':
+ return unitPositions.get(e.threatUnitId) ?? null
+ case 'SHIPPING_LANE_STATUS_CHANGE':
+ return laneMidpoints.get(e.laneId) ?? null
+ default:
+ return null
+ }
+}
+
+function formatEvent(e: GameEvent, names: Map, lanes: Map): string {
switch (e.type) {
case 'MISSILE_LAUNCHED':
return `T+${e.tick} LAUNCH ${e.weaponName} -> ${unitName(e.targetId, names)}`
@@ -339,11 +510,21 @@ function formatEvent(e: GameEvent, names: Map): string {
case 'OIL_PRICE_CHANGE':
return `T+${e.tick} OIL $${e.newPrice.toFixed(0)}/bbl (was $${e.oldPrice.toFixed(0)})`
case 'SHIPPING_LANE_STATUS_CHANGE':
- return `T+${e.tick} ${lineName(e.laneId)}: ${e.newStatus.toUpperCase()}`
+ return `T+${e.tick} ${laneName(e.laneId, lanes)}: ${e.newStatus.toUpperCase()}`
case 'MINE_CONTACT':
return `T+${e.tick} MINE HIT: ${unitName(e.targetId, names)} (-${e.damage} HP)`
case 'SUPPLY_LINE_INTERDICTED':
return `T+${e.tick} SUPPLY THREATENED: ${lineName(e.lineId)} (${e.healthAfter.toFixed(0)}% HP)`
+ case 'WAR_SUPPORT_CRITICAL':
+ return `T+${e.tick} WAR SUPPORT CRITICAL: ${e.nation.toUpperCase()} (${Math.round(e.support)}%)`
+ case 'CEASEFIRE_OFFERED':
+ return `T+${e.tick} CEASEFIRE OFFERED by ${e.by.toUpperCase()}`
+ case 'CEASEFIRE_REJECTED':
+ return `T+${e.tick} CEASEFIRE REJECTED by ${e.by.toUpperCase()}`
+ case 'WAR_ENDED':
+ return e.outcome === 'capitulation'
+ ? `T+${e.tick} WAR ENDED: ${(e.loser ?? '').toUpperCase()} CAPITULATED`
+ : `T+${e.tick} WAR ENDED: CEASEFIRE`
default:
return `T+${(e as GameEvent & { tick: number }).tick} ${(e as GameEvent & { type: string }).type}`
}
diff --git a/src/components/hud/DebriefScreen.tsx b/src/components/hud/DebriefScreen.tsx
new file mode 100644
index 0000000..3bf31a6
--- /dev/null
+++ b/src/components/hud/DebriefScreen.tsx
@@ -0,0 +1,260 @@
+import { useGameStore } from '@/store/game-store'
+import { useMenuStore } from '@/store/menu-store'
+import { useStrikeStore } from '@/store/strike-store'
+import { useIntelStore } from '@/store/intel-store'
+import { useUIStore } from '@/store/ui-store'
+import ObjectivesPanel from './ObjectivesPanel'
+import type { GameOverReport } from '@/types/game'
+
+const OUTCOME_STYLES: Record = {
+ victory: { label: 'VICTORY', color: 'var(--status-ready)' },
+ defeat: { label: 'DEFEAT', color: 'var(--status-damaged)' },
+ ceasefire: { label: 'CEASEFIRE', color: 'var(--status-engaged)' },
+}
+
+export function formatDuration(ticks: number): string {
+ const days = Math.floor(ticks / 86_400)
+ const hours = Math.floor((ticks % 86_400) / 3_600)
+ const minutes = Math.floor((ticks % 3_600) / 60)
+ if (days > 0) return `${days}d ${hours}h`
+ if (hours > 0) return `${hours}h ${minutes}m`
+ return `${minutes}m`
+}
+
+export default function DebriefScreen({ onDismiss }: { onDismiss: () => void }) {
+ const gameOver = useGameStore((s) => s.viewState.gameOver)
+ const warSupport = useGameStore((s) => s.viewState.warSupport)
+ const nations = useGameStore((s) => s.viewState.nations)
+ const playerNation = useGameStore((s) => s.viewState.playerNation)
+ const objectives = useGameStore((s) => s.viewState.objectives)
+
+ if (!gameOver) return null
+
+ const enemyId = nations.find((n) => n.id !== playerNation)?.id
+ ?? Object.keys(gameOver.stats.unitsLost).find((id) => id !== playerNation)
+ ?? 'enemy'
+ const nameOf = (id: string) => nations.find((n) => n.id === id)?.name.toUpperCase() ?? id.toUpperCase()
+
+ const outcome = OUTCOME_STYLES[gameOver.outcome]
+ const playerSupport = Math.round(warSupport[playerNation] ?? 0)
+ const enemySupport = Math.round(warSupport[enemyId] ?? 0)
+ const { stats } = gameOver
+
+ const verdict = gameOver.outcome === 'ceasefire'
+ ? playerSupport === enemySupport
+ ? 'HONORS EVEN'
+ : `${nameOf(playerSupport > enemySupport ? playerNation : enemyId)} HELD THE UPPER HAND`
+ : gameOver.loser
+ ? `${nameOf(gameOver.loser)} CAPITULATED`
+ : null
+
+ const handleMainMenu = () => {
+ useStrikeStore.getState().reset()
+ useIntelStore.getState().reset()
+ const ui = useUIStore.getState()
+ ui.clearSelection()
+ ui.setLeftPanel(null)
+ useUIStore.setState({ showIntel: false })
+ useMenuStore.getState().setScreen('start')
+ }
+
+ return (
+
+
+
+ After Action Report
+
+
+
+ {outcome.label}
+
+
+ {verdict && (
+
+ {verdict}
+
+ )}
+
+
Final War Support
+
+
+
+
+
+
War Statistics
+
+
+ {nameOf(playerNation)}
+ {nameOf(enemyId)}
+ Units lost
+ {stats.unitsLost[playerNation] ?? 0}
+ {stats.unitsLost[enemyId] ?? 0}
+ Missiles fired
+ {stats.missilesFired[playerNation] ?? 0}
+ {stats.missilesFired[enemyId] ?? 0}
+ Missiles intercepted
+ {stats.missilesIntercepted[playerNation] ?? 0}
+ {stats.missilesIntercepted[enemyId] ?? 0}
+
+
+
+
+
+
+
+
+ {objectives.length > 0 && (
+ <>
+
Objectives
+
+
+
+ >
+ )}
+
+
+
+ CONTINUE OBSERVING
+
+
+ MAIN MENU
+
+
+
+
+ )
+}
+
+function SectionHeader({ children }: { children: string }) {
+ return (
+
+ {children}
+
+ )
+}
+
+function SupportRow({ tag, support, color }: { tag: string; support: number; color: string }) {
+ return (
+
+
+ {tag}
+
+
+
+ {support}%
+
+
+ )
+}
+
+function ColHeader({ children }: { children: string }) {
+ return (
+
+ {children}
+
+ )
+}
+
+function StatLabel({ children }: { children: string }) {
+ return {children}
+}
+
+function StatValue({ children }: { children: number }) {
+ return {children}
+}
+
+function KVRow({ label, value }: { label: string; value: string }) {
+ return (
+
+ {label}
+ {value}
+
+ )
+}
diff --git a/src/components/hud/ObjectivesPanel.tsx b/src/components/hud/ObjectivesPanel.tsx
new file mode 100644
index 0000000..7587554
--- /dev/null
+++ b/src/components/hud/ObjectivesPanel.tsx
@@ -0,0 +1,50 @@
+import type { ObjectiveStatus } from '@/types/view'
+
+const STATUS_COLORS: Record = {
+ good: 'var(--status-ready)',
+ contested: 'var(--status-engaged)',
+ bad: 'var(--status-damaged)',
+}
+
+export default function ObjectivesPanel({ objectives }: { objectives: ObjectiveStatus[] }) {
+ return (
+
+ {objectives.map((obj) => (
+
+ ))}
+
+ )
+}
+
+function ObjectiveRow({ objective }: { objective: ObjectiveStatus }) {
+ const color = STATUS_COLORS[objective.status]
+ const pct = Math.round(Math.max(0, Math.min(1, objective.progress)) * 100)
+ return (
+
+
+
+ {objective.label}
+
+ {pct}%
+
+
+
+ {objective.detail}
+
+
+ )
+}
diff --git a/src/components/hud/TopBar.tsx b/src/components/hud/TopBar.tsx
index 47e193b..b2400da 100644
--- a/src/components/hud/TopBar.tsx
+++ b/src/components/hud/TopBar.tsx
@@ -1,4 +1,4 @@
-import { useState, useCallback } from 'react'
+import { useState, useCallback, useMemo } from 'react'
import { useUIStore } from '@/store/ui-store'
import { useGameStore } from '@/store/game-store'
import { useStrikeStore } from '@/store/strike-store'
@@ -6,6 +6,7 @@ import { useIntelStore } from '@/store/intel-store'
import { sendCommand, getFullState, loadState } from '@/store/bridge'
import { saveToSlot, loadFromSlot } from '@/store/save-load'
import { useIsMobile } from '@/hooks/useIsMobile'
+import ObjectivesPanel from './ObjectivesPanel'
import type { ROE } from '@/types/game'
type PanelKey = 'orbat' | 'stats' | 'economy'
@@ -61,6 +62,9 @@ export default function TopBar() {
const time = useGameStore((s) => s.viewState.time)
const playerNation = useGameStore((s) => s.viewState.playerNation)
const shippingLanes = useGameStore((s) => s.viewState.shippingLanes)
+ const warSupport = useGameStore((s) => s.viewState.warSupport)
+ const objectives = useGameStore((s) => s.viewState.objectives)
+ const eventLog = useGameStore((s) => s.eventLog)
const hormuzLane = shippingLanes.find((l) => l.id === 'hormuz')
const playerState = nations.find((n) => n.id === playerNation)
@@ -73,9 +77,22 @@ export default function TopBar() {
const [showHelp, setShowHelp] = useState(false)
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)
+
+ // Standing enemy ceasefire offer, derived from the persistent event log
+ const enemyOffered = useMemo(() => {
+ if (!primaryEnemyNation) return false
+ for (let i = eventLog.length - 1; i >= 0; i--) {
+ const e = eventLog[i]
+ if (e.type === 'WAR_ENDED') return false
+ if (e.type === 'CEASEFIRE_OFFERED' && e.by === primaryEnemyNation.id) return true
+ }
+ return false
+ }, [eventLog, primaryEnemyNation])
const panelStates: Record = {
orbat: showOrbat,
@@ -114,6 +131,16 @@ export default function TopBar() {
setWarClickPending(false)
}
+ const handleOfferCeasefire = () => {
+ if (!primaryEnemyNation) return
+ if (!offerClickPending) {
+ setOfferClickPending(true)
+ return
+ }
+ sendCommand({ type: 'OFFER_CEASEFIRE', target: primaryEnemyNation.id })
+ setOfferClickPending(false)
+ }
+
const gameDate = new Date(time.timestamp)
const dateStr = gameDate.toLocaleDateString('en-US', {
month: 'short', day: 'numeric',
@@ -503,14 +530,22 @@ export default function TopBar() {
{/* War status + ROE */}
{atWarWithPrimaryEnemy ? (
-
- {`WAR: ${primaryEnemyLabel}`}
-
+ <>
+
+ {`WAR: ${primaryEnemyLabel}`}
+
+ {primaryEnemyNation && (
+
+ )}
+ >
) : (
)}
+ {/* Objectives chip (only at war) */}
+ {atWarWithPrimaryEnemy && objectives.length > 0 && (
+
+
setObjectivesOpen(!objectivesOpen)}
+ style={{
+ background: objectivesOpen ? 'var(--bg-hover)' : 'none',
+ border: `1px solid ${objectivesOpen ? 'var(--border-accent)' : 'var(--border-default)'}`,
+ borderRadius: 3,
+ color: objectivesOpen ? 'var(--text-accent)' : 'var(--text-secondary)',
+ cursor: 'pointer',
+ fontFamily: 'var(--font-mono)',
+ fontSize: 'var(--font-size-xs)',
+ padding: '2px 4px',
+ fontWeight: 600,
+ whiteSpace: 'nowrap',
+ }}
+ >
+ {'OBJECTIVES ▾'}
+
+
+ {objectivesOpen && (
+
+
+
+ )}
+
+ )}
+
+ {/* Ceasefire controls (only at war) */}
+ {atWarWithPrimaryEnemy && primaryEnemyNation && (
+ enemyOffered ? (
+ <>
+
+ sendCommand({ type: 'CEASE_FIRE', target: primaryEnemyNation.id })}
+ title={`${primaryEnemyLabel} has offered a ceasefire`}
+ style={{
+ background: 'var(--bg-hover)',
+ border: '1px solid var(--status-ready)',
+ borderRadius: 3,
+ color: 'var(--status-ready)',
+ cursor: 'pointer',
+ fontFamily: 'var(--font-mono)',
+ fontSize: 'var(--font-size-xs)',
+ padding: '2px 4px',
+ fontWeight: 700,
+ whiteSpace: 'nowrap',
+ animation: 'cf-pulse 1.6s ease-in-out infinite',
+ }}
+ >
+ ACCEPT CEASEFIRE
+
+ >
+ ) : (
+ setOfferClickPending(false)}
+ style={{
+ background: offerClickPending ? 'var(--border-accent)' : 'var(--bg-hover)',
+ border: offerClickPending
+ ? '2px solid var(--border-accent)'
+ : '1px solid var(--border-default)',
+ borderRadius: 3,
+ color: offerClickPending ? 'var(--text-primary)' : 'var(--text-secondary)',
+ cursor: 'pointer',
+ fontFamily: 'var(--font-mono)',
+ fontSize: 'var(--font-size-xs)',
+ padding: '2px 4px',
+ fontWeight: 700,
+ whiteSpace: 'nowrap',
+ }}
+ >
+ {offerClickPending ? 'CONFIRM OFFER' : 'OFFER CEASEFIRE'}
+
+ )
+ )}
+
{/* Declare war button (only at peace) */}
{!atWarWithPrimaryEnemy && primaryEnemyNation && (
{/* Save/Load */}
setOverflowOpen(false)} />
+ {/* Resign (only at war) */}
+ {atWarWithPrimaryEnemy && (
+ setOverflowOpen(false)} />
+ )}
)}
@@ -674,9 +802,9 @@ export default function TopBar() {
{/* Close dropdowns on outside click */}
- {(roeOpen || speedDropdownOpen || overflowOpen) && (
+ {(roeOpen || speedDropdownOpen || overflowOpen || objectivesOpen) && (
{ setRoeOpen(false); setSpeedDropdownOpen(false); setOverflowOpen(false) }}
+ onClick={() => { setRoeOpen(false); setSpeedDropdownOpen(false); setOverflowOpen(false); setObjectivesOpen(false) }}
style={{
position: 'fixed',
inset: 0,
@@ -734,6 +862,8 @@ export default function TopBar() {
+
+
Targeting
@@ -807,6 +937,73 @@ function OverflowItem({ label, active, onClick }: { label: string; active: boole
)
}
+function WarSupportBars({
+ player,
+ enemy,
+}: {
+ player: { tag: string; support: number }
+ enemy: { tag: string; support: number }
+}) {
+ return (
+
+
+
+
+ )
+}
+
+function SupportBar({ tag, support, color }: { tag: string; support: number; color: string }) {
+ const pct = Math.round(Math.max(0, Math.min(100, support)))
+ return (
+
+
+ {tag}
+
+
+
+ {pct}%
+
+
+ )
+}
+
+function OverflowResign({ onDone }: { onDone: () => void }) {
+ const [pending, setPending] = useState(false)
+ return (
+
{
+ if (!pending) {
+ setPending(true)
+ return
+ }
+ sendCommand({ type: 'RESIGN' })
+ setPending(false)
+ onDone()
+ }}
+ onBlur={() => setPending(false)}
+ style={{
+ background: pending ? 'var(--status-damaged)' : 'var(--bg-hover)',
+ border: pending
+ ? '1px solid var(--status-damaged)'
+ : '1px solid var(--border-default)',
+ borderRadius: 3,
+ color: pending ? 'var(--bg-primary)' : 'var(--status-damaged)',
+ cursor: 'pointer',
+ fontFamily: 'var(--font-mono)',
+ fontSize: 'var(--font-size-xs)',
+ padding: '4px 8px',
+ fontWeight: pending ? 700 : 400,
+ textAlign: 'left',
+ whiteSpace: 'nowrap',
+ }}
+ >
+ {pending ? 'CONFIRM RESIGN' : 'RESIGN'}
+
+ )
+}
+
function OverflowSaveLoad({ onDone }: { onDone: () => void }) {
const [feedback, setFeedback] = useState
(null)
diff --git a/src/components/hud/__tests__/AlertFeed.test.tsx b/src/components/hud/__tests__/AlertFeed.test.tsx
index 80171c7..2498b85 100644
--- a/src/components/hud/__tests__/AlertFeed.test.tsx
+++ b/src/components/hud/__tests__/AlertFeed.test.tsx
@@ -1,10 +1,19 @@
-import { describe, it, expect, beforeEach, beforeAll } from 'vitest'
+import { describe, it, expect, beforeEach, beforeAll, vi } from 'vitest'
import { render, screen, fireEvent, act } from '@testing-library/react'
import AlertFeed from '../AlertFeed'
import { useGameStore } from '@/store/game-store'
+import { useUIStore } from '@/store/ui-store'
+import { sendCommand } from '@/store/bridge'
+import { shippingLanes } from '@/data/shipping/shipping-lanes'
import type { GameViewState, ViewUnit } from '@/types/view'
import type { GameEvent } from '@/types/game'
+vi.mock('@/store/bridge', () => ({
+ sendCommand: vi.fn(),
+}))
+
+const sendCommandMock = vi.mocked(sendCommand)
+
const lincoln = {
id: 'cvn72_lincoln',
name: 'CVN-72 Abraham Lincoln',
@@ -24,21 +33,41 @@ const lincoln = {
roe: 'weapons_tight',
waypoints: [],
subordinateIds: [],
+ visibility: 'identified',
+ stale: false,
} as ViewUnit
-function makeViewState(events: GameEvent[]): GameViewState {
+const jamaran: ViewUnit = {
+ ...lincoln,
+ id: 'irin_jamaran',
+ name: 'IRIN Jamaran',
+ nation: 'iran',
+ category: 'ship',
+ position: { lat: 27, lng: 56 },
+ visibility: 'tracked',
+}
+
+interface ViewOpts {
+ speed?: number
+ units?: ViewUnit[]
+}
+
+function makeViewState(events: GameEvent[], opts: ViewOpts = {}): GameViewState {
return {
playerNation: 'usa',
initialized: true,
- time: { tick: 10, timestamp: 1_000_000, speed: 1, tickIntervalMs: 100 },
+ time: { tick: 10, timestamp: 1_000_000, speed: opts.speed ?? 1, tickIntervalMs: 100 },
nations: [],
- units: [lincoln],
+ units: opts.units ?? [lincoln, jamaran],
missiles: [],
supplyLines: [],
- shippingLanes: [],
+ shippingLanes,
events,
pendingEventCount: 0,
satelliteDetectedUnitIds: [],
+ warSupport: {},
+ gameOver: null,
+ objectives: [],
}
}
@@ -47,9 +76,9 @@ const impact: GameEvent = { type: 'MISSILE_IMPACT', missileId: 'm_504', targetId
const intercept: GameEvent = { type: 'MISSILE_INTERCEPTED', missileId: 'm_504', interceptorId: 'cvn72_lincoln', position: { lat: 25, lng: 55 }, tick: 3 }
const resupplied: GameEvent = { type: 'RESUPPLIED', unitId: 'cvn72_lincoln', weaponId: 'sm3_iia', count: 1, fromBaseId: 'base1', tick: 4 }
-function setStore(eventLog: GameEvent[], currentBatch: GameEvent[] = []) {
+function setStore(eventLog: GameEvent[], currentBatch: GameEvent[] = [], opts: ViewOpts = {}) {
useGameStore.setState({
- viewState: makeViewState(currentBatch),
+ viewState: makeViewState(currentBatch, opts),
eventLog,
visualTimestamp: 1_000_000,
lastUpdateRealMs: 0,
@@ -57,6 +86,10 @@ function setStore(eventLog: GameEvent[], currentBatch: GameEvent[] = []) {
})
}
+function expandFeed() {
+ fireEvent.click(screen.getByText('EVENTS'))
+}
+
beforeAll(() => {
// jsdom has no Element.scrollTo (used by the feed's auto-scroll effect)
Element.prototype.scrollTo = Element.prototype.scrollTo ?? (() => {})
@@ -64,6 +97,11 @@ beforeAll(() => {
beforeEach(() => {
setStore([])
+ sendCommandMock.mockClear()
+ useUIStore.setState({
+ mapFocus: null,
+ autoPause: { warDeclared: true, ownUnitDestroyed: true, ceasefireOffered: true },
+ })
})
describe('AlertFeed', () => {
@@ -95,7 +133,7 @@ describe('AlertFeed', () => {
// Batch arrived while collapsed → unread badge shows 2
expect(screen.getByText('2')).toBeTruthy()
- fireEvent.click(screen.getByText('EVENTS'))
+ expandFeed()
expect(screen.getByText('Events (2)')).toBeTruthy()
expect(screen.getAllByText(/DESTROYED/)).toHaveLength(1)
@@ -103,7 +141,7 @@ describe('AlertFeed', () => {
fireEvent.click(screen.getByText('▲'))
expect(screen.queryByText('2')).toBeNull()
- fireEvent.click(screen.getByText('EVENTS'))
+ expandFeed()
expect(screen.getByText('Events (2)')).toBeTruthy()
expect(screen.getAllByText(/DESTROYED/)).toHaveLength(1)
})
@@ -114,4 +152,152 @@ describe('AlertFeed', () => {
render( )
expect(screen.getByText(/SUPPLY CUT: BANDAR SUPPLY/)).toBeTruthy()
})
+
+ it('renders lane events with the lane display name, not the underscored id', () => {
+ const laneEvent: GameEvent = {
+ type: 'SHIPPING_LANE_STATUS_CHANGE', laneId: 'bab_el_mandeb',
+ newStatus: 'reduced', suppressionFactor: 0.4, tick: 5,
+ }
+ setStore([laneEvent])
+ render( )
+ expect(screen.getByText(/BAB EL-MANDEB: REDUCED/)).toBeTruthy()
+ expect(screen.queryByText(/EL_MANDEB/)).toBeNull()
+ })
+})
+
+describe('AlertFeed click-to-zoom', () => {
+ it('clicking an event with a position sets ui-store mapFocus', () => {
+ setStore([intercept])
+ render( )
+ expandFeed()
+
+ fireEvent.click(screen.getByText(/INTERCEPT by CVN-72 Abraham Lincoln/))
+ expect(useUIStore.getState().mapFocus).toMatchObject({ lng: 55, lat: 25, nonce: 1 })
+ })
+
+ it('resolves impact position from the target unit in viewState', () => {
+ setStore([impact])
+ render( )
+ expandFeed()
+
+ fireEvent.click(screen.getByText(/IMPACT on CVN-72 Abraham Lincoln/))
+ expect(useUIStore.getState().mapFocus).toMatchObject({ lng: 55, lat: 25 })
+ })
+
+ it('resolves lane events to the lane midpoint', () => {
+ const laneEvent: GameEvent = {
+ type: 'SHIPPING_LANE_STATUS_CHANGE', laneId: 'hormuz',
+ newStatus: 'blocked', suppressionFactor: 1, tick: 5,
+ }
+ setStore([laneEvent])
+ render( )
+ expandFeed()
+
+ fireEvent.click(screen.getByText(/STRAIT OF HORMUZ: BLOCKED/))
+ expect(useUIStore.getState().mapFocus).toMatchObject({ lng: 56.3, lat: 26.5 })
+ })
+
+ it('refocusing bumps the nonce so the camera re-flies', () => {
+ setStore([intercept])
+ render( )
+ expandFeed()
+
+ const row = screen.getByText(/INTERCEPT by CVN-72 Abraham Lincoln/)
+ fireEvent.click(row)
+ fireEvent.click(row)
+ expect(useUIStore.getState().mapFocus?.nonce).toBe(2)
+ })
+
+ it('leaves rows without a resolvable position non-clickable', () => {
+ const oil: GameEvent = { type: 'OIL_PRICE_CHANGE', newPrice: 120, oldPrice: 80, tick: 6 }
+ const orphanDestroyed: GameEvent = { type: 'UNIT_DESTROYED', unitId: 'gone_unit', tick: 7 }
+ setStore([oil, orphanDestroyed])
+ render( )
+ expandFeed()
+
+ fireEvent.click(screen.getByText(/OIL \$120/))
+ fireEvent.click(screen.getByText(/DESTROYED gone_unit/))
+ expect(useUIStore.getState().mapFocus).toBeNull()
+ })
+})
+
+describe('AlertFeed auto-pause', () => {
+ it('sends SET_SPEED 0 when an own unit is destroyed and the trigger is enabled', () => {
+ render( )
+ act(() => {
+ setStore([destroyed], [destroyed], { speed: 360 })
+ })
+ expect(sendCommandMock).toHaveBeenCalledWith({ type: 'SET_SPEED', speed: 0 })
+ })
+
+ it('does not pause for enemy unit losses', () => {
+ const enemyDown: GameEvent = { type: 'UNIT_DESTROYED', unitId: 'irin_jamaran', tick: 1 }
+ render( )
+ act(() => {
+ setStore([enemyDown], [enemyDown], { speed: 360 })
+ })
+ expect(sendCommandMock).not.toHaveBeenCalled()
+ })
+
+ it('pauses on WAR_DECLARED and CEASEFIRE_OFFERED', () => {
+ const war: GameEvent = { type: 'WAR_DECLARED', attacker: 'iran', defender: 'usa', tick: 1 }
+ render( )
+ act(() => {
+ setStore([war], [war], { speed: 6 })
+ })
+ expect(sendCommandMock).toHaveBeenCalledWith({ type: 'SET_SPEED', speed: 0 })
+
+ sendCommandMock.mockClear()
+ const offer: GameEvent = { type: 'CEASEFIRE_OFFERED', by: 'iran', tick: 2 }
+ act(() => {
+ setStore([war, offer], [offer], { speed: 6 })
+ })
+ expect(sendCommandMock).toHaveBeenCalledWith({ type: 'SET_SPEED', speed: 0 })
+ })
+
+ it('does nothing when the trigger is disabled', () => {
+ useUIStore.setState({
+ autoPause: { warDeclared: false, ownUnitDestroyed: false, ceasefireOffered: false },
+ })
+ const war: GameEvent = { type: 'WAR_DECLARED', attacker: 'iran', defender: 'usa', tick: 1 }
+ render( )
+ act(() => {
+ setStore([war, destroyed], [war, destroyed], { speed: 360 })
+ })
+ expect(sendCommandMock).not.toHaveBeenCalled()
+ })
+
+ it('does nothing when already paused', () => {
+ render( )
+ act(() => {
+ setStore([destroyed], [destroyed], { speed: 0 })
+ })
+ expect(sendCommandMock).not.toHaveBeenCalled()
+ })
+
+ it('does not re-fire for the same batch on unrelated re-renders', () => {
+ render( )
+ act(() => {
+ setStore([destroyed], [destroyed], { speed: 360 })
+ })
+ expect(sendCommandMock).toHaveBeenCalledTimes(1)
+
+ expandFeed()
+ fireEvent.click(screen.getByText('▲'))
+ expect(sendCommandMock).toHaveBeenCalledTimes(1)
+ })
+
+ it('gear popover toggles the persisted triggers', () => {
+ setStore([destroyed])
+ render( )
+ expandFeed()
+
+ fireEvent.click(screen.getByLabelText('Auto-pause settings'))
+ fireEvent.click(screen.getByLabelText('War declared'))
+ expect(useUIStore.getState().autoPause.warDeclared).toBe(false)
+
+ fireEvent.click(screen.getByLabelText('War declared'))
+ expect(useUIStore.getState().autoPause.warDeclared).toBe(true)
+ expect(useUIStore.getState().autoPause.ownUnitDestroyed).toBe(true)
+ })
})
diff --git a/src/components/hud/__tests__/DebriefScreen.test.tsx b/src/components/hud/__tests__/DebriefScreen.test.tsx
new file mode 100644
index 0000000..e676e46
--- /dev/null
+++ b/src/components/hud/__tests__/DebriefScreen.test.tsx
@@ -0,0 +1,175 @@
+import { describe, it, expect, beforeEach, vi } from 'vitest'
+import { render, screen, fireEvent } from '@testing-library/react'
+import DebriefScreen, { formatDuration } from '../DebriefScreen'
+import { useGameStore } from '@/store/game-store'
+import { useMenuStore } from '@/store/menu-store'
+import { useStrikeStore } from '@/store/strike-store'
+import { useIntelStore } from '@/store/intel-store'
+import { useUIStore } from '@/store/ui-store'
+import type { GameViewState, ObjectiveStatus } from '@/types/view'
+import type { GameOverReport, Nation } from '@/types/game'
+
+function makeNation(id: string, name: string): Nation {
+ return {
+ id,
+ name,
+ economy: {
+ gdp_billions: 0,
+ military_budget_billions: 0,
+ military_budget_pct_gdp: 0,
+ oil_revenue_billions: 0,
+ sanctions_impact: 0,
+ war_cost_per_day_millions: 0,
+ reserves_billions: 0,
+ },
+ relations: {},
+ atWar: [],
+ }
+}
+
+const objectives: ObjectiveStatus[] = [
+ {
+ id: 'preserve_carrier',
+ label: 'Preserve the carrier group',
+ progress: 1,
+ status: 'good',
+ detail: 'Carrier group intact',
+ },
+ {
+ id: 'hormuz_open',
+ label: 'Keep Hormuz open',
+ progress: 0.25,
+ status: 'bad',
+ detail: 'Lane open 25% of war time',
+ },
+]
+
+const victoryReport: GameOverReport = {
+ outcome: 'victory',
+ loser: 'iran',
+ endTick: 200_000,
+ stats: {
+ durationTicks: 187_200,
+ unitsLost: { usa: 4, iran: 11 },
+ missilesFired: { usa: 84, iran: 142 },
+ missilesIntercepted: { usa: 51, iran: 12 },
+ oilPeak: 131,
+ hormuzReducedTicks: 36_000,
+ hormuzBlockedTicks: 7_200,
+ },
+}
+
+function makeViewState(over: Partial): GameViewState {
+ return {
+ playerNation: 'usa',
+ initialized: true,
+ time: { tick: 200_000, timestamp: 1_000_000, speed: 1, tickIntervalMs: 100 },
+ nations: [makeNation('usa', 'USA'), makeNation('iran', 'Iran')],
+ units: [],
+ missiles: [],
+ supplyLines: [],
+ shippingLanes: [],
+ events: [],
+ pendingEventCount: 0,
+ satelliteDetectedUnitIds: [],
+ warSupport: { usa: 64, iran: 0 },
+ gameOver: victoryReport,
+ objectives,
+ ...over,
+ }
+}
+
+beforeEach(() => {
+ useGameStore.setState({ viewState: makeViewState({}), eventLog: [] })
+ useMenuStore.setState({ screen: 'playing' })
+ useStrikeStore.getState().reset()
+ useIntelStore.getState().reset()
+ useUIStore.getState().clearSelection()
+})
+
+describe('formatDuration', () => {
+ it('formats days, hours and minutes by magnitude', () => {
+ expect(formatDuration(187_200)).toBe('2d 4h')
+ expect(formatDuration(7_200)).toBe('2h 0m')
+ expect(formatDuration(540)).toBe('9m')
+ expect(formatDuration(0)).toBe('0m')
+ })
+})
+
+describe('DebriefScreen', () => {
+ it('renders nothing when the war has not been decided', () => {
+ useGameStore.setState({ viewState: makeViewState({ gameOver: null }) })
+ const { container } = render( {}} />)
+ expect(container.firstChild).toBeNull()
+ })
+
+ it('renders the victory headline with capitulation verdict and stats table', () => {
+ render( {}} />)
+
+ expect(screen.getByText('VICTORY')).toBeTruthy()
+ expect(screen.getByText('IRAN CAPITULATED')).toBeTruthy()
+
+ expect(screen.getByText('4')).toBeTruthy()
+ expect(screen.getByText('11')).toBeTruthy()
+ expect(screen.getByText('84')).toBeTruthy()
+ expect(screen.getByText('142')).toBeTruthy()
+ expect(screen.getByText('51')).toBeTruthy()
+ expect(screen.getByText('12')).toBeTruthy()
+
+ expect(screen.getByText('2d 4h')).toBeTruthy()
+ expect(screen.getByText('$131/bbl')).toBeTruthy()
+ expect(screen.getByText('2h 0m')).toBeTruthy()
+ expect(screen.getByText('10h 0m')).toBeTruthy()
+ })
+
+ it('shows both nations final war support', () => {
+ render( {}} />)
+ expect(screen.getByText('64%')).toBeTruthy()
+ expect(screen.getByText('0%')).toBeTruthy()
+ })
+
+ it('frames a ceasefire as a scored draw naming who held the upper hand', () => {
+ useGameStore.setState({
+ viewState: makeViewState({
+ gameOver: { ...victoryReport, outcome: 'ceasefire', loser: undefined },
+ warSupport: { usa: 62, iran: 38 },
+ }),
+ })
+ render( {}} />)
+
+ expect(screen.getByText('CEASEFIRE')).toBeTruthy()
+ expect(screen.getByText('USA HELD THE UPPER HAND')).toBeTruthy()
+ expect(screen.getByText('62%')).toBeTruthy()
+ expect(screen.getByText('38%')).toBeTruthy()
+ })
+
+ it('lists the final objectives', () => {
+ render( {}} />)
+ expect(screen.getByText('Preserve the carrier group')).toBeTruthy()
+ expect(screen.getByText('Keep Hormuz open')).toBeTruthy()
+ expect(screen.getByText('Lane open 25% of war time')).toBeTruthy()
+ })
+
+ it('CONTINUE OBSERVING dismisses without touching the menu', () => {
+ const onDismiss = vi.fn()
+ render( )
+ fireEvent.click(screen.getByText('CONTINUE OBSERVING'))
+ expect(onDismiss).toHaveBeenCalledTimes(1)
+ expect(useMenuStore.getState().screen).toBe('playing')
+ })
+
+ it('MAIN MENU returns to the start screen and resets client stores', () => {
+ useStrikeStore.getState().openStrike('plan')
+ useUIStore.getState().selectUnit('ddg_milius')
+ useUIStore.getState().setLeftPanel('orbat')
+
+ render( {}} />)
+ fireEvent.click(screen.getByText('MAIN MENU'))
+
+ expect(useMenuStore.getState().screen).toBe('start')
+ expect(useStrikeStore.getState().open).toBe(false)
+ expect(useIntelStore.getState().estimatedUnits).toEqual([])
+ expect(useUIStore.getState().selectedUnitId).toBeNull()
+ expect(useUIStore.getState().leftPanel).toBeNull()
+ })
+})
diff --git a/src/components/hud/__tests__/TopBar.test.tsx b/src/components/hud/__tests__/TopBar.test.tsx
new file mode 100644
index 0000000..a53f286
--- /dev/null
+++ b/src/components/hud/__tests__/TopBar.test.tsx
@@ -0,0 +1,170 @@
+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 { sendCommand } from '@/store/bridge'
+import type { GameViewState } from '@/types/view'
+import type { GameEvent, Nation } from '@/types/game'
+
+vi.mock('@/store/bridge', () => ({
+ sendCommand: vi.fn(),
+ getFullState: vi.fn(),
+ loadState: vi.fn(),
+}))
+
+function makeNation(id: string, name: string, atWar: string[]): Nation {
+ return {
+ id,
+ name,
+ economy: {
+ gdp_billions: 0,
+ military_budget_billions: 0,
+ military_budget_pct_gdp: 0,
+ oil_revenue_billions: 0,
+ sanctions_impact: 0,
+ war_cost_per_day_millions: 0,
+ reserves_billions: 0,
+ },
+ relations: {},
+ atWar,
+ }
+}
+
+function makeViewState(over: Partial & { atWar?: boolean }): GameViewState {
+ const { atWar = false, ...rest } = over
+ return {
+ playerNation: 'usa',
+ initialized: true,
+ time: { tick: 100, timestamp: 1_000_000, speed: 1, tickIntervalMs: 100 },
+ nations: [
+ makeNation('usa', 'USA', atWar ? ['iran'] : []),
+ makeNation('iran', 'Iran', atWar ? ['usa'] : []),
+ ],
+ units: [],
+ missiles: [],
+ supplyLines: [],
+ shippingLanes: [],
+ events: [],
+ pendingEventCount: 0,
+ satelliteDetectedUnitIds: [],
+ warSupport: { usa: 72, iran: 41 },
+ gameOver: null,
+ objectives: [],
+ ...rest,
+ }
+}
+
+function setStore(viewState: GameViewState, eventLog: GameEvent[] = []) {
+ useGameStore.setState({ viewState, eventLog })
+}
+
+beforeEach(() => {
+ vi.mocked(sendCommand).mockClear()
+ setStore(makeViewState({}))
+})
+
+describe('TopBar war controls', () => {
+ it('shows DECLARE WAR at peace and no ceasefire or war-support UI', () => {
+ render( )
+ expect(screen.getByText('DECLARE WAR')).toBeTruthy()
+ expect(screen.queryByText('OFFER CEASEFIRE')).toBeNull()
+ expect(screen.queryByText('72%')).toBeNull()
+ })
+
+ it('swaps DECLARE WAR for OFFER CEASEFIRE at war', () => {
+ setStore(makeViewState({ atWar: true }))
+ render( )
+ expect(screen.queryByText('DECLARE WAR')).toBeNull()
+ expect(screen.getByText('OFFER CEASEFIRE')).toBeTruthy()
+ })
+
+ it('shows both war-support bars with numeric % at war', () => {
+ setStore(makeViewState({ atWar: true }))
+ const { container } = render( )
+ expect(container.querySelector('[title="War support"]')).toBeTruthy()
+ expect(screen.getByText('72%')).toBeTruthy()
+ expect(screen.getByText('41%')).toBeTruthy()
+ })
+
+ it('sends OFFER_CEASEFIRE only after the two-step confirm', () => {
+ setStore(makeViewState({ atWar: true }))
+ render( )
+
+ fireEvent.click(screen.getByText('OFFER CEASEFIRE'))
+ expect(sendCommand).not.toHaveBeenCalled()
+
+ fireEvent.click(screen.getByText('CONFIRM OFFER'))
+ expect(sendCommand).toHaveBeenCalledWith({ type: 'OFFER_CEASEFIRE', target: 'iran' })
+ })
+
+ it('shows ACCEPT CEASEFIRE after an enemy offer and sends CEASE_FIRE on click', () => {
+ setStore(makeViewState({ atWar: true }), [
+ { type: 'CEASEFIRE_OFFERED', by: 'iran', tick: 50 },
+ ])
+ render( )
+
+ expect(screen.queryByText('OFFER CEASEFIRE')).toBeNull()
+ fireEvent.click(screen.getByText('ACCEPT CEASEFIRE'))
+ expect(sendCommand).toHaveBeenCalledWith({ type: 'CEASE_FIRE', target: 'iran' })
+ })
+
+ it('clears the enemy offer once the war has ended', () => {
+ setStore(makeViewState({ atWar: true }), [
+ { type: 'CEASEFIRE_OFFERED', by: 'iran', tick: 50 },
+ { type: 'WAR_ENDED', outcome: 'ceasefire', tick: 60 },
+ ])
+ render( )
+ expect(screen.queryByText('ACCEPT CEASEFIRE')).toBeNull()
+ expect(screen.getByText('OFFER CEASEFIRE')).toBeTruthy()
+ })
+
+ it('ignores ceasefire offers made by the player', () => {
+ setStore(makeViewState({ atWar: true }), [
+ { type: 'CEASEFIRE_OFFERED', by: 'usa', tick: 50 },
+ ])
+ render( )
+ expect(screen.queryByText('ACCEPT CEASEFIRE')).toBeNull()
+ })
+
+ it('shows the objectives chip at war and opens the panel rows', () => {
+ setStore(makeViewState({
+ atWar: true,
+ objectives: [
+ { id: 'hormuz_open', label: 'Keep Hormuz open', progress: 0.8, status: 'good', detail: 'Lane open 80% of war time' },
+ ],
+ }))
+ render( )
+
+ fireEvent.click(screen.getByText(/OBJECTIVES/))
+ expect(screen.getByText('Keep Hormuz open')).toBeTruthy()
+ expect(screen.getByText('Lane open 80% of war time')).toBeTruthy()
+ })
+
+ it('hides the objectives chip at peace', () => {
+ setStore(makeViewState({
+ objectives: [
+ { id: 'hormuz_open', label: 'Keep Hormuz open', progress: 0.8, status: 'good', detail: 'Lane open 80% of war time' },
+ ],
+ }))
+ render( )
+ expect(screen.queryByText(/OBJECTIVES/)).toBeNull()
+ })
+
+ it('sends RESIGN from the overflow menu only after the two-step confirm', () => {
+ setStore(makeViewState({ atWar: true }))
+ render( )
+
+ fireEvent.click(screen.getByText('···'))
+ fireEvent.click(screen.getByText('RESIGN'))
+ expect(sendCommand).not.toHaveBeenCalled()
+
+ fireEvent.click(screen.getByText('CONFIRM RESIGN'))
+ expect(sendCommand).toHaveBeenCalledWith({ type: 'RESIGN' })
+ })
+
+ it('does not offer RESIGN at peace', () => {
+ render( )
+ fireEvent.click(screen.getByText('···'))
+ expect(screen.queryByText('RESIGN')).toBeNull()
+ })
+})
diff --git a/src/components/map/GameMap.tsx b/src/components/map/GameMap.tsx
index 93644d9..da96afc 100644
--- a/src/components/map/GameMap.tsx
+++ b/src/components/map/GameMap.tsx
@@ -73,6 +73,17 @@ export default function GameMap() {
const rngFilter = useUIStore((s) => s.rngFilter)
const showElevation = useUIStore((s) => s.showElevation)
const mapMode = useUIStore((s) => s.mapMode)
+ const mapFocus = useUIStore((s) => s.mapFocus)
+
+ // Fly-to requests (AlertFeed click-to-zoom) — visual only, keyed by nonce
+ useEffect(() => {
+ if (!mapFocus) return
+ mapRef.current?.flyTo({
+ center: [mapFocus.lng, mapFocus.lat],
+ duration: 1200,
+ ...(mapFocus.zoom !== undefined ? { zoom: mapFocus.zoom } : {}),
+ })
+ }, [mapFocus])
const mapStyle = useMemo(() => getMapStyle(mapMode), [mapMode])
diff --git a/src/components/map/InfoTooltip.tsx b/src/components/map/InfoTooltip.tsx
index f8da65d..b6abb5c 100644
--- a/src/components/map/InfoTooltip.tsx
+++ b/src/components/map/InfoTooltip.tsx
@@ -54,16 +54,23 @@ function MissileTooltipView({ missile, x, y }: { missile: Missile; x: number; y:
}
function UnitTooltip({ unit, x, y }: { unit: ViewUnit; x: number; y: number }) {
+ const detected = unit.visibility === 'detected'
+ const identified = unit.visibility === 'identified'
return (
{unit.name}
+ {unit.stale &&
TRACK LOST — LAST KNOWN POSITION
}
|
-
|
-
|
-
|
- {unit.speed_kts > 0 &&
|
}
- {unit.weapons.length > 0 && (
+ {!detected && (
+ <>
+
|
+
|
+ {identified &&
|
}
+ {unit.speed_kts > 0 &&
|
}
+ >
+ )}
+ {identified && unit.weapons.length > 0 && (
<>
{unit.weapons.map(w => {
@@ -78,6 +85,12 @@ function UnitTooltip({ unit, x, y }: { unit: ViewUnit; x: number; y: number }) {
})}
>
)}
+ {unit.visibility === 'tracked' && (
+ <>
+
+
NO LOADOUT DATA
+ >
+ )}
)
}
@@ -119,3 +132,10 @@ const headerStyle: React.CSSProperties = {
paddingBottom: 3,
borderBottom: '1px solid var(--border-default)',
}
+
+const staleBannerStyle: React.CSSProperties = {
+ color: 'var(--status-damaged)',
+ fontWeight: 600,
+ letterSpacing: '0.04em',
+ padding: '1px 0 3px',
+}
diff --git a/src/components/map/__tests__/ContextMenu.test.tsx b/src/components/map/__tests__/ContextMenu.test.tsx
index 4432666..7f7d017 100644
--- a/src/components/map/__tests__/ContextMenu.test.tsx
+++ b/src/components/map/__tests__/ContextMenu.test.tsx
@@ -23,6 +23,8 @@ function makeUnit(over: Partial & Pick):
roe: 'hold_fire',
waypoints: [],
subordinateIds: [],
+ visibility: 'identified',
+ stale: false,
...over,
} as ViewUnit
}
@@ -40,6 +42,9 @@ function makeViewState(units: ViewUnit[]): GameViewState {
events: [],
pendingEventCount: 0,
satelliteDetectedUnitIds: [],
+ warSupport: {},
+ gameOver: null,
+ objectives: [],
}
}
diff --git a/src/components/map/__tests__/InfoTooltip.test.tsx b/src/components/map/__tests__/InfoTooltip.test.tsx
new file mode 100644
index 0000000..5090f0b
--- /dev/null
+++ b/src/components/map/__tests__/InfoTooltip.test.tsx
@@ -0,0 +1,86 @@
+import { describe, it, expect } from 'vitest'
+import { render, screen } from '@testing-library/react'
+import InfoTooltip from '../InfoTooltip'
+import { useGameStore } from '@/store/game-store'
+import { useUIStore } from '@/store/ui-store'
+import type { GameViewState, ViewUnit } from '@/types/view'
+
+function makeUnit(overrides: Partial & Pick): ViewUnit {
+ return {
+ name: overrides.id,
+ nation: 'iran',
+ category: 'ship',
+ position: { lat: 26, lng: 56 },
+ heading: 0,
+ speed_kts: 0,
+ status: 'ready',
+ health: 100,
+ maxHealth: 100,
+ logistics: 100,
+ supplyStocks: [],
+ weapons: [],
+ pointDefense: [],
+ sensors: [],
+ roe: 'weapons_tight',
+ waypoints: [],
+ subordinateIds: [],
+ visibility: 'identified',
+ stale: false,
+ ...overrides,
+ } as ViewUnit
+}
+
+function setup(unit: ViewUnit) {
+ const viewState = {
+ playerNation: 'usa',
+ initialized: true,
+ time: { tick: 0, timestamp: 0, speed: 0, tickIntervalMs: 100 },
+ nations: [],
+ units: [unit],
+ missiles: [],
+ supplyLines: [],
+ shippingLanes: [],
+ events: [],
+ pendingEventCount: 0,
+ satelliteDetectedUnitIds: [],
+ warSupport: {},
+ gameOver: null,
+ objectives: [],
+ } as GameViewState
+ useGameStore.setState({ viewState })
+ useUIStore.setState({ hoveredUnitId: unit.id })
+ return render( )
+}
+
+describe('InfoTooltip fog of war', () => {
+ it('shows only identity and staleness for stale detected contacts', () => {
+ setup(makeUnit({ id: 'c1', name: 'Surface contact', visibility: 'detected', stale: true }))
+ expect(screen.getByText('Surface contact')).toBeTruthy()
+ expect(screen.getByText(/TRACK LOST/)).toBeTruthy()
+ expect(screen.getByText('Type')).toBeTruthy()
+ expect(screen.queryByText('Health')).toBeNull()
+ expect(screen.queryByText('Status')).toBeNull()
+ expect(screen.queryByText('ROE')).toBeNull()
+ })
+
+ it('shows condition but NO LOADOUT DATA and no ROE for tracked contacts', () => {
+ setup(makeUnit({ id: 'c2', name: 'IRIS Sahand', visibility: 'tracked', health: 70, status: 'damaged' }))
+ expect(screen.getByText('Health')).toBeTruthy()
+ expect(screen.getByText('70%')).toBeTruthy()
+ expect(screen.getByText('NO LOADOUT DATA')).toBeTruthy()
+ expect(screen.queryByText('ROE')).toBeNull()
+ expect(screen.queryByText(/TRACK LOST/)).toBeNull()
+ })
+
+ it('shows ROE and weapons for identified units', () => {
+ setup(makeUnit({
+ id: 'c3',
+ name: 'IRIS Jamaran',
+ weapons: [{ weaponId: 'mystery_missile', count: 4, maxCount: 8, reloadTimeSec: 60 }],
+ }))
+ expect(screen.getByText('ROE')).toBeTruthy()
+ expect(screen.getByText('mystery_missile')).toBeTruthy()
+ expect(screen.getByText('4/8')).toBeTruthy()
+ expect(screen.queryByText('NO LOADOUT DATA')).toBeNull()
+ })
+})
diff --git a/src/components/map/layers/UnitLayer.ts b/src/components/map/layers/UnitLayer.ts
index 2624bd4..83fb8fd 100644
--- a/src/components/map/layers/UnitLayer.ts
+++ b/src/components/map/layers/UnitLayer.ts
@@ -26,6 +26,17 @@ const STATUS_ALPHA: Record = {
reloading: 200,
}
+const VISIBILITY_RANK: Record = {
+ detected: 0,
+ tracked: 1,
+ identified: 2,
+}
+
+const TRACKED_ALPHA_FACTOR = 0.8
+const DETECTED_ALPHA_FACTOR = 0.75
+const DETECTED_STALE_ALPHA_FACTOR = 0.55
+const DESATURATION = 0.65
+
/** Flattened render item — either a solo unit or a cluster rendered as its primary */
interface RenderUnit {
id: string
@@ -38,11 +49,19 @@ interface RenderUnit {
count: number
health: number
heading: number
+ visibility: string
+ stale: boolean
}
function toRenderUnit(item: ViewUnit | UnitCluster): RenderUnit {
if (isCluster(item)) {
const avgHealth = Math.round(item.units.reduce((s, u) => s + u.health, 0) / item.units.length)
+ let visibility = 'detected'
+ let stale = true
+ for (const u of item.units) {
+ if ((VISIBILITY_RANK[u.visibility] ?? 0) > (VISIBILITY_RANK[visibility] ?? 0)) visibility = u.visibility
+ if (!u.stale) stale = false
+ }
return {
id: item.id,
position: item.position,
@@ -54,6 +73,8 @@ function toRenderUnit(item: ViewUnit | UnitCluster): RenderUnit {
count: item.count,
health: avgHealth,
heading: item.primary.heading,
+ visibility,
+ stale,
}
}
return {
@@ -67,9 +88,33 @@ function toRenderUnit(item: ViewUnit | UnitCluster): RenderUnit {
count: 1,
health: item.health,
heading: item.heading,
+ visibility: item.visibility,
+ stale: item.stale,
}
}
+function desaturate([r, g, b]: [number, number, number]): [number, number, number] {
+ const gray = 0.3 * r + 0.59 * g + 0.11 * b
+ return [
+ Math.round(r + (gray - r) * DESATURATION),
+ Math.round(g + (gray - g) * DESATURATION),
+ Math.round(b + (gray - b) * DESATURATION),
+ ]
+}
+
+function fogColor(d: RenderUnit): [number, number, number, number] {
+ const base = NATION_COLORS[d.nation] ?? [200, 200, 200]
+ const alpha = STATUS_ALPHA[d.status] ?? 255
+ if (d.visibility === 'tracked') {
+ return [...base, Math.round(alpha * TRACKED_ALPHA_FACTOR)] as [number, number, number, number]
+ }
+ if (d.visibility === 'detected') {
+ const factor = d.stale ? DETECTED_STALE_ALPHA_FACTOR : DETECTED_ALPHA_FACTOR
+ return [...desaturate(base), Math.round(alpha * factor)] as [number, number, number, number]
+ }
+ return [...base, alpha] as [number, number, number, number]
+}
+
export function createUnitLayer(
units: ViewUnit[],
selectedId: string | null,
@@ -125,9 +170,7 @@ export function createUnitLayer(
if (d.id === targetId) {
return [255, 50, 50, 255] as [number, number, number, number]
}
- const base = NATION_COLORS[d.nation] ?? [200, 200, 200]
- const alpha = STATUS_ALPHA[d.status] ?? 255
- return [...base, alpha] as [number, number, number, number]
+ return fogColor(d)
},
sizeScale: 1,
sizeUnits: 'pixels',
@@ -149,7 +192,7 @@ export function createUnitLayer(
},
updateTriggers: {
getSize: [selectedId, hoveredId, targetId],
- getColor: [targetingMode, targetId, units.map(u => `${u.id}:${u.status}`).join(',')],
+ getColor: [targetingMode, targetId, units.map(u => `${u.id}:${u.status}:${u.visibility}:${u.stale}`).join(',')],
getAngle: [units.map(u => `${u.id}:${u.heading}`).join(',')],
},
})
@@ -160,7 +203,7 @@ export function createUnitLayer(
id: 'unit-labels',
data: showLabels ? renderItems : [],
getPosition: (d) => [d.position.lng, d.position.lat],
- getText: (d) => d.isCluster ? `${d.name} [${d.count}]` : d.name,
+ getText: (d) => d.isCluster ? `${d.name} [${d.visibility === 'detected' ? '?' : d.count}]` : d.name,
getSize: 11,
getColor: (d) => {
const base = NATION_COLORS[d.nation] ?? [200, 200, 200]
@@ -178,13 +221,14 @@ export function createUnitLayer(
pickable: false,
})
- // Count badge for clusters — a bright number above the icon
+ // Count badge for clusters — a bright number above the icon ('?' when the
+ // cluster is detected-only contacts: exact strength is not known)
const clusterItems = renderItems.filter(d => d.isCluster)
const badgeLayer = new TextLayer({
id: 'cluster-badges',
data: clusterItems,
getPosition: (d) => [d.position.lng, d.position.lat],
- getText: (d) => String(d.count),
+ getText: (d) => d.visibility === 'detected' ? '?' : String(d.count),
getSize: 12,
getColor: [255, 255, 255, 240],
getPixelOffset: [16, -16],
@@ -203,6 +247,46 @@ export function createUnitLayer(
backgroundPadding: [3, 1],
})
+ // Detected contacts: hollow ring + '?' badge over a desaturated icon stands in
+ // for a dedicated contact glyph (atlas is a prebuilt PNG)
+ const detectedItems = renderItems.filter(d => d.visibility === 'detected')
+ const contactRingLayer = new ScatterplotLayer({
+ id: 'contact-rings',
+ data: detectedItems,
+ getPosition: (d) => [d.position.lng, d.position.lat],
+ getRadius: 17,
+ radiusUnits: 'pixels',
+ filled: false,
+ stroked: true,
+ getLineColor: (d) => {
+ const desat = desaturate(NATION_COLORS[d.nation] ?? [200, 200, 200])
+ return [...desat, d.stale ? 110 : 170] as [number, number, number, number]
+ },
+ lineWidthMinPixels: 1.5,
+ pickable: false,
+ })
+
+ const soloContactItems = detectedItems.filter(d => !d.isCluster)
+ const contactBadgeLayer = new TextLayer({
+ id: 'contact-badges',
+ data: soloContactItems,
+ getPosition: (d) => [d.position.lng, d.position.lat],
+ getText: () => '?',
+ getSize: 12,
+ getColor: (d) => [230, 230, 230, d.stale ? 180 : 240] as [number, number, number, number],
+ getPixelOffset: [16, -16],
+ fontFamily: 'JetBrains Mono, Fira Code, monospace',
+ fontWeight: 700,
+ outlineWidth: 3,
+ outlineColor: [13, 17, 23, 255],
+ sizeUnits: 'pixels',
+ billboard: true,
+ pickable: false,
+ background: true,
+ getBackgroundColor: [90, 95, 100, 200],
+ backgroundPadding: [4, 1],
+ })
+
// Invisible pick layer — much larger hit area (24px radius) for easy clicking
const pickLayer = new ScatterplotLayer({
id: 'unit-pick-layer',
@@ -229,10 +313,10 @@ export function createUnitLayer(
})
if (typeof window !== 'undefined' && window.innerWidth < 768) {
- return [pickLayer, iconLayer, badgeLayer]
+ return [pickLayer, contactRingLayer, iconLayer, badgeLayer, contactBadgeLayer]
}
- return [pickLayer, iconLayer, labelLayer, badgeLayer]
+ return [pickLayer, contactRingLayer, iconLayer, labelLayer, badgeLayer, contactBadgeLayer]
}
/** Highlight ring around units recently spotted by satellite passes */
diff --git a/src/components/map/layers/__tests__/ImpactLayer.test.ts b/src/components/map/layers/__tests__/ImpactLayer.test.ts
index b1d69e4..c11fd48 100644
--- a/src/components/map/layers/__tests__/ImpactLayer.test.ts
+++ b/src/components/map/layers/__tests__/ImpactLayer.test.ts
@@ -22,6 +22,8 @@ const target = {
roe: 'weapons_tight',
waypoints: [],
subordinateIds: [],
+ visibility: 'identified',
+ stale: false,
} as ViewUnit
function impactAt(tick: number): GameEvent {
diff --git a/src/components/map/layers/__tests__/UnitLayer.test.ts b/src/components/map/layers/__tests__/UnitLayer.test.ts
index ec97b4b..63f3d25 100644
--- a/src/components/map/layers/__tests__/UnitLayer.test.ts
+++ b/src/components/map/layers/__tests__/UnitLayer.test.ts
@@ -22,6 +22,8 @@ function makeUnit(overrides: Partial): ViewUnit {
roe: 'weapons_tight',
waypoints: [],
subordinateIds: [],
+ visibility: 'identified',
+ stale: false,
...overrides,
}
}
@@ -64,6 +66,81 @@ describe('createUnitLayer minefield handling', () => {
})
})
+type Datum = { id: string; isCluster: boolean }
+type ColorFn = (d: Datum) => [number, number, number, number]
+
+function colorOf(layers: { id: string; props: { data: unknown } }[], id: string) {
+ const layer = layerById(layers, 'unit-layer') as unknown as { props: { data: Datum[]; getColor: ColorFn } }
+ const datum = layer.props.data.find(d => d.id === id)!
+ return layer.props.getColor(datum)
+}
+
+describe('fog of war rendering', () => {
+ const identified = makeUnit({ id: 'id1', visibility: 'identified', position: { lat: 20, lng: 50 } })
+ const tracked = makeUnit({ id: 'tr1', visibility: 'tracked', position: { lat: 28, lng: 58 } })
+ const detected = makeUnit({ id: 'de1', visibility: 'detected', position: { lat: 24, lng: 54 } })
+ const staleDetected = makeUnit({ id: 'de2', visibility: 'detected', stale: true, position: { lat: 22, lng: 52 } })
+ const all = [identified, tracked, detected, staleDetected]
+
+ it('renders alpha buckets per visibility level', () => {
+ const layers = createUnitLayer(all, null, null, null, false, noop, noop, noop, null, 10)
+ expect(colorOf(layers, 'id1')[3]).toBe(255)
+ expect(colorOf(layers, 'tr1')[3]).toBe(204)
+ expect(colorOf(layers, 'de1')[3]).toBe(191)
+ expect(colorOf(layers, 'de2')[3]).toBe(140)
+ })
+
+ it('desaturates detected contacts toward gray', () => {
+ const layers = createUnitLayer(all, null, null, null, false, noop, noop, noop, null, 10)
+ const [r, g] = colorOf(layers, 'de1')
+ const [baseR, baseG] = colorOf(layers, 'id1')
+ expect(r).toBeLessThan(baseR)
+ expect(g).toBeGreaterThan(baseG)
+ expect(r - g).toBeLessThan(baseR - baseG)
+ })
+
+ it('marks detected contacts with a ? badge and a contact ring', () => {
+ const layers = createUnitLayer(all, null, null, null, false, noop, noop, noop, null, 10)
+ const badge = layerById(layers, 'contact-badges') as unknown as { props: { data: Datum[]; getText: (d: Datum) => string } }
+ expect(badge.props.data.map(d => d.id).sort()).toEqual(['de1', 'de2'])
+ expect(badge.props.getText(badge.props.data[0])).toBe('?')
+ const ring = layerById(layers, 'contact-rings')
+ expect((ring.props.data as Datum[]).map(d => d.id).sort()).toEqual(['de1', 'de2'])
+ })
+
+ it('shows ? instead of an exact count on detected-only clusters', () => {
+ const d1 = makeUnit({ id: 'cd1', visibility: 'detected', position: { lat: 26, lng: 56 } })
+ const d2 = makeUnit({ id: 'cd2', visibility: 'detected', position: { lat: 26.05, lng: 56.05 } })
+ const layers = createUnitLayer([d1, d2], null, null, null, false, noop, noop, noop, null, 6)
+ const badge = layerById(layers, 'cluster-badges') as unknown as { props: { data: Datum[]; getText: (d: Datum) => string } }
+ expect(badge.props.data).toHaveLength(1)
+ expect(badge.props.getText(badge.props.data[0])).toBe('?')
+ const labels = layerById(layers, 'unit-labels') as unknown as { props: { data: Datum[]; getText: (d: Datum) => string } }
+ const clusterLabel = labels.props.data.find(d => d.isCluster)!
+ expect(labels.props.getText(clusterLabel)).toContain('[?]')
+ })
+
+ it('keeps exact counts on clusters containing tracked units', () => {
+ const d1 = makeUnit({ id: 'cd1', visibility: 'detected', position: { lat: 26, lng: 56 } })
+ const t1 = makeUnit({ id: 'ct1', visibility: 'tracked', position: { lat: 26.05, lng: 56.05 } })
+ const layers = createUnitLayer([d1, t1], null, null, null, false, noop, noop, noop, null, 6)
+ const badge = layerById(layers, 'cluster-badges') as unknown as { props: { data: Datum[]; getText: (d: Datum) => string } }
+ expect(badge.props.data).toHaveLength(1)
+ expect(badge.props.getText(badge.props.data[0])).toBe('2')
+ })
+
+ it('keeps detected contacts targetable in targeting mode', () => {
+ let targeted: string | null = null
+ const layers = createUnitLayer(
+ [detected], null, null, null, true, noop, noop, (id) => { targeted = id }, 'usa', 10,
+ )
+ expect(colorOf(layers, 'de1')).toEqual([255, 80, 80, 255])
+ const icon = layerById(layers, 'unit-layer') as unknown as { props: { data: Datum[]; onClick: (info: { object: Datum }) => void } }
+ icon.props.onClick({ object: icon.props.data[0] })
+ expect(targeted).toBe('de1')
+ })
+})
+
describe('createSatelliteDetectionLayer', () => {
it('renders rings only for detected, non-destroyed units', () => {
const a = makeUnit({ id: 'a' })
diff --git a/src/components/menu/StartScreen.tsx b/src/components/menu/StartScreen.tsx
index 184e669..4d3fb04 100644
--- a/src/components/menu/StartScreen.tsx
+++ b/src/components/menu/StartScreen.tsx
@@ -198,8 +198,8 @@ export default function StartScreen() {
-
REALPOLITIK
-
GEOPOLITICAL STRATEGY SIMULATOR
+
ASHFALL COMMAND
+
MODERN GRAND STRATEGY WARGAME
{/* Mode selection */}
diff --git a/src/components/panels/StatsPanel.tsx b/src/components/panels/StatsPanel.tsx
index 1a230f6..c106595 100644
--- a/src/components/panels/StatsPanel.tsx
+++ b/src/components/panels/StatsPanel.tsx
@@ -82,7 +82,7 @@ export function computeNationStats(
}
}
-function ModernNationBlock({ nationId, nationLabel, units, events }: { nationId: NationId; nationLabel: string; units: ViewUnit[]; events: GameEvent[] }) {
+function ModernNationBlock({ nationId, nationLabel, units, events, isPlayer }: { nationId: NationId; nationLabel: string; units: ViewUnit[]; events: GameEvent[]; isPlayer: boolean }) {
const stats = computeNationStats(units, events, nationId)
const activeUnits = stats.total - stats.destroyed
const color = getNationColor(nationId)
@@ -103,26 +103,40 @@ function ModernNationBlock({ nationId, nationLabel, units, events }: { nationId:
-
+
- {stats.offensiveMissilesMax > 0 && (
-
- )}
- {stats.samInterceptorsMax > 0 && (
-
+ {isPlayer ? (
+ <>
+ {stats.offensiveMissilesMax > 0 && (
+
+ )}
+ {stats.samInterceptorsMax > 0 && (
+
+ )}
+ >
+ ) : (
+ // Enemy inventories are not knowable under fog — known contacts only
+
+ EST. ORBAT: {activeUnits} {activeUnits === 1 ? 'contact' : 'contacts'}
+
)}
@@ -158,6 +172,7 @@ function ExchangeRatio({ incoming, intercepted }: { incoming: number; intercepte
export default function StatsPanel() {
const units = useGameStore(s => s.viewState.units)
const nations = useGameStore(s => s.viewState.nations)
+ const playerNation = useGameStore(s => s.viewState.playerNation)
const eventLog = useGameStore(s => s.eventLog)
return (
@@ -179,6 +194,7 @@ export default function StatsPanel() {
nationLabel={nation.name}
units={units}
events={eventLog}
+ isPlayer={nation.id === playerNation}
/>
))}
diff --git a/src/components/panels/UnitInfoPanel.tsx b/src/components/panels/UnitInfoPanel.tsx
index db42391..f4f64b9 100644
--- a/src/components/panels/UnitInfoPanel.tsx
+++ b/src/components/panels/UnitInfoPanel.tsx
@@ -44,6 +44,8 @@ export default function UnitInfoPanel({ units }: UnitInfoPanelProps) {
if (!unit) return null
const isFriendly = unit.nation === playerNation
+ const detected = unit.visibility === 'detected'
+ const identified = unit.visibility === 'identified'
// Same gate shipping.ts uses for drone interdiction
const isDroneCapable = unit.weapons.some((w) => w.weaponId.includes('shahed'))
const droneMission: DroneMission = unit.droneMission ?? 'military'
@@ -54,33 +56,47 @@ export default function UnitInfoPanel({ units }: UnitInfoPanelProps) {
onClose={() => selectUnit(null)}
style={{ position: 'absolute', top: 60, right: 12 }}
>
- {/* Status + Health */}
+ {/* Status + Health — scrubbed to placeholders below 'tracked', so don't show them */}
- {unit.status}
+ {detected ? 'contact' : unit.status}
{unit.nation.toUpperCase()} / {unit.category.replace(/_/g, ' ')}
-
+ {!detected && (
+
+ )}
+ {unit.stale && (
+
+ TRACK LOST — LAST KNOWN POSITION
+
+ )}
+
{/* Position */}
|
{unit.speed_kts > 0 &&
|
}
-
|
+ {identified &&
|
}
{unit.mine_count != null &&
|
}
{unit.radius_km != null &&
|
}
{/* Weapons */}
- {unit.weapons.length > 0 && (
+ {identified && unit.weapons.length > 0 && (
)}
+ {unit.visibility === 'tracked' && (
+
+
+ Armament
+
+
+ NO LOADOUT DATA
+
+
+ )}
{/* COMMAND section — only for friendly units */}
{isFriendly && (
diff --git a/src/components/panels/__tests__/StatsPanel.test.tsx b/src/components/panels/__tests__/StatsPanel.test.tsx
index c1b6c90..9df6817 100644
--- a/src/components/panels/__tests__/StatsPanel.test.tsx
+++ b/src/components/panels/__tests__/StatsPanel.test.tsx
@@ -1,9 +1,11 @@
import { describe, it, expect } from 'vitest'
-import { computeNationStats } from '../StatsPanel'
-import type { GameEvent } from '@/types/game'
-import type { ViewUnit } from '@/types/view'
+import { render, screen } from '@testing-library/react'
+import StatsPanel, { computeNationStats } from '../StatsPanel'
+import { useGameStore } from '@/store/game-store'
+import type { GameEvent, Nation } from '@/types/game'
+import type { GameViewState, ViewUnit } from '@/types/view'
-function makeUnit(id: string, nation: string): ViewUnit {
+function makeUnit(id: string, nation: string, overrides: Partial
= {}): ViewUnit {
return {
id,
name: id,
@@ -23,6 +25,9 @@ function makeUnit(id: string, nation: string): ViewUnit {
roe: 'weapons_free',
waypoints: [],
subordinateIds: [],
+ visibility: 'identified',
+ stale: false,
+ ...overrides,
} as ViewUnit
}
@@ -69,3 +74,64 @@ describe('computeNationStats interception rate', () => {
expect(iran.missilesIntercepted).toBe(0)
})
})
+
+function setStore(units: ViewUnit[], eventLog: GameEvent[] = []) {
+ const viewState: GameViewState = {
+ playerNation: 'usa',
+ initialized: true,
+ time: { tick: 0, timestamp: 0, speed: 0, tickIntervalMs: 100 },
+ nations: [
+ { id: 'usa', name: 'United States' } as Nation,
+ { id: 'iran', name: 'Iran' } as Nation,
+ ],
+ units,
+ missiles: [],
+ supplyLines: [],
+ shippingLanes: [],
+ events: [],
+ pendingEventCount: 0,
+ satelliteDetectedUnitIds: [],
+ warSupport: {},
+ gameOver: null,
+ objectives: [],
+ }
+ useGameStore.setState({ viewState, eventLog, visualTimestamp: 0, lastUpdateRealMs: 0, visualRate: 0 })
+}
+
+describe('StatsPanel under fog of war', () => {
+ const offensive = { weaponId: 'tomahawk', count: 5, maxCount: 10, reloadTimeSec: 60 }
+ const sam = { weaponId: 'pac3_mse', count: 8, maxCount: 16, reloadTimeSec: 60 }
+
+ it('labels the enemy unit count as Contacts, own side stays Active', () => {
+ setStore([
+ makeUnit('usa_base', 'usa'),
+ makeUnit('iran_c1', 'iran', { visibility: 'detected', stale: true }),
+ makeUnit('iran_c2', 'iran', { visibility: 'tracked' }),
+ ])
+ render( )
+ expect(screen.getByText('Active')).toBeTruthy()
+ expect(screen.getByText('Contacts')).toBeTruthy()
+ expect(screen.getByText(/EST\. ORBAT: 2 contacts/)).toBeTruthy()
+ })
+
+ it('replaces enemy inventory bars with the contact summary even when loadout data exists', () => {
+ setStore([
+ makeUnit('usa_base', 'usa', { weapons: [offensive, sam] }),
+ makeUnit('iran_id', 'iran', { weapons: [offensive, sam] }),
+ ])
+ render( )
+ expect(screen.getAllByText('Offensive Missiles')).toHaveLength(1)
+ expect(screen.getAllByText('SAM Interceptors')).toHaveLength(1)
+ expect(screen.getByText(/EST\. ORBAT: 1 contact/)).toBeTruthy()
+ })
+
+ it('keeps observed fired / shot-down counters for both sides', () => {
+ setStore(
+ [makeUnit('usa_base', 'usa'), makeUnit('iran_tel', 'iran', { visibility: 'tracked' })],
+ [launched('iran_tel', 'usa_base', 1)],
+ )
+ render( )
+ expect(screen.getAllByText('Fired (offensive)')).toHaveLength(2)
+ expect(screen.getAllByText('Shot down (AD)')).toHaveLength(2)
+ })
+})
diff --git a/src/components/panels/__tests__/UnitInfoPanel.test.tsx b/src/components/panels/__tests__/UnitInfoPanel.test.tsx
new file mode 100644
index 0000000..322f838
--- /dev/null
+++ b/src/components/panels/__tests__/UnitInfoPanel.test.tsx
@@ -0,0 +1,92 @@
+import { describe, it, expect } from 'vitest'
+import { render, screen } from '@testing-library/react'
+import UnitInfoPanel from '../UnitInfoPanel'
+import { useGameStore } from '@/store/game-store'
+import { useUIStore } from '@/store/ui-store'
+import type { GameViewState, ViewUnit } from '@/types/view'
+
+function makeUnit(overrides: Partial & Pick): ViewUnit {
+ return {
+ name: overrides.id,
+ nation: 'iran',
+ category: 'ship',
+ position: { lat: 26, lng: 56 },
+ heading: 0,
+ speed_kts: 0,
+ status: 'ready',
+ health: 100,
+ maxHealth: 100,
+ logistics: 100,
+ supplyStocks: [],
+ weapons: [],
+ pointDefense: [],
+ sensors: [],
+ roe: 'weapons_tight',
+ waypoints: [],
+ subordinateIds: [],
+ visibility: 'identified',
+ stale: false,
+ ...overrides,
+ } as ViewUnit
+}
+
+function setup(unit: ViewUnit) {
+ const viewState = {
+ playerNation: 'usa',
+ initialized: true,
+ time: { tick: 0, timestamp: 0, speed: 0, tickIntervalMs: 100 },
+ nations: [],
+ units: [unit],
+ missiles: [],
+ supplyLines: [],
+ shippingLanes: [],
+ events: [],
+ pendingEventCount: 0,
+ satelliteDetectedUnitIds: [],
+ warSupport: {},
+ gameOver: null,
+ objectives: [],
+ } as GameViewState
+ useGameStore.setState({ viewState })
+ useUIStore.setState({ selectedUnitId: unit.id, selectedUnitIds: new Set([unit.id]) })
+ return render( )
+}
+
+describe('UnitInfoPanel fog of war', () => {
+ it('shows TRACK LOST banner and hides condition data for stale detected contacts', () => {
+ setup(makeUnit({ id: 'c1', name: 'Surface contact', visibility: 'detected', stale: true }))
+ expect(screen.getByText(/TRACK LOST/)).toBeTruthy()
+ expect(screen.getByText('contact')).toBeTruthy()
+ expect(screen.queryByText('HEALTH')).toBeNull()
+ expect(screen.queryByText('ROE')).toBeNull()
+ expect(screen.queryByText('Armament')).toBeNull()
+ })
+
+ it('shows condition but NO LOADOUT DATA for tracked contacts', () => {
+ setup(makeUnit({
+ id: 'c2',
+ name: 'IRIS Sahand',
+ visibility: 'tracked',
+ status: 'damaged',
+ health: 62,
+ speed_kts: 18,
+ }))
+ expect(screen.getByText('HEALTH')).toBeTruthy()
+ expect(screen.getByText('SPEED')).toBeTruthy()
+ expect(screen.getByText('NO LOADOUT DATA')).toBeTruthy()
+ expect(screen.queryByText(/TRACK LOST/)).toBeNull()
+ expect(screen.queryByText('ROE')).toBeNull()
+ })
+
+ it('shows everything for identified units', () => {
+ setup(makeUnit({
+ id: 'c3',
+ name: 'IRIS Jamaran',
+ weapons: [{ weaponId: 'noor_ashm', count: 4, maxCount: 8, reloadTimeSec: 60 }],
+ }))
+ expect(screen.getByText('HEALTH')).toBeTruthy()
+ expect(screen.getByText('ROE')).toBeTruthy()
+ expect(screen.getByText('Armament')).toBeTruthy()
+ expect(screen.queryByText('NO LOADOUT DATA')).toBeNull()
+ })
+})
diff --git a/src/engine/__tests__/attack-planner.test.ts b/src/engine/__tests__/attack-planner.test.ts
index ad288d4..c808d39 100644
--- a/src/engine/__tests__/attack-planner.test.ts
+++ b/src/engine/__tests__/attack-planner.test.ts
@@ -33,6 +33,8 @@ function toViewUnit(u: Unit): ViewUnit {
radius_km: u.radius_km,
mine_count: u.mine_count,
droneMission: u.droneMission,
+ visibility: 'identified',
+ stale: false,
}
}
diff --git a/src/engine/game-engine.ts b/src/engine/game-engine.ts
index 2d1edae..119e8b6 100644
--- a/src/engine/game-engine.ts
+++ b/src/engine/game-engine.ts
@@ -24,6 +24,8 @@ import { findNavalRoute } from './systems/route-planner'
import type { SatellitePass } from '@/types/game'
import { processShipping, resetShippingState } from './systems/shipping'
import { shippingLanes as defaultShippingLanes } from '@/data/shipping/shipping-lanes'
+import { processVisibility, resetVisibilityState, getViewVisibility, contactDisplayName, type ViewVisibility } from './systems/visibility'
+import { processWarSupport, resetWarSupportState, offerCeasefire, acceptCeasefire, resign, getWarSupport, getObjectives } from './systems/war-support'
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()
@@ -170,6 +172,12 @@ export class GameEngine {
// Espionage: HUMINT reveals + SIGINT multiplier
this.lastEspionageResult = processEspionage(state, this.rng)
+ // Fog of war: fold radar/satellite/HUMINT/ELINT pictures into per-nation contacts
+ processVisibility(state, this.sensorNetwork, this.lastEspionageResult, this.elevationGrid)
+
+ // Political will: war-support drains, ceasefire logic, capitulation, objectives
+ processWarSupport(state)
+
// Cap pendingEvents to prevent unbounded growth during fast-forward
if (state.pendingEvents.length > 2000) {
state.pendingEvents.splice(0, state.pendingEvents.length - 2000)
@@ -268,9 +276,15 @@ export class GameEngine {
break
}
case 'CEASE_FIRE': {
- const player = state.playerNation
- state.nations[player].atWar = state.nations[player].atWar.filter(n => n !== cmd.target)
- state.nations[cmd.target].atWar = state.nations[cmd.target].atWar.filter(n => n !== player)
+ acceptCeasefire(state, state.playerNation, cmd.target)
+ break
+ }
+ case 'OFFER_CEASEFIRE': {
+ offerCeasefire(state, state.playerNation, cmd.target)
+ break
+ }
+ case 'RESIGN': {
+ resign(state)
break
}
case 'SET_HEADING': {
@@ -297,18 +311,27 @@ export class GameEngine {
const events = [...state.pendingEvents]
state.pendingEvents = [] // one-shot delivery
+ const units: ViewUnit[] = []
+ for (const u of state.units.values()) {
+ const vis = getViewVisibility(state, state.playerNation, u)
+ if (vis) units.push(toViewUnit(u, vis))
+ }
+
return {
playerNation: state.playerNation,
initialized: state.initialized,
time: { ...state.time },
nations: Object.values(state.nations),
- units: Array.from(state.units.values()).map(toViewUnit),
+ units,
missiles: Array.from(state.missiles.values()),
supplyLines: Array.from(state.supplyLines.values()),
shippingLanes: Array.from(state.shippingLanes.values()),
events,
pendingEventCount: state.events.length,
satelliteDetectedUnitIds: Array.from(getSatelliteDetections(state.playerNation, state.time.tick)),
+ warSupport: getWarSupport(state),
+ gameOver: state.gameOver ?? null,
+ objectives: getObjectives(state),
}
}
@@ -321,6 +344,9 @@ export class GameEngine {
time: s.time,
nations: s.nations,
attackCounters: s.attackCounters ?? {},
+ visibility: s.visibility ?? {},
+ warStatus: s.warStatus ?? {},
+ gameOver: s.gameOver ?? null,
units: Array.from(s.units.entries()),
missiles: Array.from(s.missiles.entries()),
supplyLines: Array.from(s.supplyLines.entries()),
@@ -360,6 +386,9 @@ export class GameEngine {
events: raw.events ?? [],
pendingEvents: [],
attackCounters: raw.attackCounters ?? {},
+ visibility: raw.visibility ?? {},
+ warStatus: raw.warStatus ?? {},
+ gameOver: raw.gameOver ?? undefined,
}
// Backfill shipping lanes for old saves that didn't have them
if (!raw.shippingLanes || raw.shippingLanes.length === 0) {
@@ -404,6 +433,8 @@ export class GameEngine {
resetDroneAIState()
resetSatelliteState()
resetShippingState()
+ resetVisibilityState()
+ resetWarSupportState()
}
/** Set up satellite constellations for each nation */
@@ -500,31 +531,37 @@ export class GameEngine {
}
}
-function toViewUnit(u: Unit): ViewUnit {
+function toViewUnit(u: Unit, vis: ViewVisibility): 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'
return {
id: u.id,
- name: u.name,
+ name: trackedPlus ? u.name : contactDisplayName(u.category),
nation: u.nation,
category: u.category,
- position: { ...u.position },
- heading: u.heading,
- speed_kts: u.speed_kts,
- status: u.status,
- health: u.health,
- maxHealth: u.maxHealth,
- logistics: u.logistics,
- supplyStocks: u.supplyStocks.map(s => ({ ...s })),
- weapons: u.weapons.map(w => ({ ...w })),
- pointDefense: u.pointDefense.map(pd => ({ ...pd })),
- sensors: u.sensors.map(s => ({ ...s })),
+ position: { ...vis.position },
+ heading: trackedPlus ? u.heading : 0,
+ speed_kts: trackedPlus ? u.speed_kts : 0,
+ status: trackedPlus ? u.status : 'ready',
+ health: trackedPlus ? u.health : 100,
+ maxHealth: trackedPlus ? u.maxHealth : 100,
+ logistics: identified ? u.logistics : 0,
+ supplyStocks: identified ? u.supplyStocks.map(s => ({ ...s })) : [],
+ weapons: identified ? u.weapons.map(w => ({ ...w })) : [],
+ pointDefense: identified ? u.pointDefense.map(pd => ({ ...pd })) : [],
+ sensors: identified ? u.sensors.map(s => ({ ...s })) : [],
roe: u.roe,
- waypoints: u.waypoints.map(w => ({ ...w })),
- parentId: u.parentId,
- subordinateIds: [...u.subordinateIds],
- readiness: u.readiness,
- readinessTimer: u.readinessTimer,
+ waypoints: identified ? u.waypoints.map(w => ({ ...w })) : [],
+ parentId: identified ? u.parentId : undefined,
+ subordinateIds: identified ? [...u.subordinateIds] : [],
+ readiness: identified ? u.readiness : undefined,
+ readinessTimer: identified ? u.readinessTimer : undefined,
radius_km: u.radius_km,
- mine_count: u.mine_count,
- droneMission: u.droneMission,
+ mine_count: identified ? u.mine_count : undefined,
+ droneMission: identified ? u.droneMission : undefined,
+ visibility: vis.level,
+ stale: vis.stale,
}
}
diff --git a/src/engine/systems/__tests__/visibility.test.ts b/src/engine/systems/__tests__/visibility.test.ts
new file mode 100644
index 0000000..3cf89a2
--- /dev/null
+++ b/src/engine/systems/__tests__/visibility.test.ts
@@ -0,0 +1,390 @@
+import { describe, it, expect, beforeEach } from 'vitest'
+import { processVisibility, resetVisibilityState, getViewVisibility } from '../visibility'
+import { processSatellites, resetSatelliteState } from '../satellites'
+import { ElevationGrid } from '../elevation'
+import type { EspionageResult } from '../espionage'
+import type { GameState, NationId, SatellitePass, Sensor, Unit, UnitId } from '@/types/game'
+
+// ── Helpers ─────────────────────────────────────────────────────
+
+function makeUnit(overrides: Partial & { id: string; nation: NationId }): Unit {
+ return {
+ name: overrides.id,
+ category: 'ship',
+ position: { lat: 27, lng: 52 },
+ heading: 0,
+ speed_kts: 0,
+ maxSpeed_kts: 0,
+ health: 100,
+ maxHealth: 100,
+ hardness: 100,
+ logistics: 0,
+ supplyStocks: [],
+ weapons: [],
+ pointDefense: [],
+ sensors: [],
+ roe: 'weapons_tight' as const,
+ status: 'ready' as const,
+ waypoints: [],
+ subordinateIds: [],
+ ...overrides,
+ } as Unit
+}
+
+function radar(range_km: number, antenna_height_m = 15): Sensor {
+ return { type: 'radar', range_km, detection_prob: 0.9, antenna_height_m }
+}
+
+function makeState(units: Unit[], tick = 60): GameState {
+ return {
+ playerNation: 'usa',
+ initialized: true,
+ time: { tick, timestamp: 1_000_000, speed: 1, tickIntervalMs: 100 },
+ nations: {
+ usa: {
+ id: 'usa', name: 'USA',
+ economy: { gdp_billions: 28000, military_budget_billions: 886, military_budget_pct_gdp: 3.2, oil_revenue_billions: 0, sanctions_impact: 0, war_cost_per_day_millions: 0, reserves_billions: 800 },
+ relations: { usa: 100, iran: -60 }, atWar: ['iran'],
+ },
+ iran: {
+ id: 'iran', name: 'Iran',
+ economy: { gdp_billions: 400, military_budget_billions: 25, military_budget_pct_gdp: 6.3, oil_revenue_billions: 50, sanctions_impact: 0.3, war_cost_per_day_millions: 0, reserves_billions: 120 },
+ relations: { usa: -60, iran: 100 }, atWar: ['usa'],
+ },
+ },
+ units: new Map(units.map(u => [u.id, u])),
+ missiles: new Map(),
+ supplyLines: new Map(),
+ shippingLanes: new Map(),
+ events: [],
+ pendingEvents: [],
+ }
+}
+
+function runEval(state: GameState, tick: number, espionage: EspionageResult | null = null, grid: ElevationGrid | null = null): void {
+ state.time.tick = tick
+ processVisibility(state, null, espionage, grid)
+}
+
+function contact(state: GameState, observer: string, unitId: UnitId) {
+ return state.visibility?.[observer]?.[unitId]
+}
+
+function espionageWith(opts: { humint?: Record; sigint?: Record }): EspionageResult {
+ return {
+ humintRevealed: new Map(Object.entries(opts.humint ?? {})),
+ sigintMultiplier: new Map(Object.entries(opts.sigint ?? {})),
+ }
+}
+
+/** Build a minimal ElevationGrid (same binary format as elevation.test.ts) */
+function makeGrid(
+ latMin: number, latMax: number, lngMin: number, lngMax: number,
+ resolution: number, elevations: number[][],
+): ElevationGrid {
+ const rows = elevations.length
+ const cols = elevations[0].length
+ const buffer = new ArrayBuffer(20 + rows * cols * 4)
+ const header = new Float32Array(buffer, 0, 5)
+ header[0] = latMin
+ header[1] = latMax
+ header[2] = lngMin
+ header[3] = lngMax
+ header[4] = resolution
+ const data = new Float32Array(buffer, 20, rows * cols)
+ for (let r = 0; r < rows; r++) {
+ for (let c = 0; c < cols; c++) {
+ data[r * cols + c] = elevations[r][c]
+ }
+ }
+ return new ElevationGrid(buffer)
+}
+
+beforeEach(() => {
+ resetVisibilityState()
+ resetSatelliteState()
+})
+
+// At lat 27, 1 degree of longitude ≈ 99 km
+
+// ── Tests ───────────────────────────────────────────────────────
+
+describe('radar acquisition', () => {
+ it('tracks enemy units in radar range, identifies within 60% of range', () => {
+ const usRadar = makeUnit({ id: 'us_radar', nation: 'usa', sensors: [radar(100)] })
+ const irFar = makeUnit({ id: 'ir_far', nation: 'iran', position: { lat: 27, lng: 52.7 } }) // ~69 km
+ const irNear = makeUnit({ id: 'ir_near', nation: 'iran', position: { lat: 27, lng: 52.2 } }) // ~20 km
+ const irOut = makeUnit({ id: 'ir_out', nation: 'iran', position: { lat: 27, lng: 54 } }) // ~198 km
+ const state = makeState([usRadar, irFar, irNear, irOut])
+
+ runEval(state, 60)
+
+ expect(contact(state, 'usa', 'ir_far')?.level).toBe('tracked')
+ expect(contact(state, 'usa', 'ir_near')?.level).toBe('identified')
+ expect(contact(state, 'usa', 'ir_out')).toBeUndefined()
+ expect(getViewVisibility(state, 'usa', irOut)).toBeNull()
+ })
+
+ it('only evaluates sources on game-minute ticks', () => {
+ const usRadar = makeUnit({ id: 'us_radar', nation: 'usa', sensors: [radar(100)] })
+ const irShip = makeUnit({ id: 'ir_ship', nation: 'iran', position: { lat: 27, lng: 52.5 } })
+ const state = makeState([usRadar, irShip])
+
+ runEval(state, 61)
+ expect(contact(state, 'usa', 'ir_ship')).toBeUndefined()
+
+ runEval(state, 120)
+ expect(contact(state, 'usa', 'ir_ship')?.level).toBe('identified')
+ })
+
+ it('builds pictures for both nations', () => {
+ const usShip = makeUnit({ id: 'us_ship', nation: 'usa', sensors: [radar(100)] })
+ const irShip = makeUnit({ id: 'ir_ship', nation: 'iran', position: { lat: 27, lng: 52.7 }, sensors: [radar(100)] })
+ const state = makeState([usShip, irShip])
+
+ runEval(state, 60)
+
+ expect(contact(state, 'usa', 'ir_ship')?.level).toBe('tracked')
+ expect(contact(state, 'iran', 'us_ship')?.level).toBe('tracked')
+ expect(contact(state, 'usa', 'us_ship')).toBeUndefined()
+ expect(contact(state, 'iran', 'ir_ship')).toBeUndefined()
+ })
+
+ it('terrain blocks line of sight', () => {
+ // 3000 m ridge at lng 52.4-52.6, flat elsewhere
+ const elevations = Array.from({ length: 20 }, () =>
+ Array.from({ length: 30 }, (_, c) => (c >= 14 && c <= 16 ? 3000 : 0)),
+ )
+ const grid = makeGrid(26, 28, 51, 54, 0.1, elevations)
+
+ const usRadar = makeUnit({ id: 'us_radar', nation: 'usa', sensors: [radar(100)] })
+ const irBlocked = makeUnit({ id: 'ir_blocked', nation: 'iran', position: { lat: 27, lng: 53 } })
+ const irControl = makeUnit({ id: 'ir_control', nation: 'iran', position: { lat: 27, lng: 52.3 } })
+ const state = makeState([usRadar, irBlocked, irControl])
+
+ runEval(state, 60, null, grid)
+
+ expect(contact(state, 'usa', 'ir_blocked')).toBeUndefined()
+ expect(contact(state, 'usa', 'ir_control')?.level).toBe('identified')
+ })
+})
+
+describe('decay', () => {
+ it('decays tracked → detected → unseen with frozen last-known position', () => {
+ const usRadar = makeUnit({ id: 'us_radar', nation: 'usa', sensors: [radar(100)] })
+ const irShip = makeUnit({ id: 'ir_ship', nation: 'iran', position: { lat: 27, lng: 52.7 } })
+ const state = makeState([usRadar, irShip])
+
+ runEval(state, 60)
+ expect(contact(state, 'usa', 'ir_ship')?.level).toBe('tracked')
+ expect(getViewVisibility(state, 'usa', irShip)?.stale).toBe(false)
+
+ // Track lost: ship sails out of radar range
+ irShip.position = { lat: 27, lng: 60 }
+
+ runEval(state, 660) // 10 game-min after last seen
+ const c = contact(state, 'usa', 'ir_ship')
+ expect(c?.level).toBe('detected')
+ expect(c?.lastKnownPosition.lng).toBe(52.7)
+ const view = getViewVisibility(state, 'usa', irShip)
+ expect(view?.stale).toBe(true)
+ expect(view?.position.lng).toBe(52.7)
+
+ runEval(state, 2400) // still inside the 30-min detected window
+ expect(contact(state, 'usa', 'ir_ship')?.level).toBe('detected')
+
+ runEval(state, 2460) // 40 game-min after last seen
+ expect(contact(state, 'usa', 'ir_ship')).toBeUndefined()
+ expect(getViewVisibility(state, 'usa', irShip)).toBeNull()
+ })
+
+ it('keeps a refreshed track alive without decay', () => {
+ const usRadar = makeUnit({ id: 'us_radar', nation: 'usa', sensors: [radar(100)] })
+ const irShip = makeUnit({ id: 'ir_ship', nation: 'iran', position: { lat: 27, lng: 52.7 } })
+ const state = makeState([usRadar, irShip])
+
+ runEval(state, 60)
+ runEval(state, 1200)
+ expect(contact(state, 'usa', 'ir_ship')?.level).toBe('tracked')
+ expect(contact(state, 'usa', 'ir_ship')?.lastSeenTick).toBe(1200)
+ })
+
+ it('airbase stays identified forever once identified', () => {
+ const usRadar = makeUnit({ id: 'us_radar', nation: 'usa', sensors: [radar(100)] })
+ const irBase = makeUnit({ id: 'ir_base', nation: 'iran', category: 'airbase', position: { lat: 27, lng: 52.2 } })
+ const state = makeState([usRadar, irBase])
+
+ runEval(state, 60)
+ expect(contact(state, 'usa', 'ir_base')?.level).toBe('identified')
+
+ usRadar.position = { lat: 10, lng: 40 }
+ runEval(state, 60_000)
+ const c = contact(state, 'usa', 'ir_base')
+ expect(c?.level).toBe('identified')
+ expect(c?.pinned).toBe(true)
+ })
+
+ it('stationary sam_site pins at detected, moved sam_site decays to unseen', () => {
+ const usRadar = makeUnit({ id: 'us_radar', nation: 'usa', sensors: [radar(100)] })
+ const irSam = makeUnit({ id: 'ir_sam', nation: 'iran', category: 'sam_site', position: { lat: 27, lng: 52.7 } })
+ const state = makeState([usRadar, irSam])
+
+ runEval(state, 60)
+ expect(contact(state, 'usa', 'ir_sam')?.level).toBe('tracked')
+
+ usRadar.position = { lat: 10, lng: 40 }
+ runEval(state, 3060) // long past the mobile unseen threshold
+ const c = contact(state, 'usa', 'ir_sam')
+ expect(c?.level).toBe('detected')
+ expect(c?.pinned).toBe(true)
+
+ irSam.position = { lat: 27, lng: 53.5 }
+ runEval(state, 3120)
+ expect(contact(state, 'usa', 'ir_sam')).toBeUndefined()
+ })
+})
+
+describe('satellites', () => {
+ function makeSatellite(overrides: Partial & { id: string; nation: NationId }): SatellitePass {
+ return {
+ type: 'optical',
+ swathWidth_km: 50,
+ revisitInterval_sec: 3600,
+ lastPassTick: 0,
+ groundTrack: { startLat: 30, startLng: 50, endLat: 36, endLng: 56 },
+ ...overrides,
+ }
+ }
+
+ it('radar satellite pass produces a detected contact with stale position', () => {
+ const irUnit = makeUnit({ id: 'ir_unit', nation: 'iran', position: { lat: 33, lng: 53 } })
+ const state = makeState([irUnit], 3600)
+ state.nations.usa.satellites = [makeSatellite({ id: 'usa_radar_sat', nation: 'usa', type: 'radar_sat', swathWidth_km: 200 })]
+
+ processSatellites(state)
+ processVisibility(state, null, null, null)
+
+ expect(contact(state, 'usa', 'ir_unit')?.level).toBe('detected')
+ const view = getViewVisibility(state, 'usa', irUnit)
+ expect(view?.stale).toBe(true)
+ expect(view?.position.lat).toBe(33)
+ })
+
+ it('optical satellite pass produces a tracked contact', () => {
+ const irUnit = makeUnit({ id: 'ir_unit', nation: 'iran', position: { lat: 33, lng: 53 } })
+ const state = makeState([irUnit], 3600)
+ state.nations.usa.satellites = [makeSatellite({ id: 'usa_optical', nation: 'usa', type: 'optical' })]
+
+ processSatellites(state)
+ processVisibility(state, null, null, null)
+
+ expect(contact(state, 'usa', 'ir_unit')?.level).toBe('tracked')
+ })
+})
+
+describe('espionage sources', () => {
+ it('HUMINT identifies a unit and stays sticky for 30 game-min before decaying', () => {
+ const irHq = makeUnit({ id: 'ir_hq', nation: 'iran', category: 'missile_battery', position: { lat: 30, lng: 55 } })
+ const state = makeState([irHq])
+
+ runEval(state, 3600, espionageWith({ humint: { usa: ['ir_hq'] } }))
+ expect(contact(state, 'usa', 'ir_hq')?.level).toBe('identified')
+
+ runEval(state, 5340) // 29 min later, no new espionage — still sticky
+ expect(contact(state, 'usa', 'ir_hq')?.level).toBe('identified')
+ expect(contact(state, 'usa', 'ir_hq')?.lastSeenTick).toBe(5340)
+
+ runEval(state, 6000) // sticky expired at 5400, identified hold expired at 5940
+ expect(contact(state, 'usa', 'ir_hq')?.level).toBe('tracked')
+ })
+
+ it('ELINT detects enemy radar emitters at range scaled by the SIGINT multiplier', () => {
+ const usEw = makeUnit({ id: 'us_ew', nation: 'usa', sensors: [radar(50)] })
+ const irSam = makeUnit({ id: 'ir_sam', nation: 'iran', category: 'sam_site', position: { lat: 27, lng: 53.75 }, sensors: [radar(100)] }) // ~173 km
+ const state = makeState([usEw, irSam])
+
+ runEval(state, 60, espionageWith({ sigint: { usa: 1.5, iran: 1.5 } }))
+ expect(contact(state, 'usa', 'ir_sam')).toBeUndefined() // 100 × 1.5 = 150 km < 173
+
+ runEval(state, 120, espionageWith({ sigint: { usa: 2.0, iran: 1.5 } }))
+ const c = contact(state, 'usa', 'ir_sam')
+ expect(c?.level).toBe('detected') // 100 × 2.0 = 200 km ≥ 173
+ expect(getViewVisibility(state, 'usa', irSam)?.stale).toBe(true)
+ expect(contact(state, 'iran', 'us_ew')).toBeUndefined() // 50 × 1.5 = 75 km < 173
+ })
+
+ it('non-emitting units are not ELINT-detectable', () => {
+ const usEw = makeUnit({ id: 'us_ew', nation: 'usa', sensors: [radar(50)] })
+ const irSilent = makeUnit({ id: 'ir_silent', nation: 'iran', position: { lat: 27, lng: 53 } })
+ const state = makeState([usEw, irSilent])
+
+ runEval(state, 60, espionageWith({ sigint: { usa: 2.0 } }))
+ expect(contact(state, 'usa', 'ir_silent')).toBeUndefined()
+ })
+})
+
+describe('event-driven reveals', () => {
+ it('missile launch reveals the launcher at tracked on the launch tick', () => {
+ const irTel = makeUnit({ id: 'ir_tel', nation: 'iran', category: 'missile_battery', position: { lat: 29, lng: 53 } })
+ const usBase = makeUnit({ id: 'us_base', nation: 'usa', category: 'airbase' })
+ const state = makeState([irTel, usBase], 61)
+ state.events.push({ type: 'MISSILE_LAUNCHED', missileId: 'm_1', launcherId: 'ir_tel', targetId: 'us_base', weaponName: 'Zolfaghar', tick: 61 })
+
+ processVisibility(state, null, null, null)
+
+ const c = contact(state, 'usa', 'ir_tel')
+ expect(c?.level).toBe('tracked')
+ expect(c?.lastSeenTick).toBe(61)
+ expect(contact(state, 'iran', 'ir_tel')).toBeUndefined()
+ })
+
+ it('launch plume does not downgrade a fresh identified contact', () => {
+ const usRadar = makeUnit({ id: 'us_radar', nation: 'usa', sensors: [radar(100)] })
+ const irShip = makeUnit({ id: 'ir_ship', nation: 'iran', position: { lat: 27, lng: 52.2 } })
+ const state = makeState([usRadar, irShip])
+
+ runEval(state, 60)
+ expect(contact(state, 'usa', 'ir_ship')?.level).toBe('identified')
+
+ state.time.tick = 61
+ state.events.push({ type: 'MISSILE_LAUNCHED', missileId: 'm_1', launcherId: 'ir_ship', targetId: 'us_radar', weaponName: 'Noor', tick: 61 })
+ processVisibility(state, null, null, null)
+
+ const c = contact(state, 'usa', 'ir_ship')
+ expect(c?.level).toBe('identified')
+ expect(c?.lastSeenTick).toBe(60)
+ })
+
+ it('mine contact identifies the minefield permanently', () => {
+ const irMines = makeUnit({ id: 'ir_mines', nation: 'iran', category: 'minefield', position: { lat: 26.5, lng: 56 } })
+ const usShip = makeUnit({ id: 'us_ship', nation: 'usa', position: { lat: 26.5, lng: 56 } })
+ const state = makeState([irMines, usShip], 61)
+ state.events.push({ type: 'MINE_CONTACT', minefieldId: 'ir_mines', targetId: 'us_ship', damage: 35, tick: 61 })
+
+ processVisibility(state, null, null, null)
+ const c = contact(state, 'usa', 'ir_mines')
+ expect(c?.level).toBe('identified')
+ expect(c?.pinned).toBe(true)
+
+ runEval(state, 60_000)
+ expect(contact(state, 'usa', 'ir_mines')?.level).toBe('identified')
+ })
+})
+
+describe('getViewVisibility', () => {
+ it('excludes enemy units with no contact entry (fog-on)', () => {
+ const usShip = makeUnit({ id: 'us_ship', nation: 'usa' })
+ const irShip = makeUnit({ id: 'ir_ship', nation: 'iran', position: { lat: 28, lng: 55 } })
+ const state = makeState([usShip, irShip])
+
+ expect(getViewVisibility(state, 'usa', irShip)).toBeNull()
+ })
+
+ it('always reports own units identified with live position', () => {
+ const usShip = makeUnit({ id: 'us_ship', nation: 'usa' })
+ const state = makeState([usShip])
+
+ const view = getViewVisibility(state, 'usa', usShip)
+ expect(view).toEqual({ level: 'identified', stale: false, position: usShip.position })
+ })
+})
diff --git a/src/engine/systems/__tests__/war-support.test.ts b/src/engine/systems/__tests__/war-support.test.ts
new file mode 100644
index 0000000..0641c5e
--- /dev/null
+++ b/src/engine/systems/__tests__/war-support.test.ts
@@ -0,0 +1,273 @@
+import { describe, it, expect, beforeEach } from 'vitest'
+import {
+ processWarSupport,
+ resetWarSupportState,
+ offerCeasefire,
+ acceptCeasefire,
+ resign,
+ getWarSupport,
+ getObjectives,
+} from '../war-support'
+import type { GameEvent, GameState, NationId, ShippingLane, Unit, UnitCategory } from '@/types/game'
+
+function makeUnit(overrides: Partial & { id: string; nation: NationId }): Unit {
+ return {
+ name: overrides.id,
+ category: 'ship',
+ position: { lat: 27, lng: 52 },
+ heading: 0,
+ speed_kts: 0,
+ maxSpeed_kts: 0,
+ health: 100,
+ maxHealth: 100,
+ hardness: 100,
+ logistics: 0,
+ supplyStocks: [],
+ weapons: [],
+ pointDefense: [],
+ sensors: [],
+ roe: 'weapons_tight' as const,
+ status: 'ready' as const,
+ waypoints: [],
+ subordinateIds: [],
+ ...overrides,
+ } as Unit
+}
+
+function hormuz(status: ShippingLane['status']): ShippingLane {
+ return {
+ id: 'hormuz',
+ name: 'Strait of Hormuz',
+ path: [[56, 26], [57, 26.5]],
+ baseThroughput_mbd: 21,
+ currentThroughput_mbd: status === 'open' ? 21 : status === 'reduced' ? 10 : 0,
+ suppressionFactor: status === 'open' ? 0 : status === 'reduced' ? 0.5 : 1,
+ status,
+ }
+}
+
+function makeState(units: Unit[], opts: { atWar?: boolean; tick?: number } = {}): GameState {
+ const atWar = opts.atWar ?? true
+ return {
+ playerNation: 'usa',
+ initialized: true,
+ time: { tick: opts.tick ?? 0, timestamp: 1_000_000, speed: 1, tickIntervalMs: 100 },
+ nations: {
+ usa: {
+ id: 'usa', name: 'USA',
+ economy: { gdp_billions: 28000, military_budget_billions: 886, military_budget_pct_gdp: 3.2, oil_revenue_billions: 0, sanctions_impact: 0, war_cost_per_day_millions: 300, reserves_billions: 800, oilPrice_per_barrel: 80 },
+ relations: { usa: 100, iran: -60 }, atWar: atWar ? ['iran'] : [],
+ },
+ iran: {
+ id: 'iran', name: 'Iran',
+ economy: { gdp_billions: 400, military_budget_billions: 25, military_budget_pct_gdp: 6.3, oil_revenue_billions: 50, sanctions_impact: 0.3, war_cost_per_day_millions: 50, reserves_billions: 120, oilPrice_per_barrel: 80 },
+ relations: { usa: -60, iran: 100 }, atWar: atWar ? ['usa'] : [],
+ },
+ },
+ units: new Map(units.map(u => [u.id, u])),
+ missiles: new Map(),
+ supplyLines: new Map(),
+ shippingLanes: new Map([['hormuz', hormuz('open')]]),
+ events: [],
+ pendingEvents: [],
+ }
+}
+
+function destroyUnit(state: GameState, unitId: string): void {
+ const unit = state.units.get(unitId)!
+ unit.status = 'destroyed'
+ const event: GameEvent = { type: 'UNIT_DESTROYED', unitId, tick: state.time.tick }
+ state.events.push(event)
+ state.pendingEvents.push(event)
+}
+
+/** Advance to the next minute boundary and evaluate */
+function evalAt(state: GameState, tick: number): void {
+ state.time.tick = tick
+ processWarSupport(state)
+}
+
+function lossUnit(category: UnitCategory, id: string, nation: NationId): Unit {
+ return makeUnit({ id, nation, category })
+}
+
+beforeEach(() => {
+ resetWarSupportState()
+})
+
+describe('war support drains', () => {
+ it('drains the victim by category weight when a unit is destroyed', () => {
+ const state = makeState([lossUnit('carrier_group', 'cvn', 'usa'), lossUnit('ship', 'boat', 'iran')])
+ evalAt(state, 0)
+ destroyUnit(state, 'cvn')
+ evalAt(state, 60)
+ const support = getWarSupport(state)
+ expect(support.usa).toBeLessThanOrEqual(88)
+ expect(support.iran).toBeGreaterThan(support.usa)
+ })
+
+ it('caps kill gains at +10 total', () => {
+ const units: Unit[] = [lossUnit('ship', 'us1', 'usa')]
+ for (let i = 0; i < 30; i++) units.push(lossUnit('missile_battery', `tel${i}`, 'iran'))
+ const state = makeState(units)
+ evalAt(state, 0)
+ for (let i = 0; i < 30; i++) destroyUnit(state, `tel${i}`)
+ evalAt(state, 60)
+ expect(getWarSupport(state).usa).toBeGreaterThan(99.9)
+ expect(getWarSupport(state).iran).toBeLessThan(60)
+ })
+
+ it('drains slowly from war duration alone', () => {
+ const state = makeState([lossUnit('ship', 'us1', 'usa')])
+ evalAt(state, 0)
+ for (let t = 60; t <= 3600 * 10; t += 60) evalAt(state, t)
+ const support = getWarSupport(state)
+ expect(support.usa).toBeLessThan(100)
+ expect(support.usa).toBeGreaterThan(95)
+ })
+
+ it('does not drain at peace', () => {
+ const state = makeState([lossUnit('ship', 'us1', 'usa')], { atWar: false })
+ evalAt(state, 0)
+ for (let t = 60; t <= 3600; t += 60) evalAt(state, t)
+ expect(getWarSupport(state).usa).toBe(100)
+ })
+})
+
+describe('war termination', () => {
+ it('emits WAR_SUPPORT_CRITICAL once when crossing the threshold', () => {
+ const state = makeState([lossUnit('ship', 'us1', 'usa')])
+ evalAt(state, 0)
+ state.warStatus!.iran.warSupport = 34
+ state.warStatus!.iran.warStartTick = 0
+ evalAt(state, 60)
+ state.time.tick = 120
+ evalAt(state, 120)
+ const criticals = state.events.filter(e => e.type === 'WAR_SUPPORT_CRITICAL' && e.nation === 'iran')
+ expect(criticals).toHaveLength(1)
+ })
+
+ it('capitulation at 0 ends the war, sets gameOver victory for the player, holds fire', () => {
+ const state = makeState([lossUnit('ship', 'us1', 'usa'), lossUnit('ship', 'ir1', 'iran')])
+ evalAt(state, 0)
+ state.warStatus!.iran.warSupport = 0.001
+ evalAt(state, 60)
+ expect(state.gameOver).toBeTruthy()
+ expect(state.gameOver!.outcome).toBe('victory')
+ expect(state.gameOver!.loser).toBe('iran')
+ expect(state.nations.usa.atWar).toHaveLength(0)
+ expect(state.nations.iran.atWar).toHaveLength(0)
+ expect(state.units.get('us1')!.roe).toBe('hold_fire')
+ expect(state.units.get('ir1')!.roe).toBe('hold_fire')
+ expect(state.events.some(e => e.type === 'WAR_ENDED' && e.outcome === 'capitulation')).toBe(true)
+ })
+
+ it('freezes war support after the war ends', () => {
+ const state = makeState([lossUnit('ship', 'us1', 'usa')])
+ evalAt(state, 0)
+ state.warStatus!.iran.warSupport = 0.001
+ evalAt(state, 60)
+ const after = getWarSupport(state)
+ for (let t = 120; t <= 3600; t += 60) evalAt(state, t)
+ expect(getWarSupport(state)).toEqual(after)
+ })
+
+ it('resign ends the war as a player defeat', () => {
+ const state = makeState([lossUnit('ship', 'us1', 'usa')])
+ evalAt(state, 0)
+ resign(state)
+ expect(state.gameOver!.outcome).toBe('defeat')
+ expect(state.gameOver!.loser).toBe('usa')
+ })
+
+ it('includes frozen stats in the gameOver report', () => {
+ const state = makeState([lossUnit('ship', 'us1', 'usa'), lossUnit('ship', 'ir1', 'iran')])
+ evalAt(state, 0)
+ destroyUnit(state, 'ir1')
+ evalAt(state, 60)
+ resign(state)
+ const stats = state.gameOver!.stats
+ expect(stats.unitsLost.iran).toBe(1)
+ expect(stats.unitsLost.usa).toBe(0)
+ expect(stats.durationTicks).toBeGreaterThan(0)
+ })
+})
+
+describe('ceasefire', () => {
+ it('AI accepts when its support is lower than the offerer plus margin', () => {
+ const state = makeState([lossUnit('ship', 'us1', 'usa')])
+ evalAt(state, 0)
+ state.warStatus!.iran.warSupport = 50
+ state.warStatus!.usa.warSupport = 80
+ offerCeasefire(state, 'usa', 'iran')
+ expect(state.gameOver?.outcome).toBe('ceasefire')
+ expect(state.nations.usa.atWar).toHaveLength(0)
+ })
+
+ it('rejects when the target is winning, with a re-offer cooldown', () => {
+ const state = makeState([lossUnit('ship', 'us1', 'usa'), makeUnit({ id: 'tel', nation: 'iran', category: 'missile_battery', weapons: [{ weaponId: 'fateh110', count: 12, maxCount: 12, reloadTimeSec: 60 }] })])
+ evalAt(state, 0)
+ state.warStatus!.iran.warSupport = 95
+ state.warStatus!.usa.warSupport = 40
+ offerCeasefire(state, 'usa', 'iran')
+ expect(state.gameOver).toBeUndefined()
+ expect(state.events.some(e => e.type === 'CEASEFIRE_REJECTED')).toBe(true)
+
+ state.warStatus!.iran.warSupport = 10
+ state.time.tick = 120
+ offerCeasefire(state, 'usa', 'iran')
+ expect(state.gameOver).toBeUndefined()
+ })
+
+ it('accepting a standing offer during war produces a ceasefire gameOver', () => {
+ const state = makeState([lossUnit('ship', 'us1', 'usa')])
+ evalAt(state, 0)
+ acceptCeasefire(state, 'usa', 'iran')
+ expect(state.gameOver?.outcome).toBe('ceasefire')
+ })
+
+ it('acceptCeasefire at peace is a no-op', () => {
+ const state = makeState([lossUnit('ship', 'us1', 'usa')], { atWar: false })
+ acceptCeasefire(state, 'usa', 'iran')
+ expect(state.gameOver).toBeUndefined()
+ })
+})
+
+describe('objectives', () => {
+ it('returns empty at peace', () => {
+ const state = makeState([lossUnit('ship', 'us1', 'usa')], { atWar: false })
+ processWarSupport(state)
+ expect(getObjectives(state)).toEqual([])
+ })
+
+ it('USA objectives track battery kills', () => {
+ const state = makeState([
+ lossUnit('carrier_group', 'cvn', 'usa'),
+ lossUnit('missile_battery', 'tel1', 'iran'),
+ lossUnit('missile_battery', 'tel2', 'iran'),
+ ])
+ evalAt(state, 0)
+ const before = getObjectives(state).find(o => o.id === 'destroy_missile_force')!
+ expect(before.progress).toBe(0)
+
+ destroyUnit(state, 'tel1')
+ evalAt(state, 60)
+ const after = getObjectives(state).find(o => o.id === 'destroy_missile_force')!
+ expect(after.progress).toBeCloseTo(0.5)
+ const carrier = getObjectives(state).find(o => o.id === 'preserve_carrier')!
+ expect(carrier.status).toBe('good')
+ })
+
+ it('freezes objectives once the war is decided', () => {
+ const state = makeState([
+ lossUnit('carrier_group', 'cvn', 'usa'),
+ lossUnit('missile_battery', 'tel1', 'iran'),
+ ])
+ evalAt(state, 0)
+ resign(state)
+ const frozen = getObjectives(state)
+ destroyUnit(state, 'tel1')
+ evalAt(state, 120)
+ expect(getObjectives(state)).toEqual(frozen)
+ })
+})
diff --git a/src/engine/systems/ai.ts b/src/engine/systems/ai.ts
index 413b15c..c37be3f 100644
--- a/src/engine/systems/ai.ts
+++ b/src/engine/systems/ai.ts
@@ -1,9 +1,10 @@
-import type { GameState, NationId } from '@/types/game'
+import type { GameState, NationId, Position, Unit } from '@/types/game'
import type { Command } from '@/types/commands'
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'
type AIPhase = 'PEACETIME' | 'ALERT' | 'DEFENSIVE' | 'OFFENSIVE' | 'ATTRITION'
@@ -11,6 +12,9 @@ type AIPhase = 'PEACETIME' | 'ALERT' | 'DEFENSIVE' | 'OFFENSIVE' | 'ATTRITION'
const ALERT_DURATION_TICKS = 60
/** At war this long without escalating via retaliation → initiate OFFENSIVE anyway */
const OFFENSIVE_AFTER_WAR_TICKS = 1800
+/** Shahed-armed batteries retasked to choke the Hormuz lane while Iran is at war */
+const INTERDICTION_NATION: NationId = 'iran'
+const MAX_INTERDICTION_BATTERIES = 2
interface AIState {
phase: AIPhase
@@ -22,6 +26,8 @@ interface AIState {
lastSeenAttackCounter: number
/** Tick when this nation entered its current war (-1 = at peace) */
warStartTick: number
+ /** Shipping-interdiction batteries already tasked this war (assign once, don't thrash) */
+ interdictionAssigned: boolean
}
const aiStates = new Map()
@@ -42,6 +48,7 @@ function getAIState(nation: NationId, state: GameState): AIState {
// Seed at the current counter so a loaded save doesn't replay its whole attack history
lastSeenAttackCounter: state.attackCounters?.[nation] ?? 0,
warStartTick: -1,
+ interdictionAssigned: false,
}
aiStates.set(nation, s)
}
@@ -121,6 +128,20 @@ export function processAI(state: GameState, rng: SeededRNG): Command[] {
// Phase transitions
updatePhase(ai, state, nation.id)
+ // Collapsing war support: sue for peace and stand down offensive operations
+ const warStatus = state.warStatus?.[nation.id]
+ if (nation.atWar.length > 0 && warStatus && warStatus.warSupport <= WAR_SUPPORT_CRITICAL_THRESHOLD) {
+ if (!warStatus.ceasefireOffered) {
+ warStatus.ceasefireOffered = true
+ const event = { type: 'CEASEFIRE_OFFERED' as const, by: nation.id, tick: state.time.tick }
+ state.events.push(event)
+ state.pendingEvents.push(event)
+ }
+ if (ai.phase === 'OFFENSIVE' || ai.phase === 'ATTRITION') ai.phase = 'DEFENSIVE'
+ }
+
+ updateDroneInterdiction(ai, state, nation.id, commands)
+
// Generate commands based on phase
switch (ai.phase) {
case 'PEACETIME':
@@ -194,7 +215,10 @@ function updatePhase(ai: AIState, state: GameState, nationId: NationId): void {
if (!atWar) {
ai.warStartTick = -1
ai.salvosLaunched = 0
- ai.phase = ai.attacksReceived > 0 ? 'ALERT' : 'PEACETIME'
+ // Drop unanswered attacks too — a leftover count would re-arm units to
+ // weapons_free right after a ceasefire set everyone to hold_fire
+ ai.attacksReceived = 0
+ ai.phase = 'PEACETIME'
return
}
@@ -318,3 +342,48 @@ function countDestroyedUnits(state: GameState, nationId: NationId): number {
}
return count
}
+
+function minDistToLanePath(position: Position, path: [number, number][]): number {
+ let min = Infinity
+ for (const [lng, lat] of path) {
+ const d = haversine(position, { lat, lng })
+ if (d < min) min = d
+ }
+ return min
+}
+
+/** Drone interdiction doctrine: at war, task the shahed batteries nearest Hormuz with
+ * shipping interdiction (once per war); revert them to military strikes at peace. */
+function updateDroneInterdiction(ai: AIState, state: GameState, nationId: NationId, commands: Command[]): void {
+ if (nationId !== INTERDICTION_NATION) return
+
+ if (state.nations[nationId].atWar.length === 0) {
+ ai.interdictionAssigned = false
+ for (const unit of state.units.values()) {
+ if (unit.nation === nationId && unit.droneMission === 'shipping_interdiction') {
+ commands.push({ type: 'SET_DRONE_MISSION', unitId: unit.id, mission: 'military' })
+ }
+ }
+ return
+ }
+
+ if (ai.interdictionAssigned) return
+ const lane = state.shippingLanes.get('hormuz')
+ if (!lane) return
+
+ const candidates: { unit: Unit; dist: number }[] = []
+ for (const unit of state.units.values()) {
+ if (unit.nation !== nationId || unit.status === 'destroyed') continue
+ if (unit.category !== 'missile_battery') continue
+ if (unit.droneMission === 'shipping_interdiction') continue
+ if (!unit.weapons.some(w => w.count > 0 && w.weaponId.includes('shahed'))) continue
+ candidates.push({ unit, dist: minDistToLanePath(unit.position, lane.path) })
+ }
+ if (candidates.length === 0) return
+
+ candidates.sort((a, b) => a.dist - b.dist)
+ for (const { unit } of candidates.slice(0, MAX_INTERDICTION_BATTERIES)) {
+ commands.push({ type: 'SET_DRONE_MISSION', unitId: unit.id, mission: 'shipping_interdiction' })
+ }
+ ai.interdictionAssigned = true
+}
diff --git a/src/engine/systems/detection.ts b/src/engine/systems/detection.ts
index 6adb48e..889a97c 100644
--- a/src/engine/systems/detection.ts
+++ b/src/engine/systems/detection.ts
@@ -15,7 +15,7 @@ function radarHorizon(antennaHeightM: number, targetHeightM: number): number {
}
/** Check line-of-sight between two points using elevation grid */
-function hasLineOfSight(
+export function hasLineOfSight(
radarPos: Position, radarAltM: number,
targetLat: number, targetLng: number, targetAltM: number,
grid: ElevationGrid,
diff --git a/src/engine/systems/satellites.ts b/src/engine/systems/satellites.ts
index db22b49..c4a5ef5 100644
--- a/src/engine/systems/satellites.ts
+++ b/src/engine/systems/satellites.ts
@@ -8,7 +8,7 @@ import type { GameState, NationId, UnitId, Position } from '@/types/game'
const satelliteDetections = new Map>()
/** How many ticks a satellite detection remains visible before fading */
-const DETECTION_FADE_TICKS = 60
+export const DETECTION_FADE_TICKS = 60
/** Reset module-level state (call on save/load) */
export function resetSatelliteState(): void {
@@ -87,7 +87,7 @@ export function processSatellites(state: GameState): UnitId[] {
* for the theater scale (~2000 km). For short/medium distances in the
* Middle East theater this is accurate to within a few percent.
*/
-function pointToLineDistKm(
+export function pointToLineDistKm(
point: Position,
lineStart: Position,
lineEnd: Position,
diff --git a/src/engine/systems/visibility.ts b/src/engine/systems/visibility.ts
new file mode 100644
index 0000000..e1cdfe2
--- /dev/null
+++ b/src/engine/systems/visibility.ts
@@ -0,0 +1,372 @@
+import type {
+ GameState,
+ Nation,
+ NationId,
+ Position,
+ Unit,
+ UnitCategory,
+ UnitId,
+ VisibilityContact,
+ VisibilityLevel,
+} from '@/types/game'
+import type { ElevationGrid } from './elevation'
+import type { SensorNetwork } from './sensor-network'
+import type { EspionageResult } from './espionage'
+import { hasLineOfSight } from './detection'
+import { getSatelliteDetections, pointToLineDistKm, DETECTION_FADE_TICKS } from './satellites'
+import { haversine } from '../utils/geo'
+
+/**
+ * Fog of war. Maintains state.visibility — per observing nation, a contact map over
+ * enemy units — from radar coverage, satellites, HUMINT, ELINT and combat events.
+ * Full source evaluation runs once per game-minute; event-driven reveals (launch
+ * plumes, mine contacts) apply on the tick they happen.
+ * Design: docs/plans/game-loop-v2.md §1.
+ */
+
+const EVAL_INTERVAL_TICKS = 60
+const IDENTIFIED_DECAY_TICKS = 600
+const TRACKED_DECAY_TICKS = 600
+const DETECTED_DECAY_TICKS = 1800
+const HUMINT_STICKY_TICKS = 1800
+const RADAR_IDENTIFY_FRACTION = 0.6
+const DEFAULT_SIGINT_MULTIPLIER = 1.5
+const DEFAULT_ANTENNA_HEIGHT_M = 15
+const TARGET_HEIGHT_M = 10
+
+const LEVEL_RANK: Record = { unseen: 0, detected: 1, tracked: 2, identified: 3 }
+
+interface ContactMeta {
+ /** Level at lastSeenTick — decay thresholds are cumulative from this anchor */
+ anchor: VisibilityLevel
+ /** HUMINT reveals keep the contact identified until this tick */
+ humintUntil: number
+}
+
+const metaByObserver = new Map>()
+
+function metaFor(observer: string): Map {
+ let m = metaByObserver.get(observer)
+ if (!m) {
+ m = new Map()
+ metaByObserver.set(observer, m)
+ }
+ return m
+}
+
+export function processVisibility(
+ state: GameState,
+ _network: SensorNetwork | null,
+ espionage: EspionageResult | null,
+ grid: ElevationGrid | null,
+): void {
+ if (state.time.tick % EVAL_INTERVAL_TICKS === 0) {
+ evaluateSources(state, espionage, grid)
+ }
+ applyEventReveals(state)
+}
+
+export function resetVisibilityState(): void {
+ metaByObserver.clear()
+}
+
+// ---------------------------------------------------------------------------
+// Per-minute source evaluation
+// ---------------------------------------------------------------------------
+
+function evaluateSources(state: GameState, espionage: EspionageResult | null, grid: ElevationGrid | null): void {
+ const tick = state.time.tick
+ 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)
+
+ const ownRadars: Unit[] = []
+ const ownSensorUnits: Unit[] = []
+ 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)
+ }
+
+ const humint = espionage?.humintRevealed.get(nation.id)
+ if (humint) {
+ for (const unitId of humint) {
+ const m = meta.get(unitId)
+ if (m) m.humintUntil = tick + HUMINT_STICKY_TICKS
+ else meta.set(unitId, { anchor: 'unseen', humintUntil: tick + HUMINT_STICKY_TICKS })
+ }
+ }
+ const sigintMultiplier = espionage?.sigintMultiplier.get(nation.id) ?? DEFAULT_SIGINT_MULTIPLIER
+ const satDetections = getSatelliteDetections(nation.id, tick)
+
+ for (const unit of state.units.values()) {
+ if (unit.nation === nation.id) continue
+
+ let best: VisibilityLevel = 'unseen'
+ if (unit.status !== 'destroyed') {
+ best = radarContactLevel(ownRadars, unit, grid)
+ if (satDetections.has(unit.id)) {
+ best = maxLevel(best, satelliteContactLevel(nation, unit, tick))
+ }
+ if ((meta.get(unit.id)?.humintUntil ?? 0) > tick) {
+ best = 'identified'
+ }
+ if (best === 'unseen' && isElintDetected(ownSensorUnits, unit, sigintMultiplier)) {
+ best = 'detected'
+ }
+ }
+
+ const contact = contacts[unit.id]
+ if (!contact) {
+ if (best !== 'unseen') contacts[unit.id] = newContact(meta, unit, best, tick)
+ continue
+ }
+
+ const anchor = anchorOf(contact, meta.get(unit.id))
+ const floor = decayFloor(unit, contact, anchor)
+ let decayed = decayedLevel(anchor, tick - contact.lastSeenTick)
+ if (LEVEL_RANK[decayed] < LEVEL_RANK[floor]) decayed = floor
+
+ if (best !== 'unseen' && LEVEL_RANK[best] >= LEVEL_RANK[decayed]) {
+ refreshContact(contact, meta, unit, best, tick)
+ } else if (decayed === 'unseen') {
+ delete contacts[unit.id]
+ meta.delete(unit.id)
+ } else {
+ contact.level = decayed
+ contact.pinned = floor !== 'unseen'
+ }
+ }
+ }
+}
+
+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 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
+ if (!hasLineOfSight(radar.position, radarAltM, target.position.lat, target.position.lng, targetAltM, grid)) {
+ continue
+ }
+ }
+ if (dist <= range * RADAR_IDENTIFY_FRACTION) return 'identified'
+ best = 'tracked'
+ }
+ return best
+}
+
+function satelliteContactLevel(nation: Nation, unit: Unit, tick: number): VisibilityLevel {
+ for (const sat of nation.satellites ?? []) {
+ if (sat.type !== 'optical') continue
+ if (tick - sat.lastPassTick > DETECTION_FADE_TICKS) continue
+ const start = { lat: sat.groundTrack.startLat, lng: sat.groundTrack.startLng }
+ const end = { lat: sat.groundTrack.endLat, lng: sat.groundTrack.endLng }
+ if (pointToLineDistKm(unit.position, start, end) <= sat.swathWidth_km / 2) return 'tracked'
+ }
+ return 'detected'
+}
+
+function isElintDetected(ownSensorUnits: Unit[], emitter: Unit, sigintMultiplier: number): boolean {
+ let radarRange = 0
+ for (const s of emitter.sensors) {
+ if (s.type === 'radar' && s.range_km > radarRange) radarRange = s.range_km
+ }
+ if (radarRange <= 0) return false
+ const elintRange = radarRange * sigintMultiplier
+ for (const own of ownSensorUnits) {
+ if (haversine(own.position, emitter.position) <= elintRange) return true
+ }
+ return false
+}
+
+// ---------------------------------------------------------------------------
+// Event-driven reveals — applied every tick on the tick they happen
+// ---------------------------------------------------------------------------
+
+function applyEventReveals(state: GameState): void {
+ const tick = state.time.tick
+ const events = state.events
+ for (let i = events.length - 1; i >= 0; i--) {
+ const e = events[i]
+ if (e.tick !== tick) break
+ if (e.type === 'MISSILE_LAUNCHED') {
+ const launcher = state.units.get(e.launcherId)
+ if (!launcher) continue
+ for (const nation of Object.values(state.nations)) {
+ if (nation.id !== launcher.nation) {
+ revealContact(state, nation.id as string, launcher, 'tracked')
+ }
+ }
+ } else if (e.type === 'MINE_CONTACT') {
+ const minefield = state.units.get(e.minefieldId)
+ const target = state.units.get(e.targetId)
+ if (minefield && target && target.nation !== minefield.nation) {
+ revealContact(state, target.nation as string, minefield, 'identified')
+ }
+ }
+ }
+}
+
+function revealContact(state: GameState, observer: string, unit: Unit, level: VisibilityLevel): void {
+ const tick = state.time.tick
+ state.visibility ??= {}
+ const contacts = (state.visibility[observer] ??= {})
+ const meta = metaFor(observer)
+
+ const contact = contacts[unit.id]
+ if (!contact) {
+ contacts[unit.id] = newContact(meta, unit, level, tick)
+ return
+ }
+ const anchor = anchorOf(contact, meta.get(unit.id))
+ const floor = decayFloor(unit, contact, anchor)
+ let decayed = decayedLevel(anchor, tick - contact.lastSeenTick)
+ if (LEVEL_RANK[decayed] < LEVEL_RANK[floor]) decayed = floor
+ if (LEVEL_RANK[level] >= LEVEL_RANK[decayed]) {
+ refreshContact(contact, meta, unit, level, tick)
+ }
+}
+
+// ---------------------------------------------------------------------------
+// Contact bookkeeping
+// ---------------------------------------------------------------------------
+
+function newContact(meta: Map, unit: Unit, level: VisibilityLevel, tick: number): VisibilityContact {
+ const contact: VisibilityContact = {
+ level,
+ lastSeenTick: tick,
+ lastKnownPosition: { ...unit.position },
+ }
+ setAnchor(meta, unit.id, level)
+ contact.pinned = decayFloor(unit, contact, level) !== 'unseen'
+ return contact
+}
+
+function refreshContact(
+ contact: VisibilityContact,
+ meta: Map,
+ unit: Unit,
+ level: VisibilityLevel,
+ tick: number,
+): void {
+ contact.level = level
+ contact.lastSeenTick = tick
+ contact.lastKnownPosition = { ...unit.position }
+ setAnchor(meta, unit.id, level)
+ contact.pinned = decayFloor(unit, contact, level) !== 'unseen'
+}
+
+function setAnchor(meta: Map, unitId: UnitId, level: VisibilityLevel): void {
+ const m = meta.get(unitId)
+ if (m) m.anchor = level
+ else meta.set(unitId, { anchor: level, humintUntil: 0 })
+}
+
+function anchorOf(contact: VisibilityContact, m: ContactMeta | undefined): VisibilityLevel {
+ // After save/load the meta map is empty — fall back to the loaded level
+ return m && m.anchor !== 'unseen' ? m.anchor : contact.level
+}
+
+function maxLevel(a: VisibilityLevel, b: VisibilityLevel): VisibilityLevel {
+ return LEVEL_RANK[a] >= LEVEL_RANK[b] ? a : b
+}
+
+// ---------------------------------------------------------------------------
+// Decay
+// ---------------------------------------------------------------------------
+
+function decayedLevel(anchor: VisibilityLevel, age: number): VisibilityLevel {
+ if (anchor === 'identified') {
+ if (age < IDENTIFIED_DECAY_TICKS) return 'identified'
+ if (age < IDENTIFIED_DECAY_TICKS + TRACKED_DECAY_TICKS) return 'tracked'
+ if (age < IDENTIFIED_DECAY_TICKS + TRACKED_DECAY_TICKS + DETECTED_DECAY_TICKS) return 'detected'
+ return 'unseen'
+ }
+ if (anchor === 'tracked') {
+ if (age < TRACKED_DECAY_TICKS) return 'tracked'
+ if (age < TRACKED_DECAY_TICKS + DETECTED_DECAY_TICKS) return 'detected'
+ return 'unseen'
+ }
+ if (anchor === 'detected') {
+ return age < DETECTED_DECAY_TICKS ? 'detected' : 'unseen'
+ }
+ return 'unseen'
+}
+
+/** Lowest level this contact may decay to — fixed sites don't walk away */
+function decayFloor(unit: Unit, contact: VisibilityContact, anchor: VisibilityLevel): VisibilityLevel {
+ const everIdentified = contact.level === 'identified' || anchor === 'identified'
+ switch (unit.category) {
+ case 'airbase':
+ case 'naval_base':
+ return everIdentified ? 'identified' : 'detected'
+ case 'minefield':
+ return everIdentified ? 'identified' : 'unseen'
+ case 'sam_site': {
+ const p = contact.lastKnownPosition
+ const moved = unit.position.lng !== p.lng || unit.position.lat !== p.lat
+ return moved ? 'unseen' : 'detected'
+ }
+ default:
+ return 'unseen'
+ }
+}
+
+// ---------------------------------------------------------------------------
+// Snapshot queries
+// ---------------------------------------------------------------------------
+
+export interface ViewVisibility {
+ level: VisibilityLevel
+ stale: boolean
+ /** Position to show the observer (lastKnownPosition when the live track is lost) */
+ position: Position
+}
+
+/**
+ * How `observer` currently sees `unit`. Returns null when the unit should be excluded
+ * from the observer's snapshot entirely (level 'unseen').
+ */
+export function getViewVisibility(state: GameState, observer: NationId, unit: Unit): ViewVisibility | null {
+ if (unit.nation === observer) {
+ return { level: 'identified', stale: false, position: unit.position }
+ }
+ const contact = state.visibility?.[observer as string]?.[unit.id]
+ if (!contact || contact.level === 'unseen') return null
+ const live = contact.level === 'tracked' || contact.level === 'identified'
+ return {
+ level: contact.level,
+ stale: !live,
+ position: live ? unit.position : contact.lastKnownPosition,
+ }
+}
+
+const CONTACT_NAMES: Record = {
+ airbase: 'Unknown installation',
+ naval_base: 'Unknown installation',
+ sam_site: 'Unknown emitter',
+ missile_battery: 'Unknown vehicle group',
+ aircraft: 'Air contact',
+ ship: 'Surface contact',
+ submarine: 'Submerged contact',
+ carrier_group: 'Surface group',
+ minefield: 'Suspected minefield',
+}
+
+/** Generic display name for a low-confidence contact */
+export function contactDisplayName(category: UnitCategory): string {
+ return CONTACT_NAMES[category] ?? 'Unknown contact'
+}
diff --git a/src/engine/systems/war-support.ts b/src/engine/systems/war-support.ts
new file mode 100644
index 0000000..3113842
--- /dev/null
+++ b/src/engine/systems/war-support.ts
@@ -0,0 +1,476 @@
+import type { GameEvent, GameState, NationId, UnitCategory, WarStats } from '@/types/game'
+import type { ObjectiveStatus } from '@/types/view'
+import { weaponSpecs } from '@/data/weapons/missiles'
+
+/**
+ * War support (political will) and war termination: drains from losses, duration and
+ * economic pain; capitulation at 0; ceasefire offers/acceptance; scenario objectives;
+ * the GameOverReport for the debrief screen. Design: docs/plans/game-loop-v2.md §2.
+ */
+
+// ─── Tuning (design §2) ─────────────────────────────────────────
+export const WAR_SUPPORT_CRITICAL_THRESHOLD = 35
+
+const EVAL_INTERVAL_TICKS = 60
+const TICKS_PER_HOUR = 3_600
+const HORMUZ_LANE_ID = 'hormuz'
+
+const UNIT_LOSS_DRAIN: Record = {
+ carrier_group: 12,
+ naval_base: 6,
+ airbase: 6,
+ ship: 4,
+ submarine: 4,
+ sam_site: 2,
+ missile_battery: 1.5,
+ aircraft: 1,
+ minefield: 0.5,
+}
+const WAR_DURATION_DRAIN_PER_HOUR = 0.15
+const LOW_RESERVES_FRACTION = 0.25
+const LOW_RESERVES_DRAIN_PER_HOUR = 0.3
+const OIL_PRICE_DRAIN_THRESHOLD = 110
+const OIL_PRICE_DRAIN_PER_HOUR = 0.2
+const HORMUZ_BLOCKED_DRAIN_PER_HOUR = 0.2
+const KILL_GAIN = 0.5
+const KILL_GAIN_CAP = 10
+const CEASEFIRE_ACCEPT_MARGIN = 10
+const CEASEFIRE_LOW_STOCK_FRACTION = 0.25
+const CEASEFIRE_REOFFER_COOLDOWN_TICKS = 6 * TICKS_PER_HOUR
+const OBJECTIVE_GOOD_THRESHOLD = 0.66
+const OBJECTIVE_CONTESTED_THRESHOLD = 0.33
+
+// ─── Module-level state — must be resettable for save/load ──────
+
+interface WarBaselines {
+ reservesAtStart: Record
+ offensiveStockAtStart: Record
+ iranBatteries: number
+ usaNavalUnits: number
+}
+
+let seeded = false
+let lastSeenEvent: GameEvent | null = null
+let stats: WarStats = emptyStats()
+let baselines: WarBaselines | null = null
+let killGains: Record = {}
+let criticalEmitted: Record = {}
+let lastRejectionTick: Record = {}
+let cachedObjectives: ObjectiveStatus[] = []
+let cachedObjectivesBucket = -1
+let frozenObjectives: ObjectiveStatus[] | null = null
+
+function emptyStats(): WarStats {
+ return {
+ durationTicks: 0,
+ unitsLost: {},
+ missilesFired: {},
+ missilesIntercepted: {},
+ oilPeak: 0,
+ hormuzReducedTicks: 0,
+ hormuzBlockedTicks: 0,
+ }
+}
+
+export function resetWarSupportState(): void {
+ seeded = false
+ lastSeenEvent = null
+ stats = emptyStats()
+ baselines = null
+ killGains = {}
+ criticalEmitted = {}
+ lastRejectionTick = {}
+ cachedObjectives = []
+ cachedObjectivesBucket = -1
+ frozenObjectives = null
+}
+
+// ─── Event watermark over state.events ──────────────────────────
+
+// A loaded save's warStatus already accounts for its event history; a fresh game
+// (no warStatus yet) must count everything emitted since init.
+function seedWatermark(state: GameState): void {
+ seeded = true
+ if (state.warStatus !== undefined && state.events.length > 0) {
+ lastSeenEvent = state.events[state.events.length - 1]
+ }
+}
+
+// The 2000-event cap only ever splices a prefix, so if the watermark object is gone
+// everything older is gone with it and the whole remaining array is unseen.
+function takeNewEvents(state: GameState): GameEvent[] {
+ const events = state.events
+ let start = 0
+ if (lastSeenEvent) {
+ for (let i = events.length - 1; i >= 0; i--) {
+ if (events[i] === lastSeenEvent) {
+ start = i + 1
+ break
+ }
+ }
+ }
+ const fresh = events.slice(start)
+ if (events.length > 0) lastSeenEvent = events[events.length - 1]
+ return fresh
+}
+
+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)
+}
+
+// ─── Helpers ────────────────────────────────────────────────────
+
+function clampSupport(value: number): number {
+ return Math.min(100, Math.max(0, value))
+}
+
+function clamp01(value: number): number {
+ return Math.min(1, Math.max(0, value))
+}
+
+function isNavalCategory(category: UnitCategory): boolean {
+ return category === 'ship' || category === 'submarine' || category === 'carrier_group'
+}
+
+function countOffensiveMissiles(state: GameState, nationId: string): number {
+ let total = 0
+ for (const unit of state.units.values()) {
+ if (unit.nation !== nationId || unit.status === 'destroyed') continue
+ for (const w of unit.weapons) {
+ const spec = weaponSpecs[w.weaponId]
+ if (spec && spec.type !== 'sam') total += w.count
+ }
+ }
+ return total
+}
+
+function ensureBaselines(state: GameState): void {
+ if (baselines) return
+ const reservesAtStart: Record = {}
+ const offensiveStockAtStart: Record = {}
+ for (const nation of Object.values(state.nations)) {
+ reservesAtStart[nation.id] = nation.economy.reserves_billions
+ offensiveStockAtStart[nation.id] = countOffensiveMissiles(state, nation.id)
+ }
+ let iranBatteries = 0
+ let usaNavalUnits = 0
+ for (const unit of state.units.values()) {
+ if (unit.status === 'destroyed') continue
+ if (unit.nation === 'iran' && unit.category === 'missile_battery') iranBatteries++
+ if (unit.nation === 'usa' && isNavalCategory(unit.category)) usaNavalUnits++
+ }
+ baselines = { reservesAtStart, offensiveStockAtStart, iranBatteries, usaNavalUnits }
+}
+
+function detectWarStarts(state: GameState): void {
+ for (const nation of Object.values(state.nations)) {
+ if (nation.atWar.length === 0) continue
+ const ws = (state.warStatus ??= {})
+ const status = (ws[nation.id] ??= { warSupport: 100 })
+ if (status.warStartTick == null) {
+ status.warStartTick = state.time.tick
+ ensureBaselines(state)
+ }
+ }
+}
+
+// ─── Per-minute evaluation ──────────────────────────────────────
+
+export function processWarSupport(state: GameState): void {
+ if (!seeded) seedWatermark(state)
+ detectWarStarts(state)
+ if (state.time.tick % EVAL_INTERVAL_TICKS !== 0) return
+ evaluate(state)
+}
+
+function evaluate(state: GameState): void {
+ const tick = state.time.tick
+ const newEvents = takeNewEvents(state)
+ const ws = (state.warStatus ??= {})
+
+ for (const e of newEvents) {
+ switch (e.type) {
+ case 'MISSILE_LAUNCHED': {
+ const nation = state.units.get(e.launcherId)?.nation
+ if (nation) stats.missilesFired[nation] = (stats.missilesFired[nation] ?? 0) + 1
+ break
+ }
+ case 'MISSILE_INTERCEPTED': {
+ const nation = state.units.get(e.interceptorId)?.nation
+ if (nation) stats.missilesIntercepted[nation] = (stats.missilesIntercepted[nation] ?? 0) + 1
+ break
+ }
+ case 'POINT_DEFENSE_KILL': {
+ const nation = state.units.get(e.unitId)?.nation
+ if (nation) stats.missilesIntercepted[nation] = (stats.missilesIntercepted[nation] ?? 0) + 1
+ break
+ }
+ case 'UNIT_DESTROYED': {
+ const unit = state.units.get(e.unitId)
+ if (!unit) break
+ stats.unitsLost[unit.nation] = (stats.unitsLost[unit.nation] ?? 0) + 1
+ const victim = state.nations[unit.nation]
+ if (!victim || victim.atWar.length === 0) break
+ const status = (ws[unit.nation] ??= { warSupport: 100 })
+ status.warSupport = clampSupport(status.warSupport - UNIT_LOSS_DRAIN[unit.category])
+ for (const enemyId of victim.atWar) {
+ const gained = killGains[enemyId] ?? 0
+ const gain = Math.min(KILL_GAIN, KILL_GAIN_CAP - gained)
+ if (gain <= 0) continue
+ killGains[enemyId] = gained + gain
+ const enemyStatus = (ws[enemyId] ??= { warSupport: 100 })
+ enemyStatus.warSupport = clampSupport(enemyStatus.warSupport + gain)
+ }
+ break
+ }
+ }
+ }
+
+ const lane = state.shippingLanes.get(HORMUZ_LANE_ID)
+ let anyWar = false
+ for (const nation of Object.values(state.nations)) {
+ if (nation.atWar.length === 0) continue
+ anyWar = true
+ const status = (ws[nation.id] ??= { warSupport: 100 })
+ let drainPerHour = WAR_DURATION_DRAIN_PER_HOUR
+ const startReserves = baselines?.reservesAtStart[nation.id]
+ if (startReserves != null && startReserves > 0 &&
+ nation.economy.reserves_billions < startReserves * LOW_RESERVES_FRACTION) {
+ drainPerHour += LOW_RESERVES_DRAIN_PER_HOUR
+ }
+ if (nation.id === 'usa' && (nation.economy.oilPrice_per_barrel ?? 0) > OIL_PRICE_DRAIN_THRESHOLD) {
+ drainPerHour += OIL_PRICE_DRAIN_PER_HOUR
+ }
+ if (nation.id === 'iran' && lane?.status === 'blocked') {
+ drainPerHour += HORMUZ_BLOCKED_DRAIN_PER_HOUR
+ }
+ status.warSupport = clampSupport(status.warSupport - drainPerHour * (EVAL_INTERVAL_TICKS / TICKS_PER_HOUR))
+ }
+
+ if (anyWar) {
+ const oil = Object.values(state.nations)[0]?.economy.oilPrice_per_barrel
+ if (oil != null) stats.oilPeak = Math.max(stats.oilPeak, oil)
+ if (lane?.status === 'blocked') stats.hormuzBlockedTicks += EVAL_INTERVAL_TICKS
+ else if (lane?.status === 'reduced') stats.hormuzReducedTicks += EVAL_INTERVAL_TICKS
+ }
+
+ for (const nation of Object.values(state.nations)) {
+ if (nation.atWar.length === 0) continue
+ const status = ws[nation.id]
+ if (!status) continue
+ if (status.warSupport <= WAR_SUPPORT_CRITICAL_THRESHOLD) {
+ if (!criticalEmitted[nation.id]) {
+ criticalEmitted[nation.id] = true
+ emit(state, { type: 'WAR_SUPPORT_CRITICAL', nation: nation.id, support: status.warSupport, tick })
+ }
+ } else {
+ criticalEmitted[nation.id] = false
+ }
+ if (status.warSupport <= 0) {
+ endWar(state, 'capitulation', nation.id, [nation.id, ...nation.atWar])
+ break
+ }
+ }
+}
+
+// ─── War termination ────────────────────────────────────────────
+
+function endWar(
+ state: GameState,
+ outcome: 'capitulation' | 'ceasefire',
+ loser: NationId | undefined,
+ participants: NationId[],
+): void {
+ const tick = state.time.tick
+ const involved = [...new Set(participants)]
+
+ if (!state.gameOver) frozenObjectives = computeObjectives(state)
+
+ let warStart = Infinity
+ for (const id of involved) {
+ const start = state.warStatus?.[id]?.warStartTick
+ if (start != null && start < warStart) warStart = start
+ }
+ const durationTicks = Number.isFinite(warStart) ? tick - warStart : 0
+
+ for (const id of involved) {
+ const nation = state.nations[id]
+ if (nation) nation.atWar = nation.atWar.filter(other => !involved.includes(other))
+ const status = state.warStatus?.[id]
+ if (status) {
+ status.ceasefireOffered = false
+ status.warStartTick = undefined
+ }
+ delete killGains[id]
+ delete criticalEmitted[id]
+ }
+
+ for (const unit of state.units.values()) {
+ if (involved.includes(unit.nation)) unit.roe = 'hold_fire'
+ }
+
+ emit(state, { type: 'WAR_ENDED', outcome, loser, tick })
+
+ if (!state.gameOver) {
+ state.gameOver = {
+ outcome: outcome === 'ceasefire' ? 'ceasefire' : loser === state.playerNation ? 'defeat' : 'victory',
+ loser,
+ endTick: tick,
+ stats: freezeStats(durationTicks, involved),
+ }
+ }
+
+ baselines = null
+}
+
+function freezeStats(durationTicks: number, involved: NationId[]): WarStats {
+ const fill = (record: Record): Record => {
+ const out: Record = {}
+ for (const id of involved) out[id] = record[id] ?? 0
+ for (const [key, value] of Object.entries(record)) out[key] = value
+ return out
+ }
+ return {
+ durationTicks,
+ unitsLost: fill(stats.unitsLost),
+ missilesFired: fill(stats.missilesFired),
+ missilesIntercepted: fill(stats.missilesIntercepted),
+ oilPeak: stats.oilPeak,
+ hormuzReducedTicks: stats.hormuzReducedTicks,
+ hormuzBlockedTicks: stats.hormuzBlockedTicks,
+ }
+}
+
+/** Player (or AI) puts a ceasefire offer on the table; the other side decides */
+export function offerCeasefire(state: GameState, by: NationId, target: NationId): void {
+ const offerer = state.nations[by]
+ const decider = state.nations[target]
+ if (!offerer || !decider) return
+ if (!offerer.atWar.includes(target)) return
+
+ const tick = state.time.tick
+ const lastRejection = lastRejectionTick[by]
+ if (lastRejection != null && tick - lastRejection < CEASEFIRE_REOFFER_COOLDOWN_TICKS) return
+
+ ensureBaselines(state)
+ const support = getWarSupport(state)
+ const stockNow = countOffensiveMissiles(state, target)
+ const stockAtStart = baselines?.offensiveStockAtStart[target] ?? stockNow
+ const lowStock = stockNow < stockAtStart * CEASEFIRE_LOW_STOCK_FRACTION
+ const accepts = support[target] < support[by] + CEASEFIRE_ACCEPT_MARGIN || lowStock
+
+ if (accepts) {
+ endWar(state, 'ceasefire', undefined, [by, target])
+ } else {
+ lastRejectionTick[by] = tick
+ emit(state, { type: 'CEASEFIRE_REJECTED', by: target, tick })
+ }
+}
+
+/** Accept a standing offer (or mutually stand down) — ends the war between the two nations */
+export function acceptCeasefire(state: GameState, by: NationId, target: NationId): void {
+ const a = state.nations[by]
+ const b = state.nations[target]
+ if (!a || !b) return
+ if (a.atWar.includes(target) || b.atWar.includes(by)) {
+ endWar(state, 'ceasefire', undefined, [by, target])
+ return
+ }
+ a.atWar = a.atWar.filter(n => n !== target)
+ b.atWar = b.atWar.filter(n => n !== by)
+}
+
+/** Player gives up — immediate defeat */
+export function resign(state: GameState): void {
+ const player = state.playerNation
+ const nation = state.nations[player]
+ if (!nation) return
+ endWar(state, 'capitulation', player, [player, ...nation.atWar])
+}
+
+/** Current war support per nation id (defaults to 100 before any war) */
+export function getWarSupport(state: GameState): Record {
+ const out: Record = {}
+ for (const id of Object.keys(state.nations)) {
+ out[id] = state.warStatus?.[id]?.warSupport ?? 100
+ }
+ return out
+}
+
+// ─── Objectives ─────────────────────────────────────────────────
+
+/** Scenario objectives for the player's side (empty at peace; frozen once the war is decided) */
+export function getObjectives(state: GameState): ObjectiveStatus[] {
+ if (state.gameOver) return frozenObjectives ?? []
+ const bucket = Math.floor(state.time.tick / EVAL_INTERVAL_TICKS)
+ if (bucket !== cachedObjectivesBucket) {
+ cachedObjectivesBucket = bucket
+ cachedObjectives = computeObjectives(state)
+ }
+ return cachedObjectives
+}
+
+function computeObjectives(state: GameState): ObjectiveStatus[] {
+ const player = state.playerNation
+ if (player !== 'usa' && player !== 'iran') return []
+ const nation = state.nations[player]
+ const warStartTick = state.warStatus?.[player]?.warStartTick
+ if (!nation || nation.atWar.length === 0 || warStartTick == null) return []
+ ensureBaselines(state)
+
+ const warTicks = Math.max(1, state.time.tick - warStartTick)
+ let aliveIranBatteries = 0
+ let aliveUsaNaval = 0
+ let usaCarrierLost = false
+ for (const unit of state.units.values()) {
+ const destroyed = unit.status === 'destroyed'
+ if (unit.nation === 'iran' && unit.category === 'missile_battery' && !destroyed) aliveIranBatteries++
+ if (unit.nation === 'usa' && isNavalCategory(unit.category) && !destroyed) aliveUsaNaval++
+ if (unit.nation === 'usa' && unit.category === 'carrier_group' && destroyed) usaCarrierLost = true
+ }
+ const initialIranBatteries = baselines?.iranBatteries ?? aliveIranBatteries
+ const initialUsaNaval = baselines?.usaNavalUnits ?? aliveUsaNaval
+
+ if (player === 'usa') {
+ const openShare = clamp01(1 - stats.hormuzBlockedTicks / warTicks)
+ const batteriesKilled = Math.max(0, initialIranBatteries - aliveIranBatteries)
+ const batteryProgress = initialIranBatteries > 0 ? clamp01(batteriesKilled / initialIranBatteries) : 1
+ return [
+ objective('hormuz_open', 'Keep Hormuz open', openShare,
+ `Open ${Math.round(openShare * 100)}% of the war`),
+ objective('destroy_missile_force', "Destroy Iran's strategic missile force", batteryProgress,
+ `${batteriesKilled}/${initialIranBatteries} batteries destroyed`),
+ objective('preserve_carrier', 'Preserve the carrier group', usaCarrierLost ? 0 : 1,
+ usaCarrierLost ? 'Carrier group lost' : 'Carrier group intact'),
+ ]
+ }
+
+ const closedShare = clamp01((stats.hormuzBlockedTicks + stats.hormuzReducedTicks) / warTicks)
+ const navalKilled = Math.max(0, initialUsaNaval - aliveUsaNaval)
+ const attritionProgress = initialUsaNaval > 0 ? clamp01(navalKilled / initialUsaNaval) : 1
+ const preserveProgress = initialIranBatteries > 0 ? clamp01(aliveIranBatteries / initialIranBatteries) : 1
+ return [
+ objective('close_strait', 'Close the Strait', closedShare,
+ `Disrupted ${Math.round(closedShare * 100)}% of the war`),
+ objective('attrit_us_fleet', 'Attrit the US fleet', attritionProgress,
+ `${navalKilled}/${initialUsaNaval} US naval units destroyed`),
+ objective('preserve_strategic', 'Preserve strategic forces', preserveProgress,
+ `${aliveIranBatteries}/${initialIranBatteries} batteries surviving`),
+ ]
+}
+
+function objective(id: string, label: string, progress: number, detail: string): ObjectiveStatus {
+ return {
+ id,
+ label,
+ progress,
+ status: progress >= OBJECTIVE_GOOD_THRESHOLD ? 'good'
+ : progress >= OBJECTIVE_CONTESTED_THRESHOLD ? 'contested'
+ : 'bad',
+ detail,
+ }
+}
diff --git a/src/store/__tests__/game-store.test.ts b/src/store/__tests__/game-store.test.ts
index 0073c3e..8e65d04 100644
--- a/src/store/__tests__/game-store.test.ts
+++ b/src/store/__tests__/game-store.test.ts
@@ -19,6 +19,9 @@ function makeViewState(
events: [],
pendingEventCount: 0,
satelliteDetectedUnitIds: [],
+ warSupport: {},
+ gameOver: null,
+ objectives: [],
...overrides,
}
}
diff --git a/src/store/__tests__/ui-store.test.ts b/src/store/__tests__/ui-store.test.ts
index 1ea2973..e0cee06 100644
--- a/src/store/__tests__/ui-store.test.ts
+++ b/src/store/__tests__/ui-store.test.ts
@@ -17,6 +17,10 @@ beforeEach(() => {
if (store.mapMode !== 'dark') store.cycleMapMode()
// Reset left panel
store.setLeftPanel(null)
+ useUIStore.setState({
+ mapFocus: null,
+ autoPause: { warDeclared: true, ownUnitDestroyed: true, ceasefireOffered: true },
+ })
})
// ── Selection tests ───────────────────────────────────────────────
@@ -190,6 +194,47 @@ describe('toggleIntel', () => {
})
})
+// ── Map focus tests ───────────────────────────────────────────────
+
+describe('focusMap', () => {
+ it('stores the requested focus with an incrementing nonce', () => {
+ useUIStore.getState().focusMap(56.3, 26.5, 7)
+ expect(useUIStore.getState().mapFocus).toEqual({ lng: 56.3, lat: 26.5, zoom: 7, nonce: 1 })
+
+ useUIStore.getState().focusMap(56.3, 26.5, 7)
+ expect(useUIStore.getState().mapFocus?.nonce).toBe(2)
+ })
+
+ it('allows omitting zoom', () => {
+ useUIStore.getState().focusMap(55, 25)
+ expect(useUIStore.getState().mapFocus).toMatchObject({ lng: 55, lat: 25, zoom: undefined })
+ })
+})
+
+// ── Auto-pause tests ──────────────────────────────────────────────
+
+describe('toggleAutoPause', () => {
+ it('defaults all triggers on', () => {
+ expect(useUIStore.getState().autoPause).toEqual({
+ warDeclared: true,
+ ownUnitDestroyed: true,
+ ceasefireOffered: true,
+ })
+ })
+
+ it('flips one trigger without touching the others', () => {
+ useUIStore.getState().toggleAutoPause('ownUnitDestroyed')
+ expect(useUIStore.getState().autoPause).toEqual({
+ warDeclared: true,
+ ownUnitDestroyed: false,
+ ceasefireOffered: true,
+ })
+
+ useUIStore.getState().toggleAutoPause('ownUnitDestroyed')
+ expect(useUIStore.getState().autoPause.ownUnitDestroyed).toBe(true)
+ })
+})
+
// ── Left panel tests ──────────────────────────────────────────────
describe('setLeftPanel', () => {
diff --git a/src/store/game-store.ts b/src/store/game-store.ts
index 8ff8810..6dc3d88 100644
--- a/src/store/game-store.ts
+++ b/src/store/game-store.ts
@@ -19,6 +19,9 @@ const emptyViewState: GameViewState = {
events: [],
pendingEventCount: 0,
satelliteDetectedUnitIds: [],
+ warSupport: {},
+ gameOver: null,
+ objectives: [],
}
interface GameStore {
diff --git a/src/store/ui-store.ts b/src/store/ui-store.ts
index 49868fb..8ac4fbd 100644
--- a/src/store/ui-store.ts
+++ b/src/store/ui-store.ts
@@ -4,6 +4,20 @@ import type { MapMode } from '@/styles/map-providers'
export type LeftPanel = 'orbat' | 'stats' | 'economy' | null
+export interface MapFocus {
+ lng: number
+ lat: number
+ zoom?: number
+ /** Increments per request so refocusing the same spot still triggers consumers */
+ nonce: number
+}
+
+export interface AutoPauseSettings {
+ warDeclared: boolean
+ ownUnitDestroyed: boolean
+ ceasefireOffered: boolean
+}
+
interface UIState {
// Selection
selectedUnitIds: Set
@@ -27,6 +41,12 @@ interface UIState {
// Right-side panels (independent toggles)
showIntel: boolean
+ // Camera focus request (consumed by GameMap)
+ mapFocus: MapFocus | null
+
+ // Auto-pause triggers (session-only, applied by AlertFeed)
+ autoPause: AutoPauseSettings
+
// Actions — selection
selectUnit: (id: UnitId | null) => void
toggleUnitSelection: (id: UnitId) => void
@@ -48,6 +68,12 @@ interface UIState {
// Right-side panels
toggleIntel: () => void
+
+ // Camera focus
+ focusMap: (lng: number, lat: number, zoom?: number) => void
+
+ // Auto-pause
+ toggleAutoPause: (key: keyof AutoPauseSettings) => void
}
export const useUIStore = create((set) => ({
@@ -64,6 +90,8 @@ export const useUIStore = create((set) => ({
showStats: false,
showEconomy: false,
showIntel: false,
+ mapFocus: null,
+ autoPause: { warDeclared: true, ownUnitDestroyed: true, ceasefireOffered: true },
// Selection
selectUnit: (id) => set({
@@ -98,6 +126,14 @@ export const useUIStore = create((set) => ({
toggleIntel: () => set((s) => ({ showIntel: !s.showIntel })),
+ focusMap: (lng, lat, zoom) => set((s) => ({
+ mapFocus: { lng, lat, zoom, nonce: (s.mapFocus?.nonce ?? 0) + 1 },
+ })),
+
+ toggleAutoPause: (key) => set((s) => ({
+ autoPause: { ...s.autoPause, [key]: !s.autoPause[key] },
+ })),
+
// Panels — radio group
setLeftPanel: (panel) => set({
leftPanel: panel,
diff --git a/src/types/commands.ts b/src/types/commands.ts
index 8c9f0c8..35a982a 100644
--- a/src/types/commands.ts
+++ b/src/types/commands.ts
@@ -12,3 +12,5 @@ export type Command =
| { type: 'SET_HEADING'; unitId: UnitId; heading: number }
| { type: 'SET_INTEL_BUDGET'; budget: IntelBudget }
| { type: 'SET_DRONE_MISSION'; unitId: UnitId; mission: 'military' | 'shipping_interdiction' }
+ | { type: 'OFFER_CEASEFIRE'; target: NationId }
+ | { type: 'RESIGN' }
diff --git a/src/types/game.ts b/src/types/game.ts
index c2674ac..cf98ba1 100644
--- a/src/types/game.ts
+++ b/src/types/game.ts
@@ -4,6 +4,47 @@ export type WeaponId = string
export type DetectionState = 'unknown' | 'estimated' | 'detected' | 'tracked'
+/** Fog-of-war contact quality, in escalating order */
+export type VisibilityLevel = 'unseen' | 'detected' | 'tracked' | 'identified'
+
+export interface VisibilityContact {
+ level: VisibilityLevel
+ /** Tick when any sensor last refreshed this contact */
+ lastSeenTick: number
+ /** Position captured at lastSeenTick — shown when the live track is lost */
+ lastKnownPosition: Position
+ /** True for contacts whose level can no longer decay below 'detected' (fixed sites) */
+ pinned?: boolean
+}
+
+/** Political will to keep fighting — the win/lose meter */
+export interface WarStatus {
+ /** 0-100; at 0 the nation capitulates */
+ warSupport: number
+ warStartTick?: number
+ /** Set while this nation has an unanswered ceasefire offer on the table */
+ ceasefireOffered?: boolean
+}
+
+export interface WarStats {
+ durationTicks: number
+ unitsLost: Record
+ missilesFired: Record
+ missilesIntercepted: Record
+ oilPeak: number
+ /** Game seconds the Hormuz lane spent in each non-open state */
+ hormuzReducedTicks: number
+ hormuzBlockedTicks: number
+}
+
+export interface GameOverReport {
+ outcome: 'victory' | 'defeat' | 'ceasefire'
+ /** Nation whose war support collapsed (capitulation outcomes) */
+ loser?: NationId
+ endTick: number
+ stats: WarStats
+}
+
export interface Position {
lng: number
lat: number
@@ -292,6 +333,12 @@ export interface GameState {
shippingLanes: Map
/** Cumulative missile impacts + unit losses per nation — combat writes, enemy AI reads for escalation */
attackCounters?: Record
+ /** Fog of war: contacts on ENEMY units, keyed by observing nation then unit id */
+ visibility?: Record>
+ /** Per-nation war-support / termination state */
+ warStatus?: Record
+ /** Set once the war has been resolved — the world keeps ticking but the game is decided */
+ gameOver?: GameOverReport
}
export type GameEvent =
@@ -309,3 +356,7 @@ export type GameEvent =
| { type: 'SHIPPING_LANE_STATUS_CHANGE'; laneId: string; newStatus: ShippingLane['status']; suppressionFactor: number; tick: number }
| { type: 'MINE_CONTACT'; minefieldId: UnitId; targetId: UnitId; damage: number; tick: number }
| { type: 'SUPPLY_LINE_INTERDICTED'; lineId: string; threatUnitId: UnitId; healthAfter: number; tick: number }
+ | { type: 'WAR_SUPPORT_CRITICAL'; nation: NationId; support: number; tick: number }
+ | { type: 'CEASEFIRE_OFFERED'; by: NationId; tick: number }
+ | { type: 'CEASEFIRE_REJECTED'; by: NationId; tick: number }
+ | { type: 'WAR_ENDED'; outcome: 'ceasefire' | 'capitulation'; loser?: NationId; tick: number }
diff --git a/src/types/view.ts b/src/types/view.ts
index aa55261..502d631 100644
--- a/src/types/view.ts
+++ b/src/types/view.ts
@@ -1,5 +1,6 @@
import type {
GameEvent,
+ GameOverReport,
GameTime,
Missile,
Nation,
@@ -13,10 +14,21 @@ import type {
UnitCategory,
UnitId,
UnitStatus,
+ VisibilityLevel,
WeaponLoadout,
WeaponStock,
} from './game'
+/** Live status of one scenario objective, computed engine-side for the player's nation */
+export interface ObjectiveStatus {
+ id: string
+ label: string
+ /** 0-1 progress toward the player's goal */
+ progress: number
+ status: 'good' | 'contested' | 'bad'
+ detail: string
+}
+
/** Flat, serializable snapshot sent from Worker → Main at 30fps */
export interface GameViewState {
playerNation: NationId
@@ -32,6 +44,12 @@ export interface GameViewState {
pendingEventCount: number
/** Unit IDs recently detected by satellite passes (fades after ~60 ticks) */
satelliteDetectedUnitIds: string[]
+ /** Political will per nation id, 0-100 — the win/lose meter */
+ warSupport: Record
+ /** Set once the war has been decided; the world keeps ticking afterwards */
+ gameOver: GameOverReport | null
+ /** Scenario objectives for the player's side (empty at peace) */
+ objectives: ObjectiveStatus[]
}
export interface ViewUnit {
@@ -59,4 +77,8 @@ export interface ViewUnit {
radius_km?: number
mine_count?: number
droneMission?: 'military' | 'shipping_interdiction'
+ /** Fog of war: how well the player sees this unit. Own units are always 'identified'. */
+ visibility: VisibilityLevel
+ /** True when position is a last-known fix rather than a live track */
+ stale: boolean
}