Adds the sea ability: a fixed set of 12 named sea zones with sole-control
semantics, contest-by-higher-military, and a deterministic per-turn
+1 military/+1 economy bonus for each claimed zone. Durable (owns
GameState.seaZones), additive migration, stub-inert (e2e unaffected).
Fixes#65
Design note: Gitea #65 sketched a free-form 'naval' layer; the implemented
approved design is this richer 'sea' layer with a fixed zone set.
Add the 10th modular LLM ability: a turn-transient TurnResult.migration
lever declaring population flows between nations (migration / refugees /
diaspora / exodus). The source loses a little population + economy, the
destination gains a little (a large influx also strains its stability),
and the from<->to relation is nudged one step toward the positive end of
the ally/neutral/rival/hostile ladder (capped by kind; a war is never
touched). Deterministic per-scale tables, no hidden randomness, clamped
0-100, mirrored to owned regions, re-derived aggregates; never moves
territory. Registered last in ABILITIES; user prompt unchanged
(user.txt golden byte-identical), system goldens re-baselined. Fixes#64.
Add the 9th modular LLM ability: a turn-transient `TurnResult.disasters`
lever letting the LLM declare natural/man-made disasters (earthquake /
flood / volcanic / wildfire / famine / plague / epidemic) on a nation or
a region, applying a DETERMINISTIC (type, severity) stat dip (no hidden
randomness). Like the hybrid layer it owns no GameState field, needs no
db.ts change, and leaves no durable list — an ongoing plague/epidemic is
simply re-declared each turn it persists.
- shared/protocol.ts: DisasterType / DisasterSeverity / DisasterEvent /
DisastersOps, and `TurnResult.disasters` (between `ideology` and
`markers`; required, default `{ events: [] }`).
- server/abilities/disasters.ts: the ability module (validate / apply /
schemaLine / constraint / isMeaningful / inert stub / empty payload; no
stateFields). `DISASTER_EFFECTS` is the deterministic (type, severity)
dip table (population never touched). Target resolution mirrors hybrid
(a region key hits its SOLE owner; 0/2+ owners dropped) but has NO
`from` field, so there is no self-target drop. Dips clamp to 0-100,
mirror to the target's regions (regionStats), and re-derive the target
nation's stats (same 0-region / no-entry guards).
- Registered last in ABILITIES (after ideology). System-prompt goldens
regenerated (one schema line + one constraint bullet each); the
user.txt golden stays byte-identical (payload() is {}).
- New server/abilities/disasters.test.ts (33 tests); registry.test.ts
updated to the 9-ability contract.
- Secondary cleanups from the v0.40 refactor: AGENTS.md (abilities/ in
the Layout tree + a "how to add a new ability" bullet + a disasters
bullet), delete the orphaned validateTakeovers JSDoc, fold both inline
`as unknown` casts into the top-level ./abilities import, and de-dup
`str` (now imported from ./abilities/shared).
Version bumped 0.40.0 -> 0.41.0.
Per-nation named leaders (Nation.leader) and a required TurnResult.leaders
lever ({ changes: [{ target, name, cause }] }). Each change sets the target
nation's leader and applies a deterministic stability dip by cause
(assassination -15, death -3, appointed 0) mirrored to its regions and
re-derived into its stats, plus a narration event (military for an
assassination, political for a death/appointment). An assassination/death
requires the target to already have a leader (a leaderless target is
dropped); an appointment always creates/sets one. Unknown/ambiguous targets
are dropped, never thrown.
- shared/types.ts: optional Nation.leader
- shared/protocol.ts: LeaderCause/LeaderChange/LeaderOps, LEADER_STABILITY_EFFECT,
TurnResult.leaders (required, between nuclear and markers), leader in nationForPrompt
- server/engine.ts: validateLeaderOps, applyLeaderChange, the applyTurnResult
leader block (after nuclear, before markers), isMeaningful, system-prompt
contract + constraint, validateTurnResult + runTurn fallback
- server/stub.ts: leaders always { changes: [] } (e2e unaffected)
- tests: 15 engine.test.ts leader tests + 2 db.test.ts round-trips
- version 0.37.0 -> 0.38.0, CHANGELOG entry
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
- 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