batched round: queue actions/diplomacy, jump forward commits one turn (v0.2.0)
CI / build + unit + e2e (pull_request) Successful in 1m51s
CI / build + unit + e2e (pull_request) Successful in 1m51s
This commit is contained in:
+50
-20
@@ -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 }));
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user