From 325bdc525578ce1e78b2cf48cf51156b0a94ac85 Mon Sep 17 00:00:00 2001 From: Kamil Krzywda Date: Wed, 14 Oct 2026 09:14:10 +0000 Subject: [PATCH] Flip ownership to region keys: shared contract + server (v0.19 P1 of #16) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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). --- server/engine.ts | 39 ++++++++----- server/index.ts | 10 ++++ server/map.ts | 12 ++-- server/regions.ts | 140 +++++++++++++++++++++++++++++++++++++++++++++ shared/protocol.ts | 47 ++++++++------- shared/types.ts | 6 +- 6 files changed, 214 insertions(+), 40 deletions(-) create mode 100644 server/regions.ts diff --git a/server/engine.ts b/server/engine.ts index c10e596..a442ce0 100644 --- a/server/engine.ts +++ b/server/engine.ts @@ -53,11 +53,12 @@ export function buildSystemPrompt(preset: Preset): string { ' ],', ' "stateChanges": [{"nation":"","stat":"","op":"","value":<0-100>}],', ' "relations": [{"a":"","b":"","set":""}],', - ' "territoryChanges": [{"nation":"","gain":[""],"lose":[""]}]', + ' "territoryChanges": [{"nation":"","gain":[""],"lose":[""]}]', '}', '', '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 " ()". 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 { 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 = { 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): 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; + /** Set of valid region keys; territory changes with other keys are ignored. */ + validRegionKeys?: ReadonlySet; }): 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) ----------------------------------------------------- diff --git a/server/index.ts b/server/index.ts index 6ea59e3..171cae3 100644 --- a/server/index.ts +++ b/server/index.ts @@ -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 }); diff --git a/server/map.ts b/server/map.ts index df687e2..4491913 100644 --- a/server/map.ts +++ b/server/map.ts @@ -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; diff --git a/server/regions.ts b/server/regions.ts new file mode 100644 index 0000000..e5ab90a --- /dev/null +++ b/server/regions.ts @@ -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; + /** region key -> { name, parent }. */ + keyToInfo: Record; + /** ISO_A3 parent -> region keys of its sub-regions (file order, stable). */ + keysByCountry: Record; + /** the set of all region keys. */ + validKeys: Set; +} + +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; + const geo = JSON.parse(readFileSync(geoPath, 'utf8')) as { + features?: Array<{ id?: string | number; properties?: { name?: string; parent?: string } }>; + }; + const keyToInfo: Record = {}; + const keysByCountry: Record = {}; + const validKeys = new Set(); + 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(); + 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 -> " ()", 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): Record { + const out: Record = {}; + 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 { + const out = new Set(); + 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 { + return data ? data.validKeys : new Set(); +} diff --git a/shared/protocol.ts b/shared/protocol.ts index c9efdc8..1670ba8 100644 --- a/shared/protocol.ts +++ b/shared/protocol.ts @@ -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; /** * 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). */ diff --git a/shared/types.ts b/shared/types.ts index 90a2db4..fd84eed 100644 --- a/shared/types.ts +++ b/shared/types.ts @@ -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;