1652 lines
65 KiB
TypeScript
1652 lines
65 KiB
TypeScript
import { describe, expect, it } from 'vitest';
|
|
import type { GameState, Preset, RoundItem } from '../shared/types';
|
|
import { makeEvent, type NationOp, type TurnResult } from '../shared/protocol';
|
|
import type { LlmClient, ToolDefinition, WireMessage } from './llm';
|
|
import { makeStubLlmClient } from './stub';
|
|
import {
|
|
applyNationOp,
|
|
applyTurnResult,
|
|
buildSystemPrompt,
|
|
buildUserPrompt,
|
|
confirmNationOp,
|
|
freshNationId,
|
|
GET_NEIGHBOURS_TOOL,
|
|
isMeaningful,
|
|
makeNeighboursExecutor,
|
|
NEW_NATION_STATS,
|
|
parseTurnResult,
|
|
playerFrontier,
|
|
queueRoundItem,
|
|
rejectNationOp,
|
|
removeRoundItem,
|
|
resolveNationRef,
|
|
runTurn,
|
|
validateNationOps,
|
|
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,
|
|
unowned: [],
|
|
pendingNationOps: [],
|
|
};
|
|
}
|
|
|
|
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 (since v0.22 #16 P3 a key owned by NOBODY
|
|
// would be re-claimed instead of ignored; a release lives in "unowned").
|
|
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();
|
|
});
|
|
|
|
it('(v0.22 #16 P3) emits the no-man\'s-land list when provided (even when empty) and omits it otherwise', () => {
|
|
// runTurn always passes state.unowned (even []), so the field is present...
|
|
const withEmpty = JSON.parse(buildUserPrompt(baseState(), [], undefined, undefined, [])) as {
|
|
unowned?: string[];
|
|
};
|
|
expect(withEmpty.unowned).toEqual([]);
|
|
// ...and a populated list reaches the model verbatim (it knows what it can re-claim)
|
|
const withKeys = JSON.parse(
|
|
buildUserPrompt(baseState(), [], undefined, undefined, ['GBR', 'POL']),
|
|
) as { unowned?: string[] };
|
|
expect(withKeys.unowned).toEqual(['GBR', 'POL']);
|
|
// but a caller that does not provide it keeps the legacy payload shape
|
|
const omitted = JSON.parse(buildUserPrompt(baseState(), [])) as { unowned?: string[] };
|
|
expect(omitted.unowned).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('resolveNationRef (v0.23 #16 P4a)', () => {
|
|
it('resolves an exact id first (even if a name matches something else)', () => {
|
|
const state = baseState();
|
|
expect(resolveNationRef(state, 'de')).toBe('de');
|
|
});
|
|
|
|
it('resolves a unique name case-insensitively', () => {
|
|
const state = baseState();
|
|
expect(resolveNationRef(state, 'US')).toBe('us');
|
|
expect(resolveNationRef(state, ' uS ')).toBe('us'); // trimmed, case-insensitive
|
|
});
|
|
|
|
it('returns null for an ambiguous name (two nations share it)', () => {
|
|
const state = baseState();
|
|
state.nations.us.name = 'UK'; // now uk and us are both "UK"
|
|
expect(resolveNationRef(state, 'UK')).toBeNull();
|
|
// but the exact id still resolves
|
|
expect(resolveNationRef(state, 'uk')).toBe('uk');
|
|
});
|
|
|
|
it('returns null for an unknown or empty reference (never throws)', () => {
|
|
const state = baseState();
|
|
expect(resolveNationRef(state, 'zzz-not-a-nation')).toBeNull();
|
|
expect(resolveNationRef(state, ' ')).toBeNull();
|
|
expect(resolveNationRef(state, '')).toBeNull();
|
|
});
|
|
});
|
|
|
|
describe('validateNationOps (v0.23 #16 P4a)', () => {
|
|
it('parses a valid rename (trimmed, capped) and drops invalid ones', () => {
|
|
const ops = validateNationOps([
|
|
{ kind: 'rename', nationRef: ' de ', to: 'Reich' },
|
|
{ kind: 'rename', nationRef: 'uk', to: '' }, // empty to -> dropped
|
|
{ kind: 'rename', to: 'no ref' }, // missing nationRef -> dropped
|
|
{ kind: 'rename', nationRef: 'us' }, // missing to -> dropped
|
|
{ kind: 'bogus-kind' }, // unknown kind -> ignored
|
|
'not-an-object',
|
|
null,
|
|
]);
|
|
expect(ops).toEqual([{ kind: 'rename', nationRef: 'de', to: 'Reich' }]);
|
|
});
|
|
|
|
it('parses a valid split (parts with names kept, nameless parts dropped)', () => {
|
|
const ops = validateNationOps([
|
|
{
|
|
kind: 'split',
|
|
nationRef: 'de',
|
|
parts: [
|
|
{ name: 'East', territory: ['DEU', ''] },
|
|
{ name: ' ' }, // empty name -> part dropped
|
|
{ name: 'West' },
|
|
],
|
|
},
|
|
]);
|
|
expect(ops).toEqual([
|
|
{
|
|
kind: 'split',
|
|
nationRef: 'de',
|
|
parts: [{ name: 'East', territory: ['DEU'] }, { name: 'West', territory: [] }],
|
|
},
|
|
]);
|
|
// a split with no usable parts is dropped entirely
|
|
expect(validateNationOps([{ kind: 'split', nationRef: 'de', parts: [{ name: '' }] }])).toEqual([]);
|
|
});
|
|
|
|
it('parses a valid merge and drops malformed ones', () => {
|
|
const ops = validateNationOps([
|
|
{ kind: 'merge', nationRefs: ['uk', 'us'], survivingRef: 'uk', into: 'Anglo-Saxon Union' },
|
|
{ kind: 'merge', nationRefs: [], survivingRef: 'uk', into: 'X' }, // no refs -> dropped
|
|
{ kind: 'merge', nationRefs: ['uk'], survivingRef: '', into: 'X' }, // no survivor -> dropped
|
|
{ kind: 'merge', nationRefs: ['uk'], survivingRef: 'uk' }, // no into -> dropped
|
|
]);
|
|
expect(ops).toEqual([
|
|
{ kind: 'merge', nationRefs: ['uk', 'us'], survivingRef: 'uk', into: 'Anglo-Saxon Union' },
|
|
]);
|
|
});
|
|
|
|
it('returns [] for non-array input and is never thrown by', () => {
|
|
expect(validateNationOps(undefined)).toEqual([]);
|
|
expect(validateNationOps('nope')).toEqual([]);
|
|
expect(validateNationOps({})).toEqual([]);
|
|
});
|
|
|
|
it('validateTurnResult carries validated nationOps through (and defaults to [] when absent)', () => {
|
|
const withOps = validateTurnResult({
|
|
date: '1939-09-03',
|
|
events: [],
|
|
stateChanges: [],
|
|
relations: [],
|
|
territoryChanges: [],
|
|
unowned: [],
|
|
nationOps: [{ kind: 'rename', nationRef: 'France', to: 'French Republic' }],
|
|
});
|
|
expect(withOps).not.toBeNull();
|
|
expect(withOps!.nationOps).toEqual([{ kind: 'rename', nationRef: 'France', to: 'French Republic' }]);
|
|
|
|
const without = validateTurnResult({ date: '1939-09-03', events: [], stateChanges: [] });
|
|
expect(without).not.toBeNull();
|
|
expect(without!.nationOps).toEqual([]);
|
|
});
|
|
});
|
|
|
|
describe('applyTurnResult (nation-op proposals, v0.23 #16 P4a)', () => {
|
|
it('a proposed rename is NOT applied by the turn — it lands in pendingNationOps, name untouched', () => {
|
|
const state = baseState();
|
|
const next = applyTurnResult(state, {
|
|
date: '1939-09-03',
|
|
events: [{ nation: 'de', type: 'political', title: 'Reunification', text: 'The nation is renamed.', outcome: 'success' }],
|
|
stateChanges: [],
|
|
relations: [],
|
|
territoryChanges: [],
|
|
unowned: [],
|
|
nationOps: [{ kind: 'rename', nationRef: 'de', to: 'Reich' }],
|
|
});
|
|
expect(next.nations.de.name).toBe('DE'); // still the old name
|
|
expect(next.pendingNationOps).toEqual([{ kind: 'rename', nationRef: 'de', to: 'Reich' }]);
|
|
expect(next.version).toBe(2);
|
|
// the input is not mutated
|
|
expect(state.nations.de.name).toBe('DE');
|
|
expect(state.pendingNationOps).toEqual([]);
|
|
});
|
|
|
|
it('pending proposals SURVIVE a subsequent turn (unlike the round queue) and new ones append', () => {
|
|
const state = baseState();
|
|
state.pendingNationOps = [{ kind: 'rename', nationRef: 'de', to: 'Reich' }]; // unresolved from turn N-1
|
|
const next = applyTurnResult(state, {
|
|
date: '1939-09-03',
|
|
events: [],
|
|
stateChanges: [],
|
|
relations: [],
|
|
territoryChanges: [],
|
|
unowned: [],
|
|
nationOps: [{ kind: 'rename', nationRef: 'us', to: 'USA' }], // new proposal this turn
|
|
});
|
|
expect(next.pendingNationOps).toEqual([
|
|
{ kind: 'rename', nationRef: 'de', to: 'Reich' }, // the old one survived
|
|
{ kind: 'rename', nationRef: 'us', to: 'USA' }, // the new one was appended
|
|
]);
|
|
});
|
|
|
|
it('a legacy state without the pendingNationOps field accepts proposals (defaulted to [])', () => {
|
|
const state = baseState();
|
|
delete (state as { pendingNationOps?: NationOp[] }).pendingNationOps; // simulate a pre-v0.23 row
|
|
const next = applyTurnResult(state, {
|
|
date: '1939-09-03',
|
|
events: [],
|
|
stateChanges: [],
|
|
relations: [],
|
|
territoryChanges: [],
|
|
unowned: [],
|
|
nationOps: [{ kind: 'rename', nationRef: 'de', to: 'Reich' }],
|
|
});
|
|
expect(next.pendingNationOps).toEqual([{ kind: 'rename', nationRef: 'de', to: 'Reich' }]);
|
|
});
|
|
});
|
|
|
|
describe('confirmNationOp / rejectNationOp (v0.23 #16 P4a)', () => {
|
|
/**
|
|
* The pending list: op 0 is referenced by id ('de'); op 1 is referenced by
|
|
* NAME ('US' — the id is lowercase 'us'), so confirming it exercises the
|
|
* name-resolution path of resolveNationRef.
|
|
*/
|
|
function stateWithPending(): GameState {
|
|
const s = baseState();
|
|
s.pendingNationOps = [
|
|
{ kind: 'rename', nationRef: 'de', to: 'Reich' },
|
|
{ kind: 'rename', nationRef: 'US', to: 'United States' },
|
|
];
|
|
return s;
|
|
}
|
|
|
|
it('confirm applies a rename (name only — id/territory/relations/player pointer untouched) and removes the op, bumping the version', () => {
|
|
const state = stateWithPending();
|
|
const next = confirmNationOp(state, 0);
|
|
expect(next).not.toBeNull();
|
|
expect(next!.nations.de.name).toBe('Reich');
|
|
expect(next!.nations.de.id).toBe('de'); // id unchanged
|
|
expect(next!.nations.de.territory).toEqual(['DEU']); // territory unchanged
|
|
expect(next!.nations.uk.relations.de).toBe('hostile'); // relations (keyed by id) unchanged
|
|
expect(next!.playerNationId).toBe('uk'); // player pointer unchanged
|
|
expect(next!.pendingNationOps).toEqual([{ kind: 'rename', nationRef: 'US', to: 'United States' }]);
|
|
expect(next!.version).toBe(2);
|
|
// the input state is not mutated
|
|
expect(state.nations.de.name).toBe('DE');
|
|
expect(state.pendingNationOps).toHaveLength(2);
|
|
expect(state.version).toBe(1);
|
|
});
|
|
|
|
it('confirm with an edits.name override renames to the player-edited name', () => {
|
|
const next = confirmNationOp(stateWithPending(), 0, { name: 'Greater Reich' });
|
|
expect(next!.nations.de.name).toBe('Greater Reich');
|
|
});
|
|
|
|
it('confirm with an empty edits.name falls back to the proposed name', () => {
|
|
const next = confirmNationOp(stateWithPending(), 0, { name: ' ' });
|
|
expect(next!.nations.de.name).toBe('Reich');
|
|
});
|
|
|
|
it('confirm resolves the target by name (not just id) — case-insensitively', () => {
|
|
const next = confirmNationOp(stateWithPending(), 1); // nationRef 'US' is a name, not the id 'us'
|
|
expect(next).not.toBeNull();
|
|
expect(next!.nations.us.name).toBe('United States');
|
|
});
|
|
|
|
it('confirm of a 1-part split is a no-op on the world (fewer than 2 parts) — the op is still removed', () => {
|
|
const state = baseState();
|
|
state.pendingNationOps = [{ kind: 'split', nationRef: 'de', parts: [{ name: 'East' }] }];
|
|
const next = confirmNationOp(state, 0);
|
|
expect(next).not.toBeNull();
|
|
expect(next!.nations).toEqual(state.nations); // the world is untouched
|
|
expect(next!.pendingNationOps).toEqual([]); // ...but the proposal was resolved
|
|
expect(next!.version).toBe(2);
|
|
});
|
|
|
|
it('confirm with an invalid index returns null and mutates nothing', () => {
|
|
const state = stateWithPending();
|
|
expect(confirmNationOp(state, -1)).toBeNull();
|
|
expect(confirmNationOp(state, 2)).toBeNull();
|
|
expect(confirmNationOp(state, 0.5)).toBeNull();
|
|
expect(state.nations.de.name).toBe('DE');
|
|
expect(state.version).toBe(1);
|
|
});
|
|
|
|
it('reject drops the proposal without applying it, bumping the version', () => {
|
|
const state = stateWithPending();
|
|
const next = rejectNationOp(state, 0);
|
|
expect(next).not.toBeNull();
|
|
expect(next!.nations.de.name).toBe('DE'); // unchanged
|
|
expect(next!.pendingNationOps).toEqual([{ kind: 'rename', nationRef: 'US', to: 'United States' }]);
|
|
expect(next!.version).toBe(2);
|
|
expect(state.pendingNationOps).toHaveLength(2); // input untouched
|
|
});
|
|
|
|
it('reject with an invalid index returns null and mutates nothing', () => {
|
|
const state = stateWithPending();
|
|
expect(rejectNationOp(state, -1)).toBeNull();
|
|
expect(rejectNationOp(state, 2)).toBeNull();
|
|
expect(state.pendingNationOps).toHaveLength(2);
|
|
expect(state.version).toBe(1);
|
|
});
|
|
|
|
it('an ambiguous/invalid nationRef is a no-op on confirm (the op is resolved, the world unchanged)', () => {
|
|
const state = baseState();
|
|
state.nations.us.name = 'UK'; // make "UK" ambiguous (uk and us)
|
|
state.pendingNationOps = [{ kind: 'rename', nationRef: 'UK', to: 'Empire' }];
|
|
const next = confirmNationOp(state, 0);
|
|
expect(next).not.toBeNull();
|
|
expect(next!.nations.uk.name).toBe('UK'); // neither nation was renamed
|
|
expect(next!.nations.us.name).toBe('UK');
|
|
expect(next!.pendingNationOps).toEqual([]);
|
|
});
|
|
});
|
|
|
|
describe('buildSystemPrompt (nationOps contract, v0.23 #16 P4a; v0.24 #16 P4b)', () => {
|
|
it('both modes contract the nationOps field with all three op kinds and the proposal rule', () => {
|
|
for (const p of [buildSystemPrompt(preset), buildSystemPrompt(preset, true)]) {
|
|
// the JSON shape carries all three kinds in both modes
|
|
expect(p).toMatch(/"kind":"rename","nationRef":"<nation id or name>","to":"<new name>"/);
|
|
expect(p).toMatch(/"kind":"split","nationRef":"<nation id or name>","parts":\[\{"name":"<new name>","territory":\["<region key>"\]\}\]/);
|
|
expect(p).toMatch(/"kind":"merge","nationRefs":\["<id>","<id>"\],"survivingRef":"<id of the surviving nation>","into":"<new name>"/);
|
|
// the rule explains that ops are proposals only the player can confirm
|
|
expect(p).toMatch(/PROPOSAL/);
|
|
expect(p).toMatch(/only take effect if the player confirms/);
|
|
// and the split/merge shapes are spelt out in the hard constraints
|
|
expect(p).toMatch(/at least 2 parts/);
|
|
expect(p).toMatch(/survivingRef" must name one of them/);
|
|
}
|
|
});
|
|
});
|
|
|
|
describe('freshNationId (v0.24 #16 P4b)', () => {
|
|
it('returns the first free of n1, n2, ... against the taken set', () => {
|
|
expect(freshNationId(new Set())).toBe('n1');
|
|
expect(freshNationId(new Set(['n1']))).toBe('n2');
|
|
expect(freshNationId(new Set(['n1', 'n2', 'n3']))).toBe('n4');
|
|
// non-n ids never collide: existing nation ids are irrelevant
|
|
expect(freshNationId(new Set(['uk', 'de', 'us']))).toBe('n1');
|
|
});
|
|
});
|
|
|
|
describe('applyNationOp (split, v0.24 #16 P4b)', () => {
|
|
/** de owns four regions; the player is uk (so a split of de does not move the pointer). */
|
|
function splitState(): GameState {
|
|
const s = baseState();
|
|
s.nations.de.territory = ['DEU-N', 'DEU-S', 'DEU-E', 'DEU-W'];
|
|
s.nations.de.relations = { uk: 'ally' };
|
|
s.nations.uk.relations.de = 'hostile'; // mirror entry, to check the cleanup
|
|
return s;
|
|
}
|
|
|
|
it('partitions the parent territory: first-wins per key, leftovers to part 0, fresh ids, baseline stats, copied relations, parent removed', () => {
|
|
const state = splitState();
|
|
applyNationOp(state, {
|
|
kind: 'split',
|
|
nationRef: 'de',
|
|
parts: [
|
|
{ name: 'East', territory: ['DEU-E', 'DEU-W', 'ZZZ-not-a-key'] },
|
|
{ name: 'West', territory: ['DEU-W', 'DEU-S'] },
|
|
],
|
|
});
|
|
// the parent is gone and two fresh nations exist (n1, n2 — uk/us/de were taken)
|
|
expect(state.nations.de).toBeUndefined();
|
|
expect(Object.keys(state.nations).sort()).toEqual(['n1', 'n2', 'uk', 'us']);
|
|
const east = state.nations.n1;
|
|
const west = state.nations.n2;
|
|
// East claimed DEU-E and DEU-W first (the invalid key is ignored); the
|
|
// only key no part claimed — DEU-N (DEU-S went to West) — goes to part 0
|
|
// in the parent's territory order.
|
|
expect(east.territory).toEqual(['DEU-E', 'DEU-W', 'DEU-N']);
|
|
// West's DEU-W was already claimed by East; it keeps only DEU-S.
|
|
expect(west.territory).toEqual(['DEU-S']);
|
|
// every parent key sits in exactly one part (sole ownership, nothing lost)
|
|
const all = [...east.territory, ...west.territory].sort();
|
|
expect(all).toEqual(['DEU-E', 'DEU-N', 'DEU-S', 'DEU-W']);
|
|
// fresh baseline stats (not the parent's) and inherited form of state
|
|
expect(east.stats).toEqual(NEW_NATION_STATS);
|
|
expect(west.stats).toEqual(NEW_NATION_STATS);
|
|
expect(east.type).toBe('major');
|
|
expect(west.type).toBe('major');
|
|
// both parts copied the parent's relations
|
|
expect(east.relations).toEqual({ uk: 'ally' });
|
|
expect(west.relations).toEqual({ uk: 'ally' });
|
|
// the mirror entry for the removed parent is cleaned up
|
|
expect(state.nations.uk.relations.de).toBeUndefined();
|
|
expect(state.nations.uk.relations.us).toBe('neutral'); // other entries untouched
|
|
// the player (uk) is not affected
|
|
expect(state.playerNationId).toBe('uk');
|
|
});
|
|
|
|
it('an unresolvable nationRef is a no-op (the world is untouched, never throws)', () => {
|
|
const state = splitState();
|
|
const before = JSON.parse(JSON.stringify(state));
|
|
applyNationOp(state, {
|
|
kind: 'split',
|
|
nationRef: 'zzz-not-a-nation',
|
|
parts: [{ name: 'East' }, { name: 'West' }],
|
|
});
|
|
expect(state).toEqual(before);
|
|
});
|
|
|
|
it('a split named by the parent NAME works (ref resolution, not just id)', () => {
|
|
const state = splitState();
|
|
applyNationOp(state, {
|
|
kind: 'split',
|
|
nationRef: 'DE', // the name; the id is 'de'
|
|
parts: [{ name: 'East', territory: ['DEU-E'] }, { name: 'West', territory: ['DEU-W'] }],
|
|
});
|
|
expect(state.nations.de).toBeUndefined();
|
|
expect(state.nations.n1.name).toBe('East');
|
|
expect(state.nations.n2.name).toBe('West');
|
|
});
|
|
|
|
it('when the parent is the player nation, playerNationId moves to the chosen part (edits.playerPart: default 0, clamped in range)', () => {
|
|
const state = splitState();
|
|
state.playerNationId = 'de';
|
|
applyNationOp(state, {
|
|
kind: 'split',
|
|
nationRef: 'de',
|
|
parts: [{ name: 'East' }, { name: 'West' }, { name: 'Centre' }],
|
|
});
|
|
expect(state.playerNationId).toBe('n1'); // default: the first part
|
|
|
|
const state2 = splitState();
|
|
state2.playerNationId = 'de';
|
|
applyNationOp(
|
|
state2,
|
|
{
|
|
kind: 'split',
|
|
nationRef: 'de',
|
|
parts: [{ name: 'East' }, { name: 'West' }, { name: 'Centre' }],
|
|
},
|
|
{ playerPart: 1 },
|
|
);
|
|
expect(state2.playerNationId).toBe('n2');
|
|
|
|
const state3 = splitState();
|
|
state3.playerNationId = 'de';
|
|
applyNationOp(
|
|
state3,
|
|
{
|
|
kind: 'split',
|
|
nationRef: 'de',
|
|
parts: [{ name: 'East' }, { name: 'West' }],
|
|
},
|
|
{ playerPart: 99 }, // clamped down to the last part
|
|
);
|
|
expect(state3.playerNationId).toBe('n2');
|
|
|
|
const state4 = splitState();
|
|
state4.playerNationId = 'de';
|
|
applyNationOp(
|
|
state4,
|
|
{
|
|
kind: 'split',
|
|
nationRef: 'de',
|
|
parts: [{ name: 'East' }, { name: 'West' }],
|
|
},
|
|
{ playerPart: -5 }, // clamped up to the first part
|
|
);
|
|
expect(state4.playerNationId).toBe('n1');
|
|
});
|
|
|
|
it('edits.name renames the chosen part (edits.playerPart picks which one)', () => {
|
|
const state = splitState();
|
|
applyNationOp(
|
|
state,
|
|
{
|
|
kind: 'split',
|
|
nationRef: 'de',
|
|
parts: [{ name: 'East' }, { name: 'West' }],
|
|
},
|
|
{ playerPart: 1, name: 'New West' },
|
|
);
|
|
expect(state.nations.n1.name).toBe('East'); // untouched part keeps its name
|
|
expect(state.nations.n2.name).toBe('New West');
|
|
|
|
const state2 = splitState();
|
|
applyNationOp(
|
|
state2,
|
|
{
|
|
kind: 'split',
|
|
nationRef: 'de',
|
|
parts: [{ name: 'East' }, { name: 'West' }],
|
|
},
|
|
{ name: 'First of the Remnant' }, // no playerPart -> the first part
|
|
);
|
|
expect(state2.nations.n1.name).toBe('First of the Remnant');
|
|
expect(state2.nations.n2.name).toBe('West');
|
|
});
|
|
|
|
it('a 1-part split is a no-op on the world (fewer than 2 parts)', () => {
|
|
const state = splitState();
|
|
const before = JSON.parse(JSON.stringify(state));
|
|
applyNationOp(state, { kind: 'split', nationRef: 'de', parts: [{ name: 'East' }] });
|
|
expect(state).toEqual(before);
|
|
});
|
|
});
|
|
|
|
describe('applyNationOp (merge, v0.24 #16 P4b)', () => {
|
|
/**
|
|
* de and us will be merged (the survivor is us — deliberately listed
|
|
* second in nationRefs, so the "survivor's view wins" rule can be observed
|
|
* overwriting the first input's views). uk, fr and ja survive as third
|
|
* parties; the survivor has no view on ja, so ja must come from the first
|
|
* input's map.
|
|
*/
|
|
function mergeState(): GameState {
|
|
const s = baseState();
|
|
s.nations.de.relations = { us: 'rival', uk: 'hostile', fr: 'rival', ja: 'rival' };
|
|
s.nations.us.relations = { de: 'ally', uk: 'ally', fr: 'neutral' }; // no view on ja
|
|
s.nations.uk.relations = { us: 'ally', de: 'hostile', fr: 'neutral', ja: 'neutral' };
|
|
s.nations.fr = {
|
|
id: 'fr',
|
|
type: 'major',
|
|
name: 'FR',
|
|
stats: { military: 40, economy: 60, stability: 70, population: 60 },
|
|
territory: ['FRA'],
|
|
relations: { us: 'neutral', de: 'rival', uk: 'neutral', ja: 'neutral' },
|
|
};
|
|
s.nations.ja = {
|
|
id: 'ja',
|
|
type: 'major',
|
|
name: 'JA',
|
|
stats: { military: 30, economy: 80, stability: 50, population: 90 },
|
|
territory: ['JPN'],
|
|
relations: { us: 'neutral', de: 'rival', uk: 'neutral', fr: 'neutral' },
|
|
};
|
|
return s;
|
|
}
|
|
|
|
it('folds the inputs into the survivor: union territory (survivor first), survivor stats, survivor view wins per third party (else first input), non-input third parties kept, removed ids dropped from everyone else, into-name', () => {
|
|
const state = mergeState();
|
|
applyNationOp(state, { kind: 'merge', nationRefs: ['de', 'us'], survivingRef: 'us', into: 'Union' });
|
|
// the removed input is gone; the survivor and all non-inputs survive
|
|
expect(state.nations.de).toBeUndefined();
|
|
expect(Object.keys(state.nations).sort()).toEqual(['fr', 'ja', 'uk', 'us']);
|
|
// the survivor keeps its id, its own stats, and the merged name
|
|
expect(state.nations.us.id).toBe('us');
|
|
expect(state.nations.us.stats).toEqual({ military: 60, economy: 90, stability: 80, population: 80 });
|
|
expect(state.nations.us.name).toBe('Union');
|
|
// territory: the union — the survivor's keys first, then the other input's
|
|
expect(state.nations.us.territory).toEqual(['USA', 'DEU']);
|
|
// relations: on uk and fr the survivor's own views (ally/neutral) beat the
|
|
// first input's (hostile/rival); ja has no survivor view, so the first
|
|
// input's (rival) stands; the removed input's id is gone from the map.
|
|
expect(state.nations.us.relations).toEqual({ uk: 'ally', fr: 'neutral', ja: 'rival' });
|
|
// the removed id is dropped from every remaining nation's relations map
|
|
expect(state.nations.uk.relations).toEqual({ us: 'ally', fr: 'neutral', ja: 'neutral' });
|
|
expect(state.nations.fr.relations).toEqual({ us: 'neutral', uk: 'neutral', ja: 'neutral' });
|
|
expect(state.nations.ja.relations).toEqual({ us: 'neutral', uk: 'neutral', fr: 'neutral' });
|
|
// the player (uk) is not part of the merge — the pointer stays valid
|
|
expect(state.playerNationId).toBe('uk');
|
|
});
|
|
|
|
it('the player pointer moves to the survivor when the player led a removed input', () => {
|
|
const state = mergeState();
|
|
state.playerNationId = 'de'; // the player's nation is absorbed
|
|
applyNationOp(state, { kind: 'merge', nationRefs: ['de', 'us'], survivingRef: 'us', into: 'Union' });
|
|
expect(state.playerNationId).toBe('us');
|
|
expect(state.nations[state.playerNationId].name).toBe('Union');
|
|
});
|
|
|
|
it('edits.name overrides the merged name; an empty edit falls back to "into", then to the survivor name', () => {
|
|
const state = mergeState();
|
|
applyNationOp(state, { kind: 'merge', nationRefs: ['de', 'us'], survivingRef: 'us', into: 'Union' }, { name: 'United World' });
|
|
expect(state.nations.us.name).toBe('United World');
|
|
|
|
const state2 = mergeState();
|
|
applyNationOp(state2, { kind: 'merge', nationRefs: ['de', 'us'], survivingRef: 'us', into: 'Union' }, { name: ' ' });
|
|
expect(state2.nations.us.name).toBe('Union');
|
|
|
|
const state3 = mergeState();
|
|
applyNationOp(state3, { kind: 'merge', nationRefs: ['de', 'us'], survivingRef: 'us', into: '' }); // empty into: keep the survivor's name
|
|
expect(state3.nations.us.name).toBe('US');
|
|
});
|
|
|
|
it('an unresolvable input ref is a no-op (never throws, world untouched)', () => {
|
|
const state = mergeState();
|
|
const before = JSON.parse(JSON.stringify(state));
|
|
applyNationOp(state, {
|
|
kind: 'merge',
|
|
nationRefs: ['us', 'zzz-not-a-nation'],
|
|
survivingRef: 'uk',
|
|
into: 'X',
|
|
});
|
|
expect(state).toEqual(before);
|
|
});
|
|
|
|
it('a survivor that is not one of the inputs is a no-op', () => {
|
|
const state = mergeState();
|
|
const before = JSON.parse(JSON.stringify(state));
|
|
applyNationOp(state, {
|
|
kind: 'merge',
|
|
nationRefs: ['us', 'de'],
|
|
survivingRef: 'uk', // uk is not an input
|
|
into: 'X',
|
|
});
|
|
expect(state).toEqual(before);
|
|
});
|
|
|
|
it('fewer than two DISTINCT inputs is a no-op (the same nation named twice)', () => {
|
|
const state = mergeState();
|
|
const before = JSON.parse(JSON.stringify(state));
|
|
applyNationOp(state, {
|
|
kind: 'merge',
|
|
nationRefs: ['us', 'US'], // both resolve to 'us'
|
|
survivingRef: 'us',
|
|
into: 'X',
|
|
});
|
|
expect(state).toEqual(before);
|
|
});
|
|
});
|
|
|
|
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: [],
|
|
unowned: [],
|
|
nationOps: [],
|
|
};
|
|
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: [],
|
|
unowned: [],
|
|
nationOps: [],
|
|
};
|
|
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: [] }],
|
|
unowned: [],
|
|
nationOps: [],
|
|
};
|
|
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 -> a re-claim (v0.22, #16 P3): DE gains it. It was never
|
|
// released (not in state.unowned), so it does not enter GameState.unowned.
|
|
territoryChanges: [{ nation: 'de', gain: ['POL', 'wales-bbbb'], lose: [] }],
|
|
unowned: [],
|
|
nationOps: [],
|
|
};
|
|
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).toContain('POL'); // re-claimed, not ignored
|
|
expect(next.unowned).toEqual([]); // never released -> not no man's land
|
|
});
|
|
});
|
|
|
|
describe('applyTurnResult (sole-ownership invariants, v0.21 #16 P2)', () => {
|
|
const noEvents = {
|
|
date: '1939-09-03',
|
|
events: [],
|
|
stateChanges: [],
|
|
relations: [],
|
|
unowned: [],
|
|
nationOps: [],
|
|
};
|
|
|
|
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('applyTurnResult (no man\'s land, v0.22 #16 P3)', () => {
|
|
const noEvents = {
|
|
date: '1939-09-03',
|
|
events: [],
|
|
stateChanges: [],
|
|
relations: [],
|
|
territoryChanges: [],
|
|
unowned: [],
|
|
nationOps: [],
|
|
};
|
|
|
|
it('a release removes the key from its sole owner and marks it in GameState.unowned', () => {
|
|
const state = baseState();
|
|
const next = applyTurnResult(state, { ...noEvents, unowned: ['GBR'] });
|
|
expect(next.nations.uk.territory).not.toContain('GBR');
|
|
expect(next.unowned).toEqual(['GBR']);
|
|
// nothing else moved
|
|
expect(next.nations.us.territory).toEqual(['USA']);
|
|
expect(next.nations.de.territory).toEqual(['DEU']);
|
|
});
|
|
|
|
it('a re-claim (a gain on a currently-unowned key) grants it and removes it from GameState.unowned', () => {
|
|
const state = baseState();
|
|
state.unowned = ['POL']; // released in an earlier turn
|
|
const next = applyTurnResult(state, {
|
|
...noEvents,
|
|
territoryChanges: [{ nation: 'de', gain: ['POL'], lose: [] }],
|
|
});
|
|
expect(next.nations.de.territory).toContain('POL');
|
|
expect(next.unowned).toEqual([]); // it left no man's land
|
|
});
|
|
|
|
it('a release and a claim of the same key in one turn: the claim wins (the key is owned)', () => {
|
|
const state = baseState();
|
|
const next = applyTurnResult(state, {
|
|
...noEvents,
|
|
territoryChanges: [{ nation: 'de', gain: ['GBR'], lose: [] }],
|
|
unowned: ['GBR'],
|
|
});
|
|
expect(next.nations.de.territory).toContain('GBR');
|
|
expect(next.nations.uk.territory).not.toContain('GBR');
|
|
expect(next.unowned).toEqual([]);
|
|
});
|
|
|
|
it('a double claim on a released key is rejected: the key stays in no man\'s land', () => {
|
|
const state = baseState();
|
|
const next = applyTurnResult(state, {
|
|
...noEvents,
|
|
territoryChanges: [
|
|
{ nation: 'de', gain: ['GBR'], lose: [] },
|
|
{ nation: 'us', gain: ['GBR'], lose: [] },
|
|
],
|
|
unowned: ['GBR'], // released first, then both nations lunge for it
|
|
});
|
|
expect(next.nations.uk.territory).not.toContain('GBR'); // the release applied
|
|
expect(next.nations.de.territory).not.toContain('GBR'); // both claims rejected
|
|
expect(next.nations.us.territory).not.toContain('GBR');
|
|
expect(next.unowned).toEqual(['GBR']);
|
|
});
|
|
|
|
it('releasing a key no nation owns (a preset-unassigned region) is a no-op — no man\'s land can only arise from a real release', () => {
|
|
const state = baseState();
|
|
const next = applyTurnResult(state, { ...noEvents, unowned: ['POL'] });
|
|
expect(next.unowned).toEqual([]);
|
|
for (const n of Object.values(next.nations)) expect(n.territory).not.toContain('POL');
|
|
});
|
|
|
|
it('releasing a key with two owners (a corrupt state) is a no-op', () => {
|
|
const state = baseState();
|
|
state.nations.us.territory = ['USA', 'GBR']; // uk AND us share GBR
|
|
const next = applyTurnResult(state, { ...noEvents, unowned: ['GBR'] });
|
|
expect(next.nations.uk.territory).toContain('GBR');
|
|
expect(next.nations.us.territory).toContain('GBR');
|
|
expect(next.unowned).toEqual([]);
|
|
});
|
|
|
|
it('releasing a key already in no man\'s land is a no-op (it stays listed once)', () => {
|
|
const state = baseState();
|
|
state.unowned = ['POL'];
|
|
const next = applyTurnResult(state, { ...noEvents, unowned: ['POL'] });
|
|
expect(next.unowned).toEqual(['POL']);
|
|
});
|
|
|
|
it('with a validKeys set: releasing a hallucinated key is a no-op', () => {
|
|
const state = baseState();
|
|
const next = applyTurnResult(state, { ...noEvents, unowned: ['not-a-region-xyz'] }, new Set(['wales-bbbb']));
|
|
expect(next.unowned).toEqual([]);
|
|
});
|
|
|
|
it('a legacy state without the unowned field accepts a release (the result carries the field)', () => {
|
|
const state = baseState();
|
|
delete (state as { unowned?: string[] }).unowned; // simulate a pre-v0.22 row
|
|
const next = applyTurnResult(state, { ...noEvents, unowned: ['GBR'] });
|
|
expect(next.unowned).toEqual(['GBR']);
|
|
expect(next.nations.uk.territory).not.toContain('GBR');
|
|
});
|
|
|
|
it('a turn with no releases leaves the existing no man\'s land list unchanged', () => {
|
|
const state = baseState();
|
|
state.unowned = ['POL'];
|
|
const next = applyTurnResult(state, {
|
|
...noEvents,
|
|
territoryChanges: [{ nation: 'de', gain: ['GBR'], lose: [] }],
|
|
});
|
|
expect(next.unowned).toEqual(['POL']);
|
|
expect(next.nations.de.territory).toContain('GBR');
|
|
});
|
|
|
|
it('validateTurnResult dedupes and filters the unowned list (trimmed non-empty strings only)', () => {
|
|
const r = validateTurnResult({
|
|
date: '1939-09-03',
|
|
events: [],
|
|
stateChanges: [],
|
|
relations: [],
|
|
territoryChanges: [],
|
|
unowned: [' a ', 'a', '', 42, null, ' b '],
|
|
});
|
|
expect(r).not.toBeNull();
|
|
expect(r!.unowned).toEqual(['a', 'b']);
|
|
});
|
|
|
|
it('a lone release is meaningful (it survives the retry/nudge path)', () => {
|
|
expect(
|
|
isMeaningful({ date: 'x', events: [], stateChanges: [], relations: [], territoryChanges: [], unowned: ['GBR'], nationOps: [] }),
|
|
).toBe(true);
|
|
});
|
|
|
|
it('runTurn applies a release end-to-end: the model narrates it and the key leaves its owner', async () => {
|
|
const original = baseState();
|
|
original.pending = [{ kind: 'action', text: 'Scorch the earth on the retreat' }];
|
|
const releaseJson = JSON.stringify({
|
|
date: '1939-09-05',
|
|
events: [
|
|
{
|
|
nation: 'uk',
|
|
type: 'military',
|
|
title: 'A retreat through the wastelands',
|
|
text: 'The abandoned highlands burn and lie empty.',
|
|
outcome: 'partial',
|
|
},
|
|
],
|
|
stateChanges: [],
|
|
relations: [],
|
|
territoryChanges: [],
|
|
unowned: ['GBR'],
|
|
});
|
|
const payloads: string[] = [];
|
|
const client: LlmClient = {
|
|
chat: async (messages) => {
|
|
const user = messages.find((m) => m.role === 'user');
|
|
if (user) payloads.push(user.content as string);
|
|
return releaseJson;
|
|
},
|
|
listModels: async () => [],
|
|
};
|
|
const { state } = await runTurn({ llm: client, preset, state: original, items: original.pending });
|
|
// the event landed and the release applied
|
|
expect(state.events).toHaveLength(1);
|
|
expect(state.events[0].title).toBe('A retreat through the wastelands');
|
|
expect(state.nations.uk.territory).not.toContain('GBR');
|
|
expect(state.unowned).toEqual(['GBR']);
|
|
// on the first turn the model was shown the (empty) no-man's-land list...
|
|
const first = JSON.parse(payloads[0]) as { unowned?: string[] };
|
|
expect(first.unowned).toEqual([]);
|
|
// ...and on the next turn it is shown the released key (so it can re-claim it)
|
|
const next = await runTurn({ llm: client, preset, state, items: [] });
|
|
const second = JSON.parse(payloads[1]) as { unowned?: string[] };
|
|
expect(second.unowned).toEqual(['GBR']);
|
|
// the repeated "release" of an already-unowned key is a no-op
|
|
expect(next.state.unowned).toEqual(['GBR']);
|
|
});
|
|
});
|
|
|
|
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);
|
|
});
|
|
|
|
it('(v0.22 #16 P3) both modes contract the no-man\'s-land field (release + re-claim) and constrain it to owned keys', () => {
|
|
for (const p of [buildSystemPrompt(preset), buildSystemPrompt(preset, true)]) {
|
|
// the JSON shape carries the field in both modes
|
|
expect(p).toMatch(/"unowned": \["<region key>"\]/);
|
|
// the rule explains release (narrated in events) and re-claim (ordinary gain)
|
|
expect(p).toMatch(/no man's land/i);
|
|
expect(p).toMatch(/narrate the cause in "events"/);
|
|
expect(p).toMatch(/re-claims it with a "gain"/i);
|
|
// the hard constraint: only currently-owned keys may be released
|
|
expect(p).toMatch(/only region keys that a nation currently owns/);
|
|
}
|
|
});
|
|
});
|
|
|
|
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: [], unowned: [], nationOps: [] })).toBe(true);
|
|
expect(isMeaningful({ date: 'x', events: [], stateChanges: [{ nation: 'uk', stat: 'military', op: 'set', value: 5 }], relations: [], territoryChanges: [], unowned: [], nationOps: [] })).toBe(true);
|
|
expect(isMeaningful({ date: 'x', events: [], stateChanges: [], relations: [], territoryChanges: [], unowned: [], nationOps: [] })).toBe(false);
|
|
});
|
|
|
|
it('(v0.23 #16 P4a) a turn that only proposes a nation-op is meaningful (it survives the retry/nudge path)', () => {
|
|
expect(
|
|
isMeaningful({
|
|
date: 'x',
|
|
events: [],
|
|
stateChanges: [],
|
|
relations: [],
|
|
territoryChanges: [],
|
|
unowned: [],
|
|
nationOps: [{ kind: 'rename', nationRef: 'de', to: 'Reich' }],
|
|
}),
|
|
).toBe(true);
|
|
});
|
|
});
|