import { describe, expect, it } from 'vitest'; import type { GameState, MarkerType, Nation, NationStats, Preset, RegionStats, RoundItem } from '../shared/types'; import { makeEvent, markerId, MAX_MARKERS, seedRegionStats, type MarkerSpec, 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, validateMarkerOps, 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 { const nations: GameState['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 } }, }; return { id: 'g1', presetId: 'test', presetTitle: 'Test', playerNationId: 'uk', turn: 1, date: '1939-09-01', nations, events: [], pending: [], version: 1, unowned: [], pendingNationOps: [], markers: [], // (v0.28, #46) every owned region starts as a copy of its owner's stats, // so a fresh state's derived stats equal its nation-level stats. regionStats: seedRegionStats(nations), }; } 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 }; 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(); }); 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 & { 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' }], markers: { add: [], remove: [] }, }); 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' }], markers: { add: [], remove: [] }, }); 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' }], markers: { add: [], remove: [] }, // 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' }], markers: { add: [], remove: [] }, }); 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":"","to":""/); expect(p).toMatch(/"kind":"split","nationRef":"","parts":\[\{"name":"","territory":\[""\]\}\]/); expect(p).toMatch(/"kind":"merge","nationRefs":\["",""\],"survivingRef":"","into":""/); // 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: [], markers: { add: [], remove: [] }, }; 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: [], markers: { add: [], remove: [] }, }; 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: [], markers: { add: [], remove: [] }, }; 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: [], markers: { add: [], remove: [] }, }; 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: [], markers: { add: [], remove: [] }, }; 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: [], markers: { add: [], remove: [] }, }; 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: [], markers: { add: [], remove: [] } }), ).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 = { 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 = { 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 }>) { const seen: { firstMessages: WireMessage[]; tools: ToolDefinition[]; executorCalls: Array<{ name: string; args: Record; 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 = { 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": \[""\]/); // 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: [], markers: { add: [], remove: [] } })).toBe(true); expect(isMeaningful({ date: 'x', events: [], stateChanges: [{ nation: 'uk', stat: 'military', op: 'set', value: 5 }], relations: [], territoryChanges: [], unowned: [], nationOps: [], markers: { add: [], remove: [] } })).toBe(true); expect(isMeaningful({ date: 'x', events: [], stateChanges: [], relations: [], territoryChanges: [], unowned: [], nationOps: [], markers: { add: [], remove: [] } })).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' }], markers: { add: [], remove: [] }, }), ).toBe(true); }); it('(v0.27 #44) a turn that only adds a marker is meaningful (it survives the retry/nudge path)', () => { expect( isMeaningful({ date: 'x', events: [], stateChanges: [], relations: [], territoryChanges: [], unowned: [], nationOps: [], markers: { add: [{ type: 'city', name: 'Lyon', regionKey: 'rhone-rhne' }], remove: [] }, }), ).toBe(true); }); }); describe('markers (point placement, v0.27 #44)', () => { // a deterministic centroid for one region key, undefined for everything else const centroidOf = (key: string) => key === 'moselle-rygy' ? { lat: 48, lon: 6, spread: 1 } : undefined; // build a MarkerSpec from a (possibly invalid, for the guard tests) type string const mk = (type: string, name: string, regionKey: string): MarkerSpec => ({ type: type as MarkerType, name, regionKey }); it('adds a valid marker with a stable id, a jittered position within the spread, the region owner, and the placed turn', () => { const state = baseState(); state.turn = 4; // placedTurn should equal the resolved turn state.nations.uk.territory = ['GBR', 'moselle-rygy']; // the region is owned by uk const result: TurnResult = { date: '1939-09-03', events: [], stateChanges: [], relations: [], territoryChanges: [], unowned: [], nationOps: [], markers: { add: [mk('city', 'Strasbourg', 'moselle-rygy')], remove: [] }, }; const next = applyTurnResult(state, result, undefined, centroidOf); expect(next.markers).toHaveLength(1); const m = next.markers[0]; expect(m.id).toBe(markerId('city', 'Strasbourg', 'moselle-rygy')); expect(m.type).toBe('city'); expect(m.name).toBe('Strasbourg'); expect(m.regionKey).toBe('moselle-rygy'); expect(m.ownerNationId).toBe('uk'); // the region's sole owner expect(m.placedTurn).toBe(4); // the position is the centroid jittered within ±spread/2 expect(m.lat).toBeGreaterThanOrEqual(47.5); expect(m.lat).toBeLessThanOrEqual(48.5); expect(m.lon).toBeGreaterThanOrEqual(5.5); expect(m.lon).toBeLessThanOrEqual(6.5); }); it('is idempotent: re-adding the same (type, name, regionKey) does not duplicate the marker', () => { const state = baseState(); state.nations.uk.territory = ['moselle-rygy']; const add = [mk('city', 'Strasbourg', 'moselle-rygy')]; const first = applyTurnResult(state, { date: '1939-09-03', events: [], stateChanges: [], relations: [], territoryChanges: [], unowned: [], nationOps: [], markers: { add, remove: [] }, }, undefined, centroidOf); expect(first.markers).toHaveLength(1); // a second turn proposes the exact same marker again const second = applyTurnResult(first, { date: '1939-09-05', events: [], stateChanges: [], relations: [], territoryChanges: [], unowned: [], nationOps: [], markers: { add, remove: [] }, }, undefined, centroidOf); expect(second.markers).toHaveLength(1); expect(second.markers[0].id).toBe(first.markers[0].id); // stable id, no second copy }); it('applyTurnResult drops a new add whose region is unknown or has no centroid, but keeps a valid one', () => { const state = baseState(); state.nations.uk.territory = ['moselle-rygy']; // validKeys knows two keys; the centroid resolves only the first. const validKeys = new Set(['moselle-rygy', 'other-key']); const centroidOf2 = (k: string) => k === 'moselle-rygy' ? { lat: 48, lon: 6, spread: 1 } : undefined; const result: TurnResult = { date: '1939-09-03', events: [], stateChanges: [], relations: [], territoryChanges: [], unowned: [], nationOps: [], markers: { add: [ { type: 'city', name: 'Good', regionKey: 'moselle-rygy' }, // valid + centroid → kept { type: 'city', name: 'NoCent', regionKey: 'other-key' }, // in validKeys, no centroid → dropped { type: 'city', name: 'Unknown', regionKey: 'zzz-not-a-key' }, // not in validKeys → dropped ], remove: [], }, }; const next = applyTurnResult(state, result, validKeys, centroidOf2); expect(next.markers).toHaveLength(1); expect(next.markers[0].name).toBe('Good'); }); it('caps the total marker count at MAX_MARKERS, dropping the newest add that exceeds the cap', () => { const state = baseState(); // pre-seed the state with MAX_MARKERS - 1 distinct markers const seeded: typeof state.markers = []; for (let i = 0; i < MAX_MARKERS - 1; i++) { const k = `seed-${i}`; seeded.push({ id: markerId('city', `S${i}`, k), type: 'city', name: `S${i}`, regionKey: k, lat: 0, lon: 0, ownerNationId: null, placedTurn: 1, }); } state.markers = seeded; // a permissive centroid: every region key resolves const anyCentroid = () => ({ lat: 0, lon: 0, spread: 0 }); const result: TurnResult = { date: '1939-09-03', events: [], stateChanges: [], relations: [], territoryChanges: [], unowned: [], nationOps: [], markers: { add: [ { type: 'city', name: 'NewA', regionKey: 'new-a' }, { type: 'city', name: 'NewB', regionKey: 'new-b' }, ], remove: [], }, }; const next = applyTurnResult(state, result, undefined, anyCentroid); expect(next.markers).toHaveLength(MAX_MARKERS); // capped, not MAX_MARKERS + 1 expect(next.markers.some((m) => m.name === 'NewA')).toBe(true); // the 1st new one kept expect(next.markers.some((m) => m.name === 'NewB')).toBe(false); // the 2nd (over cap) dropped }); it('removes a marker by id; removing an absent id is a no-op', () => { const state = baseState(); state.nations.uk.territory = ['moselle-rygy']; const idA = markerId('city', 'Strasbourg', 'moselle-rygy'); const idB = markerId('port', 'Dunkirk', 'moselle-rygy'); state.markers = [ { id: idA, type: 'city', name: 'Strasbourg', regionKey: 'moselle-rygy', lat: 48, lon: 6, ownerNationId: 'uk', placedTurn: 1 }, { id: idB, type: 'port', name: 'Dunkirk', regionKey: 'moselle-rygy', lat: 48, lon: 6, ownerNationId: 'uk', placedTurn: 1 }, ]; const next = applyTurnResult(state, { date: '1939-09-03', events: [], stateChanges: [], relations: [], territoryChanges: [], unowned: [], nationOps: [], markers: { add: [], remove: [idA, 'zzzz-not-there'] }, }, undefined, centroidOf); expect(next.markers).toHaveLength(1); expect(next.markers[0].id).toBe(idB); // only A removed; the bogus id was a no-op }); it('a marker owner follows its region: released to no man\'s land → null; re-claimed → the new owner', () => { const state = baseState(); state.nations.uk.territory = ['moselle-rygy']; const idA = markerId('city', 'Strasbourg', 'moselle-rygy'); state.markers = [ { id: idA, type: 'city', name: 'Strasbourg', regionKey: 'moselle-rygy', lat: 48, lon: 6, ownerNationId: 'uk', placedTurn: 1 }, ]; // 1) uk releases the region → the marker becomes ownerless const released = applyTurnResult(state, { date: '1939-09-03', events: [], stateChanges: [], relations: [], territoryChanges: [], unowned: ['moselle-rygy'], nationOps: [], markers: { add: [], remove: [] }, }, undefined, centroidOf); expect(released.markers[0].ownerNationId).toBeNull(); // 2) de re-claims the region → the marker's owner follows to de const reclaimed = applyTurnResult(released, { date: '1939-09-05', events: [], stateChanges: [], relations: [], territoryChanges: [{ nation: 'de', gain: ['moselle-rygy'], lose: [] }], unowned: [], nationOps: [], markers: { add: [], remove: [] }, }, undefined, centroidOf); expect(reclaimed.markers[0].ownerNationId).toBe('de'); }); it('the user prompt carries the existing markers when provided (and omits the field otherwise)', () => { const state = baseState(); const info = { id: markerId('city', 'Strasbourg', 'moselle-rygy'), type: 'city' as MarkerType, name: 'Strasbourg', regionKey: 'moselle-rygy', owner: 'uk', }; const payload = JSON.parse( buildUserPrompt(state, [], undefined, undefined, undefined, [info]), ) as { markers?: Array> }; expect(payload.markers).toHaveLength(1); expect(payload.markers![0].name).toBe('Strasbourg'); expect(payload.markers![0].owner).toBe('uk'); // a bare call (no markers arg) omits the field entirely const bare = JSON.parse(buildUserPrompt(state, [])) as { markers?: unknown }; expect(bare.markers).toBeUndefined(); }); it('the system prompt documents the markers contract (field + placement rule + a type)', () => { const prompt = buildSystemPrompt(preset); expect(prompt).toMatch(/named points on the map/i); expect(prompt).toMatch(/"markers"/); expect(prompt).toMatch(/"add"/); expect(prompt).toMatch(/"remove"/); expect(prompt).toMatch(/gathering/); expect(prompt).toMatch(/no man's land/); // owner recompute on release }); it('validateMarkerOps cleans a raw markers field: drops bad types/empty names, dedupes, trims removes', () => { const raw = { add: [ { type: 'city', name: 'X', regionKey: 'a' }, { type: 'not-a-type', name: 'Y', regionKey: 'a' }, // invalid type -> dropped { type: 'city', name: ' ', regionKey: 'a' }, // empty name -> dropped { type: 'city', name: 'X', regionKey: 'a' }, // duplicate (same id) -> deduped { type: 'port', name: 'Z', regionKey: 'b' }, // valid ], remove: ['id-1', 42, '', ' id-2 ', 'id-1'], // number/empty dropped, trimmed, deduped }; const out = validateMarkerOps(raw); expect(out.add.map((m) => m.name).sort()).toEqual(['X', 'Z']); expect(out.remove).toEqual(['id-1', 'id-2']); }); it('validateMarkerOps returns empty ops for a non-object raw value (a string, an array, null)', () => { for (const bad of ['oops', [{ type: 'city', name: 'X', regionKey: 'a' }], null, undefined]) { expect(validateMarkerOps(bad)).toEqual({ add: [], remove: [] }); } }); it('runTurn wires the centroid through: a marker added over the LLM path is placed (owner = region owner)', async () => { const result = JSON.stringify({ date: '1939-09-03', events: [], stateChanges: [], relations: [], territoryChanges: [{ nation: 'uk', gain: ['moselle-rygy'], lose: [] }], unowned: [], nationOps: [], markers: { add: [{ type: 'city', name: 'Strasbourg', regionKey: 'moselle-rygy' }], remove: [] }, }); const { state } = await runTurn({ llm: fakeLlm([result]), preset, state: baseState(), items: [], centroidOf, }); expect(state.markers).toHaveLength(1); expect(state.markers[0].name).toBe('Strasbourg'); expect(state.markers[0].ownerNationId).toBe('uk'); // uk gained the region this turn }); }); // --- Per-region stats (v0.28, #46): the derived-stats model ------------------ // // A nation's 0–100 stats are DERIVED as the area×population-weighted median of // the stats of the regions it owns (`GameState.regionStats`). These tests pin // the turn-level maintenance of that model in `applyTurnResult`: // - a transferred region keeps its OWN stats (the conquest is what makes a // nation's regions diverge); // - a released region's entry is deleted (no man's land carries none); // - a re-claimed region with no entry is re-inited to the gainer's stats; // - a 0-region nation keeps its (directly-maintained) stats; // - the end-of-turn recompute weights by area×population when `geoOf` is // provided (and degrades to a uniform median without it). // // `applyTurnResult` is used directly (not `runTurn`) so the tests are pure and // don't depend on the LLM: the result objects below are constructed directly. const NS = (military: number, economy: number, stability: number, population: number): NationStats => ({ military, economy, stability, population, }); const RS = (military: number, economy: number, stability: number, population: number): RegionStats => ({ military, economy, stability, population, }); function mkNation(id: string, stats: NationStats, territory: string[]): Nation { return { id, name: id.toUpperCase(), type: 'major', stats, territory, relations: {} }; } function mkTurnState( nations: Record, regionStats: Record, unowned: string[] = [], ): GameState { return { id: 'g', presetId: 'p', presetTitle: 'P', playerNationId: Object.keys(nations)[0] ?? '', turn: 1, date: '1939-09-01', nations, events: [], pending: [], version: 1, unowned, pendingNationOps: [], markers: [], regionStats, }; } function mkTurnResult(partial: Partial): TurnResult { return { date: '1939-09-03', events: partial.events ?? [], stateChanges: partial.stateChanges ?? [], relations: partial.relations ?? [], territoryChanges: partial.territoryChanges ?? [], unowned: partial.unowned ?? [], nationOps: partial.nationOps ?? [], markers: partial.markers ?? { add: [], remove: [] }, }; } describe('applyTurnResult — per-region stats (v0.28, #46)', () => { it('a transferred region keeps its OWN stats; the conqueror\'s aggregate blends them', () => { // uk owns g1 (military 80), de owns d1 (military 95). de conquers g1. const state = mkTurnState( { uk: mkNation('uk', NS(80, 70, 60, 50), ['g1']), de: mkNation('de', NS(95, 90, 75, 90), ['d1']), }, { g1: RS(80, 70, 60, 50), d1: RS(95, 90, 75, 90), }, ); const next = applyTurnResult( state, mkTurnResult({ territoryChanges: [{ nation: 'de', gain: ['g1'], lose: [] }] }), ); // territory moved expect(next.nations.uk.territory).toEqual([]); expect(next.nations.de.territory).toEqual(['d1', 'g1']); // the transferred region KEPT its own (uk's) stats — not re-inited to de's expect(next.regionStats.g1).toEqual(RS(80, 70, 60, 50)); expect(next.regionStats.d1).toEqual(RS(95, 90, 75, 90)); // de's derived stats are the (uniform) median of its two regions → pulled // toward the conquered region's lower values expect(next.nations.de.stats).toEqual(RS(80, 70, 60, 50)); // uk (now 0 regions) keeps its directly-maintained stats untouched expect(next.nations.uk.stats).toEqual(NS(80, 70, 60, 50)); }); it('a released region leaves no-man\'s land and its stats entry is deleted', () => { const state = mkTurnState( { uk: mkNation('uk', NS(80, 70, 60, 50), ['g1', 'g2']) }, { g1: RS(80, 70, 60, 50), g2: RS(60, 60, 60, 60) }, ); const next = applyTurnResult(state, mkTurnResult({ unowned: ['g1'] })); expect(next.unowned).toEqual(['g1']); expect(next.nations.uk.territory).toEqual(['g2']); // g1's stats left the table with the region; g2's stay expect(next.regionStats).toEqual({ g2: RS(60, 60, 60, 60) }); // uk's derived stats now come only from g2 expect(next.nations.uk.stats).toEqual(RS(60, 60, 60, 60)); }); it('a re-claimed region with no entry is re-inited to the gainer\'s current stats', () => { // g1 is unowned (released earlier; its stats entry was deleted). de owns d1. const deStats = NS(95, 90, 75, 90); const state = mkTurnState( { uk: mkNation('uk', NS(80, 70, 60, 50), []), de: mkNation('de', deStats, ['d1']), }, { d1: RS(95, 90, 75, 90) }, // g1 has NO entry (it was released) ['g1'], ); const next = applyTurnResult(state, mkTurnResult({ territoryChanges: [{ nation: 'de', gain: ['g1'], lose: [] }] })); expect(next.unowned).toEqual([]); // g1 leaves no man's land expect(next.nations.de.territory).toEqual(['d1', 'g1']); // g1 was re-inited as a copy of de's (pre-stateChanges) stats expect(next.regionStats.g1).toEqual(RS(95, 90, 75, 90)); // both of de's regions now hold identical stats → derived stats = those stats expect(next.nations.de.stats).toEqual(RS(95, 90, 75, 90)); }); it('a 0-region nation keeps its directly-maintained stats (the recompute skips it)', () => { const state = mkTurnState( { uk: mkNation('uk', NS(80, 70, 60, 50), []) }, {}, ); const next = applyTurnResult( state, mkTurnResult({ stateChanges: [{ nation: 'uk', stat: 'military', op: 'set', value: 99 }] }), ); expect(next.nations.uk.territory).toEqual([]); // no regions to aggregate → the nation-level update sticks expect(next.nations.uk.stats.military).toBe(99); expect(next.regionStats).toEqual({}); }); it('the recompute weights by area×population: the larger region dominates (geoOf)', () => { // uk owns two regions: a small one (military 50) and a large one (military // 90). With area×population weighting the large region's value wins; without // geo the uniform median picks the lower value (reached first at half-weight). const state = mkTurnState( { uk: mkNation('uk', NS(50, 50, 50, 50), ['big', 'small']) }, { big: RS(90, 90, 90, 90), small: RS(50, 50, 50, 50) }, ); const geoOf = (k: string) => k === 'big' ? { area: 1000, population: 1000 } : { area: 10, population: 100 }; const weighted = applyTurnResult(state, mkTurnResult({}), undefined, undefined, geoOf); expect(weighted.nations.uk.stats).toEqual(RS(90, 90, 90, 90)); // the large region dominates const uniform = applyTurnResult(state, mkTurnResult({})); // no geoOf expect(uniform.nations.uk.stats).toEqual(RS(50, 50, 50, 50)); // uniform → lower value first }); it('a nation-level stat change re-applies to every owned region (before the recompute)', () => { // uk owns two identical regions. A nation-level `add` applies to each region // and the derived (uniform) median tracks it; a 0-region sibling keeps its // own nation-level update. const state = mkTurnState( { uk: mkNation('uk', NS(50, 50, 50, 50), ['a', 'b']), de: mkNation('de', NS(50, 50, 50, 50), []), }, { a: RS(50, 50, 50, 50), b: RS(50, 50, 50, 50) }, ); const next = applyTurnResult( state, mkTurnResult({ stateChanges: [ { nation: 'uk', stat: 'economy', op: 'add', value: 20 }, { nation: 'de', stat: 'military', op: 'set', value: 77 }, ], }), ); // uk: both regions +20 economy → derived economy 70 expect(next.regionStats.a).toEqual(RS(50, 70, 50, 50)); expect(next.regionStats.b).toEqual(RS(50, 70, 50, 50)); expect(next.nations.uk.stats).toEqual(RS(50, 70, 50, 50)); // de: 0 regions → its nation-level set sticks expect(next.nations.de.stats.military).toBe(77); }); it('the original state is never mutated', () => { const state = mkTurnState( { uk: mkNation('uk', NS(80, 70, 60, 50), ['g1']), de: mkNation('de', NS(95, 90, 75, 90), ['d1']) }, { g1: RS(80, 70, 60, 50), d1: RS(95, 90, 75, 90) }, ); const beforeNations = JSON.stringify(state.nations); const beforeRegions = JSON.stringify(state.regionStats); applyTurnResult(state, mkTurnResult({ territoryChanges: [{ nation: 'de', gain: ['g1'], lose: [] }] })); expect(JSON.stringify(state.nations)).toBe(beforeNations); expect(JSON.stringify(state.regionStats)).toBe(beforeRegions); }); });