batched round: queue actions/diplomacy, jump forward commits one turn (v0.2.0)
CI / build + unit + e2e (pull_request) Successful in 1m51s

This commit is contained in:
2026-10-11 14:36:35 +00:00
parent 3297658c81
commit 9bce14254d
16 changed files with 563 additions and 246 deletions
+130 -7
View File
@@ -1,11 +1,14 @@
import { describe, expect, it } from 'vitest';
import type { GameState, Preset } from '../shared/types';
import type { GameState, Preset, RoundItem } from '../shared/types';
import type { TurnResult } from '../shared/protocol';
import type { LlmClient } from './llm';
import { makeStubLlmClient } from './stub';
import {
applyTurnResult,
buildUserPrompt,
isMeaningful,
parseTurnResult,
queueRoundItem,
runTurn,
validateTurnResult,
} from './engine';
@@ -68,6 +71,7 @@ function baseState(): GameState {
de: { ...preset.nations[2], stats: { ...preset.nations[2].stats }, territory: [...preset.nations[2].territory], relations: { ...preset.nations[2].relations } },
},
events: [],
pending: [],
};
}
@@ -85,17 +89,18 @@ const validResult = JSON.stringify({
});
describe('runTurn', () => {
it('applies a canned TurnResult: stats, symmetric relations, territory, event, turn, date', async () => {
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,
action: 'We rearm the coast',
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
@@ -131,7 +136,7 @@ describe('runTurn', () => {
llm: fakeLlm([clampResult]),
preset,
state: baseState(),
action: 'x',
items: [],
});
expect(state.nations.uk.stats.military).toBe(100);
expect(state.nations.uk.stats.stability).toBe(0);
@@ -142,7 +147,7 @@ describe('runTurn', () => {
llm: fakeLlm(['total garbage', 'still not json']),
preset,
state: baseState(),
action: 'x',
items: [],
});
expect(state.turn).toBe(2);
expect(state.events).toHaveLength(1);
@@ -162,7 +167,7 @@ describe('runTurn', () => {
llm: fakeLlm([emptyResult, emptyResult]),
preset,
state: baseState(),
action: 'x',
items: [],
});
expect(state.turn).toBe(2);
expect(state.events[0].title).toBe('The turn was unclear');
@@ -174,13 +179,116 @@ describe('runTurn', () => {
const { state: next } = await runTurn({
llm: fakeLlm([validResult]),
preset,
state, // no action / no chat = jump forward
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 }]);
// the original object is not mutated (a fresh queue was consumed)
expect(original.pending).toHaveLength(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);
});
});
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);
});
});
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```');
@@ -237,6 +345,21 @@ describe('applyTurnResult', () => {
applyTurnResult(state, result);
expect(JSON.stringify(state)).toBe(snapshot);
});
it('clears the pending round on commit', () => {
const state = baseState();
state.pending = [{ kind: 'action', text: 'x' }];
const result: TurnResult = {
date: '1939-09-03',
events: [],
stateChanges: [],
relations: [],
territoryChanges: [],
};
const next = applyTurnResult(state, result);
expect(next.pending).toEqual([]);
expect(state.pending).toHaveLength(1); // original untouched
});
});
describe('isMeaningful', () => {