feat(#58): trade & embargoes — TurnResult.economy ops (trade/embargo, declare/lift) with a deterministic per-turn effect and war auto-suspension
CI / build + unit + e2e (pull_request) Successful in 2m3s
CI / build + unit + e2e (pull_request) Successful in 2m3s
This commit is contained in:
+311
-20
@@ -40,6 +40,7 @@ import {
|
||||
runTurn,
|
||||
shiftRelationTowardHostile,
|
||||
syncWarRelations,
|
||||
validateEconomyOps,
|
||||
validateHybridOps,
|
||||
validateMarkerOps,
|
||||
validateNationOps,
|
||||
@@ -118,6 +119,9 @@ function baseState(): GameState {
|
||||
regionStats: seedRegionStats(nations),
|
||||
// (v0.33.0, #56) no wars on a fresh state.
|
||||
wars: [],
|
||||
// (v0.35.0, #58) no active trades or embargoes on a fresh state.
|
||||
trades: [],
|
||||
embargoes: [],
|
||||
};
|
||||
}
|
||||
|
||||
@@ -555,7 +559,7 @@ describe('validateNationOps (v0.23 #16 P4a)', () => {
|
||||
relations: [],
|
||||
territoryChanges: [],
|
||||
unowned: [],
|
||||
nationOps: [{ kind: 'rename', nationRef: 'France', to: 'French Republic' }], takeovers: [], wars: { declare: [], end: [] }, hybrid: { ops: [] }, markers: { add: [], remove: [] },
|
||||
nationOps: [{ kind: 'rename', nationRef: 'France', to: 'French Republic' }], takeovers: [], wars: { declare: [], end: [] }, hybrid: { ops: [] }, economy: { trade: [], embargo: [] }, markers: { add: [], remove: [] },
|
||||
});
|
||||
expect(withOps).not.toBeNull();
|
||||
expect(withOps!.nationOps).toEqual([{ kind: 'rename', nationRef: 'France', to: 'French Republic' }]);
|
||||
@@ -577,7 +581,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: [] }, markers: { add: [], remove: [] },
|
||||
nationOps: [{ kind: 'rename', nationRef: 'de', to: 'Reich' }], takeovers: [], wars: { declare: [], end: [] }, hybrid: { ops: [] }, economy: { trade: [], embargo: [] }, markers: { add: [], remove: [] },
|
||||
});
|
||||
expect(next.nations.de.name).toBe('DE'); // still the old name
|
||||
expect(next.pendingNationOps).toEqual([{ kind: 'rename', nationRef: 'de', to: 'Reich' }]);
|
||||
@@ -598,7 +602,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: [] }, markers: { add: [], remove: [] }, // new proposal this turn
|
||||
nationOps: [{ kind: 'rename', nationRef: 'us', to: 'USA' }], takeovers: [], wars: { declare: [], end: [] }, hybrid: { ops: [] }, economy: { trade: [], embargo: [] }, markers: { add: [], remove: [] }, // new proposal this turn
|
||||
});
|
||||
expect(next.pendingNationOps).toEqual([
|
||||
{ kind: 'rename', nationRef: 'de', to: 'Reich' }, // the old one survived
|
||||
@@ -617,7 +621,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: [] }, markers: { add: [], remove: [] },
|
||||
nationOps: [{ kind: 'rename', nationRef: 'de', to: 'Reich' }], takeovers: [], wars: { declare: [], end: [] }, hybrid: { ops: [] }, economy: { trade: [], embargo: [] }, markers: { add: [], remove: [] },
|
||||
});
|
||||
expect(next.pendingNationOps).toEqual([{ kind: 'rename', nationRef: 'de', to: 'Reich' }]);
|
||||
});
|
||||
@@ -1074,7 +1078,7 @@ describe('applyTurnResult', () => {
|
||||
relations: [],
|
||||
territoryChanges: [],
|
||||
unowned: [],
|
||||
nationOps: [], takeovers: [], wars: { declare: [], end: [] }, hybrid: { ops: [] }, markers: { add: [], remove: [] },
|
||||
nationOps: [], takeovers: [], wars: { declare: [], end: [] }, hybrid: { ops: [] }, economy: { trade: [], embargo: [] }, markers: { add: [], remove: [] },
|
||||
};
|
||||
applyTurnResult(state, result);
|
||||
expect(JSON.stringify(state)).toBe(snapshot);
|
||||
@@ -1091,7 +1095,7 @@ describe('applyTurnResult', () => {
|
||||
relations: [],
|
||||
territoryChanges: [],
|
||||
unowned: [],
|
||||
nationOps: [], takeovers: [], wars: { declare: [], end: [] }, hybrid: { ops: [] }, markers: { add: [], remove: [] },
|
||||
nationOps: [], takeovers: [], wars: { declare: [], end: [] }, hybrid: { ops: [] }, economy: { trade: [], embargo: [] }, markers: { add: [], remove: [] },
|
||||
};
|
||||
const next = applyTurnResult(state, result);
|
||||
expect(next.pending).toEqual([]);
|
||||
@@ -1114,7 +1118,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: [] }, markers: { add: [], remove: [] },
|
||||
nationOps: [], takeovers: [], wars: { declare: [], end: [] }, hybrid: { ops: [] }, economy: { trade: [], embargo: [] }, 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
|
||||
@@ -1140,7 +1144,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: [] }, markers: { add: [], remove: [] },
|
||||
nationOps: [], takeovers: [], wars: { declare: [], end: [] }, hybrid: { ops: [] }, economy: { trade: [], embargo: [] }, markers: { add: [], remove: [] },
|
||||
};
|
||||
const next = applyTurnResult(state, result); // no validKeys -> nothing filtered
|
||||
expect(next.nations.de.territory).toContain('wales-bbbb');
|
||||
@@ -1158,7 +1162,7 @@ describe('applyTurnResult (sole-ownership invariants, v0.21 #16 P2)', () => {
|
||||
regionChanges: [],
|
||||
relations: [],
|
||||
unowned: [],
|
||||
nationOps: [], takeovers: [], wars: { declare: [], end: [] }, hybrid: { ops: [] }, markers: { add: [], remove: [] },
|
||||
nationOps: [], takeovers: [], wars: { declare: [], end: [] }, hybrid: { ops: [] }, economy: { trade: [], embargo: [] }, markers: { add: [], remove: [] },
|
||||
};
|
||||
|
||||
it('a double claim (two different gainers, one key) is rejected: the key stays with its original owner', () => {
|
||||
@@ -1248,7 +1252,7 @@ describe('applyTurnResult (no man\'s land, v0.22 #16 P3)', () => {
|
||||
relations: [],
|
||||
territoryChanges: [],
|
||||
unowned: [],
|
||||
nationOps: [], takeovers: [], wars: { declare: [], end: [] }, hybrid: { ops: [] }, markers: { add: [], remove: [] },
|
||||
nationOps: [], takeovers: [], wars: { declare: [], end: [] }, hybrid: { ops: [] }, economy: { trade: [], embargo: [] }, markers: { add: [], remove: [] },
|
||||
};
|
||||
|
||||
it('a release removes the key from its sole owner and marks it in GameState.unowned', () => {
|
||||
@@ -1363,7 +1367,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: [] }, markers: { add: [], remove: [] } }),
|
||||
isMeaningful({ date: 'x', events: [], stateChanges: [], regionChanges: [], relations: [], territoryChanges: [], unowned: ['GBR'], nationOps: [], takeovers: [], wars: { declare: [], end: [] }, hybrid: { ops: [] }, economy: { trade: [], embargo: [] }, markers: { add: [], remove: [] } }),
|
||||
).toBe(true);
|
||||
});
|
||||
|
||||
@@ -1670,9 +1674,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: [] }, 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: [] }, markers: { add: [], remove: [] } })).toBe(true);
|
||||
expect(isMeaningful({ date: 'x', events: [], stateChanges: [], regionChanges: [], relations: [], territoryChanges: [], unowned: [], nationOps: [], takeovers: [], wars: { declare: [], end: [] }, hybrid: { ops: [] }, 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: [] }, 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: [] }, 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: [] }, 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)', () => {
|
||||
@@ -1685,7 +1689,7 @@ describe('isMeaningful', () => {
|
||||
relations: [],
|
||||
territoryChanges: [],
|
||||
unowned: [],
|
||||
nationOps: [{ kind: 'rename', nationRef: 'de', to: 'Reich' }], takeovers: [], wars: { declare: [], end: [] }, hybrid: { ops: [] }, markers: { add: [], remove: [] },
|
||||
nationOps: [{ kind: 'rename', nationRef: 'de', to: 'Reich' }], takeovers: [], wars: { declare: [], end: [] }, hybrid: { ops: [] }, economy: { trade: [], embargo: [] }, markers: { add: [], remove: [] },
|
||||
}),
|
||||
).toBe(true);
|
||||
});
|
||||
@@ -1704,6 +1708,7 @@ describe('isMeaningful', () => {
|
||||
takeovers: [],
|
||||
wars: { declare: [], end: [] },
|
||||
hybrid: { ops: [] },
|
||||
economy: { trade: [], embargo: [] },
|
||||
markers: { add: [{ type: 'city', name: 'Lyon', regionKey: 'rhone-rhne' }], remove: [] },
|
||||
}),
|
||||
).toBe(true);
|
||||
@@ -1723,6 +1728,7 @@ describe('isMeaningful', () => {
|
||||
takeovers: [{ nationRef: 'de', newRegimeName: 'The Reich' }],
|
||||
wars: { declare: [], end: [] },
|
||||
hybrid: { ops: [] },
|
||||
economy: { trade: [], embargo: [] },
|
||||
markers: { add: [], remove: [] },
|
||||
}),
|
||||
).toBe(true);
|
||||
@@ -1744,6 +1750,7 @@ describe('takeovers (regime change, v0.32.0 #54)', () => {
|
||||
takeovers: [{ nationRef: 'de', newRegimeName: 'The Reich' }],
|
||||
wars: { declare: [], end: [] },
|
||||
hybrid: { ops: [] },
|
||||
economy: { trade: [], embargo: [] },
|
||||
markers: { add: [], remove: [] },
|
||||
};
|
||||
const next = applyTurnResult(state, result);
|
||||
@@ -1775,6 +1782,7 @@ describe('takeovers (regime change, v0.32.0 #54)', () => {
|
||||
takeovers: [{ nationRef: 'uk', newRegimeName: 'The Commonwealth' }],
|
||||
wars: { declare: [], end: [] },
|
||||
hybrid: { ops: [] },
|
||||
economy: { trade: [], embargo: [] },
|
||||
markers: { add: [], remove: [] },
|
||||
};
|
||||
const next = applyTurnResult(state, result);
|
||||
@@ -1797,6 +1805,7 @@ describe('takeovers (regime change, v0.32.0 #54)', () => {
|
||||
takeovers: [{ nationRef: 'nonexistent', newRegimeName: 'New Name' }],
|
||||
wars: { declare: [], end: [] },
|
||||
hybrid: { ops: [] },
|
||||
economy: { trade: [], embargo: [] },
|
||||
markers: { add: [], remove: [] },
|
||||
};
|
||||
const next = applyTurnResult(state, result);
|
||||
@@ -1822,6 +1831,7 @@ describe('takeovers (regime change, v0.32.0 #54)', () => {
|
||||
takeovers: [{ nationRef: 'UK', newRegimeName: 'The Empire' }],
|
||||
wars: { declare: [], end: [] },
|
||||
hybrid: { ops: [] },
|
||||
economy: { trade: [], embargo: [] },
|
||||
markers: { add: [], remove: [] },
|
||||
};
|
||||
const next = applyTurnResult(state, result);
|
||||
@@ -1846,6 +1856,7 @@ describe('takeovers (regime change, v0.32.0 #54)', () => {
|
||||
takeovers: [{ nationRef: 'de', newRegimeName: ' ' }],
|
||||
wars: { declare: [], end: [] },
|
||||
hybrid: { ops: [] },
|
||||
economy: { trade: [], embargo: [] },
|
||||
markers: { add: [], remove: [] },
|
||||
});
|
||||
expect(validated!.takeovers).toEqual([]);
|
||||
@@ -1870,6 +1881,7 @@ describe('takeovers (regime change, v0.32.0 #54)', () => {
|
||||
],
|
||||
wars: { declare: [], end: [] },
|
||||
hybrid: { ops: [] },
|
||||
economy: { trade: [], embargo: [] },
|
||||
markers: { add: [], remove: [] },
|
||||
};
|
||||
const next = applyTurnResult(state, result);
|
||||
@@ -1892,6 +1904,7 @@ describe('takeovers (regime change, v0.32.0 #54)', () => {
|
||||
takeovers: [{ nationRef: 'de', newRegimeName: 'The Reich' }],
|
||||
wars: { declare: [], end: [] },
|
||||
hybrid: { ops: [] },
|
||||
economy: { trade: [], embargo: [] },
|
||||
markers: { add: [], remove: [] },
|
||||
};
|
||||
const next = applyTurnResult(state, result);
|
||||
@@ -1912,6 +1925,7 @@ describe('takeovers (regime change, v0.32.0 #54)', () => {
|
||||
takeovers: [{ nationRef: 'De', newRegimeName: 'The Reich' }], // 'De' → resolves to 'de' by name
|
||||
wars: { declare: [], end: [] },
|
||||
hybrid: { ops: [] },
|
||||
economy: { trade: [], embargo: [] },
|
||||
markers: { add: [], remove: [] },
|
||||
};
|
||||
const next = applyTurnResult(state, result);
|
||||
@@ -1944,6 +1958,7 @@ describe('markers (point placement, v0.27 #44)', () => {
|
||||
takeovers: [],
|
||||
wars: { declare: [], end: [] },
|
||||
hybrid: { ops: [] },
|
||||
economy: { trade: [], embargo: [] },
|
||||
markers: { add: [mk('city', 'Strasbourg', 'moselle-rygy')], remove: [] },
|
||||
};
|
||||
const next = applyTurnResult(state, result, undefined, centroidOf);
|
||||
@@ -1968,13 +1983,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: [] }, markers: { add, remove: [] },
|
||||
unowned: [], nationOps: [], takeovers: [], wars: { declare: [], end: [] }, hybrid: { ops: [] }, economy: { trade: [], embargo: [] }, 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: [] }, markers: { add, remove: [] },
|
||||
unowned: [], nationOps: [], takeovers: [], wars: { declare: [], end: [] }, hybrid: { ops: [] }, economy: { trade: [], embargo: [] }, 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
|
||||
@@ -1999,6 +2014,7 @@ describe('markers (point placement, v0.27 #44)', () => {
|
||||
takeovers: [],
|
||||
wars: { declare: [], end: [] },
|
||||
hybrid: { ops: [] },
|
||||
economy: { trade: [], embargo: [] },
|
||||
markers: {
|
||||
add: [
|
||||
{ type: 'city', name: 'Good', regionKey: 'moselle-rygy' }, // valid + centroid → kept
|
||||
@@ -2044,6 +2060,7 @@ describe('markers (point placement, v0.27 #44)', () => {
|
||||
takeovers: [],
|
||||
wars: { declare: [], end: [] },
|
||||
hybrid: { ops: [] },
|
||||
economy: { trade: [], embargo: [] },
|
||||
markers: {
|
||||
add: [
|
||||
{ type: 'city', name: 'NewA', regionKey: 'new-a' },
|
||||
@@ -2069,7 +2086,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: [] }, markers: { add: [], remove: [idA, 'zzzz-not-there'] },
|
||||
unowned: [], nationOps: [], takeovers: [], wars: { declare: [], end: [] }, hybrid: { ops: [] }, economy: { trade: [], embargo: [] }, 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
|
||||
@@ -2085,14 +2102,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: [] }, markers: { add: [], remove: [] },
|
||||
unowned: ['moselle-rygy'], nationOps: [], takeovers: [], wars: { declare: [], end: [] }, hybrid: { ops: [] }, economy: { trade: [], embargo: [] }, 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: [] }, markers: { add: [], remove: [] },
|
||||
unowned: [], nationOps: [], takeovers: [], wars: { declare: [], end: [] }, hybrid: { ops: [] }, economy: { trade: [], embargo: [] }, markers: { add: [], remove: [] },
|
||||
}, undefined, centroidOf);
|
||||
expect(reclaimed.markers[0].ownerNationId).toBe('de');
|
||||
});
|
||||
@@ -2160,6 +2177,7 @@ describe('markers (point placement, v0.27 #44)', () => {
|
||||
nationOps: [],
|
||||
wars: { declare: [], end: [] },
|
||||
hybrid: { ops: [] },
|
||||
economy: { trade: [], embargo: [] },
|
||||
markers: { add: [{ type: 'city', name: 'Strasbourg', regionKey: 'moselle-rygy' }], remove: [] },
|
||||
});
|
||||
const { state } = await runTurn({
|
||||
@@ -2230,6 +2248,9 @@ function mkTurnState(
|
||||
markers: [],
|
||||
regionStats,
|
||||
wars: [],
|
||||
// (v0.35.0, #58) no active trades or embargoes on a fresh state.
|
||||
trades: [],
|
||||
embargoes: [],
|
||||
};
|
||||
}
|
||||
|
||||
@@ -2246,6 +2267,7 @@ function mkTurnResult(partial: Partial<TurnResult>): TurnResult {
|
||||
takeovers: partial.takeovers ?? [],
|
||||
wars: partial.wars ?? { declare: [], end: [] },
|
||||
hybrid: partial.hybrid ?? { ops: [] },
|
||||
economy: partial.economy ?? { trade: [], embargo: [] },
|
||||
markers: partial.markers ?? { add: [], remove: [] },
|
||||
};
|
||||
}
|
||||
@@ -2969,6 +2991,7 @@ describe('validateTakeovers (v0.32.0 #54)', () => {
|
||||
takeovers: [{ nationRef: 'de', newRegimeName: 'The Reich' }, { nationRef: 'bad', newRegimeName: '' }],
|
||||
wars: { declare: [], end: [] },
|
||||
hybrid: { ops: [] },
|
||||
economy: { trade: [], embargo: [] },
|
||||
markers: { add: [], remove: [] },
|
||||
});
|
||||
expect(withTakeovers).not.toBeNull();
|
||||
@@ -3355,6 +3378,7 @@ describe('isMeaningful — wars (v0.33.0 #56)', () => {
|
||||
takeovers: [],
|
||||
wars: { declare: [{ sides: ['a', 'b'] }], end: [] },
|
||||
hybrid: { ops: [] },
|
||||
economy: { trade: [], embargo: [] },
|
||||
markers: { add: [], remove: [] },
|
||||
}),
|
||||
).toBe(true);
|
||||
@@ -3374,6 +3398,7 @@ describe('isMeaningful — wars (v0.33.0 #56)', () => {
|
||||
takeovers: [],
|
||||
wars: { declare: [], end: [{ id: 'w1', outcome: 'armistice' }] },
|
||||
hybrid: { ops: [] },
|
||||
economy: { trade: [], embargo: [] },
|
||||
markers: { add: [], remove: [] },
|
||||
}),
|
||||
).toBe(true);
|
||||
@@ -3393,6 +3418,7 @@ describe('isMeaningful — wars (v0.33.0 #56)', () => {
|
||||
takeovers: [],
|
||||
wars: { declare: [], end: [] },
|
||||
hybrid: { ops: [] },
|
||||
economy: { trade: [], embargo: [] },
|
||||
markers: { add: [], remove: [] },
|
||||
}),
|
||||
).toBe(false);
|
||||
@@ -3839,3 +3865,268 @@ describe('stub — hybrid (v0.34.0 #57)', () => {
|
||||
expect(result.hybrid).toEqual({ ops: [] });
|
||||
});
|
||||
});
|
||||
|
||||
// ============================================================================
|
||||
// Economy — trade & embargo (v0.35.0, #58)
|
||||
//
|
||||
// The bilateral trade + embargo layer: `GameState.trades` (symmetric pairs,
|
||||
// stored sorted) and `GameState.embargoes` (ordered from → target). A turn's
|
||||
// `economy` ops declare/lift them (refs resolved at apply time); EVERY
|
||||
// committed turn the full active lists apply their deterministic per-turn
|
||||
// effect (trade: both parties' economy up; embargo: the target's economy
|
||||
// down), clamped and mirrored to `regionStats`, and any trade/embargo whose
|
||||
// two nations are on opposite sides of an active war is auto-suspended.
|
||||
// ============================================================================
|
||||
|
||||
describe('validateEconomyOps (v0.35.0 #58)', () => {
|
||||
it('non-object input yields empty trade/embargo', () => {
|
||||
expect(validateEconomyOps(undefined)).toEqual({ trade: [], embargo: [] });
|
||||
expect(validateEconomyOps(null)).toEqual({ trade: [], embargo: [] });
|
||||
expect(validateEconomyOps('x')).toEqual({ trade: [], embargo: [] });
|
||||
expect(validateEconomyOps([])).toEqual({ trade: [], embargo: [] });
|
||||
});
|
||||
|
||||
it('keeps well-formed ops (trimmed refs, lift normalized) and drops malformed ones', () => {
|
||||
const out = validateEconomyOps({
|
||||
trade: [{ a: ' uk ', b: 'de' }, { a: 'us', b: 'de', lift: true }, 'junk', null, { a: 'us' }],
|
||||
embargo: [{ from: 'uk', target: 'de', lift: true }, { from: 'uk', target: 'de' }, { from: 'uk' }],
|
||||
});
|
||||
expect(out.trade).toEqual([{ a: 'uk', b: 'de' }, { a: 'us', b: 'de', lift: true }]);
|
||||
expect(out.embargo).toEqual([{ from: 'uk', target: 'de', lift: true }, { from: 'uk', target: 'de' }]);
|
||||
});
|
||||
});
|
||||
|
||||
describe('validateTurnResult — economy (v0.35.0 #58)', () => {
|
||||
it('a bare (economy-less) input gets economy defaulted to { trade: [], embargo: [] }', () => {
|
||||
const out = validateTurnResult({ date: '1939-09-03', events: [], stateChanges: [] });
|
||||
expect(out).not.toBeNull();
|
||||
expect(out!.economy).toEqual({ trade: [], embargo: [] });
|
||||
});
|
||||
|
||||
it('a well-formed economy payload is kept', () => {
|
||||
const out = validateTurnResult({
|
||||
date: '1939-09-03',
|
||||
events: [],
|
||||
stateChanges: [],
|
||||
economy: { trade: [{ a: 'a', b: 'b' }], embargo: [{ from: 'a', target: 'b', lift: true }] },
|
||||
});
|
||||
expect(out).not.toBeNull();
|
||||
expect(out!.economy).toEqual({
|
||||
trade: [{ a: 'a', b: 'b' }],
|
||||
embargo: [{ from: 'a', target: 'b', lift: true }],
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('applyTurnResult — economy (v0.35.0 #58)', () => {
|
||||
it('a declared trade: both parties gain +1 economy (stats + regionStats mirror), and the agreement persists', () => {
|
||||
const state = mkTurnState(
|
||||
{ a: mkNation('a', NS(50, 60, 50, 50), ['ra1']), b: mkNation('b', NS(50, 70, 50, 50), ['rb1']) },
|
||||
{ ra1: RS(50, 60, 50, 50), rb1: RS(50, 70, 50, 50) },
|
||||
);
|
||||
const next = applyTurnResult(state, mkTurnResult({ economy: { trade: [{ a: 'a', b: 'b' }], embargo: [] } }));
|
||||
expect(next.trades).toEqual([{ a: 'a', b: 'b' }]);
|
||||
expect(next.nations.a.stats.economy).toBe(61);
|
||||
expect(next.nations.b.stats.economy).toBe(71);
|
||||
expect(next.regionStats.ra1.economy).toBe(61);
|
||||
expect(next.regionStats.rb1.economy).toBe(71);
|
||||
});
|
||||
|
||||
it('the per-turn effect applies to the FULL active lists every turn (a later empty turn re-applies it)', () => {
|
||||
let state = mkTurnState(
|
||||
{ a: mkNation('a', NS(50, 60, 50, 50), ['ra1']), b: mkNation('b', NS(50, 70, 50, 50), ['rb1']) },
|
||||
{ ra1: RS(50, 60, 50, 50), rb1: RS(50, 70, 50, 50) },
|
||||
);
|
||||
state = applyTurnResult(state, mkTurnResult({ economy: { trade: [{ a: 'a', b: 'b' }], embargo: [] } }));
|
||||
expect(state.nations.a.stats.economy).toBe(61);
|
||||
expect(state.nations.b.stats.economy).toBe(71);
|
||||
// the next (empty) turn: the still-active trade applies its effect again
|
||||
state = applyTurnResult(state, mkTurnResult({}));
|
||||
expect(state.trades).toEqual([{ a: 'a', b: 'b' }]);
|
||||
expect(state.nations.a.stats.economy).toBe(62);
|
||||
expect(state.nations.b.stats.economy).toBe(72);
|
||||
expect(state.regionStats.ra1.economy).toBe(62);
|
||||
expect(state.regionStats.rb1.economy).toBe(72);
|
||||
});
|
||||
|
||||
it('an embargo lowers only the target economy; the imposer is untouched', () => {
|
||||
const state = mkTurnState(
|
||||
{ a: mkNation('a', NS(50, 60, 50, 50), ['ra1']), b: mkNation('b', NS(50, 70, 50, 50), ['rb1']) },
|
||||
{ ra1: RS(50, 60, 50, 50), rb1: RS(50, 70, 50, 50) },
|
||||
);
|
||||
const next = applyTurnResult(state, mkTurnResult({ economy: { trade: [], embargo: [{ from: 'a', target: 'b' }] } }));
|
||||
expect(next.embargoes).toEqual([{ from: 'a', target: 'b' }]);
|
||||
expect(next.nations.a.stats.economy).toBe(60); // imposer unchanged
|
||||
expect(next.nations.b.stats.economy).toBe(69); // the target dips 1
|
||||
expect(next.regionStats.ra1.economy).toBe(60);
|
||||
expect(next.regionStats.rb1.economy).toBe(69);
|
||||
});
|
||||
|
||||
it('declares are idempotent: a trade is one symmetric (sorted) agreement; embargoes keep both ordered directions (a duplicate is dropped)', () => {
|
||||
const state = mkTurnState(
|
||||
{ a: mkNation('a', NS(50, 50, 50, 50), ['ra1']), b: mkNation('b', NS(50, 50, 50, 50), ['rb1']) },
|
||||
{ ra1: RS(50, 50, 50, 50), rb1: RS(50, 50, 50, 50) },
|
||||
);
|
||||
const next = applyTurnResult(state, mkTurnResult({
|
||||
economy: {
|
||||
trade: [{ a: 'b', b: 'a' }, { a: 'a', b: 'b' }], // the same pair, both orders → one agreement
|
||||
embargo: [{ from: 'a', target: 'b' }, { from: 'b', target: 'a' }, { from: 'a', target: 'b' }], // both directions kept, the duplicate dropped
|
||||
},
|
||||
}));
|
||||
expect(next.trades).toEqual([{ a: 'a', b: 'b' }]);
|
||||
expect(next.embargoes).toEqual([{ from: 'a', target: 'b' }, { from: 'b', target: 'a' }]);
|
||||
// the net delta is +1 (trade) - 1 (the embargo pointing at each) = 0 for both
|
||||
expect(next.nations.a.stats.economy).toBe(50);
|
||||
expect(next.nations.b.stats.economy).toBe(50);
|
||||
});
|
||||
|
||||
it('lift: true ends a trade (the effect stops) and lifts an embargo; lifting an absent one is a no-op', () => {
|
||||
let state = mkTurnState(
|
||||
{ a: mkNation('a', NS(50, 60, 50, 50), ['ra1']), b: mkNation('b', NS(50, 70, 50, 50), ['rb1']) },
|
||||
{ ra1: RS(50, 60, 50, 50), rb1: RS(50, 70, 50, 50) },
|
||||
);
|
||||
state = applyTurnResult(state, mkTurnResult({ economy: { trade: [{ a: 'a', b: 'b' }], embargo: [{ from: 'a', target: 'b' }] } }));
|
||||
// trade +1 for both; the embargo hits b only → a 61, b 70
|
||||
expect(state.nations.a.stats.economy).toBe(61);
|
||||
expect(state.nations.b.stats.economy).toBe(70);
|
||||
// lift both on the next turn (the trade lift is name/ref-insensitive: b→a resolves to the same sorted pair)
|
||||
state = applyTurnResult(state, mkTurnResult({ economy: { trade: [{ a: 'b', b: 'a', lift: true }], embargo: [{ from: 'a', target: 'b', lift: true }] } }));
|
||||
expect(state.trades).toEqual([]);
|
||||
expect(state.embargoes).toEqual([]);
|
||||
expect(state.nations.a.stats.economy).toBe(61); // no further change
|
||||
expect(state.nations.b.stats.economy).toBe(70);
|
||||
// lifting again (absent) is a no-op
|
||||
state = applyTurnResult(state, mkTurnResult({ economy: { trade: [{ a: 'a', b: 'b', lift: true }], embargo: [{ from: 'a', target: 'b', lift: true }] } }));
|
||||
expect(state.trades).toEqual([]);
|
||||
expect(state.embargoes).toEqual([]);
|
||||
});
|
||||
|
||||
it('name refs resolve (id first, then unique case-insensitive name); the pair is stored sorted by id', () => {
|
||||
const state = mkTurnState(
|
||||
{ a: { ...mkNation('a', NS(50, 60, 50, 50), ['ra1']), name: 'Alpha' }, b: mkNation('b', NS(50, 70, 50, 50), ['rb1']) },
|
||||
{ ra1: RS(50, 60, 50, 50), rb1: RS(50, 70, 50, 50) },
|
||||
);
|
||||
const next = applyTurnResult(state, mkTurnResult({ economy: { trade: [{ a: 'alpha', b: 'B' }], embargo: [] } }));
|
||||
expect(next.trades).toEqual([{ a: 'a', b: 'b' }]);
|
||||
});
|
||||
|
||||
it('unknown, ambiguous, and self-pairing refs are dropped (never throws)', () => {
|
||||
const dupA: Nation = { ...mkNation('a1', NS(50, 50, 50, 50), ['ra1']), name: 'DUP' };
|
||||
const dupB: Nation = { ...mkNation('a2', NS(50, 50, 50, 50), ['rb1']), name: 'DUP' };
|
||||
const c = mkNation('c', NS(50, 50, 50, 50), ['rc1']);
|
||||
const state = mkTurnState(
|
||||
{ a1: dupA, a2: dupB, c },
|
||||
{ ra1: RS(50, 50, 50, 50), rb1: RS(50, 50, 50, 50), rc1: RS(50, 50, 50, 50) },
|
||||
);
|
||||
const next = applyTurnResult(state, mkTurnResult({
|
||||
economy: {
|
||||
trade: [
|
||||
{ a: 'zzz', b: 'c' }, // unknown
|
||||
{ a: 'c', b: 'c' }, // self
|
||||
{ a: 'DUP', b: 'c' }, // ambiguous name
|
||||
],
|
||||
embargo: [{ from: 'zzz', target: 'c' }, { from: 'c', target: 'c' }, { from: 'DUP', target: 'c' }],
|
||||
},
|
||||
}));
|
||||
expect(next.trades).toEqual([]);
|
||||
expect(next.embargoes).toEqual([]);
|
||||
});
|
||||
|
||||
it('auto-suspension: an existing trade is removed when its two nations go to war this turn', () => {
|
||||
const state = warState({ a: warN('a'), b: warN('b') }, []);
|
||||
state.trades = [{ a: 'a', b: 'b' }];
|
||||
const next = applyTurnResult(state, mkTurnResult({ wars: { declare: [{ sides: ['a', 'b'] }], end: [] } }));
|
||||
expect(next.wars).toHaveLength(1);
|
||||
expect(next.wars[0].status).toBe('active');
|
||||
expect(next.trades).toEqual([]); // the war severs the economic link
|
||||
});
|
||||
|
||||
it('a trade declared this turn between two nations that also go to war this turn is dropped', () => {
|
||||
const state = warState({ a: warN('a'), b: warN('b') }, []);
|
||||
const next = applyTurnResult(state, mkTurnResult({
|
||||
wars: { declare: [{ sides: ['a', 'b'] }], end: [] },
|
||||
economy: { trade: [{ a: 'a', b: 'b' }], embargo: [] },
|
||||
}));
|
||||
expect(next.wars).toHaveLength(1);
|
||||
expect(next.trades).toEqual([]);
|
||||
});
|
||||
|
||||
it('a trade/embargo between a belligerent and a non-belligerent survives the war declaration', () => {
|
||||
const state = warState({ a: warN('a'), b: warN('b'), c: warN('c') }, []);
|
||||
state.trades = [{ a: 'a', b: 'c' }];
|
||||
state.embargoes = [{ from: 'a', target: 'c' }];
|
||||
const next = applyTurnResult(state, mkTurnResult({ wars: { declare: [{ sides: ['a', 'b'] }], end: [] } }));
|
||||
expect(next.trades).toEqual([{ a: 'a', b: 'c' }]);
|
||||
expect(next.embargoes).toEqual([{ from: 'a', target: 'c' }]);
|
||||
});
|
||||
|
||||
it('an ended (armistice) war does not suspend a trade', () => {
|
||||
const state = warState(
|
||||
{ a: warN('a'), b: warN('b') },
|
||||
[{ id: 'w1', sides: ['a', 'b'], declaredTurn: 1, status: 'armistice' }],
|
||||
);
|
||||
state.trades = [{ a: 'a', b: 'b' }];
|
||||
const next = applyTurnResult(state, mkTurnResult({}));
|
||||
expect(next.trades).toEqual([{ a: 'a', b: 'b' }]);
|
||||
});
|
||||
});
|
||||
|
||||
describe('isMeaningful — economy (v0.35.0 #58)', () => {
|
||||
it('a trade-only or embargo-only turn is meaningful; an empty economy is not', () => {
|
||||
expect(isMeaningful(mkTurnResult({ economy: { trade: [{ a: 'a', b: 'b' }], embargo: [] } }))).toBe(true);
|
||||
expect(isMeaningful(mkTurnResult({ economy: { trade: [], embargo: [{ from: 'a', target: 'b' }] } }))).toBe(true);
|
||||
expect(isMeaningful(mkTurnResult({}))).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe('buildSystemPrompt — economy contract (v0.35.0 #58)', () => {
|
||||
it('documents the economy field and its constraints in both modes', () => {
|
||||
for (const withTools of [false, true]) {
|
||||
const p = buildSystemPrompt(preset, withTools);
|
||||
expect(p).toContain('"economy": {"trade":');
|
||||
expect(p).toContain('Economy ("economy") is the trade & embargo layer');
|
||||
expect(p).toContain('at most 1-2 operations per turn');
|
||||
expect(p).toContain('suspended automatically by the engine');
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
describe('buildUserPrompt — economy (v0.35.0 #58)', () => {
|
||||
const state = mkTurnState(
|
||||
{ a: mkNation('a', NS(50, 50, 50, 50), ['ra1']), b: mkNation('b', NS(50, 50, 50, 50), ['rb1']) },
|
||||
{ ra1: RS(50, 50, 50, 50), rb1: RS(50, 50, 50, 50) },
|
||||
);
|
||||
|
||||
it('carries the active trades and embargoes when provided', () => {
|
||||
const out = JSON.parse(
|
||||
buildUserPrompt(state, [], undefined, undefined, [], [], undefined, undefined, [{ a: 'a', b: 'b' }], [{ from: 'a', target: 'b' }]),
|
||||
);
|
||||
expect(out.trades).toEqual([{ a: 'a', b: 'b' }]);
|
||||
expect(out.embargoes).toEqual([{ from: 'a', target: 'b' }]);
|
||||
});
|
||||
|
||||
it('omits the trades/embargoes fields entirely when undefined', () => {
|
||||
const out = JSON.parse(buildUserPrompt(state, [], undefined, undefined, [], [], undefined, undefined));
|
||||
expect(out).not.toHaveProperty('trades');
|
||||
expect(out).not.toHaveProperty('embargoes');
|
||||
});
|
||||
});
|
||||
|
||||
describe('stub — economy (v0.35.0 #58)', () => {
|
||||
it('the deterministic stub returns empty economy ops (it never declares a trade or an embargo)', () => {
|
||||
const payload = {
|
||||
state: {
|
||||
turn: 1,
|
||||
date: '1939-09-01',
|
||||
playerNationId: 'uk',
|
||||
nations: {
|
||||
uk: { stats: { military: 50, economy: 50, stability: 50, population: 50 }, territory: ['a'] },
|
||||
de: { stats: { military: 60, economy: 60, stability: 60, population: 60 }, territory: ['b'] },
|
||||
},
|
||||
},
|
||||
pending: [{ kind: 'action' as const, text: 'test' }],
|
||||
};
|
||||
const result = deterministicTurn(payload);
|
||||
expect(result.economy).toEqual({ trade: [], embargo: [] });
|
||||
});
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user