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 type { LlmClient, ToolDefinition, WireMessage } from './llm';
|
||||||
import { makeStubLlmClient } from './stub';
|
import { makeStubLlmClient } from './stub';
|
||||||
import {
|
import {
|
||||||
|
applyNationOp,
|
||||||
applyTurnResult,
|
applyTurnResult,
|
||||||
buildSystemPrompt,
|
buildSystemPrompt,
|
||||||
buildUserPrompt,
|
buildUserPrompt,
|
||||||
confirmNationOp,
|
confirmNationOp,
|
||||||
|
freshNationId,
|
||||||
GET_NEIGHBOURS_TOOL,
|
GET_NEIGHBOURS_TOOL,
|
||||||
isMeaningful,
|
isMeaningful,
|
||||||
makeNeighboursExecutor,
|
makeNeighboursExecutor,
|
||||||
|
NEW_NATION_STATS,
|
||||||
parseTurnResult,
|
parseTurnResult,
|
||||||
playerFrontier,
|
playerFrontier,
|
||||||
queueRoundItem,
|
queueRoundItem,
|
||||||
@@ -634,7 +637,7 @@ describe('confirmNationOp / rejectNationOp (v0.23 #16 P4a)', () => {
|
|||||||
expect(next!.nations.us.name).toBe('United States');
|
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();
|
const state = baseState();
|
||||||
state.pendingNationOps = [{ kind: 'split', nationRef: 'de', parts: [{ name: 'East' }] }];
|
state.pendingNationOps = [{ kind: 'split', nationRef: 'de', parts: [{ name: 'East' }] }];
|
||||||
const next = confirmNationOp(state, 0);
|
const next = confirmNationOp(state, 0);
|
||||||
@@ -683,18 +686,306 @@ describe('confirmNationOp / rejectNationOp (v0.23 #16 P4a)', () => {
|
|||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
describe('buildSystemPrompt (nationOps contract, v0.23 #16 P4a)', () => {
|
describe('buildSystemPrompt (nationOps contract, v0.23 #16 P4a; v0.24 #16 P4b)', () => {
|
||||||
it('both modes contract the nationOps field and the rename proposal rule', () => {
|
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)]) {
|
for (const p of [buildSystemPrompt(preset), buildSystemPrompt(preset, true)]) {
|
||||||
// the JSON shape carries the field in both modes
|
// the JSON shape carries all three kinds in both modes
|
||||||
expect(p).toMatch(/"nationOps": \[\{"kind":"rename","nationRef":"<nation id or name>","to":"<new name>"\}\]/);
|
expect(p).toMatch(/"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(/"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(/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', () => {
|
describe('parseTurnResult', () => {
|
||||||
it('parses a fenced JSON block', () => {
|
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```');
|
const r = parseTurnResult('```json\n{"date":"1939-09-03","events":[{"nation":"uk","type":"military","title":"t","text":"b","outcome":"success"}]}\n```');
|
||||||
|
|||||||
+204
-24
@@ -7,6 +7,8 @@ import type {
|
|||||||
EventOutcome,
|
EventOutcome,
|
||||||
EventType,
|
EventType,
|
||||||
GameState,
|
GameState,
|
||||||
|
Nation,
|
||||||
|
NationStats,
|
||||||
Preset,
|
Preset,
|
||||||
RelationStatus,
|
RelationStatus,
|
||||||
RoundItem,
|
RoundItem,
|
||||||
@@ -134,7 +136,9 @@ export function playerFrontier(
|
|||||||
* When `withNeighboursTool` is set (the turn runs with the getNeighbours tool
|
* When `withNeighboursTool` is set (the turn runs with the getNeighbours tool
|
||||||
* available) the contract gains the contiguity rules for territory transfers
|
* available) the contract gains the contiguity rules for territory transfers
|
||||||
* (v0.21, #16 P2): the model may only move regions across a shared border, and
|
* (v0.21, #16 P2): the model may only move regions across a shared border, and
|
||||||
* may verify borders with the tool before answering.
|
* may verify borders with the tool before answering. The contract also
|
||||||
|
* documents the `nationOps` proposal queue (v0.23, #16 P4a) — rename plus the
|
||||||
|
* split/merge shapes applied on player confirm (v0.24, #16 P4b).
|
||||||
*/
|
*/
|
||||||
export function buildSystemPrompt(preset: Preset, withNeighboursTool = false): string {
|
export function buildSystemPrompt(preset: Preset, withNeighboursTool = false): string {
|
||||||
const rules = preset.simulationRules
|
const rules = preset.simulationRules
|
||||||
@@ -178,14 +182,21 @@ export function buildSystemPrompt(preset: Preset, withNeighboursTool = false): s
|
|||||||
' "relations": [{"a":"<nationId>","b":"<nationId>","set":"<ally|neutral|rival|hostile|war>"}],',
|
' "relations": [{"a":"<nationId>","b":"<nationId>","set":"<ally|neutral|rival|hostile|war>"}],',
|
||||||
' "territoryChanges": [{"nation":"<nationId>","gain":["<region key>"],"lose":["<region key>"]}],',
|
' "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>"}]',
|
' "nationOps": [',
|
||||||
|
' {"kind":"rename","nationRef":"<nation id or name>","to":"<new name>"},',
|
||||||
|
' {"kind":"split","nationRef":"<nation id or name>","parts":[{"name":"<new name>","territory":["<region key>"]}]}',
|
||||||
|
' {"kind":"merge","nationRefs":["<id>","<id>"],"survivingRef":"<id of the surviving nation>","into":"<new name>"}',
|
||||||
|
' ],',
|
||||||
'}',
|
'}',
|
||||||
'',
|
'',
|
||||||
'HARD CONSTRAINTS',
|
'HARD CONSTRAINTS',
|
||||||
'- Use only nation ids and region keys 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 and unowned.',
|
'- 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".',
|
'- 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".',
|
'- Nation-management ops ("nationOps") are PROPOSALS: they only take effect if the player confirms them afterwards. Propose at most 1-2 per turn, only when the narrative justifies it, and narrate each one in "events".',
|
||||||
|
'- Rename: add {"kind":"rename","nationRef":"<nation id or name>","to":"<new name>"} for a revolution, regime change or reunification that changes a nation\'s name.',
|
||||||
|
'- Split: add {"kind":"split","nationRef":"<nation id or name>","parts":[{"name":"<new name>","territory":["<region key>"]}, ...]} when a state breaks up (dissolution, secession). Use at least 2 parts and assign each of the nation\'s region keys to at most one part — any key you leave unassigned goes to the first part.',
|
||||||
|
'- Merge: add {"kind":"merge","nationRefs":["<id or name>","<id or name>"],"survivingRef":"<id or name of the surviving nation>","into":"<new name>"} when states reunify or one is absorbed. Use at least 2 input nations and "survivingRef" must name one of them — the survivor keeps its id and its stats.',
|
||||||
'- Prefer 1-4 events per turn; keep each text 1-3 sentences.',
|
'- 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 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.',
|
'- Keep every stat value between 0 and 100.',
|
||||||
@@ -700,39 +711,208 @@ export function resolveNationRef(state: GameState, ref: string): string | null {
|
|||||||
return matches.length === 1 ? matches[0].id : null;
|
return matches.length === 1 ? matches[0].id : null;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Generate a fresh, unused nation id for a newly created nation (split
|
||||||
|
* parts, v0.24 #16 P4b): the first free of `n1`, `n2`, ... The caller adds
|
||||||
|
* each id it creates to `taken` as it goes, so ids stay unique within an
|
||||||
|
* op and are never reused from a removed nation.
|
||||||
|
*/
|
||||||
|
export function freshNationId(taken: ReadonlySet<string>): string {
|
||||||
|
for (let i = 1; ; i++) {
|
||||||
|
const id = `n${i}`;
|
||||||
|
if (!taken.has(id)) return id;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The baseline stats a freshly created nation starts with (split parts,
|
||||||
|
* v0.24 #16 P4b) — a neutral middle-of-the-scale profile, deliberately NOT
|
||||||
|
* a copy of the parent's stats (a small rump state is not the parent's
|
||||||
|
* military power).
|
||||||
|
*/
|
||||||
|
export const NEW_NATION_STATS: NationStats = {
|
||||||
|
military: 50,
|
||||||
|
economy: 50,
|
||||||
|
stability: 50,
|
||||||
|
population: 50,
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Defensive cleanup after nations are removed (merge, v0.24 #16 P4b): drop
|
||||||
|
* the removed nations' ids from every remaining nation's `relations` map so
|
||||||
|
* no key dangles. (The client already degrades gracefully on a missing
|
||||||
|
* target, but consistent data is cleaner.)
|
||||||
|
*/
|
||||||
|
function dropRelationsTo(state: GameState, removedIds: ReadonlySet<string>): void {
|
||||||
|
for (const n of Object.values(state.nations)) {
|
||||||
|
for (const id of removedIds) {
|
||||||
|
if (id in n.relations) delete n.relations[id];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Apply one NationOp to a state, MUTATING the passed object in place (the
|
* Apply one NationOp to a state, MUTATING the passed object in place (the
|
||||||
* caller passes a clone — confirmNationOp does) and NOT bumping `version`
|
* caller passes a clone — confirmNationOp does) and NOT bumping `version`
|
||||||
* (the route-level functions do that once per decision). Rename changes only
|
* (the route-level functions do that once per decision). It never throws:
|
||||||
* `Nation.name` — the id, its territory, its relations (keyed by id), and the
|
* any unresolvable reference or malformed op is a silent no-op (the op is
|
||||||
* player pointer all stay valid, so nothing downstream breaks; a
|
* still removed by confirmNationOp — the world simply doesn't change).
|
||||||
* player-supplied `edits.name` overrides the proposed `to` (an empty edit
|
*
|
||||||
* falls back to the proposal's own name). An unresolvable or ambiguous
|
* - **rename** (v0.23, #16 P4a): changes only `Nation.name` — the id, its
|
||||||
* `nationRef` is a silent no-op. Split and merge are declared for P4b and are
|
* territory, its relations (keyed by id), and the player pointer all stay
|
||||||
* a no-op until then — the op stays pending-free once the player resolves it,
|
* valid, so nothing downstream breaks; a player-supplied `edits.name`
|
||||||
* but the world does not change yet.
|
* overrides the proposed `to` (an empty edit falls back to the proposal's
|
||||||
|
* own name).
|
||||||
|
*
|
||||||
|
* - **split** (v0.24, #16 P4b): the `nationRef` must resolve and there must
|
||||||
|
* be at least two named parts, else no-op. Parts are processed in order;
|
||||||
|
* each receives the region keys of its `territory` that (a) the parent
|
||||||
|
* owns and (b) no earlier part claimed — the first part to name a key
|
||||||
|
* wins. Keys of the parent's territory that no part claimed go to part 0
|
||||||
|
* (in the parent's `territory` order), so the parent's territory is fully
|
||||||
|
* partitioned — no key is lost or ever held twice. Each part gets a
|
||||||
|
* FRESH id (`freshNationId`) and the baseline `NEW_NATION_STATS` (not a
|
||||||
|
* copy of the parent's), plus a copy of the parent's relations; the
|
||||||
|
* parent is then removed. If the parent was the player's nation,
|
||||||
|
* `playerNationId` moves to the chosen part (`edits.playerPart`, clamped
|
||||||
|
* into range, default 0); `edits.name` renames that same chosen part.
|
||||||
|
*
|
||||||
|
* - **merge** (v0.24, #16 P4b): every input ref plus the survivor ref must
|
||||||
|
* resolve, at least two inputs must resolve to DISTINCT nations, and the
|
||||||
|
* survivor must be one of them — else no-op. The survivor keeps its id,
|
||||||
|
* keeps its own stats, gains the union of all inputs' territories
|
||||||
|
* (survivor's keys first, then each other input's unclaimed keys in its
|
||||||
|
* own order), and gains a union of the inputs' relations (on a third
|
||||||
|
* party the survivor's own view wins, else the first input's). The other
|
||||||
|
* inputs are removed (their ids dropped from everyone else's relations
|
||||||
|
* maps), and if the player led a removed input, `playerNationId` moves to
|
||||||
|
* the survivor. The merged name is `edits.name` (if non-empty), else
|
||||||
|
* `op.into`, else the survivor's current name.
|
||||||
*/
|
*/
|
||||||
export function applyNationOp(state: GameState, op: NationOp, edits?: { name?: string }): void {
|
export function applyNationOp(
|
||||||
if (op.kind !== 'rename') return; // split/merge: declared for P4b, not applied yet
|
state: GameState,
|
||||||
const id = resolveNationRef(state, op.nationRef);
|
op: NationOp,
|
||||||
if (id === null) return; // invalid/ambiguous ref: no-op
|
edits?: { name?: string; playerPart?: number },
|
||||||
const n = state.nations[id];
|
): void {
|
||||||
if (!n) return;
|
if (op.kind === 'rename') {
|
||||||
const name = (edits?.name ?? '').trim() || op.to.trim();
|
const id = resolveNationRef(state, op.nationRef);
|
||||||
if (name) n.name = name;
|
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;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (op.kind === 'split') {
|
||||||
|
const id = resolveNationRef(state, op.nationRef);
|
||||||
|
if (id === null || op.parts.length < 2) return; // invalid ref / not enough parts: no-op
|
||||||
|
const parent = state.nations[id];
|
||||||
|
if (!parent) return;
|
||||||
|
const taken = new Set(Object.keys(state.nations));
|
||||||
|
const created: string[] = [];
|
||||||
|
const claimed = new Set<string>();
|
||||||
|
for (const part of op.parts) {
|
||||||
|
const newId = freshNationId(taken);
|
||||||
|
taken.add(newId);
|
||||||
|
const keys = (part.territory ?? [])
|
||||||
|
.filter((k) => parent.territory.includes(k) && !claimed.has(k));
|
||||||
|
for (const k of keys) claimed.add(k);
|
||||||
|
const partNation: Nation = {
|
||||||
|
id: newId,
|
||||||
|
type: parent.type, // a part keeps the parent's form of state
|
||||||
|
name: part.name,
|
||||||
|
stats: { ...NEW_NATION_STATS },
|
||||||
|
territory: keys,
|
||||||
|
relations: { ...parent.relations },
|
||||||
|
};
|
||||||
|
state.nations[newId] = partNation;
|
||||||
|
created.push(newId);
|
||||||
|
}
|
||||||
|
// Keys the parent owned but no part claimed go to part 0 (in the
|
||||||
|
// parent's territory order) — nothing is lost or duplicated.
|
||||||
|
for (const k of parent.territory) {
|
||||||
|
if (!claimed.has(k)) state.nations[created[0]].territory.push(k);
|
||||||
|
}
|
||||||
|
// The player chooses which part is its successor (edits.playerPart,
|
||||||
|
// clamped into range; default 0) — only relevant when the parent was
|
||||||
|
// the player's nation; the same part also gets a player-edited name.
|
||||||
|
const partIdx =
|
||||||
|
Math.min(Math.max(edits?.playerPart ?? 0, 0), created.length - 1);
|
||||||
|
if (state.playerNationId === id) state.playerNationId = created[partIdx];
|
||||||
|
const editedName = (edits?.name ?? '').trim();
|
||||||
|
if (editedName) state.nations[created[partIdx]].name = editedName;
|
||||||
|
delete state.nations[id];
|
||||||
|
dropRelationsTo(state, new Set([id]));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// merge
|
||||||
|
const resolved: string[] = [];
|
||||||
|
for (const ref of op.nationRefs) {
|
||||||
|
const rid = resolveNationRef(state, ref);
|
||||||
|
if (rid === null) return; // any unresolvable input: no-op
|
||||||
|
resolved.push(rid);
|
||||||
|
}
|
||||||
|
const inputIds = new Set(resolved);
|
||||||
|
const survivorId = resolveNationRef(state, op.survivingRef);
|
||||||
|
if (survivorId === null || !inputIds.has(survivorId) || inputIds.size < 2) return;
|
||||||
|
const survivor = state.nations[survivorId];
|
||||||
|
if (!survivor) return;
|
||||||
|
// Territory: the union of all inputs — the survivor's keys first (stable
|
||||||
|
// order), then each other input's unclaimed keys in its own order.
|
||||||
|
const territory = [...survivor.territory];
|
||||||
|
const seen = new Set(territory);
|
||||||
|
for (const rid of resolved) {
|
||||||
|
for (const k of state.nations[rid].territory) {
|
||||||
|
if (!seen.has(k)) {
|
||||||
|
seen.add(k);
|
||||||
|
territory.push(k);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// Relations: the union of all inputs' relations; the first input to name a
|
||||||
|
// third party sets it, then the survivor's own view overwrites per key.
|
||||||
|
const relations: Record<string, RelationStatus> = {};
|
||||||
|
for (const rid of resolved) {
|
||||||
|
for (const [other, status] of Object.entries(state.nations[rid].relations)) {
|
||||||
|
if (other === survivorId) continue; // never relate to oneself
|
||||||
|
if (!(other in relations)) relations[other] = status;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
for (const [other, status] of Object.entries(survivor.relations)) {
|
||||||
|
if (other !== survivorId) relations[other] = status;
|
||||||
|
}
|
||||||
|
const removed = new Set<string>();
|
||||||
|
for (const rid of inputIds) {
|
||||||
|
if (rid === survivorId) continue;
|
||||||
|
delete state.nations[rid];
|
||||||
|
removed.add(rid);
|
||||||
|
}
|
||||||
|
if (state.playerNationId !== survivorId && removed.has(state.playerNationId)) {
|
||||||
|
state.playerNationId = survivorId;
|
||||||
|
}
|
||||||
|
const name = (edits?.name ?? '').trim() || op.into.trim() || survivor.name;
|
||||||
|
if (name) survivor.name = name;
|
||||||
|
survivor.territory = territory;
|
||||||
|
survivor.relations = relations;
|
||||||
|
dropRelationsTo(state, removed);
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Confirm a pending nation-op proposal by index: apply it (a rename takes
|
* 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
|
* effect, optionally with a player-edited name; a split partitions the
|
||||||
* P4b) and remove it from `pendingNationOps`, bumping `version`. Returns
|
* nation's territory into the new parts, the player picking which part is
|
||||||
* `null` for an invalid index, so the route answers 400 without mutating.
|
* its successor via `edits.playerPart`; a merge folds the inputs into the
|
||||||
* Reused by the /nation-op/confirm route.
|
* survivor, optionally renamed via `edits.name`) 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(
|
export function confirmNationOp(
|
||||||
state: GameState,
|
state: GameState,
|
||||||
index: number,
|
index: number,
|
||||||
edits?: { name?: string },
|
edits?: { name?: string; playerPart?: number },
|
||||||
): GameState | null {
|
): GameState | null {
|
||||||
if (!validPendingOpIndex(state, index)) return null;
|
if (!validPendingOpIndex(state, index)) return null;
|
||||||
const next: GameState = JSON.parse(JSON.stringify(state));
|
const next: GameState = JSON.parse(JSON.stringify(state));
|
||||||
|
|||||||
+8
-4
@@ -230,10 +230,14 @@ app.post('/api/games/:id/queue/remove', (req, res) => {
|
|||||||
res.json({ game: next });
|
res.json({ game: next });
|
||||||
});
|
});
|
||||||
|
|
||||||
// (v0.23, #16 P4a) Resolve a pending nation-op proposal (a rename the LLM
|
// (v0.23, #16 P4a; v0.24, #16 P4b) Resolve a pending nation-op proposal
|
||||||
// proposed in an earlier turn, waiting in game.pendingNationOps). No LLM call,
|
// waiting in game.pendingNationOps: a rename takes effect (body.edits.name
|
||||||
// no turn advance — same fast save → broadcast → respond order as
|
// may override the proposed name), a split partitions the nation's territory
|
||||||
// /queue/remove. The version bump lets "never go backwards" clients reconcile.
|
// into the proposed parts (body.edits.playerPart picks which part the player
|
||||||
|
// leads, default 0), and a merge folds its inputs into the survivor
|
||||||
|
// (body.edits.name may override the merged name). No LLM call, no turn
|
||||||
|
// advance — same fast save → broadcast → respond order as /queue/remove.
|
||||||
|
// The version bump lets "never go backwards" clients reconcile.
|
||||||
app.post('/api/games/:id/nation-op/confirm', (req, res) => {
|
app.post('/api/games/:id/nation-op/confirm', (req, res) => {
|
||||||
const id = req.params.id;
|
const id = req.params.id;
|
||||||
const game = db.load(id);
|
const game = db.load(id);
|
||||||
|
|||||||
+39
-24
@@ -30,15 +30,14 @@ export interface TerritoryChange {
|
|||||||
lose: string[];
|
lose: string[];
|
||||||
}
|
}
|
||||||
|
|
||||||
// --- Nation-management ops (v0.23, #16 P4a: the hybrid "LLM proposes, player
|
// --- Nation-management ops (v0.23 P4a / v0.24 P4b, #16: the hybrid "LLM
|
||||||
// confirms" model) -----------------------------------------------------------
|
// proposes, player confirms" model) ------------------------------------------
|
||||||
//
|
//
|
||||||
// Unlike stat / relation / territory deltas, a NationOp proposed in a turn is
|
// 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
|
// NOT applied by that turn. It is stored as a *pending proposal* in
|
||||||
// `GameState.pendingNationOps` and only takes effect when the player confirms
|
// `GameState.pendingNationOps` and only takes effect when the player confirms
|
||||||
// it (optionally editing it) or is dropped when the player rejects it. Rename
|
// it (optionally editing it) or is dropped when the player rejects it. All
|
||||||
// is implemented in v0.23 (P4a); split and merge are declared here so the union
|
// three kinds are implemented: rename (v0.23 P4a), split and merge (v0.24 P4b).
|
||||||
// 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 identity reference a nation is addressed by inside a {@link NationOp}.
|
||||||
@@ -63,9 +62,13 @@ export interface RenameOp {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* A **split** proposal (declared for #16 P4b; **not** applied by the engine yet
|
* A **split** proposal (v0.24, #16 P4b). Describes the new nations a source
|
||||||
* — it is accepted into the pending list, but confirming it is a no-op until
|
* nation is divided into. On confirm the source is dissolved: each part gets a
|
||||||
* P4b lands). Describes the new nations a source nation is divided into.
|
* fresh id, the region keys it was given (restricted to keys the source
|
||||||
|
* actually owns — first claim wins), fresh baseline stats, and a copy of the
|
||||||
|
* source's relations; any of the source's unclaimed keys go to `parts[0]`.
|
||||||
|
* When the source is the player's own nation the player chooses which part
|
||||||
|
* they lead (the confirm request's `edits.playerPart`, default `parts[0]`).
|
||||||
*/
|
*/
|
||||||
export interface SplitOp {
|
export interface SplitOp {
|
||||||
kind: 'split';
|
kind: 'split';
|
||||||
@@ -76,9 +79,13 @@ export interface SplitOp {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* A **merge** proposal (declared for #16 P4b; **not** applied by the engine yet
|
* A **merge** proposal (v0.24, #16 P4b). Folds several nations into a single
|
||||||
* — accepted into the pending list, no-op on confirm until P4b). Folds several
|
* surviving nation. On confirm the survivor keeps its id (renamed to `into`,
|
||||||
* nations into a single surviving nation.
|
* or to the confirm request's `edits.name`), its territory becomes the union
|
||||||
|
* of all inputs' territories, it keeps its own stats, and it inherits the
|
||||||
|
* union of all inputs' relations (its own winning per third party, else the
|
||||||
|
* first input's); every non-survivor input is removed. If the player's nation
|
||||||
|
* was a non-survivor input the player pointer is re-pointed to the survivor.
|
||||||
*/
|
*/
|
||||||
export interface MergeOp {
|
export interface MergeOp {
|
||||||
kind: 'merge';
|
kind: 'merge';
|
||||||
@@ -93,8 +100,8 @@ export interface MergeOp {
|
|||||||
/**
|
/**
|
||||||
* A structural operation on the set of nations that the LLM may propose in a
|
* 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
|
* turn (`TurnResult.nationOps`). See the section comment above for the hybrid
|
||||||
* propose→confirm semantics. Rename is implemented (P4a); split/merge are
|
* propose→confirm semantics. All three kinds are implemented: rename (P4a),
|
||||||
* declared for P4b and are a safe no-op until then.
|
* and split / merge (P4b, v0.24).
|
||||||
*/
|
*/
|
||||||
export type NationOp = RenameOp | SplitOp | MergeOp;
|
export type NationOp = RenameOp | SplitOp | MergeOp;
|
||||||
|
|
||||||
@@ -125,12 +132,13 @@ export interface TurnResult {
|
|||||||
*/
|
*/
|
||||||
unowned: string[];
|
unowned: string[];
|
||||||
/**
|
/**
|
||||||
* (v0.23, #16 P4a) Nation-management ops the LLM proposes this turn. These
|
* (v0.23 P4a / v0.24 P4b, #16) Nation-management ops the LLM proposes this
|
||||||
* are NOT applied by the turn: each is appended to
|
* turn. These are NOT applied by the turn: each is appended to
|
||||||
* `GameState.pendingNationOps` as a proposal awaiting the player's
|
* `GameState.pendingNationOps` as a proposal awaiting the player's
|
||||||
* confirm/reject. Rename is the only kind the engine currently applies;
|
* confirm/reject. All three kinds (rename, split, merge) are implemented —
|
||||||
* split/merge are declared for P4b and are accepted-but-dropped. Empty on
|
* a confirmed rename changes `Nation.name`; a confirmed split dissolves the
|
||||||
* turns where the model proposes none.
|
* source into its parts; a confirmed merge folds its inputs into the
|
||||||
|
* survivor. Empty on turns where the model proposes none.
|
||||||
*/
|
*/
|
||||||
nationOps: NationOp[];
|
nationOps: NationOp[];
|
||||||
}
|
}
|
||||||
@@ -240,15 +248,22 @@ export interface RemoveQueueItemResponse {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* (v0.23, #16 P4a) Confirm a pending nation-op proposal by its index in
|
* (v0.23 P4a / v0.24 P4b, #16) Confirm a pending nation-op proposal by its
|
||||||
* `game.pendingNationOps`. A rename takes effect (the player may override the
|
* index in `game.pendingNationOps`. A rename takes effect (the player may
|
||||||
* proposed name via `edits.name`); split/merge proposals are a no-op on
|
* override the proposed name via `edits.name`); a split and a merge are fully
|
||||||
* confirm until P4b. No LLM call, no turn advance.
|
* applied (v0.24 P4b). No LLM call, no turn advance.
|
||||||
*/
|
*/
|
||||||
export interface ConfirmNationOpRequest {
|
export interface ConfirmNationOpRequest {
|
||||||
index: number;
|
index: number;
|
||||||
/** Optional player edits applied on confirm (rename: the final display name). */
|
/**
|
||||||
edits?: { name?: string };
|
* Optional player edits applied on confirm.
|
||||||
|
* - `name`: the final display name — overrides a rename's `to`, a merge's
|
||||||
|
* `into`, or (for a split) the name of the part the player leads.
|
||||||
|
* - `playerPart`: for a split of the player's own nation, which part (index
|
||||||
|
* into the op's `parts`) the player will lead (default `0` when missing or
|
||||||
|
* out of range, so the player is never orphaned).
|
||||||
|
*/
|
||||||
|
edits?: { name?: string; playerPart?: number };
|
||||||
}
|
}
|
||||||
export interface ConfirmNationOpResponse {
|
export interface ConfirmNationOpResponse {
|
||||||
game: GameState;
|
game: GameState;
|
||||||
|
|||||||
+13
-12
@@ -138,18 +138,19 @@ export interface GameState {
|
|||||||
*/
|
*/
|
||||||
unowned: string[];
|
unowned: string[];
|
||||||
/**
|
/**
|
||||||
* (v0.23, #16 P4a) Nation-management ops the LLM has proposed but the player
|
* (v0.23, #16 P4a; v0.24, #16 P4b) Nation-management ops the LLM has
|
||||||
* has not yet resolved: a *pending proposal* list, as opposed to `pending`
|
* proposed but the player has not yet resolved: a *pending proposal* list,
|
||||||
* (the current round's queued player actions). A turn NEVER applies one —
|
* as opposed to `pending` (the current round's queued player actions). A
|
||||||
* `applyTurnResult` appends the turn's `nationOps` to this list, and each
|
* turn NEVER applies one — `applyTurnResult` appends the turn's
|
||||||
* proposal survives across turns until the player resolves it: confirming
|
* `nationOps` to this list, and each proposal survives across turns until
|
||||||
* applies it (a rename changes `Nation.name` only — the id, its territory,
|
* the player resolves it: confirming applies it (a rename changes
|
||||||
* its relations, and the player pointer all stay valid) and removes it;
|
* `Nation.name` only; a split partitions the nation's region-key territory
|
||||||
* rejecting removes it without applying. Split/merge are declared for P4b
|
* into the new nations' territories; a merge folds the inputs' territory
|
||||||
* and are a safe no-op on confirm until then. Always `[]` on a fresh game;
|
* into the survivor) and removes it; rejecting removes it without
|
||||||
* rows saved before v0.23 load with this defaulted to `[]` (see server/db.ts).
|
* applying. Always `[]` on a fresh game; rows saved before v0.23 load with
|
||||||
* (The `NationOp` type lives in protocol.ts; the import there is type-only,
|
* this defaulted to `[]` (see server/db.ts). (The `NationOp` type lives in
|
||||||
* so this file still has no runtime dependencies.)
|
* protocol.ts; the import there is type-only, so this file still has no
|
||||||
|
* runtime dependencies.)
|
||||||
*/
|
*/
|
||||||
pendingNationOps: NationOp[];
|
pendingNationOps: NationOp[];
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user