- shared/protocol.ts: RenameOp/SplitOp/MergeOp + NationOp union; TurnResult.nationOps (required); ConfirmNationOp/RejectNationOp request+response types - shared/types.ts: GameState.pendingNationOps (type-only import of NationOp — no runtime cycle) - server/engine.ts: validateNationOps parser wired into validateTurnResult; isMeaningful counts proposals; applyTurnResult appends to the surviving pending list (never applies); runTurn fallback literal; system prompt gains the nationOps JSON field + rename proposal rule (both modes); new resolveNationRef/applyNationOp/confirmNationOp/rejectNationOp (id-first then unique case-insensitive name; rename changes Nation.name only; split/merge no-op until P4b; index validation returns null for 400) - server/db.ts: legacy rows load with pendingNationOps defaulted to [] - server/index.ts: fresh games start with pendingNationOps: [] - server/stub.ts: deterministicTurn returns nationOps: [] (the stub never proposes — unit-only coverage, matching P3) - tests: fixture updates (engine/db/map/regions) + new suites for resolveNationRef, validateNationOps, proposal lifecycle, confirm/reject (151 tests total)
176 lines
7.0 KiB
TypeScript
176 lines
7.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[];
|
||
/**
|
||
* (v0.22, #16 P3) Region keys currently in no man's land (`state.unowned`),
|
||
* emitted by the engine's user prompt. The stub ignores this field — it
|
||
* never releases a region (see deterministicTurn), so no-man's-land behavior
|
||
* is covered by unit tests against applyTurnResult, not by the stub.
|
||
*/
|
||
unowned?: 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.
|
||
*
|
||
* (v0.22, #16 P3) The stub NEVER releases a region to no man's land (it
|
||
* always returns `unowned: []`) — e2e assertions rely on its fully-deterministic
|
||
* one-conquest behavior, so the release/re-claim path is exercised by unit
|
||
* tests only (see server/engine.test.ts).
|
||
*
|
||
* (v0.23, #16 P4a) The stub likewise NEVER proposes nation-management ops (it
|
||
* always returns `nationOps: []`) — the propose→confirm/reject flow is
|
||
* covered by unit tests only, keeping the e2e assertions untouched.
|
||
*/
|
||
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,
|
||
unowned: [], // the stub never creates no man's land (see doc comment)
|
||
nationOps: [], // the stub never proposes nation ops (see doc comment)
|
||
};
|
||
}
|
||
|
||
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() });
|
||
}
|