#41: re-partition the RU-ARK mainland's 28-part lat>=68 Arctic-coast band
into two new regions via a new turf-free, idempotent pipeline stage
scripts/split-arkhangel-band.mjs (npm run split:regions:band), mirroring the
#31 split:
- RU-PEO (Pechora, 13 parts, ~37,678 km2) -> key pechora-qwhs
- RU-YAM (Yamal, 15 parts, ~51,722 km2) -> key yamal-gosw
- RU-ARK keeps its id/name/key arkhangel-sk-lhbk (ownership-safe); the 5-part
lat<68 core (~305,229 km2) stays RU-ARK.
Pure re-partition: no geometry moved (byte-identical union). The two new
features are inserted immediately after RU-ARK-SZ. Manifests regenerated with
the real scripts (1,798 -> 1,800 keys + adjacency; adjacency pairs 4,471 ->
4,473). Fixed-point dataset unchanged except feature count (11,794 parts /
11,795 rings / 164,801 verts / 1 KZ-KZY hole). Four dataset/manifest tests
updated (RUS 82 -> 84 sub-regions). Version 0.25.0 -> 0.26.0.
Fixes#41
- 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)
- 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).