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

This commit is contained in:
2026-10-15 16:42:27 +00:00
parent f45c01b8d6
commit 8eeb6df284
7 changed files with 868 additions and 22 deletions
+425 -20
View File
@@ -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: [] });
});
});