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