diff --git a/server/engine.test.ts b/server/engine.test.ts index a7f55f6..f7f3c0c 100644 --- a/server/engine.test.ts +++ b/server/engine.test.ts @@ -318,6 +318,17 @@ describe('buildUserPrompt (batched round)', () => { it('asks the model to keep its internal reasoning brief (concise-CoT constraint)', () => { expect(buildSystemPrompt(preset)).toMatch(/reasoning brief|a few sentences/i); }); + + it('includes a regions reference table only when one is provided', () => { + // with a reference table: the payload carries it (region key -> "name (parent)") + const withRef = JSON.parse( + buildUserPrompt(baseState(), [], { 'ain-lqtt': 'Ain (FRA)', 'wales-bbbb': 'Wales (GBR)' }), + ) as { regions?: Record }; + expect(withRef.regions).toEqual({ 'ain-lqtt': 'Ain (FRA)', 'wales-bbbb': 'Wales (GBR)' }); + // without one: the field is absent entirely (the legacy prompt shape is unchanged) + const withoutRef = JSON.parse(buildUserPrompt(baseState(), [])) as { regions?: Record }; + expect(withoutRef.regions).toBeUndefined(); + }); }); describe('queueRoundItem', () => { @@ -461,6 +472,41 @@ describe('applyTurnResult', () => { expect(state.pending).toHaveLength(1); // original untouched expect(state.version).toBe(1); }); + + it('with a validKeys set: applies real region keys, ignores hallucinated ones', () => { + const state = baseState(); + const result: TurnResult = { + date: '1939-09-03', + events: [], + stateChanges: [], + relations: [], + // 'wales-bbbb' is a real key (owned by uk in the fixture); 'not-a-region-xyz' + // is not a real key at all. Both are proposed as a gain by DE. + territoryChanges: [{ nation: 'de', gain: ['wales-bbbb', 'not-a-region-xyz'], lose: [] }], + }; + const next = applyTurnResult(state, result, new Set(['wales-bbbb'])); + // the valid key was gained (stolen from its owner); the bogus one was ignored + expect(next.nations.de.territory).toContain('wales-bbbb'); + expect(next.nations.uk.territory).not.toContain('wales-bbbb'); + // the bogus key never entered any nation's territory + for (const n of Object.values(next.nations)) { + expect(n.territory).not.toContain('not-a-region-xyz'); + } + }); + + it('without a validKeys set: applies every listed key (legacy/unit path)', () => { + const state = baseState(); + const result: TurnResult = { + date: '1939-09-03', + events: [], + stateChanges: [], + relations: [], + territoryChanges: [{ nation: 'de', gain: ['POL', 'wales-bbbb'], lose: [] }], + }; + const next = applyTurnResult(state, result); // no validKeys -> nothing filtered + expect(next.nations.de.territory).toContain('POL'); + expect(next.nations.de.territory).toContain('wales-bbbb'); + }); }); describe('isMeaningful', () => { diff --git a/server/map.test.ts b/server/map.test.ts index 31338ae..d61bccb 100644 --- a/server/map.test.ts +++ b/server/map.test.ts @@ -3,6 +3,10 @@ import { buildRegionMap } from './map'; import { mapFeatureOwner, nationColor, regionOwner, type RegionMap } from '../shared/protocol'; import type { GameState } from '../shared/types'; +// Region-key fixtures (v0.19, #16 P1): ownership and the RegionMap are now keyed +// by region key (one per owned sub-region), not by a parent-country ISO_A3. The +// keys below are deliberately fake (obviously not real manifest entries) so this +// test stays self-contained and format-agnostic. const state: GameState = { id: 'g', presetId: 'p', @@ -16,7 +20,7 @@ const state: GameState = { name: 'UK', type: 'major', stats: { military: 80, economy: 70, stability: 60, population: 50 }, - territory: ['GBR', 'IRL'], + territory: ['albania-aaaa', 'wales-bbbb'], relations: {}, }, de: { @@ -24,7 +28,7 @@ const state: GameState = { name: 'DE', type: 'major', stats: { military: 90, economy: 70, stability: 70, population: 90 }, - territory: ['DEU'], + territory: ['brandenburg-cccc'], relations: {}, }, }, @@ -33,28 +37,28 @@ 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 region key → 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 owned region key 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' }); + expect(m['albania-aaaa']).toEqual({ color: nationColor('uk'), owner: 'UK', ownerId: 'uk' }); + expect(m['wales-bbbb']).toEqual({ color: nationColor('uk'), owner: 'UK', ownerId: 'uk' }); + expect(m['brandenburg-cccc']).toEqual({ color: nationColor('de'), owner: 'DE', ownerId: 'de' }); // a region nobody owns is simply absent - expect(m.FRA).toBeUndefined(); + expect(m['normandie-dddd']).toBeUndefined(); }); }); describe('regionOwner', () => { - it('resolves an owned region\'s ISO_A3 to its owner record', () => { - expect(regionOwner(regions, 'GBR')).toEqual({ + it('resolves an owned region key to its owner record', () => { + expect(regionOwner(regions, 'albania-aaaa')).toEqual({ color: nationColor('uk'), owner: 'UK', ownerId: 'uk', }); - expect(regionOwner(regions, 'DEU')).toEqual({ + expect(regionOwner(regions, 'brandenburg-cccc')).toEqual({ color: nationColor('de'), owner: 'DE', ownerId: 'de', @@ -62,11 +66,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, 'normandie-dddd')).toBeUndefined(); + expect(regionOwner(regions, 'yorkshire-eeee')).toBeUndefined(); }); - it('returns undefined for an empty or missing code', () => { + it('returns undefined for an empty or missing key', () => { expect(regionOwner(regions, '')).toBeUndefined(); expect(regionOwner(regions, null)).toBeUndefined(); expect(regionOwner(regions, undefined)).toBeUndefined(); @@ -74,28 +78,29 @@ 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, region-keyed). 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 sub-national: a feature's own sub-region id is a code + * (`DEU-03`), and its owner now lives at its **region key** (`brandenburg-cccc`), + * which the client stamps into `properties.regionKey` from `region-keys.json` + * (the RegionMap is keyed by that key, never the sub-region id nor the repacked + * id). This pins the exact data shape and proves the resolution: + * `mapFeatureOwner` works via `properties.regionKey`, while the two wrong paths + * (the repacked numeric `feature.id`, and the sub-region `regionId`, which is + * not a RegionMap key) do not. */ -describe('#6 — sub-region owner must be resolved from properties.parent, not the sub-region id / repacked feature.id', () => { +describe('#6 — sub-region owner must be resolved from properties.regionKey, not the sub-region id / 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). + // `regionId` (the sub-region code) and `regionKey` (the region key). const repackedFeature = { id: 0, - properties: { name: 'Germany R03', regionId: 'DEU-03', parent: 'DEU' }, + properties: { name: 'Brandenburg R03', regionId: 'DEU-03', regionKey: 'brandenburg-cccc' }, }; - it('resolves the owner from properties.parent (the fix)', () => { + it('resolves the owner from properties.regionKey (the fix)', () => { expect(mapFeatureOwner(regions, repackedFeature)).toEqual({ color: nationColor('de'), owner: 'DE', @@ -104,7 +109,7 @@ 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'. + // 'DEU-03' is a sub-region id; the RegionMap is keyed by region key. expect(regionOwner(regions, repackedFeature.properties.regionId)).toBeUndefined(); }); @@ -116,17 +121,17 @@ describe('#6 — sub-region owner must be resolved from properties.parent, not t }); describe('mapFeatureOwner', () => { - it('returns undefined for a missing feature, missing parent, or non-string parent', () => { + it('returns undefined for a missing feature, missing regionKey, or non-string regionKey', () => { 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: { regionKey: 42 } })).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 region key nobody owns', () => { + expect(mapFeatureOwner(regions, { properties: { regionKey: 'normandie-dddd' } })).toBeUndefined(); + expect(mapFeatureOwner(regions, { properties: { regionKey: 'yorkshire-eeee' } })).toBeUndefined(); }); }); diff --git a/server/regions.test.ts b/server/regions.test.ts new file mode 100644 index 0000000..0859ed6 --- /dev/null +++ b/server/regions.test.ts @@ -0,0 +1,103 @@ +import { afterAll, beforeEach, describe, expect, it } from 'vitest'; +import { fileURLToPath } from 'node:url'; +import { + allTerritoryKeys, + expandCountriesToKeys, + hasRegionData, + loadRegions, + referenceForKeys, + regionName, + resetRegions, + validKeys, +} from './regions'; +import type { GameState } from '../shared/types'; + +// The repo root: this file lives in server/, so '..' is the directory holding +// public/region-keys.json and public/regions.geo.json. cwd-independent (mirrors +// how server/index.ts locates its root via import.meta.url). +const root = fileURLToPath(new URL('..', import.meta.url)); + +describe('regions (region-key loader, v0.19 #16 P1)', () => { + beforeEach(() => { + expect(loadRegions(root)).toBe(true); + }); + + it('loads the full manifest: 1,796 unique valid region keys', () => { + expect(hasRegionData()).toBe(true); + expect(validKeys().size).toBe(1796); + }); + + it('expands an ISO_A3 country to the region keys of all its sub-regions', () => { + const fra = expandCountriesToKeys(['FRA']); + // FRA has 42 sub-regions in the committed dataset. + expect(fra).toHaveLength(42); + // every expanded key is a real region key + for (const k of fra) expect(validKeys().has(k)).toBe(true); + // and the reference table labels each one with its parent country + const ref = referenceForKeys(fra); + expect(Object.keys(ref)).toHaveLength(42); + for (const [k, label] of Object.entries(ref)) { + expect(label.endsWith('(FRA)')).toBe(true); + expect(regionName(k)).toBeTruthy(); + } + // sample known FRA keys, and their exact reference strings + expect(fra).toContain('ain-lqtt'); + expect(ref['ain-lqtt']).toBe('Ain (FRA)'); + expect(ref['aisne-gstu']).toBe('Aisne (FRA)'); + }); + + it('referenceForKeys labels only known keys and omits unknown ones', () => { + const ref = referenceForKeys(['ain-lqtt', 'not-a-real-key-xyz']); + expect(ref['ain-lqtt']).toBe('Ain (FRA)'); + expect(ref['not-a-real-key-xyz']).toBeUndefined(); + }); + + it('allTerritoryKeys unions every nation\'s territory keys (no overlap)', () => { + 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: expandCountriesToKeys(['GBR']), + relations: {}, + }, + fr: { + id: 'fr', + name: 'FR', + type: 'major', + stats: { military: 80, economy: 70, stability: 60, population: 50 }, + territory: expandCountriesToKeys(['FRA']), + relations: {}, + }, + }, + events: [], + pending: [], + version: 1, + }; + // 16 GBR sub-regions + 42 FRA sub-regions, no overlap between the two. + expect(allTerritoryKeys(state).size).toBe(58); + }); + + describe('graceful degradation with no region data', () => { + beforeEach(() => resetRegions()); + + it('expands to the raw input, omits references, and reports no data', () => { + expect(hasRegionData()).toBe(false); + // no manifest -> the country list passes through unchanged (old model) + expect(expandCountriesToKeys(['FRA', 'GBR'])).toEqual(['FRA', 'GBR']); + expect(referenceForKeys(['FRA'])).toEqual({}); + expect(validKeys().size).toBe(0); + expect(regionName('FRA')).toBeUndefined(); + }); + }); + + afterAll(() => resetRegions()); +});