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

This commit is contained in:
2026-10-15 17:26:24 +00:00
parent ed91735c8a
commit b5ea8ba5a4
15 changed files with 776 additions and 23 deletions
+311 -20
View File
@@ -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: [] });
});
});