feat(#60): nuclear/WMD — capability flag + strikes (region → no-man's-land, nation collapse + war capitulation, MAD retaliation) (v0.37.0)
CI / build + unit + e2e (pull_request) Successful in 2m33s

This commit is contained in:
2026-10-15 19:17:26 +00:00
parent 0fd8981452
commit 31182c8730
8 changed files with 858 additions and 25 deletions
+316 -21
View File
@@ -1,15 +1,19 @@
import { describe, expect, it } from 'vitest';
import type { GameState, MarkerType, Nation, NationStats, Preset, RelationStatus, RegionStats, RoundItem, War } from '../shared/types';
import type { GameState, MapMarker, MarkerType, Nation, NationStats, Preset, RelationStatus, RegionStats, RoundItem, War } from '../shared/types';
import {
CONQUEST_MILITARY_MARGIN,
makeEvent,
markerId,
MAX_MARKERS,
seedRegionStats,
isNuclearCapable,
NUCLEAR_COLLAPSE,
NUCLEAR_RETALIATION,
type HybridOp,
type MarkerInfo,
type MarkerSpec,
type NationOp,
type NuclearStrike,
type TurnResult,
} from '../shared/protocol';
import type { LlmClient, ToolDefinition, WireMessage } from './llm';
@@ -45,6 +49,7 @@ import {
validateHybridOps,
validateIndustryOps,
validateMarkerOps,
validateNuclearOps,
validateNationOps,
validateRegionChanges,
validateTakeovers,
@@ -563,7 +568,7 @@ describe('validateNationOps (v0.23 #16 P4a)', () => {
relations: [],
territoryChanges: [],
unowned: [],
nationOps: [{ kind: 'rename', nationRef: 'France', to: 'French Republic' }], takeovers: [], wars: { declare: [], end: [] }, hybrid: { ops: [] }, economy: { trade: [], embargo: [] }, industry: { projects: [] }, markers: { add: [], remove: [] },
nationOps: [{ kind: 'rename', nationRef: 'France', to: 'French Republic' }], takeovers: [], wars: { declare: [], end: [] }, hybrid: { ops: [] }, economy: { trade: [], embargo: [] }, industry: { projects: [] }, nuclear: { arm: [], strikes: [] }, markers: { add: [], remove: [] },
});
expect(withOps).not.toBeNull();
expect(withOps!.nationOps).toEqual([{ kind: 'rename', nationRef: 'France', to: 'French Republic' }]);
@@ -585,7 +590,7 @@ describe('applyTurnResult (nation-op proposals, v0.23 #16 P4a)', () => {
relations: [],
territoryChanges: [],
unowned: [],
nationOps: [{ kind: 'rename', nationRef: 'de', to: 'Reich' }], takeovers: [], wars: { declare: [], end: [] }, hybrid: { ops: [] }, economy: { trade: [], embargo: [] }, industry: { projects: [] }, markers: { add: [], remove: [] },
nationOps: [{ kind: 'rename', nationRef: 'de', to: 'Reich' }], takeovers: [], wars: { declare: [], end: [] }, hybrid: { ops: [] }, economy: { trade: [], embargo: [] }, industry: { projects: [] }, nuclear: { arm: [], strikes: [] }, markers: { add: [], remove: [] },
});
expect(next.nations.de.name).toBe('DE'); // still the old name
expect(next.pendingNationOps).toEqual([{ kind: 'rename', nationRef: 'de', to: 'Reich' }]);
@@ -606,7 +611,7 @@ describe('applyTurnResult (nation-op proposals, v0.23 #16 P4a)', () => {
relations: [],
territoryChanges: [],
unowned: [],
nationOps: [{ kind: 'rename', nationRef: 'us', to: 'USA' }], takeovers: [], wars: { declare: [], end: [] }, hybrid: { ops: [] }, economy: { trade: [], embargo: [] }, industry: { projects: [] }, markers: { add: [], remove: [] }, // new proposal this turn
nationOps: [{ kind: 'rename', nationRef: 'us', to: 'USA' }], takeovers: [], wars: { declare: [], end: [] }, hybrid: { ops: [] }, economy: { trade: [], embargo: [] }, industry: { projects: [] }, nuclear: { arm: [], strikes: [] }, markers: { add: [], remove: [] }, // new proposal this turn
});
expect(next.pendingNationOps).toEqual([
{ kind: 'rename', nationRef: 'de', to: 'Reich' }, // the old one survived
@@ -625,7 +630,7 @@ describe('applyTurnResult (nation-op proposals, v0.23 #16 P4a)', () => {
relations: [],
territoryChanges: [],
unowned: [],
nationOps: [{ kind: 'rename', nationRef: 'de', to: 'Reich' }], takeovers: [], wars: { declare: [], end: [] }, hybrid: { ops: [] }, economy: { trade: [], embargo: [] }, industry: { projects: [] }, markers: { add: [], remove: [] },
nationOps: [{ kind: 'rename', nationRef: 'de', to: 'Reich' }], takeovers: [], wars: { declare: [], end: [] }, hybrid: { ops: [] }, economy: { trade: [], embargo: [] }, industry: { projects: [] }, nuclear: { arm: [], strikes: [] }, markers: { add: [], remove: [] },
});
expect(next.pendingNationOps).toEqual([{ kind: 'rename', nationRef: 'de', to: 'Reich' }]);
});
@@ -1082,7 +1087,7 @@ describe('applyTurnResult', () => {
relations: [],
territoryChanges: [],
unowned: [],
nationOps: [], takeovers: [], wars: { declare: [], end: [] }, hybrid: { ops: [] }, economy: { trade: [], embargo: [] }, industry: { projects: [] }, markers: { add: [], remove: [] },
nationOps: [], takeovers: [], wars: { declare: [], end: [] }, hybrid: { ops: [] }, economy: { trade: [], embargo: [] }, industry: { projects: [] }, nuclear: { arm: [], strikes: [] }, markers: { add: [], remove: [] },
};
applyTurnResult(state, result);
expect(JSON.stringify(state)).toBe(snapshot);
@@ -1099,7 +1104,7 @@ describe('applyTurnResult', () => {
relations: [],
territoryChanges: [],
unowned: [],
nationOps: [], takeovers: [], wars: { declare: [], end: [] }, hybrid: { ops: [] }, economy: { trade: [], embargo: [] }, industry: { projects: [] }, markers: { add: [], remove: [] },
nationOps: [], takeovers: [], wars: { declare: [], end: [] }, hybrid: { ops: [] }, economy: { trade: [], embargo: [] }, industry: { projects: [] }, nuclear: { arm: [], strikes: [] }, markers: { add: [], remove: [] },
};
const next = applyTurnResult(state, result);
expect(next.pending).toEqual([]);
@@ -1122,7 +1127,7 @@ describe('applyTurnResult', () => {
// is not a real key at all. Both are proposed as a gain by DE.
territoryChanges: [{ nation: 'de', gain: ['wales-bbbb', 'not-a-region-xyz'], lose: [] }],
unowned: [],
nationOps: [], takeovers: [], wars: { declare: [], end: [] }, hybrid: { ops: [] }, economy: { trade: [], embargo: [] }, industry: { projects: [] }, markers: { add: [], remove: [] },
nationOps: [], takeovers: [], wars: { declare: [], end: [] }, hybrid: { ops: [] }, economy: { trade: [], embargo: [] }, industry: { projects: [] }, nuclear: { arm: [], strikes: [] }, 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
@@ -1148,7 +1153,7 @@ describe('applyTurnResult', () => {
// released (not in state.unowned), so it does not enter GameState.unowned.
territoryChanges: [{ nation: 'de', gain: ['POL', 'wales-bbbb'], lose: [] }],
unowned: [],
nationOps: [], takeovers: [], wars: { declare: [], end: [] }, hybrid: { ops: [] }, economy: { trade: [], embargo: [] }, industry: { projects: [] }, markers: { add: [], remove: [] },
nationOps: [], takeovers: [], wars: { declare: [], end: [] }, hybrid: { ops: [] }, economy: { trade: [], embargo: [] }, industry: { projects: [] }, nuclear: { arm: [], strikes: [] }, markers: { add: [], remove: [] },
};
const next = applyTurnResult(state, result); // no validKeys -> nothing filtered
expect(next.nations.de.territory).toContain('wales-bbbb');
@@ -1166,7 +1171,7 @@ describe('applyTurnResult (sole-ownership invariants, v0.21 #16 P2)', () => {
regionChanges: [],
relations: [],
unowned: [],
nationOps: [], takeovers: [], wars: { declare: [], end: [] }, hybrid: { ops: [] }, economy: { trade: [], embargo: [] }, industry: { projects: [] }, markers: { add: [], remove: [] },
nationOps: [], takeovers: [], wars: { declare: [], end: [] }, hybrid: { ops: [] }, economy: { trade: [], embargo: [] }, industry: { projects: [] }, nuclear: { arm: [], strikes: [] }, markers: { add: [], remove: [] },
};
it('a double claim (two different gainers, one key) is rejected: the key stays with its original owner', () => {
@@ -1256,7 +1261,7 @@ describe('applyTurnResult (no man\'s land, v0.22 #16 P3)', () => {
relations: [],
territoryChanges: [],
unowned: [],
nationOps: [], takeovers: [], wars: { declare: [], end: [] }, hybrid: { ops: [] }, economy: { trade: [], embargo: [] }, industry: { projects: [] }, markers: { add: [], remove: [] },
nationOps: [], takeovers: [], wars: { declare: [], end: [] }, hybrid: { ops: [] }, economy: { trade: [], embargo: [] }, industry: { projects: [] }, nuclear: { arm: [], strikes: [] }, markers: { add: [], remove: [] },
};
it('a release removes the key from its sole owner and marks it in GameState.unowned', () => {
@@ -1371,7 +1376,7 @@ describe('applyTurnResult (no man\'s land, v0.22 #16 P3)', () => {
it('a lone release is meaningful (it survives the retry/nudge path)', () => {
expect(
isMeaningful({ date: 'x', events: [], stateChanges: [], regionChanges: [], relations: [], territoryChanges: [], unowned: ['GBR'], nationOps: [], takeovers: [], wars: { declare: [], end: [] }, hybrid: { ops: [] }, economy: { trade: [], embargo: [] }, industry: { projects: [] }, markers: { add: [], remove: [] } }),
isMeaningful({ date: 'x', events: [], stateChanges: [], regionChanges: [], relations: [], territoryChanges: [], unowned: ['GBR'], nationOps: [], takeovers: [], wars: { declare: [], end: [] }, hybrid: { ops: [] }, economy: { trade: [], embargo: [] }, industry: { projects: [] }, nuclear: { arm: [], strikes: [] }, markers: { add: [], remove: [] } }),
).toBe(true);
});
@@ -1678,9 +1683,9 @@ describe('buildSystemPrompt (tool mode, v0.21 #16 P2)', () => {
describe('isMeaningful', () => {
it('is true with an event, false when everything is empty', () => {
expect(isMeaningful({ date: 'x', events: [{ nation: 'uk', type: 'military', title: 't', text: 'b', outcome: 'neutral' }], stateChanges: [], regionChanges: [], relations: [], territoryChanges: [], unowned: [], nationOps: [], takeovers: [], wars: { declare: [], end: [] }, hybrid: { ops: [] }, economy: { trade: [], embargo: [] }, industry: { projects: [] }, markers: { add: [], remove: [] } })).toBe(true);
expect(isMeaningful({ date: 'x', events: [], stateChanges: [{ nation: 'uk', stat: 'military', op: 'set', value: 5 }], regionChanges: [], relations: [], territoryChanges: [], unowned: [], nationOps: [], takeovers: [], wars: { declare: [], end: [] }, hybrid: { ops: [] }, economy: { trade: [], embargo: [] }, industry: { projects: [] }, markers: { add: [], remove: [] } })).toBe(true);
expect(isMeaningful({ date: 'x', events: [], stateChanges: [], regionChanges: [], relations: [], territoryChanges: [], unowned: [], nationOps: [], takeovers: [], wars: { declare: [], end: [] }, hybrid: { ops: [] }, economy: { trade: [], embargo: [] }, industry: { projects: [] }, markers: { add: [], remove: [] } })).toBe(false);
expect(isMeaningful({ date: 'x', events: [{ nation: 'uk', type: 'military', title: 't', text: 'b', outcome: 'neutral' }], stateChanges: [], regionChanges: [], relations: [], territoryChanges: [], unowned: [], nationOps: [], takeovers: [], wars: { declare: [], end: [] }, hybrid: { ops: [] }, economy: { trade: [], embargo: [] }, industry: { projects: [] }, nuclear: { arm: [], strikes: [] }, markers: { add: [], remove: [] } })).toBe(true);
expect(isMeaningful({ date: 'x', events: [], stateChanges: [{ nation: 'uk', stat: 'military', op: 'set', value: 5 }], regionChanges: [], relations: [], territoryChanges: [], unowned: [], nationOps: [], takeovers: [], wars: { declare: [], end: [] }, hybrid: { ops: [] }, economy: { trade: [], embargo: [] }, industry: { projects: [] }, nuclear: { arm: [], strikes: [] }, markers: { add: [], remove: [] } })).toBe(true);
expect(isMeaningful({ date: 'x', events: [], stateChanges: [], regionChanges: [], relations: [], territoryChanges: [], unowned: [], nationOps: [], takeovers: [], wars: { declare: [], end: [] }, hybrid: { ops: [] }, economy: { trade: [], embargo: [] }, industry: { projects: [] }, nuclear: { arm: [], strikes: [] }, 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)', () => {
@@ -1693,7 +1698,7 @@ describe('isMeaningful', () => {
relations: [],
territoryChanges: [],
unowned: [],
nationOps: [{ kind: 'rename', nationRef: 'de', to: 'Reich' }], takeovers: [], wars: { declare: [], end: [] }, hybrid: { ops: [] }, economy: { trade: [], embargo: [] }, industry: { projects: [] }, markers: { add: [], remove: [] },
nationOps: [{ kind: 'rename', nationRef: 'de', to: 'Reich' }], takeovers: [], wars: { declare: [], end: [] }, hybrid: { ops: [] }, economy: { trade: [], embargo: [] }, industry: { projects: [] }, nuclear: { arm: [], strikes: [] }, markers: { add: [], remove: [] },
}),
).toBe(true);
});
@@ -1713,6 +1718,7 @@ describe('isMeaningful', () => {
wars: { declare: [], end: [] },
hybrid: { ops: [] },
economy: { trade: [], embargo: [] }, industry: { projects: [] },
nuclear: { arm: [], strikes: [] },
markers: { add: [{ type: 'city', name: 'Lyon', regionKey: 'rhone-rhne' }], remove: [] },
}),
).toBe(true);
@@ -1733,6 +1739,7 @@ describe('isMeaningful', () => {
wars: { declare: [], end: [] },
hybrid: { ops: [] },
economy: { trade: [], embargo: [] }, industry: { projects: [] },
nuclear: { arm: [], strikes: [] },
markers: { add: [], remove: [] },
}),
).toBe(true);
@@ -1755,6 +1762,7 @@ describe('takeovers (regime change, v0.32.0 #54)', () => {
wars: { declare: [], end: [] },
hybrid: { ops: [] },
economy: { trade: [], embargo: [] }, industry: { projects: [] },
nuclear: { arm: [], strikes: [] },
markers: { add: [], remove: [] },
};
const next = applyTurnResult(state, result);
@@ -1787,6 +1795,7 @@ describe('takeovers (regime change, v0.32.0 #54)', () => {
wars: { declare: [], end: [] },
hybrid: { ops: [] },
economy: { trade: [], embargo: [] }, industry: { projects: [] },
nuclear: { arm: [], strikes: [] },
markers: { add: [], remove: [] },
};
const next = applyTurnResult(state, result);
@@ -1810,6 +1819,7 @@ describe('takeovers (regime change, v0.32.0 #54)', () => {
wars: { declare: [], end: [] },
hybrid: { ops: [] },
economy: { trade: [], embargo: [] }, industry: { projects: [] },
nuclear: { arm: [], strikes: [] },
markers: { add: [], remove: [] },
};
const next = applyTurnResult(state, result);
@@ -1836,6 +1846,7 @@ describe('takeovers (regime change, v0.32.0 #54)', () => {
wars: { declare: [], end: [] },
hybrid: { ops: [] },
economy: { trade: [], embargo: [] }, industry: { projects: [] },
nuclear: { arm: [], strikes: [] },
markers: { add: [], remove: [] },
};
const next = applyTurnResult(state, result);
@@ -1861,6 +1872,7 @@ describe('takeovers (regime change, v0.32.0 #54)', () => {
wars: { declare: [], end: [] },
hybrid: { ops: [] },
economy: { trade: [], embargo: [] }, industry: { projects: [] },
nuclear: { arm: [], strikes: [] },
markers: { add: [], remove: [] },
});
expect(validated!.takeovers).toEqual([]);
@@ -1886,6 +1898,7 @@ describe('takeovers (regime change, v0.32.0 #54)', () => {
wars: { declare: [], end: [] },
hybrid: { ops: [] },
economy: { trade: [], embargo: [] }, industry: { projects: [] },
nuclear: { arm: [], strikes: [] },
markers: { add: [], remove: [] },
};
const next = applyTurnResult(state, result);
@@ -1909,6 +1922,7 @@ describe('takeovers (regime change, v0.32.0 #54)', () => {
wars: { declare: [], end: [] },
hybrid: { ops: [] },
economy: { trade: [], embargo: [] }, industry: { projects: [] },
nuclear: { arm: [], strikes: [] },
markers: { add: [], remove: [] },
};
const next = applyTurnResult(state, result);
@@ -1930,6 +1944,7 @@ describe('takeovers (regime change, v0.32.0 #54)', () => {
wars: { declare: [], end: [] },
hybrid: { ops: [] },
economy: { trade: [], embargo: [] }, industry: { projects: [] },
nuclear: { arm: [], strikes: [] },
markers: { add: [], remove: [] },
};
const next = applyTurnResult(state, result);
@@ -1963,6 +1978,7 @@ describe('markers (point placement, v0.27 #44)', () => {
wars: { declare: [], end: [] },
hybrid: { ops: [] },
economy: { trade: [], embargo: [] }, industry: { projects: [] },
nuclear: { arm: [], strikes: [] },
markers: { add: [mk('city', 'Strasbourg', 'moselle-rygy')], remove: [] },
};
const next = applyTurnResult(state, result, undefined, centroidOf);
@@ -1987,13 +2003,13 @@ describe('markers (point placement, v0.27 #44)', () => {
const add = [mk('city', 'Strasbourg', 'moselle-rygy')];
const first = applyTurnResult(state, {
date: '1939-09-03', events: [], stateChanges: [], regionChanges: [], relations: [], territoryChanges: [],
unowned: [], nationOps: [], takeovers: [], wars: { declare: [], end: [] }, hybrid: { ops: [] }, economy: { trade: [], embargo: [] }, industry: { projects: [] }, markers: { add, remove: [] },
unowned: [], nationOps: [], takeovers: [], wars: { declare: [], end: [] }, hybrid: { ops: [] }, economy: { trade: [], embargo: [] }, industry: { projects: [] }, nuclear: { arm: [], strikes: [] }, markers: { add, remove: [] },
}, undefined, centroidOf);
expect(first.markers).toHaveLength(1);
// a second turn proposes the exact same marker again
const second = applyTurnResult(first, {
date: '1939-09-05', events: [], stateChanges: [], regionChanges: [], relations: [], territoryChanges: [],
unowned: [], nationOps: [], takeovers: [], wars: { declare: [], end: [] }, hybrid: { ops: [] }, economy: { trade: [], embargo: [] }, industry: { projects: [] }, markers: { add, remove: [] },
unowned: [], nationOps: [], takeovers: [], wars: { declare: [], end: [] }, hybrid: { ops: [] }, economy: { trade: [], embargo: [] }, industry: { projects: [] }, nuclear: { arm: [], strikes: [] }, 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
@@ -2019,6 +2035,7 @@ describe('markers (point placement, v0.27 #44)', () => {
wars: { declare: [], end: [] },
hybrid: { ops: [] },
economy: { trade: [], embargo: [] }, industry: { projects: [] },
nuclear: { arm: [], strikes: [] },
markers: {
add: [
{ type: 'city', name: 'Good', regionKey: 'moselle-rygy' }, // valid + centroid → kept
@@ -2065,6 +2082,7 @@ describe('markers (point placement, v0.27 #44)', () => {
wars: { declare: [], end: [] },
hybrid: { ops: [] },
economy: { trade: [], embargo: [] }, industry: { projects: [] },
nuclear: { arm: [], strikes: [] },
markers: {
add: [
{ type: 'city', name: 'NewA', regionKey: 'new-a' },
@@ -2090,7 +2108,7 @@ describe('markers (point placement, v0.27 #44)', () => {
];
const next = applyTurnResult(state, {
date: '1939-09-03', events: [], stateChanges: [], regionChanges: [], relations: [], territoryChanges: [],
unowned: [], nationOps: [], takeovers: [], wars: { declare: [], end: [] }, hybrid: { ops: [] }, economy: { trade: [], embargo: [] }, industry: { projects: [] }, markers: { add: [], remove: [idA, 'zzzz-not-there'] },
unowned: [], nationOps: [], takeovers: [], wars: { declare: [], end: [] }, hybrid: { ops: [] }, economy: { trade: [], embargo: [] }, industry: { projects: [] }, nuclear: { arm: [], strikes: [] }, 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
@@ -2106,14 +2124,14 @@ describe('markers (point placement, v0.27 #44)', () => {
// 1) uk releases the region → the marker becomes ownerless
const released = applyTurnResult(state, {
date: '1939-09-03', events: [], stateChanges: [], regionChanges: [], relations: [], territoryChanges: [],
unowned: ['moselle-rygy'], nationOps: [], takeovers: [], wars: { declare: [], end: [] }, hybrid: { ops: [] }, economy: { trade: [], embargo: [] }, industry: { projects: [] }, markers: { add: [], remove: [] },
unowned: ['moselle-rygy'], nationOps: [], takeovers: [], wars: { declare: [], end: [] }, hybrid: { ops: [] }, economy: { trade: [], embargo: [] }, industry: { projects: [] }, nuclear: { arm: [], strikes: [] }, markers: { add: [], remove: [] },
}, undefined, centroidOf);
expect(released.markers[0].ownerNationId).toBeNull();
// 2) de re-claims the region → the marker's owner follows to de
const reclaimed = applyTurnResult(released, {
date: '1939-09-05', events: [], stateChanges: [], regionChanges: [], relations: [],
territoryChanges: [{ nation: 'de', gain: ['moselle-rygy'], lose: [] }],
unowned: [], nationOps: [], takeovers: [], wars: { declare: [], end: [] }, hybrid: { ops: [] }, economy: { trade: [], embargo: [] }, industry: { projects: [] }, markers: { add: [], remove: [] },
unowned: [], nationOps: [], takeovers: [], wars: { declare: [], end: [] }, hybrid: { ops: [] }, economy: { trade: [], embargo: [] }, industry: { projects: [] }, nuclear: { arm: [], strikes: [] }, markers: { add: [], remove: [] },
}, undefined, centroidOf);
expect(reclaimed.markers[0].ownerNationId).toBe('de');
});
@@ -2182,6 +2200,7 @@ describe('markers (point placement, v0.27 #44)', () => {
wars: { declare: [], end: [] },
hybrid: { ops: [] },
economy: { trade: [], embargo: [] }, industry: { projects: [] },
nuclear: { arm: [], strikes: [] },
markers: { add: [{ type: 'city', name: 'Strasbourg', regionKey: 'moselle-rygy' }], remove: [] },
});
const { state } = await runTurn({
@@ -2275,6 +2294,7 @@ function mkTurnResult(partial: Partial<TurnResult>): TurnResult {
hybrid: partial.hybrid ?? { ops: [] },
economy: partial.economy ?? { trade: [], embargo: [] },
industry: partial.industry ?? { projects: [] },
nuclear: partial.nuclear ?? { arm: [], strikes: [] },
markers: partial.markers ?? { add: [], remove: [] },
};
}
@@ -2999,6 +3019,7 @@ describe('validateTakeovers (v0.32.0 #54)', () => {
wars: { declare: [], end: [] },
hybrid: { ops: [] },
economy: { trade: [], embargo: [] }, industry: { projects: [] },
nuclear: { arm: [], strikes: [] },
markers: { add: [], remove: [] },
});
expect(withTakeovers).not.toBeNull();
@@ -3386,6 +3407,7 @@ describe('isMeaningful — wars (v0.33.0 #56)', () => {
wars: { declare: [{ sides: ['a', 'b'] }], end: [] },
hybrid: { ops: [] },
economy: { trade: [], embargo: [] }, industry: { projects: [] },
nuclear: { arm: [], strikes: [] },
markers: { add: [], remove: [] },
}),
).toBe(true);
@@ -3406,6 +3428,7 @@ describe('isMeaningful — wars (v0.33.0 #56)', () => {
wars: { declare: [], end: [{ id: 'w1', outcome: 'armistice' }] },
hybrid: { ops: [] },
economy: { trade: [], embargo: [] }, industry: { projects: [] },
nuclear: { arm: [], strikes: [] },
markers: { add: [], remove: [] },
}),
).toBe(true);
@@ -3426,6 +3449,7 @@ describe('isMeaningful — wars (v0.33.0 #56)', () => {
wars: { declare: [], end: [] },
hybrid: { ops: [] },
economy: { trade: [], embargo: [] }, industry: { projects: [] },
nuclear: { arm: [], strikes: [] },
markers: { add: [], remove: [] },
}),
).toBe(false);
@@ -4349,3 +4373,274 @@ describe('runTurn — industry (v0.36.0 #59)', () => {
expect(state.industry).toEqual([]);
});
});
// --- Nuclear / WMD (v0.37.0, #60) -------------------------------------------
describe('isNuclearCapable (v0.37.0 #60)', () => {
const marker = (id: string, type: MarkerType, ownerNationId: string | null): MapMarker =>
({ id, type, name: 'site', regionKey: 'g1', lat: 0, lon: 0, ownerNationId, placedTurn: 1 });
const n = (id: string, nuclear?: boolean): Nation =>
nuclear ? { ...mkNation(id, NS(50, 50, 50, 50), []), nuclear } : mkNation(id, NS(50, 50, 50, 50), []);
it('is true when the nuclear flag is set', () => {
expect(isNuclearCapable(n('uk', true), [])).toBe(true);
});
it('is true when the nation owns a rocket marker (a launch site is an arsenal)', () => {
expect(isNuclearCapable(n('uk'), [marker('m1', 'rocket', 'uk')])).toBe(true);
});
it('is false when the flag is unset and no rocket marker is owned', () => {
expect(isNuclearCapable(n('uk'), [marker('m1', 'city', 'uk')])).toBe(false);
});
it('a rocket marker owned by another nation does not confer capability', () => {
expect(isNuclearCapable(n('uk'), [marker('m1', 'rocket', 'de')])).toBe(false);
});
it('a rocket marker with a null owner does not confer capability', () => {
expect(isNuclearCapable(n('uk'), [marker('m1', 'rocket', null)])).toBe(false);
});
});
describe('validateNuclearOps (v0.37.0 #60)', () => {
it('accepts well-formed arm + strikes', () => {
const out = validateNuclearOps({ arm: ['uk'], strikes: [{ from: 'uk', target: 'de' }] });
expect(out).toEqual({ arm: ['uk'], strikes: [{ from: 'uk', target: 'de' }] });
});
it('dedupes arm refs and drops empty / non-string entries', () => {
const out = validateNuclearOps({ arm: ['uk', 'uk', '', ' ', 'de', null, 5] as unknown[] });
expect(out.arm).toEqual(['uk', 'de']);
});
it('drops a strike missing a from or a target', () => {
const out = validateNuclearOps({
strikes: [
{ target: 'de' } as unknown as NuclearStrike, // no from
{ from: 'uk' } as unknown as NuclearStrike, // no target
'not-an-object' as unknown as NuclearStrike,
null as unknown as NuclearStrike,
{ from: 'uk', target: 'g1' }, // valid
],
});
expect(out.strikes).toEqual([{ from: 'uk', target: 'g1' }]);
});
it('drops a self-target (from === target, case-insensitive)', () => {
const out = validateNuclearOps({ strikes: [{ from: 'UK', target: 'uk' }, { from: 'uk', target: 'de' }] });
expect(out.strikes).toEqual([{ from: 'uk', target: 'de' }]);
});
it('a missing / malformed top-level object yields { arm: [], strikes: [] }', () => {
expect(validateNuclearOps(undefined)).toEqual({ arm: [], strikes: [] });
expect(validateNuclearOps(null)).toEqual({ arm: [], strikes: [] });
expect(validateNuclearOps('nope')).toEqual({ arm: [], strikes: [] });
expect(validateNuclearOps({})).toEqual({ arm: [], strikes: [] });
expect(validateNuclearOps({ arm: 'x', strikes: 'y' })).toEqual({ arm: [], strikes: [] });
});
});
describe('applyTurnResult — nuclear ops (v0.37.0 #60)', () => {
// uk starts nuclear-capable (the flag); de is not. Each owns one region.
const base = (): GameState => {
const uk: Nation = { ...mkNation('uk', NS(50, 50, 50, 50), ['g1']), nuclear: true };
const de: Nation = { ...mkNation('de', NS(50, 50, 50, 50), ['d1']) };
return mkTurnState({ uk, de }, { g1: RS(50, 50, 50, 50), d1: RS(50, 50, 50, 50) });
};
it('arming a nation sets its nuclear flag (idempotent across turns)', () => {
const next = applyTurnResult(base(), mkTurnResult({ nuclear: { arm: ['uk', 'de'], strikes: [] } }));
expect(next.nations.uk.nuclear).toBe(true);
expect(next.nations.de.nuclear).toBe(true);
const next2 = applyTurnResult(next, mkTurnResult({ nuclear: { arm: ['uk'], strikes: [] } }));
expect(next2.nations.uk.nuclear).toBe(true);
expect(next2.nations.de.nuclear).toBe(true); // unchanged
});
it('a nation that arms this turn is capable and can strike in the same turn', () => {
const s = base();
delete (s.nations.uk as { nuclear?: boolean }).nuclear; // start unarmed
const next = applyTurnResult(s, mkTurnResult({
nuclear: { arm: ['uk'], strikes: [{ from: 'uk', target: 'd1' }] },
}));
expect(next.nations.uk.nuclear).toBe(true);
expect(next.unowned).toContain('d1'); // the armed-this-turn strike landed
});
it('a region strike releases the region, deletes its stats, and collapses the owner', () => {
const next = applyTurnResult(base(), mkTurnResult({
nuclear: { arm: [], strikes: [{ from: 'uk', target: 'd1' }] },
}));
expect(next.nations.de.territory).toEqual([]);
expect(next.unowned).toContain('d1');
expect(next.regionStats['d1']).toBeUndefined();
expect(next.regionStats['g1']).toBeDefined();
expect(next.nations.de.stats.economy).toBe(50 + NUCLEAR_COLLAPSE.economy);
expect(next.nations.de.stats.military).toBe(50 + NUCLEAR_COLLAPSE.military);
expect(next.nations.de.stats.stability).toBe(50 + NUCLEAR_COLLAPSE.stability);
expect(next.nations.de.stats.population).toBe(50);
// de is not capable → no MAD, the striker is unharmed
expect(next.nations.uk.stats.military).toBe(50);
expect(next.nations.uk.stats.economy).toBe(50);
expect(next.events).toHaveLength(1);
expect(next.events[0].type).toBe('military');
expect(next.events[0].title).toBe('Nuclear strike on d1');
});
it('a nation strike devastates the target and ends a shared war in capitulation (relation → hostile)', () => {
const s = base();
s.wars = [{ id: 'w1', sides: ['uk', 'de'], declaredTurn: 1, declaredDate: '1939-09-01', status: 'active' }];
const next = applyTurnResult(s, mkTurnResult({
nuclear: { arm: [], strikes: [{ from: 'uk', target: 'de' }] },
}));
expect(next.nations.de.stats.military).toBe(50 + NUCLEAR_COLLAPSE.military);
expect(next.wars.find((w) => w.id === 'w1')?.status).toBe('capitulation');
expect(next.nations.uk.relations.de).toBe('hostile');
expect(next.nations.de.relations.uk).toBe('hostile');
// de is not capable → no MAD
expect(next.nations.uk.stats.military).toBe(50);
});
it('a nation strike ends a war declared in the same turn (the sync-then-end ordering)', () => {
const next = applyTurnResult(base(), mkTurnResult({
wars: { declare: [{ sides: ['uk', 'de'] }], end: [] },
nuclear: { arm: [], strikes: [{ from: 'uk', target: 'de' }] },
}));
const w = next.wars.find((w) => w.sides.includes('uk') && w.sides.includes('de'));
expect(w?.status).toBe('capitulation');
expect(next.nations.uk.relations.de).toBe('hostile');
expect(next.nations.de.relations.uk).toBe('hostile');
});
it('a strike on a nuclear-capable target triggers a MAD retaliation on the striker', () => {
const s = base();
s.nations.de.nuclear = true; // the target is capable
const next = applyTurnResult(s, mkTurnResult({
nuclear: { arm: [], strikes: [{ from: 'uk', target: 'de' }] },
}));
expect(next.nations.de.stats.military).toBe(50 + NUCLEAR_COLLAPSE.military);
expect(next.nations.uk.stats.military).toBe(50 + NUCLEAR_RETALIATION.military);
expect(next.nations.uk.stats.economy).toBe(50 + NUCLEAR_RETALIATION.economy);
expect(next.nations.uk.stats.stability).toBe(50 + NUCLEAR_RETALIATION.stability);
expect(next.nations.uk.relations.de).toBe('hostile');
expect(next.nations.de.relations.uk).toBe('hostile');
expect(next.events).toHaveLength(2);
expect(next.events.some((e) => e.title === 'MAD retaliation')).toBe(true);
});
it('a region strike on a nuclear-capable owner triggers a MAD retaliation', () => {
const s = base();
s.nations.de.nuclear = true; // the owner of d1 is capable
const next = applyTurnResult(s, mkTurnResult({
nuclear: { arm: [], strikes: [{ from: 'uk', target: 'd1' }] },
}));
expect(next.unowned).toContain('d1');
expect(next.nations.de.stats.military).toBe(50 + NUCLEAR_COLLAPSE.military);
expect(next.nations.uk.stats.military).toBe(50 + NUCLEAR_RETALIATION.military);
expect(next.nations.uk.relations.de).toBe('hostile');
expect(next.nations.de.relations.uk).toBe('hostile');
expect(next.events).toHaveLength(2);
});
it('a nation with a rocket marker (not the flag) can strike', () => {
const s = base();
delete (s.nations.uk as { nuclear?: boolean }).nuclear; // no flag
s.markers = [{ id: 'm1', type: 'rocket', name: 'site', regionKey: 'g1', lat: 0, lon: 0, ownerNationId: 'uk', placedTurn: 1 }];
const next = applyTurnResult(s, mkTurnResult({
nuclear: { arm: [], strikes: [{ from: 'uk', target: 'd1' }] },
}));
expect(next.unowned).toContain('d1');
});
it('a strike by a non-nuclear-capable nation is dropped (never thrown)', () => {
const s = base();
delete (s.nations.uk as { nuclear?: boolean }).nuclear;
const next = applyTurnResult(s, mkTurnResult({
nuclear: { arm: [], strikes: [{ from: 'uk', target: 'd1' }] },
}));
expect(next.nations.de.territory).toEqual(['d1']);
expect(next.unowned).toEqual([]);
expect(next.nations.de.stats.military).toBe(50);
expect(next.events).toHaveLength(0);
});
it('a strike on a region with two or more owners is dropped (never thrown)', () => {
const s = base();
s.nations.uk.territory = ['g1', 'd1']; // d1 now has two owners (uk, de)
const next = applyTurnResult(s, mkTurnResult({
nuclear: { arm: [], strikes: [{ from: 'uk', target: 'd1' }] },
}));
expect(next.unowned).toEqual([]);
expect(next.nations.de.stats.military).toBe(50);
expect(next.events).toHaveLength(0);
});
it('a strike on an unowned (no-man\'s-land) region is dropped (nothing to devastate)', () => {
const s = base();
s.nations.de.territory = []; // d1 has zero owners
s.unowned = ['d1'];
delete s.regionStats['d1'];
const next = applyTurnResult(s, mkTurnResult({
nuclear: { arm: [], strikes: [{ from: 'uk', target: 'd1' }] },
}));
expect(next.unowned).toEqual(['d1']);
expect(next.nations.uk.stats.military).toBe(50);
expect(next.events).toHaveLength(0);
});
it('a strike whose target is the striker itself is dropped (never thrown)', () => {
const next = applyTurnResult(base(), mkTurnResult({
nuclear: { arm: [], strikes: [{ from: 'uk', target: 'uk' }] },
}));
expect(next.nations.uk.stats.military).toBe(50);
expect(next.events).toHaveLength(0);
});
it('never throws on malformed strikes (unknown from, unknown target, empty strings)', () => {
const next = applyTurnResult(base(), mkTurnResult({
nuclear: {
arm: [],
strikes: [
{ from: 'ghost', target: 'de' },
{ from: 'uk', target: 'nowhere' },
{ from: 'uk', target: '' },
],
},
}));
expect(next.nations.de.stats.military).toBe(50);
expect(next.events).toHaveLength(0);
});
});
describe('isMeaningful — nuclear (v0.37.0 #60)', () => {
it('a turn with only an arm op or a strike is meaningful', () => {
expect(isMeaningful(mkTurnResult({ nuclear: { arm: ['uk'], strikes: [] } }))).toBe(true);
expect(isMeaningful(mkTurnResult({ nuclear: { arm: [], strikes: [{ from: 'uk', target: 'de' }] } }))).toBe(true);
});
it('a turn with empty nuclear ops and nothing else is not meaningful', () => {
expect(isMeaningful(mkTurnResult({ nuclear: { arm: [], strikes: [] } }))).toBe(false);
});
});
describe('buildUserPrompt — nuclear capability (v0.37.0 #60)', () => {
const state = (): GameState =>
mkTurnState(
{
uk: { ...mkNation('uk', NS(50, 50, 50, 50), ['g1']), nuclear: true },
de: mkNation('de', NS(50, 50, 50, 50), ['d1']),
},
{ g1: RS(50, 50, 50, 50), d1: RS(50, 50, 50, 50) },
);
it('stamps each nation\'s nuclear capability when the map is provided', () => {
const out = JSON.parse(
buildUserPrompt(state(), [], undefined, undefined, [], [], undefined, undefined, undefined, undefined, undefined, undefined, {
uk: true,
de: false,
}),
);
expect(out.state.nations.uk.nuclear).toBe(true);
expect(out.state.nations.de.nuclear).toBe(false);
});
it('omits the nuclear capability entirely when the map is undefined', () => {
const out = JSON.parse(
buildUserPrompt(state(), [], undefined, undefined, [], [], undefined, undefined, undefined, undefined, undefined, undefined),
);
expect(out.state.nations.uk).not.toHaveProperty('nuclear');
expect(out.state.nations.de).not.toHaveProperty('nuclear');
});
});