Flip ownership to region keys: shared contract + server (v0.19 P1 of #16)

The single source of truth now keys ownership by region key (one per owned
sub-region) instead of a parent-country ISO_A3:

- shared/types.ts, shared/protocol.ts: TerritoryChange / RegionMap / regionOwner
  doc flips; mapFeatureOwner now reads the feature's stamped properties.regionKey
  (never the repacked numeric feature.id, nor the sub-region id — the #6 guard).
- server/regions.ts (new, turf-free): loads region-keys.json + regions.geo.json
  once at boot; expands a nation's ISO_A3 list to its sub-regions' keys, builds
  the LLM 'regions' reference table (key -> "name (parent)"), and exposes the
  set of valid keys. Degrades gracefully when the files are missing.
- server/engine.ts: buildSystemPrompt instructs the model to use region keys;
  buildUserPrompt gains an optional regionReference table; applyTurnResult gains
  an optional validKeys set so hallucinated keys are ignored.
- server/index.ts: loads region data at boot; expands each nation's country list
  to region keys at game creation; the jump route passes the reference + valid
  keys through runTurn.

The stub is unchanged (it emits an empty, region-key-conformant territoryChanges).
This commit is contained in:
2026-10-14 09:14:10 +00:00
parent 2cfa2f78f9
commit 325bdc5255
6 changed files with 214 additions and 40 deletions
+26 -13
View File
@@ -53,11 +53,12 @@ export function buildSystemPrompt(preset: Preset): string {
' ],',
' "stateChanges": [{"nation":"<nationId>","stat":"<military|economy|stability|population>","op":"<set|add>","value":<0-100>}],',
' "relations": [{"a":"<nationId>","b":"<nationId>","set":"<ally|neutral|rival|hostile|war>"}],',
' "territoryChanges": [{"nation":"<nationId>","gain":["<ISO_A3>"],"lose":["<ISO_A3>"]}]',
' "territoryChanges": [{"nation":"<nationId>","gain":["<region key>"],"lose":["<region key>"]}]',
'}',
'',
'HARD CONSTRAINTS',
'- Use only nation ids and ISO_A3 codes that exist in the provided state.',
'- Use only nation ids and region keys that exist in the provided state.',
'- A region key identifies one sub-region; the "regions" table in the user message maps each key to "<region name> (<parent country ISO_A3>)". Use those keys in territoryChanges.',
'- Prefer 1-4 events per turn; keep each text 1-3 sentences.',
'- Keep your internal reasoning brief (a few sentences) and go straight to the JSON result; do not over-deliberate or re-derive facts already present in the state.',
'- Keep every stat value between 0 and 100.',
@@ -75,7 +76,11 @@ export function buildSystemPrompt(preset: Preset): string {
* model to simulate the combined consequences of the whole batch (or to let the
* world advance on its own when the batch is empty) and return one TurnResult.
*/
export function buildUserPrompt(state: GameState, items: RoundItem[]): string {
export function buildUserPrompt(
state: GameState,
items: RoundItem[],
regionReference?: Record<string, string>,
): string {
const player = state.nations[state.playerNationId];
const playerName = player?.name ?? state.playerNationId;
const playerActions = items.map((i) =>
@@ -88,7 +93,7 @@ export function buildUserPrompt(state: GameState, items: RoundItem[]): string {
)}. Simulate the combined consequences across all nations and advance the world by one period. Return a single TurnResult.`
: 'The player takes no action this period; let the world advance on its own momentum. Return a single TurnResult.';
const payload = {
const payload: Record<string, unknown> = {
state: {
turn: state.turn,
date: state.date,
@@ -110,6 +115,7 @@ export function buildUserPrompt(state: GameState, items: RoundItem[]): string {
pending: items,
roundInstruction,
};
if (regionReference) payload.regions = regionReference;
return JSON.stringify(payload, null, 2);
}
@@ -283,7 +289,7 @@ 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.
*/
export function applyTurnResult(state: GameState, result: TurnResult): GameState {
export function applyTurnResult(state: GameState, result: TurnResult, validKeys?: ReadonlySet<string>): 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
@@ -309,15 +315,18 @@ export function applyTurnResult(state: GameState, result: TurnResult): GameState
for (const t of result.territoryChanges) {
const n = next.nations[t.nation];
if (!n) continue;
for (const code of t.gain) {
const valid = (k: string) => !validKeys || validKeys.has(k);
for (const key of t.gain) {
if (!valid(key)) continue; // ignore a key that is not a real region
for (const other of Object.values(next.nations)) {
const i = other.territory.indexOf(code);
const i = other.territory.indexOf(key);
if (i !== -1) other.territory.splice(i, 1);
}
if (!n.territory.includes(code)) n.territory.push(code);
if (!n.territory.includes(key)) n.territory.push(key);
}
for (const code of t.lose) {
const i = n.territory.indexOf(code);
for (const key of t.lose) {
if (!valid(key)) continue;
const i = n.territory.indexOf(key);
if (i !== -1) n.territory.splice(i, 1);
}
}
@@ -367,10 +376,14 @@ export async function runTurn(opts: {
preset: Preset;
state: GameState;
items: RoundItem[];
/** Region reference table (key -> "name (parent)") to include in the user prompt. */
regionReference?: Record<string, string>;
/** Set of valid region keys; territory changes with other keys are ignored. */
validRegionKeys?: ReadonlySet<string>;
}): Promise<{ state: GameState; result: TurnResult }> {
const { llm, preset, state, items } = opts;
const { llm, preset, state, items, regionReference, validRegionKeys } = opts;
const system = buildSystemPrompt(preset);
const user = buildUserPrompt(state, items);
const user = buildUserPrompt(state, items, regionReference);
const nudge =
'Your previous output was invalid. Respond with ONLY the JSON object described above for this batch — no prose, no fences.';
@@ -398,7 +411,7 @@ export async function runTurn(opts: {
};
}
return { state: applyTurnResult(state, result), result };
return { state: applyTurnResult(state, result, validRegionKeys), result };
}
// --- Advisor (brainstorm) -----------------------------------------------------