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
+6 -1
View File
@@ -3,6 +3,7 @@ import { mkdirSync } from 'node:fs';
import path from 'node:path';
import Database from 'better-sqlite3';
import type { GameState } from '../shared/types';
import { migrateTerritoryToRegions } from './regions';
export interface Db {
save(state: GameState): void;
@@ -39,7 +40,11 @@ export function openDb(filePath: string): Db {
const row = byId.get(id) as { data: string } | undefined;
if (!row) return null;
try {
return JSON.parse(row.data) as GameState;
// v0.19: migrate any pre-v0.19 (whole-country ISO_A3) territory to
// sub-region ids on read. A no-op when the region geo is not loaded,
// and idempotent once a state has been migrated (the next save then
// persists the region-level territory).
return migrateTerritoryToRegions(JSON.parse(row.data) as GameState);
} catch {
return null;
}
+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', () => {
+13 -2
View File
@@ -24,6 +24,7 @@ import {
} from '../shared/protocol';
import { DEFAULT_MAX_TOKENS, type LlmClient } from './llm';
import { ADVISOR_MARKER } from './stub';
import { regionNamesFor } from './regions';
const VALID_EVENT_TYPES = new Set<string>(EVENT_TYPES);
const VALID_RELATIONS = new Set<string>(RELATION_STATUSES);
@@ -53,11 +54,12 @@ export function buildSystemPrompt(preset: Preset): string {
' ],',
' "stateChanges": [{"nation":"<nationId>","stat":"<military|economy|stability|population>","op":"<set|add>","value":<0-100>}],',
' "relations": [{"a":"<nationId>","b":"<nationId>","set":"<ally|neutral|rival|hostile|war>"}],',
' "territoryChanges": [{"nation":"<nationId>","gain":["<ISO_A3>"],"lose":["<ISO_A3>"]}]',
' "territoryChanges": [{"nation":"<nationId>","gain":["<regionId>"],"lose":["<regionId>"]}]',
'}',
'',
'HARD CONSTRAINTS',
'- Use only nation ids and ISO_A3 codes that exist in the provided state.',
'- Use only nation ids and sub-region ids (e.g. `FR-57`) that exist in the provided state.',
'- A region id (e.g. `FR-57`) identifies a single sub-region — the `regions` table below lists each id with its name and country; a nation\'s border can change one region at a time.',
'- Prefer 1-4 events per turn; keep each text 1-3 sentences.',
'- Keep your internal reasoning brief (a few sentences) and go straight to the JSON result; do not over-deliberate or re-derive facts already present in the state.',
'- Keep every stat value between 0 and 100.',
@@ -106,6 +108,15 @@ export function buildUserPrompt(state: GameState, items: RoundItem[]): string {
outcome: e.outcome,
})),
},
// A name lookup for every region id referenced anywhere in the state, so the
// model can map an id it is considering (e.g. `FR-57`) to a place it
// recognizes. This is a top-level reference table keyed by sub-region id;
// each nation's own `territory` still carries raw ids (via nationForPrompt,
// unchanged) so the deterministic stub can parse them. When the region geo
// is not loaded the table is empty and the model falls back to the raw ids.
regions: regionNamesFor(
Object.values(state.nations).flatMap((n) => n.territory),
),
playerActions,
pending: items,
roundInstruction,
+15 -1
View File
@@ -19,6 +19,7 @@ import type { GameState } from '../shared/types';
import { createLlmClient } from './llm';
import { makeStubLlmClient } from './stub';
import { askAdvisor, queueRoundItem, removeRoundItem, runTurn } from './engine';
import { expandTerritory, loadRegions } from './regions';
import { buildRegionMap } from './map';
import { createPresetStore } from './presets';
import { openDb, type Db } from './db';
@@ -41,6 +42,10 @@ function findRoot(): string {
}
const root = findRoot();
// Load the sub-region geo once at boot (a graceful no-op, with a warning, when
// the file is missing — the territory model then degrades to whole-country
// ISO_A3 codes). Must run before the first game creation/load.
loadRegions(root);
const presetsDir = path.join(root, 'presets');
const clientDir = process.env.CLIENT_DIST ?? path.join(root, 'dist');
const dataDir = process.env.DATA_DIR ?? path.join(root, 'data');
@@ -116,7 +121,16 @@ app.post('/api/games', (req: Request, res) => {
playerNationId: nation.id,
turn: 1,
date: preset.startDate,
nations: Object.fromEntries(preset.nations.map((n) => [n.id, deepClone(n)])),
// v0.19: territory is at sub-region granularity — expand each preset
// nation's ISO_A3 country codes into all their sub-region ids (a no-op that
// keeps the country codes when the region geo is not loaded).
nations: Object.fromEntries(
preset.nations.map((n) => {
const nation = deepClone(n);
nation.territory = expandTerritory(nation.territory);
return [n.id, nation];
}),
),
events: [],
pending: [],
version: 1, // the first saved revision; every later mutation bumps it
+45 -42
View File
@@ -3,6 +3,8 @@ import { buildRegionMap } from './map';
import { mapFeatureOwner, nationColor, regionOwner, type RegionMap } from '../shared/protocol';
import type { GameState } from '../shared/types';
// As of v0.19 territory is a list of **sub-region ids** (the RegionMap keys),
// not whole-country ISO_A3 codes.
const state: GameState = {
id: 'g',
presetId: 'p',
@@ -16,7 +18,7 @@ const state: GameState = {
name: 'UK',
type: 'major',
stats: { military: 80, economy: 70, stability: 60, population: 50 },
territory: ['GBR', 'IRL'],
territory: ['GB-01', 'GB-02'],
relations: {},
},
de: {
@@ -24,7 +26,7 @@ const state: GameState = {
name: 'DE',
type: 'major',
stats: { military: 90, economy: 70, stability: 70, population: 90 },
territory: ['DEU'],
territory: ['DE-01'],
relations: {},
},
},
@@ -33,28 +35,29 @@ const state: GameState = {
version: 1,
};
// The RegionMap built from the fixture: the authoritative ISO_A3 → owner source.
// The RegionMap built from the fixture: the authoritative sub-region id → owner
// source.
const regions: RegionMap = buildRegionMap(state);
describe('buildRegionMap', () => {
it('maps each ISO_A3 region to its owner + that nation\'s stable color', () => {
it('maps each sub-region id to its owner + that nation\'s stable color', () => {
const m = buildRegionMap(state);
expect(m.GBR).toEqual({ color: nationColor('uk'), owner: 'UK', ownerId: 'uk' });
expect(m.IRL).toEqual({ color: nationColor('uk'), owner: 'UK', ownerId: 'uk' });
expect(m.DEU).toEqual({ color: nationColor('de'), owner: 'DE', ownerId: 'de' });
// a region nobody owns is simply absent
expect(m.FRA).toBeUndefined();
expect(m['GB-01']).toEqual({ color: nationColor('uk'), owner: 'UK', ownerId: 'uk' });
expect(m['GB-02']).toEqual({ color: nationColor('uk'), owner: 'UK', ownerId: 'uk' });
expect(m['DE-01']).toEqual({ color: nationColor('de'), owner: 'DE', ownerId: 'de' });
// a sub-region nobody owns is simply absent
expect(m['FR-57']).toBeUndefined();
});
});
describe('regionOwner', () => {
it('resolves an owned region\'s ISO_A3 to its owner record', () => {
expect(regionOwner(regions, 'GBR')).toEqual({
it('resolves an owned sub-region id to its owner record', () => {
expect(regionOwner(regions, 'GB-01')).toEqual({
color: nationColor('uk'),
owner: 'UK',
ownerId: 'uk',
});
expect(regionOwner(regions, 'DEU')).toEqual({
expect(regionOwner(regions, 'DE-01')).toEqual({
color: nationColor('de'),
owner: 'DE',
ownerId: 'de',
@@ -62,11 +65,11 @@ describe('regionOwner', () => {
});
it('returns undefined for an unowned region (no RegionMap entry)', () => {
expect(regionOwner(regions, 'FRA')).toBeUndefined();
expect(regionOwner(regions, 'USA')).toBeUndefined();
expect(regionOwner(regions, 'FR-57')).toBeUndefined();
expect(regionOwner(regions, 'US-01')).toBeUndefined();
});
it('returns undefined for an empty or missing code', () => {
it('returns undefined for an empty or missing id', () => {
expect(regionOwner(regions, '')).toBeUndefined();
expect(regionOwner(regions, null)).toBeUndefined();
expect(regionOwner(regions, undefined)).toBeUndefined();
@@ -74,28 +77,28 @@ describe('regionOwner', () => {
});
/**
* #6 regression guard (sub-national map). The click/hover handlers resolve the
* owning nation from a rendered map feature. MapLibre repacks a GeoJSON
* source's top-level `id` into a *numeric index* when it stores the source, so a
* feature the map hands back at interaction time has a number for `id`. The
* choropleth is now sub-national: a feature's id is a sub-region code
* (`DEU-03`) and its owner lives at the *parent country* (`DEU`), which the
* client stamps into `properties.parent` (the RegionMap is keyed by that
* country code, never the sub-region id). This pins the exact data shape and
* proves the resolution: `mapFeatureOwner` works via `properties.parent`, while
* the two wrong paths (the repacked numeric `feature.id`, and the sub-region
* `regionId`, which is not a RegionMap key) do not.
* #6 regression guard (sub-national map, v0.19 region-level ownership). The
* click/hover handlers resolve the owning nation from a rendered map feature.
* MapLibre repacks a GeoJSON source's top-level `id` into a *numeric index*
* when it stores the source, so a feature the map hands back at interaction
* time has a number for `id`. Since v0.19 ownership is at **sub-region**
* granularity: a feature's owner lives at its **own sub-region id** (e.g.
* `DE-01`), which the client stamps into `properties.regionId` (the RegionMap
* is keyed by that id). This pins the exact data shape and proves the
* resolution: `mapFeatureOwner` works via `properties.regionId`, while the
* repacked numeric `feature.id` path does not.
*/
describe('#6 — sub-region owner must be resolved from properties.parent, not the sub-region id / repacked feature.id', () => {
describe('#6 — region owner must be resolved from properties.regionId, never the repacked feature.id', () => {
// The shape MapLibre returns for a sub-region feature after setData(): `id`
// is a number (repacked), and the stable identifiers live only in properties —
// `regionId` (the sub-region code) and `parent` (the ISO_A3 country).
// is a number (repacked), and the stable identifiers live only in properties
// — `regionId` (the sub-region id, the RegionMap key) and `parent` (the
// static parent country's ISO_A3).
const repackedFeature = {
id: 0,
properties: { name: 'Germany R03', regionId: 'DEU-03', parent: 'DEU' },
properties: { name: 'Rhineland', regionId: 'DE-01', parent: 'DEU' },
};
it('resolves the owner from properties.parent (the fix)', () => {
it('resolves the owner from properties.regionId (the fix)', () => {
expect(mapFeatureOwner(regions, repackedFeature)).toEqual({
color: nationColor('de'),
owner: 'DE',
@@ -103,30 +106,30 @@ describe('#6 — sub-region owner must be resolved from properties.parent, not t
});
});
it('the sub-region regionId is NOT a RegionMap key — resolving from it fails', () => {
// 'DEU-03' is a sub-region id; the RegionMap is keyed by the parent 'DEU'.
expect(regionOwner(regions, repackedFeature.properties.regionId)).toBeUndefined();
});
it('the repacked numeric feature.id does NOT resolve — the bug', () => {
it('the repacked numeric feature.id does NOT resolve — the original bug', () => {
// String(0) === '0' is not a valid key, so the pre-fix lookup failed and
// reported "No owner" even for owned regions.
expect(regionOwner(regions, String(repackedFeature.id))).toBeUndefined();
});
it('a sub-region id nobody owns does NOT resolve', () => {
expect(regionOwner(regions, 'FR-57')).toBeUndefined();
});
});
describe('mapFeatureOwner', () => {
it('returns undefined for a missing feature, missing parent, or non-string parent', () => {
it('returns undefined for a missing feature, missing regionId, or non-string regionId', () => {
expect(mapFeatureOwner(regions, null)).toBeUndefined();
expect(mapFeatureOwner(regions, undefined)).toBeUndefined();
expect(mapFeatureOwner(regions, {})).toBeUndefined();
expect(mapFeatureOwner(regions, { properties: {} })).toBeUndefined();
expect(mapFeatureOwner(regions, { properties: { parent: 42 } })).toBeUndefined();
expect(mapFeatureOwner(regions, { properties: { regionId: 42 } })).toBeUndefined();
expect(mapFeatureOwner(regions, { properties: { regionId: '' } })).toBeUndefined();
});
it('returns undefined for a parent country nobody owns', () => {
expect(mapFeatureOwner(regions, { properties: { parent: 'FRA' } })).toBeUndefined();
expect(mapFeatureOwner(regions, { properties: { parent: 'USA' } })).toBeUndefined();
it('returns undefined for a sub-region nobody owns', () => {
expect(mapFeatureOwner(regions, { properties: { regionId: 'FR-57' } })).toBeUndefined();
expect(mapFeatureOwner(regions, { properties: { regionId: 'US-01' } })).toBeUndefined();
});
});
+9 -5
View File
@@ -1,6 +1,10 @@
// Build the choropleth payload from a GameState: every ISO_A3 region maps to its
// owning nation plus that nation's stable color (see shared.nationColor). The
// client merges this onto public/countries.geo.json and calls source.setData().
// Build the choropleth payload from a GameState: every **sub-region id** in a
// nation's territory maps to its owning nation plus that nation's stable color
// (see shared.nationColor). The client merges this onto public/regions.geo.json
// and calls source.setData(). The map is keyed by whatever ids sit in
// `nation.territory` — sub-region ids (e.g. `FR-57`) as of v0.19 — so it needs
// no change to move from country to sub-region granularity; a sub-region in no
// nation's territory is simply absent (unowned, rendered grey by the map).
import { nationColor, type RegionMap } from '../shared/protocol';
import type { GameState } from '../shared/types';
@@ -8,8 +12,8 @@ export function buildRegionMap(state: GameState): RegionMap {
const map: RegionMap = {};
for (const [id, nation] of Object.entries(state.nations)) {
const color = nationColor(id);
for (const code of nation.territory) {
map[code] = { color, owner: nation.name, ownerId: id };
for (const regionId of nation.territory) {
map[regionId] = { color, owner: nation.name, ownerId: id };
}
}
return map;
+176
View File
@@ -0,0 +1,176 @@
/**
* server/regions.test.ts — the sub-region data module (v0.19).
*
* The first suite loads the REAL `public/regions.geo.json` (the same file the
* production server loads at boot), so expansion/migration runs against the
* actual 1,796-region dataset. The second suite exercises the graceful
* "geo missing" path (module not loaded) via the `_resetRegionsForTests` seam,
* and pins the `_setRegionsForTests` fixture seam used by the other unit tests.
* Module state is per vitest file, so these suites never collide with other
* test files.
*/
import path from 'node:path';
import { fileURLToPath } from 'node:url';
import { afterAll, beforeAll, describe, expect, it } from 'vitest';
import type { GameState } from '../shared/types';
import {
_resetRegionsForTests,
_setRegionsForTests,
expandTerritory,
hasRegionData,
loadRegions,
migrateTerritoryToRegions,
regionLabel,
regionNamesFor,
} from './regions';
const here = path.dirname(fileURLToPath(import.meta.url));
const root = path.resolve(here, '..');
function makeState(territory: string[]): GameState {
return {
id: 'g',
presetId: 'p',
presetTitle: 'P',
playerNationId: 'uk',
turn: 1,
date: '1939-09-01',
nations: {
uk: {
id: 'uk',
name: 'UK',
type: 'major',
stats: { military: 80, economy: 70, stability: 60, population: 50 },
territory,
relations: {},
},
},
events: [],
pending: [],
version: 1,
};
}
describe('server/regions (real geo)', () => {
beforeAll(() => {
_resetRegionsForTests();
loadRegions(root);
});
it('loads public/regions.geo.json (hasRegionData becomes true)', () => {
expect(hasRegionData()).toBe(true);
});
it('loadRegions is idempotent: a second call keeps the loaded data', () => {
loadRegions(root);
expect(hasRegionData()).toBe(true);
});
it('expandTerritory expands an ISO_A3 into its sub-region ids (unique, all with that parent)', () => {
const ids = expandTerritory(['FRA']);
expect(ids.length).toBeGreaterThan(1); // France is subdivided, not one region
expect(new Set(ids).size).toBe(ids.length); // deduped
// every expanded id resolves back to the same parent country
const names = regionNamesFor(ids);
for (const id of ids) expect(names[id]).toMatch(/\(FRA\)$/);
});
it('expandTerritory is order-preserving and dedupes repeated country codes', () => {
expect(expandTerritory(['FRA', 'FRA'])).toEqual(expandTerritory(['FRA']));
// two countries: the union, in first-seen order (the ids are disjoint)
expect(expandTerritory(['FRA', 'DEU'])).toEqual([
...expandTerritory(['FRA']),
...expandTerritory(['DEU']),
]);
});
it('expandTerritory of an empty or unknown list yields no regions', () => {
expect(expandTerritory([])).toEqual([]);
expect(expandTerritory(['XXX'])).toEqual([]); // no country with that code
});
it('regionNamesFor maps every known id to "name (parent)"', () => {
const ids = expandTerritory(['DEU']);
const names = regionNamesFor(ids);
expect(Object.keys(names).sort()).toEqual([...ids].sort());
for (const id of ids) expect(names[id]).toMatch(/\(DEU\)$/);
});
it('regionNamesFor skips ids that are not in the loaded index', () => {
expect(regionNamesFor(['NO-SUCH-01'])).toEqual({});
});
it('regionLabel is "id — name (parent)" for known ids and the bare id for unknown', () => {
const [id] = expandTerritory(['FRA']);
expect(regionLabel(id)).toMatch(new RegExp(`^${id} — .+ \\(FRA\\)$`));
expect(regionLabel('NO-SUCH-01')).toBe('NO-SUCH-01');
});
it('migrateTerritoryToRegions rewrites ISO_A3 entries to region ids, keeps existing region ids, and is idempotent', () => {
const [deId] = expandTerritory(['DEU']); // a real region id of another country
const state = makeState(['FRA', deId]); // mixed: legacy ISO_A3 + already migrated
const originalArray = state.nations.uk.territory;
// it mutates and returns the (fresh) state it is given — callers pass a
// just-parsed object (db.load), so the in-place mutation is safe
const out = migrateTerritoryToRegions(state);
expect(out).toBe(state);
// the original array object is never mutated in place — a new one is
// installed on the nation, and the original stays as it was
expect(out.nations.uk.territory).not.toBe(originalArray);
expect(originalArray).toEqual(['FRA', deId]);
// the ISO_A3 entry is gone, replaced by its sub-region ids…
expect(out.nations.uk.territory).not.toContain('FRA');
for (const id of expandTerritory(['FRA'])) expect(out.nations.uk.territory).toContain(id);
// …and the existing region id survives, exactly once
expect(out.nations.uk.territory.filter((t) => t === deId)).toEqual([deId]);
// idempotent: a second migration changes nothing
expect(migrateTerritoryToRegions(out).nations.uk.territory).toEqual(out.nations.uk.territory);
});
});
describe('server/regions (fallback: geo not loaded)', () => {
beforeAll(() => {
_resetRegionsForTests();
});
afterAll(() => {
// restore the real geo for anything in this file that runs later
_resetRegionsForTests();
loadRegions(root);
});
it('hasRegionData is false after a reset', () => {
expect(hasRegionData()).toBe(false);
});
it('expandTerritory returns its input unchanged (whole-country fallback)', () => {
expect(expandTerritory(['FRA'])).toEqual(['FRA']);
expect(expandTerritory([])).toEqual([]);
});
it('regionNamesFor is empty and regionLabel falls back to the bare id', () => {
expect(regionNamesFor(['FRA'])).toEqual({});
expect(regionLabel('FRA')).toBe('FRA');
});
it('migrateTerritoryToRegions is a no-op (returns the state untouched)', () => {
const state = makeState(['FRA']);
const out = migrateTerritoryToRegions(state);
expect(out).toBe(state);
expect(out.nations.uk.territory).toEqual(['FRA']);
});
it('_setRegionsForTests loads a fixture and drives the pure helpers', () => {
_setRegionsForTests(
{
'GB-01': { name: 'Greater London', parent: 'GBR' },
'GB-02': { name: 'Scotland', parent: 'GBR' },
},
{ GBR: ['GB-01', 'GB-02'] },
);
expect(hasRegionData()).toBe(true);
expect(expandTerritory(['GBR'])).toEqual(['GB-01', 'GB-02']);
expect(regionNamesFor(['GB-01'])).toEqual({ 'GB-01': 'Greater London (GBR)' });
const state = makeState(['GBR']);
expect(migrateTerritoryToRegions(state).nations.uk.territory).toEqual(['GB-01', 'GB-02']);
});
});
+187
View File
@@ -0,0 +1,187 @@
// Sub-national region data: loaded ONCE at server boot from
// `public/regions.geo.json` (dev) or `dist/regions.geo.json` (prod — Vite copies
// `public/` → `dist/`). It builds two in-memory maps that power the
// sub-region-level territory model (v0.19):
//
// - `regionIndex` : region id → { name, parent } (parent = ISO_A3 country)
// - `regionsByCountry`: ISO_A3 country → array of its region ids
//
// These are used to (a) expand a nation's ISO_A3 territory into its sub-region
// ids at game creation / load-time migration, and (b) hand the LLM a human
// name for each region id it may reference in a TurnResult.
//
// This is deliberately turf-free (plain `JSON.parse`): importing `@turf/*`
// would pull `@types/geojson` into the tsc program and break `WorldMap.tsx`
// (see AGENTS.md / the v0.17 changelog note on the geojson type gotcha).
//
// The server must still boot and the app still work when the geo file is
// missing or unreadable: the maps stay empty, `hasRegionData()` stays false,
// and the territory model gracefully degrades to whole-country ISO_A3 codes
// (the pre-v0.19 behavior). Nothing here ever throws.
import { existsSync, readFileSync } from 'node:fs';
import path from 'node:path';
import type { GameState } from '../shared/types';
/** A region's stable identity: display name + the parent country's ISO_A3. */
export interface RegionInfo {
name: string;
parent: string;
}
let regionIndex: Record<string, RegionInfo> = {};
let regionsByCountry: Record<string, string[]> = {};
let loaded = false;
/** True once the region geo has been loaded successfully. */
export function hasRegionData(): boolean {
return loaded;
}
/**
* (Re)build the in-memory region maps from the region geo. Idempotent: a second
* call is a no-op. Tries `<root>/public/regions.geo.json` first (dev), then
* `<root>/dist/regions.geo.json` (prod, where Vite copies `public/` → `dist/`).
* If no candidate file is found, or it fails to parse, the maps stay empty and
* `hasRegionData()` stays false (graceful fallback to ISO_A3) — never throws.
*/
export function loadRegions(root: string): void {
if (loaded) return;
const candidates = [
path.join(root, 'public', 'regions.geo.json'),
path.join(root, 'dist', 'regions.geo.json'),
];
const file = candidates.find((c) => existsSync(c));
if (!file) {
console.warn(
'[history] regions.geo.json not found (tried: ' + candidates.join(', ') +
') — falling back to whole-country (ISO_A3) territory',
);
return;
}
try {
const geo = JSON.parse(readFileSync(file, 'utf8')) as {
features?: Array<{
id?: string | number;
properties?: { name?: string; parent?: string };
}>;
};
const index: Record<string, RegionInfo> = {};
const byCountry: Record<string, string[]> = {};
for (const f of geo.features ?? []) {
const id = f.id == null ? '' : String(f.id);
const parent = typeof f.properties?.parent === 'string' ? f.properties.parent : '';
const name = typeof f.properties?.name === 'string' ? f.properties.name : '';
if (!id || !parent) continue;
index[id] = { name, parent };
(byCountry[parent] ??= []).push(id);
}
regionIndex = index;
regionsByCountry = byCountry;
loaded = Object.keys(index).length > 0;
} catch (e) {
console.warn('[history] failed to load regions.geo.json (' + file + '):', e);
}
}
/**
* Expand a list of ISO_A3 country codes into the union of all their sub-region
* ids (deduped, preserving first-seen order). When the region data is not
* loaded, returns the input unchanged (graceful fallback to ISO_A3 territory).
*/
export function expandTerritory(countryCodes: string[]): string[] {
if (!loaded) return countryCodes;
const seen = new Set<string>();
const out: string[] = [];
for (const code of countryCodes) {
for (const id of regionsByCountry[code] ?? []) {
if (!seen.has(id)) {
seen.add(id);
out.push(id);
}
}
}
return out;
}
/**
* A `{ [regionId]: "name (parent)" }` object for the given region ids, for the
* LLM prompt. Unknown ids (not in the loaded index) are skipped. Empty when the
* region data is not loaded.
*/
export function regionNamesFor(ids: Iterable<string>): Record<string, string> {
const out: Record<string, string> = {};
for (const id of ids) {
const info = regionIndex[id];
if (info) out[id] = `${info.name} (${info.parent})`;
}
return out;
}
/**
* Human-readable label for a single region id: `id — name (parent)`, or just
* the id when it is unknown (e.g. a region that predates the geo or a
* synthetic id).
*/
export function regionLabel(id: string): string {
const info = regionIndex[id];
return info ? `${id} — ${info.name} (${info.parent})` : id;
}
/** A bare ISO_A3 country code: exactly three uppercase letters (no hyphen). */
const ISO_A3_RE = /^[A-Z]{3}$/;
/**
* Migrate a loaded game's territory from whole-country ISO_A3 codes to
* sub-region ids, for nations that still carry ISO_A3 entries (i.e. games
* saved before v0.19). Each such nation's ISO_A3 codes are expanded to all
* their sub-region ids; any entries that are already region ids (they contain
* a hyphen, so they never match `ISO_A3_RE`) are kept. Idempotent and
* no-op when the region data is not loaded or a nation has no ISO_A3 codes.
* Mutates and returns the (fresh) state it is given.
*/
export function migrateTerritoryToRegions(state: GameState): GameState {
if (!loaded) return state;
for (const n of Object.values(state.nations)) {
if (!n.territory.some((t) => ISO_A3_RE.test(t))) continue;
const isoCodes = n.territory.filter((t) => ISO_A3_RE.test(t));
const existingRegions = n.territory.filter((t) => !ISO_A3_RE.test(t));
const merged = expandTerritory(isoCodes).concat(existingRegions);
const seen = new Set<string>();
const out: string[] = [];
for (const id of merged) {
if (!seen.has(id)) {
seen.add(id);
out.push(id);
}
}
n.territory = out;
}
return state;
}
// --- Test-only seams ---------------------------------------------------------
/**
* Test-only: reset the module state to "not loaded" (empty maps,
* `hasRegionData()` false). Lets a test exercise the graceful-fallback path.
*/
export function _resetRegionsForTests(): void {
regionIndex = {};
regionsByCountry = {};
loaded = false;
}
/**
* Test-only: inject region maps directly (bypassing the file load) so a unit
* test can drive `expandTerritory` / `regionNamesFor` / `regionLabel` /
* `migrateTerritoryToRegions` against a small, deterministic fixture without
* reading the 3.4 MB geo file. `loaded` becomes true iff `index` is non-empty.
*/
export function _setRegionsForTests(
index: Record<string, RegionInfo>,
byCountry: Record<string, string[]>,
): void {
regionIndex = index;
regionsByCountry = byCountry;
loaded = Object.keys(index).length > 0;
}
+12 -3
View File
@@ -13,7 +13,8 @@ export interface StubPayload {
turn: number;
date: string;
playerNationId: string;
nations: Record<string, { stats: Record<string, number> }>;
/** As of v0.19 territory is a list of sub-region ids (not ISO_A3 codes). */
nations: Record<string, { stats: Record<string, number>; territory?: string[] }>;
};
/** The raw pending round the engine emitted (one entry per queued item). */
pending?: RoundItem[];
@@ -30,7 +31,12 @@ function truncate(s: string, n: number): string {
* single political "The world moves on" event when the round is empty — so a
* caller can predict the exact next state for assertions. It always sets the
* player's military to (current + 5) and allies the player with the first other
* nation, advancing the date by 2 days.
* nation (by key order), advancing the date by 2 days. As of v0.19, when the
* round is non-empty it also makes exactly **one** territory move: the player
* annexes the lexicographically-first sub-region id of that first other
* nation's territory (an empty round makes no move, and a player with nothing
* to annex from that nation also makes no move) — so unit tests and e2e can
* assert the exact region that changes hands.
*/
export function deterministicTurn(p: StubPayload): TurnResult {
const state = p.state;
@@ -41,6 +47,9 @@ export function deterministicTurn(p: StubPayload): TurnResult {
? (Object.keys(state.nations).find((id) => id !== pid) ?? '')
: 'us';
const items = p.pending ?? [];
// The one deterministic per-turn territory move (see the doc above).
const otherTerritory = [...(state?.nations?.[otherId]?.territory ?? [])].sort();
const gain = items.length > 0 && otherTerritory.length > 0 ? [otherTerritory[0]] : [];
const events: TurnEventSpec[] =
items.length === 0
@@ -76,7 +85,7 @@ export function deterministicTurn(p: StubPayload): TurnResult {
events,
stateChanges: [{ nation: pid, stat: 'military', op: 'set', value: mil }],
relations: otherId ? [{ a: pid, b: otherId, set: 'ally' }] : [],
territoryChanges: [],
territoryChanges: gain.length > 0 ? [{ nation: pid, gain, lose: [] }] : [],
};
}