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:
@@ -21,6 +21,7 @@ function makeState(id: string): GameState {
|
||||
},
|
||||
},
|
||||
events: [],
|
||||
pending: [],
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
+130
-7
@@ -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
@@ -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
@@ -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}` });
|
||||
}
|
||||
});
|
||||
|
||||
@@ -29,6 +29,7 @@ const state: GameState = {
|
||||
},
|
||||
},
|
||||
events: [],
|
||||
pending: [],
|
||||
};
|
||||
|
||||
describe('buildRegionMap', () => {
|
||||
|
||||
+35
-30
@@ -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),
|
||||
|
||||
Reference in New Issue
Block a user