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 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
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).
Blocking a user prevents them from interacting with repositories, such as opening or commenting on pull requests or issues. Learn more about blocking a user.
Part 2 of #16 (region-level territory): territory now moves region-by-region along real land borders, and the LLM gains a live
getNeighboursfunction-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.mjsonly, never.ts): cheap bbox-proximity prefilter (0.05° gap) then a@turf/boolean-intersectsarbiter 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: newLlmClient.chatWithTools()— a bounded OpenAI function-calling loop: on afinish_reason: "tool_calls"response it runs the injected executor for every call, appends the assistant tool-call message + onetoolresult message per call, and re-sends until a finalcontent(max 6 tool rounds, then one forced no-tools request). Shares thechat()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;toolsis included in the request body only when non-empty (plainchat()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 afrontierfield in the user payload.applyTurnResultis 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, loneloseentries 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 plainchat()path — everything keeps working, the tool/frontier simply switch off.frontier[0](sorted → stable) and its owner loses it; empty frontier (e.g. the UK inwwii-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
chatWithTools, the frontier stub, the region-keyed RegionMap /mapFeatureOwnerresolution).No data-format change → no save wipe: the
TerritoryChangewire shape is unchanged and saved games load as-is, so the deploy is a plaindocker compose up -d --build(nodown -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 todist/,getNeighbourspresent in the server bundle), 14/14 e2e (locally withLLM_STUB=1; CI re-runs the suite on this PR).Part of #16