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:
+105
-11
@@ -1,8 +1,9 @@
|
||||
import { describe, expect, it } from 'vitest';
|
||||
import { beforeAll, describe, expect, it } from 'vitest';
|
||||
import type { GameState, Preset, RoundItem } from '../shared/types';
|
||||
import { makeEvent, type TurnResult } from '../shared/protocol';
|
||||
import type { LlmClient } from './llm';
|
||||
import { makeStubLlmClient } from './stub';
|
||||
import { _setRegionsForTests } from './regions';
|
||||
import {
|
||||
applyTurnResult,
|
||||
buildSystemPrompt,
|
||||
@@ -24,6 +25,27 @@ function fakeLlm(responses: string[]): LlmClient {
|
||||
};
|
||||
}
|
||||
|
||||
// Give the region module a small, deterministic index so `buildUserPrompt`'s
|
||||
// `regions` reference table is populated in these fast unit tests (the real
|
||||
// 3.4 MB geo stays out of this file). The ids match the synthetic territories
|
||||
// below.
|
||||
beforeAll(() => {
|
||||
_setRegionsForTests(
|
||||
{
|
||||
'GB-01': { name: 'Greater London', parent: 'GBR' },
|
||||
'GB-02': { name: 'Scotland', parent: 'GBR' },
|
||||
'US-01': { name: 'New England', parent: 'USA' },
|
||||
'US-02': { name: 'The Midwest', parent: 'USA' },
|
||||
'PL-01': { name: 'Pomerania', parent: 'POL' },
|
||||
'DE-01': { name: 'Rhineland', parent: 'DEU' },
|
||||
},
|
||||
{ GBR: ['GB-01', 'GB-02'], USA: ['US-01', 'US-02'], POL: ['PL-01'], DEU: ['DE-01'] },
|
||||
);
|
||||
});
|
||||
|
||||
// As of v0.19 territory is a list of **sub-region ids** (the RegionMap keys),
|
||||
// not whole-country ISO_A3 codes. `us` also holds `PL-01` so that a canned or
|
||||
// stubbed gain of that region has a prior owner to move it from.
|
||||
const preset: Preset = {
|
||||
id: 'test',
|
||||
title: 'Test',
|
||||
@@ -37,7 +59,7 @@ const preset: Preset = {
|
||||
name: 'UK',
|
||||
type: 'major',
|
||||
stats: { military: 80, economy: 70, stability: 60, population: 50 },
|
||||
territory: ['GBR'],
|
||||
territory: ['GB-01', 'GB-02'],
|
||||
relations: { us: 'neutral', de: 'hostile' },
|
||||
},
|
||||
{
|
||||
@@ -45,7 +67,7 @@ const preset: Preset = {
|
||||
name: 'US',
|
||||
type: 'major',
|
||||
stats: { military: 60, economy: 90, stability: 80, population: 80 },
|
||||
territory: ['USA'],
|
||||
territory: ['US-01', 'US-02', 'PL-01'],
|
||||
relations: { uk: 'neutral' },
|
||||
},
|
||||
{
|
||||
@@ -53,7 +75,7 @@ const preset: Preset = {
|
||||
name: 'DE',
|
||||
type: 'major',
|
||||
stats: { military: 95, economy: 70, stability: 75, population: 90 },
|
||||
territory: ['DEU'],
|
||||
territory: ['DE-01'],
|
||||
relations: {},
|
||||
},
|
||||
],
|
||||
@@ -88,7 +110,7 @@ const validResult = JSON.stringify({
|
||||
{ nation: 'de', stat: 'economy', op: 'add', value: 10 },
|
||||
],
|
||||
relations: [{ a: 'uk', b: 'us', set: 'ally' }],
|
||||
territoryChanges: [{ nation: 'de', gain: ['POL'], lose: [] }],
|
||||
territoryChanges: [{ nation: 'de', gain: ['PL-01'], lose: [] }],
|
||||
});
|
||||
|
||||
describe('runTurn', () => {
|
||||
@@ -109,8 +131,9 @@ describe('runTurn', () => {
|
||||
// relations are symmetric
|
||||
expect(state.nations.uk.relations.us).toBe('ally');
|
||||
expect(state.nations.us.relations.uk).toBe('ally');
|
||||
// territory moved to DE
|
||||
expect(state.nations.de.territory).toContain('POL');
|
||||
// the region moved to DE, out of its prior owner (us)
|
||||
expect(state.nations.de.territory).toContain('PL-01');
|
||||
expect(state.nations.us.territory).not.toContain('PL-01');
|
||||
// one event appended, tagged with the turn that was resolved (1)
|
||||
expect(state.events).toHaveLength(1);
|
||||
expect(state.events[0].turn).toBe(1);
|
||||
@@ -121,7 +144,8 @@ describe('runTurn', () => {
|
||||
// and the original state object is NOT mutated
|
||||
expect(original.turn).toBe(1);
|
||||
expect(original.nations.uk.stats.military).toBe(80);
|
||||
expect(original.nations.de.territory).not.toContain('POL');
|
||||
expect(original.nations.us.territory).toContain('PL-01');
|
||||
expect(original.nations.de.territory).not.toContain('PL-01');
|
||||
});
|
||||
|
||||
it('clamps out-of-range stat values into 0-100', async () => {
|
||||
@@ -191,7 +215,7 @@ describe('runTurn', () => {
|
||||
});
|
||||
|
||||
describe('runTurn (batched round, deterministic stub)', () => {
|
||||
it('a single action item: turn+1, date+2, pending cleared, one military event, military set to clamp(cur+5)', async () => {
|
||||
it('a single action item: turn+1, date+2, pending cleared, one military event, military set to clamp(cur+5), one deterministic region gain', async () => {
|
||||
const original = baseState();
|
||||
original.pending = [{ kind: 'action', text: 'Fortify the coast and mobilize the home guard' }];
|
||||
const { state, result } = await runTurn({
|
||||
@@ -209,6 +233,12 @@ describe('runTurn (batched round, deterministic stub)', () => {
|
||||
expect(state.events[0].nation).toBe('uk');
|
||||
expect(state.events[0].outcome).toBe('success');
|
||||
expect(result.stateChanges).toEqual([{ nation: 'uk', stat: 'military', op: 'set', value: 85 }]);
|
||||
// the stub's one deterministic per-turn territory move: the player annexes
|
||||
// the lexicographically-first region of the first other nation (us holds
|
||||
// PL-01, US-01, US-02 → PL-01 wins), moved out of us.
|
||||
expect(result.territoryChanges).toEqual([{ nation: 'uk', gain: ['PL-01'], lose: [] }]);
|
||||
expect(state.nations.uk.territory).toEqual(['GB-01', 'GB-02', 'PL-01']);
|
||||
expect(state.nations.us.territory).toEqual(['US-01', 'US-02']);
|
||||
// committing a turn is a new revision too
|
||||
expect(state.version).toBe(2);
|
||||
// the original object is not mutated (a fresh queue was consumed)
|
||||
@@ -216,7 +246,7 @@ describe('runTurn (batched round, deterministic stub)', () => {
|
||||
expect(original.version).toBe(1);
|
||||
});
|
||||
|
||||
it('an empty round: turn advances, one political "world moves on" event, pending stays empty', async () => {
|
||||
it('an empty round: turn advances, one political "world moves on" event, no territory move', async () => {
|
||||
const { state } = await runTurn({
|
||||
llm: makeStubLlmClient(),
|
||||
preset,
|
||||
@@ -230,9 +260,13 @@ describe('runTurn (batched round, deterministic stub)', () => {
|
||||
expect(state.events[0].type).toBe('political');
|
||||
expect(state.events[0].title).toBe('The world moves on');
|
||||
expect(state.events[0].outcome).toBe('neutral');
|
||||
// an empty round makes no move: every territory is untouched
|
||||
expect(state.nations.uk.territory).toEqual(['GB-01', 'GB-02']);
|
||||
expect(state.nations.us.territory).toEqual(['US-01', 'US-02', 'PL-01']);
|
||||
expect(state.nations.de.territory).toEqual(['DE-01']);
|
||||
});
|
||||
|
||||
it('a round with two actions and one diplomacy: 3 events (2 military + 1 diplomatic), exactly one turn advance', async () => {
|
||||
it('a round with two actions and one diplomacy: 3 events (2 military + 1 diplomatic), exactly one turn advance, one region gain', async () => {
|
||||
const state = baseState();
|
||||
state.pending = [
|
||||
{ kind: 'action', text: 'Reinforce the Atlantic convoys' },
|
||||
@@ -251,6 +285,9 @@ describe('runTurn (batched round, deterministic stub)', () => {
|
||||
expect(next.events.filter((e) => e.type === 'military')).toHaveLength(2);
|
||||
expect(next.events.filter((e) => e.type === 'diplomatic')).toHaveLength(1);
|
||||
expect(next.nations.uk.stats.military).toBe(85); // one +5 per turn, not per item
|
||||
// …and one (not three) territory move for the whole batch
|
||||
expect(next.nations.uk.territory).toEqual(['GB-01', 'GB-02', 'PL-01']);
|
||||
expect(next.nations.us.territory).toEqual(['US-01', 'US-02']);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -264,6 +301,7 @@ describe('buildUserPrompt (batched round)', () => {
|
||||
pending: RoundItem[];
|
||||
playerActions: unknown[];
|
||||
roundInstruction: string;
|
||||
regions: Record<string, string>;
|
||||
};
|
||||
expect(parsed.pending).toEqual(items);
|
||||
expect(parsed.playerActions).toEqual(['Fortify Dover', { to: 'us', message: 'Propose a pact' }]);
|
||||
@@ -271,6 +309,20 @@ describe('buildUserPrompt (batched round)', () => {
|
||||
expect(parsed.roundInstruction).toContain('Propose a pact');
|
||||
});
|
||||
|
||||
it('emits a regions reference table ("id → name (parent)") covering every territory id in the state', () => {
|
||||
const parsed = JSON.parse(buildUserPrompt(baseState(), [])) as {
|
||||
regions: Record<string, string>;
|
||||
state: { nations: Record<string, { territory: string[] }> };
|
||||
};
|
||||
// the table is keyed by the union of all nations' (sub-region) territory ids
|
||||
const allIds = Object.values(parsed.state.nations).flatMap((n) => n.territory);
|
||||
expect(Object.keys(parsed.regions).sort()).toEqual([...allIds].sort());
|
||||
// each id maps to a human "name (parent)" label from the region index
|
||||
expect(parsed.regions['GB-01']).toBe('Greater London (GBR)');
|
||||
expect(parsed.regions['DE-01']).toBe('Rhineland (DEU)');
|
||||
expect(parsed.regions['PL-01']).toBe('Pomerania (POL)');
|
||||
});
|
||||
|
||||
it('frames an empty round as "the world advances on its own momentum"', () => {
|
||||
const parsed = JSON.parse(buildUserPrompt(baseState(), [])) as {
|
||||
pending: RoundItem[];
|
||||
@@ -461,6 +513,48 @@ describe('applyTurnResult', () => {
|
||||
expect(state.pending).toHaveLength(1); // original untouched
|
||||
expect(state.version).toBe(1);
|
||||
});
|
||||
|
||||
it('moves a gained region out of its prior owner (region-level transfer)', () => {
|
||||
const state = baseState();
|
||||
const result: TurnResult = {
|
||||
date: '1939-09-03',
|
||||
events: [],
|
||||
stateChanges: [],
|
||||
relations: [],
|
||||
territoryChanges: [{ nation: 'de', gain: ['PL-01'], lose: [] }],
|
||||
};
|
||||
const next = applyTurnResult(state, result);
|
||||
expect(next.nations.de.territory).toContain('PL-01');
|
||||
expect(next.nations.us.territory).not.toContain('PL-01'); // spliced out of the prior owner
|
||||
expect(next.nations.uk.territory).toEqual(['GB-01', 'GB-02']);
|
||||
});
|
||||
|
||||
it('adds a region nobody owned (no prior owner to splice from)', () => {
|
||||
const state = baseState();
|
||||
const result: TurnResult = {
|
||||
date: '1939-09-03',
|
||||
events: [],
|
||||
stateChanges: [],
|
||||
relations: [],
|
||||
territoryChanges: [{ nation: 'uk', gain: ['FR-57'], lose: [] }],
|
||||
};
|
||||
const next = applyTurnResult(state, result);
|
||||
expect(next.nations.uk.territory).toEqual(['GB-01', 'GB-02', 'FR-57']);
|
||||
});
|
||||
|
||||
it('drops a lost region with no gainer (it becomes unowned / no-man\'s-land)', () => {
|
||||
const state = baseState();
|
||||
const result: TurnResult = {
|
||||
date: '1939-09-03',
|
||||
events: [],
|
||||
stateChanges: [],
|
||||
relations: [],
|
||||
territoryChanges: [{ nation: 'us', gain: [], lose: ['PL-01'] }],
|
||||
};
|
||||
const next = applyTurnResult(state, result);
|
||||
expect(next.nations.us.territory).toEqual(['US-01', 'US-02']);
|
||||
expect(Object.values(next.nations).every((n) => !n.territory.includes('PL-01'))).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
describe('isMeaningful', () => {
|
||||
|
||||
Reference in New Issue
Block a user