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:
@@ -0,0 +1,176 @@
|
||||
/**
|
||||
* server/regions.test.ts — the sub-region data module (v0.19).
|
||||
*
|
||||
* The first suite loads the REAL `public/regions.geo.json` (the same file the
|
||||
* production server loads at boot), so expansion/migration runs against the
|
||||
* actual 1,796-region dataset. The second suite exercises the graceful
|
||||
* "geo missing" path (module not loaded) via the `_resetRegionsForTests` seam,
|
||||
* and pins the `_setRegionsForTests` fixture seam used by the other unit tests.
|
||||
* Module state is per vitest file, so these suites never collide with other
|
||||
* test files.
|
||||
*/
|
||||
import path from 'node:path';
|
||||
import { fileURLToPath } from 'node:url';
|
||||
import { afterAll, beforeAll, describe, expect, it } from 'vitest';
|
||||
import type { GameState } from '../shared/types';
|
||||
import {
|
||||
_resetRegionsForTests,
|
||||
_setRegionsForTests,
|
||||
expandTerritory,
|
||||
hasRegionData,
|
||||
loadRegions,
|
||||
migrateTerritoryToRegions,
|
||||
regionLabel,
|
||||
regionNamesFor,
|
||||
} from './regions';
|
||||
|
||||
const here = path.dirname(fileURLToPath(import.meta.url));
|
||||
const root = path.resolve(here, '..');
|
||||
|
||||
function makeState(territory: string[]): GameState {
|
||||
return {
|
||||
id: 'g',
|
||||
presetId: 'p',
|
||||
presetTitle: 'P',
|
||||
playerNationId: 'uk',
|
||||
turn: 1,
|
||||
date: '1939-09-01',
|
||||
nations: {
|
||||
uk: {
|
||||
id: 'uk',
|
||||
name: 'UK',
|
||||
type: 'major',
|
||||
stats: { military: 80, economy: 70, stability: 60, population: 50 },
|
||||
territory,
|
||||
relations: {},
|
||||
},
|
||||
},
|
||||
events: [],
|
||||
pending: [],
|
||||
version: 1,
|
||||
};
|
||||
}
|
||||
|
||||
describe('server/regions (real geo)', () => {
|
||||
beforeAll(() => {
|
||||
_resetRegionsForTests();
|
||||
loadRegions(root);
|
||||
});
|
||||
|
||||
it('loads public/regions.geo.json (hasRegionData becomes true)', () => {
|
||||
expect(hasRegionData()).toBe(true);
|
||||
});
|
||||
|
||||
it('loadRegions is idempotent: a second call keeps the loaded data', () => {
|
||||
loadRegions(root);
|
||||
expect(hasRegionData()).toBe(true);
|
||||
});
|
||||
|
||||
it('expandTerritory expands an ISO_A3 into its sub-region ids (unique, all with that parent)', () => {
|
||||
const ids = expandTerritory(['FRA']);
|
||||
expect(ids.length).toBeGreaterThan(1); // France is subdivided, not one region
|
||||
expect(new Set(ids).size).toBe(ids.length); // deduped
|
||||
// every expanded id resolves back to the same parent country
|
||||
const names = regionNamesFor(ids);
|
||||
for (const id of ids) expect(names[id]).toMatch(/\(FRA\)$/);
|
||||
});
|
||||
|
||||
it('expandTerritory is order-preserving and dedupes repeated country codes', () => {
|
||||
expect(expandTerritory(['FRA', 'FRA'])).toEqual(expandTerritory(['FRA']));
|
||||
// two countries: the union, in first-seen order (the ids are disjoint)
|
||||
expect(expandTerritory(['FRA', 'DEU'])).toEqual([
|
||||
...expandTerritory(['FRA']),
|
||||
...expandTerritory(['DEU']),
|
||||
]);
|
||||
});
|
||||
|
||||
it('expandTerritory of an empty or unknown list yields no regions', () => {
|
||||
expect(expandTerritory([])).toEqual([]);
|
||||
expect(expandTerritory(['XXX'])).toEqual([]); // no country with that code
|
||||
});
|
||||
|
||||
it('regionNamesFor maps every known id to "name (parent)"', () => {
|
||||
const ids = expandTerritory(['DEU']);
|
||||
const names = regionNamesFor(ids);
|
||||
expect(Object.keys(names).sort()).toEqual([...ids].sort());
|
||||
for (const id of ids) expect(names[id]).toMatch(/\(DEU\)$/);
|
||||
});
|
||||
|
||||
it('regionNamesFor skips ids that are not in the loaded index', () => {
|
||||
expect(regionNamesFor(['NO-SUCH-01'])).toEqual({});
|
||||
});
|
||||
|
||||
it('regionLabel is "id — name (parent)" for known ids and the bare id for unknown', () => {
|
||||
const [id] = expandTerritory(['FRA']);
|
||||
expect(regionLabel(id)).toMatch(new RegExp(`^${id} — .+ \\(FRA\\)$`));
|
||||
expect(regionLabel('NO-SUCH-01')).toBe('NO-SUCH-01');
|
||||
});
|
||||
|
||||
it('migrateTerritoryToRegions rewrites ISO_A3 entries to region ids, keeps existing region ids, and is idempotent', () => {
|
||||
const [deId] = expandTerritory(['DEU']); // a real region id of another country
|
||||
const state = makeState(['FRA', deId]); // mixed: legacy ISO_A3 + already migrated
|
||||
const originalArray = state.nations.uk.territory;
|
||||
// it mutates and returns the (fresh) state it is given — callers pass a
|
||||
// just-parsed object (db.load), so the in-place mutation is safe
|
||||
const out = migrateTerritoryToRegions(state);
|
||||
expect(out).toBe(state);
|
||||
// the original array object is never mutated in place — a new one is
|
||||
// installed on the nation, and the original stays as it was
|
||||
expect(out.nations.uk.territory).not.toBe(originalArray);
|
||||
expect(originalArray).toEqual(['FRA', deId]);
|
||||
// the ISO_A3 entry is gone, replaced by its sub-region ids…
|
||||
expect(out.nations.uk.territory).not.toContain('FRA');
|
||||
for (const id of expandTerritory(['FRA'])) expect(out.nations.uk.territory).toContain(id);
|
||||
// …and the existing region id survives, exactly once
|
||||
expect(out.nations.uk.territory.filter((t) => t === deId)).toEqual([deId]);
|
||||
// idempotent: a second migration changes nothing
|
||||
expect(migrateTerritoryToRegions(out).nations.uk.territory).toEqual(out.nations.uk.territory);
|
||||
});
|
||||
});
|
||||
|
||||
describe('server/regions (fallback: geo not loaded)', () => {
|
||||
beforeAll(() => {
|
||||
_resetRegionsForTests();
|
||||
});
|
||||
afterAll(() => {
|
||||
// restore the real geo for anything in this file that runs later
|
||||
_resetRegionsForTests();
|
||||
loadRegions(root);
|
||||
});
|
||||
|
||||
it('hasRegionData is false after a reset', () => {
|
||||
expect(hasRegionData()).toBe(false);
|
||||
});
|
||||
|
||||
it('expandTerritory returns its input unchanged (whole-country fallback)', () => {
|
||||
expect(expandTerritory(['FRA'])).toEqual(['FRA']);
|
||||
expect(expandTerritory([])).toEqual([]);
|
||||
});
|
||||
|
||||
it('regionNamesFor is empty and regionLabel falls back to the bare id', () => {
|
||||
expect(regionNamesFor(['FRA'])).toEqual({});
|
||||
expect(regionLabel('FRA')).toBe('FRA');
|
||||
});
|
||||
|
||||
it('migrateTerritoryToRegions is a no-op (returns the state untouched)', () => {
|
||||
const state = makeState(['FRA']);
|
||||
const out = migrateTerritoryToRegions(state);
|
||||
expect(out).toBe(state);
|
||||
expect(out.nations.uk.territory).toEqual(['FRA']);
|
||||
});
|
||||
|
||||
it('_setRegionsForTests loads a fixture and drives the pure helpers', () => {
|
||||
_setRegionsForTests(
|
||||
{
|
||||
'GB-01': { name: 'Greater London', parent: 'GBR' },
|
||||
'GB-02': { name: 'Scotland', parent: 'GBR' },
|
||||
},
|
||||
{ GBR: ['GB-01', 'GB-02'] },
|
||||
);
|
||||
expect(hasRegionData()).toBe(true);
|
||||
expect(expandTerritory(['GBR'])).toEqual(['GB-01', 'GB-02']);
|
||||
expect(regionNamesFor(['GB-01'])).toEqual({ 'GB-01': 'Greater London (GBR)' });
|
||||
const state = makeState(['GBR']);
|
||||
expect(migrateTerritoryToRegions(state).nations.uk.territory).toEqual(['GB-01', 'GB-02']);
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user