Files
History/server/engine.test.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

862 lines
33 KiB
TypeScript

import { describe, expect, it } from 'vitest';
import type { GameState, Preset, RoundItem } from '../shared/types';
import { makeEvent, type TurnResult } from '../shared/protocol';
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,
validateTurnResult,
} from './engine';
/** A fake LlmClient that returns a fixed list of raw responses in order. */
function fakeLlm(responses: string[]): LlmClient {
let i = 0;
return {
chat: async () => responses[Math.min(i++, responses.length - 1)],
listModels: async () => [],
};
}
const preset: Preset = {
id: 'test',
title: 'Test',
startDate: '1939-09-01',
summary: '',
worldBackground: 'A test world.',
simulationRules: ['Prefer gradual change'],
nations: [
{
id: 'uk',
name: 'UK',
type: 'major',
stats: { military: 80, economy: 70, stability: 60, population: 50 },
territory: ['GBR'],
relations: { us: 'neutral', de: 'hostile' },
},
{
id: 'us',
name: 'US',
type: 'major',
stats: { military: 60, economy: 90, stability: 80, population: 80 },
territory: ['USA'],
relations: { uk: 'neutral' },
},
{
id: 'de',
name: 'DE',
type: 'major',
stats: { military: 95, economy: 70, stability: 75, population: 90 },
territory: ['DEU'],
relations: {},
},
],
};
function baseState(): GameState {
return {
id: 'g1',
presetId: 'test',
presetTitle: 'Test',
playerNationId: 'uk',
turn: 1,
date: '1939-09-01',
nations: {
uk: { ...preset.nations[0], stats: { ...preset.nations[0].stats }, territory: [...preset.nations[0].territory], relations: { ...preset.nations[0].relations } },
us: { ...preset.nations[1], stats: { ...preset.nations[1].stats }, territory: [...preset.nations[1].territory], relations: { ...preset.nations[1].relations } },
de: { ...preset.nations[2], stats: { ...preset.nations[2].stats }, territory: [...preset.nations[2].territory], relations: { ...preset.nations[2].relations } },
},
events: [],
pending: [],
version: 1,
};
}
const validResult = JSON.stringify({
date: '1939-09-03',
events: [
{ nation: 'uk', type: 'military', title: 'T-26s cross the border', text: 'The border is breached.', outcome: 'success' },
],
stateChanges: [
{ nation: 'uk', stat: 'military', op: 'set', value: 85 },
{ nation: 'de', stat: 'economy', op: 'add', value: 10 },
],
relations: [{ a: 'uk', b: 'us', set: 'ally' }],
// 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', () => {
it('applies a canned TurnResult: stats, symmetric relations, territory, event, turn, date, pending cleared', async () => {
const original = baseState();
const { state, result } = await runTurn({
llm: fakeLlm([validResult]),
preset,
state: original,
items: [{ kind: 'action', text: 'We rearm the coast' }],
});
expect(state.turn).toBe(2); // the turn counter advanced
expect(state.date).toBe('1939-09-03');
expect(state.pending).toEqual([]); // committing a round always clears it
expect(state.nations.uk.stats.military).toBe(85);
expect(state.nations.de.stats.economy).toBe(80); // 70 + 10 (add)
// relations are symmetric
expect(state.nations.uk.relations.us).toBe('ally');
expect(state.nations.us.relations.uk).toBe('ally');
// 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);
expect(state.events[0].title).toBe('T-26s cross the border');
expect(state.events[0].nation).toBe('uk');
// the return value carries the validated result too
expect(result.events[0].title).toBe('T-26s cross the border');
// 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('GBR');
expect(original.nations.uk.territory).toContain('GBR');
});
it('clamps out-of-range stat values into 0-100', async () => {
const clampResult = JSON.stringify({
date: '1939-09-03',
events: [{ nation: 'uk', type: 'economic', title: 'cap', text: 'x', outcome: 'neutral' }],
stateChanges: [
{ nation: 'uk', stat: 'military', op: 'set', value: 250 },
{ nation: 'uk', stat: 'stability', op: 'set', value: -20 },
],
relations: [],
territoryChanges: [],
});
const { state } = await runTurn({
llm: fakeLlm([clampResult]),
preset,
state: baseState(),
items: [],
});
expect(state.nations.uk.stats.military).toBe(100);
expect(state.nations.uk.stats.stability).toBe(0);
});
it('survives an invalid-JSON response: no throw, advances turn, emits a generic event', async () => {
const { state, result } = await runTurn({
llm: fakeLlm(['total garbage', 'still not json']),
preset,
state: baseState(),
items: [],
});
expect(state.turn).toBe(2);
expect(state.events).toHaveLength(1);
expect(state.events[0].title).toBe('The turn was unclear');
expect(result.events[0].title).toBe('The turn was unclear');
});
it('treats a valid-but-empty result as unclear (not meaningful)', async () => {
const emptyResult = JSON.stringify({
date: '1939-09-03',
events: [],
stateChanges: [],
relations: [],
territoryChanges: [],
});
const { state } = await runTurn({
llm: fakeLlm([emptyResult, emptyResult]),
preset,
state: baseState(),
items: [],
});
expect(state.turn).toBe(2);
expect(state.events[0].title).toBe('The turn was unclear');
});
it('"jump forward" (no action) still advances the turn and emits an event', async () => {
const state = baseState();
state.turn = 3; // start mid-game
const { state: next } = await runTurn({
llm: fakeLlm([validResult]),
preset,
state, // no items = an empty round / jump forward
items: [],
});
expect(next.turn).toBe(4);
expect(next.events).toHaveLength(1);
});
});
describe('runTurn (batched round, deterministic stub)', () => {
it('a single action item: turn+1, date+2, pending cleared, one military event, military set to clamp(cur+5)', async () => {
const original = baseState();
original.pending = [{ kind: 'action', text: 'Fortify the coast and mobilize the home guard' }];
const { state, result } = await runTurn({
llm: makeStubLlmClient(),
preset,
state: original,
items: original.pending,
});
expect(state.turn).toBe(2);
expect(state.date).toBe('1939-09-03');
expect(state.pending).toEqual([]);
expect(state.nations.uk.stats.military).toBe(85); // clamp(80 + 5)
expect(state.events).toHaveLength(1);
expect(state.events[0].type).toBe('military');
expect(state.events[0].nation).toBe('uk');
expect(state.events[0].outcome).toBe('success');
expect(result.stateChanges).toEqual([{ nation: 'uk', stat: 'military', op: 'set', value: 85 }]);
// committing a turn is a new revision too
expect(state.version).toBe(2);
// the original object is not mutated (a fresh queue was consumed)
expect(original.pending).toHaveLength(1);
expect(original.version).toBe(1);
});
it('an empty round: turn advances, one political "world moves on" event, pending stays empty', async () => {
const { state } = await runTurn({
llm: makeStubLlmClient(),
preset,
state: baseState(), // pending: []
items: [],
});
expect(state.turn).toBe(2);
expect(state.date).toBe('1939-09-03');
expect(state.pending).toEqual([]);
expect(state.events).toHaveLength(1);
expect(state.events[0].type).toBe('political');
expect(state.events[0].title).toBe('The world moves on');
expect(state.events[0].outcome).toBe('neutral');
});
it('a round with two actions and one diplomacy: 3 events (2 military + 1 diplomatic), exactly one turn advance', async () => {
const state = baseState();
state.pending = [
{ kind: 'action', text: 'Reinforce the Atlantic convoys' },
{ kind: 'action', text: 'Expand the shipyards' },
{ kind: 'diplomacy', to: 'us', text: 'Propose a mutual defense pact' },
];
const { state: next } = await runTurn({
llm: makeStubLlmClient(),
preset,
state,
items: state.pending,
});
expect(next.turn).toBe(2); // a whole batch = one turn
expect(next.pending).toEqual([]);
expect(next.events).toHaveLength(3);
expect(next.events.filter((e) => e.type === 'military')).toHaveLength(2);
expect(next.events.filter((e) => e.type === 'diplomatic')).toHaveLength(1);
expect(next.nations.uk.stats.military).toBe(85); // one +5 per turn, not per item
});
});
describe('buildUserPrompt (batched round)', () => {
it('includes every queued item (action + diplomacy) in the emitted payload', () => {
const items: RoundItem[] = [
{ kind: 'action', text: 'Fortify Dover' },
{ kind: 'diplomacy', to: 'us', text: 'Propose a pact' },
];
const parsed = JSON.parse(buildUserPrompt(baseState(), items)) as {
pending: RoundItem[];
playerActions: unknown[];
roundInstruction: string;
};
expect(parsed.pending).toEqual(items);
expect(parsed.playerActions).toEqual(['Fortify Dover', { to: 'us', message: 'Propose a pact' }]);
expect(parsed.roundInstruction).toContain('Fortify Dover');
expect(parsed.roundInstruction).toContain('Propose a pact');
});
it('frames an empty round as "the world advances on its own momentum"', () => {
const parsed = JSON.parse(buildUserPrompt(baseState(), [])) as {
pending: RoundItem[];
roundInstruction: string;
};
expect(parsed.pending).toEqual([]);
expect(parsed.roundInstruction).toMatch(/no action/i);
expect(parsed.roundInstruction).toMatch(/own momentum/i);
});
it('caps recentEvents at the last 5 (in order) when the state has more than 5 events', () => {
const state = baseState();
const events = Array.from({ length: 8 }, (_, i) =>
makeEvent(
{
nation: 'uk',
type: 'political',
title: `Event ${i + 1}`,
text: `Narrative ${i + 1}`,
outcome: 'neutral',
},
i + 1,
`1939-09-0${i + 1}`,
),
);
state.events = events;
const parsed = JSON.parse(buildUserPrompt(state, [])) as {
state: { recentEvents: Array<{ title: string; turn: number; date: string }> };
};
expect(parsed.state.recentEvents).toHaveLength(5);
// the last 5 of the 8 input events, in order
expect(parsed.state.recentEvents[0].title).toBe(events[3].title); // 'Event 4'
expect(parsed.state.recentEvents[4].title).toBe(events[7].title); // 'Event 8'
expect(parsed.state.recentEvents.map((e) => e.title)).toEqual([
events[3].title,
events[4].title,
events[5].title,
events[6].title,
events[7].title,
]);
});
it('asks the model to keep its internal reasoning brief (concise-CoT constraint)', () => {
expect(buildSystemPrompt(preset)).toMatch(/reasoning brief|a few sentences/i);
});
it('includes a regions reference table only when one is provided', () => {
// with a reference table: the payload carries it (region key -> "name (parent)")
const withRef = JSON.parse(
buildUserPrompt(baseState(), [], { 'ain-lqtt': 'Ain (FRA)', 'wales-bbbb': 'Wales (GBR)' }),
) as { regions?: Record<string, string> };
expect(withRef.regions).toEqual({ 'ain-lqtt': 'Ain (FRA)', 'wales-bbbb': 'Wales (GBR)' });
// without one: the field is absent entirely (the legacy prompt shape is unchanged)
const withoutRef = JSON.parse(buildUserPrompt(baseState(), [])) as { regions?: Record<string, string> };
expect(withoutRef.regions).toBeUndefined();
});
});
describe('queueRoundItem', () => {
it('appends an item to pending without advancing the turn or the date', () => {
const before = baseState();
const after = queueRoundItem(before, { kind: 'diplomacy', to: 'us', text: 'hello' });
expect(after.turn).toBe(before.turn);
expect(after.date).toBe(before.date);
expect(after.pending).toHaveLength(1);
expect(after.pending[0]).toEqual({ kind: 'diplomacy', to: 'us', text: 'hello' });
// the original state is not mutated
expect(before.pending).toHaveLength(0);
expect(before.version).toBe(1);
});
it('bumps the monotonic version on each queue mutation', () => {
const a = queueRoundItem(baseState(), { kind: 'action', text: 'one' });
expect(a.version).toBe(2);
const b = queueRoundItem(a, { kind: 'action', text: 'two' });
expect(b.version).toBe(3);
// and never backwards: two consecutive adds from the same base both
// advance past it, so a late snapshot of the older state is always older.
const b2 = queueRoundItem(baseState(), { kind: 'action', text: 'two' });
expect(b2.version).toBe(2);
expect(b.version).toBeGreaterThan(b2.version);
});
it('treats a legacy state without a version field as version 0', () => {
const legacy = { ...baseState() } as Omit<GameState, 'version'> & { version?: number };
delete legacy.version; // a row saved before the version field existed
// simulate a legacy row coming back from SQLite: the JSON has no field.
const after = queueRoundItem(legacy as unknown as GameState, {
kind: 'action',
text: 'first after upgrade',
});
expect(after.version).toBe(1);
});
});
describe('removeRoundItem', () => {
it('removes the item at the index, bumps the version, and leaves the input untouched', () => {
const state = baseState();
state.pending = [
{ kind: 'action', text: 'one' },
{ kind: 'diplomacy', to: 'us', text: 'two' },
{ kind: 'action', text: 'three' },
];
const next = removeRoundItem(state, 1);
expect(next).not.toBeNull();
expect(next!.version).toBe(2);
expect(next!.pending).toEqual([
{ kind: 'action', text: 'one' },
{ kind: 'action', text: 'three' },
]);
expect(state.pending).toHaveLength(3); // original untouched
expect(state.version).toBe(1);
});
it('returns null for out-of-bounds or non-integer indices (route answers 400, nothing mutates)', () => {
const state = baseState();
state.pending = [{ kind: 'action', text: 'one' }];
expect(removeRoundItem(state, -1)).toBeNull();
expect(removeRoundItem(state, 1)).toBeNull(); // == length
expect(removeRoundItem(state, 5)).toBeNull();
expect(removeRoundItem(state, 0.5)).toBeNull();
expect(removeRoundItem(state, Number.NaN)).toBeNull();
expect(state.pending).toHaveLength(1); // still untouched
});
});
describe('parseTurnResult', () => {
it('parses a fenced JSON block', () => {
const r = parseTurnResult('```json\n{"date":"1939-09-03","events":[{"nation":"uk","type":"military","title":"t","text":"b","outcome":"success"}]}\n```');
expect(r?.events[0].title).toBe('t');
});
it('parses JSON prefixed by leading whitespace/newlines (reasoning model emits "\\n\\n{...}")', () => {
const r = parseTurnResult('\n\n{"date":"1939-09-03","events":[{"nation":"uk","type":"military","title":"t","text":"b","outcome":"success"}]}\n');
expect(r?.events[0].title).toBe('t');
expect(r?.date).toBe('1939-09-03');
});
it('extracts the object from surrounding prose', () => {
const r = parseTurnResult('Sure! Here is the result: {"date":"1939-09-03","events":[]} hope that helps');
expect(r).not.toBeNull();
});
it('returns null for non-JSON output', () => {
expect(parseTurnResult('no json here at all')).toBeNull();
});
it('validateTurnResult ignores unknown/invalid entries and clamps', () => {
const r = validateTurnResult({
date: '1939-09-03',
events: [
{ nation: 'uk', type: 'bogus', title: 't', text: 'b', outcome: 'weird' },
{ nation: 'uk', title: 'missing text' },
],
stateChanges: [
{ nation: 'uk', stat: 'notastat', op: 'set', value: 5 },
{ nation: 'uk', stat: 'military', op: 'set', value: 150 },
],
});
expect(r).not.toBeNull();
expect(r!.events).toHaveLength(1); // the invalid one was dropped
expect(r!.events[0].type).toBe('political'); // bogus type -> default
expect(r!.events[0].outcome).toBe('neutral'); // bogus outcome -> default
expect(r!.stateChanges).toHaveLength(1);
expect(r!.stateChanges[0].value).toBe(100); // clamped
});
});
describe('applyTurnResult', () => {
it('does not mutate its input', () => {
const state = baseState();
const snapshot = JSON.stringify(state);
const result: TurnResult = {
date: '1939-09-03',
events: [{ nation: 'uk', type: 'military', title: 't', text: 'b', outcome: 'success' }],
stateChanges: [{ nation: 'uk', stat: 'military', op: 'set', value: 99 }],
relations: [],
territoryChanges: [],
};
applyTurnResult(state, result);
expect(JSON.stringify(state)).toBe(snapshot);
});
it('clears the pending round on commit and bumps the version', () => {
const state = baseState();
state.pending = [{ kind: 'action', text: 'x' }];
const result: TurnResult = {
date: '1939-09-03',
events: [],
stateChanges: [],
relations: [],
territoryChanges: [],
};
const next = applyTurnResult(state, result);
expect(next.pending).toEqual([]);
expect(next.version).toBe(2);
expect(state.pending).toHaveLength(1); // original untouched
expect(state.version).toBe(1);
});
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: [],
stateChanges: [],
relations: [],
// 'wales-bbbb' is a real key (owned by uk in the fixture); 'not-a-region-xyz'
// is not a real key at all. Both are proposed as a gain by DE.
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 (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
for (const n of Object.values(next.nations)) {
expect(n.territory).not.toContain('not-a-region-xyz');
}
});
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('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);
});
});
describe('isMeaningful', () => {
it('is true with an event, false when everything is empty', () => {
expect(isMeaningful({ date: 'x', events: [{ nation: 'uk', type: 'military', title: 't', text: 'b', outcome: 'neutral' }], stateChanges: [], relations: [], territoryChanges: [] })).toBe(true);
expect(isMeaningful({ date: 'x', events: [], stateChanges: [{ nation: 'uk', stat: 'military', op: 'set', value: 5 }], relations: [], territoryChanges: [] })).toBe(true);
expect(isMeaningful({ date: 'x', events: [], stateChanges: [], relations: [], territoryChanges: [] })).toBe(false);
});
});