Promote the bare 'war' relation to a named War entity in GameState.wars:
- War: { id, sides[], declaredTurn, declaredDate?, aims?, status, front? }
- TurnResult.wars: { declare: WarDeclareOp[], end: WarEndOp[] }
- syncWarRelations: derive 'war' from active wars, fallback to 'hostile' on end
- Mutual-defense drag-in: belligerent allies added to sides (one level)
- Bare 'war' back-compat: never downgraded, never force-created
- Stub: empty wars (e2e unaffected); ~30 new unit tests
v0.33.0
v0.29.0 — the LLM can now target individual regions' stats via
TurnResult.regionChanges (set/add per stat, clamped 0-100), applied
after nation-wide stateChanges and before the weighted-median
recompute. The map hover tooltip shows each region's own four stats.
The stub stays deterministic (regionChanges: []).
Fixes#46 (PR 2 of 2)
- 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).