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:
@@ -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
@@ -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:
|
||||||
|
|||||||
@@ -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');
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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>
|
||||||
|
|||||||
@@ -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>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -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>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -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
@@ -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) => ({ '&': '&', '<': '<', '>': '>', '"': '"', "'": ''' }[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
@@ -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;
|
||||||
|
}
|
||||||
|
|||||||
@@ -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%)`;
|
||||||
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user