CI / build + unit + e2e (pull_request) Successful in 2m33s
Add a per-nation free-form ideology (form of government) field and a
TurnResult.ideology lever (coups / revolutions / reforms / constitutional
changes) with a deterministic -5 stability dip and political narration.
- shared/types.ts: Nation.ideology?: string (optional/additive)
- shared/protocol.ts: IdeologyChange / IdeologyOps, IDEOLOGY_STABILITY_EFFECT
= -5, TurnResult.ideology (required, between leaders and markers),
nationForPrompt now carries ideology
- server/engine.ts: validateIdeologyOps (exported, never throws),
applyIdeologyChange (resolve -> set field -> -5 dip mirrored to regions +
re-derived -> political event), ideology block in applyTurnResult after
leaders / before markers, isMeaningful + validateTurnResult + system prompt
+ runTurn unclear fallback
- server/stub.ts: StubPayload gains ideology; deterministicTurn returns
ideology: { changes: [] } (never changes one, e2e unaffected)
- Tests: engine.test.ts (validate / isMeaningful / applyTurnResult /
buildUserPrompt), protocol.test.ts (constant + nationForPrompt),
db.test.ts (legacy load + round-trip); every TurnResult fixture updated
- package.json -> 0.39.0; CHANGELOG v0.39.0 entry
5037 lines
210 KiB
TypeScript
5037 lines
210 KiB
TypeScript
import { describe, expect, it } from 'vitest';
|
||
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';
|
||
import { deterministicTurn, makeStubLlmClient } from './stub';
|
||
import {
|
||
activeWarPairs,
|
||
applyIndustryEffects,
|
||
applyNationOp,
|
||
applyTurnResult,
|
||
atWar,
|
||
buildSystemPrompt,
|
||
buildUserPrompt,
|
||
confirmNationOp,
|
||
freshNationId,
|
||
freshWarId,
|
||
GET_NEIGHBOURS_TOOL,
|
||
HYBRID_STAT_EFFECT,
|
||
isHybridOpType,
|
||
isMeaningful,
|
||
makeNeighboursExecutor,
|
||
NEW_NATION_STATS,
|
||
parseTurnResult,
|
||
playerFrontier,
|
||
queueRoundItem,
|
||
rejectNationOp,
|
||
removeRoundItem,
|
||
resolveNationRef,
|
||
resolveRegionKey,
|
||
runTurn,
|
||
shiftRelationTowardHostile,
|
||
syncWarRelations,
|
||
validateEconomyOps,
|
||
validateHybridOps,
|
||
validateIdeologyOps,
|
||
validateIndustryOps,
|
||
validateLeaderOps,
|
||
validateMarkerOps,
|
||
validateNuclearOps,
|
||
validateNationOps,
|
||
validateRegionChanges,
|
||
validateTakeovers,
|
||
validateTurnResult,
|
||
validateWarOps,
|
||
} from './engine';
|
||
|
||
/** A fake LlmClient that returns a fixed list of raw responses in order. */
|
||
function fakeLlm(responses: string[]): LlmClient {
|
||
let i = 0;
|
||
return {
|
||
chat: async () => responses[Math.min(i++, responses.length - 1)],
|
||
listModels: async () => [],
|
||
};
|
||
}
|
||
|
||
const preset: Preset = {
|
||
id: 'test',
|
||
title: 'Test',
|
||
startDate: '1939-09-01',
|
||
summary: '',
|
||
worldBackground: 'A test world.',
|
||
simulationRules: ['Prefer gradual change'],
|
||
nations: [
|
||
{
|
||
id: 'uk',
|
||
name: 'UK',
|
||
type: 'major',
|
||
stats: { military: 80, economy: 70, stability: 60, population: 50 },
|
||
territory: ['GBR'],
|
||
relations: { us: 'neutral', de: 'hostile' },
|
||
},
|
||
{
|
||
id: 'us',
|
||
name: 'US',
|
||
type: 'major',
|
||
stats: { military: 60, economy: 90, stability: 80, population: 80 },
|
||
territory: ['USA'],
|
||
relations: { uk: 'neutral' },
|
||
},
|
||
{
|
||
id: 'de',
|
||
name: 'DE',
|
||
type: 'major',
|
||
stats: { military: 95, economy: 70, stability: 75, population: 90 },
|
||
territory: ['DEU'],
|
||
relations: {},
|
||
},
|
||
],
|
||
};
|
||
|
||
function baseState(): GameState {
|
||
const nations: GameState['nations'] = {
|
||
uk: { ...preset.nations[0], stats: { ...preset.nations[0].stats }, territory: [...preset.nations[0].territory], relations: { ...preset.nations[0].relations } },
|
||
us: { ...preset.nations[1], stats: { ...preset.nations[1].stats }, territory: [...preset.nations[1].territory], relations: { ...preset.nations[1].relations } },
|
||
de: { ...preset.nations[2], stats: { ...preset.nations[2].stats }, territory: [...preset.nations[2].territory], relations: { ...preset.nations[2].relations } },
|
||
};
|
||
return {
|
||
id: 'g1',
|
||
presetId: 'test',
|
||
presetTitle: 'Test',
|
||
playerNationId: 'uk',
|
||
turn: 1,
|
||
date: '1939-09-01',
|
||
nations,
|
||
events: [],
|
||
pending: [],
|
||
version: 1,
|
||
unowned: [],
|
||
pendingNationOps: [],
|
||
markers: [],
|
||
// (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: [],
|
||
// (v0.35.0, #58) no active trades or embargoes on a fresh state.
|
||
trades: [],
|
||
embargoes: [],
|
||
// (v0.36.0, #59) no industry projects on a fresh state.
|
||
industry: [],
|
||
};
|
||
}
|
||
|
||
const validResult = JSON.stringify({
|
||
date: '1939-09-03',
|
||
events: [
|
||
{ nation: 'uk', type: 'military', title: 'T-26s cross the border', text: 'The border is breached.', outcome: 'success' },
|
||
],
|
||
stateChanges: [
|
||
{ nation: 'uk', stat: 'military', op: 'set', value: 85 },
|
||
{ nation: 'de', stat: 'economy', op: 'add', value: 10 },
|
||
],
|
||
relations: [{ a: 'uk', b: 'us', set: 'ally' }],
|
||
// v0.21 (#16 P2): DE gains the UK's GBR — the key has exactly one previous
|
||
// owner, so the transfer applies (since v0.22 #16 P3 a key owned by NOBODY
|
||
// would be re-claimed instead of ignored; a release lives in "unowned").
|
||
territoryChanges: [{ nation: 'de', gain: ['GBR'], lose: [] }],
|
||
});
|
||
|
||
describe('runTurn', () => {
|
||
it('applies a canned TurnResult: stats, symmetric relations, territory, event, turn, date, pending cleared', async () => {
|
||
const original = baseState();
|
||
const { state, result } = await runTurn({
|
||
llm: fakeLlm([validResult]),
|
||
preset,
|
||
state: original,
|
||
items: [{ kind: 'action', text: 'We rearm the coast' }],
|
||
});
|
||
|
||
expect(state.turn).toBe(2); // the turn counter advanced
|
||
expect(state.date).toBe('1939-09-03');
|
||
expect(state.pending).toEqual([]); // committing a round always clears it
|
||
expect(state.nations.uk.stats.military).toBe(85);
|
||
expect(state.nations.de.stats.economy).toBe(80); // 70 + 10 (add)
|
||
// relations are symmetric
|
||
expect(state.nations.uk.relations.us).toBe('ally');
|
||
expect(state.nations.us.relations.uk).toBe('ally');
|
||
// territory moved to DE: it gains GBR (the UK's sole previous owner) and
|
||
// the old owner correspondingly loses it (v0.21 #16 P2: one owner per key)
|
||
expect(state.nations.de.territory).toContain('GBR');
|
||
expect(state.nations.uk.territory).not.toContain('GBR');
|
||
// one event appended, tagged with the turn that was resolved (1)
|
||
expect(state.events).toHaveLength(1);
|
||
expect(state.events[0].turn).toBe(1);
|
||
expect(state.events[0].title).toBe('T-26s cross the border');
|
||
expect(state.events[0].nation).toBe('uk');
|
||
// the return value carries the validated result too
|
||
expect(result.events[0].title).toBe('T-26s cross the border');
|
||
// and the original state object is NOT mutated
|
||
expect(original.turn).toBe(1);
|
||
expect(original.nations.uk.stats.military).toBe(80);
|
||
expect(original.nations.de.territory).not.toContain('GBR');
|
||
expect(original.nations.uk.territory).toContain('GBR');
|
||
});
|
||
|
||
it('clamps out-of-range stat values into 0-100', async () => {
|
||
const clampResult = JSON.stringify({
|
||
date: '1939-09-03',
|
||
events: [{ nation: 'uk', type: 'economic', title: 'cap', text: 'x', outcome: 'neutral' }],
|
||
stateChanges: [
|
||
{ nation: 'uk', stat: 'military', op: 'set', value: 250 },
|
||
{ nation: 'uk', stat: 'stability', op: 'set', value: -20 },
|
||
],
|
||
relations: [],
|
||
territoryChanges: [],
|
||
});
|
||
const { state } = await runTurn({
|
||
llm: fakeLlm([clampResult]),
|
||
preset,
|
||
state: baseState(),
|
||
items: [],
|
||
});
|
||
expect(state.nations.uk.stats.military).toBe(100);
|
||
expect(state.nations.uk.stats.stability).toBe(0);
|
||
});
|
||
|
||
it('survives an invalid-JSON response: no throw, advances turn, emits a generic event', async () => {
|
||
const { state, result } = await runTurn({
|
||
llm: fakeLlm(['total garbage', 'still not json']),
|
||
preset,
|
||
state: baseState(),
|
||
items: [],
|
||
});
|
||
expect(state.turn).toBe(2);
|
||
expect(state.events).toHaveLength(1);
|
||
expect(state.events[0].title).toBe('The turn was unclear');
|
||
expect(result.events[0].title).toBe('The turn was unclear');
|
||
});
|
||
|
||
it('treats a valid-but-empty result as unclear (not meaningful)', async () => {
|
||
const emptyResult = JSON.stringify({
|
||
date: '1939-09-03',
|
||
events: [],
|
||
stateChanges: [],
|
||
relations: [],
|
||
territoryChanges: [],
|
||
});
|
||
const { state } = await runTurn({
|
||
llm: fakeLlm([emptyResult, emptyResult]),
|
||
preset,
|
||
state: baseState(),
|
||
items: [],
|
||
});
|
||
expect(state.turn).toBe(2);
|
||
expect(state.events[0].title).toBe('The turn was unclear');
|
||
});
|
||
|
||
it('"jump forward" (no action) still advances the turn and emits an event', async () => {
|
||
const state = baseState();
|
||
state.turn = 3; // start mid-game
|
||
const { state: next } = await runTurn({
|
||
llm: fakeLlm([validResult]),
|
||
preset,
|
||
state, // no items = an empty round / jump forward
|
||
items: [],
|
||
});
|
||
expect(next.turn).toBe(4);
|
||
expect(next.events).toHaveLength(1);
|
||
});
|
||
});
|
||
|
||
describe('runTurn (batched round, deterministic stub)', () => {
|
||
it('a single action item: turn+1, date+2, pending cleared, one military event, military set to clamp(cur+5)', async () => {
|
||
const original = baseState();
|
||
original.pending = [{ kind: 'action', text: 'Fortify the coast and mobilize the home guard' }];
|
||
const { state, result } = await runTurn({
|
||
llm: makeStubLlmClient(),
|
||
preset,
|
||
state: original,
|
||
items: original.pending,
|
||
});
|
||
expect(state.turn).toBe(2);
|
||
expect(state.date).toBe('1939-09-03');
|
||
expect(state.pending).toEqual([]);
|
||
expect(state.nations.uk.stats.military).toBe(85); // clamp(80 + 5)
|
||
expect(state.events).toHaveLength(1);
|
||
expect(state.events[0].type).toBe('military');
|
||
expect(state.events[0].nation).toBe('uk');
|
||
expect(state.events[0].outcome).toBe('success');
|
||
expect(result.stateChanges).toEqual([{ nation: 'uk', stat: 'military', op: 'set', value: 85 }]);
|
||
// committing a turn is a new revision too
|
||
expect(state.version).toBe(2);
|
||
// the original object is not mutated (a fresh queue was consumed)
|
||
expect(original.pending).toHaveLength(1);
|
||
expect(original.version).toBe(1);
|
||
});
|
||
|
||
it('an empty round: turn advances, one political "world moves on" event, pending stays empty', async () => {
|
||
const { state } = await runTurn({
|
||
llm: makeStubLlmClient(),
|
||
preset,
|
||
state: baseState(), // pending: []
|
||
items: [],
|
||
});
|
||
expect(state.turn).toBe(2);
|
||
expect(state.date).toBe('1939-09-03');
|
||
expect(state.pending).toEqual([]);
|
||
expect(state.events).toHaveLength(1);
|
||
expect(state.events[0].type).toBe('political');
|
||
expect(state.events[0].title).toBe('The world moves on');
|
||
expect(state.events[0].outcome).toBe('neutral');
|
||
});
|
||
|
||
it('a round with two actions and one diplomacy: 3 events (2 military + 1 diplomatic), exactly one turn advance', async () => {
|
||
const state = baseState();
|
||
state.pending = [
|
||
{ kind: 'action', text: 'Reinforce the Atlantic convoys' },
|
||
{ kind: 'action', text: 'Expand the shipyards' },
|
||
{ kind: 'diplomacy', to: 'us', text: 'Propose a mutual defense pact' },
|
||
];
|
||
const { state: next } = await runTurn({
|
||
llm: makeStubLlmClient(),
|
||
preset,
|
||
state,
|
||
items: state.pending,
|
||
});
|
||
expect(next.turn).toBe(2); // a whole batch = one turn
|
||
expect(next.pending).toEqual([]);
|
||
expect(next.events).toHaveLength(3);
|
||
expect(next.events.filter((e) => e.type === 'military')).toHaveLength(2);
|
||
expect(next.events.filter((e) => e.type === 'diplomatic')).toHaveLength(1);
|
||
expect(next.nations.uk.stats.military).toBe(85); // one +5 per turn, not per item
|
||
});
|
||
});
|
||
|
||
describe('buildUserPrompt (batched round)', () => {
|
||
it('includes every queued item (action + diplomacy) in the emitted payload', () => {
|
||
const items: RoundItem[] = [
|
||
{ kind: 'action', text: 'Fortify Dover' },
|
||
{ kind: 'diplomacy', to: 'us', text: 'Propose a pact' },
|
||
];
|
||
const parsed = JSON.parse(buildUserPrompt(baseState(), items)) as {
|
||
pending: RoundItem[];
|
||
playerActions: unknown[];
|
||
roundInstruction: string;
|
||
};
|
||
expect(parsed.pending).toEqual(items);
|
||
expect(parsed.playerActions).toEqual(['Fortify Dover', { to: 'us', message: 'Propose a pact' }]);
|
||
expect(parsed.roundInstruction).toContain('Fortify Dover');
|
||
expect(parsed.roundInstruction).toContain('Propose a pact');
|
||
});
|
||
|
||
it('frames an empty round as "the world advances on its own momentum"', () => {
|
||
const parsed = JSON.parse(buildUserPrompt(baseState(), [])) as {
|
||
pending: RoundItem[];
|
||
roundInstruction: string;
|
||
};
|
||
expect(parsed.pending).toEqual([]);
|
||
expect(parsed.roundInstruction).toMatch(/no action/i);
|
||
expect(parsed.roundInstruction).toMatch(/own momentum/i);
|
||
});
|
||
|
||
it('caps recentEvents at the last 5 (in order) when the state has more than 5 events', () => {
|
||
const state = baseState();
|
||
const events = Array.from({ length: 8 }, (_, i) =>
|
||
makeEvent(
|
||
{
|
||
nation: 'uk',
|
||
type: 'political',
|
||
title: `Event ${i + 1}`,
|
||
text: `Narrative ${i + 1}`,
|
||
outcome: 'neutral',
|
||
},
|
||
i + 1,
|
||
`1939-09-0${i + 1}`,
|
||
),
|
||
);
|
||
state.events = events;
|
||
|
||
const parsed = JSON.parse(buildUserPrompt(state, [])) as {
|
||
state: { recentEvents: Array<{ title: string; turn: number; date: string }> };
|
||
};
|
||
|
||
expect(parsed.state.recentEvents).toHaveLength(5);
|
||
// the last 5 of the 8 input events, in order
|
||
expect(parsed.state.recentEvents[0].title).toBe(events[3].title); // 'Event 4'
|
||
expect(parsed.state.recentEvents[4].title).toBe(events[7].title); // 'Event 8'
|
||
expect(parsed.state.recentEvents.map((e) => e.title)).toEqual([
|
||
events[3].title,
|
||
events[4].title,
|
||
events[5].title,
|
||
events[6].title,
|
||
events[7].title,
|
||
]);
|
||
});
|
||
|
||
it('asks the model to keep its internal reasoning brief (concise-CoT constraint)', () => {
|
||
expect(buildSystemPrompt(preset)).toMatch(/reasoning brief|a few sentences/i);
|
||
});
|
||
|
||
it('includes a regions reference table only when one is provided', () => {
|
||
// with a reference table: the payload carries it (region key -> "name (parent)")
|
||
const withRef = JSON.parse(
|
||
buildUserPrompt(baseState(), [], { 'ain-lqtt': 'Ain (FRA)', 'wales-bbbb': 'Wales (GBR)' }),
|
||
) as { regions?: Record<string, string> };
|
||
expect(withRef.regions).toEqual({ 'ain-lqtt': 'Ain (FRA)', 'wales-bbbb': 'Wales (GBR)' });
|
||
// without one: the field is absent entirely (the legacy prompt shape is unchanged)
|
||
const withoutRef = JSON.parse(buildUserPrompt(baseState(), [])) as { regions?: Record<string, string> };
|
||
expect(withoutRef.regions).toBeUndefined();
|
||
});
|
||
|
||
it('(v0.22 #16 P3) emits the no-man\'s-land list when provided (even when empty) and omits it otherwise', () => {
|
||
// runTurn always passes state.unowned (even []), so the field is present...
|
||
const withEmpty = JSON.parse(buildUserPrompt(baseState(), [], undefined, undefined, [])) as {
|
||
unowned?: string[];
|
||
};
|
||
expect(withEmpty.unowned).toEqual([]);
|
||
// ...and a populated list reaches the model verbatim (it knows what it can re-claim)
|
||
const withKeys = JSON.parse(
|
||
buildUserPrompt(baseState(), [], undefined, undefined, ['GBR', 'POL']),
|
||
) as { unowned?: string[] };
|
||
expect(withKeys.unowned).toEqual(['GBR', 'POL']);
|
||
// but a caller that does not provide it keeps the legacy payload shape
|
||
const omitted = JSON.parse(buildUserPrompt(baseState(), [])) as { unowned?: string[] };
|
||
expect(omitted.unowned).toBeUndefined();
|
||
});
|
||
});
|
||
|
||
describe('queueRoundItem', () => {
|
||
it('appends an item to pending without advancing the turn or the date', () => {
|
||
const before = baseState();
|
||
const after = queueRoundItem(before, { kind: 'diplomacy', to: 'us', text: 'hello' });
|
||
expect(after.turn).toBe(before.turn);
|
||
expect(after.date).toBe(before.date);
|
||
expect(after.pending).toHaveLength(1);
|
||
expect(after.pending[0]).toEqual({ kind: 'diplomacy', to: 'us', text: 'hello' });
|
||
// the original state is not mutated
|
||
expect(before.pending).toHaveLength(0);
|
||
expect(before.version).toBe(1);
|
||
});
|
||
|
||
it('bumps the monotonic version on each queue mutation', () => {
|
||
const a = queueRoundItem(baseState(), { kind: 'action', text: 'one' });
|
||
expect(a.version).toBe(2);
|
||
const b = queueRoundItem(a, { kind: 'action', text: 'two' });
|
||
expect(b.version).toBe(3);
|
||
// and never backwards: two consecutive adds from the same base both
|
||
// advance past it, so a late snapshot of the older state is always older.
|
||
const b2 = queueRoundItem(baseState(), { kind: 'action', text: 'two' });
|
||
expect(b2.version).toBe(2);
|
||
expect(b.version).toBeGreaterThan(b2.version);
|
||
});
|
||
|
||
it('treats a legacy state without a version field as version 0', () => {
|
||
const legacy = { ...baseState() } as Omit<GameState, 'version'> & { version?: number };
|
||
delete legacy.version; // a row saved before the version field existed
|
||
// simulate a legacy row coming back from SQLite: the JSON has no field.
|
||
const after = queueRoundItem(legacy as unknown as GameState, {
|
||
kind: 'action',
|
||
text: 'first after upgrade',
|
||
});
|
||
expect(after.version).toBe(1);
|
||
});
|
||
});
|
||
|
||
describe('removeRoundItem', () => {
|
||
it('removes the item at the index, bumps the version, and leaves the input untouched', () => {
|
||
const state = baseState();
|
||
state.pending = [
|
||
{ kind: 'action', text: 'one' },
|
||
{ kind: 'diplomacy', to: 'us', text: 'two' },
|
||
{ kind: 'action', text: 'three' },
|
||
];
|
||
const next = removeRoundItem(state, 1);
|
||
expect(next).not.toBeNull();
|
||
expect(next!.version).toBe(2);
|
||
expect(next!.pending).toEqual([
|
||
{ kind: 'action', text: 'one' },
|
||
{ kind: 'action', text: 'three' },
|
||
]);
|
||
expect(state.pending).toHaveLength(3); // original untouched
|
||
expect(state.version).toBe(1);
|
||
});
|
||
|
||
it('returns null for out-of-bounds or non-integer indices (route answers 400, nothing mutates)', () => {
|
||
const state = baseState();
|
||
state.pending = [{ kind: 'action', text: 'one' }];
|
||
expect(removeRoundItem(state, -1)).toBeNull();
|
||
expect(removeRoundItem(state, 1)).toBeNull(); // == length
|
||
expect(removeRoundItem(state, 5)).toBeNull();
|
||
expect(removeRoundItem(state, 0.5)).toBeNull();
|
||
expect(removeRoundItem(state, Number.NaN)).toBeNull();
|
||
expect(state.pending).toHaveLength(1); // still untouched
|
||
});
|
||
});
|
||
|
||
describe('resolveNationRef (v0.23 #16 P4a)', () => {
|
||
it('resolves an exact id first (even if a name matches something else)', () => {
|
||
const state = baseState();
|
||
expect(resolveNationRef(state, 'de')).toBe('de');
|
||
});
|
||
|
||
it('resolves a unique name case-insensitively', () => {
|
||
const state = baseState();
|
||
expect(resolveNationRef(state, 'US')).toBe('us');
|
||
expect(resolveNationRef(state, ' uS ')).toBe('us'); // trimmed, case-insensitive
|
||
});
|
||
|
||
it('returns null for an ambiguous name (two nations share it)', () => {
|
||
const state = baseState();
|
||
state.nations.us.name = 'UK'; // now uk and us are both "UK"
|
||
expect(resolveNationRef(state, 'UK')).toBeNull();
|
||
// but the exact id still resolves
|
||
expect(resolveNationRef(state, 'uk')).toBe('uk');
|
||
});
|
||
|
||
it('returns null for an unknown or empty reference (never throws)', () => {
|
||
const state = baseState();
|
||
expect(resolveNationRef(state, 'zzz-not-a-nation')).toBeNull();
|
||
expect(resolveNationRef(state, ' ')).toBeNull();
|
||
expect(resolveNationRef(state, '')).toBeNull();
|
||
});
|
||
});
|
||
|
||
describe('validateNationOps (v0.23 #16 P4a)', () => {
|
||
it('parses a valid rename (trimmed, capped) and drops invalid ones', () => {
|
||
const ops = validateNationOps([
|
||
{ kind: 'rename', nationRef: ' de ', to: 'Reich' },
|
||
{ kind: 'rename', nationRef: 'uk', to: '' }, // empty to -> dropped
|
||
{ kind: 'rename', to: 'no ref' }, // missing nationRef -> dropped
|
||
{ kind: 'rename', nationRef: 'us' }, // missing to -> dropped
|
||
{ kind: 'bogus-kind' }, // unknown kind -> ignored
|
||
'not-an-object',
|
||
null,
|
||
]);
|
||
expect(ops).toEqual([{ kind: 'rename', nationRef: 'de', to: 'Reich' }]);
|
||
});
|
||
|
||
it('parses a valid split (parts with names kept, nameless parts dropped)', () => {
|
||
const ops = validateNationOps([
|
||
{
|
||
kind: 'split',
|
||
nationRef: 'de',
|
||
parts: [
|
||
{ name: 'East', territory: ['DEU', ''] },
|
||
{ name: ' ' }, // empty name -> part dropped
|
||
{ name: 'West' },
|
||
],
|
||
},
|
||
]);
|
||
expect(ops).toEqual([
|
||
{
|
||
kind: 'split',
|
||
nationRef: 'de',
|
||
parts: [{ name: 'East', territory: ['DEU'] }, { name: 'West', territory: [] }],
|
||
},
|
||
]);
|
||
// a split with no usable parts is dropped entirely
|
||
expect(validateNationOps([{ kind: 'split', nationRef: 'de', parts: [{ name: '' }] }])).toEqual([]);
|
||
});
|
||
|
||
it('parses a valid merge and drops malformed ones', () => {
|
||
const ops = validateNationOps([
|
||
{ kind: 'merge', nationRefs: ['uk', 'us'], survivingRef: 'uk', into: 'Anglo-Saxon Union' },
|
||
{ kind: 'merge', nationRefs: [], survivingRef: 'uk', into: 'X' }, // no refs -> dropped
|
||
{ kind: 'merge', nationRefs: ['uk'], survivingRef: '', into: 'X' }, // no survivor -> dropped
|
||
{ kind: 'merge', nationRefs: ['uk'], survivingRef: 'uk' }, // no into -> dropped
|
||
]);
|
||
expect(ops).toEqual([
|
||
{ kind: 'merge', nationRefs: ['uk', 'us'], survivingRef: 'uk', into: 'Anglo-Saxon Union' },
|
||
]);
|
||
});
|
||
|
||
it('returns [] for non-array input and is never thrown by', () => {
|
||
expect(validateNationOps(undefined)).toEqual([]);
|
||
expect(validateNationOps('nope')).toEqual([]);
|
||
expect(validateNationOps({})).toEqual([]);
|
||
});
|
||
|
||
it('validateTurnResult carries validated nationOps through (and defaults to [] when absent)', () => {
|
||
const withOps = validateTurnResult({
|
||
date: '1939-09-03',
|
||
events: [],
|
||
stateChanges: [],
|
||
relations: [],
|
||
territoryChanges: [],
|
||
unowned: [],
|
||
nationOps: [{ kind: 'rename', nationRef: 'France', to: 'French Republic' }], takeovers: [], wars: { declare: [], end: [] }, hybrid: { ops: [] }, economy: { trade: [], embargo: [] }, industry: { projects: [] }, nuclear: { arm: [], strikes: [] }, leaders: { changes: [] }, ideology: { changes: [] }, markers: { add: [], remove: [] },
|
||
});
|
||
expect(withOps).not.toBeNull();
|
||
expect(withOps!.nationOps).toEqual([{ kind: 'rename', nationRef: 'France', to: 'French Republic' }]);
|
||
|
||
const without = validateTurnResult({ date: '1939-09-03', events: [], stateChanges: [] });
|
||
expect(without).not.toBeNull();
|
||
expect(without!.nationOps).toEqual([]);
|
||
});
|
||
});
|
||
|
||
describe('applyTurnResult (nation-op proposals, v0.23 #16 P4a)', () => {
|
||
it('a proposed rename is NOT applied by the turn — it lands in pendingNationOps, name untouched', () => {
|
||
const state = baseState();
|
||
const next = applyTurnResult(state, {
|
||
date: '1939-09-03',
|
||
events: [{ nation: 'de', type: 'political', title: 'Reunification', text: 'The nation is renamed.', outcome: 'success' }],
|
||
stateChanges: [],
|
||
regionChanges: [],
|
||
relations: [],
|
||
territoryChanges: [],
|
||
unowned: [],
|
||
nationOps: [{ kind: 'rename', nationRef: 'de', to: 'Reich' }], takeovers: [], wars: { declare: [], end: [] }, hybrid: { ops: [] }, economy: { trade: [], embargo: [] }, industry: { projects: [] }, nuclear: { arm: [], strikes: [] }, leaders: { changes: [] }, ideology: { changes: [] }, markers: { add: [], remove: [] },
|
||
});
|
||
expect(next.nations.de.name).toBe('DE'); // still the old name
|
||
expect(next.pendingNationOps).toEqual([{ kind: 'rename', nationRef: 'de', to: 'Reich' }]);
|
||
expect(next.version).toBe(2);
|
||
// the input is not mutated
|
||
expect(state.nations.de.name).toBe('DE');
|
||
expect(state.pendingNationOps).toEqual([]);
|
||
});
|
||
|
||
it('pending proposals SURVIVE a subsequent turn (unlike the round queue) and new ones append', () => {
|
||
const state = baseState();
|
||
state.pendingNationOps = [{ kind: 'rename', nationRef: 'de', to: 'Reich' }]; // unresolved from turn N-1
|
||
const next = applyTurnResult(state, {
|
||
date: '1939-09-03',
|
||
events: [],
|
||
stateChanges: [],
|
||
regionChanges: [],
|
||
relations: [],
|
||
territoryChanges: [],
|
||
unowned: [],
|
||
nationOps: [{ kind: 'rename', nationRef: 'us', to: 'USA' }], takeovers: [], wars: { declare: [], end: [] }, hybrid: { ops: [] }, economy: { trade: [], embargo: [] }, industry: { projects: [] }, nuclear: { arm: [], strikes: [] }, leaders: { changes: [] }, ideology: { changes: [] }, markers: { add: [], remove: [] }, // new proposal this turn
|
||
});
|
||
expect(next.pendingNationOps).toEqual([
|
||
{ kind: 'rename', nationRef: 'de', to: 'Reich' }, // the old one survived
|
||
{ kind: 'rename', nationRef: 'us', to: 'USA' }, // the new one was appended
|
||
]);
|
||
});
|
||
|
||
it('a legacy state without the pendingNationOps field accepts proposals (defaulted to [])', () => {
|
||
const state = baseState();
|
||
delete (state as { pendingNationOps?: NationOp[] }).pendingNationOps; // simulate a pre-v0.23 row
|
||
const next = applyTurnResult(state, {
|
||
date: '1939-09-03',
|
||
events: [],
|
||
stateChanges: [],
|
||
regionChanges: [],
|
||
relations: [],
|
||
territoryChanges: [],
|
||
unowned: [],
|
||
nationOps: [{ kind: 'rename', nationRef: 'de', to: 'Reich' }], takeovers: [], wars: { declare: [], end: [] }, hybrid: { ops: [] }, economy: { trade: [], embargo: [] }, industry: { projects: [] }, nuclear: { arm: [], strikes: [] }, leaders: { changes: [] }, ideology: { changes: [] }, markers: { add: [], remove: [] },
|
||
});
|
||
expect(next.pendingNationOps).toEqual([{ kind: 'rename', nationRef: 'de', to: 'Reich' }]);
|
||
});
|
||
});
|
||
|
||
describe('confirmNationOp / rejectNationOp (v0.23 #16 P4a)', () => {
|
||
/**
|
||
* The pending list: op 0 is referenced by id ('de'); op 1 is referenced by
|
||
* NAME ('US' — the id is lowercase 'us'), so confirming it exercises the
|
||
* name-resolution path of resolveNationRef.
|
||
*/
|
||
function stateWithPending(): GameState {
|
||
const s = baseState();
|
||
s.pendingNationOps = [
|
||
{ kind: 'rename', nationRef: 'de', to: 'Reich' },
|
||
{ kind: 'rename', nationRef: 'US', to: 'United States' },
|
||
];
|
||
return s;
|
||
}
|
||
|
||
it('confirm applies a rename (name only — id/territory/relations/player pointer untouched) and removes the op, bumping the version', () => {
|
||
const state = stateWithPending();
|
||
const next = confirmNationOp(state, 0);
|
||
expect(next).not.toBeNull();
|
||
expect(next!.nations.de.name).toBe('Reich');
|
||
expect(next!.nations.de.id).toBe('de'); // id unchanged
|
||
expect(next!.nations.de.territory).toEqual(['DEU']); // territory unchanged
|
||
expect(next!.nations.uk.relations.de).toBe('hostile'); // relations (keyed by id) unchanged
|
||
expect(next!.playerNationId).toBe('uk'); // player pointer unchanged
|
||
expect(next!.pendingNationOps).toEqual([{ kind: 'rename', nationRef: 'US', to: 'United States' }]);
|
||
expect(next!.version).toBe(2);
|
||
// the input state is not mutated
|
||
expect(state.nations.de.name).toBe('DE');
|
||
expect(state.pendingNationOps).toHaveLength(2);
|
||
expect(state.version).toBe(1);
|
||
});
|
||
|
||
it('confirm with an edits.name override renames to the player-edited name', () => {
|
||
const next = confirmNationOp(stateWithPending(), 0, { name: 'Greater Reich' });
|
||
expect(next!.nations.de.name).toBe('Greater Reich');
|
||
});
|
||
|
||
it('confirm with an empty edits.name falls back to the proposed name', () => {
|
||
const next = confirmNationOp(stateWithPending(), 0, { name: ' ' });
|
||
expect(next!.nations.de.name).toBe('Reich');
|
||
});
|
||
|
||
it('confirm resolves the target by name (not just id) — case-insensitively', () => {
|
||
const next = confirmNationOp(stateWithPending(), 1); // nationRef 'US' is a name, not the id 'us'
|
||
expect(next).not.toBeNull();
|
||
expect(next!.nations.us.name).toBe('United States');
|
||
});
|
||
|
||
it('confirm of a 1-part split is a no-op on the world (fewer than 2 parts) — the op is still removed', () => {
|
||
const state = baseState();
|
||
state.pendingNationOps = [{ kind: 'split', nationRef: 'de', parts: [{ name: 'East' }] }];
|
||
const next = confirmNationOp(state, 0);
|
||
expect(next).not.toBeNull();
|
||
expect(next!.nations).toEqual(state.nations); // the world is untouched
|
||
expect(next!.pendingNationOps).toEqual([]); // ...but the proposal was resolved
|
||
expect(next!.version).toBe(2);
|
||
});
|
||
|
||
it('confirm with an invalid index returns null and mutates nothing', () => {
|
||
const state = stateWithPending();
|
||
expect(confirmNationOp(state, -1)).toBeNull();
|
||
expect(confirmNationOp(state, 2)).toBeNull();
|
||
expect(confirmNationOp(state, 0.5)).toBeNull();
|
||
expect(state.nations.de.name).toBe('DE');
|
||
expect(state.version).toBe(1);
|
||
});
|
||
|
||
it('reject drops the proposal without applying it, bumping the version', () => {
|
||
const state = stateWithPending();
|
||
const next = rejectNationOp(state, 0);
|
||
expect(next).not.toBeNull();
|
||
expect(next!.nations.de.name).toBe('DE'); // unchanged
|
||
expect(next!.pendingNationOps).toEqual([{ kind: 'rename', nationRef: 'US', to: 'United States' }]);
|
||
expect(next!.version).toBe(2);
|
||
expect(state.pendingNationOps).toHaveLength(2); // input untouched
|
||
});
|
||
|
||
it('reject with an invalid index returns null and mutates nothing', () => {
|
||
const state = stateWithPending();
|
||
expect(rejectNationOp(state, -1)).toBeNull();
|
||
expect(rejectNationOp(state, 2)).toBeNull();
|
||
expect(state.pendingNationOps).toHaveLength(2);
|
||
expect(state.version).toBe(1);
|
||
});
|
||
|
||
it('an ambiguous/invalid nationRef is a no-op on confirm (the op is resolved, the world unchanged)', () => {
|
||
const state = baseState();
|
||
state.nations.us.name = 'UK'; // make "UK" ambiguous (uk and us)
|
||
state.pendingNationOps = [{ kind: 'rename', nationRef: 'UK', to: 'Empire' }];
|
||
const next = confirmNationOp(state, 0);
|
||
expect(next).not.toBeNull();
|
||
expect(next!.nations.uk.name).toBe('UK'); // neither nation was renamed
|
||
expect(next!.nations.us.name).toBe('UK');
|
||
expect(next!.pendingNationOps).toEqual([]);
|
||
});
|
||
});
|
||
|
||
describe('buildSystemPrompt (nationOps contract, v0.23 #16 P4a; v0.24 #16 P4b)', () => {
|
||
it('both modes contract the nationOps field with all three op kinds and the proposal rule', () => {
|
||
for (const p of [buildSystemPrompt(preset), buildSystemPrompt(preset, true)]) {
|
||
// the JSON shape carries all three kinds in both modes
|
||
expect(p).toMatch(/"kind":"rename","nationRef":"<nation id or name>","to":"<new name>"/);
|
||
expect(p).toMatch(/"kind":"split","nationRef":"<nation id or name>","parts":\[\{"name":"<new name>","territory":\["<region key>"\]\}\]/);
|
||
expect(p).toMatch(/"kind":"merge","nationRefs":\["<id>","<id>"\],"survivingRef":"<id of the surviving nation>","into":"<new name>"/);
|
||
// the rule explains that ops are proposals only the player can confirm
|
||
expect(p).toMatch(/PROPOSAL/);
|
||
expect(p).toMatch(/only take effect if the player confirms/);
|
||
// and the split/merge shapes are spelt out in the hard constraints
|
||
expect(p).toMatch(/at least 2 parts/);
|
||
expect(p).toMatch(/survivingRef" must name one of them/);
|
||
}
|
||
});
|
||
});
|
||
|
||
describe('freshNationId (v0.24 #16 P4b)', () => {
|
||
it('returns the first free of n1, n2, ... against the taken set', () => {
|
||
expect(freshNationId(new Set())).toBe('n1');
|
||
expect(freshNationId(new Set(['n1']))).toBe('n2');
|
||
expect(freshNationId(new Set(['n1', 'n2', 'n3']))).toBe('n4');
|
||
// non-n ids never collide: existing nation ids are irrelevant
|
||
expect(freshNationId(new Set(['uk', 'de', 'us']))).toBe('n1');
|
||
});
|
||
});
|
||
|
||
describe('applyNationOp (split, v0.24 #16 P4b)', () => {
|
||
/** de owns four regions; the player is uk (so a split of de does not move the pointer). */
|
||
function splitState(): GameState {
|
||
const s = baseState();
|
||
s.nations.de.territory = ['DEU-N', 'DEU-S', 'DEU-E', 'DEU-W'];
|
||
s.nations.de.relations = { uk: 'ally' };
|
||
s.nations.uk.relations.de = 'hostile'; // mirror entry, to check the cleanup
|
||
return s;
|
||
}
|
||
|
||
it('partitions the parent territory: first-wins per key, leftovers to part 0, fresh ids, baseline stats, copied relations, parent removed', () => {
|
||
const state = splitState();
|
||
applyNationOp(state, {
|
||
kind: 'split',
|
||
nationRef: 'de',
|
||
parts: [
|
||
{ name: 'East', territory: ['DEU-E', 'DEU-W', 'ZZZ-not-a-key'] },
|
||
{ name: 'West', territory: ['DEU-W', 'DEU-S'] },
|
||
],
|
||
});
|
||
// the parent is gone and two fresh nations exist (n1, n2 — uk/us/de were taken)
|
||
expect(state.nations.de).toBeUndefined();
|
||
expect(Object.keys(state.nations).sort()).toEqual(['n1', 'n2', 'uk', 'us']);
|
||
const east = state.nations.n1;
|
||
const west = state.nations.n2;
|
||
// East claimed DEU-E and DEU-W first (the invalid key is ignored); the
|
||
// only key no part claimed — DEU-N (DEU-S went to West) — goes to part 0
|
||
// in the parent's territory order.
|
||
expect(east.territory).toEqual(['DEU-E', 'DEU-W', 'DEU-N']);
|
||
// West's DEU-W was already claimed by East; it keeps only DEU-S.
|
||
expect(west.territory).toEqual(['DEU-S']);
|
||
// every parent key sits in exactly one part (sole ownership, nothing lost)
|
||
const all = [...east.territory, ...west.territory].sort();
|
||
expect(all).toEqual(['DEU-E', 'DEU-N', 'DEU-S', 'DEU-W']);
|
||
// fresh baseline stats (not the parent's) and inherited form of state
|
||
expect(east.stats).toEqual(NEW_NATION_STATS);
|
||
expect(west.stats).toEqual(NEW_NATION_STATS);
|
||
expect(east.type).toBe('major');
|
||
expect(west.type).toBe('major');
|
||
// both parts copied the parent's relations
|
||
expect(east.relations).toEqual({ uk: 'ally' });
|
||
expect(west.relations).toEqual({ uk: 'ally' });
|
||
// the mirror entry for the removed parent is cleaned up
|
||
expect(state.nations.uk.relations.de).toBeUndefined();
|
||
expect(state.nations.uk.relations.us).toBe('neutral'); // other entries untouched
|
||
// the player (uk) is not affected
|
||
expect(state.playerNationId).toBe('uk');
|
||
});
|
||
|
||
it('an unresolvable nationRef is a no-op (the world is untouched, never throws)', () => {
|
||
const state = splitState();
|
||
const before = JSON.parse(JSON.stringify(state));
|
||
applyNationOp(state, {
|
||
kind: 'split',
|
||
nationRef: 'zzz-not-a-nation',
|
||
parts: [{ name: 'East' }, { name: 'West' }],
|
||
});
|
||
expect(state).toEqual(before);
|
||
});
|
||
|
||
it('a split named by the parent NAME works (ref resolution, not just id)', () => {
|
||
const state = splitState();
|
||
applyNationOp(state, {
|
||
kind: 'split',
|
||
nationRef: 'DE', // the name; the id is 'de'
|
||
parts: [{ name: 'East', territory: ['DEU-E'] }, { name: 'West', territory: ['DEU-W'] }],
|
||
});
|
||
expect(state.nations.de).toBeUndefined();
|
||
expect(state.nations.n1.name).toBe('East');
|
||
expect(state.nations.n2.name).toBe('West');
|
||
});
|
||
|
||
it('when the parent is the player nation, playerNationId moves to the chosen part (edits.playerPart: default 0, clamped in range)', () => {
|
||
const state = splitState();
|
||
state.playerNationId = 'de';
|
||
applyNationOp(state, {
|
||
kind: 'split',
|
||
nationRef: 'de',
|
||
parts: [{ name: 'East' }, { name: 'West' }, { name: 'Centre' }],
|
||
});
|
||
expect(state.playerNationId).toBe('n1'); // default: the first part
|
||
|
||
const state2 = splitState();
|
||
state2.playerNationId = 'de';
|
||
applyNationOp(
|
||
state2,
|
||
{
|
||
kind: 'split',
|
||
nationRef: 'de',
|
||
parts: [{ name: 'East' }, { name: 'West' }, { name: 'Centre' }],
|
||
},
|
||
{ playerPart: 1 },
|
||
);
|
||
expect(state2.playerNationId).toBe('n2');
|
||
|
||
const state3 = splitState();
|
||
state3.playerNationId = 'de';
|
||
applyNationOp(
|
||
state3,
|
||
{
|
||
kind: 'split',
|
||
nationRef: 'de',
|
||
parts: [{ name: 'East' }, { name: 'West' }],
|
||
},
|
||
{ playerPart: 99 }, // clamped down to the last part
|
||
);
|
||
expect(state3.playerNationId).toBe('n2');
|
||
|
||
const state4 = splitState();
|
||
state4.playerNationId = 'de';
|
||
applyNationOp(
|
||
state4,
|
||
{
|
||
kind: 'split',
|
||
nationRef: 'de',
|
||
parts: [{ name: 'East' }, { name: 'West' }],
|
||
},
|
||
{ playerPart: -5 }, // clamped up to the first part
|
||
);
|
||
expect(state4.playerNationId).toBe('n1');
|
||
});
|
||
|
||
it('edits.name renames the chosen part (edits.playerPart picks which one)', () => {
|
||
const state = splitState();
|
||
applyNationOp(
|
||
state,
|
||
{
|
||
kind: 'split',
|
||
nationRef: 'de',
|
||
parts: [{ name: 'East' }, { name: 'West' }],
|
||
},
|
||
{ playerPart: 1, name: 'New West' },
|
||
);
|
||
expect(state.nations.n1.name).toBe('East'); // untouched part keeps its name
|
||
expect(state.nations.n2.name).toBe('New West');
|
||
|
||
const state2 = splitState();
|
||
applyNationOp(
|
||
state2,
|
||
{
|
||
kind: 'split',
|
||
nationRef: 'de',
|
||
parts: [{ name: 'East' }, { name: 'West' }],
|
||
},
|
||
{ name: 'First of the Remnant' }, // no playerPart -> the first part
|
||
);
|
||
expect(state2.nations.n1.name).toBe('First of the Remnant');
|
||
expect(state2.nations.n2.name).toBe('West');
|
||
});
|
||
|
||
it('a 1-part split is a no-op on the world (fewer than 2 parts)', () => {
|
||
const state = splitState();
|
||
const before = JSON.parse(JSON.stringify(state));
|
||
applyNationOp(state, { kind: 'split', nationRef: 'de', parts: [{ name: 'East' }] });
|
||
expect(state).toEqual(before);
|
||
});
|
||
});
|
||
|
||
describe('applyNationOp (merge, v0.24 #16 P4b)', () => {
|
||
/**
|
||
* de and us will be merged (the survivor is us — deliberately listed
|
||
* second in nationRefs, so the "survivor's view wins" rule can be observed
|
||
* overwriting the first input's views). uk, fr and ja survive as third
|
||
* parties; the survivor has no view on ja, so ja must come from the first
|
||
* input's map.
|
||
*/
|
||
function mergeState(): GameState {
|
||
const s = baseState();
|
||
s.nations.de.relations = { us: 'rival', uk: 'hostile', fr: 'rival', ja: 'rival' };
|
||
s.nations.us.relations = { de: 'ally', uk: 'ally', fr: 'neutral' }; // no view on ja
|
||
s.nations.uk.relations = { us: 'ally', de: 'hostile', fr: 'neutral', ja: 'neutral' };
|
||
s.nations.fr = {
|
||
id: 'fr',
|
||
type: 'major',
|
||
name: 'FR',
|
||
stats: { military: 40, economy: 60, stability: 70, population: 60 },
|
||
territory: ['FRA'],
|
||
relations: { us: 'neutral', de: 'rival', uk: 'neutral', ja: 'neutral' },
|
||
};
|
||
s.nations.ja = {
|
||
id: 'ja',
|
||
type: 'major',
|
||
name: 'JA',
|
||
stats: { military: 30, economy: 80, stability: 50, population: 90 },
|
||
territory: ['JPN'],
|
||
relations: { us: 'neutral', de: 'rival', uk: 'neutral', fr: 'neutral' },
|
||
};
|
||
return s;
|
||
}
|
||
|
||
it('folds the inputs into the survivor: union territory (survivor first), survivor stats, survivor view wins per third party (else first input), non-input third parties kept, removed ids dropped from everyone else, into-name', () => {
|
||
const state = mergeState();
|
||
applyNationOp(state, { kind: 'merge', nationRefs: ['de', 'us'], survivingRef: 'us', into: 'Union' });
|
||
// the removed input is gone; the survivor and all non-inputs survive
|
||
expect(state.nations.de).toBeUndefined();
|
||
expect(Object.keys(state.nations).sort()).toEqual(['fr', 'ja', 'uk', 'us']);
|
||
// the survivor keeps its id, its own stats, and the merged name
|
||
expect(state.nations.us.id).toBe('us');
|
||
expect(state.nations.us.stats).toEqual({ military: 60, economy: 90, stability: 80, population: 80 });
|
||
expect(state.nations.us.name).toBe('Union');
|
||
// territory: the union — the survivor's keys first, then the other input's
|
||
expect(state.nations.us.territory).toEqual(['USA', 'DEU']);
|
||
// relations: on uk and fr the survivor's own views (ally/neutral) beat the
|
||
// first input's (hostile/rival); ja has no survivor view, so the first
|
||
// input's (rival) stands; the removed input's id is gone from the map.
|
||
expect(state.nations.us.relations).toEqual({ uk: 'ally', fr: 'neutral', ja: 'rival' });
|
||
// the removed id is dropped from every remaining nation's relations map
|
||
expect(state.nations.uk.relations).toEqual({ us: 'ally', fr: 'neutral', ja: 'neutral' });
|
||
expect(state.nations.fr.relations).toEqual({ us: 'neutral', uk: 'neutral', ja: 'neutral' });
|
||
expect(state.nations.ja.relations).toEqual({ us: 'neutral', uk: 'neutral', fr: 'neutral' });
|
||
// the player (uk) is not part of the merge — the pointer stays valid
|
||
expect(state.playerNationId).toBe('uk');
|
||
});
|
||
|
||
it('the player pointer moves to the survivor when the player led a removed input', () => {
|
||
const state = mergeState();
|
||
state.playerNationId = 'de'; // the player's nation is absorbed
|
||
applyNationOp(state, { kind: 'merge', nationRefs: ['de', 'us'], survivingRef: 'us', into: 'Union' });
|
||
expect(state.playerNationId).toBe('us');
|
||
expect(state.nations[state.playerNationId].name).toBe('Union');
|
||
});
|
||
|
||
it('edits.name overrides the merged name; an empty edit falls back to "into", then to the survivor name', () => {
|
||
const state = mergeState();
|
||
applyNationOp(state, { kind: 'merge', nationRefs: ['de', 'us'], survivingRef: 'us', into: 'Union' }, { name: 'United World' });
|
||
expect(state.nations.us.name).toBe('United World');
|
||
|
||
const state2 = mergeState();
|
||
applyNationOp(state2, { kind: 'merge', nationRefs: ['de', 'us'], survivingRef: 'us', into: 'Union' }, { name: ' ' });
|
||
expect(state2.nations.us.name).toBe('Union');
|
||
|
||
const state3 = mergeState();
|
||
applyNationOp(state3, { kind: 'merge', nationRefs: ['de', 'us'], survivingRef: 'us', into: '' }); // empty into: keep the survivor's name
|
||
expect(state3.nations.us.name).toBe('US');
|
||
});
|
||
|
||
it('an unresolvable input ref is a no-op (never throws, world untouched)', () => {
|
||
const state = mergeState();
|
||
const before = JSON.parse(JSON.stringify(state));
|
||
applyNationOp(state, {
|
||
kind: 'merge',
|
||
nationRefs: ['us', 'zzz-not-a-nation'],
|
||
survivingRef: 'uk',
|
||
into: 'X',
|
||
});
|
||
expect(state).toEqual(before);
|
||
});
|
||
|
||
it('a survivor that is not one of the inputs is a no-op', () => {
|
||
const state = mergeState();
|
||
const before = JSON.parse(JSON.stringify(state));
|
||
applyNationOp(state, {
|
||
kind: 'merge',
|
||
nationRefs: ['us', 'de'],
|
||
survivingRef: 'uk', // uk is not an input
|
||
into: 'X',
|
||
});
|
||
expect(state).toEqual(before);
|
||
});
|
||
|
||
it('fewer than two DISTINCT inputs is a no-op (the same nation named twice)', () => {
|
||
const state = mergeState();
|
||
const before = JSON.parse(JSON.stringify(state));
|
||
applyNationOp(state, {
|
||
kind: 'merge',
|
||
nationRefs: ['us', 'US'], // both resolve to 'us'
|
||
survivingRef: 'us',
|
||
into: 'X',
|
||
});
|
||
expect(state).toEqual(before);
|
||
});
|
||
});
|
||
|
||
describe('parseTurnResult', () => {
|
||
it('parses a fenced JSON block', () => {
|
||
const r = parseTurnResult('```json\n{"date":"1939-09-03","events":[{"nation":"uk","type":"military","title":"t","text":"b","outcome":"success"}]}\n```');
|
||
expect(r?.events[0].title).toBe('t');
|
||
});
|
||
|
||
it('parses JSON prefixed by leading whitespace/newlines (reasoning model emits "\\n\\n{...}")', () => {
|
||
const r = parseTurnResult('\n\n{"date":"1939-09-03","events":[{"nation":"uk","type":"military","title":"t","text":"b","outcome":"success"}]}\n');
|
||
expect(r?.events[0].title).toBe('t');
|
||
expect(r?.date).toBe('1939-09-03');
|
||
});
|
||
|
||
it('extracts the object from surrounding prose', () => {
|
||
const r = parseTurnResult('Sure! Here is the result: {"date":"1939-09-03","events":[]} hope that helps');
|
||
expect(r).not.toBeNull();
|
||
});
|
||
|
||
it('returns null for non-JSON output', () => {
|
||
expect(parseTurnResult('no json here at all')).toBeNull();
|
||
});
|
||
|
||
it('validateTurnResult ignores unknown/invalid entries and clamps', () => {
|
||
const r = validateTurnResult({
|
||
date: '1939-09-03',
|
||
events: [
|
||
{ nation: 'uk', type: 'bogus', title: 't', text: 'b', outcome: 'weird' },
|
||
{ nation: 'uk', title: 'missing text' },
|
||
],
|
||
stateChanges: [
|
||
{ nation: 'uk', stat: 'notastat', op: 'set', value: 5 },
|
||
{ nation: 'uk', stat: 'military', op: 'set', value: 150 },
|
||
],
|
||
});
|
||
expect(r).not.toBeNull();
|
||
expect(r!.events).toHaveLength(1); // the invalid one was dropped
|
||
expect(r!.events[0].type).toBe('political'); // bogus type -> default
|
||
expect(r!.events[0].outcome).toBe('neutral'); // bogus outcome -> default
|
||
expect(r!.stateChanges).toHaveLength(1);
|
||
expect(r!.stateChanges[0].value).toBe(100); // clamped
|
||
});
|
||
});
|
||
|
||
describe('applyTurnResult', () => {
|
||
it('does not mutate its input', () => {
|
||
const state = baseState();
|
||
const snapshot = JSON.stringify(state);
|
||
const result: TurnResult = {
|
||
date: '1939-09-03',
|
||
events: [{ nation: 'uk', type: 'military', title: 't', text: 'b', outcome: 'success' }],
|
||
stateChanges: [{ nation: 'uk', stat: 'military', op: 'set', value: 99 }],
|
||
regionChanges: [],
|
||
relations: [],
|
||
territoryChanges: [],
|
||
unowned: [],
|
||
nationOps: [], takeovers: [], wars: { declare: [], end: [] }, hybrid: { ops: [] }, economy: { trade: [], embargo: [] }, industry: { projects: [] }, nuclear: { arm: [], strikes: [] }, leaders: { changes: [] }, ideology: { changes: [] }, markers: { add: [], remove: [] },
|
||
};
|
||
applyTurnResult(state, result);
|
||
expect(JSON.stringify(state)).toBe(snapshot);
|
||
});
|
||
|
||
it('clears the pending round on commit and bumps the version', () => {
|
||
const state = baseState();
|
||
state.pending = [{ kind: 'action', text: 'x' }];
|
||
const result: TurnResult = {
|
||
date: '1939-09-03',
|
||
events: [],
|
||
stateChanges: [],
|
||
regionChanges: [],
|
||
relations: [],
|
||
territoryChanges: [],
|
||
unowned: [],
|
||
nationOps: [], takeovers: [], wars: { declare: [], end: [] }, hybrid: { ops: [] }, economy: { trade: [], embargo: [] }, industry: { projects: [] }, nuclear: { arm: [], strikes: [] }, leaders: { changes: [] }, ideology: { changes: [] }, markers: { add: [], remove: [] },
|
||
};
|
||
const next = applyTurnResult(state, result);
|
||
expect(next.pending).toEqual([]);
|
||
expect(next.version).toBe(2);
|
||
expect(state.pending).toHaveLength(1); // original untouched
|
||
expect(state.version).toBe(1);
|
||
});
|
||
|
||
it('with a validKeys set: applies real region keys, ignores hallucinated ones', () => {
|
||
const state = baseState();
|
||
// give the fixture's uk a real key ('wales-bbbb') for DE to be able to take
|
||
state.nations.uk.territory = ['GBR', 'wales-bbbb'];
|
||
const result: TurnResult = {
|
||
date: '1939-09-03',
|
||
events: [],
|
||
stateChanges: [],
|
||
regionChanges: [],
|
||
relations: [],
|
||
// 'wales-bbbb' is a real key (owned by uk in the fixture); 'not-a-region-xyz'
|
||
// 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: [] }, nuclear: { arm: [], strikes: [] }, leaders: { changes: [] }, ideology: { changes: [] }, 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
|
||
expect(next.nations.de.territory).toContain('wales-bbbb');
|
||
expect(next.nations.uk.territory).not.toContain('wales-bbbb');
|
||
// the bogus key never entered any nation's territory
|
||
for (const n of Object.values(next.nations)) {
|
||
expect(n.territory).not.toContain('not-a-region-xyz');
|
||
}
|
||
});
|
||
|
||
it('without a validKeys set: applies every listed key (legacy/unit path)', () => {
|
||
const state = baseState();
|
||
state.nations.uk.territory = ['GBR', 'wales-bbbb'];
|
||
const result: TurnResult = {
|
||
date: '1939-09-03',
|
||
events: [],
|
||
stateChanges: [],
|
||
regionChanges: [],
|
||
relations: [],
|
||
// 'wales-bbbb' has a sole previous owner (uk) -> it transfers; 'POL' is
|
||
// owned by no one -> a re-claim (v0.22, #16 P3): DE gains it. It was never
|
||
// 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: [] }, nuclear: { arm: [], strikes: [] }, leaders: { changes: [] }, ideology: { changes: [] }, markers: { add: [], remove: [] },
|
||
};
|
||
const next = applyTurnResult(state, result); // no validKeys -> nothing filtered
|
||
expect(next.nations.de.territory).toContain('wales-bbbb');
|
||
expect(next.nations.uk.territory).not.toContain('wales-bbbb');
|
||
expect(next.nations.de.territory).toContain('POL'); // re-claimed, not ignored
|
||
expect(next.unowned).toEqual([]); // never released -> not no man's land
|
||
});
|
||
});
|
||
|
||
describe('applyTurnResult (sole-ownership invariants, v0.21 #16 P2)', () => {
|
||
const noEvents = {
|
||
date: '1939-09-03',
|
||
events: [],
|
||
stateChanges: [],
|
||
regionChanges: [],
|
||
relations: [],
|
||
unowned: [],
|
||
nationOps: [], takeovers: [], wars: { declare: [], end: [] }, hybrid: { ops: [] }, economy: { trade: [], embargo: [] }, industry: { projects: [] }, nuclear: { arm: [], strikes: [] }, leaders: { changes: [] }, ideology: { changes: [] }, markers: { add: [], remove: [] },
|
||
};
|
||
|
||
it('a double claim (two different gainers, one key) is rejected: the key stays with its original owner', () => {
|
||
const state = baseState();
|
||
state.nations.uk.territory = ['GBR', 'wales-bbbb'];
|
||
const next = applyTurnResult(state, {
|
||
...noEvents,
|
||
territoryChanges: [
|
||
{ nation: 'de', gain: ['wales-bbbb'], lose: [] },
|
||
{ nation: 'us', gain: ['wales-bbbb'], lose: [] },
|
||
],
|
||
});
|
||
expect(next.nations.uk.territory).toContain('wales-bbbb');
|
||
expect(next.nations.de.territory).not.toContain('wales-bbbb');
|
||
expect(next.nations.us.territory).not.toContain('wales-bbbb');
|
||
});
|
||
|
||
it('duplicate gain entries for the SAME nation are deduped (one claim, one copy)', () => {
|
||
const state = baseState();
|
||
state.nations.uk.territory = ['GBR', 'wales-bbbb'];
|
||
const next = applyTurnResult(state, {
|
||
...noEvents,
|
||
territoryChanges: [
|
||
{ nation: 'de', gain: ['wales-bbbb'], lose: [] },
|
||
{ nation: 'de', gain: ['wales-bbbb'], lose: [] },
|
||
],
|
||
});
|
||
expect(next.nations.de.territory.filter((k) => k === 'wales-bbbb')).toHaveLength(1);
|
||
expect(next.nations.uk.territory).not.toContain('wales-bbbb');
|
||
});
|
||
|
||
it('a lone "lose" is a no-op: the key cannot be orphaned (zero owners)', () => {
|
||
const state = baseState();
|
||
const next = applyTurnResult(state, {
|
||
...noEvents,
|
||
territoryChanges: [{ nation: 'uk', gain: [], lose: ['GBR'] }],
|
||
});
|
||
expect(next.nations.uk.territory).toContain('GBR');
|
||
});
|
||
|
||
it('a self-gain is a no-op (a nation keeps exactly one copy of its own key)', () => {
|
||
const state = baseState();
|
||
const next = applyTurnResult(state, {
|
||
...noEvents,
|
||
territoryChanges: [{ nation: 'uk', gain: ['GBR'], lose: [] }],
|
||
});
|
||
expect(next.nations.uk.territory.filter((k) => k === 'GBR')).toHaveLength(1);
|
||
});
|
||
|
||
it('a pre-existing double ownership is never touched (a key with 2 owners keeps 2)', () => {
|
||
const state = baseState();
|
||
state.nations.us.territory = ['USA', 'GBR']; // uk AND us already share GBR
|
||
const next = applyTurnResult(state, {
|
||
...noEvents,
|
||
territoryChanges: [{ nation: 'de', gain: ['GBR'], lose: [] }],
|
||
});
|
||
expect(next.nations.uk.territory).toContain('GBR');
|
||
expect(next.nations.us.territory).toContain('GBR');
|
||
expect(next.nations.de.territory).not.toContain('GBR');
|
||
});
|
||
|
||
it('ownership is conserved across a transfer: exactly one owner before and after', () => {
|
||
const state = baseState();
|
||
state.nations.uk.territory = ['GBR', 'wales-bbbb'];
|
||
const count = (s: GameState) =>
|
||
Object.values(s.nations).reduce(
|
||
(acc, n) => acc + n.territory.filter((k) => k === 'wales-bbbb').length,
|
||
0,
|
||
);
|
||
const next = applyTurnResult(state, {
|
||
...noEvents,
|
||
territoryChanges: [{ nation: 'de', gain: ['wales-bbbb'], lose: [] }],
|
||
});
|
||
expect(count(state)).toBe(1);
|
||
expect(count(next)).toBe(1);
|
||
expect(next.nations.de.territory).toContain('wales-bbbb');
|
||
expect(next.nations.uk.territory).not.toContain('wales-bbbb');
|
||
});
|
||
});
|
||
|
||
describe('applyTurnResult (no man\'s land, v0.22 #16 P3)', () => {
|
||
const noEvents = {
|
||
date: '1939-09-03',
|
||
events: [],
|
||
stateChanges: [],
|
||
regionChanges: [],
|
||
relations: [],
|
||
territoryChanges: [],
|
||
unowned: [],
|
||
nationOps: [], takeovers: [], wars: { declare: [], end: [] }, hybrid: { ops: [] }, economy: { trade: [], embargo: [] }, industry: { projects: [] }, nuclear: { arm: [], strikes: [] }, leaders: { changes: [] }, ideology: { changes: [] }, markers: { add: [], remove: [] },
|
||
};
|
||
|
||
it('a release removes the key from its sole owner and marks it in GameState.unowned', () => {
|
||
const state = baseState();
|
||
const next = applyTurnResult(state, { ...noEvents, unowned: ['GBR'] });
|
||
expect(next.nations.uk.territory).not.toContain('GBR');
|
||
expect(next.unowned).toEqual(['GBR']);
|
||
// nothing else moved
|
||
expect(next.nations.us.territory).toEqual(['USA']);
|
||
expect(next.nations.de.territory).toEqual(['DEU']);
|
||
});
|
||
|
||
it('a re-claim (a gain on a currently-unowned key) grants it and removes it from GameState.unowned', () => {
|
||
const state = baseState();
|
||
state.unowned = ['POL']; // released in an earlier turn
|
||
const next = applyTurnResult(state, {
|
||
...noEvents,
|
||
territoryChanges: [{ nation: 'de', gain: ['POL'], lose: [] }],
|
||
});
|
||
expect(next.nations.de.territory).toContain('POL');
|
||
expect(next.unowned).toEqual([]); // it left no man's land
|
||
});
|
||
|
||
it('a release and a claim of the same key in one turn: the claim wins (the key is owned)', () => {
|
||
const state = baseState();
|
||
const next = applyTurnResult(state, {
|
||
...noEvents,
|
||
territoryChanges: [{ nation: 'de', gain: ['GBR'], lose: [] }],
|
||
unowned: ['GBR'],
|
||
});
|
||
expect(next.nations.de.territory).toContain('GBR');
|
||
expect(next.nations.uk.territory).not.toContain('GBR');
|
||
expect(next.unowned).toEqual([]);
|
||
});
|
||
|
||
it('a double claim on a released key is rejected: the key stays in no man\'s land', () => {
|
||
const state = baseState();
|
||
const next = applyTurnResult(state, {
|
||
...noEvents,
|
||
territoryChanges: [
|
||
{ nation: 'de', gain: ['GBR'], lose: [] },
|
||
{ nation: 'us', gain: ['GBR'], lose: [] },
|
||
],
|
||
unowned: ['GBR'], // released first, then both nations lunge for it
|
||
});
|
||
expect(next.nations.uk.territory).not.toContain('GBR'); // the release applied
|
||
expect(next.nations.de.territory).not.toContain('GBR'); // both claims rejected
|
||
expect(next.nations.us.territory).not.toContain('GBR');
|
||
expect(next.unowned).toEqual(['GBR']);
|
||
});
|
||
|
||
it('releasing a key no nation owns (a preset-unassigned region) is a no-op — no man\'s land can only arise from a real release', () => {
|
||
const state = baseState();
|
||
const next = applyTurnResult(state, { ...noEvents, unowned: ['POL'] });
|
||
expect(next.unowned).toEqual([]);
|
||
for (const n of Object.values(next.nations)) expect(n.territory).not.toContain('POL');
|
||
});
|
||
|
||
it('releasing a key with two owners (a corrupt state) is a no-op', () => {
|
||
const state = baseState();
|
||
state.nations.us.territory = ['USA', 'GBR']; // uk AND us share GBR
|
||
const next = applyTurnResult(state, { ...noEvents, unowned: ['GBR'] });
|
||
expect(next.nations.uk.territory).toContain('GBR');
|
||
expect(next.nations.us.territory).toContain('GBR');
|
||
expect(next.unowned).toEqual([]);
|
||
});
|
||
|
||
it('releasing a key already in no man\'s land is a no-op (it stays listed once)', () => {
|
||
const state = baseState();
|
||
state.unowned = ['POL'];
|
||
const next = applyTurnResult(state, { ...noEvents, unowned: ['POL'] });
|
||
expect(next.unowned).toEqual(['POL']);
|
||
});
|
||
|
||
it('with a validKeys set: releasing a hallucinated key is a no-op', () => {
|
||
const state = baseState();
|
||
const next = applyTurnResult(state, { ...noEvents, unowned: ['not-a-region-xyz'] }, new Set(['wales-bbbb']));
|
||
expect(next.unowned).toEqual([]);
|
||
});
|
||
|
||
it('a legacy state without the unowned field accepts a release (the result carries the field)', () => {
|
||
const state = baseState();
|
||
delete (state as { unowned?: string[] }).unowned; // simulate a pre-v0.22 row
|
||
const next = applyTurnResult(state, { ...noEvents, unowned: ['GBR'] });
|
||
expect(next.unowned).toEqual(['GBR']);
|
||
expect(next.nations.uk.territory).not.toContain('GBR');
|
||
});
|
||
|
||
it('a turn with no releases leaves the existing no man\'s land list unchanged', () => {
|
||
const state = baseState();
|
||
state.unowned = ['POL'];
|
||
const next = applyTurnResult(state, {
|
||
...noEvents,
|
||
territoryChanges: [{ nation: 'de', gain: ['GBR'], lose: [] }],
|
||
});
|
||
expect(next.unowned).toEqual(['POL']);
|
||
expect(next.nations.de.territory).toContain('GBR');
|
||
});
|
||
|
||
it('validateTurnResult dedupes and filters the unowned list (trimmed non-empty strings only)', () => {
|
||
const r = validateTurnResult({
|
||
date: '1939-09-03',
|
||
events: [],
|
||
stateChanges: [],
|
||
relations: [],
|
||
territoryChanges: [],
|
||
unowned: [' a ', 'a', '', 42, null, ' b '],
|
||
});
|
||
expect(r).not.toBeNull();
|
||
expect(r!.unowned).toEqual(['a', 'b']);
|
||
});
|
||
|
||
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: [] }, nuclear: { arm: [], strikes: [] }, leaders: { changes: [] }, ideology: { changes: [] }, markers: { add: [], remove: [] } }),
|
||
).toBe(true);
|
||
});
|
||
|
||
it('runTurn applies a release end-to-end: the model narrates it and the key leaves its owner', async () => {
|
||
const original = baseState();
|
||
original.pending = [{ kind: 'action', text: 'Scorch the earth on the retreat' }];
|
||
const releaseJson = JSON.stringify({
|
||
date: '1939-09-05',
|
||
events: [
|
||
{
|
||
nation: 'uk',
|
||
type: 'military',
|
||
title: 'A retreat through the wastelands',
|
||
text: 'The abandoned highlands burn and lie empty.',
|
||
outcome: 'partial',
|
||
},
|
||
],
|
||
stateChanges: [],
|
||
relations: [],
|
||
territoryChanges: [],
|
||
unowned: ['GBR'],
|
||
});
|
||
const payloads: string[] = [];
|
||
const client: LlmClient = {
|
||
chat: async (messages) => {
|
||
const user = messages.find((m) => m.role === 'user');
|
||
if (user) payloads.push(user.content as string);
|
||
return releaseJson;
|
||
},
|
||
listModels: async () => [],
|
||
};
|
||
const { state } = await runTurn({ llm: client, preset, state: original, items: original.pending });
|
||
// the event landed and the release applied
|
||
expect(state.events).toHaveLength(1);
|
||
expect(state.events[0].title).toBe('A retreat through the wastelands');
|
||
expect(state.nations.uk.territory).not.toContain('GBR');
|
||
expect(state.unowned).toEqual(['GBR']);
|
||
// on the first turn the model was shown the (empty) no-man's-land list...
|
||
const first = JSON.parse(payloads[0]) as { unowned?: string[] };
|
||
expect(first.unowned).toEqual([]);
|
||
// ...and on the next turn it is shown the released key (so it can re-claim it)
|
||
const next = await runTurn({ llm: client, preset, state, items: [] });
|
||
const second = JSON.parse(payloads[1]) as { unowned?: string[] };
|
||
expect(second.unowned).toEqual(['GBR']);
|
||
// the repeated "release" of an already-unowned key is a no-op
|
||
expect(next.state.unowned).toEqual(['GBR']);
|
||
});
|
||
});
|
||
|
||
describe('makeNeighboursExecutor (v0.21 #16 P2)', () => {
|
||
const getNeighbours = (keys: string[]) => keys.map((k) => `nb-${k}`);
|
||
|
||
it('answers getNeighbours with the resolver output', () => {
|
||
const exec = makeNeighboursExecutor(getNeighbours);
|
||
expect(exec('getNeighbours', { regionKeys: ['a', 'b'] })).toBe(
|
||
JSON.stringify({ neighbours: ['nb-a', 'nb-b'] }),
|
||
);
|
||
});
|
||
|
||
it('degrades unknown tools, malformed args, and unknown keys without throwing', () => {
|
||
const exec = makeNeighboursExecutor(getNeighbours, new Set(['a']));
|
||
expect(exec('otherTool', {})).toBe(JSON.stringify({ error: 'unknown tool: otherTool' }));
|
||
expect(exec('getNeighbours', { regionKeys: 'not-an-array' })).toBe(
|
||
JSON.stringify({ neighbours: [] }),
|
||
);
|
||
expect(exec('getNeighbours', { regionKeys: ['a', 'zzz-not-valid'] })).toBe(
|
||
JSON.stringify({ neighbours: ['nb-a'] }), // the unknown key is filtered out
|
||
);
|
||
});
|
||
});
|
||
|
||
describe('playerFrontier (v0.21 #16 P2)', () => {
|
||
const adjacency: Record<string, string[]> = {
|
||
GBR: ['DEU', 'USA'],
|
||
GBR2: ['DEU'],
|
||
DEU: ['GBR', 'GBR2', 'USA'],
|
||
USA: ['GBR', 'DEU'],
|
||
};
|
||
const getNeighbours = (keys: string[]) =>
|
||
[...new Set(keys.flatMap((k) => adjacency[k] ?? []))]
|
||
.filter((k) => !keys.includes(k))
|
||
.sort();
|
||
|
||
it('is the sorted, deduped set of other-nation keys bordering the player', () => {
|
||
const state = baseState();
|
||
state.nations.uk.territory = ['GBR', 'GBR2']; // the player owns both
|
||
// DEU (de) and USA (us) border the player; deduped and sorted
|
||
expect(playerFrontier(state, getNeighbours)).toEqual(['DEU', 'USA']);
|
||
});
|
||
|
||
it('drops candidates the player itself owns (a sloppy resolver may report them)', () => {
|
||
const state = baseState();
|
||
state.nations.uk.territory = ['GBR', 'GBR2'];
|
||
const sloppy = () => ['GBR2', 'DEU'];
|
||
expect(playerFrontier(state, sloppy)).toEqual(['DEU']);
|
||
});
|
||
|
||
it('is empty when the player has no territory or no controllable border', () => {
|
||
const empty = baseState();
|
||
empty.nations.uk.territory = [];
|
||
expect(playerFrontier(empty, getNeighbours)).toEqual([]);
|
||
const noBorders = () => [];
|
||
expect(playerFrontier(baseState(), noBorders)).toEqual([]);
|
||
});
|
||
});
|
||
|
||
describe('runTurn (getNeighbours tool path, v0.21 #16 P2)', () => {
|
||
const adjacency: Record<string, string[]> = {
|
||
GBR: ['DEU'],
|
||
DEU: ['GBR', 'USA'],
|
||
USA: ['DEU'],
|
||
};
|
||
const getNeighbours = (keys: string[]) =>
|
||
[...new Set(keys.flatMap((k) => adjacency[k] ?? []))]
|
||
.filter((k) => !keys.includes(k))
|
||
.sort();
|
||
|
||
const finalTurn = JSON.stringify({
|
||
date: '1939-09-03',
|
||
events: [
|
||
{
|
||
nation: 'uk',
|
||
type: 'military',
|
||
title: 'The border region falls',
|
||
text: 'UK seizes the contiguous region.',
|
||
outcome: 'success',
|
||
},
|
||
],
|
||
stateChanges: [],
|
||
relations: [],
|
||
// the player gains its only frontier region (DEU, owned by de)
|
||
territoryChanges: [{ nation: 'uk', gain: ['DEU'], lose: [] }],
|
||
});
|
||
|
||
/**
|
||
* A fake LlmClient implementing BOTH chat() and chatWithTools(). The
|
||
* chatWithTools fake plays the model's side of the protocol: it records
|
||
* the initial messages + tools, executes the scripted tool calls through
|
||
* the executor the engine handed it, and returns the final content.
|
||
*/
|
||
function toolLlm(final: string, scriptedCalls: Array<{ name: string; args: Record<string, unknown> }>) {
|
||
const seen: {
|
||
firstMessages: WireMessage[];
|
||
tools: ToolDefinition[];
|
||
executorCalls: Array<{ name: string; args: Record<string, unknown>; result: string }>;
|
||
chatCalls: number;
|
||
} = { firstMessages: [], tools: [], executorCalls: [], chatCalls: 0 };
|
||
const client: LlmClient = {
|
||
chat: async (messages) => {
|
||
seen.chatCalls += 1;
|
||
seen.firstMessages = messages as WireMessage[];
|
||
return final;
|
||
},
|
||
listModels: async () => [],
|
||
chatWithTools: async (messages, tools, executeTool) => {
|
||
seen.firstMessages = messages;
|
||
seen.tools = tools;
|
||
for (const call of scriptedCalls) {
|
||
seen.executorCalls.push({ ...call, result: executeTool(call.name, call.args) });
|
||
}
|
||
return final;
|
||
},
|
||
};
|
||
return { client, seen };
|
||
}
|
||
|
||
it('uses chatWithTools with the tool, a frontier in the prompt, the wired executor, and applies the transfer', async () => {
|
||
const original = baseState();
|
||
original.pending = [{ kind: 'action', text: 'Seize the border region' }];
|
||
const { client, seen } = toolLlm(finalTurn, [
|
||
{ name: 'getNeighbours', args: { regionKeys: ['GBR'] } },
|
||
]);
|
||
const { state } = await runTurn({
|
||
llm: client,
|
||
preset,
|
||
state: original,
|
||
items: original.pending,
|
||
getNeighbours,
|
||
});
|
||
|
||
// the tool was offered and the system prompt advertises it
|
||
expect(seen.tools.map((t) => t.function.name)).toEqual([GET_NEIGHBOURS_TOOL.function.name]);
|
||
const system = seen.firstMessages[0] as { role: 'system'; content: string };
|
||
expect(system.content).toContain('getNeighbours');
|
||
|
||
// the user payload carries the player's frontier (DEU: the player's only
|
||
// controllable border, owned by de)
|
||
const user = seen.firstMessages[1] as { role: 'user'; content: string };
|
||
const payload = JSON.parse(user.content) as { frontier?: string[] };
|
||
expect(payload.frontier).toEqual(['DEU']);
|
||
|
||
// the executor the engine built answers from the resolver
|
||
expect(seen.executorCalls).toHaveLength(1);
|
||
expect(seen.executorCalls[0].args).toEqual({ regionKeys: ['GBR'] });
|
||
expect(seen.executorCalls[0].result).toBe(JSON.stringify({ neighbours: ['DEU'] }));
|
||
|
||
// chat() was never used on the tool path
|
||
expect(seen.chatCalls).toBe(0);
|
||
|
||
// the contiguous transfer was applied: uk gains DEU, de loses it
|
||
expect(state.nations.uk.territory).toContain('DEU');
|
||
expect(state.nations.de.territory).not.toContain('DEU');
|
||
});
|
||
|
||
it('falls back to plain chat() when the client lacks chatWithTools (no tool; the frontier hint stays in the prompt)', async () => {
|
||
const original = baseState();
|
||
const { client, seen } = toolLlm(validResult, []);
|
||
delete client.chatWithTools; // simulate a minimal client (chat only)
|
||
const { state } = await runTurn({
|
||
llm: client,
|
||
preset,
|
||
state: original,
|
||
items: original.pending,
|
||
getNeighbours,
|
||
});
|
||
expect(seen.chatCalls).toBe(1);
|
||
expect(seen.tools).toEqual([]); // the tool path was never entered
|
||
const system = seen.firstMessages[0] as { content: string };
|
||
expect(system.content).not.toContain('getNeighbours');
|
||
const user = seen.firstMessages[1] as { content: string };
|
||
const payload = JSON.parse(user.content) as { frontier?: string[] };
|
||
// the frontier hint stays (a resolver was given) even without the tool —
|
||
// it also tells the model which borders are realistically contestable
|
||
expect(payload.frontier).toEqual(['DEU']);
|
||
// the canned result still applied (GBR moved uk -> de)
|
||
expect(state.nations.de.territory).toContain('GBR');
|
||
expect(state.nations.uk.territory).not.toContain('GBR');
|
||
});
|
||
});
|
||
|
||
describe('deterministic stub (one contiguous conquest per turn, v0.21 #16 P2)', () => {
|
||
const adjacency: Record<string, string[]> = {
|
||
GBR: ['DEU'],
|
||
DEU: ['GBR', 'USA'],
|
||
USA: ['DEU'],
|
||
};
|
||
const getNeighbours = (keys: string[]) =>
|
||
[...new Set(keys.flatMap((k) => adjacency[k] ?? []))]
|
||
.filter((k) => !keys.includes(k))
|
||
.sort();
|
||
|
||
it('transfers the first frontier region to the player (and its owner loses it)', async () => {
|
||
const original = baseState();
|
||
original.pending = [{ kind: 'action', text: 'March on the border region' }];
|
||
const { state } = await runTurn({
|
||
llm: makeStubLlmClient(),
|
||
preset,
|
||
state: original,
|
||
items: original.pending,
|
||
getNeighbours,
|
||
});
|
||
expect(state.nations.uk.territory).toContain('DEU'); // frontier[0]
|
||
expect(state.nations.de.territory).not.toContain('DEU');
|
||
// the rest of the stub behavior is unchanged
|
||
expect(state.turn).toBe(2);
|
||
expect(state.date).toBe('1939-09-03');
|
||
expect(state.nations.uk.stats.military).toBe(85); // clamp(80 + 5)
|
||
expect(state.events).toHaveLength(1);
|
||
});
|
||
|
||
it('with an empty frontier (no controllable border, like the UK in wwii-1939): no transfer, the turn still advances', async () => {
|
||
const original = baseState();
|
||
original.pending = [{ kind: 'action', text: 'Hold the line' }];
|
||
const { state } = await runTurn({
|
||
llm: makeStubLlmClient(),
|
||
preset,
|
||
state: original,
|
||
items: original.pending,
|
||
getNeighbours: () => [], // no borders at all
|
||
});
|
||
expect(state.nations.uk.territory).toEqual(['GBR']); // untouched
|
||
expect(state.nations.de.territory).toEqual(['DEU']);
|
||
expect(state.nations.us.territory).toEqual(['USA']);
|
||
expect(state.turn).toBe(2);
|
||
expect(state.events).toHaveLength(1);
|
||
});
|
||
});
|
||
|
||
describe('buildSystemPrompt (tool mode, v0.21 #16 P2)', () => {
|
||
it('mentions the getNeighbours tool and the contiguity rules only when the tool is sent', () => {
|
||
const plain = buildSystemPrompt(preset);
|
||
expect(plain).not.toContain('getNeighbours');
|
||
expect(plain).toMatch(/Only change borders when war/i); // the legacy rule stays
|
||
const withTool = buildSystemPrompt(preset, true);
|
||
expect(withTool).toContain('getNeighbours');
|
||
expect(withTool).toMatch(/contiguous/i);
|
||
expect(withTool).toMatch(/frontier/i);
|
||
expect(withTool).not.toMatch(/Only change borders when war/i);
|
||
});
|
||
|
||
it('(v0.22 #16 P3) both modes contract the no-man\'s-land field (release + re-claim) and constrain it to owned keys', () => {
|
||
for (const p of [buildSystemPrompt(preset), buildSystemPrompt(preset, true)]) {
|
||
// the JSON shape carries the field in both modes
|
||
expect(p).toMatch(/"unowned": \["<region key>"\]/);
|
||
// the rule explains release (narrated in events) and re-claim (ordinary gain)
|
||
expect(p).toMatch(/no man's land/i);
|
||
expect(p).toMatch(/narrate the cause in "events"/);
|
||
expect(p).toMatch(/re-claims it with a "gain"/i);
|
||
// the hard constraint: only currently-owned keys may be released
|
||
expect(p).toMatch(/only region keys that a nation currently owns/);
|
||
}
|
||
});
|
||
});
|
||
|
||
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: [] }, nuclear: { arm: [], strikes: [] }, leaders: { changes: [] }, ideology: { changes: [] }, 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: [] }, leaders: { changes: [] }, ideology: { changes: [] }, 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: [] }, leaders: { changes: [] }, ideology: { changes: [] }, 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)', () => {
|
||
expect(
|
||
isMeaningful({
|
||
date: 'x',
|
||
events: [],
|
||
stateChanges: [],
|
||
regionChanges: [],
|
||
relations: [],
|
||
territoryChanges: [],
|
||
unowned: [],
|
||
nationOps: [{ kind: 'rename', nationRef: 'de', to: 'Reich' }], takeovers: [], wars: { declare: [], end: [] }, hybrid: { ops: [] }, economy: { trade: [], embargo: [] }, industry: { projects: [] }, nuclear: { arm: [], strikes: [] }, leaders: { changes: [] }, ideology: { changes: [] }, markers: { add: [], remove: [] },
|
||
}),
|
||
).toBe(true);
|
||
});
|
||
|
||
it('(v0.27 #44) a turn that only adds a marker is meaningful (it survives the retry/nudge path)', () => {
|
||
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: [] }, leaders: { changes: [] },
|
||
ideology: { changes: [] },
|
||
markers: { add: [{ type: 'city', name: 'Lyon', regionKey: 'rhone-rhne' }], remove: [] },
|
||
}),
|
||
).toBe(true);
|
||
});
|
||
|
||
it('(v0.32.0 #54) a turn that only performs a takeover is meaningful (it survives the retry/nudge path)', () => {
|
||
expect(
|
||
isMeaningful({
|
||
date: 'x',
|
||
events: [],
|
||
stateChanges: [],
|
||
regionChanges: [],
|
||
relations: [],
|
||
territoryChanges: [],
|
||
unowned: [],
|
||
nationOps: [],
|
||
takeovers: [{ nationRef: 'de', newRegimeName: 'The Reich' }],
|
||
wars: { declare: [], end: [] },
|
||
hybrid: { ops: [] },
|
||
economy: { trade: [], embargo: [] }, industry: { projects: [] },
|
||
nuclear: { arm: [], strikes: [] }, leaders: { changes: [] },
|
||
ideology: { changes: [] },
|
||
markers: { add: [], remove: [] },
|
||
}),
|
||
).toBe(true);
|
||
});
|
||
});
|
||
|
||
describe('takeovers (regime change, v0.32.0 #54)', () => {
|
||
it('a takeover renames the target nation; id, territory, relations, and stats are preserved', () => {
|
||
const state = baseState();
|
||
const result: TurnResult = {
|
||
date: '1939-09-03',
|
||
events: [],
|
||
stateChanges: [],
|
||
regionChanges: [],
|
||
relations: [],
|
||
territoryChanges: [],
|
||
unowned: [],
|
||
nationOps: [],
|
||
takeovers: [{ nationRef: 'de', newRegimeName: 'The Reich' }],
|
||
wars: { declare: [], end: [] },
|
||
hybrid: { ops: [] },
|
||
economy: { trade: [], embargo: [] }, industry: { projects: [] },
|
||
nuclear: { arm: [], strikes: [] }, leaders: { changes: [] },
|
||
ideology: { changes: [] },
|
||
markers: { add: [], remove: [] },
|
||
};
|
||
const next = applyTurnResult(state, result);
|
||
expect(next.nations.de.name).toBe('The Reich');
|
||
// id is preserved
|
||
expect(next.nations.de.id).toBe('de');
|
||
// territory is preserved
|
||
expect(next.nations.de.territory).toEqual(['DEU']);
|
||
// relations are preserved (keyed by id, not name)
|
||
expect(next.nations.de.relations).toEqual({});
|
||
// stats are preserved
|
||
expect(next.nations.de.stats.military).toBe(95);
|
||
// other nations are untouched
|
||
expect(next.nations.uk.name).toBe('UK');
|
||
expect(next.nations.us.name).toBe('US');
|
||
});
|
||
|
||
it('a takeover of the player\'s own nation preserves playerNationId (player keeps leading it)', () => {
|
||
const state = baseState(); // playerNationId is 'uk'
|
||
const result: TurnResult = {
|
||
date: '1939-09-03',
|
||
events: [],
|
||
stateChanges: [],
|
||
regionChanges: [],
|
||
relations: [],
|
||
territoryChanges: [],
|
||
unowned: [],
|
||
nationOps: [],
|
||
takeovers: [{ nationRef: 'uk', newRegimeName: 'The Commonwealth' }],
|
||
wars: { declare: [], end: [] },
|
||
hybrid: { ops: [] },
|
||
economy: { trade: [], embargo: [] }, industry: { projects: [] },
|
||
nuclear: { arm: [], strikes: [] }, leaders: { changes: [] },
|
||
ideology: { changes: [] },
|
||
markers: { add: [], remove: [] },
|
||
};
|
||
const next = applyTurnResult(state, result);
|
||
expect(next.playerNationId).toBe('uk'); // player still leads it
|
||
expect(next.nations.uk.name).toBe('The Commonwealth');
|
||
expect(next.nations.uk.id).toBe('uk');
|
||
});
|
||
|
||
it('an unknown nationRef → no-op (nation unchanged, no throw)', () => {
|
||
const state = baseState();
|
||
const result: TurnResult = {
|
||
date: '1939-09-03',
|
||
events: [],
|
||
stateChanges: [],
|
||
regionChanges: [],
|
||
relations: [],
|
||
territoryChanges: [],
|
||
unowned: [],
|
||
nationOps: [],
|
||
takeovers: [{ nationRef: 'nonexistent', newRegimeName: 'New Name' }],
|
||
wars: { declare: [], end: [] },
|
||
hybrid: { ops: [] },
|
||
economy: { trade: [], embargo: [] }, industry: { projects: [] },
|
||
nuclear: { arm: [], strikes: [] }, leaders: { changes: [] },
|
||
ideology: { changes: [] },
|
||
markers: { add: [], remove: [] },
|
||
};
|
||
const next = applyTurnResult(state, result);
|
||
// no nation was renamed
|
||
expect(next.nations.uk.name).toBe('UK');
|
||
expect(next.nations.us.name).toBe('US');
|
||
expect(next.nations.de.name).toBe('DE');
|
||
});
|
||
|
||
it('an ambiguous nationRef (two nations share the name) → no-op', () => {
|
||
const state = baseState();
|
||
// make two nations share the same name
|
||
state.nations.us.name = 'UK'; // now both 'uk' and 'us' are named 'UK'
|
||
const result: TurnResult = {
|
||
date: '1939-09-03',
|
||
events: [],
|
||
stateChanges: [],
|
||
regionChanges: [],
|
||
relations: [],
|
||
territoryChanges: [],
|
||
unowned: [],
|
||
nationOps: [],
|
||
takeovers: [{ nationRef: 'UK', newRegimeName: 'The Empire' }],
|
||
wars: { declare: [], end: [] },
|
||
hybrid: { ops: [] },
|
||
economy: { trade: [], embargo: [] }, industry: { projects: [] },
|
||
nuclear: { arm: [], strikes: [] }, leaders: { changes: [] },
|
||
ideology: { changes: [] },
|
||
markers: { add: [], remove: [] },
|
||
};
|
||
const next = applyTurnResult(state, result);
|
||
// neither was renamed (ambiguous)
|
||
expect(next.nations.uk.name).toBe('UK');
|
||
expect(next.nations.us.name).toBe('UK');
|
||
});
|
||
|
||
it('an empty/whitespace newRegimeName → no-op', () => {
|
||
const state = baseState();
|
||
// validateTakeovers drops entries with empty newRegimeName, so the validated
|
||
// result has no takeovers → applyTurnResult is a no-op.
|
||
const validated = validateTurnResult({
|
||
date: '1939-09-03',
|
||
events: [],
|
||
stateChanges: [],
|
||
regionChanges: [],
|
||
relations: [],
|
||
territoryChanges: [],
|
||
unowned: [],
|
||
nationOps: [],
|
||
takeovers: [{ nationRef: 'de', newRegimeName: ' ' }],
|
||
wars: { declare: [], end: [] },
|
||
hybrid: { ops: [] },
|
||
economy: { trade: [], embargo: [] }, industry: { projects: [] },
|
||
nuclear: { arm: [], strikes: [] }, leaders: { changes: [] },
|
||
ideology: { changes: [] },
|
||
markers: { add: [], remove: [] },
|
||
});
|
||
expect(validated!.takeovers).toEqual([]);
|
||
const next = applyTurnResult(state, validated!);
|
||
expect(next.nations.de.name).toBe('DE');
|
||
});
|
||
|
||
it('multiple takeovers in one turn all apply', () => {
|
||
const state = baseState();
|
||
const result: TurnResult = {
|
||
date: '1939-09-03',
|
||
events: [],
|
||
stateChanges: [],
|
||
regionChanges: [],
|
||
relations: [],
|
||
territoryChanges: [],
|
||
unowned: [],
|
||
nationOps: [],
|
||
takeovers: [
|
||
{ nationRef: 'uk', newRegimeName: 'The Commonwealth' },
|
||
{ nationRef: 'de', newRegimeName: 'The Reich' },
|
||
],
|
||
wars: { declare: [], end: [] },
|
||
hybrid: { ops: [] },
|
||
economy: { trade: [], embargo: [] }, industry: { projects: [] },
|
||
nuclear: { arm: [], strikes: [] }, leaders: { changes: [] },
|
||
ideology: { changes: [] },
|
||
markers: { add: [], remove: [] },
|
||
};
|
||
const next = applyTurnResult(state, result);
|
||
expect(next.nations.uk.name).toBe('The Commonwealth');
|
||
expect(next.nations.de.name).toBe('The Reich');
|
||
expect(next.nations.us.name).toBe('US'); // untouched
|
||
});
|
||
|
||
it('a takeover turn bumps the version once (same as any other turn)', () => {
|
||
const state = baseState(); // version 1
|
||
const result: TurnResult = {
|
||
date: '1939-09-03',
|
||
events: [],
|
||
stateChanges: [],
|
||
regionChanges: [],
|
||
relations: [],
|
||
territoryChanges: [],
|
||
unowned: [],
|
||
nationOps: [],
|
||
takeovers: [{ nationRef: 'de', newRegimeName: 'The Reich' }],
|
||
wars: { declare: [], end: [] },
|
||
hybrid: { ops: [] },
|
||
economy: { trade: [], embargo: [] }, industry: { projects: [] },
|
||
nuclear: { arm: [], strikes: [] }, leaders: { changes: [] },
|
||
ideology: { changes: [] },
|
||
markers: { add: [], remove: [] },
|
||
};
|
||
const next = applyTurnResult(state, result);
|
||
expect(next.version).toBe(2); // exactly one bump for the whole turn
|
||
});
|
||
|
||
it('a takeover via case-insensitive name resolution works', () => {
|
||
const state = baseState();
|
||
const result: TurnResult = {
|
||
date: '1939-09-03',
|
||
events: [],
|
||
stateChanges: [],
|
||
regionChanges: [],
|
||
relations: [],
|
||
territoryChanges: [],
|
||
unowned: [],
|
||
nationOps: [],
|
||
takeovers: [{ nationRef: 'De', newRegimeName: 'The Reich' }], // 'De' → resolves to 'de' by name
|
||
wars: { declare: [], end: [] },
|
||
hybrid: { ops: [] },
|
||
economy: { trade: [], embargo: [] }, industry: { projects: [] },
|
||
nuclear: { arm: [], strikes: [] }, leaders: { changes: [] },
|
||
ideology: { changes: [] },
|
||
markers: { add: [], remove: [] },
|
||
};
|
||
const next = applyTurnResult(state, result);
|
||
expect(next.nations.de.name).toBe('The Reich');
|
||
});
|
||
});
|
||
|
||
describe('markers (point placement, v0.27 #44)', () => {
|
||
// a deterministic centroid for one region key, undefined for everything else
|
||
const centroidOf = (key: string) =>
|
||
key === 'moselle-rygy' ? { lat: 48, lon: 6, spread: 1 } : undefined;
|
||
|
||
// build a MarkerSpec from a (possibly invalid, for the guard tests) type string
|
||
const mk = (type: string, name: string, regionKey: string): MarkerSpec =>
|
||
({ type: type as MarkerType, name, regionKey });
|
||
|
||
it('adds a valid marker with a stable id, a jittered position within the spread, the region owner, and the placed turn', () => {
|
||
const state = baseState();
|
||
state.turn = 4; // placedTurn should equal the resolved turn
|
||
state.nations.uk.territory = ['GBR', 'moselle-rygy']; // the region is owned by uk
|
||
const result: TurnResult = {
|
||
date: '1939-09-03',
|
||
events: [],
|
||
stateChanges: [],
|
||
regionChanges: [],
|
||
relations: [],
|
||
territoryChanges: [],
|
||
unowned: [],
|
||
nationOps: [],
|
||
takeovers: [],
|
||
wars: { declare: [], end: [] },
|
||
hybrid: { ops: [] },
|
||
economy: { trade: [], embargo: [] }, industry: { projects: [] },
|
||
nuclear: { arm: [], strikes: [] }, leaders: { changes: [] },
|
||
ideology: { changes: [] },
|
||
markers: { add: [mk('city', 'Strasbourg', 'moselle-rygy')], remove: [] },
|
||
};
|
||
const next = applyTurnResult(state, result, undefined, centroidOf);
|
||
expect(next.markers).toHaveLength(1);
|
||
const m = next.markers[0];
|
||
expect(m.id).toBe(markerId('city', 'Strasbourg', 'moselle-rygy'));
|
||
expect(m.type).toBe('city');
|
||
expect(m.name).toBe('Strasbourg');
|
||
expect(m.regionKey).toBe('moselle-rygy');
|
||
expect(m.ownerNationId).toBe('uk'); // the region's sole owner
|
||
expect(m.placedTurn).toBe(4);
|
||
// the position is the centroid jittered within ±spread/2
|
||
expect(m.lat).toBeGreaterThanOrEqual(47.5);
|
||
expect(m.lat).toBeLessThanOrEqual(48.5);
|
||
expect(m.lon).toBeGreaterThanOrEqual(5.5);
|
||
expect(m.lon).toBeLessThanOrEqual(6.5);
|
||
});
|
||
|
||
it('is idempotent: re-adding the same (type, name, regionKey) does not duplicate the marker', () => {
|
||
const state = baseState();
|
||
state.nations.uk.territory = ['moselle-rygy'];
|
||
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: [] }, nuclear: { arm: [], strikes: [] }, leaders: { changes: [] }, ideology: { changes: [] }, 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: [] }, nuclear: { arm: [], strikes: [] }, leaders: { changes: [] }, ideology: { changes: [] }, 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
|
||
});
|
||
|
||
it('applyTurnResult drops a new add whose region is unknown or has no centroid, but keeps a valid one', () => {
|
||
const state = baseState();
|
||
state.nations.uk.territory = ['moselle-rygy'];
|
||
// validKeys knows two keys; the centroid resolves only the first.
|
||
const validKeys = new Set(['moselle-rygy', 'other-key']);
|
||
const centroidOf2 = (k: string) =>
|
||
k === 'moselle-rygy' ? { lat: 48, lon: 6, spread: 1 } : undefined;
|
||
const result: TurnResult = {
|
||
date: '1939-09-03',
|
||
events: [],
|
||
stateChanges: [],
|
||
regionChanges: [],
|
||
relations: [],
|
||
territoryChanges: [],
|
||
unowned: [],
|
||
nationOps: [],
|
||
takeovers: [],
|
||
wars: { declare: [], end: [] },
|
||
hybrid: { ops: [] },
|
||
economy: { trade: [], embargo: [] }, industry: { projects: [] },
|
||
nuclear: { arm: [], strikes: [] }, leaders: { changes: [] },
|
||
ideology: { changes: [] },
|
||
markers: {
|
||
add: [
|
||
{ type: 'city', name: 'Good', regionKey: 'moselle-rygy' }, // valid + centroid → kept
|
||
{ type: 'city', name: 'NoCent', regionKey: 'other-key' }, // in validKeys, no centroid → dropped
|
||
{ type: 'city', name: 'Unknown', regionKey: 'zzz-not-a-key' }, // not in validKeys → dropped
|
||
],
|
||
remove: [],
|
||
},
|
||
};
|
||
const next = applyTurnResult(state, result, validKeys, centroidOf2);
|
||
expect(next.markers).toHaveLength(1);
|
||
expect(next.markers[0].name).toBe('Good');
|
||
});
|
||
|
||
it('caps the total marker count at MAX_MARKERS, dropping the newest add that exceeds the cap', () => {
|
||
const state = baseState();
|
||
// pre-seed the state with MAX_MARKERS - 1 distinct markers
|
||
const seeded: typeof state.markers = [];
|
||
for (let i = 0; i < MAX_MARKERS - 1; i++) {
|
||
const k = `seed-${i}`;
|
||
seeded.push({
|
||
id: markerId('city', `S${i}`, k),
|
||
type: 'city',
|
||
name: `S${i}`,
|
||
regionKey: k,
|
||
lat: 0, lon: 0,
|
||
ownerNationId: null,
|
||
placedTurn: 1,
|
||
});
|
||
}
|
||
state.markers = seeded;
|
||
// a permissive centroid: every region key resolves
|
||
const anyCentroid = () => ({ lat: 0, lon: 0, spread: 0 });
|
||
const result: TurnResult = {
|
||
date: '1939-09-03',
|
||
events: [],
|
||
stateChanges: [],
|
||
regionChanges: [],
|
||
relations: [],
|
||
territoryChanges: [],
|
||
unowned: [],
|
||
nationOps: [],
|
||
takeovers: [],
|
||
wars: { declare: [], end: [] },
|
||
hybrid: { ops: [] },
|
||
economy: { trade: [], embargo: [] }, industry: { projects: [] },
|
||
nuclear: { arm: [], strikes: [] }, leaders: { changes: [] },
|
||
ideology: { changes: [] },
|
||
markers: {
|
||
add: [
|
||
{ type: 'city', name: 'NewA', regionKey: 'new-a' },
|
||
{ type: 'city', name: 'NewB', regionKey: 'new-b' },
|
||
],
|
||
remove: [],
|
||
},
|
||
};
|
||
const next = applyTurnResult(state, result, undefined, anyCentroid);
|
||
expect(next.markers).toHaveLength(MAX_MARKERS); // capped, not MAX_MARKERS + 1
|
||
expect(next.markers.some((m) => m.name === 'NewA')).toBe(true); // the 1st new one kept
|
||
expect(next.markers.some((m) => m.name === 'NewB')).toBe(false); // the 2nd (over cap) dropped
|
||
});
|
||
|
||
it('removes a marker by id; removing an absent id is a no-op', () => {
|
||
const state = baseState();
|
||
state.nations.uk.territory = ['moselle-rygy'];
|
||
const idA = markerId('city', 'Strasbourg', 'moselle-rygy');
|
||
const idB = markerId('port', 'Dunkirk', 'moselle-rygy');
|
||
state.markers = [
|
||
{ id: idA, type: 'city', name: 'Strasbourg', regionKey: 'moselle-rygy', lat: 48, lon: 6, ownerNationId: 'uk', placedTurn: 1 },
|
||
{ id: idB, type: 'port', name: 'Dunkirk', regionKey: 'moselle-rygy', lat: 48, lon: 6, ownerNationId: 'uk', placedTurn: 1 },
|
||
];
|
||
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: [] }, nuclear: { arm: [], strikes: [] }, leaders: { changes: [] }, ideology: { changes: [] }, 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
|
||
});
|
||
|
||
it('a marker owner follows its region: released to no man\'s land → null; re-claimed → the new owner', () => {
|
||
const state = baseState();
|
||
state.nations.uk.territory = ['moselle-rygy'];
|
||
const idA = markerId('city', 'Strasbourg', 'moselle-rygy');
|
||
state.markers = [
|
||
{ id: idA, type: 'city', name: 'Strasbourg', regionKey: 'moselle-rygy', lat: 48, lon: 6, ownerNationId: 'uk', placedTurn: 1 },
|
||
];
|
||
// 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: [] }, nuclear: { arm: [], strikes: [] }, leaders: { changes: [] }, ideology: { changes: [] }, 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: [] }, nuclear: { arm: [], strikes: [] }, leaders: { changes: [] }, ideology: { changes: [] }, markers: { add: [], remove: [] },
|
||
}, undefined, centroidOf);
|
||
expect(reclaimed.markers[0].ownerNationId).toBe('de');
|
||
});
|
||
|
||
it('the user prompt carries the existing markers when provided (and omits the field otherwise)', () => {
|
||
const state = baseState();
|
||
const info = {
|
||
id: markerId('city', 'Strasbourg', 'moselle-rygy'),
|
||
type: 'city' as MarkerType,
|
||
name: 'Strasbourg',
|
||
regionKey: 'moselle-rygy',
|
||
owner: 'uk',
|
||
};
|
||
const payload = JSON.parse(
|
||
buildUserPrompt(state, [], undefined, undefined, undefined, [info]),
|
||
) as { markers?: Array<Record<string, unknown>> };
|
||
expect(payload.markers).toHaveLength(1);
|
||
expect(payload.markers![0].name).toBe('Strasbourg');
|
||
expect(payload.markers![0].owner).toBe('uk');
|
||
// a bare call (no markers arg) omits the field entirely
|
||
const bare = JSON.parse(buildUserPrompt(state, [])) as { markers?: unknown };
|
||
expect(bare.markers).toBeUndefined();
|
||
});
|
||
|
||
it('the system prompt documents the markers contract (field + placement rule + a type)', () => {
|
||
const prompt = buildSystemPrompt(preset);
|
||
expect(prompt).toMatch(/named points on the map/i);
|
||
expect(prompt).toMatch(/"markers"/);
|
||
expect(prompt).toMatch(/"add"/);
|
||
expect(prompt).toMatch(/"remove"/);
|
||
expect(prompt).toMatch(/gathering/);
|
||
expect(prompt).toMatch(/no man's land/); // owner recompute on release
|
||
});
|
||
|
||
it('validateMarkerOps cleans a raw markers field: drops bad types/empty names, dedupes, trims removes', () => {
|
||
const raw = {
|
||
add: [
|
||
{ type: 'city', name: 'X', regionKey: 'a' },
|
||
{ type: 'not-a-type', name: 'Y', regionKey: 'a' }, // invalid type -> dropped
|
||
{ type: 'city', name: ' ', regionKey: 'a' }, // empty name -> dropped
|
||
{ type: 'city', name: 'X', regionKey: 'a' }, // duplicate (same id) -> deduped
|
||
{ type: 'port', name: 'Z', regionKey: 'b' }, // valid
|
||
],
|
||
remove: ['id-1', 42, '', ' id-2 ', 'id-1'], // number/empty dropped, trimmed, deduped
|
||
};
|
||
const out = validateMarkerOps(raw);
|
||
expect(out.add.map((m) => m.name).sort()).toEqual(['X', 'Z']);
|
||
expect(out.remove).toEqual(['id-1', 'id-2']);
|
||
});
|
||
|
||
it('validateMarkerOps returns empty ops for a non-object raw value (a string, an array, null)', () => {
|
||
for (const bad of ['oops', [{ type: 'city', name: 'X', regionKey: 'a' }], null, undefined]) {
|
||
expect(validateMarkerOps(bad)).toEqual({ add: [], remove: [] });
|
||
}
|
||
});
|
||
|
||
it('runTurn wires the centroid through: a marker added over the LLM path is placed (owner = region owner)', async () => {
|
||
const result = JSON.stringify({
|
||
date: '1939-09-03',
|
||
events: [],
|
||
stateChanges: [],
|
||
relations: [],
|
||
territoryChanges: [{ nation: 'uk', gain: ['moselle-rygy'], lose: [] }],
|
||
unowned: [],
|
||
nationOps: [],
|
||
wars: { declare: [], end: [] },
|
||
hybrid: { ops: [] },
|
||
economy: { trade: [], embargo: [] }, industry: { projects: [] },
|
||
nuclear: { arm: [], strikes: [] }, leaders: { changes: [] },
|
||
ideology: { changes: [] },
|
||
markers: { add: [{ type: 'city', name: 'Strasbourg', regionKey: 'moselle-rygy' }], remove: [] },
|
||
});
|
||
const { state } = await runTurn({
|
||
llm: fakeLlm([result]),
|
||
preset,
|
||
state: baseState(),
|
||
items: [],
|
||
centroidOf,
|
||
});
|
||
expect(state.markers).toHaveLength(1);
|
||
expect(state.markers[0].name).toBe('Strasbourg');
|
||
expect(state.markers[0].ownerNationId).toBe('uk'); // uk gained the region this turn
|
||
});
|
||
});
|
||
|
||
// --- Per-region stats (v0.28, #46): the derived-stats model ------------------
|
||
//
|
||
// A nation's 0–100 stats are DERIVED as the area×population-weighted median of
|
||
// the stats of the regions it owns (`GameState.regionStats`). These tests pin
|
||
// the turn-level maintenance of that model in `applyTurnResult`:
|
||
// - a transferred region keeps its OWN stats (the conquest is what makes a
|
||
// nation's regions diverge);
|
||
// - a released region's entry is deleted (no man's land carries none);
|
||
// - a re-claimed region with no entry is re-inited to the gainer's stats;
|
||
// - a 0-region nation keeps its (directly-maintained) stats;
|
||
// - the end-of-turn recompute weights by area×population when `geoOf` is
|
||
// provided (and degrades to a uniform median without it).
|
||
//
|
||
// `applyTurnResult` is used directly (not `runTurn`) so the tests are pure and
|
||
// don't depend on the LLM: the result objects below are constructed directly.
|
||
|
||
const NS = (military: number, economy: number, stability: number, population: number): NationStats => ({
|
||
military,
|
||
economy,
|
||
stability,
|
||
population,
|
||
});
|
||
|
||
const RS = (military: number, economy: number, stability: number, population: number): RegionStats => ({
|
||
military,
|
||
economy,
|
||
stability,
|
||
population,
|
||
});
|
||
|
||
function mkNation(id: string, stats: NationStats, territory: string[]): Nation {
|
||
return { id, name: id.toUpperCase(), type: 'major', stats, territory, relations: {} };
|
||
}
|
||
|
||
function mkTurnState(
|
||
nations: Record<string, Nation>,
|
||
regionStats: Record<string, RegionStats>,
|
||
unowned: string[] = [],
|
||
): GameState {
|
||
return {
|
||
id: 'g',
|
||
presetId: 'p',
|
||
presetTitle: 'P',
|
||
playerNationId: Object.keys(nations)[0] ?? '',
|
||
turn: 1,
|
||
date: '1939-09-01',
|
||
nations,
|
||
events: [],
|
||
pending: [],
|
||
version: 1,
|
||
unowned,
|
||
pendingNationOps: [],
|
||
markers: [],
|
||
regionStats,
|
||
wars: [],
|
||
// (v0.35.0, #58) no active trades or embargoes on a fresh state.
|
||
trades: [],
|
||
embargoes: [],
|
||
// (v0.36.0, #59) no industry projects on a fresh state.
|
||
industry: [],
|
||
};
|
||
}
|
||
|
||
function mkTurnResult(partial: Partial<TurnResult>): TurnResult {
|
||
return {
|
||
date: '1939-09-03',
|
||
events: partial.events ?? [],
|
||
stateChanges: partial.stateChanges ?? [],
|
||
regionChanges: partial.regionChanges ?? [],
|
||
relations: partial.relations ?? [],
|
||
territoryChanges: partial.territoryChanges ?? [],
|
||
unowned: partial.unowned ?? [],
|
||
nationOps: partial.nationOps ?? [],
|
||
takeovers: partial.takeovers ?? [],
|
||
wars: partial.wars ?? { declare: [], end: [] },
|
||
hybrid: partial.hybrid ?? { ops: [] },
|
||
economy: partial.economy ?? { trade: [], embargo: [] },
|
||
industry: partial.industry ?? { projects: [] },
|
||
nuclear: partial.nuclear ?? { arm: [], strikes: [] },
|
||
leaders: partial.leaders ?? { changes: [] },
|
||
ideology: partial.ideology ?? { changes: [] },
|
||
markers: partial.markers ?? { add: [], remove: [] },
|
||
};
|
||
}
|
||
|
||
describe('applyTurnResult — per-region stats (v0.28, #46)', () => {
|
||
it('a transferred region keeps its OWN stats; the conqueror\'s aggregate blends them', () => {
|
||
// uk owns g1 (military 80), de owns d1 (military 95). de conquers g1.
|
||
const state = mkTurnState(
|
||
{
|
||
uk: mkNation('uk', NS(80, 70, 60, 50), ['g1']),
|
||
de: mkNation('de', NS(95, 90, 75, 90), ['d1']),
|
||
},
|
||
{
|
||
g1: RS(80, 70, 60, 50),
|
||
d1: RS(95, 90, 75, 90),
|
||
},
|
||
);
|
||
const next = applyTurnResult(
|
||
state,
|
||
mkTurnResult({ territoryChanges: [{ nation: 'de', gain: ['g1'], lose: [] }] }),
|
||
);
|
||
// territory moved
|
||
expect(next.nations.uk.territory).toEqual([]);
|
||
expect(next.nations.de.territory).toEqual(['d1', 'g1']);
|
||
// the transferred region KEPT its own (uk's) stats — not re-inited to de's
|
||
expect(next.regionStats.g1).toEqual(RS(80, 70, 60, 50));
|
||
expect(next.regionStats.d1).toEqual(RS(95, 90, 75, 90));
|
||
// de's derived stats are the (uniform) median of its two regions → pulled
|
||
// toward the conquered region's lower values
|
||
expect(next.nations.de.stats).toEqual(RS(80, 70, 60, 50));
|
||
// uk (now 0 regions) keeps its directly-maintained stats untouched
|
||
expect(next.nations.uk.stats).toEqual(NS(80, 70, 60, 50));
|
||
});
|
||
|
||
it('a released region leaves no-man\'s land and its stats entry is deleted', () => {
|
||
const state = mkTurnState(
|
||
{ uk: mkNation('uk', NS(80, 70, 60, 50), ['g1', 'g2']) },
|
||
{ g1: RS(80, 70, 60, 50), g2: RS(60, 60, 60, 60) },
|
||
);
|
||
const next = applyTurnResult(state, mkTurnResult({ unowned: ['g1'] }));
|
||
expect(next.unowned).toEqual(['g1']);
|
||
expect(next.nations.uk.territory).toEqual(['g2']);
|
||
// g1's stats left the table with the region; g2's stay
|
||
expect(next.regionStats).toEqual({ g2: RS(60, 60, 60, 60) });
|
||
// uk's derived stats now come only from g2
|
||
expect(next.nations.uk.stats).toEqual(RS(60, 60, 60, 60));
|
||
});
|
||
|
||
it('a re-claimed region with no entry is re-inited to the gainer\'s current stats', () => {
|
||
// g1 is unowned (released earlier; its stats entry was deleted). de owns d1.
|
||
const deStats = NS(95, 90, 75, 90);
|
||
const state = mkTurnState(
|
||
{
|
||
uk: mkNation('uk', NS(80, 70, 60, 50), []),
|
||
de: mkNation('de', deStats, ['d1']),
|
||
},
|
||
{ d1: RS(95, 90, 75, 90) }, // g1 has NO entry (it was released)
|
||
['g1'],
|
||
);
|
||
const next = applyTurnResult(state, mkTurnResult({ territoryChanges: [{ nation: 'de', gain: ['g1'], lose: [] }] }));
|
||
expect(next.unowned).toEqual([]); // g1 leaves no man's land
|
||
expect(next.nations.de.territory).toEqual(['d1', 'g1']);
|
||
// g1 was re-inited as a copy of de's (pre-stateChanges) stats
|
||
expect(next.regionStats.g1).toEqual(RS(95, 90, 75, 90));
|
||
// both of de's regions now hold identical stats → derived stats = those stats
|
||
expect(next.nations.de.stats).toEqual(RS(95, 90, 75, 90));
|
||
});
|
||
|
||
it('a 0-region nation keeps its directly-maintained stats (the recompute skips it)', () => {
|
||
const state = mkTurnState(
|
||
{ uk: mkNation('uk', NS(80, 70, 60, 50), []) },
|
||
{},
|
||
);
|
||
const next = applyTurnResult(
|
||
state,
|
||
mkTurnResult({ stateChanges: [{ nation: 'uk', stat: 'military', op: 'set', value: 99 }] }),
|
||
);
|
||
expect(next.nations.uk.territory).toEqual([]);
|
||
// no regions to aggregate → the nation-level update sticks
|
||
expect(next.nations.uk.stats.military).toBe(99);
|
||
expect(next.regionStats).toEqual({});
|
||
});
|
||
|
||
it('the recompute weights by area×population: the larger region dominates (geoOf)', () => {
|
||
// uk owns two regions: a small one (military 50) and a large one (military
|
||
// 90). With area×population weighting the large region's value wins; without
|
||
// geo the uniform median picks the lower value (reached first at half-weight).
|
||
const state = mkTurnState(
|
||
{ uk: mkNation('uk', NS(50, 50, 50, 50), ['big', 'small']) },
|
||
{ big: RS(90, 90, 90, 90), small: RS(50, 50, 50, 50) },
|
||
);
|
||
const geoOf = (k: string) =>
|
||
k === 'big' ? { area: 1000, population: 1000 } : { area: 10, population: 100 };
|
||
|
||
const weighted = applyTurnResult(state, mkTurnResult({}), undefined, undefined, geoOf);
|
||
expect(weighted.nations.uk.stats).toEqual(RS(90, 90, 90, 90)); // the large region dominates
|
||
|
||
const uniform = applyTurnResult(state, mkTurnResult({})); // no geoOf
|
||
expect(uniform.nations.uk.stats).toEqual(RS(50, 50, 50, 50)); // uniform → lower value first
|
||
});
|
||
|
||
it('a nation-level stat change re-applies to every owned region (before the recompute)', () => {
|
||
// uk owns two identical regions. A nation-level `add` applies to each region
|
||
// and the derived (uniform) median tracks it; a 0-region sibling keeps its
|
||
// own nation-level update.
|
||
const state = mkTurnState(
|
||
{
|
||
uk: mkNation('uk', NS(50, 50, 50, 50), ['a', 'b']),
|
||
de: mkNation('de', NS(50, 50, 50, 50), []),
|
||
},
|
||
{ a: RS(50, 50, 50, 50), b: RS(50, 50, 50, 50) },
|
||
);
|
||
const next = applyTurnResult(
|
||
state,
|
||
mkTurnResult({
|
||
stateChanges: [
|
||
{ nation: 'uk', stat: 'economy', op: 'add', value: 20 },
|
||
{ nation: 'de', stat: 'military', op: 'set', value: 77 },
|
||
],
|
||
}),
|
||
);
|
||
// uk: both regions +20 economy → derived economy 70
|
||
expect(next.regionStats.a).toEqual(RS(50, 70, 50, 50));
|
||
expect(next.regionStats.b).toEqual(RS(50, 70, 50, 50));
|
||
expect(next.nations.uk.stats).toEqual(RS(50, 70, 50, 50));
|
||
// de: 0 regions → its nation-level set sticks
|
||
expect(next.nations.de.stats.military).toBe(77);
|
||
});
|
||
|
||
it('the original state is never mutated', () => {
|
||
const state = mkTurnState(
|
||
{ uk: mkNation('uk', NS(80, 70, 60, 50), ['g1']), de: mkNation('de', NS(95, 90, 75, 90), ['d1']) },
|
||
{ g1: RS(80, 70, 60, 50), d1: RS(95, 90, 75, 90) },
|
||
);
|
||
const beforeNations = JSON.stringify(state.nations);
|
||
const beforeRegions = JSON.stringify(state.regionStats);
|
||
applyTurnResult(state, mkTurnResult({ territoryChanges: [{ nation: 'de', gain: ['g1'], lose: [] }] }));
|
||
expect(JSON.stringify(state.nations)).toBe(beforeNations);
|
||
expect(JSON.stringify(state.regionStats)).toBe(beforeRegions);
|
||
});
|
||
});
|
||
|
||
// --- (v0.29, #46 PR 2) Per-region stat lever + region-stat hover ---
|
||
|
||
describe('resolveRegionKey (v0.29, #46 PR 2)', () => {
|
||
const keys = new Set(['aabb-1111', 'bccc-2222', 'cddd-3333']);
|
||
const nameOf = (k: string) =>
|
||
k === 'aabb-1111' ? 'Alpha' : k === 'bccc-2222' ? 'Beta' : k === 'cddd-3333' ? 'Gamma' : undefined;
|
||
|
||
it('an exact key match returns the key', () => {
|
||
expect(resolveRegionKey('aabb-1111', keys, nameOf)).toBe('aabb-1111');
|
||
});
|
||
|
||
it('a case-insensitive name match returns the key', () => {
|
||
expect(resolveRegionKey('alpha', keys, nameOf)).toBe('aabb-1111');
|
||
expect(resolveRegionKey('ALPHA', keys, nameOf)).toBe('aabb-1111');
|
||
expect(resolveRegionKey(' gamma ', keys, nameOf)).toBe('cddd-3333');
|
||
});
|
||
|
||
it('an unknown ref (not a key, no name match) returns null', () => {
|
||
expect(resolveRegionKey('not-a-key', keys, nameOf)).toBeNull();
|
||
});
|
||
|
||
it('an ambiguous name (two regions share it) returns null', () => {
|
||
const nameOf2 = (k: string) =>
|
||
k === 'aabb-1111' || k === 'bccc-2222' ? 'Twin' : undefined;
|
||
expect(resolveRegionKey('twin', keys, nameOf2)).toBeNull();
|
||
});
|
||
|
||
it('empty or whitespace ref returns null', () => {
|
||
expect(resolveRegionKey('', keys, nameOf)).toBeNull();
|
||
expect(resolveRegionKey(' ', keys, nameOf)).toBeNull();
|
||
});
|
||
|
||
it('a key that matches both a key and a different region\'s name prefers the key', () => {
|
||
// 'aabb-1111' is a valid key; nameOf maps 'bccc-2222' to 'Aabb-1111' (case-insensitive match)
|
||
const nameOf2 = (k: string) => (k === 'bccc-2222' ? 'Aabb-1111' : undefined);
|
||
expect(resolveRegionKey('aabb-1111', keys, nameOf2)).toBe('aabb-1111'); // key wins
|
||
});
|
||
});
|
||
|
||
describe('validateRegionChanges (v0.29, #46 PR 2)', () => {
|
||
const keys = new Set(['aabb-1111', 'bccc-2222']);
|
||
const nameOf = (k: string) =>
|
||
k === 'aabb-1111' ? 'Alpha' : k === 'bccc-2222' ? 'Beta' : undefined;
|
||
const resolver = (ref: string) => resolveRegionKey(ref, keys, nameOf);
|
||
|
||
it('a valid key ref is resolved and passed through', () => {
|
||
const out = validateRegionChanges(
|
||
[{ region: 'aabb-1111', stat: 'military', op: 'set', value: 50 }],
|
||
resolver,
|
||
);
|
||
expect(out).toEqual([{ region: 'aabb-1111', stat: 'military', op: 'set', value: 50 }]);
|
||
});
|
||
|
||
it('a case-insensitive name ref is resolved to the key', () => {
|
||
const out = validateRegionChanges(
|
||
[{ region: 'alpha', stat: 'economy', op: 'set', value: 30 }],
|
||
resolver,
|
||
);
|
||
expect(out).toEqual([{ region: 'aabb-1111', stat: 'economy', op: 'set', value: 30 }]);
|
||
});
|
||
|
||
it('an unknown ref (resolver returns null) is dropped', () => {
|
||
const out = validateRegionChanges(
|
||
[{ region: 'no-such-region', stat: 'military', op: 'set', value: 50 }],
|
||
resolver,
|
||
);
|
||
expect(out).toEqual([]);
|
||
});
|
||
|
||
it('an invalid stat is dropped', () => {
|
||
const out = validateRegionChanges(
|
||
[{ region: 'aabb-1111', stat: 'bogus', op: 'set', value: 50 }],
|
||
resolver,
|
||
);
|
||
expect(out).toEqual([]);
|
||
});
|
||
|
||
it('a non-numeric value is dropped', () => {
|
||
const out = validateRegionChanges(
|
||
[{ region: 'aabb-1111', stat: 'military', op: 'set', value: 'fifty' }],
|
||
resolver,
|
||
);
|
||
expect(out).toEqual([]);
|
||
});
|
||
|
||
it('a NaN value is dropped', () => {
|
||
const out = validateRegionChanges(
|
||
[{ region: 'aabb-1111', stat: 'military', op: 'set', value: NaN }],
|
||
resolver,
|
||
);
|
||
expect(out).toEqual([]);
|
||
});
|
||
|
||
it('an invalid op is dropped', () => {
|
||
const out = validateRegionChanges(
|
||
[{ region: 'aabb-1111', stat: 'military', op: 'multiply', value: 2 }],
|
||
resolver,
|
||
);
|
||
expect(out).toEqual([]);
|
||
});
|
||
|
||
it('a set value is clamped to 0-100 at validation', () => {
|
||
const out = validateRegionChanges(
|
||
[
|
||
{ region: 'aabb-1111', stat: 'military', op: 'set', value: 150 },
|
||
{ region: 'aabb-1111', stat: 'economy', op: 'set', value: -10 },
|
||
],
|
||
resolver,
|
||
);
|
||
expect(out).toEqual([
|
||
{ region: 'aabb-1111', stat: 'military', op: 'set', value: 100 },
|
||
{ region: 'aabb-1111', stat: 'economy', op: 'set', value: 0 },
|
||
]);
|
||
});
|
||
|
||
it('an add value is NOT clamped at validation (it is clamped at apply time)', () => {
|
||
const out = validateRegionChanges(
|
||
[{ region: 'aabb-1111', stat: 'military', op: 'add', value: 999 }],
|
||
resolver,
|
||
);
|
||
expect(out).toEqual([{ region: 'aabb-1111', stat: 'military', op: 'add', value: 999 }]);
|
||
});
|
||
|
||
it('non-array input returns []', () => {
|
||
expect(validateRegionChanges(null)).toEqual([]);
|
||
expect(validateRegionChanges('hello')).toEqual([]);
|
||
expect(validateRegionChanges(undefined)).toEqual([]);
|
||
});
|
||
|
||
it('non-object entries are skipped', () => {
|
||
const out = validateRegionChanges(
|
||
[null, 42, 'x', { region: 'aabb-1111', stat: 'military', op: 'set', value: 50 }],
|
||
resolver,
|
||
);
|
||
expect(out).toEqual([{ region: 'aabb-1111', stat: 'military', op: 'set', value: 50 }]);
|
||
});
|
||
|
||
it('without a resolver, the raw ref is kept (apply will no-op on a miss)', () => {
|
||
const out = validateRegionChanges(
|
||
[{ region: 'some-name', stat: 'military', op: 'set', value: 50 }],
|
||
);
|
||
expect(out).toEqual([{ region: 'some-name', stat: 'military', op: 'set', value: 50 }]);
|
||
});
|
||
});
|
||
|
||
describe('applyTurnResult — regionChanges (v0.29, #46 PR 2)', () => {
|
||
it('a set changes only that region\'s stats; the derived aggregate shifts', () => {
|
||
const state = mkTurnState(
|
||
{ uk: mkNation('uk', NS(50, 50, 50, 50), ['a', 'b']) },
|
||
{ a: RS(50, 50, 50, 50), b: RS(50, 50, 50, 50) },
|
||
);
|
||
const next = applyTurnResult(
|
||
state,
|
||
mkTurnResult({ regionChanges: [{ region: 'a', stat: 'military', op: 'set', value: 80 }] }),
|
||
);
|
||
// region a got the set; region b is untouched
|
||
expect(next.regionStats.a).toEqual(RS(80, 50, 50, 50));
|
||
expect(next.regionStats.b).toEqual(RS(50, 50, 50, 50));
|
||
// derived stats are the uniform median of a and b → military median(80,50)=50
|
||
expect(next.nations.uk.stats.military).toBe(50);
|
||
});
|
||
|
||
it('an add clamps to 0 and 100 at apply time', () => {
|
||
const state = mkTurnState(
|
||
{ uk: mkNation('uk', NS(90, 50, 50, 50), ['a']) },
|
||
{ a: RS(90, 50, 50, 50) },
|
||
);
|
||
const next = applyTurnResult(
|
||
state,
|
||
mkTurnResult({
|
||
regionChanges: [
|
||
{ region: 'a', stat: 'military', op: 'add', value: 999 }, // 90+999 → clamps to 100
|
||
{ region: 'a', stat: 'economy', op: 'add', value: -999 }, // 50-999 → clamps to 0
|
||
],
|
||
}),
|
||
);
|
||
expect(next.regionStats.a.military).toBe(100);
|
||
expect(next.regionStats.a.economy).toBe(0);
|
||
});
|
||
|
||
it('a region with no stats entry (unowned / no man\'s land) is a no-op', () => {
|
||
const state = mkTurnState(
|
||
{ uk: mkNation('uk', NS(50, 50, 50, 50), ['a']) },
|
||
{ a: RS(50, 50, 50, 50) },
|
||
['ghost'], // ghost is unowned, has no entry
|
||
);
|
||
const next = applyTurnResult(
|
||
state,
|
||
mkTurnResult({ regionChanges: [{ region: 'ghost', stat: 'military', op: 'set', value: 99 }] }),
|
||
);
|
||
// ghost has no entry → no-op; a is unchanged; ghost does NOT gain an entry
|
||
expect(next.regionStats.a).toEqual(RS(50, 50, 50, 50));
|
||
expect(next.regionStats).not.toHaveProperty('ghost');
|
||
});
|
||
|
||
it('regionChanges are applied AFTER stateChanges (region-level overrides nation-level)', () => {
|
||
const state = mkTurnState(
|
||
{ uk: mkNation('uk', NS(50, 50, 50, 50), ['a']) },
|
||
{ a: RS(50, 50, 50, 50) },
|
||
);
|
||
const next = applyTurnResult(
|
||
state,
|
||
mkTurnResult({
|
||
stateChanges: [{ nation: 'uk', stat: 'military', op: 'set', value: 70 }],
|
||
regionChanges: [{ region: 'a', stat: 'military', op: 'set', value: 90 }],
|
||
}),
|
||
);
|
||
// stateChanges set all of uk's regions to 70; then regionChanges overwrote a to 90
|
||
expect(next.regionStats.a.military).toBe(90);
|
||
});
|
||
|
||
it('a nation-level add and a region-level set compose correctly', () => {
|
||
const state = mkTurnState(
|
||
{ uk: mkNation('uk', NS(50, 50, 50, 50), ['a', 'b']) },
|
||
{ a: RS(50, 50, 50, 50), b: RS(50, 50, 50, 50) },
|
||
);
|
||
const next = applyTurnResult(
|
||
state,
|
||
mkTurnResult({
|
||
stateChanges: [{ nation: 'uk', stat: 'economy', op: 'add', value: 10 }],
|
||
regionChanges: [{ region: 'a', stat: 'economy', op: 'set', value: 99 }],
|
||
}),
|
||
);
|
||
// stateChanges: both regions +10 → a=60, b=60
|
||
// regionChanges: a set to 99 → a=99, b=60
|
||
expect(next.regionStats.a.economy).toBe(99);
|
||
expect(next.regionStats.b.economy).toBe(60);
|
||
});
|
||
|
||
it('the original state is never mutated', () => {
|
||
const state = mkTurnState(
|
||
{ uk: mkNation('uk', NS(50, 50, 50, 50), ['a']) },
|
||
{ a: RS(50, 50, 50, 50) },
|
||
);
|
||
const before = JSON.stringify(state);
|
||
applyTurnResult(
|
||
state,
|
||
mkTurnResult({ regionChanges: [{ region: 'a', stat: 'military', op: 'set', value: 99 }] }),
|
||
);
|
||
expect(JSON.stringify(state)).toBe(before);
|
||
});
|
||
});
|
||
|
||
describe('prompt contract — regionChanges (v0.29, #46 PR 2)', () => {
|
||
it('the system prompt documents the regionChanges JSON field', () => {
|
||
const p = buildSystemPrompt(preset);
|
||
expect(p).toMatch(/"regionChanges": \[\{"region":"<region key or name>"/);
|
||
});
|
||
|
||
it('the system prompt has the per-region stat change constraint bullet', () => {
|
||
const p = buildSystemPrompt(preset);
|
||
expect(p).toMatch(/Per-region stat changes \("regionChanges"\) target ONE region/);
|
||
expect(p).toMatch(/"playerRegionStats"/);
|
||
});
|
||
|
||
it('the user prompt emits playerRegionStats bounded to the player territory', () => {
|
||
const state = mkTurnState(
|
||
{
|
||
uk: mkNation('uk', NS(50, 50, 50, 50), ['a', 'b']),
|
||
de: mkNation('de', NS(90, 90, 90, 90), ['c']),
|
||
},
|
||
{ a: RS(50, 50, 50, 50), b: RS(60, 60, 60, 60), c: RS(90, 90, 90, 90) },
|
||
);
|
||
const p = buildUserPrompt(state, []);
|
||
const payload = JSON.parse(p);
|
||
// only the player's regions are in playerRegionStats
|
||
expect(payload.playerRegionStats).toEqual({
|
||
a: RS(50, 50, 50, 50),
|
||
b: RS(60, 60, 60, 60),
|
||
});
|
||
// de's region c is NOT in playerRegionStats
|
||
expect(payload.playerRegionStats).not.toHaveProperty('c');
|
||
});
|
||
|
||
it('playerRegionStats is {} when the player has no regions with entries', () => {
|
||
const state = mkTurnState(
|
||
{ uk: mkNation('uk', NS(50, 50, 50, 50), []) },
|
||
{},
|
||
);
|
||
const p = buildUserPrompt(state, []);
|
||
const payload = JSON.parse(p);
|
||
expect(payload.playerRegionStats).toEqual({});
|
||
});
|
||
|
||
it('playerRegionStats omits a player region that has no stats entry', () => {
|
||
const state = mkTurnState(
|
||
{ uk: mkNation('uk', NS(50, 50, 50, 50), ['a', 'b']) },
|
||
{ a: RS(50, 50, 50, 50) }, // b has no entry
|
||
);
|
||
const p = buildUserPrompt(state, []);
|
||
const payload = JSON.parse(p);
|
||
expect(payload.playerRegionStats).toEqual({ a: RS(50, 50, 50, 50) });
|
||
expect(payload.playerRegionStats).not.toHaveProperty('b');
|
||
});
|
||
});
|
||
|
||
describe('isMeaningful — regionChanges (v0.29, #46 PR 2)', () => {
|
||
it('a turn with only regionChanges is meaningful', () => {
|
||
const result = mkTurnResult({
|
||
regionChanges: [{ region: 'a', stat: 'military', op: 'set', value: 90 }],
|
||
});
|
||
expect(isMeaningful(result)).toBe(true);
|
||
});
|
||
});
|
||
|
||
describe('stub — regionChanges (v0.29, #46 PR 2)', () => {
|
||
it('the deterministic stub returns regionChanges: []', () => {
|
||
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.regionChanges).toEqual([]);
|
||
// all other required fields are present
|
||
expect(result).toHaveProperty('date');
|
||
expect(result).toHaveProperty('events');
|
||
expect(result).toHaveProperty('stateChanges');
|
||
expect(result).toHaveProperty('relations');
|
||
expect(result).toHaveProperty('territoryChanges');
|
||
expect(result).toHaveProperty('unowned');
|
||
expect(result).toHaveProperty('nationOps');
|
||
expect(result).toHaveProperty('markers');
|
||
});
|
||
});
|
||
|
||
describe('applyTurnResult — military hard gate (v0.30 #50)', () => {
|
||
it('an invasion whose attacker is MORE than the margin below the defender is REPELLED (key stays, a repelled event is emitted)', () => {
|
||
// uk owns 'a' (military 80). de (military 50) tries to gain 'a'.
|
||
// gap = 80 - 50 = 30 > 15 → repelled.
|
||
const state = mkTurnState(
|
||
{
|
||
uk: mkNation('uk', NS(80, 70, 60, 50), ['a']),
|
||
de: mkNation('de', NS(50, 50, 50, 50), ['b']),
|
||
},
|
||
{ a: RS(80, 70, 60, 50), b: RS(50, 50, 50, 50) },
|
||
);
|
||
const next = applyTurnResult(
|
||
state,
|
||
mkTurnResult({ territoryChanges: [{ nation: 'de', gain: ['a'], lose: [] }] }),
|
||
undefined,
|
||
undefined,
|
||
undefined,
|
||
(k) => (k === 'a' ? 'Region A' : undefined),
|
||
);
|
||
// the key stays with uk; de gains nothing
|
||
expect(next.nations.uk.territory).toEqual(['a']);
|
||
expect(next.nations.de.territory).toEqual(['b']);
|
||
// exactly one repelled event, concerning the attacker (de), military type
|
||
expect(next.events).toHaveLength(1);
|
||
expect(next.events[0]).toMatchObject({
|
||
nation: 'de',
|
||
type: 'military',
|
||
outcome: 'repelled',
|
||
title: 'Invasion repelled',
|
||
});
|
||
// the text names the attacker, the defender, and the region's display name
|
||
expect(next.events[0].text).toBe("DE's invasion of Region A was repelled by UK.");
|
||
// the attacker's military is NOT weakened by the failed attack
|
||
expect(next.nations.de.stats.military).toBe(50);
|
||
});
|
||
|
||
it('an invasion within the margin (gap <= 15) PROCEEDS (no repelled event)', () => {
|
||
// uk owns 'a' (military 80). de (military 66) tries to gain 'a'. gap = 14.
|
||
const state = mkTurnState(
|
||
{
|
||
uk: mkNation('uk', NS(80, 70, 60, 50), ['a']),
|
||
de: mkNation('de', NS(66, 50, 50, 50), ['b']),
|
||
},
|
||
{ a: RS(80, 70, 60, 50), b: RS(66, 50, 50, 50) },
|
||
);
|
||
const next = applyTurnResult(
|
||
state,
|
||
mkTurnResult({ territoryChanges: [{ nation: 'de', gain: ['a'], lose: [] }] }),
|
||
);
|
||
expect(next.nations.de.territory).toContain('a');
|
||
expect(next.nations.uk.territory).not.toContain('a');
|
||
expect(next.events).toHaveLength(0);
|
||
});
|
||
|
||
it('a STRONGER attacker (negative gap) proceeds', () => {
|
||
// uk owns 'a' (military 50). de (military 80) gains 'a'.
|
||
const state = mkTurnState(
|
||
{
|
||
uk: mkNation('uk', NS(50, 70, 60, 50), ['a']),
|
||
de: mkNation('de', NS(80, 50, 50, 50), ['b']),
|
||
},
|
||
{ a: RS(50, 70, 60, 50), b: RS(80, 50, 50, 50) },
|
||
);
|
||
const next = applyTurnResult(
|
||
state,
|
||
mkTurnResult({ territoryChanges: [{ nation: 'de', gain: ['a'], lose: [] }] }),
|
||
);
|
||
expect(next.nations.de.territory).toContain('a');
|
||
expect(next.events).toHaveLength(0);
|
||
});
|
||
|
||
it('a re-claim of an UNOWNED key is NOT gated (it always proceeds)', () => {
|
||
// 'a' is in no man's land. de (military 10) re-claims it even though uk
|
||
// (military 80) is far stronger — there is no defender, so the gate is bypassed.
|
||
const state = mkTurnState(
|
||
{
|
||
uk: mkNation('uk', NS(80, 70, 60, 50), ['b']),
|
||
de: mkNation('de', NS(10, 50, 50, 50), ['c']),
|
||
},
|
||
{ b: RS(80, 70, 60, 50), c: RS(10, 50, 50, 50) },
|
||
['a'],
|
||
);
|
||
const next = applyTurnResult(
|
||
state,
|
||
mkTurnResult({ territoryChanges: [{ nation: 'de', gain: ['a'], lose: [] }] }),
|
||
);
|
||
expect(next.nations.de.territory).toContain('a');
|
||
expect(next.unowned).toEqual([]); // 'a' leaves no man's land
|
||
expect(next.events).toHaveLength(0);
|
||
});
|
||
|
||
it('boundary: gap exactly CONQUEST_MILITARY_MARGIN proceeds; one more point is repelled', () => {
|
||
expect(CONQUEST_MILITARY_MARGIN).toBe(15);
|
||
// uk 65 vs de 50 → gap 15 (== margin) → proceeds
|
||
{
|
||
const state = mkTurnState(
|
||
{ uk: mkNation('uk', NS(65, 70, 60, 50), ['a']), de: mkNation('de', NS(50, 50, 50, 50), ['b']) },
|
||
{ a: RS(65, 70, 60, 50), b: RS(50, 50, 50, 50) },
|
||
);
|
||
const next = applyTurnResult(state, mkTurnResult({ territoryChanges: [{ nation: 'de', gain: ['a'], lose: [] }] }));
|
||
expect(next.nations.de.territory).toContain('a');
|
||
expect(next.events).toHaveLength(0);
|
||
}
|
||
// uk 66 vs de 50 → gap 16 (margin + 1) → repelled
|
||
{
|
||
const state = mkTurnState(
|
||
{ uk: mkNation('uk', NS(66, 70, 60, 50), ['a']), de: mkNation('de', NS(50, 50, 50, 50), ['b']) },
|
||
{ a: RS(66, 70, 60, 50), b: RS(50, 50, 50, 50) },
|
||
);
|
||
const next = applyTurnResult(state, mkTurnResult({ territoryChanges: [{ nation: 'de', gain: ['a'], lose: [] }] }));
|
||
expect(next.nations.uk.territory).toContain('a');
|
||
expect(next.events.some((e) => e.outcome === 'repelled')).toBe(true);
|
||
}
|
||
});
|
||
});
|
||
|
||
describe('buildSystemPrompt — stats mechanics (v0.30 #50)', () => {
|
||
it('both tool and plain modes document the hard gate, the stability takeover, and economy growth/decay', () => {
|
||
for (const useTools of [true, false]) {
|
||
const prompt = buildSystemPrompt(preset, useTools);
|
||
// section header + the military hard gate
|
||
expect(prompt).toMatch(/HOW THE FOUR 0-100 STATS DRIVE OUTCOMES/);
|
||
expect(prompt).toMatch(/MILITARY/);
|
||
expect(prompt).toMatch(/the engine ENFORCES/);
|
||
expect(prompt).toMatch(/MORE than 15 below the defender is REPELLED/);
|
||
expect(prompt).toMatch(/no man's land/);
|
||
// the soft stability takeover
|
||
expect(prompt).toMatch(/STABILITY/);
|
||
expect(prompt).toMatch(/stability is below 30/);
|
||
expect(prompt).toMatch(/gathering/);
|
||
expect(prompt).toMatch(/NON-military takeover/i);
|
||
// the economy growth / decay lever
|
||
expect(prompt).toMatch(/ECONOMY/);
|
||
expect(prompt).toMatch(/trend UP/);
|
||
expect(prompt).toMatch(/DECAY/);
|
||
}
|
||
});
|
||
});
|
||
|
||
describe('buildUserPrompt — frontier balance + takeover risks (v0.30 #50)', () => {
|
||
const pending: RoundItem[] = [{ kind: 'action', text: 'x' }];
|
||
|
||
it('emits a military-balance hint per frontier neighbour (repelled vs could succeed)', () => {
|
||
// player uk (military 50) borders de (60, within 15 → could succeed) and
|
||
// us (90, 40 above → repelled).
|
||
const state = mkTurnState(
|
||
{
|
||
uk: mkNation('uk', NS(50, 70, 60, 50), ['a']),
|
||
de: mkNation('de', NS(60, 70, 60, 50), ['b']),
|
||
us: mkNation('us', NS(90, 70, 60, 50), ['c']),
|
||
},
|
||
{ a: RS(50, 70, 60, 50), b: RS(60, 70, 60, 50), c: RS(90, 70, 60, 50) },
|
||
);
|
||
const prompt = buildUserPrompt(state, pending, undefined, ['b', 'c']);
|
||
expect(prompt).toMatch(/\[Your military: 50\] vs \[DE: 60\] — a direct invasion could succeed/);
|
||
expect(prompt).toMatch(/\[Your military: 50\] vs \[US: 90\] — a direct invasion would be repelled/);
|
||
});
|
||
|
||
it('does NOT emit the frontier military balance when no frontier is supplied', () => {
|
||
const state = mkTurnState({ uk: mkNation('uk', NS(50, 70, 60, 50), ['a']) }, { a: RS(50, 70, 60, 50) });
|
||
const prompt = buildUserPrompt(state, pending);
|
||
expect(prompt).not.toMatch(/frontierMilitary/);
|
||
});
|
||
|
||
it('emits takeover risks for low-stability nations that host a gathering (and not for others)', () => {
|
||
// de has stability 20 + a gathering marker → at risk. uk has stability 50 → not.
|
||
const markers: MarkerInfo[] = [
|
||
{ id: 'g1', type: 'gathering', name: 'Revolt', regionKey: 'b', owner: 'de' },
|
||
{ id: 'g2', type: 'gathering', name: 'Rally', regionKey: 'a', owner: 'uk' },
|
||
];
|
||
const state = mkTurnState(
|
||
{
|
||
uk: mkNation('uk', NS(50, 70, 50, 50), ['a']),
|
||
de: mkNation('de', NS(50, 70, 20, 50), ['b']),
|
||
},
|
||
{ a: RS(50, 70, 50, 50), b: RS(50, 70, 20, 50) },
|
||
);
|
||
const prompt = buildUserPrompt(state, pending, undefined, undefined, undefined, markers);
|
||
expect(prompt).toMatch(/takeoverRisks/);
|
||
expect(prompt).toMatch(/DE \(stability 20, a gathering is in progress\)/);
|
||
expect(prompt).not.toMatch(/UK \(stability/);
|
||
});
|
||
|
||
it('does NOT emit takeover risks when no markers exist', () => {
|
||
const state = mkTurnState({ uk: mkNation('uk', NS(50, 70, 20, 50), ['a']) }, { a: RS(50, 70, 20, 50) });
|
||
const prompt = buildUserPrompt(state, pending);
|
||
expect(prompt).not.toMatch(/takeoverRisks/);
|
||
});
|
||
});
|
||
|
||
describe('buildSystemPrompt — takeovers contract (v0.32.0 #54)', () => {
|
||
it('both modes document the takeovers field and the automatic (NOT player-confirmed) rule', () => {
|
||
for (const p of [buildSystemPrompt(preset), buildSystemPrompt(preset, true)]) {
|
||
// the JSON shape carries the field in both modes
|
||
expect(p).toMatch(
|
||
/"takeovers": \[\{"nationRef":"<nation id or name>","newRegimeName":"<name of the new regime>"\}\]/,
|
||
);
|
||
// the rule: a takeover is an automatic in-world regime change, not a proposal
|
||
expect(p).toMatch(/Takeover \(automatic, NOT player-confirmed\)/);
|
||
// and it is tied to the stability + gathering mechanic
|
||
expect(p).toMatch(/low-stability nation/);
|
||
expect(p).toMatch(/gathering/);
|
||
// and it is distinguished from the player-confirmed nationOps rename
|
||
expect(p).toMatch(/use "nationOps" rename only for a diplomatic\/political name change/);
|
||
}
|
||
});
|
||
});
|
||
|
||
describe('validateTakeovers (v0.32.0 #54)', () => {
|
||
it('parses valid entries (trimmed, capped) and drops invalid ones', () => {
|
||
const takeovers = validateTakeovers([
|
||
{ nationRef: ' de ', newRegimeName: 'The Reich' },
|
||
{ nationRef: 'uk', newRegimeName: ' ' }, // empty name -> dropped
|
||
{ nationRef: '', newRegimeName: 'X' }, // empty ref -> dropped
|
||
{ newRegimeName: 'no ref' }, // missing nationRef -> dropped
|
||
{ nationRef: 'us' }, // missing newRegimeName -> dropped
|
||
'not-an-object',
|
||
null,
|
||
42,
|
||
]);
|
||
expect(takeovers).toEqual([{ nationRef: 'de', newRegimeName: 'The Reich' }]);
|
||
});
|
||
|
||
it('caps both fields at 200 characters', () => {
|
||
const long = 'x'.repeat(250);
|
||
const takeovers = validateTakeovers([{ nationRef: long, newRegimeName: long }]);
|
||
expect(takeovers).toEqual([{ nationRef: 'x'.repeat(200), newRegimeName: 'x'.repeat(200) }]);
|
||
});
|
||
|
||
it('returns [] for non-array input and is never thrown by', () => {
|
||
expect(validateTakeovers(undefined)).toEqual([]);
|
||
expect(validateTakeovers('nope')).toEqual([]);
|
||
expect(validateTakeovers({})).toEqual([]);
|
||
});
|
||
|
||
it('validateTurnResult carries validated takeovers through (and defaults to [] when absent)', () => {
|
||
const withTakeovers = validateTurnResult({
|
||
date: '1939-09-03',
|
||
events: [],
|
||
stateChanges: [],
|
||
regionChanges: [],
|
||
relations: [],
|
||
territoryChanges: [],
|
||
unowned: [],
|
||
nationOps: [],
|
||
takeovers: [{ nationRef: 'de', newRegimeName: 'The Reich' }, { nationRef: 'bad', newRegimeName: '' }],
|
||
wars: { declare: [], end: [] },
|
||
hybrid: { ops: [] },
|
||
economy: { trade: [], embargo: [] }, industry: { projects: [] },
|
||
nuclear: { arm: [], strikes: [] }, leaders: { changes: [] },
|
||
ideology: { changes: [] },
|
||
markers: { add: [], remove: [] },
|
||
});
|
||
expect(withTakeovers).not.toBeNull();
|
||
// the valid one survives, the empty-name one is dropped
|
||
expect(withTakeovers!.takeovers).toEqual([{ nationRef: 'de', newRegimeName: 'The Reich' }]);
|
||
|
||
const without = validateTurnResult({ date: '1939-09-03', events: [], stateChanges: [] });
|
||
expect(without).not.toBeNull();
|
||
expect(without!.takeovers).toEqual([]);
|
||
});
|
||
});
|
||
|
||
describe('stub — takeovers (v0.32.0 #54)', () => {
|
||
it('the deterministic stub returns takeovers: [] (it never performs a takeover)', () => {
|
||
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.takeovers).toEqual([]);
|
||
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: [] },
|
||
hybrid: { ops: [] },
|
||
economy: { trade: [], embargo: [] }, industry: { projects: [] },
|
||
nuclear: { arm: [], strikes: [] }, leaders: { changes: [] },
|
||
ideology: { changes: [] },
|
||
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' }] },
|
||
hybrid: { ops: [] },
|
||
economy: { trade: [], embargo: [] }, industry: { projects: [] },
|
||
nuclear: { arm: [], strikes: [] }, leaders: { changes: [] },
|
||
ideology: { changes: [] },
|
||
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: [] },
|
||
hybrid: { ops: [] },
|
||
economy: { trade: [], embargo: [] }, industry: { projects: [] },
|
||
nuclear: { arm: [], strikes: [] }, leaders: { changes: [] },
|
||
ideology: { changes: [] },
|
||
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: [] });
|
||
});
|
||
});
|
||
|
||
// --- Hybrid war (v0.34.0, #57) ---------------------------------------------
|
||
|
||
/**
|
||
* Build a two-nation state for the hybrid tests: `a` (the actor) owns region
|
||
* `ra1`, `b` (the target) owns region `rb1`, both with uniform 50 stats and the
|
||
* given relations. Returns the state plus the two region keys.
|
||
*/
|
||
function hybState(
|
||
rels: { a?: Record<string, RelationStatus>; b?: Record<string, RelationStatus> },
|
||
): GameState {
|
||
const a = mkNation('a', NS(50, 50, 50, 50), ['ra1']);
|
||
const b = mkNation('b', NS(50, 50, 50, 50), ['rb1']);
|
||
a.relations = rels.a ?? {};
|
||
b.relations = rels.b ?? {};
|
||
return mkTurnState({ a, b }, { ra1: RS(50, 50, 50, 50), rb1: RS(50, 50, 50, 50) }, []);
|
||
}
|
||
|
||
describe('isHybridOpType (v0.34.0 #57)', () => {
|
||
it('accepts all seven types and rejects anything else', () => {
|
||
for (const t of ['spy', 'sabotage', 'assassinate', 'cyber', 'economic', 'information', 'proxy']) {
|
||
expect(isHybridOpType(t)).toBe(true);
|
||
}
|
||
for (const t of ['bogus', 'SPY', '', null, 5, undefined]) {
|
||
expect(isHybridOpType(t)).toBe(false);
|
||
}
|
||
});
|
||
});
|
||
|
||
describe('validateHybridOps (v0.34.0 #57)', () => {
|
||
it('non-object / null / array-of-wrongs input yields []', () => {
|
||
expect(validateHybridOps(undefined)).toEqual([]);
|
||
expect(validateHybridOps(null)).toEqual([]);
|
||
expect(validateHybridOps('x')).toEqual([]);
|
||
expect(validateHybridOps(5)).toEqual([]);
|
||
expect(validateHybridOps([])).toEqual([]); // a bare [] of "ops" — empty
|
||
});
|
||
|
||
it('accepts BOTH the { ops: [...] } object and a bare array', () => {
|
||
const op = { type: 'spy', from: 'a', target: 'b' };
|
||
expect(validateHybridOps({ ops: [op] })).toEqual([op]);
|
||
expect(validateHybridOps([op])).toEqual([op]);
|
||
});
|
||
|
||
it('keeps a valid op, trims/caps from & target, and drops outcome:success', () => {
|
||
const out = validateHybridOps([
|
||
{ type: 'sabotage', from: ' a ', target: ' b ', detail: ' blew up a plant ' },
|
||
]);
|
||
expect(out).toEqual([{ type: 'sabotage', from: 'a', target: 'b', detail: 'blew up a plant' }]);
|
||
});
|
||
|
||
it('stores outcome:exposed but omits the default success', () => {
|
||
const out = validateHybridOps([
|
||
{ type: 'spy', from: 'a', target: 'b', outcome: 'exposed' },
|
||
{ type: 'spy', from: 'a', target: 'b', outcome: 'success' },
|
||
{ type: 'spy', from: 'a', target: 'b' },
|
||
]);
|
||
expect(out).toEqual([
|
||
{ type: 'spy', from: 'a', target: 'b', outcome: 'exposed' },
|
||
{ type: 'spy', from: 'a', target: 'b' },
|
||
{ type: 'spy', from: 'a', target: 'b' },
|
||
]);
|
||
});
|
||
|
||
it('drops an unknown type, and empty from or target', () => {
|
||
const out = validateHybridOps([
|
||
{ type: 'bogus', from: 'a', target: 'b' },
|
||
{ type: 'spy', from: ' ', target: 'b' },
|
||
{ type: 'spy', from: 'a', target: '' },
|
||
{ type: 'spy', from: 'a' }, // missing target
|
||
]);
|
||
expect(out).toEqual([]);
|
||
});
|
||
|
||
it('caps from/target at 200 chars and detail at 300', () => {
|
||
const out = validateHybridOps([
|
||
{ type: 'spy', from: 'a'.repeat(250), target: 'b'.repeat(250), detail: 'd'.repeat(350) },
|
||
]);
|
||
expect(out).toHaveLength(1);
|
||
expect(out[0]!.from).toHaveLength(200);
|
||
expect(out[0]!.target).toHaveLength(200);
|
||
expect(out[0]!.detail).toHaveLength(300);
|
||
});
|
||
});
|
||
|
||
describe('validateTurnResult — hybrid (v0.34.0 #57)', () => {
|
||
it('defaults hybrid to { ops: [] } when the field is absent', () => {
|
||
const r = validateTurnResult({ date: 'x' });
|
||
expect(r?.hybrid).toEqual({ ops: [] });
|
||
});
|
||
|
||
it('parses a hybrid field into validated ops', () => {
|
||
const r = validateTurnResult({
|
||
date: 'x',
|
||
hybrid: { ops: [{ type: 'cyber', from: 'a', target: 'b', outcome: 'exposed' }] },
|
||
});
|
||
expect(r?.hybrid).toEqual({ ops: [{ type: 'cyber', from: 'a', target: 'b', outcome: 'exposed' }] });
|
||
});
|
||
});
|
||
|
||
describe('isMeaningful — hybrid (v0.34.0 #57)', () => {
|
||
it('a hybrid op makes an otherwise-empty turn meaningful', () => {
|
||
expect(
|
||
isMeaningful(mkTurnResult({ hybrid: { ops: [{ type: 'spy', from: 'a', target: 'b' }] } })),
|
||
).toBe(true);
|
||
});
|
||
it('an empty hybrid (and nothing else) is NOT meaningful', () => {
|
||
expect(isMeaningful(mkTurnResult({ hybrid: { ops: [] } }))).toBe(false);
|
||
});
|
||
});
|
||
|
||
describe('shiftRelationTowardHostile (v0.34.0 #57)', () => {
|
||
it('steps one rung toward hostile (cap hostile), symmetrically', () => {
|
||
const mk = (ab: RelationStatus, ba: RelationStatus) => {
|
||
const a = mkNation('a', NS(50, 50, 50, 50), []);
|
||
const b = mkNation('b', NS(50, 50, 50, 50), []);
|
||
a.relations = { b: ab };
|
||
b.relations = { a: ba };
|
||
return mkTurnState({ a, b }, {});
|
||
};
|
||
let s = mk('neutral', 'neutral');
|
||
shiftRelationTowardHostile(s, 'a', 'b', 'hostile');
|
||
expect(s.nations.a.relations.b).toBe('rival');
|
||
expect(s.nations.b.relations.a).toBe('rival');
|
||
s = mk('rival', 'rival');
|
||
shiftRelationTowardHostile(s, 'a', 'b', 'hostile');
|
||
expect(s.nations.a.relations.b).toBe('hostile');
|
||
s = mk('ally', 'neutral'); // asymmetric: each side steps from its own value
|
||
shiftRelationTowardHostile(s, 'a', 'b', 'hostile');
|
||
expect(s.nations.a.relations.b).toBe('neutral'); // ally -> neutral
|
||
expect(s.nations.b.relations.a).toBe('rival'); // neutral -> rival
|
||
});
|
||
|
||
it('never moves past the cap (hostile cap, rival cap)', () => {
|
||
const mk = (ab: RelationStatus, ba: RelationStatus) => {
|
||
const a = mkNation('a', NS(50, 50, 50, 50), []);
|
||
const b = mkNation('b', NS(50, 50, 50, 50), []);
|
||
a.relations = { b: ab };
|
||
b.relations = { a: ba };
|
||
return mkTurnState({ a, b }, {});
|
||
};
|
||
// hostile cap: hostile stays, rival -> hostile
|
||
let s = mk('hostile', 'hostile');
|
||
shiftRelationTowardHostile(s, 'a', 'b', 'hostile');
|
||
expect(s.nations.a.relations.b).toBe('hostile');
|
||
// rival cap: hostile stays (past cap), rival stays (at cap), neutral -> rival
|
||
s = mk('hostile', 'hostile');
|
||
shiftRelationTowardHostile(s, 'a', 'b', 'rival');
|
||
expect(s.nations.a.relations.b).toBe('hostile');
|
||
s = mk('rival', 'rival');
|
||
shiftRelationTowardHostile(s, 'a', 'b', 'rival');
|
||
expect(s.nations.a.relations.b).toBe('rival');
|
||
s = mk('neutral', 'neutral');
|
||
shiftRelationTowardHostile(s, 'a', 'b', 'rival');
|
||
expect(s.nations.a.relations.b).toBe('rival');
|
||
});
|
||
|
||
it('NEVER touches a war relation (neither direction)', () => {
|
||
const a = mkNation('a', NS(50, 50, 50, 50), []);
|
||
const b = mkNation('b', NS(50, 50, 50, 50), []);
|
||
a.relations = { b: 'war' };
|
||
b.relations = { a: 'war' };
|
||
const s = mkTurnState({ a, b }, {});
|
||
shiftRelationTowardHostile(s, 'a', 'b', 'hostile');
|
||
expect(s.nations.a.relations.b).toBe('war');
|
||
expect(s.nations.b.relations.a).toBe('war');
|
||
});
|
||
|
||
it('treats a missing relation as neutral and is a no-op for unknown nations', () => {
|
||
const a = mkNation('a', NS(50, 50, 50, 50), []);
|
||
const b = mkNation('b', NS(50, 50, 50, 50), []);
|
||
const s = mkTurnState({ a, b }, {}); // no relations set
|
||
shiftRelationTowardHostile(s, 'a', 'b', 'hostile');
|
||
expect(s.nations.a.relations.b).toBe('rival'); // neutral -> rival
|
||
shiftRelationTowardHostile(s, 'a', 'ghost', 'hostile'); // unknown nation: no-op
|
||
expect(s.nations.a.relations.ghost).toBeUndefined();
|
||
});
|
||
});
|
||
|
||
describe('applyTurnResult — hybrid (v0.34.0 #57)', () => {
|
||
it('sabotage (nation target): dips ALL of the target\'s economy, re-derives, no relation/event', () => {
|
||
// b owns TWO regions — a nation-target op must dip both.
|
||
const a = mkNation('a', NS(50, 50, 50, 50), ['ra1']);
|
||
const b = mkNation('b', NS(50, 50, 50, 50), ['rb1', 'rb2']);
|
||
const state = mkTurnState(
|
||
{ a, b },
|
||
{ ra1: RS(50, 50, 50, 50), rb1: RS(50, 50, 50, 50), rb2: RS(50, 50, 50, 50) },
|
||
);
|
||
const next = applyTurnResult(state, mkTurnResult({ hybrid: { ops: [{ type: 'sabotage', from: 'a', target: 'b' }] } }));
|
||
expect(next.regionStats.rb1.economy).toBe(47);
|
||
expect(next.regionStats.rb2.economy).toBe(47);
|
||
expect(next.nations.b.stats.economy).toBe(47);
|
||
expect(next.nations.a.stats.economy).toBe(50); // actor untouched
|
||
expect(next.nations.a.relations.b).toBeUndefined(); // no relation nudge for a stat op
|
||
expect(next.nations.b.relations.a).toBeUndefined();
|
||
expect(next.events).toHaveLength(0); // the engine emits no event for a successful stat op
|
||
});
|
||
|
||
it('a region target dips ONLY that region (not the rest of the nation)', () => {
|
||
const a = mkNation('a', NS(50, 50, 50, 50), ['ra1']);
|
||
const b = mkNation('b', NS(50, 50, 50, 50), ['rb1', 'rb2']);
|
||
const state = mkTurnState(
|
||
{ a, b },
|
||
{ ra1: RS(50, 50, 50, 50), rb1: RS(50, 50, 50, 50), rb2: RS(50, 50, 50, 50) },
|
||
);
|
||
const next = applyTurnResult(state, mkTurnResult({ hybrid: { ops: [{ type: 'cyber', from: 'a', target: 'rb1' }] } }));
|
||
expect(next.regionStats.rb1.military).toBe(47); // the named region
|
||
expect(next.regionStats.rb2.military).toBe(50); // the sibling region untouched
|
||
});
|
||
|
||
it('each stat type applies its documented dip', () => {
|
||
const run = (type: HybridOp['type']) => {
|
||
const state = hybState({});
|
||
return applyTurnResult(state, mkTurnResult({ hybrid: { ops: [{ type, from: 'a', target: 'b' }] } }));
|
||
};
|
||
expect(run('sabotage').nations.b.stats.economy).toBe(47); // -3
|
||
expect(run('assassinate').nations.b.stats.stability).toBe(45); // -5
|
||
expect(run('cyber').nations.b.stats.military).toBe(47); // -3
|
||
expect(run('economic').nations.b.stats.economy).toBe(45); // -5
|
||
});
|
||
|
||
it('spy is informational only — no stat, no relation change', () => {
|
||
const state = hybState({ a: { b: 'neutral' }, b: { a: 'neutral' } });
|
||
const next = applyTurnResult(state, mkTurnResult({ hybrid: { ops: [{ type: 'spy', from: 'a', target: 'b' }] } }));
|
||
expect(next.nations.b.stats).toEqual(NS(50, 50, 50, 50));
|
||
expect(next.nations.a.relations.b).toBe('neutral');
|
||
expect(next.nations.b.relations.a).toBe('neutral');
|
||
expect(next.events).toHaveLength(0);
|
||
});
|
||
|
||
it('information nudges the relation one step toward hostile (cap hostile)', () => {
|
||
const state = hybState({ a: { b: 'neutral' }, b: { a: 'neutral' } });
|
||
const next = applyTurnResult(state, mkTurnResult({ hybrid: { ops: [{ type: 'information', from: 'a', target: 'b' }] } }));
|
||
expect(next.nations.a.relations.b).toBe('rival');
|
||
expect(next.nations.b.relations.a).toBe('rival');
|
||
expect(next.nations.b.stats).toEqual(NS(50, 50, 50, 50)); // no stat change
|
||
});
|
||
|
||
it('proxy nudges the relation but caps at rival (hostile is not reached)', () => {
|
||
const state = hybState({ a: { b: 'neutral' }, b: { a: 'neutral' } });
|
||
const next = applyTurnResult(state, mkTurnResult({ hybrid: { ops: [{ type: 'proxy', from: 'a', target: 'b' }] } }));
|
||
expect(next.nations.a.relations.b).toBe('rival');
|
||
expect(next.nations.b.relations.a).toBe('rival');
|
||
// a hostile pair is left alone by a proxy op (capped at rival)
|
||
const state2 = hybState({ a: { b: 'hostile' }, b: { a: 'hostile' } });
|
||
const next2 = applyTurnResult(state2, mkTurnResult({ hybrid: { ops: [{ type: 'proxy', from: 'a', target: 'b' }] } }));
|
||
expect(next2.nations.a.relations.b).toBe('hostile');
|
||
});
|
||
|
||
it('an EXPOSED op backfires: no stat effect, relation sours one step, auto diplomatic/failure event', () => {
|
||
const state = hybState({ a: { b: 'neutral' }, b: { a: 'neutral' } });
|
||
const next = applyTurnResult(
|
||
state,
|
||
mkTurnResult({ hybrid: { ops: [{ type: 'sabotage', from: 'a', target: 'b', outcome: 'exposed' }] } }),
|
||
);
|
||
expect(next.nations.b.stats.economy).toBe(50); // the intended dip did NOT happen
|
||
expect(next.regionStats.rb1.economy).toBe(50);
|
||
expect(next.nations.a.relations.b).toBe('rival'); // neutral -> rival (cap hostile)
|
||
expect(next.nations.b.relations.a).toBe('rival');
|
||
expect(next.events).toHaveLength(1);
|
||
expect(next.events[0]).toMatchObject({ nation: 'a', type: 'diplomatic', outcome: 'failure', title: "Covert operation exposed" });
|
||
});
|
||
|
||
it('an EXPOSED op never touches a war relation', () => {
|
||
const state = hybState({ a: { b: 'war' }, b: { a: 'war' } });
|
||
const next = applyTurnResult(
|
||
state,
|
||
mkTurnResult({ hybrid: { ops: [{ type: 'cyber', from: 'a', target: 'b', outcome: 'exposed' }] } }),
|
||
);
|
||
expect(next.nations.a.relations.b).toBe('war');
|
||
expect(next.nations.b.relations.a).toBe('war');
|
||
expect(next.events).toHaveLength(1); // the backfire event is still emitted
|
||
});
|
||
|
||
it('drops an unknown actor, a self-target, an unknown region, and a double-owned region', () => {
|
||
// unknown actor
|
||
let state = hybState({ a: { b: 'neutral' }, b: { a: 'neutral' } });
|
||
let next = applyTurnResult(state, mkTurnResult({ hybrid: { ops: [{ type: 'sabotage', from: 'zzz', target: 'b' }] } }));
|
||
expect(next.nations.b.stats.economy).toBe(50);
|
||
expect(next.events).toHaveLength(0);
|
||
// self-target
|
||
state = hybState({ a: { b: 'neutral' }, b: { a: 'neutral' } });
|
||
next = applyTurnResult(state, mkTurnResult({ hybrid: { ops: [{ type: 'sabotage', from: 'a', target: 'a' }] } }));
|
||
expect(next.nations.a.stats.economy).toBe(50);
|
||
expect(next.events).toHaveLength(0);
|
||
// unknown region key
|
||
state = hybState({ a: { b: 'neutral' }, b: { a: 'neutral' } });
|
||
next = applyTurnResult(state, mkTurnResult({ hybrid: { ops: [{ type: 'cyber', from: 'a', target: 'not-a-region' }] } }));
|
||
expect(next.nations.b.stats.military).toBe(50);
|
||
expect(next.events).toHaveLength(0);
|
||
// double-owned region (a and b both own 'rx')
|
||
const a = mkNation('a', NS(50, 50, 50, 50), ['ra1', 'rx']);
|
||
const b = mkNation('b', NS(50, 50, 50, 50), ['rb1', 'rx']);
|
||
const st = mkTurnState(
|
||
{ a, b },
|
||
{ ra1: RS(50, 50, 50, 50), rb1: RS(50, 50, 50, 50), rx: RS(50, 50, 50, 50) },
|
||
);
|
||
const nx = applyTurnResult(st, mkTurnResult({ hybrid: { ops: [{ type: 'cyber', from: 'a', target: 'rx' }] } }));
|
||
expect(nx.regionStats.rx.military).toBe(50); // not a valid sole-owner target: dropped
|
||
expect(nx.events).toHaveLength(0);
|
||
});
|
||
|
||
it('a region target outside the valid-key set is dropped (validKeys gate)', () => {
|
||
const state = hybState({ a: { b: 'neutral' }, b: { a: 'neutral' } });
|
||
const next = applyTurnResult(
|
||
state,
|
||
mkTurnResult({ hybrid: { ops: [{ type: 'cyber', from: 'a', target: 'rb1' }] } }),
|
||
new Set(['ra1']), // rb1 is not a valid key
|
||
);
|
||
expect(next.regionStats.rb1.military).toBe(50);
|
||
expect(next.nations.b.stats.military).toBe(50);
|
||
});
|
||
|
||
it('NEVER moves borders: a hybrid op leaves every nation\'s territory untouched', () => {
|
||
const state = hybState({ a: { b: 'neutral' }, b: { a: 'neutral' } });
|
||
const next = applyTurnResult(state, mkTurnResult({ hybrid: { ops: [{ type: 'economic', from: 'a', target: 'b' }] } }));
|
||
expect(next.nations.a.territory).toEqual(['ra1']);
|
||
expect(next.nations.b.territory).toEqual(['rb1']);
|
||
expect(next.nations.b.stats.economy).toBe(45); // the op still took effect on stats
|
||
});
|
||
|
||
it('HYBRID_STAT_EFFECT encodes the per-type magnitudes', () => {
|
||
expect(HYBRID_STAT_EFFECT.sabotage).toEqual({ stat: 'economy', delta: -3 });
|
||
expect(HYBRID_STAT_EFFECT.assassinate).toEqual({ stat: 'stability', delta: -5 });
|
||
expect(HYBRID_STAT_EFFECT.cyber).toEqual({ stat: 'military', delta: -3 });
|
||
expect(HYBRID_STAT_EFFECT.economic).toEqual({ stat: 'economy', delta: -5 });
|
||
expect(HYBRID_STAT_EFFECT.spy).toBeNull();
|
||
expect(HYBRID_STAT_EFFECT.information).toBeNull();
|
||
expect(HYBRID_STAT_EFFECT.proxy).toBeNull();
|
||
});
|
||
});
|
||
|
||
describe('buildSystemPrompt — hybrid contract (v0.34.0 #57)', () => {
|
||
it('documents the hybrid field and its constraints in both modes', () => {
|
||
for (const withTools of [false, true]) {
|
||
const p = buildSystemPrompt(preset, withTools);
|
||
expect(p).toContain('"hybrid": {"ops":');
|
||
expect(p).toContain('spy|sabotage|assassinate|cyber|economic|information|proxy');
|
||
expect(p).toContain('Covert / hybrid ops ("hybrid.ops") are non-kinetic');
|
||
expect(p).toContain('NEVER move borders');
|
||
expect(p).toContain("sours by one step toward hostile");
|
||
expect(p).toContain("a region key hits that region's sole owner");
|
||
}
|
||
});
|
||
});
|
||
|
||
describe('buildUserPrompt — hybrid relations (v0.34.0 #57)', () => {
|
||
const state = mkTurnState(
|
||
{ a: mkNation('a', NS(50, 50, 50, 50), ['ra1']), b: mkNation('b', NS(50, 50, 50, 50), ['rb1']) },
|
||
{ ra1: RS(50, 50, 50, 50), rb1: RS(50, 50, 50, 50) },
|
||
);
|
||
|
||
it('carries the player\'s relations when provided', () => {
|
||
const out = JSON.parse(buildUserPrompt(state, [], undefined, undefined, [], [], undefined, { b: 'rival' }));
|
||
expect(out.relations).toEqual({ b: 'rival' });
|
||
});
|
||
|
||
it('omits the relations field entirely when the param is undefined', () => {
|
||
const out = JSON.parse(buildUserPrompt(state, [], undefined, undefined, [], [], undefined));
|
||
expect(out).not.toHaveProperty('relations');
|
||
});
|
||
});
|
||
|
||
describe('stub — hybrid (v0.34.0 #57)', () => {
|
||
it('the deterministic stub returns empty hybrid ops (it never runs a covert op)', () => {
|
||
const payload = {
|
||
state: {
|
||
turn: 1,
|
||
date: '1939-09-01',
|
||
playerNationId: 'uk',
|
||
nations: {
|
||
uk: { stats: { military: 50, economy: 50, stability: 50, population: 50 }, territory: ['a'] },
|
||
de: { stats: { military: 60, economy: 60, stability: 60, population: 60 }, territory: ['b'] },
|
||
},
|
||
},
|
||
pending: [{ kind: 'action' as const, text: 'test' }],
|
||
};
|
||
const result = deterministicTurn(payload);
|
||
expect(result.hybrid).toEqual({ ops: [] });
|
||
});
|
||
});
|
||
|
||
// ============================================================================
|
||
// Economy — trade & embargo (v0.35.0, #58)
|
||
//
|
||
// The bilateral trade + embargo layer: `GameState.trades` (symmetric pairs,
|
||
// stored sorted) and `GameState.embargoes` (ordered from → target). A turn's
|
||
// `economy` ops declare/lift them (refs resolved at apply time); EVERY
|
||
// committed turn the full active lists apply their deterministic per-turn
|
||
// effect (trade: both parties' economy up; embargo: the target's economy
|
||
// down), clamped and mirrored to `regionStats`, and any trade/embargo whose
|
||
// two nations are on opposite sides of an active war is auto-suspended.
|
||
// ============================================================================
|
||
|
||
describe('validateEconomyOps (v0.35.0 #58)', () => {
|
||
it('non-object input yields empty trade/embargo', () => {
|
||
expect(validateEconomyOps(undefined)).toEqual({ trade: [], embargo: [] });
|
||
expect(validateEconomyOps(null)).toEqual({ trade: [], embargo: [] });
|
||
expect(validateEconomyOps('x')).toEqual({ trade: [], embargo: [] });
|
||
expect(validateEconomyOps([])).toEqual({ trade: [], embargo: [] });
|
||
});
|
||
|
||
it('keeps well-formed ops (trimmed refs, lift normalized) and drops malformed ones', () => {
|
||
const out = validateEconomyOps({
|
||
trade: [{ a: ' uk ', b: 'de' }, { a: 'us', b: 'de', lift: true }, 'junk', null, { a: 'us' }],
|
||
embargo: [{ from: 'uk', target: 'de', lift: true }, { from: 'uk', target: 'de' }, { from: 'uk' }],
|
||
});
|
||
expect(out.trade).toEqual([{ a: 'uk', b: 'de' }, { a: 'us', b: 'de', lift: true }]);
|
||
expect(out.embargo).toEqual([{ from: 'uk', target: 'de', lift: true }, { from: 'uk', target: 'de' }]);
|
||
});
|
||
});
|
||
|
||
describe('validateTurnResult — economy (v0.35.0 #58)', () => {
|
||
it('a bare (economy-less) input gets economy defaulted to { trade: [], embargo: [] }', () => {
|
||
const out = validateTurnResult({ date: '1939-09-03', events: [], stateChanges: [] });
|
||
expect(out).not.toBeNull();
|
||
expect(out!.economy).toEqual({ trade: [], embargo: [] });
|
||
});
|
||
|
||
it('a well-formed economy payload is kept', () => {
|
||
const out = validateTurnResult({
|
||
date: '1939-09-03',
|
||
events: [],
|
||
stateChanges: [],
|
||
economy: { trade: [{ a: 'a', b: 'b' }], embargo: [{ from: 'a', target: 'b', lift: true }] },
|
||
});
|
||
expect(out).not.toBeNull();
|
||
expect(out!.economy).toEqual({
|
||
trade: [{ a: 'a', b: 'b' }],
|
||
embargo: [{ from: 'a', target: 'b', lift: true }],
|
||
});
|
||
});
|
||
});
|
||
|
||
describe('applyTurnResult — economy (v0.35.0 #58)', () => {
|
||
it('a declared trade: both parties gain +1 economy (stats + regionStats mirror), and the agreement persists', () => {
|
||
const state = mkTurnState(
|
||
{ a: mkNation('a', NS(50, 60, 50, 50), ['ra1']), b: mkNation('b', NS(50, 70, 50, 50), ['rb1']) },
|
||
{ ra1: RS(50, 60, 50, 50), rb1: RS(50, 70, 50, 50) },
|
||
);
|
||
const next = applyTurnResult(state, mkTurnResult({ economy: { trade: [{ a: 'a', b: 'b' }], embargo: [] } }));
|
||
expect(next.trades).toEqual([{ a: 'a', b: 'b' }]);
|
||
expect(next.nations.a.stats.economy).toBe(61);
|
||
expect(next.nations.b.stats.economy).toBe(71);
|
||
expect(next.regionStats.ra1.economy).toBe(61);
|
||
expect(next.regionStats.rb1.economy).toBe(71);
|
||
});
|
||
|
||
it('the per-turn effect applies to the FULL active lists every turn (a later empty turn re-applies it)', () => {
|
||
let state = mkTurnState(
|
||
{ a: mkNation('a', NS(50, 60, 50, 50), ['ra1']), b: mkNation('b', NS(50, 70, 50, 50), ['rb1']) },
|
||
{ ra1: RS(50, 60, 50, 50), rb1: RS(50, 70, 50, 50) },
|
||
);
|
||
state = applyTurnResult(state, mkTurnResult({ economy: { trade: [{ a: 'a', b: 'b' }], embargo: [] } }));
|
||
expect(state.nations.a.stats.economy).toBe(61);
|
||
expect(state.nations.b.stats.economy).toBe(71);
|
||
// the next (empty) turn: the still-active trade applies its effect again
|
||
state = applyTurnResult(state, mkTurnResult({}));
|
||
expect(state.trades).toEqual([{ a: 'a', b: 'b' }]);
|
||
expect(state.nations.a.stats.economy).toBe(62);
|
||
expect(state.nations.b.stats.economy).toBe(72);
|
||
expect(state.regionStats.ra1.economy).toBe(62);
|
||
expect(state.regionStats.rb1.economy).toBe(72);
|
||
});
|
||
|
||
it('an embargo lowers only the target economy; the imposer is untouched', () => {
|
||
const state = mkTurnState(
|
||
{ a: mkNation('a', NS(50, 60, 50, 50), ['ra1']), b: mkNation('b', NS(50, 70, 50, 50), ['rb1']) },
|
||
{ ra1: RS(50, 60, 50, 50), rb1: RS(50, 70, 50, 50) },
|
||
);
|
||
const next = applyTurnResult(state, mkTurnResult({ economy: { trade: [], embargo: [{ from: 'a', target: 'b' }] } }));
|
||
expect(next.embargoes).toEqual([{ from: 'a', target: 'b' }]);
|
||
expect(next.nations.a.stats.economy).toBe(60); // imposer unchanged
|
||
expect(next.nations.b.stats.economy).toBe(69); // the target dips 1
|
||
expect(next.regionStats.ra1.economy).toBe(60);
|
||
expect(next.regionStats.rb1.economy).toBe(69);
|
||
});
|
||
|
||
it('declares are idempotent: a trade is one symmetric (sorted) agreement; embargoes keep both ordered directions (a duplicate is dropped)', () => {
|
||
const state = mkTurnState(
|
||
{ a: mkNation('a', NS(50, 50, 50, 50), ['ra1']), b: mkNation('b', NS(50, 50, 50, 50), ['rb1']) },
|
||
{ ra1: RS(50, 50, 50, 50), rb1: RS(50, 50, 50, 50) },
|
||
);
|
||
const next = applyTurnResult(state, mkTurnResult({
|
||
economy: {
|
||
trade: [{ a: 'b', b: 'a' }, { a: 'a', b: 'b' }], // the same pair, both orders → one agreement
|
||
embargo: [{ from: 'a', target: 'b' }, { from: 'b', target: 'a' }, { from: 'a', target: 'b' }], // both directions kept, the duplicate dropped
|
||
},
|
||
}));
|
||
expect(next.trades).toEqual([{ a: 'a', b: 'b' }]);
|
||
expect(next.embargoes).toEqual([{ from: 'a', target: 'b' }, { from: 'b', target: 'a' }]);
|
||
// the net delta is +1 (trade) - 1 (the embargo pointing at each) = 0 for both
|
||
expect(next.nations.a.stats.economy).toBe(50);
|
||
expect(next.nations.b.stats.economy).toBe(50);
|
||
});
|
||
|
||
it('lift: true ends a trade (the effect stops) and lifts an embargo; lifting an absent one is a no-op', () => {
|
||
let state = mkTurnState(
|
||
{ a: mkNation('a', NS(50, 60, 50, 50), ['ra1']), b: mkNation('b', NS(50, 70, 50, 50), ['rb1']) },
|
||
{ ra1: RS(50, 60, 50, 50), rb1: RS(50, 70, 50, 50) },
|
||
);
|
||
state = applyTurnResult(state, mkTurnResult({ economy: { trade: [{ a: 'a', b: 'b' }], embargo: [{ from: 'a', target: 'b' }] } }));
|
||
// trade +1 for both; the embargo hits b only → a 61, b 70
|
||
expect(state.nations.a.stats.economy).toBe(61);
|
||
expect(state.nations.b.stats.economy).toBe(70);
|
||
// lift both on the next turn (the trade lift is name/ref-insensitive: b→a resolves to the same sorted pair)
|
||
state = applyTurnResult(state, mkTurnResult({ economy: { trade: [{ a: 'b', b: 'a', lift: true }], embargo: [{ from: 'a', target: 'b', lift: true }] } }));
|
||
expect(state.trades).toEqual([]);
|
||
expect(state.embargoes).toEqual([]);
|
||
expect(state.nations.a.stats.economy).toBe(61); // no further change
|
||
expect(state.nations.b.stats.economy).toBe(70);
|
||
// lifting again (absent) is a no-op
|
||
state = applyTurnResult(state, mkTurnResult({ economy: { trade: [{ a: 'a', b: 'b', lift: true }], embargo: [{ from: 'a', target: 'b', lift: true }] } }));
|
||
expect(state.trades).toEqual([]);
|
||
expect(state.embargoes).toEqual([]);
|
||
});
|
||
|
||
it('name refs resolve (id first, then unique case-insensitive name); the pair is stored sorted by id', () => {
|
||
const state = mkTurnState(
|
||
{ a: { ...mkNation('a', NS(50, 60, 50, 50), ['ra1']), name: 'Alpha' }, b: mkNation('b', NS(50, 70, 50, 50), ['rb1']) },
|
||
{ ra1: RS(50, 60, 50, 50), rb1: RS(50, 70, 50, 50) },
|
||
);
|
||
const next = applyTurnResult(state, mkTurnResult({ economy: { trade: [{ a: 'alpha', b: 'B' }], embargo: [] } }));
|
||
expect(next.trades).toEqual([{ a: 'a', b: 'b' }]);
|
||
});
|
||
|
||
it('unknown, ambiguous, and self-pairing refs are dropped (never throws)', () => {
|
||
const dupA: Nation = { ...mkNation('a1', NS(50, 50, 50, 50), ['ra1']), name: 'DUP' };
|
||
const dupB: Nation = { ...mkNation('a2', NS(50, 50, 50, 50), ['rb1']), name: 'DUP' };
|
||
const c = mkNation('c', NS(50, 50, 50, 50), ['rc1']);
|
||
const state = mkTurnState(
|
||
{ a1: dupA, a2: dupB, c },
|
||
{ ra1: RS(50, 50, 50, 50), rb1: RS(50, 50, 50, 50), rc1: RS(50, 50, 50, 50) },
|
||
);
|
||
const next = applyTurnResult(state, mkTurnResult({
|
||
economy: {
|
||
trade: [
|
||
{ a: 'zzz', b: 'c' }, // unknown
|
||
{ a: 'c', b: 'c' }, // self
|
||
{ a: 'DUP', b: 'c' }, // ambiguous name
|
||
],
|
||
embargo: [{ from: 'zzz', target: 'c' }, { from: 'c', target: 'c' }, { from: 'DUP', target: 'c' }],
|
||
},
|
||
}));
|
||
expect(next.trades).toEqual([]);
|
||
expect(next.embargoes).toEqual([]);
|
||
});
|
||
|
||
it('auto-suspension: an existing trade is removed when its two nations go to war this turn', () => {
|
||
const state = warState({ a: warN('a'), b: warN('b') }, []);
|
||
state.trades = [{ a: 'a', b: 'b' }];
|
||
const next = applyTurnResult(state, mkTurnResult({ wars: { declare: [{ sides: ['a', 'b'] }], end: [] } }));
|
||
expect(next.wars).toHaveLength(1);
|
||
expect(next.wars[0].status).toBe('active');
|
||
expect(next.trades).toEqual([]); // the war severs the economic link
|
||
});
|
||
|
||
it('a trade declared this turn between two nations that also go to war this turn is dropped', () => {
|
||
const state = warState({ a: warN('a'), b: warN('b') }, []);
|
||
const next = applyTurnResult(state, mkTurnResult({
|
||
wars: { declare: [{ sides: ['a', 'b'] }], end: [] },
|
||
economy: { trade: [{ a: 'a', b: 'b' }], embargo: [] },
|
||
}));
|
||
expect(next.wars).toHaveLength(1);
|
||
expect(next.trades).toEqual([]);
|
||
});
|
||
|
||
it('a trade/embargo between a belligerent and a non-belligerent survives the war declaration', () => {
|
||
const state = warState({ a: warN('a'), b: warN('b'), c: warN('c') }, []);
|
||
state.trades = [{ a: 'a', b: 'c' }];
|
||
state.embargoes = [{ from: 'a', target: 'c' }];
|
||
const next = applyTurnResult(state, mkTurnResult({ wars: { declare: [{ sides: ['a', 'b'] }], end: [] } }));
|
||
expect(next.trades).toEqual([{ a: 'a', b: 'c' }]);
|
||
expect(next.embargoes).toEqual([{ from: 'a', target: 'c' }]);
|
||
});
|
||
|
||
it('an ended (armistice) war does not suspend a trade', () => {
|
||
const state = warState(
|
||
{ a: warN('a'), b: warN('b') },
|
||
[{ id: 'w1', sides: ['a', 'b'], declaredTurn: 1, status: 'armistice' }],
|
||
);
|
||
state.trades = [{ a: 'a', b: 'b' }];
|
||
const next = applyTurnResult(state, mkTurnResult({}));
|
||
expect(next.trades).toEqual([{ a: 'a', b: 'b' }]);
|
||
});
|
||
});
|
||
|
||
describe('isMeaningful — economy (v0.35.0 #58)', () => {
|
||
it('a trade-only or embargo-only turn is meaningful; an empty economy is not', () => {
|
||
expect(isMeaningful(mkTurnResult({ economy: { trade: [{ a: 'a', b: 'b' }], embargo: [] } }))).toBe(true);
|
||
expect(isMeaningful(mkTurnResult({ economy: { trade: [], embargo: [{ from: 'a', target: 'b' }] } }))).toBe(true);
|
||
expect(isMeaningful(mkTurnResult({}))).toBe(false);
|
||
});
|
||
});
|
||
|
||
describe('buildSystemPrompt — economy contract (v0.35.0 #58)', () => {
|
||
it('documents the economy field and its constraints in both modes', () => {
|
||
for (const withTools of [false, true]) {
|
||
const p = buildSystemPrompt(preset, withTools);
|
||
expect(p).toContain('"economy": {"trade":');
|
||
expect(p).toContain('Economy ("economy") is the trade & embargo layer');
|
||
expect(p).toContain('at most 1-2 operations per turn');
|
||
expect(p).toContain('suspended automatically by the engine');
|
||
}
|
||
});
|
||
});
|
||
|
||
describe('buildUserPrompt — economy (v0.35.0 #58)', () => {
|
||
const state = mkTurnState(
|
||
{ a: mkNation('a', NS(50, 50, 50, 50), ['ra1']), b: mkNation('b', NS(50, 50, 50, 50), ['rb1']) },
|
||
{ ra1: RS(50, 50, 50, 50), rb1: RS(50, 50, 50, 50) },
|
||
);
|
||
|
||
it('carries the active trades and embargoes when provided', () => {
|
||
const out = JSON.parse(
|
||
buildUserPrompt(state, [], undefined, undefined, [], [], undefined, undefined, [{ a: 'a', b: 'b' }], [{ from: 'a', target: 'b' }]),
|
||
);
|
||
expect(out.trades).toEqual([{ a: 'a', b: 'b' }]);
|
||
expect(out.embargoes).toEqual([{ from: 'a', target: 'b' }]);
|
||
});
|
||
|
||
it('omits the trades/embargoes fields entirely when undefined', () => {
|
||
const out = JSON.parse(buildUserPrompt(state, [], undefined, undefined, [], [], undefined, undefined));
|
||
expect(out).not.toHaveProperty('trades');
|
||
expect(out).not.toHaveProperty('embargoes');
|
||
});
|
||
});
|
||
|
||
describe('stub — economy (v0.35.0 #58)', () => {
|
||
it('the deterministic stub returns empty economy ops (it never declares a trade or an embargo)', () => {
|
||
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.economy).toEqual({ trade: [], embargo: [] });
|
||
expect(result.industry).toEqual({ projects: [] });
|
||
});
|
||
});
|
||
|
||
// --- Industry projects (v0.36.0, #59) ----------------------------------------
|
||
|
||
describe('validateIndustryOps (v0.36.0 #59)', () => {
|
||
it('accepts well-formed project ops', () => {
|
||
const out = validateIndustryOps({ projects: [{ regionKey: 'moselle-rygy', type: 'oil' }] });
|
||
expect(out).toEqual({ projects: [{ regionKey: 'moselle-rygy', type: 'oil' }] });
|
||
});
|
||
|
||
it('drops ops with a missing regionKey, an unknown type, or a non-object entry', () => {
|
||
const out = validateIndustryOps({
|
||
projects: [
|
||
{ type: 'oil' }, // no regionKey
|
||
{ regionKey: 'k1', type: 'coal' }, // unknown type
|
||
{ regionKey: '', type: 'iron' }, // empty key
|
||
'not-an-object',
|
||
null,
|
||
{ regionKey: 'k2', type: 'food' }, // valid
|
||
],
|
||
});
|
||
expect(out).toEqual({ projects: [{ regionKey: 'k2', type: 'food' }] });
|
||
});
|
||
|
||
it('a missing / malformed top-level object yields { projects: [] }', () => {
|
||
expect(validateIndustryOps(undefined)).toEqual({ projects: [] });
|
||
expect(validateIndustryOps(null)).toEqual({ projects: [] });
|
||
expect(validateIndustryOps('nope')).toEqual({ projects: [] });
|
||
expect(validateIndustryOps({})).toEqual({ projects: [] });
|
||
expect(validateIndustryOps({ projects: 'x' })).toEqual({ projects: [] });
|
||
});
|
||
});
|
||
|
||
describe('applyTurnResult — industry ops (v0.36.0 #59)', () => {
|
||
const state = () =>
|
||
mkTurnState(
|
||
{
|
||
uk: mkNation('uk', NS(50, 50, 50, 50), ['g1', 'g2']),
|
||
de: mkNation('de', NS(50, 50, 50, 50), ['d1']),
|
||
},
|
||
{ g1: RS(50, 50, 50, 50), g2: RS(50, 50, 50, 50), d1: RS(50, 50, 50, 50) },
|
||
);
|
||
|
||
it('declares a project on each valid region (per-region set)', () => {
|
||
const next = applyTurnResult(state(), mkTurnResult({
|
||
industry: { projects: [{ regionKey: 'g1', type: 'oil' }, { regionKey: 'd1', type: 'industry' }] },
|
||
}));
|
||
expect(next.industry).toEqual([
|
||
{ regionKey: 'g1', type: 'oil' },
|
||
{ regionKey: 'd1', type: 'industry' },
|
||
]);
|
||
});
|
||
|
||
it('re-declaring on a region that already hosts a project is a no-op (first type wins)', () => {
|
||
const s = state();
|
||
s.industry = [{ regionKey: 'g1', type: 'oil' }];
|
||
const next = applyTurnResult(s, mkTurnResult({
|
||
industry: { projects: [{ regionKey: 'g1', type: 'iron' }, { regionKey: 'g2', type: 'food' }] },
|
||
}));
|
||
expect(next.industry).toEqual([
|
||
{ regionKey: 'g1', type: 'oil' }, // the existing type is never replaced
|
||
{ regionKey: 'g2', type: 'food' },
|
||
]);
|
||
});
|
||
|
||
it('drops a project whose region key is not in the valid-keys manifest', () => {
|
||
const next = applyTurnResult(
|
||
state(),
|
||
mkTurnResult({ industry: { projects: [{ regionKey: 'not-a-real-key-xyz', type: 'oil' }] } }),
|
||
new Set(['g1', 'g2', 'd1']),
|
||
);
|
||
expect(next.industry).toEqual([]);
|
||
});
|
||
|
||
it('active projects persist across turns (the set is never cleared on commit)', () => {
|
||
const s = state();
|
||
s.industry = [{ regionKey: 'g1', type: 'oil' }];
|
||
const next = applyTurnResult(s, mkTurnResult({}));
|
||
expect(next.industry).toEqual([{ regionKey: 'g1', type: 'oil' }]);
|
||
});
|
||
});
|
||
|
||
describe('applyIndustryEffects (v0.36.0 #59)', () => {
|
||
const mk = () =>
|
||
mkTurnState(
|
||
{
|
||
uk: mkNation('uk', NS(50, 50, 50, 50), ['g1']),
|
||
de: mkNation('de', NS(50, 50, 50, 50), ['d1']),
|
||
},
|
||
{ g1: RS(50, 50, 50, 50), d1: RS(50, 50, 50, 50) },
|
||
);
|
||
|
||
it('boosts the region\'s SOLE owner by the base value (no resource manifest)', () => {
|
||
const s = mk();
|
||
s.industry = [{ regionKey: 'g1', type: 'oil' }];
|
||
applyIndustryEffects(s);
|
||
expect(s.nations.uk.stats.economy).toBe(51); // 50 + 1
|
||
expect(s.nations.de.stats.economy).toBe(50); // d1 hosts no project
|
||
// the boost is mirrored to every region the owner holds
|
||
expect(s.regionStats.g1.economy).toBe(51);
|
||
expect(s.regionStats.d1.economy).toBe(50);
|
||
});
|
||
|
||
it('adds one extra point when the region\'s industry value is >= 0.5 (and not below)', () => {
|
||
const s = mk();
|
||
s.industry = [{ regionKey: 'g1', type: 'oil' }, { regionKey: 'd1', type: 'iron' }];
|
||
applyIndustryEffects(
|
||
s,
|
||
(k) => (k === 'g1'
|
||
? { oil: 0, iron: 0, food: 0, industry: 0.5 } // 0.5 >= 0.5 → boosted
|
||
: { oil: 0, iron: 0, food: 0, industry: 0.4 }), // 0.4 < 0.5 → base
|
||
);
|
||
expect(s.nations.uk.stats.economy).toBe(52); // 1 + 1
|
||
expect(s.nations.de.stats.economy).toBe(51); // 1 + 0
|
||
});
|
||
|
||
it('a nation owning several project regions gets each of their boosts', () => {
|
||
const s = mk();
|
||
s.nations.uk.territory = ['g1', 'd1'];
|
||
s.nations.de.territory = [];
|
||
s.industry = [{ regionKey: 'g1', type: 'oil' }, { regionKey: 'd1', type: 'iron' }];
|
||
applyIndustryEffects(s);
|
||
expect(s.nations.uk.stats.economy).toBe(52); // 2 projects × base 1
|
||
});
|
||
|
||
it('a project on an ownerless (released) region yields no boost but is kept', () => {
|
||
const s = mk();
|
||
s.nations.uk.territory = []; // g1 is no man's land
|
||
s.industry = [{ regionKey: 'g1', type: 'oil' }];
|
||
applyIndustryEffects(s);
|
||
expect(s.industry).toEqual([{ regionKey: 'g1', type: 'oil' }]); // kept, not removed
|
||
expect(s.nations.de.stats.economy).toBe(50); // nobody was boosted
|
||
});
|
||
|
||
it('a project on a region with 2+ owners (corrupt) yields no boost but is kept', () => {
|
||
const s = mk();
|
||
s.nations.de.territory = ['d1', 'g1']; // corrupt: g1 has two owners
|
||
s.industry = [{ regionKey: 'g1', type: 'oil' }];
|
||
applyIndustryEffects(s);
|
||
expect(s.industry).toEqual([{ regionKey: 'g1', type: 'oil' }]); // kept
|
||
expect(s.nations.uk.stats.economy).toBe(50);
|
||
expect(s.nations.de.stats.economy).toBe(50);
|
||
});
|
||
|
||
it('is a no-op when there are no active projects', () => {
|
||
const s = mk();
|
||
applyIndustryEffects(s);
|
||
expect(s.nations.uk.stats.economy).toBe(50);
|
||
expect(s.nations.de.stats.economy).toBe(50);
|
||
});
|
||
});
|
||
|
||
describe('isMeaningful — industry (v0.36.0 #59)', () => {
|
||
it('a project-only turn is meaningful; an empty industry is not', () => {
|
||
expect(isMeaningful(mkTurnResult({ industry: { projects: [{ regionKey: 'k', type: 'oil' }] } }))).toBe(true);
|
||
expect(isMeaningful(mkTurnResult({}))).toBe(false);
|
||
});
|
||
});
|
||
|
||
describe('buildSystemPrompt — industry contract (v0.36.0 #59)', () => {
|
||
it('documents the industry field and its constraints in both modes', () => {
|
||
for (const withTools of [false, true]) {
|
||
const p = buildSystemPrompt(preset, withTools);
|
||
expect(p).toContain('"industry": {"projects":');
|
||
expect(p).toContain('Industry ("industry") is the per-resource development lever');
|
||
expect(p).toContain('do not re-declare one on a region that already has one');
|
||
}
|
||
});
|
||
});
|
||
|
||
describe('buildUserPrompt — industry (v0.36.0 #59)', () => {
|
||
const state = mkTurnState(
|
||
{ a: mkNation('a', NS(50, 50, 50, 50), ['ra1']), b: mkNation('b', NS(50, 50, 50, 50), ['rb1']) },
|
||
{ ra1: RS(50, 50, 50, 50), rb1: RS(50, 50, 50, 50) },
|
||
);
|
||
|
||
it('carries the resource aggregate and active projects when provided', () => {
|
||
const out = JSON.parse(
|
||
buildUserPrompt(
|
||
state,
|
||
[],
|
||
undefined,
|
||
undefined,
|
||
[],
|
||
[],
|
||
undefined,
|
||
undefined,
|
||
undefined,
|
||
undefined,
|
||
{ a: { oil: 0.9, iron: 0.1, food: 0.5, industry: 0.2 } },
|
||
[{ regionKey: 'ra1', type: 'oil' }],
|
||
),
|
||
);
|
||
expect(out.resources).toEqual({ a: { oil: 0.9, iron: 0.1, food: 0.5, industry: 0.2 } });
|
||
expect(out.industry).toEqual([{ regionKey: 'ra1', type: 'oil' }]);
|
||
});
|
||
|
||
it('omits the resources/industry fields entirely when undefined', () => {
|
||
const out = JSON.parse(buildUserPrompt(state, [], undefined, undefined, [], [], undefined, undefined));
|
||
expect(out).not.toHaveProperty('resources');
|
||
expect(out).not.toHaveProperty('industry');
|
||
});
|
||
});
|
||
|
||
describe('runTurn — industry (v0.36.0 #59)', () => {
|
||
it('a result without an industry field leaves state.industry = [] (defensive init)', async () => {
|
||
const s = baseState();
|
||
delete (s as { industry?: unknown[] }).industry; // simulate a pre-v0.36 row
|
||
const { state } = await runTurn({ llm: fakeLlm([validResult]), preset, state: s, items: [] });
|
||
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: [] }, leaders: { changes: [] } }))).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');
|
||
});
|
||
});
|
||
|
||
describe('named leaders (v0.38.0 #61)', () => {
|
||
// `uk` starts with a named leader; `de` has none. Each owns one region
|
||
// seeded to its stats (the fresh-state invariant), so a uniform dip leaves
|
||
// the derived median equal to the dipped value.
|
||
const leaderBase = (): GameState => {
|
||
const uk: Nation = { ...mkNation('uk', NS(50, 50, 50, 50), ['g1']), leader: { name: 'Admiral Old' } };
|
||
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) });
|
||
};
|
||
|
||
describe('validateLeaderOps', () => {
|
||
it('accepts a {changes} object and a bare array of valid changes', () => {
|
||
const fromObj = validateLeaderOps({
|
||
changes: [
|
||
{ target: 'uk', name: 'New Leader', cause: 'assassination' },
|
||
{ target: 'de', name: 'Other', cause: 'death' },
|
||
],
|
||
});
|
||
expect(fromObj).toEqual([
|
||
{ target: 'uk', name: 'New Leader', cause: 'assassination' },
|
||
{ target: 'de', name: 'Other', cause: 'death' },
|
||
]);
|
||
expect(validateLeaderOps([{ target: 'uk', name: 'X', cause: 'appointed' }])).toEqual([
|
||
{ target: 'uk', name: 'X', cause: 'appointed' },
|
||
]);
|
||
});
|
||
|
||
it('drops a change with an unknown cause', () => {
|
||
expect(validateLeaderOps({ changes: [{ target: 'uk', name: 'X', cause: 'explosion' }] })).toEqual([]);
|
||
});
|
||
|
||
it('drops a change missing a target or a name', () => {
|
||
expect(
|
||
validateLeaderOps({
|
||
changes: [
|
||
{ target: '', name: 'X', cause: 'appointed' },
|
||
{ target: 'uk', name: '', cause: 'death' },
|
||
],
|
||
}),
|
||
).toEqual([]);
|
||
});
|
||
|
||
it('returns [] for undefined / non-object / non-array-changes input', () => {
|
||
expect(validateLeaderOps(undefined)).toEqual([]);
|
||
expect(validateLeaderOps('nope')).toEqual([]);
|
||
expect(validateLeaderOps({ changes: 'nope' })).toEqual([]);
|
||
});
|
||
|
||
it('keeps only the valid changes of a mixed batch', () => {
|
||
const out = validateLeaderOps({
|
||
changes: [
|
||
{ target: 'uk', name: 'Good', cause: 'appointed' },
|
||
{ target: 'uk', name: 'Bad', cause: 'nope' },
|
||
null,
|
||
{ target: 'uk', name: 'Also Good', cause: 'death' },
|
||
],
|
||
});
|
||
expect(out).toEqual([
|
||
{ target: 'uk', name: 'Good', cause: 'appointed' },
|
||
{ target: 'uk', name: 'Also Good', cause: 'death' },
|
||
]);
|
||
});
|
||
});
|
||
|
||
describe('isMeaningful — leaders', () => {
|
||
it('a turn with only a leader change is meaningful', () => {
|
||
expect(isMeaningful(mkTurnResult({ leaders: { changes: [{ target: 'uk', name: 'X', cause: 'death' }] } }))).toBe(true);
|
||
});
|
||
it('a turn with empty leaders and nothing else is not meaningful', () => {
|
||
expect(isMeaningful(mkTurnResult({ leaders: { changes: [] } }))).toBe(false);
|
||
});
|
||
});
|
||
|
||
describe('applyTurnResult — leaders', () => {
|
||
it('assassinating a nation with a leader replaces it, dips stability, and adds a military event', () => {
|
||
const next = applyTurnResult(
|
||
leaderBase(),
|
||
mkTurnResult({ leaders: { changes: [{ target: 'uk', name: 'New Leader', cause: 'assassination' }] } }),
|
||
);
|
||
expect(next.nations.uk.leader).toEqual({ name: 'New Leader' });
|
||
expect(next.nations.uk.stats.stability).toBe(35); // 50 - 15
|
||
expect(next.regionStats.g1.stability).toBe(35); // mirrored to the region
|
||
expect(next.events).toHaveLength(1);
|
||
expect(next.events[0]!.type).toBe('military');
|
||
});
|
||
|
||
it('assassinating a leaderless nation is dropped (no leader, no stat change, no event)', () => {
|
||
const next = applyTurnResult(
|
||
leaderBase(),
|
||
mkTurnResult({ leaders: { changes: [{ target: 'de', name: 'New Leader', cause: 'assassination' }] } }),
|
||
);
|
||
expect(next.nations.de.leader).toBeUndefined();
|
||
expect(next.nations.de.stats).toEqual(NS(50, 50, 50, 50));
|
||
expect(next.events).toHaveLength(0);
|
||
});
|
||
|
||
it('a death of a leader replaces it, dips stability a little, and adds a political succession event', () => {
|
||
const next = applyTurnResult(
|
||
leaderBase(),
|
||
mkTurnResult({ leaders: { changes: [{ target: 'uk', name: 'Successor', cause: 'death' }] } }),
|
||
);
|
||
expect(next.nations.uk.leader).toEqual({ name: 'Successor' });
|
||
expect(next.nations.uk.stats.stability).toBe(47); // 50 - 3
|
||
expect(next.events).toHaveLength(1);
|
||
expect(next.events[0]!.type).toBe('political');
|
||
});
|
||
|
||
it('a death of a leaderless nation is dropped', () => {
|
||
const next = applyTurnResult(
|
||
leaderBase(),
|
||
mkTurnResult({ leaders: { changes: [{ target: 'de', name: 'X', cause: 'death' }] } }),
|
||
);
|
||
expect(next.nations.de.leader).toBeUndefined();
|
||
expect(next.nations.de.stats).toEqual(NS(50, 50, 50, 50));
|
||
expect(next.events).toHaveLength(0);
|
||
});
|
||
|
||
it('an appointment on a nation that has a leader replaces it with no stability dip', () => {
|
||
const next = applyTurnResult(
|
||
leaderBase(),
|
||
mkTurnResult({ leaders: { changes: [{ target: 'uk', name: 'New PM', cause: 'appointed' }] } }),
|
||
);
|
||
expect(next.nations.uk.leader).toEqual({ name: 'New PM' });
|
||
expect(next.nations.uk.stats).toEqual(NS(50, 50, 50, 50)); // neutral: no dip
|
||
expect(next.events).toHaveLength(1);
|
||
expect(next.events[0]!.type).toBe('political');
|
||
});
|
||
|
||
it('an appointment creates the first leader on a leaderless nation', () => {
|
||
const next = applyTurnResult(
|
||
leaderBase(),
|
||
mkTurnResult({ leaders: { changes: [{ target: 'de', name: 'First', cause: 'appointed' }] } }),
|
||
);
|
||
expect(next.nations.de.leader).toEqual({ name: 'First' });
|
||
expect(next.nations.de.stats).toEqual(NS(50, 50, 50, 50));
|
||
});
|
||
|
||
it('a leader change on a multi-region nation dips every region and re-derives its stats', () => {
|
||
const uk: Nation = { ...mkNation('uk', NS(50, 50, 50, 50), ['g1', 'g2']), leader: { name: 'Old' } };
|
||
const state = mkTurnState({ uk }, { g1: RS(50, 50, 50, 50), g2: RS(50, 50, 50, 50) });
|
||
const next = applyTurnResult(
|
||
state,
|
||
mkTurnResult({ leaders: { changes: [{ target: 'uk', name: 'New', cause: 'assassination' }] } }),
|
||
);
|
||
expect(next.regionStats.g1.stability).toBe(35);
|
||
expect(next.regionStats.g2.stability).toBe(35);
|
||
expect(next.nations.uk.stats.stability).toBe(35); // re-derived from the dipped regions
|
||
});
|
||
|
||
it('an unknown target is dropped without throwing (no leader change, no event, no crash)', () => {
|
||
const next = applyTurnResult(
|
||
leaderBase(),
|
||
mkTurnResult({ leaders: { changes: [{ target: 'ghost', name: 'X', cause: 'assassination' }] } }),
|
||
);
|
||
expect(next.nations.uk.leader).toEqual({ name: 'Admiral Old' }); // untouched
|
||
expect(next.events).toHaveLength(0);
|
||
});
|
||
|
||
it('an ambiguous target (two nations share the name) is dropped without throwing', () => {
|
||
const a: Nation = { ...mkNation('a', NS(50, 50, 50, 50), ['g1']), name: 'Same', leader: { name: 'Old' } };
|
||
const b: Nation = { ...mkNation('b', NS(50, 50, 50, 50), ['d1']), name: 'Same' };
|
||
const state = mkTurnState({ a, b }, { g1: RS(50, 50, 50, 50), d1: RS(50, 50, 50, 50) });
|
||
const next = applyTurnResult(
|
||
state,
|
||
mkTurnResult({ leaders: { changes: [{ target: 'Same', name: 'New', cause: 'assassination' }] } }),
|
||
);
|
||
// 'Same' matches two nations -> ambiguous -> dropped; neither leader changes.
|
||
expect(next.nations.a.leader).toEqual({ name: 'Old' });
|
||
expect(next.nations.b.leader).toBeUndefined();
|
||
expect(next.events).toHaveLength(0);
|
||
});
|
||
});
|
||
});
|
||
|
||
describe('ideology / form of government (v0.39.0 #62)', () => {
|
||
// `uk` starts with an ideology ('monarchy'); `de` has none. Each owns one
|
||
// region seeded to its stats (the fresh-state invariant), so a uniform dip
|
||
// leaves the derived median equal to the dipped value.
|
||
const ideoBase = (): GameState => {
|
||
const uk: Nation = { ...mkNation('uk', NS(50, 50, 50, 50), ['g1']), ideology: 'monarchy' };
|
||
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) });
|
||
};
|
||
|
||
describe('validateIdeologyOps', () => {
|
||
it('accepts a {changes} object and a bare array of valid changes', () => {
|
||
const fromObj = validateIdeologyOps({
|
||
changes: [
|
||
{ target: 'uk', to: 'fascism' },
|
||
{ target: 'de', to: 'democracy' },
|
||
],
|
||
});
|
||
expect(fromObj).toEqual([
|
||
{ target: 'uk', to: 'fascism' },
|
||
{ target: 'de', to: 'democracy' },
|
||
]);
|
||
expect(validateIdeologyOps([{ target: 'uk', to: 'x' }])).toEqual([{ target: 'uk', to: 'x' }]);
|
||
});
|
||
|
||
it('drops a change with a missing / empty / whitespace `to`', () => {
|
||
expect(
|
||
validateIdeologyOps({ changes: [{ target: 'uk', to: '' }, { target: 'uk', to: ' ' }] }),
|
||
).toEqual([]);
|
||
});
|
||
|
||
it('drops a change with a missing / empty / whitespace target', () => {
|
||
expect(
|
||
validateIdeologyOps({ changes: [{ target: '', to: 'fascism' }, { target: ' ', to: 'fascism' }] }),
|
||
).toEqual([]);
|
||
});
|
||
|
||
it('returns [] for undefined / non-object / non-array-changes input', () => {
|
||
expect(validateIdeologyOps(undefined)).toEqual([]);
|
||
expect(validateIdeologyOps('nope')).toEqual([]);
|
||
expect(validateIdeologyOps({ changes: 'nope' })).toEqual([]);
|
||
});
|
||
|
||
it('keeps only the valid changes of a mixed batch', () => {
|
||
const out = validateIdeologyOps({
|
||
changes: [
|
||
{ target: 'uk', to: 'Good' },
|
||
null,
|
||
{ target: '', to: 'Bad' },
|
||
{ target: 'de', to: 'Also Good' },
|
||
],
|
||
});
|
||
expect(out).toEqual([
|
||
{ target: 'uk', to: 'Good' },
|
||
{ target: 'de', to: 'Also Good' },
|
||
]);
|
||
});
|
||
|
||
it('trims both fields and caps each at 120 chars', () => {
|
||
const long = 'x'.repeat(200);
|
||
const out = validateIdeologyOps({ changes: [{ target: ' uk ', to: ` ${long} ` }] });
|
||
expect(out).toEqual([{ target: 'uk', to: 'x'.repeat(120) }]);
|
||
});
|
||
});
|
||
|
||
describe('isMeaningful — ideology', () => {
|
||
it('a turn with only an ideology change is meaningful', () => {
|
||
expect(isMeaningful(mkTurnResult({ ideology: { changes: [{ target: 'uk', to: 'fascism' }] } }))).toBe(true);
|
||
});
|
||
it('a turn with empty ideology and nothing else is not meaningful', () => {
|
||
expect(isMeaningful(mkTurnResult({ ideology: { changes: [] } }))).toBe(false);
|
||
});
|
||
});
|
||
|
||
describe('applyTurnResult — ideology', () => {
|
||
it('a valid change sets the field, dips stability −5, and adds a political event', () => {
|
||
const next = applyTurnResult(
|
||
ideoBase(),
|
||
mkTurnResult({ ideology: { changes: [{ target: 'uk', to: 'fascism' }] } }),
|
||
);
|
||
expect(next.nations.uk.ideology).toBe('fascism');
|
||
expect(next.nations.uk.stats.stability).toBe(45); // 50 - 5
|
||
expect(next.regionStats.g1.stability).toBe(45); // mirrored to the region
|
||
expect(next.events).toHaveLength(1);
|
||
expect(next.events[0]!.type).toBe('political');
|
||
expect(next.events[0]!.title).toBe('UK\'s government shifted to fascism');
|
||
// the other nation is untouched
|
||
expect(next.nations.de.ideology).toBeUndefined();
|
||
expect(next.nations.de.stats.stability).toBe(50);
|
||
});
|
||
|
||
it('re-setting the SAME ideology still dips (idempotent for the field, not the stat)', () => {
|
||
const uk: Nation = { ...mkNation('uk', NS(50, 50, 50, 50), ['g1']), ideology: 'fascism' };
|
||
const state = mkTurnState({ uk }, { g1: RS(50, 50, 50, 50) });
|
||
const next = applyTurnResult(
|
||
state,
|
||
mkTurnResult({ ideology: { changes: [{ target: 'uk', to: 'fascism' }] } }),
|
||
);
|
||
expect(next.nations.uk.ideology).toBe('fascism'); // the same label
|
||
expect(next.nations.uk.stats.stability).toBe(45); // still a −5 dip
|
||
expect(next.events).toHaveLength(1);
|
||
expect(next.events[0]!.type).toBe('political');
|
||
});
|
||
|
||
it('a change on a multi-region nation dips every region and re-derives its stats', () => {
|
||
const uk: Nation = { ...mkNation('uk', NS(50, 50, 50, 50), ['g1', 'g2']), ideology: 'monarchy' };
|
||
const state = mkTurnState({ uk }, { g1: RS(50, 50, 50, 50), g2: RS(50, 50, 50, 50) });
|
||
const next = applyTurnResult(
|
||
state,
|
||
mkTurnResult({ ideology: { changes: [{ target: 'uk', to: 'dictatorship' }] } }),
|
||
);
|
||
expect(next.regionStats.g1.stability).toBe(45);
|
||
expect(next.regionStats.g2.stability).toBe(45);
|
||
expect(next.nations.uk.stats.stability).toBe(45); // re-derived from the dipped regions
|
||
});
|
||
|
||
it('resolves the target by (case-insensitive) name as well as id', () => {
|
||
const next = applyTurnResult(
|
||
ideoBase(),
|
||
mkTurnResult({ ideology: { changes: [{ target: 'UK', to: 'fascism' }] } }),
|
||
);
|
||
expect(next.nations.uk.ideology).toBe('fascism');
|
||
expect(next.nations.uk.stats.stability).toBe(45);
|
||
});
|
||
|
||
it('a 0-region nation keeps its directly-dipped stat (no re-derive to zero)', () => {
|
||
const iso: Nation = { ...mkNation('iso', NS(50, 50, 50, 50), []) };
|
||
const state = mkTurnState({ iso }, {});
|
||
const next = applyTurnResult(
|
||
state,
|
||
mkTurnResult({ ideology: { changes: [{ target: 'iso', to: 'fascism' }] } }),
|
||
);
|
||
expect(next.nations.iso.ideology).toBe('fascism');
|
||
expect(next.nations.iso.stats.stability).toBe(45); // direct dip, not re-derived
|
||
});
|
||
|
||
it('clamps the dip at 0 (a stability of 3 becomes 0, not −2)', () => {
|
||
const low: Nation = { ...mkNation('low', NS(50, 50, 3, 50), ['g1']), ideology: 'monarchy' };
|
||
const state = mkTurnState({ low }, { g1: RS(50, 50, 3, 50) });
|
||
const next = applyTurnResult(
|
||
state,
|
||
mkTurnResult({ ideology: { changes: [{ target: 'low', to: 'fascism' }] } }),
|
||
);
|
||
expect(next.nations.low.stats.stability).toBe(0);
|
||
expect(next.regionStats.g1.stability).toBe(0);
|
||
});
|
||
|
||
it('an unknown target is dropped without throwing (no change, no event, no crash)', () => {
|
||
const next = applyTurnResult(
|
||
ideoBase(),
|
||
mkTurnResult({ ideology: { changes: [{ target: 'ghost', to: 'fascism' }] } }),
|
||
);
|
||
expect(next.nations.uk.ideology).toBe('monarchy'); // untouched
|
||
expect(next.nations.de.ideology).toBeUndefined();
|
||
expect(next.events).toHaveLength(0);
|
||
});
|
||
|
||
it('an ambiguous target (two nations share the name) is dropped without throwing', () => {
|
||
const a: Nation = { ...mkNation('a', NS(50, 50, 50, 50), ['g1']), name: 'Same' };
|
||
const b: Nation = { ...mkNation('b', NS(50, 50, 50, 50), ['d1']), name: 'Same' };
|
||
const state = mkTurnState({ a, b }, { g1: RS(50, 50, 50, 50), d1: RS(50, 50, 50, 50) });
|
||
const next = applyTurnResult(
|
||
state,
|
||
mkTurnResult({ ideology: { changes: [{ target: 'Same', to: 'fascism' }] } }),
|
||
);
|
||
// 'Same' matches two nations -> ambiguous -> dropped; neither ideology is set.
|
||
expect(next.nations.a.ideology).toBeUndefined();
|
||
expect(next.nations.b.ideology).toBeUndefined();
|
||
expect(next.events).toHaveLength(0);
|
||
});
|
||
});
|
||
|
||
describe('buildUserPrompt — ideology', () => {
|
||
it('stamps a nation\'s ideology in the prompt nations when it has one', () => {
|
||
const uk: Nation = { ...mkNation('uk', NS(50, 50, 50, 50), ['g1']), ideology: 'fascism' };
|
||
const state = mkTurnState({ uk }, { g1: RS(50, 50, 50, 50) });
|
||
const parsed = JSON.parse(buildUserPrompt(state, [])) as {
|
||
state: { nations: Record<string, { ideology?: string }> };
|
||
};
|
||
expect(parsed.state.nations.uk.ideology).toBe('fascism');
|
||
});
|
||
|
||
it('omits a nation\'s ideology from the prompt when it has none', () => {
|
||
const uk: Nation = { ...mkNation('uk', NS(50, 50, 50, 50), ['g1']) };
|
||
const state = mkTurnState({ uk }, { g1: RS(50, 50, 50, 50) });
|
||
const parsed = JSON.parse(buildUserPrompt(state, [])) as {
|
||
state: { nations: Record<string, { ideology?: string }> };
|
||
};
|
||
expect(parsed.state.nations.uk.ideology).toBeUndefined();
|
||
expect(JSON.stringify(parsed.state.nations.uk)).not.toContain('ideology');
|
||
});
|
||
});
|
||
});
|