feat(#57): hybrid war — TurnResult.hybrid ops (spy/sabotage/assassinate/cyber/economic/information/proxy) with exposure backfire
CI / build + unit + e2e (pull_request) Successful in 2m7s
CI / build + unit + e2e (pull_request) Successful in 2m7s
This commit is contained in:
+425
-20
@@ -6,6 +6,7 @@ import {
|
||||
markerId,
|
||||
MAX_MARKERS,
|
||||
seedRegionStats,
|
||||
type HybridOp,
|
||||
type MarkerInfo,
|
||||
type MarkerSpec,
|
||||
type NationOp,
|
||||
@@ -24,6 +25,8 @@ import {
|
||||
freshNationId,
|
||||
freshWarId,
|
||||
GET_NEIGHBOURS_TOOL,
|
||||
HYBRID_STAT_EFFECT,
|
||||
isHybridOpType,
|
||||
isMeaningful,
|
||||
makeNeighboursExecutor,
|
||||
NEW_NATION_STATS,
|
||||
@@ -35,7 +38,9 @@ import {
|
||||
resolveNationRef,
|
||||
resolveRegionKey,
|
||||
runTurn,
|
||||
shiftRelationTowardHostile,
|
||||
syncWarRelations,
|
||||
validateHybridOps,
|
||||
validateMarkerOps,
|
||||
validateNationOps,
|
||||
validateRegionChanges,
|
||||
@@ -550,7 +555,7 @@ describe('validateNationOps (v0.23 #16 P4a)', () => {
|
||||
relations: [],
|
||||
territoryChanges: [],
|
||||
unowned: [],
|
||||
nationOps: [{ kind: 'rename', nationRef: 'France', to: 'French Republic' }], takeovers: [], wars: { declare: [], end: [] }, markers: { add: [], remove: [] },
|
||||
nationOps: [{ kind: 'rename', nationRef: 'France', to: 'French Republic' }], takeovers: [], wars: { declare: [], end: [] }, hybrid: { ops: [] }, markers: { add: [], remove: [] },
|
||||
});
|
||||
expect(withOps).not.toBeNull();
|
||||
expect(withOps!.nationOps).toEqual([{ kind: 'rename', nationRef: 'France', to: 'French Republic' }]);
|
||||
@@ -572,7 +577,7 @@ describe('applyTurnResult (nation-op proposals, v0.23 #16 P4a)', () => {
|
||||
relations: [],
|
||||
territoryChanges: [],
|
||||
unowned: [],
|
||||
nationOps: [{ kind: 'rename', nationRef: 'de', to: 'Reich' }], takeovers: [], wars: { declare: [], end: [] }, markers: { add: [], remove: [] },
|
||||
nationOps: [{ kind: 'rename', nationRef: 'de', to: 'Reich' }], takeovers: [], wars: { declare: [], end: [] }, hybrid: { ops: [] }, markers: { add: [], remove: [] },
|
||||
});
|
||||
expect(next.nations.de.name).toBe('DE'); // still the old name
|
||||
expect(next.pendingNationOps).toEqual([{ kind: 'rename', nationRef: 'de', to: 'Reich' }]);
|
||||
@@ -593,7 +598,7 @@ describe('applyTurnResult (nation-op proposals, v0.23 #16 P4a)', () => {
|
||||
relations: [],
|
||||
territoryChanges: [],
|
||||
unowned: [],
|
||||
nationOps: [{ kind: 'rename', nationRef: 'us', to: 'USA' }], takeovers: [], wars: { declare: [], end: [] }, markers: { add: [], remove: [] }, // new proposal this turn
|
||||
nationOps: [{ kind: 'rename', nationRef: 'us', to: 'USA' }], takeovers: [], wars: { declare: [], end: [] }, hybrid: { ops: [] }, markers: { add: [], remove: [] }, // new proposal this turn
|
||||
});
|
||||
expect(next.pendingNationOps).toEqual([
|
||||
{ kind: 'rename', nationRef: 'de', to: 'Reich' }, // the old one survived
|
||||
@@ -612,7 +617,7 @@ describe('applyTurnResult (nation-op proposals, v0.23 #16 P4a)', () => {
|
||||
relations: [],
|
||||
territoryChanges: [],
|
||||
unowned: [],
|
||||
nationOps: [{ kind: 'rename', nationRef: 'de', to: 'Reich' }], takeovers: [], wars: { declare: [], end: [] }, markers: { add: [], remove: [] },
|
||||
nationOps: [{ kind: 'rename', nationRef: 'de', to: 'Reich' }], takeovers: [], wars: { declare: [], end: [] }, hybrid: { ops: [] }, markers: { add: [], remove: [] },
|
||||
});
|
||||
expect(next.pendingNationOps).toEqual([{ kind: 'rename', nationRef: 'de', to: 'Reich' }]);
|
||||
});
|
||||
@@ -1069,7 +1074,7 @@ describe('applyTurnResult', () => {
|
||||
relations: [],
|
||||
territoryChanges: [],
|
||||
unowned: [],
|
||||
nationOps: [], takeovers: [], wars: { declare: [], end: [] }, markers: { add: [], remove: [] },
|
||||
nationOps: [], takeovers: [], wars: { declare: [], end: [] }, hybrid: { ops: [] }, markers: { add: [], remove: [] },
|
||||
};
|
||||
applyTurnResult(state, result);
|
||||
expect(JSON.stringify(state)).toBe(snapshot);
|
||||
@@ -1086,7 +1091,7 @@ describe('applyTurnResult', () => {
|
||||
relations: [],
|
||||
territoryChanges: [],
|
||||
unowned: [],
|
||||
nationOps: [], takeovers: [], wars: { declare: [], end: [] }, markers: { add: [], remove: [] },
|
||||
nationOps: [], takeovers: [], wars: { declare: [], end: [] }, hybrid: { ops: [] }, markers: { add: [], remove: [] },
|
||||
};
|
||||
const next = applyTurnResult(state, result);
|
||||
expect(next.pending).toEqual([]);
|
||||
@@ -1109,7 +1114,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: [] }, markers: { add: [], remove: [] },
|
||||
nationOps: [], takeovers: [], wars: { declare: [], end: [] }, hybrid: { ops: [] }, 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
|
||||
@@ -1135,7 +1140,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: [] }, markers: { add: [], remove: [] },
|
||||
nationOps: [], takeovers: [], wars: { declare: [], end: [] }, hybrid: { ops: [] }, markers: { add: [], remove: [] },
|
||||
};
|
||||
const next = applyTurnResult(state, result); // no validKeys -> nothing filtered
|
||||
expect(next.nations.de.territory).toContain('wales-bbbb');
|
||||
@@ -1153,7 +1158,7 @@ describe('applyTurnResult (sole-ownership invariants, v0.21 #16 P2)', () => {
|
||||
regionChanges: [],
|
||||
relations: [],
|
||||
unowned: [],
|
||||
nationOps: [], takeovers: [], wars: { declare: [], end: [] }, markers: { add: [], remove: [] },
|
||||
nationOps: [], takeovers: [], wars: { declare: [], end: [] }, hybrid: { ops: [] }, markers: { add: [], remove: [] },
|
||||
};
|
||||
|
||||
it('a double claim (two different gainers, one key) is rejected: the key stays with its original owner', () => {
|
||||
@@ -1243,7 +1248,7 @@ describe('applyTurnResult (no man\'s land, v0.22 #16 P3)', () => {
|
||||
relations: [],
|
||||
territoryChanges: [],
|
||||
unowned: [],
|
||||
nationOps: [], takeovers: [], wars: { declare: [], end: [] }, markers: { add: [], remove: [] },
|
||||
nationOps: [], takeovers: [], wars: { declare: [], end: [] }, hybrid: { ops: [] }, markers: { add: [], remove: [] },
|
||||
};
|
||||
|
||||
it('a release removes the key from its sole owner and marks it in GameState.unowned', () => {
|
||||
@@ -1358,7 +1363,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: [] }, markers: { add: [], remove: [] } }),
|
||||
isMeaningful({ date: 'x', events: [], stateChanges: [], regionChanges: [], relations: [], territoryChanges: [], unowned: ['GBR'], nationOps: [], takeovers: [], wars: { declare: [], end: [] }, hybrid: { ops: [] }, markers: { add: [], remove: [] } }),
|
||||
).toBe(true);
|
||||
});
|
||||
|
||||
@@ -1665,9 +1670,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: [] }, 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: [] }, markers: { add: [], remove: [] } })).toBe(true);
|
||||
expect(isMeaningful({ date: 'x', events: [], stateChanges: [], regionChanges: [], relations: [], territoryChanges: [], unowned: [], nationOps: [], takeovers: [], wars: { declare: [], end: [] }, 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: [] }, 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);
|
||||
});
|
||||
|
||||
it('(v0.23 #16 P4a) a turn that only proposes a nation-op is meaningful (it survives the retry/nudge path)', () => {
|
||||
@@ -1680,7 +1685,7 @@ describe('isMeaningful', () => {
|
||||
relations: [],
|
||||
territoryChanges: [],
|
||||
unowned: [],
|
||||
nationOps: [{ kind: 'rename', nationRef: 'de', to: 'Reich' }], takeovers: [], wars: { declare: [], end: [] }, markers: { add: [], remove: [] },
|
||||
nationOps: [{ kind: 'rename', nationRef: 'de', to: 'Reich' }], takeovers: [], wars: { declare: [], end: [] }, hybrid: { ops: [] }, markers: { add: [], remove: [] },
|
||||
}),
|
||||
).toBe(true);
|
||||
});
|
||||
@@ -1698,6 +1703,7 @@ describe('isMeaningful', () => {
|
||||
nationOps: [],
|
||||
takeovers: [],
|
||||
wars: { declare: [], end: [] },
|
||||
hybrid: { ops: [] },
|
||||
markers: { add: [{ type: 'city', name: 'Lyon', regionKey: 'rhone-rhne' }], remove: [] },
|
||||
}),
|
||||
).toBe(true);
|
||||
@@ -1716,6 +1722,7 @@ describe('isMeaningful', () => {
|
||||
nationOps: [],
|
||||
takeovers: [{ nationRef: 'de', newRegimeName: 'The Reich' }],
|
||||
wars: { declare: [], end: [] },
|
||||
hybrid: { ops: [] },
|
||||
markers: { add: [], remove: [] },
|
||||
}),
|
||||
).toBe(true);
|
||||
@@ -1736,6 +1743,7 @@ describe('takeovers (regime change, v0.32.0 #54)', () => {
|
||||
nationOps: [],
|
||||
takeovers: [{ nationRef: 'de', newRegimeName: 'The Reich' }],
|
||||
wars: { declare: [], end: [] },
|
||||
hybrid: { ops: [] },
|
||||
markers: { add: [], remove: [] },
|
||||
};
|
||||
const next = applyTurnResult(state, result);
|
||||
@@ -1766,6 +1774,7 @@ describe('takeovers (regime change, v0.32.0 #54)', () => {
|
||||
nationOps: [],
|
||||
takeovers: [{ nationRef: 'uk', newRegimeName: 'The Commonwealth' }],
|
||||
wars: { declare: [], end: [] },
|
||||
hybrid: { ops: [] },
|
||||
markers: { add: [], remove: [] },
|
||||
};
|
||||
const next = applyTurnResult(state, result);
|
||||
@@ -1787,6 +1796,7 @@ describe('takeovers (regime change, v0.32.0 #54)', () => {
|
||||
nationOps: [],
|
||||
takeovers: [{ nationRef: 'nonexistent', newRegimeName: 'New Name' }],
|
||||
wars: { declare: [], end: [] },
|
||||
hybrid: { ops: [] },
|
||||
markers: { add: [], remove: [] },
|
||||
};
|
||||
const next = applyTurnResult(state, result);
|
||||
@@ -1811,6 +1821,7 @@ describe('takeovers (regime change, v0.32.0 #54)', () => {
|
||||
nationOps: [],
|
||||
takeovers: [{ nationRef: 'UK', newRegimeName: 'The Empire' }],
|
||||
wars: { declare: [], end: [] },
|
||||
hybrid: { ops: [] },
|
||||
markers: { add: [], remove: [] },
|
||||
};
|
||||
const next = applyTurnResult(state, result);
|
||||
@@ -1834,6 +1845,7 @@ describe('takeovers (regime change, v0.32.0 #54)', () => {
|
||||
nationOps: [],
|
||||
takeovers: [{ nationRef: 'de', newRegimeName: ' ' }],
|
||||
wars: { declare: [], end: [] },
|
||||
hybrid: { ops: [] },
|
||||
markers: { add: [], remove: [] },
|
||||
});
|
||||
expect(validated!.takeovers).toEqual([]);
|
||||
@@ -1857,6 +1869,7 @@ describe('takeovers (regime change, v0.32.0 #54)', () => {
|
||||
{ nationRef: 'de', newRegimeName: 'The Reich' },
|
||||
],
|
||||
wars: { declare: [], end: [] },
|
||||
hybrid: { ops: [] },
|
||||
markers: { add: [], remove: [] },
|
||||
};
|
||||
const next = applyTurnResult(state, result);
|
||||
@@ -1878,6 +1891,7 @@ describe('takeovers (regime change, v0.32.0 #54)', () => {
|
||||
nationOps: [],
|
||||
takeovers: [{ nationRef: 'de', newRegimeName: 'The Reich' }],
|
||||
wars: { declare: [], end: [] },
|
||||
hybrid: { ops: [] },
|
||||
markers: { add: [], remove: [] },
|
||||
};
|
||||
const next = applyTurnResult(state, result);
|
||||
@@ -1897,6 +1911,7 @@ describe('takeovers (regime change, v0.32.0 #54)', () => {
|
||||
nationOps: [],
|
||||
takeovers: [{ nationRef: 'De', newRegimeName: 'The Reich' }], // 'De' → resolves to 'de' by name
|
||||
wars: { declare: [], end: [] },
|
||||
hybrid: { ops: [] },
|
||||
markers: { add: [], remove: [] },
|
||||
};
|
||||
const next = applyTurnResult(state, result);
|
||||
@@ -1928,6 +1943,7 @@ describe('markers (point placement, v0.27 #44)', () => {
|
||||
nationOps: [],
|
||||
takeovers: [],
|
||||
wars: { declare: [], end: [] },
|
||||
hybrid: { ops: [] },
|
||||
markers: { add: [mk('city', 'Strasbourg', 'moselle-rygy')], remove: [] },
|
||||
};
|
||||
const next = applyTurnResult(state, result, undefined, centroidOf);
|
||||
@@ -1952,13 +1968,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: [] }, markers: { add, remove: [] },
|
||||
unowned: [], nationOps: [], takeovers: [], wars: { declare: [], end: [] }, hybrid: { ops: [] }, 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: [] }, markers: { add, remove: [] },
|
||||
unowned: [], nationOps: [], takeovers: [], wars: { declare: [], end: [] }, hybrid: { ops: [] }, 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
|
||||
@@ -1982,6 +1998,7 @@ describe('markers (point placement, v0.27 #44)', () => {
|
||||
nationOps: [],
|
||||
takeovers: [],
|
||||
wars: { declare: [], end: [] },
|
||||
hybrid: { ops: [] },
|
||||
markers: {
|
||||
add: [
|
||||
{ type: 'city', name: 'Good', regionKey: 'moselle-rygy' }, // valid + centroid → kept
|
||||
@@ -2026,6 +2043,7 @@ describe('markers (point placement, v0.27 #44)', () => {
|
||||
nationOps: [],
|
||||
takeovers: [],
|
||||
wars: { declare: [], end: [] },
|
||||
hybrid: { ops: [] },
|
||||
markers: {
|
||||
add: [
|
||||
{ type: 'city', name: 'NewA', regionKey: 'new-a' },
|
||||
@@ -2051,7 +2069,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: [] }, markers: { add: [], remove: [idA, 'zzzz-not-there'] },
|
||||
unowned: [], nationOps: [], takeovers: [], wars: { declare: [], end: [] }, hybrid: { ops: [] }, 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
|
||||
@@ -2067,14 +2085,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: [] }, markers: { add: [], remove: [] },
|
||||
unowned: ['moselle-rygy'], nationOps: [], takeovers: [], wars: { declare: [], end: [] }, hybrid: { ops: [] }, 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: [] }, markers: { add: [], remove: [] },
|
||||
unowned: [], nationOps: [], takeovers: [], wars: { declare: [], end: [] }, hybrid: { ops: [] }, markers: { add: [], remove: [] },
|
||||
}, undefined, centroidOf);
|
||||
expect(reclaimed.markers[0].ownerNationId).toBe('de');
|
||||
});
|
||||
@@ -2141,6 +2159,7 @@ describe('markers (point placement, v0.27 #44)', () => {
|
||||
unowned: [],
|
||||
nationOps: [],
|
||||
wars: { declare: [], end: [] },
|
||||
hybrid: { ops: [] },
|
||||
markers: { add: [{ type: 'city', name: 'Strasbourg', regionKey: 'moselle-rygy' }], remove: [] },
|
||||
});
|
||||
const { state } = await runTurn({
|
||||
@@ -2226,6 +2245,7 @@ function mkTurnResult(partial: Partial<TurnResult>): TurnResult {
|
||||
nationOps: partial.nationOps ?? [],
|
||||
takeovers: partial.takeovers ?? [],
|
||||
wars: partial.wars ?? { declare: [], end: [] },
|
||||
hybrid: partial.hybrid ?? { ops: [] },
|
||||
markers: partial.markers ?? { add: [], remove: [] },
|
||||
};
|
||||
}
|
||||
@@ -2948,6 +2968,7 @@ describe('validateTakeovers (v0.32.0 #54)', () => {
|
||||
nationOps: [],
|
||||
takeovers: [{ nationRef: 'de', newRegimeName: 'The Reich' }, { nationRef: 'bad', newRegimeName: '' }],
|
||||
wars: { declare: [], end: [] },
|
||||
hybrid: { ops: [] },
|
||||
markers: { add: [], remove: [] },
|
||||
});
|
||||
expect(withTakeovers).not.toBeNull();
|
||||
@@ -3333,6 +3354,7 @@ describe('isMeaningful — wars (v0.33.0 #56)', () => {
|
||||
nationOps: [],
|
||||
takeovers: [],
|
||||
wars: { declare: [{ sides: ['a', 'b'] }], end: [] },
|
||||
hybrid: { ops: [] },
|
||||
markers: { add: [], remove: [] },
|
||||
}),
|
||||
).toBe(true);
|
||||
@@ -3351,6 +3373,7 @@ describe('isMeaningful — wars (v0.33.0 #56)', () => {
|
||||
nationOps: [],
|
||||
takeovers: [],
|
||||
wars: { declare: [], end: [{ id: 'w1', outcome: 'armistice' }] },
|
||||
hybrid: { ops: [] },
|
||||
markers: { add: [], remove: [] },
|
||||
}),
|
||||
).toBe(true);
|
||||
@@ -3369,6 +3392,7 @@ describe('isMeaningful — wars (v0.33.0 #56)', () => {
|
||||
nationOps: [],
|
||||
takeovers: [],
|
||||
wars: { declare: [], end: [] },
|
||||
hybrid: { ops: [] },
|
||||
markers: { add: [], remove: [] },
|
||||
}),
|
||||
).toBe(false);
|
||||
@@ -3434,3 +3458,384 @@ describe('stub — wars (v0.33.0 #56)', () => {
|
||||
expect(result.wars).toEqual({ declare: [], end: [] });
|
||||
});
|
||||
});
|
||||
|
||||
// --- Hybrid war (v0.34.0, #57) ---------------------------------------------
|
||||
|
||||
/**
|
||||
* Build a two-nation state for the hybrid tests: `a` (the actor) owns region
|
||||
* `ra1`, `b` (the target) owns region `rb1`, both with uniform 50 stats and the
|
||||
* given relations. Returns the state plus the two region keys.
|
||||
*/
|
||||
function hybState(
|
||||
rels: { a?: Record<string, RelationStatus>; b?: Record<string, RelationStatus> },
|
||||
): GameState {
|
||||
const a = mkNation('a', NS(50, 50, 50, 50), ['ra1']);
|
||||
const b = mkNation('b', NS(50, 50, 50, 50), ['rb1']);
|
||||
a.relations = rels.a ?? {};
|
||||
b.relations = rels.b ?? {};
|
||||
return mkTurnState({ a, b }, { ra1: RS(50, 50, 50, 50), rb1: RS(50, 50, 50, 50) }, []);
|
||||
}
|
||||
|
||||
describe('isHybridOpType (v0.34.0 #57)', () => {
|
||||
it('accepts all seven types and rejects anything else', () => {
|
||||
for (const t of ['spy', 'sabotage', 'assassinate', 'cyber', 'economic', 'information', 'proxy']) {
|
||||
expect(isHybridOpType(t)).toBe(true);
|
||||
}
|
||||
for (const t of ['bogus', 'SPY', '', null, 5, undefined]) {
|
||||
expect(isHybridOpType(t)).toBe(false);
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
describe('validateHybridOps (v0.34.0 #57)', () => {
|
||||
it('non-object / null / array-of-wrongs input yields []', () => {
|
||||
expect(validateHybridOps(undefined)).toEqual([]);
|
||||
expect(validateHybridOps(null)).toEqual([]);
|
||||
expect(validateHybridOps('x')).toEqual([]);
|
||||
expect(validateHybridOps(5)).toEqual([]);
|
||||
expect(validateHybridOps([])).toEqual([]); // a bare [] of "ops" — empty
|
||||
});
|
||||
|
||||
it('accepts BOTH the { ops: [...] } object and a bare array', () => {
|
||||
const op = { type: 'spy', from: 'a', target: 'b' };
|
||||
expect(validateHybridOps({ ops: [op] })).toEqual([op]);
|
||||
expect(validateHybridOps([op])).toEqual([op]);
|
||||
});
|
||||
|
||||
it('keeps a valid op, trims/caps from & target, and drops outcome:success', () => {
|
||||
const out = validateHybridOps([
|
||||
{ type: 'sabotage', from: ' a ', target: ' b ', detail: ' blew up a plant ' },
|
||||
]);
|
||||
expect(out).toEqual([{ type: 'sabotage', from: 'a', target: 'b', detail: 'blew up a plant' }]);
|
||||
});
|
||||
|
||||
it('stores outcome:exposed but omits the default success', () => {
|
||||
const out = validateHybridOps([
|
||||
{ type: 'spy', from: 'a', target: 'b', outcome: 'exposed' },
|
||||
{ type: 'spy', from: 'a', target: 'b', outcome: 'success' },
|
||||
{ type: 'spy', from: 'a', target: 'b' },
|
||||
]);
|
||||
expect(out).toEqual([
|
||||
{ type: 'spy', from: 'a', target: 'b', outcome: 'exposed' },
|
||||
{ type: 'spy', from: 'a', target: 'b' },
|
||||
{ type: 'spy', from: 'a', target: 'b' },
|
||||
]);
|
||||
});
|
||||
|
||||
it('drops an unknown type, and empty from or target', () => {
|
||||
const out = validateHybridOps([
|
||||
{ type: 'bogus', from: 'a', target: 'b' },
|
||||
{ type: 'spy', from: ' ', target: 'b' },
|
||||
{ type: 'spy', from: 'a', target: '' },
|
||||
{ type: 'spy', from: 'a' }, // missing target
|
||||
]);
|
||||
expect(out).toEqual([]);
|
||||
});
|
||||
|
||||
it('caps from/target at 200 chars and detail at 300', () => {
|
||||
const out = validateHybridOps([
|
||||
{ type: 'spy', from: 'a'.repeat(250), target: 'b'.repeat(250), detail: 'd'.repeat(350) },
|
||||
]);
|
||||
expect(out).toHaveLength(1);
|
||||
expect(out[0]!.from).toHaveLength(200);
|
||||
expect(out[0]!.target).toHaveLength(200);
|
||||
expect(out[0]!.detail).toHaveLength(300);
|
||||
});
|
||||
});
|
||||
|
||||
describe('validateTurnResult — hybrid (v0.34.0 #57)', () => {
|
||||
it('defaults hybrid to { ops: [] } when the field is absent', () => {
|
||||
const r = validateTurnResult({ date: 'x' });
|
||||
expect(r?.hybrid).toEqual({ ops: [] });
|
||||
});
|
||||
|
||||
it('parses a hybrid field into validated ops', () => {
|
||||
const r = validateTurnResult({
|
||||
date: 'x',
|
||||
hybrid: { ops: [{ type: 'cyber', from: 'a', target: 'b', outcome: 'exposed' }] },
|
||||
});
|
||||
expect(r?.hybrid).toEqual({ ops: [{ type: 'cyber', from: 'a', target: 'b', outcome: 'exposed' }] });
|
||||
});
|
||||
});
|
||||
|
||||
describe('isMeaningful — hybrid (v0.34.0 #57)', () => {
|
||||
it('a hybrid op makes an otherwise-empty turn meaningful', () => {
|
||||
expect(
|
||||
isMeaningful(mkTurnResult({ hybrid: { ops: [{ type: 'spy', from: 'a', target: 'b' }] } })),
|
||||
).toBe(true);
|
||||
});
|
||||
it('an empty hybrid (and nothing else) is NOT meaningful', () => {
|
||||
expect(isMeaningful(mkTurnResult({ hybrid: { ops: [] } }))).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe('shiftRelationTowardHostile (v0.34.0 #57)', () => {
|
||||
it('steps one rung toward hostile (cap hostile), symmetrically', () => {
|
||||
const mk = (ab: RelationStatus, ba: RelationStatus) => {
|
||||
const a = mkNation('a', NS(50, 50, 50, 50), []);
|
||||
const b = mkNation('b', NS(50, 50, 50, 50), []);
|
||||
a.relations = { b: ab };
|
||||
b.relations = { a: ba };
|
||||
return mkTurnState({ a, b }, {});
|
||||
};
|
||||
let s = mk('neutral', 'neutral');
|
||||
shiftRelationTowardHostile(s, 'a', 'b', 'hostile');
|
||||
expect(s.nations.a.relations.b).toBe('rival');
|
||||
expect(s.nations.b.relations.a).toBe('rival');
|
||||
s = mk('rival', 'rival');
|
||||
shiftRelationTowardHostile(s, 'a', 'b', 'hostile');
|
||||
expect(s.nations.a.relations.b).toBe('hostile');
|
||||
s = mk('ally', 'neutral'); // asymmetric: each side steps from its own value
|
||||
shiftRelationTowardHostile(s, 'a', 'b', 'hostile');
|
||||
expect(s.nations.a.relations.b).toBe('neutral'); // ally -> neutral
|
||||
expect(s.nations.b.relations.a).toBe('rival'); // neutral -> rival
|
||||
});
|
||||
|
||||
it('never moves past the cap (hostile cap, rival cap)', () => {
|
||||
const mk = (ab: RelationStatus, ba: RelationStatus) => {
|
||||
const a = mkNation('a', NS(50, 50, 50, 50), []);
|
||||
const b = mkNation('b', NS(50, 50, 50, 50), []);
|
||||
a.relations = { b: ab };
|
||||
b.relations = { a: ba };
|
||||
return mkTurnState({ a, b }, {});
|
||||
};
|
||||
// hostile cap: hostile stays, rival -> hostile
|
||||
let s = mk('hostile', 'hostile');
|
||||
shiftRelationTowardHostile(s, 'a', 'b', 'hostile');
|
||||
expect(s.nations.a.relations.b).toBe('hostile');
|
||||
// rival cap: hostile stays (past cap), rival stays (at cap), neutral -> rival
|
||||
s = mk('hostile', 'hostile');
|
||||
shiftRelationTowardHostile(s, 'a', 'b', 'rival');
|
||||
expect(s.nations.a.relations.b).toBe('hostile');
|
||||
s = mk('rival', 'rival');
|
||||
shiftRelationTowardHostile(s, 'a', 'b', 'rival');
|
||||
expect(s.nations.a.relations.b).toBe('rival');
|
||||
s = mk('neutral', 'neutral');
|
||||
shiftRelationTowardHostile(s, 'a', 'b', 'rival');
|
||||
expect(s.nations.a.relations.b).toBe('rival');
|
||||
});
|
||||
|
||||
it('NEVER touches a war relation (neither direction)', () => {
|
||||
const a = mkNation('a', NS(50, 50, 50, 50), []);
|
||||
const b = mkNation('b', NS(50, 50, 50, 50), []);
|
||||
a.relations = { b: 'war' };
|
||||
b.relations = { a: 'war' };
|
||||
const s = mkTurnState({ a, b }, {});
|
||||
shiftRelationTowardHostile(s, 'a', 'b', 'hostile');
|
||||
expect(s.nations.a.relations.b).toBe('war');
|
||||
expect(s.nations.b.relations.a).toBe('war');
|
||||
});
|
||||
|
||||
it('treats a missing relation as neutral and is a no-op for unknown nations', () => {
|
||||
const a = mkNation('a', NS(50, 50, 50, 50), []);
|
||||
const b = mkNation('b', NS(50, 50, 50, 50), []);
|
||||
const s = mkTurnState({ a, b }, {}); // no relations set
|
||||
shiftRelationTowardHostile(s, 'a', 'b', 'hostile');
|
||||
expect(s.nations.a.relations.b).toBe('rival'); // neutral -> rival
|
||||
shiftRelationTowardHostile(s, 'a', 'ghost', 'hostile'); // unknown nation: no-op
|
||||
expect(s.nations.a.relations.ghost).toBeUndefined();
|
||||
});
|
||||
});
|
||||
|
||||
describe('applyTurnResult — hybrid (v0.34.0 #57)', () => {
|
||||
it('sabotage (nation target): dips ALL of the target\'s economy, re-derives, no relation/event', () => {
|
||||
// b owns TWO regions — a nation-target op must dip both.
|
||||
const a = mkNation('a', NS(50, 50, 50, 50), ['ra1']);
|
||||
const b = mkNation('b', NS(50, 50, 50, 50), ['rb1', 'rb2']);
|
||||
const state = mkTurnState(
|
||||
{ a, b },
|
||||
{ ra1: RS(50, 50, 50, 50), rb1: RS(50, 50, 50, 50), rb2: RS(50, 50, 50, 50) },
|
||||
);
|
||||
const next = applyTurnResult(state, mkTurnResult({ hybrid: { ops: [{ type: 'sabotage', from: 'a', target: 'b' }] } }));
|
||||
expect(next.regionStats.rb1.economy).toBe(47);
|
||||
expect(next.regionStats.rb2.economy).toBe(47);
|
||||
expect(next.nations.b.stats.economy).toBe(47);
|
||||
expect(next.nations.a.stats.economy).toBe(50); // actor untouched
|
||||
expect(next.nations.a.relations.b).toBeUndefined(); // no relation nudge for a stat op
|
||||
expect(next.nations.b.relations.a).toBeUndefined();
|
||||
expect(next.events).toHaveLength(0); // the engine emits no event for a successful stat op
|
||||
});
|
||||
|
||||
it('a region target dips ONLY that region (not the rest of the nation)', () => {
|
||||
const a = mkNation('a', NS(50, 50, 50, 50), ['ra1']);
|
||||
const b = mkNation('b', NS(50, 50, 50, 50), ['rb1', 'rb2']);
|
||||
const state = mkTurnState(
|
||||
{ a, b },
|
||||
{ ra1: RS(50, 50, 50, 50), rb1: RS(50, 50, 50, 50), rb2: RS(50, 50, 50, 50) },
|
||||
);
|
||||
const next = applyTurnResult(state, mkTurnResult({ hybrid: { ops: [{ type: 'cyber', from: 'a', target: 'rb1' }] } }));
|
||||
expect(next.regionStats.rb1.military).toBe(47); // the named region
|
||||
expect(next.regionStats.rb2.military).toBe(50); // the sibling region untouched
|
||||
});
|
||||
|
||||
it('each stat type applies its documented dip', () => {
|
||||
const run = (type: HybridOp['type']) => {
|
||||
const state = hybState({});
|
||||
return applyTurnResult(state, mkTurnResult({ hybrid: { ops: [{ type, from: 'a', target: 'b' }] } }));
|
||||
};
|
||||
expect(run('sabotage').nations.b.stats.economy).toBe(47); // -3
|
||||
expect(run('assassinate').nations.b.stats.stability).toBe(45); // -5
|
||||
expect(run('cyber').nations.b.stats.military).toBe(47); // -3
|
||||
expect(run('economic').nations.b.stats.economy).toBe(45); // -5
|
||||
});
|
||||
|
||||
it('spy is informational only — no stat, no relation change', () => {
|
||||
const state = hybState({ a: { b: 'neutral' }, b: { a: 'neutral' } });
|
||||
const next = applyTurnResult(state, mkTurnResult({ hybrid: { ops: [{ type: 'spy', from: 'a', target: 'b' }] } }));
|
||||
expect(next.nations.b.stats).toEqual(NS(50, 50, 50, 50));
|
||||
expect(next.nations.a.relations.b).toBe('neutral');
|
||||
expect(next.nations.b.relations.a).toBe('neutral');
|
||||
expect(next.events).toHaveLength(0);
|
||||
});
|
||||
|
||||
it('information nudges the relation one step toward hostile (cap hostile)', () => {
|
||||
const state = hybState({ a: { b: 'neutral' }, b: { a: 'neutral' } });
|
||||
const next = applyTurnResult(state, mkTurnResult({ hybrid: { ops: [{ type: 'information', from: 'a', target: 'b' }] } }));
|
||||
expect(next.nations.a.relations.b).toBe('rival');
|
||||
expect(next.nations.b.relations.a).toBe('rival');
|
||||
expect(next.nations.b.stats).toEqual(NS(50, 50, 50, 50)); // no stat change
|
||||
});
|
||||
|
||||
it('proxy nudges the relation but caps at rival (hostile is not reached)', () => {
|
||||
const state = hybState({ a: { b: 'neutral' }, b: { a: 'neutral' } });
|
||||
const next = applyTurnResult(state, mkTurnResult({ hybrid: { ops: [{ type: 'proxy', from: 'a', target: 'b' }] } }));
|
||||
expect(next.nations.a.relations.b).toBe('rival');
|
||||
expect(next.nations.b.relations.a).toBe('rival');
|
||||
// a hostile pair is left alone by a proxy op (capped at rival)
|
||||
const state2 = hybState({ a: { b: 'hostile' }, b: { a: 'hostile' } });
|
||||
const next2 = applyTurnResult(state2, mkTurnResult({ hybrid: { ops: [{ type: 'proxy', from: 'a', target: 'b' }] } }));
|
||||
expect(next2.nations.a.relations.b).toBe('hostile');
|
||||
});
|
||||
|
||||
it('an EXPOSED op backfires: no stat effect, relation sours one step, auto diplomatic/failure event', () => {
|
||||
const state = hybState({ a: { b: 'neutral' }, b: { a: 'neutral' } });
|
||||
const next = applyTurnResult(
|
||||
state,
|
||||
mkTurnResult({ hybrid: { ops: [{ type: 'sabotage', from: 'a', target: 'b', outcome: 'exposed' }] } }),
|
||||
);
|
||||
expect(next.nations.b.stats.economy).toBe(50); // the intended dip did NOT happen
|
||||
expect(next.regionStats.rb1.economy).toBe(50);
|
||||
expect(next.nations.a.relations.b).toBe('rival'); // neutral -> rival (cap hostile)
|
||||
expect(next.nations.b.relations.a).toBe('rival');
|
||||
expect(next.events).toHaveLength(1);
|
||||
expect(next.events[0]).toMatchObject({ nation: 'a', type: 'diplomatic', outcome: 'failure', title: "Covert operation exposed" });
|
||||
});
|
||||
|
||||
it('an EXPOSED op never touches a war relation', () => {
|
||||
const state = hybState({ a: { b: 'war' }, b: { a: 'war' } });
|
||||
const next = applyTurnResult(
|
||||
state,
|
||||
mkTurnResult({ hybrid: { ops: [{ type: 'cyber', from: 'a', target: 'b', outcome: 'exposed' }] } }),
|
||||
);
|
||||
expect(next.nations.a.relations.b).toBe('war');
|
||||
expect(next.nations.b.relations.a).toBe('war');
|
||||
expect(next.events).toHaveLength(1); // the backfire event is still emitted
|
||||
});
|
||||
|
||||
it('drops an unknown actor, a self-target, an unknown region, and a double-owned region', () => {
|
||||
// unknown actor
|
||||
let state = hybState({ a: { b: 'neutral' }, b: { a: 'neutral' } });
|
||||
let next = applyTurnResult(state, mkTurnResult({ hybrid: { ops: [{ type: 'sabotage', from: 'zzz', target: 'b' }] } }));
|
||||
expect(next.nations.b.stats.economy).toBe(50);
|
||||
expect(next.events).toHaveLength(0);
|
||||
// self-target
|
||||
state = hybState({ a: { b: 'neutral' }, b: { a: 'neutral' } });
|
||||
next = applyTurnResult(state, mkTurnResult({ hybrid: { ops: [{ type: 'sabotage', from: 'a', target: 'a' }] } }));
|
||||
expect(next.nations.a.stats.economy).toBe(50);
|
||||
expect(next.events).toHaveLength(0);
|
||||
// unknown region key
|
||||
state = hybState({ a: { b: 'neutral' }, b: { a: 'neutral' } });
|
||||
next = applyTurnResult(state, mkTurnResult({ hybrid: { ops: [{ type: 'cyber', from: 'a', target: 'not-a-region' }] } }));
|
||||
expect(next.nations.b.stats.military).toBe(50);
|
||||
expect(next.events).toHaveLength(0);
|
||||
// double-owned region (a and b both own 'rx')
|
||||
const a = mkNation('a', NS(50, 50, 50, 50), ['ra1', 'rx']);
|
||||
const b = mkNation('b', NS(50, 50, 50, 50), ['rb1', 'rx']);
|
||||
const st = mkTurnState(
|
||||
{ a, b },
|
||||
{ ra1: RS(50, 50, 50, 50), rb1: RS(50, 50, 50, 50), rx: RS(50, 50, 50, 50) },
|
||||
);
|
||||
const nx = applyTurnResult(st, mkTurnResult({ hybrid: { ops: [{ type: 'cyber', from: 'a', target: 'rx' }] } }));
|
||||
expect(nx.regionStats.rx.military).toBe(50); // not a valid sole-owner target: dropped
|
||||
expect(nx.events).toHaveLength(0);
|
||||
});
|
||||
|
||||
it('a region target outside the valid-key set is dropped (validKeys gate)', () => {
|
||||
const state = hybState({ a: { b: 'neutral' }, b: { a: 'neutral' } });
|
||||
const next = applyTurnResult(
|
||||
state,
|
||||
mkTurnResult({ hybrid: { ops: [{ type: 'cyber', from: 'a', target: 'rb1' }] } }),
|
||||
new Set(['ra1']), // rb1 is not a valid key
|
||||
);
|
||||
expect(next.regionStats.rb1.military).toBe(50);
|
||||
expect(next.nations.b.stats.military).toBe(50);
|
||||
});
|
||||
|
||||
it('NEVER moves borders: a hybrid op leaves every nation\'s territory untouched', () => {
|
||||
const state = hybState({ a: { b: 'neutral' }, b: { a: 'neutral' } });
|
||||
const next = applyTurnResult(state, mkTurnResult({ hybrid: { ops: [{ type: 'economic', from: 'a', target: 'b' }] } }));
|
||||
expect(next.nations.a.territory).toEqual(['ra1']);
|
||||
expect(next.nations.b.territory).toEqual(['rb1']);
|
||||
expect(next.nations.b.stats.economy).toBe(45); // the op still took effect on stats
|
||||
});
|
||||
|
||||
it('HYBRID_STAT_EFFECT encodes the per-type magnitudes', () => {
|
||||
expect(HYBRID_STAT_EFFECT.sabotage).toEqual({ stat: 'economy', delta: -3 });
|
||||
expect(HYBRID_STAT_EFFECT.assassinate).toEqual({ stat: 'stability', delta: -5 });
|
||||
expect(HYBRID_STAT_EFFECT.cyber).toEqual({ stat: 'military', delta: -3 });
|
||||
expect(HYBRID_STAT_EFFECT.economic).toEqual({ stat: 'economy', delta: -5 });
|
||||
expect(HYBRID_STAT_EFFECT.spy).toBeNull();
|
||||
expect(HYBRID_STAT_EFFECT.information).toBeNull();
|
||||
expect(HYBRID_STAT_EFFECT.proxy).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe('buildSystemPrompt — hybrid contract (v0.34.0 #57)', () => {
|
||||
it('documents the hybrid field and its constraints in both modes', () => {
|
||||
for (const withTools of [false, true]) {
|
||||
const p = buildSystemPrompt(preset, withTools);
|
||||
expect(p).toContain('"hybrid": {"ops":');
|
||||
expect(p).toContain('spy|sabotage|assassinate|cyber|economic|information|proxy');
|
||||
expect(p).toContain('Covert / hybrid ops ("hybrid.ops") are non-kinetic');
|
||||
expect(p).toContain('NEVER move borders');
|
||||
expect(p).toContain("sours by one step toward hostile");
|
||||
expect(p).toContain("a region key hits that region's sole owner");
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
describe('buildUserPrompt — hybrid relations (v0.34.0 #57)', () => {
|
||||
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 player\'s relations when provided', () => {
|
||||
const out = JSON.parse(buildUserPrompt(state, [], undefined, undefined, [], [], undefined, { b: 'rival' }));
|
||||
expect(out.relations).toEqual({ b: 'rival' });
|
||||
});
|
||||
|
||||
it('omits the relations field entirely when the param is undefined', () => {
|
||||
const out = JSON.parse(buildUserPrompt(state, [], undefined, undefined, [], [], undefined));
|
||||
expect(out).not.toHaveProperty('relations');
|
||||
});
|
||||
});
|
||||
|
||||
describe('stub — hybrid (v0.34.0 #57)', () => {
|
||||
it('the deterministic stub returns empty hybrid ops (it never runs a covert op)', () => {
|
||||
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.hybrid).toEqual({ ops: [] });
|
||||
});
|
||||
});
|
||||
|
||||
@@ -24,12 +24,15 @@ import {
|
||||
clampStat,
|
||||
deriveNationStats,
|
||||
fnv1a32,
|
||||
HYBRID_OP_TYPES,
|
||||
isDateLike,
|
||||
isMarkerType,
|
||||
makeEvent,
|
||||
markerId,
|
||||
MAX_MARKERS,
|
||||
nationForPrompt,
|
||||
type HybridOp,
|
||||
type HybridOpType,
|
||||
type MarkerInfo,
|
||||
type MarkerSpec,
|
||||
type NationOp,
|
||||
@@ -55,6 +58,12 @@ const VALID_EVENT_TYPES = new Set<string>(EVENT_TYPES);
|
||||
const VALID_RELATIONS = new Set<string>(RELATION_STATUSES);
|
||||
const VALID_OUTCOMES = new Set<string>(['success', 'failure', 'partial', 'neutral']);
|
||||
const VALID_STATS = new Set<string>(['military', 'economy', 'stability', 'population']);
|
||||
const VALID_HYBRID_TYPES = new Set<string>(HYBRID_OP_TYPES);
|
||||
|
||||
/** True when `t` is one of the seven known hybrid op types (v0.34.0, #57). */
|
||||
export function isHybridOpType(t: unknown): t is HybridOpType {
|
||||
return typeof t === 'string' && VALID_HYBRID_TYPES.has(t);
|
||||
}
|
||||
|
||||
// --- The LLM's getNeighbours tool (v0.21, #16 P2: contiguous region transfers) ---
|
||||
|
||||
@@ -206,6 +215,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>"}]},',
|
||||
' "markers": {"add":[{"type":"<city|factory|army|port|capital|fleet|rocket|gathering>","name":"<short name>","regionKey":"<region key>"}],"remove":["<marker id>"]}',
|
||||
'}',
|
||||
'',
|
||||
@@ -225,6 +235,7 @@ export function buildSystemPrompt(preset: Preset, withNeighboursTool = false): s
|
||||
'- Merge: add {"kind":"merge","nationRefs":["<id or name>","<id or name>"],"survivingRef":"<id or name of the surviving nation>","into":"<new name>"} when states reunify or one is absorbed. Use at least 2 input nations and "survivingRef" must name one of them — the survivor keeps its id and its stats.',
|
||||
'- 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".',
|
||||
'- 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.',
|
||||
@@ -276,6 +287,14 @@ export function buildUserPrompt(
|
||||
* Omitted entirely when undefined.
|
||||
*/
|
||||
wars?: WarInfo[],
|
||||
/**
|
||||
* (v0.34.0, #57) The player's current relations to every other nation
|
||||
* (`nationId -> RelationStatus`). Emitted verbatim (even when empty) so the
|
||||
* model sees the relation ladder it can nudge with `information` / `proxy`
|
||||
* hybrid ops and knows the current state before an `exposed` backfire.
|
||||
* Omitted entirely when undefined.
|
||||
*/
|
||||
relations?: Record<string, RelationStatus>,
|
||||
): string {
|
||||
const player = state.nations[state.playerNationId];
|
||||
const playerName = player?.name ?? state.playerNationId;
|
||||
@@ -389,6 +408,7 @@ export function buildUserPrompt(
|
||||
if (noMansLand !== undefined) payload.unowned = noMansLand;
|
||||
if (markers !== undefined) payload.markers = markers;
|
||||
if (wars !== undefined) payload.wars = wars;
|
||||
if (relations !== undefined) payload.relations = relations;
|
||||
return JSON.stringify(payload, null, 2);
|
||||
}
|
||||
|
||||
@@ -528,6 +548,7 @@ export function validateTurnResult(
|
||||
nationOps: [],
|
||||
takeovers: [],
|
||||
wars: { declare: [], end: [] },
|
||||
hybrid: { ops: [] },
|
||||
markers: { add: [], remove: [] },
|
||||
};
|
||||
|
||||
@@ -626,6 +647,12 @@ export function validateTurnResult(
|
||||
// Applied directly by applyTurnResult (after takeovers, before markers).
|
||||
result.wars = validateWarOps(o.wars);
|
||||
|
||||
// (v0.34.0, #57) Covert hybrid ops (spies, sabotage, assassination, cyber,
|
||||
// economic, disinformation, proxy). Applied directly by applyTurnResult
|
||||
// (after wars, before markers) — a deterministic per-type effect or an
|
||||
// exposed backfire.
|
||||
result.hybrid = { ops: validateHybridOps(o.hybrid) };
|
||||
|
||||
// (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).
|
||||
@@ -806,6 +833,54 @@ export function validateWarOps(raw: unknown): { declare: WarDeclareOp[]; end: Wa
|
||||
return out;
|
||||
}
|
||||
|
||||
/**
|
||||
* (v0.34.0, #57) Validate the raw `hybrid` field of a turn into a safe
|
||||
* `{ ops }` shape. Defensive like the rest of the parser — never throws. The
|
||||
* raw value may be the object `{ ops: [...] }` (the TurnResult shape) or a bare
|
||||
* array (be lenient about either). Each op needs a known `type` (one of
|
||||
* {@link HYBRID_OP_TYPES}), a non-empty `from` (the acting nation, capped at
|
||||
* 200 chars) and a non-empty `target` (a nation id/name or a region key, capped
|
||||
* at 200 chars); an `outcome` of exactly `success` or `exposed` is kept (the
|
||||
* default, `success`, is stored as absent to keep the shape minimal — the
|
||||
* engine treats a missing outcome as success); a non-empty `detail` is capped
|
||||
* at 300 chars. Resolution of `from` / `target` to concrete nation ids or
|
||||
* region owners happens at apply time (`applyHybridOp`), not here.
|
||||
*/
|
||||
export function validateHybridOps(raw: unknown): HybridOp[] {
|
||||
if (!Array.isArray(raw)) {
|
||||
// Lenient: accept the `{ ops: [...] }` object shape (or a non-array → []).
|
||||
if (raw && typeof raw === 'object' && !Array.isArray(raw)) {
|
||||
const inner = (raw as Record<string, unknown>).ops;
|
||||
if (!Array.isArray(inner)) return [];
|
||||
return validateHybridOpsList(inner);
|
||||
}
|
||||
return [];
|
||||
}
|
||||
return validateHybridOpsList(raw);
|
||||
}
|
||||
|
||||
function validateHybridOpsList(raw: unknown[]): HybridOp[] {
|
||||
const out: HybridOp[] = [];
|
||||
for (const e of raw) {
|
||||
if (!e || typeof e !== 'object') continue;
|
||||
const o = e as Record<string, unknown>;
|
||||
if (!isHybridOpType(o.type)) continue;
|
||||
const from = str(o.from, 200);
|
||||
const target = str(o.target, 200);
|
||||
if (!from || !target) continue;
|
||||
const detail = str(o.detail, 300);
|
||||
const exposed = o.outcome === 'exposed'; // 'success' / missing → success
|
||||
out.push({
|
||||
type: o.type,
|
||||
from,
|
||||
target,
|
||||
...(detail ? { detail } : {}),
|
||||
...(exposed ? { outcome: 'exposed' as const } : {}),
|
||||
});
|
||||
}
|
||||
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},
|
||||
@@ -879,6 +954,217 @@ export function syncWarRelations(prev: GameState, next: GameState): void {
|
||||
}
|
||||
}
|
||||
|
||||
// --- Hybrid war (v0.34.0, #57): covert, non-kinetic operations -------------
|
||||
//
|
||||
// The engine's DETERMINISTIC effects for the seven hybrid op types. There is no
|
||||
// hidden randomness: the LLM declares an intended `outcome` (success / exposed)
|
||||
// and the engine acts only on that. Each effect is small and documented:
|
||||
//
|
||||
// spy informational only — no stat, no relation (pure intel)
|
||||
// sabotage a small ECONOMY dip on the target
|
||||
// assassinate a small STABILITY dip on the target (absent the named-leaders
|
||||
// feature, #61, a leader's removal sours the polity)
|
||||
// cyber a small MILITARY dip on the target
|
||||
// economic a small ECONOMY dip on the target
|
||||
// information a relation nudge toward hostile (cap hostile) — disinformation
|
||||
// proxy a light relation nudge toward hostile (cap rival) — proxy war
|
||||
//
|
||||
// A successful stat op dips the target's `regionStats` (the single named region
|
||||
// when the target was a region key, otherwise every region the target owns) and
|
||||
// re-derives the target nation's aggregate stats so the effect persists. A
|
||||
// successful information/proxy and any exposed op sour the actor's relation
|
||||
// with the target by one step on the hostile ladder.
|
||||
|
||||
/** The deterministic per-type stat effect of a hybrid op (v0.34.0, #57). */
|
||||
export const HYBRID_STAT_EFFECT: Record<HybridOpType, { stat: StatKey; delta: number } | null> = {
|
||||
spy: null, // informational only
|
||||
sabotage: { stat: 'economy', delta: -3 },
|
||||
assassinate: { stat: 'stability', delta: -5 },
|
||||
cyber: { stat: 'military', delta: -3 },
|
||||
economic: { stat: 'economy', delta: -5 },
|
||||
information: null, // relation nudge only
|
||||
proxy: null, // relation nudge only
|
||||
};
|
||||
|
||||
/**
|
||||
* The hostile-relations ladder, weakest to strongest (the `cap` in
|
||||
* {@link shiftRelationTowardHostile} is a member of this array). A missing
|
||||
* relation is treated as `neutral`; `war` is never touched (a war is its own
|
||||
* entity — a covert op neither fabricates nor ends one).
|
||||
*/
|
||||
const HOSTILE_ORDER: RelationStatus[] = ['ally', 'neutral', 'rival', 'hostile'];
|
||||
|
||||
/**
|
||||
* (v0.34.0, #57) Nudge the bilateral relation between nations `a` and `b`
|
||||
* ONE step toward hostile (on the {@link HOSTILE_ORDER} ladder), applied
|
||||
* symmetrically to both directions, but never past the given `cap`. A missing
|
||||
* relation starts at `neutral`; a `war` relation is never touched (a covert op
|
||||
* neither fabricates nor downgrades a war). Used for the `information` /
|
||||
* `proxy` nudge and the `exposed` backfire. Mutates `state` in place (the
|
||||
* caller passes a clone — applyTurnResult does). Never throws.
|
||||
*/
|
||||
export function shiftRelationTowardHostile(
|
||||
state: GameState,
|
||||
a: string,
|
||||
b: string,
|
||||
cap: RelationStatus,
|
||||
): void {
|
||||
const na = state.nations[a];
|
||||
const nb = state.nations[b];
|
||||
if (!na || !nb) return;
|
||||
const capIdx = HOSTILE_ORDER.indexOf(cap);
|
||||
if (capIdx === -1) return; // unknown cap: no-op (defensive)
|
||||
const step = (cur: RelationStatus | undefined): RelationStatus | null => {
|
||||
if (cur === 'war') return null; // never touch a war relation
|
||||
const s = cur ?? 'neutral'; // a missing relation is neutral
|
||||
const i = HOSTILE_ORDER.indexOf(s);
|
||||
if (i === -1) return 'neutral'; // defensive: unknown status
|
||||
if (i >= capIdx) return s; // at or past the cap: no change
|
||||
return HOSTILE_ORDER[i + 1];
|
||||
};
|
||||
const stepA = step(na.relations[b]);
|
||||
if (stepA !== null) na.relations[b] = stepA;
|
||||
const stepB = step(nb.relations[a]);
|
||||
if (stepB !== null) nb.relations[a] = stepB;
|
||||
}
|
||||
|
||||
/**
|
||||
* (v0.34.0, #57) The display name of a hybrid op's target for the auto
|
||||
* backfire event: a nation target shows the nation's name; a region target
|
||||
* shows the region's display name (or its raw key when no resolver is given).
|
||||
*/
|
||||
function hybridTargetDisplayName(
|
||||
target: { nation: Nation; regionKey?: string },
|
||||
regionNameOf?: (key: string) => string | undefined,
|
||||
): string {
|
||||
if (target.regionKey !== undefined) return regionNameOf?.(target.regionKey) ?? target.regionKey;
|
||||
return target.nation.name;
|
||||
}
|
||||
|
||||
/**
|
||||
* (v0.34.0, #57) Resolve a hybrid op's `target` (a nation id/name OR a region
|
||||
* key) to the concrete nation it affects. A nation ref resolves via
|
||||
* {@link resolveNationRef}; a region key resolves to its SOLE owner (a region
|
||||
* with zero or 2+ owners can't be a valid target). Returns the affected nation
|
||||
* plus the specific region key (when the target was a region) so a stat op can
|
||||
* dip just that region. `validKeys` (when supplied) gates the region-key path;
|
||||
* a ref that is neither a known nation nor a valid region key returns `null`.
|
||||
* Pure; never throws.
|
||||
*/
|
||||
function resolveHybridTarget(
|
||||
state: GameState,
|
||||
target: string,
|
||||
validKeys?: ReadonlySet<string>,
|
||||
): { nation: Nation; regionKey?: string } | null {
|
||||
const id = resolveNationRef(state, target);
|
||||
if (id !== null) {
|
||||
const n = state.nations[id];
|
||||
return n ? { nation: n } : null;
|
||||
}
|
||||
const key = (target ?? '').trim();
|
||||
if (!key) return null;
|
||||
if (validKeys && !validKeys.has(key)) return null; // unknown region key
|
||||
const owners = Object.values(state.nations).filter((n) => n.territory.includes(key));
|
||||
if (owners.length !== 1) return null; // 0 or 2+ owners: not a targetable region
|
||||
return { nation: owners[0], regionKey: key };
|
||||
}
|
||||
|
||||
/**
|
||||
* (v0.34.0, #57) Apply ONE hybrid op to the (cloned) `next` state, mutating it
|
||||
* in place and never throwing. Resolution order:
|
||||
* 1. `from` must resolve to a nation (id first, then a unique name); an
|
||||
* unknown / ambiguous actor drops the op.
|
||||
* 2. `target` must resolve to a concrete nation (a nation ref, or a region
|
||||
* key's sole owner); an unresolvable target drops the op.
|
||||
* 3. A self-target (`from` and the target nation are the same) is dropped.
|
||||
* Then, per type:
|
||||
* - an `exposed` op BACKFIRES: no intended effect, the actor's relation with
|
||||
* the target sours by one step (cap hostile), and an auto diplomatic/
|
||||
* failure event is emitted (the engine's own narrative, mirroring the
|
||||
* "repelled" auto-event).
|
||||
* - a successful stat op (sabotage / assassinate / cyber / economic) dips the
|
||||
* target's `regionStats` by its per-type delta — the single named region
|
||||
* when the target was a region key, otherwise every region the target
|
||||
* owns — then re-derives the target nation's aggregate stats (the same
|
||||
* 0-region / no-entry guards as the main recompute), so the effect
|
||||
* persists to the next turn.
|
||||
* - a successful `information` / `proxy` sours the actor's relation with the
|
||||
* target by one step (information caps at hostile, proxy at rival).
|
||||
* - `spy` is informational only (no stat, no relation) — a no-op except for
|
||||
* whatever the LLM narrates in `events`.
|
||||
*/
|
||||
function applyHybridOp(
|
||||
next: GameState,
|
||||
op: HybridOp,
|
||||
geoOf?: (key: string) => { area: number; population: number } | undefined,
|
||||
validKeys?: ReadonlySet<string>,
|
||||
regionNameOf?: (key: string) => string | undefined,
|
||||
actingTurn?: number,
|
||||
): void {
|
||||
const turn = actingTurn ?? next.turn;
|
||||
const from = resolveNationRef(next, op.from);
|
||||
if (from === null) return; // unknown / ambiguous actor: drop
|
||||
const target = resolveHybridTarget(next, op.target, validKeys);
|
||||
if (target === null) return; // unresolvable target: drop
|
||||
if (from === target.nation.id) return; // self-target: drop
|
||||
|
||||
if (op.outcome === 'exposed') {
|
||||
// Backfire: no intended effect; the relation sours by one step (cap
|
||||
// hostile) and an auto diplomatic/failure event is emitted.
|
||||
shiftRelationTowardHostile(next, from, target.nation.id, 'hostile');
|
||||
const actor = next.nations[from];
|
||||
next.events.push(
|
||||
makeEvent(
|
||||
{
|
||||
nation: from,
|
||||
type: 'diplomatic',
|
||||
title: 'Covert operation exposed',
|
||||
text: `${actor?.name ?? from}'s ${op.type} operation against ${hybridTargetDisplayName(
|
||||
target,
|
||||
regionNameOf,
|
||||
)} was exposed; relations sour in response.`,
|
||||
outcome: 'failure',
|
||||
},
|
||||
turn,
|
||||
next.date,
|
||||
),
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
const eff = HYBRID_STAT_EFFECT[op.type];
|
||||
if (eff) {
|
||||
// A successful stat op: dip the target's region(s), then re-derive.
|
||||
if (target.regionKey !== undefined) {
|
||||
const rs = next.regionStats[target.regionKey];
|
||||
if (rs) rs[eff.stat] = clampStat(rs[eff.stat] + eff.delta);
|
||||
} else {
|
||||
for (const key of target.nation.territory) {
|
||||
const rs = next.regionStats[key];
|
||||
if (!rs) continue;
|
||||
rs[eff.stat] = clampStat(rs[eff.stat] + eff.delta);
|
||||
}
|
||||
}
|
||||
// Re-derive the target nation's aggregate stats. The main recompute ran
|
||||
// earlier this turn, so without this the dip would be washed out next
|
||||
// turn. The same 0-region / no-entry guards as the main recompute apply.
|
||||
if (target.nation.territory.length > 0) {
|
||||
if (target.nation.territory.some((k) => next.regionStats[k])) {
|
||||
target.nation.stats = deriveNationStats(
|
||||
target.nation.territory,
|
||||
next.regionStats,
|
||||
geoOf,
|
||||
);
|
||||
}
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
// information / proxy: a relation nudge (spy is a pure no-op here).
|
||||
if (op.type === 'information') shiftRelationTowardHostile(next, from, target.nation.id, 'hostile');
|
||||
else if (op.type === 'proxy') shiftRelationTowardHostile(next, from, target.nation.id, 'rival');
|
||||
}
|
||||
|
||||
/**
|
||||
* 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
|
||||
@@ -901,6 +1187,9 @@ export function isMeaningful(result: TurnResult): boolean {
|
||||
// (v0.33.0, #56) a war declared or ended is a real world change.
|
||||
result.wars.declare.length > 0 ||
|
||||
result.wars.end.length > 0 ||
|
||||
// (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.27, #44) a marker placed or removed this turn is a real world change.
|
||||
result.markers.add.length > 0 ||
|
||||
result.markers.remove.length > 0
|
||||
@@ -1246,6 +1535,17 @@ export function applyTurnResult(
|
||||
syncWarRelations(state, next);
|
||||
}
|
||||
|
||||
// --- Hybrid war (v0.34.0, #57): covert, non-kinetic operations ---
|
||||
//
|
||||
// Each op's deterministic per-type effect is applied to the post-wars state
|
||||
// (a stat dip on the target's regions, or a relation nudge toward hostile).
|
||||
// An `exposed` op backfires instead. They never move borders, so they run
|
||||
// after the territory changes and before the markers (a marker's owner is
|
||||
// recomputed from the final ownership, unaffected by a stat/relation dip).
|
||||
for (const op of result.hybrid.ops) {
|
||||
applyHybridOp(next, op, geoOf, validKeys, regionNameOf, actingTurn);
|
||||
}
|
||||
|
||||
// --- Map markers (v0.27, #44) ---
|
||||
//
|
||||
// Markers are anchored to a region key and are processed against the FINAL
|
||||
@@ -1744,6 +2044,11 @@ export async function runTurn(opts: {
|
||||
sides: w.sides,
|
||||
...(w.aims && w.aims.length > 0 ? { aims: w.aims } : {}),
|
||||
}));
|
||||
// (v0.34.0, #57) the player's current relations to every other nation, so the
|
||||
// model sees the ladder its `information` / `proxy` hybrid ops nudge (and the
|
||||
// starting point before an `exposed` backfire). Always emitted ({} when the
|
||||
// player has no relations yet).
|
||||
const relations = state.nations[state.playerNationId]?.relations ?? {};
|
||||
const user = buildUserPrompt(
|
||||
state,
|
||||
items,
|
||||
@@ -1752,6 +2057,7 @@ export async function runTurn(opts: {
|
||||
state.unowned ?? [],
|
||||
markers,
|
||||
wars,
|
||||
relations,
|
||||
);
|
||||
const nudge =
|
||||
'Your previous output was invalid. Respond with ONLY the JSON object described above for this batch — no prose, no fences.';
|
||||
@@ -1806,6 +2112,7 @@ export async function runTurn(opts: {
|
||||
nationOps: [],
|
||||
takeovers: [],
|
||||
wars: { declare: [], end: [] },
|
||||
hybrid: { ops: [] },
|
||||
markers: { add: [], remove: [] },
|
||||
};
|
||||
}
|
||||
|
||||
+13
-1
@@ -3,7 +3,7 @@
|
||||
// speaks the OpenAI chat-completions wire shape, so it plugs in as a Transport.
|
||||
import { createLlmClient, type ChatMessage, type Transport } from './llm';
|
||||
import { addDays, clampStat, type TerritoryChange, type TurnEventSpec, type TurnResult } from '../shared/protocol';
|
||||
import type { RoundItem } from '../shared/types';
|
||||
import type { RelationStatus, RoundItem } from '../shared/types';
|
||||
|
||||
export const ADVISOR_MARKER = '<<ADVISOR>>';
|
||||
|
||||
@@ -45,6 +45,13 @@ export interface StubPayload {
|
||||
* tests against applyTurnResult, not by the stub.
|
||||
*/
|
||||
wars?: Array<{ id: string; sides: string[]; aims?: string[] }>;
|
||||
/**
|
||||
* (v0.34.0, #57) The player's current relations to every other nation
|
||||
* (`nationId -> RelationStatus`), emitted by the engine's user prompt. The
|
||||
* stub ignores this field — it never runs a hybrid op (see
|
||||
* deterministicTurn), so the hybrid path is covered by unit tests only.
|
||||
*/
|
||||
relations?: Record<string, RelationStatus>;
|
||||
}
|
||||
|
||||
function truncate(s: string, n: number): string {
|
||||
@@ -92,6 +99,10 @@ function truncate(s: string, n: number): string {
|
||||
* (v0.33.0, #56) The stub likewise NEVER declares or ends a war (it always
|
||||
* returns `wars: { declare: [], end: [] }`) — the war path is covered by unit
|
||||
* tests only, keeping the e2e assertions untouched.
|
||||
*
|
||||
* (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.
|
||||
*/
|
||||
export function deterministicTurn(p: StubPayload): TurnResult {
|
||||
const state = p.state;
|
||||
@@ -160,6 +171,7 @@ export function deterministicTurn(p: StubPayload): TurnResult {
|
||||
nationOps: [], // the stub never proposes nation ops (see doc comment)
|
||||
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)
|
||||
markers: { add: [], remove: [] }, // the stub never touches markers (see doc comment)
|
||||
};
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user