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
+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,