feat: nation-op split/merge — apply on confirm (playerPart, fresh ids, relations union) (P4b, #16)
This commit is contained in:
+298
-7
@@ -4,13 +4,16 @@ 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,
|
||||
freshNationId,
|
||||
GET_NEIGHBOURS_TOOL,
|
||||
isMeaningful,
|
||||
makeNeighboursExecutor,
|
||||
NEW_NATION_STATS,
|
||||
parseTurnResult,
|
||||
playerFrontier,
|
||||
queueRoundItem,
|
||||
@@ -634,7 +637,7 @@ describe('confirmNationOp / rejectNationOp (v0.23 #16 P4a)', () => {
|
||||
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', () => {
|
||||
it('confirm of a 1-part split is a no-op on the world (fewer than 2 parts) — the op is still removed', () => {
|
||||
const state = baseState();
|
||||
state.pendingNationOps = [{ kind: 'split', nationRef: 'de', parts: [{ name: 'East' }] }];
|
||||
const next = confirmNationOp(state, 0);
|
||||
@@ -683,18 +686,306 @@ describe('confirmNationOp / rejectNationOp (v0.23 #16 P4a)', () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe('buildSystemPrompt (nationOps contract, v0.23 #16 P4a)', () => {
|
||||
it('both modes contract the nationOps field and the rename proposal rule', () => {
|
||||
describe('buildSystemPrompt (nationOps contract, v0.23 #16 P4a; v0.24 #16 P4b)', () => {
|
||||
it('both modes contract the nationOps field with all three op kinds and the 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
|
||||
// the JSON shape carries all three kinds in both modes
|
||||
expect(p).toMatch(/"kind":"rename","nationRef":"<nation id or name>","to":"<new name>"/);
|
||||
expect(p).toMatch(/"kind":"split","nationRef":"<nation id or name>","parts":\[\{"name":"<new name>","territory":\["<region key>"\]\}\]/);
|
||||
expect(p).toMatch(/"kind":"merge","nationRefs":\["<id>","<id>"\],"survivingRef":"<id of the surviving nation>","into":"<new name>"/);
|
||||
// the rule explains that ops are proposals only the player can confirm
|
||||
expect(p).toMatch(/PROPOSAL/);
|
||||
expect(p).toMatch(/only takes effect if the player confirms/);
|
||||
expect(p).toMatch(/only take effect if the player confirms/);
|
||||
// and the split/merge shapes are spelt out in the hard constraints
|
||||
expect(p).toMatch(/at least 2 parts/);
|
||||
expect(p).toMatch(/survivingRef" must name one of them/);
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
describe('freshNationId (v0.24 #16 P4b)', () => {
|
||||
it('returns the first free of n1, n2, ... against the taken set', () => {
|
||||
expect(freshNationId(new Set())).toBe('n1');
|
||||
expect(freshNationId(new Set(['n1']))).toBe('n2');
|
||||
expect(freshNationId(new Set(['n1', 'n2', 'n3']))).toBe('n4');
|
||||
// non-n ids never collide: existing nation ids are irrelevant
|
||||
expect(freshNationId(new Set(['uk', 'de', 'us']))).toBe('n1');
|
||||
});
|
||||
});
|
||||
|
||||
describe('applyNationOp (split, v0.24 #16 P4b)', () => {
|
||||
/** de owns four regions; the player is uk (so a split of de does not move the pointer). */
|
||||
function splitState(): GameState {
|
||||
const s = baseState();
|
||||
s.nations.de.territory = ['DEU-N', 'DEU-S', 'DEU-E', 'DEU-W'];
|
||||
s.nations.de.relations = { uk: 'ally' };
|
||||
s.nations.uk.relations.de = 'hostile'; // mirror entry, to check the cleanup
|
||||
return s;
|
||||
}
|
||||
|
||||
it('partitions the parent territory: first-wins per key, leftovers to part 0, fresh ids, baseline stats, copied relations, parent removed', () => {
|
||||
const state = splitState();
|
||||
applyNationOp(state, {
|
||||
kind: 'split',
|
||||
nationRef: 'de',
|
||||
parts: [
|
||||
{ name: 'East', territory: ['DEU-E', 'DEU-W', 'ZZZ-not-a-key'] },
|
||||
{ name: 'West', territory: ['DEU-W', 'DEU-S'] },
|
||||
],
|
||||
});
|
||||
// the parent is gone and two fresh nations exist (n1, n2 — uk/us/de were taken)
|
||||
expect(state.nations.de).toBeUndefined();
|
||||
expect(Object.keys(state.nations).sort()).toEqual(['n1', 'n2', 'uk', 'us']);
|
||||
const east = state.nations.n1;
|
||||
const west = state.nations.n2;
|
||||
// East claimed DEU-E and DEU-W first (the invalid key is ignored); the
|
||||
// only key no part claimed — DEU-N (DEU-S went to West) — goes to part 0
|
||||
// in the parent's territory order.
|
||||
expect(east.territory).toEqual(['DEU-E', 'DEU-W', 'DEU-N']);
|
||||
// West's DEU-W was already claimed by East; it keeps only DEU-S.
|
||||
expect(west.territory).toEqual(['DEU-S']);
|
||||
// every parent key sits in exactly one part (sole ownership, nothing lost)
|
||||
const all = [...east.territory, ...west.territory].sort();
|
||||
expect(all).toEqual(['DEU-E', 'DEU-N', 'DEU-S', 'DEU-W']);
|
||||
// fresh baseline stats (not the parent's) and inherited form of state
|
||||
expect(east.stats).toEqual(NEW_NATION_STATS);
|
||||
expect(west.stats).toEqual(NEW_NATION_STATS);
|
||||
expect(east.type).toBe('major');
|
||||
expect(west.type).toBe('major');
|
||||
// both parts copied the parent's relations
|
||||
expect(east.relations).toEqual({ uk: 'ally' });
|
||||
expect(west.relations).toEqual({ uk: 'ally' });
|
||||
// the mirror entry for the removed parent is cleaned up
|
||||
expect(state.nations.uk.relations.de).toBeUndefined();
|
||||
expect(state.nations.uk.relations.us).toBe('neutral'); // other entries untouched
|
||||
// the player (uk) is not affected
|
||||
expect(state.playerNationId).toBe('uk');
|
||||
});
|
||||
|
||||
it('an unresolvable nationRef is a no-op (the world is untouched, never throws)', () => {
|
||||
const state = splitState();
|
||||
const before = JSON.parse(JSON.stringify(state));
|
||||
applyNationOp(state, {
|
||||
kind: 'split',
|
||||
nationRef: 'zzz-not-a-nation',
|
||||
parts: [{ name: 'East' }, { name: 'West' }],
|
||||
});
|
||||
expect(state).toEqual(before);
|
||||
});
|
||||
|
||||
it('a split named by the parent NAME works (ref resolution, not just id)', () => {
|
||||
const state = splitState();
|
||||
applyNationOp(state, {
|
||||
kind: 'split',
|
||||
nationRef: 'DE', // the name; the id is 'de'
|
||||
parts: [{ name: 'East', territory: ['DEU-E'] }, { name: 'West', territory: ['DEU-W'] }],
|
||||
});
|
||||
expect(state.nations.de).toBeUndefined();
|
||||
expect(state.nations.n1.name).toBe('East');
|
||||
expect(state.nations.n2.name).toBe('West');
|
||||
});
|
||||
|
||||
it('when the parent is the player nation, playerNationId moves to the chosen part (edits.playerPart: default 0, clamped in range)', () => {
|
||||
const state = splitState();
|
||||
state.playerNationId = 'de';
|
||||
applyNationOp(state, {
|
||||
kind: 'split',
|
||||
nationRef: 'de',
|
||||
parts: [{ name: 'East' }, { name: 'West' }, { name: 'Centre' }],
|
||||
});
|
||||
expect(state.playerNationId).toBe('n1'); // default: the first part
|
||||
|
||||
const state2 = splitState();
|
||||
state2.playerNationId = 'de';
|
||||
applyNationOp(
|
||||
state2,
|
||||
{
|
||||
kind: 'split',
|
||||
nationRef: 'de',
|
||||
parts: [{ name: 'East' }, { name: 'West' }, { name: 'Centre' }],
|
||||
},
|
||||
{ playerPart: 1 },
|
||||
);
|
||||
expect(state2.playerNationId).toBe('n2');
|
||||
|
||||
const state3 = splitState();
|
||||
state3.playerNationId = 'de';
|
||||
applyNationOp(
|
||||
state3,
|
||||
{
|
||||
kind: 'split',
|
||||
nationRef: 'de',
|
||||
parts: [{ name: 'East' }, { name: 'West' }],
|
||||
},
|
||||
{ playerPart: 99 }, // clamped down to the last part
|
||||
);
|
||||
expect(state3.playerNationId).toBe('n2');
|
||||
|
||||
const state4 = splitState();
|
||||
state4.playerNationId = 'de';
|
||||
applyNationOp(
|
||||
state4,
|
||||
{
|
||||
kind: 'split',
|
||||
nationRef: 'de',
|
||||
parts: [{ name: 'East' }, { name: 'West' }],
|
||||
},
|
||||
{ playerPart: -5 }, // clamped up to the first part
|
||||
);
|
||||
expect(state4.playerNationId).toBe('n1');
|
||||
});
|
||||
|
||||
it('edits.name renames the chosen part (edits.playerPart picks which one)', () => {
|
||||
const state = splitState();
|
||||
applyNationOp(
|
||||
state,
|
||||
{
|
||||
kind: 'split',
|
||||
nationRef: 'de',
|
||||
parts: [{ name: 'East' }, { name: 'West' }],
|
||||
},
|
||||
{ playerPart: 1, name: 'New West' },
|
||||
);
|
||||
expect(state.nations.n1.name).toBe('East'); // untouched part keeps its name
|
||||
expect(state.nations.n2.name).toBe('New West');
|
||||
|
||||
const state2 = splitState();
|
||||
applyNationOp(
|
||||
state2,
|
||||
{
|
||||
kind: 'split',
|
||||
nationRef: 'de',
|
||||
parts: [{ name: 'East' }, { name: 'West' }],
|
||||
},
|
||||
{ name: 'First of the Remnant' }, // no playerPart -> the first part
|
||||
);
|
||||
expect(state2.nations.n1.name).toBe('First of the Remnant');
|
||||
expect(state2.nations.n2.name).toBe('West');
|
||||
});
|
||||
|
||||
it('a 1-part split is a no-op on the world (fewer than 2 parts)', () => {
|
||||
const state = splitState();
|
||||
const before = JSON.parse(JSON.stringify(state));
|
||||
applyNationOp(state, { kind: 'split', nationRef: 'de', parts: [{ name: 'East' }] });
|
||||
expect(state).toEqual(before);
|
||||
});
|
||||
});
|
||||
|
||||
describe('applyNationOp (merge, v0.24 #16 P4b)', () => {
|
||||
/**
|
||||
* de and us will be merged (the survivor is us — deliberately listed
|
||||
* second in nationRefs, so the "survivor's view wins" rule can be observed
|
||||
* overwriting the first input's views). uk, fr and ja survive as third
|
||||
* parties; the survivor has no view on ja, so ja must come from the first
|
||||
* input's map.
|
||||
*/
|
||||
function mergeState(): GameState {
|
||||
const s = baseState();
|
||||
s.nations.de.relations = { us: 'rival', uk: 'hostile', fr: 'rival', ja: 'rival' };
|
||||
s.nations.us.relations = { de: 'ally', uk: 'ally', fr: 'neutral' }; // no view on ja
|
||||
s.nations.uk.relations = { us: 'ally', de: 'hostile', fr: 'neutral', ja: 'neutral' };
|
||||
s.nations.fr = {
|
||||
id: 'fr',
|
||||
type: 'major',
|
||||
name: 'FR',
|
||||
stats: { military: 40, economy: 60, stability: 70, population: 60 },
|
||||
territory: ['FRA'],
|
||||
relations: { us: 'neutral', de: 'rival', uk: 'neutral', ja: 'neutral' },
|
||||
};
|
||||
s.nations.ja = {
|
||||
id: 'ja',
|
||||
type: 'major',
|
||||
name: 'JA',
|
||||
stats: { military: 30, economy: 80, stability: 50, population: 90 },
|
||||
territory: ['JPN'],
|
||||
relations: { us: 'neutral', de: 'rival', uk: 'neutral', fr: 'neutral' },
|
||||
};
|
||||
return s;
|
||||
}
|
||||
|
||||
it('folds the inputs into the survivor: union territory (survivor first), survivor stats, survivor view wins per third party (else first input), non-input third parties kept, removed ids dropped from everyone else, into-name', () => {
|
||||
const state = mergeState();
|
||||
applyNationOp(state, { kind: 'merge', nationRefs: ['de', 'us'], survivingRef: 'us', into: 'Union' });
|
||||
// the removed input is gone; the survivor and all non-inputs survive
|
||||
expect(state.nations.de).toBeUndefined();
|
||||
expect(Object.keys(state.nations).sort()).toEqual(['fr', 'ja', 'uk', 'us']);
|
||||
// the survivor keeps its id, its own stats, and the merged name
|
||||
expect(state.nations.us.id).toBe('us');
|
||||
expect(state.nations.us.stats).toEqual({ military: 60, economy: 90, stability: 80, population: 80 });
|
||||
expect(state.nations.us.name).toBe('Union');
|
||||
// territory: the union — the survivor's keys first, then the other input's
|
||||
expect(state.nations.us.territory).toEqual(['USA', 'DEU']);
|
||||
// relations: on uk and fr the survivor's own views (ally/neutral) beat the
|
||||
// first input's (hostile/rival); ja has no survivor view, so the first
|
||||
// input's (rival) stands; the removed input's id is gone from the map.
|
||||
expect(state.nations.us.relations).toEqual({ uk: 'ally', fr: 'neutral', ja: 'rival' });
|
||||
// the removed id is dropped from every remaining nation's relations map
|
||||
expect(state.nations.uk.relations).toEqual({ us: 'ally', fr: 'neutral', ja: 'neutral' });
|
||||
expect(state.nations.fr.relations).toEqual({ us: 'neutral', uk: 'neutral', ja: 'neutral' });
|
||||
expect(state.nations.ja.relations).toEqual({ us: 'neutral', uk: 'neutral', fr: 'neutral' });
|
||||
// the player (uk) is not part of the merge — the pointer stays valid
|
||||
expect(state.playerNationId).toBe('uk');
|
||||
});
|
||||
|
||||
it('the player pointer moves to the survivor when the player led a removed input', () => {
|
||||
const state = mergeState();
|
||||
state.playerNationId = 'de'; // the player's nation is absorbed
|
||||
applyNationOp(state, { kind: 'merge', nationRefs: ['de', 'us'], survivingRef: 'us', into: 'Union' });
|
||||
expect(state.playerNationId).toBe('us');
|
||||
expect(state.nations[state.playerNationId].name).toBe('Union');
|
||||
});
|
||||
|
||||
it('edits.name overrides the merged name; an empty edit falls back to "into", then to the survivor name', () => {
|
||||
const state = mergeState();
|
||||
applyNationOp(state, { kind: 'merge', nationRefs: ['de', 'us'], survivingRef: 'us', into: 'Union' }, { name: 'United World' });
|
||||
expect(state.nations.us.name).toBe('United World');
|
||||
|
||||
const state2 = mergeState();
|
||||
applyNationOp(state2, { kind: 'merge', nationRefs: ['de', 'us'], survivingRef: 'us', into: 'Union' }, { name: ' ' });
|
||||
expect(state2.nations.us.name).toBe('Union');
|
||||
|
||||
const state3 = mergeState();
|
||||
applyNationOp(state3, { kind: 'merge', nationRefs: ['de', 'us'], survivingRef: 'us', into: '' }); // empty into: keep the survivor's name
|
||||
expect(state3.nations.us.name).toBe('US');
|
||||
});
|
||||
|
||||
it('an unresolvable input ref is a no-op (never throws, world untouched)', () => {
|
||||
const state = mergeState();
|
||||
const before = JSON.parse(JSON.stringify(state));
|
||||
applyNationOp(state, {
|
||||
kind: 'merge',
|
||||
nationRefs: ['us', 'zzz-not-a-nation'],
|
||||
survivingRef: 'uk',
|
||||
into: 'X',
|
||||
});
|
||||
expect(state).toEqual(before);
|
||||
});
|
||||
|
||||
it('a survivor that is not one of the inputs is a no-op', () => {
|
||||
const state = mergeState();
|
||||
const before = JSON.parse(JSON.stringify(state));
|
||||
applyNationOp(state, {
|
||||
kind: 'merge',
|
||||
nationRefs: ['us', 'de'],
|
||||
survivingRef: 'uk', // uk is not an input
|
||||
into: 'X',
|
||||
});
|
||||
expect(state).toEqual(before);
|
||||
});
|
||||
|
||||
it('fewer than two DISTINCT inputs is a no-op (the same nation named twice)', () => {
|
||||
const state = mergeState();
|
||||
const before = JSON.parse(JSON.stringify(state));
|
||||
applyNationOp(state, {
|
||||
kind: 'merge',
|
||||
nationRefs: ['us', 'US'], // both resolve to 'us'
|
||||
survivingRef: 'us',
|
||||
into: 'X',
|
||||
});
|
||||
expect(state).toEqual(before);
|
||||
});
|
||||
});
|
||||
|
||||
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```');
|
||||
|
||||
Reference in New Issue
Block a user