feat: sub-region-level territory — ownership, LLM and map at region granularity (v0.19.0)

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
This commit is contained in:
2026-10-13 22:07:18 +00:00
parent 9c29779572
commit fdf6eb236a
14 changed files with 698 additions and 162 deletions
+105 -11
View File
@@ -1,8 +1,9 @@
import { describe, expect, it } from 'vitest';
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,
@@ -24,6 +25,27 @@ function fakeLlm(responses: string[]): LlmClient {
};
}
// 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',
@@ -37,7 +59,7 @@ const preset: Preset = {
name: 'UK',
type: 'major',
stats: { military: 80, economy: 70, stability: 60, population: 50 },
territory: ['GBR'],
territory: ['GB-01', 'GB-02'],
relations: { us: 'neutral', de: 'hostile' },
},
{
@@ -45,7 +67,7 @@ const preset: Preset = {
name: 'US',
type: 'major',
stats: { military: 60, economy: 90, stability: 80, population: 80 },
territory: ['USA'],
territory: ['US-01', 'US-02', 'PL-01'],
relations: { uk: 'neutral' },
},
{
@@ -53,7 +75,7 @@ const preset: Preset = {
name: 'DE',
type: 'major',
stats: { military: 95, economy: 70, stability: 75, population: 90 },
territory: ['DEU'],
territory: ['DE-01'],
relations: {},
},
],
@@ -88,7 +110,7 @@ const validResult = JSON.stringify({
{ nation: 'de', stat: 'economy', op: 'add', value: 10 },
],
relations: [{ a: 'uk', b: 'us', set: 'ally' }],
territoryChanges: [{ nation: 'de', gain: ['POL'], lose: [] }],
territoryChanges: [{ nation: 'de', gain: ['PL-01'], lose: [] }],
});
describe('runTurn', () => {
@@ -109,8 +131,9 @@ describe('runTurn', () => {
// relations are symmetric
expect(state.nations.uk.relations.us).toBe('ally');
expect(state.nations.us.relations.uk).toBe('ally');
// territory moved to DE
expect(state.nations.de.territory).toContain('POL');
// 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);
@@ -121,7 +144,8 @@ describe('runTurn', () => {
// and the original state object is NOT mutated
expect(original.turn).toBe(1);
expect(original.nations.uk.stats.military).toBe(80);
expect(original.nations.de.territory).not.toContain('POL');
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 () => {
@@ -191,7 +215,7 @@ describe('runTurn', () => {
});
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 () => {
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({
@@ -209,6 +233,12 @@ describe('runTurn (batched round, deterministic stub)', () => {
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)
@@ -216,7 +246,7 @@ describe('runTurn (batched round, deterministic stub)', () => {
expect(original.version).toBe(1);
});
it('an empty round: turn advances, one political "world moves on" event, pending stays empty', async () => {
it('an empty round: turn advances, one political "world moves on" event, no territory move', async () => {
const { state } = await runTurn({
llm: makeStubLlmClient(),
preset,
@@ -230,9 +260,13 @@ describe('runTurn (batched round, deterministic stub)', () => {
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', async () => {
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' },
@@ -251,6 +285,9 @@ describe('runTurn (batched round, deterministic stub)', () => {
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']);
});
});
@@ -264,6 +301,7 @@ describe('buildUserPrompt (batched round)', () => {
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' }]);
@@ -271,6 +309,20 @@ describe('buildUserPrompt (batched round)', () => {
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[];
@@ -461,6 +513,48 @@ describe('applyTurnResult', () => {
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', () => {