Files
History/server/engine.ts
T
kamilkrzywda e327e9aea0 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.
2026-10-10 22:15:05 +00:00

422 lines
14 KiB
TypeScript

// The world-sim engine: pure, testable, no I/O. It (1) builds the LLM prompt from a
// preset + state, (2) parses and strictly validates the LLM's TurnResult JSON
// (defensively — never throws), (3) applies the delta atomically to a GameState,
// and (4) builds the advisor brainstorm prompt. The network call itself lives in
// llm.ts (injectable) and is invoked by runTurn.
import type {
EventOutcome,
EventType,
GameState,
Preset,
RelationStatus,
StatKey,
} from '../shared/types';
import { EVENT_TYPES, RELATION_STATUSES } from '../shared/types';
import {
addDays,
clampStat,
isDateLike,
makeEvent,
nationForPrompt,
type TurnEventSpec,
type TurnResult,
} from '../shared/protocol';
import { DEFAULT_MAX_TOKENS, type LlmClient } from './llm';
import { ADVISOR_MARKER } from './stub';
const VALID_EVENT_TYPES = new Set<string>(EVENT_TYPES);
const VALID_RELATIONS = new Set<string>(RELATION_STATUSES);
const VALID_OUTCOMES = new Set<string>(['success', 'failure', 'partial', 'neutral']);
const VALID_STATS = new Set<string>(['military', 'economy', 'stability', 'population']);
// --- Prompt building ----------------------------------------------------------
/** The LLM system prompt: world context + rules + a strict JSON contract. */
export function buildSystemPrompt(preset: Preset): string {
const rules = preset.simulationRules
.map((r) => `- ${r}`)
.join('\n');
return [
`WORLD: ${preset.title} — ${preset.startDate}`,
preset.worldBackground,
'',
'SIMULATION RULES',
rules,
'',
'You are the world simulator. You advance the shared world state for ALL nations given the current state and the player\'s move.',
'Respond with ONLY a valid JSON object of exactly this shape. No prose, no commentary, no markdown fences, no trailing commas:',
'{',
' "date": "YYYY-MM-DD (a few days after the current date)",',
' "events": [',
' {"nation":"<nationId>","type":"<military|diplomatic|economic|political|social|disaster|scientific|cultural>","title":"short headline","text":"1-3 sentences of narrative","outcome":"<success|failure|partial|neutral>"}',
' ],',
' "stateChanges": [{"nation":"<nationId>","stat":"<military|economy|stability|population>","op":"<set|add>","value":<0-100>}],',
' "relations": [{"a":"<nationId>","b":"<nationId>","set":"<ally|neutral|rival|hostile|war>"}],',
' "territoryChanges": [{"nation":"<nationId>","gain":["<ISO_A3>"],"lose":["<ISO_A3>"]}]',
'}',
'',
'HARD CONSTRAINTS',
'- Use only nation ids and ISO_A3 codes that exist in the provided state.',
'- Prefer 1-4 events per turn; keep each text 1-3 sentences.',
'- Keep every stat value between 0 and 100.',
'- Only change borders when war or a decisive political shift justifies it.',
'- If a field is not relevant this turn, set it to an empty array.',
].join('\n');
}
/** The user message: a compact JSON snapshot + the player's action/chat. */
export function buildUserPrompt(
state: GameState,
action?: string,
chat?: { to: string; message: string },
): string {
const payload = {
state: {
turn: state.turn,
date: state.date,
playerNationId: state.playerNationId,
nations: Object.fromEntries(
Object.entries(state.nations).map(([id, n]) => [id, nationForPrompt(n)]),
),
recentEvents: state.events.slice(-10).map((e) => ({
turn: e.turn,
date: e.date,
nation: e.nation,
type: e.type,
title: e.title,
text: e.text,
outcome: e.outcome,
})),
},
action: action ?? null,
chat: chat ?? null,
};
return JSON.stringify(payload, null, 2);
}
// --- Defensive parsing --------------------------------------------------------
function stripFence(text: string): string {
let t = text.trim();
const lead = t.match(/^```[a-zA-Z]*\s*\n?/);
if (lead) t = t.slice(lead[0].length);
const tail = t.match(/\n?\s*```\s*$/);
if (tail) t = t.slice(0, t.length - tail[0].length);
return t.trim();
}
/** Extract the first balanced `{ ... }` substring (string-aware). */
function extractBalancedObject(text: string): string | null {
const start = text.indexOf('{');
if (start === -1) return null;
let depth = 0;
let inStr = false;
let esc = false;
for (let i = start; i < text.length; i++) {
const c = text[i];
if (inStr) {
if (esc) esc = false;
else if (c === '\\') esc = true;
else if (c === '"') inStr = false;
continue;
}
if (c === '"') inStr = true;
else if (c === '{') depth++;
else if (c === '}' && --depth === 0) return text.slice(start, i + 1);
}
return null;
}
/**
* Parse raw LLM output into a validated TurnResult, or null if unusable. Strips
* code fences, extracts the first balanced object, JSON.parse, then validates.
*/
export function parseTurnResult(raw: string): TurnResult | null {
const cleaned = stripFence(raw);
const jsonText = extractBalancedObject(cleaned);
if (!jsonText) return null;
let obj: unknown;
try {
obj = JSON.parse(jsonText);
} catch {
return null;
}
return validateTurnResult(obj);
}
function str(s: unknown, max = 0): string | null {
if (typeof s !== 'string') return null;
const t = s.trim();
if (!t) return null;
return max ? t.slice(0, max) : t;
}
/**
* Validate and normalize an arbitrary parsed object into a safe TurnResult.
* Ignores unknown/invalid entries; clamps numeric stats to 0-100. Returns null
* only when the input is not even a plain object.
*/
export function validateTurnResult(input: unknown): TurnResult | null {
if (typeof input !== 'object' || input === null || Array.isArray(input)) return null;
const o = input as Record<string, unknown>;
const result: TurnResult = {
date: typeof o.date === 'string' ? o.date : '',
events: [],
stateChanges: [],
relations: [],
territoryChanges: [],
};
if (Array.isArray(o.events)) {
for (const e of o.events) {
if (!e || typeof e !== 'object') continue;
const ev = e as Record<string, unknown>;
const nation = str(ev.nation);
const title = str(ev.title, 200);
const text = str(ev.text, 1200);
if (!nation || !title || !text) continue;
const type = VALID_EVENT_TYPES.has(String(ev.type))
? (ev.type as EventType)
: 'political';
const outcome = VALID_OUTCOMES.has(String(ev.outcome))
? (ev.outcome as EventOutcome)
: 'neutral';
result.events.push({ nation, type, title, text, outcome });
}
}
if (Array.isArray(o.stateChanges)) {
for (const c of o.stateChanges) {
if (!c || typeof c !== 'object') continue;
const cc = c as Record<string, unknown>;
const nation = str(cc.nation);
const stat = str(cc.stat);
if (!nation || !stat || !VALID_STATS.has(stat)) continue;
if (typeof cc.value !== 'number' || !Number.isFinite(cc.value)) continue;
if (cc.op === 'add') {
result.stateChanges.push({
nation,
stat: stat as StatKey,
op: 'add',
value: cc.value,
});
} else if (cc.op === 'set') {
result.stateChanges.push({
nation,
stat: stat as StatKey,
op: 'set',
value: clampStat(cc.value),
});
}
}
}
if (Array.isArray(o.relations)) {
for (const r of o.relations) {
if (!r || typeof r !== 'object') continue;
const rr = r as Record<string, unknown>;
const a = str(rr.a);
const b = str(rr.b);
if (!a || !b || a === b) continue;
if (!VALID_RELATIONS.has(String(rr.set))) continue;
result.relations.push({ a, b, set: rr.set as RelationStatus });
}
}
if (Array.isArray(o.territoryChanges)) {
for (const t of o.territoryChanges) {
if (!t || typeof t !== 'object') continue;
const tt = t as Record<string, unknown>;
const nation = str(tt.nation);
if (!nation) continue;
const gain = Array.isArray(tt.gain)
? tt.gain.filter((x): x is string => typeof x === 'string')
: [];
const lose = Array.isArray(tt.lose)
? tt.lose.filter((x): x is string => typeof x === 'string')
: [];
if (gain.length === 0 && lose.length === 0) continue;
result.territoryChanges.push({ nation, gain, lose });
}
}
return result;
}
/** A turn is "meaningful" if it produced at least an event or a stat change. */
export function isMeaningful(result: TurnResult): boolean {
return result.events.length > 0 || result.stateChanges.length > 0;
}
// --- Applying the delta -------------------------------------------------------
function genericUnclearEvent(state: GameState): TurnEventSpec {
return {
nation: state.playerNationId,
type: 'political',
title: 'The turn was unclear',
text: 'The simulation could not resolve a clear outcome this turn; the world drifts forward on its own momentum.',
outcome: 'neutral',
};
}
/**
* Apply a TurnResult to a GameState immutably: advance the date/turn, append
* events, apply stat changes (clamped), symmetric relations, and territory moves.
*/
export function applyTurnResult(state: GameState, result: TurnResult): GameState {
const next: GameState = JSON.parse(JSON.stringify(state));
const actingTurn = state.turn; // this action resolves the current turn
next.turn = state.turn + 1; // then we advance to the next turn to play
next.date = isDateLike(result.date) ? result.date : addDays(state.date, 2);
for (const ev of result.events) {
next.events.push(makeEvent(ev, actingTurn, next.date));
}
for (const c of result.stateChanges) {
const n = next.nations[c.nation];
if (!n) continue;
n.stats[c.stat] = c.op === 'set' ? clampStat(c.value) : clampStat(n.stats[c.stat] + c.value);
}
for (const r of result.relations) {
const a = next.nations[r.a];
const b = next.nations[r.b];
if (!a || !b) continue;
a.relations[r.b] = r.set;
b.relations[r.a] = r.set;
}
for (const t of result.territoryChanges) {
const n = next.nations[t.nation];
if (!n) continue;
for (const code of t.gain) {
for (const other of Object.values(next.nations)) {
const i = other.territory.indexOf(code);
if (i !== -1) other.territory.splice(i, 1);
}
if (!n.territory.includes(code)) n.territory.push(code);
}
for (const code of t.lose) {
const i = n.territory.indexOf(code);
if (i !== -1) n.territory.splice(i, 1);
}
}
return next;
}
/**
* Run one turn end-to-end against the (injectable) LLM: build prompts, call the
* model, parse/validate, retry once with a nudge if invalid, and — if it is still
* invalid — emit a generic "unclear" event and advance. Never throws on bad
* model output; only the underlying transport/network can throw.
*/
export async function runTurn(opts: {
llm: LlmClient;
preset: Preset;
state: GameState;
action?: string;
chat?: { to: string; message: string };
}): Promise<{ state: GameState; result: TurnResult }> {
const { llm, preset, state, action, chat } = opts;
const system = buildSystemPrompt(preset);
const user = buildUserPrompt(state, action, chat);
let result = parseTurnResult(await llm.chat([
{ role: 'system', content: system },
{ role: 'user', content: user },
], { temperature: 0.4, maxTokens: DEFAULT_MAX_TOKENS }));
if (result === null || !isMeaningful(result)) {
result = parseTurnResult(await llm.chat([
{ role: 'system', content: system },
{ role: 'user', content: user },
{ role: 'assistant', content: '' },
{
role: 'user',
content:
'Your previous output was invalid. Respond with ONLY the JSON object described above — no prose, no fences.',
},
], { temperature: 0.4, maxTokens: DEFAULT_MAX_TOKENS }));
}
if (result === null || !isMeaningful(result)) {
result = {
date: addDays(state.date, 2),
events: [genericUnclearEvent(state)],
stateChanges: [],
relations: [],
territoryChanges: [],
};
}
return { state: applyTurnResult(state, result), result };
}
// --- Advisor (brainstorm) -----------------------------------------------------
export function buildAdvisorPrompt(
state: GameState,
prompt?: string,
): { system: string; user: string } {
const player = state.nations[state.playerNationId];
const system = [
`${ADVISOR_MARKER} You are a trusted strategic advisor to ${player?.name ?? 'the player'} (${state.playerNationId}).`,
'Given the current world state, brainstorm 3-5 concrete, in-character action ideas the player could take this turn.',
'Respond with ONLY a JSON array of 3-5 short strings. No object, no markdown fences, no prose.',
].join('\n');
const user = JSON.stringify({
player: player ? nationForPrompt(player) : null,
date: state.date,
turn: state.turn,
prompt: prompt ?? null,
});
return { system, user };
}
/** Parse advisor output (a JSON array of strings) into a clean suggestion list. */
export function parseAdvisor(raw: string): string[] {
const t = stripFence(raw);
const a = t.indexOf('[');
const b = t.lastIndexOf(']');
if (a !== -1 && b > a) {
try {
const arr = JSON.parse(t.slice(a, b + 1));
if (Array.isArray(arr)) {
const out = arr
.map((x) =>
typeof x === 'string'
? x
: x && typeof x === 'object'
? String((x as Record<string, unknown>).title ?? (x as Record<string, unknown>).text ?? '')
: '',
)
.map((s) => s.trim())
.filter((s) => s.length > 2);
if (out.length) return out.slice(0, 5);
}
} catch {
/* fall through to line-splitting */
}
}
return t
.split(/\n+/)
.map((l) => l.replace(/^[-*\d.)\s]+/, '').trim())
.filter((l) => l.length > 3)
.slice(0, 5);
}
/** Ask the LLM for 3-5 action ideas; degrades to a canned line if the model fails. */
export async function askAdvisor(
llm: LlmClient,
state: GameState,
prompt?: string,
): Promise<string[]> {
const { system, user } = buildAdvisorPrompt(state, prompt);
const raw = await llm.chat([
{ role: 'system', content: system },
{ role: 'user', content: user },
], { temperature: 0.7, maxTokens: DEFAULT_MAX_TOKENS });
const out = parseAdvisor(raw);
return out.length
? out
: ['The council is divided; no clear course of action is advisable this turn.'];
}