Polish map & UI into a command-center war-room; rewire live LLM endpoint

Map (WorldMap):
- Keep the near-black OpenFreeMap `dark` basemap (verified HTTP 200; the
  suggested dark-matter / dark-outdoors / liberty-dark variants 404 on that
  host) so the choropleth nation colors pop
- Stable per-nation fills at 0.8 opacity, thin subtle borders, and a brighter
  thicker amber border for the player nation so "this is me" is always obvious
- Hover brightens a region and shows a color-chipped owner + key stats popup;
  click opens a full dossier popup and focuses the left panel on that nation
- Transient, self-cleaning flash (pulsing amber outline) over regions whose
  ownership changed this turn
- Bottom-left legend auto-generated from the region map; per-scenario initial
  camera (Europe for the WWII scenarios, wider for Cold War / Modern / Aegis)

Command-center UI:
- New header bar: scenario title, prominent turn + date, the player nation
  (name + color chip), and a live Idle / "Simulating…" status indicator
- 3-column operations-console grid: nation dossier (left) / dominant map
  (center) / command deck + event feed (right); stacks map-first on mobile
- EventFeed: one card per event with a category icon, a colored outcome badge
  (success/failure/partial/neutral), a nation-colored accent edge, and the
  turn + date; new events slide in and flash at the top
- NationPanel: value-colored animated stat bars (red → green), territory as
  color-coded region chips, and diplomacy as a list with a colored indicator
  per relation (ally/rival/war/neutral)
- ActionPanel: a "Your move" composer with one prominent Send button and a
  spinner + "turns can take a few seconds" hint while a turn resolves
- sonner error toasts: a 502 / model-down surfaces as "AI temporarily
  unavailable — try again"

Live LLM rewire (server, previously uncommitted):
- Default endpoint https://ai.kkhost.pl/v1 (vLLM, no API key); reasoning model
  qwen3.8-27b; always send a generous max_tokens (4000) so it finishes its
  chain-of-thought before emitting content; read only `content`, ignore
  `reasoning`. docker-compose + AGENTS.md updated to match.

