Additive 'Create scenario' path (v0.31): the home screen's create-scenario
card opens a ScenarioForm (pick one of 3 light era templates + a free-text
description + an editable, era-prefilled start year) whose 'Generate world'
posts the draft request; the CheckpointReview screen shows the generated
world (per-nation stats/territory/relations + backstory) and the advisor's
checkpoint list as an approve/remove checklist, then the player picks a
nation to lead and finalizes into a saved, playable game. Both LLM steps ride
the scenario client (deterministic stub under LLM_STUB=1); a failure toasts
and stays on the current screen. The turn stub and static presets are
untouched. New e2e covers the full deterministic flow (near-future 2035,
remove checkpoint 3, lead Boreas, finalize, one empty jump) and the 3-template
manifest.
- server/scenario.ts: buildGeneratePrompt / buildRegeneratePrompt, defensive
parseGenerate / parseRegenerate (fence-strip + balanced-object + validate),
generateDraft (draft world + advisor checkpoints) and finalizeWorld (final
world constrained to approved checkpoints -> playable GameState), all over
the injectable LlmClient with a nudge re-send. SCENARIO_* markers for the
stub.
- server/stubScenario.ts: deterministic scenario stub transport (fixed 3-nation
world + checkpoint list; finalize refines stability per approved checkpoint)
+ makeScenarioStubLlmClient. Distinct from and never touching turn stub.ts.
- server/regions.ts: validCountryCodes() accessor (ISO_A3 parents) for draft
territory validation.
- shared/scenario.ts: worldDraftToGameState now guards a dangling playerNationId
(falls back to the first nation) so a finalize that drops it stays playable.
- server/scenario.test.ts: 16 tests (prompt builders, defensive parsing,
generateDraft/finalizeWorld over a fake client + the stub).
Fixes#52
- Add a hard military conquest gate (canConquer, CONQUEST_MILITARY_MARGIN=15)
enforced in applyTurnResult: a contested invasion is repelled when the
attacker's military is more than 15 below the defender's; re-claims of
unowned regions are never gated. Emits a new 'repelled' event.
- Document the soft levers in the system prompt: stability<30 + gathering
marker -> non-military takeover; economy growth/decay drives other stats.
- Emit bounded frontierMilitary (per-neighbour balance) and takeoverRisks
hints in the user payload (only when present).
- repelled outcome color (#9f1239); full unit test coverage; stub untouched.
- Bump to v0.30.0.
Fixes#50
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)
The single source of truth now keys ownership by region key (one per owned
sub-region) instead of a parent-country ISO_A3:
- shared/types.ts, shared/protocol.ts: TerritoryChange / RegionMap / regionOwner
doc flips; mapFeatureOwner now reads the feature's stamped properties.regionKey
(never the repacked numeric feature.id, nor the sub-region id — the #6 guard).
- server/regions.ts (new, turf-free): loads region-keys.json + regions.geo.json
once at boot; expands a nation's ISO_A3 list to its sub-regions' keys, builds
the LLM 'regions' reference table (key -> "name (parent)"), and exposes the
set of valid keys. Degrades gracefully when the files are missing.
- server/engine.ts: buildSystemPrompt instructs the model to use region keys;
buildUserPrompt gains an optional regionReference table; applyTurnResult gains
an optional validKeys set so hallucinated keys are ignored.
- server/index.ts: loads region data at boot; expands each nation's country list
to region keys at game creation; the jump route passes the reference + valid
keys through runTurn.
The stub is unchanged (it emits an empty, region-key-conformant territoryChanges).
- DELETE /api/games/:id route + Db.delete() (404 when absent)
- PresetPicker: per-saved-game trash button + confirm dialog (Cancel/Delete)
- App: runDeleteGame; useSse onError -> goHome on 'deleted'/'not found'
(a deleted/missing game now returns the live tab and any deep link home)
- DeleteGameResponse in shared/protocol.ts; api.deleteGame()
- unit test (Db.delete) + 2 e2e tests (home-screen delete; in-flight delete
returns the active tab home and a fresh deep link lands on home)
- version 0.7.0 -> 0.8.0 + changelog
Fixes#20