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
+31 -20
View File
@@ -23,7 +23,13 @@ export interface RelationChange {
set: RelationStatus;
}
/** A nation gains/loses ISO_A3 regions this turn. */
/**
* A nation gains/loses **sub-region ids** this turn (e.g. `FR-57`), not
* whole-country ISO_A3 codes. Each id identifies a single sub-region, so a
* border can shift one region at a time; a gained region is moved out of
* whichever nation currently owns it, and a lost region with no matching
* gainer simply becomes unowned (no-man's-land).
*/
export interface TerritoryChange {
nation: string;
gain: string[];
@@ -48,26 +54,31 @@ export interface TurnResult {
territoryChanges: TerritoryChange[];
}
/** Per-region map payload: ISO_A3 -> owning nation + its stable color. */
/** Per-region map payload: **sub-region id** -> owning nation + its stable color. */
export interface RegionOwner {
color: string;
owner: string;
ownerId: string;
}
/**
* The authoritative region→owner map, **keyed by sub-region id** (e.g. `FR-57`).
* Every sub-region any nation owns appears here; a sub-region in no nation's
* territory is absent (unowned / no-man's-land) and rendered grey by the map.
*/
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 the **sub-region's own id** (e.g. `FR-57` — never a
* bare ISO_A3 like `FRA`, and never the repacked numeric `feature.id`), so `key`
* is a sub-region id such as `FR-57`. Returns `undefined` for an empty/missing
* key or an unowned region (one in no nation's territory).
*
* 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.
* resolution (deciding which key to pass) lives in {@link mapFeatureOwner}
* below, which reads the feature's `properties.regionId` — never the repacked
* top-level `id` (MapLibre turns it into a numeric index, the "no owner" bug
* #6).
*/
export function regionOwner(regions: RegionMap, key: string | null | undefined): RegionOwner | undefined {
if (!key) return undefined;
@@ -76,22 +87,22 @@ 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
* authoritative RegionMap. Ownership is now at **sub-region** granularity, so a
* feature's owner is found via its **own sub-region id** — the client stamps
* the feature's id into `properties.regionId` (see WorldMap `applyRegions`), and
* we look that up with `regionOwner`. This is the single shared source of truth
* for that resolution, so the "use `properties.regionId`, never the repacked
* top-level `feature.id` (a MapLibre numeric index, the "no owner" bug #6)" 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/regionId or an unowned region.
*/
export function mapFeatureOwner(
regions: RegionMap,
feature: { properties?: { parent?: unknown } } | null | undefined,
feature: { properties?: { regionId?: unknown } } | null | undefined,
): RegionOwner | undefined {
const parent = feature?.properties?.parent;
return regionOwner(regions, typeof parent === 'string' ? parent : '');
const regionId = feature?.properties?.regionId;
return regionOwner(regions, typeof regionId === 'string' ? regionId : '');
}
/** Server -> client Server-Sent Events payload (see server/index.ts /sse). */
+9 -2
View File
@@ -26,8 +26,15 @@ 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 **sub-region ids** (e.g.
* `FR-57`) — one entry per individually-ownable sub-region, at sub-region
* granularity rather than whole-country ISO_A3. The map colors each sub-region
* with this nation's stable color (see protocol.nationColor). At scenario start
* a nation owns **all** sub-regions of the countries it holds (the server
* expands each ISO_A3 to its sub-regions; see server/regions.expandTerritory),
* so no region starts unowned. A sub-region that is in **no** nation's list is
* unowned (no-man's-land) — the map renders it grey and it can be gained or
* lost region-by-region by the LLM.
*/
export interface Nation {
id: string;