Files
History/CHANGELOG.md
T
kamilkrzywda 6949739956
CI / build + unit + e2e (pull_request) Successful in 1m31s
Bump to v0.20.0 (region loader prod-path fix)
2026-10-14 09:32:29 +00:00

37 KiB
Raw Blame History

Changelog

All notable changes to History are listed here, newest first.

v0.20.0 — 2026-10-14 09:30

  • Fixed: v0.19.0's region-level ownership did not activate in the production image. The server-side region loader (server/regions.ts) only looked for the manifest + geo in public/, but in the Docker image Vite copies public/* → dist/ and no public/ directory is kept — so loadRegions found nothing, expandCountriesToKeys degraded to a no-op, and freshly-created games kept raw ISO_A3 codes (DEU, GBR,IRL,IND, …) instead of expanding to region keys. The choropleth therefore still rendered whole-country ownership and the model's region-key contract was inactive in prod (the client's own /region-keys.json fetch still worked, because the client is served from dist/). loadRegions now probes public/ (dev) then dist/ (prod, == clientDir) and uses the first directory holding both files, so one build works in both environments. A new test covers the prod-only (dist/), dev (public/), and empty-root (no data) layouts.
  • Deploy: docker compose down -v (drops the history-data volume — pre-v0.19 saves were already dropped by v0.19.0 and are dropped again here) then up -d --build.

v0.19.0 — 2026-10-14 09:14

  • Region-level ownership foundation (Part 1 of #16): internal territory ownership flips from whole countries to individual sub-regions. Previously a nation owned whole countries (a territory list of ISO_A3 codes) and every sub-region of a country shared the parent's color, so a single region could never change owner independently. Now each owned sub-region is addressed by a stable region key — <name-slug>-<4char>, e.g. moselle-rygy, ain-lqtt, western-alun — generated deterministically by scripts/build-region-keys.mjs into public/region-keys.json (1,796 unique keys over the 1,796 sub-regions). A key is a slug of the region's name (NFKD, diacritics stripped, lowercased, non-alphanumerics → -) plus a 4-character hash of the feature's stable id, salted on within-country name collisions, so it is stable, human-readable, and globally unique (a "western" collision group yields western-alun / western-qwvr / western-adsp / …; an accented name like Shkodër becomes shkoder-gmnr).
  • The map, the model, and the LLM all now reason in region keys. Nation.territory and the RegionMap are keyed by region key; the choropleth resolves each sub-region's owner by its stamped properties.regionKey (never the repacked numeric feature id, never the raw sub-region id — the #6 guard, now region-keyed). A new server/regions.ts (turf-free) loads the manifest + geo once at boot and expands each nation's country list to all its sub-regions' keys at game creation, so a fresh game shows every sub-region in its owner's color — visually identical to the old whole-country choropleth, but each sub-region is now individually addressable. The LLM prompt gains a regions reference table (region key → " (parent ISO_A3)"), and applyTurnResult ignores any territory key not in the valid set, so a hallucinated key can't corrupt ownership.
  • The dossier shows region names. NationPanel's territory chips display each sub-region's human name (e.g. "Ain", "Wales", "Moselle") with the raw key in the tooltip / data attribute, instead of bare codes.
  • ⚠ Breaking — pre-v0.19 saved games are dropped. The territory field's meaning changed from ISO_A3 country codes to region keys, so a game saved before this version can't be read by it. Deploy runs docker compose down -v (dropping the history-data volume) then up -d --build; there is no migration — start fresh after deploying.
  • Supersedes the abandoned feat/sub-region-territory branch, which prototyped raw feature-id keys (DEU-03); this version uses the slugged, human-readable key scheme instead.
  • Verification. Typecheck, lint, and the unit suite (now 71 tests across 7 files: +server/regions.test.ts, a rewritten server/map.test.ts, +2 in engine.test.ts) all pass; the production build is clean. The deterministic LLM_STUB and the format-agnostic e2e suite (legend/stats/dates) are unchanged. #16 stays open — P2 (LLM tools / function-calling for reliable region ops) and the no-man's-land rename/split/merge phases follow.

v0.18.0 — 2026-10-13 18:54

  • Fixed a stale-owner display bug: the region hover tooltip and the dossier "Selected" label no longer print the static parent-country code (e.g. "FRA"). The GeoJSON properties.parent is a static geographic-origin field that never updates when a country is taken, yet it was rendered in the hover tooltip (as a sub-line and as a title fallback) and in the dossier's Selected: label (as a (FRA) suffix) — so a sub-region of a transferred country still displayed the old country's code next to the (correct) new owner. The tooltip now shows only the region name + the live owner chip (from the RegionMap) + key stats, and the dossier label shows only the region name. Ownership resolution still keys on properties.parent (unchanged) — only the display of it was removed.

v0.17.0 — 2026-10-13 15:13

  • Filled the no-one's-land left between merged sub-regions (Part of #16). The v0.14 coarsening's boolean dissolves left slivers of each country's landmass inside no region — measured at ≈ 3.90M km² globally (the union of a country's regions covered only 98% of its landmass). The new scripts/fill-gaps.mjs (npm run fill:regions) closes them: per country it computes the gap (landmass − union(its regions), @turf/difference+union), drops slivers < 25 km² (66,637 of them — sub-pixel on the map), and assigns each remaining piece to the nearest region (centroid distance) as an appended MultiPolygon part. 2,696,811 km² of no-one's-land was filled into 1,575 regions (+6,768 parts: 5,026 → 11,794), leaving 63,889 km² residual (98.4% reduction — the rest is coastline digitization fuzz and 14 tiny border-band overlap artifacts, see below). The named "messy" regions absorb their surroundings: SI-048 Kočevje (SVN) 16,692 → 19,816 km² (57 → 90 parts), MK-36 Kavadarci (MKD) 21,297 → 25,190 km² (8 → 17), AZ-AGC Ağcabədi (AZE) 18,657 → 32,065 km² (7 → 14); the biggest single fills are **AQ-X01 (Antarctica) +195,351 km²**, CA-NU (Nunavut) +143,447, AQ-X02~ +142,615, CL-MA (Magallanes) +81,806, US-AK (Alaska) +63,830, CA-NT (NWT) +46,333, GL-UO (Kommuneqarfiup) +41,971, RU-KAM (Kamchatka) +41,911.
  • Correct, not just greedy: clipping, hole policy, dedup. Each gap piece is clipped sequentially against (a) all other countries' regions, (b) its own country's existing regions, and (c) pieces already appended (bbox-touching) — so a piece straddling a border is split, and no piece can double-cover another region's area (1,149,841 km² of foreign overhang removed this way; 0 clip failures). FR-57 Moselle (FRA) gains nothing on purpose: its apparent gap was a cross-border phantom consumed by the neighbor clip — correct behavior. Holes follow a keep-or-drop policy: a gap hole whose centroid matches a pre-existing hole (within 10 km) or lies inside another country's landmass is kept, otherwise it's a phantom and dropped — so the KZ-KZY Aral hole survives (5,858 km² planar, the file's only hole; 5,875 km² of water kept open). A global dedup set guarantees each cleaned piece is appended at most once.
  • The dataset contract is untouched. Still 1,796 regions with identical id / properties.name / properties.parent and order — the fill only appends parts; all 177 countries covered; the single KZ-KZY hole intact. 14 genuinely new overlap pairs remain (13 same-country border bands + 1 cross CN-FJ×TW-KIN) — a known polychip artifact of filling right up to a shared boundary (the pre-fill file already carried 3,690 pre-existing pairs), and they are sub-pixel at map scale, so they're accepted. The file grows 2,289,528 → 3,399,806 bytes (2.29 → 3.40 MB) — under the 5 MB guard, so no Douglas–Peucker pass is needed.
  • Deterministic + idempotent. Fixed 25 km² threshold, deterministic ordering (centroid → area → id), 4-dp rounding (same convention as the other scripts). A fresh run on the pristine v0.16 file reproduces the shipped byte-for-byte (sha256 87f44198…), and re-running on the filled file is a no-op (byte-identical output).
  • Tests — scripts/regions.test.ts (now 9 tests): the v0.16 fixed-point pin is updated to the v0.17 topology (1,796 regions, 11,794 parts, 11,795 rings, 164,801 vertices, 1 hole); a new test asserts the only hole in the file is KZ-KZY and sits in a realistic 5,000–7,000 km² band; a new grid-sampled per-country coverage test (25×25 grid per country, inline even-odd ray cast) asserts no significant no-one's-land remains — median 1.8% / p90 6.1% / max 26.4% (BRN, a coastline-denseness artifact, not a real gap), with thresholds at 5% / 15% / 40% and ≤3 countries above 20%. The planar sum band's upper bound widens 175% → 190%: the fill only adds area inside each country, so it can't shrink the pre-existing admin-1 over-extension tail (Vanuatu 178.9%, Solomon Is. 133% — tiny island nations whose admin-1 polygons extend beyond their own country polygon), while the old under-fill tail is gone (minimum is now Brunei at 78.8%, was ~52%).
  • Type-check gotcha (why the test stays turf-free): the project keeps @types/geojson out of the tsc program (tsconfig types: ["vite/client", "node"]). maplibre-gl's d.ts references the GeoJSON UMD global without importing it, so under skipLibCheck the reference degrades to any and WorldMap.tsx's deliberately-minimal GeoFeature (with geometry: unknown) passes. The moment anything (e.g. a test importing @turf/turf) pulls @types/geojson into the program, those references typecheck for real and WorldMap.tsx fails with 7 errors. The v0.14–0.16 scripts are .mjs (never typechecked) for exactly this reason; the new test uses an inline ray cast instead of a geometry library, and @turf/* (a new devDependency, 137 packages including the meta @turf/turf) is only ever run by the .mjs scripts.
  • Part of #16

v0.16.0 — 2026-10-13 09:37

  • Smoothed the jagged sub-national region boundaries with Douglas–Peucker vertex-thinning (Part of #16). The v0.14 coarsening (boolean dissolve) and Natural Earth's 1:10m digitization left some boundaries overly jagged — the worst being regions the dissolve split into many small zig-zag fragments, e.g. SI-048 Kocevje (Slovenia, 57 parts) and MK-36 Kavadartsi (N. Macedonia, 8 parts). The new scripts/smooth-regions.mjs (npm run smooth:regions) runs @turf/simplify (Ramer–Douglas–Peucker, tolerance ~50 m) over every ring of every polygon, dropping the redundant "in-between" vertices of a shallow zig-zag while keeping the overall shape.
  • Vertex-thinning only — no topology change. DP only removes points: it never moves a border, never merges/dissolves a part, and never creates or removes a ring. So the dataset contract is fully preserved — still 1,796 regions with identical id / properties.name / properties.parent, all 177 countries covered, the same 5,026 parts / 5,027 rings, the single KZ-KZY hole, and all 83 islands. The 18 degenerate 3-point "micro-dot" rings (Ragged Island, the Paracels, …) are kept verbatim (@turf/simplify requires ≥4 pts and would otherwise throw).
  • A gentle pass, reported honestly. At 50 m the dataset is already fairly smooth (the v0.14 dissolve produced low-vertex, widely-spaced boundaries — median consecutive-vertex spacing ~18 km), so this is a light cleanup: 111,891 → 111,175 vertices (716 removed, 0.64%) across 542 of 1,796 regions. Kocelje 612 → 599 and Kavadartsi 169 → 163 verts; both keep their exact part count (the "mess" there is fragmentation, which thinning — under the no-topology-change constraint — does not fix). The file shrinks 2,302,473 → 2,289,528 bytes (2.29 MB). Bump TOLERANCE_M to 100 / 200 m and re-run for a stronger pass (100 m → 974 removed; 200 m → 1,312 removed) — the pass stays idempotent either way.
  • The "messy" regions are really fragmentation artifacts, not fine jaggedness. Ranking by part density (parts packed per unit area) rather than raw part count — which is dominated by legitimate dispersed archipelagos (Nunavut 213, Alaska 159, Antarctica 135, British Columbia 84) — surfaces the genuinely messy coherent regions the dissolve left fragmented: SI-048 Kocevje (SVN, 57 parts in ~16,600 km²), GR-H Stereá Elláda / Central Greece (GRC, 53 parts), KR-46 South Jeolla (KOR, 28 parts), SE-C Uppsala (SWE, 19 parts), HR-13 Zadarska (HRV, 17 parts), plus the milder Balkan MK-36 Kavadartsi (MKD, 8 parts). These real land regions (the Balkan ones in particular) ended up as many scattered fragments — the "similar shape problems".
  • Deterministic + idempotent. One @turf/simplify pass at a fixed tolerance, then 4-dp rounding (the same convention as the other scripts). The output already sits at the pass's fixed point, so re-running on the smoothed file is byte-identical (sha256 70a43541…, verified across repeated runs); public/regions.geo.json and the built dist/regions.geo.json are sha256-identical.
  • Tests. scripts/regions.test.ts gains an eighth test pinning the v0.16 fixed point: 1,796 regions, 5,026 parts, 5,027 rings, the single KZ-KZY hole, and exactly 111,175 vertices (8 tests total). All prior assertions (well-formed, unique ids, 177-country coverage, area floor, holes, coverage band) pass unchanged on the smoothed dataset.
  • Part of #16

v0.15.0 — 2026-10-12 13:49

  • Filled the small holes left by the v0.14 coarsening (Part of #16). The coarsened public/regions.geo.json still carried 2,245 tiny enclosed holes (all < 500 km² in practice) — unfilled patches left by the boolean dissolves and Natural Earth's own digitization, which showed up as "holes in regions". The new scripts/cleanup-regions.mjs (npm run cleanup:regions) drops every inner ring smaller than the 1,000 km² threshold so each region becomes solid: across 498 features, 56,484 km² of artifact area was filled, removing the visible unfilled dots.
  • Real water is kept, not erased. Any enclosed hole ≥ 1,000 km² survives as a hole. In this dataset the genuinely large lakes (the Great Lakes, the Caspian, the Aral, Lake Superior) had already been dissolved by the v0.14 coarsening, so none remain to preserve; the only surviving hole is a single medium one — KZ-KZY (Kyzylorda, Kazakhstan) at 5,858 km², a real water body in the Aral basin — which is kept. The 1,000 km² threshold sits comfortably between the tiny artifacts (all < 500) and any genuine lake, so it fills exactly the artifacts and nothing else.
  • The dataset contract is untouched. Still 1,796 regions with identical ids / properties.name / properties.parent (0 mismatches), all 177 countries still covered, the ~100 km floor and the island exemption intact — only inner rings were dropped, never a feature or its identity. The file shrinks 2,431 → 2,249 KB (2.20 MB) (fewer inner rings).
  • Deterministic + idempotent. Fixed threshold, deterministic planar geometry, 4-dp rounding; re-running on the cleaned file rewrites a byte-identical file (sha256-verified). Bridging very-close MultiPolygon parts is available behind an opt-in --bridge flag but is off by default, because the union it performs can itself mint new medium-sized holes — counter to the fill-holes goal — so the shipped run is the clean hole-fill only.
  • Tests. scripts/regions.test.ts gains a seventh test asserting that no enclosed hole below the cleanup threshold remains and that only a handful of real water bodies survive (57 tests total). public/regions.geo.json and the built dist/regions.geo.json are sha256-identical.
  • Part of #16.

v0.14.0 — 2026-10-12 09:51

  • Coarsened the sub-national choropleth dataset to a ~100 km floor (Part of #16). public/regions.geo.json shrinks from 4,138 real administrative regions to 1,796 (2,342 merges): every region smaller than ~100×100 km (10,000 km²) that has a same-country neighbor within 0.2° (≈22 km) of its bounding box is dissolved into its nearest such neighbor (nearest by centroid distance; ties → larger area, then id). Standalone cities and micro-districts collapse into the region around them: US-DC (191 km²) into Maryland, FR-75 Paris (59 km²) into Hauts-de-Seine, the UK's 232 admin-1 districts down to 16 regions, and all of North Macedonia's 85 municipalities into one whole-country region.
  • Islands are exempt. A region with no same-country neighbor within 0.2° keeps its shape whatever its size — 83 such small islands survive in the final set (Corsica, the Kerguelen/Crozet archipelagos, the Bahamas districts, Lord Howe, …) and the coarsening's fixed point leaves 0 non-island regions below the floor.
  • Real dissolve, not overlap. Each merge is a real boolean union (@turf/union, polygon clipping) that removes the shared boundary between the merged region and its host, yielding one clean polygon — the final dataset has 0 fallback (non-dissolved) unions. Passes iterate smallest-area-first to a fixed point (2 passes on the v0.13 data, ~7 s); the output is sorted by (parent, id) at 4-dp precision and is byte-reproducible: re-running on the coarsened file reaches the fixed point immediately and rewrites an identical file (sha256-verified).
  • Numbers. Per-country region count: min 1 / median 7 / max 80 (Russia; was 1 / 15 / 232 — the UK). Region areas: median 23,933 km²; smallest ≈0 (degenerate micro-dots like Ragged Island and the Paracel Islands, kept as islands); largest is Antarctica itself (≈21.8M km² in the planar model, exempt — it wraps a full 360°). 158 of the 177 countries had at least one merge. The file is 2,431 KB (2.37 MB) — under the 5 MB guard, so no Douglas–Peucker pass was needed.
  • Tooling + tests. New scripts/coarsen-regions.mjs (npm run coarsen:regions) performs the merge in place; scripts/regions.test.ts gains a sixth test asserting that below-floor non-island regions stay under 1% of the dataset (currently 0) alongside the existing well-formed / unique-id / 177-country coverage / area / coverage-band checks (56 tests total). The map's data contract is unchanged (properties.parent → RegionMap), so WorldMap.tsx, App.tsx, shared/protocol.ts, and server/map.ts all keep working as-is.
  • Part of #16.

v0.13.0 — 2026-10-12 08:30

  • Replaced the geometric sub-region grid with real named sub-national administrative regions (#16, PR 2). The choropleth source public/regions.geo.json now contains real named administrative regions — states, provinces, governorates, oblasts, … (Natural Earth admin-1, 1:10m) — instead of the abstract ~100–200 km geometric grid. Each region keeps a stable id and a name. The map's data contract is unchanged (properties.parent → the parent country's ISO_A3, properties.name → the region name), so no change to the map code or the LLM contract: WorldMap.tsx, App.tsx, shared/protocol.ts, and server/map.ts all keep working as-is.
  • Generator rewritten — scripts/generate-regions.mjs (same path, dependency-free, npm run generate:regions) now groups the admin-1 features by their adm0_a3 (the parent's ISO_A3), keeps only parents in the 177-country set, and id's each region with its ISO 3166-2 code when present (e.g. US-CA, FR-75, RU-MOW) else <ISO_A3>-<nn>; within a country, ids are disambiguated with a -2/-3/… suffix on collision (ISO 3166-2 codes are not always unique within a country — e.g. all nine of Bosnia's entities report BA-BIH). A country with no admin-1 data would collapse to one whole-country region <ISO_A3>-01 (0 are needed at 1:10m, which covers all 177). Coordinates are rounded to 4 decimal places, and the file is Douglas–Peucker-simplified only if it exceeds the ~8 MB guard. Output is sorted by (parent, id) and fully deterministic.
  • The dataset is 4,138 regions covering all 177 countries, 2,831 KB (2.76 MB) after Douglas–Peucker (the 4-dp base is ~23 MB). Per-country region count: min 1 / median 15 / max 232 (United Kingdom). There are 0 whole-country fallbacks — 1:10m admin-1 covers every one of the 177 (the five single-region countries are SOL, SAH, CYN, PRI, FLK). Samples: US-CA California and US-NY New York (US); FR-75 Paris and FR-13 Bouches-du-Rhône (France); RU-MOW Moskovskaya (Russia).
  • Validation test updated — scripts/regions.test.ts was rewritten for the new dataset: well-formed features, globally unique ids, all 177 countries covered (≥1 region each), sane region areas (median ≥ 100 km², no non-Antarctica region ≥ 5,000,000 km² — Antarctica is exempt because its full-360° wrap ring breaks the planar area model), and each country's region area summing to within [40%, 175%] of its own area (median within [70%, 130%] — the band is wider than a naive [40%, 110%] because Natural Earth's admin-1 polygons are digitized independently of the admin-0 country polygons and are not clipped to them). Runs in O(total vertices).
  • Part of #16.

v0.12.0 — 2026-10-12 01:03

  • Map now renders the sub-national regional dataset (#16, PR 2). The choropleth source switches from public/countries.geo.json (177 countries) to public/regions.geo.json (2,597 sub-regions): each country is subdivided into ~100–200 km regions, shown as a finer choropleth with thin internal border lines. The MapLibre source/layer ids are renamed countries/countries-fill/countries-line → regions/regions-fill/regions-line, and the border line is thinned to 0.75 (from 1.25) so the subdivision reads within each country.
  • Ownership stays at country granularity. A nation still owns whole countries, so every sub-region of a country shares that country's owning-nation color (the fill is "same colors as the country map, but subdivided"). The owner of a sub-region is resolved from the RegionMap via the feature's properties.parent (the parent country's ISO_A3) — never the sub-region id (FRA-03) nor the repacked top-level feature.id. Clicking a sub-region switches the dossier to its parent country's owning nation (unchanged behavior); the hover tooltip now also shows the sub-region name + its parent country; a small "Selected …" label appears in the dossier after a map click.
  • New shared helper mapFeatureOwner(regions, feature) in @shared/protocol — the single source of truth for resolving a rendered map feature's owner via properties.parent → RegionMap[parent] (built on the existing regionOwner key lookup). The territory-change flash now expands a changed country to all of its sub-regions (via the new featuresByParents), so a whole-country transfer still pulses correctly at the finer resolution.
  • No ownership-model or LLM change — buildRegionMap/RegionMap remain ISO_A3-keyed and the engine still references whole countries; this PR only changes what the map draws. Sub-region-level ownership (individual regions changing hands) is PR 3.
  • Tests — new mapFeatureOwner unit tests and an updated #6 regression guard pinning the sub-region feature shape (numeric id, regionId = sub-region code, parent = ISO_A3) and proving resolution via parent while the sub-region id and repacked id do not resolve (55 unit tests). The e2e suite needed no changes: it asserts on the legend (derived from the ISO_A3 RegionMap) and screen structure, not on map geometry.
  • Part of #16.

v0.11.0 — 2026-10-12 00:31

  • Sub-national region dataset + generator (#16, foundation only — no app behavior change). A new dependency-free scripts/generate-regions.mjs (run via npm run generate:regions) splits each of the 177 countries in public/countries.geo.json into adaptive ~100–200 km sub-national regions on a regular lat/lon grid clipped to the country (Sutherland–Hodgman), and writes the committed public/regions.geo.json (2,597 regions, ~755 KB). Each region is id'd <ISO_A3>-<nn> (nn = 1..k in row-major order, capped at 36 per country) with properties = { name, parent }, so downstream work can map any region back to its parent nation. Antimeridian countries are handled three ways: small island groups (Fiji) stay a single whole-country region; large ones (Russia) are tiled in shifted [0,360) longitude space with output rings split at ±180°; and the full-360° Antarctic wrap ring becomes two hemispheric wedges. The output is fully deterministic — the same input yields a byte-identical file (verified by sha256).
  • Validation test — new self-contained scripts/regions.test.ts (picked up by npm test) reads the two GeoJSON files once each and asserts: the file is a well-formed FeatureCollection; every region is a valid feature whose id matches ^[A-Z]{3}-\d+$ and whose parent is a real country id; every country has 1–36 regions (≥2 for any country larger than 50,000 km²); every region has a sane area (≥ 200 km², with a microstate exemption, and < 5,000,000 km²); and each country's regions sum to 45–108% of its own area (the measured range is ~[64%, 105%] — Antarctica's wedges under-fill, Norway's high-latitude fjord coast over-fills by 0.1%). No change to the running app, the LLM contract, or the map — this is purely the dataset + tooling that later PRs for #16 build on.
  • Fixes #16.

v0.10.0 — 2026-10-11 21:25

  • Fix world map style (#2) — made the choropleth fill fully opaque (fill-opacity 0.85 → 1.0) and darkened the Natural Earth terrain raster (raster-opacity 0.5 → 0.15), so the raster's flat water tint and fine 1:10m coastline no longer bleed through countries and compete with the borders.
  • Strengthened country border lines — border width 0.7 → 1.25, and the per-region stroke alphas raised (owned 0.10 → 0.30, unowned 0.06 → 0.14) so the vector borders read clearly as the dominant edge, with owned regions still more prominent than unowned ones.
  • Fixes #2.

v0.9.0 — 2026-10-11 20:57

  • Reduced turn latency (prompt-level): capped recentEvents in the turn prompt from the last 10 to the last 5 events, and added a HARD CONSTRAINT asking the reasoning model to keep its internal analysis brief before emitting the JSON. This shortens the chain-of-thought (the 170–223s turn driver) and lowers the risk of exhausting the 4000-token output budget before the answer (the original 502 failure mode). Reasoning stays enabled (reasoning_effort: low); the deterministic stub, the TurnResult contract, max_tokens, and reasoning_effort are all unchanged.

v0.8.0 — 2026-10-11 20:29

  • Remove a saved game (#20) — every saved game now has a delete control: a trash button on its card in the home screen's "Saved games" section opens a confirmation dialog ("Delete this game?" showing the scenario/turn/date) with Cancel and a destructive Delete. The new DELETE /api/games/:id route removes the game's row from SQLite and returns { deleted: true } (404 if the id is unknown).
  • Redirect on a missing/deleted game (#20) — loading a non-existent game id no longer leaves you stranded. A deep link to a missing game already fell back to home, and now a game that disappears while its tab is open (deleted from another tab or the same session) is pushed home too: the server broadcasts an SSE error event on delete, and the client's useSse gains an onError handler that returns the tab to the home screen (matching the existing "game not found" connect behavior).
  • PresetPicker gained an onDelete prop, a per-card data-game-id attribute, the delete button, and the confirm dialog; api.deleteGame(id), Db.delete(id), and the shared DeleteGameResponse type were added to support it.
  • Unit + e2e tests — new Db.delete test (removes by id; reports absence for unknown / already-gone ids) and two e2e specs: (a) deleting a saved game from the home screen (open → cancel leaves it; confirm removes the card, the response is {deleted:true}, and a follow-up GET is 404), and (b) deleting an open game (the live tab is pushed home via the SSE error, and a fresh deep-link to the deleted id lands on home).

v0.7.0 — 2026-10-11 19:17

  • Clicking a map region now switches the selected dossier to that region's owning nation instead of opening a popup (#7). The closeable click popup is gone; a hover tooltip (owner + key stats) is kept. The manual "Inspect" selector still works and stays in sync — both it and a map click drive the same dossier target.
  • Fixed: a clicked owned region no longer reports "no owner" (#6). Root cause: MapLibre repacks a GeoJSON source's top-level id (the ISO_A3 string) into a numeric index when it stores the source, so the feature a click/hover handler receives has a number for id — the old regions[f.id] lookup resolved to nothing, and the popup read "No owner" even for owned regions (the choropleth fill still worked because it is stamped from the original id before setData). Fix: applyRegions now stamps the stable ISO_A3 code into properties.regionId (and ownerId), which survives into the rendered features, and both the click and hover handlers resolve the owning nation from that code via the authoritative RegionMap — never from the repacked feature.id.
  • Unowned regions (no entry in the RegionMap) do not change the dossier when clicked — the current target is left in place (a deliberate, non-crashing choice rather than a special "unowned" dossier state).
  • New shared helper regionOwner(regions, isoA3) in @shared/protocol (the pure, single source of truth for "ISO_A3 → owner record").
  • Unit tests — new regionOwner tests (owned → owner record, unowned → undefined, empty/missing → undefined) plus a #6 regression guard that pins the exact MapLibre-repacked feature shape (numeric id, ISO_A3 in properties.regionId) and asserts resolution via properties.regionId works while the old feature.id path fails (44 tests, up from 39).

v0.6.0 — 2026-10-11 18:27

  • Advisor fetches now show a "thinking" indicator (mirroring the turn's "Simulating…" hint) while the model responds, and the Ask-advisor button disables during the fetch; the top-bar status shows "Advisor thinking…" in the meantime. Fixes #15.

v0.5.0 — 2026-10-11 17:36

  • HUD revamp — removed the "Map" window (the full-screen map is directly interactive via scroll/trackpad; no floating zoom control, and the now-dead MapApi/onMapReady map plumbing is removed from WorldMap); the top bar is now a fixed, non-movable element pinned to top-center a few px from the top (a new pinned mode on FloatingWindow — viewport-centered, no drag, no persisted position, always above other windows); and the dossier / event-feed / action panels can each be docked into a left or right full-height pane (a new dock/onDockChange mode on FloatingWindow, with small left/right/float controls in each title bar, choice persisted in localStorage and reproduced on reload; the default "float" reproduces the current first-run layout exactly). The legend stays a small floating widget. Fixes #4, Fixes #5.
  • FloatingWindow — new optional props: pinned (top-center fixed bar), dock ('left'/'right' full-height columns) and onDockChange (renders the dock controls and reports the mode), plus dataTestId (a data-testid on the window root for e2e). anchor is now optional (defaults to top-center).
  • New data-testids — top-bar (pinned header), panel-dossier / panel-feed / panel-action (per-window roots), and dock-left / dock-right / dock-float (per-window dock controls).
  • New localStorage key — history.floatwin.v1.dock (per-id dock mode); mirrors the existing history.floatwin.v1.collapsed.
  • E2E — new "HUD revamp" suite: asserts the Map window and its zoom buttons are gone, the top bar is pinned and horizontally centered a few px from the top (and doesn't move under a drag attempt), the dossier docks to the left edge (full-height) and the feed to the right edge, and a dock choice survives a page reload — all deterministic under LLM_STUB=1 (11 tests, up from 9).

v0.4.0 — 2026-10-11 16:02

  • Fix: queued items disappearing from the pending list (#10). Root cause: SSE state events carried no freshness information, and the client's onState/onTurn handlers applied any incoming snapshot unconditionally. A stale snapshot — e.g. the SSE on-connect resync taken just before an add was saved, or an EventSource auto-reconnect — could arrive after the add's REST response and clobber it, so "add one: nothing happens, add another: both appear". Fix: every mutation now bumps a monotonic GameState.version (persisted in SQLite, started at 1 on create, bumped by queueRoundItem, the new removeRoundItem, and applyTurnResult; legacy games without the field are treated as version 0), and the client routes every state ingestion (SSE events, add/remove/jump responses, game loads) through a single "never go backwards" rule: a different game id is always adopted, the same game only when the incoming version is not older than the current one (compared inside the functional setGame updater, so it is stale-closure-proof).
  • New: remove button for queued items (#11). Each pending-queue row in "Your move" has a small trash-icon button that opens a shadcn Dialog confirmation (item quoted, Cancel / destructive Remove, dark command-center styling). Confirming calls the new POST /api/games/:id/queue/remove (body { index }) — same fast save → broadcast → respond order as the add routes, no LLM, no turn advance; invalid indices get a 400. The client api.removeQueueItem(id, index) is version-gated like every other state update, so the list can never regress after a removal.
  • Unit tests — new removeRoundItem tests (valid removal + version bump, out-of-bounds/non-integer → null), version-bump assertions for queueRoundItem/applyTurnResult/stub turn, a legacy-no-version case, and version round-trip in db.test.ts (39 tests, up from 35).
  • E2E — new "queue add is never clobbered by a stale SSE snapshot" test (one add appears immediately, still there after a 1s grace period for late SSE events, version strictly increases across adds, queue survives a full page reload) and a "remove with confirmation" test (cancel keeps the item, confirm removes it, no turn advance, version persisted) — all deterministic under LLM_STUB=1.

v0.3.0 — 2026-10-11 15:29

  • Deep-linking — the active game's id now lives in the URL (#/game/<id>), so a link opens that exact game and two tabs can run two different games; a bad or missing id falls back to the home screen. Fixes #3.

v0.2.0 — 2026-10-11 14:24

  • Batched round model — replaced the single-action/turn with a queued-round model. Actions and diplomatic messages accumulate in game.pending (no LLM call, no turn advance); a single "Jump forward" button commits the whole batch in one LLM turn. This fixes the 502 reliability problem by making each LLM call do one coherent batch of work instead of one tiny fragment.
  • New routes — POST /api/games/:id/action and POST /api/games/:id/diplomacy (fast, no LLM); POST /api/games/:id/jump (the single LLM call); POST /api/games/:id/turn removed.
  • New client API functions — api.addAction(id, text), api.addDiplomacy(id, to, message), api.jump(id); api.turn(...) removed.
  • ActionPanel rewrite — Action tab: textarea + small "Add action" button. Diplomacy tab: target select + textarea + small "Add to round" button. Advisor tab: input + "Ask advisor" button. Below the tabs: a visible pending-queue list (⚡ for action, 💬 for diplomacy with target name, truncated text, count badge) and a large "Jump forward" primary button that shows the pending count and disables while a jump is in flight.
  • App.tsx — runAddAction / runAddDiplomacy handlers (no busy state); runJump (with busy); the "Your move" window's title bar now shows a pending-count badge chip instead of the old jump button.
  • Deterministic stub updated — deterministicTurn now emits one event per queued item (action → military/success, diplomacy → diplomatic/partial) instead of one event per turn; still sets player military to clamp(cur+5) and allies with the first other nation.
  • Unit tests — rewrote engine.test.ts with batch-aware tests (7 new tests, 35 total up from 28); updated db.test.ts and map.test.ts fixtures to include pending: [].
  • E2E — rewrote e2e/sim.spec.ts to the new flow: queue action → assert pending list → queue diplomacy → assert pending list → jump → assert turn+1, date+2, military 80→85, two events, pending cleared, ally relation.
  • AGENTS.md — added Gitea-issue step to the change process; updated Layout and Conventions to reflect the batched-round model.

v0.1.0 — 2026-10-11 08:02

  • Added a Gitea Actions CI pipeline (.gitea/workflows/ci.yml: typecheck → lint → unit → build → deterministic Playwright e2e). E2e runs against the app's own dev webServer (LLM_STUB=1) — no dind/docker.
  • Committed package-lock.json so CI uses a reproducible npm ci.
  • Switched to the mandatory PR-based workflow: feature branch → PR to master → CI green → auto-merge → tag + manual deploy (documented in AGENTS.md).
  • Versioned the repo at 0.1.0 (package.json) and added CHANGELOG.md, .env (gitignored) and .env.example.
  • This change is the first one landed through the new PR process (dogfooding it).