CI / build + unit + e2e (pull_request) Successful in 1m55s
Add the 9th modular LLM ability: a turn-transient `TurnResult.disasters`
lever letting the LLM declare natural/man-made disasters (earthquake /
flood / volcanic / wildfire / famine / plague / epidemic) on a nation or
a region, applying a DETERMINISTIC (type, severity) stat dip (no hidden
randomness). Like the hybrid layer it owns no GameState field, needs no
db.ts change, and leaves no durable list — an ongoing plague/epidemic is
simply re-declared each turn it persists.
- shared/protocol.ts: DisasterType / DisasterSeverity / DisasterEvent /
DisastersOps, and `TurnResult.disasters` (between `ideology` and
`markers`; required, default `{ events: [] }`).
- server/abilities/disasters.ts: the ability module (validate / apply /
schemaLine / constraint / isMeaningful / inert stub / empty payload; no
stateFields). `DISASTER_EFFECTS` is the deterministic (type, severity)
dip table (population never touched). Target resolution mirrors hybrid
(a region key hits its SOLE owner; 0/2+ owners dropped) but has NO
`from` field, so there is no self-target drop. Dips clamp to 0-100,
mirror to the target's regions (regionStats), and re-derive the target
nation's stats (same 0-region / no-entry guards).
- Registered last in ABILITIES (after ideology). System-prompt goldens
regenerated (one schema line + one constraint bullet each); the
user.txt golden stays byte-identical (payload() is {}).
- New server/abilities/disasters.test.ts (33 tests); registry.test.ts
updated to the 9-ability contract.
- Secondary cleanups from the v0.40 refactor: AGENTS.md (abilities/ in
the Layout tree + a "how to add a new ability" bullet + a disasters
bullet), delete the orphaned validateTakeovers JSDoc, fold both inline
`as unknown` casts into the top-level ./abilities import, and de-dup
`str` (now imported from ./abilities/shared).
Version bumped 0.40.0 -> 0.41.0.
317 lines
14 KiB
TypeScript
317 lines
14 KiB
TypeScript
// 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';
|
||
import { ABILITIES, type AbilityField } from './abilities';
|
||
|
||
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).
|
||
// `ideology` (v0.39.0, #62) is the per-nation form of government stamped
|
||
// by the engine; the stub ignores it — it never changes an ideology (see
|
||
// deterministicTurn).
|
||
nations: Record<
|
||
string,
|
||
{
|
||
stats: Record<string, number>;
|
||
territory?: string[];
|
||
nuclear?: boolean;
|
||
leader?: { name: string };
|
||
ideology?: 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.
|
||
*
|
||
* (v0.38.0, #61) The stub likewise NEVER changes a leader (it always returns
|
||
* `leaders: { changes: [] }`) — the leader path is covered by unit tests
|
||
* only, keeping the e2e assertions untouched.
|
||
*
|
||
* (v0.39.0, #62) The stub likewise NEVER changes a nation's ideology (it
|
||
* always returns `ideology: { changes: [] }`) — the ideology 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,
|
||
},
|
||
);
|
||
|
||
// Core (non-ability) turn fields, in their historical position order.
|
||
const pre: Pick<
|
||
TurnResult,
|
||
| 'date'
|
||
| 'events'
|
||
| 'stateChanges'
|
||
| 'regionChanges'
|
||
| 'relations'
|
||
| 'territoryChanges'
|
||
| 'unowned'
|
||
| 'nationOps'
|
||
> = {
|
||
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)
|
||
};
|
||
|
||
// The 9 ability fields, filled by the registry in its canonical order. The
|
||
// stub is ability-inert: every ability contributes its own no-op default
|
||
// (each ability's `stub()` — see the per-ability doc comments above), so the
|
||
// e2e assertions are unaffected. The insertion order here (ABILITIES) is the
|
||
// historical key order pinned by the stub's deterministic output
|
||
// (takeovers → wars → hybrid → economy → industry → nuclear → leaders →
|
||
// ideology → disasters), which must stay byte-identical.
|
||
const abilities = {} as Record<AbilityField, unknown>;
|
||
for (const a of ABILITIES) abilities[a.field] = a.stub();
|
||
|
||
// The single field positioned after the abilities (markers last, as before).
|
||
const post: Pick<TurnResult, 'markers'> = {
|
||
markers: { add: [], remove: [] }, // the stub never touches markers (see doc comment)
|
||
};
|
||
|
||
return { ...pre, ...abilities, ...post } as TurnResult;
|
||
}
|
||
|
||
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() });
|
||
}
|