- 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).
158 lines
6.0 KiB
TypeScript
158 lines
6.0 KiB
TypeScript
// 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 TerritoryChange, type TurnEventSpec, type TurnResult } from '../shared/protocol';
|
||
import type { RoundItem } from '../shared/types';
|
||
|
||
export const ADVISOR_MARKER = '<<ADVISOR>>';
|
||
|
||
/** The parsed body of the user message the engine sends (see engine.buildUserPrompt). */
|
||
export interface StubPayload {
|
||
state?: {
|
||
turn: number;
|
||
date: string;
|
||
playerNationId: string;
|
||
nations: Record<string, { stats: Record<string, number>; territory?: string[] }>;
|
||
};
|
||
/** The raw pending round the engine emitted (one entry per queued item). */
|
||
pending?: RoundItem[];
|
||
/**
|
||
* Region keys owned by other nations that share a land border with the
|
||
* player's territory — the engine's `frontier` field, computed from the
|
||
* adjacency manifest (v0.21, #16 P2). The stub cannot call tools, so it
|
||
* reads the frontier from the prompt instead.
|
||
*/
|
||
frontier?: string[];
|
||
}
|
||
|
||
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 per queued
|
||
* round item (action → military/success, diplomacy → diplomatic/partial), or a
|
||
* single political "The world moves on" event when the round is empty — so a
|
||
* caller can predict the exact next state for assertions. It always sets the
|
||
* player's military to (current + 5) and allies the player with the first other
|
||
* nation, advancing the date by 2 days.
|
||
*
|
||
* (v0.21, #16 P2) It also makes ONE contiguous conquest per turn: the player
|
||
* gains the first frontier region (sorted order) from its current owner, and
|
||
* that owner loses it. The stub bypasses the tool loop (it answers with plain
|
||
* content in one round), so the frontier comes from the prompt payload, not
|
||
* from a getNeighbours call. With an empty frontier (no controllable border)
|
||
* no territory change is emitted, keeping every existing assertion stable.
|
||
*/
|
||
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 items = p.pending ?? [];
|
||
|
||
// The one deterministic region transfer: the player takes the first
|
||
// frontier region (sorted → stable) from whichever other nation owns it.
|
||
let territoryChanges: TerritoryChange[] = [];
|
||
const frontier = p.frontier ?? [];
|
||
if (state && frontier.length > 0) {
|
||
const key = frontier[0];
|
||
const loserId = Object.keys(state.nations).find(
|
||
(id) => id !== pid && (state.nations[id].territory ?? []).includes(key),
|
||
);
|
||
if (loserId) {
|
||
territoryChanges = [
|
||
{ nation: pid, gain: [key], lose: [] },
|
||
{ nation: loserId, gain: [], lose: [key] },
|
||
];
|
||
}
|
||
}
|
||
|
||
const events: TurnEventSpec[] =
|
||
items.length === 0
|
||
? [
|
||
{
|
||
nation: pid,
|
||
type: 'political',
|
||
title: 'The world moves on',
|
||
text: 'Weeks pass without a decisive event.',
|
||
outcome: 'neutral',
|
||
},
|
||
]
|
||
: items.map((it) =>
|
||
it.kind === 'action'
|
||
? {
|
||
nation: pid,
|
||
type: 'military' as const,
|
||
title: `Action: ${truncate(it.text, 60)}`,
|
||
text: 'Your move takes effect across the region.',
|
||
outcome: 'success' as const,
|
||
}
|
||
: {
|
||
nation: pid,
|
||
type: 'diplomatic' as const,
|
||
title: 'Diplomatic outreach',
|
||
text: 'Your message is received.',
|
||
outcome: 'partial' 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() });
|
||
}
|