No man's land: region release + re-claim + distinct grey map rendering (v0.22.0) — part 3 of #16 #37

Merged
kamilkrzywda merged 2 commits from feat/16-p3-no-mans-land into master 2026-10-14 14:09:22 +02:00
16 changed files with 602 additions and 62 deletions
+23 -8
View File
@@ -52,7 +52,7 @@ history/
├── eslint.config.js # flat config (ESLint 9) ├── eslint.config.js # flat config (ESLint 9)
├── tsconfig.json # strict; aliases @/ → src/, @shared/ → shared/ ├── tsconfig.json # strict; aliases @/ → src/, @shared/ → shared/
├── vite.config.ts # dev server + proxy /api,/sse → :4321; @ / @shared aliases ├── vite.config.ts # dev server + proxy /api,/sse → :4321; @ / @shared aliases
├── vitest.config.ts # unit tests (server/**/*.test.ts + scripts/**/*.test.ts; 105 tests) ├── vitest.config.ts # unit tests (server/**/*.test.ts + scripts/**/*.test.ts; 127 tests)
├── playwright.config.ts # deterministic e2e (webServer runs npm run dev, LLM_STUB=1) ├── playwright.config.ts # deterministic e2e (webServer runs npm run dev, LLM_STUB=1)
├── index.html ├── index.html
├── presets/ # 5 scenario JSON files + index.json (manifest) ├── presets/ # 5 scenario JSON files + index.json (manifest)
@@ -67,10 +67,10 @@ history/
│ └── *.test.ts # turf-free vitest tests pinning the dataset + both manifests │ └── *.test.ts # turf-free vitest tests pinning the dataset + both manifests
├── shared/ # NO runtime deps — the single source of truth ├── shared/ # NO runtime deps — the single source of truth
│ ├── types.ts # domain types (Nation, GameState, RoundItem, Preset, …) │ ├── types.ts # domain types (Nation, GameState, RoundItem, Preset, …)
│ └── protocol.ts # LLM TurnResult schema + AddAction/AddDiplomacy/RemoveQueueItem/Jump request types + pure helpers │ └── protocol.ts # LLM TurnResult schema (incl. `unowned`, v0.22 #16 P3) + AddAction/AddDiplomacy/RemoveQueueItem/Jump request types + pure helpers (incl. the no-man's-land map-presentation contract)
├── server/ ├── server/
│ ├── index.ts # Express: /api/* (health|action|diplomacy|queue/remove|jump|advisor|games|map|presets), /sse/:id; reads PORT/LLM_*/DATA_DIR │ ├── index.ts # Express: /api/* (health|action|diplomacy|queue/remove|jump|advisor|games|map|presets), /sse/:id; reads PORT/LLM_*/DATA_DIR
│ ├── engine.ts # pure: prompts / parse+validate / applyTurnResult (sole-ownership) / runTurn (tool path + plain fallback) / getNeighbours tool + executor / playerFrontier / queue ops / advisor │ ├── engine.ts # pure: prompts / parse+validate / applyTurnResult (sole-ownership + no-man's-land releases, v0.22 #16 P3) / runTurn (tool path + plain fallback) / getNeighbours tool + executor / playerFrontier / queue ops / advisor
│ ├── llm.ts # LlmClient: chat() + chatWithTools() (bounded function-calling loop), injectable transport, retry+backoff, logging + env config │ ├── llm.ts # LlmClient: chat() + chatWithTools() (bounded function-calling loop), injectable transport, retry+backoff, logging + env config
│ ├── regions.ts # turf-free region-key data access: load manifest/geo/adjacency (public/ then dist/), country→keys expansion, reference table, neighboursOf/hasAdjacency │ ├── regions.ts # turf-free region-key data access: load manifest/geo/adjacency (public/ then dist/), country→keys expansion, reference table, neighboursOf/hasAdjacency
│ ├── stub.ts # deterministic LLM transport (tests/e2e): one frontier conquest per turn + ADVISOR_MARKER │ ├── stub.ts # deterministic LLM transport (tests/e2e): one frontier conquest per turn + ADVISOR_MARKER
@@ -105,7 +105,7 @@ history/
| `npm run dev` | Express on :4321 (tsx watch) + Vite on :5173 (proxies `/api`,`/sse`) concurrently | | `npm run dev` | Express on :4321 (tsx watch) + Vite on :5173 (proxies `/api`,`/sse`) concurrently |
| `npm run build` | `vite build` (client → `dist/`) then `tsup server/index.ts` → `server/dist/index.js` | | `npm run build` | `vite build` (client → `dist/`) then `tsup server/index.ts` → `server/dist/index.js` |
| `npm start` | run the compiled server (`node server/dist/index.js`) on :4321 | | `npm start` | run the compiled server (`node server/dist/index.js`) on :4321 |
| `npm test` | vitest unit tests (`server/` + `scripts/`; 105 tests) | | `npm test` | vitest unit tests (`server/` + `scripts/`; 127 tests) |
| `npm run typecheck` | `tsc --noEmit` (strict) across src/server/shared/e2e/configs | | `npm run typecheck` | `tsc --noEmit` (strict) across src/server/shared/e2e/configs |
| `npm run lint` | ESLint flat config | | `npm run lint` | ESLint flat config |
| `npm run e2e` | Playwright, fully deterministic (`LLM_STUB=1`); **runs in CI on every PR** (see below) | | `npm run e2e` | Playwright, fully deterministic (`LLM_STUB=1`); **runs in CI on every PR** (see below) |
@@ -171,7 +171,19 @@ history/
transfer; the user payload carries the player's `frontier` (the sorted other-nation keys bordering transfer; the user payload carries the player's `frontier` (the sorted other-nation keys bordering
the player's territory, via `playerFrontier`). The tool path degrades gracefully: without the the player's territory, via `playerFrontier`). The tool path degrades gracefully: without the
adjacency manifest (or with a client lacking `chatWithTools`), a turn runs the legacy plain adjacency manifest (or with a client lacking `chatWithTools`), a turn runs the legacy plain
`chat()` path and the tool/frontier simply switch off. `chat()` path and the tool/frontier simply switch off. Since v0.22 (#16 P3) a key may also end
a turn **unowned** — "no man's land": `TurnResult.unowned` lists keys released this turn;
`applyTurnResult` applies releases FIRST (a key leaves its sole owner — only a key with exactly
one current owner can be released; a never-assigned or corrupt key is a no-op), then claims
against the post-release snapshot, so a `gain` on an ownerless key is a **re-claim** (granted to
the sole gainer and removed from the cumulative `GameState.unowned` list). The user payload
carries the current `GameState.unowned` as `unowned`, and the jump route's `regions` reference
table includes those keys so the model can name them when re-claiming. The map renders a
released key in the distinct visible grey (`NO_MANS_LAND_FILL` in `shared/protocol.ts`, with a
"No man's land" tooltip sub-line) while a preset-unassigned key keeps the faint look ("No owner")
— the distinction is decided by the client from `GameState.unowned` (via `regionFillStyle` /
`unownedTooltipLabel`), never by the `RegionMap`, which only knows "owned" vs "absent". A fresh
game always starts with `unowned: []` (legacy SQLite rows load with it defaulted).
- **Map click → dossier target (owner resolved from the RegionMap, by region key).** - **Map click → dossier target (owner resolved from the RegionMap, by region key).**
Clicking a sub-region does NOT open a popup — it switches the dossier to that sub-region's Clicking a sub-region does NOT open a popup — it switches the dossier to that sub-region's
owning nation (`focusNation(ownerId, {name})` in `App.tsx`, which also records which region was owning nation (`focusNation(ownerId, {name})` in `App.tsx`, which also records which region was
@@ -194,9 +206,12 @@ history/
date advances 2 days. Since v0.21 the stub also makes **one contiguous conquest per turn**: the date advances 2 days. Since v0.21 the stub also makes **one contiguous conquest per turn**: the
player gains `frontier[0]` (the first of the sorted `frontier` list from the prompt payload — player gains `frontier[0]` (the first of the sorted `frontier` list from the prompt payload —
stable) and that region's owner loses it; an empty frontier (no controllable border, e.g. the UK stable) and that region's owner loses it; an empty frontier (no controllable border, e.g. the UK
in `wwii-1939`) transfers nothing. The stub answers plain `content` (never tool calls), so it in `wwii-1939`) transfers nothing. Since v0.22 (#16 P3) the stub **never releases** a region to
naturally bypasses the tool loop in a single round. Unit + e2e assertions rely on these exact no man's land (it always returns `unowned: []` and ignores the payload's `unowned` field) — its
values, so keep the stub deterministic. one-conquest behavior is what the e2e assertions rely on, so the release/re-claim path is
covered by unit tests only (`server/engine.test.ts`, `server/map.test.ts`). The stub answers
plain `content` (never tool calls), so it naturally bypasses the tool loop in a single round.
Unit + e2e assertions rely on these exact values, so keep the stub deterministic.
- **HUD layout.** The game screen is a full-viewport map (z-0) with `FloatingWindow` overlays. The - **HUD layout.** The game screen is a full-viewport map (z-0) with `FloatingWindow` overlays. The
top bar is **pinned** — a fixed, non-movable bar centered at the top a few px down (it re-centers top bar is **pinned** — a fixed, non-movable bar centered at the top a few px down (it re-centers
on resize and always floats above the other windows). The **dossier / event-feed / action** on resize and always floats above the other windows). The **dossier / event-feed / action**
+11
View File
@@ -2,6 +2,17 @@
All notable changes to **History** are listed here, newest first. All notable changes to **History** are listed here, newest first.
## v0.22.0 — 2026-10-14 12:02
- **Part 3 of #16: no man's land — a region can be left unowned mid-game and re-claimed later.** After a war or cataclysm the LLM can leave a region without an owner: it lists the key in the new `TurnResult.unowned` field (and narrates the cause in `events`); the engine removes the key from its sole owner, and the region ends the turn ownerless. A later ordinary `gain` on that key is a **re-claim** — the key is granted to the (sole) gainer and removed from the no-man's-land list. A fresh game always starts fully owned (no man's land can only arise mid-game).
- **Engine (`server/engine.ts`).** `applyTurnResult` applies **releases first**: a key is released only when it has exactly one current owner (0 owners — a preset never assigned it — is a no-op, so no man's land can never be fabricated from an ordinary unowned region; 2+ owners — corruption — is a no-op; already-unowned is a no-op). Claims are then resolved against the post-release ownership snapshot: sole gainer + sole owner ≠ gainer = a transfer (the v0.21 rules, unchanged); sole gainer + no owner = a re-claim; a double claim is still rejected. Invariants after every turn: no key sits in two or more territories, a released key sits in none, and `GameState.unowned` holds exactly the keys released mid-game. A lone release counts as `isMeaningful` (it survives the retry/nudge path instead of being replaced by the generic "unclear" result). The system prompt gains the JSON field, the no-man's-land rule (in both tool-mode and plain-mode), and the hard constraint that `unowned` may contain only region keys a nation currently owns. The user payload always carries the current `GameState.unowned` as `unowned` (even when empty), and the jump route's `regions` reference table now includes the released keys so the model can name them when re-claiming.
- **New required `GameState.unowned` field** (`shared/types.ts`): the cumulative list of keys released mid-game. A no-man's-land region is **distinct from a preset-unassigned region** — the presets assign only ~25–30% of the 1,796 sub-regions (e.g. `wwii-1939` starts with 411 owned / 1,385 unowned), so ~1,300 ownerless regions exist at init; those keep the existing faint look, and only `GameState.unowned` keys get the distinct one. Legacy SQLite rows saved before v0.22 load with the field defaulted to `[]` (`server/db.ts`), and game creation sets it to `[]`.
- **Map presentation (a new contract in `shared/protocol.ts`).** `regionFillStyle(owner, isNoMansLand)` + `unownedTooltipLabel` decide the look: an owned region is unchanged (nation color + `OWNED_STROKE`); a released region gets the distinct visible grey `NO_MANS_LAND_FILL = '#3f3f46'` (hue-less by construction, so it can never collide with a nation's `hsl(h 58% 46%)` color) with a brighter border (`NO_MANS_LAND_STROKE`) and the tooltip sub-line "No man's land"; every other ownerless region keeps the faint `rgba(148,163,184,0.05)` fill and "No owner". The client (`WorldMap`, via a new `unowned` prop from `App.tsx`) is the sole consumer of the distinction — the `RegionMap` simply has no entry for an unowned key (`buildRegionMap` is unchanged). Clicking a no-man's-land region does not change the dossier (there is no owner to focus), and a release/re-claim flashes like any other ownership change.
- **Stub + tests.** The deterministic stub is deliberately **unchanged** — it never releases a region (it always returns `unowned: []` and ignores the payload's `unowned` field), because its one-conquest behavior is what the e2e assertions rely on; the release/re-claim path is therefore covered by **unit tests only** (a new `applyTurnResult (no man's land)` suite: release, re-claim, release+claim of the same key, a double claim on a released key, the four no-op release cases, a legacy state without the field, and a `runTurn` end-to-end test with a narrated release; new `buildUserPrompt`/`buildSystemPrompt` contract tests; a presentation-contract suite in `server/map.test.ts`; a legacy-row normalization test in `server/db.test.ts`). The unit suite grows **105 → 127**.
- **No data-format wipe.** The `GameState` shape only gains a field that legacy rows load with, so existing saves remain compatible: the deploy is a plain `up -d --build` (no `down -v`).
- **Verification.** Typecheck, lint, **127 unit tests**, and the production build all pass. The e2e suite (CI, `LLM_STUB=1`) carries no map-color/no-man's-land assertions, so it stays green with the unchanged stub.
- **Deploy:** manual `docker compose up -d --build` on the host (no `down -v`).
- Part of #16 — no man's land (P3); #16 stays open.
## v0.21.0 — 2026-10-14 10:37 ## v0.21.0 — 2026-10-14 10:37
- **Part 2 of #16: territory now moves region-by-region along real land borders, and the LLM can verify borders with a live tool.** v0.19 made ownership region-keyed, but the model's `territoryChanges` were still applied wholesale — any key could jump to any nation, effectively moving whole countries. Now: - **Part 2 of #16: territory now moves region-by-region along real land borders, and the LLM can verify borders with a live tool.** v0.19 made ownership region-keyed, but the model's `territoryChanges` were still applied wholesale — any key could jump to any nation, effectively moving whole countries. Now:
- **Region-adjacency manifest (Stage 1).** `scripts/build-region-adjacency.mjs` (`npm run build:region-adjacency`) computes which of the 1,796 sub-regions share a land border: per candidate pair, a cheap bbox-proximity prefilter (0.05° gap reject) then a `@turf/boolean-intersects` arbiter. The output `public/region-adjacency.json` is symmetric and deduped — **1,796 entries, 4,470 border pairs (8,940 directed), max degree 19; 1,727 regions have ≥1 neighbour, 69 don't** — and is deterministic/idempotent (sha256 `e9f1d475…`; re-running is byte-identical). Vite copies it into `dist/` for prod; `server/regions.ts` loads it (turf-free — plain `JSON.parse`, no `@turf` import in any `.ts`) and degrades gracefully when it is missing. - **Region-adjacency manifest (Stage 1).** `scripts/build-region-adjacency.mjs` (`npm run build:region-adjacency`) computes which of the 1,796 sub-regions share a land border: per candidate pair, a cheap bbox-proximity prefilter (0.05° gap reject) then a `@turf/boolean-intersects` arbiter. The output `public/region-adjacency.json` is symmetric and deduped — **1,796 entries, 4,470 border pairs (8,940 directed), max degree 19; 1,727 regions have ≥1 neighbour, 69 don't** — and is deterministic/idempotent (sha256 `e9f1d475…`; re-running is byte-identical). Vite copies it into `dist/` for prod; `server/regions.ts` loads it (turf-free — plain `JSON.parse`, no `@turf` import in any `.ts`) and degrades gracefully when it is missing.
+1 -1
View File
@@ -1,6 +1,6 @@
{ {
"name": "history", "name": "history",
"version": "0.21.0", "version": "0.22.0",
"private": true, "private": true,
"type": "module", "type": "module",
"description": "AI-driven alternate-history simulator — the LLM is the world engine (Pax Historia style).", "description": "AI-driven alternate-history simulator — the LLM is the world engine (Pax Historia style).",
+12
View File
@@ -23,6 +23,7 @@ function makeState(id: string): GameState {
events: [], events: [],
pending: [], pending: [],
version: 1, version: 1,
unowned: [],
}; };
} }
@@ -73,6 +74,17 @@ describe('openDb', () => {
db.close(); db.close();
}); });
it('(v0.22 #16 P3) a legacy row saved before the unowned field existed loads with it defaulted to []', () => {
const db = openDb(':memory:');
const legacy = makeState('legacy');
delete (legacy as { unowned?: string[] }).unowned; // a pre-v0.22 save (field absent)
db.save(legacy);
const loaded = db.load('legacy');
expect(loaded).not.toBeNull();
expect(loaded!.unowned).toEqual([]);
db.close();
});
it('delete removes a game by id and reports absence otherwise', () => { it('delete removes a game by id and reports absence otherwise', () => {
const db = openDb(':memory:'); const db = openDb(':memory:');
db.save(makeState('a')); db.save(makeState('a'));
+5 -1
View File
@@ -39,7 +39,11 @@ export function openDb(filePath: string): Db {
const row = byId.get(id) as { data: string } | undefined; const row = byId.get(id) as { data: string } | undefined;
if (!row) return null; if (!row) return null;
try { try {
return JSON.parse(row.data) as GameState; const state = JSON.parse(row.data) as GameState;
// (v0.22, #16 P3) Rows saved before `GameState.unowned` existed load
// with it defaulted: a legacy game has no no-man's-land regions.
if (state.unowned === undefined) state.unowned = [];
return state;
} catch { } catch {
return null; return null;
} }
+216 -7
View File
@@ -78,6 +78,7 @@ function baseState(): GameState {
events: [], events: [],
pending: [], pending: [],
version: 1, version: 1,
unowned: [],
}; };
} }
@@ -92,8 +93,8 @@ const validResult = JSON.stringify({
], ],
relations: [{ a: 'uk', b: 'us', set: 'ally' }], relations: [{ a: 'uk', b: 'us', set: 'ally' }],
// v0.21 (#16 P2): DE gains the UK's GBR — the key has exactly one previous // v0.21 (#16 P2): DE gains the UK's GBR — the key has exactly one previous
// owner, so the transfer applies (an unowned key like 'POL' would be ignored // owner, so the transfer applies (since v0.22 #16 P3 a key owned by NOBODY
// by the new sole-ownership rules in applyTurnResult). // would be re-claimed instead of ignored; a release lives in "unowned").
territoryChanges: [{ nation: 'de', gain: ['GBR'], lose: [] }], territoryChanges: [{ nation: 'de', gain: ['GBR'], lose: [] }],
}); });
@@ -338,6 +339,22 @@ describe('buildUserPrompt (batched round)', () => {
const withoutRef = JSON.parse(buildUserPrompt(baseState(), [])) as { regions?: Record<string, string> }; const withoutRef = JSON.parse(buildUserPrompt(baseState(), [])) as { regions?: Record<string, string> };
expect(withoutRef.regions).toBeUndefined(); expect(withoutRef.regions).toBeUndefined();
}); });
it('(v0.22 #16 P3) emits the no-man\'s-land list when provided (even when empty) and omits it otherwise', () => {
// runTurn always passes state.unowned (even []), so the field is present...
const withEmpty = JSON.parse(buildUserPrompt(baseState(), [], undefined, undefined, [])) as {
unowned?: string[];
};
expect(withEmpty.unowned).toEqual([]);
// ...and a populated list reaches the model verbatim (it knows what it can re-claim)
const withKeys = JSON.parse(
buildUserPrompt(baseState(), [], undefined, undefined, ['GBR', 'POL']),
) as { unowned?: string[] };
expect(withKeys.unowned).toEqual(['GBR', 'POL']);
// but a caller that does not provide it keeps the legacy payload shape
const omitted = JSON.parse(buildUserPrompt(baseState(), [])) as { unowned?: string[] };
expect(omitted.unowned).toBeUndefined();
});
}); });
describe('queueRoundItem', () => { describe('queueRoundItem', () => {
@@ -460,6 +477,7 @@ describe('applyTurnResult', () => {
stateChanges: [{ nation: 'uk', stat: 'military', op: 'set', value: 99 }], stateChanges: [{ nation: 'uk', stat: 'military', op: 'set', value: 99 }],
relations: [], relations: [],
territoryChanges: [], territoryChanges: [],
unowned: [],
}; };
applyTurnResult(state, result); applyTurnResult(state, result);
expect(JSON.stringify(state)).toBe(snapshot); expect(JSON.stringify(state)).toBe(snapshot);
@@ -474,6 +492,7 @@ describe('applyTurnResult', () => {
stateChanges: [], stateChanges: [],
relations: [], relations: [],
territoryChanges: [], territoryChanges: [],
unowned: [],
}; };
const next = applyTurnResult(state, result); const next = applyTurnResult(state, result);
expect(next.pending).toEqual([]); expect(next.pending).toEqual([]);
@@ -494,6 +513,7 @@ describe('applyTurnResult', () => {
// 'wales-bbbb' is a real key (owned by uk in the fixture); 'not-a-region-xyz' // '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. // 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: [] }], territoryChanges: [{ nation: 'de', gain: ['wales-bbbb', 'not-a-region-xyz'], lose: [] }],
unowned: [],
}; };
const next = applyTurnResult(state, result, new Set(['wales-bbbb'])); const next = applyTurnResult(state, result, new Set(['wales-bbbb']));
// the valid key was gained (taken from its sole previous owner); the bogus one was ignored // the valid key was gained (taken from its sole previous owner); the bogus one was ignored
@@ -514,13 +534,16 @@ describe('applyTurnResult', () => {
stateChanges: [], stateChanges: [],
relations: [], relations: [],
// 'wales-bbbb' has a sole previous owner (uk) -> it transfers; 'POL' is // 'wales-bbbb' has a sole previous owner (uk) -> it transfers; 'POL' is
// owned by no one -> it is IGNORED (a key may not end a turn unowned). // owned by no one -> a re-claim (v0.22, #16 P3): DE gains it. It was never
// released (not in state.unowned), so it does not enter GameState.unowned.
territoryChanges: [{ nation: 'de', gain: ['POL', 'wales-bbbb'], lose: [] }], territoryChanges: [{ nation: 'de', gain: ['POL', 'wales-bbbb'], lose: [] }],
unowned: [],
}; };
const next = applyTurnResult(state, result); // no validKeys -> nothing filtered const next = applyTurnResult(state, result); // no validKeys -> nothing filtered
expect(next.nations.de.territory).toContain('wales-bbbb'); expect(next.nations.de.territory).toContain('wales-bbbb');
expect(next.nations.uk.territory).not.toContain('wales-bbbb'); expect(next.nations.uk.territory).not.toContain('wales-bbbb');
expect(next.nations.de.territory).not.toContain('POL'); expect(next.nations.de.territory).toContain('POL'); // re-claimed, not ignored
expect(next.unowned).toEqual([]); // never released -> not no man's land
}); });
}); });
@@ -530,6 +553,7 @@ describe('applyTurnResult (sole-ownership invariants, v0.21 #16 P2)', () => {
events: [], events: [],
stateChanges: [], stateChanges: [],
relations: [], relations: [],
unowned: [],
}; };
it('a double claim (two different gainers, one key) is rejected: the key stays with its original owner', () => { it('a double claim (two different gainers, one key) is rejected: the key stays with its original owner', () => {
@@ -610,6 +634,178 @@ describe('applyTurnResult (sole-ownership invariants, v0.21 #16 P2)', () => {
}); });
}); });
describe('applyTurnResult (no man\'s land, v0.22 #16 P3)', () => {
const noEvents = {
date: '1939-09-03',
events: [],
stateChanges: [],
relations: [],
territoryChanges: [],
unowned: [],
};
it('a release removes the key from its sole owner and marks it in GameState.unowned', () => {
const state = baseState();
const next = applyTurnResult(state, { ...noEvents, unowned: ['GBR'] });
expect(next.nations.uk.territory).not.toContain('GBR');
expect(next.unowned).toEqual(['GBR']);
// nothing else moved
expect(next.nations.us.territory).toEqual(['USA']);
expect(next.nations.de.territory).toEqual(['DEU']);
});
it('a re-claim (a gain on a currently-unowned key) grants it and removes it from GameState.unowned', () => {
const state = baseState();
state.unowned = ['POL']; // released in an earlier turn
const next = applyTurnResult(state, {
...noEvents,
territoryChanges: [{ nation: 'de', gain: ['POL'], lose: [] }],
});
expect(next.nations.de.territory).toContain('POL');
expect(next.unowned).toEqual([]); // it left no man's land
});
it('a release and a claim of the same key in one turn: the claim wins (the key is owned)', () => {
const state = baseState();
const next = applyTurnResult(state, {
...noEvents,
territoryChanges: [{ nation: 'de', gain: ['GBR'], lose: [] }],
unowned: ['GBR'],
});
expect(next.nations.de.territory).toContain('GBR');
expect(next.nations.uk.territory).not.toContain('GBR');
expect(next.unowned).toEqual([]);
});
it('a double claim on a released key is rejected: the key stays in no man\'s land', () => {
const state = baseState();
const next = applyTurnResult(state, {
...noEvents,
territoryChanges: [
{ nation: 'de', gain: ['GBR'], lose: [] },
{ nation: 'us', gain: ['GBR'], lose: [] },
],
unowned: ['GBR'], // released first, then both nations lunge for it
});
expect(next.nations.uk.territory).not.toContain('GBR'); // the release applied
expect(next.nations.de.territory).not.toContain('GBR'); // both claims rejected
expect(next.nations.us.territory).not.toContain('GBR');
expect(next.unowned).toEqual(['GBR']);
});
it('releasing a key no nation owns (a preset-unassigned region) is a no-op — no man\'s land can only arise from a real release', () => {
const state = baseState();
const next = applyTurnResult(state, { ...noEvents, unowned: ['POL'] });
expect(next.unowned).toEqual([]);
for (const n of Object.values(next.nations)) expect(n.territory).not.toContain('POL');
});
it('releasing a key with two owners (a corrupt state) is a no-op', () => {
const state = baseState();
state.nations.us.territory = ['USA', 'GBR']; // uk AND us share GBR
const next = applyTurnResult(state, { ...noEvents, unowned: ['GBR'] });
expect(next.nations.uk.territory).toContain('GBR');
expect(next.nations.us.territory).toContain('GBR');
expect(next.unowned).toEqual([]);
});
it('releasing a key already in no man\'s land is a no-op (it stays listed once)', () => {
const state = baseState();
state.unowned = ['POL'];
const next = applyTurnResult(state, { ...noEvents, unowned: ['POL'] });
expect(next.unowned).toEqual(['POL']);
});
it('with a validKeys set: releasing a hallucinated key is a no-op', () => {
const state = baseState();
const next = applyTurnResult(state, { ...noEvents, unowned: ['not-a-region-xyz'] }, new Set(['wales-bbbb']));
expect(next.unowned).toEqual([]);
});
it('a legacy state without the unowned field accepts a release (the result carries the field)', () => {
const state = baseState();
delete (state as { unowned?: string[] }).unowned; // simulate a pre-v0.22 row
const next = applyTurnResult(state, { ...noEvents, unowned: ['GBR'] });
expect(next.unowned).toEqual(['GBR']);
expect(next.nations.uk.territory).not.toContain('GBR');
});
it('a turn with no releases leaves the existing no man\'s land list unchanged', () => {
const state = baseState();
state.unowned = ['POL'];
const next = applyTurnResult(state, {
...noEvents,
territoryChanges: [{ nation: 'de', gain: ['GBR'], lose: [] }],
});
expect(next.unowned).toEqual(['POL']);
expect(next.nations.de.territory).toContain('GBR');
});
it('validateTurnResult dedupes and filters the unowned list (trimmed non-empty strings only)', () => {
const r = validateTurnResult({
date: '1939-09-03',
events: [],
stateChanges: [],
relations: [],
territoryChanges: [],
unowned: [' a ', 'a', '', 42, null, ' b '],
});
expect(r).not.toBeNull();
expect(r!.unowned).toEqual(['a', 'b']);
});
it('a lone release is meaningful (it survives the retry/nudge path)', () => {
expect(
isMeaningful({ date: 'x', events: [], stateChanges: [], relations: [], territoryChanges: [], unowned: ['GBR'] }),
).toBe(true);
});
it('runTurn applies a release end-to-end: the model narrates it and the key leaves its owner', async () => {
const original = baseState();
original.pending = [{ kind: 'action', text: 'Scorch the earth on the retreat' }];
const releaseJson = JSON.stringify({
date: '1939-09-05',
events: [
{
nation: 'uk',
type: 'military',
title: 'A retreat through the wastelands',
text: 'The abandoned highlands burn and lie empty.',
outcome: 'partial',
},
],
stateChanges: [],
relations: [],
territoryChanges: [],
unowned: ['GBR'],
});
const payloads: string[] = [];
const client: LlmClient = {
chat: async (messages) => {
const user = messages.find((m) => m.role === 'user');
if (user) payloads.push(user.content as string);
return releaseJson;
},
listModels: async () => [],
};
const { state } = await runTurn({ llm: client, preset, state: original, items: original.pending });
// the event landed and the release applied
expect(state.events).toHaveLength(1);
expect(state.events[0].title).toBe('A retreat through the wastelands');
expect(state.nations.uk.territory).not.toContain('GBR');
expect(state.unowned).toEqual(['GBR']);
// on the first turn the model was shown the (empty) no-man's-land list...
const first = JSON.parse(payloads[0]) as { unowned?: string[] };
expect(first.unowned).toEqual([]);
// ...and on the next turn it is shown the released key (so it can re-claim it)
const next = await runTurn({ llm: client, preset, state, items: [] });
const second = JSON.parse(payloads[1]) as { unowned?: string[] };
expect(second.unowned).toEqual(['GBR']);
// the repeated "release" of an already-unowned key is a no-op
expect(next.state.unowned).toEqual(['GBR']);
});
});
describe('makeNeighboursExecutor (v0.21 #16 P2)', () => { describe('makeNeighboursExecutor (v0.21 #16 P2)', () => {
const getNeighbours = (keys: string[]) => keys.map((k) => `nb-${k}`); const getNeighbours = (keys: string[]) => keys.map((k) => `nb-${k}`);
@@ -850,12 +1046,25 @@ describe('buildSystemPrompt (tool mode, v0.21 #16 P2)', () => {
expect(withTool).toMatch(/frontier/i); expect(withTool).toMatch(/frontier/i);
expect(withTool).not.toMatch(/Only change borders when war/i); expect(withTool).not.toMatch(/Only change borders when war/i);
}); });
it('(v0.22 #16 P3) both modes contract the no-man\'s-land field (release + re-claim) and constrain it to owned keys', () => {
for (const p of [buildSystemPrompt(preset), buildSystemPrompt(preset, true)]) {
// the JSON shape carries the field in both modes
expect(p).toMatch(/"unowned": \["<region key>"\]/);
// the rule explains release (narrated in events) and re-claim (ordinary gain)
expect(p).toMatch(/no man's land/i);
expect(p).toMatch(/narrate the cause in "events"/);
expect(p).toMatch(/re-claims it with a "gain"/i);
// the hard constraint: only currently-owned keys may be released
expect(p).toMatch(/only region keys that a nation currently owns/);
}
});
}); });
describe('isMeaningful', () => { describe('isMeaningful', () => {
it('is true with an event, false when everything is empty', () => { it('is true with an event, false when everything is empty', () => {
expect(isMeaningful({ date: 'x', events: [{ nation: 'uk', type: 'military', title: 't', text: 'b', outcome: 'neutral' }], stateChanges: [], relations: [], territoryChanges: [] })).toBe(true); expect(isMeaningful({ date: 'x', events: [{ nation: 'uk', type: 'military', title: 't', text: 'b', outcome: 'neutral' }], stateChanges: [], relations: [], territoryChanges: [], unowned: [] })).toBe(true);
expect(isMeaningful({ date: 'x', events: [], stateChanges: [{ nation: 'uk', stat: 'military', op: 'set', value: 5 }], relations: [], territoryChanges: [] })).toBe(true); expect(isMeaningful({ date: 'x', events: [], stateChanges: [{ nation: 'uk', stat: 'military', op: 'set', value: 5 }], relations: [], territoryChanges: [], unowned: [] })).toBe(true);
expect(isMeaningful({ date: 'x', events: [], stateChanges: [], relations: [], territoryChanges: [] })).toBe(false); expect(isMeaningful({ date: 'x', events: [], stateChanges: [], relations: [], territoryChanges: [], unowned: [] })).toBe(false);
}); });
}); });
+99 -26
View File
@@ -142,12 +142,23 @@ export function buildSystemPrompt(preset: Preset, withNeighboursTool = false): s
const respondLine = withNeighboursTool const respondLine = withNeighboursTool
? 'You have a getNeighbours tool: given region keys it returns the region keys that border them. You may call it (a few times) to verify land borders before deciding territory transfers. Once done, respond with ONLY a valid JSON object of exactly this shape. No prose, no commentary, no markdown fences, no trailing commas:' ? 'You have a getNeighbours tool: given region keys it returns the region keys that border them. You may call it (a few times) to verify land borders before deciding territory transfers. Once done, respond with ONLY a valid JSON object of exactly this shape. No prose, no commentary, no markdown fences, no trailing commas:'
: 'Respond with ONLY a valid JSON object of exactly this shape. No prose, no commentary, no markdown fences, no trailing commas:'; : 'Respond with ONLY a valid JSON object of exactly this shape. No prose, no commentary, no markdown fences, no trailing commas:';
// (v0.22, #16 P3) No man's land: a region can be left unowned after a war or
// cataclysm (the model narrates the cause in "events" and lists the key in
// "unowned"); unowned regions can later be re-claimed with an ordinary gain.
// The model sees the current no-man's-land keys in the user payload's
// "unowned" field and in the "regions" reference table.
const noMansLandRule =
'- A region key has at most one owner at all times — never assign a key to two nations. After a war or cataclysm a region may be left unowned ("no man\'s land"): list its key in "unowned" and narrate the cause in "events"; it stays unowned (grey on the map) until a nation re-claims it with a "gain". The "unowned" field of the user message lists the regions currently in no man\'s land.';
const territoryRules = withNeighboursTool const territoryRules = withNeighboursTool
? [ ? [
'- Territory changes must be contiguous: a nation may only GAIN region keys that share a land border with a region it already owns (verify with getNeighbours) and may only LOSE region keys it currently owns. One region key has exactly one owner at all times — never assign a key to two nations or to none.', '- Territory changes must be contiguous: a nation may only GAIN region keys that share a land border with a region it already owns (verify with getNeighbours) and may only LOSE region keys it currently owns.',
'- The "frontier" field of the user message lists region keys owned by other nations that border the player\'s territory — the natural targets of a player-led conquest this turn.', '- The "frontier" field of the user message lists region keys owned by other nations that border the player\'s territory — the natural targets of a player-led conquest this turn.',
noMansLandRule,
] ]
: ['- Only change borders when war or a decisive political shift justifies it.']; : [
'- Only change borders when war or a decisive political shift justifies it.',
noMansLandRule,
];
return [ return [
`WORLD: ${preset.title} — ${preset.startDate}`, `WORLD: ${preset.title} — ${preset.startDate}`,
preset.worldBackground, preset.worldBackground,
@@ -164,12 +175,14 @@ export function buildSystemPrompt(preset: Preset, withNeighboursTool = false): s
' ],', ' ],',
' "stateChanges": [{"nation":"<nationId>","stat":"<military|economy|stability|population>","op":"<set|add>","value":<0-100>}],', ' "stateChanges": [{"nation":"<nationId>","stat":"<military|economy|stability|population>","op":"<set|add>","value":<0-100>}],',
' "relations": [{"a":"<nationId>","b":"<nationId>","set":"<ally|neutral|rival|hostile|war>"}],', ' "relations": [{"a":"<nationId>","b":"<nationId>","set":"<ally|neutral|rival|hostile|war>"}],',
' "territoryChanges": [{"nation":"<nationId>","gain":["<region key>"],"lose":["<region key>"]}]', ' "territoryChanges": [{"nation":"<nationId>","gain":["<region key>"],"lose":["<region key>"]}],',
' "unowned": ["<region key>"]',
'}', '}',
'', '',
'HARD CONSTRAINTS', 'HARD CONSTRAINTS',
'- Use only nation ids and region keys that exist in the provided state.', '- Use only nation ids and region keys that exist in the provided state.',
'- A region key identifies one sub-region; the "regions" table in the user message maps each key to "<region name> (<parent country ISO_A3>)". Use those keys in territoryChanges.', '- A region key identifies one sub-region; the "regions" table in the user message maps each key to "<region name> (<parent country ISO_A3>)". Use those keys in territoryChanges and unowned.',
'- List in "unowned" only region keys that a nation currently owns, and narrate each release in "events".',
'- Prefer 1-4 events per turn; keep each text 1-3 sentences.', '- Prefer 1-4 events per turn; keep each text 1-3 sentences.',
'- Keep your internal reasoning brief (a few sentences) and go straight to the JSON result; do not over-deliberate or re-derive facts already present in the state.', '- Keep your internal reasoning brief (a few sentences) and go straight to the JSON result; do not over-deliberate or re-derive facts already present in the state.',
'- Keep every stat value between 0 and 100.', '- Keep every stat value between 0 and 100.',
@@ -198,6 +211,13 @@ export function buildUserPrompt(
* contiguous targets. Omitted entirely when undefined (no adjacency data). * contiguous targets. Omitted entirely when undefined (no adjacency data).
*/ */
frontier?: string[], frontier?: string[],
/**
* (v0.22, #16 P3) Region keys that are currently "no man's land" (released
* mid-game — `state.unowned`). Emitted verbatim (even when empty) so the
* model knows which unowned regions exist and can be re-claimed this turn.
* Omitted entirely when undefined.
*/
noMansLand?: string[],
): string { ): string {
const player = state.nations[state.playerNationId]; const player = state.nations[state.playerNationId];
const playerName = player?.name ?? state.playerNationId; const playerName = player?.name ?? state.playerNationId;
@@ -235,6 +255,7 @@ export function buildUserPrompt(
}; };
if (regionReference) payload.regions = regionReference; if (regionReference) payload.regions = regionReference;
if (frontier !== undefined) payload.frontier = frontier; if (frontier !== undefined) payload.frontier = frontier;
if (noMansLand !== undefined) payload.unowned = noMansLand;
return JSON.stringify(payload, null, 2); return JSON.stringify(payload, null, 2);
} }
@@ -309,6 +330,7 @@ export function validateTurnResult(input: unknown): TurnResult | null {
stateChanges: [], stateChanges: [],
relations: [], relations: [],
territoryChanges: [], territoryChanges: [],
unowned: [],
}; };
if (Array.isArray(o.events)) { if (Array.isArray(o.events)) {
@@ -384,12 +406,26 @@ export function validateTurnResult(input: unknown): TurnResult | null {
} }
} }
// (v0.22, #16 P3) Keys released to "no man's land": deduped, non-empty
// strings only (applied later by applyTurnResult against validKeys).
if (Array.isArray(o.unowned)) {
for (const k of o.unowned) {
const s = typeof k === 'string' ? k.trim() : '';
if (s && !result.unowned.includes(s)) result.unowned.push(s);
}
}
return result; return result;
} }
/** A turn is "meaningful" if it produced at least an event or a stat change. */ /**
* A turn is "meaningful" if it produced at least an event, a stat change, or
* a no-man's-land release (v0.22 #16 P3: a release is a real world change even
* when the model under-narrates it, so it must survive the retry/nudge path
* instead of being replaced by the generic "unclear" result).
*/
export function isMeaningful(result: TurnResult): boolean { export function isMeaningful(result: TurnResult): boolean {
return result.events.length > 0 || result.stateChanges.length > 0; return result.events.length > 0 || result.stateChanges.length > 0 || result.unowned.length > 0;
} }
// --- Applying the delta ------------------------------------------------------- // --- Applying the delta -------------------------------------------------------
@@ -406,7 +442,8 @@ function genericUnclearEvent(state: GameState): TurnEventSpec {
/** /**
* Apply a TurnResult to a GameState immutably: advance the date/turn, append * Apply a TurnResult to a GameState immutably: advance the date/turn, append
* events, apply stat changes (clamped), symmetric relations, and territory moves. * events, apply stat changes (clamped), symmetric relations, and territory
* moves (including no-man's-land releases, v0.22 #16 P3).
*/ */
export function applyTurnResult(state: GameState, result: TurnResult, validKeys?: ReadonlySet<string>): GameState { export function applyTurnResult(state: GameState, result: TurnResult, validKeys?: ReadonlySet<string>): GameState {
const next: GameState = JSON.parse(JSON.stringify(state)); const next: GameState = JSON.parse(JSON.stringify(state));
@@ -431,22 +468,53 @@ export function applyTurnResult(state: GameState, result: TurnResult, validKeys?
a.relations[r.b] = r.set; a.relations[r.b] = r.set;
b.relations[r.a] = r.set; b.relations[r.a] = r.set;
} }
// --- Territory (v0.21, #16 P2: a region key has exactly one owner) --- // --- Territory (v0.21, #16 P2: at most one owner per key;
// v0.22, #16 P3: a key may also be released to "no man's land") ---
// //
// Snapshot-first: BEFORE any mutation, compute who currently owns each // Two kinds of change, applied in this order:
// region key. A key transfers only when (a) exactly one nation claims to //
// gain it — a double claim (two or more gainers) is rejected so the key // (1) RELEASES (`result.unowned`, v0.22 #16 P3): each key that a nation
// stays where it is, (b) the key currently has exactly one owner — a // CURRENTLY owns (exactly one owner) is removed from that owner, left
// pre-existing double ownership (or unownership) is never touched, and // ownerless, and added to `GameState.unowned` — the client renders it
// (c) that owner is not the gainer (a self-gain is a no-op). A transfer // as the distinct no-man's-land grey. A key with zero owners (a preset
// removes the key from every nation first (defensive) and then grants it // never assigned it) is never released: doing so would fabricate a
// to the gainer; "lose" entries are pure no-ops by design, because a lone // visible no-man's-land out of an ordinary unowned region. A key with
// loss would orphan the key and a valid gain already removes it from the // two or more owners (pre-existing corruption) is never touched, and
// old owner. Ownership is therefore conserved: a key can never end a turn // a key already in `unowned` is a no-op (it is already unowned).
// unowned or multiply-owned. //
// (2) CLAIMS (`gain` entries), resolved against the ownership AFTER the
// releases (snapshot-first):
// - a key with exactly one owner that is not the gainer → a TRANSFER:
// removed from the old owner, granted to the gainer (v0.21 rules);
// - a key with NO owner → a RE-CLAIM: granted to the (sole) gainer and
// removed from `GameState.unowned` (it leaves no man's land);
// - a double claim (two or more distinct gainers) is rejected — the
// key stays as it is; a self-gain is a no-op; a pre-existing double
// ownership is never touched.
//
// Invariants after the turn: no key sits in two or more territories; a
// released key sits in none; `GameState.unowned` holds exactly the
// zero-owner keys that were released mid-game (not the preset-unassigned
// ones). "lose" entries remain deliberate no-ops: releasing is the
// `unowned` list's job, and a lone loss without a release would just
// orphan a key silently.
const valid = (k: string) => !validKeys || validKeys.has(k); const valid = (k: string) => !validKeys || validKeys.has(k);
const ownerOf = new Map<string, string>(); // key -> sole owner id, or '' (0 or 2+ owners) const unowned = new Set(state.unowned ?? []);
for (const n of Object.values(state.nations)) {
// (1) Releases: establish the new no-man's-land set before any claim.
for (const key of new Set(result.unowned)) {
if (!valid(key) || unowned.has(key)) continue;
const owners = Object.values(next.nations)
.filter((n) => n.territory.includes(key))
.map((n) => n.id);
if (owners.length !== 1) continue; // 0 owners (never assigned) or 2+ (corrupt): no-op
unowned.add(key);
next.nations[owners[0]].territory = next.nations[owners[0]].territory.filter((k) => k !== key);
}
// (2) Claims against the post-release ownership snapshot.
const ownerOf = new Map<string, string>(); // key -> sole owner id, or '' (2+ owners; 0 = absent)
for (const n of Object.values(next.nations)) {
for (const key of n.territory) { for (const key of n.territory) {
if (!valid(key)) continue; if (!valid(key)) continue;
const cur = ownerOf.get(key); const cur = ownerOf.get(key);
@@ -479,16 +547,18 @@ export function applyTurnResult(state: GameState, result: TurnResult, validKeys?
for (const [key, claiming] of claimants) { for (const [key, claiming] of claimants) {
if (claiming.size !== 1) continue; // double claim: the key stays as it was if (claiming.size !== 1) continue; // double claim: the key stays as it was
const gainer = [...claiming][0]; const gainer = [...claiming][0];
const owner = ownerOf.get(key); const owner = ownerOf.get(key); // undefined: no owner; '': 2+ owners (corrupt)
if (owner === undefined || owner === '' || owner === gainer) continue; if (owner === '') continue; // pre-existing double ownership: never touched
if (owner !== undefined && owner === gainer) continue; // self-gain: no-op
for (const n of Object.values(next.nations)) { for (const n of Object.values(next.nations)) {
const i = n.territory.indexOf(key); const i = n.territory.indexOf(key);
if (i !== -1) n.territory.splice(i, 1); if (i !== -1) n.territory.splice(i, 1);
} }
next.nations[gainer].territory.push(key); next.nations[gainer].territory.push(key);
unowned.delete(key); // re-claimed: the key leaves no man's land
} }
// "lose" entries are deliberate no-ops (see above): a valid gain already
// removes the key from its old owner, and a lone loss would orphan it. next.unowned = [...unowned];
return next; return next;
} }
@@ -558,7 +628,9 @@ export async function runTurn(opts: {
const useTools = getNeighbours !== undefined && chatWithTools !== undefined; const useTools = getNeighbours !== undefined && chatWithTools !== undefined;
const system = buildSystemPrompt(preset, useTools); const system = buildSystemPrompt(preset, useTools);
const frontier = getNeighbours ? playerFrontier(state, getNeighbours) : undefined; const frontier = getNeighbours ? playerFrontier(state, getNeighbours) : undefined;
const user = buildUserPrompt(state, items, regionReference, frontier); // (v0.22, #16 P3) the model always sees the current no-man's-land keys (empty
// list on a fresh game) so it knows which unowned regions can be re-claimed.
const user = buildUserPrompt(state, items, regionReference, frontier, state.unowned ?? []);
const nudge = const nudge =
'Your previous output was invalid. Respond with ONLY the JSON object described above for this batch — no prose, no fences.'; 'Your previous output was invalid. Respond with ONLY the JSON object described above for this batch — no prose, no fences.';
@@ -601,6 +673,7 @@ export async function runTurn(opts: {
stateChanges: [], stateChanges: [],
relations: [], relations: [],
territoryChanges: [], territoryChanges: [],
unowned: [],
}; };
} }
+10 -1
View File
@@ -124,6 +124,10 @@ app.post('/api/games', (req: Request, res) => {
events: [], events: [],
pending: [], pending: [],
version: 1, // the first saved revision; every later mutation bumps it version: 1, // the first saved revision; every later mutation bumps it
// (v0.22, #16 P3) No man's land can only arise mid-game — a fresh game
// starts with nothing released; this list grows as the LLM releases
// regions and shrinks as they get re-claimed.
unowned: [],
}; };
// v0.19 (#16 P1): expand each nation's ISO_A3 country list to the region keys // v0.19 (#16 P1): expand each nation's ISO_A3 country list to the region keys
// of all its sub-regions, so ownership (and the choropleth) is at region // of all its sub-regions, so ownership (and the choropleth) is at region
@@ -235,7 +239,12 @@ app.post('/api/games/:id/jump', async (req, res) => {
preset, preset,
state: game, state: game,
items: game.pending, items: game.pending,
regionReference: referenceForKeys(allTerritoryKeys(game)), // (v0.22, #16 P3) the region reference table must also cover released
// (no-man's-land) keys — they belong to no nation's territory, but the
// model needs their names to re-claim them.
regionReference: referenceForKeys(
new Set([...allTerritoryKeys(game), ...(game.unowned ?? [])]),
),
validRegionKeys: validKeys(), validRegionKeys: validKeys(),
// v0.21 (#16 P2): when the region-adjacency manifest loaded, hand the // v0.21 (#16 P2): when the region-adjacency manifest loaded, hand the
// engine the resolver — the turn then runs with the LLM's getNeighbours // engine the resolver — the turn then runs with the LLM's getNeighbours
+69 -1
View File
@@ -1,6 +1,18 @@
import { describe, expect, it } from 'vitest'; import { describe, expect, it } from 'vitest';
import { buildRegionMap } from './map'; import { buildRegionMap } from './map';
import { mapFeatureOwner, nationColor, regionOwner, type RegionMap } from '../shared/protocol'; import {
mapFeatureOwner,
nationColor,
NO_MANS_LAND_FILL,
NO_MANS_LAND_STROKE,
OWNED_STROKE,
regionFillStyle,
regionOwner,
UNOWNED_FILL,
UNOWNED_STROKE,
unownedTooltipLabel,
type RegionMap,
} from '../shared/protocol';
import type { GameState } from '../shared/types'; import type { GameState } from '../shared/types';
// Region-key fixtures (v0.19, #16 P1): ownership and the RegionMap are now keyed // Region-key fixtures (v0.19, #16 P1): ownership and the RegionMap are now keyed
@@ -35,6 +47,7 @@ const state: GameState = {
events: [], events: [],
pending: [], pending: [],
version: 1, version: 1,
unowned: [],
}; };
// The RegionMap built from the fixture: the authoritative region key → owner source. // The RegionMap built from the fixture: the authoritative region key → owner source.
@@ -49,6 +62,16 @@ describe('buildRegionMap', () => {
// a region nobody owns is simply absent // a region nobody owns is simply absent
expect(m['normandie-dddd']).toBeUndefined(); expect(m['normandie-dddd']).toBeUndefined();
}); });
it('(v0.22 #16 P3) a released (no-man\'s-land) key — in GameState.unowned, owned by no nation — is absent: absence IS unowned', () => {
const s: GameState = JSON.parse(JSON.stringify(state));
// simulate a mid-game release: the key left every territory and entered unowned
s.nations.uk.territory = s.nations.uk.territory.filter((k) => k !== 'albania-aaaa');
s.unowned = ['albania-aaaa'];
const m = buildRegionMap(s);
expect(m['albania-aaaa']).toBeUndefined(); // the map cannot tell which kind of unowned
expect(s.unowned).toEqual(['albania-aaaa']); // ...the client's GameState.unowned decides that
});
}); });
describe('regionOwner', () => { describe('regionOwner', () => {
@@ -143,3 +166,48 @@ describe('nationColor', () => {
expect(nationColor('uk')).toMatch(/^hsl\(\d+ 58% 46%\)$/); expect(nationColor('uk')).toMatch(/^hsl\(\d+ 58% 46%\)$/);
}); });
}); });
// (v0.22, #16 P3) The no-man's-land presentation contract lives in
// shared/protocol (shared/*.test.ts is not in the vitest glob, so these
// tests live here): the distinct visible grey, the faint preset-unassigned
// look, the per-kind borders, and the tooltip sub-line.
describe('no man\'s land presentation contract (v0.22 #16 P3)', () => {
it('regionFillStyle: an owned region uses its nation color + the owned border', () => {
expect(regionFillStyle(regions['albania-aaaa'], false)).toEqual({
fill: nationColor('uk'),
stroke: OWNED_STROKE,
});
expect(regionFillStyle(regions['brandenburg-cccc'], true)).toEqual({
fill: nationColor('de'),
stroke: OWNED_STROKE,
}); // the no-man's-land flag never affects an owned region
});
it('regionFillStyle: an unowned region that IS no man\'s land gets the distinct visible grey', () => {
expect(regionFillStyle(undefined, true)).toEqual({
fill: NO_MANS_LAND_FILL,
stroke: NO_MANS_LAND_STROKE,
});
});
it('regionFillStyle: an unowned region that was never released keeps the faint look', () => {
expect(regionFillStyle(undefined, false)).toEqual({
fill: UNOWNED_FILL,
stroke: UNOWNED_STROKE,
});
});
it('the no-man\'s-land grey can never collide with a nation color (hue-less vs HSL)', () => {
expect(NO_MANS_LAND_FILL).not.toMatch(/^hsl\(/);
expect(NO_MANS_LAND_FILL).not.toBe(nationColor('uk'));
expect(NO_MANS_LAND_FILL).not.toBe(nationColor('de'));
// and it must be visibly different from the faint preset-unassigned fill
expect(NO_MANS_LAND_FILL).not.toBe(UNOWNED_FILL);
expect(NO_MANS_LAND_STROKE).not.toBe(UNOWNED_STROKE);
});
it('unownedTooltipLabel: the tooltip sub-line names the two kinds of unowned', () => {
expect(unownedTooltipLabel(true)).toBe("No man's land");
expect(unownedTooltipLabel(false)).toBe('No owner');
});
});
+7
View File
@@ -3,6 +3,13 @@
// color (see shared.nationColor). The client merges this onto the sub-national // color (see shared.nationColor). The client merges this onto the sub-national
// regions.geo.json (resolving each feature by its own region key) and calls // regions.geo.json (resolving each feature by its own region key) and calls
// source.setData(). Format-agnostic: it maps whatever keys sit in territory. // source.setData(). Format-agnostic: it maps whatever keys sit in territory.
//
// A key ABSENT from the returned map is unowned (no nation's territory holds
// it) — this covers both regions a preset never assigned and regions released
// to no man's land mid-game (v0.22, #16 P3). The map deliberately does NOT
// distinguish the two: the visible no-man's-land grey vs the faint
// preset-unassigned look is decided by the client from `GameState.unowned`
// (see shared/protocol.ts's regionFillStyle).
import { nationColor, type RegionMap } from '../shared/protocol'; import { nationColor, type RegionMap } from '../shared/protocol';
import type { GameState } from '../shared/types'; import type { GameState } from '../shared/types';
+1
View File
@@ -86,6 +86,7 @@ describe('regions (region-key loader, v0.19 #16 P1)', () => {
events: [], events: [],
pending: [], pending: [],
version: 1, version: 1,
unowned: [],
}; };
// 16 GBR sub-regions + 42 FRA sub-regions, no overlap between the two. // 16 GBR sub-regions + 42 FRA sub-regions, no overlap between the two.
expect(allTerritoryKeys(state).size).toBe(58); expect(allTerritoryKeys(state).size).toBe(58);
+13
View File
@@ -24,6 +24,13 @@ export interface StubPayload {
* reads the frontier from the prompt instead. * reads the frontier from the prompt instead.
*/ */
frontier?: string[]; frontier?: string[];
/**
* (v0.22, #16 P3) Region keys currently in no man's land (`state.unowned`),
* emitted by the engine's user prompt. The stub ignores this field — it
* never releases a region (see deterministicTurn), so no-man's-land behavior
* is covered by unit tests against applyTurnResult, not by the stub.
*/
unowned?: string[];
} }
function truncate(s: string, n: number): string { function truncate(s: string, n: number): string {
@@ -45,6 +52,11 @@ function truncate(s: string, n: number): string {
* content in one round), so the frontier comes from the prompt payload, not * content in one round), so the frontier comes from the prompt payload, not
* from a getNeighbours call. With an empty frontier (no controllable border) * from a getNeighbours call. With an empty frontier (no controllable border)
* no territory change is emitted, keeping every existing assertion stable. * no territory change is emitted, keeping every existing assertion stable.
*
* (v0.22, #16 P3) The stub NEVER releases a region to no man's land (it
* always returns `unowned: []`) — e2e assertions rely on its fully-deterministic
* one-conquest behavior, so the release/re-claim path is exercised by unit
* tests only (see server/engine.test.ts).
*/ */
export function deterministicTurn(p: StubPayload): TurnResult { export function deterministicTurn(p: StubPayload): TurnResult {
const state = p.state; const state = p.state;
@@ -108,6 +120,7 @@ export function deterministicTurn(p: StubPayload): TurnResult {
stateChanges: [{ nation: pid, stat: 'military', op: 'set', value: mil }], stateChanges: [{ nation: pid, stat: 'military', op: 'set', value: mil }],
relations: otherId ? [{ a: pid, b: otherId, set: 'ally' }] : [], relations: otherId ? [{ a: pid, b: otherId, set: 'ally' }] : [],
territoryChanges, territoryChanges,
unowned: [], // the stub never creates no man's land (see doc comment)
}; };
} }
+51
View File
@@ -46,6 +46,16 @@ export interface TurnResult {
stateChanges: StateChange[]; stateChanges: StateChange[];
relations: RelationChange[]; relations: RelationChange[];
territoryChanges: TerritoryChange[]; territoryChanges: TerritoryChange[];
/**
* (v0.22, #16 P3) Region keys that become **unowned** — "no man's land" —
* this turn. The engine removes each key from its current (sole) owner so it
* ends the turn ownerless and marks it in `GameState.unowned`; the LLM
* narrates the cause (war, cataclysm, collapse) in `events`. Re-claiming is
* NOT expressed here: a nation re-claims an unowned region with an ordinary
* `gain` on that key. Only keys a nation currently owns can be released;
* empty on normal turns.
*/
unowned: string[];
} }
/** Per-region map payload: region key -> owning nation + its stable color. */ /** Per-region map payload: region key -> owning nation + its stable color. */
@@ -188,6 +198,47 @@ export function nationColor(id: string): string {
return `hsl(${hue} 58% 46%)`; return `hsl(${hue} 58% 46%)`;
} }
// --- No man's land (v0.22, #16 P3) — the map presentation contract ---
//
// An unowned region has two distinct looks, and the difference is NOT in the
// RegionMap (a key absent there is simply unowned): it is decided by the
// client from `GameState.unowned` (the keys released mid-game). The constants
// and the one decision function live here — shared and pure — so the
// client's choropleth is the only consumer and the color contract is testable
// from the server-side tests (shared/*.test.ts is not in the vitest glob).
/** The distinct, visible neutral grey for a **no man's land** region (released mid-game). Hue-less by construction, so it can never collide with a nation's `nationColor` (`hsl(h 58% 46%)`, always chromatic). */
export const NO_MANS_LAND_FILL = '#3f3f46';
/** The border of a no-man's-land region (brighter than a plain unowned one). */
export const NO_MANS_LAND_STROKE = 'rgba(255,255,255,0.25)';
/** The faint neutral fill for a region no nation owns that was never released mid-game (a preset never assigned it). */
export const UNOWNED_FILL = 'rgba(148,163,184,0.05)';
/** The border of a plain unowned region. */
export const UNOWNED_STROKE = 'rgba(255,255,255,0.14)';
/** The border of an owned region (brighter than unowned). */
export const OWNED_STROKE = 'rgba(255,255,255,0.30)';
/** The hover-tooltip sub-line for a region with no owning nation. */
export function unownedTooltipLabel(isNoMansLand: boolean): string {
return isNoMansLand ? "No man's land" : 'No owner';
}
/**
* The fill + border for one sub-region, given its RegionMap entry
* (`undefined` = unowned) and whether its key is no man's land (released
* mid-game, i.e. in `GameState.unowned`). Pure + shared; see the section
* comment above for why the distinction is not encoded in the RegionMap.
*/
export function regionFillStyle(
owner: RegionOwner | undefined,
isNoMansLand: boolean,
): { fill: string; stroke: string } {
if (owner) return { fill: owner.color, stroke: OWNED_STROKE };
return isNoMansLand
? { fill: NO_MANS_LAND_FILL, stroke: NO_MANS_LAND_STROKE }
: { fill: UNOWNED_FILL, stroke: UNOWNED_STROKE };
}
/** Rebuild the full event list entry shape from a TurnEventSpec + turn/date. */ /** Rebuild the full event list entry shape from a TurnEventSpec + turn/date. */
export function makeEvent( export function makeEvent(
spec: TurnEventSpec, spec: TurnEventSpec,
+13
View File
@@ -120,6 +120,19 @@ export interface GameState {
* in src/App.tsx. Starts at 1 on game creation. * in src/App.tsx. Starts at 1 on game creation.
*/ */
version: GameVersion; version: GameVersion;
/**
* (v0.22, #16 P3) Region keys that became **unowned** — "no man's land" —
* during play: a region that was owned and was released to no owner by a
* committed turn (the LLM narrates the cause in that turn's events). This is
* deliberately distinct from the many regions a preset never assigns, which
* are also unowned but were never *released* mid-game; only the keys in this
* list render as the distinct visible no-man's-land grey (see
* protocol.regionFillStyle). A region leaves the list when any nation
* re-claims it (an ordinary `gain`). Always `[]` on a fresh game — no
* man's land can only arise mid-game. Rows saved before v0.22 load with
* this defaulted to `[]` (see server/db.ts).
*/
unowned: string[];
} }
/** /**
+1
View File
@@ -653,6 +653,7 @@ function GameScreen({
playerNationId={game.playerNationId} playerNationId={game.playerNationId}
nations={game.nations} nations={game.nations}
presetId={game.presetId} presetId={game.presetId}
unowned={game.unowned ?? []}
onFocusNation={focusNation} onFocusNation={focusNation}
/> />
+70 -17
View File
@@ -8,7 +8,13 @@ import 'maplibre-gl/dist/maplibre-gl.css';
// Point it at a Vite-emitted copy of the worker module so it loads in prod. // Point it at a Vite-emitted copy of the worker module so it loads in prod.
import workerUrl from 'maplibre-gl/dist/maplibre-gl-worker.mjs?url'; import workerUrl from 'maplibre-gl/dist/maplibre-gl-worker.mjs?url';
maplibregl.setWorkerUrl(workerUrl); maplibregl.setWorkerUrl(workerUrl);
import { mapFeatureOwner, type RegionMap, type RegionOwner } from '@shared/protocol'; import {
mapFeatureOwner,
regionFillStyle,
unownedTooltipLabel,
type RegionMap,
type RegionOwner,
} from '@shared/protocol';
import type { Nation } from '@shared/types'; import type { Nation } from '@shared/types';
import { cn } from '@/lib/utils'; import { cn } from '@/lib/utils';
import { idToKey } from '@/lib/regions'; import { idToKey } from '@/lib/regions';
@@ -114,19 +120,27 @@ function cameraForPreset(presetId: string): { center: [number, number]; zoom: nu
* - `regionKey` – the region key (e.g. `moselle-rygy`), the RegionMap key; * - `regionKey` – the region key (e.g. `moselle-rygy`), the RegionMap key;
* - `fill` – the owning nation's stable color (from `RegionMap[regionKey]`); * - `fill` – the owning nation's stable color (from `RegionMap[regionKey]`);
* - `owner`/`ownerId` – that nation's name/id (empty for an unowned region); * - `owner`/`ownerId` – that nation's name/id (empty for an unowned region);
* - `stroke` – a per-border alpha (owned brighter than unowned); * - `stroke` – a per-border alpha (owned brightest, no man's land next,
* plain-unowned faintest);
* - `isPlayer` – 1 when the owning nation is the player (drives the amber * - `isPlayer` – 1 when the owning nation is the player (drives the amber
* player-highlight border). * player-highlight border).
* *
* Crucially, the owner lookup keys the RegionMap by the stamped * Crucially, the owner lookup keys the RegionMap by the stamped
* `properties.regionKey`, **never** by the sub-region id or the repacked * `properties.regionKey`, **never** by the sub-region id or the repacked
* top-level `id` — the RegionMap has no `FRA-03` entries, only region keys. * top-level `id` — the RegionMap has no `FRA-03` entries, only region keys.
*
* (v0.22, #16 P3) Unowned regions have two looks, decided here (the
* RegionMap simply has no entry for them): a key in `unownedSet` — released to
* no man's land mid-game (`GameState.unowned`) — gets the distinct visible grey
* (`NO_MANS_LAND_FILL`); every other ownerless region keeps the faint
* preset-unassigned look. Both come from the shared `regionFillStyle` contract.
*/ */
function applyRegions( function applyRegions(
geo: FeatureCollection, geo: FeatureCollection,
regions: RegionMap, regions: RegionMap,
playerNationId: string, playerNationId: string,
idKey: Record<string, string>, idKey: Record<string, string>,
unownedSet: ReadonlySet<string>,
): FeatureCollection { ): FeatureCollection {
const out: FeatureCollection = JSON.parse(JSON.stringify(geo)); const out: FeatureCollection = JSON.parse(JSON.stringify(geo));
for (const f of out.features) { for (const f of out.features) {
@@ -141,14 +155,18 @@ function applyRegions(
f.properties.regionKey = key; f.properties.regionKey = key;
const r = key ? regions[key] : undefined; const r = key ? regions[key] : undefined;
if (r) { if (r) {
f.properties.fill = r.color; const style = regionFillStyle(r, false);
f.properties.fill = style.fill;
f.properties.stroke = style.stroke;
f.properties.owner = r.owner; f.properties.owner = r.owner;
f.properties.ownerId = r.ownerId; f.properties.ownerId = r.ownerId;
f.properties.stroke = 'rgba(255,255,255,0.30)';
f.properties.isPlayer = r.ownerId === playerNationId ? 1 : 0; f.properties.isPlayer = r.ownerId === playerNationId ? 1 : 0;
} else { } else {
f.properties.fill = 'rgba(148,163,184,0.05)'; // Ownerless: visible grey only for keys released mid-game (no man's
f.properties.stroke = 'rgba(255,255,255,0.14)'; // land); a preset that never assigned this sub-region stays faint.
const style = regionFillStyle(undefined, key !== '' && unownedSet.has(key));
f.properties.fill = style.fill;
f.properties.stroke = style.stroke;
f.properties.owner = ''; f.properties.owner = '';
f.properties.ownerId = ''; f.properties.ownerId = '';
f.properties.isPlayer = 0; f.properties.isPlayer = 0;
@@ -188,14 +206,21 @@ function statRow(label: string, value?: number): string {
/** /**
* Compact hover tooltip: the sub-region's name, its **current owner** * Compact hover tooltip: the sub-region's name, its **current owner**
* (color-chipped), and 1–2 key stats. When the region's country has no owning * (color-chipped), and 1–2 key stats. When the region's country has no owning
* nation, a minimal "No owner" card is shown instead. The static * nation, a minimal card is shown instead; its sub-line says "No man's land"
* parent-country code (`properties.parent`) is deliberately NOT printed here — * for a region released mid-game (v0.22, #16 P3) and "No owner" for a region
* it never updates when ownership is transferred, so only the live owner chip * no preset ever assigned. The static parent-country code
* (resolved from the RegionMap) is shown. * (`properties.parent`) is deliberately NOT printed here — it never updates
* when ownership is transferred, so only the live owner chip (resolved from
* the RegionMap) is shown.
*/ */
function hoverHtml(subRegionName: string, region?: RegionOwner, nation?: Nation): string { function hoverHtml(
subRegionName: string,
region?: RegionOwner,
nation?: Nation,
noMansLand = false,
): string {
if (!region || !nation) { if (!region || !nation) {
return `<div class="hm-popup"><div class="hm-popup-title">${esc(subRegionName || 'Unclaimed')}</div><div class="hm-popup-sub">No owner</div></div>`; return `<div class="hm-popup"><div class="hm-popup-title">${esc(subRegionName || 'Unclaimed')}</div><div class="hm-popup-sub">${unownedTooltipLabel(noMansLand)}</div></div>`;
} }
const stats = [statRow('Mil', nation.stats.military), statRow('Eco', nation.stats.economy)].join(''); const stats = [statRow('Mil', nation.stats.military), statRow('Eco', nation.stats.economy)].join('');
return `<div class="hm-popup"> return `<div class="hm-popup">
@@ -213,6 +238,14 @@ interface Props {
/** The active scenario id, for a sensible initial camera. */ /** The active scenario id, for a sensible initial camera. */
presetId: string; presetId: string;
className?: string; className?: string;
/**
* (v0.22, #16 P3) Region keys currently in no man's land (released
* mid-game — `GameState.unowned`). Such regions render the distinct visible
* grey (vs the faint preset-unassigned look of every other ownerless
* region) and their hover tooltip says "No man's land" instead of "No
* owner".
*/
unowned?: string[];
/** /**
* Called with a region's owning-nation id when a sub-region is clicked, so the * Called with a region's owning-nation id when a sub-region is clicked, so the
* dossier switches to that nation. The optional `info` carries the clicked * dossier switches to that nation. The optional `info` carries the clicked
@@ -246,6 +279,13 @@ interface Props {
* never from the raw sub-region id (not a RegionMap key). Unowned sub-regions * never from the raw sub-region id (not a RegionMap key). Unowned sub-regions
* (no RegionMap entry for their key) simply do not change the dossier. * (no RegionMap entry for their key) simply do not change the dossier.
* *
* (v0.22, #16 P3) An unowned region renders two distinct ways: a key in the
* `unowned` prop (released to no man's land mid-game — `GameState.unowned`)
* gets the distinct visible grey with a "No man's land" tooltip sub-line; every
* other ownerless region (a preset never assigned it) keeps the faint look and
* a plain "No owner" sub-line. The colors/labels come from the shared
* `regionFillStyle` / `unownedTooltipLabel` contract in @shared/protocol.
*
* Zoom and pan are driven directly by the user (MapLibre wheel/trackpad + drag) * Zoom and pan are driven directly by the user (MapLibre wheel/trackpad + drag)
* — no floating control and no external API. * — no floating control and no external API.
*/ */
@@ -255,6 +295,7 @@ export default function WorldMap({
nations, nations,
presetId, presetId,
className, className,
unowned,
onFocusNation, onFocusNation,
}: Props) { }: Props) {
const containerRef = useRef<HTMLDivElement>(null); const containerRef = useRef<HTMLDivElement>(null);
@@ -268,11 +309,13 @@ export default function WorldMap({
const nationsRef = useRef(nations); const nationsRef = useRef(nations);
const playerRef = useRef(playerNationId); const playerRef = useRef(playerNationId);
const presetRef = useRef(presetId); const presetRef = useRef(presetId);
const unownedRef = useRef<Set<string>>(new Set(unowned ?? []));
const onFocusNationRef = useRef(onFocusNation); const onFocusNationRef = useRef(onFocusNation);
regionsRef.current = regions; regionsRef.current = regions;
nationsRef.current = nations; nationsRef.current = nations;
playerRef.current = playerNationId; playerRef.current = playerNationId;
presetRef.current = presetId; presetRef.current = presetId;
unownedRef.current = new Set(unowned ?? []);
onFocusNationRef.current = onFocusNation; onFocusNationRef.current = onFocusNation;
// Diffing state for the transient territory-change flash (owned by this component // Diffing state for the transient territory-change flash (owned by this component
@@ -396,9 +439,14 @@ export default function WorldMap({
} }
const region = mapFeatureOwner(regionsRef.current, f); const region = mapFeatureOwner(regionsRef.current, f);
const nation = region ? nationsRef.current[region.ownerId] : undefined; const nation = region ? nationsRef.current[region.ownerId] : undefined;
// (v0.22, #16 P3) whether this region is no man's land (released
// mid-game), so the ownerless tooltip can say so.
const pKey = f.properties?.regionKey;
const noMansLand =
!region && typeof pKey === 'string' && pKey !== '' && unownedRef.current.has(pKey);
hoverPopup hoverPopup
.setLngLat(e.lngLat) .setLngLat(e.lngLat)
.setHTML(hoverHtml(p?.name ?? subId, region, nation)) .setHTML(hoverHtml(p?.name ?? subId, region, nation, noMansLand))
.addTo(map); .addTo(map);
map.getCanvas().style.cursor = 'pointer'; map.getCanvas().style.cursor = 'pointer';
}; };
@@ -458,7 +506,7 @@ export default function WorldMap({
const idKey = idKeyRef.current; const idKey = idKeyRef.current;
const src = map.getSource(REGIONS_SOURCE) as GeoSource | undefined; const src = map.getSource(REGIONS_SOURCE) as GeoSource | undefined;
if (!geo || !idKey || !src) return; if (!geo || !idKey || !src) return;
src.setData(applyRegions(geo, regionsRef.current, playerRef.current, idKey)); src.setData(applyRegions(geo, regionsRef.current, playerRef.current, idKey, unownedRef.current));
}; };
const ensureLayers = async () => { const ensureLayers = async () => {
@@ -568,7 +616,9 @@ export default function WorldMap({
// sub-region whose owner changed since the last update (a transient, // sub-region whose owner changed since the last update (a transient,
// self-cleaning pulse). The diff is over the region-keyed RegionMap (v0.19 // self-cleaning pulse). The diff is over the region-keyed RegionMap (v0.19
// #16 P1); `animateFlash` lights up exactly the sub-region for each changed // #16 P1); `animateFlash` lights up exactly the sub-region for each changed
// key. // key. Re-runs when the no-man's-land set changes too (v0.22 #16 P3) so a
// release/re-claim repaints the grey even if the RegionMap diff alone
// wouldn't catch it.
useEffect(() => { useEffect(() => {
const map = mapRef.current; const map = mapRef.current;
const geo = baseGeoRef.current; const geo = baseGeoRef.current;
@@ -578,10 +628,11 @@ export default function WorldMap({
prevRegionsRef.current = cur; prevRegionsRef.current = cur;
const wasPrimed = primedRef.current; const wasPrimed = primedRef.current;
if (Object.keys(cur).length > 0) primedRef.current = true; if (Object.keys(cur).length > 0) primedRef.current = true;
const unownedSet = new Set(unowned ?? []);
if (map && geo && idKey) { if (map && geo && idKey) {
const src = map.getSource(REGIONS_SOURCE) as GeoSource | undefined; const src = map.getSource(REGIONS_SOURCE) as GeoSource | undefined;
if (src) void src.setData(applyRegions(geo, cur, playerNationId, idKey)); if (src) void src.setData(applyRegions(geo, cur, playerNationId, idKey, unownedSet));
} }
if (wasPrimed && prev && idKey) { if (wasPrimed && prev && idKey) {
@@ -589,9 +640,11 @@ export default function WorldMap({
for (const key of new Set([...Object.keys(prev), ...Object.keys(cur)])) { for (const key of new Set([...Object.keys(prev), ...Object.keys(cur)])) {
if ((prev[key]?.ownerId ?? '') !== (cur[key]?.ownerId ?? '')) changed.add(key); if ((prev[key]?.ownerId ?? '') !== (cur[key]?.ownerId ?? '')) changed.add(key);
} }
// A release (owned → unowned) and a re-claim (unowned → owned) both show
// up as ownerId changes above, so they flash like any other transfer.
if (changed.size > 0) flashApiRef.current?.([...changed]); if (changed.size > 0) flashApiRef.current?.([...changed]);
} }
}, [regions, playerNationId]); }, [regions, playerNationId, unowned]);
// Full-viewport, edge-to-edge background layer. `position: fixed` is set // Full-viewport, edge-to-edge background layer. `position: fixed` is set
// inline because MapLibre's own stylesheet sets `.maplibregl-map` to // inline because MapLibre's own stylesheet sets `.maplibregl-map` to