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:
+31
-20
@@ -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
@@ -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;
|
||||
|
||||
Reference in New Issue
Block a user