473 lines
17 KiB
TypeScript
473 lines
17 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,
|
|
RoundItem,
|
|
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 your internal reasoning brief (a few sentences) and go straight to the JSON result; do not over-deliberate or re-derive facts already present in the state.',
|
|
'- 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 of the world plus the player's
|
|
* queued round (`items`) — the free-text actions and diplomatic messages to be
|
|
* simulated this period. `playerActions` is an LLM-friendly rendering of each
|
|
* item; the raw `pending` list is also emitted so the deterministic stub (and any
|
|
* other consumer) can read the exact items. The framing instruction tells the
|
|
* model to simulate the combined consequences of the whole batch (or to let the
|
|
* world advance on its own when the batch is empty) and return one TurnResult.
|
|
*/
|
|
export function buildUserPrompt(state: GameState, items: RoundItem[]): string {
|
|
const player = state.nations[state.playerNationId];
|
|
const playerName = player?.name ?? state.playerNationId;
|
|
const playerActions = items.map((i) =>
|
|
i.kind === 'action' ? i.text : { to: i.to ?? '', message: i.text },
|
|
);
|
|
const roundInstruction =
|
|
items.length > 0
|
|
? `This period the player (${playerName}) takes the following actions and sends the following diplomatic messages: ${JSON.stringify(
|
|
playerActions,
|
|
)}. Simulate the combined consequences across all nations and advance the world by one period. Return a single TurnResult.`
|
|
: 'The player takes no action this period; let the world advance on its own momentum. Return a single TurnResult.';
|
|
|
|
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(-5).map((e) => ({
|
|
turn: e.turn,
|
|
date: e.date,
|
|
nation: e.nation,
|
|
type: e.type,
|
|
title: e.title,
|
|
text: e.text,
|
|
outcome: e.outcome,
|
|
})),
|
|
},
|
|
playerActions,
|
|
pending: items,
|
|
roundInstruction,
|
|
};
|
|
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));
|
|
next.version = (state.version ?? 0) + 1; // every mutation is a new revision
|
|
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);
|
|
// Committing a turn consumes the whole queued round — reset it for the next.
|
|
next.pending = [];
|
|
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;
|
|
}
|
|
|
|
/**
|
|
* Immutably queue one item onto a round: append it to `pending` and return a new
|
|
* state. This never advances the turn or the date — that only happens when a
|
|
* round is committed via runTurn. Bumps `version` (a new revision, so stale SSE
|
|
* snapshots can never clobber it client-side). Exported so the append-without-
|
|
* advance behavior is unit-testable, and reused by the /action and /diplomacy
|
|
* routes.
|
|
*/
|
|
export function queueRoundItem(state: GameState, item: RoundItem): GameState {
|
|
const next: GameState = JSON.parse(JSON.stringify(state));
|
|
next.version = (state.version ?? 0) + 1;
|
|
next.pending.push(item);
|
|
return next;
|
|
}
|
|
|
|
/**
|
|
* Immutably remove one item from the pending round by index and return a new
|
|
* state (bumping `version`). Returns `null` when the index is missing or not a
|
|
* valid, in-bounds integer position, so the route can answer 400 without
|
|
* mutating anything. Reused by the server's /queue/remove route.
|
|
*/
|
|
export function removeRoundItem(state: GameState, index: number | undefined): GameState | null {
|
|
if (index === undefined || !Number.isInteger(index) || index < 0 || index >= state.pending.length) {
|
|
return null;
|
|
}
|
|
const next: GameState = JSON.parse(JSON.stringify(state));
|
|
next.version = (state.version ?? 0) + 1;
|
|
next.pending.splice(index, 1);
|
|
return next;
|
|
}
|
|
|
|
/**
|
|
* Run one turn end-to-end against the (injectable) LLM: build prompts, call the
|
|
* model ONCE over the whole pending batch, parse/validate, retry once with a
|
|
* nudge if the result is empty/invalid, and — if it is still invalid — emit a
|
|
* generic "unclear" event and advance. The committed state has `pending` cleared.
|
|
* Never throws on bad model output; only the underlying transport/network can throw.
|
|
*/
|
|
export async function runTurn(opts: {
|
|
llm: LlmClient;
|
|
preset: Preset;
|
|
state: GameState;
|
|
items: RoundItem[];
|
|
}): Promise<{ state: GameState; result: TurnResult }> {
|
|
const { llm, preset, state, items } = opts;
|
|
const system = buildSystemPrompt(preset);
|
|
const user = buildUserPrompt(state, items);
|
|
const nudge =
|
|
'Your previous output was invalid. Respond with ONLY the JSON object described above for this batch — no prose, no fences.';
|
|
|
|
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: nudge },
|
|
], { 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.'];
|
|
}
|