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
CI / build + unit + e2e (pull_request) Successful in 2m33s
This commit is contained in:
+316
-21
@@ -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');
|
||||
});
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user