- 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).
v0.19.0's region-level ownership did not activate in the production image:
server/regions.ts loadRegions only looked in public/, but the Docker image keeps
only dist/ (Vite copies public/* -> dist/ at build), so the loader found nothing
and expandCountriesToKeys degraded to a no-op — fresh games kept raw ISO_A3 codes
instead of region keys (the client's own /region-keys.json fetch still worked,
since the client is served from dist/).
loadRegions now probes public/ (dev) then dist/ (prod, == clientDir) and uses the
first directory holding both files. A new test covers the prod-only (dist/), dev
(public/), and empty-root (no data) layouts.
- map.test.ts: rewrite the fixture for region keys; the #6 regression guard now
proves the owner resolves via properties.regionKey while the repacked numeric
feature.id and the sub-region regionId do not.
- regions.test.ts (new): the loader sees 1,796 valid keys; FRA expands to its
42 sub-region keys (all valid, all labelled '… (FRA)'); allTerritoryKeys
unions correctly; and with no data it degrades to the raw input.
- engine.test.ts: buildUserPrompt includes a regions table only when provided;
applyTurnResult with a validKeys set applies real keys and ignores bogus ones,
and without one applies all (the legacy/unit path).