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
+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}` });
}
});