From 70773ff295233cd5ad2f9840d77add6c1be87997 Mon Sep 17 00:00:00 2001 From: CarlBarl <145713155+CarlBarl@users.noreply.github.com> Date: Wed, 10 Jun 2026 21:40:40 +0200 Subject: [PATCH 1/3] Scaffold game-loop v2 contracts: fog of war + war termination Design in docs/plans/game-loop-v2.md. Adds the type contracts (VisibilityLevel/Contact, WarStatus, GameOverReport/WarStats, ObjectiveStatus, ViewUnit.visibility/stale, GameViewState warSupport/gameOver/objectives, OFFER_CEASEFIRE/RESIGN commands, ceasefire and war-support events), stub systems visibility.ts and war-support.ts, and the engine wiring: tick calls, command cases, visibility-aware snapshot scrubbing in toViewUnit, save/load fields, system resets. Stubs preserve current behavior (everything identified, support stays 100): tsc clean, 341 tests green. Co-Authored-By: Claude Fable 5 --- docs/plans/game-loop-v2.md | 122 ++++++++++++++++++ .../hud/__tests__/AlertFeed.test.tsx | 5 + .../map/__tests__/ContextMenu.test.tsx | 5 + .../map/layers/__tests__/ImpactLayer.test.ts | 2 + .../map/layers/__tests__/UnitLayer.test.ts | 2 + .../panels/__tests__/StatsPanel.test.tsx | 2 + src/engine/__tests__/attack-planner.test.ts | 2 + src/engine/game-engine.ts | 85 ++++++++---- src/engine/systems/visibility.ts | 71 ++++++++++ src/engine/systems/war-support.ts | 50 +++++++ src/store/__tests__/game-store.test.ts | 3 + src/store/game-store.ts | 3 + src/types/commands.ts | 2 + src/types/game.ts | 51 ++++++++ src/types/view.ts | 22 ++++ 15 files changed, 403 insertions(+), 24 deletions(-) create mode 100644 docs/plans/game-loop-v2.md create mode 100644 src/engine/systems/visibility.ts create mode 100644 src/engine/systems/war-support.ts 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/src/components/hud/__tests__/AlertFeed.test.tsx b/src/components/hud/__tests__/AlertFeed.test.tsx index 80171c7..1e5ae49 100644 --- a/src/components/hud/__tests__/AlertFeed.test.tsx +++ b/src/components/hud/__tests__/AlertFeed.test.tsx @@ -24,6 +24,8 @@ const lincoln = { roe: 'weapons_tight', waypoints: [], subordinateIds: [], + visibility: 'identified', + stale: false, } as ViewUnit function makeViewState(events: GameEvent[]): GameViewState { @@ -39,6 +41,9 @@ function makeViewState(events: GameEvent[]): GameViewState { events, pendingEventCount: 0, satelliteDetectedUnitIds: [], + warSupport: {}, + gameOver: null, + objectives: [], } } 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/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..a442d0c 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, } } diff --git a/src/components/panels/__tests__/StatsPanel.test.tsx b/src/components/panels/__tests__/StatsPanel.test.tsx index c1b6c90..64d0df6 100644 --- a/src/components/panels/__tests__/StatsPanel.test.tsx +++ b/src/components/panels/__tests__/StatsPanel.test.tsx @@ -23,6 +23,8 @@ function makeUnit(id: string, nation: string): ViewUnit { roe: 'weapons_free', waypoints: [], subordinateIds: [], + visibility: 'identified', + stale: false, } as ViewUnit } 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/visibility.ts b/src/engine/systems/visibility.ts new file mode 100644 index 0000000..d919a3d --- /dev/null +++ b/src/engine/systems/visibility.ts @@ -0,0 +1,71 @@ +import type { GameState, NationId, Position, Unit, UnitCategory, VisibilityLevel } from '@/types/game' +import type { ElevationGrid } from './elevation' +import type { SensorNetwork } from './sensor-network' +import type { EspionageResult } from './espionage' + +/** + * Fog of war. Maintains state.visibility — per observing nation, a contact map over + * enemy units — from radar coverage, satellites, HUMINT, ELINT and combat events. + * Design: docs/plans/game-loop-v2.md §1. + * + * STUB: not yet implemented — getViewVisibility currently reports everything as + * identified, which preserves pre-fog behavior. + */ +export function processVisibility( + _state: GameState, + _network: SensorNetwork | null, + _espionage: EspionageResult | null, + _grid: ElevationGrid | null, +): void { + // implemented by the visibility work package +} + +export function resetVisibilityState(): void { + // module-level state reset (worker reuses one engine across games) +} + +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) { + // STUB default: full knowledge until processVisibility is implemented + return { level: 'identified', stale: false, position: unit.position } + } + if (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..4c68099 --- /dev/null +++ b/src/engine/systems/war-support.ts @@ -0,0 +1,50 @@ +import type { GameState, NationId } from '@/types/game' +import type { ObjectiveStatus } from '@/types/view' + +/** + * 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. + * + * STUB: not yet implemented — war support stays at 100 and wars never end on their own. + */ +export function processWarSupport(_state: GameState): void { + // implemented by the war-support work package +} + +export function resetWarSupportState(): void { + // module-level state reset (worker reuses one engine across games) +} + +/** Player (or AI) puts a ceasefire offer on the table; the other side decides */ +export function offerCeasefire(_state: GameState, _by: NationId, _target: NationId): void { + // implemented by the war-support work package +} + +/** 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 as string] + const b = state.nations[target as string] + if (!a || !b) 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 { + // implemented by the war-support work package +} + +/** 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 +} + +/** Scenario objectives for the player's side (empty at peace or until implemented) */ +export function getObjectives(_state: GameState): ObjectiveStatus[] { + return [] +} 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/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/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 } From 7883371d60555d84f9af760d3fdd15dd215e574f Mon Sep 17 00:00:00 2001 From: CarlBarl <145713155+CarlBarl@users.noreply.github.com> Date: Wed, 10 Jun 2026 22:09:02 +0200 Subject: [PATCH 2/3] Implement game-loop v2: fog of war, war termination, product polish Fog of war (engine + UI): per-nation contact model (unseen/detected/ tracked/identified) fed by radar coverage with terrain LOS, satellite passes, HUMINT, ELINT scaled by the SIGINT budget, launch plumes and mine contacts; contacts decay to last-known fixes, fixed sites pin once found. Snapshots scrub enemy data by contact quality; the map renders dimmed tracks, desaturated "?" contacts and hollow contact rings; tooltips, unit panel and SITREP show only what the contact level grants (enemy inventory bars replaced with contact estimates). War termination: war support (political will) drains from losses, duration, depleted reserves, oil shock (USA) and a blocked Hormuz (Iran); capitulation at zero ends the war, low support makes the AI sue for peace and stand down; player can offer/accept ceasefire or resign. Objectives per side (Hormuz, strategic forces, fleet) shown in a TopBar chip; war support bars in the TopBar; full-screen debrief with stats (losses, missiles, interceptions, oil peak, duration) and main-menu return. Iran AI now tasks its closest Shahed batteries with shipping interdiction while at war. Product polish: ASHFALL COMMAND branding, click-to-zoom event feed, auto-pause options (war declared / own unit lost / ceasefire offered), lane display names, feed entries for the new war events. 426 tests green (85 new), tsc clean. Co-Authored-By: Claude Fable 5 --- index.html | 2 +- src/App.tsx | 16 + src/components/hud/AlertFeed.tsx | 227 ++++++++- src/components/hud/DebriefScreen.tsx | 260 ++++++++++ src/components/hud/ObjectivesPanel.tsx | 50 ++ src/components/hud/TopBar.tsx | 219 ++++++++- .../hud/__tests__/AlertFeed.test.tsx | 199 +++++++- .../hud/__tests__/DebriefScreen.test.tsx | 175 +++++++ src/components/hud/__tests__/TopBar.test.tsx | 170 +++++++ src/components/map/GameMap.tsx | 11 + src/components/map/InfoTooltip.tsx | 30 +- .../map/__tests__/InfoTooltip.test.tsx | 86 ++++ src/components/map/layers/UnitLayer.ts | 102 +++- .../map/layers/__tests__/UnitLayer.test.ts | 75 +++ src/components/menu/StartScreen.tsx | 4 +- src/components/panels/StatsPanel.tsx | 50 +- src/components/panels/UnitInfoPanel.tsx | 43 +- .../panels/__tests__/StatsPanel.test.tsx | 72 ++- .../panels/__tests__/UnitInfoPanel.test.tsx | 92 ++++ .../systems/__tests__/visibility.test.ts | 390 +++++++++++++++ .../systems/__tests__/war-support.test.ts | 273 +++++++++++ src/engine/systems/ai.ts | 73 ++- src/engine/systems/detection.ts | 2 +- src/engine/systems/satellites.ts | 4 +- src/engine/systems/visibility.ts | 329 ++++++++++++- src/engine/systems/war-support.ts | 456 +++++++++++++++++- src/store/__tests__/ui-store.test.ts | 45 ++ src/store/ui-store.ts | 36 ++ 28 files changed, 3370 insertions(+), 121 deletions(-) create mode 100644 src/components/hud/DebriefScreen.tsx create mode 100644 src/components/hud/ObjectivesPanel.tsx create mode 100644 src/components/hud/__tests__/DebriefScreen.test.tsx create mode 100644 src/components/hud/__tests__/TopBar.test.tsx create mode 100644 src/components/map/__tests__/InfoTooltip.test.tsx create mode 100644 src/components/panels/__tests__/UnitInfoPanel.test.tsx create mode 100644 src/engine/systems/__tests__/visibility.test.ts create mode 100644 src/engine/systems/__tests__/war-support.test.ts 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/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}) - +
+
+ + {gearOpen && ( +
+ + Pause on + + {AUTO_PAUSE_OPTIONS.map(({ key, label }) => ( + + ))} +
+ )} +
+ +
{/* 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 +
+ +
+ + )} + +
+ + +
+
+
+ ) +} + +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 && ( +
+ + + {objectivesOpen && ( +
+ +
+ )} +
+ )} + + {/* Ceasefire controls (only at war) */} + {atWarWithPrimaryEnemy && primaryEnemyNation && ( + enemyOffered ? ( + <> + + + + ) : ( + + ) + )} + {/* Declare war button (only at peace) */} {!atWarWithPrimaryEnemy && primaryEnemyNation && (
)}
@@ -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 ( + + ) +} + 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 1e5ae49..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', @@ -28,16 +37,31 @@ const lincoln = { 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: [], @@ -52,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, @@ -62,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 ?? (() => {}) @@ -69,6 +97,11 @@ beforeAll(() => { beforeEach(() => { setStore([]) + sendCommandMock.mockClear() + useUIStore.setState({ + mapFocus: null, + autoPause: { warDeclared: true, ownUnitDestroyed: true, ceasefireOffered: true }, + }) }) describe('AlertFeed', () => { @@ -100,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) @@ -108,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) }) @@ -119,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__/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__/UnitLayer.test.ts b/src/components/map/layers/__tests__/UnitLayer.test.ts index a442d0c..63f3d25 100644 --- a/src/components/map/layers/__tests__/UnitLayer.test.ts +++ b/src/components/map/layers/__tests__/UnitLayer.test.ts @@ -66,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 64d0df6..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, @@ -25,6 +27,7 @@ function makeUnit(id: string, nation: string): ViewUnit { subordinateIds: [], visibility: 'identified', stale: false, + ...overrides, } as ViewUnit } @@ -71,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/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 index d919a3d..e1cdfe2 100644 --- a/src/engine/systems/visibility.ts +++ b/src/engine/systems/visibility.ts @@ -1,29 +1,334 @@ -import type { GameState, NationId, Position, Unit, UnitCategory, VisibilityLevel } from '@/types/game' +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. - * - * STUB: not yet implemented — getViewVisibility currently reports everything as - * identified, which preserves pre-fog behavior. */ + +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, + state: GameState, _network: SensorNetwork | null, - _espionage: EspionageResult | null, - _grid: ElevationGrid | null, + espionage: EspionageResult | null, + grid: ElevationGrid | null, ): void { - // implemented by the visibility work package + if (state.time.tick % EVAL_INTERVAL_TICKS === 0) { + evaluateSources(state, espionage, grid) + } + applyEventReveals(state) } export function resetVisibilityState(): void { - // module-level state reset (worker reuses one engine across games) + 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 @@ -40,11 +345,7 @@ export function getViewVisibility(state: GameState, observer: NationId, unit: Un return { level: 'identified', stale: false, position: unit.position } } const contact = state.visibility?.[observer as string]?.[unit.id] - if (!contact) { - // STUB default: full knowledge until processVisibility is implemented - return { level: 'identified', stale: false, position: unit.position } - } - if (contact.level === 'unseen') return null + if (!contact || contact.level === 'unseen') return null const live = contact.level === 'tracked' || contact.level === 'identified' return { level: contact.level, diff --git a/src/engine/systems/war-support.ts b/src/engine/systems/war-support.ts index 4c68099..3113842 100644 --- a/src/engine/systems/war-support.ts +++ b/src/engine/systems/war-support.ts @@ -1,38 +1,395 @@ -import type { GameState, NationId } from '@/types/game' +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. - * - * STUB: not yet implemented — war support stays at 100 and wars never end on their own. */ -export function processWarSupport(_state: GameState): void { - // implemented by the war-support work package + +// ─── 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 { - // module-level state reset (worker reuses one engine across games) + 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 { - // implemented by the war-support work package +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 as string] - const b = state.nations[target as string] + 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 { - // implemented by the war-support work package +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) */ @@ -44,7 +401,76 @@ export function getWarSupport(state: GameState): Record { return out } -/** Scenario objectives for the player's side (empty at peace or until implemented) */ -export function getObjectives(_state: GameState): ObjectiveStatus[] { - return [] +// ─── 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__/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/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, From 0e4c29ae5466a43ca8a1176e1d7696593bf8529b Mon Sep 17 00:00:00 2001 From: CarlBarl <145713155+CarlBarl@users.noreply.github.com> Date: Wed, 10 Jun 2026 22:13:34 +0200 Subject: [PATCH 3/3] Add Playwright e2e smoke test for the full game loop Drives the production build through scenario launch, fog-of-war SITREP, war declaration, ceasefire/objectives UI, resign and debrief, main-menu return; fails on console errors. Run: npx vite preview --port 4173, then node scripts/e2e-smoke.mjs Co-Authored-By: Claude Fable 5 --- scripts/e2e-smoke.mjs | 98 +++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 98 insertions(+) create mode 100644 scripts/e2e-smoke.mjs 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()