Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
122 changes: 122 additions & 0 deletions docs/plans/game-loop-v2.md
Original file line number Diff line number Diff line change
@@ -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.
2 changes: 1 addition & 1 deletion index.html
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,7 @@
<meta charset="UTF-8" />
<link rel="icon" type="image/svg+xml" href="/favicon.svg" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>REALPOLITIK</title>
<title>ASHFALL COMMAND</title>
</head>
<body>
<div id="root"></div>
Expand Down
98 changes: 98 additions & 0 deletions scripts/e2e-smoke.mjs
Original file line number Diff line number Diff line change
@@ -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()
16 changes: 16 additions & 0 deletions src/App.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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'
Expand Down Expand Up @@ -59,13 +60,26 @@ 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)
const showEconomy = useUIStore((s) => s.showEconomy)
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<number | null>(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
Expand Down Expand Up @@ -155,6 +169,7 @@ export default function App() {
{mobilePanel === 'events' && <AlertFeed />}
{mobilePanel === 'intel' && <IntelPanel onClose={() => setMobilePanel(null)} />}
<MobileNav active={mobilePanel} onSelect={setMobilePanel} hasSelection={!!selectedUnitId} />
{showDebrief && <DebriefScreen onDismiss={dismissDebrief} />}
</div>
)
}
Expand All @@ -170,6 +185,7 @@ export default function App() {
{showStats && <StatsPanel />}
{showEconomy && <EconomyPanel />}
{showIntel && <IntelPanel />}
{showDebrief && <DebriefScreen onDismiss={dismissDebrief} />}
</div>
)
}
Expand Down
Loading