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:
+26
-13
@@ -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) -----------------------------------------------------
|
||||
|
||||
Reference in New Issue
Block a user