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
+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). */