127 lines
4.6 KiB
TypeScript
127 lines
4.6 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 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> }>;
|
||
};
|
||
/** The raw pending round the engine emitted (one entry per queued item). */
|
||
pending?: RoundItem[];
|
||
}
|
||
|
||
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.
|
||
*/
|
||
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 ?? [];
|
||
|
||
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() });
|
||
}
|