diff --git a/AGENTS.md b/AGENTS.md index e43bce0..6a047f6 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -123,20 +123,27 @@ history/ | Var | Default | Notes | |---|---|---| -| `LLM_API_BASE` | `http://192.168.1.70:1111/v1` | OpenAI-compatible base URL | -| `LLM_MODEL` | `qwen3.8-27b` | model id sent in the request body | -| `LLM_TIMEOUT_MS` | `180000` | local 27B model can be slow | +| `LLM_API_BASE` | `https://ai.kkhost.pl/v1` | OpenAI-compatible **vLLM** base URL (no API key required) | +| `LLM_MODEL` | `qwen3.8-27b` | vLLM **reasoning** model — CoT in `reasoning`, final answer in `content` | +| `LLM_TIMEOUT_MS` | `180000` | the 27B reasoning model can be slow | | `LLM_STUB` | _(unset = live)_ | `1` → deterministic stub transport (no model needed) | + +> **Reasoning model:** `qwen3.8-27b` emits its chain-of-thought in the response's `reasoning` +> field and the final JSON answer in `content`. `server/llm.ts` therefore always sends a generous +> `max_tokens` (`DEFAULT_MAX_TOKENS = 4000`) so the model finishes reasoning before writing +> `content` (a too-small budget returns a null `content`), and reads only `content`, ignoring +> `reasoning`. No auth header is sent — the endpoint requires no key. | `PORT` | `4321` | single server port | | `DATA_DIR` | `./data` | where `history.db` lives | | `CLIENT_DIST` | `./dist` | built client to serve (auto-found) | ## Gotchas -- **Live LLM is a runtime dependency.** If `192.168.1.70:1111` is down, turns return 502 (and the - advisor degrades to a canned list). Set `LLM_STUB=1` for a fully functional offline instance. - The map's remote OpenFreeMap style may fail to load without internet — the app still works, the - choropleth just waits for `styledata`. +- **Live LLM is a runtime dependency.** The live model is `qwen3.8-27b` on + `https://ai.kkhost.pl/v1` (a vLLM **reasoning** model, no API key). If that endpoint is down, + turns return 502 (and the advisor degrades to a canned list). Set `LLM_STUB=1` for a fully + functional offline instance. The map's remote OpenFreeMap style may fail to load without internet + — the app still works, the choropleth just waits for `styledata`. - **Dev port override.** Vite defaults to :5173 → proxies to :4321. Both are overridable via `VITE_PORT` / `HISTORY_API_PORT` (the Playwright config reads them) so a run can dodge a port another project already holds. The client uses relative URLs, so it follows whichever port Vite diff --git a/docker-compose.yml b/docker-compose.yml index bbb19de..7c433de 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -5,9 +5,14 @@ services: restart: unless-stopped environment: - PORT=4321 - # Live LLM (default) reads LLM_API_BASE / LLM_MODEL. Set LLM_STUB=1 to use - # the deterministic stub instead (no model required). Both are optional env. - # - LLM_STUB=1 + # LIVE LLM: point at the vLLM endpoint on ai.kkhost.pl (no API key needed). + # This is a REASONING model (chain-of-thought in `reasoning`, final JSON + # answer in `content`), so llm.ts sends a generous max_tokens (4000) to let + # it finish thinking before emitting content. LLM_API_BASE / LLM_MODEL are + # optional env overrides (these match the defaults in server/llm.ts). + # To run the deterministic stub instead (no model required), set LLM_STUB=1. + - LLM_API_BASE=https://ai.kkhost.pl/v1 + - LLM_MODEL=qwen3.8-27b networks: - dev_net volumes: diff --git a/e2e/sim.spec.ts b/e2e/sim.spec.ts index 413ed0b..79b3a1c 100644 --- a/e2e/sim.spec.ts +++ b/e2e/sim.spec.ts @@ -7,6 +7,11 @@ import { test, expect } from '@playwright/test'; * stat is set to (current + 5, clamped) each turn and exactly one event is * emitted (action → military, chat → diplomatic, jump → political). There is no * live LLM and no flakiness — every assertion below is an exact, predicted value. + * + * Beyond the core simulation loop it also asserts the polished command-center + * UI: the dossier's animated stat bars, the map legend (generated from the + * region map), and the new event cards (type icon + label + colored outcome + * badge). The map itself is a canvas, so we assert on its DOM legend, not pixels. */ test.describe('History — deterministic simulation (LLM_STUB=1)', () => { test('scenario → nation → action → jump, with predicted stat changes', async ({ page }) => { @@ -33,11 +38,22 @@ test.describe('History — deterministic simulation (LLM_STUB=1)', () => { await expect(page.getByTestId('event-feed-panel')).toBeVisible(); await expect(page.getByTestId('turn-count')).toHaveText('1'); + // The dossier shows the player's four stat bars (value-colored, animated). + await expect(page.getByTestId('stat-military')).toBeVisible(); + await expect(page.getByTestId('stat-economy')).toBeVisible(); + await expect(page.getByTestId('stat-stability')).toBeVisible(); + await expect(page.getByTestId('stat-population')).toBeVisible(); + // The player (UK) starts at military 80, with no events yet. const military = page.getByTestId('stat-military'); await expect(military).toHaveText('80'); await expect(page.getByTestId('event-item')).toHaveCount(0); + // The map legend is auto-generated from the region map: all 7 nations of the + // scenario own at least one region, including the player (United Kingdom). + await expect(page.getByTestId('map-legend-item')).toHaveCount(7, { timeout: 15000 }); + await expect(page.getByTestId('map-legend-item').filter({ hasText: 'United Kingdom' })).toBeVisible(); + // Take an action: the stub bumps UK military to 85 and emits a military event. await page.getByTestId('action-textarea').fill('Mobilize the home guard and fortify Dover'); await page.getByTestId('action-submit').click(); @@ -49,6 +65,11 @@ test.describe('History — deterministic simulation (LLM_STUB=1)', () => { page.getByTestId('event-feed').getByText('Mobilize the home guard and fortify Dover').first(), ).toBeVisible(); + // The new event card carries its type (icon + label) and a colored outcome badge. + await expect(page.getByTestId('event-item').first().getByTestId('event-type-icon')).toBeVisible(); + await expect(page.getByTestId('event-item').first().getByTestId('event-type')).toHaveText('military'); + await expect(page.getByTestId('event-item').first().getByTestId('event-outcome')).toHaveText('success'); + // Jump forward (no action): a "world moves on" political event, military → 90. await page.getByTestId('jump-forward').click(); @@ -56,5 +77,11 @@ test.describe('History — deterministic simulation (LLM_STUB=1)', () => { await expect(military).toHaveText('90', { timeout: 20000 }); await expect(page.getByTestId('event-item')).toHaveCount(2); await expect(page.getByTestId('event-feed').getByText('The world moves on').first()).toBeVisible(); + + // Newest first: the jump event (political / neutral) sits on top, and the + // action event (military / success) is second — both with their outcome badges. + await expect(page.getByTestId('event-item').first().getByTestId('event-type')).toHaveText('political'); + await expect(page.getByTestId('event-item').first().getByTestId('event-outcome')).toHaveText('neutral'); + await expect(page.getByTestId('event-item').nth(1).getByTestId('event-outcome')).toHaveText('success'); }); }); diff --git a/server/engine.test.ts b/server/engine.test.ts index 139ffd3..dad499a 100644 --- a/server/engine.test.ts +++ b/server/engine.test.ts @@ -187,6 +187,12 @@ describe('parseTurnResult', () => { expect(r?.events[0].title).toBe('t'); }); + it('parses JSON prefixed by leading whitespace/newlines (reasoning model emits "\\n\\n{...}")', () => { + const r = parseTurnResult('\n\n{"date":"1939-09-03","events":[{"nation":"uk","type":"military","title":"t","text":"b","outcome":"success"}]}\n'); + expect(r?.events[0].title).toBe('t'); + expect(r?.date).toBe('1939-09-03'); + }); + it('extracts the object from surrounding prose', () => { const r = parseTurnResult('Sure! Here is the result: {"date":"1939-09-03","events":[]} hope that helps'); expect(r).not.toBeNull(); diff --git a/server/engine.ts b/server/engine.ts index 8fde0d7..f524185 100644 --- a/server/engine.ts +++ b/server/engine.ts @@ -21,7 +21,7 @@ import { type TurnEventSpec, type TurnResult, } from '../shared/protocol'; -import type { LlmClient } from './llm'; +import { DEFAULT_MAX_TOKENS, type LlmClient } from './llm'; import { ADVISOR_MARKER } from './stub'; const VALID_EVENT_TYPES = new Set(EVENT_TYPES); @@ -322,7 +322,7 @@ export async function runTurn(opts: { let result = parseTurnResult(await llm.chat([ { role: 'system', content: system }, { role: 'user', content: user }, - ], { temperature: 0.4, maxTokens: 2000 })); + ], { temperature: 0.4, maxTokens: DEFAULT_MAX_TOKENS })); if (result === null || !isMeaningful(result)) { result = parseTurnResult(await llm.chat([ @@ -334,7 +334,7 @@ export async function runTurn(opts: { content: 'Your previous output was invalid. Respond with ONLY the JSON object described above — no prose, no fences.', }, - ], { temperature: 0.4, maxTokens: 2000 })); + ], { temperature: 0.4, maxTokens: DEFAULT_MAX_TOKENS })); } if (result === null || !isMeaningful(result)) { @@ -413,7 +413,7 @@ export async function askAdvisor( const raw = await llm.chat([ { role: 'system', content: system }, { role: 'user', content: user }, - ], { temperature: 0.7, maxTokens: 500 }); + ], { temperature: 0.7, maxTokens: DEFAULT_MAX_TOKENS }); const out = parseAdvisor(raw); return out.length ? out diff --git a/server/llm.test.ts b/server/llm.test.ts index 82fbd2d..e203e02 100644 --- a/server/llm.test.ts +++ b/server/llm.test.ts @@ -1,5 +1,5 @@ import { describe, expect, it } from 'vitest'; -import { createLlmClient } from './llm'; +import { createLlmClient, DEFAULT_MAX_TOKENS } from './llm'; function json(body: unknown) { return { @@ -22,12 +22,28 @@ describe('createLlmClient (injectable transport)', () => { expect(out).toBe('hello back'); expect(calls[0].url).toBe('http://x/v1/chat/completions'); // trailing slash normalized const body = JSON.parse(calls[0].init!.body as string); + // The wire key is `max_tokens` (snake_case), never camelCase `maxTokens`. expect(body).toEqual({ model: 'm1', messages: [{ role: 'user', content: 'hi' }], - maxTokens: 10, temperature: 0.5, + max_tokens: 10, }); + // No auth header: the live vLLM endpoint requires no API key. + expect(calls[0].init!.headers).toEqual({ 'content-type': 'application/json' }); + }); + + it('chat uses a generous default max_tokens when the caller omits it', async () => { + const calls: { url: string; init?: RequestInit }[] = []; + const transport = async (url: string, init?: RequestInit) => { + calls.push({ url, init }); + return json({ choices: [{ message: { content: 'ok' } }] }); + }; + const llm = createLlmClient({ transport, apiBase: 'http://x/v1', model: 'm1' }); + await llm.chat([{ role: 'user', content: 'hi' }]); + const body = JSON.parse(calls[0].init!.body as string); + expect(body.max_tokens).toBe(DEFAULT_MAX_TOKENS); + expect(body.max_tokens).toBe(4000); }); it('listModels returns data[].id from GET {base}/models', async () => { diff --git a/server/llm.ts b/server/llm.ts index 2e87013..7aa9c33 100644 --- a/server/llm.ts +++ b/server/llm.ts @@ -1,9 +1,18 @@ // Thin OpenAI chat-completions client over native fetch (mirrors mesh-bot/src/llm). // The transport is injectable so unit tests and the deterministic e2e stub can // replace the network call. Config is read from env, never hardcoded: -// LLM_API_BASE (default http://192.168.1.70:1111/v1) -// LLM_MODEL (default qwen3.8-27b) -// LLM_TIMEOUT_MS (default 180000 — the local 27B model can be slow) +// LLM_API_BASE (default https://ai.kkhost.pl/v1 — a vLLM endpoint, no API key) +// LLM_MODEL (default qwen3.8-27b — a vLLM *reasoning* model) +// LLM_TIMEOUT_MS (default 180000 — the 27B reasoning model can be slow) +// +// The live model is a REASONING model: it emits its chain-of-thought in the +// response's `reasoning` field and the final answer in `content`. It therefore +// needs a generous `max_tokens` budget to finish thinking before writing +// `content` — a too-small budget makes it run out mid-reasoning and return a +// null `content`. We always send an explicit, generous `max_tokens` (the +// OpenAI/vLLM wire key — NOT camelCase `maxTokens`, which the server ignores) +// and read only `choices[0].message.content`, ignoring `reasoning`. No auth +// header is added (the endpoint requires no key). export interface ChatMessage { role: 'system' | 'user' | 'assistant'; @@ -23,9 +32,18 @@ export interface LlmClient { listModels(): Promise; } -export const DEFAULT_BASE = 'http://192.168.1.70:1111/v1'; +export const DEFAULT_BASE = 'https://ai.kkhost.pl/v1'; export const DEFAULT_MODEL = 'qwen3.8-27b'; +/** + * Generous token budget for every chat request. The live qwen3.8-27b is a + * reasoning model: it spends `reasoning_tokens` on its chain-of-thought before + * writing the final answer into `content`. A small budget makes it run out + * mid-reasoning and return a null `content`, so we give it room to finish both. + * `max_tokens` is always set in the request body, even when a caller omits it. + */ +export const DEFAULT_MAX_TOKENS = 4000; + const fetchTransport: Transport = (url, init) => fetch(url, init); export interface LlmClientDeps { @@ -45,18 +63,28 @@ export function createLlmClient(deps: LlmClientDeps = {}): LlmClient { ); async function chat(messages: ChatMessage[], opts: ChatOptions = {}): Promise { + const payload: Record = { model, messages }; + if (opts.temperature != null) payload.temperature = opts.temperature; + // Always send an explicit, generous `max_tokens` (the vLLM/OpenAI wire key — + // camelCase `maxTokens` is ignored by the server) so the reasoning model has + // room to finish `reasoning` before emitting `content`. + payload.max_tokens = opts.maxTokens ?? DEFAULT_MAX_TOKENS; const res = await transport(`${apiBase}/chat/completions`, { method: 'POST', + // No auth header: the ai.kkhost.pl vLLM endpoint requires no API key. headers: { 'content-type': 'application/json' }, - body: JSON.stringify({ model, messages, ...opts }), + body: JSON.stringify(payload), signal: AbortSignal.timeout(timeoutMs), }); if (!res.ok) { const text = await res.text().catch(() => ''); throw new Error(`chat failed: HTTP ${res.status} ${text.slice(0, 200)}`); } + // Reasoning models also return their chain-of-thought in a separate + // `reasoning` field. We deliberately ignore it and use only the final + // answer, which lives in `content`. const body = (await res.json()) as { - choices?: { message?: { content?: string } }[]; + choices?: { message?: { content?: string; reasoning?: string | null } }[]; }; const content = body.choices?.[0]?.message?.content; if (typeof content !== 'string') { diff --git a/src/App.tsx b/src/App.tsx index defb4bd..7787177 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -1,10 +1,10 @@ import { useCallback, useEffect, useState } from 'react'; import { toast } from 'sonner'; +import { CircleDot, Loader2, Radar } from 'lucide-react'; import { Toaster } from './components/ui/sonner'; import { Button } from './components/ui/button'; -import { Badge } from './components/ui/badge'; -import { Separator } from './components/ui/separator'; import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from './components/ui/select'; +import { cn } from './lib/utils'; import PresetPicker from './components/PresetPicker'; import NationSelect from './components/NationSelect'; import WorldMap from './components/WorldMap'; @@ -19,6 +19,19 @@ import type { GameState, Preset, PresetMeta } from '@shared/types'; /** Which top-level screen the app is showing. */ type Screen = { name: 'home' } | { name: 'pick'; preset: Preset } | { name: 'game' }; +/** + * Surface a turn failure. A 502 (live model down / timeout) gets a friendly, + * actionable message; anything else passes the underlying error through. + */ +function toastError(e: unknown): void { + const msg = e instanceof Error ? e.message : String(e ?? 'Something went wrong'); + if (/502|temporarily|timeout|fetch failed|network/i.test(msg)) { + toast.error('AI temporarily unavailable — try again'); + } else { + toast.error(msg); + } +} + export default function App() { const [screen, setScreen] = useState({ name: 'home' }); const [presets, setPresets] = useState([]); @@ -63,7 +76,7 @@ export default function App() { const { preset } = await api.getPreset(presetId); setScreen({ name: 'pick', preset }); } catch (e) { - toast.error(e instanceof Error ? e.message : 'Could not load scenario'); + toastError(e); setScreen({ name: 'home' }); } finally { setLoading(false); @@ -80,7 +93,7 @@ export default function App() { setAdvisors([]); setScreen({ name: 'game' }); } catch (e) { - toast.error(e instanceof Error ? e.message : 'Could not start the game'); + toastError(e); } finally { setLoading(false); } @@ -96,7 +109,7 @@ export default function App() { const { state } = await api.turn(game.id, action); setGame(state); } catch (e) { - toast.error(e instanceof Error ? e.message : 'Action failed'); + toastError(e); } finally { setBusy(false); } @@ -112,7 +125,7 @@ export default function App() { const { state } = await api.turn(game.id, undefined, { to, message }); setGame(state); } catch (e) { - toast.error(e instanceof Error ? e.message : 'Diplomacy failed'); + toastError(e); } finally { setBusy(false); } @@ -127,7 +140,7 @@ export default function App() { const { state } = await api.jump(game.id); setGame(state); } catch (e) { - toast.error(e instanceof Error ? e.message : 'Jump failed'); + toastError(e); } finally { setBusy(false); } @@ -140,7 +153,7 @@ export default function App() { const { suggestions } = await api.advisor(game.id, prompt); setAdvisors(suggestions); } catch (e) { - toast.error(e instanceof Error ? e.message : 'Advisor failed'); + toastError(e); } }, [game], @@ -177,12 +190,7 @@ export default function App() { return ( <> - + ); } @@ -207,7 +215,13 @@ interface GameScreenProps { onHome: () => void; } -/** The main play view: map + nation dossier on the left, command deck + event feed on the right. */ +/** + * The main play view — an operations console. A top status bar carries the + * scenario, the current turn + date, the player nation (color-chipped), and a + * live idle/simulating indicator. Below it, a three-column grid: the nation + * dossier (left), the dominant map (center), and the command deck + event feed + * (right). On small screens these stack map-first. + */ function GameScreen({ game, regions, @@ -226,38 +240,70 @@ function GameScreen({ const nations = Object.values(game.nations).sort((a, b) => a.name.localeCompare(b.name)); return ( -
-
+
+
- -

{player?.name ?? game.playerNationId}

- {game.presetTitle} + +
+

History

+

{game.presetTitle}

+
-
- - Turn {game.turn} + +
+
+
Turn
+
+ {game.turn} +
+
+
+
+
Date
+
+ {game.date} +
+
+
+
+ + + {player?.name ?? game.playerNationId} + +
+ + {busy ? ( + + ) : ( + + )} + {busy ? 'Simulating…' : 'Idle'} - - - {game.date} - - {busy && · simulating…}
-
-
-
- -
- -
- Inspect nation +
+ {/* Left: nation dossier (mobile order 3). */} +
+
+ Inspect
-
-
- {relations.length > 0 && ( -
-

Diplomacy

-
+
+

+ Diplomacy +

+ {relations.length > 0 ? ( +
    {relations.map(([id, status]) => { const other = nations[id]; const c = RELATION_COLORS[status] ?? '#94a3b8'; return ( - - {other?.name ?? id}: {status} - + + + {other?.name ?? id} + + + {RELATION_LABEL[status] ?? status} + + ); })} -
-
- )} + + ) : ( +

No diplomatic relations yet.

+ )} + ); diff --git a/src/components/StatBar.tsx b/src/components/StatBar.tsx index b6e134a..2035475 100644 --- a/src/components/StatBar.tsx +++ b/src/components/StatBar.tsx @@ -1,26 +1,35 @@ import { cn } from '@/lib/utils'; +import { statValueColor } from '@/lib/colors'; interface Props { label: string; value: number; + /** + * Optional explicit bar color. When omitted, the bar is colored by its value + * on a red → yellow → green ramp (low = danger, high = strong). + */ color?: string; testId?: string; className?: string; } -/** A compact 0-100 stat bar with a value readout. */ +/** + * A compact 0–100 stat bar. The fill animates (width + color) when the value + * changes, so a stat bump is visible at a glance. Defaults to a value-driven + * red→green color so a low stat reads as "danger". + */ export default function StatBar({ label, value, color, testId, className }: Props) { const v = Math.max(0, Math.min(100, Math.round(value))); return (
- {label} + {label}
- + {v}
diff --git a/src/components/WorldMap.tsx b/src/components/WorldMap.tsx index a7aa1dc..15fced4 100644 --- a/src/components/WorldMap.tsx +++ b/src/components/WorldMap.tsx @@ -1,7 +1,9 @@ import { useEffect, useRef } from 'react'; import * as maplibregl from 'maplibre-gl'; import 'maplibre-gl/dist/maplibre-gl.css'; -import type { RegionMap } from '@shared/protocol'; +import type { RegionMap, RegionOwner } from '@shared/protocol'; +import type { Nation } from '@shared/types'; +import { cn } from '@/lib/utils'; // Minimal GeoJSON types (avoids a hard @types/geojson dependency). interface GeoFeature { @@ -14,18 +16,47 @@ interface FeatureCollection { type: 'FeatureCollection'; features: GeoFeature[]; } +type GeoSource = maplibregl.GeoJSONSource; -// Remote OpenFreeMap dark basemap ("command center" look). +// Remote OpenFreeMap "dark" basemap — a near-black (rgb 12,12,12) canvas so the +// choropleth nation colors pop. (Verified HTTP 200; the suggested dark-matter / +// dark-outdoors / liberty-dark variants do not exist on that host.) const STYLE_URL = 'https://tiles.openfreemap.org/styles/dark'; + const COUNTRIES_SOURCE = 'countries'; +const HOVER_SOURCE = 'hover'; +const FLASH_SOURCE = 'flash'; const FILL_LAYER = 'countries-fill'; const LINE_LAYER = 'countries-line'; +const PLAYER_LINE = 'player-line'; +const FLASH_LINE = 'flash-line'; +const HOVER_FILL = 'hover-fill'; +const HOVER_LINE = 'hover-line'; -function applyRegions( - geo: FeatureCollection, - regions: RegionMap, - playerNationId: string, -): FeatureCollection { +/** Sensible per-scenario initial camera: Europe for the WWII scenarios, wider for the global ones. */ +function cameraForPreset(presetId: string): { center: [number, number]; zoom: number } { + switch (presetId) { + case 'wwii-1939': + case 'no-wwii-1940': + return { center: [12, 48], zoom: 3.4 }; // Europe + case 'coldwar-1955': + return { center: [-18, 42], zoom: 2.4 }; // Atlantic: both superpowers + case 'modern-2025': + return { center: [10, 25], zoom: 1.7 }; // The whole world + case 'aegis-2400': + return { center: [10, 25], zoom: 1.6 }; // Far-future, global view + default: + return { center: [10, 25], zoom: 1.8 }; + } +} + +/** + * Stamp the current ownership + colors onto a clone of the base geo. Each region + * carries its owner's stable fill color, a subtle stroke, and a flag for the + * player's own regions (rendered as a brighter, thicker border by a dedicated + * layer). Unknown owners fall back to a faint neutral fill — never an error. + */ +function applyRegions(geo: FeatureCollection, regions: RegionMap, playerNationId: string): FeatureCollection { const out: FeatureCollection = JSON.parse(JSON.stringify(geo)); for (const f of out.features) { f.properties = f.properties ?? {}; @@ -33,12 +64,11 @@ function applyRegions( if (r) { f.properties.fill = r.color; f.properties.owner = r.owner; - // Highlight the player's regions with a bright stroke. - f.properties.stroke = r.ownerId === playerNationId ? '#fbbf24' : 'rgba(255,255,255,0.18)'; + f.properties.stroke = 'rgba(255,255,255,0.10)'; f.properties.isPlayer = r.ownerId === playerNationId ? 1 : 0; } else { - f.properties.fill = 'rgba(148,163,184,0.06)'; - f.properties.stroke = 'rgba(255,255,255,0.08)'; + f.properties.fill = 'rgba(148,163,184,0.05)'; + f.properties.stroke = 'rgba(255,255,255,0.06)'; f.properties.owner = ''; f.properties.isPlayer = 0; } @@ -46,42 +76,144 @@ function applyRegions( return out; } +/** Build a FeatureCollection of just the features whose id is in `codes` (cloned). */ +function featuresByCodes(geo: FeatureCollection, codes: Iterable): FeatureCollection { + const set = new Set(codes); + const features = geo.features.filter((f) => f.id != null && set.has(String(f.id))); + return { type: 'FeatureCollection', features: JSON.parse(JSON.stringify(features)) }; +} + +const esc = (s: string) => + s.replace(/[&<>"']/g, (c) => ({ '&': '&', '<': '<', '>': '>', '"': '"', "'": ''' }[c] as string)); + +function statRow(label: string, value?: number): string { + if (value == null || Number.isNaN(value)) return ''; + return `
${label}${Math.round(value)}
`; +} + +/** Compact hover tooltip: country, its owner (color-chipped), and 1–2 key stats. */ +function hoverHtml(countryName: string, region?: RegionOwner, nation?: Nation): string { + if (!region || !nation) { + return `
${esc(countryName || 'Unclaimed')}
No owner
`; + } + const stats = [statRow('Mil', nation.stats.military), statRow('Eco', nation.stats.economy)].join(''); + return `
+
${esc(countryName || region.owner)}
+
${esc(region.owner)}
+
${stats}
+
`; +} + +/** Click popup: the full owning-nation profile with all four stats. */ +function clickHtml(countryName: string, region?: RegionOwner, nation?: Nation): string { + if (!region || !nation) { + return `
${esc(countryName || 'Unclaimed')}
No owner
`; + } + const stats = [ + statRow('Mil', nation.stats.military), + statRow('Eco', nation.stats.economy), + statRow('Stab', nation.stats.stability), + statRow('Pop', nation.stats.population), + ].join(''); + const regions = nation.territory.length; + return `
+
${esc(countryName || region.owner)}
+
${esc(region.owner)}
+
${regions} region${regions === 1 ? '' : 's'} · click focuses the dossier
+
${stats}
+
`; +} + +/** Small bottom-left legend mapping each nation on the map to its stable color. */ +function MapLegend({ regions }: { regions: RegionMap }) { + const owners = new Map(); + for (const r of Object.values(regions)) { + if (r.ownerId && !owners.has(r.ownerId)) owners.set(r.ownerId, { color: r.color, owner: r.owner }); + } + const list = [...owners.entries()].sort((a, b) => a[1].owner.localeCompare(b[1].owner)); + if (list.length === 0) return null; + return ( +
+
+ Nations +
+
    + {list.map(([id, o]) => ( +
  • + + {o.owner} +
  • + ))} +
+
+ ); +} + interface Props { regions: RegionMap; playerNationId: string; + /** All nations, for hover/click stats and color chips. */ + nations: Record; + /** The active scenario id, for a sensible initial camera. */ + presetId: string; className?: string; + /** Called when a region is clicked, so the dossier can focus its owner. */ + onFocusNation?: (id: string) => void; } /** * World basemap (MapLibre GL) + a country choropleth colored by the owning - * nation. Ported from flight's Map.tsx (remote style + idempotent `ensureLayers` - * re-establish on `styledata`), stripped of all flight-specific data/logic. + * nation. The choropleth is the star: a near-black basemap, per-nation stable + * fills, subtle borders, a brighter/thicker border for the player, hover + click + * popups with owner + stats, a transient flash on territory changes, a + * bottom-left legend, and a per-scenario initial camera. */ -export default function WorldMap({ regions, playerNationId, className }: Props) { +export default function WorldMap({ regions, playerNationId, nations, presetId, className, onFocusNation }: Props) { const containerRef = useRef(null); const mapRef = useRef(null); const baseGeoRef = useRef(null); // Latest props, so the mount effect's (re)establish closure always sees them. const regionsRef = useRef(regions); + const nationsRef = useRef(nations); const playerRef = useRef(playerNationId); + const presetRef = useRef(presetId); + const onFocusNationRef = useRef(onFocusNation); regionsRef.current = regions; + nationsRef.current = nations; playerRef.current = playerNationId; + presetRef.current = presetId; + onFocusNationRef.current = onFocusNation; + + // Diffing state for the transient territory-change flash (owned by this component + // so a resync with identical data never re-flashes). + const prevRegionsRef = useRef(null); + const primedRef = useRef(false); + const flashApiRef = useRef<((codes: string[]) => void) | null>(null); useEffect(() => { if (!containerRef.current) return; let cancelled = false; + let flashRaf = 0; + const cam = cameraForPreset(presetRef.current); const map = new maplibregl.Map({ container: containerRef.current, style: STYLE_URL, - center: [15, 50], - zoom: 2.5, + center: cam.center, + zoom: cam.zoom, minZoom: 1.25, maxZoom: 8, attributionControl: { compact: true }, }); mapRef.current = map; + const hoverPopup = new maplibregl.Popup({ closeButton: false, closeOnClick: false, offset: 10 }); + const clickPopup = new maplibregl.Popup({ closeButton: true, closeOnClick: true, offset: 12 }); + let lastHoverId: string | null = null; + const loadGeo = (): Promise => baseGeoRef.current ? Promise.resolve(baseGeoRef.current) @@ -95,19 +227,89 @@ export default function WorldMap({ regions, playerNationId, className }: Props) return g; }); - const onCountryClick = (e: maplibregl.MapLayerMouseEvent) => { + const emptyFC = (): FeatureCollection => ({ type: 'FeatureCollection', features: [] }); + + // Transient flash: pulse a bright outline over the changed regions, then fade + // to zero over a few seconds and self-clean. Idempotent per call. + function animateFlash(codes: string[]) { + const geo = baseGeoRef.current; + const src = map.getSource(FLASH_SOURCE) as GeoSource | undefined; + if (!geo || !src || cancelled) return; + src.setData(featuresByCodes(geo, codes)); + if (!map.getLayer(FLASH_LINE)) { + // Defensive re-add (normally created by ensureLayers). Keep it beneath the + // hover highlight when that layer exists; otherwise just append. + map.addLayer( + { + id: FLASH_LINE, + type: 'line', + source: FLASH_SOURCE, + paint: { 'line-color': '#fbbf24', 'line-width': 2.6, 'line-opacity': 0 }, + }, + map.getLayer(HOVER_FILL) ? HOVER_FILL : undefined, + ); + } + cancelAnimationFrame(flashRaf); + const start = performance.now(); + const DURATION = 2600; + const tick = (now: number) => { + if (cancelled) return; + const t = Math.min(1, (now - start) / DURATION); + const pulse = 0.55 + 0.45 * Math.sin(t * Math.PI * 3); + const opacity = t >= 1 ? 0 : 0.95 * (1 - t) * pulse; + try { + map.setPaintProperty(FLASH_LINE, 'line-opacity', opacity); + } catch { + /* map removed mid-animation */ + } + if (t < 1) flashRaf = requestAnimationFrame(tick); + }; + flashRaf = requestAnimationFrame(tick); + } + flashApiRef.current = animateFlash; + + const onHover = (e: maplibregl.MapLayerMouseEvent) => { const f = e.features?.[0]; if (!f) return; - const p = f.properties as { owner?: string; name?: string; isPlayer?: number }; - const label = p.owner ? `${p.owner}${p.isPlayer ? ' (your nation)' : ''}` : p.name ?? 'Unclaimed'; - new maplibregl.Popup({ offset: 8 }) - .setLngLat(f.geometry.coordinates as [number, number]) - .setText(label) + const id = f.id == null ? null : String(f.id); + if (id && id !== lastHoverId) { + lastHoverId = id; + const geo = baseGeoRef.current; + const feat = geo?.features.find((x) => x.id != null && String(x.id) === id); + (map.getSource(HOVER_SOURCE) as GeoSource | undefined)?.setData( + feat ? { type: 'FeatureCollection', features: [JSON.parse(JSON.stringify(feat))] } : emptyFC(), + ); + } + const p = f.properties as { name?: string }; + const region = id ? regionsRef.current[id] : undefined; + const nation = region ? nationsRef.current[region.ownerId] : undefined; + hoverPopup + .setLngLat(e.lngLat) + .setHTML(hoverHtml(p?.name ?? id ?? '', region, nation)) .addTo(map); + map.getCanvas().style.cursor = 'pointer'; }; - // Idempotent: re-adds the countries source + layers after any setStyle, and on - // first load. (Vite serves MapLibre's worker automatically, so no setWorkerUrl.) + const onHoverOut = () => { + lastHoverId = null; + (map.getSource(HOVER_SOURCE) as GeoSource | undefined)?.setData(emptyFC()); + hoverPopup.remove(); + map.getCanvas().style.cursor = ''; + }; + + const onClick = (e: maplibregl.MapLayerMouseEvent) => { + const f = e.features?.[0]; + if (!f) return; + const id = f.id == null ? null : String(f.id); + const p = f.properties as { name?: string }; + const region = id ? regionsRef.current[id] : undefined; + const nation = region ? nationsRef.current[region.ownerId] : undefined; + clickPopup.setLngLat(e.lngLat).setHTML(clickHtml(p?.name ?? id ?? '', region, nation)).addTo(map); + if (region?.ownerId) onFocusNationRef.current?.(region.ownerId); + }; + + // Idempotent: (re)adds the sources + layers after any setStyle and on first + // load. (Vite serves MapLibre's worker automatically, so no setWorkerUrl.) const ensureLayers = async () => { let geo: FeatureCollection; try { @@ -123,15 +325,18 @@ export default function WorldMap({ regions, playerNationId, className }: Props) data: applyRegions(geo, regionsRef.current, playerRef.current), }); } + if (!map.getSource(HOVER_SOURCE)) { + map.addSource(HOVER_SOURCE, { type: 'geojson', data: emptyFC() }); + } + if (!map.getSource(FLASH_SOURCE)) { + map.addSource(FLASH_SOURCE, { type: 'geojson', data: emptyFC() }); + } if (!map.getLayer(FILL_LAYER)) { map.addLayer({ id: FILL_LAYER, type: 'fill', source: COUNTRIES_SOURCE, - paint: { - 'fill-color': ['get', 'fill'], - 'fill-opacity': 0.55, - }, + paint: { 'fill-color': ['get', 'fill'], 'fill-opacity': 0.8 }, }); } if (!map.getLayer(LINE_LAYER)) { @@ -139,14 +344,52 @@ export default function WorldMap({ regions, playerNationId, className }: Props) id: LINE_LAYER, type: 'line', source: COUNTRIES_SOURCE, - paint: { - 'line-color': ['get', 'stroke'], - 'line-width': 0.75, - }, + paint: { 'line-color': ['get', 'stroke'], 'line-width': 0.6, 'line-opacity': 0.9 }, }); } - map.off('click', FILL_LAYER, onCountryClick); - map.on('click', FILL_LAYER, onCountryClick); + // NOTE: layers are added in bottom→top order (each appends on top). The + // stack is: fill, base borders, player border, territory-change flash, + // hover brighten, hover outline. Do not use a `beforeId` here — it throws + // if that layer does not exist yet on a fresh style load. + if (!map.getLayer(PLAYER_LINE)) { + map.addLayer({ + id: PLAYER_LINE, + type: 'line', + source: COUNTRIES_SOURCE, + filter: ['==', ['get', 'isPlayer'], 1], + paint: { 'line-color': '#f59e0b', 'line-width': 2.2, 'line-opacity': 0.95 }, + }); + } + if (!map.getLayer(FLASH_LINE)) { + map.addLayer({ + id: FLASH_LINE, + type: 'line', + source: FLASH_SOURCE, + paint: { 'line-color': '#fbbf24', 'line-width': 2.6, 'line-opacity': 0 }, + }); + } + if (!map.getLayer(HOVER_FILL)) { + map.addLayer({ + id: HOVER_FILL, + type: 'fill', + source: HOVER_SOURCE, + paint: { 'fill-color': '#ffffff', 'fill-opacity': 0.16 }, + }); + } + if (!map.getLayer(HOVER_LINE)) { + map.addLayer({ + id: HOVER_LINE, + type: 'line', + source: HOVER_SOURCE, + paint: { 'line-color': '#ffffff', 'line-width': 1.6, 'line-opacity': 0.9 }, + }); + } + map.off('mousemove', FILL_LAYER, onHover); + map.off('mouseout', FILL_LAYER, onHoverOut); + map.off('click', FILL_LAYER, onClick); + map.on('mousemove', FILL_LAYER, onHover); + map.on('mouseout', FILL_LAYER, onHoverOut); + map.on('click', FILL_LAYER, onClick); }; if (map.isStyleLoaded()) void ensureLayers(); @@ -154,19 +397,42 @@ export default function WorldMap({ regions, playerNationId, className }: Props) return () => { cancelled = true; + cancelAnimationFrame(flashRaf); + flashApiRef.current = null; map.remove(); mapRef.current = null; }; }, []); - // Re-color the choropleth whenever the region ownership changes (each turn). + // Re-color the choropleth whenever ownership changes, and flash any regions + // whose owner changed since the last update (transient, self-cleaning). useEffect(() => { const map = mapRef.current; const geo = baseGeoRef.current; - if (!map || !geo) return; - const src = map.getSource(COUNTRIES_SOURCE) as maplibregl.GeoJSONSource | undefined; - if (src) void src.setData(applyRegions(geo, regions, playerNationId)); + const cur = regions; + const prev = prevRegionsRef.current; + prevRegionsRef.current = cur; + const wasPrimed = primedRef.current; + if (Object.keys(cur).length > 0) primedRef.current = true; + + if (map && geo) { + const src = map.getSource(COUNTRIES_SOURCE) as GeoSource | undefined; + if (src) void src.setData(applyRegions(geo, cur, playerNationId)); + } + + if (wasPrimed && prev) { + const changed = new Set(); + for (const code of new Set([...Object.keys(prev), ...Object.keys(cur)])) { + if ((prev[code]?.ownerId ?? '') !== (cur[code]?.ownerId ?? '')) changed.add(code); + } + if (changed.size > 0) flashApiRef.current?.([...changed]); + } }, [regions, playerNationId]); - return
; + return ( +
+
+ +
+ ); } diff --git a/src/index.css b/src/index.css index ba63031..120be9f 100644 --- a/src/index.css +++ b/src/index.css @@ -100,3 +100,119 @@ background-color: var(--background); } } + +/* ------------------------------------------------------------------ */ +/* Map popups (MapLibre renders these in the DOM, so style them here). */ +/* ------------------------------------------------------------------ */ +.maplibregl-popup-content { + background: var(--popover); + color: var(--popover-foreground); + border: 1px solid var(--border); + border-radius: var(--radius); + padding: 9px 11px; + box-shadow: 0 10px 30px -10px rgb(0 0 0 / 0.7); + font-size: 12px; + line-height: 1.35; + max-width: 230px; +} +.maplibregl-popup-tip { + border-color: var(--popover) var(--popover) var(--border) var(--border); +} +.maplibregl-popup-close-button { + color: var(--muted-foreground); + font-size: 18px; + padding: 0 6px; +} +.hm-popup-title { + font-weight: 600; + font-size: 13px; + margin-bottom: 2px; +} +.hm-popup-sub { + color: var(--muted-foreground); + font-size: 11px; + margin-top: 2px; +} +.hm-popup-owner { + display: flex; + align-items: center; + gap: 6px; + font-size: 12px; + font-weight: 500; + margin-top: 5px; +} +.hm-popup-owner-big { + font-size: 14px; + font-weight: 700; +} +.hm-chip { + display: inline-block; + width: 10px; + height: 10px; + border-radius: 3px; + flex-shrink: 0; +} +.hm-stats { + display: grid; + grid-template-columns: 1fr 1fr; + gap: 4px 12px; + margin-top: 7px; +} +.hm-stat { + display: flex; + align-items: center; + justify-content: space-between; + gap: 8px; +} +.hm-stat-k { + color: var(--muted-foreground); + font-size: 10px; + text-transform: uppercase; + letter-spacing: 0.05em; +} +.hm-stat-v { + font-weight: 600; + font-variant-numeric: tabular-nums; +} + +/* ------------------------------------------------------------------ */ +/* Micro-interactions: event cards slide in + flash on arrival; the */ +/* "simulating" dot pulses. Kept here (map/feed-specific animation CSS).*/ +/* ------------------------------------------------------------------ */ +@keyframes hm-event-slide { + 0% { + opacity: 0; + transform: translateY(-7px); + } + 100% { + opacity: 1; + transform: translateY(0); + } +} +@keyframes hm-event-flash { + 0% { + box-shadow: 0 0 0 1px var(--primary), 0 0 22px -4px var(--primary); + } + 100% { + box-shadow: 0 0 0 0 transparent; + } +} +.event-new { + animation: + hm-event-slide 0.35s ease-out, + hm-event-flash 1.1s ease-out; +} +@keyframes hm-sim-pulse { + 0%, + 100% { + opacity: 1; + transform: scale(1); + } + 50% { + opacity: 0.35; + transform: scale(0.7); + } +} +.sim-pulse { + animation: hm-sim-pulse 1.1s ease-in-out infinite; +} diff --git a/src/lib/colors.ts b/src/lib/colors.ts index 1db9713..f7c23db 100644 --- a/src/lib/colors.ts +++ b/src/lib/colors.ts @@ -27,3 +27,14 @@ export const OUTCOME_COLORS: Record = { partial: '#f59e0b', neutral: '#94a3b8', }; + +/** + * A 0–100 value → a color on a smooth red → yellow → green ramp, for stat bars. + * Low values read as "danger" (red), high values as "strong" (green). Pure so the + * dossier and any future gauge agree. + */ +export function statValueColor(v: number): string { + const x = Math.max(0, Math.min(100, v)); + const hue = x * 1.2; // 0 → red, 60 → yellow, 120 → green + return `hsl(${Math.round(hue)} 72% 46%)`; +}