- llm.ts: LlmClient.chatWithTools — bounded OpenAI function-calling loop (default 6 tool rounds, then one forced no-tools request); shares the retry/backoff/reasoning-effort machinery with chat(); tools are only added to the request body when non-empty, so plain chat() bodies and their tests are unchanged; a tool-call response is a success (never a truncation); malformed tool arguments become an error tool-message. - engine.ts: GET_NEIGHBOURS_TOOL + makeNeighboursExecutor (degrades unknown tools/args/keys without throwing); playerFrontier (sorted, deduped, other-nation-owned only); buildSystemPrompt gains a tool variant (contiguity rules, frontier hint) used only when the tool is actually sent; buildUserPrompt emits a frontier field whenever a resolver is given; runTurn takes getNeighbours and uses chatWithTools when both the resolver and the client's capability are present, falling back to plain chat() otherwise. - applyTurnResult: snapshot-first sole-ownership algorithm — a region key transfers only with exactly one claimant and exactly one previous owner (different from the gainer); double claims and pre-existing double ownership are left untouched; lone losses are no-ops; every key has 0 or 2+ -> 1 owner post-turn, never orphaned or duplicated. - stub.ts: deterministic one-region contiguous conquest per turn from the prompt's frontier field (bypasses the tool loop; empty frontier -> no transfer, all existing assertions stay stable). - regions.ts: optional region-adjacency.json load; hasAdjacency() and neighboursOf(keys) (union, deduped, queried excluded, sorted). - index.ts: jump route passes the adjacency resolver when loaded. - tests: chatWithTools protocol (echo, executor args, bounded rounds, malformed args, retry), tool-path runTurn wiring + fallback, playerFrontier, executor, applyTurnResult invariants, neighboursOf/ hasAdjacency (105 unit tests, 14 e2e — all green).
679 lines
26 KiB
TypeScript
679 lines
26 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 ChatMessage,
|
|
type LlmClient,
|
|
type ToolDefinition,
|
|
type ToolExecutor,
|
|
type WireMessage,
|
|
} 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']);
|
|
|
|
// --- The LLM's getNeighbours tool (v0.21, #16 P2: contiguous region transfers) ---
|
|
|
|
/**
|
|
* The single tool the turn loop exposes to the model: the precomputed region
|
|
* adjacency manifest (public/region-adjacency.json, via the resolver injected
|
|
* into runTurn). Given a set of region keys it returns the union of their
|
|
* bordering keys (the queried keys themselves excluded, sorted for
|
|
* determinism) — so the model can verify that a territory transfer is
|
|
* *contiguous* (a nation gains a region it already abuts) before committing
|
|
* it, instead of moving whole countries at a whim.
|
|
*/
|
|
export const GET_NEIGHBOURS_TOOL: ToolDefinition = {
|
|
type: 'function',
|
|
function: {
|
|
name: 'getNeighbours',
|
|
description:
|
|
'List the region keys that share a land border with any of the given region keys. ' +
|
|
'Pass a nation\'s territory keys (or a single region key) to see which regions are ' +
|
|
'contiguous to it. Use it before transferring territory: a nation can realistically ' +
|
|
'only gain regions that border its existing territory.',
|
|
parameters: {
|
|
type: 'object',
|
|
properties: {
|
|
regionKeys: {
|
|
type: 'array',
|
|
items: { type: 'string' },
|
|
description: 'One or more region keys to query (e.g. a gaining nation\'s territory).',
|
|
},
|
|
},
|
|
required: ['regionKeys'],
|
|
additionalProperties: false,
|
|
},
|
|
},
|
|
};
|
|
|
|
/**
|
|
* Build the tool executor for the turn loop. Never throws: unknown tool
|
|
* names, malformed arguments, and unknown region keys all degrade to a safe
|
|
* response (an error object or an empty list) that the model can recover from.
|
|
*/
|
|
export function makeNeighboursExecutor(
|
|
getNeighbours: (keys: string[]) => string[],
|
|
validRegionKeys?: ReadonlySet<string>,
|
|
): ToolExecutor {
|
|
return (name, args) => {
|
|
if (name !== GET_NEIGHBOURS_TOOL.function.name) {
|
|
return JSON.stringify({ error: `unknown tool: ${name}` });
|
|
}
|
|
const raw = Array.isArray(args.regionKeys) ? args.regionKeys : [];
|
|
const keys = raw.filter((k): k is string => typeof k === 'string' && k.length > 0);
|
|
const valid = validRegionKeys ? keys.filter((k) => validRegionKeys.has(k)) : keys;
|
|
if (valid.length === 0) return JSON.stringify({ neighbours: [] });
|
|
return JSON.stringify({ neighbours: getNeighbours(valid) });
|
|
};
|
|
}
|
|
|
|
/**
|
|
* The player's frontier: region keys owned by *other* nations that share a
|
|
* land border with the player's territory (deduped, sorted — stable order for
|
|
* the deterministic stub). Computed server-side from the adjacency manifest so
|
|
* both the LLM (the `frontier` field of the user prompt) and the stub (which
|
|
* cannot call tools) see the same candidates. Empty when there is no
|
|
* adjacency data or no controllable border (e.g. the UK in 1939).
|
|
*/
|
|
export function playerFrontier(
|
|
state: GameState,
|
|
getNeighbours: (keys: string[]) => string[],
|
|
): string[] {
|
|
const player = state.nations[state.playerNationId];
|
|
if (!player || player.territory.length === 0) return [];
|
|
const playerKeys = new Set(player.territory);
|
|
const candidates = new Set<string>();
|
|
for (const key of getNeighbours(player.territory)) {
|
|
if (!playerKeys.has(key)) candidates.add(key);
|
|
}
|
|
if (candidates.size === 0) return [];
|
|
const frontier: string[] = [];
|
|
for (const n of Object.values(state.nations)) {
|
|
if (n.id === state.playerNationId) continue;
|
|
for (const key of n.territory) {
|
|
if (candidates.has(key)) {
|
|
candidates.delete(key);
|
|
frontier.push(key);
|
|
}
|
|
}
|
|
}
|
|
return frontier.sort();
|
|
}
|
|
|
|
// --- Prompt building ----------------------------------------------------------
|
|
|
|
/**
|
|
* The LLM system prompt: world context + rules + a strict JSON contract.
|
|
* When `withNeighboursTool` is set (the turn runs with the getNeighbours tool
|
|
* available) the contract gains the contiguity rules for territory transfers
|
|
* (v0.21, #16 P2): the model may only move regions across a shared border, and
|
|
* may verify borders with the tool before answering.
|
|
*/
|
|
export function buildSystemPrompt(preset: Preset, withNeighboursTool = false): string {
|
|
const rules = preset.simulationRules
|
|
.map((r) => `- ${r}`)
|
|
.join('\n');
|
|
const respondLine = withNeighboursTool
|
|
? 'You have a getNeighbours tool: given region keys it returns the region keys that border them. You may call it (a few times) to verify land borders before deciding territory transfers. Once done, respond with ONLY a valid JSON object of exactly this shape. No prose, no commentary, no markdown fences, no trailing commas:'
|
|
: 'Respond with ONLY a valid JSON object of exactly this shape. No prose, no commentary, no markdown fences, no trailing commas:';
|
|
const territoryRules = withNeighboursTool
|
|
? [
|
|
'- Territory changes must be contiguous: a nation may only GAIN region keys that share a land border with a region it already owns (verify with getNeighbours) and may only LOSE region keys it currently owns. One region key has exactly one owner at all times — never assign a key to two nations or to none.',
|
|
'- The "frontier" field of the user message lists region keys owned by other nations that border the player\'s territory — the natural targets of a player-led conquest this turn.',
|
|
]
|
|
: ['- Only change borders when war or a decisive political shift justifies it.'];
|
|
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.',
|
|
respondLine,
|
|
'{',
|
|
' "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":["<region key>"],"lose":["<region key>"]}]',
|
|
'}',
|
|
'',
|
|
'HARD CONSTRAINTS',
|
|
'- Use only nation ids and region keys that exist in the provided state.',
|
|
'- A region key identifies one sub-region; the "regions" table in the user message maps each key to "<region name> (<parent country ISO_A3>)". Use those keys in territoryChanges.',
|
|
'- 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.',
|
|
...territoryRules,
|
|
'- 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[],
|
|
regionReference?: Record<string, string>,
|
|
/**
|
|
* The player's frontier: region keys owned by other nations that border the
|
|
* player's territory (v0.21, #16 P2). Emitted verbatim (even when empty) so
|
|
* the model — and the deterministic stub, which reads it — see the same
|
|
* contiguous targets. Omitted entirely when undefined (no adjacency data).
|
|
*/
|
|
frontier?: string[],
|
|
): 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: Record<string, unknown> = {
|
|
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,
|
|
};
|
|
if (regionReference) payload.regions = regionReference;
|
|
if (frontier !== undefined) payload.frontier = frontier;
|
|
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, validKeys?: ReadonlySet<string>): 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;
|
|
}
|
|
// --- Territory (v0.21, #16 P2: a region key has exactly one owner) ---
|
|
//
|
|
// Snapshot-first: BEFORE any mutation, compute who currently owns each
|
|
// region key. A key transfers only when (a) exactly one nation claims to
|
|
// gain it — a double claim (two or more gainers) is rejected so the key
|
|
// stays where it is, (b) the key currently has exactly one owner — a
|
|
// pre-existing double ownership (or unownership) is never touched, and
|
|
// (c) that owner is not the gainer (a self-gain is a no-op). A transfer
|
|
// removes the key from every nation first (defensive) and then grants it
|
|
// to the gainer; "lose" entries are pure no-ops by design, because a lone
|
|
// loss would orphan the key and a valid gain already removes it from the
|
|
// old owner. Ownership is therefore conserved: a key can never end a turn
|
|
// unowned or multiply-owned.
|
|
const valid = (k: string) => !validKeys || validKeys.has(k);
|
|
const ownerOf = new Map<string, string>(); // key -> sole owner id, or '' (0 or 2+ owners)
|
|
for (const n of Object.values(state.nations)) {
|
|
for (const key of n.territory) {
|
|
if (!valid(key)) continue;
|
|
const cur = ownerOf.get(key);
|
|
if (cur === undefined) ownerOf.set(key, n.id);
|
|
else if (cur !== '') ownerOf.set(key, '');
|
|
}
|
|
}
|
|
// Claim pairs, deduped: one (key, gainer) per pair even if the model emits
|
|
// duplicate entries; two DIFFERENT gainers for one key is a double claim.
|
|
const claimPairs = new Set<string>();
|
|
for (const t of result.territoryChanges) {
|
|
if (!next.nations[t.nation]) continue;
|
|
for (const key of new Set(t.gain)) {
|
|
if (!valid(key)) continue;
|
|
claimPairs.add(`${key}\u0000${t.nation}`);
|
|
}
|
|
}
|
|
const claimants = new Map<string, Set<string>>(); // key -> distinct gainers
|
|
for (const pair of claimPairs) {
|
|
const idx = pair.indexOf('\u0000');
|
|
const key = pair.slice(0, idx);
|
|
const gainer = pair.slice(idx + 1);
|
|
let set = claimants.get(key);
|
|
if (set === undefined) {
|
|
set = new Set();
|
|
claimants.set(key, set);
|
|
}
|
|
set.add(gainer);
|
|
}
|
|
for (const [key, claiming] of claimants) {
|
|
if (claiming.size !== 1) continue; // double claim: the key stays as it was
|
|
const gainer = [...claiming][0];
|
|
const owner = ownerOf.get(key);
|
|
if (owner === undefined || owner === '' || owner === gainer) continue;
|
|
for (const n of Object.values(next.nations)) {
|
|
const i = n.territory.indexOf(key);
|
|
if (i !== -1) n.territory.splice(i, 1);
|
|
}
|
|
next.nations[gainer].territory.push(key);
|
|
}
|
|
// "lose" entries are deliberate no-ops (see above): a valid gain already
|
|
// removes the key from its old owner, and a lone loss would orphan it.
|
|
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, 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.
|
|
*
|
|
* When `getNeighbours` is provided AND the LLM client implements
|
|
* `chatWithTools` (v0.21, #16 P2), the turn runs the bounded function-
|
|
* calling loop with the getNeighbours tool (the model may verify land
|
|
* borders before committing a territory transfer) and the user prompt gains
|
|
* the player's `frontier` field. Otherwise it falls back to a plain chat()
|
|
* call — same as before the tool existed.
|
|
*/
|
|
export async function runTurn(opts: {
|
|
llm: LlmClient;
|
|
preset: Preset;
|
|
state: GameState;
|
|
items: RoundItem[];
|
|
/** Region reference table (key -> "name (parent)") to include in the user prompt. */
|
|
regionReference?: Record<string, string>;
|
|
/** Set of valid region keys; territory changes with other keys are ignored. */
|
|
validRegionKeys?: ReadonlySet<string>;
|
|
/**
|
|
* Region adjacency resolver (keys -> bordering keys, from the precomputed
|
|
* manifest). Its presence (together with the client's chatWithTools)
|
|
* switches the turn onto the getNeighbours tool path.
|
|
*/
|
|
getNeighbours?: (keys: string[]) => string[];
|
|
}): Promise<{ state: GameState; result: TurnResult }> {
|
|
const { llm, preset, state, items, regionReference, validRegionKeys, getNeighbours } = opts;
|
|
const chatWithTools = llm.chatWithTools;
|
|
const useTools = getNeighbours !== undefined && chatWithTools !== undefined;
|
|
const system = buildSystemPrompt(preset, useTools);
|
|
const frontier = getNeighbours ? playerFrontier(state, getNeighbours) : undefined;
|
|
const user = buildUserPrompt(state, items, regionReference, frontier);
|
|
const nudge =
|
|
'Your previous output was invalid. Respond with ONLY the JSON object described above for this batch — no prose, no fences.';
|
|
|
|
/**
|
|
* One model call with the standard turn sampling: the bounded
|
|
* function-calling loop when available, else a plain chat(). The nudge
|
|
* re-send below always starts a FRESH history (no tool context carried
|
|
* over).
|
|
*/
|
|
const call = async (history: WireMessage[]): Promise<string> => {
|
|
if (useTools) {
|
|
return await chatWithTools(
|
|
history,
|
|
[GET_NEIGHBOURS_TOOL],
|
|
makeNeighboursExecutor(getNeighbours, validRegionKeys),
|
|
{ temperature: 0.4, maxTokens: DEFAULT_MAX_TOKENS },
|
|
);
|
|
}
|
|
return llm.chat(history as ChatMessage[], { temperature: 0.4, maxTokens: DEFAULT_MAX_TOKENS });
|
|
};
|
|
|
|
let result = parseTurnResult(await call([
|
|
{ role: 'system', content: system },
|
|
{ role: 'user', content: user },
|
|
]));
|
|
|
|
if (result === null || !isMeaningful(result)) {
|
|
result = parseTurnResult(await call([
|
|
{ role: 'system', content: system },
|
|
{ role: 'user', content: user },
|
|
{ role: 'assistant', content: '' },
|
|
{ role: 'user', content: nudge },
|
|
]));
|
|
}
|
|
|
|
if (result === null || !isMeaningful(result)) {
|
|
result = {
|
|
date: addDays(state.date, 2),
|
|
events: [genericUnclearEvent(state)],
|
|
stateChanges: [],
|
|
relations: [],
|
|
territoryChanges: [],
|
|
};
|
|
}
|
|
|
|
return { state: applyTurnResult(state, result, validRegionKeys), 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.'];
|
|
}
|