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
+16
View File
@@ -33,6 +33,9 @@ function makeState(id: string): GameState {
},
// (v0.33.0, #56) no wars.
wars: [],
// (v0.35.0, #58) no active trades or embargoes.
trades: [],
embargoes: [],
};
}
@@ -149,6 +152,19 @@ describe('openDb', () => {
db.close();
});
it('(v0.35.0 #58) a legacy row saved before the trades/embargoes fields existed loads with them defaulted to []', () => {
const db = openDb(':memory:');
const legacy = makeState('legacy');
delete (legacy as { trades?: unknown[] }).trades; // a pre-v0.35 save (fields absent)
delete (legacy as { embargoes?: unknown[] }).embargoes;
db.save(legacy);
const loaded = db.load('legacy');
expect(loaded).not.toBeNull();
expect(loaded!.trades).toEqual([]);
expect(loaded!.embargoes).toEqual([]);
db.close();
});
it('delete removes a game by id and reports absence otherwise', () => {
const db = openDb(':memory:');
db.save(makeState('a'));
+4
View File
@@ -53,6 +53,10 @@ export function openDb(filePath: string): Db {
// (any pre-v0.33 `war` relation stays a bare/implicit relation — see
// syncWarRelations, which never force-creates a War entity).
if (state.wars === undefined) state.wars = [];
// (v0.35.0, #58) Same for the trade & embargo economy layer: a
// legacy game has no active trades or embargoes.
if (state.trades === undefined) state.trades = [];
if (state.embargoes === undefined) state.embargoes = [];
// (v0.28, #46) Rows saved before per-region stats existed are migrated
// ONCE at load time (and never re-migrated afterwards — the field is
// then present): each owned region is seeded with a copy of its owner's
+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: [] });
});
});
+239 -1
View File
@@ -4,6 +4,7 @@
// and (4) builds the advisor brainstorm prompt. The network call itself lives in
// llm.ts (injectable) and is invoked by runTurn.
import type {
Embargo,
EventOutcome,
EventType,
GameState,
@@ -15,6 +16,7 @@ import type {
RegionStats,
RoundItem,
StatKey,
TradeAgreement,
War,
} from '../shared/types';
import { EVENT_TYPES, RELATION_STATUSES } from '../shared/types';
@@ -31,6 +33,9 @@ import {
markerId,
MAX_MARKERS,
nationForPrompt,
type EconomyEmbargoOp,
type EconomyOps,
type EconomyTradeOp,
type HybridOp,
type HybridOpType,
type MarkerInfo,
@@ -163,7 +168,9 @@ export function playerFrontier(
* (v0.21, #16 P2): the model may only move regions across a shared border, and
* may verify borders with the tool before answering. The contract also
* documents the `nationOps` proposal queue (v0.23, #16 P4a) — rename plus the
* split/merge shapes applied on player confirm (v0.24, #16 P4b).
* split/merge shapes applied on player confirm (v0.24, #16 P4b), and the
* trade & embargo economy layer (v0.35.0, #58): bilateral trades and
* unilateral embargoes, each ended with a `lift: true` op.
*/
export function buildSystemPrompt(preset: Preset, withNeighboursTool = false): string {
const rules = preset.simulationRules
@@ -216,6 +223,7 @@ export function buildSystemPrompt(preset: Preset, withNeighboursTool = false): s
' "takeovers": [{"nationRef":"<nation id or name>","newRegimeName":"<name of the new regime>"}],',
' "wars": {"declare":[{"sides":["<nationId>","<nationId>"],"aims":["<short aim>"]}],"end":[{"id":"<war id>","outcome":"<armistice|capitulation>","terms":"<short terms>"}]},',
' "hybrid": {"ops":[{"type":"<spy|sabotage|assassinate|cyber|economic|information|proxy>","from":"<nation id or name>","target":"<nation id or name, or region key>","detail":"<short detail>","outcome":"<success|exposed>"}]},',
' "economy": {"trade":[{"a":"<nationId>","b":"<nationId>","lift":<true|false>}],"embargo":[{"from":"<nationId>","target":"<nationId>","lift":<true|false>}]},',
' "markers": {"add":[{"type":"<city|factory|army|port|capital|fleet|rocket|gathering>","name":"<short name>","regionKey":"<region key>"}],"remove":["<marker id>"]}',
'}',
'',
@@ -236,6 +244,7 @@ export function buildSystemPrompt(preset: Preset, withNeighboursTool = false): s
'- Takeover (automatic, NOT player-confirmed): add {"takeovers":[{"nationRef":"<nation id or name>","newRegimeName":"<name of the new regime>"}]} when a low-stability nation (e.g. stability < 30) hosting a "gathering" marker is overthrown in a coup or revolution. This renames the nation to the new regime (e.g. "France" → "Vichy Regime"). Also emit a narrative "event" (political or social, outcome "success") and a stability/economy shock via "stateChanges". A takeover is an automatic in-world regime change — use "nationOps" rename only for a diplomatic/political name change that the player must confirm.',
'- Wars ("wars") are named conflicts, each with a stable id. To DECLARE one, add {"declare":[{"sides":["<id>","<id>"],"aims":[...]}]} with at least 2 distinct belligerents: while it is active the engine AUTOMATICALLY sets both directions of every warring pair\'s relation to "war" and drags in the mutual-defense allies of each belligerent, so do NOT also emit a "war" relation for those pairs (you can never "make peace" between them without ending the war). To END an active war, add {"end":[{"id":"<war id from the user message\'s "wars" field>","outcome":"<armistice|capitulation>"}]} — only active wars can be ended, and when one ends its belligerent pairs fall back to "hostile". Propose at most 1-2 declarations per turn and narrate each declaration and each ending in "events".',
'- Covert / hybrid ops ("hybrid.ops") are non-kinetic operations — spies, sabotage, assassination attempts, cyberattacks, economic pressure, disinformation, and proxy support. Unlike territory they NEVER move borders: a successful op applies a small, deterministic effect (a small dip in the target\'s economy / stability / military for sabotage / assassinate / cyber / economic, or a relation nudge toward hostile for information / proxy), while an "exposed" op backfires (no effect, and the actor\'s relation with the target sours by one step toward hostile). "target" may be a nation id / name or a region key (a region key hits that region\'s sole owner). Keep each detail short, propose at most a few per turn, and narrate each op in "events".',
'- Economy ("economy") is the trade & embargo layer: a bilateral trade (trade: {"a":"..","b":".."}) gives BOTH parties a small, deterministic economy gain every turn, while a unilateral embargo (embargo: {"from":"..","target":".."}) lowers only the TARGET\'s economy every turn. Omit "lift" to declare / impose, and set "lift":true to end or lift an existing one (the user message\'s "trades"/"embargoes" fields list what is currently active). Propose at most 1-2 operations per turn and narrate each one in "events". Trades and embargoes between nations at war are suspended automatically by the engine.',
'- Prefer 1-4 events per turn; keep each text 1-3 sentences.',
'- Keep your internal reasoning brief (a few sentences) and go straight to the JSON result; do not over-deliberate or re-derive facts already present in the state.',
'- Keep every stat value between 0 and 100.',
@@ -253,6 +262,9 @@ export function buildSystemPrompt(preset: Preset, withNeighboursTool = false): s
* other consumer) can read the exact items. The framing instruction tells the
* model to simulate the combined consequences of the whole batch (or to let the
* world advance on its own when the batch is empty) and return one TurnResult.
* (v0.35.0, #58) It also carries the currently active `trades` / `embargoes`
* (omitted entirely when undefined, like the other optional fields) so the
* model can reference them — and `lift` them — in `TurnResult.economy`.
*/
export function buildUserPrompt(
state: GameState,
@@ -295,6 +307,20 @@ export function buildUserPrompt(
* Omitted entirely when undefined.
*/
relations?: Record<string, RelationStatus>,
/**
* (v0.35.0, #58) The currently active bilateral trade agreements (each a
* pair of nation ids). Emitted verbatim (even when empty) so the model
* knows which trades exist and can end one with `economy.trade` +
* `lift: true`. Omitted entirely when undefined.
*/
trades?: TradeAgreement[],
/**
* (v0.35.0, #58) The currently active unilateral embargoes (each an
* `from` → `target` pair of nation ids). Emitted verbatim (even when
* empty) so the model knows which embargoes are in force and can lift one
* with `economy.embargo` + `lift: true`. Omitted entirely when undefined.
*/
embargoes?: Embargo[],
): string {
const player = state.nations[state.playerNationId];
const playerName = player?.name ?? state.playerNationId;
@@ -409,6 +435,8 @@ export function buildUserPrompt(
if (markers !== undefined) payload.markers = markers;
if (wars !== undefined) payload.wars = wars;
if (relations !== undefined) payload.relations = relations;
if (trades !== undefined) payload.trades = trades;
if (embargoes !== undefined) payload.embargoes = embargoes;
return JSON.stringify(payload, null, 2);
}
@@ -549,6 +577,7 @@ export function validateTurnResult(
takeovers: [],
wars: { declare: [], end: [] },
hybrid: { ops: [] },
economy: { trade: [], embargo: [] },
markers: { add: [], remove: [] },
};
@@ -653,6 +682,12 @@ export function validateTurnResult(
// exposed backfire.
result.hybrid = { ops: validateHybridOps(o.hybrid) };
// (v0.35.0, #58) Trade & embargo ops: a no-`lift` op declares / imposes,
// `lift: true` ends / lifts. Refs stay raw here and are resolved at apply
// time against the real state (like wars / hybrid ops); unknown /
// ambiguous / self-pairing ops are dropped there, never thrown.
result.economy = validateEconomyOps(o.economy);
// (v0.27, #44) Map-marker ops: new placements (validated here) and
// removals by stable id. Applied by applyTurnResult after the territory
// moves (a new marker's owner is the region's post-turn owner).
@@ -881,6 +916,52 @@ function validateHybridOpsList(raw: unknown[]): HybridOp[] {
return out;
}
/**
* (v0.35.0, #58) Validate the raw `economy` object of a turn into a safe
* {@link EconomyOps}. Defensive like the rest of the parser — never throws.
* `trade`: each op needs non-empty `a` and `b` refs (a nation's id or unique
* name, capped at 200 chars) and a boolean `lift` (default false = declare);
* ops with a missing ref are dropped — ref resolution and the
* a≠b / from≠target checks happen at apply time against the real state
* (like wars / hybrid ops, whose raw refs are kept here). `embargo`: each op
* needs non-empty `from` and `target` refs and a boolean `lift` (default
* false = impose). A missing / malformed top-level object yields
* `{ trade: [], embargo: [] }`.
*/
export function validateEconomyOps(raw: unknown): EconomyOps {
const out: EconomyOps = { trade: [], embargo: [] };
if (!raw || typeof raw !== 'object' || Array.isArray(raw)) return out;
const o = raw as Record<string, unknown>;
if (Array.isArray(o.trade)) {
for (const e of o.trade) {
if (!e || typeof e !== 'object') continue;
const t = e as Record<string, unknown>;
const a = str(t.a, 200);
const b = str(t.b, 200);
if (!a || !b) continue;
const op: EconomyTradeOp = { a, b };
if (t.lift === true) op.lift = true;
out.trade.push(op);
}
}
if (Array.isArray(o.embargo)) {
for (const e of o.embargo) {
if (!e || typeof e !== 'object') continue;
const t = e as Record<string, unknown>;
const from = str(t.from, 200);
const target = str(t.target, 200);
if (!from || !target) continue;
const op: EconomyEmbargoOp = { from, target };
if (t.lift === true) op.lift = true;
out.embargo.push(op);
}
}
return out;
}
/**
* (v0.33.0, #56) The UNORDERED pairs of belligerents of a set of ACTIVE
* wars: a war with sides [a, b, c] contributes the pairs {a,b}, {a,c},
@@ -1165,6 +1246,128 @@ function applyHybridOp(
else if (op.type === 'proxy') shiftRelationTowardHostile(next, from, target.nation.id, 'rival');
}
// --- Economy ops: trade + embargo (v0.35.0, #58) ------------------------------
/**
* (v0.35.0, #58) The per-turn economic effect of the economy layer, applied
* EVERY committed turn (not just to this turn's declares): each active
* bilateral trade grants both parties a small, deterministic economy gain;
* each active embargo applies a small, deterministic economy dip to the
* target only. Clamped to 0–100. The effect lands on `stats.economy` AND on
* every region the nation owns (`regionStats`), mirroring the hybrid-op
* effect, and each affected nation's derived stats are re-derived afterwards
* (the same 0-region / no-entry guards as `applyHybridOp`). No-op when there
* are no active trades or embargoes. Never throws.
*/
const TRADE_ECONOMY_BONUS = 1;
const EMBARGO_ECONOMY_PENALTY = 1;
export function applyEconomyEffects(
next: GameState,
geoOf?: (key: string) => { area: number; population: number } | undefined,
): void {
if (next.trades.length === 0 && next.embargoes.length === 0) return;
// nation id -> signed economy delta (trades add for both parties, embargoes
// subtract for the target; deltas accumulate for nations hit by several).
const delta = new Map<string, number>();
for (const t of next.trades) {
for (const id of [t.a, t.b]) {
delta.set(id, (delta.get(id) ?? 0) + TRADE_ECONOMY_BONUS);
}
}
for (const e of next.embargoes) {
delta.set(e.target, (delta.get(e.target) ?? 0) - EMBARGO_ECONOMY_PENALTY);
}
const affected = new Set<string>();
for (const [id, d] of delta) {
const n = next.nations[id];
if (!n) continue; // a nation removed this turn (a merge): the delta is dropped
const cur = clampStat(n.stats.economy + d);
if (cur === n.stats.economy) continue; // no change (already at the clamp)
n.stats.economy = cur;
for (const key of n.territory) {
const rs = next.regionStats[key];
if (rs) rs.economy = clampStat(rs.economy + d);
}
affected.add(id);
}
if (affected.size > 0) {
for (const id of affected) {
const n = next.nations[id];
if (n && n.territory.length > 0 && n.territory.some((k) => next.regionStats[k])) {
n.stats = deriveNationStats(n.territory, next.regionStats, geoOf);
}
}
}
}
/**
* (v0.35.0, #58) Apply the turn's economy ops to the active lists on
* `next`, RESOLVING the (possibly name-based) refs against the current
* state: a no-`lift` trade op declares the bilateral agreement (idempotent —
* an already-active pair is not duplicated; the pair is stored sorted, since
* a trade is symmetric); `lift: true` ends it (a no-op when absent). A no-
* `lift` embargo op imposes the unilateral embargo (idempotent — the ordered
* from→target pair is kept as-is); `lift: true` lifts it. An op whose refs
* don't resolve to distinct EXISTING nations is dropped (defensive — never
* throws). Mutates `next` in place.
*/
function applyEconomyOps(next: GameState, ops: EconomyOps): void {
const tradeKey = (a: string, b: string): string => (a < b ? `${a}\u0000${b}` : `${b}\u0000${a}`);
const embargoKey = (f: string, t: string): string => `${f}\u0000${t}`;
for (const op of ops.trade) {
const a = resolveNationRef(next, op.a);
const b = resolveNationRef(next, op.b);
if (!a || !b || a === b) continue; // unknown / ambiguous / self: drop
const key = tradeKey(a, b);
if (op.lift === true) {
next.trades = next.trades.filter((t) => tradeKey(t.a, t.b) !== key);
} else if (!next.trades.some((t) => tradeKey(t.a, t.b) === key)) {
// stored sorted (a < b): a trade is symmetric, so the agreement has a
// canonical shape no matter which way the LLM named the pair.
next.trades.push(a < b ? { a, b } : { a: b, b: a });
}
}
for (const op of ops.embargo) {
const from = resolveNationRef(next, op.from);
const target = resolveNationRef(next, op.target);
if (!from || !target || from === target) continue; // unknown / ambiguous / self: drop
const key = embargoKey(from, target);
if (op.lift === true) {
next.embargoes = next.embargoes.filter((e) => embargoKey(e.from, e.target) !== key);
} else if (!next.embargoes.some((e) => embargoKey(e.from, e.target) === key)) {
next.embargoes.push({ from, target });
}
}
}
/**
* (v0.35.0, #58) Auto-suspend the economy layer when war breaks out: any
* active trade or embargo whose two nations are on OPPOSITE sides of an
* active war (v0.33, #56) is removed — a war severs the economic link, so
* the suspended agreement stops producing its per-turn effect from now on.
* A trade/embargo between two non-belligerents, or between two nations on
* the SAME side, is untouched. Mutates `next` in place; idempotent. Must be
* called AFTER the war declare/end ops of the turn (so it sees the final
* `next.wars`). Never throws.
*/
function suspendEconomyOnWar(next: GameState): void {
if (next.trades.length === 0 && next.embargoes.length === 0) return;
const warring = activeWarPairs(next.wars);
const atWarNow = (x: string, y: string): boolean => {
if (x === y) return false;
const key = x < y ? `${x}\u0000${y}` : `${y}\u0000${x}`;
return warring.has(key);
};
next.trades = next.trades.filter((t) => !atWarNow(t.a, t.b));
next.embargoes = next.embargoes.filter((e) => !atWarNow(e.from, e.target));
}
/**
* A turn is "meaningful" if it produced at least an event, a stat change, a
* no-man's-land release (v0.22 #16 P3: a release is a real world change even
@@ -1190,6 +1393,10 @@ export function isMeaningful(result: TurnResult): boolean {
// (v0.34.0, #57) a hybrid op (a successful one or an exposed backfire) is
// a real world change (a stat dip or a soured relation).
result.hybrid.ops.length > 0 ||
// (v0.35.0, #58) a trade declared/ended or an embargo imposed/lifted this
// turn is a real world change (the active economy lists moved).
result.economy.trade.length > 0 ||
result.economy.embargo.length > 0 ||
// (v0.27, #44) a marker placed or removed this turn is a real world change.
result.markers.add.length > 0 ||
result.markers.remove.length > 0
@@ -1546,6 +1753,32 @@ export function applyTurnResult(
applyHybridOp(next, op, geoOf, validKeys, regionNameOf, actingTurn);
}
// --- Trade & embargo economy layer (v0.35.0, #58) ---
//
// Placed AFTER the wars block (so the auto-suspension sees the FINAL
// next.wars, including wars declared this turn) and before markers /
// nationOps (which never touch the economy lists). Order:
// (1) APPLY OPS: the turn's declare/lift ops mutate the active
// GameState.trades / GameState.embargoes lists (a declare is
// idempotent; refs resolved against the current state, unknown /
// ambiguous / self-pairing ops dropped).
// (2) WAR AUTO-SUSPENSION: any active trade or embargo whose two
// nations are on opposite sides of an active war is removed —
// a war severs the economic link, so the suspended agreement
// stops producing its per-turn effect from now on (this also
// drops a trade declared this turn between two nations that just
// went to war).
// (3) PER-TURN EFFECT: the FULL active lists (not just this turn's
// declares) each apply their deterministic effect this turn —
// trade: both parties' economy up a little; embargo: the
// target's economy down a little — clamped, mirrored to the
// owning nations' regions, and re-derived like the hybrid dip.
// Additive only: declares add, lifts remove, and nothing else (not even
// a war) mutates the lists besides the suspension above.
applyEconomyOps(next, result.economy);
suspendEconomyOnWar(next);
applyEconomyEffects(next, geoOf);
// --- Map markers (v0.27, #44) ---
//
// Markers are anchored to a region key and are processed against the FINAL
@@ -2058,6 +2291,10 @@ export async function runTurn(opts: {
markers,
wars,
relations,
// (v0.35.0, #58) the active trade / embargo lists, so the model can
// reference — and `lift` — what is currently in force.
state.trades,
state.embargoes,
);
const nudge =
'Your previous output was invalid. Respond with ONLY the JSON object described above for this batch — no prose, no fences.';
@@ -2113,6 +2350,7 @@ export async function runTurn(opts: {
takeovers: [],
wars: { declare: [], end: [] },
hybrid: { ops: [] },
economy: { trade: [], embargo: [] },
markers: { add: [], remove: [] },
};
}
+4
View File
@@ -182,6 +182,10 @@ app.post('/api/games', (req: Request, res) => {
// (any preset-level `war` relation stays a bare/implicit one, not an
// entity — see syncWarRelations).
wars: [],
// (v0.35.0, #58) No active trades or embargoes on a fresh game; the LLM
// declares (and lifts) them over time via TurnResult.economy.
trades: [],
embargoes: [],
};
// v0.19 (#16 P1): expand each nation's ISO_A3 country list to the region keys
// of all its sub-regions, so ownership (and the choropleth) is at region
+3
View File
@@ -54,6 +54,9 @@ const state: GameState = {
regionStats: {},
// (v0.33.0, #56) the map is ownership-only; wars are unused here.
wars: [],
// (v0.35.0, #58) the map is ownership-only; trades/embargoes are unused here.
trades: [],
embargoes: [],
};
// The RegionMap built from the fixture: the authoritative region key → owner source.
+2
View File
@@ -42,6 +42,8 @@ function mkState(
markers: [],
regionStats: {} as GameState['regionStats'],
wars: [],
trades: [],
embargoes: [],
};
if (regionStats !== undefined) base.regionStats = regionStats as GameState['regionStats'];
else delete (base as { regionStats?: unknown }).regionStats; // simulate a legacy row
+3
View File
@@ -111,6 +111,9 @@ describe('regions (region-key loader, v0.19 #16 P1)', () => {
// (v0.28, #46) unused here (allTerritoryKeys is ownership-only).
regionStats: {},
wars: [],
// (v0.35.0, #58) unused here (allTerritoryKeys is ownership-only).
trades: [],
embargoes: [],
};
// 16 GBR sub-regions + 42 FRA sub-regions, no overlap between the two.
expect(allTerritoryKeys(state).size).toBe(58);
+21
View File
@@ -52,6 +52,21 @@ export interface StubPayload {
* deterministicTurn), so the hybrid path is covered by unit tests only.
*/
relations?: Record<string, RelationStatus>;
/**
* (v0.35.0, #58) The currently active bilateral trade agreements
* (`{ a, b }` nation-id pairs), emitted by the engine's user prompt. The
* stub ignores this field — it never declares, lifts or suspends a trade
* (see deterministicTurn), so the trade path is covered by unit tests only.
*/
trades?: Array<{ a: string; b: string }>;
/**
* (v0.35.0, #58) The currently active unilateral embargoes
* (`{ from, target }` nation-id pairs), emitted by the engine's user
* prompt. The stub ignores this field — it never imposes, lifts or
* suspends an embargo (see deterministicTurn), so the embargo path is
* covered by unit tests only.
*/
embargoes?: Array<{ from: string; target: string }>;
}
function truncate(s: string, n: number): string {
@@ -103,6 +118,11 @@ function truncate(s: string, n: number): string {
* (v0.34.0, #57) The stub likewise NEVER runs a hybrid / covert op (it always
* returns `hybrid: { ops: [] }`) — the hybrid-war path is covered by unit
* tests only, keeping the e2e assertions untouched.
*
* (v0.35.0, #58) The stub likewise NEVER declares, lifts or suspends a trade
* or an embargo (it always returns `economy: { trade: [], embargo: [] }`) —
* the trade & embargo economy path is covered by unit tests only, keeping
* the e2e assertions untouched.
*/
export function deterministicTurn(p: StubPayload): TurnResult {
const state = p.state;
@@ -172,6 +192,7 @@ export function deterministicTurn(p: StubPayload): TurnResult {
takeovers: [], // the stub never performs a takeover (see doc comment)
wars: { declare: [], end: [] }, // the stub never declares/ends wars (see doc comment)
hybrid: { ops: [] }, // the stub never runs hybrid/covert ops (see doc comment)
economy: { trade: [], embargo: [] }, // the stub never touches trades/embargoes (see doc comment)
markers: { add: [], remove: [] }, // the stub never touches markers (see doc comment)
};
}