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
+1
View File
@@ -21,6 +21,7 @@ function makeState(id: string): GameState {
},
},
events: [],
pending: [],
};
}
+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', () => {
+50 -20
View File
@@ -9,6 +9,7 @@ import type {
GameState,
Preset,
RelationStatus,
RoundItem,
StatKey,
} from '../shared/types';
import { EVENT_TYPES, RELATION_STATUSES } from '../shared/types';
@@ -64,12 +65,28 @@ export function buildSystemPrompt(preset: Preset): string {
].join('\n');
}
/** The user message: a compact JSON snapshot + the player's action/chat. */
export function buildUserPrompt(
state: GameState,
action?: string,
chat?: { to: string; message: string },
): string {
/**
* The user message: a compact JSON snapshot of the world plus the player's
* queued round (`items`) — the free-text actions and diplomatic messages to be
* simulated this period. `playerActions` is an LLM-friendly rendering of each
* item; the raw `pending` list is also emitted so the deterministic stub (and any
* other consumer) can read the exact items. The framing instruction tells the
* model to simulate the combined consequences of the whole batch (or to let the
* world advance on its own when the batch is empty) and return one TurnResult.
*/
export function buildUserPrompt(state: GameState, items: RoundItem[]): string {
const player = state.nations[state.playerNationId];
const playerName = player?.name ?? state.playerNationId;
const playerActions = items.map((i) =>
i.kind === 'action' ? i.text : { to: i.to ?? '', message: i.text },
);
const roundInstruction =
items.length > 0
? `This period the player (${playerName}) takes the following actions and sends the following diplomatic messages: ${JSON.stringify(
playerActions,
)}. Simulate the combined consequences across all nations and advance the world by one period. Return a single TurnResult.`
: 'The player takes no action this period; let the world advance on its own momentum. Return a single TurnResult.';
const payload = {
state: {
turn: state.turn,
@@ -88,8 +105,9 @@ export function buildUserPrompt(
outcome: e.outcome,
})),
},
action: action ?? null,
chat: chat ?? null,
playerActions,
pending: items,
roundInstruction,
};
return JSON.stringify(payload, null, 2);
}
@@ -269,6 +287,8 @@ export function applyTurnResult(state: GameState, result: TurnResult): GameState
const actingTurn = state.turn; // this action resolves the current turn
next.turn = state.turn + 1; // then we advance to the next turn to play
next.date = isDateLike(result.date) ? result.date : addDays(state.date, 2);
// Committing a turn consumes the whole queued round — reset it for the next.
next.pending = [];
for (const ev of result.events) {
next.events.push(makeEvent(ev, actingTurn, next.date));
}
@@ -302,22 +322,36 @@ export function applyTurnResult(state: GameState, result: TurnResult): GameState
return next;
}
/**
* Immutably queue one item onto a round: append it to `pending` and return a new
* state. This never advances the turn or the date — that only happens when a
* round is committed via runTurn. Exported so the append-without-advance behavior
* is unit-testable, and reused by the server's /action and /diplomacy routes.
*/
export function queueRoundItem(state: GameState, item: RoundItem): GameState {
const next: GameState = JSON.parse(JSON.stringify(state));
next.pending.push(item);
return next;
}
/**
* Run one turn end-to-end against the (injectable) LLM: build prompts, call the
* model, parse/validate, retry once with a nudge if invalid, and — if it is still
* invalid — emit a generic "unclear" event and advance. Never throws on bad
* model output; only the underlying transport/network can throw.
* model ONCE over the whole pending batch, parse/validate, retry once with a
* nudge if the result is empty/invalid, and — if it is still invalid — emit a
* generic "unclear" event and advance. The committed state has `pending` cleared.
* Never throws on bad model output; only the underlying transport/network can throw.
*/
export async function runTurn(opts: {
llm: LlmClient;
preset: Preset;
state: GameState;
action?: string;
chat?: { to: string; message: string };
items: RoundItem[];
}): Promise<{ state: GameState; result: TurnResult }> {
const { llm, preset, state, action, chat } = opts;
const { llm, preset, state, items } = opts;
const system = buildSystemPrompt(preset);
const user = buildUserPrompt(state, action, chat);
const user = buildUserPrompt(state, items);
const nudge =
'Your previous output was invalid. Respond with ONLY the JSON object described above for this batch — no prose, no fences.';
let result = parseTurnResult(await llm.chat([
{ role: 'system', content: system },
@@ -329,11 +363,7 @@ export async function runTurn(opts: {
{ role: 'system', content: system },
{ role: 'user', content: user },
{ role: 'assistant', content: '' },
{
role: 'user',
content:
'Your previous output was invalid. Respond with ONLY the JSON object described above — no prose, no fences.',
},
{ role: 'user', content: nudge },
], { temperature: 0.4, maxTokens: DEFAULT_MAX_TOKENS }));
}
+45 -34
View File
@@ -9,11 +9,11 @@ import path from 'node:path';
import { fileURLToPath } from 'node:url';
import { deepClone } from '../shared/protocol';
import type { CreateGameRequest, TurnRequest } from '../shared/protocol';
import type { AddActionRequest, AddDiplomacyRequest, CreateGameRequest } from '../shared/protocol';
import type { GameState } from '../shared/types';
import { createLlmClient } from './llm';
import { makeStubLlmClient } from './stub';
import { askAdvisor, runTurn } from './engine';
import { askAdvisor, queueRoundItem, runTurn } from './engine';
import { buildRegionMap } from './map';
import { createPresetStore } from './presets';
import { openDb, type Db } from './db';
@@ -113,6 +113,7 @@ app.post('/api/games', (req: Request, res) => {
date: preset.startDate,
nations: Object.fromEntries(preset.nations.map((n) => [n.id, deepClone(n)])),
events: [],
pending: [],
};
db.save(state);
res.json({ game: state });
@@ -149,46 +150,56 @@ app.get('/api/games/:id/map', (req, res) => {
res.json({ regions: buildRegionMap(game) });
});
async function handleTurn(id: string, body: TurnRequest): Promise<{ state: GameState; result: unknown }> {
// Queue a free-text player action onto the pending round. No LLM call and no
// turn advance — the round is only committed (in one turn) by "Jump forward".
app.post('/api/games/:id/action', (req, res) => {
const id = req.params.id;
const game = db.load(id);
if (!game) throw new Error('not_found');
const preset = presetStore.get(game.presetId);
if (!preset) throw new Error('preset_missing');
const { state, result } = await runTurn({
llm,
preset,
state: game,
action: body?.action,
chat: body?.chat,
});
db.save(state);
broadcast(id, 'turn', { state, result });
return { state, result };
}
app.post('/api/games/:id/turn', async (req, res) => {
try {
const { state, result } = await handleTurn(req.params.id, req.body as TurnRequest);
res.json({ state, result });
} catch (e) {
const msg = e instanceof Error ? e.message : String(e);
if (msg === 'not_found' || msg === 'preset_missing') {
return res.status(404).json({ error: msg });
}
res.status(502).json({ error: `turn failed: ${msg}` });
}
if (!game) return res.status(404).json({ error: 'game not found' });
const text = (req.body as AddActionRequest | undefined)?.text?.trim();
if (!text) return res.status(400).json({ error: 'empty action' });
const next = queueRoundItem(game, { kind: 'action', text });
db.save(next);
broadcast(id, 'state', { state: next });
res.json({ game: next });
});
// Queue a diplomatic message to a target nation onto the pending round. No LLM
// call and no advance.
app.post('/api/games/:id/diplomacy', (req, res) => {
const id = req.params.id;
const game = db.load(id);
if (!game) return res.status(404).json({ error: 'game not found' });
const body = (req.body ?? {}) as AddDiplomacyRequest;
const message = body.message?.trim();
if (!body.to || !message) return res.status(400).json({ error: 'empty message' });
const next = queueRoundItem(game, { kind: 'diplomacy', to: body.to, text: message });
db.save(next);
broadcast(id, 'state', { state: next });
res.json({ game: next });
});
// Jump forward: commit the whole pending round (or an empty one) to the model
// in a single turn. applyTurnResult clears the pending queue on the returned
// state. 404 for not-found/preset-missing; 502 for LLM/model (transport) errors.
app.post('/api/games/:id/jump', async (req, res) => {
const id = req.params.id;
const game = db.load(id);
if (!game) return res.status(404).json({ error: 'game not found' });
const preset = presetStore.get(game.presetId);
if (!preset) return res.status(404).json({ error: 'preset_missing' });
try {
// Jump forward = a turn with no player action (auto-simulate the world).
const { state, result } = await handleTurn(req.params.id, {});
const { state, result } = await runTurn({
llm,
preset,
state: game,
items: game.pending,
});
db.save(state);
broadcast(id, 'turn', { state, result });
res.json({ state, result });
} catch (e) {
const msg = e instanceof Error ? e.message : String(e);
if (msg === 'not_found' || msg === 'preset_missing') {
return res.status(404).json({ error: msg });
}
res.status(502).json({ error: `jump failed: ${msg}` });
}
});
+1
View File
@@ -29,6 +29,7 @@ const state: GameState = {
},
},
events: [],
pending: [],
};
describe('buildRegionMap', () => {
+35 -30
View File
@@ -2,11 +2,12 @@
// sets LLM_STUB=1) so the whole loop is reproducible with no live model. It
// speaks the OpenAI chat-completions wire shape, so it plugs in as a Transport.
import { createLlmClient, type ChatMessage, type Transport } from './llm';
import { addDays, clampStat, type TurnResult } from '../shared/protocol';
import { addDays, clampStat, type TurnEventSpec, type TurnResult } from '../shared/protocol';
import type { RoundItem } from '../shared/types';
export const ADVISOR_MARKER = '<<ADVISOR>>';
/** The parsed body of the user message the engine sends (see engine.buildPrompt). */
/** The parsed body of the user message the engine sends (see engine.buildUserPrompt). */
export interface StubPayload {
state?: {
turn: number;
@@ -14,8 +15,8 @@ export interface StubPayload {
playerNationId: string;
nations: Record<string, { stats: Record<string, number> }>;
};
action?: string | null;
chat?: { to: string; message: string } | null;
/** The raw pending round the engine emitted (one entry per queued item). */
pending?: RoundItem[];
}
function truncate(s: string, n: number): string {
@@ -24,8 +25,12 @@ function truncate(s: string, n: number): string {
/**
* Pure deterministic TurnResult for a parsed payload. Bumps the player nation's
* military by 5 each turn (clamped to 0–100) and emits one event, so a caller can
* predict the exact next state for assertions.
* military by 5 each turn (clamped to 0–100) and emits one event per queued
* round item (action → military/success, diplomacy → diplomatic/partial), or a
* single political "The world moves on" event when the round is empty — so a
* caller can predict the exact next state for assertions. It always sets the
* player's military to (current + 5) and allies the player with the first other
* nation, advancing the date by 2 days.
*/
export function deterministicTurn(p: StubPayload): TurnResult {
const state = p.state;
@@ -35,36 +40,36 @@ export function deterministicTurn(p: StubPayload): TurnResult {
const otherId = state
? (Object.keys(state.nations).find((id) => id !== pid) ?? '')
: 'us';
const items = p.pending ?? [];
const events = p.action
? [
{
nation: pid,
type: 'military' as const,
title: `Action: ${truncate(p.action, 60)}`,
text: 'Your move takes effect across the region.',
outcome: 'success' as const,
},
]
: p.chat
const events: TurnEventSpec[] =
items.length === 0
? [
{
nation: pid,
type: 'diplomatic' as const,
title: 'Diplomatic outreach',
text: 'Your message is received by the target nation.',
outcome: 'partial' as const,
type: 'political',
title: 'The world moves on',
text: 'Weeks pass without a decisive event.',
outcome: 'neutral',
},
]
: [
{
nation: pid,
type: 'political' as const,
title: 'The world moves on',
text: 'Weeks pass; the great powers posture and rearm quietly.',
outcome: 'neutral' as const,
},
];
: items.map((it) =>
it.kind === 'action'
? {
nation: pid,
type: 'military' as const,
title: `Action: ${truncate(it.text, 60)}`,
text: 'Your move takes effect across the region.',
outcome: 'success' as const,
}
: {
nation: pid,
type: 'diplomatic' as const,
title: 'Diplomatic outreach',
text: 'Your message is received.',
outcome: 'partial' as const,
},
);
return {
date: addDays(state?.date ?? '1939-09-01', 2),