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:
+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);
|
||||
});
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user