Files
kamilkrzywda 764f24fc55 No man's land: TurnResult.unowned + release/re-claim in applyTurnResult + distinct grey map rendering (#16 P3)
- shared/protocol.ts: TurnResult.unowned (keys released this turn) + the map
  presentation contract (regionFillStyle / unownedTooltipLabel, hue-less grey)
- shared/types.ts: required GameState.unowned (cumulative mid-game releases;
  [] at init, distinct from preset-unassigned regions)
- server/engine.ts: applyTurnResult applies releases first (sole-owner only,
  never fabricating no man's land from unassigned keys), then claims against
  the post-release snapshot (gain on an ownerless key = re-claim); lone
  release is isMeaningful; prompts + user payload carry the unowned field
- server/stub.ts: unchanged behavior (never releases; e2e stays stable)
- server/db.ts: legacy rows load with unowned defaulted to []
- server/index.ts: create-game sets []; jump reference table includes
  released keys so the model can name them for re-claim
- WorldMap/App: unowned prop -> visible grey + 'No man's land' tooltip
- unit tests: 105 -> 127 (release/re-claim suites, presentation contract,
  legacy-row normalization)
2026-10-14 12:04:03 +00:00

171 lines
6.7 KiB
TypeScript
Raw Permalink 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 { 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;
nations: Record<string, { stats: Record<string, number>; territory?: 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[];
}
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).
*/
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 }],
relations: otherId ? [{ a: pid, b: otherId, set: 'ally' }] : [],
territoryChanges,
unowned: [], // the stub never creates no man's land (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() });
}