LLM getNeighbours tool + contiguous region transfers (Stage 2 of #16 P2)
- llm.ts: LlmClient.chatWithTools — bounded OpenAI function-calling loop (default 6 tool rounds, then one forced no-tools request); shares the retry/backoff/reasoning-effort machinery with chat(); tools are only added to the request body when non-empty, so plain chat() bodies and their tests are unchanged; a tool-call response is a success (never a truncation); malformed tool arguments become an error tool-message. - engine.ts: GET_NEIGHBOURS_TOOL + makeNeighboursExecutor (degrades unknown tools/args/keys without throwing); playerFrontier (sorted, deduped, other-nation-owned only); buildSystemPrompt gains a tool variant (contiguity rules, frontier hint) used only when the tool is actually sent; buildUserPrompt emits a frontier field whenever a resolver is given; runTurn takes getNeighbours and uses chatWithTools when both the resolver and the client's capability are present, falling back to plain chat() otherwise. - applyTurnResult: snapshot-first sole-ownership algorithm — a region key transfers only with exactly one claimant and exactly one previous owner (different from the gainer); double claims and pre-existing double ownership are left untouched; lone losses are no-ops; every key has 0 or 2+ -> 1 owner post-turn, never orphaned or duplicated. - stub.ts: deterministic one-region contiguous conquest per turn from the prompt's frontier field (bypasses the tool loop; empty frontier -> no transfer, all existing assertions stay stable). - regions.ts: optional region-adjacency.json load; hasAdjacency() and neighboursOf(keys) (union, deduped, queried excluded, sorted). - index.ts: jump route passes the adjacency resolver when loaded. - tests: chatWithTools protocol (echo, executor args, bounded rounds, malformed args, retry), tool-path runTurn wiring + fallback, playerFrontier, executor, applyTurnResult invariants, neighboursOf/ hasAdjacency (105 unit tests, 14 e2e — all green).
This commit is contained in:
+34
-3
@@ -2,7 +2,7 @@
|
||||
// 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 { addDays, clampStat, type TerritoryChange, type TurnEventSpec, type TurnResult } from '../shared/protocol';
|
||||
import type { RoundItem } from '../shared/types';
|
||||
|
||||
export const ADVISOR_MARKER = '<<ADVISOR>>';
|
||||
@@ -13,10 +13,17 @@ export interface StubPayload {
|
||||
turn: number;
|
||||
date: string;
|
||||
playerNationId: string;
|
||||
nations: Record<string, { stats: Record<string, number> }>;
|
||||
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[];
|
||||
}
|
||||
|
||||
function truncate(s: string, n: number): string {
|
||||
@@ -31,6 +38,13 @@ function truncate(s: string, n: number): string {
|
||||
* 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.
|
||||
*/
|
||||
export function deterministicTurn(p: StubPayload): TurnResult {
|
||||
const state = p.state;
|
||||
@@ -42,6 +56,23 @@ export function deterministicTurn(p: StubPayload): TurnResult {
|
||||
: '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
|
||||
? [
|
||||
@@ -76,7 +107,7 @@ export function deterministicTurn(p: StubPayload): TurnResult {
|
||||
events,
|
||||
stateChanges: [{ nation: pid, stat: 'military', op: 'set', value: mil }],
|
||||
relations: otherId ? [{ a: pid, b: otherId, set: 'ally' }] : [],
|
||||
territoryChanges: [],
|
||||
territoryChanges,
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user