feat(#62): ideology / government-type change (v0.39.0)
CI / build + unit + e2e (pull_request) Successful in 2m33s
CI / build + unit + e2e (pull_request) Successful in 2m33s
Add a per-nation free-form ideology (form of government) field and a
TurnResult.ideology lever (coups / revolutions / reforms / constitutional
changes) with a deterministic -5 stability dip and political narration.
- shared/types.ts: Nation.ideology?: string (optional/additive)
- shared/protocol.ts: IdeologyChange / IdeologyOps, IDEOLOGY_STABILITY_EFFECT
= -5, TurnResult.ideology (required, between leaders and markers),
nationForPrompt now carries ideology
- server/engine.ts: validateIdeologyOps (exported, never throws),
applyIdeologyChange (resolve -> set field -> -5 dip mirrored to regions +
re-derived -> political event), ideology block in applyTurnResult after
leaders / before markers, isMeaningful + validateTurnResult + system prompt
+ runTurn unclear fallback
- server/stub.ts: StubPayload gains ideology; deterministicTurn returns
ideology: { changes: [] } (never changes one, e2e unaffected)
- Tests: engine.test.ts (validate / isMeaningful / applyTurnResult /
buildUserPrompt), protocol.test.ts (constant + nationForPrompt),
db.test.ts (legacy load + round-trip); every TurnResult fixture updated
- package.json -> 0.39.0; CHANGELOG v0.39.0 entry
This commit is contained in:
+234
-20
@@ -47,6 +47,7 @@ import {
|
||||
syncWarRelations,
|
||||
validateEconomyOps,
|
||||
validateHybridOps,
|
||||
validateIdeologyOps,
|
||||
validateIndustryOps,
|
||||
validateLeaderOps,
|
||||
validateMarkerOps,
|
||||
@@ -569,7 +570,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: [] }, 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: [] }, markers: { add: [], remove: [] },
|
||||
});
|
||||
expect(withOps).not.toBeNull();
|
||||
expect(withOps!.nationOps).toEqual([{ kind: 'rename', nationRef: 'France', to: 'French Republic' }]);
|
||||
@@ -591,7 +592,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: [] }, 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: [] }, markers: { add: [], remove: [] },
|
||||
});
|
||||
expect(next.nations.de.name).toBe('DE'); // still the old name
|
||||
expect(next.pendingNationOps).toEqual([{ kind: 'rename', nationRef: 'de', to: 'Reich' }]);
|
||||
@@ -612,7 +613,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: [] }, 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: [] }, markers: { add: [], remove: [] }, // new proposal this turn
|
||||
});
|
||||
expect(next.pendingNationOps).toEqual([
|
||||
{ kind: 'rename', nationRef: 'de', to: 'Reich' }, // the old one survived
|
||||
@@ -631,7 +632,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: [] }, 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: [] }, markers: { add: [], remove: [] },
|
||||
});
|
||||
expect(next.pendingNationOps).toEqual([{ kind: 'rename', nationRef: 'de', to: 'Reich' }]);
|
||||
});
|
||||
@@ -1088,7 +1089,7 @@ describe('applyTurnResult', () => {
|
||||
relations: [],
|
||||
territoryChanges: [],
|
||||
unowned: [],
|
||||
nationOps: [], takeovers: [], wars: { declare: [], end: [] }, hybrid: { ops: [] }, economy: { trade: [], embargo: [] }, industry: { projects: [] }, nuclear: { arm: [], strikes: [] }, leaders: { changes: [] }, markers: { add: [], remove: [] },
|
||||
nationOps: [], takeovers: [], wars: { declare: [], end: [] }, hybrid: { ops: [] }, economy: { trade: [], embargo: [] }, industry: { projects: [] }, nuclear: { arm: [], strikes: [] }, leaders: { changes: [] }, ideology: { changes: [] }, markers: { add: [], remove: [] },
|
||||
};
|
||||
applyTurnResult(state, result);
|
||||
expect(JSON.stringify(state)).toBe(snapshot);
|
||||
@@ -1105,7 +1106,7 @@ describe('applyTurnResult', () => {
|
||||
relations: [],
|
||||
territoryChanges: [],
|
||||
unowned: [],
|
||||
nationOps: [], takeovers: [], wars: { declare: [], end: [] }, hybrid: { ops: [] }, economy: { trade: [], embargo: [] }, industry: { projects: [] }, nuclear: { arm: [], strikes: [] }, leaders: { changes: [] }, markers: { add: [], remove: [] },
|
||||
nationOps: [], takeovers: [], wars: { declare: [], end: [] }, hybrid: { ops: [] }, economy: { trade: [], embargo: [] }, industry: { projects: [] }, nuclear: { arm: [], strikes: [] }, leaders: { changes: [] }, ideology: { changes: [] }, markers: { add: [], remove: [] },
|
||||
};
|
||||
const next = applyTurnResult(state, result);
|
||||
expect(next.pending).toEqual([]);
|
||||
@@ -1128,7 +1129,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: [] }, markers: { add: [], remove: [] },
|
||||
nationOps: [], takeovers: [], wars: { declare: [], end: [] }, hybrid: { ops: [] }, economy: { trade: [], embargo: [] }, industry: { projects: [] }, nuclear: { arm: [], strikes: [] }, leaders: { changes: [] }, ideology: { 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
|
||||
@@ -1154,7 +1155,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: [] }, markers: { add: [], remove: [] },
|
||||
nationOps: [], takeovers: [], wars: { declare: [], end: [] }, hybrid: { ops: [] }, economy: { trade: [], embargo: [] }, industry: { projects: [] }, nuclear: { arm: [], strikes: [] }, leaders: { changes: [] }, ideology: { changes: [] }, markers: { add: [], remove: [] },
|
||||
};
|
||||
const next = applyTurnResult(state, result); // no validKeys -> nothing filtered
|
||||
expect(next.nations.de.territory).toContain('wales-bbbb');
|
||||
@@ -1172,7 +1173,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: [] }, markers: { add: [], remove: [] },
|
||||
nationOps: [], takeovers: [], wars: { declare: [], end: [] }, hybrid: { ops: [] }, economy: { trade: [], embargo: [] }, industry: { projects: [] }, nuclear: { arm: [], strikes: [] }, leaders: { changes: [] }, ideology: { changes: [] }, markers: { add: [], remove: [] },
|
||||
};
|
||||
|
||||
it('a double claim (two different gainers, one key) is rejected: the key stays with its original owner', () => {
|
||||
@@ -1262,7 +1263,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: [] }, markers: { add: [], remove: [] },
|
||||
nationOps: [], takeovers: [], wars: { declare: [], end: [] }, hybrid: { ops: [] }, economy: { trade: [], embargo: [] }, industry: { projects: [] }, nuclear: { arm: [], strikes: [] }, leaders: { changes: [] }, ideology: { changes: [] }, markers: { add: [], remove: [] },
|
||||
};
|
||||
|
||||
it('a release removes the key from its sole owner and marks it in GameState.unowned', () => {
|
||||
@@ -1377,7 +1378,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: [] }, 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: [] }, markers: { add: [], remove: [] } }),
|
||||
).toBe(true);
|
||||
});
|
||||
|
||||
@@ -1684,9 +1685,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: [] }, 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);
|
||||
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: [] }, 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: [] }, 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: [] }, 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)', () => {
|
||||
@@ -1699,7 +1700,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: [] }, 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: [] }, markers: { add: [], remove: [] },
|
||||
}),
|
||||
).toBe(true);
|
||||
});
|
||||
@@ -1720,6 +1721,7 @@ describe('isMeaningful', () => {
|
||||
hybrid: { ops: [] },
|
||||
economy: { trade: [], embargo: [] }, industry: { projects: [] },
|
||||
nuclear: { arm: [], strikes: [] }, leaders: { changes: [] },
|
||||
ideology: { changes: [] },
|
||||
markers: { add: [{ type: 'city', name: 'Lyon', regionKey: 'rhone-rhne' }], remove: [] },
|
||||
}),
|
||||
).toBe(true);
|
||||
@@ -1741,6 +1743,7 @@ describe('isMeaningful', () => {
|
||||
hybrid: { ops: [] },
|
||||
economy: { trade: [], embargo: [] }, industry: { projects: [] },
|
||||
nuclear: { arm: [], strikes: [] }, leaders: { changes: [] },
|
||||
ideology: { changes: [] },
|
||||
markers: { add: [], remove: [] },
|
||||
}),
|
||||
).toBe(true);
|
||||
@@ -1764,6 +1767,7 @@ describe('takeovers (regime change, v0.32.0 #54)', () => {
|
||||
hybrid: { ops: [] },
|
||||
economy: { trade: [], embargo: [] }, industry: { projects: [] },
|
||||
nuclear: { arm: [], strikes: [] }, leaders: { changes: [] },
|
||||
ideology: { changes: [] },
|
||||
markers: { add: [], remove: [] },
|
||||
};
|
||||
const next = applyTurnResult(state, result);
|
||||
@@ -1797,6 +1801,7 @@ describe('takeovers (regime change, v0.32.0 #54)', () => {
|
||||
hybrid: { ops: [] },
|
||||
economy: { trade: [], embargo: [] }, industry: { projects: [] },
|
||||
nuclear: { arm: [], strikes: [] }, leaders: { changes: [] },
|
||||
ideology: { changes: [] },
|
||||
markers: { add: [], remove: [] },
|
||||
};
|
||||
const next = applyTurnResult(state, result);
|
||||
@@ -1821,6 +1826,7 @@ describe('takeovers (regime change, v0.32.0 #54)', () => {
|
||||
hybrid: { ops: [] },
|
||||
economy: { trade: [], embargo: [] }, industry: { projects: [] },
|
||||
nuclear: { arm: [], strikes: [] }, leaders: { changes: [] },
|
||||
ideology: { changes: [] },
|
||||
markers: { add: [], remove: [] },
|
||||
};
|
||||
const next = applyTurnResult(state, result);
|
||||
@@ -1848,6 +1854,7 @@ describe('takeovers (regime change, v0.32.0 #54)', () => {
|
||||
hybrid: { ops: [] },
|
||||
economy: { trade: [], embargo: [] }, industry: { projects: [] },
|
||||
nuclear: { arm: [], strikes: [] }, leaders: { changes: [] },
|
||||
ideology: { changes: [] },
|
||||
markers: { add: [], remove: [] },
|
||||
};
|
||||
const next = applyTurnResult(state, result);
|
||||
@@ -1874,6 +1881,7 @@ describe('takeovers (regime change, v0.32.0 #54)', () => {
|
||||
hybrid: { ops: [] },
|
||||
economy: { trade: [], embargo: [] }, industry: { projects: [] },
|
||||
nuclear: { arm: [], strikes: [] }, leaders: { changes: [] },
|
||||
ideology: { changes: [] },
|
||||
markers: { add: [], remove: [] },
|
||||
});
|
||||
expect(validated!.takeovers).toEqual([]);
|
||||
@@ -1900,6 +1908,7 @@ describe('takeovers (regime change, v0.32.0 #54)', () => {
|
||||
hybrid: { ops: [] },
|
||||
economy: { trade: [], embargo: [] }, industry: { projects: [] },
|
||||
nuclear: { arm: [], strikes: [] }, leaders: { changes: [] },
|
||||
ideology: { changes: [] },
|
||||
markers: { add: [], remove: [] },
|
||||
};
|
||||
const next = applyTurnResult(state, result);
|
||||
@@ -1924,6 +1933,7 @@ describe('takeovers (regime change, v0.32.0 #54)', () => {
|
||||
hybrid: { ops: [] },
|
||||
economy: { trade: [], embargo: [] }, industry: { projects: [] },
|
||||
nuclear: { arm: [], strikes: [] }, leaders: { changes: [] },
|
||||
ideology: { changes: [] },
|
||||
markers: { add: [], remove: [] },
|
||||
};
|
||||
const next = applyTurnResult(state, result);
|
||||
@@ -1946,6 +1956,7 @@ describe('takeovers (regime change, v0.32.0 #54)', () => {
|
||||
hybrid: { ops: [] },
|
||||
economy: { trade: [], embargo: [] }, industry: { projects: [] },
|
||||
nuclear: { arm: [], strikes: [] }, leaders: { changes: [] },
|
||||
ideology: { changes: [] },
|
||||
markers: { add: [], remove: [] },
|
||||
};
|
||||
const next = applyTurnResult(state, result);
|
||||
@@ -1980,6 +1991,7 @@ describe('markers (point placement, v0.27 #44)', () => {
|
||||
hybrid: { ops: [] },
|
||||
economy: { trade: [], embargo: [] }, industry: { projects: [] },
|
||||
nuclear: { arm: [], strikes: [] }, leaders: { changes: [] },
|
||||
ideology: { changes: [] },
|
||||
markers: { add: [mk('city', 'Strasbourg', 'moselle-rygy')], remove: [] },
|
||||
};
|
||||
const next = applyTurnResult(state, result, undefined, centroidOf);
|
||||
@@ -2004,13 +2016,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: [] }, markers: { add, remove: [] },
|
||||
unowned: [], nationOps: [], takeovers: [], wars: { declare: [], end: [] }, hybrid: { ops: [] }, economy: { trade: [], embargo: [] }, industry: { projects: [] }, nuclear: { arm: [], strikes: [] }, leaders: { changes: [] }, ideology: { 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: [] }, leaders: { changes: [] }, markers: { add, remove: [] },
|
||||
unowned: [], nationOps: [], takeovers: [], wars: { declare: [], end: [] }, hybrid: { ops: [] }, economy: { trade: [], embargo: [] }, industry: { projects: [] }, nuclear: { arm: [], strikes: [] }, leaders: { changes: [] }, ideology: { 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
|
||||
@@ -2037,6 +2049,7 @@ describe('markers (point placement, v0.27 #44)', () => {
|
||||
hybrid: { ops: [] },
|
||||
economy: { trade: [], embargo: [] }, industry: { projects: [] },
|
||||
nuclear: { arm: [], strikes: [] }, leaders: { changes: [] },
|
||||
ideology: { changes: [] },
|
||||
markers: {
|
||||
add: [
|
||||
{ type: 'city', name: 'Good', regionKey: 'moselle-rygy' }, // valid + centroid → kept
|
||||
@@ -2084,6 +2097,7 @@ describe('markers (point placement, v0.27 #44)', () => {
|
||||
hybrid: { ops: [] },
|
||||
economy: { trade: [], embargo: [] }, industry: { projects: [] },
|
||||
nuclear: { arm: [], strikes: [] }, leaders: { changes: [] },
|
||||
ideology: { changes: [] },
|
||||
markers: {
|
||||
add: [
|
||||
{ type: 'city', name: 'NewA', regionKey: 'new-a' },
|
||||
@@ -2109,7 +2123,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: [] }, 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: [] }, 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
|
||||
@@ -2125,14 +2139,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: [] }, 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: [] }, 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: [] }, markers: { add: [], remove: [] },
|
||||
unowned: [], nationOps: [], takeovers: [], wars: { declare: [], end: [] }, hybrid: { ops: [] }, economy: { trade: [], embargo: [] }, industry: { projects: [] }, nuclear: { arm: [], strikes: [] }, leaders: { changes: [] }, ideology: { changes: [] }, markers: { add: [], remove: [] },
|
||||
}, undefined, centroidOf);
|
||||
expect(reclaimed.markers[0].ownerNationId).toBe('de');
|
||||
});
|
||||
@@ -2202,6 +2216,7 @@ describe('markers (point placement, v0.27 #44)', () => {
|
||||
hybrid: { ops: [] },
|
||||
economy: { trade: [], embargo: [] }, industry: { projects: [] },
|
||||
nuclear: { arm: [], strikes: [] }, leaders: { changes: [] },
|
||||
ideology: { changes: [] },
|
||||
markers: { add: [{ type: 'city', name: 'Strasbourg', regionKey: 'moselle-rygy' }], remove: [] },
|
||||
});
|
||||
const { state } = await runTurn({
|
||||
@@ -2297,6 +2312,7 @@ function mkTurnResult(partial: Partial<TurnResult>): TurnResult {
|
||||
industry: partial.industry ?? { projects: [] },
|
||||
nuclear: partial.nuclear ?? { arm: [], strikes: [] },
|
||||
leaders: partial.leaders ?? { changes: [] },
|
||||
ideology: partial.ideology ?? { changes: [] },
|
||||
markers: partial.markers ?? { add: [], remove: [] },
|
||||
};
|
||||
}
|
||||
@@ -3022,6 +3038,7 @@ describe('validateTakeovers (v0.32.0 #54)', () => {
|
||||
hybrid: { ops: [] },
|
||||
economy: { trade: [], embargo: [] }, industry: { projects: [] },
|
||||
nuclear: { arm: [], strikes: [] }, leaders: { changes: [] },
|
||||
ideology: { changes: [] },
|
||||
markers: { add: [], remove: [] },
|
||||
});
|
||||
expect(withTakeovers).not.toBeNull();
|
||||
@@ -3410,6 +3427,7 @@ describe('isMeaningful — wars (v0.33.0 #56)', () => {
|
||||
hybrid: { ops: [] },
|
||||
economy: { trade: [], embargo: [] }, industry: { projects: [] },
|
||||
nuclear: { arm: [], strikes: [] }, leaders: { changes: [] },
|
||||
ideology: { changes: [] },
|
||||
markers: { add: [], remove: [] },
|
||||
}),
|
||||
).toBe(true);
|
||||
@@ -3431,6 +3449,7 @@ describe('isMeaningful — wars (v0.33.0 #56)', () => {
|
||||
hybrid: { ops: [] },
|
||||
economy: { trade: [], embargo: [] }, industry: { projects: [] },
|
||||
nuclear: { arm: [], strikes: [] }, leaders: { changes: [] },
|
||||
ideology: { changes: [] },
|
||||
markers: { add: [], remove: [] },
|
||||
}),
|
||||
).toBe(true);
|
||||
@@ -3452,6 +3471,7 @@ describe('isMeaningful — wars (v0.33.0 #56)', () => {
|
||||
hybrid: { ops: [] },
|
||||
economy: { trade: [], embargo: [] }, industry: { projects: [] },
|
||||
nuclear: { arm: [], strikes: [] }, leaders: { changes: [] },
|
||||
ideology: { changes: [] },
|
||||
markers: { add: [], remove: [] },
|
||||
}),
|
||||
).toBe(false);
|
||||
@@ -4820,3 +4840,197 @@ describe('named leaders (v0.38.0 #61)', () => {
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('ideology / form of government (v0.39.0 #62)', () => {
|
||||
// `uk` starts with an ideology ('monarchy'); `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 ideoBase = (): GameState => {
|
||||
const uk: Nation = { ...mkNation('uk', NS(50, 50, 50, 50), ['g1']), ideology: 'monarchy' };
|
||||
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('validateIdeologyOps', () => {
|
||||
it('accepts a {changes} object and a bare array of valid changes', () => {
|
||||
const fromObj = validateIdeologyOps({
|
||||
changes: [
|
||||
{ target: 'uk', to: 'fascism' },
|
||||
{ target: 'de', to: 'democracy' },
|
||||
],
|
||||
});
|
||||
expect(fromObj).toEqual([
|
||||
{ target: 'uk', to: 'fascism' },
|
||||
{ target: 'de', to: 'democracy' },
|
||||
]);
|
||||
expect(validateIdeologyOps([{ target: 'uk', to: 'x' }])).toEqual([{ target: 'uk', to: 'x' }]);
|
||||
});
|
||||
|
||||
it('drops a change with a missing / empty / whitespace `to`', () => {
|
||||
expect(
|
||||
validateIdeologyOps({ changes: [{ target: 'uk', to: '' }, { target: 'uk', to: ' ' }] }),
|
||||
).toEqual([]);
|
||||
});
|
||||
|
||||
it('drops a change with a missing / empty / whitespace target', () => {
|
||||
expect(
|
||||
validateIdeologyOps({ changes: [{ target: '', to: 'fascism' }, { target: ' ', to: 'fascism' }] }),
|
||||
).toEqual([]);
|
||||
});
|
||||
|
||||
it('returns [] for undefined / non-object / non-array-changes input', () => {
|
||||
expect(validateIdeologyOps(undefined)).toEqual([]);
|
||||
expect(validateIdeologyOps('nope')).toEqual([]);
|
||||
expect(validateIdeologyOps({ changes: 'nope' })).toEqual([]);
|
||||
});
|
||||
|
||||
it('keeps only the valid changes of a mixed batch', () => {
|
||||
const out = validateIdeologyOps({
|
||||
changes: [
|
||||
{ target: 'uk', to: 'Good' },
|
||||
null,
|
||||
{ target: '', to: 'Bad' },
|
||||
{ target: 'de', to: 'Also Good' },
|
||||
],
|
||||
});
|
||||
expect(out).toEqual([
|
||||
{ target: 'uk', to: 'Good' },
|
||||
{ target: 'de', to: 'Also Good' },
|
||||
]);
|
||||
});
|
||||
|
||||
it('trims both fields and caps each at 120 chars', () => {
|
||||
const long = 'x'.repeat(200);
|
||||
const out = validateIdeologyOps({ changes: [{ target: ' uk ', to: ` ${long} ` }] });
|
||||
expect(out).toEqual([{ target: 'uk', to: 'x'.repeat(120) }]);
|
||||
});
|
||||
});
|
||||
|
||||
describe('isMeaningful — ideology', () => {
|
||||
it('a turn with only an ideology change is meaningful', () => {
|
||||
expect(isMeaningful(mkTurnResult({ ideology: { changes: [{ target: 'uk', to: 'fascism' }] } }))).toBe(true);
|
||||
});
|
||||
it('a turn with empty ideology and nothing else is not meaningful', () => {
|
||||
expect(isMeaningful(mkTurnResult({ ideology: { changes: [] } }))).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe('applyTurnResult — ideology', () => {
|
||||
it('a valid change sets the field, dips stability −5, and adds a political event', () => {
|
||||
const next = applyTurnResult(
|
||||
ideoBase(),
|
||||
mkTurnResult({ ideology: { changes: [{ target: 'uk', to: 'fascism' }] } }),
|
||||
);
|
||||
expect(next.nations.uk.ideology).toBe('fascism');
|
||||
expect(next.nations.uk.stats.stability).toBe(45); // 50 - 5
|
||||
expect(next.regionStats.g1.stability).toBe(45); // mirrored to the region
|
||||
expect(next.events).toHaveLength(1);
|
||||
expect(next.events[0]!.type).toBe('political');
|
||||
expect(next.events[0]!.title).toBe('UK\'s government shifted to fascism');
|
||||
// the other nation is untouched
|
||||
expect(next.nations.de.ideology).toBeUndefined();
|
||||
expect(next.nations.de.stats.stability).toBe(50);
|
||||
});
|
||||
|
||||
it('re-setting the SAME ideology still dips (idempotent for the field, not the stat)', () => {
|
||||
const uk: Nation = { ...mkNation('uk', NS(50, 50, 50, 50), ['g1']), ideology: 'fascism' };
|
||||
const state = mkTurnState({ uk }, { g1: RS(50, 50, 50, 50) });
|
||||
const next = applyTurnResult(
|
||||
state,
|
||||
mkTurnResult({ ideology: { changes: [{ target: 'uk', to: 'fascism' }] } }),
|
||||
);
|
||||
expect(next.nations.uk.ideology).toBe('fascism'); // the same label
|
||||
expect(next.nations.uk.stats.stability).toBe(45); // still a −5 dip
|
||||
expect(next.events).toHaveLength(1);
|
||||
expect(next.events[0]!.type).toBe('political');
|
||||
});
|
||||
|
||||
it('a 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']), ideology: 'monarchy' };
|
||||
const state = mkTurnState({ uk }, { g1: RS(50, 50, 50, 50), g2: RS(50, 50, 50, 50) });
|
||||
const next = applyTurnResult(
|
||||
state,
|
||||
mkTurnResult({ ideology: { changes: [{ target: 'uk', to: 'dictatorship' }] } }),
|
||||
);
|
||||
expect(next.regionStats.g1.stability).toBe(45);
|
||||
expect(next.regionStats.g2.stability).toBe(45);
|
||||
expect(next.nations.uk.stats.stability).toBe(45); // re-derived from the dipped regions
|
||||
});
|
||||
|
||||
it('resolves the target by (case-insensitive) name as well as id', () => {
|
||||
const next = applyTurnResult(
|
||||
ideoBase(),
|
||||
mkTurnResult({ ideology: { changes: [{ target: 'UK', to: 'fascism' }] } }),
|
||||
);
|
||||
expect(next.nations.uk.ideology).toBe('fascism');
|
||||
expect(next.nations.uk.stats.stability).toBe(45);
|
||||
});
|
||||
|
||||
it('a 0-region nation keeps its directly-dipped stat (no re-derive to zero)', () => {
|
||||
const iso: Nation = { ...mkNation('iso', NS(50, 50, 50, 50), []) };
|
||||
const state = mkTurnState({ iso }, {});
|
||||
const next = applyTurnResult(
|
||||
state,
|
||||
mkTurnResult({ ideology: { changes: [{ target: 'iso', to: 'fascism' }] } }),
|
||||
);
|
||||
expect(next.nations.iso.ideology).toBe('fascism');
|
||||
expect(next.nations.iso.stats.stability).toBe(45); // direct dip, not re-derived
|
||||
});
|
||||
|
||||
it('clamps the dip at 0 (a stability of 3 becomes 0, not −2)', () => {
|
||||
const low: Nation = { ...mkNation('low', NS(50, 50, 3, 50), ['g1']), ideology: 'monarchy' };
|
||||
const state = mkTurnState({ low }, { g1: RS(50, 50, 3, 50) });
|
||||
const next = applyTurnResult(
|
||||
state,
|
||||
mkTurnResult({ ideology: { changes: [{ target: 'low', to: 'fascism' }] } }),
|
||||
);
|
||||
expect(next.nations.low.stats.stability).toBe(0);
|
||||
expect(next.regionStats.g1.stability).toBe(0);
|
||||
});
|
||||
|
||||
it('an unknown target is dropped without throwing (no change, no event, no crash)', () => {
|
||||
const next = applyTurnResult(
|
||||
ideoBase(),
|
||||
mkTurnResult({ ideology: { changes: [{ target: 'ghost', to: 'fascism' }] } }),
|
||||
);
|
||||
expect(next.nations.uk.ideology).toBe('monarchy'); // untouched
|
||||
expect(next.nations.de.ideology).toBeUndefined();
|
||||
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' };
|
||||
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({ ideology: { changes: [{ target: 'Same', to: 'fascism' }] } }),
|
||||
);
|
||||
// 'Same' matches two nations -> ambiguous -> dropped; neither ideology is set.
|
||||
expect(next.nations.a.ideology).toBeUndefined();
|
||||
expect(next.nations.b.ideology).toBeUndefined();
|
||||
expect(next.events).toHaveLength(0);
|
||||
});
|
||||
});
|
||||
|
||||
describe('buildUserPrompt — ideology', () => {
|
||||
it('stamps a nation\'s ideology in the prompt nations when it has one', () => {
|
||||
const uk: Nation = { ...mkNation('uk', NS(50, 50, 50, 50), ['g1']), ideology: 'fascism' };
|
||||
const state = mkTurnState({ uk }, { g1: RS(50, 50, 50, 50) });
|
||||
const parsed = JSON.parse(buildUserPrompt(state, [])) as {
|
||||
state: { nations: Record<string, { ideology?: string }> };
|
||||
};
|
||||
expect(parsed.state.nations.uk.ideology).toBe('fascism');
|
||||
});
|
||||
|
||||
it('omits a nation\'s ideology from the prompt when it has none', () => {
|
||||
const uk: Nation = { ...mkNation('uk', NS(50, 50, 50, 50), ['g1']) };
|
||||
const state = mkTurnState({ uk }, { g1: RS(50, 50, 50, 50) });
|
||||
const parsed = JSON.parse(buildUserPrompt(state, [])) as {
|
||||
state: { nations: Record<string, { ideology?: string }> };
|
||||
};
|
||||
expect(parsed.state.nations.uk.ideology).toBeUndefined();
|
||||
expect(JSON.stringify(parsed.state.nations.uk)).not.toContain('ideology');
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user