import { describe, expect, it } from 'vitest'; import type { GameState, Preset, RoundItem } from '../shared/types'; import { makeEvent, type TurnResult } from '../shared/protocol'; import type { LlmClient } from './llm'; import { makeStubLlmClient } from './stub'; import { applyTurnResult, buildSystemPrompt, buildUserPrompt, isMeaningful, parseTurnResult, 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' }], territoryChanges: [{ nation: 'de', gain: ['POL'], 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 expect(state.nations.de.territory).toContain('POL'); // 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('POL'); }); 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 }; 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 }; 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 & { 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(); 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 (stolen from its 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(); const result: TurnResult = { date: '1939-09-03', events: [], stateChanges: [], relations: [], territoryChanges: [{ nation: 'de', gain: ['POL', 'wales-bbbb'], lose: [] }], }; const next = applyTurnResult(state, result); // no validKeys -> nothing filtered expect(next.nations.de.territory).toContain('POL'); expect(next.nations.de.territory).toContain('wales-bbbb'); }); }); 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); }); });