Nation-op proposals (P4a, #16): protocol types, pendingNationOps state, engine propose/resolve (stage 1)
- shared/protocol.ts: RenameOp/SplitOp/MergeOp + NationOp union; TurnResult.nationOps (required); ConfirmNationOp/RejectNationOp request+response types - shared/types.ts: GameState.pendingNationOps (type-only import of NationOp — no runtime cycle) - server/engine.ts: validateNationOps parser wired into validateTurnResult; isMeaningful counts proposals; applyTurnResult appends to the surviving pending list (never applies); runTurn fallback literal; system prompt gains the nationOps JSON field + rename proposal rule (both modes); new resolveNationRef/applyNationOp/confirmNationOp/rejectNationOp (id-first then unique case-insensitive name; rename changes Nation.name only; split/merge no-op until P4b; index validation returns null for 400) - server/db.ts: legacy rows load with pendingNationOps defaulted to [] - server/index.ts: fresh games start with pendingNationOps: [] - server/stub.ts: deterministicTurn returns nationOps: [] (the stub never proposes — unit-only coverage, matching P3) - tests: fixture updates (engine/db/map/regions) + new suites for resolveNationRef, validateNationOps, proposal lifecycle, confirm/reject (151 tests total)
This commit is contained in:
@@ -24,6 +24,7 @@ function makeState(id: string): GameState {
|
||||
pending: [],
|
||||
version: 1,
|
||||
unowned: [],
|
||||
pendingNationOps: [],
|
||||
};
|
||||
}
|
||||
|
||||
@@ -85,6 +86,17 @@ describe('openDb', () => {
|
||||
db.close();
|
||||
});
|
||||
|
||||
it('(v0.23 #16 P4a) a legacy row saved before the pendingNationOps field existed loads with it defaulted to []', () => {
|
||||
const db = openDb(':memory:');
|
||||
const legacy = makeState('legacy');
|
||||
delete (legacy as { pendingNationOps?: unknown[] }).pendingNationOps; // a pre-v0.23 save (field absent)
|
||||
db.save(legacy);
|
||||
const loaded = db.load('legacy');
|
||||
expect(loaded).not.toBeNull();
|
||||
expect(loaded!.pendingNationOps).toEqual([]);
|
||||
db.close();
|
||||
});
|
||||
|
||||
it('delete removes a game by id and reports absence otherwise', () => {
|
||||
const db = openDb(':memory:');
|
||||
db.save(makeState('a'));
|
||||
|
||||
@@ -43,6 +43,9 @@ export function openDb(filePath: string): Db {
|
||||
// (v0.22, #16 P3) Rows saved before `GameState.unowned` existed load
|
||||
// with it defaulted: a legacy game has no no-man's-land regions.
|
||||
if (state.unowned === undefined) state.unowned = [];
|
||||
// (v0.23, #16 P4a) Same for pending nation-op proposals: a legacy game
|
||||
// has none awaiting confirm/reject.
|
||||
if (state.pendingNationOps === undefined) state.pendingNationOps = [];
|
||||
return state;
|
||||
} catch {
|
||||
return null;
|
||||
|
||||
+296
-5
@@ -1,20 +1,25 @@
|
||||
import { describe, expect, it } from 'vitest';
|
||||
import type { GameState, Preset, RoundItem } from '../shared/types';
|
||||
import { makeEvent, type TurnResult } from '../shared/protocol';
|
||||
import { makeEvent, type NationOp, type TurnResult } from '../shared/protocol';
|
||||
import type { LlmClient, ToolDefinition, WireMessage } from './llm';
|
||||
import { makeStubLlmClient } from './stub';
|
||||
import {
|
||||
applyNationOp,
|
||||
applyTurnResult,
|
||||
buildSystemPrompt,
|
||||
buildUserPrompt,
|
||||
confirmNationOp,
|
||||
GET_NEIGHBOURS_TOOL,
|
||||
isMeaningful,
|
||||
makeNeighboursExecutor,
|
||||
parseTurnResult,
|
||||
playerFrontier,
|
||||
queueRoundItem,
|
||||
rejectNationOp,
|
||||
removeRoundItem,
|
||||
resolveNationRef,
|
||||
runTurn,
|
||||
validateNationOps,
|
||||
validateTurnResult,
|
||||
} from './engine';
|
||||
|
||||
@@ -79,6 +84,7 @@ function baseState(): GameState {
|
||||
pending: [],
|
||||
version: 1,
|
||||
unowned: [],
|
||||
pendingNationOps: [],
|
||||
};
|
||||
}
|
||||
|
||||
@@ -425,6 +431,271 @@ describe('removeRoundItem', () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe('resolveNationRef (v0.23 #16 P4a)', () => {
|
||||
it('resolves an exact id first (even if a name matches something else)', () => {
|
||||
const state = baseState();
|
||||
expect(resolveNationRef(state, 'de')).toBe('de');
|
||||
});
|
||||
|
||||
it('resolves a unique name case-insensitively', () => {
|
||||
const state = baseState();
|
||||
expect(resolveNationRef(state, 'US')).toBe('us');
|
||||
expect(resolveNationRef(state, ' uS ')).toBe('us'); // trimmed, case-insensitive
|
||||
});
|
||||
|
||||
it('returns null for an ambiguous name (two nations share it)', () => {
|
||||
const state = baseState();
|
||||
state.nations.us.name = 'UK'; // now uk and us are both "UK"
|
||||
expect(resolveNationRef(state, 'UK')).toBeNull();
|
||||
// but the exact id still resolves
|
||||
expect(resolveNationRef(state, 'uk')).toBe('uk');
|
||||
});
|
||||
|
||||
it('returns null for an unknown or empty reference (never throws)', () => {
|
||||
const state = baseState();
|
||||
expect(resolveNationRef(state, 'zzz-not-a-nation')).toBeNull();
|
||||
expect(resolveNationRef(state, ' ')).toBeNull();
|
||||
expect(resolveNationRef(state, '')).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe('validateNationOps (v0.23 #16 P4a)', () => {
|
||||
it('parses a valid rename (trimmed, capped) and drops invalid ones', () => {
|
||||
const ops = validateNationOps([
|
||||
{ kind: 'rename', nationRef: ' de ', to: 'Reich' },
|
||||
{ kind: 'rename', nationRef: 'uk', to: '' }, // empty to -> dropped
|
||||
{ kind: 'rename', to: 'no ref' }, // missing nationRef -> dropped
|
||||
{ kind: 'rename', nationRef: 'us' }, // missing to -> dropped
|
||||
{ kind: 'bogus-kind' }, // unknown kind -> ignored
|
||||
'not-an-object',
|
||||
null,
|
||||
]);
|
||||
expect(ops).toEqual([{ kind: 'rename', nationRef: 'de', to: 'Reich' }]);
|
||||
});
|
||||
|
||||
it('parses a valid split (parts with names kept, nameless parts dropped)', () => {
|
||||
const ops = validateNationOps([
|
||||
{
|
||||
kind: 'split',
|
||||
nationRef: 'de',
|
||||
parts: [
|
||||
{ name: 'East', territory: ['DEU', ''] },
|
||||
{ name: ' ' }, // empty name -> part dropped
|
||||
{ name: 'West' },
|
||||
],
|
||||
},
|
||||
]);
|
||||
expect(ops).toEqual([
|
||||
{
|
||||
kind: 'split',
|
||||
nationRef: 'de',
|
||||
parts: [{ name: 'East', territory: ['DEU'] }, { name: 'West', territory: [] }],
|
||||
},
|
||||
]);
|
||||
// a split with no usable parts is dropped entirely
|
||||
expect(validateNationOps([{ kind: 'split', nationRef: 'de', parts: [{ name: '' }] }])).toEqual([]);
|
||||
});
|
||||
|
||||
it('parses a valid merge and drops malformed ones', () => {
|
||||
const ops = validateNationOps([
|
||||
{ kind: 'merge', nationRefs: ['uk', 'us'], survivingRef: 'uk', into: 'Anglo-Saxon Union' },
|
||||
{ kind: 'merge', nationRefs: [], survivingRef: 'uk', into: 'X' }, // no refs -> dropped
|
||||
{ kind: 'merge', nationRefs: ['uk'], survivingRef: '', into: 'X' }, // no survivor -> dropped
|
||||
{ kind: 'merge', nationRefs: ['uk'], survivingRef: 'uk' }, // no into -> dropped
|
||||
]);
|
||||
expect(ops).toEqual([
|
||||
{ kind: 'merge', nationRefs: ['uk', 'us'], survivingRef: 'uk', into: 'Anglo-Saxon Union' },
|
||||
]);
|
||||
});
|
||||
|
||||
it('returns [] for non-array input and is never thrown by', () => {
|
||||
expect(validateNationOps(undefined)).toEqual([]);
|
||||
expect(validateNationOps('nope')).toEqual([]);
|
||||
expect(validateNationOps({})).toEqual([]);
|
||||
});
|
||||
|
||||
it('validateTurnResult carries validated nationOps through (and defaults to [] when absent)', () => {
|
||||
const withOps = validateTurnResult({
|
||||
date: '1939-09-03',
|
||||
events: [],
|
||||
stateChanges: [],
|
||||
relations: [],
|
||||
territoryChanges: [],
|
||||
unowned: [],
|
||||
nationOps: [{ kind: 'rename', nationRef: 'France', to: 'French Republic' }],
|
||||
});
|
||||
expect(withOps).not.toBeNull();
|
||||
expect(withOps!.nationOps).toEqual([{ kind: 'rename', nationRef: 'France', to: 'French Republic' }]);
|
||||
|
||||
const without = validateTurnResult({ date: '1939-09-03', events: [], stateChanges: [] });
|
||||
expect(without).not.toBeNull();
|
||||
expect(without!.nationOps).toEqual([]);
|
||||
});
|
||||
});
|
||||
|
||||
describe('applyTurnResult (nation-op proposals, v0.23 #16 P4a)', () => {
|
||||
it('a proposed rename is NOT applied by the turn — it lands in pendingNationOps, name untouched', () => {
|
||||
const state = baseState();
|
||||
const next = applyTurnResult(state, {
|
||||
date: '1939-09-03',
|
||||
events: [{ nation: 'de', type: 'political', title: 'Reunification', text: 'The nation is renamed.', outcome: 'success' }],
|
||||
stateChanges: [],
|
||||
relations: [],
|
||||
territoryChanges: [],
|
||||
unowned: [],
|
||||
nationOps: [{ kind: 'rename', nationRef: 'de', to: 'Reich' }],
|
||||
});
|
||||
expect(next.nations.de.name).toBe('DE'); // still the old name
|
||||
expect(next.pendingNationOps).toEqual([{ kind: 'rename', nationRef: 'de', to: 'Reich' }]);
|
||||
expect(next.version).toBe(2);
|
||||
// the input is not mutated
|
||||
expect(state.nations.de.name).toBe('DE');
|
||||
expect(state.pendingNationOps).toEqual([]);
|
||||
});
|
||||
|
||||
it('pending proposals SURVIVE a subsequent turn (unlike the round queue) and new ones append', () => {
|
||||
const state = baseState();
|
||||
state.pendingNationOps = [{ kind: 'rename', nationRef: 'de', to: 'Reich' }]; // unresolved from turn N-1
|
||||
const next = applyTurnResult(state, {
|
||||
date: '1939-09-03',
|
||||
events: [],
|
||||
stateChanges: [],
|
||||
relations: [],
|
||||
territoryChanges: [],
|
||||
unowned: [],
|
||||
nationOps: [{ kind: 'rename', nationRef: 'us', to: 'USA' }], // new proposal this turn
|
||||
});
|
||||
expect(next.pendingNationOps).toEqual([
|
||||
{ kind: 'rename', nationRef: 'de', to: 'Reich' }, // the old one survived
|
||||
{ kind: 'rename', nationRef: 'us', to: 'USA' }, // the new one was appended
|
||||
]);
|
||||
});
|
||||
|
||||
it('a legacy state without the pendingNationOps field accepts proposals (defaulted to [])', () => {
|
||||
const state = baseState();
|
||||
delete (state as { pendingNationOps?: NationOp[] }).pendingNationOps; // simulate a pre-v0.23 row
|
||||
const next = applyTurnResult(state, {
|
||||
date: '1939-09-03',
|
||||
events: [],
|
||||
stateChanges: [],
|
||||
relations: [],
|
||||
territoryChanges: [],
|
||||
unowned: [],
|
||||
nationOps: [{ kind: 'rename', nationRef: 'de', to: 'Reich' }],
|
||||
});
|
||||
expect(next.pendingNationOps).toEqual([{ kind: 'rename', nationRef: 'de', to: 'Reich' }]);
|
||||
});
|
||||
});
|
||||
|
||||
describe('confirmNationOp / rejectNationOp (v0.23 #16 P4a)', () => {
|
||||
/**
|
||||
* The pending list: op 0 is referenced by id ('de'); op 1 is referenced by
|
||||
* NAME ('US' — the id is lowercase 'us'), so confirming it exercises the
|
||||
* name-resolution path of resolveNationRef.
|
||||
*/
|
||||
function stateWithPending(): GameState {
|
||||
const s = baseState();
|
||||
s.pendingNationOps = [
|
||||
{ kind: 'rename', nationRef: 'de', to: 'Reich' },
|
||||
{ kind: 'rename', nationRef: 'US', to: 'United States' },
|
||||
];
|
||||
return s;
|
||||
}
|
||||
|
||||
it('confirm applies a rename (name only — id/territory/relations/player pointer untouched) and removes the op, bumping the version', () => {
|
||||
const state = stateWithPending();
|
||||
const next = confirmNationOp(state, 0);
|
||||
expect(next).not.toBeNull();
|
||||
expect(next!.nations.de.name).toBe('Reich');
|
||||
expect(next!.nations.de.id).toBe('de'); // id unchanged
|
||||
expect(next!.nations.de.territory).toEqual(['DEU']); // territory unchanged
|
||||
expect(next!.nations.uk.relations.de).toBe('hostile'); // relations (keyed by id) unchanged
|
||||
expect(next!.playerNationId).toBe('uk'); // player pointer unchanged
|
||||
expect(next!.pendingNationOps).toEqual([{ kind: 'rename', nationRef: 'US', to: 'United States' }]);
|
||||
expect(next!.version).toBe(2);
|
||||
// the input state is not mutated
|
||||
expect(state.nations.de.name).toBe('DE');
|
||||
expect(state.pendingNationOps).toHaveLength(2);
|
||||
expect(state.version).toBe(1);
|
||||
});
|
||||
|
||||
it('confirm with an edits.name override renames to the player-edited name', () => {
|
||||
const next = confirmNationOp(stateWithPending(), 0, { name: 'Greater Reich' });
|
||||
expect(next!.nations.de.name).toBe('Greater Reich');
|
||||
});
|
||||
|
||||
it('confirm with an empty edits.name falls back to the proposed name', () => {
|
||||
const next = confirmNationOp(stateWithPending(), 0, { name: ' ' });
|
||||
expect(next!.nations.de.name).toBe('Reich');
|
||||
});
|
||||
|
||||
it('confirm resolves the target by name (not just id) — case-insensitively', () => {
|
||||
const next = confirmNationOp(stateWithPending(), 1); // nationRef 'US' is a name, not the id 'us'
|
||||
expect(next).not.toBeNull();
|
||||
expect(next!.nations.us.name).toBe('United States');
|
||||
});
|
||||
|
||||
it('confirm is a no-op on the world for a P4b-kind op (split/merge) — the op is still removed', () => {
|
||||
const state = baseState();
|
||||
state.pendingNationOps = [{ kind: 'split', nationRef: 'de', parts: [{ name: 'East' }] }];
|
||||
const next = confirmNationOp(state, 0);
|
||||
expect(next).not.toBeNull();
|
||||
expect(next!.nations).toEqual(state.nations); // the world is untouched
|
||||
expect(next!.pendingNationOps).toEqual([]); // ...but the proposal was resolved
|
||||
expect(next!.version).toBe(2);
|
||||
});
|
||||
|
||||
it('confirm with an invalid index returns null and mutates nothing', () => {
|
||||
const state = stateWithPending();
|
||||
expect(confirmNationOp(state, -1)).toBeNull();
|
||||
expect(confirmNationOp(state, 2)).toBeNull();
|
||||
expect(confirmNationOp(state, 0.5)).toBeNull();
|
||||
expect(state.nations.de.name).toBe('DE');
|
||||
expect(state.version).toBe(1);
|
||||
});
|
||||
|
||||
it('reject drops the proposal without applying it, bumping the version', () => {
|
||||
const state = stateWithPending();
|
||||
const next = rejectNationOp(state, 0);
|
||||
expect(next).not.toBeNull();
|
||||
expect(next!.nations.de.name).toBe('DE'); // unchanged
|
||||
expect(next!.pendingNationOps).toEqual([{ kind: 'rename', nationRef: 'US', to: 'United States' }]);
|
||||
expect(next!.version).toBe(2);
|
||||
expect(state.pendingNationOps).toHaveLength(2); // input untouched
|
||||
});
|
||||
|
||||
it('reject with an invalid index returns null and mutates nothing', () => {
|
||||
const state = stateWithPending();
|
||||
expect(rejectNationOp(state, -1)).toBeNull();
|
||||
expect(rejectNationOp(state, 2)).toBeNull();
|
||||
expect(state.pendingNationOps).toHaveLength(2);
|
||||
expect(state.version).toBe(1);
|
||||
});
|
||||
|
||||
it('an ambiguous/invalid nationRef is a no-op on confirm (the op is resolved, the world unchanged)', () => {
|
||||
const state = baseState();
|
||||
state.nations.us.name = 'UK'; // make "UK" ambiguous (uk and us)
|
||||
state.pendingNationOps = [{ kind: 'rename', nationRef: 'UK', to: 'Empire' }];
|
||||
const next = confirmNationOp(state, 0);
|
||||
expect(next).not.toBeNull();
|
||||
expect(next!.nations.uk.name).toBe('UK'); // neither nation was renamed
|
||||
expect(next!.nations.us.name).toBe('UK');
|
||||
expect(next!.pendingNationOps).toEqual([]);
|
||||
});
|
||||
});
|
||||
|
||||
describe('buildSystemPrompt (nationOps contract, v0.23 #16 P4a)', () => {
|
||||
it('both modes contract the nationOps field and the rename proposal rule', () => {
|
||||
for (const p of [buildSystemPrompt(preset), buildSystemPrompt(preset, true)]) {
|
||||
// the JSON shape carries the field in both modes
|
||||
expect(p).toMatch(/"nationOps": \[\{"kind":"rename","nationRef":"<nation id or name>","to":"<new name>"\}\]/);
|
||||
// the rule explains that it is a proposal only the player can confirm
|
||||
expect(p).toMatch(/PROPOSAL/);
|
||||
expect(p).toMatch(/only takes effect if the player confirms/);
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
describe('parseTurnResult', () => {
|
||||
it('parses a fenced JSON block', () => {
|
||||
const r = parseTurnResult('```json\n{"date":"1939-09-03","events":[{"nation":"uk","type":"military","title":"t","text":"b","outcome":"success"}]}\n```');
|
||||
@@ -478,6 +749,7 @@ describe('applyTurnResult', () => {
|
||||
relations: [],
|
||||
territoryChanges: [],
|
||||
unowned: [],
|
||||
nationOps: [],
|
||||
};
|
||||
applyTurnResult(state, result);
|
||||
expect(JSON.stringify(state)).toBe(snapshot);
|
||||
@@ -493,6 +765,7 @@ describe('applyTurnResult', () => {
|
||||
relations: [],
|
||||
territoryChanges: [],
|
||||
unowned: [],
|
||||
nationOps: [],
|
||||
};
|
||||
const next = applyTurnResult(state, result);
|
||||
expect(next.pending).toEqual([]);
|
||||
@@ -514,6 +787,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: [],
|
||||
};
|
||||
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
|
||||
@@ -538,6 +812,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: [],
|
||||
};
|
||||
const next = applyTurnResult(state, result); // no validKeys -> nothing filtered
|
||||
expect(next.nations.de.territory).toContain('wales-bbbb');
|
||||
@@ -554,6 +829,7 @@ describe('applyTurnResult (sole-ownership invariants, v0.21 #16 P2)', () => {
|
||||
stateChanges: [],
|
||||
relations: [],
|
||||
unowned: [],
|
||||
nationOps: [],
|
||||
};
|
||||
|
||||
it('a double claim (two different gainers, one key) is rejected: the key stays with its original owner', () => {
|
||||
@@ -642,6 +918,7 @@ describe('applyTurnResult (no man\'s land, v0.22 #16 P3)', () => {
|
||||
relations: [],
|
||||
territoryChanges: [],
|
||||
unowned: [],
|
||||
nationOps: [],
|
||||
};
|
||||
|
||||
it('a release removes the key from its sole owner and marks it in GameState.unowned', () => {
|
||||
@@ -756,7 +1033,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'] }),
|
||||
isMeaningful({ date: 'x', events: [], stateChanges: [], relations: [], territoryChanges: [], unowned: ['GBR'], nationOps: [] }),
|
||||
).toBe(true);
|
||||
});
|
||||
|
||||
@@ -1063,8 +1340,22 @@ 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: [] })).toBe(true);
|
||||
expect(isMeaningful({ date: 'x', events: [], stateChanges: [{ nation: 'uk', stat: 'military', op: 'set', value: 5 }], relations: [], territoryChanges: [], unowned: [] })).toBe(true);
|
||||
expect(isMeaningful({ date: 'x', events: [], stateChanges: [], relations: [], territoryChanges: [], unowned: [] })).toBe(false);
|
||||
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);
|
||||
});
|
||||
|
||||
it('(v0.23 #16 P4a) a turn that only proposes a nation-op is meaningful (it survives the retry/nudge path)', () => {
|
||||
expect(
|
||||
isMeaningful({
|
||||
date: 'x',
|
||||
events: [],
|
||||
stateChanges: [],
|
||||
relations: [],
|
||||
territoryChanges: [],
|
||||
unowned: [],
|
||||
nationOps: [{ kind: 'rename', nationRef: 'de', to: 'Reich' }],
|
||||
}),
|
||||
).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
+171
-7
@@ -19,6 +19,7 @@ import {
|
||||
isDateLike,
|
||||
makeEvent,
|
||||
nationForPrompt,
|
||||
type NationOp,
|
||||
type TurnEventSpec,
|
||||
type TurnResult,
|
||||
} from '../shared/protocol';
|
||||
@@ -176,13 +177,15 @@ export function buildSystemPrompt(preset: Preset, withNeighboursTool = false): s
|
||||
' "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":["<region key>"],"lose":["<region key>"]}],',
|
||||
' "unowned": ["<region key>"]',
|
||||
' "unowned": ["<region key>"],',
|
||||
' "nationOps": [{"kind":"rename","nationRef":"<nation id or name>","to":"<new name>"}]',
|
||||
'}',
|
||||
'',
|
||||
'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".',
|
||||
'- To rename a nation, add {"kind":"rename","nationRef":"<nation id or name>","to":"<new name>"} to "nationOps". This is a PROPOSAL: it only takes effect if the player confirms it, so use it only when the narrative justifies it (a revolution, reunification, regime change) — at most 1-2 per turn — and narrate it in "events".',
|
||||
'- 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.',
|
||||
@@ -316,6 +319,53 @@ function str(s: unknown, max = 0): string | null {
|
||||
return max ? t.slice(0, max) : t;
|
||||
}
|
||||
|
||||
/**
|
||||
* Validate a raw (LLM) `nationOps` array into safe {@link NationOp} values
|
||||
* (v0.23, #16 P4a). Defensive like the rest of the parser: entries with an
|
||||
* unknown `kind` or missing/empty required fields are dropped, never throw.
|
||||
* Rename: non-empty `nationRef` + `to` (each capped at 200 chars — resolution
|
||||
* of `nationRef` to a concrete nation happens later, at confirm time). Split:
|
||||
* non-empty `nationRef` + at least one part with a name. Merge: non-empty
|
||||
* `nationRefs` (capped at 20) + `survivingRef` + `into`.
|
||||
*/
|
||||
export function validateNationOps(raw: unknown): NationOp[] {
|
||||
if (!Array.isArray(raw)) return [];
|
||||
const out: NationOp[] = [];
|
||||
for (const e of raw) {
|
||||
if (!e || typeof e !== 'object') continue;
|
||||
const o = e as Record<string, unknown>;
|
||||
if (o.kind === 'rename') {
|
||||
const nationRef = str(o.nationRef, 200);
|
||||
const to = str(o.to, 200);
|
||||
if (nationRef && to) out.push({ kind: 'rename', nationRef, to });
|
||||
} else if (o.kind === 'split') {
|
||||
const nationRef = str(o.nationRef, 200);
|
||||
const parts = (Array.isArray(o.parts) ? o.parts : [])
|
||||
.filter((p): p is Record<string, unknown> => !!p && typeof p === 'object')
|
||||
.map((p) => {
|
||||
const name = str(p.name, 200);
|
||||
const territory = Array.isArray(p.territory)
|
||||
? p.territory.filter((k): k is string => typeof k === 'string' && k.trim() !== '')
|
||||
: [];
|
||||
return { name, territory };
|
||||
})
|
||||
.filter((p): p is { name: string; territory: string[] } => p.name !== null);
|
||||
if (nationRef && parts.length > 0) out.push({ kind: 'split', nationRef, parts });
|
||||
} else if (o.kind === 'merge') {
|
||||
const nationRefs = (Array.isArray(o.nationRefs) ? o.nationRefs : [])
|
||||
.map((x) => (typeof x === 'string' ? x.trim().slice(0, 200) : ''))
|
||||
.filter((x) => x !== '')
|
||||
.slice(0, 20);
|
||||
const survivingRef = str(o.survivingRef, 200);
|
||||
const into = str(o.into, 200);
|
||||
if (nationRefs.length > 0 && survivingRef && into) {
|
||||
out.push({ kind: 'merge', nationRefs, survivingRef, into });
|
||||
}
|
||||
}
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
/**
|
||||
* Validate and normalize an arbitrary parsed object into a safe TurnResult.
|
||||
* Ignores unknown/invalid entries; clamps numeric stats to 0-100. Returns null
|
||||
@@ -331,6 +381,7 @@ export function validateTurnResult(input: unknown): TurnResult | null {
|
||||
relations: [],
|
||||
territoryChanges: [],
|
||||
unowned: [],
|
||||
nationOps: [],
|
||||
};
|
||||
|
||||
if (Array.isArray(o.events)) {
|
||||
@@ -415,17 +466,29 @@ export function validateTurnResult(input: unknown): TurnResult | null {
|
||||
}
|
||||
}
|
||||
|
||||
// (v0.23, #16 P4a) Nation-management ops the LLM proposes this turn. These
|
||||
// are NOT applied by the turn — applyTurnResult appends them to the pending
|
||||
// list for the player to confirm/reject.
|
||||
result.nationOps = validateNationOps(o.nationOps);
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
/**
|
||||
* A turn is "meaningful" if it produced at least an event, a stat change, or
|
||||
* a no-man's-land release (v0.22 #16 P3: a release is a real world change even
|
||||
* when the model under-narrates it, so it must survive the retry/nudge path
|
||||
* instead of being replaced by the generic "unclear" result).
|
||||
* 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
|
||||
* when the model under-narrates it), or a nation-op proposal (v0.23 #16 P4a:
|
||||
* a proposal is a real pending-world change the player will resolve, so it
|
||||
* must survive the retry/nudge path instead of being replaced by the generic
|
||||
* "unclear" result).
|
||||
*/
|
||||
export function isMeaningful(result: TurnResult): boolean {
|
||||
return result.events.length > 0 || result.stateChanges.length > 0 || result.unowned.length > 0;
|
||||
return (
|
||||
result.events.length > 0 ||
|
||||
result.stateChanges.length > 0 ||
|
||||
result.unowned.length > 0 ||
|
||||
result.nationOps.length > 0
|
||||
);
|
||||
}
|
||||
|
||||
// --- Applying the delta -------------------------------------------------------
|
||||
@@ -443,7 +506,10 @@ 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).
|
||||
* 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.
|
||||
*/
|
||||
export function applyTurnResult(state: GameState, result: TurnResult, validKeys?: ReadonlySet<string>): GameState {
|
||||
const next: GameState = JSON.parse(JSON.stringify(state));
|
||||
@@ -559,6 +625,13 @@ export function applyTurnResult(state: GameState, result: TurnResult, validKeys?
|
||||
}
|
||||
|
||||
next.unowned = [...unowned];
|
||||
// (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
|
||||
// deep clone of `state`, so `next.pendingNationOps` is a copy of whatever
|
||||
// the state already held (or undefined for a legacy row) — start from that
|
||||
// and append the new proposals, defaulting a missing field to `[]`.
|
||||
next.pendingNationOps = [...(next.pendingNationOps ?? []), ...result.nationOps];
|
||||
return next;
|
||||
}
|
||||
|
||||
@@ -593,6 +666,96 @@ export function removeRoundItem(state: GameState, index: number | undefined): Ga
|
||||
return next;
|
||||
}
|
||||
|
||||
// --- Nation-management ops (v0.23, #16 P4a: the hybrid "LLM proposes,
|
||||
// player confirms" model) ----------------------------------------------------
|
||||
//
|
||||
// A NationOp proposed by a turn lands in `GameState.pendingNationOps` (see
|
||||
// applyTurnResult) and waits there across turns. These functions resolve the
|
||||
// player's decision on one pending proposal, by index. Like removeRoundItem
|
||||
// they are pure: clone the state, mutate the clone, bump `version` once,
|
||||
// return the new state — and return `null` for a bad index so the route can
|
||||
// answer 400 without mutating anything.
|
||||
|
||||
/** True when `index` is a valid, in-bounds integer position in the pending list. */
|
||||
function validPendingOpIndex(state: GameState, index: number): boolean {
|
||||
const len = state.pendingNationOps?.length ?? 0;
|
||||
return Number.isInteger(index) && index >= 0 && index < len;
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolve a nation reference (the `nationRef` of a NationOp — the LLM may
|
||||
* write either the nation's stable `id` or its human-readable `name`) to a
|
||||
* concrete nation id: an exact id match wins, then a unique case-insensitive
|
||||
* name match. Returns `null` when the reference is empty, unknown, or
|
||||
* ambiguous (two or more nations share that name) — every caller treats
|
||||
* `null` as "no-op" and nothing here ever throws.
|
||||
*/
|
||||
export function resolveNationRef(state: GameState, ref: string): string | null {
|
||||
const r = (ref ?? '').trim();
|
||||
if (!r) return null;
|
||||
if (state.nations[r]) return r; // exact id first
|
||||
const matches = Object.values(state.nations).filter(
|
||||
(n) => n.name.trim().toLowerCase() === r.toLowerCase(),
|
||||
);
|
||||
return matches.length === 1 ? matches[0].id : null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Apply one NationOp to a state, MUTATING the passed object in place (the
|
||||
* caller passes a clone — confirmNationOp does) and NOT bumping `version`
|
||||
* (the route-level functions do that once per decision). Rename changes only
|
||||
* `Nation.name` — the id, its territory, its relations (keyed by id), and the
|
||||
* player pointer all stay valid, so nothing downstream breaks; a
|
||||
* player-supplied `edits.name` overrides the proposed `to` (an empty edit
|
||||
* falls back to the proposal's own name). An unresolvable or ambiguous
|
||||
* `nationRef` is a silent no-op. Split and merge are declared for P4b and are
|
||||
* a no-op until then — the op stays pending-free once the player resolves it,
|
||||
* but the world does not change yet.
|
||||
*/
|
||||
export function applyNationOp(state: GameState, op: NationOp, edits?: { name?: string }): void {
|
||||
if (op.kind !== 'rename') return; // split/merge: declared for P4b, not applied yet
|
||||
const id = resolveNationRef(state, op.nationRef);
|
||||
if (id === null) return; // invalid/ambiguous ref: no-op
|
||||
const n = state.nations[id];
|
||||
if (!n) return;
|
||||
const name = (edits?.name ?? '').trim() || op.to.trim();
|
||||
if (name) n.name = name;
|
||||
}
|
||||
|
||||
/**
|
||||
* Confirm a pending nation-op proposal by index: apply it (a rename takes
|
||||
* effect, optionally with a player-edited name; split/merge are a no-op until
|
||||
* P4b) and remove it from `pendingNationOps`, bumping `version`. Returns
|
||||
* `null` for an invalid index, so the route answers 400 without mutating.
|
||||
* Reused by the /nation-op/confirm route.
|
||||
*/
|
||||
export function confirmNationOp(
|
||||
state: GameState,
|
||||
index: number,
|
||||
edits?: { name?: string },
|
||||
): GameState | null {
|
||||
if (!validPendingOpIndex(state, index)) return null;
|
||||
const next: GameState = JSON.parse(JSON.stringify(state));
|
||||
next.version = (state.version ?? 0) + 1;
|
||||
const op = next.pendingNationOps[index];
|
||||
applyNationOp(next, op, edits);
|
||||
next.pendingNationOps.splice(index, 1);
|
||||
return next;
|
||||
}
|
||||
|
||||
/**
|
||||
* Reject (drop) a pending nation-op proposal by index without applying it,
|
||||
* bumping `version`. Returns `null` for an invalid index, so the route
|
||||
* answers 400 without mutating. Reused by the /nation-op/reject route.
|
||||
*/
|
||||
export function rejectNationOp(state: GameState, index: number): GameState | null {
|
||||
if (!validPendingOpIndex(state, index)) return null;
|
||||
const next: GameState = JSON.parse(JSON.stringify(state));
|
||||
next.version = (state.version ?? 0) + 1;
|
||||
next.pendingNationOps.splice(index, 1);
|
||||
return next;
|
||||
}
|
||||
|
||||
/**
|
||||
* Run one turn end-to-end against the (injectable) LLM: build prompts, parse/
|
||||
* validate, retry once with a nudge if the result is empty/invalid, and — if
|
||||
@@ -674,6 +837,7 @@ export async function runTurn(opts: {
|
||||
relations: [],
|
||||
territoryChanges: [],
|
||||
unowned: [],
|
||||
nationOps: [],
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
@@ -128,6 +128,10 @@ app.post('/api/games', (req: Request, res) => {
|
||||
// starts with nothing released; this list grows as the LLM releases
|
||||
// regions and shrinks as they get re-claimed.
|
||||
unowned: [],
|
||||
// (v0.23, #16 P4a) No pending nation-op proposals on a fresh game; the
|
||||
// list grows as the LLM proposes renames (and, later, splits/merges) and
|
||||
// shrinks as the player confirms or rejects them.
|
||||
pendingNationOps: [],
|
||||
};
|
||||
// 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
|
||||
|
||||
@@ -48,6 +48,7 @@ const state: GameState = {
|
||||
pending: [],
|
||||
version: 1,
|
||||
unowned: [],
|
||||
pendingNationOps: [],
|
||||
};
|
||||
|
||||
// The RegionMap built from the fixture: the authoritative region key → owner source.
|
||||
|
||||
@@ -87,6 +87,7 @@ describe('regions (region-key loader, v0.19 #16 P1)', () => {
|
||||
pending: [],
|
||||
version: 1,
|
||||
unowned: [],
|
||||
pendingNationOps: [],
|
||||
};
|
||||
// 16 GBR sub-regions + 42 FRA sub-regions, no overlap between the two.
|
||||
expect(allTerritoryKeys(state).size).toBe(58);
|
||||
|
||||
@@ -57,6 +57,10 @@ function truncate(s: string, n: number): string {
|
||||
* always returns `unowned: []`) — e2e assertions rely on its fully-deterministic
|
||||
* one-conquest behavior, so the release/re-claim path is exercised by unit
|
||||
* tests only (see server/engine.test.ts).
|
||||
*
|
||||
* (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.
|
||||
*/
|
||||
export function deterministicTurn(p: StubPayload): TurnResult {
|
||||
const state = p.state;
|
||||
@@ -121,6 +125,7 @@ export function deterministicTurn(p: StubPayload): TurnResult {
|
||||
relations: otherId ? [{ a: pid, b: otherId, set: 'ally' }] : [],
|
||||
territoryChanges,
|
||||
unowned: [], // the stub never creates no man's land (see doc comment)
|
||||
nationOps: [], // the stub never proposes nation ops (see doc comment)
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
@@ -30,6 +30,74 @@ export interface TerritoryChange {
|
||||
lose: string[];
|
||||
}
|
||||
|
||||
// --- Nation-management ops (v0.23, #16 P4a: the hybrid "LLM proposes, player
|
||||
// confirms" model) -----------------------------------------------------------
|
||||
//
|
||||
// Unlike stat / relation / territory deltas, a NationOp proposed in a turn is
|
||||
// NOT applied by that turn. It is stored as a *pending proposal* in
|
||||
// `GameState.pendingNationOps` and only takes effect when the player confirms
|
||||
// it (optionally editing it) or is dropped when the player rejects it. Rename
|
||||
// is implemented in v0.23 (P4a); split and merge are declared here so the union
|
||||
// is stable for P4b, and are defensively accepted-but-not-applied until then.
|
||||
|
||||
/**
|
||||
* The identity reference a nation is addressed by inside a {@link NationOp}.
|
||||
* The LLM may use either the nation's stable `id` (the `nations` record key,
|
||||
* e.g. `f`) or its human-readable `name` (e.g. `France`); the engine resolves
|
||||
* it to a concrete `nationId` (id first, then a unique name match — see the
|
||||
* engine's `resolveNationRef`).
|
||||
*/
|
||||
|
||||
/**
|
||||
* A **rename** proposal: change a nation's human-readable `name`. Only the
|
||||
* `name` changes — the stable `id`, its territory keys, its relations (keyed
|
||||
* by id), and the player pointer are all left untouched, so nothing downstream
|
||||
* breaks. `to` is the new display name.
|
||||
*/
|
||||
export interface RenameOp {
|
||||
kind: 'rename';
|
||||
/** The target nation's id or current name (see the note above). */
|
||||
nationRef: string;
|
||||
/** The new display name to apply when the player confirms. */
|
||||
to: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* A **split** proposal (declared for #16 P4b; **not** applied by the engine yet
|
||||
* — it is accepted into the pending list, but confirming it is a no-op until
|
||||
* P4b lands). Describes the new nations a source nation is divided into.
|
||||
*/
|
||||
export interface SplitOp {
|
||||
kind: 'split';
|
||||
/** The id/name of the nation being split. */
|
||||
nationRef: string;
|
||||
/** The new nations created from the split. */
|
||||
parts: Array<{ name: string; territory?: string[] }>;
|
||||
}
|
||||
|
||||
/**
|
||||
* A **merge** proposal (declared for #16 P4b; **not** applied by the engine yet
|
||||
* — accepted into the pending list, no-op on confirm until P4b). Folds several
|
||||
* nations into a single surviving nation.
|
||||
*/
|
||||
export interface MergeOp {
|
||||
kind: 'merge';
|
||||
/** The id/name of every nation folded into the result. */
|
||||
nationRefs: string[];
|
||||
/** The id/name of the nation that survives and keeps its identity. */
|
||||
survivingRef: string;
|
||||
/** The name to give the merged nation. */
|
||||
into: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* A structural operation on the set of nations that the LLM may propose in a
|
||||
* turn (`TurnResult.nationOps`). See the section comment above for the hybrid
|
||||
* propose→confirm semantics. Rename is implemented (P4a); split/merge are
|
||||
* declared for P4b and are a safe no-op until then.
|
||||
*/
|
||||
export type NationOp = RenameOp | SplitOp | MergeOp;
|
||||
|
||||
/** One narrative event the LLM proposes for this turn. */
|
||||
export interface TurnEventSpec {
|
||||
nation: string;
|
||||
@@ -56,6 +124,15 @@ export interface TurnResult {
|
||||
* empty on normal turns.
|
||||
*/
|
||||
unowned: string[];
|
||||
/**
|
||||
* (v0.23, #16 P4a) Nation-management ops the LLM proposes this turn. These
|
||||
* are NOT applied by the turn: each is appended to
|
||||
* `GameState.pendingNationOps` as a proposal awaiting the player's
|
||||
* confirm/reject. Rename is the only kind the engine currently applies;
|
||||
* split/merge are declared for P4b and are accepted-but-dropped. Empty on
|
||||
* turns where the model proposes none.
|
||||
*/
|
||||
nationOps: NationOp[];
|
||||
}
|
||||
|
||||
/** Per-region map payload: region key -> owning nation + its stable color. */
|
||||
@@ -162,6 +239,32 @@ export interface RemoveQueueItemResponse {
|
||||
game: GameState;
|
||||
}
|
||||
|
||||
/**
|
||||
* (v0.23, #16 P4a) Confirm a pending nation-op proposal by its index in
|
||||
* `game.pendingNationOps`. A rename takes effect (the player may override the
|
||||
* proposed name via `edits.name`); split/merge proposals are a no-op on
|
||||
* confirm until P4b. No LLM call, no turn advance.
|
||||
*/
|
||||
export interface ConfirmNationOpRequest {
|
||||
index: number;
|
||||
/** Optional player edits applied on confirm (rename: the final display name). */
|
||||
edits?: { name?: string };
|
||||
}
|
||||
export interface ConfirmNationOpResponse {
|
||||
game: GameState;
|
||||
}
|
||||
|
||||
/**
|
||||
* (v0.23, #16 P4a) Reject (drop) a pending nation-op proposal by its index
|
||||
* in `game.pendingNationOps` without applying it. No LLM call, no turn advance.
|
||||
*/
|
||||
export interface RejectNationOpRequest {
|
||||
index: number;
|
||||
}
|
||||
export interface RejectNationOpResponse {
|
||||
game: GameState;
|
||||
}
|
||||
|
||||
export interface AdvisorRequest {
|
||||
/** Optional prompt to steer the brainstorm. */
|
||||
prompt?: string;
|
||||
|
||||
@@ -1,5 +1,9 @@
|
||||
// Domain types — the single source of truth shared by the frontend (src/), the
|
||||
// backend (server/), and the LLM prompt contract. No runtime dependencies here.
|
||||
// (The single `import type` below erases at compile time, so no runtime
|
||||
// dependency on protocol.ts is introduced — see the GameState.pendingNationOps
|
||||
// doc comment.)
|
||||
import type { NationOp } from './protocol';
|
||||
|
||||
/** A nation's 0–100 status indices. Every value is clamped to 0–100 by the engine. */
|
||||
export interface NationStats {
|
||||
@@ -133,6 +137,21 @@ export interface GameState {
|
||||
* this defaulted to `[]` (see server/db.ts).
|
||||
*/
|
||||
unowned: string[];
|
||||
/**
|
||||
* (v0.23, #16 P4a) Nation-management ops the LLM has proposed but the player
|
||||
* has not yet resolved: a *pending proposal* list, as opposed to `pending`
|
||||
* (the current round's queued player actions). A turn NEVER applies one —
|
||||
* `applyTurnResult` appends the turn's `nationOps` to this list, and each
|
||||
* proposal survives across turns until the player resolves it: confirming
|
||||
* applies it (a rename changes `Nation.name` only — the id, its territory,
|
||||
* its relations, and the player pointer all stay valid) and removes it;
|
||||
* rejecting removes it without applying. Split/merge are declared for P4b
|
||||
* and are a safe no-op on confirm until then. Always `[]` on a fresh game;
|
||||
* rows saved before v0.23 load with this defaulted to `[]` (see server/db.ts).
|
||||
* (The `NationOp` type lives in protocol.ts; the import there is type-only,
|
||||
* so this file still has no runtime dependencies.)
|
||||
*/
|
||||
pendingNationOps: NationOp[];
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
Reference in New Issue
Block a user