From 3c187c69010e3239220c152def0646917e91162e Mon Sep 17 00:00:00 2001 From: Kamil Krzywda Date: Thu, 15 Oct 2026 10:58:23 +0000 Subject: [PATCH 1/2] =?UTF-8?q?feat(#54):=20non-military=20takeovers=20?= =?UTF-8?q?=E2=80=94=20automatic=20regime=20rename=20(TurnResult.takeovers?= =?UTF-8?q?)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- server/engine.test.ts | 325 +++++++++++++++++++++++++++++++++++++++--- server/engine.ts | 58 ++++++++ server/stub.ts | 5 + shared/protocol.ts | 30 ++++ 4 files changed, 398 insertions(+), 20 deletions(-) diff --git a/server/engine.test.ts b/server/engine.test.ts index ddeb5ff..24e73f6 100644 --- a/server/engine.test.ts +++ b/server/engine.test.ts @@ -35,6 +35,7 @@ import { validateMarkerOps, validateNationOps, validateRegionChanges, + validateTakeovers, validateTurnResult, } from './engine'; @@ -542,7 +543,7 @@ describe('validateNationOps (v0.23 #16 P4a)', () => { relations: [], territoryChanges: [], unowned: [], - nationOps: [{ kind: 'rename', nationRef: 'France', to: 'French Republic' }], markers: { add: [], remove: [] }, + nationOps: [{ kind: 'rename', nationRef: 'France', to: 'French Republic' }], takeovers: [], markers: { add: [], remove: [] }, }); expect(withOps).not.toBeNull(); expect(withOps!.nationOps).toEqual([{ kind: 'rename', nationRef: 'France', to: 'French Republic' }]); @@ -564,7 +565,7 @@ describe('applyTurnResult (nation-op proposals, v0.23 #16 P4a)', () => { relations: [], territoryChanges: [], unowned: [], - nationOps: [{ kind: 'rename', nationRef: 'de', to: 'Reich' }], markers: { add: [], remove: [] }, + nationOps: [{ kind: 'rename', nationRef: 'de', to: 'Reich' }], takeovers: [], markers: { add: [], remove: [] }, }); expect(next.nations.de.name).toBe('DE'); // still the old name expect(next.pendingNationOps).toEqual([{ kind: 'rename', nationRef: 'de', to: 'Reich' }]); @@ -585,7 +586,7 @@ describe('applyTurnResult (nation-op proposals, v0.23 #16 P4a)', () => { relations: [], territoryChanges: [], unowned: [], - nationOps: [{ kind: 'rename', nationRef: 'us', to: 'USA' }], markers: { add: [], remove: [] }, // new proposal this turn + nationOps: [{ kind: 'rename', nationRef: 'us', to: 'USA' }], takeovers: [], markers: { add: [], remove: [] }, // new proposal this turn }); expect(next.pendingNationOps).toEqual([ { kind: 'rename', nationRef: 'de', to: 'Reich' }, // the old one survived @@ -604,7 +605,7 @@ describe('applyTurnResult (nation-op proposals, v0.23 #16 P4a)', () => { relations: [], territoryChanges: [], unowned: [], - nationOps: [{ kind: 'rename', nationRef: 'de', to: 'Reich' }], markers: { add: [], remove: [] }, + nationOps: [{ kind: 'rename', nationRef: 'de', to: 'Reich' }], takeovers: [], markers: { add: [], remove: [] }, }); expect(next.pendingNationOps).toEqual([{ kind: 'rename', nationRef: 'de', to: 'Reich' }]); }); @@ -1061,7 +1062,7 @@ describe('applyTurnResult', () => { relations: [], territoryChanges: [], unowned: [], - nationOps: [], markers: { add: [], remove: [] }, + nationOps: [], takeovers: [], markers: { add: [], remove: [] }, }; applyTurnResult(state, result); expect(JSON.stringify(state)).toBe(snapshot); @@ -1078,7 +1079,7 @@ describe('applyTurnResult', () => { relations: [], territoryChanges: [], unowned: [], - nationOps: [], markers: { add: [], remove: [] }, + nationOps: [], takeovers: [], markers: { add: [], remove: [] }, }; const next = applyTurnResult(state, result); expect(next.pending).toEqual([]); @@ -1101,7 +1102,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: [], markers: { add: [], remove: [] }, + nationOps: [], takeovers: [], markers: { add: [], remove: [] }, }; 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 @@ -1127,7 +1128,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: [], markers: { add: [], remove: [] }, + nationOps: [], takeovers: [], markers: { add: [], remove: [] }, }; const next = applyTurnResult(state, result); // no validKeys -> nothing filtered expect(next.nations.de.territory).toContain('wales-bbbb'); @@ -1145,7 +1146,7 @@ describe('applyTurnResult (sole-ownership invariants, v0.21 #16 P2)', () => { regionChanges: [], relations: [], unowned: [], - nationOps: [], markers: { add: [], remove: [] }, + nationOps: [], takeovers: [], markers: { add: [], remove: [] }, }; it('a double claim (two different gainers, one key) is rejected: the key stays with its original owner', () => { @@ -1235,7 +1236,7 @@ describe('applyTurnResult (no man\'s land, v0.22 #16 P3)', () => { relations: [], territoryChanges: [], unowned: [], - nationOps: [], markers: { add: [], remove: [] }, + nationOps: [], takeovers: [], markers: { add: [], remove: [] }, }; it('a release removes the key from its sole owner and marks it in GameState.unowned', () => { @@ -1350,7 +1351,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: [], regionChanges: [], relations: [], territoryChanges: [], unowned: ['GBR'], nationOps: [], markers: { add: [], remove: [] } }), + isMeaningful({ date: 'x', events: [], stateChanges: [], regionChanges: [], relations: [], territoryChanges: [], unowned: ['GBR'], nationOps: [], takeovers: [], markers: { add: [], remove: [] } }), ).toBe(true); }); @@ -1657,9 +1658,9 @@ 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: [], regionChanges: [], relations: [], territoryChanges: [], unowned: [], nationOps: [], markers: { add: [], remove: [] } })).toBe(true); - expect(isMeaningful({ date: 'x', events: [], stateChanges: [{ nation: 'uk', stat: 'military', op: 'set', value: 5 }], regionChanges: [], relations: [], territoryChanges: [], unowned: [], nationOps: [], markers: { add: [], remove: [] } })).toBe(true); - expect(isMeaningful({ date: 'x', events: [], stateChanges: [], regionChanges: [], relations: [], territoryChanges: [], unowned: [], nationOps: [], markers: { add: [], remove: [] } })).toBe(false); + expect(isMeaningful({ date: 'x', events: [{ nation: 'uk', type: 'military', title: 't', text: 'b', outcome: 'neutral' }], stateChanges: [], regionChanges: [], relations: [], territoryChanges: [], unowned: [], nationOps: [], takeovers: [], markers: { add: [], remove: [] } })).toBe(true); + expect(isMeaningful({ date: 'x', events: [], stateChanges: [{ nation: 'uk', stat: 'military', op: 'set', value: 5 }], regionChanges: [], relations: [], territoryChanges: [], unowned: [], nationOps: [], takeovers: [], markers: { add: [], remove: [] } })).toBe(true); + expect(isMeaningful({ date: 'x', events: [], stateChanges: [], regionChanges: [], relations: [], territoryChanges: [], unowned: [], nationOps: [], takeovers: [], markers: { add: [], remove: [] } })).toBe(false); }); it('(v0.23 #16 P4a) a turn that only proposes a nation-op is meaningful (it survives the retry/nudge path)', () => { @@ -1672,7 +1673,7 @@ describe('isMeaningful', () => { relations: [], territoryChanges: [], unowned: [], - nationOps: [{ kind: 'rename', nationRef: 'de', to: 'Reich' }], markers: { add: [], remove: [] }, + nationOps: [{ kind: 'rename', nationRef: 'de', to: 'Reich' }], takeovers: [], markers: { add: [], remove: [] }, }), ).toBe(true); }); @@ -1688,10 +1689,202 @@ describe('isMeaningful', () => { territoryChanges: [], unowned: [], nationOps: [], + takeovers: [], markers: { add: [{ type: 'city', name: 'Lyon', regionKey: 'rhone-rhne' }], remove: [] }, }), ).toBe(true); }); + + it('(v0.32.0 #54) a turn that only performs a takeover is meaningful (it survives the retry/nudge path)', () => { + expect( + isMeaningful({ + date: 'x', + events: [], + stateChanges: [], + regionChanges: [], + relations: [], + territoryChanges: [], + unowned: [], + nationOps: [], + takeovers: [{ nationRef: 'de', newRegimeName: 'The Reich' }], + markers: { add: [], remove: [] }, + }), + ).toBe(true); + }); +}); + +describe('takeovers (regime change, v0.32.0 #54)', () => { + it('a takeover renames the target nation; id, territory, relations, and stats are preserved', () => { + const state = baseState(); + const result: TurnResult = { + date: '1939-09-03', + events: [], + stateChanges: [], + regionChanges: [], + relations: [], + territoryChanges: [], + unowned: [], + nationOps: [], + takeovers: [{ nationRef: 'de', newRegimeName: 'The Reich' }], + markers: { add: [], remove: [] }, + }; + const next = applyTurnResult(state, result); + expect(next.nations.de.name).toBe('The Reich'); + // id is preserved + expect(next.nations.de.id).toBe('de'); + // territory is preserved + expect(next.nations.de.territory).toEqual(['DEU']); + // relations are preserved (keyed by id, not name) + expect(next.nations.de.relations).toEqual({}); + // stats are preserved + expect(next.nations.de.stats.military).toBe(95); + // other nations are untouched + expect(next.nations.uk.name).toBe('UK'); + expect(next.nations.us.name).toBe('US'); + }); + + it('a takeover of the player\'s own nation preserves playerNationId (player keeps leading it)', () => { + const state = baseState(); // playerNationId is 'uk' + const result: TurnResult = { + date: '1939-09-03', + events: [], + stateChanges: [], + regionChanges: [], + relations: [], + territoryChanges: [], + unowned: [], + nationOps: [], + takeovers: [{ nationRef: 'uk', newRegimeName: 'The Commonwealth' }], + markers: { add: [], remove: [] }, + }; + const next = applyTurnResult(state, result); + expect(next.playerNationId).toBe('uk'); // player still leads it + expect(next.nations.uk.name).toBe('The Commonwealth'); + expect(next.nations.uk.id).toBe('uk'); + }); + + it('an unknown nationRef → no-op (nation unchanged, no throw)', () => { + const state = baseState(); + const result: TurnResult = { + date: '1939-09-03', + events: [], + stateChanges: [], + regionChanges: [], + relations: [], + territoryChanges: [], + unowned: [], + nationOps: [], + takeovers: [{ nationRef: 'nonexistent', newRegimeName: 'New Name' }], + markers: { add: [], remove: [] }, + }; + const next = applyTurnResult(state, result); + // no nation was renamed + expect(next.nations.uk.name).toBe('UK'); + expect(next.nations.us.name).toBe('US'); + expect(next.nations.de.name).toBe('DE'); + }); + + it('an ambiguous nationRef (two nations share the name) → no-op', () => { + const state = baseState(); + // make two nations share the same name + state.nations.us.name = 'UK'; // now both 'uk' and 'us' are named 'UK' + const result: TurnResult = { + date: '1939-09-03', + events: [], + stateChanges: [], + regionChanges: [], + relations: [], + territoryChanges: [], + unowned: [], + nationOps: [], + takeovers: [{ nationRef: 'UK', newRegimeName: 'The Empire' }], + markers: { add: [], remove: [] }, + }; + const next = applyTurnResult(state, result); + // neither was renamed (ambiguous) + expect(next.nations.uk.name).toBe('UK'); + expect(next.nations.us.name).toBe('UK'); + }); + + it('an empty/whitespace newRegimeName → no-op', () => { + const state = baseState(); + // validateTakeovers drops entries with empty newRegimeName, so the validated + // result has no takeovers → applyTurnResult is a no-op. + const validated = validateTurnResult({ + date: '1939-09-03', + events: [], + stateChanges: [], + regionChanges: [], + relations: [], + territoryChanges: [], + unowned: [], + nationOps: [], + takeovers: [{ nationRef: 'de', newRegimeName: ' ' }], + markers: { add: [], remove: [] }, + }); + expect(validated!.takeovers).toEqual([]); + const next = applyTurnResult(state, validated!); + expect(next.nations.de.name).toBe('DE'); + }); + + it('multiple takeovers in one turn all apply', () => { + const state = baseState(); + const result: TurnResult = { + date: '1939-09-03', + events: [], + stateChanges: [], + regionChanges: [], + relations: [], + territoryChanges: [], + unowned: [], + nationOps: [], + takeovers: [ + { nationRef: 'uk', newRegimeName: 'The Commonwealth' }, + { nationRef: 'de', newRegimeName: 'The Reich' }, + ], + markers: { add: [], remove: [] }, + }; + const next = applyTurnResult(state, result); + expect(next.nations.uk.name).toBe('The Commonwealth'); + expect(next.nations.de.name).toBe('The Reich'); + expect(next.nations.us.name).toBe('US'); // untouched + }); + + it('a takeover turn bumps the version once (same as any other turn)', () => { + const state = baseState(); // version 1 + const result: TurnResult = { + date: '1939-09-03', + events: [], + stateChanges: [], + regionChanges: [], + relations: [], + territoryChanges: [], + unowned: [], + nationOps: [], + takeovers: [{ nationRef: 'de', newRegimeName: 'The Reich' }], + markers: { add: [], remove: [] }, + }; + const next = applyTurnResult(state, result); + expect(next.version).toBe(2); // exactly one bump for the whole turn + }); + + it('a takeover via case-insensitive name resolution works', () => { + const state = baseState(); + const result: TurnResult = { + date: '1939-09-03', + events: [], + stateChanges: [], + regionChanges: [], + relations: [], + territoryChanges: [], + unowned: [], + nationOps: [], + takeovers: [{ nationRef: 'De', newRegimeName: 'The Reich' }], // 'De' → resolves to 'de' by name + markers: { add: [], remove: [] }, + }; + const next = applyTurnResult(state, result); + expect(next.nations.de.name).toBe('The Reich'); + }); }); describe('markers (point placement, v0.27 #44)', () => { @@ -1716,6 +1909,7 @@ describe('markers (point placement, v0.27 #44)', () => { territoryChanges: [], unowned: [], nationOps: [], + takeovers: [], markers: { add: [mk('city', 'Strasbourg', 'moselle-rygy')], remove: [] }, }; const next = applyTurnResult(state, result, undefined, centroidOf); @@ -1740,13 +1934,13 @@ describe('markers (point placement, v0.27 #44)', () => { const add = [mk('city', 'Strasbourg', 'moselle-rygy')]; const first = applyTurnResult(state, { date: '1939-09-03', events: [], stateChanges: [], regionChanges: [], relations: [], territoryChanges: [], - unowned: [], nationOps: [], markers: { add, remove: [] }, + unowned: [], nationOps: [], takeovers: [], markers: { add, remove: [] }, }, undefined, centroidOf); expect(first.markers).toHaveLength(1); // a second turn proposes the exact same marker again const second = applyTurnResult(first, { date: '1939-09-05', events: [], stateChanges: [], regionChanges: [], relations: [], territoryChanges: [], - unowned: [], nationOps: [], markers: { add, remove: [] }, + unowned: [], nationOps: [], takeovers: [], markers: { add, remove: [] }, }, undefined, centroidOf); expect(second.markers).toHaveLength(1); expect(second.markers[0].id).toBe(first.markers[0].id); // stable id, no second copy @@ -1768,6 +1962,7 @@ describe('markers (point placement, v0.27 #44)', () => { territoryChanges: [], unowned: [], nationOps: [], + takeovers: [], markers: { add: [ { type: 'city', name: 'Good', regionKey: 'moselle-rygy' }, // valid + centroid → kept @@ -1810,6 +2005,7 @@ describe('markers (point placement, v0.27 #44)', () => { territoryChanges: [], unowned: [], nationOps: [], + takeovers: [], markers: { add: [ { type: 'city', name: 'NewA', regionKey: 'new-a' }, @@ -1835,7 +2031,7 @@ describe('markers (point placement, v0.27 #44)', () => { ]; const next = applyTurnResult(state, { date: '1939-09-03', events: [], stateChanges: [], regionChanges: [], relations: [], territoryChanges: [], - unowned: [], nationOps: [], markers: { add: [], remove: [idA, 'zzzz-not-there'] }, + unowned: [], nationOps: [], takeovers: [], markers: { add: [], remove: [idA, 'zzzz-not-there'] }, }, undefined, centroidOf); expect(next.markers).toHaveLength(1); expect(next.markers[0].id).toBe(idB); // only A removed; the bogus id was a no-op @@ -1851,14 +2047,14 @@ describe('markers (point placement, v0.27 #44)', () => { // 1) uk releases the region → the marker becomes ownerless const released = applyTurnResult(state, { date: '1939-09-03', events: [], stateChanges: [], regionChanges: [], relations: [], territoryChanges: [], - unowned: ['moselle-rygy'], nationOps: [], markers: { add: [], remove: [] }, + unowned: ['moselle-rygy'], nationOps: [], takeovers: [], markers: { add: [], remove: [] }, }, undefined, centroidOf); expect(released.markers[0].ownerNationId).toBeNull(); // 2) de re-claims the region → the marker's owner follows to de const reclaimed = applyTurnResult(released, { date: '1939-09-05', events: [], stateChanges: [], regionChanges: [], relations: [], territoryChanges: [{ nation: 'de', gain: ['moselle-rygy'], lose: [] }], - unowned: [], nationOps: [], markers: { add: [], remove: [] }, + unowned: [], nationOps: [], takeovers: [], markers: { add: [], remove: [] }, }, undefined, centroidOf); expect(reclaimed.markers[0].ownerNationId).toBe('de'); }); @@ -2006,6 +2202,7 @@ function mkTurnResult(partial: Partial): TurnResult { territoryChanges: partial.territoryChanges ?? [], unowned: partial.unowned ?? [], nationOps: partial.nationOps ?? [], + takeovers: partial.takeovers ?? [], markers: partial.markers ?? { add: [], remove: [] }, }; } @@ -2670,3 +2867,91 @@ describe('buildUserPrompt — frontier balance + takeover risks (v0.30 #50)', () expect(prompt).not.toMatch(/takeoverRisks/); }); }); + +describe('buildSystemPrompt — takeovers contract (v0.32.0 #54)', () => { + it('both modes document the takeovers field and the automatic (NOT player-confirmed) rule', () => { + for (const p of [buildSystemPrompt(preset), buildSystemPrompt(preset, true)]) { + // the JSON shape carries the field in both modes + expect(p).toMatch( + /"takeovers": \[\{"nationRef":"","newRegimeName":""\}\]/, + ); + // the rule: a takeover is an automatic in-world regime change, not a proposal + expect(p).toMatch(/Takeover \(automatic, NOT player-confirmed\)/); + // and it is tied to the stability + gathering mechanic + expect(p).toMatch(/low-stability nation/); + expect(p).toMatch(/gathering/); + // and it is distinguished from the player-confirmed nationOps rename + expect(p).toMatch(/use "nationOps" rename only for a diplomatic\/political name change/); + } + }); +}); + +describe('validateTakeovers (v0.32.0 #54)', () => { + it('parses valid entries (trimmed, capped) and drops invalid ones', () => { + const takeovers = validateTakeovers([ + { nationRef: ' de ', newRegimeName: 'The Reich' }, + { nationRef: 'uk', newRegimeName: ' ' }, // empty name -> dropped + { nationRef: '', newRegimeName: 'X' }, // empty ref -> dropped + { newRegimeName: 'no ref' }, // missing nationRef -> dropped + { nationRef: 'us' }, // missing newRegimeName -> dropped + 'not-an-object', + null, + 42, + ]); + expect(takeovers).toEqual([{ nationRef: 'de', newRegimeName: 'The Reich' }]); + }); + + it('caps both fields at 200 characters', () => { + const long = 'x'.repeat(250); + const takeovers = validateTakeovers([{ nationRef: long, newRegimeName: long }]); + expect(takeovers).toEqual([{ nationRef: 'x'.repeat(200), newRegimeName: 'x'.repeat(200) }]); + }); + + it('returns [] for non-array input and is never thrown by', () => { + expect(validateTakeovers(undefined)).toEqual([]); + expect(validateTakeovers('nope')).toEqual([]); + expect(validateTakeovers({})).toEqual([]); + }); + + it('validateTurnResult carries validated takeovers through (and defaults to [] when absent)', () => { + const withTakeovers = validateTurnResult({ + date: '1939-09-03', + events: [], + stateChanges: [], + regionChanges: [], + relations: [], + territoryChanges: [], + unowned: [], + nationOps: [], + takeovers: [{ nationRef: 'de', newRegimeName: 'The Reich' }, { nationRef: 'bad', newRegimeName: '' }], + markers: { add: [], remove: [] }, + }); + expect(withTakeovers).not.toBeNull(); + // the valid one survives, the empty-name one is dropped + expect(withTakeovers!.takeovers).toEqual([{ nationRef: 'de', newRegimeName: 'The Reich' }]); + + const without = validateTurnResult({ date: '1939-09-03', events: [], stateChanges: [] }); + expect(without).not.toBeNull(); + expect(without!.takeovers).toEqual([]); + }); +}); + +describe('stub — takeovers (v0.32.0 #54)', () => { + it('the deterministic stub returns takeovers: [] (it never performs a takeover)', () => { + const payload = { + state: { + turn: 1, + date: '1939-09-01', + playerNationId: 'uk', + nations: { + uk: { stats: { military: 50, economy: 50, stability: 50, population: 50 }, territory: ['a'] }, + de: { stats: { military: 60, economy: 60, stability: 60, population: 60 }, territory: ['b'] }, + }, + }, + pending: [{ kind: 'action' as const, text: 'test' }], + }; + const result = deterministicTurn(payload); + expect(result.takeovers).toEqual([]); + expect(result).toHaveProperty('takeovers'); + }); +}); diff --git a/server/engine.ts b/server/engine.ts index 011815b..b4fa78b 100644 --- a/server/engine.ts +++ b/server/engine.ts @@ -33,6 +33,7 @@ import { type MarkerSpec, type NationOp, type RegionStatChange, + type Takeover, type TurnEventSpec, type TurnResult, } from '../shared/protocol'; @@ -199,6 +200,7 @@ export function buildSystemPrompt(preset: Preset, withNeighboursTool = false): s ' {"kind":"split","nationRef":"","parts":[{"name":"","territory":[""]}]}', ' {"kind":"merge","nationRefs":["",""],"survivingRef":"","into":""}', ' ],', + ' "takeovers": [{"nationRef":"","newRegimeName":""}],', ' "markers": {"add":[{"type":"","name":"","regionKey":""}],"remove":[""]}', '}', '', @@ -216,6 +218,7 @@ export function buildSystemPrompt(preset: Preset, withNeighboursTool = false): s '- Rename: add {"kind":"rename","nationRef":"","to":""} for a revolution, regime change or reunification that changes a nation\'s name.', '- Split: add {"kind":"split","nationRef":"","parts":[{"name":"","territory":[""]}, ...]} 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":["",""],"survivingRef":"","into":""} 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.', + '- Takeover (automatic, NOT player-confirmed): add {"takeovers":[{"nationRef":"","newRegimeName":""}]} when a low-stability nation (e.g. stability < 30) hosting a "gathering" marker is overthrown in a coup or revolution. This renames the nation to the new regime (e.g. "France" → "Vichy Regime"). Also emit a narrative "event" (political or social, outcome "success") and a stability/economy shock via "stateChanges". A takeover is an automatic in-world regime change — use "nationOps" rename only for a diplomatic/political name change that the player must confirm.', '- 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.', @@ -509,6 +512,7 @@ export function validateTurnResult( territoryChanges: [], unowned: [], nationOps: [], + takeovers: [], markers: { add: [], remove: [] }, }; @@ -599,6 +603,10 @@ export function validateTurnResult( // list for the player to confirm/reject. result.nationOps = validateNationOps(o.nationOps); + // (v0.32.0, #54) Non-military regime changes (takeovers) — applied directly + // by applyTurnResult (automatic, not player-confirmed). + result.takeovers = validateTakeovers(o.takeovers); + // (v0.27, #44) Map-marker ops: new placements (validated here) and // removals by stable id. Applied by applyTurnResult after the territory // moves (a new marker's owner is the region's post-turn owner). @@ -705,6 +713,29 @@ export function validateRegionChanges( return out; } +/** + * (v0.32.0, #54) Validate the raw `takeovers` array of a turn into a safe + * {@link Takeover} list — the non-military regime-change lever (a coup or + * revolution that renames a nation to its new regime name). Defensive like + * the rest of the parser — never throws. Each entry needs a non-empty + * `nationRef` (the target nation's id or name — capped at 200 chars) and a + * non-empty `newRegimeName` (capped at 200 chars). Resolution of `nationRef` + * to a concrete nation id happens at apply time (`resolveNationRef`), not + * here. Entries with either field missing or whitespace-only are dropped. + */ +export function validateTakeovers(raw: unknown): Takeover[] { + if (!Array.isArray(raw)) return []; + const out: Takeover[] = []; + for (const e of raw) { + if (!e || typeof e !== 'object') continue; + const o = e as Record; + const nationRef = str(o.nationRef, 200); + const newRegimeName = str(o.newRegimeName, 200); + if (nationRef && newRegimeName) out.push({ nationRef, newRegimeName }); + } + return out; +} + /** * 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 @@ -721,6 +752,8 @@ export function isMeaningful(result: TurnResult): boolean { result.regionChanges.length > 0 || result.unowned.length > 0 || result.nationOps.length > 0 || + // (v0.32.0, #54) a takeover is a real world change (regime rename). + result.takeovers.length > 0 || // (v0.27, #44) a marker placed or removed this turn is a real world change. result.markers.add.length > 0 || result.markers.remove.length > 0 @@ -973,6 +1006,30 @@ export function applyTurnResult( n.stats = deriveNationStats(n.territory, next.regionStats, geoOf); } + // --- Takeovers (v0.32.0, #54): non-military regime changes (coups / + // revolutions) that rename a nation to its new regime. Applied AFTER the + // per-region recompute (the rename doesn't depend on territory or stats, + // so it is order-independent with respect to those steps) and BEFORE the + // markers block (a marker's owner is a nation id, not a name, so the + // rename does not affect the owner recompute). + // + // For each takeover: + // 1. Resolve the target nation via `resolveNationRef` (id first, then + // case-insensitive unique name). Skip (no-op) if `null` — an + // unknown or ambiguous ref is silently dropped, never thrown. + // 2. Rename the resolved nation: set `n.name = newRegimeName`. Only + // the display name changes — the stable id, territory, relations + // (keyed by id), stats, and `playerNationId` (which references the + // id, not the name) are all preserved. The player keeps leading an + // overthrown nation under its new regime. + for (const t of result.takeovers) { + const id = resolveNationRef(next, t.nationRef); + if (id === null) continue; // unknown / ambiguous ref: no-op + const n = next.nations[id]; + if (!n) continue; // defensive: id resolved but nation missing (corrupt state) + n.name = t.newRegimeName; + } + // --- Map markers (v0.27, #44) --- // // Markers are anchored to a region key and are processed against the FINAL @@ -1500,6 +1557,7 @@ export async function runTurn(opts: { territoryChanges: [], unowned: [], nationOps: [], + takeovers: [], markers: { add: [], remove: [] }, }; } diff --git a/server/stub.ts b/server/stub.ts index 146f071..ec719b3 100644 --- a/server/stub.ts +++ b/server/stub.ts @@ -77,6 +77,10 @@ function truncate(s: string, n: number): string { * always returns `regionChanges: []`) — the per-region lever is covered by * unit tests only (see server/engine.test.ts), keeping the e2e assertions * untouched. + * + * (v0.32.0, #54) The stub likewise NEVER performs a non-military takeover + * (it always returns `takeovers: []`) — the regime-rename path is covered by + * unit tests only, keeping the e2e assertions untouched. */ export function deterministicTurn(p: StubPayload): TurnResult { const state = p.state; @@ -143,6 +147,7 @@ export function deterministicTurn(p: StubPayload): TurnResult { territoryChanges, unowned: [], // the stub never creates no man's land (see doc comment) nationOps: [], // the stub never proposes nation ops (see doc comment) + takeovers: [], // the stub never performs a takeover (see doc comment) markers: { add: [], remove: [] }, // the stub never touches markers (see doc comment) }; } diff --git a/shared/protocol.ts b/shared/protocol.ts index 996ef49..dd37f72 100644 --- a/shared/protocol.ts +++ b/shared/protocol.ts @@ -68,6 +68,25 @@ export interface TerritoryChange { * engine's `resolveNationRef`). */ +/** + * (v0.32.0, #54) A **non-military regime change** — a coup or revolution that + * renames a nation to the name of the new regime. Unlike a {@link RenameOp} + * (a player-confirmed diplomatic/political name change), a takeover is + * **automatic and in-world**: `applyTurnResult` applies it directly (no + * pending queue, no player confirm). Only `Nation.name` changes — the stable + * `id`, territory, relations (keyed by id), stats, and `playerNationId` + * (which references the id) all stay intact, so the player keeps leading the + * nation under its new regime. `nationRef` is resolved exactly like the + * `nationRef` of a NationOp (id first, then a case-insensitive unique name + * match — see `resolveNationRef`). + */ +export interface Takeover { + /** The target nation's id or current name (resolved by `resolveNationRef`). */ + nationRef: string; + /** The name of the new regime to set as the nation's display name. */ + newRegimeName: string; +} + /** * A **rename** proposal: change a nation's human-readable `name`. Only the * `name` changes — the stable `id`, its territory keys, its relations (keyed @@ -267,6 +286,17 @@ export interface TurnResult { * survivor. Empty on turns where the model proposes none. */ nationOps: NationOp[]; + /** + * (v0.32.0, #54) Non-military regime changes (coups / revolutions) that take + * effect **this turn** — each renames the target nation to `newRegimeName`. + * Unlike `nationOps` (player-confirmed proposals), a takeover is **automatic + * and in-world**: it is applied directly by `applyTurnResult` (like territory + * / state changes), not queued to `pendingNationOps`. Only the nation's + * `name` changes; the id, territory, relations, stats, and `playerNationId` + * are all preserved. Empty on turns where no takeover occurs. Required + * (default `[]`). + */ + takeovers: Takeover[]; /** * (v0.27, #44) Map-marker ops this turn. `add` places new named markers * (the engine positions each at its region's centroid + a small From 58c56024bc0006a54cd879595a4b649bf2f049d5 Mon Sep 17 00:00:00 2001 From: Kamil Krzywda Date: Thu, 15 Oct 2026 10:58:23 +0000 Subject: [PATCH 2/2] =?UTF-8?q?chore(#54):=20v0.32.0=20=E2=80=94=20version?= =?UTF-8?q?=20bump=20+=20changelog?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- CHANGELOG.md | 13 +++++++++++++ package.json | 2 +- 2 files changed, 14 insertions(+), 1 deletion(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 3c1e62e..b6a5166 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,19 @@ All notable changes to **History** are listed here, newest first. +## v0.32.0 — 2026-10-15 10:56 +- **#54: non-military takeovers become a visible regime change — a `Takeover` in the turn renames the nation.** The v0.30.0 stats work made stability a *soft* lever: the system prompt told the model that a low-stability nation (`stability < 30`) hosting a `gathering` marker "can suffer a non-military takeover — a coup, revolution or faction seizure of power", narrated with an event and a stat shock — but the model had no actual *field* to express the regime change itself, so the nation's name never changed and the takeover was invisible in the world. This version closes that gap with a new `TurnResult.takeovers` field: `Takeover = { nationRef, newRegimeName }`. Each entry is an **automatic, in-world** regime change applied directly by `applyTurnResult` (unlike `nationOps`, which are player-confirmed proposals) — the target nation's display `name` becomes `newRegimeName`, while its stable `id`, territory, relations (keyed by id), stats, and `playerNationId` are all preserved, so a player whose nation is overthrown keeps leading it under the new regime. + - **Protocol (`shared/protocol.ts`).** New `Takeover` interface (`nationRef` + `newRegimeName`, both strings). `TurnResult.takeovers: Takeover[]` is **required** (default `[]`), placed between `nationOps` and `markers`. + - **Engine (`server/engine.ts`).** + - `validateTakeovers(raw)` — new exported, defensive validator (same style as `validateNationOps`, never throws): non-array → `[]`; each entry must be an object with a non-empty (whitespace-trimmed) `nationRef` **and** a non-empty `newRegimeName`, each capped at 200 chars; anything else is dropped. Resolution of `nationRef` (id first, then a case-insensitive unique name) happens at apply time via the existing `resolveNationRef` — an unknown or ambiguous ref is a no-op at apply, never a throw. + - `validateTurnResult` populates `takeovers` from the validated array (and the generic "unclear" fallback in `runTurn` carries `takeovers: []`). + - `applyTurnResult` applies takeovers **after** the per-region stats recompute and **before** the markers block (the rename is order-independent of territory/stats; marker owners are nation **ids**, not names, so the rename never disturbs the owner recompute). + - `isMeaningful` — a turn that only performs a takeover is meaningful (it survives the retry/nudge path). + - `buildSystemPrompt` — the JSON shape gains `"takeovers": [{…}]` and a new HARD CONSTRAINTS bullet documents the field: **automatic, NOT player-confirmed**; fire it when a low-stability nation hosting a `gathering` marker is overthrown (the v0.30 stability/gathering mechanic now has a concrete consequence), narrate it with a political/social success event plus a stability/economy shock, and use `nationOps` rename only for the player-confirmed kind of name change. + - **Stub (`server/stub.ts`).** `deterministicTurn` returns `takeovers: []` (the stub never performs a takeover) — its one-conquest behavior and every e2e assertion are untouched; the regime-rename path is covered by unit tests only. + - **Client.** No change — a takeover is just a nation `name` change, which the existing dossier / event feed / map rendering already show (the nation keeps its stable color, since that is keyed by id). + - **Unit tests (`server/engine.test.ts`).** 15 new tests: the apply suite (a takeover renames the target with id/territory/relations/stats preserved; a takeover of the player's own nation preserves `playerNationId`; unknown ref → no-op; ambiguous ref → no-op; empty/whitespace `newRegimeName` dropped at validation; multiple takeovers in one turn all apply; a takeover turn bumps the version exactly once; case-insensitive name resolution); `isMeaningful` with takeovers; the system-prompt contract (both modes document the field + the automatic/not-player-confirmed rule + the stability/gathering tie-in + the distinction from `nationOps` rename); `validateTakeovers` (valid entries trimmed/capped, invalid dropped, non-array → `[]`, `validateTurnResult` carry-through + default); the stub returns `takeovers: []`. All existing `TurnResult` literals in the suite gained the new required field. Totals: **322 unit tests (14 files)**; typecheck, lint, and build green. + ## v0.31.0 — 2026-10-15 10:22 - **#52: dynamic scenario generation — the "Create scenario" path.** An additive second way to start a game alongside the 5 static presets: the user picks one of **3 light era templates** (`ww2` 1939, `present` 2026, `near-future` 2035) and writes a free-text **description** (plus an editable start year), and the LLM **generates** a draft world + an advisor **checkpoint list** (step 1). The player reviews the checkpoints (approve/remove) and picks a nation to lead, and a second LLM call **finalizes** the world — constrained to the *approved* checkpoints — into a saved, playable `GameState` (step 2): a normal game at `#/game/` that plays with the existing turn engine, map, and panels untouched. The 5 static presets and the turn stub (`server/stub.ts`) are 100% unchanged; a new deterministic **scenario stub** (a distinct transport discriminating the two calls by marker) makes the whole path reproducible under `LLM_STUB=1`. - **Era templates (`presets/templates/`).** Three small JSON seeds + an `index.json` manifest — each `{ id, name, year, eraDescription, suggestedPowers: [{ name, region (ISO_A3), note }], tensions: [] }` (e.g. near-future suggests a North American Union/USA, a Chinese Federation/CHN, a European Confederation/FRA, a Russian Union/RUS, with tensions like "autonomous weapons and the new arms race"). `server/templates.ts` is a tiny store mirroring `createPresetStore` (manifest + per-file load; a missing/broken file degrades to absent). diff --git a/package.json b/package.json index 57046a5..0bb06b2 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "history", - "version": "0.31.0", + "version": "0.32.0", "private": true, "type": "module", "description": "AI-driven alternate-history simulator — the LLM is the world engine (Pax Historia style).",