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
177 lines
6.5 KiB
TypeScript
177 lines
6.5 KiB
TypeScript
/**
|
|
* 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']);
|
|
});
|
|
});
|