v0.27.0 (#44): LLM-placed named map point markers
CI / build + unit + e2e (pull_request) Successful in 2m36s

- shared: 8 MarkerTypes + MARKER_TYPE_INFO (label/color/glyph); MapMarker;
  GameState.markers (required, [] at init); TurnResult.markers {add,remove};
  MarkerSpec/MarkerInfo; FNV-1a 4-char markerId; MAX_MARKERS=400.
- data: new public/region-centroids.json (1,800 anchors = largest-part centroid
  + spread ~0.1x min-bbox) built by new idempotent scripts/build-region-centroids.mjs.
- server: regions.ts centroid loader (public/ then dist/); engine applyTurnResult
  marker ops (remove -> owner-recompute -> add, jitter, cap, never throws);
  prompt documents the contract; buildUserPrompt carries markers; stub marker-free.
- client: WorldMap symbol layer rendered as canvas per-type icons (glyph CDN dead);
  hover tooltip shows name; click focuses owning nation.
- tests: 168 -> 189 (centroids script, regions centroids, db legacy, engine markers).
This commit is contained in:
2026-10-14 22:37:29 +00:00
parent aa1bd3c5aa
commit a129c2ab81
19 changed files with 10437 additions and 45 deletions
+12
View File
@@ -25,6 +25,7 @@ function makeState(id: string): GameState {
version: 1,
unowned: [],
pendingNationOps: [],
markers: [],
};
}
@@ -97,6 +98,17 @@ describe('openDb', () => {
db.close();
});
it('(v0.27 #44) a legacy row saved before the markers field existed loads with it defaulted to []', () => {
const db = openDb(':memory:');
const legacy = makeState('legacy');
delete (legacy as { markers?: unknown[] }).markers; // a pre-v0.27 save (field absent)
db.save(legacy);
const loaded = db.load('legacy');
expect(loaded).not.toBeNull();
expect(loaded!.markers).toEqual([]);
db.close();
});
it('delete removes a game by id and reports absence otherwise', () => {
const db = openDb(':memory:');
db.save(makeState('a'));
+2
View File
@@ -46,6 +46,8 @@ export function openDb(filePath: string): Db {
// (v0.23, #16 P4a) Same for pending nation-op proposals: a legacy game
// has none awaiting confirm/reject.
if (state.pendingNationOps === undefined) state.pendingNationOps = [];
// (v0.27, #44) Same for map markers: a legacy game has none placed.
if (state.markers === undefined) state.markers = [];
return state;
} catch {
return null;
+276 -17
View File
@@ -1,6 +1,6 @@
import { describe, expect, it } from 'vitest';
import type { GameState, Preset, RoundItem } from '../shared/types';
import { makeEvent, type NationOp, type TurnResult } from '../shared/protocol';
import type { GameState, MarkerType, Preset, RoundItem } from '../shared/types';
import { makeEvent, markerId, MAX_MARKERS, type MarkerSpec, type NationOp, type TurnResult } from '../shared/protocol';
import type { LlmClient, ToolDefinition, WireMessage } from './llm';
import { makeStubLlmClient } from './stub';
import {
@@ -21,6 +21,7 @@ import {
removeRoundItem,
resolveNationRef,
runTurn,
validateMarkerOps,
validateNationOps,
validateTurnResult,
} from './engine';
@@ -87,6 +88,7 @@ function baseState(): GameState {
version: 1,
unowned: [],
pendingNationOps: [],
markers: [],
};
}
@@ -524,7 +526,7 @@ describe('validateNationOps (v0.23 #16 P4a)', () => {
relations: [],
territoryChanges: [],
unowned: [],
nationOps: [{ kind: 'rename', nationRef: 'France', to: 'French Republic' }],
nationOps: [{ kind: 'rename', nationRef: 'France', to: 'French Republic' }], markers: { add: [], remove: [] },
});
expect(withOps).not.toBeNull();
expect(withOps!.nationOps).toEqual([{ kind: 'rename', nationRef: 'France', to: 'French Republic' }]);
@@ -545,7 +547,7 @@ describe('applyTurnResult (nation-op proposals, v0.23 #16 P4a)', () => {
relations: [],
territoryChanges: [],
unowned: [],
nationOps: [{ kind: 'rename', nationRef: 'de', to: 'Reich' }],
nationOps: [{ kind: 'rename', nationRef: 'de', to: 'Reich' }], markers: { add: [], remove: [] },
});
expect(next.nations.de.name).toBe('DE'); // still the old name
expect(next.pendingNationOps).toEqual([{ kind: 'rename', nationRef: 'de', to: 'Reich' }]);
@@ -565,7 +567,7 @@ describe('applyTurnResult (nation-op proposals, v0.23 #16 P4a)', () => {
relations: [],
territoryChanges: [],
unowned: [],
nationOps: [{ kind: 'rename', nationRef: 'us', to: 'USA' }], // new proposal this turn
nationOps: [{ kind: 'rename', nationRef: 'us', to: 'USA' }], markers: { add: [], remove: [] }, // new proposal this turn
});
expect(next.pendingNationOps).toEqual([
{ kind: 'rename', nationRef: 'de', to: 'Reich' }, // the old one survived
@@ -583,7 +585,7 @@ describe('applyTurnResult (nation-op proposals, v0.23 #16 P4a)', () => {
relations: [],
territoryChanges: [],
unowned: [],
nationOps: [{ kind: 'rename', nationRef: 'de', to: 'Reich' }],
nationOps: [{ kind: 'rename', nationRef: 'de', to: 'Reich' }], markers: { add: [], remove: [] },
});
expect(next.pendingNationOps).toEqual([{ kind: 'rename', nationRef: 'de', to: 'Reich' }]);
});
@@ -1039,7 +1041,7 @@ describe('applyTurnResult', () => {
relations: [],
territoryChanges: [],
unowned: [],
nationOps: [],
nationOps: [], markers: { add: [], remove: [] },
};
applyTurnResult(state, result);
expect(JSON.stringify(state)).toBe(snapshot);
@@ -1055,7 +1057,7 @@ describe('applyTurnResult', () => {
relations: [],
territoryChanges: [],
unowned: [],
nationOps: [],
nationOps: [], markers: { add: [], remove: [] },
};
const next = applyTurnResult(state, result);
expect(next.pending).toEqual([]);
@@ -1077,7 +1079,7 @@ describe('applyTurnResult', () => {
// 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: [] }],
unowned: [],
nationOps: [],
nationOps: [], markers: { add: [], remove: [] },
};
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
@@ -1102,7 +1104,7 @@ describe('applyTurnResult', () => {
// released (not in state.unowned), so it does not enter GameState.unowned.
territoryChanges: [{ nation: 'de', gain: ['POL', 'wales-bbbb'], lose: [] }],
unowned: [],
nationOps: [],
nationOps: [], markers: { add: [], remove: [] },
};
const next = applyTurnResult(state, result); // no validKeys -> nothing filtered
expect(next.nations.de.territory).toContain('wales-bbbb');
@@ -1119,7 +1121,7 @@ describe('applyTurnResult (sole-ownership invariants, v0.21 #16 P2)', () => {
stateChanges: [],
relations: [],
unowned: [],
nationOps: [],
nationOps: [], markers: { add: [], remove: [] },
};
it('a double claim (two different gainers, one key) is rejected: the key stays with its original owner', () => {
@@ -1208,7 +1210,7 @@ describe('applyTurnResult (no man\'s land, v0.22 #16 P3)', () => {
relations: [],
territoryChanges: [],
unowned: [],
nationOps: [],
nationOps: [], markers: { add: [], remove: [] },
};
it('a release removes the key from its sole owner and marks it in GameState.unowned', () => {
@@ -1323,7 +1325,7 @@ describe('applyTurnResult (no man\'s land, v0.22 #16 P3)', () => {
it('a lone release is meaningful (it survives the retry/nudge path)', () => {
expect(
isMeaningful({ date: 'x', events: [], stateChanges: [], relations: [], territoryChanges: [], unowned: ['GBR'], nationOps: [] }),
isMeaningful({ date: 'x', events: [], stateChanges: [], relations: [], territoryChanges: [], unowned: ['GBR'], nationOps: [], markers: { add: [], remove: [] } }),
).toBe(true);
});
@@ -1630,9 +1632,9 @@ describe('buildSystemPrompt (tool mode, v0.21 #16 P2)', () => {
describe('isMeaningful', () => {
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: [], unowned: [], nationOps: [] })).toBe(true);
expect(isMeaningful({ date: 'x', events: [], stateChanges: [{ nation: 'uk', stat: 'military', op: 'set', value: 5 }], relations: [], territoryChanges: [], unowned: [], nationOps: [] })).toBe(true);
expect(isMeaningful({ date: 'x', events: [], stateChanges: [], relations: [], territoryChanges: [], unowned: [], nationOps: [] })).toBe(false);
expect(isMeaningful({ date: 'x', events: [{ nation: 'uk', type: 'military', title: 't', text: 'b', outcome: 'neutral' }], stateChanges: [], relations: [], territoryChanges: [], unowned: [], nationOps: [], markers: { add: [], remove: [] } })).toBe(true);
expect(isMeaningful({ date: 'x', events: [], stateChanges: [{ nation: 'uk', stat: 'military', op: 'set', value: 5 }], relations: [], territoryChanges: [], unowned: [], nationOps: [], markers: { add: [], remove: [] } })).toBe(true);
expect(isMeaningful({ date: 'x', events: [], stateChanges: [], relations: [], territoryChanges: [], unowned: [], nationOps: [], markers: { add: [], remove: [] } })).toBe(false);
});
it('(v0.23 #16 P4a) a turn that only proposes a nation-op is meaningful (it survives the retry/nudge path)', () => {
@@ -1644,8 +1646,265 @@ describe('isMeaningful', () => {
relations: [],
territoryChanges: [],
unowned: [],
nationOps: [{ kind: 'rename', nationRef: 'de', to: 'Reich' }],
nationOps: [{ kind: 'rename', nationRef: 'de', to: 'Reich' }], markers: { add: [], remove: [] },
}),
).toBe(true);
});
it('(v0.27 #44) a turn that only adds a marker is meaningful (it survives the retry/nudge path)', () => {
expect(
isMeaningful({
date: 'x',
events: [],
stateChanges: [],
relations: [],
territoryChanges: [],
unowned: [],
nationOps: [],
markers: { add: [{ type: 'city', name: 'Lyon', regionKey: 'rhone-rhne' }], remove: [] },
}),
).toBe(true);
});
});
describe('markers (point placement, v0.27 #44)', () => {
// a deterministic centroid for one region key, undefined for everything else
const centroidOf = (key: string) =>
key === 'moselle-rygy' ? { lat: 48, lon: 6, spread: 1 } : undefined;
// build a MarkerSpec from a (possibly invalid, for the guard tests) type string
const mk = (type: string, name: string, regionKey: string): MarkerSpec =>
({ type: type as MarkerType, name, regionKey });
it('adds a valid marker with a stable id, a jittered position within the spread, the region owner, and the placed turn', () => {
const state = baseState();
state.turn = 4; // placedTurn should equal the resolved turn
state.nations.uk.territory = ['GBR', 'moselle-rygy']; // the region is owned by uk
const result: TurnResult = {
date: '1939-09-03',
events: [],
stateChanges: [],
relations: [],
territoryChanges: [],
unowned: [],
nationOps: [],
markers: { add: [mk('city', 'Strasbourg', 'moselle-rygy')], remove: [] },
};
const next = applyTurnResult(state, result, undefined, centroidOf);
expect(next.markers).toHaveLength(1);
const m = next.markers[0];
expect(m.id).toBe(markerId('city', 'Strasbourg', 'moselle-rygy'));
expect(m.type).toBe('city');
expect(m.name).toBe('Strasbourg');
expect(m.regionKey).toBe('moselle-rygy');
expect(m.ownerNationId).toBe('uk'); // the region's sole owner
expect(m.placedTurn).toBe(4);
// the position is the centroid jittered within ±spread/2
expect(m.lat).toBeGreaterThanOrEqual(47.5);
expect(m.lat).toBeLessThanOrEqual(48.5);
expect(m.lon).toBeGreaterThanOrEqual(5.5);
expect(m.lon).toBeLessThanOrEqual(6.5);
});
it('is idempotent: re-adding the same (type, name, regionKey) does not duplicate the marker', () => {
const state = baseState();
state.nations.uk.territory = ['moselle-rygy'];
const add = [mk('city', 'Strasbourg', 'moselle-rygy')];
const first = applyTurnResult(state, {
date: '1939-09-03', events: [], stateChanges: [], relations: [], territoryChanges: [],
unowned: [], nationOps: [], markers: { add, remove: [] },
}, undefined, centroidOf);
expect(first.markers).toHaveLength(1);
// a second turn proposes the exact same marker again
const second = applyTurnResult(first, {
date: '1939-09-05', events: [], stateChanges: [], relations: [], territoryChanges: [],
unowned: [], nationOps: [], markers: { add, remove: [] },
}, undefined, centroidOf);
expect(second.markers).toHaveLength(1);
expect(second.markers[0].id).toBe(first.markers[0].id); // stable id, no second copy
});
it('applyTurnResult drops a new add whose region is unknown or has no centroid, but keeps a valid one', () => {
const state = baseState();
state.nations.uk.territory = ['moselle-rygy'];
// validKeys knows two keys; the centroid resolves only the first.
const validKeys = new Set(['moselle-rygy', 'other-key']);
const centroidOf2 = (k: string) =>
k === 'moselle-rygy' ? { lat: 48, lon: 6, spread: 1 } : undefined;
const result: TurnResult = {
date: '1939-09-03',
events: [],
stateChanges: [],
relations: [],
territoryChanges: [],
unowned: [],
nationOps: [],
markers: {
add: [
{ type: 'city', name: 'Good', regionKey: 'moselle-rygy' }, // valid + centroid → kept
{ type: 'city', name: 'NoCent', regionKey: 'other-key' }, // in validKeys, no centroid → dropped
{ type: 'city', name: 'Unknown', regionKey: 'zzz-not-a-key' }, // not in validKeys → dropped
],
remove: [],
},
};
const next = applyTurnResult(state, result, validKeys, centroidOf2);
expect(next.markers).toHaveLength(1);
expect(next.markers[0].name).toBe('Good');
});
it('caps the total marker count at MAX_MARKERS, dropping the newest add that exceeds the cap', () => {
const state = baseState();
// pre-seed the state with MAX_MARKERS - 1 distinct markers
const seeded: typeof state.markers = [];
for (let i = 0; i < MAX_MARKERS - 1; i++) {
const k = `seed-${i}`;
seeded.push({
id: markerId('city', `S${i}`, k),
type: 'city',
name: `S${i}`,
regionKey: k,
lat: 0, lon: 0,
ownerNationId: null,
placedTurn: 1,
});
}
state.markers = seeded;
// a permissive centroid: every region key resolves
const anyCentroid = () => ({ lat: 0, lon: 0, spread: 0 });
const result: TurnResult = {
date: '1939-09-03',
events: [],
stateChanges: [],
relations: [],
territoryChanges: [],
unowned: [],
nationOps: [],
markers: {
add: [
{ type: 'city', name: 'NewA', regionKey: 'new-a' },
{ type: 'city', name: 'NewB', regionKey: 'new-b' },
],
remove: [],
},
};
const next = applyTurnResult(state, result, undefined, anyCentroid);
expect(next.markers).toHaveLength(MAX_MARKERS); // capped, not MAX_MARKERS + 1
expect(next.markers.some((m) => m.name === 'NewA')).toBe(true); // the 1st new one kept
expect(next.markers.some((m) => m.name === 'NewB')).toBe(false); // the 2nd (over cap) dropped
});
it('removes a marker by id; removing an absent id is a no-op', () => {
const state = baseState();
state.nations.uk.territory = ['moselle-rygy'];
const idA = markerId('city', 'Strasbourg', 'moselle-rygy');
const idB = markerId('port', 'Dunkirk', 'moselle-rygy');
state.markers = [
{ id: idA, type: 'city', name: 'Strasbourg', regionKey: 'moselle-rygy', lat: 48, lon: 6, ownerNationId: 'uk', placedTurn: 1 },
{ id: idB, type: 'port', name: 'Dunkirk', regionKey: 'moselle-rygy', lat: 48, lon: 6, ownerNationId: 'uk', placedTurn: 1 },
];
const next = applyTurnResult(state, {
date: '1939-09-03', events: [], stateChanges: [], relations: [], territoryChanges: [],
unowned: [], nationOps: [], markers: { add: [], remove: [idA, 'zzzz-not-there'] },
}, undefined, centroidOf);
expect(next.markers).toHaveLength(1);
expect(next.markers[0].id).toBe(idB); // only A removed; the bogus id was a no-op
});
it('a marker owner follows its region: released to no man\'s land → null; re-claimed → the new owner', () => {
const state = baseState();
state.nations.uk.territory = ['moselle-rygy'];
const idA = markerId('city', 'Strasbourg', 'moselle-rygy');
state.markers = [
{ id: idA, type: 'city', name: 'Strasbourg', regionKey: 'moselle-rygy', lat: 48, lon: 6, ownerNationId: 'uk', placedTurn: 1 },
];
// 1) uk releases the region → the marker becomes ownerless
const released = applyTurnResult(state, {
date: '1939-09-03', events: [], stateChanges: [], relations: [], territoryChanges: [],
unowned: ['moselle-rygy'], nationOps: [], markers: { add: [], remove: [] },
}, undefined, centroidOf);
expect(released.markers[0].ownerNationId).toBeNull();
// 2) de re-claims the region → the marker's owner follows to de
const reclaimed = applyTurnResult(released, {
date: '1939-09-05', events: [], stateChanges: [], relations: [],
territoryChanges: [{ nation: 'de', gain: ['moselle-rygy'], lose: [] }],
unowned: [], nationOps: [], markers: { add: [], remove: [] },
}, undefined, centroidOf);
expect(reclaimed.markers[0].ownerNationId).toBe('de');
});
it('the user prompt carries the existing markers when provided (and omits the field otherwise)', () => {
const state = baseState();
const info = {
id: markerId('city', 'Strasbourg', 'moselle-rygy'),
type: 'city' as MarkerType,
name: 'Strasbourg',
regionKey: 'moselle-rygy',
owner: 'uk',
};
const payload = JSON.parse(
buildUserPrompt(state, [], undefined, undefined, undefined, [info]),
) as { markers?: Array<Record<string, unknown>> };
expect(payload.markers).toHaveLength(1);
expect(payload.markers![0].name).toBe('Strasbourg');
expect(payload.markers![0].owner).toBe('uk');
// a bare call (no markers arg) omits the field entirely
const bare = JSON.parse(buildUserPrompt(state, [])) as { markers?: unknown };
expect(bare.markers).toBeUndefined();
});
it('the system prompt documents the markers contract (field + placement rule + a type)', () => {
const prompt = buildSystemPrompt(preset);
expect(prompt).toMatch(/named points on the map/i);
expect(prompt).toMatch(/"markers"/);
expect(prompt).toMatch(/"add"/);
expect(prompt).toMatch(/"remove"/);
expect(prompt).toMatch(/gathering/);
expect(prompt).toMatch(/no man's land/); // owner recompute on release
});
it('validateMarkerOps cleans a raw markers field: drops bad types/empty names, dedupes, trims removes', () => {
const raw = {
add: [
{ type: 'city', name: 'X', regionKey: 'a' },
{ type: 'not-a-type', name: 'Y', regionKey: 'a' }, // invalid type -> dropped
{ type: 'city', name: ' ', regionKey: 'a' }, // empty name -> dropped
{ type: 'city', name: 'X', regionKey: 'a' }, // duplicate (same id) -> deduped
{ type: 'port', name: 'Z', regionKey: 'b' }, // valid
],
remove: ['id-1', 42, '', ' id-2 ', 'id-1'], // number/empty dropped, trimmed, deduped
};
const out = validateMarkerOps(raw);
expect(out.add.map((m) => m.name).sort()).toEqual(['X', 'Z']);
expect(out.remove).toEqual(['id-1', 'id-2']);
});
it('validateMarkerOps returns empty ops for a non-object raw value (a string, an array, null)', () => {
for (const bad of ['oops', [{ type: 'city', name: 'X', regionKey: 'a' }], null, undefined]) {
expect(validateMarkerOps(bad)).toEqual({ add: [], remove: [] });
}
});
it('runTurn wires the centroid through: a marker added over the LLM path is placed (owner = region owner)', async () => {
const result = JSON.stringify({
date: '1939-09-03',
events: [],
stateChanges: [],
relations: [],
territoryChanges: [{ nation: 'uk', gain: ['moselle-rygy'], lose: [] }],
unowned: [],
nationOps: [],
markers: { add: [{ type: 'city', name: 'Strasbourg', regionKey: 'moselle-rygy' }], remove: [] },
});
const { state } = await runTurn({
llm: fakeLlm([result]),
preset,
state: baseState(),
items: [],
centroidOf,
});
expect(state.markers).toHaveLength(1);
expect(state.markers[0].name).toBe('Strasbourg');
expect(state.markers[0].ownerNationId).toBe('uk'); // uk gained the region this turn
});
});
+170 -10
View File
@@ -7,6 +7,7 @@ import type {
EventOutcome,
EventType,
GameState,
MapMarker,
Nation,
NationStats,
Preset,
@@ -18,9 +19,15 @@ import { EVENT_TYPES, RELATION_STATUSES } from '../shared/types';
import {
addDays,
clampStat,
fnv1a32,
isDateLike,
isMarkerType,
makeEvent,
markerId,
MAX_MARKERS,
nationForPrompt,
type MarkerInfo,
type MarkerSpec,
type NationOp,
type TurnEventSpec,
type TurnResult,
@@ -187,12 +194,14 @@ export function buildSystemPrompt(preset: Preset, withNeighboursTool = false): s
' {"kind":"split","nationRef":"<nation id or name>","parts":[{"name":"<new name>","territory":["<region key>"]}]}',
' {"kind":"merge","nationRefs":["<id>","<id>"],"survivingRef":"<id of the surviving nation>","into":"<new name>"}',
' ],',
' "markers": {"add":[{"type":"<city|factory|army|port|capital|fleet|rocket|gathering>","name":"<short name>","regionKey":"<region key>"}],"remove":["<marker id>"]}',
'}',
'',
'HARD CONSTRAINTS',
'- 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 and unowned.',
'- List in "unowned" only region keys that a nation currently owns, and narrate each release in "events".',
'- Map markers ("markers") are named points on the map, anchored to a region key you own or that the narrative justifies: a capital (a nation\'s main seat of power — at most one per nation), major cities, factory complexes, army deployments, ports, fleet concentrations, rocket launch sites, or a gathering (a large political/social mass event that can enable a non-military takeover). Use only region keys from the "regions" table, keep each name under 40 characters, and place at most a handful of new markers per turn — only where the narrative justifies it. List a marker id from the user message\'s "markers" field in "remove" when it ceases to exist (a city razed, an army dispersed, a site destroyed).',
'- Nation-management ops ("nationOps") are PROPOSALS: they only take effect if the player confirms them afterwards. Propose at most 1-2 per turn, only when the narrative justifies it, and narrate each one in "events".',
'- Rename: add {"kind":"rename","nationRef":"<nation id or name>","to":"<new name>"} for a revolution, regime change or reunification that changes a nation\'s name.',
'- Split: add {"kind":"split","nationRef":"<nation id or name>","parts":[{"name":"<new name>","territory":["<region key>"]}, ...]} when a state breaks up (dissolution, secession). Use at least 2 parts and assign each of the nation\'s region keys to at most one part — any key you leave unassigned goes to the first part.',
@@ -232,6 +241,14 @@ export function buildUserPrompt(
* Omitted entirely when undefined.
*/
noMansLand?: string[],
/**
* (v0.27, #44) The game's current map markers, as the model sees them:
* stable id, type, name, region key and current owner. Emitted verbatim
* (even when empty) so the model can reference a marker's id in
* `TurnResult.markers.remove` or leave an existing marker untouched.
* Omitted entirely when undefined.
*/
markers?: MarkerInfo[],
): string {
const player = state.nations[state.playerNationId];
const playerName = player?.name ?? state.playerNationId;
@@ -270,6 +287,7 @@ export function buildUserPrompt(
if (regionReference) payload.regions = regionReference;
if (frontier !== undefined) payload.frontier = frontier;
if (noMansLand !== undefined) payload.unowned = noMansLand;
if (markers !== undefined) payload.markers = markers;
return JSON.stringify(payload, null, 2);
}
@@ -393,6 +411,7 @@ export function validateTurnResult(input: unknown): TurnResult | null {
territoryChanges: [],
unowned: [],
nationOps: [],
markers: { add: [], remove: [] },
};
if (Array.isArray(o.events)) {
@@ -482,9 +501,51 @@ export function validateTurnResult(input: unknown): TurnResult | null {
// list for the player to confirm/reject.
result.nationOps = validateNationOps(o.nationOps);
// (v0.27, #44) Map-marker ops: new placements (validated here) and
// removals by stable id. Applied by applyTurnResult after the territory
// moves (a new marker's owner is the region's post-turn owner).
result.markers = validateMarkerOps(o.markers);
return result;
}
/**
* (v0.27, #44) Validate the raw `markers` object of a turn into a safe
* `{ add, remove }` shape. Defensive like the rest of the parser — never
* throws. `add`: each spec needs a known marker type, a non-empty `name`
* (capped at 120 chars) and a non-empty `regionKey` (capped at 120 chars);
* specs whose derived id collides with an earlier one in the same batch
* collapse to the first (re-adding is idempotent). `remove`: deduped,
* non-empty marker ids (capped at 120 chars).
*/
export function validateMarkerOps(raw: unknown): { add: MarkerSpec[]; remove: string[] } {
const out: { add: MarkerSpec[]; remove: string[] } = { add: [], remove: [] };
if (!raw || typeof raw !== 'object' || Array.isArray(raw)) return out;
const o = raw as Record<string, unknown>;
if (Array.isArray(o.add)) {
const seen = new Set<string>();
for (const e of o.add) {
if (!e || typeof e !== 'object') continue;
const s = e as Record<string, unknown>;
if (!isMarkerType(s.type)) continue;
const name = str(s.name, 120);
const regionKey = str(s.regionKey, 120);
if (!name || !regionKey) continue;
const id = markerId(s.type, name, regionKey);
if (seen.has(id)) continue;
seen.add(id);
out.add.push({ type: s.type, name, regionKey });
}
}
if (Array.isArray(o.remove)) {
for (const k of o.remove) {
const s = typeof k === 'string' ? k.trim() : '';
if (s && !out.remove.includes(s)) out.remove.push(s.slice(0, 120));
}
}
return out;
}
/**
* A turn is "meaningful" if it produced at least an event, a stat change, a
* no-man's-land release (v0.22 #16 P3: a release is a real world change even
@@ -498,7 +559,10 @@ export function isMeaningful(result: TurnResult): boolean {
result.events.length > 0 ||
result.stateChanges.length > 0 ||
result.unowned.length > 0 ||
result.nationOps.length > 0
result.nationOps.length > 0 ||
// (v0.27, #44) a marker placed or removed this turn is a real world change.
result.markers.add.length > 0 ||
result.markers.remove.length > 0
);
}
@@ -516,13 +580,25 @@ function genericUnclearEvent(state: GameState): TurnEventSpec {
/**
* Apply a TurnResult to a GameState immutably: advance the date/turn, append
* events, apply stat changes (clamped), symmetric relations, and territory
* moves (including no-man's-land releases, v0.22 #16 P3). It also appends any
* proposed nation-ops to the pending list (v0.23 #16 P4a) without applying
* them — see the doc comment on `pendingNationOps` for why that list
* deliberately survives across turns.
* events, apply stat changes (clamped), symmetric relations, territory moves
* (including no-man's-land releases, v0.22 #16 P3) and map markers (v0.27
* #44). It also appends any proposed nation-ops to the pending list (v0.23
* #16 P4a) without applying them — see the doc comment on `pendingNationOps`
* for why that list deliberately survives across turns.
*
* `centroidOf` (v0.27, #44) resolves a region key to its precomputed centroid
* (`{ lat, lon, spread }`, from public/region-centroids.json via
* server/regions.ts). New markers are positioned at that centroid plus a small
* deterministic offset; a spec whose key has no known centroid is dropped.
* Without it no marker can be placed (all adds are dropped), which keeps the
* engine pure and lets tests inject their own data.
*/
export function applyTurnResult(state: GameState, result: TurnResult, validKeys?: ReadonlySet<string>): GameState {
export function applyTurnResult(
state: GameState,
result: TurnResult,
validKeys?: ReadonlySet<string>,
centroidOf?: (key: string) => { lat: number; lon: number; spread: number } | undefined,
): GameState {
const next: GameState = JSON.parse(JSON.stringify(state));
next.version = (state.version ?? 0) + 1; // every mutation is a new revision
const actingTurn = state.turn; // this action resolves the current turn
@@ -636,6 +712,73 @@ export function applyTurnResult(state: GameState, result: TurnResult, validKeys?
}
next.unowned = [...unowned];
// --- Map markers (v0.27, #44) ---
//
// Markers are anchored to a region key and are processed against the FINAL
// post-claim ownership, in this order:
// (1) REMOVE: a marker whose id is in result.markers.remove is dropped
// (a listed-but-absent id is a no-op).
// (2) OWNER RECOMPUTE: every surviving marker's ownerNationId is set to
// its region's current owner — a captured region follows the new
// owner, a region released to no man's land (or a key with 0 or 2+
// owners) becomes `null`. A marker always follows its region.
// (3) ADD: each new spec (in order) is placed at its region's centroid
// (injected via centroidOf) plus a small deterministic per-axis
// offset, owned by the region's post-turn owner, and stamped with
// the acting turn. Dropped (never throws): a region key that is not
// in validKeys or has no known centroid, an id that already exists
// (a re-add is idempotent — the existing marker stays), and any adds
// beyond MAX_MARKERS (the later ones).
{
const finalOwner = new Map<string, string | null>();
{
const owners = new Map<string, string[]>();
for (const n of Object.values(next.nations)) {
for (const key of n.territory) {
if (!valid(key)) continue;
const list = owners.get(key);
if (list) list.push(n.id);
else owners.set(key, [n.id]);
}
}
for (const [key, list] of owners) finalOwner.set(key, list.length === 1 ? list[0] : null);
}
const removeIds = new Set(result.markers.remove);
const markers: MapMarker[] = [];
for (const m of next.markers ?? []) {
if (removeIds.has(m.id)) continue;
markers.push({ ...m, ownerNationId: finalOwner.get(m.regionKey) ?? null });
}
// Deterministic ±(spread/2) offset per axis: a FNV-1a-derived unit
// fraction of the marker id (stable across runs and platforms), so
// markers of one region scatter naturally instead of stacking.
const jitter = (id: string, axis: 'lat' | 'lon', spread: number): number => {
const frac = (fnv1a32(`${id}#${axis}`) >>> 8) / 0x00ffffff;
return (frac * 2 - 1) * (spread / 2);
};
const round6 = (v: number): number => Math.round(v * 1e6) / 1e6;
for (const spec of result.markers.add) {
const id = markerId(spec.type, spec.name, spec.regionKey);
if (markers.some((m) => m.id === id)) continue; // re-add: idempotent, keep the existing
if (markers.length >= MAX_MARKERS) continue; // hard cap: drop the excess
if (!valid(spec.regionKey)) continue; // unknown region key
const c = centroidOf?.(spec.regionKey);
if (!c) continue; // no centroid data for this key
markers.push({
id,
type: spec.type,
name: spec.name,
regionKey: spec.regionKey,
lat: round6(c.lat + jitter(id, 'lat', c.spread)),
lon: round6(c.lon + jitter(id, 'lon', c.spread)),
ownerNationId: finalOwner.get(spec.regionKey) ?? null,
placedTurn: actingTurn,
});
}
next.markers = markers;
}
// (v0.23, #16 P4a) Nation-op proposals are NOT applied by the turn — they
// are appended to the pending list, which (unlike `pending`) SURVIVES
// across turns until the player confirms or rejects each one. `next` is a
@@ -965,15 +1108,31 @@ export async function runTurn(opts: {
* switches the turn onto the getNeighbours tool path.
*/
getNeighbours?: (keys: string[]) => string[];
/**
* (v0.27, #44) Region-key -> precomputed centroid resolver (from
* public/region-centroids.json). Present, the turn's new markers can be
* placed at their region's centroid; absent, marker adds are dropped and
* the existing markers' owners still get recomputed.
*/
centroidOf?: (key: string) => { lat: number; lon: number; spread: number } | undefined;
}): Promise<{ state: GameState; result: TurnResult }> {
const { llm, preset, state, items, regionReference, validRegionKeys, getNeighbours } = opts;
const { llm, preset, state, items, regionReference, validRegionKeys, getNeighbours, centroidOf } = opts;
const chatWithTools = llm.chatWithTools;
const useTools = getNeighbours !== undefined && chatWithTools !== undefined;
const system = buildSystemPrompt(preset, useTools);
const frontier = getNeighbours ? playerFrontier(state, getNeighbours) : undefined;
// (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 ?? []);
// (v0.27, #44) likewise the current map markers, so it can reference their
// stable ids in `markers.remove` (or leave them untouched).
const markers = (state.markers ?? []).map((m) => ({
id: m.id,
type: m.type,
name: m.name,
regionKey: m.regionKey,
owner: m.ownerNationId,
}));
const user = buildUserPrompt(state, items, regionReference, frontier, state.unowned ?? [], markers);
const nudge =
'Your previous output was invalid. Respond with ONLY the JSON object described above for this batch — no prose, no fences.';
@@ -1018,10 +1177,11 @@ export async function runTurn(opts: {
territoryChanges: [],
unowned: [],
nationOps: [],
markers: { add: [], remove: [] },
};
}
return { state: applyTurnResult(state, result, validRegionKeys), result };
return { state: applyTurnResult(state, result, validRegionKeys, centroidOf), result };
}
// --- Advisor (brainstorm) -----------------------------------------------------
+8 -1
View File
@@ -22,7 +22,7 @@ import { createLlmClient } from './llm';
import { makeStubLlmClient } from './stub';
import { askAdvisor, confirmNationOp, queueRoundItem, rejectNationOp, removeRoundItem, runTurn } from './engine';
import { buildRegionMap } from './map';
import { allTerritoryKeys, expandCountriesToKeys, hasAdjacency, loadRegions, neighboursOf, referenceForKeys, validKeys } from './regions';
import { allTerritoryKeys, centroidOf, expandCountriesToKeys, hasAdjacency, hasCentroids, loadRegions, neighboursOf, referenceForKeys, validKeys } from './regions';
import { createPresetStore } from './presets';
import { openDb, type Db } from './db';
@@ -134,6 +134,8 @@ app.post('/api/games', (req: Request, res) => {
// list grows as the LLM proposes renames (and, later, splits/merges) and
// shrinks as the player confirms or rejects them.
pendingNationOps: [],
// (v0.27, #44) No markers on a fresh game; the LLM places them over time.
markers: [],
};
// 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
@@ -288,6 +290,11 @@ app.post('/api/games/:id/jump', async (req, res) => {
// engine the resolver — the turn then runs with the LLM's getNeighbours
// tool and the player-frontier prompt field (both switch off without it).
getNeighbours: hasAdjacency() ? (keys) => neighboursOf(keys) : undefined,
// (v0.27, #44) when the centroid manifest loaded, hand the engine the
// resolver so a turn can place new map markers at their region's
// centroid (without it, marker adds are dropped; existing markers are
// still maintained).
centroidOf: hasCentroids() ? (key) => centroidOf(key) : undefined,
});
db.save(state);
broadcast(id, 'turn', { state, result });
+1
View File
@@ -49,6 +49,7 @@ const state: GameState = {
version: 1,
unowned: [],
pendingNationOps: [],
markers: [],
};
// The RegionMap built from the fixture: the authoritative region key → owner source.
+34
View File
@@ -5,8 +5,10 @@ import { tmpdir } from 'node:os';
import path from 'node:path';
import {
allTerritoryKeys,
centroidOf,
expandCountriesToKeys,
hasAdjacency,
hasCentroids,
hasRegionData,
loadRegions,
neighboursOf,
@@ -103,6 +105,7 @@ describe('regions (region-key loader, v0.19 #16 P1)', () => {
version: 1,
unowned: [],
pendingNationOps: [],
markers: [],
};
// 16 GBR sub-regions + 42 FRA sub-regions, no overlap between the two.
expect(allTerritoryKeys(state).size).toBe(58);
@@ -133,6 +136,19 @@ describe('regions (region-key loader, v0.19 #16 P1)', () => {
expect(neighboursOf([])).toEqual([]);
});
it('(v0.27 #44) hasCentroids() is true and centroidOf resolves a real key to a plausible location', () => {
expect(hasCentroids()).toBe(true);
const ain = centroidOf('ain-lqtt');
expect(ain).toBeDefined();
expect(ain!.lat).toBeCloseTo(46.16, 1);
expect(ain!.lon).toBeCloseTo(4.8, 1);
expect(ain!.spread).toBeGreaterThan(0);
});
it('(v0.27 #44) centroidOf returns undefined for an unknown key', () => {
expect(centroidOf('not-a-real-key-xyz')).toBeUndefined();
});
describe('graceful degradation with no region data', () => {
beforeEach(() => resetRegions());
@@ -146,6 +162,9 @@ describe('regions (region-key loader, v0.19 #16 P1)', () => {
// and no adjacency: the LLM tool / frontier field switch off
expect(hasAdjacency()).toBe(false);
expect(neighboursOf(['moselle-rygy'])).toEqual([]);
// and no centroids: the marker-placement path degrades to a no-op
expect(hasCentroids()).toBe(false);
expect(centroidOf('ain-lqtt')).toBeUndefined();
});
});
@@ -197,6 +216,21 @@ describe('loadRegions resolves the manifest per environment (dev public/ vs prod
expect(loadRegions(prodRoot)).toBe(true);
expect(hasAdjacency()).toBe(false);
expect(neighboursOf(['moselle-rygy'])).toEqual([]);
// and no centroids (not copied into this dist/) -> marker placement degrades
expect(hasCentroids()).toBe(false);
expect(centroidOf('ain-lqtt')).toBeUndefined();
});
it('(v0.27 #44) a dist/ WITH the centroids manifest loads them (prod layout)', () => {
copyFileSync(
path.join(realRoot, 'public', 'region-centroids.json'),
path.join(prodRoot, 'dist', 'region-centroids.json'),
);
expect(loadRegions(prodRoot)).toBe(true);
expect(hasCentroids()).toBe(true);
const ain = centroidOf('ain-lqtt');
expect(ain).toBeDefined();
expect(ain!.lat).toBeCloseTo(46.16, 1);
});
it('returns false and no data when neither public/ nor dist/ holds the files', () => {
+67 -4
View File
@@ -2,7 +2,9 @@
// (region-keys.json: stable feature id -> region key), the sub-national geo
// (regions.geo.json: feature id -> { name, parent }), and — optionally — the
// precomputed region-adjacency manifest (region-adjacency.json: region key ->
// bordering region keys, built by scripts/build-region-adjacency.mjs) — all
// bordering region keys, built by scripts/build-region-adjacency.mjs) and the
// precomputed region-centroid manifest (region-centroids.json: region key ->
// { lat, lon, spread }, built by scripts/build-region-centroids.mjs) — all
// from public/ in dev (Vite serves it) or dist/ in prod (Vite copies
// public/* -> dist/ at build, and the Docker image keeps only dist/) — and
// joins them into the lookups the region-level ownership model needs:
@@ -13,6 +15,8 @@
// - neighboursOf(): the union of the bordering keys for a set of keys — the
// data behind the LLM's getNeighbours tool and the player-frontier prompt
// field (v0.21, #16 P2)
// - centroidOf(): a region key's precomputed anchor point ({ lat, lon, spread })
// — the data behind LLM-placed map markers (v0.27, #44)
//
// Deliberately turf-free: it reads the JSON files with plain JSON.parse and
// never imports @turf/@types/geojson (importing one pulls @types/geojson into
@@ -39,12 +43,15 @@ interface RegionData {
validKeys: Set<string>;
/** region key -> bordering region keys (from public/region-adjacency.json). */
adjacency: Record<string, string[]>;
/** region key -> anchor point (from public/region-centroids.json). */
centroids: Record<string, { lat: number; lon: number; spread: number }>;
}
let data: RegionData | null = null;
/**
* Load (or reload) the region-key manifest, geo, and adjacency. Idempotent.
* Load (or reload) the region-key manifest, geo, adjacency, and centroids.
* Idempotent.
* The files are served to the client from `public/` in dev (Vite serves it
* directly) and from `dist/` in prod (Vite copies `public/*` → `dist/` at build
* and the Docker image keeps only `dist/`), so probe `public/` then `dist/`
@@ -52,7 +59,10 @@ let data: RegionData | null = null;
* key manifest + geo. The adjacency manifest is OPTIONAL: when it is missing
* or broken, `adjacency` stays empty and `neighboursOf`/`hasAdjacency` simply
* report "no adjacency" (the LLM tool and the frontier prompt field switch
* off; everything else keeps working). Returns true when the core pair loaded
* off; everything else keeps working). The centroid manifest is likewise
* OPTIONAL (v0.27, #44): when missing or broken, `centroids` stays empty and
* `centroidOf`/`hasCentroids` report "no centroids" (new map markers can't be
* placed, but everything else keeps working). Returns true when the core pair loaded
* cleanly; a missing/broken pair clears the data and returns false so the
* server still boots and degrades gracefully (expansion becomes a no-op,
* ownership falls back to the raw input keys).
@@ -61,6 +71,7 @@ export function loadRegions(root: string): boolean {
const keyFile = 'region-keys.json';
const geoFile = 'regions.geo.json';
const adjacencyFile = 'region-adjacency.json';
const centroidsFile = 'region-centroids.json';
const base = [
path.join(root, 'public'), // dev: Vite serves public/ at /
path.join(root, 'dist'), // prod: Vite copies public/* -> dist/ (== clientDir)
@@ -105,7 +116,38 @@ export function loadRegions(root: string): boolean {
}
}
data = { idToKey, keyToInfo, keysByCountry, validKeys, adjacency };
// Centroids are optional too (v0.27, #44): a missing/broken file means
// "no centroid data" — new markers can't be placed (their adds are
// dropped), but everything else keeps working.
let centroids: Record<string, { lat: number; lon: number; spread: number }> = {};
const centroidsPath = path.join(base, centroidsFile);
if (existsSync(centroidsPath)) {
try {
const raw = JSON.parse(readFileSync(centroidsPath, 'utf8')) as Record<
string,
{ lat?: unknown; lon?: unknown; spread?: unknown }
>;
// Keep only well-formed entries; a corrupt file degrades to "none".
for (const [k, v] of Object.entries(raw)) {
if (
v &&
typeof v.lat === 'number' &&
typeof v.lon === 'number' &&
typeof v.spread === 'number' &&
Number.isFinite(v.lat) &&
Number.isFinite(v.lon) &&
Number.isFinite(v.spread)
) {
centroids[k] = { lat: v.lat, lon: v.lon, spread: v.spread };
}
}
} catch (err) {
console.error('[regions] failed to load region-centroids.json, continuing without it', err);
centroids = {};
}
}
data = { idToKey, keyToInfo, keysByCountry, validKeys, adjacency, centroids };
return validKeys.size > 0;
} catch (err) {
console.error('[regions] failed to load region data', err);
@@ -189,6 +231,27 @@ export function hasAdjacency(): boolean {
return data !== null && Object.keys(data.adjacency).length > 0;
}
/**
* True when the region-centroid manifest loaded with at least one entry
* (v0.27, #44). The server's jump route uses this to decide whether to hand
* the engine a centroid resolver (which lets a turn place new map markers)
* or skip marker placement (existing markers are still maintained).
*/
export function hasCentroids(): boolean {
return data !== null && Object.keys(data.centroids).length > 0;
}
/**
* The precomputed anchor point for a region key: the centroid of its
* largest-area part, plus a `spread` (≈ 0.1 × that part's smallest bbox
* dimension, in degrees) the engine scatters markers within
* ±(spread/2) of the centroid. `undefined` for an unknown key or when no
* centroid data is loaded (v0.27, #44).
*/
export function centroidOf(key: string): { lat: number; lon: number; spread: number } | undefined {
return data?.centroids[key];
}
/**
* The union of the region keys that share a land border with any of the given
* keys — deduplicated, the queried keys themselves excluded, and sorted for
+12
View File
@@ -31,6 +31,13 @@ export interface StubPayload {
* is covered by unit tests against applyTurnResult, not by the stub.
*/
unowned?: string[];
/**
* (v0.27, #44) Current map markers (`{ id, type, name, regionKey, owner }`),
* emitted by the engine's user prompt. The stub ignores this field — it
* never places or removes a marker (see deterministicTurn), so the marker
* path is covered by unit tests against applyTurnResult, not by the stub.
*/
markers?: Array<{ id: string; type: string; name: string; regionKey: string; owner: string | null }>;
}
function truncate(s: string, n: number): string {
@@ -61,6 +68,10 @@ function truncate(s: string, n: number): string {
* (v0.23, #16 P4a) The stub likewise NEVER proposes nation-management ops (it
* always returns `nationOps: []`) — the propose→confirm/reject flow is
* covered by unit tests only, keeping the e2e assertions untouched.
*
* (v0.27, #44) The stub likewise NEVER places or removes a map marker (it
* always returns `markers: { add: [], remove: [] }`) — the marker path is
* covered by unit tests only, keeping the e2e assertions untouched.
*/
export function deterministicTurn(p: StubPayload): TurnResult {
const state = p.state;
@@ -126,6 +137,7 @@ export function deterministicTurn(p: StubPayload): TurnResult {
territoryChanges,
unowned: [], // the stub never creates no man's land (see doc comment)
nationOps: [], // the stub never proposes nation ops (see doc comment)
markers: { add: [], remove: [] }, // the stub never touches markers (see doc comment)
};
}