Flip ownership to region keys: shared contract + server (v0.19 P1 of #16)

The single source of truth now keys ownership by region key (one per owned
sub-region) instead of a parent-country ISO_A3:

- shared/types.ts, shared/protocol.ts: TerritoryChange / RegionMap / regionOwner
  doc flips; mapFeatureOwner now reads the feature's stamped properties.regionKey
  (never the repacked numeric feature.id, nor the sub-region id — the #6 guard).
- server/regions.ts (new, turf-free): loads region-keys.json + regions.geo.json
  once at boot; expands a nation's ISO_A3 list to its sub-regions' keys, builds
  the LLM 'regions' reference table (key -> "name (parent)"), and exposes the
  set of valid keys. Degrades gracefully when the files are missing.
- server/engine.ts: buildSystemPrompt instructs the model to use region keys;
  buildUserPrompt gains an optional regionReference table; applyTurnResult gains
  an optional validKeys set so hallucinated keys are ignored.
- server/index.ts: loads region data at boot; expands each nation's country list
  to region keys at game creation; the jump route passes the reference + valid
  keys through runTurn.

The stub is unchanged (it emits an empty, region-key-conformant territoryChanges).
This commit is contained in:
2026-10-14 09:14:10 +00:00
parent 2cfa2f78f9
commit 325bdc5255
6 changed files with 214 additions and 40 deletions
+26 -13
View File
@@ -53,11 +53,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":["<region key>"],"lose":["<region key>"]}]',
'}',
'',
'HARD CONSTRAINTS',
'- Use only nation ids and ISO_A3 codes that exist in the provided state.',
'- Use only nation ids and region keys that exist in the provided state.',
'- A region key identifies one sub-region; the "regions" table in the user message maps each key to "<region name> (<parent country ISO_A3>)". Use those keys in territoryChanges.',
'- 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.',
@@ -75,7 +76,11 @@ export function buildSystemPrompt(preset: Preset): string {
* model to simulate the combined consequences of the whole batch (or to let the
* world advance on its own when the batch is empty) and return one TurnResult.
*/
export function buildUserPrompt(state: GameState, items: RoundItem[]): string {
export function buildUserPrompt(
state: GameState,
items: RoundItem[],
regionReference?: Record<string, string>,
): string {
const player = state.nations[state.playerNationId];
const playerName = player?.name ?? state.playerNationId;
const playerActions = items.map((i) =>
@@ -88,7 +93,7 @@ export function buildUserPrompt(state: GameState, items: RoundItem[]): string {
)}. Simulate the combined consequences across all nations and advance the world by one period. Return a single TurnResult.`
: 'The player takes no action this period; let the world advance on its own momentum. Return a single TurnResult.';
const payload = {
const payload: Record<string, unknown> = {
state: {
turn: state.turn,
date: state.date,
@@ -110,6 +115,7 @@ export function buildUserPrompt(state: GameState, items: RoundItem[]): string {
pending: items,
roundInstruction,
};
if (regionReference) payload.regions = regionReference;
return JSON.stringify(payload, null, 2);
}
@@ -283,7 +289,7 @@ function genericUnclearEvent(state: GameState): TurnEventSpec {
* Apply a TurnResult to a GameState immutably: advance the date/turn, append
* events, apply stat changes (clamped), symmetric relations, and territory moves.
*/
export function applyTurnResult(state: GameState, result: TurnResult): GameState {
export function applyTurnResult(state: GameState, result: TurnResult, validKeys?: ReadonlySet<string>): GameState {
const next: GameState = JSON.parse(JSON.stringify(state));
next.version = (state.version ?? 0) + 1; // every mutation is a new revision
const actingTurn = state.turn; // this action resolves the current turn
@@ -309,15 +315,18 @@ export function applyTurnResult(state: GameState, result: TurnResult): GameState
for (const t of result.territoryChanges) {
const n = next.nations[t.nation];
if (!n) continue;
for (const code of t.gain) {
const valid = (k: string) => !validKeys || validKeys.has(k);
for (const key of t.gain) {
if (!valid(key)) continue; // ignore a key that is not a real region
for (const other of Object.values(next.nations)) {
const i = other.territory.indexOf(code);
const i = other.territory.indexOf(key);
if (i !== -1) other.territory.splice(i, 1);
}
if (!n.territory.includes(code)) n.territory.push(code);
if (!n.territory.includes(key)) n.territory.push(key);
}
for (const code of t.lose) {
const i = n.territory.indexOf(code);
for (const key of t.lose) {
if (!valid(key)) continue;
const i = n.territory.indexOf(key);
if (i !== -1) n.territory.splice(i, 1);
}
}
@@ -367,10 +376,14 @@ export async function runTurn(opts: {
preset: Preset;
state: GameState;
items: RoundItem[];
/** Region reference table (key -> "name (parent)") to include in the user prompt. */
regionReference?: Record<string, string>;
/** Set of valid region keys; territory changes with other keys are ignored. */
validRegionKeys?: ReadonlySet<string>;
}): Promise<{ state: GameState; result: TurnResult }> {
const { llm, preset, state, items } = opts;
const { llm, preset, state, items, regionReference, validRegionKeys } = opts;
const system = buildSystemPrompt(preset);
const user = buildUserPrompt(state, items);
const user = buildUserPrompt(state, items, regionReference);
const nudge =
'Your previous output was invalid. Respond with ONLY the JSON object described above for this batch — no prose, no fences.';
@@ -398,7 +411,7 @@ export async function runTurn(opts: {
};
}
return { state: applyTurnResult(state, result), result };
return { state: applyTurnResult(state, result, validRegionKeys), result };
}
// --- Advisor (brainstorm) -----------------------------------------------------
+10
View File
@@ -20,6 +20,7 @@ import { createLlmClient } from './llm';
import { makeStubLlmClient } from './stub';
import { askAdvisor, queueRoundItem, removeRoundItem, runTurn } from './engine';
import { buildRegionMap } from './map';
import { allTerritoryKeys, expandCountriesToKeys, loadRegions, referenceForKeys, validKeys } from './regions';
import { createPresetStore } from './presets';
import { openDb, type Db } from './db';
@@ -49,6 +50,9 @@ const dataDir = process.env.DATA_DIR ?? path.join(root, 'data');
// the e2e config, so honor it as a fallback to keep all three in agreement.
const PORT = parseInt(process.env.PORT ?? process.env.HISTORY_API_PORT ?? '4321', 10);
const presetStore = createPresetStore(presetsDir);
// Load the region-key manifest + geo once at boot (v0.19 #16 P1). A missing file
// just means region expansion/reference degrade to the raw keys (server still boots).
loadRegions(root);
const db: Db = openDb(path.join(dataDir, 'history.db'));
// LLM_STUB=1 → deterministic stub (tests/e2e/dev without a live model).
const useStub = process.env.LLM_STUB === '1';
@@ -121,6 +125,10 @@ app.post('/api/games', (req: Request, res) => {
pending: [],
version: 1, // the first saved revision; every later mutation bumps it
};
// v0.19 (#16 P1): expand each nation's ISO_A3 country list to the region keys
// of all its sub-regions, so ownership (and the choropleth) is at region
// granularity from the very first turn. Preset JSONs stay country-level.
for (const n of Object.values(state.nations)) n.territory = expandCountriesToKeys(n.territory);
db.save(state);
res.json({ game: state });
});
@@ -227,6 +235,8 @@ app.post('/api/games/:id/jump', async (req, res) => {
preset,
state: game,
items: game.pending,
regionReference: referenceForKeys(allTerritoryKeys(game)),
validRegionKeys: validKeys(),
});
db.save(state);
broadcast(id, 'turn', { state, result });
+7 -5
View File
@@ -1,6 +1,8 @@
// 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 region key (see
// public/region-keys.json) maps to its owning nation plus that nation's stable
// color (see shared.nationColor). The client merges this onto the sub-national
// regions.geo.json (resolving each feature by its own region key) and calls
// source.setData(). Format-agnostic: it maps whatever keys sit in territory.
import { nationColor, type RegionMap } from '../shared/protocol';
import type { GameState } from '../shared/types';
@@ -8,8 +10,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 key of nation.territory) {
map[key] = { color, owner: nation.name, ownerId: id };
}
}
return map;
+140
View File
@@ -0,0 +1,140 @@
// Region-key data access (server). Loads the deterministic region-key manifest
// (public/region-keys.json: stable feature id -> region key) and the sub-national
// geo (public/regions.geo.json: feature id -> { name, parent }), then joins them
// into the lookups the region-level ownership model (v0.19, #16 P1) needs:
//
// - expand a nation's ISO_A3 country list -> the region keys of all its sub-regions
// - build the LLM "regions" reference table (key -> "name (parent ISO_A3)")
// - the set of all valid region keys (to ignore hallucinated keys in applyTurnResult)
//
// Deliberately turf-free: it reads the two JSON files with plain JSON.parse and
// never imports @turf/@types/geojson (importing one pulls @types/geojson into the
// tsc program and breaks WorldMap.tsx; see the v0.17 changelog note). Loaded once
// at boot; the accessors below are pure data reads thereafter.
import { existsSync, readFileSync } from 'node:fs';
import path from 'node:path';
import type { GameState } from '../shared/types';
/** { name, parent } for one sub-region, where parent is the ISO_A3 country. */
interface RegionInfo {
name: string;
parent: string;
}
interface RegionData {
/** stable feature id -> region key (from public/region-keys.json). */
idToKey: Record<string, string>;
/** region key -> { name, parent }. */
keyToInfo: Record<string, RegionInfo>;
/** ISO_A3 parent -> region keys of its sub-regions (file order, stable). */
keysByCountry: Record<string, string[]>;
/** the set of all region keys. */
validKeys: Set<string>;
}
let data: RegionData | null = null;
/**
* Load (or reload) the region-key manifest + geo from `root`. Idempotent.
* Returns true when both files loaded cleanly. A missing/broken file clears the
* data and returns false, so the server still boots and degrades gracefully
* (expansion becomes a no-op, ownership falls back to the raw input keys).
*/
export function loadRegions(root: string): boolean {
const keyPath = path.join(root, 'public', 'region-keys.json');
const geoPath = path.join(root, 'public', 'regions.geo.json');
if (!existsSync(keyPath) || !existsSync(geoPath)) {
data = null;
return false;
}
try {
const idToKey = JSON.parse(readFileSync(keyPath, 'utf8')) as Record<string, string>;
const geo = JSON.parse(readFileSync(geoPath, 'utf8')) as {
features?: Array<{ id?: string | number; properties?: { name?: string; parent?: string } }>;
};
const keyToInfo: Record<string, RegionInfo> = {};
const keysByCountry: Record<string, string[]> = {};
const validKeys = new Set<string>();
for (const f of geo.features ?? []) {
const id = f.id == null ? '' : String(f.id);
const key = idToKey[id];
if (!key) continue; // a geo feature with no manifest entry is skipped
const name = typeof f.properties?.name === 'string' ? f.properties.name : '';
const parent = typeof f.properties?.parent === 'string' ? f.properties.parent : '';
keyToInfo[key] = { name, parent };
validKeys.add(key);
if (parent) (keysByCountry[parent] ??= []).push(key);
}
data = { idToKey, keyToInfo, keysByCountry, validKeys };
return validKeys.size > 0;
} catch (err) {
console.error('[regions] failed to load region data', err);
data = null;
return false;
}
}
/** Test/dev seam: drop the loaded data (the next accessors behave as "no data"). */
export function resetRegions(): void {
data = null;
}
/** True when the manifest + geo loaded cleanly. */
export function hasRegionData(): boolean {
return data !== null;
}
/**
* Expand a nation's ISO_A3 country list to the region keys of all its sub-regions
* (file order, deduped). This is what turns a preset's country-level territory
* into region-level ownership at game creation. Returns the input unchanged when
* no region data is loaded (graceful degradation — behaves like the old model).
*/
export function expandCountriesToKeys(countries: string[]): string[] {
if (!data) return countries;
const out: string[] = [];
const seen = new Set<string>();
for (const c of countries) {
for (const key of data.keysByCountry[c] ?? []) {
if (!seen.has(key)) {
seen.add(key);
out.push(key);
}
}
}
return out;
}
/**
* Build the LLM reference table: region key -> "<name> (<parent ISO_A3>)", e.g.
* `moselle-rygy -> "Moselle (FRA)"`. Only the given keys are included (the caller
* passes the game's owned-region universe) so the prompt stays bounded.
*/
export function referenceForKeys(keys: Iterable<string>): Record<string, string> {
const out: Record<string, string> = {};
if (!data) return out;
for (const key of keys) {
const info = data.keyToInfo[key];
if (info) out[key] = info.parent ? `${info.name} (${info.parent})` : info.name;
}
return out;
}
/** The region key's human name (for the dossier), or undefined if unknown. */
export function regionName(key: string): string | undefined {
return data?.keyToInfo[key]?.name;
}
/** The union of every nation's territory region keys (the game's region universe). */
export function allTerritoryKeys(state: GameState): Set<string> {
const out = new Set<string>();
for (const n of Object.values(state.nations)) {
for (const key of n.territory) out.add(key);
}
return out;
}
/** The set of all valid region keys (to ignore hallucinated keys in applyTurnResult). */
export function validKeys(): ReadonlySet<string> {
return data ? data.validKeys : new Set<string>();
}
+27 -20
View File
@@ -23,7 +23,7 @@ export interface RelationChange {
set: RelationStatus;
}
/** A nation gains/loses ISO_A3 regions this turn. */
/** A nation gains/loses region keys (see `public/region-keys.json`) this turn. */
export interface TerritoryChange {
nation: string;
gain: string[];
@@ -48,26 +48,32 @@ export interface TurnResult {
territoryChanges: TerritoryChange[];
}
/** Per-region map payload: ISO_A3 -> owning nation + its stable color. */
/** Per-region map payload: region key -> owning nation + its stable color. */
export interface RegionOwner {
color: string;
owner: string;
ownerId: string;
}
/**
* The authoritative ownership map: a **region key** (see `public/region-keys.json`)
* -> the owning nation + its stable color. Owned at sub-region granularity (v0.19,
* #16 P1); a key absent from this map is unowned (rendered grey).
*/
export type RegionMap = Record<string, RegionOwner>;
/**
* Look up the owner record from the authoritative RegionMap by a RegionMap key.
* The RegionMap is keyed by the **parent country's** ISO_A3 code (ownership and
* the LLM stay at country granularity — see #16), so `key` is a country code such
* as `DEU`, never a sub-region id such as `DEU-03`. Returns `undefined` for an
* empty/missing key or an unowned country.
* The RegionMap is keyed by **region key** (see `public/region-keys.json`) — one
* per owned sub-region (v0.19, #16 P1) — so `key` is a region key such as
* `moselle-rygy`, never a raw feature id such as `FR-57` and never a
* parent-country ISO_A3. Returns `undefined` for an empty/missing key or an
* unowned region.
*
* Pure + shared: the primitive "key → owner" lookup. The *map feature → owner*
* resolution (deciding which key to pass) lives in {@link mapFeatureOwner} below,
* which reads the feature's `properties.parent` — never the repacked top-level
* `id` (MapLibre turns it into a numeric index, the "no owner" bug #6) and never
* the sub-region id.
* which reads the feature's stamped `properties.regionKey` — never the repacked
* top-level `id` (MapLibre turns it into a numeric index, the "no owner" bug #6)
* and never the sub-region id.
*/
export function regionOwner(regions: RegionMap, key: string | null | undefined): RegionOwner | undefined {
if (!key) return undefined;
@@ -76,22 +82,23 @@ export function regionOwner(regions: RegionMap, key: string | null | undefined):
/**
* Resolve the owning-nation record for a **rendered map feature** from the
* authoritative RegionMap. The choropleth renders sub-national regions, but the
* RegionMap is keyed by the *parent country's* ISO_A3 (not the sub-region's own
* id), so a feature's owner is found via its `properties.parent` (the ISO_A3 the
* client stamps from the GeoJSON `parent` property — see WorldMap) and then
* `regionOwner`. This is the single shared source of truth for that resolution,
* so the "use `parent`, never the sub-region `id`/repacked `feature.id`" rule
* lives in one tested place rather than in the React component.
* authoritative RegionMap. The choropleth renders sub-national regions, and the
* RegionMap is now keyed by **region key** (one per sub-region, v0.19 #16 P1),
* so a feature's owner is found via its `properties.regionKey` — the region key
* the client stamps from `public/region-keys.json` by the feature's stable id
* (see WorldMap) — and then `regionOwner`. This is the single shared source of
* truth for that resolution, so the "use `regionKey`, never the sub-region `id`
* / repacked `feature.id`" rule lives in one tested place rather than in the
* React component.
*
* Returns `undefined` for a missing feature/parent or an unowned country.
* Returns `undefined` for a missing feature/regionKey or an unowned region.
*/
export function mapFeatureOwner(
regions: RegionMap,
feature: { properties?: { parent?: unknown } } | null | undefined,
feature: { properties?: { regionKey?: unknown } } | null | undefined,
): RegionOwner | undefined {
const parent = feature?.properties?.parent;
return regionOwner(regions, typeof parent === 'string' ? parent : '');
const regionKey = feature?.properties?.regionKey;
return regionOwner(regions, typeof regionKey === 'string' ? regionKey : '');
}
/** Server -> client Server-Sent Events payload (see server/index.ts /sse). */
+4 -2
View File
@@ -26,8 +26,10 @@ export const RELATION_STATUSES: RelationStatus[] = [
];
/**
* A single nation in the world. `territory` holds ISO_A3 codes; the map colors
* each of those regions with this nation's stable color (see protocol.nationColor).
* A single nation in the world. `territory` holds **region keys** (see
* `public/region-keys.json`) — one per owned sub-region, so the choropleth can
* color individual sub-regions, not whole countries (v0.19, #16 P1). The map
* resolves each sub-region's owner by its region key (see protocol.mapFeatureOwner).
*/
export interface Nation {
id: string;