v0.27.0 (#44): LLM-placed named map point markers
CI / build + unit + e2e (pull_request) Successful in 2m36s
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:
@@ -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'));
|
||||
|
||||
@@ -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
@@ -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
@@ -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
@@ -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 });
|
||||
|
||||
@@ -49,6 +49,7 @@ const state: GameState = {
|
||||
version: 1,
|
||||
unowned: [],
|
||||
pendingNationOps: [],
|
||||
markers: [],
|
||||
};
|
||||
|
||||
// The RegionMap built from the fixture: the authoritative region key → owner source.
|
||||
|
||||
@@ -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
@@ -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
|
||||
|
||||
@@ -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)
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user