Tests: the deterministic Playwright e2e gains assertions for the dossier stat
bars, the map legend (all nations), and the event-card type icon + outcome
badge; it stays fully deterministic under LLM_STUB=1.
This commit is contained in:
2026-10-10 22:15:05 +00:00
parent e2936e85bb
commit e327e9aea0
15 changed files with 880 additions and 192 deletions
+14 -7
View File
@@ -123,20 +123,27 @@ history/
| Var | Default | Notes | | Var | Default | Notes |
|---|---|---| |---|---|---|
| `LLM_API_BASE` | `http://192.168.1.70:1111/v1` | OpenAI-compatible base URL | | `LLM_API_BASE` | `https://ai.kkhost.pl/v1` | OpenAI-compatible **vLLM** base URL (no API key required) |
| `LLM_MODEL` | `qwen3.8-27b` | model id sent in the request body | | `LLM_MODEL` | `qwen3.8-27b` | vLLM **reasoning** model — CoT in `reasoning`, final answer in `content` |
| `LLM_TIMEOUT_MS` | `180000` | local 27B model can be slow | | `LLM_TIMEOUT_MS` | `180000` | the 27B reasoning model can be slow |
| `LLM_STUB` | _(unset = live)_ | `1` → deterministic stub transport (no model needed) | | `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 | | `PORT` | `4321` | single server port |
| `DATA_DIR` | `./data` | where `history.db` lives | | `DATA_DIR` | `./data` | where `history.db` lives |
| `CLIENT_DIST` | `./dist` | built client to serve (auto-found) | | `CLIENT_DIST` | `./dist` | built client to serve (auto-found) |
## Gotchas ## Gotchas
- **Live LLM is a runtime dependency.** If `192.168.1.70:1111` is down, turns return 502 (and the - **Live LLM is a runtime dependency.** The live model is `qwen3.8-27b` on
advisor degrades to a canned list). Set `LLM_STUB=1` for a fully functional offline instance. `https://ai.kkhost.pl/v1` (a vLLM **reasoning** model, no API key). If that endpoint is down,
The map's remote OpenFreeMap style may fail to load without internet — the app still works, the turns return 502 (and the advisor degrades to a canned list). Set `LLM_STUB=1` for a fully
choropleth just waits for `styledata`. 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 - **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 `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 another project already holds. The client uses relative URLs, so it follows whichever port Vite
+8 -3
View File
@@ -5,9 +5,14 @@ services:
restart: unless-stopped restart: unless-stopped
environment: environment:
- PORT=4321 - PORT=4321
# Live LLM (default) reads LLM_API_BASE / LLM_MODEL. Set LLM_STUB=1 to use # LIVE LLM: point at the vLLM endpoint on ai.kkhost.pl (no API key needed).
# the deterministic stub instead (no model required). Both are optional env. # This is a REASONING model (chain-of-thought in `reasoning`, final JSON
# - LLM_STUB=1 # 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: networks:
- dev_net - dev_net
volumes: volumes:
+27
View File
@@ -7,6 +7,11 @@ import { test, expect } from '@playwright/test';
* stat is set to (current + 5, clamped) each turn and exactly one event is * stat is set to (current + 5, clamped) each turn and exactly one event is
* emitted (action → military, chat → diplomatic, jump → political). There is no * emitted (action → military, chat → diplomatic, jump → political). There is no
* live LLM and no flakiness — every assertion below is an exact, predicted value. * 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.describe('History — deterministic simulation (LLM_STUB=1)', () => {
test('scenario → nation → action → jump, with predicted stat changes', async ({ page }) => { 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('event-feed-panel')).toBeVisible();
await expect(page.getByTestId('turn-count')).toHaveText('1'); 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. // The player (UK) starts at military 80, with no events yet.
const military = page.getByTestId('stat-military'); const military = page.getByTestId('stat-military');
await expect(military).toHaveText('80'); await expect(military).toHaveText('80');
await expect(page.getByTestId('event-item')).toHaveCount(0); 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. // 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-textarea').fill('Mobilize the home guard and fortify Dover');
await page.getByTestId('action-submit').click(); 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(), page.getByTestId('event-feed').getByText('Mobilize the home guard and fortify Dover').first(),
).toBeVisible(); ).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. // Jump forward (no action): a "world moves on" political event, military → 90.
await page.getByTestId('jump-forward').click(); 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(military).toHaveText('90', { timeout: 20000 });
await expect(page.getByTestId('event-item')).toHaveCount(2); await expect(page.getByTestId('event-item')).toHaveCount(2);
await expect(page.getByTestId('event-feed').getByText('The world moves on').first()).toBeVisible(); 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');
}); });
}); });
+6
View File
@@ -187,6 +187,12 @@ describe('parseTurnResult', () => {
expect(r?.events[0].title).toBe('t'); 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', () => { it('extracts the object from surrounding prose', () => {
const r = parseTurnResult('Sure! Here is the result: {"date":"1939-09-03","events":[]} hope that helps'); const r = parseTurnResult('Sure! Here is the result: {"date":"1939-09-03","events":[]} hope that helps');
expect(r).not.toBeNull(); expect(r).not.toBeNull();
+4 -4
View File
@@ -21,7 +21,7 @@ import {
type TurnEventSpec, type TurnEventSpec,
type TurnResult, type TurnResult,
} from '../shared/protocol'; } from '../shared/protocol';
import type { LlmClient } from './llm'; import { DEFAULT_MAX_TOKENS, type LlmClient } from './llm';
import { ADVISOR_MARKER } from './stub'; import { ADVISOR_MARKER } from './stub';
const VALID_EVENT_TYPES = new Set<string>(EVENT_TYPES); const VALID_EVENT_TYPES = new Set<string>(EVENT_TYPES);
@@ -322,7 +322,7 @@ export async function runTurn(opts: {
let result = parseTurnResult(await llm.chat([ let result = parseTurnResult(await llm.chat([
{ role: 'system', content: system }, { role: 'system', content: system },
{ role: 'user', content: user }, { role: 'user', content: user },
], { temperature: 0.4, maxTokens: 2000 })); ], { temperature: 0.4, maxTokens: DEFAULT_MAX_TOKENS }));
if (result === null || !isMeaningful(result)) { if (result === null || !isMeaningful(result)) {
result = parseTurnResult(await llm.chat([ result = parseTurnResult(await llm.chat([
@@ -334,7 +334,7 @@ export async function runTurn(opts: {
content: content:
'Your previous output was invalid. Respond with ONLY the JSON object described above — no prose, no fences.', '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)) { if (result === null || !isMeaningful(result)) {
@@ -413,7 +413,7 @@ export async function askAdvisor(
const raw = await llm.chat([ const raw = await llm.chat([
{ role: 'system', content: system }, { role: 'system', content: system },
{ role: 'user', content: user }, { role: 'user', content: user },
], { temperature: 0.7, maxTokens: 500 }); ], { temperature: 0.7, maxTokens: DEFAULT_MAX_TOKENS });
const out = parseAdvisor(raw); const out = parseAdvisor(raw);
return out.length return out.length
? out ? out
+18 -2
View File
@@ -1,5 +1,5 @@
import { describe, expect, it } from 'vitest'; import { describe, expect, it } from 'vitest';
import { createLlmClient } from './llm'; import { createLlmClient, DEFAULT_MAX_TOKENS } from './llm';
function json(body: unknown) { function json(body: unknown) {
return { return {
@@ -22,12 +22,28 @@ describe('createLlmClient (injectable transport)', () => {
expect(out).toBe('hello back'); expect(out).toBe('hello back');
expect(calls[0].url).toBe('http://x/v1/chat/completions'); // trailing slash normalized expect(calls[0].url).toBe('http://x/v1/chat/completions'); // trailing slash normalized
const body = JSON.parse(calls[0].init!.body as string); const body = JSON.parse(calls[0].init!.body as string);
// The wire key is `max_tokens` (snake_case), never camelCase `maxTokens`.
expect(body).toEqual({ expect(body).toEqual({
model: 'm1', model: 'm1',
messages: [{ role: 'user', content: 'hi' }], messages: [{ role: 'user', content: 'hi' }],
maxTokens: 10,
temperature: 0.5, 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 () => { it('listModels returns data[].id from GET {base}/models', async () => {
+34 -6
View File
@@ -1,9 +1,18 @@
// Thin OpenAI chat-completions client over native fetch (mirrors mesh-bot/src/llm). // 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 // The transport is injectable so unit tests and the deterministic e2e stub can
// replace the network call. Config is read from env, never hardcoded: // replace the network call. Config is read from env, never hardcoded:
// LLM_API_BASE (default http://192.168.1.70:1111/v1) // LLM_API_BASE (default https://ai.kkhost.pl/v1 — a vLLM endpoint, no API key)
// LLM_MODEL (default qwen3.8-27b) // LLM_MODEL (default qwen3.8-27b — a vLLM *reasoning* model)
// LLM_TIMEOUT_MS (default 180000 — the local 27B model can be slow) // 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 { export interface ChatMessage {
role: 'system' | 'user' | 'assistant'; role: 'system' | 'user' | 'assistant';
@@ -23,9 +32,18 @@ export interface LlmClient {
listModels(): Promise<string[]>; listModels(): Promise<string[]>;
} }
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'; 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); const fetchTransport: Transport = (url, init) => fetch(url, init);
export interface LlmClientDeps { export interface LlmClientDeps {
@@ -45,18 +63,28 @@ export function createLlmClient(deps: LlmClientDeps = {}): LlmClient {
); );
async function chat(messages: ChatMessage[], opts: ChatOptions = {}): Promise<string> { async function chat(messages: ChatMessage[], opts: ChatOptions = {}): Promise<string> {
const payload: Record<string, unknown> = { 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`, { const res = await transport(`${apiBase}/chat/completions`, {
method: 'POST', method: 'POST',
// No auth header: the ai.kkhost.pl vLLM endpoint requires no API key.
headers: { 'content-type': 'application/json' }, headers: { 'content-type': 'application/json' },
body: JSON.stringify({ model, messages, ...opts }), body: JSON.stringify(payload),
signal: AbortSignal.timeout(timeoutMs), signal: AbortSignal.timeout(timeoutMs),
}); });
if (!res.ok) { if (!res.ok) {
const text = await res.text().catch(() => ''); const text = await res.text().catch(() => '');
throw new Error(`chat failed: HTTP ${res.status} ${text.slice(0, 200)}`); 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 { const body = (await res.json()) as {
choices?: { message?: { content?: string } }[]; choices?: { message?: { content?: string; reasoning?: string | null } }[];
}; };
const content = body.choices?.[0]?.message?.content; const content = body.choices?.[0]?.message?.content;
if (typeof content !== 'string') { if (typeof content !== 'string') {
+99 -39
View File
@@ -1,10 +1,10 @@
import { useCallback, useEffect, useState } from 'react'; import { useCallback, useEffect, useState } from 'react';
import { toast } from 'sonner'; import { toast } from 'sonner';
import { CircleDot, Loader2, Radar } from 'lucide-react';
import { Toaster } from './components/ui/sonner'; import { Toaster } from './components/ui/sonner';
import { Button } from './components/ui/button'; 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 { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from './components/ui/select';
import { cn } from './lib/utils';
import PresetPicker from './components/PresetPicker'; import PresetPicker from './components/PresetPicker';
import NationSelect from './components/NationSelect'; import NationSelect from './components/NationSelect';
import WorldMap from './components/WorldMap'; 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. */ /** Which top-level screen the app is showing. */
type Screen = { name: 'home' } | { name: 'pick'; preset: Preset } | { name: 'game' }; 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() { export default function App() {
const [screen, setScreen] = useState<Screen>({ name: 'home' }); const [screen, setScreen] = useState<Screen>({ name: 'home' });
const [presets, setPresets] = useState<PresetMeta[]>([]); const [presets, setPresets] = useState<PresetMeta[]>([]);
@@ -63,7 +76,7 @@ export default function App() {
const { preset } = await api.getPreset(presetId); const { preset } = await api.getPreset(presetId);
setScreen({ name: 'pick', preset }); setScreen({ name: 'pick', preset });
} catch (e) { } catch (e) {
toast.error(e instanceof Error ? e.message : 'Could not load scenario'); toastError(e);
setScreen({ name: 'home' }); setScreen({ name: 'home' });
} finally { } finally {
setLoading(false); setLoading(false);
@@ -80,7 +93,7 @@ export default function App() {
setAdvisors([]); setAdvisors([]);
setScreen({ name: 'game' }); setScreen({ name: 'game' });
} catch (e) { } catch (e) {
toast.error(e instanceof Error ? e.message : 'Could not start the game'); toastError(e);
} finally { } finally {
setLoading(false); setLoading(false);
} }
@@ -96,7 +109,7 @@ export default function App() {
const { state } = await api.turn(game.id, action); const { state } = await api.turn(game.id, action);
setGame(state); setGame(state);
} catch (e) { } catch (e) {
toast.error(e instanceof Error ? e.message : 'Action failed'); toastError(e);
} finally { } finally {
setBusy(false); setBusy(false);
} }
@@ -112,7 +125,7 @@ export default function App() {
const { state } = await api.turn(game.id, undefined, { to, message }); const { state } = await api.turn(game.id, undefined, { to, message });
setGame(state); setGame(state);
} catch (e) { } catch (e) {
toast.error(e instanceof Error ? e.message : 'Diplomacy failed'); toastError(e);
} finally { } finally {
setBusy(false); setBusy(false);
} }
@@ -127,7 +140,7 @@ export default function App() {
const { state } = await api.jump(game.id); const { state } = await api.jump(game.id);
setGame(state); setGame(state);
} catch (e) { } catch (e) {
toast.error(e instanceof Error ? e.message : 'Jump failed'); toastError(e);
} finally { } finally {
setBusy(false); setBusy(false);
} }
@@ -140,7 +153,7 @@ export default function App() {
const { suggestions } = await api.advisor(game.id, prompt); const { suggestions } = await api.advisor(game.id, prompt);
setAdvisors(suggestions); setAdvisors(suggestions);
} catch (e) { } catch (e) {
toast.error(e instanceof Error ? e.message : 'Advisor failed'); toastError(e);
} }
}, },
[game], [game],
@@ -177,12 +190,7 @@ export default function App() {
return ( return (
<> <>
<Toaster richColors position="top-right" /> <Toaster richColors position="top-right" />
<NationSelect <NationSelect preset={screen.preset} loading={loading} onPick={pickNation} onBack={goHome} />
preset={screen.preset}
loading={loading}
onPick={pickNation}
onBack={goHome}
/>
</> </>
); );
} }
@@ -207,7 +215,13 @@ interface GameScreenProps {
onHome: () => void; 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({ function GameScreen({
game, game,
regions, regions,
@@ -226,38 +240,70 @@ function GameScreen({
const nations = Object.values(game.nations).sort((a, b) => a.name.localeCompare(b.name)); const nations = Object.values(game.nations).sort((a, b) => a.name.localeCompare(b.name));
return ( return (
<div className="mx-auto flex min-h-screen max-w-[1500px] flex-col px-4 py-5 lg:px-6"> <div className="mx-auto flex min-h-screen max-w-[1600px] flex-col px-3 py-4 sm:px-4 lg:px-6">
<header className="mb-4 flex flex-wrap items-center gap-x-4 gap-y-2" data-testid="game-header"> <header
data-testid="game-header"
className="mb-4 flex flex-wrap items-center gap-x-4 gap-y-3 rounded-xl border bg-card/60 px-4 py-3"
>
<Button variant="ghost" size="sm" onClick={onHome}> <Button variant="ghost" size="sm" onClick={onHome}>
← Scenarios ← Scenarios
</Button> </Button>
<div className="flex items-center gap-2.5"> <div className="flex items-center gap-2.5">
<span className="inline-block size-3.5 rounded-full" style={{ background: nationColor(game.playerNationId) }} /> <Radar className="size-5 text-primary" />
<h1 className="text-2xl font-bold tracking-tight">{player?.name ?? game.playerNationId}</h1> <div className="leading-tight">
<Badge variant="secondary">{game.presetTitle}</Badge> <h1 className="text-lg font-bold tracking-tight">History</h1>
<p className="text-[11px] text-muted-foreground">{game.presetTitle}</p>
</div> </div>
<div className="ml-auto flex items-center gap-2 text-sm text-muted-foreground"> </div>
<span>
Turn <span className="font-semibold tabular-nums text-foreground" data-testid="turn-count">{game.turn}</span> <div className="ml-auto flex flex-wrap items-center gap-x-4 gap-y-2">
</span> <div className="text-right leading-none">
<Separator orientation="vertical" className="h-4" /> <div className="text-[10px] font-medium uppercase tracking-widest text-muted-foreground">Turn</div>
<span className="tabular-nums" data-testid="game-date"> <div className="mt-1 text-2xl font-bold tabular-nums" data-testid="turn-count">
{game.turn}
</div>
</div>
<div className="hidden h-9 w-px bg-border sm:block" />
<div className="text-right leading-none">
<div className="text-[10px] font-medium uppercase tracking-widest text-muted-foreground">Date</div>
<div className="mt-1 text-sm font-semibold tabular-nums" data-testid="game-date">
{game.date} {game.date}
</div>
</div>
<div className="hidden h-9 w-px bg-border sm:block" />
<div className="flex items-center gap-2">
<span
className="inline-block size-3.5 rounded-full ring-2 ring-white/10"
style={{ background: nationColor(game.playerNationId) }}
/>
<span className="text-sm font-medium" data-testid="player-nation">
{player?.name ?? game.playerNationId}
</span>
</div>
<span
data-testid="status-indicator"
className={cn(
'flex items-center gap-1.5 rounded-full border px-2.5 py-1 text-xs font-medium',
busy ? 'border-primary/40 bg-primary/10 text-primary' : 'border-border bg-muted/40 text-muted-foreground',
)}
>
{busy ? (
<Loader2 className="size-3 animate-spin" />
) : (
<CircleDot className="size-3" />
)}
{busy ? 'Simulating…' : 'Idle'}
</span> </span>
{busy && <span className="animate-pulse text-primary">· simulating…</span>}
</div> </div>
</header> </header>
<main className="grid flex-1 gap-4 lg:grid-cols-[2fr_1fr]"> <main className="grid flex-1 gap-4 lg:grid-cols-[300px_minmax(0,1fr)_340px]">
<section className="min-w-0 space-y-4"> {/* Left: nation dossier (mobile order 3). */}
<div className="h-[62vh] min-h-[420px] overflow-hidden rounded-xl border"> <section className="order-3 min-w-0 lg:order-1">
<WorldMap regions={regions} playerNationId={game.playerNationId} className="h-full w-full" /> <div className="mb-2 flex items-center gap-2">
</div> <span className="text-xs font-medium uppercase tracking-wide text-muted-foreground">Inspect</span>
<div className="flex flex-wrap items-center gap-2">
<span className="text-sm font-medium text-muted-foreground">Inspect nation</span>
<Select value={viewingId} onValueChange={setViewingId}> <Select value={viewingId} onValueChange={setViewingId}>
<SelectTrigger className="w-56" data-testid="inspect-select"> <SelectTrigger className="w-full" data-testid="inspect-select">
<SelectValue /> <SelectValue />
</SelectTrigger> </SelectTrigger>
<SelectContent> <SelectContent>
@@ -269,11 +315,25 @@ function GameScreen({
</SelectContent> </SelectContent>
</Select> </Select>
</div> </div>
<NationPanel nation={viewed} nations={game.nations} isPlayer={viewingId === game.playerNationId} /> <NationPanel nation={viewed} nations={game.nations} isPlayer={viewingId === game.playerNationId} />
</section> </section>
<aside className="flex min-w-0 flex-col gap-4"> {/* Center: the dominant map (mobile order 1). */}
<section className="order-1 min-w-0 lg:order-2">
<div className="relative h-[58vh] min-h-[420px] overflow-hidden rounded-xl border lg:h-[80vh]">
<WorldMap
regions={regions}
playerNationId={game.playerNationId}
nations={game.nations}
presetId={game.presetId}
onFocusNation={setViewingId}
className="h-full w-full"
/>
</div>
</section>
{/* Right: command deck + event feed (mobile order 2). */}
<aside className="order-2 flex min-h-0 min-w-0 flex-col gap-4 lg:order-3">
<ActionPanel <ActionPanel
game={game} game={game}
busy={busy} busy={busy}
@@ -283,7 +343,7 @@ function GameScreen({
onAdvisor={onAdvisor} onAdvisor={onAdvisor}
onJump={onJump} onJump={onJump}
/> />
<div className="h-[46vh] min-h-[280px]" data-testid="event-feed-slot"> <div className="min-h-[360px] flex-1 lg:min-h-0" data-testid="event-feed-slot">
<EventFeed events={game.events} nations={game.nations} /> <EventFeed events={game.events} nations={game.nations} />
</div> </div>
</aside> </aside>
+55 -21
View File
@@ -1,4 +1,5 @@
import { useState } from 'react'; import { useState } from 'react';
import { Loader2, Radar, Send } from 'lucide-react';
import { Button } from './ui/button'; import { Button } from './ui/button';
import { Card, CardContent, CardHeader, CardTitle } from './ui/card'; import { Card, CardContent, CardHeader, CardTitle } from './ui/card';
import { Input } from './ui/input'; import { Input } from './ui/input';
@@ -20,12 +21,12 @@ interface Props {
} }
/** /**
* The player's command deck. Three tabs: * The player's command deck ("Your move"). Three tabs — Action (free-text move),
* - Action: free-text move (the primary way to shape the world). * Diplomacy (a message to a specific nation), Advisor (ask the LLM for ideas,
* - Diplomacy: a message to a specific other nation. * "Use" feeds one into the Action tab) — share one prominent Send button that
* - Advisor: ask the LLM for 3–5 in-character action ideas; "Use" feeds one * routes to the active tab. While a turn resolves it shows a spinner + a hint
* straight into the Action tab. * that turns take a few seconds. A "Jump forward" button auto-simulates the
* A prominent "Jump forward" button auto-simulates the world with no player move. * world with no player move.
*/ */
export default function ActionPanel({ game, busy, advisors, onAction, onChat, onAdvisor, onJump }: Props) { export default function ActionPanel({ game, busy, advisors, onAction, onChat, onAdvisor, onJump }: Props) {
const [tab, setTab] = useState('action'); const [tab, setTab] = useState('action');
@@ -57,10 +58,37 @@ export default function ActionPanel({ game, busy, advisors, onAction, onChat, on
setTab('action'); setTab('action');
}; };
// The single Send button routes to the active tab.
const sendDisabled =
busy ||
(tab === 'action'
? !action.trim()
: tab === 'diplo'
? !message.trim() || !target
: false);
const sendLabel = busy
? 'Simulating…'
: tab === 'action'
? 'Take action'
: tab === 'diplo'
? 'Send message'
: 'Ask the advisor';
const send = () => {
if (busy) return;
if (tab === 'action') submitAction();
else if (tab === 'diplo') submitChat();
else onAdvisor(prompt.trim() || undefined);
};
return ( return (
<Card className="gap-4" data-testid="action-panel"> <Card className="gap-4" data-testid="action-panel">
<CardHeader className="flex-row items-center justify-between space-y-0"> <CardHeader className="flex-row items-center justify-between space-y-0">
<CardTitle className="text-base">Command</CardTitle> <div className="flex items-center gap-2">
<Radar className="size-4 text-primary" />
<CardTitle className="text-base">Your move</CardTitle>
</div>
<Button variant="secondary" size="sm" onClick={onJump} disabled={busy} data-testid="jump-forward"> <Button variant="secondary" size="sm" onClick={onJump} disabled={busy} data-testid="jump-forward">
{busy ? 'Simulating…' : 'Jump forward'} {busy ? 'Simulating…' : 'Jump forward'}
</Button> </Button>
@@ -88,9 +116,6 @@ export default function ActionPanel({ game, busy, advisors, onAction, onChat, on
rows={3} rows={3}
data-testid="action-textarea" data-testid="action-textarea"
/> />
<Button onClick={submitAction} disabled={busy || !action.trim()} className="w-full" data-testid="action-submit">
Take action
</Button>
</TabsContent> </TabsContent>
<TabsContent value="diplo" className="space-y-2"> <TabsContent value="diplo" className="space-y-2">
@@ -113,9 +138,6 @@ export default function ActionPanel({ game, busy, advisors, onAction, onChat, on
rows={3} rows={3}
data-testid="chat-message" data-testid="chat-message"
/> />
<Button onClick={submitChat} disabled={busy || !message.trim() || !target} className="w-full" data-testid="chat-submit">
Send message
</Button>
</TabsContent> </TabsContent>
<TabsContent value="advisor" className="space-y-2"> <TabsContent value="advisor" className="space-y-2">
@@ -125,14 +147,6 @@ export default function ActionPanel({ game, busy, advisors, onAction, onChat, on
placeholder="Optional focus — e.g. “the nuclear standoff”" placeholder="Optional focus — e.g. “the nuclear standoff”"
data-testid="advisor-prompt" data-testid="advisor-prompt"
/> />
<Button
variant="secondary"
onClick={() => onAdvisor(prompt.trim() || undefined)}
className="w-full"
data-testid="advisor-ask"
>
Ask the advisor
</Button>
{advisors.length > 0 && ( {advisors.length > 0 && (
<ul className="space-y-1.5" data-testid="advisor-list"> <ul className="space-y-1.5" data-testid="advisor-list">
{advisors.map((a, i) => ( {advisors.map((a, i) => (
@@ -151,6 +165,26 @@ export default function ActionPanel({ game, busy, advisors, onAction, onChat, on
)} )}
</TabsContent> </TabsContent>
</Tabs> </Tabs>
<div className="mt-3 space-y-2">
<Button className="h-11 w-full" onClick={send} disabled={sendDisabled} data-testid="action-submit">
{busy ? (
<Loader2 className="size-4 animate-spin" />
) : (
<Send className="size-4" />
)}
{sendLabel}
</Button>
{busy && (
<p
className="flex items-center justify-center gap-1.5 text-xs text-muted-foreground"
data-testid="sim-hint"
>
<span className="sim-pulse inline-block size-1.5 rounded-full bg-primary" />
The world is thinking — turns can take a few seconds.
</p>
)}
</div>
</CardContent> </CardContent>
</Card> </Card>
); );
+76 -25
View File
@@ -1,7 +1,32 @@
import {
DollarSign,
Flame,
FlaskConical,
Handshake,
Landmark,
Palette,
Radar,
Swords,
Users,
type LucideIcon,
} from 'lucide-react';
import { Badge } from './ui/badge'; import { Badge } from './ui/badge';
import { ScrollArea } from './ui/scroll-area'; import { ScrollArea } from './ui/scroll-area';
import { EVENT_COLORS, OUTCOME_COLORS } from '@/lib/colors'; import { EVENT_COLORS, OUTCOME_COLORS } from '@/lib/colors';
import type { EventEntry, Nation } from '@shared/types'; import { nationColor } from '@shared/protocol';
import type { EventEntry, EventType, Nation } from '@shared/types';
/** One icon per event category, so a type is readable without reading. */
const EVENT_ICONS: Record<EventType, LucideIcon> = {
military: Swords,
diplomatic: Handshake,
economic: DollarSign,
political: Landmark,
social: Users,
disaster: Flame,
scientific: FlaskConical,
cultural: Palette,
};
interface Props { interface Props {
events: EventEntry[]; events: EventEntry[];
@@ -9,54 +34,80 @@ interface Props {
} }
/** /**
* Chronological event log (newest first). Each entry shows the turn it resolved, * A scrolling event log, newest first. Each event is a card with a category
* its date, a color-coded type, a headline, the narrative, the nation it concerns, * icon, a color-coded outcome badge, a nation-colored accent edge, and the turn
* and the outcome. Scrolls internally; the parent supplies a bounded height. * + date it resolved on. New events slide in and flash briefly at the top.
*/ */
export default function EventFeed({ events, nations }: Props) { export default function EventFeed({ events, nations }: Props) {
const latestFirst = [...events].reverse(); const latestFirst = [...events].reverse();
return ( return (
<div className="flex h-full min-h-0 flex-col rounded-xl border bg-card" data-testid="event-feed-panel"> <div
<div className="flex items-center justify-between px-4 pt-4"> className="flex h-full min-h-0 flex-col rounded-xl border bg-card"
<h2 className="text-sm font-semibold">Event feed</h2> data-testid="event-feed-panel"
>
<div className="flex items-center justify-between border-b px-4 py-2.5">
<div className="flex items-center gap-2">
<Radar className="size-4 text-primary" />
<h2 className="text-sm font-semibold tracking-wide">Event feed</h2>
</div>
<Badge variant="secondary" className="text-xs tabular-nums"> <Badge variant="secondary" className="text-xs tabular-nums">
{events.length} {events.length}
</Badge> </Badge>
</div> </div>
<ScrollArea className="mx-4 mb-4 min-h-0 flex-1"> <ScrollArea className="mx-3 mb-3 min-h-0 flex-1">
<div className="space-y-2 pr-1" data-testid="event-feed"> <div className="space-y-2 pr-1" data-testid="event-feed">
{latestFirst.length === 0 && ( {latestFirst.length === 0 && (
<p className="rounded-md border border-dashed p-3 text-sm text-muted-foreground"> <div className="mt-3 rounded-md border border-dashed p-4 text-center text-sm text-muted-foreground">
No events yet. Take an action, send a message, or jump forward to move the world. No events yet. Take an action, send a message, or jump forward to move the world.
</p> </div>
)} )}
{latestFirst.map((e) => { {latestFirst.map((e) => {
const nation = nations[e.nation]; const nation = nations[e.nation];
const color = EVENT_COLORS[e.type] ?? '#94a3b8'; // Accent edge uses the nation's stable choropleth color (falls back to
const outcome = OUTCOME_COLORS[e.outcome] ?? '#94a3b8'; // the type color if the nation is missing — never crashes).
const accent = nation ? nationColor(e.nation) : EVENT_COLORS[e.type] ?? '#94a3b8';
const typeColor = EVENT_COLORS[e.type] ?? '#94a3b8';
const outcomeColor = OUTCOME_COLORS[e.outcome] ?? '#94a3b8';
const Icon = EVENT_ICONS[e.type] ?? Landmark;
return ( return (
<article key={e.id} className="rounded-lg border bg-card/50 p-2.5" data-testid="event-item"> <article
<div className="mb-1 flex items-center gap-1.5 text-[11px] text-muted-foreground"> key={e.id}
<span className="font-medium tabular-nums">T{e.turn}</span> className="event-new relative overflow-hidden rounded-lg border bg-card/50 p-3 pl-4"
style={{ borderLeft: `3px solid ${accent}` }}
data-testid="event-item"
>
<div className="mb-1.5 flex items-center gap-1.5 text-[11px] text-muted-foreground">
<span className="font-semibold tabular-nums text-foreground/70">T{e.turn}</span>
<span>·</span> <span>·</span>
<span className="tabular-nums">{e.date}</span> <span className="tabular-nums">{e.date}</span>
<span className="ml-auto inline-flex items-center gap-1.5"> <span
<span className="inline-block size-2 rounded-full" style={{ background: color }} /> className="ml-auto inline-flex items-center gap-1.5 rounded-full px-1.5 py-0.5"
style={{ color: typeColor, background: `${typeColor}1a` }}
>
<Icon className="size-3" data-testid="event-type-icon" aria-hidden />
<span data-testid="event-type" className="capitalize">
{e.type} {e.type}
</span> </span>
</span>
</div> </div>
<p className="text-sm font-medium leading-snug" style={{ color }}> <p className="text-sm font-medium leading-snug">{e.title}</p>
{e.title} <p className="mt-1 text-[13px] leading-snug text-foreground/75">{e.text}</p>
</p> <div className="mt-2 flex items-center gap-2 text-[11px]">
<p className="mt-0.5 text-sm leading-snug text-foreground/80">{e.text}</p> <span className="inline-flex min-w-0 items-center gap-1.5">
<div className="mt-1.5 flex items-center gap-2 text-[11px] text-muted-foreground"> <span className="inline-block size-2 shrink-0 rounded-full" style={{ background: accent }} />
<span>{nation?.name ?? e.nation}</span> <span className="truncate text-muted-foreground">{nation?.name ?? e.nation}</span>
</span>
<span <span
className="ml-auto rounded px-1.5 py-0.5 font-medium capitalize" className="ml-auto shrink-0 rounded-md px-2 py-0.5 font-semibold capitalize"
style={{ background: `${outcome}22`, color: outcome }} style={{
background: `${outcomeColor}22`,
color: outcomeColor,
border: `1px solid ${outcomeColor}55`,
}}
data-testid="event-outcome"
> >
{e.outcome} {e.outcome}
</span> </span>
+87 -35
View File
@@ -3,7 +3,16 @@ import { Card, CardContent, CardDescription, CardHeader, CardTitle } from './ui/
import StatBar from './StatBar'; import StatBar from './StatBar';
import { nationColor } from '@shared/protocol'; import { nationColor } from '@shared/protocol';
import { RELATION_COLORS } from '@/lib/colors'; import { RELATION_COLORS } from '@/lib/colors';
import type { Nation } from '@shared/types'; import type { Nation, RelationStatus } from '@shared/types';
/** Human labels for a diplomatic status (the raw value is kept in data-attrs). */
const RELATION_LABEL: Record<RelationStatus, string> = {
ally: 'Ally',
neutral: 'Neutral',
rival: 'Rival',
hostile: 'Hostile',
war: 'At war',
};
interface Props { interface Props {
nation: Nation; nation: Nation;
@@ -13,10 +22,11 @@ interface Props {
} }
/** /**
* A single nation's dossier: identity, the four 0–100 stat bars (colored by the * A single nation's dossier: identity + color chip, the four 0–100 stat bars
* nation's stable choropleth color), territory count, and its diplomatic posture * (colored by value: low = red → high = green, animated on change), territory as
* toward every other nation. Used for the player (highlighted) or any inspected * color-coded region chips, and diplomacy as a list with a colored indicator per
* nation. * relation (ally = green, rival = amber, war = red, neutral = slate). Used for
* the player (highlighted) or any inspected nation.
*/ */
export default function NationPanel({ nation, nations, isPlayer }: Props) { export default function NationPanel({ nation, nations, isPlayer }: Props) {
const color = nationColor(nation.id); const color = nationColor(nation.id);
@@ -24,67 +34,109 @@ export default function NationPanel({ nation, nations, isPlayer }: Props) {
const regions = nation.territory; const regions = nation.territory;
return ( return (
<Card data-testid="nation-panel" className={isPlayer ? 'gap-4 border-primary/40' : 'gap-4'}> <Card data-testid="nation-panel" className={isPlayer ? 'border-primary/40' : undefined}>
<CardHeader className="flex-row items-center justify-between space-y-0"> <CardHeader className="space-y-0">
<div className="flex items-center gap-2.5"> <div className="flex items-center gap-3">
<span className="inline-block size-3.5 rounded-full" style={{ background: color }} /> <span
<div className="space-y-0.5"> className="inline-block size-4 shrink-0 rounded-md ring-2 ring-white/10"
<CardTitle className="text-lg">{nation.name}</CardTitle> style={{ background: color }}
data-testid="nation-color-chip"
/>
<div className="min-w-0 flex-1 space-y-0.5">
<CardTitle className="truncate text-lg">{nation.name}</CardTitle>
<CardDescription> <CardDescription>
{nation.type} · {regions.length} region{regions.length === 1 ? '' : 's'} {nation.type} · {regions.length} region{regions.length === 1 ? '' : 's'}
</CardDescription> </CardDescription>
</div> </div>
</div> {isPlayer && (
<Badge variant={isPlayer ? 'default' : 'secondary'} className="shrink-0"> <Badge variant="default" className="shrink-0">
{isPlayer ? 'Your nation' : 'Other'} Your nation
</Badge> </Badge>
)}
</div>
</CardHeader> </CardHeader>
<CardContent className="space-y-4"> <CardContent className="space-y-5">
<div className="grid gap-x-6 gap-y-2 sm:grid-cols-2"> <section>
<StatBar label="Military" value={nation.stats.military} color={color} testId={isPlayer ? 'stat-military' : undefined} /> <h3 className="mb-2 text-xs font-medium uppercase tracking-wide text-muted-foreground">
<StatBar label="Economy" value={nation.stats.economy} color={color} testId={isPlayer ? 'stat-economy' : undefined} /> Status
<StatBar label="Stability" value={nation.stats.stability} color={color} testId={isPlayer ? 'stat-stability' : undefined} /> </h3>
<StatBar label="Population" value={nation.stats.population} color={color} testId={isPlayer ? 'stat-population' : undefined} /> <div className="space-y-2">
<StatBar
label="Military"
value={nation.stats.military}
testId={isPlayer ? 'stat-military' : undefined}
/>
<StatBar
label="Economy"
value={nation.stats.economy}
testId={isPlayer ? 'stat-economy' : undefined}
/>
<StatBar
label="Stability"
value={nation.stats.stability}
testId={isPlayer ? 'stat-stability' : undefined}
/>
<StatBar
label="Population"
value={nation.stats.population}
testId={isPlayer ? 'stat-population' : undefined}
/>
</div> </div>
</section>
{regions.length > 0 && ( <section>
<div> <h3 className="mb-2 text-xs font-medium uppercase tracking-wide text-muted-foreground">
<h3 className="mb-1.5 text-xs font-medium uppercase tracking-wide text-muted-foreground">Territory</h3> Territory
</h3>
{regions.length > 0 ? (
<div className="flex flex-wrap gap-1.5"> <div className="flex flex-wrap gap-1.5">
{regions.map((code) => ( {regions.map((code) => (
<span <span
key={code} key={code}
className="rounded border px-1.5 py-0.5 text-xs tabular-nums" className="rounded border px-1.5 py-0.5 text-xs tabular-nums"
style={{ borderColor: `${color}66`, color }} style={{ borderColor: `${color}66`, color, background: `${color}12` }}
> >
{code} {code}
</span> </span>
))} ))}
</div> </div>
</div> ) : (
<p className="text-sm text-muted-foreground">No territory.</p>
)} )}
</section>
{relations.length > 0 && ( <section>
<div> <h3 className="mb-2 text-xs font-medium uppercase tracking-wide text-muted-foreground">
<h3 className="mb-1.5 text-xs font-medium uppercase tracking-wide text-muted-foreground">Diplomacy</h3> Diplomacy
<div className="flex flex-wrap gap-1.5"> </h3>
{relations.length > 0 ? (
<ul className="space-y-1.5" data-testid="relations-list">
{relations.map(([id, status]) => { {relations.map(([id, status]) => {
const other = nations[id]; const other = nations[id];
const c = RELATION_COLORS[status] ?? '#94a3b8'; const c = RELATION_COLORS[status] ?? '#94a3b8';
return ( return (
<span <li
key={id} key={id}
className="rounded-md border px-2 py-0.5 text-xs" className="flex items-center gap-2 text-sm"
style={{ borderColor: `${c}66`, color: c }} data-testid="relation-row"
data-status={status}
> >
{other?.name ?? id}: {status} <span className="inline-block size-2.5 shrink-0 rounded-full" style={{ background: c }} />
<span className="min-w-0 flex-1 truncate text-foreground/85">
{other?.name ?? id}
</span> </span>
<span className="shrink-0 text-xs font-medium" style={{ color: c }}>
{RELATION_LABEL[status] ?? status}
</span>
</li>
); );
})} })}
</div> </ul>
</div> ) : (
<p className="text-sm text-muted-foreground">No diplomatic relations yet.</p>
)} )}
</section>
</CardContent> </CardContent>
</Card> </Card>
); );
+14 -5
View File
@@ -1,26 +1,35 @@
import { cn } from '@/lib/utils'; import { cn } from '@/lib/utils';
import { statValueColor } from '@/lib/colors';
interface Props { interface Props {
label: string; label: string;
value: number; 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; color?: string;
testId?: string; testId?: string;
className?: 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) { export default function StatBar({ label, value, color, testId, className }: Props) {
const v = Math.max(0, Math.min(100, Math.round(value))); const v = Math.max(0, Math.min(100, Math.round(value)));
return ( return (
<div className={cn('flex items-center gap-2 text-xs', className)}> <div className={cn('flex items-center gap-2 text-xs', className)}>
<span className="w-12 shrink-0 capitalize text-muted-foreground">{label}</span> <span className="w-16 shrink-0 capitalize text-muted-foreground">{label}</span>
<div className="h-2 flex-1 overflow-hidden rounded-full bg-muted"> <div className="h-2 flex-1 overflow-hidden rounded-full bg-muted">
<div <div
className="h-full rounded-full transition-all" className="h-full rounded-full transition-[width,background-color] duration-500 ease-out"
style={{ width: `${v}%`, background: color ?? 'var(--primary)' }} style={{ width: `${v}%`, background: color ?? statValueColor(v) }}
/> />
</div> </div>
<span className="w-7 text-right tabular-nums" data-testid={testId}> <span className="w-8 shrink-0 text-right font-semibold tabular-nums" data-testid={testId}>
{v} {v}
</span> </span>
</div> </div>
+305 -39
View File
@@ -1,7 +1,9 @@
import { useEffect, useRef } from 'react'; import { useEffect, useRef } from 'react';
import * as maplibregl from 'maplibre-gl'; import * as maplibregl from 'maplibre-gl';
import 'maplibre-gl/dist/maplibre-gl.css'; 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). // Minimal GeoJSON types (avoids a hard @types/geojson dependency).
interface GeoFeature { interface GeoFeature {
@@ -14,18 +16,47 @@ interface FeatureCollection {
type: 'FeatureCollection'; type: 'FeatureCollection';
features: GeoFeature[]; 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 STYLE_URL = 'https://tiles.openfreemap.org/styles/dark';
const COUNTRIES_SOURCE = 'countries'; const COUNTRIES_SOURCE = 'countries';
const HOVER_SOURCE = 'hover';
const FLASH_SOURCE = 'flash';
const FILL_LAYER = 'countries-fill'; const FILL_LAYER = 'countries-fill';
const LINE_LAYER = 'countries-line'; 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( /** Sensible per-scenario initial camera: Europe for the WWII scenarios, wider for the global ones. */
geo: FeatureCollection, function cameraForPreset(presetId: string): { center: [number, number]; zoom: number } {
regions: RegionMap, switch (presetId) {
playerNationId: string, case 'wwii-1939':
): FeatureCollection { 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)); const out: FeatureCollection = JSON.parse(JSON.stringify(geo));
for (const f of out.features) { for (const f of out.features) {
f.properties = f.properties ?? {}; f.properties = f.properties ?? {};
@@ -33,12 +64,11 @@ function applyRegions(
if (r) { if (r) {
f.properties.fill = r.color; f.properties.fill = r.color;
f.properties.owner = r.owner; f.properties.owner = r.owner;
// Highlight the player's regions with a bright stroke. f.properties.stroke = 'rgba(255,255,255,0.10)';
f.properties.stroke = r.ownerId === playerNationId ? '#fbbf24' : 'rgba(255,255,255,0.18)';
f.properties.isPlayer = r.ownerId === playerNationId ? 1 : 0; f.properties.isPlayer = r.ownerId === playerNationId ? 1 : 0;
} else { } else {
f.properties.fill = 'rgba(148,163,184,0.06)'; f.properties.fill = 'rgba(148,163,184,0.05)';
f.properties.stroke = 'rgba(255,255,255,0.08)'; f.properties.stroke = 'rgba(255,255,255,0.06)';
f.properties.owner = ''; f.properties.owner = '';
f.properties.isPlayer = 0; f.properties.isPlayer = 0;
} }
@@ -46,42 +76,144 @@ function applyRegions(
return out; return out;
} }
/** Build a FeatureCollection of just the features whose id is in `codes` (cloned). */
function featuresByCodes(geo: FeatureCollection, codes: Iterable<string>): 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) => ({ '&': '&amp;', '<': '&lt;', '>': '&gt;', '"': '&quot;', "'": '&#39;' }[c] as string));
function statRow(label: string, value?: number): string {
if (value == null || Number.isNaN(value)) return '';
return `<div class="hm-stat"><span class="hm-stat-k">${label}</span><span class="hm-stat-v">${Math.round(value)}</span></div>`;
}
/** 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 `<div class="hm-popup"><div class="hm-popup-title">${esc(countryName || 'Unclaimed')}</div><div class="hm-popup-sub">No owner</div></div>`;
}
const stats = [statRow('Mil', nation.stats.military), statRow('Eco', nation.stats.economy)].join('');
return `<div class="hm-popup">
<div class="hm-popup-title">${esc(countryName || region.owner)}</div>
<div class="hm-popup-owner"><span class="hm-chip" style="background:${region.color}"></span>${esc(region.owner)}</div>
<div class="hm-stats">${stats}</div>
</div>`;
}
/** Click popup: the full owning-nation profile with all four stats. */
function clickHtml(countryName: string, region?: RegionOwner, nation?: Nation): string {
if (!region || !nation) {
return `<div class="hm-popup"><div class="hm-popup-title">${esc(countryName || 'Unclaimed')}</div><div class="hm-popup-sub">No owner</div></div>`;
}
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 `<div class="hm-popup">
<div class="hm-popup-title">${esc(countryName || region.owner)}</div>
<div class="hm-popup-owner hm-popup-owner-big"><span class="hm-chip" style="background:${region.color}"></span>${esc(region.owner)}</div>
<div class="hm-popup-sub">${regions} region${regions === 1 ? '' : 's'} · click focuses the dossier</div>
<div class="hm-stats">${stats}</div>
</div>`;
}
/** Small bottom-left legend mapping each nation on the map to its stable color. */
function MapLegend({ regions }: { regions: RegionMap }) {
const owners = new Map<string, { color: string; owner: string }>();
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 (
<div
data-testid="map-legend"
className="pointer-events-none absolute bottom-3 left-3 z-10 max-h-[62%] overflow-hidden rounded-lg border bg-card/85 p-2.5 backdrop-blur-sm"
>
<div className="mb-1.5 text-[10px] font-semibold uppercase tracking-widest text-muted-foreground">
Nations
</div>
<ul className="space-y-1">
{list.map(([id, o]) => (
<li key={id} data-testid="map-legend-item" className="flex items-center gap-1.5 text-xs">
<span className="inline-block size-2.5 shrink-0 rounded-sm" style={{ background: o.color }} />
<span className="truncate">{o.owner}</span>
</li>
))}
</ul>
</div>
);
}
interface Props { interface Props {
regions: RegionMap; regions: RegionMap;
playerNationId: string; playerNationId: string;
/** All nations, for hover/click stats and color chips. */
nations: Record<string, Nation>;
/** The active scenario id, for a sensible initial camera. */
presetId: string;
className?: 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 * World basemap (MapLibre GL) + a country choropleth colored by the owning
* nation. Ported from flight's Map.tsx (remote style + idempotent `ensureLayers` * nation. The choropleth is the star: a near-black basemap, per-nation stable
* re-establish on `styledata`), stripped of all flight-specific data/logic. * 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<HTMLDivElement>(null); const containerRef = useRef<HTMLDivElement>(null);
const mapRef = useRef<maplibregl.Map | null>(null); const mapRef = useRef<maplibregl.Map | null>(null);
const baseGeoRef = useRef<FeatureCollection | null>(null); const baseGeoRef = useRef<FeatureCollection | null>(null);
// Latest props, so the mount effect's (re)establish closure always sees them. // Latest props, so the mount effect's (re)establish closure always sees them.
const regionsRef = useRef(regions); const regionsRef = useRef(regions);
const nationsRef = useRef(nations);
const playerRef = useRef(playerNationId); const playerRef = useRef(playerNationId);
const presetRef = useRef(presetId);
const onFocusNationRef = useRef(onFocusNation);
regionsRef.current = regions; regionsRef.current = regions;
nationsRef.current = nations;
playerRef.current = playerNationId; 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<RegionMap | null>(null);
const primedRef = useRef(false);
const flashApiRef = useRef<((codes: string[]) => void) | null>(null);
useEffect(() => { useEffect(() => {
if (!containerRef.current) return; if (!containerRef.current) return;
let cancelled = false; let cancelled = false;
let flashRaf = 0;
const cam = cameraForPreset(presetRef.current);
const map = new maplibregl.Map({ const map = new maplibregl.Map({
container: containerRef.current, container: containerRef.current,
style: STYLE_URL, style: STYLE_URL,
center: [15, 50], center: cam.center,
zoom: 2.5, zoom: cam.zoom,
minZoom: 1.25, minZoom: 1.25,
maxZoom: 8, maxZoom: 8,
attributionControl: { compact: true }, attributionControl: { compact: true },
}); });
mapRef.current = map; 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<FeatureCollection> => const loadGeo = (): Promise<FeatureCollection> =>
baseGeoRef.current baseGeoRef.current
? Promise.resolve(baseGeoRef.current) ? Promise.resolve(baseGeoRef.current)
@@ -95,19 +227,89 @@ export default function WorldMap({ regions, playerNationId, className }: Props)
return g; 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]; const f = e.features?.[0];
if (!f) return; if (!f) return;
const p = f.properties as { owner?: string; name?: string; isPlayer?: number }; const id = f.id == null ? null : String(f.id);
const label = p.owner ? `${p.owner}${p.isPlayer ? ' (your nation)' : ''}` : p.name ?? 'Unclaimed'; if (id && id !== lastHoverId) {
new maplibregl.Popup({ offset: 8 }) lastHoverId = id;
.setLngLat(f.geometry.coordinates as [number, number]) const geo = baseGeoRef.current;
.setText(label) 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); .addTo(map);
map.getCanvas().style.cursor = 'pointer';
}; };
// Idempotent: re-adds the countries source + layers after any setStyle, and on const onHoverOut = () => {
// first load. (Vite serves MapLibre's worker automatically, so no setWorkerUrl.) 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 () => { const ensureLayers = async () => {
let geo: FeatureCollection; let geo: FeatureCollection;
try { try {
@@ -123,15 +325,18 @@ export default function WorldMap({ regions, playerNationId, className }: Props)
data: applyRegions(geo, regionsRef.current, playerRef.current), 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)) { if (!map.getLayer(FILL_LAYER)) {
map.addLayer({ map.addLayer({
id: FILL_LAYER, id: FILL_LAYER,
type: 'fill', type: 'fill',
source: COUNTRIES_SOURCE, source: COUNTRIES_SOURCE,
paint: { paint: { 'fill-color': ['get', 'fill'], 'fill-opacity': 0.8 },
'fill-color': ['get', 'fill'],
'fill-opacity': 0.55,
},
}); });
} }
if (!map.getLayer(LINE_LAYER)) { if (!map.getLayer(LINE_LAYER)) {
@@ -139,14 +344,52 @@ export default function WorldMap({ regions, playerNationId, className }: Props)
id: LINE_LAYER, id: LINE_LAYER,
type: 'line', type: 'line',
source: COUNTRIES_SOURCE, source: COUNTRIES_SOURCE,
paint: { paint: { 'line-color': ['get', 'stroke'], 'line-width': 0.6, 'line-opacity': 0.9 },
'line-color': ['get', 'stroke'],
'line-width': 0.75,
},
}); });
} }
map.off('click', FILL_LAYER, onCountryClick); // NOTE: layers are added in bottom→top order (each appends on top). The
map.on('click', FILL_LAYER, onCountryClick); // 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(); if (map.isStyleLoaded()) void ensureLayers();
@@ -154,19 +397,42 @@ export default function WorldMap({ regions, playerNationId, className }: Props)
return () => { return () => {
cancelled = true; cancelled = true;
cancelAnimationFrame(flashRaf);
flashApiRef.current = null;
map.remove(); map.remove();
mapRef.current = null; 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(() => { useEffect(() => {
const map = mapRef.current; const map = mapRef.current;
const geo = baseGeoRef.current; const geo = baseGeoRef.current;
if (!map || !geo) return; const cur = regions;
const src = map.getSource(COUNTRIES_SOURCE) as maplibregl.GeoJSONSource | undefined; const prev = prevRegionsRef.current;
if (src) void src.setData(applyRegions(geo, regions, playerNationId)); 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<string>();
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]); }, [regions, playerNationId]);
return <div ref={containerRef} data-testid="world-map" className={className} />; return (
<div className={cn('relative h-full w-full', className)}>
<div ref={containerRef} data-testid="world-map" className="h-full w-full" />
<MapLegend regions={regions} />
</div>
);
} }
+116
View File
@@ -100,3 +100,119 @@
background-color: var(--background); 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;
}
+11
View File
@@ -27,3 +27,14 @@ export const OUTCOME_COLORS: Record<EventOutcome, string> = {
partial: '#f59e0b', partial: '#f59e0b',
neutral: '#94a3b8', 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%)`;
}