Files
History/server/stub.ts
T
kamilkrzywda 8730a56170
CI / build + unit + e2e (pull_request) Successful in 2m33s
feat(#61): named leaders — assassination / death / appointment with succession (v0.38.0)
Per-nation named leaders (Nation.leader) and a required TurnResult.leaders
lever ({ changes: [{ target, name, cause }] }). Each change sets the target
nation's leader and applies a deterministic stability dip by cause
(assassination -15, death -3, appointed 0) mirrored to its regions and
re-derived into its stats, plus a narration event (military for an
assassination, political for a death/appointment). An assassination/death
requires the target to already have a leader (a leaderless target is
dropped); an appointment always creates/sets one. Unknown/ambiguous targets
are dropped, never thrown.

- shared/types.ts: optional Nation.leader
- shared/protocol.ts: LeaderCause/LeaderChange/LeaderOps, LEADER_STABILITY_EFFECT,
  TurnResult.leaders (required, between nuclear and markers), leader in nationForPrompt
- server/engine.ts: validateLeaderOps, applyLeaderChange, the applyTurnResult
  leader block (after nuclear, before markers), isMeaningful, system-prompt
  contract + constraint, validateTurnResult + runTurn fallback
- server/stub.ts: leaders always { changes: [] } (e2e unaffected)
- tests: 15 engine.test.ts leader tests + 2 db.test.ts round-trips
- version 0.37.0 -> 0.38.0, CHANGELOG entry
2026-10-15 20:09:19 +00:00

284 lines
13 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
// Deterministic LLM stub. Used by unit tests and the Playwright e2e (webServer
// 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 TerritoryChange, type TurnEventSpec, type TurnResult } from '../shared/protocol';
import type { RelationStatus, RoundItem } from '../shared/types';
export const ADVISOR_MARKER = '<<ADVISOR>>';
/** The parsed body of the user message the engine sends (see engine.buildUserPrompt). */
export interface StubPayload {
state?: {
turn: number;
date: string;
playerNationId: string;
// `nuclear` (v0.37.0, #60) is the per-nation nuclear capability stamped by
// the engine (its `nuclear` flag OR a `rocket` marker); the stub ignores
// it — it never arms or strikes (see deterministicTurn).
// `leader` (v0.38.0, #61) is the per-nation named leader stamped by the
// engine; the stub ignores it — it never changes a leader (see
// deterministicTurn).
nations: Record<
string,
{
stats: Record<string, number>;
territory?: string[];
nuclear?: boolean;
leader?: { name: string };
}
>;
};
/** The raw pending round the engine emitted (one entry per queued item). */
pending?: RoundItem[];
/**
* Region keys owned by other nations that share a land border with the
* player's territory — the engine's `frontier` field, computed from the
* adjacency manifest (v0.21, #16 P2). The stub cannot call tools, so it
* reads the frontier from the prompt instead.
*/
frontier?: string[];
/**
* (v0.22, #16 P3) Region keys currently in no man's land (`state.unowned`),
* emitted by the engine's user prompt. The stub ignores this field — it
* never releases a region (see deterministicTurn), so no-man's-land behavior
* is covered by unit tests against applyTurnResult, not by the stub.
*/
unowned?: string[];
/**
* (v0.27, #44) Current map markers (`{ id, type, name, regionKey, owner }`),
* emitted by the engine's user prompt. The stub ignores this field — it
* never places or removes a marker (see deterministicTurn), so the marker
* path is covered by unit tests against applyTurnResult, not by the stub.
*/
markers?: Array<{ id: string; type: string; name: string; regionKey: string; owner: string | null }>;
/**
* (v0.33.0, #56) Currently ACTIVE wars (`{ id, sides, aims? }`), emitted by
* the engine's user prompt. The stub ignores this field — it never declares
* or ends a war (see deterministicTurn), so the war path is covered by unit
* tests against applyTurnResult, not by the stub.
*/
wars?: Array<{ id: string; sides: string[]; aims?: string[] }>;
/**
* (v0.34.0, #57) The player's current relations to every other nation
* (`nationId -> RelationStatus`), emitted by the engine's user prompt. The
* stub ignores this field — it never runs a hybrid op (see
* deterministicTurn), so the hybrid path is covered by unit tests only.
*/
relations?: Record<string, RelationStatus>;
/**
* (v0.35.0, #58) The currently active bilateral trade agreements
* (`{ a, b }` nation-id pairs), emitted by the engine's user prompt. The
* stub ignores this field — it never declares, lifts or suspends a trade
* (see deterministicTurn), so the trade path is covered by unit tests only.
*/
trades?: Array<{ a: string; b: string }>;
/**
* (v0.35.0, #58) The currently active unilateral embargoes
* (`{ from, target }` nation-id pairs), emitted by the engine's user
* prompt. The stub ignores this field — it never imposes, lifts or
* suspends an embargo (see deterministicTurn), so the embargo path is
* covered by unit tests only.
*/
embargoes?: Array<{ from: string; target: string }>;
/**
* (v0.36.0, #59) Each nation's aggregate resource profile
* (`{ oil, iron, food, industry }`), emitted by the engine's user prompt.
* The stub ignores this field — it never declares an industry project (see
* deterministicTurn), so the industry path is covered by unit tests only.
*/
resources?: Record<string, { oil: number; iron: number; food: number; industry: number }>;
/**
* (v0.36.0, #59) The currently active industry projects
* (`{ regionKey, type }`), emitted by the engine's user prompt. The stub
* ignores this field — it never declares an industry project (see
* deterministicTurn), so the industry path is covered by unit tests only.
*/
industry?: Array<{ regionKey: string; type: string }>;
}
function truncate(s: string, n: number): string {
return s.length <= n ? s : s.slice(0, n - 1) + '…';
}
/**
* 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 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.
*
* (v0.21, #16 P2) It also makes ONE contiguous conquest per turn: the player
* gains the first frontier region (sorted order) from its current owner, and
* that owner loses it. The stub bypasses the tool loop (it answers with plain
* content in one round), so the frontier comes from the prompt payload, not
* from a getNeighbours call. With an empty frontier (no controllable border)
* no territory change is emitted, keeping every existing assertion stable.
*
* (v0.22, #16 P3) The stub NEVER releases a region to no man's land (it
* always returns `unowned: []`) — e2e assertions rely on its fully-deterministic
* one-conquest behavior, so the release/re-claim path is exercised by unit
* tests only (see server/engine.test.ts).
*
* (v0.23, #16 P4a) The stub likewise NEVER proposes nation-management ops (it
* always returns `nationOps: []`) — the propose→confirm/reject flow is
* covered by unit tests only, keeping the e2e assertions untouched.
*
* (v0.27, #44) The stub likewise NEVER places or removes a map marker (it
* always returns `markers: { add: [], remove: [] }`) — the marker path is
* covered by unit tests only, keeping the e2e assertions untouched.
*
* (v0.29, #46 PR 2) The stub likewise NEVER changes per-region stats (it
* always returns `regionChanges: []`) — the per-region lever is covered by
* unit tests only (see server/engine.test.ts), keeping the e2e assertions
* untouched.
*
* (v0.32.0, #54) The stub likewise NEVER performs a non-military takeover
* (it always returns `takeovers: []`) — the regime-rename path is covered by
* unit tests only, keeping the e2e assertions untouched.
*
* (v0.33.0, #56) The stub likewise NEVER declares or ends a war (it always
* returns `wars: { declare: [], end: [] }`) — the war path is covered by unit
* tests only, keeping the e2e assertions untouched.
*
* (v0.34.0, #57) The stub likewise NEVER runs a hybrid / covert op (it always
* returns `hybrid: { ops: [] }`) — the hybrid-war path is covered by unit
* tests only, keeping the e2e assertions untouched.
*
* (v0.35.0, #58) The stub likewise NEVER declares, lifts or suspends a trade
* or an embargo (it always returns `economy: { trade: [], embargo: [] }`) —
* the trade & embargo economy path is covered by unit tests only, keeping
* the e2e assertions untouched.
*
* (v0.36.0, #59) The stub likewise NEVER declares an industry project (it
* always returns `industry: { projects: [] }`) — the per-region industry
* path is covered by unit tests only, keeping the e2e assertions untouched.
*
* (v0.37.0, #60) The stub likewise NEVER arms a nation or fires a nuclear
* strike (it always returns `nuclear: { arm: [], strikes: [] }`) — the
* nuclear / WMD path is covered by unit tests only, keeping the e2e
* assertions untouched.
*/
export function deterministicTurn(p: StubPayload): TurnResult {
const state = p.state;
const pid = state?.playerNationId ?? 'uk';
const player = state?.nations?.[pid];
const mil = clampStat((player?.stats?.military ?? 50) + 5);
const otherId = state
? (Object.keys(state.nations).find((id) => id !== pid) ?? '')
: 'us';
const items = p.pending ?? [];
// The one deterministic region transfer: the player takes the first
// frontier region (sorted → stable) from whichever other nation owns it.
let territoryChanges: TerritoryChange[] = [];
const frontier = p.frontier ?? [];
if (state && frontier.length > 0) {
const key = frontier[0];
const loserId = Object.keys(state.nations).find(
(id) => id !== pid && (state.nations[id].territory ?? []).includes(key),
);
if (loserId) {
territoryChanges = [
{ nation: pid, gain: [key], lose: [] },
{ nation: loserId, gain: [], lose: [key] },
];
}
}
const events: TurnEventSpec[] =
items.length === 0
? [
{
nation: pid,
type: 'political',
title: 'The world moves on',
text: 'Weeks pass without a decisive event.',
outcome: 'neutral',
},
]
: 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),
events,
stateChanges: [{ nation: pid, stat: 'military', op: 'set', value: mil }],
regionChanges: [], // the stub never changes per-region stats (see doc comment)
relations: otherId ? [{ a: pid, b: otherId, set: 'ally' }] : [],
territoryChanges,
unowned: [], // the stub never creates no man's land (see doc comment)
nationOps: [], // the stub never proposes nation ops (see doc comment)
takeovers: [], // the stub never performs a takeover (see doc comment)
wars: { declare: [], end: [] }, // the stub never declares/ends wars (see doc comment)
hybrid: { ops: [] }, // the stub never runs hybrid/covert ops (see doc comment)
economy: { trade: [], embargo: [] }, // the stub never touches trades/embargoes (see doc comment)
industry: { projects: [] }, // the stub never declares industry projects (see doc comment)
nuclear: { arm: [], strikes: [] }, // the stub never arms or strikes (see doc comment)
leaders: { changes: [] }, // the stub never changes leaders (see doc comment)
markers: { add: [], remove: [] }, // the stub never touches markers (see doc comment)
};
}
const CANNED_ADVISOR: string[] = [
'Rearm the navy and expand the shipyards to secure the sea lanes.',
'Open a back-channel with a rival to trade for rare resources.',
'Invest in domestic industry to fund a long war effort.',
'Dispatch envoys to neutral states to lock in non-aggression pacts.',
'Build coastal fortifications to blunt any surprise invasion.',
];
/**
* A Transport that deterministically answers LLM requests. Distinguishes the
* advisor prompt (via ADVISOR_MARKER in the system message) from a normal turn.
* Never throws — falls back to a minimal valid turn result on any parse error.
*/
export function makeDeterministicTransport(): Transport {
return async (_url, init) => {
let content: string;
try {
const req = JSON.parse(String(init?.body ?? '{}')) as {
messages?: ChatMessage[];
};
const sys = req.messages?.find((m) => m.role === 'system')?.content ?? '';
if (sys.includes(ADVISOR_MARKER)) {
content = JSON.stringify(CANNED_ADVISOR);
} else {
const user = req.messages?.find((m) => m.role === 'user');
const payload = JSON.parse(user?.content ?? '{}') as StubPayload;
content = JSON.stringify(deterministicTurn(payload));
}
} catch {
content = JSON.stringify(deterministicTurn({}));
}
return {
ok: true,
status: 200,
json: async () => ({ choices: [{ message: { content } }] }),
text: async () => content,
} as unknown as Response;
};
}
/** A ready-made LlmClient backed by the deterministic transport. */
export function makeStubLlmClient() {
return createLlmClient({ transport: makeDeterministicTransport() });
}