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
+45 -42
View File
@@ -3,6 +3,8 @@ import { buildRegionMap } from './map';
import { mapFeatureOwner, nationColor, regionOwner, type RegionMap } from '../shared/protocol';
import type { GameState } from '../shared/types';
// As of v0.19 territory is a list of **sub-region ids** (the RegionMap keys),
// not whole-country ISO_A3 codes.
const state: GameState = {
id: 'g',
presetId: 'p',
@@ -16,7 +18,7 @@ const state: GameState = {
name: 'UK',
type: 'major',
stats: { military: 80, economy: 70, stability: 60, population: 50 },
territory: ['GBR', 'IRL'],
territory: ['GB-01', 'GB-02'],
relations: {},
},
de: {
@@ -24,7 +26,7 @@ const state: GameState = {
name: 'DE',
type: 'major',
stats: { military: 90, economy: 70, stability: 70, population: 90 },
territory: ['DEU'],
territory: ['DE-01'],
relations: {},
},
},
@@ -33,28 +35,29 @@ const state: GameState = {
version: 1,
};
// The RegionMap built from the fixture: the authoritative ISO_A3 → owner source.
// The RegionMap built from the fixture: the authoritative sub-region id → owner
// source.
const regions: RegionMap = buildRegionMap(state);
describe('buildRegionMap', () => {
it('maps each ISO_A3 region to its owner + that nation\'s stable color', () => {
it('maps each sub-region id to its owner + that nation\'s stable color', () => {
const m = buildRegionMap(state);
expect(m.GBR).toEqual({ color: nationColor('uk'), owner: 'UK', ownerId: 'uk' });
expect(m.IRL).toEqual({ color: nationColor('uk'), owner: 'UK', ownerId: 'uk' });
expect(m.DEU).toEqual({ color: nationColor('de'), owner: 'DE', ownerId: 'de' });
// a region nobody owns is simply absent
expect(m.FRA).toBeUndefined();
expect(m['GB-01']).toEqual({ color: nationColor('uk'), owner: 'UK', ownerId: 'uk' });
expect(m['GB-02']).toEqual({ color: nationColor('uk'), owner: 'UK', ownerId: 'uk' });
expect(m['DE-01']).toEqual({ color: nationColor('de'), owner: 'DE', ownerId: 'de' });
// a sub-region nobody owns is simply absent
expect(m['FR-57']).toBeUndefined();
});
});
describe('regionOwner', () => {
it('resolves an owned region\'s ISO_A3 to its owner record', () => {
expect(regionOwner(regions, 'GBR')).toEqual({
it('resolves an owned sub-region id to its owner record', () => {
expect(regionOwner(regions, 'GB-01')).toEqual({
color: nationColor('uk'),
owner: 'UK',
ownerId: 'uk',
});
expect(regionOwner(regions, 'DEU')).toEqual({
expect(regionOwner(regions, 'DE-01')).toEqual({
color: nationColor('de'),
owner: 'DE',
ownerId: 'de',
@@ -62,11 +65,11 @@ describe('regionOwner', () => {
});
it('returns undefined for an unowned region (no RegionMap entry)', () => {
expect(regionOwner(regions, 'FRA')).toBeUndefined();
expect(regionOwner(regions, 'USA')).toBeUndefined();
expect(regionOwner(regions, 'FR-57')).toBeUndefined();
expect(regionOwner(regions, 'US-01')).toBeUndefined();
});
it('returns undefined for an empty or missing code', () => {
it('returns undefined for an empty or missing id', () => {
expect(regionOwner(regions, '')).toBeUndefined();
expect(regionOwner(regions, null)).toBeUndefined();
expect(regionOwner(regions, undefined)).toBeUndefined();
@@ -74,28 +77,28 @@ describe('regionOwner', () => {
});
/**
* #6 regression guard (sub-national map). The click/hover handlers resolve the
* owning nation from a rendered map feature. MapLibre repacks a GeoJSON
* source's top-level `id` into a *numeric index* when it stores the source, so a
* feature the map hands back at interaction time has a number for `id`. The
* choropleth is now sub-national: a feature's id is a sub-region code
* (`DEU-03`) and its owner lives at the *parent country* (`DEU`), which the
* client stamps into `properties.parent` (the RegionMap is keyed by that
* country code, never the sub-region id). This pins the exact data shape and
* proves the resolution: `mapFeatureOwner` works via `properties.parent`, while
* the two wrong paths (the repacked numeric `feature.id`, and the sub-region
* `regionId`, which is not a RegionMap key) do not.
* #6 regression guard (sub-national map, v0.19 region-level ownership). The
* click/hover handlers resolve the owning nation from a rendered map feature.
* MapLibre repacks a GeoJSON source's top-level `id` into a *numeric index*
* when it stores the source, so a feature the map hands back at interaction
* time has a number for `id`. Since v0.19 ownership is at **sub-region**
* granularity: a feature's owner lives at its **own sub-region id** (e.g.
* `DE-01`), which the client stamps into `properties.regionId` (the RegionMap
* is keyed by that id). This pins the exact data shape and proves the
* resolution: `mapFeatureOwner` works via `properties.regionId`, while the
* repacked numeric `feature.id` path does not.
*/
describe('#6 — sub-region owner must be resolved from properties.parent, not the sub-region id / repacked feature.id', () => {
describe('#6 — region owner must be resolved from properties.regionId, never the repacked feature.id', () => {
// The shape MapLibre returns for a sub-region feature after setData(): `id`
// is a number (repacked), and the stable identifiers live only in properties —
// `regionId` (the sub-region code) and `parent` (the ISO_A3 country).
// is a number (repacked), and the stable identifiers live only in properties
// — `regionId` (the sub-region id, the RegionMap key) and `parent` (the
// static parent country's ISO_A3).
const repackedFeature = {
id: 0,
properties: { name: 'Germany R03', regionId: 'DEU-03', parent: 'DEU' },
properties: { name: 'Rhineland', regionId: 'DE-01', parent: 'DEU' },
};
it('resolves the owner from properties.parent (the fix)', () => {
it('resolves the owner from properties.regionId (the fix)', () => {
expect(mapFeatureOwner(regions, repackedFeature)).toEqual({
color: nationColor('de'),
owner: 'DE',
@@ -103,30 +106,30 @@ describe('#6 — sub-region owner must be resolved from properties.parent, not t
});
});
it('the sub-region regionId is NOT a RegionMap key — resolving from it fails', () => {
// 'DEU-03' is a sub-region id; the RegionMap is keyed by the parent 'DEU'.
expect(regionOwner(regions, repackedFeature.properties.regionId)).toBeUndefined();
});
it('the repacked numeric feature.id does NOT resolve — the bug', () => {
it('the repacked numeric feature.id does NOT resolve — the original bug', () => {
// String(0) === '0' is not a valid key, so the pre-fix lookup failed and
// reported "No owner" even for owned regions.
expect(regionOwner(regions, String(repackedFeature.id))).toBeUndefined();
});
it('a sub-region id nobody owns does NOT resolve', () => {
expect(regionOwner(regions, 'FR-57')).toBeUndefined();
});
});
describe('mapFeatureOwner', () => {
it('returns undefined for a missing feature, missing parent, or non-string parent', () => {
it('returns undefined for a missing feature, missing regionId, or non-string regionId', () => {
expect(mapFeatureOwner(regions, null)).toBeUndefined();
expect(mapFeatureOwner(regions, undefined)).toBeUndefined();
expect(mapFeatureOwner(regions, {})).toBeUndefined();
expect(mapFeatureOwner(regions, { properties: {} })).toBeUndefined();
expect(mapFeatureOwner(regions, { properties: { parent: 42 } })).toBeUndefined();
expect(mapFeatureOwner(regions, { properties: { regionId: 42 } })).toBeUndefined();
expect(mapFeatureOwner(regions, { properties: { regionId: '' } })).toBeUndefined();
});
it('returns undefined for a parent country nobody owns', () => {
expect(mapFeatureOwner(regions, { properties: { parent: 'FRA' } })).toBeUndefined();
expect(mapFeatureOwner(regions, { properties: { parent: 'USA' } })).toBeUndefined();
it('returns undefined for a sub-region nobody owns', () => {
expect(mapFeatureOwner(regions, { properties: { regionId: 'FR-57' } })).toBeUndefined();
expect(mapFeatureOwner(regions, { properties: { regionId: 'US-01' } })).toBeUndefined();
});
});