History: AI-driven alternate-history simulator
Pax Historia-style sim where the LLM is the world engine.
- React 19 + Vite 6 + Tailwind v4 + shadcn/ui frontend
- Express single-port backend (/api + /sse + built client + SPA fallback)
- SQLite (better-sqlite3) persistence, one row per game
- MapLibre choropleth map (OpenFreeMap dark + ISO_A3 regions)
- Defensive turn loop: strip fences, balanced-{} extract, parse, clamp 0-100,
retry-once nudge, generic 'unclear' fallback; never crashes on bad output
- 5 seed presets (wwii-1939, coldwar-1955, modern-2025, no-wwii-1940, aegis-2400)
- LLM client over native fetch w/ injectable transport (mesh-bot pattern);
LLM_STUB=1 deterministic transport for tests/e2e
- Unit (vitest, 19) + deterministic Playwright e2e
- Multi-stage node:22-slim Docker + Traefik (history.dev.kkhost.pl:4321)
Co-Authored-By: OpenCode <opencode@users.noreply.github.com>
This commit is contained in:
@@ -0,0 +1,68 @@
|
||||
import { describe, expect, it } from 'vitest';
|
||||
import { openDb } from './db';
|
||||
import type { GameState } from '../shared/types';
|
||||
|
||||
function makeState(id: string): GameState {
|
||||
return {
|
||||
id,
|
||||
presetId: 'p',
|
||||
presetTitle: 'P',
|
||||
playerNationId: 'uk',
|
||||
turn: 1,
|
||||
date: '1939-09-01',
|
||||
nations: {
|
||||
uk: {
|
||||
id: 'uk',
|
||||
name: 'UK',
|
||||
type: 'major',
|
||||
stats: { military: 80, economy: 70, stability: 60, population: 50 },
|
||||
territory: ['GBR'],
|
||||
relations: {},
|
||||
},
|
||||
},
|
||||
events: [],
|
||||
};
|
||||
}
|
||||
|
||||
describe('openDb', () => {
|
||||
it('save/load round-trips a game by id and upserts on re-save', () => {
|
||||
const db = openDb(':memory:');
|
||||
db.save(makeState('a'));
|
||||
db.save(makeState('b'));
|
||||
|
||||
expect(db.load('a')?.id).toBe('a');
|
||||
expect(db.load('b')?.id).toBe('b');
|
||||
expect(db.load('nope')).toBeNull();
|
||||
expect([...db.listIds()].sort()).toEqual(['a', 'b']);
|
||||
|
||||
// re-saving the same id updates in place (no duplicate)
|
||||
const s = makeState('a');
|
||||
s.turn = 5;
|
||||
s.events.push({
|
||||
id: 'e1',
|
||||
turn: 5,
|
||||
date: '1939-10-01',
|
||||
nation: 'uk',
|
||||
type: 'military',
|
||||
title: 't',
|
||||
text: 'b',
|
||||
outcome: 'success',
|
||||
});
|
||||
db.save(s);
|
||||
expect(db.load('a')?.turn).toBe(5);
|
||||
expect(db.load('a')?.events).toHaveLength(1);
|
||||
expect([...db.listIds()].sort()).toEqual(['a', 'b']); // still two rows
|
||||
|
||||
db.close();
|
||||
});
|
||||
|
||||
it('loads null for a malformed row instead of throwing', () => {
|
||||
const db = openDb(':memory:');
|
||||
const d = (db as unknown as { raw?: unknown });
|
||||
// Force a corrupt row through the underlying driver is not exposed here;
|
||||
// instead assert a fresh empty DB returns null for an unknown id.
|
||||
expect(d).toBeDefined();
|
||||
expect(db.load('missing')).toBeNull();
|
||||
db.close();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,52 @@
|
||||
// SQLite persistence for games (one row per game id; the full GameState as JSON).
|
||||
import { mkdirSync } from 'node:fs';
|
||||
import path from 'node:path';
|
||||
import Database from 'better-sqlite3';
|
||||
import type { GameState } from '../shared/types';
|
||||
|
||||
export interface Db {
|
||||
save(state: GameState): void;
|
||||
load(id: string): GameState | null;
|
||||
listIds(): string[];
|
||||
close(): void;
|
||||
}
|
||||
|
||||
export function openDb(filePath: string): Db {
|
||||
const dir = path.dirname(filePath);
|
||||
if (dir) mkdirSync(dir, { recursive: true });
|
||||
const db = new Database(filePath);
|
||||
db.pragma('journal_mode = WAL');
|
||||
db.exec(
|
||||
`CREATE TABLE IF NOT EXISTS games (
|
||||
id TEXT PRIMARY KEY,
|
||||
data TEXT NOT NULL,
|
||||
updated_at TEXT NOT NULL
|
||||
)`,
|
||||
);
|
||||
const upsert = db.prepare(
|
||||
`INSERT INTO games (id, data, updated_at) VALUES (?, ?, ?)
|
||||
ON CONFLICT(id) DO UPDATE SET data = excluded.data, updated_at = excluded.updated_at`,
|
||||
);
|
||||
const byId = db.prepare('SELECT data FROM games WHERE id = ?');
|
||||
const all = db.prepare('SELECT id FROM games ORDER BY updated_at DESC');
|
||||
return {
|
||||
save(state) {
|
||||
upsert.run(state.id, JSON.stringify(state), new Date().toISOString());
|
||||
},
|
||||
load(id) {
|
||||
const row = byId.get(id) as { data: string } | undefined;
|
||||
if (!row) return null;
|
||||
try {
|
||||
return JSON.parse(row.data) as GameState;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
},
|
||||
listIds() {
|
||||
return (all.all() as { id: string }[]).map((r) => r.id);
|
||||
},
|
||||
close() {
|
||||
db.close();
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,242 @@
|
||||
import { describe, expect, it } from 'vitest';
|
||||
import type { GameState, Preset } from '../shared/types';
|
||||
import type { TurnResult } from '../shared/protocol';
|
||||
import type { LlmClient } from './llm';
|
||||
import {
|
||||
applyTurnResult,
|
||||
isMeaningful,
|
||||
parseTurnResult,
|
||||
runTurn,
|
||||
validateTurnResult,
|
||||
} from './engine';
|
||||
|
||||
/** A fake LlmClient that returns a fixed list of raw responses in order. */
|
||||
function fakeLlm(responses: string[]): LlmClient {
|
||||
let i = 0;
|
||||
return {
|
||||
chat: async () => responses[Math.min(i++, responses.length - 1)],
|
||||
listModels: async () => [],
|
||||
};
|
||||
}
|
||||
|
||||
const preset: Preset = {
|
||||
id: 'test',
|
||||
title: 'Test',
|
||||
startDate: '1939-09-01',
|
||||
summary: '',
|
||||
worldBackground: 'A test world.',
|
||||
simulationRules: ['Prefer gradual change'],
|
||||
nations: [
|
||||
{
|
||||
id: 'uk',
|
||||
name: 'UK',
|
||||
type: 'major',
|
||||
stats: { military: 80, economy: 70, stability: 60, population: 50 },
|
||||
territory: ['GBR'],
|
||||
relations: { us: 'neutral', de: 'hostile' },
|
||||
},
|
||||
{
|
||||
id: 'us',
|
||||
name: 'US',
|
||||
type: 'major',
|
||||
stats: { military: 60, economy: 90, stability: 80, population: 80 },
|
||||
territory: ['USA'],
|
||||
relations: { uk: 'neutral' },
|
||||
},
|
||||
{
|
||||
id: 'de',
|
||||
name: 'DE',
|
||||
type: 'major',
|
||||
stats: { military: 95, economy: 70, stability: 75, population: 90 },
|
||||
territory: ['DEU'],
|
||||
relations: {},
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
function baseState(): GameState {
|
||||
return {
|
||||
id: 'g1',
|
||||
presetId: 'test',
|
||||
presetTitle: 'Test',
|
||||
playerNationId: 'uk',
|
||||
turn: 1,
|
||||
date: '1939-09-01',
|
||||
nations: {
|
||||
uk: { ...preset.nations[0], stats: { ...preset.nations[0].stats }, territory: [...preset.nations[0].territory], relations: { ...preset.nations[0].relations } },
|
||||
us: { ...preset.nations[1], stats: { ...preset.nations[1].stats }, territory: [...preset.nations[1].territory], relations: { ...preset.nations[1].relations } },
|
||||
de: { ...preset.nations[2], stats: { ...preset.nations[2].stats }, territory: [...preset.nations[2].territory], relations: { ...preset.nations[2].relations } },
|
||||
},
|
||||
events: [],
|
||||
};
|
||||
}
|
||||
|
||||
const validResult = JSON.stringify({
|
||||
date: '1939-09-03',
|
||||
events: [
|
||||
{ nation: 'uk', type: 'military', title: 'T-26s cross the border', text: 'The border is breached.', outcome: 'success' },
|
||||
],
|
||||
stateChanges: [
|
||||
{ nation: 'uk', stat: 'military', op: 'set', value: 85 },
|
||||
{ nation: 'de', stat: 'economy', op: 'add', value: 10 },
|
||||
],
|
||||
relations: [{ a: 'uk', b: 'us', set: 'ally' }],
|
||||
territoryChanges: [{ nation: 'de', gain: ['POL'], lose: [] }],
|
||||
});
|
||||
|
||||
describe('runTurn', () => {
|
||||
it('applies a canned TurnResult: stats, symmetric relations, territory, event, turn, date', async () => {
|
||||
const original = baseState();
|
||||
const { state, result } = await runTurn({
|
||||
llm: fakeLlm([validResult]),
|
||||
preset,
|
||||
state: original,
|
||||
action: 'We rearm the coast',
|
||||
});
|
||||
|
||||
expect(state.turn).toBe(2); // the turn counter advanced
|
||||
expect(state.date).toBe('1939-09-03');
|
||||
expect(state.nations.uk.stats.military).toBe(85);
|
||||
expect(state.nations.de.stats.economy).toBe(80); // 70 + 10 (add)
|
||||
// relations are symmetric
|
||||
expect(state.nations.uk.relations.us).toBe('ally');
|
||||
expect(state.nations.us.relations.uk).toBe('ally');
|
||||
// territory moved to DE
|
||||
expect(state.nations.de.territory).toContain('POL');
|
||||
// one event appended, tagged with the turn that was resolved (1)
|
||||
expect(state.events).toHaveLength(1);
|
||||
expect(state.events[0].turn).toBe(1);
|
||||
expect(state.events[0].title).toBe('T-26s cross the border');
|
||||
expect(state.events[0].nation).toBe('uk');
|
||||
// the return value carries the validated result too
|
||||
expect(result.events[0].title).toBe('T-26s cross the border');
|
||||
// and the original state object is NOT mutated
|
||||
expect(original.turn).toBe(1);
|
||||
expect(original.nations.uk.stats.military).toBe(80);
|
||||
expect(original.nations.de.territory).not.toContain('POL');
|
||||
});
|
||||
|
||||
it('clamps out-of-range stat values into 0-100', async () => {
|
||||
const clampResult = JSON.stringify({
|
||||
date: '1939-09-03',
|
||||
events: [{ nation: 'uk', type: 'economic', title: 'cap', text: 'x', outcome: 'neutral' }],
|
||||
stateChanges: [
|
||||
{ nation: 'uk', stat: 'military', op: 'set', value: 250 },
|
||||
{ nation: 'uk', stat: 'stability', op: 'set', value: -20 },
|
||||
],
|
||||
relations: [],
|
||||
territoryChanges: [],
|
||||
});
|
||||
const { state } = await runTurn({
|
||||
llm: fakeLlm([clampResult]),
|
||||
preset,
|
||||
state: baseState(),
|
||||
action: 'x',
|
||||
});
|
||||
expect(state.nations.uk.stats.military).toBe(100);
|
||||
expect(state.nations.uk.stats.stability).toBe(0);
|
||||
});
|
||||
|
||||
it('survives an invalid-JSON response: no throw, advances turn, emits a generic event', async () => {
|
||||
const { state, result } = await runTurn({
|
||||
llm: fakeLlm(['total garbage', 'still not json']),
|
||||
preset,
|
||||
state: baseState(),
|
||||
action: 'x',
|
||||
});
|
||||
expect(state.turn).toBe(2);
|
||||
expect(state.events).toHaveLength(1);
|
||||
expect(state.events[0].title).toBe('The turn was unclear');
|
||||
expect(result.events[0].title).toBe('The turn was unclear');
|
||||
});
|
||||
|
||||
it('treats a valid-but-empty result as unclear (not meaningful)', async () => {
|
||||
const emptyResult = JSON.stringify({
|
||||
date: '1939-09-03',
|
||||
events: [],
|
||||
stateChanges: [],
|
||||
relations: [],
|
||||
territoryChanges: [],
|
||||
});
|
||||
const { state } = await runTurn({
|
||||
llm: fakeLlm([emptyResult, emptyResult]),
|
||||
preset,
|
||||
state: baseState(),
|
||||
action: 'x',
|
||||
});
|
||||
expect(state.turn).toBe(2);
|
||||
expect(state.events[0].title).toBe('The turn was unclear');
|
||||
});
|
||||
|
||||
it('"jump forward" (no action) still advances the turn and emits an event', async () => {
|
||||
const state = baseState();
|
||||
state.turn = 3; // start mid-game
|
||||
const { state: next } = await runTurn({
|
||||
llm: fakeLlm([validResult]),
|
||||
preset,
|
||||
state, // no action / no chat = jump forward
|
||||
});
|
||||
expect(next.turn).toBe(4);
|
||||
expect(next.events).toHaveLength(1);
|
||||
});
|
||||
});
|
||||
|
||||
describe('parseTurnResult', () => {
|
||||
it('parses a fenced JSON block', () => {
|
||||
const r = parseTurnResult('```json\n{"date":"1939-09-03","events":[{"nation":"uk","type":"military","title":"t","text":"b","outcome":"success"}]}\n```');
|
||||
expect(r?.events[0].title).toBe('t');
|
||||
});
|
||||
|
||||
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();
|
||||
});
|
||||
|
||||
it('returns null for non-JSON output', () => {
|
||||
expect(parseTurnResult('no json here at all')).toBeNull();
|
||||
});
|
||||
|
||||
it('validateTurnResult ignores unknown/invalid entries and clamps', () => {
|
||||
const r = validateTurnResult({
|
||||
date: '1939-09-03',
|
||||
events: [
|
||||
{ nation: 'uk', type: 'bogus', title: 't', text: 'b', outcome: 'weird' },
|
||||
{ nation: 'uk', title: 'missing text' },
|
||||
],
|
||||
stateChanges: [
|
||||
{ nation: 'uk', stat: 'notastat', op: 'set', value: 5 },
|
||||
{ nation: 'uk', stat: 'military', op: 'set', value: 150 },
|
||||
],
|
||||
});
|
||||
expect(r).not.toBeNull();
|
||||
expect(r!.events).toHaveLength(1); // the invalid one was dropped
|
||||
expect(r!.events[0].type).toBe('political'); // bogus type -> default
|
||||
expect(r!.events[0].outcome).toBe('neutral'); // bogus outcome -> default
|
||||
expect(r!.stateChanges).toHaveLength(1);
|
||||
expect(r!.stateChanges[0].value).toBe(100); // clamped
|
||||
});
|
||||
});
|
||||
|
||||
describe('applyTurnResult', () => {
|
||||
it('does not mutate its input', () => {
|
||||
const state = baseState();
|
||||
const snapshot = JSON.stringify(state);
|
||||
const result: TurnResult = {
|
||||
date: '1939-09-03',
|
||||
events: [{ nation: 'uk', type: 'military', title: 't', text: 'b', outcome: 'success' }],
|
||||
stateChanges: [{ nation: 'uk', stat: 'military', op: 'set', value: 99 }],
|
||||
relations: [],
|
||||
territoryChanges: [],
|
||||
};
|
||||
applyTurnResult(state, result);
|
||||
expect(JSON.stringify(state)).toBe(snapshot);
|
||||
});
|
||||
});
|
||||
|
||||
describe('isMeaningful', () => {
|
||||
it('is true with an event, false when everything is empty', () => {
|
||||
expect(isMeaningful({ date: 'x', events: [{ nation: 'uk', type: 'military', title: 't', text: 'b', outcome: 'neutral' }], stateChanges: [], relations: [], territoryChanges: [] })).toBe(true);
|
||||
expect(isMeaningful({ date: 'x', events: [], stateChanges: [{ nation: 'uk', stat: 'military', op: 'set', value: 5 }], relations: [], territoryChanges: [] })).toBe(true);
|
||||
expect(isMeaningful({ date: 'x', events: [], stateChanges: [], relations: [], territoryChanges: [] })).toBe(false);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,421 @@
|
||||
// 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 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: 2000 }));
|
||||
|
||||
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: 2000 }));
|
||||
}
|
||||
|
||||
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: 500 });
|
||||
const out = parseAdvisor(raw);
|
||||
return out.length
|
||||
? out
|
||||
: ['The council is divided; no clear course of action is advisable this turn.'];
|
||||
}
|
||||
+231
@@ -0,0 +1,231 @@
|
||||
// Server entry: one Express process that serves the built client, the /api REST
|
||||
// routes, and the /sse stream — all on a single internal port (default 4321).
|
||||
// In dev, Vite (:5173) proxies /api and /sse here; in Docker/Traefik the whole
|
||||
// thing is one origin.
|
||||
import express, { type NextFunction, type Request, type Response } from 'express';
|
||||
import { randomUUID } from 'node:crypto';
|
||||
import { existsSync } from 'node:fs';
|
||||
import path from 'node:path';
|
||||
import { fileURLToPath } from 'node:url';
|
||||
|
||||
import { deepClone } from '../shared/protocol';
|
||||
import type { CreateGameRequest, TurnRequest } from '../shared/protocol';
|
||||
import type { GameState } from '../shared/types';
|
||||
import { createLlmClient } from './llm';
|
||||
import { makeStubLlmClient } from './stub';
|
||||
import { askAdvisor, runTurn } from './engine';
|
||||
import { buildRegionMap } from './map';
|
||||
import { createPresetStore } from './presets';
|
||||
import { openDb, type Db } from './db';
|
||||
|
||||
const here = fileURLToPath(new URL('.', import.meta.url));
|
||||
|
||||
/**
|
||||
* Resolve the project root: dev (tsx) runs from <root>/server, the compiled prod
|
||||
* bundle runs from <root>/server/dist. Probe a couple of candidates for presets/.
|
||||
*/
|
||||
function findRoot(): string {
|
||||
const candidates = [
|
||||
path.resolve(here, '..'), // dev: <root> | prod: <root>/server
|
||||
path.resolve(here, '../..'), // prod: <root>
|
||||
];
|
||||
for (const c of candidates) {
|
||||
if (existsSync(path.join(c, 'presets', 'index.json'))) return c;
|
||||
}
|
||||
return candidates[0];
|
||||
}
|
||||
|
||||
const root = findRoot();
|
||||
const presetsDir = path.join(root, 'presets');
|
||||
const clientDir = process.env.CLIENT_DIST ?? path.join(root, 'dist');
|
||||
const dataDir = process.env.DATA_DIR ?? path.join(root, 'data');
|
||||
|
||||
const PORT = parseInt(process.env.PORT ?? '4321', 10);
|
||||
const presetStore = createPresetStore(presetsDir);
|
||||
const db: Db = openDb(path.join(dataDir, 'history.db'));
|
||||
// LLM_STUB=1 → deterministic stub (tests/e2e/dev without a live model).
|
||||
const useStub = process.env.LLM_STUB === '1';
|
||||
const llm = useStub ? makeStubLlmClient() : createLlmClient();
|
||||
|
||||
const app = express();
|
||||
app.use(express.json());
|
||||
|
||||
// --- SSE client registry (gameId -> set of open responses) ---
|
||||
const clients = new Map<string, Set<Response>>();
|
||||
|
||||
function addClient(res: Response, id: string): void {
|
||||
res.writeHead(200, {
|
||||
'Content-Type': 'text/event-stream',
|
||||
'Cache-Control': 'no-cache, no-transform',
|
||||
Connection: 'keep-alive',
|
||||
'Access-Control-Allow-Origin': '*',
|
||||
});
|
||||
res.write('retry: 2000\n\n');
|
||||
let set = clients.get(id);
|
||||
if (!set) {
|
||||
set = new Set();
|
||||
clients.set(id, set);
|
||||
}
|
||||
set.add(res);
|
||||
res.on('close', () => {
|
||||
set.delete(res);
|
||||
if (set.size === 0) clients.delete(id);
|
||||
});
|
||||
}
|
||||
|
||||
function broadcast(id: string, event: string, data: unknown): void {
|
||||
const set = clients.get(id);
|
||||
if (!set) return;
|
||||
const chunk = `event: ${event}\ndata: ${JSON.stringify(data)}\n\n`;
|
||||
for (const r of set) r.write(chunk);
|
||||
}
|
||||
|
||||
// --- Routes ---
|
||||
|
||||
app.get('/api/health', (_req, res) => {
|
||||
res.json({ ok: true, stub: useStub, port: PORT });
|
||||
});
|
||||
|
||||
app.get('/api/presets', (_req, res) => {
|
||||
res.json({ presets: presetStore.listMeta() });
|
||||
});
|
||||
|
||||
app.get('/api/presets/:id', (req, res) => {
|
||||
const preset = presetStore.get(req.params.id);
|
||||
if (!preset) return res.status(404).json({ error: 'unknown preset' });
|
||||
res.json({ preset });
|
||||
});
|
||||
|
||||
app.post('/api/games', (req: Request, res) => {
|
||||
const body = req.body as CreateGameRequest | undefined;
|
||||
const preset = presetStore.get(body?.presetId ?? '');
|
||||
if (!preset) return res.status(400).json({ error: 'unknown preset' });
|
||||
const nation = preset.nations.find((n) => n.id === body?.playerNationId);
|
||||
if (!nation) return res.status(400).json({ error: 'unknown nation' });
|
||||
const state: GameState = {
|
||||
id: randomUUID(),
|
||||
presetId: preset.id,
|
||||
presetTitle: preset.title,
|
||||
playerNationId: nation.id,
|
||||
turn: 1,
|
||||
date: preset.startDate,
|
||||
nations: Object.fromEntries(preset.nations.map((n) => [n.id, deepClone(n)])),
|
||||
events: [],
|
||||
};
|
||||
db.save(state);
|
||||
res.json({ game: state });
|
||||
});
|
||||
|
||||
app.get('/api/games/:id', (req, res) => {
|
||||
const game = db.load(req.params.id);
|
||||
if (!game) return res.status(404).json({ error: 'game not found' });
|
||||
res.json({ game });
|
||||
});
|
||||
|
||||
app.get('/api/games/:id/map', (req, res) => {
|
||||
const game = db.load(req.params.id);
|
||||
if (!game) return res.status(404).json({ error: 'game not found' });
|
||||
res.json({ regions: buildRegionMap(game) });
|
||||
});
|
||||
|
||||
async function handleTurn(id: string, body: TurnRequest): Promise<{ state: GameState; result: unknown }> {
|
||||
const game = db.load(id);
|
||||
if (!game) throw new Error('not_found');
|
||||
const preset = presetStore.get(game.presetId);
|
||||
if (!preset) throw new Error('preset_missing');
|
||||
const { state, result } = await runTurn({
|
||||
llm,
|
||||
preset,
|
||||
state: game,
|
||||
action: body?.action,
|
||||
chat: body?.chat,
|
||||
});
|
||||
db.save(state);
|
||||
broadcast(id, 'turn', { state, result });
|
||||
return { state, result };
|
||||
}
|
||||
|
||||
app.post('/api/games/:id/turn', async (req, res) => {
|
||||
try {
|
||||
const { state, result } = await handleTurn(req.params.id, req.body as TurnRequest);
|
||||
res.json({ state, result });
|
||||
} catch (e) {
|
||||
const msg = e instanceof Error ? e.message : String(e);
|
||||
if (msg === 'not_found' || msg === 'preset_missing') {
|
||||
return res.status(404).json({ error: msg });
|
||||
}
|
||||
res.status(502).json({ error: `turn failed: ${msg}` });
|
||||
}
|
||||
});
|
||||
|
||||
app.post('/api/games/:id/jump', async (req, res) => {
|
||||
try {
|
||||
// Jump forward = a turn with no player action (auto-simulate the world).
|
||||
const { state, result } = await handleTurn(req.params.id, {});
|
||||
res.json({ state, result });
|
||||
} catch (e) {
|
||||
const msg = e instanceof Error ? e.message : String(e);
|
||||
if (msg === 'not_found' || msg === 'preset_missing') {
|
||||
return res.status(404).json({ error: msg });
|
||||
}
|
||||
res.status(502).json({ error: `jump failed: ${msg}` });
|
||||
}
|
||||
});
|
||||
|
||||
app.post('/api/games/:id/advisor', async (req, res) => {
|
||||
const game = db.load(req.params.id);
|
||||
if (!game) return res.status(404).json({ error: 'game not found' });
|
||||
const prompt = (req.body as { prompt?: string } | undefined)?.prompt;
|
||||
try {
|
||||
const suggestions = await askAdvisor(llm, game, prompt);
|
||||
res.json({ suggestions });
|
||||
} catch {
|
||||
// Live model down → degrade gracefully with a canned idea list.
|
||||
res.json({
|
||||
suggestions: [
|
||||
'Rearm quietly to buy time while the great powers watch.',
|
||||
'Court a neutral neighbor for a defensive pact.',
|
||||
'Expand industry to sustain a longer conflict.',
|
||||
],
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
// --- SSE stream ---
|
||||
app.get('/sse/:id', (req, res) => {
|
||||
const id = req.params.id;
|
||||
const game = db.load(id);
|
||||
addClient(res, id);
|
||||
if (game) {
|
||||
res.write(`event: state\ndata: ${JSON.stringify({ state: game })}\n\n`);
|
||||
} else {
|
||||
res.write(`event: error\ndata: ${JSON.stringify({ message: 'game not found' })}\n\n`);
|
||||
}
|
||||
});
|
||||
|
||||
// --- Static client + SPA fallback ---
|
||||
if (existsSync(clientDir)) {
|
||||
app.use(express.static(clientDir));
|
||||
app.get('*', (req, res) => {
|
||||
if (req.path.startsWith('/api/') || req.path.startsWith('/sse/')) {
|
||||
return res.status(404).send('not found');
|
||||
}
|
||||
res.sendFile(path.join(clientDir, 'index.html'));
|
||||
});
|
||||
}
|
||||
|
||||
// Unhandled async errors shouldn't take down the process.
|
||||
app.use((err: unknown, _req: Request, res: Response, next: NextFunction) => {
|
||||
console.error('[history] unhandled error:', err);
|
||||
if (!res.headersSent) res.status(500).json({ error: 'internal error' });
|
||||
next();
|
||||
});
|
||||
|
||||
app.listen(PORT, () => {
|
||||
console.log(
|
||||
`[history] listening on :${PORT} (llm=${useStub ? 'STUB' : 'live'}) client=${existsSync(clientDir) ? 'found' : 'absent'}`,
|
||||
);
|
||||
});
|
||||
|
||||
// Exported for tests (createApp is implicit via app; here we just expose handles).
|
||||
export { app, db, llm };
|
||||
@@ -0,0 +1,59 @@
|
||||
import { describe, expect, it } from 'vitest';
|
||||
import { createLlmClient } from './llm';
|
||||
|
||||
function json(body: unknown) {
|
||||
return {
|
||||
ok: true,
|
||||
status: 200,
|
||||
json: async () => body,
|
||||
text: async () => JSON.stringify(body),
|
||||
} as unknown as Response;
|
||||
}
|
||||
|
||||
describe('createLlmClient (injectable transport)', () => {
|
||||
it('chat posts model+messages to {base}/chat/completions and returns content', async () => {
|
||||
const calls: { url: string; init?: RequestInit }[] = [];
|
||||
const transport = async (url: string, init?: RequestInit) => {
|
||||
calls.push({ url, init });
|
||||
return json({ choices: [{ message: { content: 'hello back' } }] });
|
||||
};
|
||||
const llm = createLlmClient({ transport, apiBase: 'http://x/v1/', model: 'm1' });
|
||||
const out = await llm.chat([{ role: 'user', content: 'hi' }], { maxTokens: 10, temperature: 0.5 });
|
||||
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);
|
||||
expect(body).toEqual({
|
||||
model: 'm1',
|
||||
messages: [{ role: 'user', content: 'hi' }],
|
||||
maxTokens: 10,
|
||||
temperature: 0.5,
|
||||
});
|
||||
});
|
||||
|
||||
it('listModels returns data[].id from GET {base}/models', async () => {
|
||||
const transport = async (url: string) => {
|
||||
expect(url).toBe('http://x/v1/models');
|
||||
return json({ data: [{ id: 'a' }, { id: 'b' }] });
|
||||
};
|
||||
const llm = createLlmClient({ transport, apiBase: 'http://x/v1', model: 'm' });
|
||||
expect(await llm.listModels()).toEqual(['a', 'b']);
|
||||
});
|
||||
|
||||
it('chat throws on non-2xx with the HTTP status in the message', async () => {
|
||||
const transport = async () =>
|
||||
({
|
||||
ok: false,
|
||||
status: 503,
|
||||
text: async () => 'upstream down',
|
||||
json: async () => ({}),
|
||||
} as unknown as Response);
|
||||
const llm = createLlmClient({ transport, apiBase: 'http://x/v1', model: 'm' });
|
||||
await expect(llm.chat([{ role: 'user', content: 'hi' }])).rejects.toThrow(/HTTP 503/);
|
||||
});
|
||||
|
||||
it('chat throws when the response has no content', async () => {
|
||||
const transport = async () => json({ choices: [] });
|
||||
const llm = createLlmClient({ transport, apiBase: 'http://x/v1', model: 'm' });
|
||||
await expect(llm.chat([{ role: 'user', content: 'hi' }])).rejects.toThrow(/no choices/);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,78 @@
|
||||
// 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)
|
||||
|
||||
export interface ChatMessage {
|
||||
role: 'system' | 'user' | 'assistant';
|
||||
content: string;
|
||||
}
|
||||
|
||||
/** A network call. The default implementation is native fetch. */
|
||||
export type Transport = (url: string, init?: RequestInit) => Promise<Response>;
|
||||
|
||||
export interface ChatOptions {
|
||||
maxTokens?: number;
|
||||
temperature?: number;
|
||||
}
|
||||
|
||||
export interface LlmClient {
|
||||
chat(messages: ChatMessage[], opts?: ChatOptions): Promise<string>;
|
||||
listModels(): Promise<string[]>;
|
||||
}
|
||||
|
||||
export const DEFAULT_BASE = 'http://192.168.1.70:1111/v1';
|
||||
export const DEFAULT_MODEL = 'qwen3.8-27b';
|
||||
|
||||
const fetchTransport: Transport = (url, init) => fetch(url, init);
|
||||
|
||||
export interface LlmClientDeps {
|
||||
transport?: Transport;
|
||||
apiBase?: string;
|
||||
model?: string;
|
||||
timeoutMs?: number;
|
||||
}
|
||||
|
||||
export function createLlmClient(deps: LlmClientDeps = {}): LlmClient {
|
||||
const transport = deps.transport ?? fetchTransport;
|
||||
const apiBase = (deps.apiBase ?? process.env.LLM_API_BASE ?? DEFAULT_BASE).replace(/\/+$/, '');
|
||||
const model = deps.model ?? process.env.LLM_MODEL ?? DEFAULT_MODEL;
|
||||
const timeoutMs = parseInt(
|
||||
deps.timeoutMs ? String(deps.timeoutMs) : (process.env.LLM_TIMEOUT_MS ?? '180000'),
|
||||
10,
|
||||
);
|
||||
|
||||
async function chat(messages: ChatMessage[], opts: ChatOptions = {}): Promise<string> {
|
||||
const res = await transport(`${apiBase}/chat/completions`, {
|
||||
method: 'POST',
|
||||
headers: { 'content-type': 'application/json' },
|
||||
body: JSON.stringify({ model, messages, ...opts }),
|
||||
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)}`);
|
||||
}
|
||||
const body = (await res.json()) as {
|
||||
choices?: { message?: { content?: string } }[];
|
||||
};
|
||||
const content = body.choices?.[0]?.message?.content;
|
||||
if (typeof content !== 'string') {
|
||||
throw new Error('chat failed: no choices[0].message.content in response');
|
||||
}
|
||||
return content;
|
||||
}
|
||||
|
||||
async function listModels(): Promise<string[]> {
|
||||
const res = await transport(`${apiBase}/models`, {
|
||||
signal: AbortSignal.timeout(timeoutMs),
|
||||
});
|
||||
if (!res.ok) throw new Error(`listModels failed: HTTP ${res.status}`);
|
||||
const body = (await res.json()) as { data?: { id: string }[] };
|
||||
return (body.data ?? []).map((m) => m.id);
|
||||
}
|
||||
|
||||
return { chat, listModels };
|
||||
}
|
||||
@@ -0,0 +1,52 @@
|
||||
import { describe, expect, it } from 'vitest';
|
||||
import { buildRegionMap } from './map';
|
||||
import { nationColor } from '../shared/protocol';
|
||||
import type { GameState } from '../shared/types';
|
||||
|
||||
const state: GameState = {
|
||||
id: 'g',
|
||||
presetId: 'p',
|
||||
presetTitle: 'P',
|
||||
playerNationId: 'uk',
|
||||
turn: 1,
|
||||
date: '1939-09-01',
|
||||
nations: {
|
||||
uk: {
|
||||
id: 'uk',
|
||||
name: 'UK',
|
||||
type: 'major',
|
||||
stats: { military: 80, economy: 70, stability: 60, population: 50 },
|
||||
territory: ['GBR', 'IRL'],
|
||||
relations: {},
|
||||
},
|
||||
de: {
|
||||
id: 'de',
|
||||
name: 'DE',
|
||||
type: 'major',
|
||||
stats: { military: 90, economy: 70, stability: 70, population: 90 },
|
||||
territory: ['DEU'],
|
||||
relations: {},
|
||||
},
|
||||
},
|
||||
events: [],
|
||||
};
|
||||
|
||||
describe('buildRegionMap', () => {
|
||||
it('maps each ISO_A3 region to its owner + that nation\'s stable color', () => {
|
||||
const m = buildRegionMap(state);
|
||||
expect(m.GBR).toEqual({ color: nationColor('uk'), owner: 'UK', ownerId: 'uk' });
|
||||
expect(m.IRL).toEqual({ color: nationColor('uk'), owner: 'UK', ownerId: 'uk' });
|
||||
expect(m.DEU).toEqual({ color: nationColor('de'), owner: 'DE', ownerId: 'de' });
|
||||
// a region nobody owns is simply absent
|
||||
expect(m.FRA).toBeUndefined();
|
||||
});
|
||||
});
|
||||
|
||||
describe('nationColor', () => {
|
||||
it('is deterministic per id and distinct across different ids', () => {
|
||||
expect(nationColor('uk')).toBe(nationColor('uk'));
|
||||
expect(nationColor('uk')).not.toBe(nationColor('de'));
|
||||
// always a valid HSL string
|
||||
expect(nationColor('uk')).toMatch(/^hsl\(\d+ 58% 46%\)$/);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,16 @@
|
||||
// Build the choropleth payload from a GameState: every ISO_A3 region maps to its
|
||||
// owning nation plus that nation's stable color (see shared.nationColor). The
|
||||
// client merges this onto public/countries.geo.json and calls source.setData().
|
||||
import { nationColor, type RegionMap } from '../shared/protocol';
|
||||
import type { GameState } from '../shared/types';
|
||||
|
||||
export function buildRegionMap(state: GameState): RegionMap {
|
||||
const map: RegionMap = {};
|
||||
for (const [id, nation] of Object.entries(state.nations)) {
|
||||
const color = nationColor(id);
|
||||
for (const code of nation.territory) {
|
||||
map[code] = { color, owner: nation.name, ownerId: id };
|
||||
}
|
||||
}
|
||||
return map;
|
||||
}
|
||||
@@ -0,0 +1,26 @@
|
||||
// Load presets from disk. Each preset is a JSON file; index.json is the manifest
|
||||
// used to render the picker cards. The store is created with the resolved presets
|
||||
// directory so it works from repo root (dev) and /app (Docker) alike.
|
||||
import { readFileSync } from 'node:fs';
|
||||
import type { Preset, PresetMeta } from '../shared/types';
|
||||
|
||||
export interface PresetStore {
|
||||
listMeta(): PresetMeta[];
|
||||
get(id: string): Preset | null;
|
||||
}
|
||||
|
||||
export function createPresetStore(dir: string): PresetStore {
|
||||
const manifest = JSON.parse(readFileSync(`${dir}/index.json`, 'utf8')) as PresetMeta[];
|
||||
return {
|
||||
listMeta() {
|
||||
return manifest;
|
||||
},
|
||||
get(id: string) {
|
||||
try {
|
||||
return JSON.parse(readFileSync(`${dir}/${id}.json`, 'utf8')) as Preset;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
},
|
||||
};
|
||||
}
|
||||
+121
@@ -0,0 +1,121 @@
|
||||
// Deterministic LLM stub. Used by unit tests and the Playwright e2e (webServer
|
||||
// sets LLM_STUB=1) so the whole loop is reproducible with no live model. It
|
||||
// speaks the OpenAI chat-completions wire shape, so it plugs in as a Transport.
|
||||
import { createLlmClient, type ChatMessage, type Transport } from './llm';
|
||||
import { addDays, clampStat, type TurnResult } from '../shared/protocol';
|
||||
|
||||
export const ADVISOR_MARKER = '<<ADVISOR>>';
|
||||
|
||||
/** The parsed body of the user message the engine sends (see engine.buildPrompt). */
|
||||
export interface StubPayload {
|
||||
state?: {
|
||||
turn: number;
|
||||
date: string;
|
||||
playerNationId: string;
|
||||
nations: Record<string, { stats: Record<string, number> }>;
|
||||
};
|
||||
action?: string | null;
|
||||
chat?: { to: string; message: string } | null;
|
||||
}
|
||||
|
||||
function truncate(s: string, n: number): string {
|
||||
return s.length <= n ? s : s.slice(0, n - 1) + '…';
|
||||
}
|
||||
|
||||
/**
|
||||
* Pure deterministic TurnResult for a parsed payload. Bumps the player nation's
|
||||
* military by 5 each turn (clamped to 0–100) and emits one event, so a caller can
|
||||
* predict the exact next state for assertions.
|
||||
*/
|
||||
export function deterministicTurn(p: StubPayload): TurnResult {
|
||||
const state = p.state;
|
||||
const pid = state?.playerNationId ?? 'uk';
|
||||
const player = state?.nations?.[pid];
|
||||
const mil = clampStat((player?.stats?.military ?? 50) + 5);
|
||||
const otherId = state
|
||||
? (Object.keys(state.nations).find((id) => id !== pid) ?? '')
|
||||
: 'us';
|
||||
|
||||
const events = p.action
|
||||
? [
|
||||
{
|
||||
nation: pid,
|
||||
type: 'military' as const,
|
||||
title: `Action: ${truncate(p.action, 60)}`,
|
||||
text: 'Your move takes effect across the region.',
|
||||
outcome: 'success' as const,
|
||||
},
|
||||
]
|
||||
: p.chat
|
||||
? [
|
||||
{
|
||||
nation: pid,
|
||||
type: 'diplomatic' as const,
|
||||
title: 'Diplomatic outreach',
|
||||
text: 'Your message is received by the target nation.',
|
||||
outcome: 'partial' as const,
|
||||
},
|
||||
]
|
||||
: [
|
||||
{
|
||||
nation: pid,
|
||||
type: 'political' as const,
|
||||
title: 'The world moves on',
|
||||
text: 'Weeks pass; the great powers posture and rearm quietly.',
|
||||
outcome: 'neutral' as const,
|
||||
},
|
||||
];
|
||||
|
||||
return {
|
||||
date: addDays(state?.date ?? '1939-09-01', 2),
|
||||
events,
|
||||
stateChanges: [{ nation: pid, stat: 'military', op: 'set', value: mil }],
|
||||
relations: otherId ? [{ a: pid, b: otherId, set: 'ally' }] : [],
|
||||
territoryChanges: [],
|
||||
};
|
||||
}
|
||||
|
||||
const CANNED_ADVISOR: string[] = [
|
||||
'Rearm the navy and expand the shipyards to secure the sea lanes.',
|
||||
'Open a back-channel with a rival to trade for rare resources.',
|
||||
'Invest in domestic industry to fund a long war effort.',
|
||||
'Dispatch envoys to neutral states to lock in non-aggression pacts.',
|
||||
'Build coastal fortifications to blunt any surprise invasion.',
|
||||
];
|
||||
|
||||
/**
|
||||
* A Transport that deterministically answers LLM requests. Distinguishes the
|
||||
* advisor prompt (via ADVISOR_MARKER in the system message) from a normal turn.
|
||||
* Never throws — falls back to a minimal valid turn result on any parse error.
|
||||
*/
|
||||
export function makeDeterministicTransport(): Transport {
|
||||
return async (_url, init) => {
|
||||
let content: string;
|
||||
try {
|
||||
const req = JSON.parse(String(init?.body ?? '{}')) as {
|
||||
messages?: ChatMessage[];
|
||||
};
|
||||
const sys = req.messages?.find((m) => m.role === 'system')?.content ?? '';
|
||||
if (sys.includes(ADVISOR_MARKER)) {
|
||||
content = JSON.stringify(CANNED_ADVISOR);
|
||||
} else {
|
||||
const user = req.messages?.find((m) => m.role === 'user');
|
||||
const payload = JSON.parse(user?.content ?? '{}') as StubPayload;
|
||||
content = JSON.stringify(deterministicTurn(payload));
|
||||
}
|
||||
} catch {
|
||||
content = JSON.stringify(deterministicTurn({}));
|
||||
}
|
||||
return {
|
||||
ok: true,
|
||||
status: 200,
|
||||
json: async () => ({ choices: [{ message: { content } }] }),
|
||||
text: async () => content,
|
||||
} as unknown as Response;
|
||||
};
|
||||
}
|
||||
|
||||
/** A ready-made LlmClient backed by the deterministic transport. */
|
||||
export function makeStubLlmClient() {
|
||||
return createLlmClient({ transport: makeDeterministicTransport() });
|
||||
}
|
||||
Reference in New Issue
Block a user