Move territory from whole-country (ISO_A3) to individual sub-region id granularity (PR 3 of #16): - new server/regions.ts (turf-free): loadRegions (boot), expandTerritory (game creation), migrateTerritoryToRegions (legacy games on db.load), regionNamesFor / regionLabel (LLM prompt), graceful ISO_A3 fallback - game creation expands each preset nation's ISO_A3 list to its sub-region ids; pre-v0.19 saved games migrate transparently on read (idempotent) - LLM contract: territoryChanges gain/lose sub-region ids (one region at a time; gain moves out of the prior owner; lost-without-gainer = unowned); user prompt carries a top-level regions reference table ({id: 'name (parent)'} over the state's territory ids) - WorldMap: choropleth fill per sub-region (countries can split between nations), ownership via properties.regionId through the shared mapFeatureOwner, flash lights exactly the changed features (featuresByRegionIds) - deterministic stub: one region-level move per non-empty round (player annexes the lexicographically-first region of the first other nation) - tests: new server/regions.test.ts (real geo + fallback + seams); map/engine tests re-pinned to region ids; scripts/regions.test.ts stays green Measured full wwii-1939 user prompt (411 regions): ~24.7k chars / ~6.2k tokens — far under the 80k frontier limit, so the full regions table is always sent (no frontier-limited mode needed). Part of #16
567 lines
22 KiB
TypeScript
567 lines
22 KiB
TypeScript
import { beforeAll, 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 { _setRegionsForTests } from './regions';
|
|
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 () => [],
|
|
};
|
|
}
|
|
|
|
// Give the region module a small, deterministic index so `buildUserPrompt`'s
|
|
// `regions` reference table is populated in these fast unit tests (the real
|
|
// 3.4 MB geo stays out of this file). The ids match the synthetic territories
|
|
// below.
|
|
beforeAll(() => {
|
|
_setRegionsForTests(
|
|
{
|
|
'GB-01': { name: 'Greater London', parent: 'GBR' },
|
|
'GB-02': { name: 'Scotland', parent: 'GBR' },
|
|
'US-01': { name: 'New England', parent: 'USA' },
|
|
'US-02': { name: 'The Midwest', parent: 'USA' },
|
|
'PL-01': { name: 'Pomerania', parent: 'POL' },
|
|
'DE-01': { name: 'Rhineland', parent: 'DEU' },
|
|
},
|
|
{ GBR: ['GB-01', 'GB-02'], USA: ['US-01', 'US-02'], POL: ['PL-01'], DEU: ['DE-01'] },
|
|
);
|
|
});
|
|
|
|
// As of v0.19 territory is a list of **sub-region ids** (the RegionMap keys),
|
|
// not whole-country ISO_A3 codes. `us` also holds `PL-01` so that a canned or
|
|
// stubbed gain of that region has a prior owner to move it from.
|
|
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: ['GB-01', 'GB-02'],
|
|
relations: { us: 'neutral', de: 'hostile' },
|
|
},
|
|
{
|
|
id: 'us',
|
|
name: 'US',
|
|
type: 'major',
|
|
stats: { military: 60, economy: 90, stability: 80, population: 80 },
|
|
territory: ['US-01', 'US-02', 'PL-01'],
|
|
relations: { uk: 'neutral' },
|
|
},
|
|
{
|
|
id: 'de',
|
|
name: 'DE',
|
|
type: 'major',
|
|
stats: { military: 95, economy: 70, stability: 75, population: 90 },
|
|
territory: ['DE-01'],
|
|
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: ['PL-01'], 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');
|
|
// the region moved to DE, out of its prior owner (us)
|
|
expect(state.nations.de.territory).toContain('PL-01');
|
|
expect(state.nations.us.territory).not.toContain('PL-01');
|
|
// 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.us.territory).toContain('PL-01');
|
|
expect(original.nations.de.territory).not.toContain('PL-01');
|
|
});
|
|
|
|
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), one deterministic region gain', 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 }]);
|
|
// the stub's one deterministic per-turn territory move: the player annexes
|
|
// the lexicographically-first region of the first other nation (us holds
|
|
// PL-01, US-01, US-02 → PL-01 wins), moved out of us.
|
|
expect(result.territoryChanges).toEqual([{ nation: 'uk', gain: ['PL-01'], lose: [] }]);
|
|
expect(state.nations.uk.territory).toEqual(['GB-01', 'GB-02', 'PL-01']);
|
|
expect(state.nations.us.territory).toEqual(['US-01', 'US-02']);
|
|
// 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, no territory move', 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');
|
|
// an empty round makes no move: every territory is untouched
|
|
expect(state.nations.uk.territory).toEqual(['GB-01', 'GB-02']);
|
|
expect(state.nations.us.territory).toEqual(['US-01', 'US-02', 'PL-01']);
|
|
expect(state.nations.de.territory).toEqual(['DE-01']);
|
|
});
|
|
|
|
it('a round with two actions and one diplomacy: 3 events (2 military + 1 diplomatic), exactly one turn advance, one region gain', 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
|
|
// …and one (not three) territory move for the whole batch
|
|
expect(next.nations.uk.territory).toEqual(['GB-01', 'GB-02', 'PL-01']);
|
|
expect(next.nations.us.territory).toEqual(['US-01', 'US-02']);
|
|
});
|
|
});
|
|
|
|
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;
|
|
regions: Record<string, 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('emits a regions reference table ("id → name (parent)") covering every territory id in the state', () => {
|
|
const parsed = JSON.parse(buildUserPrompt(baseState(), [])) as {
|
|
regions: Record<string, string>;
|
|
state: { nations: Record<string, { territory: string[] }> };
|
|
};
|
|
// the table is keyed by the union of all nations' (sub-region) territory ids
|
|
const allIds = Object.values(parsed.state.nations).flatMap((n) => n.territory);
|
|
expect(Object.keys(parsed.regions).sort()).toEqual([...allIds].sort());
|
|
// each id maps to a human "name (parent)" label from the region index
|
|
expect(parsed.regions['GB-01']).toBe('Greater London (GBR)');
|
|
expect(parsed.regions['DE-01']).toBe('Rhineland (DEU)');
|
|
expect(parsed.regions['PL-01']).toBe('Pomerania (POL)');
|
|
});
|
|
|
|
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);
|
|
});
|
|
});
|
|
|
|
describe('queueRoundItem', () => {
|
|
it('appends an item to pending without advancing the turn or the date', () => {
|
|
const before = baseState();
|
|
const after = queueRoundItem(before, { kind: 'diplomacy', to: 'us', text: 'hello' });
|
|
expect(after.turn).toBe(before.turn);
|
|
expect(after.date).toBe(before.date);
|
|
expect(after.pending).toHaveLength(1);
|
|
expect(after.pending[0]).toEqual({ kind: 'diplomacy', to: 'us', text: 'hello' });
|
|
// the original state is not mutated
|
|
expect(before.pending).toHaveLength(0);
|
|
expect(before.version).toBe(1);
|
|
});
|
|
|
|
it('bumps the monotonic version on each queue mutation', () => {
|
|
const a = queueRoundItem(baseState(), { kind: 'action', text: 'one' });
|
|
expect(a.version).toBe(2);
|
|
const b = queueRoundItem(a, { kind: 'action', text: 'two' });
|
|
expect(b.version).toBe(3);
|
|
// and never backwards: two consecutive adds from the same base both
|
|
// advance past it, so a late snapshot of the older state is always older.
|
|
const b2 = queueRoundItem(baseState(), { kind: 'action', text: 'two' });
|
|
expect(b2.version).toBe(2);
|
|
expect(b.version).toBeGreaterThan(b2.version);
|
|
});
|
|
|
|
it('treats a legacy state without a version field as version 0', () => {
|
|
const legacy = { ...baseState() } as Omit<GameState, 'version'> & { version?: number };
|
|
delete legacy.version; // a row saved before the version field existed
|
|
// simulate a legacy row coming back from SQLite: the JSON has no field.
|
|
const after = queueRoundItem(legacy as unknown as GameState, {
|
|
kind: 'action',
|
|
text: 'first after upgrade',
|
|
});
|
|
expect(after.version).toBe(1);
|
|
});
|
|
});
|
|
|
|
describe('removeRoundItem', () => {
|
|
it('removes the item at the index, bumps the version, and leaves the input untouched', () => {
|
|
const state = baseState();
|
|
state.pending = [
|
|
{ kind: 'action', text: 'one' },
|
|
{ kind: 'diplomacy', to: 'us', text: 'two' },
|
|
{ kind: 'action', text: 'three' },
|
|
];
|
|
const next = removeRoundItem(state, 1);
|
|
expect(next).not.toBeNull();
|
|
expect(next!.version).toBe(2);
|
|
expect(next!.pending).toEqual([
|
|
{ kind: 'action', text: 'one' },
|
|
{ kind: 'action', text: 'three' },
|
|
]);
|
|
expect(state.pending).toHaveLength(3); // original untouched
|
|
expect(state.version).toBe(1);
|
|
});
|
|
|
|
it('returns null for out-of-bounds or non-integer indices (route answers 400, nothing mutates)', () => {
|
|
const state = baseState();
|
|
state.pending = [{ kind: 'action', text: 'one' }];
|
|
expect(removeRoundItem(state, -1)).toBeNull();
|
|
expect(removeRoundItem(state, 1)).toBeNull(); // == length
|
|
expect(removeRoundItem(state, 5)).toBeNull();
|
|
expect(removeRoundItem(state, 0.5)).toBeNull();
|
|
expect(removeRoundItem(state, Number.NaN)).toBeNull();
|
|
expect(state.pending).toHaveLength(1); // still untouched
|
|
});
|
|
});
|
|
|
|
describe('parseTurnResult', () => {
|
|
it('parses a fenced JSON block', () => {
|
|
const r = parseTurnResult('```json\n{"date":"1939-09-03","events":[{"nation":"uk","type":"military","title":"t","text":"b","outcome":"success"}]}\n```');
|
|
expect(r?.events[0].title).toBe('t');
|
|
});
|
|
|
|
it('parses JSON prefixed by leading whitespace/newlines (reasoning model emits "\\n\\n{...}")', () => {
|
|
const r = parseTurnResult('\n\n{"date":"1939-09-03","events":[{"nation":"uk","type":"military","title":"t","text":"b","outcome":"success"}]}\n');
|
|
expect(r?.events[0].title).toBe('t');
|
|
expect(r?.date).toBe('1939-09-03');
|
|
});
|
|
|
|
it('extracts the object from surrounding prose', () => {
|
|
const r = parseTurnResult('Sure! Here is the result: {"date":"1939-09-03","events":[]} hope that helps');
|
|
expect(r).not.toBeNull();
|
|
});
|
|
|
|
it('returns null for non-JSON output', () => {
|
|
expect(parseTurnResult('no json here at all')).toBeNull();
|
|
});
|
|
|
|
it('validateTurnResult ignores unknown/invalid entries and clamps', () => {
|
|
const r = validateTurnResult({
|
|
date: '1939-09-03',
|
|
events: [
|
|
{ nation: 'uk', type: 'bogus', title: 't', text: 'b', outcome: 'weird' },
|
|
{ nation: 'uk', title: 'missing text' },
|
|
],
|
|
stateChanges: [
|
|
{ nation: 'uk', stat: 'notastat', op: 'set', value: 5 },
|
|
{ nation: 'uk', stat: 'military', op: 'set', value: 150 },
|
|
],
|
|
});
|
|
expect(r).not.toBeNull();
|
|
expect(r!.events).toHaveLength(1); // the invalid one was dropped
|
|
expect(r!.events[0].type).toBe('political'); // bogus type -> default
|
|
expect(r!.events[0].outcome).toBe('neutral'); // bogus outcome -> default
|
|
expect(r!.stateChanges).toHaveLength(1);
|
|
expect(r!.stateChanges[0].value).toBe(100); // clamped
|
|
});
|
|
});
|
|
|
|
describe('applyTurnResult', () => {
|
|
it('does not mutate its input', () => {
|
|
const state = baseState();
|
|
const snapshot = JSON.stringify(state);
|
|
const result: TurnResult = {
|
|
date: '1939-09-03',
|
|
events: [{ nation: 'uk', type: 'military', title: 't', text: 'b', outcome: 'success' }],
|
|
stateChanges: [{ nation: 'uk', stat: 'military', op: 'set', value: 99 }],
|
|
relations: [],
|
|
territoryChanges: [],
|
|
};
|
|
applyTurnResult(state, result);
|
|
expect(JSON.stringify(state)).toBe(snapshot);
|
|
});
|
|
|
|
it('clears the pending round on commit and bumps the version', () => {
|
|
const state = baseState();
|
|
state.pending = [{ kind: 'action', text: 'x' }];
|
|
const result: TurnResult = {
|
|
date: '1939-09-03',
|
|
events: [],
|
|
stateChanges: [],
|
|
relations: [],
|
|
territoryChanges: [],
|
|
};
|
|
const next = applyTurnResult(state, result);
|
|
expect(next.pending).toEqual([]);
|
|
expect(next.version).toBe(2);
|
|
expect(state.pending).toHaveLength(1); // original untouched
|
|
expect(state.version).toBe(1);
|
|
});
|
|
|
|
it('moves a gained region out of its prior owner (region-level transfer)', () => {
|
|
const state = baseState();
|
|
const result: TurnResult = {
|
|
date: '1939-09-03',
|
|
events: [],
|
|
stateChanges: [],
|
|
relations: [],
|
|
territoryChanges: [{ nation: 'de', gain: ['PL-01'], lose: [] }],
|
|
};
|
|
const next = applyTurnResult(state, result);
|
|
expect(next.nations.de.territory).toContain('PL-01');
|
|
expect(next.nations.us.territory).not.toContain('PL-01'); // spliced out of the prior owner
|
|
expect(next.nations.uk.territory).toEqual(['GB-01', 'GB-02']);
|
|
});
|
|
|
|
it('adds a region nobody owned (no prior owner to splice from)', () => {
|
|
const state = baseState();
|
|
const result: TurnResult = {
|
|
date: '1939-09-03',
|
|
events: [],
|
|
stateChanges: [],
|
|
relations: [],
|
|
territoryChanges: [{ nation: 'uk', gain: ['FR-57'], lose: [] }],
|
|
};
|
|
const next = applyTurnResult(state, result);
|
|
expect(next.nations.uk.territory).toEqual(['GB-01', 'GB-02', 'FR-57']);
|
|
});
|
|
|
|
it('drops a lost region with no gainer (it becomes unowned / no-man\'s-land)', () => {
|
|
const state = baseState();
|
|
const result: TurnResult = {
|
|
date: '1939-09-03',
|
|
events: [],
|
|
stateChanges: [],
|
|
relations: [],
|
|
territoryChanges: [{ nation: 'us', gain: [], lose: ['PL-01'] }],
|
|
};
|
|
const next = applyTurnResult(state, result);
|
|
expect(next.nations.us.territory).toEqual(['US-01', 'US-02']);
|
|
expect(Object.values(next.nations).every((n) => !n.territory.includes('PL-01'))).toBe(true);
|
|
});
|
|
});
|
|
|
|
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);
|
|
});
|
|
});
|