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
+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) -----------------------------------------------------