feat(#56): War as a first-class state — declare/end ops, mutual-defense drag-in, relation sync
CI / build + unit + e2e (pull_request) Successful in 2m32s
CI / build + unit + e2e (pull_request) Successful in 2m32s
Promote the bare 'war' relation to a named War entity in GameState.wars:
- War: { id, sides[], declaredTurn, declaredDate?, aims?, status, front? }
- TurnResult.wars: { declare: WarDeclareOp[], end: WarEndOp[] }
- syncWarRelations: derive 'war' from active wars, fallback to 'hostile' on end
- Mutual-defense drag-in: belligerent allies added to sides (one level)
- Bare 'war' back-compat: never downgraded, never force-created
- Stub: empty wars (e2e unaffected); ~30 new unit tests
v0.33.0
This commit is contained in:
+500
-21
@@ -1,5 +1,5 @@
|
||||
import { describe, expect, it } from 'vitest';
|
||||
import type { GameState, MarkerType, Nation, NationStats, Preset, RegionStats, RoundItem } from '../shared/types';
|
||||
import type { GameState, MarkerType, Nation, NationStats, Preset, RelationStatus, RegionStats, RoundItem, War } from '../shared/types';
|
||||
import {
|
||||
CONQUEST_MILITARY_MARGIN,
|
||||
makeEvent,
|
||||
@@ -14,12 +14,15 @@ import {
|
||||
import type { LlmClient, ToolDefinition, WireMessage } from './llm';
|
||||
import { deterministicTurn, makeStubLlmClient } from './stub';
|
||||
import {
|
||||
activeWarPairs,
|
||||
applyNationOp,
|
||||
applyTurnResult,
|
||||
atWar,
|
||||
buildSystemPrompt,
|
||||
buildUserPrompt,
|
||||
confirmNationOp,
|
||||
freshNationId,
|
||||
freshWarId,
|
||||
GET_NEIGHBOURS_TOOL,
|
||||
isMeaningful,
|
||||
makeNeighboursExecutor,
|
||||
@@ -32,11 +35,13 @@ import {
|
||||
resolveNationRef,
|
||||
resolveRegionKey,
|
||||
runTurn,
|
||||
syncWarRelations,
|
||||
validateMarkerOps,
|
||||
validateNationOps,
|
||||
validateRegionChanges,
|
||||
validateTakeovers,
|
||||
validateTurnResult,
|
||||
validateWarOps,
|
||||
} from './engine';
|
||||
|
||||
/** A fake LlmClient that returns a fixed list of raw responses in order. */
|
||||
@@ -106,6 +111,8 @@ function baseState(): GameState {
|
||||
// (v0.28, #46) every owned region starts as a copy of its owner's stats,
|
||||
// so a fresh state's derived stats equal its nation-level stats.
|
||||
regionStats: seedRegionStats(nations),
|
||||
// (v0.33.0, #56) no wars on a fresh state.
|
||||
wars: [],
|
||||
};
|
||||
}
|
||||
|
||||
@@ -543,7 +550,7 @@ describe('validateNationOps (v0.23 #16 P4a)', () => {
|
||||
relations: [],
|
||||
territoryChanges: [],
|
||||
unowned: [],
|
||||
nationOps: [{ kind: 'rename', nationRef: 'France', to: 'French Republic' }], takeovers: [], markers: { add: [], remove: [] },
|
||||
nationOps: [{ kind: 'rename', nationRef: 'France', to: 'French Republic' }], takeovers: [], wars: { declare: [], end: [] }, markers: { add: [], remove: [] },
|
||||
});
|
||||
expect(withOps).not.toBeNull();
|
||||
expect(withOps!.nationOps).toEqual([{ kind: 'rename', nationRef: 'France', to: 'French Republic' }]);
|
||||
@@ -565,7 +572,7 @@ describe('applyTurnResult (nation-op proposals, v0.23 #16 P4a)', () => {
|
||||
relations: [],
|
||||
territoryChanges: [],
|
||||
unowned: [],
|
||||
nationOps: [{ kind: 'rename', nationRef: 'de', to: 'Reich' }], takeovers: [], markers: { add: [], remove: [] },
|
||||
nationOps: [{ kind: 'rename', nationRef: 'de', to: 'Reich' }], takeovers: [], wars: { declare: [], end: [] }, markers: { add: [], remove: [] },
|
||||
});
|
||||
expect(next.nations.de.name).toBe('DE'); // still the old name
|
||||
expect(next.pendingNationOps).toEqual([{ kind: 'rename', nationRef: 'de', to: 'Reich' }]);
|
||||
@@ -586,7 +593,7 @@ describe('applyTurnResult (nation-op proposals, v0.23 #16 P4a)', () => {
|
||||
relations: [],
|
||||
territoryChanges: [],
|
||||
unowned: [],
|
||||
nationOps: [{ kind: 'rename', nationRef: 'us', to: 'USA' }], takeovers: [], markers: { add: [], remove: [] }, // new proposal this turn
|
||||
nationOps: [{ kind: 'rename', nationRef: 'us', to: 'USA' }], takeovers: [], wars: { declare: [], end: [] }, markers: { add: [], remove: [] }, // new proposal this turn
|
||||
});
|
||||
expect(next.pendingNationOps).toEqual([
|
||||
{ kind: 'rename', nationRef: 'de', to: 'Reich' }, // the old one survived
|
||||
@@ -605,7 +612,7 @@ describe('applyTurnResult (nation-op proposals, v0.23 #16 P4a)', () => {
|
||||
relations: [],
|
||||
territoryChanges: [],
|
||||
unowned: [],
|
||||
nationOps: [{ kind: 'rename', nationRef: 'de', to: 'Reich' }], takeovers: [], markers: { add: [], remove: [] },
|
||||
nationOps: [{ kind: 'rename', nationRef: 'de', to: 'Reich' }], takeovers: [], wars: { declare: [], end: [] }, markers: { add: [], remove: [] },
|
||||
});
|
||||
expect(next.pendingNationOps).toEqual([{ kind: 'rename', nationRef: 'de', to: 'Reich' }]);
|
||||
});
|
||||
@@ -1062,7 +1069,7 @@ describe('applyTurnResult', () => {
|
||||
relations: [],
|
||||
territoryChanges: [],
|
||||
unowned: [],
|
||||
nationOps: [], takeovers: [], markers: { add: [], remove: [] },
|
||||
nationOps: [], takeovers: [], wars: { declare: [], end: [] }, markers: { add: [], remove: [] },
|
||||
};
|
||||
applyTurnResult(state, result);
|
||||
expect(JSON.stringify(state)).toBe(snapshot);
|
||||
@@ -1079,7 +1086,7 @@ describe('applyTurnResult', () => {
|
||||
relations: [],
|
||||
territoryChanges: [],
|
||||
unowned: [],
|
||||
nationOps: [], takeovers: [], markers: { add: [], remove: [] },
|
||||
nationOps: [], takeovers: [], wars: { declare: [], end: [] }, markers: { add: [], remove: [] },
|
||||
};
|
||||
const next = applyTurnResult(state, result);
|
||||
expect(next.pending).toEqual([]);
|
||||
@@ -1102,7 +1109,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: [], markers: { add: [], remove: [] },
|
||||
nationOps: [], takeovers: [], wars: { declare: [], end: [] }, 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
|
||||
@@ -1128,7 +1135,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: [], markers: { add: [], remove: [] },
|
||||
nationOps: [], takeovers: [], wars: { declare: [], end: [] }, markers: { add: [], remove: [] },
|
||||
};
|
||||
const next = applyTurnResult(state, result); // no validKeys -> nothing filtered
|
||||
expect(next.nations.de.territory).toContain('wales-bbbb');
|
||||
@@ -1146,7 +1153,7 @@ describe('applyTurnResult (sole-ownership invariants, v0.21 #16 P2)', () => {
|
||||
regionChanges: [],
|
||||
relations: [],
|
||||
unowned: [],
|
||||
nationOps: [], takeovers: [], markers: { add: [], remove: [] },
|
||||
nationOps: [], takeovers: [], wars: { declare: [], end: [] }, markers: { add: [], remove: [] },
|
||||
};
|
||||
|
||||
it('a double claim (two different gainers, one key) is rejected: the key stays with its original owner', () => {
|
||||
@@ -1236,7 +1243,7 @@ describe('applyTurnResult (no man\'s land, v0.22 #16 P3)', () => {
|
||||
relations: [],
|
||||
territoryChanges: [],
|
||||
unowned: [],
|
||||
nationOps: [], takeovers: [], markers: { add: [], remove: [] },
|
||||
nationOps: [], takeovers: [], wars: { declare: [], end: [] }, markers: { add: [], remove: [] },
|
||||
};
|
||||
|
||||
it('a release removes the key from its sole owner and marks it in GameState.unowned', () => {
|
||||
@@ -1351,7 +1358,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: [], markers: { add: [], remove: [] } }),
|
||||
isMeaningful({ date: 'x', events: [], stateChanges: [], regionChanges: [], relations: [], territoryChanges: [], unowned: ['GBR'], nationOps: [], takeovers: [], wars: { declare: [], end: [] }, markers: { add: [], remove: [] } }),
|
||||
).toBe(true);
|
||||
});
|
||||
|
||||
@@ -1658,9 +1665,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: [], 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: [], markers: { add: [], remove: [] } })).toBe(true);
|
||||
expect(isMeaningful({ date: 'x', events: [], stateChanges: [], regionChanges: [], relations: [], territoryChanges: [], unowned: [], nationOps: [], takeovers: [], 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: [] }, 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);
|
||||
});
|
||||
|
||||
it('(v0.23 #16 P4a) a turn that only proposes a nation-op is meaningful (it survives the retry/nudge path)', () => {
|
||||
@@ -1673,7 +1680,7 @@ describe('isMeaningful', () => {
|
||||
relations: [],
|
||||
territoryChanges: [],
|
||||
unowned: [],
|
||||
nationOps: [{ kind: 'rename', nationRef: 'de', to: 'Reich' }], takeovers: [], markers: { add: [], remove: [] },
|
||||
nationOps: [{ kind: 'rename', nationRef: 'de', to: 'Reich' }], takeovers: [], wars: { declare: [], end: [] }, markers: { add: [], remove: [] },
|
||||
}),
|
||||
).toBe(true);
|
||||
});
|
||||
@@ -1690,6 +1697,7 @@ describe('isMeaningful', () => {
|
||||
unowned: [],
|
||||
nationOps: [],
|
||||
takeovers: [],
|
||||
wars: { declare: [], end: [] },
|
||||
markers: { add: [{ type: 'city', name: 'Lyon', regionKey: 'rhone-rhne' }], remove: [] },
|
||||
}),
|
||||
).toBe(true);
|
||||
@@ -1707,6 +1715,7 @@ describe('isMeaningful', () => {
|
||||
unowned: [],
|
||||
nationOps: [],
|
||||
takeovers: [{ nationRef: 'de', newRegimeName: 'The Reich' }],
|
||||
wars: { declare: [], end: [] },
|
||||
markers: { add: [], remove: [] },
|
||||
}),
|
||||
).toBe(true);
|
||||
@@ -1726,6 +1735,7 @@ describe('takeovers (regime change, v0.32.0 #54)', () => {
|
||||
unowned: [],
|
||||
nationOps: [],
|
||||
takeovers: [{ nationRef: 'de', newRegimeName: 'The Reich' }],
|
||||
wars: { declare: [], end: [] },
|
||||
markers: { add: [], remove: [] },
|
||||
};
|
||||
const next = applyTurnResult(state, result);
|
||||
@@ -1755,6 +1765,7 @@ describe('takeovers (regime change, v0.32.0 #54)', () => {
|
||||
unowned: [],
|
||||
nationOps: [],
|
||||
takeovers: [{ nationRef: 'uk', newRegimeName: 'The Commonwealth' }],
|
||||
wars: { declare: [], end: [] },
|
||||
markers: { add: [], remove: [] },
|
||||
};
|
||||
const next = applyTurnResult(state, result);
|
||||
@@ -1775,6 +1786,7 @@ describe('takeovers (regime change, v0.32.0 #54)', () => {
|
||||
unowned: [],
|
||||
nationOps: [],
|
||||
takeovers: [{ nationRef: 'nonexistent', newRegimeName: 'New Name' }],
|
||||
wars: { declare: [], end: [] },
|
||||
markers: { add: [], remove: [] },
|
||||
};
|
||||
const next = applyTurnResult(state, result);
|
||||
@@ -1798,6 +1810,7 @@ describe('takeovers (regime change, v0.32.0 #54)', () => {
|
||||
unowned: [],
|
||||
nationOps: [],
|
||||
takeovers: [{ nationRef: 'UK', newRegimeName: 'The Empire' }],
|
||||
wars: { declare: [], end: [] },
|
||||
markers: { add: [], remove: [] },
|
||||
};
|
||||
const next = applyTurnResult(state, result);
|
||||
@@ -1820,6 +1833,7 @@ describe('takeovers (regime change, v0.32.0 #54)', () => {
|
||||
unowned: [],
|
||||
nationOps: [],
|
||||
takeovers: [{ nationRef: 'de', newRegimeName: ' ' }],
|
||||
wars: { declare: [], end: [] },
|
||||
markers: { add: [], remove: [] },
|
||||
});
|
||||
expect(validated!.takeovers).toEqual([]);
|
||||
@@ -1842,6 +1856,7 @@ describe('takeovers (regime change, v0.32.0 #54)', () => {
|
||||
{ nationRef: 'uk', newRegimeName: 'The Commonwealth' },
|
||||
{ nationRef: 'de', newRegimeName: 'The Reich' },
|
||||
],
|
||||
wars: { declare: [], end: [] },
|
||||
markers: { add: [], remove: [] },
|
||||
};
|
||||
const next = applyTurnResult(state, result);
|
||||
@@ -1862,6 +1877,7 @@ describe('takeovers (regime change, v0.32.0 #54)', () => {
|
||||
unowned: [],
|
||||
nationOps: [],
|
||||
takeovers: [{ nationRef: 'de', newRegimeName: 'The Reich' }],
|
||||
wars: { declare: [], end: [] },
|
||||
markers: { add: [], remove: [] },
|
||||
};
|
||||
const next = applyTurnResult(state, result);
|
||||
@@ -1880,6 +1896,7 @@ describe('takeovers (regime change, v0.32.0 #54)', () => {
|
||||
unowned: [],
|
||||
nationOps: [],
|
||||
takeovers: [{ nationRef: 'De', newRegimeName: 'The Reich' }], // 'De' → resolves to 'de' by name
|
||||
wars: { declare: [], end: [] },
|
||||
markers: { add: [], remove: [] },
|
||||
};
|
||||
const next = applyTurnResult(state, result);
|
||||
@@ -1910,6 +1927,7 @@ describe('markers (point placement, v0.27 #44)', () => {
|
||||
unowned: [],
|
||||
nationOps: [],
|
||||
takeovers: [],
|
||||
wars: { declare: [], end: [] },
|
||||
markers: { add: [mk('city', 'Strasbourg', 'moselle-rygy')], remove: [] },
|
||||
};
|
||||
const next = applyTurnResult(state, result, undefined, centroidOf);
|
||||
@@ -1934,13 +1952,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: [], markers: { add, remove: [] },
|
||||
unowned: [], nationOps: [], takeovers: [], wars: { declare: [], end: [] }, 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: [], markers: { add, remove: [] },
|
||||
unowned: [], nationOps: [], takeovers: [], wars: { declare: [], end: [] }, 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
|
||||
@@ -1963,6 +1981,7 @@ describe('markers (point placement, v0.27 #44)', () => {
|
||||
unowned: [],
|
||||
nationOps: [],
|
||||
takeovers: [],
|
||||
wars: { declare: [], end: [] },
|
||||
markers: {
|
||||
add: [
|
||||
{ type: 'city', name: 'Good', regionKey: 'moselle-rygy' }, // valid + centroid → kept
|
||||
@@ -2006,6 +2025,7 @@ describe('markers (point placement, v0.27 #44)', () => {
|
||||
unowned: [],
|
||||
nationOps: [],
|
||||
takeovers: [],
|
||||
wars: { declare: [], end: [] },
|
||||
markers: {
|
||||
add: [
|
||||
{ type: 'city', name: 'NewA', regionKey: 'new-a' },
|
||||
@@ -2031,7 +2051,7 @@ describe('markers (point placement, v0.27 #44)', () => {
|
||||
];
|
||||
const next = applyTurnResult(state, {
|
||||
date: '1939-09-03', events: [], stateChanges: [], regionChanges: [], relations: [], territoryChanges: [],
|
||||
unowned: [], nationOps: [], takeovers: [], markers: { add: [], remove: [idA, 'zzzz-not-there'] },
|
||||
unowned: [], nationOps: [], takeovers: [], wars: { declare: [], end: [] }, 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
|
||||
@@ -2047,14 +2067,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: [], markers: { add: [], remove: [] },
|
||||
unowned: ['moselle-rygy'], nationOps: [], takeovers: [], wars: { declare: [], end: [] }, 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: [], markers: { add: [], remove: [] },
|
||||
unowned: [], nationOps: [], takeovers: [], wars: { declare: [], end: [] }, markers: { add: [], remove: [] },
|
||||
}, undefined, centroidOf);
|
||||
expect(reclaimed.markers[0].ownerNationId).toBe('de');
|
||||
});
|
||||
@@ -2120,6 +2140,7 @@ describe('markers (point placement, v0.27 #44)', () => {
|
||||
territoryChanges: [{ nation: 'uk', gain: ['moselle-rygy'], lose: [] }],
|
||||
unowned: [],
|
||||
nationOps: [],
|
||||
wars: { declare: [], end: [] },
|
||||
markers: { add: [{ type: 'city', name: 'Strasbourg', regionKey: 'moselle-rygy' }], remove: [] },
|
||||
});
|
||||
const { state } = await runTurn({
|
||||
@@ -2189,6 +2210,7 @@ function mkTurnState(
|
||||
pendingNationOps: [],
|
||||
markers: [],
|
||||
regionStats,
|
||||
wars: [],
|
||||
};
|
||||
}
|
||||
|
||||
@@ -2203,6 +2225,7 @@ function mkTurnResult(partial: Partial<TurnResult>): TurnResult {
|
||||
unowned: partial.unowned ?? [],
|
||||
nationOps: partial.nationOps ?? [],
|
||||
takeovers: partial.takeovers ?? [],
|
||||
wars: partial.wars ?? { declare: [], end: [] },
|
||||
markers: partial.markers ?? { add: [], remove: [] },
|
||||
};
|
||||
}
|
||||
@@ -2924,6 +2947,7 @@ describe('validateTakeovers (v0.32.0 #54)', () => {
|
||||
unowned: [],
|
||||
nationOps: [],
|
||||
takeovers: [{ nationRef: 'de', newRegimeName: 'The Reich' }, { nationRef: 'bad', newRegimeName: '' }],
|
||||
wars: { declare: [], end: [] },
|
||||
markers: { add: [], remove: [] },
|
||||
});
|
||||
expect(withTakeovers).not.toBeNull();
|
||||
@@ -2955,3 +2979,458 @@ describe('stub — takeovers (v0.32.0 #54)', () => {
|
||||
expect(result).toHaveProperty('takeovers');
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
// ============================================================================
|
||||
// Wars — first-class state (v0.33.0, #56)
|
||||
// ============================================================================
|
||||
//
|
||||
// A `War` is a named entity in GameState.wars, not just a `war` relation. The
|
||||
// engine (1) creates wars from a turn's `wars.declare` (fresh id, drag-in of
|
||||
// mutual-defense allies), (2) closes them via `wars.end` (armistice /
|
||||
// capitulation), and (3) DERIVES the `war` relation from the ACTIVE wars on
|
||||
// every turn so the relation and the entities can never drift apart. A bare
|
||||
// preset `war` relation with no war entity stays as-is (an implicit, unnamed
|
||||
// war — never force-created, never downgraded).
|
||||
|
||||
/** A test nation with the given relations (empty stats/territory). */
|
||||
const warN = (id: string, relations: Record<string, RelationStatus> = {}): Nation => ({
|
||||
id,
|
||||
name: id.toUpperCase(),
|
||||
type: 'major',
|
||||
stats: NS(50, 50, 50, 50),
|
||||
territory: [],
|
||||
relations,
|
||||
});
|
||||
|
||||
/** A GameState whose `wars` are set explicitly (mkTurnState defaults to []). */
|
||||
function warState(nations: Record<string, Nation>, wars: War[] = []): GameState {
|
||||
const s = mkTurnState(nations, {}, []);
|
||||
s.wars = wars;
|
||||
return s;
|
||||
}
|
||||
|
||||
describe('freshWarId (v0.33.0 #56)', () => {
|
||||
it('returns the first free of w1, w2, ...', () => {
|
||||
expect(freshWarId(new Set())).toBe('w1');
|
||||
expect(freshWarId(new Set(['w1']))).toBe('w2');
|
||||
expect(freshWarId(new Set(['w1', 'w2', 'w3']))).toBe('w4');
|
||||
});
|
||||
});
|
||||
|
||||
describe('validateWarOps (v0.33.0 #56)', () => {
|
||||
it('non-object input yields empty declare/end', () => {
|
||||
expect(validateWarOps(undefined)).toEqual({ declare: [], end: [] });
|
||||
expect(validateWarOps(null)).toEqual({ declare: [], end: [] });
|
||||
expect(validateWarOps('x')).toEqual({ declare: [], end: [] });
|
||||
expect(validateWarOps([])).toEqual({ declare: [], end: [] });
|
||||
});
|
||||
|
||||
it('keeps a declare with >=2 sides (trimmed, deduped) and optional aims', () => {
|
||||
const out = validateWarOps({
|
||||
declare: [{ sides: [' uk ', 'de', ' uk '], aims: ['aim1', 'aim2'] }],
|
||||
});
|
||||
expect(out.declare).toEqual([{ sides: ['uk', 'de'], aims: ['aim1', 'aim2'] }]);
|
||||
});
|
||||
|
||||
it('drops a declare with fewer than 2 distinct sides', () => {
|
||||
const out = validateWarOps({ declare: [{ sides: ['uk'] }, { sides: ['uk', 'uk'] }] });
|
||||
expect(out.declare).toEqual([]);
|
||||
});
|
||||
|
||||
it('caps aims at 5 (each trimmed to 200 chars) and drops empty aims', () => {
|
||||
const long = 'a'.repeat(300);
|
||||
const out = validateWarOps({
|
||||
declare: [{ sides: ['uk', 'de'], aims: [long, '', 'b', 'c', 'd', 'e', 'f'] }],
|
||||
});
|
||||
expect(out.declare).toEqual([
|
||||
{ sides: ['uk', 'de'], aims: ['a'.repeat(200), 'b', 'c', 'd', 'e'] },
|
||||
]);
|
||||
});
|
||||
|
||||
it('keeps a valid end (id, outcome, optional terms) and drops invalid ones', () => {
|
||||
const out = validateWarOps({
|
||||
end: [
|
||||
{ id: 'w1', outcome: 'armistice', terms: 'peace' },
|
||||
{ id: 'w2', outcome: 'capitulation' },
|
||||
{ id: '', outcome: 'armistice' }, // empty id -> dropped
|
||||
{ id: 'w3', outcome: 'truce' }, // bad outcome -> dropped
|
||||
{ id: 'w4', outcome: 'armistice', terms: 'x'.repeat(500) }, // terms capped at 400
|
||||
],
|
||||
});
|
||||
expect(out.end).toEqual([
|
||||
{ id: 'w1', outcome: 'armistice', terms: 'peace' },
|
||||
{ id: 'w2', outcome: 'capitulation' },
|
||||
{ id: 'w4', outcome: 'armistice', terms: 'x'.repeat(400) },
|
||||
]);
|
||||
});
|
||||
});
|
||||
|
||||
describe('validateTurnResult — wars (v0.33.0 #56)', () => {
|
||||
it('defaults wars to empty when the input has none', () => {
|
||||
const r = validateTurnResult({ date: 'x' });
|
||||
expect(r?.wars).toEqual({ declare: [], end: [] });
|
||||
});
|
||||
|
||||
it('carries through a validated declare and end', () => {
|
||||
const r = validateTurnResult({
|
||||
date: 'x',
|
||||
wars: {
|
||||
declare: [{ sides: ['uk', 'de'] }],
|
||||
end: [{ id: 'w1', outcome: 'capitulation' }],
|
||||
},
|
||||
});
|
||||
expect(r?.wars.declare).toEqual([{ sides: ['uk', 'de'] }]);
|
||||
expect(r?.wars.end).toEqual([{ id: 'w1', outcome: 'capitulation' }]);
|
||||
});
|
||||
});
|
||||
|
||||
describe('activeWarPairs / atWar (v0.33.0 #56)', () => {
|
||||
it('a 3-side active war yields all 3 pairs; non-active wars are excluded', () => {
|
||||
const wars: War[] = [
|
||||
{ id: 'w1', sides: ['a', 'b', 'c'], declaredTurn: 1, status: 'active' },
|
||||
{ id: 'w2', sides: ['a', 'd'], declaredTurn: 1, status: 'armistice' },
|
||||
];
|
||||
const pairs = activeWarPairs(wars);
|
||||
expect(pairs.has('a\u0000b')).toBe(true);
|
||||
expect(pairs.has('a\u0000c')).toBe(true);
|
||||
expect(pairs.has('b\u0000c')).toBe(true);
|
||||
expect(pairs.has('a\u0000d')).toBe(false); // w2 is not active
|
||||
expect(atWar(wars, 'a', 'b')).toBe(true);
|
||||
expect(atWar(wars, 'b', 'a')).toBe(true); // symmetric
|
||||
expect(atWar(wars, 'a', 'd')).toBe(false);
|
||||
});
|
||||
|
||||
it('an empty / all-ended set yields no pairs', () => {
|
||||
expect(activeWarPairs([]).size).toBe(0);
|
||||
expect(
|
||||
activeWarPairs([{ id: 'w1', sides: ['a', 'b'], declaredTurn: 1, status: 'capitulation' }]).size,
|
||||
).toBe(0);
|
||||
});
|
||||
});
|
||||
|
||||
describe('syncWarRelations (v0.33.0 #56)', () => {
|
||||
it('sets both directions to war for pairs in an active war (overriding lesser relations)', () => {
|
||||
const s = warState(
|
||||
{ a: warN('a', { b: 'neutral' }), b: warN('b', { a: 'neutral' }) },
|
||||
[{ id: 'w1', sides: ['a', 'b'], declaredTurn: 1, status: 'active' }],
|
||||
);
|
||||
syncWarRelations(s, s);
|
||||
expect(s.nations.a.relations.b).toBe('war');
|
||||
expect(s.nations.b.relations.a).toBe('war');
|
||||
});
|
||||
|
||||
it('downgrades a pair to hostile when the war that tied them ends', () => {
|
||||
const prev = warState(
|
||||
{ a: warN('a', { b: 'war' }), b: warN('b', { a: 'war' }) },
|
||||
[{ id: 'w1', sides: ['a', 'b'], declaredTurn: 1, status: 'active' }],
|
||||
);
|
||||
const next = warState(
|
||||
{ a: warN('a', { b: 'war' }), b: warN('b', { a: 'war' }) },
|
||||
[{ id: 'w1', sides: ['a', 'b'], declaredTurn: 1, status: 'armistice' }],
|
||||
);
|
||||
syncWarRelations(prev, next);
|
||||
expect(next.nations.a.relations.b).toBe('hostile');
|
||||
expect(next.nations.b.relations.a).toBe('hostile');
|
||||
});
|
||||
|
||||
it('leaves a bare war (no active war behind it) untouched', () => {
|
||||
const prev = warState({ a: warN('a', { b: 'war' }), b: warN('b', { a: 'war' }) }, []);
|
||||
const next = warState({ a: warN('a', { b: 'war' }), b: warN('b', { a: 'war' }) }, []);
|
||||
syncWarRelations(prev, next);
|
||||
expect(next.nations.a.relations.b).toBe('war');
|
||||
expect(next.nations.b.relations.a).toBe('war');
|
||||
});
|
||||
|
||||
it('keeps a pair at war when still covered by at least one active war', () => {
|
||||
const prev = warState(
|
||||
{ a: warN('a', { b: 'war' }), b: warN('b', { a: 'war' }) },
|
||||
[
|
||||
{ id: 'w1', sides: ['a', 'b'], declaredTurn: 1, status: 'active' },
|
||||
{ id: 'w2', sides: ['a', 'b'], declaredTurn: 2, status: 'active' },
|
||||
],
|
||||
);
|
||||
const next = warState(
|
||||
{ a: warN('a', { b: 'war' }), b: warN('b', { a: 'war' }) },
|
||||
[
|
||||
{ id: 'w1', sides: ['a', 'b'], declaredTurn: 1, status: 'armistice' },
|
||||
{ id: 'w2', sides: ['a', 'b'], declaredTurn: 2, status: 'active' },
|
||||
],
|
||||
);
|
||||
syncWarRelations(prev, next);
|
||||
expect(next.nations.a.relations.b).toBe('war');
|
||||
expect(next.nations.b.relations.a).toBe('war');
|
||||
});
|
||||
});
|
||||
|
||||
describe('applyTurnResult — wars (v0.33.0 #56)', () => {
|
||||
it('declare creates an active war with a fresh id, stamps turn/date/aims, and derives the war relation', () => {
|
||||
const state = warState({ uk: warN('uk'), de: warN('de') }, []);
|
||||
const result = mkTurnResult({
|
||||
wars: { declare: [{ sides: ['uk', 'de'], aims: ['conquest'] }], end: [] },
|
||||
});
|
||||
const next = applyTurnResult(state, result);
|
||||
expect(next.wars).toHaveLength(1);
|
||||
const w = next.wars[0];
|
||||
expect(w.id).toBe('w1');
|
||||
expect([...w.sides].sort()).toEqual(['de', 'uk']);
|
||||
expect(w.status).toBe('active');
|
||||
expect(w.declaredTurn).toBe(1);
|
||||
expect(w.declaredDate).toBe(next.date); // stamped with the turn's new date
|
||||
expect(w.aims).toEqual(['conquest']);
|
||||
expect(next.nations.uk.relations.de).toBe('war');
|
||||
expect(next.nations.de.relations.uk).toBe('war');
|
||||
});
|
||||
|
||||
it('a second declare gets the next fresh id (w2)', () => {
|
||||
const state = warState(
|
||||
{ a: warN('a'), b: warN('b'), c: warN('c') },
|
||||
[{ id: 'w1', sides: ['a', 'b'], declaredTurn: 1, status: 'active' }],
|
||||
);
|
||||
const result = mkTurnResult({ wars: { declare: [{ sides: ['c', 'a'] }], end: [] } });
|
||||
const next = applyTurnResult(state, result);
|
||||
expect(next.wars).toHaveLength(2);
|
||||
expect(next.wars[1].id).toBe('w2');
|
||||
});
|
||||
|
||||
it('end (armistice) sets the status and downgrades the belligerent pair to hostile', () => {
|
||||
const state = warState(
|
||||
{ uk: warN('uk', { de: 'war' }), de: warN('de', { uk: 'war' }) },
|
||||
[{ id: 'w1', sides: ['uk', 'de'], declaredTurn: 1, status: 'active' }],
|
||||
);
|
||||
const result = mkTurnResult({ wars: { declare: [], end: [{ id: 'w1', outcome: 'armistice' }] } });
|
||||
const next = applyTurnResult(state, result);
|
||||
expect(next.wars[0].status).toBe('armistice');
|
||||
expect(next.nations.uk.relations.de).toBe('hostile');
|
||||
expect(next.nations.de.relations.uk).toBe('hostile');
|
||||
});
|
||||
|
||||
it('end (capitulation) sets the status (relations still fall back to hostile)', () => {
|
||||
const state = warState(
|
||||
{ uk: warN('uk', { de: 'war' }), de: warN('de', { uk: 'war' }) },
|
||||
[{ id: 'w1', sides: ['uk', 'de'], declaredTurn: 1, status: 'active' }],
|
||||
);
|
||||
const result = mkTurnResult({ wars: { declare: [], end: [{ id: 'w1', outcome: 'capitulation' }] } });
|
||||
const next = applyTurnResult(state, result);
|
||||
expect(next.wars[0].status).toBe('capitulation');
|
||||
expect(next.nations.uk.relations.de).toBe('hostile');
|
||||
});
|
||||
|
||||
it('end with an unknown id is a no-op (the war stays active and at war)', () => {
|
||||
const state = warState(
|
||||
{ uk: warN('uk', { de: 'war' }), de: warN('de', { uk: 'war' }) },
|
||||
[{ id: 'w1', sides: ['uk', 'de'], declaredTurn: 1, status: 'active' }],
|
||||
);
|
||||
const result = mkTurnResult({ wars: { declare: [], end: [{ id: 'nope', outcome: 'armistice' }] } });
|
||||
const next = applyTurnResult(state, result);
|
||||
expect(next.wars[0].status).toBe('active');
|
||||
expect(next.nations.uk.relations.de).toBe('war');
|
||||
});
|
||||
|
||||
it('end of an already-ended war is a no-op (its status is not re-changed)', () => {
|
||||
const state = warState(
|
||||
{ uk: warN('uk'), de: warN('de') },
|
||||
[{ id: 'w1', sides: ['uk', 'de'], declaredTurn: 1, status: 'armistice' }],
|
||||
);
|
||||
const result = mkTurnResult({ wars: { declare: [], end: [{ id: 'w1', outcome: 'capitulation' }] } });
|
||||
const next = applyTurnResult(state, result);
|
||||
expect(next.wars[0].status).toBe('armistice');
|
||||
});
|
||||
|
||||
it('mutual-defense drag-in: an ally of a belligerent joins the war (and is now at war with its former ally)', () => {
|
||||
const state = warState(
|
||||
{
|
||||
uk: warN('uk', { fr: 'ally' }),
|
||||
fr: warN('fr', { uk: 'ally' }),
|
||||
de: warN('de'),
|
||||
},
|
||||
[],
|
||||
);
|
||||
const result = mkTurnResult({ wars: { declare: [{ sides: ['uk', 'de'] }], end: [] } });
|
||||
const next = applyTurnResult(state, result);
|
||||
expect(next.wars).toHaveLength(1);
|
||||
expect([...next.wars[0].sides].sort()).toEqual(['de', 'fr', 'uk']); // fr dragged in
|
||||
expect(next.nations.uk.relations.fr).toBe('war');
|
||||
expect(next.nations.fr.relations.uk).toBe('war');
|
||||
expect(next.nations.fr.relations.de).toBe('war');
|
||||
});
|
||||
|
||||
it('a duplicate declare (its sides are all in an existing active war) is skipped', () => {
|
||||
const state = warState(
|
||||
{ uk: warN('uk', { de: 'war' }), de: warN('de', { uk: 'war' }) },
|
||||
[{ id: 'w1', sides: ['uk', 'de'], declaredTurn: 1, status: 'active' }],
|
||||
);
|
||||
const result = mkTurnResult({ wars: { declare: [{ sides: ['uk', 'de'] }], end: [] } });
|
||||
const next = applyTurnResult(state, result);
|
||||
expect(next.wars).toHaveLength(1);
|
||||
expect(next.wars[0].id).toBe('w1');
|
||||
});
|
||||
|
||||
it('a declare whose sides do not all resolve is dropped (fewer than 2 belligerents)', () => {
|
||||
const state = warState({ uk: warN('uk'), de: warN('de') }, []);
|
||||
const result = mkTurnResult({ wars: { declare: [{ sides: ['uk', 'ghost'] }], end: [] } });
|
||||
const next = applyTurnResult(state, result);
|
||||
expect(next.wars).toHaveLength(0);
|
||||
});
|
||||
|
||||
it('a bare preset war (no war entity) is preserved — never force-created, never downgraded', () => {
|
||||
const state = warState(
|
||||
{ uk: warN('uk', { de: 'war' }), de: warN('de', { uk: 'war' }) },
|
||||
[],
|
||||
);
|
||||
const result = mkTurnResult({ wars: { declare: [], end: [] } });
|
||||
const next = applyTurnResult(state, result);
|
||||
expect(next.wars).toHaveLength(0);
|
||||
expect(next.nations.uk.relations.de).toBe('war');
|
||||
expect(next.nations.de.relations.uk).toBe('war');
|
||||
});
|
||||
|
||||
it('a manual relations override on a warring pair is forced back to war while the war is active', () => {
|
||||
const state = warState(
|
||||
{ uk: warN('uk', { de: 'war' }), de: warN('de', { uk: 'war' }) },
|
||||
[{ id: 'w1', sides: ['uk', 'de'], declaredTurn: 1, status: 'active' }],
|
||||
);
|
||||
const result = mkTurnResult({
|
||||
relations: [{ a: 'uk', b: 'de', set: 'neutral' }], // the LLM tries to "make peace"
|
||||
wars: { declare: [], end: [] },
|
||||
});
|
||||
const next = applyTurnResult(state, result);
|
||||
expect(next.nations.uk.relations.de).toBe('war');
|
||||
expect(next.nations.de.relations.uk).toBe('war');
|
||||
});
|
||||
|
||||
it('a war declared and ended in the same turn ends up non-active (and the pair is not at war)', () => {
|
||||
const state = warState(
|
||||
{ uk: warN('uk', { de: 'neutral' }), de: warN('de', { uk: 'neutral' }) },
|
||||
[],
|
||||
);
|
||||
const result = mkTurnResult({
|
||||
wars: {
|
||||
declare: [{ sides: ['uk', 'de'] }],
|
||||
end: [{ id: 'w1', outcome: 'armistice' }], // w1 is the id assigned to the just-declared war
|
||||
},
|
||||
});
|
||||
const next = applyTurnResult(state, result);
|
||||
expect(next.wars).toHaveLength(1);
|
||||
expect(next.wars[0].status).toBe('armistice');
|
||||
// never active across a turn boundary, so the pair keeps its prior relation
|
||||
expect(next.nations.uk.relations.de).toBe('neutral');
|
||||
expect(atWar(next.wars, 'uk', 'de')).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe('isMeaningful — wars (v0.33.0 #56)', () => {
|
||||
it('a declare-only turn is meaningful', () => {
|
||||
expect(
|
||||
isMeaningful({
|
||||
date: 'x',
|
||||
events: [],
|
||||
stateChanges: [],
|
||||
regionChanges: [],
|
||||
relations: [],
|
||||
territoryChanges: [],
|
||||
unowned: [],
|
||||
nationOps: [],
|
||||
takeovers: [],
|
||||
wars: { declare: [{ sides: ['a', 'b'] }], end: [] },
|
||||
markers: { add: [], remove: [] },
|
||||
}),
|
||||
).toBe(true);
|
||||
});
|
||||
|
||||
it('an end-only turn is meaningful', () => {
|
||||
expect(
|
||||
isMeaningful({
|
||||
date: 'x',
|
||||
events: [],
|
||||
stateChanges: [],
|
||||
regionChanges: [],
|
||||
relations: [],
|
||||
territoryChanges: [],
|
||||
unowned: [],
|
||||
nationOps: [],
|
||||
takeovers: [],
|
||||
wars: { declare: [], end: [{ id: 'w1', outcome: 'armistice' }] },
|
||||
markers: { add: [], remove: [] },
|
||||
}),
|
||||
).toBe(true);
|
||||
});
|
||||
|
||||
it('an empty-wars (and otherwise empty) turn is not meaningful', () => {
|
||||
expect(
|
||||
isMeaningful({
|
||||
date: 'x',
|
||||
events: [],
|
||||
stateChanges: [],
|
||||
regionChanges: [],
|
||||
relations: [],
|
||||
territoryChanges: [],
|
||||
unowned: [],
|
||||
nationOps: [],
|
||||
takeovers: [],
|
||||
wars: { declare: [], end: [] },
|
||||
markers: { add: [], remove: [] },
|
||||
}),
|
||||
).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe('buildSystemPrompt — wars contract (v0.33.0 #56)', () => {
|
||||
it('documents the wars field and its constraints in both modes', () => {
|
||||
for (const withTools of [false, true]) {
|
||||
const p = buildSystemPrompt(preset, withTools);
|
||||
expect(p).toContain('"wars": {"declare":');
|
||||
expect(p).toContain('"end":[{"id":"<war id>"');
|
||||
expect(p).toContain('Wars ("wars") are named conflicts');
|
||||
expect(p).toContain('mutual-defense allies');
|
||||
expect(p).toContain('at most 1-2 declarations per turn');
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
describe('buildUserPrompt — wars (v0.33.0 #56)', () => {
|
||||
it('carries the active wars (id, sides, aims) and omits ended ones', () => {
|
||||
const state = warState(
|
||||
{ uk: warN('uk'), de: warN('de') },
|
||||
[
|
||||
{ id: 'w1', sides: ['uk', 'de'], declaredTurn: 1, status: 'active', aims: ['conquest'] },
|
||||
{ id: 'w2', sides: ['uk', 'de'], declaredTurn: 2, status: 'armistice' },
|
||||
],
|
||||
);
|
||||
// mirror runTurn: only active wars are surfaced, with aims when present
|
||||
const wars = (state.wars ?? [])
|
||||
.filter((w) => w.status === 'active')
|
||||
.map((w) => ({
|
||||
id: w.id,
|
||||
sides: w.sides,
|
||||
...(w.aims && w.aims.length > 0 ? { aims: w.aims } : {}),
|
||||
}));
|
||||
const out = JSON.parse(buildUserPrompt(state, [], undefined, undefined, [], [], wars));
|
||||
expect(out.wars).toEqual([{ id: 'w1', sides: ['uk', 'de'], aims: ['conquest'] }]);
|
||||
});
|
||||
|
||||
it('omits the wars field entirely when the param is undefined', () => {
|
||||
const state = warState({ uk: warN('uk') }, []);
|
||||
const out = JSON.parse(buildUserPrompt(state, [], undefined, undefined, [], [], undefined));
|
||||
expect(out).not.toHaveProperty('wars');
|
||||
});
|
||||
});
|
||||
|
||||
describe('stub — wars (v0.33.0 #56)', () => {
|
||||
it('the deterministic stub returns empty wars (it never declares or ends a war)', () => {
|
||||
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.wars).toEqual({ declare: [], end: [] });
|
||||
});
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user