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
+6
View File
@@ -187,6 +187,12 @@ describe('parseTurnResult', () => {
expect(r?.events[0].title).toBe('t');
});
it('parses JSON prefixed by leading whitespace/newlines (reasoning model emits "\\n\\n{...}")', () => {
const r = parseTurnResult('\n\n{"date":"1939-09-03","events":[{"nation":"uk","type":"military","title":"t","text":"b","outcome":"success"}]}\n');
expect(r?.events[0].title).toBe('t');
expect(r?.date).toBe('1939-09-03');
});
it('extracts the object from surrounding prose', () => {
const r = parseTurnResult('Sure! Here is the result: {"date":"1939-09-03","events":[]} hope that helps');
expect(r).not.toBeNull();
+4 -4
View File
@@ -21,7 +21,7 @@ import {
type TurnEventSpec,
type TurnResult,
} from '../shared/protocol';
import type { LlmClient } from './llm';
import { DEFAULT_MAX_TOKENS, type LlmClient } from './llm';
import { ADVISOR_MARKER } from './stub';
const VALID_EVENT_TYPES = new Set<string>(EVENT_TYPES);
@@ -322,7 +322,7 @@ export async function runTurn(opts: {
let result = parseTurnResult(await llm.chat([
{ role: 'system', content: system },
{ role: 'user', content: user },
], { temperature: 0.4, maxTokens: 2000 }));
], { temperature: 0.4, maxTokens: DEFAULT_MAX_TOKENS }));
if (result === null || !isMeaningful(result)) {
result = parseTurnResult(await llm.chat([
@@ -334,7 +334,7 @@ export async function runTurn(opts: {
content:
'Your previous output was invalid. Respond with ONLY the JSON object described above — no prose, no fences.',
},
], { temperature: 0.4, maxTokens: 2000 }));
], { temperature: 0.4, maxTokens: DEFAULT_MAX_TOKENS }));
}
if (result === null || !isMeaningful(result)) {
@@ -413,7 +413,7 @@ export async function askAdvisor(
const raw = await llm.chat([
{ role: 'system', content: system },
{ role: 'user', content: user },
], { temperature: 0.7, maxTokens: 500 });
], { temperature: 0.7, maxTokens: DEFAULT_MAX_TOKENS });
const out = parseAdvisor(raw);
return out.length
? out
+18 -2
View File
@@ -1,5 +1,5 @@
import { describe, expect, it } from 'vitest';
import { createLlmClient } from './llm';
import { createLlmClient, DEFAULT_MAX_TOKENS } from './llm';
function json(body: unknown) {
return {
@@ -22,12 +22,28 @@ describe('createLlmClient (injectable transport)', () => {
expect(out).toBe('hello back');
expect(calls[0].url).toBe('http://x/v1/chat/completions'); // trailing slash normalized
const body = JSON.parse(calls[0].init!.body as string);
// The wire key is `max_tokens` (snake_case), never camelCase `maxTokens`.
expect(body).toEqual({
model: 'm1',
messages: [{ role: 'user', content: 'hi' }],
maxTokens: 10,
temperature: 0.5,
max_tokens: 10,
});
// No auth header: the live vLLM endpoint requires no API key.
expect(calls[0].init!.headers).toEqual({ 'content-type': 'application/json' });
});
it('chat uses a generous default max_tokens when the caller omits it', async () => {
const calls: { url: string; init?: RequestInit }[] = [];
const transport = async (url: string, init?: RequestInit) => {
calls.push({ url, init });
return json({ choices: [{ message: { content: 'ok' } }] });
};
const llm = createLlmClient({ transport, apiBase: 'http://x/v1', model: 'm1' });
await llm.chat([{ role: 'user', content: 'hi' }]);
const body = JSON.parse(calls[0].init!.body as string);
expect(body.max_tokens).toBe(DEFAULT_MAX_TOKENS);
expect(body.max_tokens).toBe(4000);
});
it('listModels returns data[].id from GET {base}/models', async () => {
+34 -6
View File
@@ -1,9 +1,18 @@
// Thin OpenAI chat-completions client over native fetch (mirrors mesh-bot/src/llm).
// The transport is injectable so unit tests and the deterministic e2e stub can
// replace the network call. Config is read from env, never hardcoded:
// LLM_API_BASE (default http://192.168.1.70:1111/v1)
// LLM_MODEL (default qwen3.8-27b)
// LLM_TIMEOUT_MS (default 180000 — the local 27B model can be slow)
// LLM_API_BASE (default https://ai.kkhost.pl/v1 — a vLLM endpoint, no API key)
// LLM_MODEL (default qwen3.8-27b — a vLLM *reasoning* model)
// LLM_TIMEOUT_MS (default 180000 — the 27B reasoning model can be slow)
//
// The live model is a REASONING model: it emits its chain-of-thought in the
// response's `reasoning` field and the final answer in `content`. It therefore
// needs a generous `max_tokens` budget to finish thinking before writing
// `content` — a too-small budget makes it run out mid-reasoning and return a
// null `content`. We always send an explicit, generous `max_tokens` (the
// OpenAI/vLLM wire key — NOT camelCase `maxTokens`, which the server ignores)
// and read only `choices[0].message.content`, ignoring `reasoning`. No auth
// header is added (the endpoint requires no key).
export interface ChatMessage {
role: 'system' | 'user' | 'assistant';
@@ -23,9 +32,18 @@ export interface LlmClient {
listModels(): Promise<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';
/**
* Generous token budget for every chat request. The live qwen3.8-27b is a
* reasoning model: it spends `reasoning_tokens` on its chain-of-thought before
* writing the final answer into `content`. A small budget makes it run out
* mid-reasoning and return a null `content`, so we give it room to finish both.
* `max_tokens` is always set in the request body, even when a caller omits it.
*/
export const DEFAULT_MAX_TOKENS = 4000;
const fetchTransport: Transport = (url, init) => fetch(url, init);
export interface LlmClientDeps {
@@ -45,18 +63,28 @@ export function createLlmClient(deps: LlmClientDeps = {}): LlmClient {
);
async function chat(messages: ChatMessage[], opts: ChatOptions = {}): Promise<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`, {
method: 'POST',
// No auth header: the ai.kkhost.pl vLLM endpoint requires no API key.
headers: { 'content-type': 'application/json' },
body: JSON.stringify({ model, messages, ...opts }),
body: JSON.stringify(payload),
signal: AbortSignal.timeout(timeoutMs),
});
if (!res.ok) {
const text = await res.text().catch(() => '');
throw new Error(`chat failed: HTTP ${res.status} ${text.slice(0, 200)}`);
}
// Reasoning models also return their chain-of-thought in a separate
// `reasoning` field. We deliberately ignore it and use only the final
// answer, which lives in `content`.
const body = (await res.json()) as {
choices?: { message?: { content?: string } }[];
choices?: { message?: { content?: string; reasoning?: string | null } }[];
};
const content = body.choices?.[0]?.message?.content;
if (typeof content !== 'string') {