Files
History/server/stub.ts
T
kamilkrzywda 5abbebc58f LLM getNeighbours tool + contiguous region transfers (Stage 2 of #16 P2)
- 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).
2026-10-14 10:37:25 +00:00

158 lines
6.0 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
// 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() });
}