feat: #80 + #81 alliances & space program — 13th & 14th abilities (v0.45.0)
CI / build + unit + e2e (pull_request) Successful in 2m20s

#80: TurnResult.alliances — form/break durable mutual-defense pacts (GameState.alliances; pair relation → ally/neutral, an active war overrides).
#81: TurnResult.space — launch/deorbit satellites (GameState.satellites; +1 stability per active satellite per turn).
Both appended last to the ABILITIES registry (entries 13 & 14, after contested); durable in SQLite via the stateFields loop, mirrored to regionStats, stats re-derived. System + user prompt goldens updated; 738 unit tests passing.
This commit is contained in:
2026-10-17 16:54:28 +00:00
parent 0a6c5aef9c
commit 283f9e0306
26 changed files with 1493 additions and 49 deletions
+48 -38
View File
@@ -137,6 +137,10 @@ function baseState(): GameState {
seaZones: [],
// (v0.44.0, #66) no contested regions on a fresh state.
contested: [],
// (v0.45.0, #80) no alliances on a fresh state.
alliances: [],
// (v0.45.0, #81) no satellites on a fresh state.
satellites: [],
};
}
@@ -574,7 +578,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: [] }, leaders: { changes: [] }, ideology: { changes: [] }, disasters: { events: [] }, migration: { ops: [] }, sea: { control: [] }, contested: { open: [], resolve: [] }, 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: [] }, ideology: { changes: [] }, disasters: { events: [] }, migration: { ops: [] }, sea: { control: [] }, contested: { open: [], resolve: [] }, alliances: { form: [], break: [] }, space: { launch: [], deorbit: [] }, markers: { add: [], remove: [] },
});
expect(withOps).not.toBeNull();
expect(withOps!.nationOps).toEqual([{ kind: 'rename', nationRef: 'France', to: 'French Republic' }]);
@@ -596,7 +600,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: [] }, leaders: { changes: [] }, ideology: { changes: [] }, disasters: { events: [] }, migration: { ops: [] }, sea: { control: [] }, contested: { open: [], resolve: [] }, 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: [] }, ideology: { changes: [] }, disasters: { events: [] }, migration: { ops: [] }, sea: { control: [] }, contested: { open: [], resolve: [] }, alliances: { form: [], break: [] }, space: { launch: [], deorbit: [] }, markers: { add: [], remove: [] },
});
expect(next.nations.de.name).toBe('DE'); // still the old name
expect(next.pendingNationOps).toEqual([{ kind: 'rename', nationRef: 'de', to: 'Reich' }]);
@@ -617,7 +621,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: [] }, leaders: { changes: [] }, ideology: { changes: [] }, disasters: { events: [] }, migration: { ops: [] }, sea: { control: [] }, contested: { open: [], resolve: [] }, 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: [] }, ideology: { changes: [] }, disasters: { events: [] }, migration: { ops: [] }, sea: { control: [] }, contested: { open: [], resolve: [] }, alliances: { form: [], break: [] }, space: { launch: [], deorbit: [] }, markers: { add: [], remove: [] }, // new proposal this turn
});
expect(next.pendingNationOps).toEqual([
{ kind: 'rename', nationRef: 'de', to: 'Reich' }, // the old one survived
@@ -636,7 +640,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: [] }, leaders: { changes: [] }, ideology: { changes: [] }, disasters: { events: [] }, migration: { ops: [] }, sea: { control: [] }, contested: { open: [], resolve: [] }, 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: [] }, ideology: { changes: [] }, disasters: { events: [] }, migration: { ops: [] }, sea: { control: [] }, contested: { open: [], resolve: [] }, alliances: { form: [], break: [] }, space: { launch: [], deorbit: [] }, markers: { add: [], remove: [] },
});
expect(next.pendingNationOps).toEqual([{ kind: 'rename', nationRef: 'de', to: 'Reich' }]);
});
@@ -1093,7 +1097,7 @@ describe('applyTurnResult', () => {
relations: [],
territoryChanges: [],
unowned: [],
nationOps: [], takeovers: [], wars: { declare: [], end: [] }, hybrid: { ops: [] }, economy: { trade: [], embargo: [] }, industry: { projects: [] }, nuclear: { arm: [], strikes: [] }, leaders: { changes: [] }, ideology: { changes: [] }, disasters: { events: [] }, migration: { ops: [] }, sea: { control: [] }, contested: { open: [], resolve: [] }, markers: { add: [], remove: [] },
nationOps: [], takeovers: [], wars: { declare: [], end: [] }, hybrid: { ops: [] }, economy: { trade: [], embargo: [] }, industry: { projects: [] }, nuclear: { arm: [], strikes: [] }, leaders: { changes: [] }, ideology: { changes: [] }, disasters: { events: [] }, migration: { ops: [] }, sea: { control: [] }, contested: { open: [], resolve: [] }, alliances: { form: [], break: [] }, space: { launch: [], deorbit: [] }, markers: { add: [], remove: [] },
};
applyTurnResult(state, result);
expect(JSON.stringify(state)).toBe(snapshot);
@@ -1110,7 +1114,7 @@ describe('applyTurnResult', () => {
relations: [],
territoryChanges: [],
unowned: [],
nationOps: [], takeovers: [], wars: { declare: [], end: [] }, hybrid: { ops: [] }, economy: { trade: [], embargo: [] }, industry: { projects: [] }, nuclear: { arm: [], strikes: [] }, leaders: { changes: [] }, ideology: { changes: [] }, disasters: { events: [] }, migration: { ops: [] }, sea: { control: [] }, contested: { open: [], resolve: [] }, markers: { add: [], remove: [] },
nationOps: [], takeovers: [], wars: { declare: [], end: [] }, hybrid: { ops: [] }, economy: { trade: [], embargo: [] }, industry: { projects: [] }, nuclear: { arm: [], strikes: [] }, leaders: { changes: [] }, ideology: { changes: [] }, disasters: { events: [] }, migration: { ops: [] }, sea: { control: [] }, contested: { open: [], resolve: [] }, alliances: { form: [], break: [] }, space: { launch: [], deorbit: [] }, markers: { add: [], remove: [] },
};
const next = applyTurnResult(state, result);
expect(next.pending).toEqual([]);
@@ -1133,7 +1137,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: [] }, leaders: { changes: [] }, ideology: { changes: [] }, disasters: { events: [] }, migration: { ops: [] }, sea: { control: [] }, contested: { open: [], resolve: [] }, markers: { add: [], remove: [] },
nationOps: [], takeovers: [], wars: { declare: [], end: [] }, hybrid: { ops: [] }, economy: { trade: [], embargo: [] }, industry: { projects: [] }, nuclear: { arm: [], strikes: [] }, leaders: { changes: [] }, ideology: { changes: [] }, disasters: { events: [] }, migration: { ops: [] }, sea: { control: [] }, contested: { open: [], resolve: [] }, alliances: { form: [], break: [] }, space: { launch: [], deorbit: [] }, 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
@@ -1159,7 +1163,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: [] }, leaders: { changes: [] }, ideology: { changes: [] }, disasters: { events: [] }, migration: { ops: [] }, sea: { control: [] }, contested: { open: [], resolve: [] }, markers: { add: [], remove: [] },
nationOps: [], takeovers: [], wars: { declare: [], end: [] }, hybrid: { ops: [] }, economy: { trade: [], embargo: [] }, industry: { projects: [] }, nuclear: { arm: [], strikes: [] }, leaders: { changes: [] }, ideology: { changes: [] }, disasters: { events: [] }, migration: { ops: [] }, sea: { control: [] }, contested: { open: [], resolve: [] }, alliances: { form: [], break: [] }, space: { launch: [], deorbit: [] }, markers: { add: [], remove: [] },
};
const next = applyTurnResult(state, result); // no validKeys -> nothing filtered
expect(next.nations.de.territory).toContain('wales-bbbb');
@@ -1177,7 +1181,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: [] }, leaders: { changes: [] }, ideology: { changes: [] }, disasters: { events: [] }, migration: { ops: [] }, sea: { control: [] }, contested: { open: [], resolve: [] }, markers: { add: [], remove: [] },
nationOps: [], takeovers: [], wars: { declare: [], end: [] }, hybrid: { ops: [] }, economy: { trade: [], embargo: [] }, industry: { projects: [] }, nuclear: { arm: [], strikes: [] }, leaders: { changes: [] }, ideology: { changes: [] }, disasters: { events: [] }, migration: { ops: [] }, sea: { control: [] }, contested: { open: [], resolve: [] }, alliances: { form: [], break: [] }, space: { launch: [], deorbit: [] }, markers: { add: [], remove: [] },
};
it('a double claim (two different gainers, one key) is rejected: the key stays with its original owner', () => {
@@ -1267,7 +1271,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: [] }, leaders: { changes: [] }, ideology: { changes: [] }, disasters: { events: [] }, migration: { ops: [] }, sea: { control: [] }, contested: { open: [], resolve: [] }, markers: { add: [], remove: [] },
nationOps: [], takeovers: [], wars: { declare: [], end: [] }, hybrid: { ops: [] }, economy: { trade: [], embargo: [] }, industry: { projects: [] }, nuclear: { arm: [], strikes: [] }, leaders: { changes: [] }, ideology: { changes: [] }, disasters: { events: [] }, migration: { ops: [] }, sea: { control: [] }, contested: { open: [], resolve: [] }, alliances: { form: [], break: [] }, space: { launch: [], deorbit: [] }, markers: { add: [], remove: [] },
};
it('a release removes the key from its sole owner and marks it in GameState.unowned', () => {
@@ -1382,7 +1386,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: [] }, leaders: { changes: [] }, ideology: { changes: [] }, disasters: { events: [] }, migration: { ops: [] }, sea: { control: [] }, contested: { open: [], resolve: [] }, 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: [] }, ideology: { changes: [] }, disasters: { events: [] }, migration: { ops: [] }, sea: { control: [] }, contested: { open: [], resolve: [] }, alliances: { form: [], break: [] }, space: { launch: [], deorbit: [] }, markers: { add: [], remove: [] } }),
).toBe(true);
});
@@ -1689,9 +1693,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: [] }, leaders: { changes: [] }, ideology: { changes: [] }, disasters: { events: [] }, migration: { ops: [] }, sea: { control: [] }, contested: { open: [], resolve: [] }, 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: [] }, ideology: { changes: [] }, disasters: { events: [] }, migration: { ops: [] }, sea: { control: [] }, contested: { open: [], resolve: [] }, 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: [] }, ideology: { changes: [] }, disasters: { events: [] }, migration: { ops: [] }, sea: { control: [] }, contested: { open: [], resolve: [] }, 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: [] }, ideology: { changes: [] }, disasters: { events: [] }, migration: { ops: [] }, sea: { control: [] }, contested: { open: [], resolve: [] }, alliances: { form: [], break: [] }, space: { launch: [], deorbit: [] }, 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: [] }, ideology: { changes: [] }, disasters: { events: [] }, migration: { ops: [] }, sea: { control: [] }, contested: { open: [], resolve: [] }, alliances: { form: [], break: [] }, space: { launch: [], deorbit: [] }, 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: [] }, ideology: { changes: [] }, disasters: { events: [] }, migration: { ops: [] }, sea: { control: [] }, contested: { open: [], resolve: [] }, alliances: { form: [], break: [] }, space: { launch: [], deorbit: [] }, 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)', () => {
@@ -1704,7 +1708,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: [] }, leaders: { changes: [] }, ideology: { changes: [] }, disasters: { events: [] }, migration: { ops: [] }, sea: { control: [] }, contested: { open: [], resolve: [] }, 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: [] }, ideology: { changes: [] }, disasters: { events: [] }, migration: { ops: [] }, sea: { control: [] }, contested: { open: [], resolve: [] }, alliances: { form: [], break: [] }, space: { launch: [], deorbit: [] }, markers: { add: [], remove: [] },
}),
).toBe(true);
});
@@ -1725,7 +1729,7 @@ describe('isMeaningful', () => {
hybrid: { ops: [] },
economy: { trade: [], embargo: [] }, industry: { projects: [] },
nuclear: { arm: [], strikes: [] }, leaders: { changes: [] },
ideology: { changes: [] }, disasters: { events: [] }, migration: { ops: [] }, sea: { control: [] }, contested: { open: [], resolve: [] }, markers: { add: [{ type: 'city', name: 'Lyon', regionKey: 'rhone-rhne' }], remove: [] },
ideology: { changes: [] }, disasters: { events: [] }, migration: { ops: [] }, sea: { control: [] }, contested: { open: [], resolve: [] }, alliances: { form: [], break: [] }, space: { launch: [], deorbit: [] }, markers: { add: [{ type: 'city', name: 'Lyon', regionKey: 'rhone-rhne' }], remove: [] },
}),
).toBe(true);
});
@@ -1746,7 +1750,7 @@ describe('isMeaningful', () => {
hybrid: { ops: [] },
economy: { trade: [], embargo: [] }, industry: { projects: [] },
nuclear: { arm: [], strikes: [] }, leaders: { changes: [] },
ideology: { changes: [] }, disasters: { events: [] }, migration: { ops: [] }, sea: { control: [] }, contested: { open: [], resolve: [] }, markers: { add: [], remove: [] },
ideology: { changes: [] }, disasters: { events: [] }, migration: { ops: [] }, sea: { control: [] }, contested: { open: [], resolve: [] }, alliances: { form: [], break: [] }, space: { launch: [], deorbit: [] }, markers: { add: [], remove: [] },
}),
).toBe(true);
});
@@ -1769,7 +1773,7 @@ describe('takeovers (regime change, v0.32.0 #54)', () => {
hybrid: { ops: [] },
economy: { trade: [], embargo: [] }, industry: { projects: [] },
nuclear: { arm: [], strikes: [] }, leaders: { changes: [] },
ideology: { changes: [] }, disasters: { events: [] }, migration: { ops: [] }, sea: { control: [] }, contested: { open: [], resolve: [] }, markers: { add: [], remove: [] },
ideology: { changes: [] }, disasters: { events: [] }, migration: { ops: [] }, sea: { control: [] }, contested: { open: [], resolve: [] }, alliances: { form: [], break: [] }, space: { launch: [], deorbit: [] }, markers: { add: [], remove: [] },
};
const next = applyTurnResult(state, result);
expect(next.nations.de.name).toBe('The Reich');
@@ -1802,7 +1806,7 @@ describe('takeovers (regime change, v0.32.0 #54)', () => {
hybrid: { ops: [] },
economy: { trade: [], embargo: [] }, industry: { projects: [] },
nuclear: { arm: [], strikes: [] }, leaders: { changes: [] },
ideology: { changes: [] }, disasters: { events: [] }, migration: { ops: [] }, sea: { control: [] }, contested: { open: [], resolve: [] }, markers: { add: [], remove: [] },
ideology: { changes: [] }, disasters: { events: [] }, migration: { ops: [] }, sea: { control: [] }, contested: { open: [], resolve: [] }, alliances: { form: [], break: [] }, space: { launch: [], deorbit: [] }, markers: { add: [], remove: [] },
};
const next = applyTurnResult(state, result);
expect(next.playerNationId).toBe('uk'); // player still leads it
@@ -1826,7 +1830,7 @@ describe('takeovers (regime change, v0.32.0 #54)', () => {
hybrid: { ops: [] },
economy: { trade: [], embargo: [] }, industry: { projects: [] },
nuclear: { arm: [], strikes: [] }, leaders: { changes: [] },
ideology: { changes: [] }, disasters: { events: [] }, migration: { ops: [] }, sea: { control: [] }, contested: { open: [], resolve: [] }, markers: { add: [], remove: [] },
ideology: { changes: [] }, disasters: { events: [] }, migration: { ops: [] }, sea: { control: [] }, contested: { open: [], resolve: [] }, alliances: { form: [], break: [] }, space: { launch: [], deorbit: [] }, markers: { add: [], remove: [] },
};
const next = applyTurnResult(state, result);
// no nation was renamed
@@ -1853,7 +1857,7 @@ describe('takeovers (regime change, v0.32.0 #54)', () => {
hybrid: { ops: [] },
economy: { trade: [], embargo: [] }, industry: { projects: [] },
nuclear: { arm: [], strikes: [] }, leaders: { changes: [] },
ideology: { changes: [] }, disasters: { events: [] }, migration: { ops: [] }, sea: { control: [] }, contested: { open: [], resolve: [] }, markers: { add: [], remove: [] },
ideology: { changes: [] }, disasters: { events: [] }, migration: { ops: [] }, sea: { control: [] }, contested: { open: [], resolve: [] }, alliances: { form: [], break: [] }, space: { launch: [], deorbit: [] }, markers: { add: [], remove: [] },
};
const next = applyTurnResult(state, result);
// neither was renamed (ambiguous)
@@ -1879,7 +1883,7 @@ describe('takeovers (regime change, v0.32.0 #54)', () => {
hybrid: { ops: [] },
economy: { trade: [], embargo: [] }, industry: { projects: [] },
nuclear: { arm: [], strikes: [] }, leaders: { changes: [] },
ideology: { changes: [] }, disasters: { events: [] }, migration: { ops: [] }, sea: { control: [] }, contested: { open: [], resolve: [] }, markers: { add: [], remove: [] },
ideology: { changes: [] }, disasters: { events: [] }, migration: { ops: [] }, sea: { control: [] }, contested: { open: [], resolve: [] }, alliances: { form: [], break: [] }, space: { launch: [], deorbit: [] }, markers: { add: [], remove: [] },
});
expect(validated!.takeovers).toEqual([]);
const next = applyTurnResult(state, validated!);
@@ -1905,7 +1909,7 @@ describe('takeovers (regime change, v0.32.0 #54)', () => {
hybrid: { ops: [] },
economy: { trade: [], embargo: [] }, industry: { projects: [] },
nuclear: { arm: [], strikes: [] }, leaders: { changes: [] },
ideology: { changes: [] }, disasters: { events: [] }, migration: { ops: [] }, sea: { control: [] }, contested: { open: [], resolve: [] }, markers: { add: [], remove: [] },
ideology: { changes: [] }, disasters: { events: [] }, migration: { ops: [] }, sea: { control: [] }, contested: { open: [], resolve: [] }, alliances: { form: [], break: [] }, space: { launch: [], deorbit: [] }, markers: { add: [], remove: [] },
};
const next = applyTurnResult(state, result);
expect(next.nations.uk.name).toBe('The Commonwealth');
@@ -1929,7 +1933,7 @@ describe('takeovers (regime change, v0.32.0 #54)', () => {
hybrid: { ops: [] },
economy: { trade: [], embargo: [] }, industry: { projects: [] },
nuclear: { arm: [], strikes: [] }, leaders: { changes: [] },
ideology: { changes: [] }, disasters: { events: [] }, migration: { ops: [] }, sea: { control: [] }, contested: { open: [], resolve: [] }, markers: { add: [], remove: [] },
ideology: { changes: [] }, disasters: { events: [] }, migration: { ops: [] }, sea: { control: [] }, contested: { open: [], resolve: [] }, alliances: { form: [], break: [] }, space: { launch: [], deorbit: [] }, markers: { add: [], remove: [] },
};
const next = applyTurnResult(state, result);
expect(next.version).toBe(2); // exactly one bump for the whole turn
@@ -1951,7 +1955,7 @@ describe('takeovers (regime change, v0.32.0 #54)', () => {
hybrid: { ops: [] },
economy: { trade: [], embargo: [] }, industry: { projects: [] },
nuclear: { arm: [], strikes: [] }, leaders: { changes: [] },
ideology: { changes: [] }, disasters: { events: [] }, migration: { ops: [] }, sea: { control: [] }, contested: { open: [], resolve: [] }, markers: { add: [], remove: [] },
ideology: { changes: [] }, disasters: { events: [] }, migration: { ops: [] }, sea: { control: [] }, contested: { open: [], resolve: [] }, alliances: { form: [], break: [] }, space: { launch: [], deorbit: [] }, markers: { add: [], remove: [] },
};
const next = applyTurnResult(state, result);
expect(next.nations.de.name).toBe('The Reich');
@@ -1985,7 +1989,7 @@ describe('markers (point placement, v0.27 #44)', () => {
hybrid: { ops: [] },
economy: { trade: [], embargo: [] }, industry: { projects: [] },
nuclear: { arm: [], strikes: [] }, leaders: { changes: [] },
ideology: { changes: [] }, disasters: { events: [] }, migration: { ops: [] }, sea: { control: [] }, contested: { open: [], resolve: [] }, markers: { add: [mk('city', 'Strasbourg', 'moselle-rygy')], remove: [] },
ideology: { changes: [] }, disasters: { events: [] }, migration: { ops: [] }, sea: { control: [] }, contested: { open: [], resolve: [] }, alliances: { form: [], break: [] }, space: { launch: [], deorbit: [] }, markers: { add: [mk('city', 'Strasbourg', 'moselle-rygy')], remove: [] },
};
const next = applyTurnResult(state, result, undefined, centroidOf);
expect(next.markers).toHaveLength(1);
@@ -2009,13 +2013,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: [] }, leaders: { changes: [] }, ideology: { changes: [] }, disasters: { events: [] }, migration: { ops: [] }, sea: { control: [] }, contested: { open: [], resolve: [] }, markers: { add, remove: [] },
unowned: [], nationOps: [], takeovers: [], wars: { declare: [], end: [] }, hybrid: { ops: [] }, economy: { trade: [], embargo: [] }, industry: { projects: [] }, nuclear: { arm: [], strikes: [] }, leaders: { changes: [] }, ideology: { changes: [] }, disasters: { events: [] }, migration: { ops: [] }, sea: { control: [] }, contested: { open: [], resolve: [] }, alliances: { form: [], break: [] }, space: { launch: [], deorbit: [] }, 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: [] }, leaders: { changes: [] }, ideology: { changes: [] }, disasters: { events: [] }, migration: { ops: [] }, sea: { control: [] }, contested: { open: [], resolve: [] }, markers: { add, remove: [] },
unowned: [], nationOps: [], takeovers: [], wars: { declare: [], end: [] }, hybrid: { ops: [] }, economy: { trade: [], embargo: [] }, industry: { projects: [] }, nuclear: { arm: [], strikes: [] }, leaders: { changes: [] }, ideology: { changes: [] }, disasters: { events: [] }, migration: { ops: [] }, sea: { control: [] }, contested: { open: [], resolve: [] }, alliances: { form: [], break: [] }, space: { launch: [], deorbit: [] }, 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
@@ -2042,7 +2046,7 @@ describe('markers (point placement, v0.27 #44)', () => {
hybrid: { ops: [] },
economy: { trade: [], embargo: [] }, industry: { projects: [] },
nuclear: { arm: [], strikes: [] }, leaders: { changes: [] },
ideology: { changes: [] }, disasters: { events: [] }, migration: { ops: [] }, sea: { control: [] }, contested: { open: [], resolve: [] }, markers: {
ideology: { changes: [] }, disasters: { events: [] }, migration: { ops: [] }, sea: { control: [] }, contested: { open: [], resolve: [] }, alliances: { form: [], break: [] }, space: { launch: [], deorbit: [] }, markers: {
add: [
{ type: 'city', name: 'Good', regionKey: 'moselle-rygy' }, // valid + centroid → kept
{ type: 'city', name: 'NoCent', regionKey: 'other-key' }, // in validKeys, no centroid → dropped
@@ -2089,7 +2093,7 @@ describe('markers (point placement, v0.27 #44)', () => {
hybrid: { ops: [] },
economy: { trade: [], embargo: [] }, industry: { projects: [] },
nuclear: { arm: [], strikes: [] }, leaders: { changes: [] },
ideology: { changes: [] }, disasters: { events: [] }, migration: { ops: [] }, sea: { control: [] }, contested: { open: [], resolve: [] }, markers: {
ideology: { changes: [] }, disasters: { events: [] }, migration: { ops: [] }, sea: { control: [] }, contested: { open: [], resolve: [] }, alliances: { form: [], break: [] }, space: { launch: [], deorbit: [] }, markers: {
add: [
{ type: 'city', name: 'NewA', regionKey: 'new-a' },
{ type: 'city', name: 'NewB', regionKey: 'new-b' },
@@ -2114,7 +2118,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: [] }, leaders: { changes: [] }, ideology: { changes: [] }, disasters: { events: [] }, migration: { ops: [] }, sea: { control: [] }, contested: { open: [], resolve: [] }, 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: [] }, ideology: { changes: [] }, disasters: { events: [] }, migration: { ops: [] }, sea: { control: [] }, contested: { open: [], resolve: [] }, alliances: { form: [], break: [] }, space: { launch: [], deorbit: [] }, 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
@@ -2130,14 +2134,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: [] }, leaders: { changes: [] }, ideology: { changes: [] }, disasters: { events: [] }, migration: { ops: [] }, sea: { control: [] }, contested: { open: [], resolve: [] }, markers: { add: [], remove: [] },
unowned: ['moselle-rygy'], nationOps: [], takeovers: [], wars: { declare: [], end: [] }, hybrid: { ops: [] }, economy: { trade: [], embargo: [] }, industry: { projects: [] }, nuclear: { arm: [], strikes: [] }, leaders: { changes: [] }, ideology: { changes: [] }, disasters: { events: [] }, migration: { ops: [] }, sea: { control: [] }, contested: { open: [], resolve: [] }, alliances: { form: [], break: [] }, space: { launch: [], deorbit: [] }, 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: [] }, leaders: { changes: [] }, ideology: { changes: [] }, disasters: { events: [] }, migration: { ops: [] }, sea: { control: [] }, contested: { open: [], resolve: [] }, markers: { add: [], remove: [] },
unowned: [], nationOps: [], takeovers: [], wars: { declare: [], end: [] }, hybrid: { ops: [] }, economy: { trade: [], embargo: [] }, industry: { projects: [] }, nuclear: { arm: [], strikes: [] }, leaders: { changes: [] }, ideology: { changes: [] }, disasters: { events: [] }, migration: { ops: [] }, sea: { control: [] }, contested: { open: [], resolve: [] }, alliances: { form: [], break: [] }, space: { launch: [], deorbit: [] }, markers: { add: [], remove: [] },
}, undefined, centroidOf);
expect(reclaimed.markers[0].ownerNationId).toBe('de');
});
@@ -2207,7 +2211,7 @@ describe('markers (point placement, v0.27 #44)', () => {
hybrid: { ops: [] },
economy: { trade: [], embargo: [] }, industry: { projects: [] },
nuclear: { arm: [], strikes: [] }, leaders: { changes: [] },
ideology: { changes: [] }, disasters: { events: [] }, migration: { ops: [] }, sea: { control: [] }, contested: { open: [], resolve: [] }, markers: { add: [{ type: 'city', name: 'Strasbourg', regionKey: 'moselle-rygy' }], remove: [] },
ideology: { changes: [] }, disasters: { events: [] }, migration: { ops: [] }, sea: { control: [] }, contested: { open: [], resolve: [] }, alliances: { form: [], break: [] }, space: { launch: [], deorbit: [] }, markers: { add: [{ type: 'city', name: 'Strasbourg', regionKey: 'moselle-rygy' }], remove: [] },
});
const { state } = await runTurn({
llm: fakeLlm([result]),
@@ -2286,6 +2290,10 @@ function mkTurnState(
seaZones: [],
// (v0.44.0, #66) no contested regions on a fresh state.
contested: [],
// (v0.45.0, #80) no alliances on a fresh state.
alliances: [],
// (v0.45.0, #81) no satellites on a fresh state.
satellites: [],
};
}
@@ -2311,6 +2319,8 @@ function mkTurnResult(partial: Partial<TurnResult>): TurnResult {
migration: partial.migration ?? { ops: [] },
sea: partial.sea ?? { control: [] },
contested: partial.contested ?? { open: [], resolve: [] },
alliances: partial.alliances ?? { form: [], break: [] },
space: partial.space ?? { launch: [], deorbit: [] },
markers: partial.markers ?? { add: [], remove: [] },
};
}
@@ -3036,7 +3046,7 @@ describe('validateTakeovers (v0.32.0 #54)', () => {
hybrid: { ops: [] },
economy: { trade: [], embargo: [] }, industry: { projects: [] },
nuclear: { arm: [], strikes: [] }, leaders: { changes: [] },
ideology: { changes: [] }, disasters: { events: [] }, migration: { ops: [] }, sea: { control: [] }, contested: { open: [], resolve: [] }, markers: { add: [], remove: [] },
ideology: { changes: [] }, disasters: { events: [] }, migration: { ops: [] }, sea: { control: [] }, contested: { open: [], resolve: [] }, alliances: { form: [], break: [] }, space: { launch: [], deorbit: [] }, markers: { add: [], remove: [] },
});
expect(withTakeovers).not.toBeNull();
// the valid one survives, the empty-name one is dropped
@@ -3424,7 +3434,7 @@ describe('isMeaningful — wars (v0.33.0 #56)', () => {
hybrid: { ops: [] },
economy: { trade: [], embargo: [] }, industry: { projects: [] },
nuclear: { arm: [], strikes: [] }, leaders: { changes: [] },
ideology: { changes: [] }, disasters: { events: [] }, migration: { ops: [] }, sea: { control: [] }, contested: { open: [], resolve: [] }, markers: { add: [], remove: [] },
ideology: { changes: [] }, disasters: { events: [] }, migration: { ops: [] }, sea: { control: [] }, contested: { open: [], resolve: [] }, alliances: { form: [], break: [] }, space: { launch: [], deorbit: [] }, markers: { add: [], remove: [] },
}),
).toBe(true);
});
@@ -3445,7 +3455,7 @@ describe('isMeaningful — wars (v0.33.0 #56)', () => {
hybrid: { ops: [] },
economy: { trade: [], embargo: [] }, industry: { projects: [] },
nuclear: { arm: [], strikes: [] }, leaders: { changes: [] },
ideology: { changes: [] }, disasters: { events: [] }, migration: { ops: [] }, sea: { control: [] }, contested: { open: [], resolve: [] }, markers: { add: [], remove: [] },
ideology: { changes: [] }, disasters: { events: [] }, migration: { ops: [] }, sea: { control: [] }, contested: { open: [], resolve: [] }, alliances: { form: [], break: [] }, space: { launch: [], deorbit: [] }, markers: { add: [], remove: [] },
}),
).toBe(true);
});
@@ -3466,7 +3476,7 @@ describe('isMeaningful — wars (v0.33.0 #56)', () => {
hybrid: { ops: [] },
economy: { trade: [], embargo: [] }, industry: { projects: [] },
nuclear: { arm: [], strikes: [] }, leaders: { changes: [] },
ideology: { changes: [] }, disasters: { events: [] }, migration: { ops: [] }, sea: { control: [] }, contested: { open: [], resolve: [] }, markers: { add: [], remove: [] },
ideology: { changes: [] }, disasters: { events: [] }, migration: { ops: [] }, sea: { control: [] }, contested: { open: [], resolve: [] }, alliances: { form: [], break: [] }, space: { launch: [], deorbit: [] }, markers: { add: [], remove: [] },
}),
).toBe(false);
});