feat(#61): named leaders — assassination / death / appointment with succession (v0.38.0)
CI / build + unit + e2e (pull_request) Successful in 2m33s

Per-nation named leaders (Nation.leader) and a required TurnResult.leaders
lever ({ changes: [{ target, name, cause }] }). Each change sets the target
nation's leader and applies a deterministic stability dip by cause
(assassination -15, death -3, appointed 0) mirrored to its regions and
re-derived into its stats, plus a narration event (military for an
assassination, political for a death/appointment). An assassination/death
requires the target to already have a leader (a leaderless target is
dropped); an appointment always creates/sets one. Unknown/ambiguous targets
are dropped, never thrown.

- shared/types.ts: optional Nation.leader
- shared/protocol.ts: LeaderCause/LeaderChange/LeaderOps, LEADER_STABILITY_EFFECT,
  TurnResult.leaders (required, between nuclear and markers), leader in nationForPrompt
- server/engine.ts: validateLeaderOps, applyLeaderChange, the applyTurnResult
  leader block (after nuclear, before markers), isMeaningful, system-prompt
  contract + constraint, validateTurnResult + runTurn fallback
- server/stub.ts: leaders always { changes: [] } (e2e unaffected)
- tests: 15 engine.test.ts leader tests + 2 db.test.ts round-trips
- version 0.37.0 -> 0.38.0, CHANGELOG entry
This commit is contained in:
2026-10-15 20:09:19 +00:00
parent b6dcd3f979
commit 8730a56170
8 changed files with 469 additions and 41 deletions
+215 -39
View File
@@ -48,6 +48,7 @@ import {
validateEconomyOps,
validateHybridOps,
validateIndustryOps,
validateLeaderOps,
validateMarkerOps,
validateNuclearOps,
validateNationOps,
@@ -568,7 +569,7 @@ describe('validateNationOps (v0.23 #16 P4a)', () => {
relations: [],
territoryChanges: [],
unowned: [],
nationOps: [{ kind: 'rename', nationRef: 'France', to: 'French Republic' }], takeovers: [], wars: { declare: [], end: [] }, hybrid: { ops: [] }, economy: { trade: [], embargo: [] }, industry: { projects: [] }, nuclear: { arm: [], strikes: [] }, markers: { add: [], remove: [] },
nationOps: [{ kind: 'rename', nationRef: 'France', to: 'French Republic' }], takeovers: [], wars: { declare: [], end: [] }, hybrid: { ops: [] }, economy: { trade: [], embargo: [] }, industry: { projects: [] }, nuclear: { arm: [], strikes: [] }, leaders: { changes: [] }, markers: { add: [], remove: [] },
});
expect(withOps).not.toBeNull();
expect(withOps!.nationOps).toEqual([{ kind: 'rename', nationRef: 'France', to: 'French Republic' }]);
@@ -590,7 +591,7 @@ describe('applyTurnResult (nation-op proposals, v0.23 #16 P4a)', () => {
relations: [],
territoryChanges: [],
unowned: [],
nationOps: [{ kind: 'rename', nationRef: 'de', to: 'Reich' }], takeovers: [], wars: { declare: [], end: [] }, hybrid: { ops: [] }, economy: { trade: [], embargo: [] }, industry: { projects: [] }, nuclear: { arm: [], strikes: [] }, markers: { add: [], remove: [] },
nationOps: [{ kind: 'rename', nationRef: 'de', to: 'Reich' }], takeovers: [], wars: { declare: [], end: [] }, hybrid: { ops: [] }, economy: { trade: [], embargo: [] }, industry: { projects: [] }, nuclear: { arm: [], strikes: [] }, leaders: { changes: [] }, markers: { add: [], remove: [] },
});
expect(next.nations.de.name).toBe('DE'); // still the old name
expect(next.pendingNationOps).toEqual([{ kind: 'rename', nationRef: 'de', to: 'Reich' }]);
@@ -611,7 +612,7 @@ describe('applyTurnResult (nation-op proposals, v0.23 #16 P4a)', () => {
relations: [],
territoryChanges: [],
unowned: [],
nationOps: [{ kind: 'rename', nationRef: 'us', to: 'USA' }], takeovers: [], wars: { declare: [], end: [] }, hybrid: { ops: [] }, economy: { trade: [], embargo: [] }, industry: { projects: [] }, nuclear: { arm: [], strikes: [] }, markers: { add: [], remove: [] }, // new proposal this turn
nationOps: [{ kind: 'rename', nationRef: 'us', to: 'USA' }], takeovers: [], wars: { declare: [], end: [] }, hybrid: { ops: [] }, economy: { trade: [], embargo: [] }, industry: { projects: [] }, nuclear: { arm: [], strikes: [] }, leaders: { changes: [] }, markers: { add: [], remove: [] }, // new proposal this turn
});
expect(next.pendingNationOps).toEqual([
{ kind: 'rename', nationRef: 'de', to: 'Reich' }, // the old one survived
@@ -630,7 +631,7 @@ describe('applyTurnResult (nation-op proposals, v0.23 #16 P4a)', () => {
relations: [],
territoryChanges: [],
unowned: [],
nationOps: [{ kind: 'rename', nationRef: 'de', to: 'Reich' }], takeovers: [], wars: { declare: [], end: [] }, hybrid: { ops: [] }, economy: { trade: [], embargo: [] }, industry: { projects: [] }, nuclear: { arm: [], strikes: [] }, markers: { add: [], remove: [] },
nationOps: [{ kind: 'rename', nationRef: 'de', to: 'Reich' }], takeovers: [], wars: { declare: [], end: [] }, hybrid: { ops: [] }, economy: { trade: [], embargo: [] }, industry: { projects: [] }, nuclear: { arm: [], strikes: [] }, leaders: { changes: [] }, markers: { add: [], remove: [] },
});
expect(next.pendingNationOps).toEqual([{ kind: 'rename', nationRef: 'de', to: 'Reich' }]);
});
@@ -1087,7 +1088,7 @@ describe('applyTurnResult', () => {
relations: [],
territoryChanges: [],
unowned: [],
nationOps: [], takeovers: [], wars: { declare: [], end: [] }, hybrid: { ops: [] }, economy: { trade: [], embargo: [] }, industry: { projects: [] }, nuclear: { arm: [], strikes: [] }, markers: { add: [], remove: [] },
nationOps: [], takeovers: [], wars: { declare: [], end: [] }, hybrid: { ops: [] }, economy: { trade: [], embargo: [] }, industry: { projects: [] }, nuclear: { arm: [], strikes: [] }, leaders: { changes: [] }, markers: { add: [], remove: [] },
};
applyTurnResult(state, result);
expect(JSON.stringify(state)).toBe(snapshot);
@@ -1104,7 +1105,7 @@ describe('applyTurnResult', () => {
relations: [],
territoryChanges: [],
unowned: [],
nationOps: [], takeovers: [], wars: { declare: [], end: [] }, hybrid: { ops: [] }, economy: { trade: [], embargo: [] }, industry: { projects: [] }, nuclear: { arm: [], strikes: [] }, markers: { add: [], remove: [] },
nationOps: [], takeovers: [], wars: { declare: [], end: [] }, hybrid: { ops: [] }, economy: { trade: [], embargo: [] }, industry: { projects: [] }, nuclear: { arm: [], strikes: [] }, leaders: { changes: [] }, markers: { add: [], remove: [] },
};
const next = applyTurnResult(state, result);
expect(next.pending).toEqual([]);
@@ -1127,7 +1128,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: [], takeovers: [], wars: { declare: [], end: [] }, hybrid: { ops: [] }, economy: { trade: [], embargo: [] }, industry: { projects: [] }, nuclear: { arm: [], strikes: [] }, markers: { add: [], remove: [] },
nationOps: [], takeovers: [], wars: { declare: [], end: [] }, hybrid: { ops: [] }, economy: { trade: [], embargo: [] }, industry: { projects: [] }, nuclear: { arm: [], strikes: [] }, leaders: { changes: [] }, 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
@@ -1153,7 +1154,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: [], takeovers: [], wars: { declare: [], end: [] }, hybrid: { ops: [] }, economy: { trade: [], embargo: [] }, industry: { projects: [] }, nuclear: { arm: [], strikes: [] }, markers: { add: [], remove: [] },
nationOps: [], takeovers: [], wars: { declare: [], end: [] }, hybrid: { ops: [] }, economy: { trade: [], embargo: [] }, industry: { projects: [] }, nuclear: { arm: [], strikes: [] }, leaders: { changes: [] }, markers: { add: [], remove: [] },
};
const next = applyTurnResult(state, result); // no validKeys -> nothing filtered
expect(next.nations.de.territory).toContain('wales-bbbb');
@@ -1171,7 +1172,7 @@ describe('applyTurnResult (sole-ownership invariants, v0.21 #16 P2)', () => {
regionChanges: [],
relations: [],
unowned: [],
nationOps: [], takeovers: [], wars: { declare: [], end: [] }, hybrid: { ops: [] }, economy: { trade: [], embargo: [] }, industry: { projects: [] }, nuclear: { arm: [], strikes: [] }, markers: { add: [], remove: [] },
nationOps: [], takeovers: [], wars: { declare: [], end: [] }, hybrid: { ops: [] }, economy: { trade: [], embargo: [] }, industry: { projects: [] }, nuclear: { arm: [], strikes: [] }, leaders: { changes: [] }, markers: { add: [], remove: [] },
};
it('a double claim (two different gainers, one key) is rejected: the key stays with its original owner', () => {
@@ -1261,7 +1262,7 @@ describe('applyTurnResult (no man\'s land, v0.22 #16 P3)', () => {
relations: [],
territoryChanges: [],
unowned: [],
nationOps: [], takeovers: [], wars: { declare: [], end: [] }, hybrid: { ops: [] }, economy: { trade: [], embargo: [] }, industry: { projects: [] }, nuclear: { arm: [], strikes: [] }, markers: { add: [], remove: [] },
nationOps: [], takeovers: [], wars: { declare: [], end: [] }, hybrid: { ops: [] }, economy: { trade: [], embargo: [] }, industry: { projects: [] }, nuclear: { arm: [], strikes: [] }, leaders: { changes: [] }, markers: { add: [], remove: [] },
};
it('a release removes the key from its sole owner and marks it in GameState.unowned', () => {
@@ -1376,7 +1377,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: [], takeovers: [], wars: { declare: [], end: [] }, hybrid: { ops: [] }, economy: { trade: [], embargo: [] }, industry: { projects: [] }, nuclear: { arm: [], strikes: [] }, markers: { add: [], remove: [] } }),
isMeaningful({ date: 'x', events: [], stateChanges: [], regionChanges: [], relations: [], territoryChanges: [], unowned: ['GBR'], nationOps: [], takeovers: [], wars: { declare: [], end: [] }, hybrid: { ops: [] }, economy: { trade: [], embargo: [] }, industry: { projects: [] }, nuclear: { arm: [], strikes: [] }, leaders: { changes: [] }, markers: { add: [], remove: [] } }),
).toBe(true);
});
@@ -1683,9 +1684,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: [], takeovers: [], wars: { declare: [], end: [] }, hybrid: { ops: [] }, economy: { trade: [], embargo: [] }, industry: { projects: [] }, nuclear: { arm: [], strikes: [] }, 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: [], wars: { declare: [], end: [] }, hybrid: { ops: [] }, economy: { trade: [], embargo: [] }, industry: { projects: [] }, nuclear: { arm: [], strikes: [] }, markers: { add: [], remove: [] } })).toBe(true);
expect(isMeaningful({ date: 'x', events: [], stateChanges: [], regionChanges: [], relations: [], territoryChanges: [], unowned: [], nationOps: [], takeovers: [], wars: { declare: [], end: [] }, hybrid: { ops: [] }, economy: { trade: [], embargo: [] }, industry: { projects: [] }, nuclear: { arm: [], strikes: [] }, 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: [], wars: { declare: [], end: [] }, hybrid: { ops: [] }, economy: { trade: [], embargo: [] }, industry: { projects: [] }, nuclear: { arm: [], strikes: [] }, leaders: { changes: [] }, 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: [], wars: { declare: [], end: [] }, hybrid: { ops: [] }, economy: { trade: [], embargo: [] }, industry: { projects: [] }, nuclear: { arm: [], strikes: [] }, leaders: { changes: [] }, markers: { add: [], remove: [] } })).toBe(true);
expect(isMeaningful({ date: 'x', events: [], stateChanges: [], regionChanges: [], relations: [], territoryChanges: [], unowned: [], nationOps: [], takeovers: [], wars: { declare: [], end: [] }, hybrid: { ops: [] }, economy: { trade: [], embargo: [] }, industry: { projects: [] }, nuclear: { arm: [], strikes: [] }, leaders: { changes: [] }, 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)', () => {
@@ -1698,7 +1699,7 @@ describe('isMeaningful', () => {
relations: [],
territoryChanges: [],
unowned: [],
nationOps: [{ kind: 'rename', nationRef: 'de', to: 'Reich' }], takeovers: [], wars: { declare: [], end: [] }, hybrid: { ops: [] }, economy: { trade: [], embargo: [] }, industry: { projects: [] }, nuclear: { arm: [], strikes: [] }, markers: { add: [], remove: [] },
nationOps: [{ kind: 'rename', nationRef: 'de', to: 'Reich' }], takeovers: [], wars: { declare: [], end: [] }, hybrid: { ops: [] }, economy: { trade: [], embargo: [] }, industry: { projects: [] }, nuclear: { arm: [], strikes: [] }, leaders: { changes: [] }, markers: { add: [], remove: [] },
}),
).toBe(true);
});
@@ -1718,7 +1719,7 @@ describe('isMeaningful', () => {
wars: { declare: [], end: [] },
hybrid: { ops: [] },
economy: { trade: [], embargo: [] }, industry: { projects: [] },
nuclear: { arm: [], strikes: [] },
nuclear: { arm: [], strikes: [] }, leaders: { changes: [] },
markers: { add: [{ type: 'city', name: 'Lyon', regionKey: 'rhone-rhne' }], remove: [] },
}),
).toBe(true);
@@ -1739,7 +1740,7 @@ describe('isMeaningful', () => {
wars: { declare: [], end: [] },
hybrid: { ops: [] },
economy: { trade: [], embargo: [] }, industry: { projects: [] },
nuclear: { arm: [], strikes: [] },
nuclear: { arm: [], strikes: [] }, leaders: { changes: [] },
markers: { add: [], remove: [] },
}),
).toBe(true);
@@ -1762,7 +1763,7 @@ describe('takeovers (regime change, v0.32.0 #54)', () => {
wars: { declare: [], end: [] },
hybrid: { ops: [] },
economy: { trade: [], embargo: [] }, industry: { projects: [] },
nuclear: { arm: [], strikes: [] },
nuclear: { arm: [], strikes: [] }, leaders: { changes: [] },
markers: { add: [], remove: [] },
};
const next = applyTurnResult(state, result);
@@ -1795,7 +1796,7 @@ describe('takeovers (regime change, v0.32.0 #54)', () => {
wars: { declare: [], end: [] },
hybrid: { ops: [] },
economy: { trade: [], embargo: [] }, industry: { projects: [] },
nuclear: { arm: [], strikes: [] },
nuclear: { arm: [], strikes: [] }, leaders: { changes: [] },
markers: { add: [], remove: [] },
};
const next = applyTurnResult(state, result);
@@ -1819,7 +1820,7 @@ describe('takeovers (regime change, v0.32.0 #54)', () => {
wars: { declare: [], end: [] },
hybrid: { ops: [] },
economy: { trade: [], embargo: [] }, industry: { projects: [] },
nuclear: { arm: [], strikes: [] },
nuclear: { arm: [], strikes: [] }, leaders: { changes: [] },
markers: { add: [], remove: [] },
};
const next = applyTurnResult(state, result);
@@ -1846,7 +1847,7 @@ describe('takeovers (regime change, v0.32.0 #54)', () => {
wars: { declare: [], end: [] },
hybrid: { ops: [] },
economy: { trade: [], embargo: [] }, industry: { projects: [] },
nuclear: { arm: [], strikes: [] },
nuclear: { arm: [], strikes: [] }, leaders: { changes: [] },
markers: { add: [], remove: [] },
};
const next = applyTurnResult(state, result);
@@ -1872,7 +1873,7 @@ describe('takeovers (regime change, v0.32.0 #54)', () => {
wars: { declare: [], end: [] },
hybrid: { ops: [] },
economy: { trade: [], embargo: [] }, industry: { projects: [] },
nuclear: { arm: [], strikes: [] },
nuclear: { arm: [], strikes: [] }, leaders: { changes: [] },
markers: { add: [], remove: [] },
});
expect(validated!.takeovers).toEqual([]);
@@ -1898,7 +1899,7 @@ describe('takeovers (regime change, v0.32.0 #54)', () => {
wars: { declare: [], end: [] },
hybrid: { ops: [] },
economy: { trade: [], embargo: [] }, industry: { projects: [] },
nuclear: { arm: [], strikes: [] },
nuclear: { arm: [], strikes: [] }, leaders: { changes: [] },
markers: { add: [], remove: [] },
};
const next = applyTurnResult(state, result);
@@ -1922,7 +1923,7 @@ describe('takeovers (regime change, v0.32.0 #54)', () => {
wars: { declare: [], end: [] },
hybrid: { ops: [] },
economy: { trade: [], embargo: [] }, industry: { projects: [] },
nuclear: { arm: [], strikes: [] },
nuclear: { arm: [], strikes: [] }, leaders: { changes: [] },
markers: { add: [], remove: [] },
};
const next = applyTurnResult(state, result);
@@ -1944,7 +1945,7 @@ describe('takeovers (regime change, v0.32.0 #54)', () => {
wars: { declare: [], end: [] },
hybrid: { ops: [] },
economy: { trade: [], embargo: [] }, industry: { projects: [] },
nuclear: { arm: [], strikes: [] },
nuclear: { arm: [], strikes: [] }, leaders: { changes: [] },
markers: { add: [], remove: [] },
};
const next = applyTurnResult(state, result);
@@ -1978,7 +1979,7 @@ describe('markers (point placement, v0.27 #44)', () => {
wars: { declare: [], end: [] },
hybrid: { ops: [] },
economy: { trade: [], embargo: [] }, industry: { projects: [] },
nuclear: { arm: [], strikes: [] },
nuclear: { arm: [], strikes: [] }, leaders: { changes: [] },
markers: { add: [mk('city', 'Strasbourg', 'moselle-rygy')], remove: [] },
};
const next = applyTurnResult(state, result, undefined, centroidOf);
@@ -2003,13 +2004,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: [], takeovers: [], wars: { declare: [], end: [] }, hybrid: { ops: [] }, economy: { trade: [], embargo: [] }, industry: { projects: [] }, nuclear: { arm: [], strikes: [] }, markers: { add, remove: [] },
unowned: [], nationOps: [], takeovers: [], wars: { declare: [], end: [] }, hybrid: { ops: [] }, economy: { trade: [], embargo: [] }, industry: { projects: [] }, nuclear: { arm: [], strikes: [] }, leaders: { changes: [] }, 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: [], takeovers: [], wars: { declare: [], end: [] }, hybrid: { ops: [] }, economy: { trade: [], embargo: [] }, industry: { projects: [] }, nuclear: { arm: [], strikes: [] }, markers: { add, remove: [] },
unowned: [], nationOps: [], takeovers: [], wars: { declare: [], end: [] }, hybrid: { ops: [] }, economy: { trade: [], embargo: [] }, industry: { projects: [] }, nuclear: { arm: [], strikes: [] }, leaders: { changes: [] }, 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
@@ -2035,7 +2036,7 @@ describe('markers (point placement, v0.27 #44)', () => {
wars: { declare: [], end: [] },
hybrid: { ops: [] },
economy: { trade: [], embargo: [] }, industry: { projects: [] },
nuclear: { arm: [], strikes: [] },
nuclear: { arm: [], strikes: [] }, leaders: { changes: [] },
markers: {
add: [
{ type: 'city', name: 'Good', regionKey: 'moselle-rygy' }, // valid + centroid → kept
@@ -2082,7 +2083,7 @@ describe('markers (point placement, v0.27 #44)', () => {
wars: { declare: [], end: [] },
hybrid: { ops: [] },
economy: { trade: [], embargo: [] }, industry: { projects: [] },
nuclear: { arm: [], strikes: [] },
nuclear: { arm: [], strikes: [] }, leaders: { changes: [] },
markers: {
add: [
{ type: 'city', name: 'NewA', regionKey: 'new-a' },
@@ -2108,7 +2109,7 @@ describe('markers (point placement, v0.27 #44)', () => {
];
const next = applyTurnResult(state, {
date: '1939-09-03', events: [], stateChanges: [], regionChanges: [], relations: [], territoryChanges: [],
unowned: [], nationOps: [], takeovers: [], wars: { declare: [], end: [] }, hybrid: { ops: [] }, economy: { trade: [], embargo: [] }, industry: { projects: [] }, nuclear: { arm: [], strikes: [] }, markers: { add: [], remove: [idA, 'zzzz-not-there'] },
unowned: [], nationOps: [], takeovers: [], wars: { declare: [], end: [] }, hybrid: { ops: [] }, economy: { trade: [], embargo: [] }, industry: { projects: [] }, nuclear: { arm: [], strikes: [] }, leaders: { changes: [] }, 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
@@ -2124,14 +2125,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: [], takeovers: [], wars: { declare: [], end: [] }, hybrid: { ops: [] }, economy: { trade: [], embargo: [] }, industry: { projects: [] }, nuclear: { arm: [], strikes: [] }, markers: { add: [], remove: [] },
unowned: ['moselle-rygy'], nationOps: [], takeovers: [], wars: { declare: [], end: [] }, hybrid: { ops: [] }, economy: { trade: [], embargo: [] }, industry: { projects: [] }, nuclear: { arm: [], strikes: [] }, leaders: { changes: [] }, 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: [], takeovers: [], wars: { declare: [], end: [] }, hybrid: { ops: [] }, economy: { trade: [], embargo: [] }, industry: { projects: [] }, nuclear: { arm: [], strikes: [] }, markers: { add: [], remove: [] },
unowned: [], nationOps: [], takeovers: [], wars: { declare: [], end: [] }, hybrid: { ops: [] }, economy: { trade: [], embargo: [] }, industry: { projects: [] }, nuclear: { arm: [], strikes: [] }, leaders: { changes: [] }, markers: { add: [], remove: [] },
}, undefined, centroidOf);
expect(reclaimed.markers[0].ownerNationId).toBe('de');
});
@@ -2200,7 +2201,7 @@ describe('markers (point placement, v0.27 #44)', () => {
wars: { declare: [], end: [] },
hybrid: { ops: [] },
economy: { trade: [], embargo: [] }, industry: { projects: [] },
nuclear: { arm: [], strikes: [] },
nuclear: { arm: [], strikes: [] }, leaders: { changes: [] },
markers: { add: [{ type: 'city', name: 'Strasbourg', regionKey: 'moselle-rygy' }], remove: [] },
});
const { state } = await runTurn({
@@ -2295,6 +2296,7 @@ function mkTurnResult(partial: Partial<TurnResult>): TurnResult {
economy: partial.economy ?? { trade: [], embargo: [] },
industry: partial.industry ?? { projects: [] },
nuclear: partial.nuclear ?? { arm: [], strikes: [] },
leaders: partial.leaders ?? { changes: [] },
markers: partial.markers ?? { add: [], remove: [] },
};
}
@@ -3019,7 +3021,7 @@ describe('validateTakeovers (v0.32.0 #54)', () => {
wars: { declare: [], end: [] },
hybrid: { ops: [] },
economy: { trade: [], embargo: [] }, industry: { projects: [] },
nuclear: { arm: [], strikes: [] },
nuclear: { arm: [], strikes: [] }, leaders: { changes: [] },
markers: { add: [], remove: [] },
});
expect(withTakeovers).not.toBeNull();
@@ -3407,7 +3409,7 @@ describe('isMeaningful — wars (v0.33.0 #56)', () => {
wars: { declare: [{ sides: ['a', 'b'] }], end: [] },
hybrid: { ops: [] },
economy: { trade: [], embargo: [] }, industry: { projects: [] },
nuclear: { arm: [], strikes: [] },
nuclear: { arm: [], strikes: [] }, leaders: { changes: [] },
markers: { add: [], remove: [] },
}),
).toBe(true);
@@ -3428,7 +3430,7 @@ describe('isMeaningful — wars (v0.33.0 #56)', () => {
wars: { declare: [], end: [{ id: 'w1', outcome: 'armistice' }] },
hybrid: { ops: [] },
economy: { trade: [], embargo: [] }, industry: { projects: [] },
nuclear: { arm: [], strikes: [] },
nuclear: { arm: [], strikes: [] }, leaders: { changes: [] },
markers: { add: [], remove: [] },
}),
).toBe(true);
@@ -3449,7 +3451,7 @@ describe('isMeaningful — wars (v0.33.0 #56)', () => {
wars: { declare: [], end: [] },
hybrid: { ops: [] },
economy: { trade: [], embargo: [] }, industry: { projects: [] },
nuclear: { arm: [], strikes: [] },
nuclear: { arm: [], strikes: [] }, leaders: { changes: [] },
markers: { add: [], remove: [] },
}),
).toBe(false);
@@ -4611,7 +4613,7 @@ describe('isMeaningful — nuclear (v0.37.0 #60)', () => {
expect(isMeaningful(mkTurnResult({ nuclear: { arm: [], strikes: [{ from: 'uk', target: 'de' }] } }))).toBe(true);
});
it('a turn with empty nuclear ops and nothing else is not meaningful', () => {
expect(isMeaningful(mkTurnResult({ nuclear: { arm: [], strikes: [] } }))).toBe(false);
expect(isMeaningful(mkTurnResult({ nuclear: { arm: [], strikes: [] }, leaders: { changes: [] } }))).toBe(false);
});
});
@@ -4644,3 +4646,177 @@ describe('buildUserPrompt — nuclear capability (v0.37.0 #60)', () => {
expect(out.state.nations.de).not.toHaveProperty('nuclear');
});
});
describe('named leaders (v0.38.0 #61)', () => {
// `uk` starts with a named leader; `de` has none. Each owns one region
// seeded to its stats (the fresh-state invariant), so a uniform dip leaves
// the derived median equal to the dipped value.
const leaderBase = (): GameState => {
const uk: Nation = { ...mkNation('uk', NS(50, 50, 50, 50), ['g1']), leader: { name: 'Admiral Old' } };
const de: Nation = { ...mkNation('de', NS(50, 50, 50, 50), ['d1']) };
return mkTurnState({ uk, de }, { g1: RS(50, 50, 50, 50), d1: RS(50, 50, 50, 50) });
};
describe('validateLeaderOps', () => {
it('accepts a {changes} object and a bare array of valid changes', () => {
const fromObj = validateLeaderOps({
changes: [
{ target: 'uk', name: 'New Leader', cause: 'assassination' },
{ target: 'de', name: 'Other', cause: 'death' },
],
});
expect(fromObj).toEqual([
{ target: 'uk', name: 'New Leader', cause: 'assassination' },
{ target: 'de', name: 'Other', cause: 'death' },
]);
expect(validateLeaderOps([{ target: 'uk', name: 'X', cause: 'appointed' }])).toEqual([
{ target: 'uk', name: 'X', cause: 'appointed' },
]);
});
it('drops a change with an unknown cause', () => {
expect(validateLeaderOps({ changes: [{ target: 'uk', name: 'X', cause: 'explosion' }] })).toEqual([]);
});
it('drops a change missing a target or a name', () => {
expect(
validateLeaderOps({
changes: [
{ target: '', name: 'X', cause: 'appointed' },
{ target: 'uk', name: '', cause: 'death' },
],
}),
).toEqual([]);
});
it('returns [] for undefined / non-object / non-array-changes input', () => {
expect(validateLeaderOps(undefined)).toEqual([]);
expect(validateLeaderOps('nope')).toEqual([]);
expect(validateLeaderOps({ changes: 'nope' })).toEqual([]);
});
it('keeps only the valid changes of a mixed batch', () => {
const out = validateLeaderOps({
changes: [
{ target: 'uk', name: 'Good', cause: 'appointed' },
{ target: 'uk', name: 'Bad', cause: 'nope' },
null,
{ target: 'uk', name: 'Also Good', cause: 'death' },
],
});
expect(out).toEqual([
{ target: 'uk', name: 'Good', cause: 'appointed' },
{ target: 'uk', name: 'Also Good', cause: 'death' },
]);
});
});
describe('isMeaningful — leaders', () => {
it('a turn with only a leader change is meaningful', () => {
expect(isMeaningful(mkTurnResult({ leaders: { changes: [{ target: 'uk', name: 'X', cause: 'death' }] } }))).toBe(true);
});
it('a turn with empty leaders and nothing else is not meaningful', () => {
expect(isMeaningful(mkTurnResult({ leaders: { changes: [] } }))).toBe(false);
});
});
describe('applyTurnResult — leaders', () => {
it('assassinating a nation with a leader replaces it, dips stability, and adds a military event', () => {
const next = applyTurnResult(
leaderBase(),
mkTurnResult({ leaders: { changes: [{ target: 'uk', name: 'New Leader', cause: 'assassination' }] } }),
);
expect(next.nations.uk.leader).toEqual({ name: 'New Leader' });
expect(next.nations.uk.stats.stability).toBe(35); // 50 - 15
expect(next.regionStats.g1.stability).toBe(35); // mirrored to the region
expect(next.events).toHaveLength(1);
expect(next.events[0]!.type).toBe('military');
});
it('assassinating a leaderless nation is dropped (no leader, no stat change, no event)', () => {
const next = applyTurnResult(
leaderBase(),
mkTurnResult({ leaders: { changes: [{ target: 'de', name: 'New Leader', cause: 'assassination' }] } }),
);
expect(next.nations.de.leader).toBeUndefined();
expect(next.nations.de.stats).toEqual(NS(50, 50, 50, 50));
expect(next.events).toHaveLength(0);
});
it('a death of a leader replaces it, dips stability a little, and adds a political succession event', () => {
const next = applyTurnResult(
leaderBase(),
mkTurnResult({ leaders: { changes: [{ target: 'uk', name: 'Successor', cause: 'death' }] } }),
);
expect(next.nations.uk.leader).toEqual({ name: 'Successor' });
expect(next.nations.uk.stats.stability).toBe(47); // 50 - 3
expect(next.events).toHaveLength(1);
expect(next.events[0]!.type).toBe('political');
});
it('a death of a leaderless nation is dropped', () => {
const next = applyTurnResult(
leaderBase(),
mkTurnResult({ leaders: { changes: [{ target: 'de', name: 'X', cause: 'death' }] } }),
);
expect(next.nations.de.leader).toBeUndefined();
expect(next.nations.de.stats).toEqual(NS(50, 50, 50, 50));
expect(next.events).toHaveLength(0);
});
it('an appointment on a nation that has a leader replaces it with no stability dip', () => {
const next = applyTurnResult(
leaderBase(),
mkTurnResult({ leaders: { changes: [{ target: 'uk', name: 'New PM', cause: 'appointed' }] } }),
);
expect(next.nations.uk.leader).toEqual({ name: 'New PM' });
expect(next.nations.uk.stats).toEqual(NS(50, 50, 50, 50)); // neutral: no dip
expect(next.events).toHaveLength(1);
expect(next.events[0]!.type).toBe('political');
});
it('an appointment creates the first leader on a leaderless nation', () => {
const next = applyTurnResult(
leaderBase(),
mkTurnResult({ leaders: { changes: [{ target: 'de', name: 'First', cause: 'appointed' }] } }),
);
expect(next.nations.de.leader).toEqual({ name: 'First' });
expect(next.nations.de.stats).toEqual(NS(50, 50, 50, 50));
});
it('a leader change on a multi-region nation dips every region and re-derives its stats', () => {
const uk: Nation = { ...mkNation('uk', NS(50, 50, 50, 50), ['g1', 'g2']), leader: { name: 'Old' } };
const state = mkTurnState({ uk }, { g1: RS(50, 50, 50, 50), g2: RS(50, 50, 50, 50) });
const next = applyTurnResult(
state,
mkTurnResult({ leaders: { changes: [{ target: 'uk', name: 'New', cause: 'assassination' }] } }),
);
expect(next.regionStats.g1.stability).toBe(35);
expect(next.regionStats.g2.stability).toBe(35);
expect(next.nations.uk.stats.stability).toBe(35); // re-derived from the dipped regions
});
it('an unknown target is dropped without throwing (no leader change, no event, no crash)', () => {
const next = applyTurnResult(
leaderBase(),
mkTurnResult({ leaders: { changes: [{ target: 'ghost', name: 'X', cause: 'assassination' }] } }),
);
expect(next.nations.uk.leader).toEqual({ name: 'Admiral Old' }); // untouched
expect(next.events).toHaveLength(0);
});
it('an ambiguous target (two nations share the name) is dropped without throwing', () => {
const a: Nation = { ...mkNation('a', NS(50, 50, 50, 50), ['g1']), name: 'Same', leader: { name: 'Old' } };
const b: Nation = { ...mkNation('b', NS(50, 50, 50, 50), ['d1']), name: 'Same' };
const state = mkTurnState({ a, b }, { g1: RS(50, 50, 50, 50), d1: RS(50, 50, 50, 50) });
const next = applyTurnResult(
state,
mkTurnResult({ leaders: { changes: [{ target: 'Same', name: 'New', cause: 'assassination' }] } }),
);
// 'Same' matches two nations -> ambiguous -> dropped; neither leader changes.
expect(next.nations.a.leader).toEqual({ name: 'Old' });
expect(next.nations.b.leader).toBeUndefined();
expect(next.events).toHaveLength(0);
});
});
});