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
144 lines
5.4 KiB
TypeScript
144 lines
5.4 KiB
TypeScript
import { describe, expect, it } from 'vitest';
|
|
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',
|
|
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: ['GB-01', 'GB-02'],
|
|
relations: {},
|
|
},
|
|
de: {
|
|
id: 'de',
|
|
name: 'DE',
|
|
type: 'major',
|
|
stats: { military: 90, economy: 70, stability: 70, population: 90 },
|
|
territory: ['DE-01'],
|
|
relations: {},
|
|
},
|
|
},
|
|
events: [],
|
|
pending: [],
|
|
version: 1,
|
|
};
|
|
|
|
// The RegionMap built from the fixture: the authoritative sub-region id → owner
|
|
// source.
|
|
const regions: RegionMap = buildRegionMap(state);
|
|
|
|
describe('buildRegionMap', () => {
|
|
it('maps each sub-region id to its owner + that nation\'s stable color', () => {
|
|
const m = buildRegionMap(state);
|
|
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 sub-region id to its owner record', () => {
|
|
expect(regionOwner(regions, 'GB-01')).toEqual({
|
|
color: nationColor('uk'),
|
|
owner: 'UK',
|
|
ownerId: 'uk',
|
|
});
|
|
expect(regionOwner(regions, 'DE-01')).toEqual({
|
|
color: nationColor('de'),
|
|
owner: 'DE',
|
|
ownerId: 'de',
|
|
});
|
|
});
|
|
|
|
it('returns undefined for an unowned region (no RegionMap entry)', () => {
|
|
expect(regionOwner(regions, 'FR-57')).toBeUndefined();
|
|
expect(regionOwner(regions, 'US-01')).toBeUndefined();
|
|
});
|
|
|
|
it('returns undefined for an empty or missing id', () => {
|
|
expect(regionOwner(regions, '')).toBeUndefined();
|
|
expect(regionOwner(regions, null)).toBeUndefined();
|
|
expect(regionOwner(regions, undefined)).toBeUndefined();
|
|
});
|
|
});
|
|
|
|
/**
|
|
* #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 — 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 id, the RegionMap key) and `parent` (the
|
|
// static parent country's ISO_A3).
|
|
const repackedFeature = {
|
|
id: 0,
|
|
properties: { name: 'Rhineland', regionId: 'DE-01', parent: 'DEU' },
|
|
};
|
|
|
|
it('resolves the owner from properties.regionId (the fix)', () => {
|
|
expect(mapFeatureOwner(regions, repackedFeature)).toEqual({
|
|
color: nationColor('de'),
|
|
owner: 'DE',
|
|
ownerId: 'de',
|
|
});
|
|
});
|
|
|
|
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 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: { regionId: 42 } })).toBeUndefined();
|
|
expect(mapFeatureOwner(regions, { properties: { regionId: '' } })).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();
|
|
});
|
|
});
|
|
|
|
describe('nationColor', () => {
|
|
it('is deterministic per id and distinct across different ids', () => {
|
|
expect(nationColor('uk')).toBe(nationColor('uk'));
|
|
expect(nationColor('uk')).not.toBe(nationColor('de'));
|
|
// always a valid HSL string
|
|
expect(nationColor('uk')).toMatch(/^hsl\(\d+ 58% 46%\)$/);
|
|
});
|
|
});
|