Region transfers along real borders + getNeighbours LLM tool (v0.21.0) — part 2 of #16 #36

Merged
kamilkrzywda merged 3 commits from feat/16-p2-region-transfers into master 2026-10-14 12:51:30 +02:00
Owner

Part 2 of #16 (region-level territory): territory now moves region-by-region along real land borders, and the LLM gains a live getNeighbours function-calling tool to verify borders before proposing transfers. #16 stays open — P3 (rename/split/merge) and P4 follow.

Stage 1 — region-adjacency manifest

  • scripts/build-region-adjacency.mjs (npm run build:region-adjacency; turf in .mjs only, never .ts): cheap bbox-proximity prefilter (0.05° gap) then a @turf/boolean-intersects arbiter for every candidate pair → public/region-adjacency.json: 1,796 entries, 4,470 border pairs (8,940 directed), max degree 19 (1,727 regions with ≥1 neighbour, 69 without), symmetric + deduped. Deterministic/idempotent — a sha256-pinning test asserts a byte-identical re-run.

Stage 2 — tool loop + engine wiring

  • server/llm.ts: new LlmClient.chatWithTools() — a bounded OpenAI function-calling loop: on a finish_reason: "tool_calls" response it runs the injected executor for every call, appends the assistant tool-call message + one tool result message per call, and re-sends until a final content (max 6 tool rounds, then one forced no-tools request). Shares the chat() retry/backoff/reasoning_effort: "low" machinery; a tool-call round is a success (never truncation); malformed arguments become an {"error":…} tool message so a bad reply can never throw; tools is included in the request body only when non-empty (plain chat() bodies stay byte-identical).
  • server/engine.ts: GET_NEIGHBOURS_TOOL + makeNeighboursExecutor (never throws — unknown tool/args/keys degrade to a safe response), playerFrontier (sorted other-nation keys bordering the player's territory), contiguity rules in the system prompt (a nation may only GAIN a key bordering its own territory; every key has exactly one owner), and a frontier field in the user payload.
  • Sole-ownership enforcement: applyTurnResult is snapshot-first — a key transfers only when exactly one nation claims it, it has exactly one current owner, and the owner ≠ gainer (double claims rejected, pre-existing double ownership untouched, lone lose entries no-ops). A key can never be orphaned or duplicated; a bad model answer degrades to "no transfer", never to a corrupt map.
  • server/index.ts: the jump route passes the resolver only when the manifest loaded (hasAdjacency()); without it (or with a chat-only client) the turn falls back to the legacy plain chat() path — everything keeps working, the tool/frontier simply switch off.
  • Deterministic stub: one contiguous conquest per turn — the player gains frontier[0] (sorted → stable) and its owner loses it; empty frontier (e.g. the UK in wwii-1939, whose only external neighbour is Ireland) transfers nothing, so all e2e assertions stay stable. The stub answers plain content (no tool calls) and naturally bypasses the loop.

Stage 3 — release

  • Bump 0.20.0 → 0.21.0, changelog entry, AGENTS.md refreshed (region-key ownership, the two manifests, the scripts/ pipeline, chatWithTools, the frontier stub, the region-keyed RegionMap / mapFeatureOwner resolution).

No data-format change → no save wipe: the TerritoryChange wire shape is unchanged and saved games load as-is, so the deploy is a plain docker compose up -d --build (no down -v).

Verification (all green): npm run typecheck, npm run lint, 105/105 unit tests (79 baseline + 26 new: tool-loop protocol, engine tool-path/fallback, playerFrontier, executor degradation, sole-ownership invariants, neighboursOf/hasAdjacency), production build (both manifests copied to dist/, getNeighbours present in the server bundle), 14/14 e2e (locally with LLM_STUB=1; CI re-runs the suite on this PR).

Part of #16

Part 2 of #16 (region-level territory): territory now moves **region-by-region along real land borders**, and the LLM gains a live **`getNeighbours`** function-calling tool to verify borders before proposing transfers. **#16 stays open** — P3 (rename/split/merge) and P4 follow. ## Stage 1 — region-adjacency manifest - `scripts/build-region-adjacency.mjs` (`npm run build:region-adjacency`; turf in `.mjs` only, never `.ts`): cheap bbox-proximity prefilter (0.05° gap) then a `@turf/boolean-intersects` arbiter for every candidate pair → `public/region-adjacency.json`: **1,796 entries, 4,470 border pairs (8,940 directed), max degree 19** (1,727 regions with ≥1 neighbour, 69 without), symmetric + deduped. Deterministic/idempotent — a sha256-pinning test asserts a byte-identical re-run. ## Stage 2 — tool loop + engine wiring - `server/llm.ts`: new `LlmClient.chatWithTools()` — a **bounded OpenAI function-calling loop**: on a `finish_reason: "tool_calls"` response it runs the injected executor for every call, appends the assistant tool-call message + one `tool` result message per call, and re-sends until a final `content` (max **6** tool rounds, then one forced *no-tools* request). Shares the `chat()` retry/backoff/`reasoning_effort: "low"` machinery; a tool-call round is a success (never truncation); malformed arguments become an `{"error":…}` tool message so a bad reply can never throw; `tools` is included in the request body only when non-empty (plain `chat()` bodies stay byte-identical). - `server/engine.ts`: `GET_NEIGHBOURS_TOOL` + `makeNeighboursExecutor` (never throws — unknown tool/args/keys degrade to a safe response), `playerFrontier` (sorted other-nation keys bordering the player's territory), contiguity rules in the system prompt (a nation may only GAIN a key bordering its own territory; every key has exactly one owner), and a `frontier` field in the user payload. - **Sole-ownership enforcement:** `applyTurnResult` is snapshot-first — a key transfers only when exactly one nation claims it, it has exactly one current owner, and the owner ≠ gainer (double claims rejected, pre-existing double ownership untouched, lone `lose` entries no-ops). A key can never be orphaned or duplicated; a bad model answer degrades to "no transfer", never to a corrupt map. - `server/index.ts`: the jump route passes the resolver only when the manifest loaded (`hasAdjacency()`); without it (or with a chat-only client) the turn falls back to the legacy plain `chat()` path — everything keeps working, the tool/frontier simply switch off. - **Deterministic stub:** one contiguous conquest per turn — the player gains `frontier[0]` (sorted → stable) and its owner loses it; empty frontier (e.g. the UK in `wwii-1939`, whose only external neighbour is Ireland) transfers nothing, so all e2e assertions stay stable. The stub answers plain content (no tool calls) and naturally bypasses the loop. ## Stage 3 — release - Bump **0.20.0 → 0.21.0**, changelog entry, AGENTS.md refreshed (region-key ownership, the two manifests, the scripts/ pipeline, `chatWithTools`, the frontier stub, the region-keyed RegionMap / `mapFeatureOwner` resolution). **No data-format change → no save wipe:** the `TerritoryChange` wire shape is unchanged and saved games load as-is, so the deploy is a plain `docker compose up -d --build` (no `down -v`). **Verification (all green):** `npm run typecheck`, `npm run lint`, **105/105 unit tests** (79 baseline + 26 new: tool-loop protocol, engine tool-path/fallback, `playerFrontier`, executor degradation, sole-ownership invariants, `neighboursOf`/`hasAdjacency`), production build (both manifests copied to `dist/`, `getNeighbours` present in the server bundle), **14/14 e2e** (locally with `LLM_STUB=1`; CI re-runs the suite on this PR). Part of #16
kamilkrzywda added 3 commits 2026-10-14 12:49:51 +02:00
Generate public/region-adjacency.json: regionKey -> neighbourKeys, built
from regions.geo.json (bbox proximity reject + @turf/boolean-intersects
arbitration; symmetric, deduped, no self-refs). Deterministic + idempotent.

This is the data backing the LLM's getNeighbours tool (Stage 2) which lets
it move contiguous frontier regions instead of whole countries.
- 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).
Bump to v0.21.0 (region transfers + getNeighbours tool, #16 P2)
CI / build + unit + e2e (pull_request) Successful in 1m34s
0b999f1448
kamilkrzywda scheduled this pull request to auto merge when all checks succeed 2026-10-14 12:50:06 +02:00
kamilkrzywda merged commit cb426273a9 into master 2026-10-14 12:51:30 +02:00
Sign in to join this conversation.
No Reviewers
No labels
1 Participants
Notifications
Due Date
No due date set.
Dependencies

No dependencies set.

Reference: OpenCode/History#36