Files
History/server/stub.ts
T
kamilkrzywda fdf6eb236a feat: sub-region-level territory — ownership, LLM and map at region granularity (v0.19.0)
Move territory from whole-country (ISO_A3) to individual sub-region id
granularity (PR 3 of #16):

- new server/regions.ts (turf-free): loadRegions (boot), expandTerritory
  (game creation), migrateTerritoryToRegions (legacy games on db.load),
  regionNamesFor / regionLabel (LLM prompt), graceful ISO_A3 fallback
- game creation expands each preset nation's ISO_A3 list to its sub-region
  ids; pre-v0.19 saved games migrate transparently on read (idempotent)
- LLM contract: territoryChanges gain/lose sub-region ids (one region at a
  time; gain moves out of the prior owner; lost-without-gainer = unowned);
  user prompt carries a top-level regions reference table
  ({id: 'name (parent)'} over the state's territory ids)
- WorldMap: choropleth fill per sub-region (countries can split between
  nations), ownership via properties.regionId through the shared
  mapFeatureOwner, flash lights exactly the changed features
  (featuresByRegionIds)
- deterministic stub: one region-level move per non-empty round (player
  annexes the lexicographically-first region of the first other nation)
- tests: new server/regions.test.ts (real geo + fallback + seams);
  map/engine tests re-pinned to region ids; scripts/regions.test.ts stays green

Measured full wwii-1939 user prompt (411 regions): ~24.7k chars / ~6.2k
tokens — far under the 80k frontier limit, so the full regions table is
always sent (no frontier-limited mode needed).

Part of #16
2026-10-13 22:07:18 +00:00

136 lines
5.4 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 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.buildUserPrompt). */
export interface StubPayload {
state?: {
turn: number;
date: string;
playerNationId: string;
/** As of v0.19 territory is a list of sub-region ids (not ISO_A3 codes). */
nations: Record<string, { stats: Record<string, number>; territory?: string[] }>;
};
/** The raw pending round the engine emitted (one entry per queued item). */
pending?: RoundItem[];
}
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 (by key order), advancing the date by 2 days. As of v0.19, when the
* round is non-empty it also makes exactly **one** territory move: the player
* annexes the lexicographically-first sub-region id of that first other
* nation's territory (an empty round makes no move, and a player with nothing
* to annex from that nation also makes no move) — so unit tests and e2e can
* assert the exact region that changes hands.
*/
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 per-turn territory move (see the doc above).
const otherTerritory = [...(state?.nations?.[otherId]?.territory ?? [])].sort();
const gain = items.length > 0 && otherTerritory.length > 0 ? [otherTerritory[0]] : [];
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 }],
relations: otherId ? [{ a: pid, b: otherId, set: 'ally' }] : [],
territoryChanges: gain.length > 0 ? [{ nation: pid, gain, lose: [] }] : [],
};
}
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() });
}