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).
This commit is contained in:
+350
-7
@@ -1,14 +1,17 @@
|
||||
import { describe, expect, it } from 'vitest';
|
||||
import type { GameState, Preset, RoundItem } from '../shared/types';
|
||||
import { makeEvent, type TurnResult } from '../shared/protocol';
|
||||
import type { LlmClient } from './llm';
|
||||
import type { LlmClient, ToolDefinition, WireMessage } from './llm';
|
||||
import { makeStubLlmClient } from './stub';
|
||||
import {
|
||||
applyTurnResult,
|
||||
buildSystemPrompt,
|
||||
buildUserPrompt,
|
||||
GET_NEIGHBOURS_TOOL,
|
||||
isMeaningful,
|
||||
makeNeighboursExecutor,
|
||||
parseTurnResult,
|
||||
playerFrontier,
|
||||
queueRoundItem,
|
||||
removeRoundItem,
|
||||
runTurn,
|
||||
@@ -88,7 +91,10 @@ const validResult = JSON.stringify({
|
||||
{ nation: 'de', stat: 'economy', op: 'add', value: 10 },
|
||||
],
|
||||
relations: [{ a: 'uk', b: 'us', set: 'ally' }],
|
||||
territoryChanges: [{ nation: 'de', gain: ['POL'], lose: [] }],
|
||||
// v0.21 (#16 P2): DE gains the UK's GBR — the key has exactly one previous
|
||||
// owner, so the transfer applies (an unowned key like 'POL' would be ignored
|
||||
// by the new sole-ownership rules in applyTurnResult).
|
||||
territoryChanges: [{ nation: 'de', gain: ['GBR'], lose: [] }],
|
||||
});
|
||||
|
||||
describe('runTurn', () => {
|
||||
@@ -109,8 +115,10 @@ describe('runTurn', () => {
|
||||
// relations are symmetric
|
||||
expect(state.nations.uk.relations.us).toBe('ally');
|
||||
expect(state.nations.us.relations.uk).toBe('ally');
|
||||
// territory moved to DE
|
||||
expect(state.nations.de.territory).toContain('POL');
|
||||
// territory moved to DE: it gains GBR (the UK's sole previous owner) and
|
||||
// the old owner correspondingly loses it (v0.21 #16 P2: one owner per key)
|
||||
expect(state.nations.de.territory).toContain('GBR');
|
||||
expect(state.nations.uk.territory).not.toContain('GBR');
|
||||
// one event appended, tagged with the turn that was resolved (1)
|
||||
expect(state.events).toHaveLength(1);
|
||||
expect(state.events[0].turn).toBe(1);
|
||||
@@ -121,7 +129,8 @@ describe('runTurn', () => {
|
||||
// and the original state object is NOT mutated
|
||||
expect(original.turn).toBe(1);
|
||||
expect(original.nations.uk.stats.military).toBe(80);
|
||||
expect(original.nations.de.territory).not.toContain('POL');
|
||||
expect(original.nations.de.territory).not.toContain('GBR');
|
||||
expect(original.nations.uk.territory).toContain('GBR');
|
||||
});
|
||||
|
||||
it('clamps out-of-range stat values into 0-100', async () => {
|
||||
@@ -475,6 +484,8 @@ describe('applyTurnResult', () => {
|
||||
|
||||
it('with a validKeys set: applies real region keys, ignores hallucinated ones', () => {
|
||||
const state = baseState();
|
||||
// give the fixture's uk a real key ('wales-bbbb') for DE to be able to take
|
||||
state.nations.uk.territory = ['GBR', 'wales-bbbb'];
|
||||
const result: TurnResult = {
|
||||
date: '1939-09-03',
|
||||
events: [],
|
||||
@@ -485,7 +496,7 @@ describe('applyTurnResult', () => {
|
||||
territoryChanges: [{ nation: 'de', gain: ['wales-bbbb', 'not-a-region-xyz'], lose: [] }],
|
||||
};
|
||||
const next = applyTurnResult(state, result, new Set(['wales-bbbb']));
|
||||
// the valid key was gained (stolen from its owner); the bogus one was ignored
|
||||
// the valid key was gained (taken from its sole previous owner); the bogus one was ignored
|
||||
expect(next.nations.de.territory).toContain('wales-bbbb');
|
||||
expect(next.nations.uk.territory).not.toContain('wales-bbbb');
|
||||
// the bogus key never entered any nation's territory
|
||||
@@ -496,16 +507,348 @@ describe('applyTurnResult', () => {
|
||||
|
||||
it('without a validKeys set: applies every listed key (legacy/unit path)', () => {
|
||||
const state = baseState();
|
||||
state.nations.uk.territory = ['GBR', 'wales-bbbb'];
|
||||
const result: TurnResult = {
|
||||
date: '1939-09-03',
|
||||
events: [],
|
||||
stateChanges: [],
|
||||
relations: [],
|
||||
// 'wales-bbbb' has a sole previous owner (uk) -> it transfers; 'POL' is
|
||||
// owned by no one -> it is IGNORED (a key may not end a turn unowned).
|
||||
territoryChanges: [{ nation: 'de', gain: ['POL', 'wales-bbbb'], lose: [] }],
|
||||
};
|
||||
const next = applyTurnResult(state, result); // no validKeys -> nothing filtered
|
||||
expect(next.nations.de.territory).toContain('POL');
|
||||
expect(next.nations.de.territory).toContain('wales-bbbb');
|
||||
expect(next.nations.uk.territory).not.toContain('wales-bbbb');
|
||||
expect(next.nations.de.territory).not.toContain('POL');
|
||||
});
|
||||
});
|
||||
|
||||
describe('applyTurnResult (sole-ownership invariants, v0.21 #16 P2)', () => {
|
||||
const noEvents = {
|
||||
date: '1939-09-03',
|
||||
events: [],
|
||||
stateChanges: [],
|
||||
relations: [],
|
||||
};
|
||||
|
||||
it('a double claim (two different gainers, one key) is rejected: the key stays with its original owner', () => {
|
||||
const state = baseState();
|
||||
state.nations.uk.territory = ['GBR', 'wales-bbbb'];
|
||||
const next = applyTurnResult(state, {
|
||||
...noEvents,
|
||||
territoryChanges: [
|
||||
{ nation: 'de', gain: ['wales-bbbb'], lose: [] },
|
||||
{ nation: 'us', gain: ['wales-bbbb'], lose: [] },
|
||||
],
|
||||
});
|
||||
expect(next.nations.uk.territory).toContain('wales-bbbb');
|
||||
expect(next.nations.de.territory).not.toContain('wales-bbbb');
|
||||
expect(next.nations.us.territory).not.toContain('wales-bbbb');
|
||||
});
|
||||
|
||||
it('duplicate gain entries for the SAME nation are deduped (one claim, one copy)', () => {
|
||||
const state = baseState();
|
||||
state.nations.uk.territory = ['GBR', 'wales-bbbb'];
|
||||
const next = applyTurnResult(state, {
|
||||
...noEvents,
|
||||
territoryChanges: [
|
||||
{ nation: 'de', gain: ['wales-bbbb'], lose: [] },
|
||||
{ nation: 'de', gain: ['wales-bbbb'], lose: [] },
|
||||
],
|
||||
});
|
||||
expect(next.nations.de.territory.filter((k) => k === 'wales-bbbb')).toHaveLength(1);
|
||||
expect(next.nations.uk.territory).not.toContain('wales-bbbb');
|
||||
});
|
||||
|
||||
it('a lone "lose" is a no-op: the key cannot be orphaned (zero owners)', () => {
|
||||
const state = baseState();
|
||||
const next = applyTurnResult(state, {
|
||||
...noEvents,
|
||||
territoryChanges: [{ nation: 'uk', gain: [], lose: ['GBR'] }],
|
||||
});
|
||||
expect(next.nations.uk.territory).toContain('GBR');
|
||||
});
|
||||
|
||||
it('a self-gain is a no-op (a nation keeps exactly one copy of its own key)', () => {
|
||||
const state = baseState();
|
||||
const next = applyTurnResult(state, {
|
||||
...noEvents,
|
||||
territoryChanges: [{ nation: 'uk', gain: ['GBR'], lose: [] }],
|
||||
});
|
||||
expect(next.nations.uk.territory.filter((k) => k === 'GBR')).toHaveLength(1);
|
||||
});
|
||||
|
||||
it('a pre-existing double ownership is never touched (a key with 2 owners keeps 2)', () => {
|
||||
const state = baseState();
|
||||
state.nations.us.territory = ['USA', 'GBR']; // uk AND us already share GBR
|
||||
const next = applyTurnResult(state, {
|
||||
...noEvents,
|
||||
territoryChanges: [{ nation: 'de', gain: ['GBR'], lose: [] }],
|
||||
});
|
||||
expect(next.nations.uk.territory).toContain('GBR');
|
||||
expect(next.nations.us.territory).toContain('GBR');
|
||||
expect(next.nations.de.territory).not.toContain('GBR');
|
||||
});
|
||||
|
||||
it('ownership is conserved across a transfer: exactly one owner before and after', () => {
|
||||
const state = baseState();
|
||||
state.nations.uk.territory = ['GBR', 'wales-bbbb'];
|
||||
const count = (s: GameState) =>
|
||||
Object.values(s.nations).reduce(
|
||||
(acc, n) => acc + n.territory.filter((k) => k === 'wales-bbbb').length,
|
||||
0,
|
||||
);
|
||||
const next = applyTurnResult(state, {
|
||||
...noEvents,
|
||||
territoryChanges: [{ nation: 'de', gain: ['wales-bbbb'], lose: [] }],
|
||||
});
|
||||
expect(count(state)).toBe(1);
|
||||
expect(count(next)).toBe(1);
|
||||
expect(next.nations.de.territory).toContain('wales-bbbb');
|
||||
expect(next.nations.uk.territory).not.toContain('wales-bbbb');
|
||||
});
|
||||
});
|
||||
|
||||
describe('makeNeighboursExecutor (v0.21 #16 P2)', () => {
|
||||
const getNeighbours = (keys: string[]) => keys.map((k) => `nb-${k}`);
|
||||
|
||||
it('answers getNeighbours with the resolver output', () => {
|
||||
const exec = makeNeighboursExecutor(getNeighbours);
|
||||
expect(exec('getNeighbours', { regionKeys: ['a', 'b'] })).toBe(
|
||||
JSON.stringify({ neighbours: ['nb-a', 'nb-b'] }),
|
||||
);
|
||||
});
|
||||
|
||||
it('degrades unknown tools, malformed args, and unknown keys without throwing', () => {
|
||||
const exec = makeNeighboursExecutor(getNeighbours, new Set(['a']));
|
||||
expect(exec('otherTool', {})).toBe(JSON.stringify({ error: 'unknown tool: otherTool' }));
|
||||
expect(exec('getNeighbours', { regionKeys: 'not-an-array' })).toBe(
|
||||
JSON.stringify({ neighbours: [] }),
|
||||
);
|
||||
expect(exec('getNeighbours', { regionKeys: ['a', 'zzz-not-valid'] })).toBe(
|
||||
JSON.stringify({ neighbours: ['nb-a'] }), // the unknown key is filtered out
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe('playerFrontier (v0.21 #16 P2)', () => {
|
||||
const adjacency: Record<string, string[]> = {
|
||||
GBR: ['DEU', 'USA'],
|
||||
GBR2: ['DEU'],
|
||||
DEU: ['GBR', 'GBR2', 'USA'],
|
||||
USA: ['GBR', 'DEU'],
|
||||
};
|
||||
const getNeighbours = (keys: string[]) =>
|
||||
[...new Set(keys.flatMap((k) => adjacency[k] ?? []))]
|
||||
.filter((k) => !keys.includes(k))
|
||||
.sort();
|
||||
|
||||
it('is the sorted, deduped set of other-nation keys bordering the player', () => {
|
||||
const state = baseState();
|
||||
state.nations.uk.territory = ['GBR', 'GBR2']; // the player owns both
|
||||
// DEU (de) and USA (us) border the player; deduped and sorted
|
||||
expect(playerFrontier(state, getNeighbours)).toEqual(['DEU', 'USA']);
|
||||
});
|
||||
|
||||
it('drops candidates the player itself owns (a sloppy resolver may report them)', () => {
|
||||
const state = baseState();
|
||||
state.nations.uk.territory = ['GBR', 'GBR2'];
|
||||
const sloppy = () => ['GBR2', 'DEU'];
|
||||
expect(playerFrontier(state, sloppy)).toEqual(['DEU']);
|
||||
});
|
||||
|
||||
it('is empty when the player has no territory or no controllable border', () => {
|
||||
const empty = baseState();
|
||||
empty.nations.uk.territory = [];
|
||||
expect(playerFrontier(empty, getNeighbours)).toEqual([]);
|
||||
const noBorders = () => [];
|
||||
expect(playerFrontier(baseState(), noBorders)).toEqual([]);
|
||||
});
|
||||
});
|
||||
|
||||
describe('runTurn (getNeighbours tool path, v0.21 #16 P2)', () => {
|
||||
const adjacency: Record<string, string[]> = {
|
||||
GBR: ['DEU'],
|
||||
DEU: ['GBR', 'USA'],
|
||||
USA: ['DEU'],
|
||||
};
|
||||
const getNeighbours = (keys: string[]) =>
|
||||
[...new Set(keys.flatMap((k) => adjacency[k] ?? []))]
|
||||
.filter((k) => !keys.includes(k))
|
||||
.sort();
|
||||
|
||||
const finalTurn = JSON.stringify({
|
||||
date: '1939-09-03',
|
||||
events: [
|
||||
{
|
||||
nation: 'uk',
|
||||
type: 'military',
|
||||
title: 'The border region falls',
|
||||
text: 'UK seizes the contiguous region.',
|
||||
outcome: 'success',
|
||||
},
|
||||
],
|
||||
stateChanges: [],
|
||||
relations: [],
|
||||
// the player gains its only frontier region (DEU, owned by de)
|
||||
territoryChanges: [{ nation: 'uk', gain: ['DEU'], lose: [] }],
|
||||
});
|
||||
|
||||
/**
|
||||
* A fake LlmClient implementing BOTH chat() and chatWithTools(). The
|
||||
* chatWithTools fake plays the model's side of the protocol: it records
|
||||
* the initial messages + tools, executes the scripted tool calls through
|
||||
* the executor the engine handed it, and returns the final content.
|
||||
*/
|
||||
function toolLlm(final: string, scriptedCalls: Array<{ name: string; args: Record<string, unknown> }>) {
|
||||
const seen: {
|
||||
firstMessages: WireMessage[];
|
||||
tools: ToolDefinition[];
|
||||
executorCalls: Array<{ name: string; args: Record<string, unknown>; result: string }>;
|
||||
chatCalls: number;
|
||||
} = { firstMessages: [], tools: [], executorCalls: [], chatCalls: 0 };
|
||||
const client: LlmClient = {
|
||||
chat: async (messages) => {
|
||||
seen.chatCalls += 1;
|
||||
seen.firstMessages = messages as WireMessage[];
|
||||
return final;
|
||||
},
|
||||
listModels: async () => [],
|
||||
chatWithTools: async (messages, tools, executeTool) => {
|
||||
seen.firstMessages = messages;
|
||||
seen.tools = tools;
|
||||
for (const call of scriptedCalls) {
|
||||
seen.executorCalls.push({ ...call, result: executeTool(call.name, call.args) });
|
||||
}
|
||||
return final;
|
||||
},
|
||||
};
|
||||
return { client, seen };
|
||||
}
|
||||
|
||||
it('uses chatWithTools with the tool, a frontier in the prompt, the wired executor, and applies the transfer', async () => {
|
||||
const original = baseState();
|
||||
original.pending = [{ kind: 'action', text: 'Seize the border region' }];
|
||||
const { client, seen } = toolLlm(finalTurn, [
|
||||
{ name: 'getNeighbours', args: { regionKeys: ['GBR'] } },
|
||||
]);
|
||||
const { state } = await runTurn({
|
||||
llm: client,
|
||||
preset,
|
||||
state: original,
|
||||
items: original.pending,
|
||||
getNeighbours,
|
||||
});
|
||||
|
||||
// the tool was offered and the system prompt advertises it
|
||||
expect(seen.tools.map((t) => t.function.name)).toEqual([GET_NEIGHBOURS_TOOL.function.name]);
|
||||
const system = seen.firstMessages[0] as { role: 'system'; content: string };
|
||||
expect(system.content).toContain('getNeighbours');
|
||||
|
||||
// the user payload carries the player's frontier (DEU: the player's only
|
||||
// controllable border, owned by de)
|
||||
const user = seen.firstMessages[1] as { role: 'user'; content: string };
|
||||
const payload = JSON.parse(user.content) as { frontier?: string[] };
|
||||
expect(payload.frontier).toEqual(['DEU']);
|
||||
|
||||
// the executor the engine built answers from the resolver
|
||||
expect(seen.executorCalls).toHaveLength(1);
|
||||
expect(seen.executorCalls[0].args).toEqual({ regionKeys: ['GBR'] });
|
||||
expect(seen.executorCalls[0].result).toBe(JSON.stringify({ neighbours: ['DEU'] }));
|
||||
|
||||
// chat() was never used on the tool path
|
||||
expect(seen.chatCalls).toBe(0);
|
||||
|
||||
// the contiguous transfer was applied: uk gains DEU, de loses it
|
||||
expect(state.nations.uk.territory).toContain('DEU');
|
||||
expect(state.nations.de.territory).not.toContain('DEU');
|
||||
});
|
||||
|
||||
it('falls back to plain chat() when the client lacks chatWithTools (no tool; the frontier hint stays in the prompt)', async () => {
|
||||
const original = baseState();
|
||||
const { client, seen } = toolLlm(validResult, []);
|
||||
delete client.chatWithTools; // simulate a minimal client (chat only)
|
||||
const { state } = await runTurn({
|
||||
llm: client,
|
||||
preset,
|
||||
state: original,
|
||||
items: original.pending,
|
||||
getNeighbours,
|
||||
});
|
||||
expect(seen.chatCalls).toBe(1);
|
||||
expect(seen.tools).toEqual([]); // the tool path was never entered
|
||||
const system = seen.firstMessages[0] as { content: string };
|
||||
expect(system.content).not.toContain('getNeighbours');
|
||||
const user = seen.firstMessages[1] as { content: string };
|
||||
const payload = JSON.parse(user.content) as { frontier?: string[] };
|
||||
// the frontier hint stays (a resolver was given) even without the tool —
|
||||
// it also tells the model which borders are realistically contestable
|
||||
expect(payload.frontier).toEqual(['DEU']);
|
||||
// the canned result still applied (GBR moved uk -> de)
|
||||
expect(state.nations.de.territory).toContain('GBR');
|
||||
expect(state.nations.uk.territory).not.toContain('GBR');
|
||||
});
|
||||
});
|
||||
|
||||
describe('deterministic stub (one contiguous conquest per turn, v0.21 #16 P2)', () => {
|
||||
const adjacency: Record<string, string[]> = {
|
||||
GBR: ['DEU'],
|
||||
DEU: ['GBR', 'USA'],
|
||||
USA: ['DEU'],
|
||||
};
|
||||
const getNeighbours = (keys: string[]) =>
|
||||
[...new Set(keys.flatMap((k) => adjacency[k] ?? []))]
|
||||
.filter((k) => !keys.includes(k))
|
||||
.sort();
|
||||
|
||||
it('transfers the first frontier region to the player (and its owner loses it)', async () => {
|
||||
const original = baseState();
|
||||
original.pending = [{ kind: 'action', text: 'March on the border region' }];
|
||||
const { state } = await runTurn({
|
||||
llm: makeStubLlmClient(),
|
||||
preset,
|
||||
state: original,
|
||||
items: original.pending,
|
||||
getNeighbours,
|
||||
});
|
||||
expect(state.nations.uk.territory).toContain('DEU'); // frontier[0]
|
||||
expect(state.nations.de.territory).not.toContain('DEU');
|
||||
// the rest of the stub behavior is unchanged
|
||||
expect(state.turn).toBe(2);
|
||||
expect(state.date).toBe('1939-09-03');
|
||||
expect(state.nations.uk.stats.military).toBe(85); // clamp(80 + 5)
|
||||
expect(state.events).toHaveLength(1);
|
||||
});
|
||||
|
||||
it('with an empty frontier (no controllable border, like the UK in wwii-1939): no transfer, the turn still advances', async () => {
|
||||
const original = baseState();
|
||||
original.pending = [{ kind: 'action', text: 'Hold the line' }];
|
||||
const { state } = await runTurn({
|
||||
llm: makeStubLlmClient(),
|
||||
preset,
|
||||
state: original,
|
||||
items: original.pending,
|
||||
getNeighbours: () => [], // no borders at all
|
||||
});
|
||||
expect(state.nations.uk.territory).toEqual(['GBR']); // untouched
|
||||
expect(state.nations.de.territory).toEqual(['DEU']);
|
||||
expect(state.nations.us.territory).toEqual(['USA']);
|
||||
expect(state.turn).toBe(2);
|
||||
expect(state.events).toHaveLength(1);
|
||||
});
|
||||
});
|
||||
|
||||
describe('buildSystemPrompt (tool mode, v0.21 #16 P2)', () => {
|
||||
it('mentions the getNeighbours tool and the contiguity rules only when the tool is sent', () => {
|
||||
const plain = buildSystemPrompt(preset);
|
||||
expect(plain).not.toContain('getNeighbours');
|
||||
expect(plain).toMatch(/Only change borders when war/i); // the legacy rule stays
|
||||
const withTool = buildSystemPrompt(preset, true);
|
||||
expect(withTool).toContain('getNeighbours');
|
||||
expect(withTool).toMatch(/contiguous/i);
|
||||
expect(withTool).toMatch(/frontier/i);
|
||||
expect(withTool).not.toMatch(/Only change borders when war/i);
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
+223
-30
@@ -22,7 +22,14 @@ import {
|
||||
type TurnEventSpec,
|
||||
type TurnResult,
|
||||
} from '../shared/protocol';
|
||||
import { DEFAULT_MAX_TOKENS, type LlmClient } from './llm';
|
||||
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);
|
||||
@@ -30,13 +37,117 @@ 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. */
|
||||
export function buildSystemPrompt(preset: Preset): string {
|
||||
/**
|
||||
* 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,
|
||||
@@ -45,7 +156,7 @@ export function buildSystemPrompt(preset: Preset): string {
|
||||
rules,
|
||||
'',
|
||||
'You are the world simulator. You advance the shared world state for ALL nations given the current state and the player\'s move.',
|
||||
'Respond with ONLY a valid JSON object of exactly this shape. No prose, no commentary, no markdown fences, no trailing commas:',
|
||||
respondLine,
|
||||
'{',
|
||||
' "date": "YYYY-MM-DD (a few days after the current date)",',
|
||||
' "events": [',
|
||||
@@ -62,7 +173,7 @@ export function buildSystemPrompt(preset: Preset): string {
|
||||
'- 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.',
|
||||
'- Only change borders when war or a decisive political shift justifies it.',
|
||||
...territoryRules,
|
||||
'- If a field is not relevant this turn, set it to an empty array.',
|
||||
].join('\n');
|
||||
}
|
||||
@@ -80,6 +191,13 @@ 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;
|
||||
@@ -116,6 +234,7 @@ export function buildUserPrompt(
|
||||
roundInstruction,
|
||||
};
|
||||
if (regionReference) payload.regions = regionReference;
|
||||
if (frontier !== undefined) payload.frontier = frontier;
|
||||
return JSON.stringify(payload, null, 2);
|
||||
}
|
||||
|
||||
@@ -312,24 +431,64 @@ export function applyTurnResult(state: GameState, result: TurnResult, validKeys?
|
||||
a.relations[r.b] = r.set;
|
||||
b.relations[r.a] = r.set;
|
||||
}
|
||||
for (const t of result.territoryChanges) {
|
||||
const n = next.nations[t.nation];
|
||||
if (!n) continue;
|
||||
const valid = (k: string) => !validKeys || validKeys.has(k);
|
||||
for (const key of t.gain) {
|
||||
if (!valid(key)) continue; // ignore a key that is not a real region
|
||||
for (const other of Object.values(next.nations)) {
|
||||
const i = other.territory.indexOf(key);
|
||||
if (i !== -1) other.territory.splice(i, 1);
|
||||
}
|
||||
if (!n.territory.includes(key)) n.territory.push(key);
|
||||
}
|
||||
for (const key of t.lose) {
|
||||
// --- 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;
|
||||
}
|
||||
|
||||
@@ -365,11 +524,18 @@ export function removeRoundItem(state: GameState, index: number | undefined): Ga
|
||||
}
|
||||
|
||||
/**
|
||||
* Run one turn end-to-end against the (injectable) LLM: build prompts, call the
|
||||
* model ONCE over the whole pending batch, 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.
|
||||
* 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;
|
||||
@@ -380,25 +546,52 @@ export async function runTurn(opts: {
|
||||
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 } = opts;
|
||||
const system = buildSystemPrompt(preset);
|
||||
const user = buildUserPrompt(state, items, regionReference);
|
||||
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.';
|
||||
|
||||
let result = parseTurnResult(await llm.chat([
|
||||
/**
|
||||
* 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 },
|
||||
], { temperature: 0.4, maxTokens: DEFAULT_MAX_TOKENS }));
|
||||
]));
|
||||
|
||||
if (result === null || !isMeaningful(result)) {
|
||||
result = parseTurnResult(await llm.chat([
|
||||
result = parseTurnResult(await call([
|
||||
{ role: 'system', content: system },
|
||||
{ role: 'user', content: user },
|
||||
{ role: 'assistant', content: '' },
|
||||
{ role: 'user', content: nudge },
|
||||
], { temperature: 0.4, maxTokens: DEFAULT_MAX_TOKENS }));
|
||||
]));
|
||||
}
|
||||
|
||||
if (result === null || !isMeaningful(result)) {
|
||||
|
||||
+5
-1
@@ -20,7 +20,7 @@ import { createLlmClient } from './llm';
|
||||
import { makeStubLlmClient } from './stub';
|
||||
import { askAdvisor, queueRoundItem, removeRoundItem, runTurn } from './engine';
|
||||
import { buildRegionMap } from './map';
|
||||
import { allTerritoryKeys, expandCountriesToKeys, loadRegions, referenceForKeys, validKeys } from './regions';
|
||||
import { allTerritoryKeys, expandCountriesToKeys, hasAdjacency, loadRegions, neighboursOf, referenceForKeys, validKeys } from './regions';
|
||||
import { createPresetStore } from './presets';
|
||||
import { openDb, type Db } from './db';
|
||||
|
||||
@@ -237,6 +237,10 @@ app.post('/api/games/:id/jump', async (req, res) => {
|
||||
items: game.pending,
|
||||
regionReference: referenceForKeys(allTerritoryKeys(game)),
|
||||
validRegionKeys: validKeys(),
|
||||
// v0.21 (#16 P2): when the region-adjacency manifest loaded, hand the
|
||||
// engine the resolver — the turn then runs with the LLM's getNeighbours
|
||||
// tool and the player-frontier prompt field (both switch off without it).
|
||||
getNeighbours: hasAdjacency() ? (keys) => neighboursOf(keys) : undefined,
|
||||
});
|
||||
db.save(state);
|
||||
broadcast(id, 'turn', { state, result });
|
||||
|
||||
@@ -208,3 +208,182 @@ describe('createLlmClient (injectable transport)', () => {
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe('createLlmClient.chatWithTools (function-calling loop, v0.21 #16 P2)', () => {
|
||||
const tool = {
|
||||
type: 'function' as const,
|
||||
function: {
|
||||
name: 'getNeighbours',
|
||||
description: 'list bordering region keys',
|
||||
parameters: { type: 'object' },
|
||||
},
|
||||
};
|
||||
|
||||
function toolCallResponse(id: string, name: string, args: string) {
|
||||
return json({
|
||||
choices: [
|
||||
{
|
||||
finish_reason: 'tool_calls',
|
||||
message: {
|
||||
content: null,
|
||||
tool_calls: [{ id, type: 'function', function: { name, arguments: args } }],
|
||||
},
|
||||
},
|
||||
],
|
||||
});
|
||||
}
|
||||
|
||||
function contentResponse(content: string) {
|
||||
return json({ choices: [{ finish_reason: 'stop', message: { content } }] });
|
||||
}
|
||||
|
||||
it('sends tools in the first request; echoes the assistant tool_calls + tool result; the executor gets parsed args', async () => {
|
||||
const sent: string[] = [];
|
||||
const transport = async (_url: string, init?: RequestInit) => {
|
||||
sent.push(String(init?.body ?? ''));
|
||||
if (sent.length === 1) return toolCallResponse('call_1', 'getNeighbours', '{"regionKeys":["a"]}');
|
||||
return contentResponse('final answer');
|
||||
};
|
||||
const executorCalls: Array<{ name: string; args: Record<string, unknown> }> = [];
|
||||
const llm = createLlmClient({ transport, apiBase: 'http://x/v1', model: 'm' });
|
||||
const out = await llm.chatWithTools!(
|
||||
[{ role: 'user', content: 'hi' }],
|
||||
[tool],
|
||||
(name, args) => {
|
||||
executorCalls.push({ name, args });
|
||||
return JSON.stringify({ neighbours: ['b'] });
|
||||
},
|
||||
);
|
||||
expect(out).toBe('final answer');
|
||||
expect(sent).toHaveLength(2);
|
||||
// request 1: the tools array is present, history is just the initial message
|
||||
const body1 = JSON.parse(sent[0]);
|
||||
expect(body1.tools).toEqual([tool]);
|
||||
expect(body1.messages).toEqual([{ role: 'user', content: 'hi' }]);
|
||||
// request 2: the assistant tool-call and the tool result are echoed back
|
||||
const body2 = JSON.parse(sent[1]);
|
||||
expect(body2.tools).toEqual([tool]);
|
||||
expect(body2.messages).toEqual([
|
||||
{ role: 'user', content: 'hi' },
|
||||
{
|
||||
role: 'assistant',
|
||||
content: null,
|
||||
tool_calls: [
|
||||
{
|
||||
id: 'call_1',
|
||||
type: 'function',
|
||||
function: { name: 'getNeighbours', arguments: '{"regionKeys":["a"]}' },
|
||||
},
|
||||
],
|
||||
},
|
||||
{ role: 'tool', tool_call_id: 'call_1', content: JSON.stringify({ neighbours: ['b'] }) },
|
||||
]);
|
||||
// the executor received the parsed (object) arguments, not the raw string
|
||||
expect(executorCalls).toEqual([{ name: 'getNeighbours', args: { regionKeys: ['a'] } }]);
|
||||
});
|
||||
|
||||
it('a content-only response ends in one round (the executor is never called)', async () => {
|
||||
const sent: string[] = [];
|
||||
const transport = async (_url: string, init?: RequestInit) => {
|
||||
sent.push(String(init?.body ?? ''));
|
||||
return contentResponse('done');
|
||||
};
|
||||
const llm = createLlmClient({ transport, apiBase: 'http://x/v1', model: 'm' });
|
||||
const out = await llm.chatWithTools!(
|
||||
[{ role: 'user', content: 'hi' }],
|
||||
[tool],
|
||||
() => {
|
||||
throw new Error('the executor must not be called');
|
||||
},
|
||||
);
|
||||
expect(out).toBe('done');
|
||||
expect(sent).toHaveLength(1);
|
||||
});
|
||||
|
||||
it('bounds the tool rounds: maxRounds tool-call rounds, then one final no-tools request', async () => {
|
||||
const sent: string[] = [];
|
||||
let n = 0;
|
||||
const transport = async (_url: string, init?: RequestInit) => {
|
||||
sent.push(String(init?.body ?? ''));
|
||||
n += 1;
|
||||
if (n <= 2) return toolCallResponse(`call_${n}`, 'getNeighbours', '{}');
|
||||
return contentResponse('committed');
|
||||
};
|
||||
const llm = createLlmClient({ transport, apiBase: 'http://x/v1', model: 'm' });
|
||||
const out = await llm.chatWithTools!(
|
||||
[{ role: 'user', content: 'hi' }],
|
||||
[tool],
|
||||
() => JSON.stringify({ neighbours: [] }),
|
||||
{ maxRounds: 2 },
|
||||
);
|
||||
expect(out).toBe('committed');
|
||||
expect(sent).toHaveLength(3); // 2 tool rounds + the forced final request
|
||||
expect(JSON.parse(sent[2]).tools).toBeUndefined(); // the final request has no tools
|
||||
});
|
||||
|
||||
it('malformed tool arguments produce an "invalid tool arguments" tool message (no throw)', async () => {
|
||||
const sent: string[] = [];
|
||||
let n = 0;
|
||||
const transport = async (_url: string, init?: RequestInit) => {
|
||||
sent.push(String(init?.body ?? ''));
|
||||
n += 1;
|
||||
if (n === 1) return toolCallResponse('call_1', 'getNeighbours', '{not valid json');
|
||||
return contentResponse('recovered');
|
||||
};
|
||||
const llm = createLlmClient({ transport, apiBase: 'http://x/v1', model: 'm' });
|
||||
const out = await llm.chatWithTools!(
|
||||
[{ role: 'user', content: 'hi' }],
|
||||
[tool],
|
||||
() => {
|
||||
throw new Error('the executor must not be called on malformed args');
|
||||
},
|
||||
);
|
||||
expect(out).toBe('recovered');
|
||||
const body2 = JSON.parse(sent[1]);
|
||||
const toolMsg = body2.messages.find((m: { role?: string }) => m.role === 'tool');
|
||||
expect(toolMsg).toBeDefined();
|
||||
expect(toolMsg.content).toMatch(/invalid tool arguments/);
|
||||
});
|
||||
|
||||
it('retries a tool-call round on transient failure and still resolves the tool (500 → tool_calls → content)', async () => {
|
||||
const sent: string[] = [];
|
||||
let n = 0;
|
||||
const transport = async (_url: string, init?: RequestInit) => {
|
||||
sent.push(String(init?.body ?? ''));
|
||||
n += 1;
|
||||
if (n === 1) return httpError(500);
|
||||
if (n === 2) return toolCallResponse('call_1', 'getNeighbours', '{"regionKeys":["a"]}');
|
||||
return contentResponse('ok');
|
||||
};
|
||||
const llm = createLlmClient({
|
||||
transport,
|
||||
apiBase: 'http://x/v1',
|
||||
model: 'm',
|
||||
retryBaseDelayMs: 1,
|
||||
});
|
||||
const out = await llm.chatWithTools!(
|
||||
[{ role: 'user', content: 'hi' }],
|
||||
[tool],
|
||||
() => JSON.stringify({ neighbours: [] }),
|
||||
);
|
||||
expect(out).toBe('ok');
|
||||
expect(sent).toHaveLength(3);
|
||||
});
|
||||
|
||||
it('a tool-call response is never treated as a truncation failure (content null is fine)', async () => {
|
||||
const sent: string[] = [];
|
||||
const transport = async (_url: string, init?: RequestInit) => {
|
||||
sent.push(String(init?.body ?? ''));
|
||||
if (sent.length === 1) return toolCallResponse('call_1', 'getNeighbours', '{}');
|
||||
return contentResponse('ok');
|
||||
};
|
||||
const llm = createLlmClient({ transport, apiBase: 'http://x/v1', model: 'm' });
|
||||
const out = await llm.chatWithTools!(
|
||||
[{ role: 'user', content: 'hi' }],
|
||||
[tool],
|
||||
() => JSON.stringify({ neighbours: [] }),
|
||||
);
|
||||
expect(out).toBe('ok');
|
||||
expect(sent).toHaveLength(2); // no retry of the first (successful) round
|
||||
});
|
||||
});
|
||||
|
||||
+189
-9
@@ -23,12 +23,55 @@
|
||||
// to 3 total attempts and 2s→3s backoff (±20% jitter). HTTP 4xx is a
|
||||
// deterministic client error and throws immediately, with no retry. Every
|
||||
// call is logged: one line on success, one on final failure.
|
||||
//
|
||||
// Function calling: `chatWithTools()` extends `chat()` with the OpenAI tool
|
||||
// loop. When the model answers with `finish_reason: "tool_calls"`, the client
|
||||
// runs the provided executor for every requested call, appends the assistant
|
||||
// tool-call message plus one `tool` result message per call, and re-sends the
|
||||
// growing history until the model produces a final `content`. The loop is
|
||||
// bounded to DEFAULT_MAX_TOOL_ROUNDS (6) tool rounds; if the budget is spent
|
||||
// without a final answer, one last *no-tools* request forces the model to
|
||||
// commit. The same retry/backoff/reasoning-effort machinery applies per
|
||||
// request, so an injectable transport only has to forward `tools` in the body
|
||||
// and accept `assistant`/`tool` messages in the history. The deterministic
|
||||
// stub answers plain `content` (no tool calls) and therefore bypasses the
|
||||
// loop in a single round.
|
||||
|
||||
export interface ChatMessage {
|
||||
role: 'system' | 'user' | 'assistant';
|
||||
content: string;
|
||||
}
|
||||
|
||||
/** One OpenAI-style function call the model requests (`finish_reason: "tool_calls"`). */
|
||||
export interface ToolCallSpec {
|
||||
id: string;
|
||||
type: 'function';
|
||||
function: { name: string; arguments: string };
|
||||
}
|
||||
|
||||
/** A function the model may call (one OpenAI `tools` entry). */
|
||||
export interface ToolDefinition {
|
||||
type: 'function';
|
||||
function: {
|
||||
name: string;
|
||||
description?: string;
|
||||
parameters?: Record<string, unknown>;
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* The full message vocabulary a request can carry: plain chat messages, plus
|
||||
* assistant messages that request tool calls and the `tool` results that
|
||||
* answer them (the OpenAI function-calling wire shape).
|
||||
*/
|
||||
export type WireMessage =
|
||||
| ChatMessage
|
||||
| { role: 'assistant'; content: string | null; tool_calls: ToolCallSpec[] }
|
||||
| { role: 'tool'; tool_call_id: string; content: string };
|
||||
|
||||
/** Executes one tool call; the returned string is fed back to the model as the `tool` result. */
|
||||
export type ToolExecutor = (name: string, args: Record<string, unknown>) => string;
|
||||
|
||||
/** A network call. The default implementation is native fetch. */
|
||||
export type Transport = (url: string, init?: RequestInit) => Promise<Response>;
|
||||
|
||||
@@ -39,6 +82,18 @@ export interface ChatOptions {
|
||||
|
||||
export interface LlmClient {
|
||||
chat(messages: ChatMessage[], opts?: ChatOptions): Promise<string>;
|
||||
/**
|
||||
* The function-calling variant of chat() — see the file header for the loop
|
||||
* semantics (bounded tool rounds, then a forced final answer). Optional on
|
||||
* the interface: minimal test doubles may omit it, and callers (the
|
||||
* engine's runTurn) fall back to chat() when it is absent.
|
||||
*/
|
||||
chatWithTools?(
|
||||
messages: WireMessage[],
|
||||
tools: ToolDefinition[],
|
||||
executeTool: ToolExecutor,
|
||||
opts?: ChatOptions & { maxRounds?: number },
|
||||
): Promise<string>;
|
||||
listModels(): Promise<string[]>;
|
||||
}
|
||||
|
||||
@@ -63,9 +118,17 @@ export const DEFAULT_MAX_TOKENS = 4000;
|
||||
*/
|
||||
export const DEFAULT_REASONING_EFFORT = 'low';
|
||||
|
||||
/** Total attempts `chat()` makes before giving up (1 initial + 2 retries). */
|
||||
/** Total attempts per request (chat / chatWithTools) before giving up (1 initial + 2 retries). */
|
||||
export const MAX_ATTEMPTS = 3;
|
||||
|
||||
/**
|
||||
* Max tool-call rounds (assistant tool_calls → tool results) a chatWithTools
|
||||
* loop may make before a final no-tools request forces the answer. Keeps a
|
||||
* turn bounded even if the model keeps asking for data; a realistic turn uses
|
||||
* 0–2 rounds.
|
||||
*/
|
||||
export const DEFAULT_MAX_TOOL_ROUNDS = 6;
|
||||
|
||||
/** Base retry backoff: retry 1 waits 2000ms, retry 2 waits 3000ms, each ±20% jitter. */
|
||||
const RETRY_BASE_DELAY_MS = 2000;
|
||||
|
||||
@@ -154,6 +217,30 @@ function isNetworkError(err: unknown): boolean {
|
||||
);
|
||||
}
|
||||
|
||||
/** Parse the wire `tool_calls` array into validated ToolCallSpecs (malformed entries are dropped). */
|
||||
function parseToolCalls(raw: unknown): ToolCallSpec[] {
|
||||
if (!Array.isArray(raw)) return [];
|
||||
const out: ToolCallSpec[] = [];
|
||||
for (const tc of raw) {
|
||||
if (!tc || typeof tc !== 'object') continue;
|
||||
const t = tc as Record<string, unknown>;
|
||||
const fn = t.function as Record<string, unknown> | undefined;
|
||||
if (!fn || typeof fn.name !== 'string' || fn.name === '') continue;
|
||||
const argumentsText =
|
||||
typeof fn.arguments === 'string'
|
||||
? fn.arguments
|
||||
: fn.arguments != null
|
||||
? JSON.stringify(fn.arguments)
|
||||
: '{}';
|
||||
out.push({
|
||||
id: typeof t.id === 'string' && t.id !== '' ? t.id : `call_${out.length}`,
|
||||
type: 'function',
|
||||
function: { name: fn.name, arguments: argumentsText },
|
||||
});
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
/** One transport attempt, classified as success / retryable / fatal. */
|
||||
interface Attempt {
|
||||
status?: number;
|
||||
@@ -161,6 +248,7 @@ interface Attempt {
|
||||
retryable: boolean;
|
||||
content?: string;
|
||||
finishReason?: string | null;
|
||||
toolCalls?: ToolCallSpec[];
|
||||
}
|
||||
|
||||
export function createLlmClient(deps: LlmClientDeps = {}): LlmClient {
|
||||
@@ -179,7 +267,7 @@ export function createLlmClient(deps: LlmClientDeps = {}): LlmClient {
|
||||
|
||||
/**
|
||||
* One POST to /chat/completions. Classifies the outcome:
|
||||
* - success → content + finish_reason
|
||||
* - success → content and/or tool_calls
|
||||
* - retryable → timeout/abort, HTTP 5xx, network error, null/empty content
|
||||
* - fatal → HTTP 4xx (deterministic client error) or an unexpected throw
|
||||
*/
|
||||
@@ -213,12 +301,27 @@ export function createLlmClient(deps: LlmClientDeps = {}): LlmClient {
|
||||
const body = (await res.json()) as {
|
||||
choices?: {
|
||||
finish_reason?: string | null;
|
||||
message?: { content?: string; reasoning?: string | null };
|
||||
message?: { content?: string; reasoning?: string | null; tool_calls?: unknown };
|
||||
}[];
|
||||
};
|
||||
const choice = body.choices?.[0];
|
||||
const message = choice?.message;
|
||||
// A tool-call round is a SUCCESS even though `content` is null there: the
|
||||
// model is asking for data, not failing (never treat it as truncation).
|
||||
const toolCalls = parseToolCalls(message?.tool_calls);
|
||||
if (toolCalls.length > 0) {
|
||||
return {
|
||||
status: res.status,
|
||||
retryable: false,
|
||||
content:
|
||||
typeof message?.content === 'string' && message.content !== '' ? message.content : undefined,
|
||||
finishReason: choice?.finish_reason ?? null,
|
||||
toolCalls,
|
||||
};
|
||||
}
|
||||
// The reasoning model can exhaust its output budget on the CoT and return
|
||||
// a null `content` — treat that as a retryable truncation, not a success.
|
||||
const content = body.choices?.[0]?.message?.content;
|
||||
const content = message?.content;
|
||||
if (typeof content !== 'string' || content === '') {
|
||||
return {
|
||||
status: res.status,
|
||||
@@ -230,11 +333,16 @@ export function createLlmClient(deps: LlmClientDeps = {}): LlmClient {
|
||||
status: res.status,
|
||||
retryable: false,
|
||||
content,
|
||||
finishReason: body.choices?.[0]?.finish_reason ?? null,
|
||||
finishReason: choice?.finish_reason ?? null,
|
||||
};
|
||||
}
|
||||
|
||||
async function chat(messages: ChatMessage[], opts: ChatOptions = {}): Promise<string> {
|
||||
/** Build one /chat/completions request body. `tools` is only included when non-empty, so plain chat() keeps its exact legacy shape. */
|
||||
function buildPayload(
|
||||
messages: WireMessage[],
|
||||
tools: ToolDefinition[] | undefined,
|
||||
opts: ChatOptions,
|
||||
): Record<string, unknown> {
|
||||
const payload: Record<string, unknown> = {
|
||||
model,
|
||||
messages,
|
||||
@@ -245,7 +353,16 @@ export function createLlmClient(deps: LlmClientDeps = {}): LlmClient {
|
||||
};
|
||||
if (opts.temperature != null) payload.temperature = opts.temperature;
|
||||
if (sendEffort) payload.reasoning_effort = effort;
|
||||
if (tools && tools.length > 0) payload.tools = tools;
|
||||
return payload;
|
||||
}
|
||||
|
||||
/**
|
||||
* One request with the standard retry/backoff: up to MAX_ATTEMPTS per
|
||||
* request; resolves with a successful Attempt (content and/or toolCalls) or
|
||||
* throws the last error once the attempts are spent.
|
||||
*/
|
||||
async function requestWithRetry(payload: Record<string, unknown>): Promise<Attempt> {
|
||||
const totalStart = Date.now();
|
||||
let lastStatus: number | undefined;
|
||||
let lastError: Error = new Error('chat failed: no attempt completed');
|
||||
@@ -255,12 +372,13 @@ export function createLlmClient(deps: LlmClientDeps = {}): LlmClient {
|
||||
if (out.status !== undefined) lastStatus = out.status;
|
||||
if (out.error) lastError = out.error;
|
||||
|
||||
if (out.content !== undefined) {
|
||||
if (out.content !== undefined || out.toolCalls !== undefined) {
|
||||
console.log('[llm] chat ok', {
|
||||
ms: Date.now() - totalStart,
|
||||
finishReason: out.finishReason ?? null,
|
||||
toolCalls: out.toolCalls?.length ?? 0,
|
||||
});
|
||||
return out.content;
|
||||
return out;
|
||||
}
|
||||
// Give up on a non-retryable failure, or when the last attempt is spent.
|
||||
if (!out.retryable || attempt === MAX_ATTEMPTS) {
|
||||
@@ -278,6 +396,68 @@ export function createLlmClient(deps: LlmClientDeps = {}): LlmClient {
|
||||
throw lastError;
|
||||
}
|
||||
|
||||
async function chat(messages: ChatMessage[], opts: ChatOptions = {}): Promise<string> {
|
||||
const out = await requestWithRetry(buildPayload(messages, undefined, opts));
|
||||
return out.content ?? '';
|
||||
}
|
||||
|
||||
/**
|
||||
* The function-calling loop — see the file header. Bounded: at most
|
||||
* `maxRounds` tool rounds, then one final no-tools request forces an answer.
|
||||
* Throws like chat() only on transport-level exhaustion (the engine maps that
|
||||
* to a 502; bad *content* never throws, it is parsed defensively downstream).
|
||||
*/
|
||||
async function chatWithTools(
|
||||
initial: WireMessage[],
|
||||
tools: ToolDefinition[],
|
||||
executeTool: ToolExecutor,
|
||||
opts: ChatOptions & { maxRounds?: number } = {},
|
||||
): Promise<string> {
|
||||
const maxRounds = Math.max(1, opts.maxRounds ?? DEFAULT_MAX_TOOL_ROUNDS);
|
||||
const messages: WireMessage[] = [...initial];
|
||||
for (let round = 0; round < maxRounds; round++) {
|
||||
const out = await requestWithRetry(buildPayload(messages, tools, opts));
|
||||
// Tool calls win over any partial content: the model asked for data
|
||||
// before it finished.
|
||||
if (out.toolCalls && out.toolCalls.length > 0) {
|
||||
messages.push({
|
||||
role: 'assistant',
|
||||
content: out.content ?? null,
|
||||
tool_calls: out.toolCalls,
|
||||
});
|
||||
for (const tc of out.toolCalls) {
|
||||
let result: string;
|
||||
try {
|
||||
const parsed: unknown =
|
||||
tc.function.arguments !== '' ? JSON.parse(tc.function.arguments) : {};
|
||||
const args =
|
||||
parsed !== null && typeof parsed === 'object' && !Array.isArray(parsed)
|
||||
? (parsed as Record<string, unknown>)
|
||||
: {};
|
||||
result = executeTool(tc.function.name, args);
|
||||
} catch (err) {
|
||||
// Malformed arguments are not fatal: hand the model an error
|
||||
// result so it can re-call with valid arguments or move on.
|
||||
result = JSON.stringify({
|
||||
error: `invalid tool arguments: ${err instanceof Error ? err.message : String(err)}`,
|
||||
});
|
||||
}
|
||||
messages.push({ role: 'tool', tool_call_id: tc.id, content: result });
|
||||
}
|
||||
continue;
|
||||
}
|
||||
if (out.content !== undefined) return out.content;
|
||||
}
|
||||
// The tool budget is spent without a final answer: one last no-tools
|
||||
// request forces the model to commit.
|
||||
messages.push({
|
||||
role: 'user',
|
||||
content: 'You have reached your tool budget. Answer now with the final result only.',
|
||||
});
|
||||
const out = await requestWithRetry(buildPayload(messages, undefined, opts));
|
||||
return out.content ?? '';
|
||||
}
|
||||
|
||||
async function listModels(): Promise<string[]> {
|
||||
const res = await transport(`${apiBase}/models`, {
|
||||
signal: AbortSignal.timeout(timeoutMs),
|
||||
@@ -287,5 +467,5 @@ export function createLlmClient(deps: LlmClientDeps = {}): LlmClient {
|
||||
return (body.data ?? []).map((m) => m.id);
|
||||
}
|
||||
|
||||
return { chat, listModels };
|
||||
return { chat, chatWithTools, listModels };
|
||||
}
|
||||
|
||||
@@ -6,8 +6,10 @@ import path from 'node:path';
|
||||
import {
|
||||
allTerritoryKeys,
|
||||
expandCountriesToKeys,
|
||||
hasAdjacency,
|
||||
hasRegionData,
|
||||
loadRegions,
|
||||
neighboursOf,
|
||||
referenceForKeys,
|
||||
regionName,
|
||||
resetRegions,
|
||||
@@ -89,6 +91,31 @@ describe('regions (region-key loader, v0.19 #16 P1)', () => {
|
||||
expect(allTerritoryKeys(state).size).toBe(58);
|
||||
});
|
||||
|
||||
it('hasAdjacency() is true: the region-adjacency manifest loaded (v0.21 #16 P2)', () => {
|
||||
expect(hasAdjacency()).toBe(true);
|
||||
});
|
||||
|
||||
it('neighboursOf returns the union of bordering keys (deduped, sorted, queried excluded)', () => {
|
||||
// known mutual pairs from the manifest (verified in the Stage 1 spot checks)
|
||||
const moselle = neighboursOf(['moselle-rygy']);
|
||||
expect(moselle).toContain('baden-wurttemberg-oyje'); // FRA/DEU border
|
||||
expect(neighboursOf(['baden-wurttemberg-oyje'])).toContain('moselle-rygy'); // mutual
|
||||
expect(neighboursOf(['acre-ekzw'])).toContain('loreto-ipkx'); // BRA/PER border
|
||||
expect(neighboursOf(['loreto-ipkx'])).toContain('acre-ekzw'); // mutual
|
||||
|
||||
// union + dedupe + sorted + the queried keys themselves excluded
|
||||
const union = neighboursOf(['moselle-rygy', 'baden-wurttemberg-oyje']);
|
||||
expect(union).toEqual([...union].sort());
|
||||
expect(new Set(union).size).toBe(union.length); // no duplicates
|
||||
expect(union).not.toContain('moselle-rygy');
|
||||
expect(union).not.toContain('baden-wurttemberg-oyje');
|
||||
});
|
||||
|
||||
it('neighboursOf returns [] for unknown keys or an empty set', () => {
|
||||
expect(neighboursOf(['not-a-real-key-xyz'])).toEqual([]);
|
||||
expect(neighboursOf([])).toEqual([]);
|
||||
});
|
||||
|
||||
describe('graceful degradation with no region data', () => {
|
||||
beforeEach(() => resetRegions());
|
||||
|
||||
@@ -99,6 +126,9 @@ describe('regions (region-key loader, v0.19 #16 P1)', () => {
|
||||
expect(referenceForKeys(['FRA'])).toEqual({});
|
||||
expect(validKeys().size).toBe(0);
|
||||
expect(regionName('FRA')).toBeUndefined();
|
||||
// and no adjacency: the LLM tool / frontier field switch off
|
||||
expect(hasAdjacency()).toBe(false);
|
||||
expect(neighboursOf(['moselle-rygy'])).toEqual([]);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -143,6 +173,15 @@ describe('loadRegions resolves the manifest per environment (dev public/ vs prod
|
||||
expect(validKeys().size).toBe(1796);
|
||||
});
|
||||
|
||||
it('a dist/ WITHOUT the adjacency manifest degrades: hasAdjacency() false, neighboursOf []', () => {
|
||||
// this fixture copies only region-keys.json + regions.geo.json (no
|
||||
// region-adjacency.json) — the server must boot and serve turns with the
|
||||
// tool/frontier simply switched off.
|
||||
expect(loadRegions(prodRoot)).toBe(true);
|
||||
expect(hasAdjacency()).toBe(false);
|
||||
expect(neighboursOf(['moselle-rygy'])).toEqual([]);
|
||||
});
|
||||
|
||||
it('returns false and no data when neither public/ nor dist/ holds the files', () => {
|
||||
const emptyRoot = path.join(base, 'empty');
|
||||
mkdirSync(emptyRoot, { recursive: true });
|
||||
|
||||
+72
-18
@@ -1,18 +1,23 @@
|
||||
// Region-key data access (server). Loads the deterministic region-key manifest
|
||||
// (region-keys.json: stable feature id -> region key) and the sub-national geo
|
||||
// (regions.geo.json: feature id -> { name, parent }) — from public/ in dev (Vite
|
||||
// serves it) or dist/ in prod (Vite copies public/* -> dist/ at build, and the
|
||||
// Docker image keeps only dist/) — and joins them into the lookups the
|
||||
// region-level ownership model (v0.19, #16 P1) needs:
|
||||
// (region-keys.json: stable feature id -> region key), the sub-national geo
|
||||
// (regions.geo.json: feature id -> { name, parent }), and — optionally — the
|
||||
// precomputed region-adjacency manifest (region-adjacency.json: region key ->
|
||||
// bordering region keys, built by scripts/build-region-adjacency.mjs) — all
|
||||
// from public/ in dev (Vite serves it) or dist/ in prod (Vite copies
|
||||
// public/* -> dist/ at build, and the Docker image keeps only dist/) — and
|
||||
// joins them into the lookups the region-level ownership model needs:
|
||||
//
|
||||
// - expand a nation's ISO_A3 country list -> the region keys of all its sub-regions
|
||||
// - expand a nation's ISO_A3 country list -> the region keys of all its sub-regions (v0.19, #16 P1)
|
||||
// - build the LLM "regions" reference table (key -> "name (parent ISO_A3)")
|
||||
// - the set of all valid region keys (to ignore hallucinated keys in applyTurnResult)
|
||||
// - neighboursOf(): the union of the bordering keys for a set of keys — the
|
||||
// data behind the LLM's getNeighbours tool and the player-frontier prompt
|
||||
// field (v0.21, #16 P2)
|
||||
//
|
||||
// Deliberately turf-free: it reads the two JSON files with plain JSON.parse and
|
||||
// never imports @turf/@types/geojson (importing one pulls @types/geojson into the
|
||||
// tsc program and breaks WorldMap.tsx; see the v0.17 changelog note). Loaded once
|
||||
// at boot; the accessors below are pure data reads thereafter.
|
||||
// Deliberately turf-free: it reads the JSON files with plain JSON.parse and
|
||||
// never imports @turf/@types/geojson (importing one pulls @types/geojson into
|
||||
// the tsc program and breaks WorldMap.tsx; see the v0.17 changelog note).
|
||||
// Loaded once at boot; the accessors below are pure data reads thereafter.
|
||||
import { existsSync, readFileSync } from 'node:fs';
|
||||
import path from 'node:path';
|
||||
import type { GameState } from '../shared/types';
|
||||
@@ -32,23 +37,30 @@ interface RegionData {
|
||||
keysByCountry: Record<string, string[]>;
|
||||
/** the set of all region keys. */
|
||||
validKeys: Set<string>;
|
||||
/** region key -> bordering region keys (from public/region-adjacency.json). */
|
||||
adjacency: Record<string, string[]>;
|
||||
}
|
||||
|
||||
let data: RegionData | null = null;
|
||||
|
||||
/**
|
||||
* Load (or reload) the region-key manifest + geo. Idempotent. The two files are
|
||||
* served to the client from `public/` in dev (Vite serves it directly) and from
|
||||
* `dist/` in prod (Vite copies `public/*` → `dist/` at build and the Docker image
|
||||
* keeps only `dist/`), so probe `public/` then `dist/` (plus the root as a last
|
||||
* resort) and use the first directory that holds both files. Returns true when
|
||||
* both loaded cleanly; a missing/broken pair clears the data and returns false
|
||||
* so the server still boots and degrades gracefully (expansion becomes a no-op,
|
||||
* Load (or reload) the region-key manifest, geo, and adjacency. Idempotent.
|
||||
* The files are served to the client from `public/` in dev (Vite serves it
|
||||
* directly) and from `dist/` in prod (Vite copies `public/*` → `dist/` at build
|
||||
* and the Docker image keeps only `dist/`), so probe `public/` then `dist/`
|
||||
* (plus the root as a last resort) and use the first directory that holds the
|
||||
* key manifest + geo. The adjacency manifest is OPTIONAL: when it is missing
|
||||
* or broken, `adjacency` stays empty and `neighboursOf`/`hasAdjacency` simply
|
||||
* report "no adjacency" (the LLM tool and the frontier prompt field switch
|
||||
* off; everything else keeps working). Returns true when the core pair loaded
|
||||
* cleanly; a missing/broken pair clears the data and returns false so the
|
||||
* server still boots and degrades gracefully (expansion becomes a no-op,
|
||||
* ownership falls back to the raw input keys).
|
||||
*/
|
||||
export function loadRegions(root: string): boolean {
|
||||
const keyFile = 'region-keys.json';
|
||||
const geoFile = 'regions.geo.json';
|
||||
const adjacencyFile = 'region-adjacency.json';
|
||||
const base = [
|
||||
path.join(root, 'public'), // dev: Vite serves public/ at /
|
||||
path.join(root, 'dist'), // prod: Vite copies public/* -> dist/ (== clientDir)
|
||||
@@ -78,7 +90,22 @@ export function loadRegions(root: string): boolean {
|
||||
validKeys.add(key);
|
||||
if (parent) (keysByCountry[parent] ??= []).push(key);
|
||||
}
|
||||
data = { idToKey, keyToInfo, keysByCountry, validKeys };
|
||||
|
||||
// Adjacency is optional: a missing file means "no adjacency data".
|
||||
let adjacency: Record<string, string[]> = {};
|
||||
const adjacencyPath = path.join(base, adjacencyFile);
|
||||
if (existsSync(adjacencyPath)) {
|
||||
try {
|
||||
adjacency = JSON.parse(readFileSync(adjacencyPath, 'utf8')) as Record<string, string[]>;
|
||||
} catch (err) {
|
||||
// A corrupt manifest must not kill the core region data: degrade to
|
||||
// "no adjacency" (the LLM tool / frontier guidance simply switch off).
|
||||
console.error('[regions] failed to load region-adjacency.json, continuing without it', err);
|
||||
adjacency = {};
|
||||
}
|
||||
}
|
||||
|
||||
data = { idToKey, keyToInfo, keysByCountry, validKeys, adjacency };
|
||||
return validKeys.size > 0;
|
||||
} catch (err) {
|
||||
console.error('[regions] failed to load region data', err);
|
||||
@@ -151,3 +178,30 @@ export function allTerritoryKeys(state: GameState): Set<string> {
|
||||
export function validKeys(): ReadonlySet<string> {
|
||||
return data ? data.validKeys : new Set<string>();
|
||||
}
|
||||
|
||||
/**
|
||||
* True when the region-adjacency manifest loaded with at least one entry.
|
||||
* Callers (the server's jump route) use this to decide whether to hand the
|
||||
* engine an adjacency resolver (which turns on the LLM's getNeighbours tool
|
||||
* and the frontier prompt field) or fall back to the plain chat() path.
|
||||
*/
|
||||
export function hasAdjacency(): boolean {
|
||||
return data !== null && Object.keys(data.adjacency).length > 0;
|
||||
}
|
||||
|
||||
/**
|
||||
* The union of the region keys that share a land border with any of the given
|
||||
* keys — deduplicated, the queried keys themselves excluded, and sorted for
|
||||
* determinism. This is the data behind the LLM's getNeighbours tool and the
|
||||
* player-frontier computation in the turn prompt (v0.21, #16 P2). Returns
|
||||
* `[]` when no region data (or no adjacency manifest) is loaded.
|
||||
*/
|
||||
export function neighboursOf(keys: Iterable<string>): string[] {
|
||||
if (!data) return [];
|
||||
const out = new Set<string>();
|
||||
for (const key of keys) {
|
||||
for (const n of data.adjacency[key] ?? []) out.add(n);
|
||||
}
|
||||
for (const key of keys) out.delete(key); // never report a key as its own neighbour
|
||||
return [...out].sort();
|
||||
}
|
||||
|
||||
+34
-3
@@ -2,7 +2,7 @@
|
||||
// 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 { addDays, clampStat, type TerritoryChange, type TurnEventSpec, type TurnResult } from '../shared/protocol';
|
||||
import type { RoundItem } from '../shared/types';
|
||||
|
||||
export const ADVISOR_MARKER = '<<ADVISOR>>';
|
||||
@@ -13,10 +13,17 @@ export interface StubPayload {
|
||||
turn: number;
|
||||
date: string;
|
||||
playerNationId: string;
|
||||
nations: Record<string, { stats: Record<string, number> }>;
|
||||
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 {
|
||||
@@ -31,6 +38,13 @@ function truncate(s: string, n: number): string {
|
||||
* 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;
|
||||
@@ -42,6 +56,23 @@ export function deterministicTurn(p: StubPayload): TurnResult {
|
||||
: '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
|
||||
? [
|
||||
@@ -76,7 +107,7 @@ export function deterministicTurn(p: StubPayload): TurnResult {
|
||||
events,
|
||||
stateChanges: [{ nation: pid, stat: 'military', op: 'set', value: mil }],
|
||||
relations: otherId ? [{ a: pid, b: otherId, set: 'ally' }] : [],
|
||||
territoryChanges: [],
|
||||
territoryChanges,
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user