37 KiB
37 KiB
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 inpublic/, but in the Docker image Vite copiespublic/*→dist/and nopublic/directory is kept — soloadRegionsfound nothing,expandCountriesToKeysdegraded 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.jsonfetch still worked, because the client is served fromdist/).loadRegionsnow probespublic/(dev) thendist/(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 thehistory-datavolume — pre-v0.19 saves were already dropped by v0.19.0 and are dropped again here) thenup -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
territorylist 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 byscripts/build-region-keys.mjsintopublic/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 yieldswestern-alun/western-qwvr/western-adsp/ …; an accented name like Shkodër becomesshkoder-gmnr). - The map, the model, and the LLM all now reason in region keys.
Nation.territoryand theRegionMapare keyed by region key; the choropleth resolves each sub-region's owner by its stampedproperties.regionKey(never the repacked numeric featureid, never the raw sub-region id — the #6 guard, now region-keyed). A newserver/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 aregionsreference table (region key → " (parent ISO_A3)"), andapplyTurnResultignores 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
territoryfield'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 runsdocker compose down -v(dropping thehistory-datavolume) thenup -d --build; there is no migration — start fresh after deploying. - Supersedes the abandoned
feat/sub-region-territorybranch, 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 rewrittenserver/map.test.ts, +2 inengine.test.ts) all pass; the production build is clean. The deterministicLLM_STUBand 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.parentis 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'sSelected: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 onproperties.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(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.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 - 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.parentand 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/geojsonout of the tsc program (tsconfigtypes: ["vite/client", "node"]). maplibre-gl's d.ts references theGeoJSONUMD global without importing it, so underskipLibCheckthe reference degrades toanyandWorldMap.tsx's deliberately-minimalGeoFeature(withgeometry: unknown) passes. The moment anything (e.g. a test importing@turf/turf) pulls@types/geojsoninto the program, those references typecheck for real andWorldMap.tsxfails 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.mjsscripts. - 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/simplifyrequires ≥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_Mto 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/simplifypass 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 (sha25670a43541…, verified across repeated runs);public/regions.geo.jsonand the builtdist/regions.geo.jsonare sha256-identical. - Tests.
scripts/regions.test.tsgains 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.jsonstill 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 newscripts/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
--bridgeflag 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.tsgains 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.jsonand the builtdist/regions.geo.jsonare 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.jsonshrinks 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-75Paris (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.tsgains 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), soWorldMap.tsx,App.tsx,shared/protocol.ts, andserver/map.tsall 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.jsonnow 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 stableidand aname. 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, andserver/map.tsall 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 theiradm0_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 reportBA-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-CACalifornia andUS-NYNew York (US);FR-75Paris andFR-13Bouches-du-Rhône (France);RU-MOWMoskovskaya (Russia). - Validation test updated —
scripts/regions.test.tswas 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) topublic/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 renamedcountries/countries-fill/countries-line→regions/regions-fill/regions-line, and the border line is thinned to0.75(from1.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-levelfeature.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 viaproperties.parent→RegionMap[parent](built on the existingregionOwnerkey lookup). The territory-change flash now expands a changed country to all of its sub-regions (via the newfeaturesByParents), so a whole-country transfer still pulses correctly at the finer resolution. - No ownership-model or LLM change —
buildRegionMap/RegionMapremain 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
mapFeatureOwnerunit tests and an updated #6 regression guard pinning the sub-region feature shape (numericid,regionId= sub-region code,parent= ISO_A3) and proving resolution viaparentwhile 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 vianpm run generate:regions) splits each of the 177 countries inpublic/countries.geo.jsoninto adaptive ~100–200 km sub-national regions on a regular lat/lon grid clipped to the country (Sutherland–Hodgman), and writes the committedpublic/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) withproperties = { 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 bynpm 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 whoseparentis 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-opacity0.85 → 1.0) and darkened the Natural Earth terrain raster (raster-opacity0.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
recentEventsin 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, theTurnResultcontract,max_tokens, andreasoning_effortare 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/:idroute 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
errorevent on delete, and the client'suseSsegains anonErrorhandler that returns the tab to the home screen (matching the existing "game not found" connect behavior). PresetPickergained anonDeleteprop, a per-carddata-game-idattribute, the delete button, and the confirm dialog;api.deleteGame(id),Db.delete(id), and the sharedDeleteGameResponsetype were added to support it.- Unit + e2e tests — new
Db.deletetest (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-upGETis 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 forid— the oldregions[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 originalidbeforesetData). Fix:applyRegionsnow stamps the stable ISO_A3 code intoproperties.regionId(andownerId), 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 repackedfeature.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
regionOwnertests (owned → owner record, unowned → undefined, empty/missing → undefined) plus a #6 regression guard that pins the exact MapLibre-repacked feature shape (numericid, ISO_A3 inproperties.regionId) and asserts resolution viaproperties.regionIdworks while the oldfeature.idpath 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/onMapReadymap plumbing is removed fromWorldMap); the top bar is now a fixed, non-movable element pinned to top-center a few px from the top (a newpinnedmode onFloatingWindow— 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 newdock/onDockChangemode onFloatingWindow, with small left/right/float controls in each title bar, choice persisted inlocalStorageand 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) andonDockChange(renders the dock controls and reports the mode), plusdataTestId(adata-testidon the window root for e2e).anchoris now optional (defaults totop-center).- New
data-testids —top-bar(pinned header),panel-dossier/panel-feed/panel-action(per-window roots), anddock-left/dock-right/dock-float(per-window dock controls). - New localStorage key —
history.floatwin.v1.dock(per-id dock mode); mirrors the existinghistory.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
stateevents carried no freshness information, and the client'sonState/onTurnhandlers 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 monotonicGameState.version(persisted in SQLite, started at 1 on create, bumped byqueueRoundItem, the newremoveRoundItem, andapplyTurnResult; 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 functionalsetGameupdater, 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
Dialogconfirmation (item quoted, Cancel / destructive Remove, dark command-center styling). Confirming calls the newPOST /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 clientapi.removeQueueItem(id, index)is version-gated like every other state update, so the list can never regress after a removal. - Unit tests — new
removeRoundItemtests (valid removal + version bump, out-of-bounds/non-integer →null), version-bump assertions forqueueRoundItem/applyTurnResult/stub turn, a legacy-no-version case, and version round-trip indb.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/actionandPOST /api/games/:id/diplomacy(fast, no LLM);POST /api/games/:id/jump(the single LLM call);POST /api/games/:id/turnremoved. - 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/runAddDiplomacyhandlers (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 —
deterministicTurnnow emits one event per queued item (action → military/success, diplomacy → diplomatic/partial) instead of one event per turn; still sets player military toclamp(cur+5)and allies with the first other nation. - Unit tests — rewrote
engine.test.tswith batch-aware tests (7 new tests, 35 total up from 28); updateddb.test.tsandmap.test.tsfixtures to includepending: []. - E2E — rewrote
e2e/sim.spec.tsto 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.jsonso CI uses a reproduciblenpm ci. - Switched to the mandatory PR-based workflow: feature branch → PR to
master→ CI green → auto-merge → tag + manual deploy (documented inAGENTS.md). - Versioned the repo at
0.1.0(package.json) and addedCHANGELOG.md,.env(gitignored) and.env.example. - This change is the first one landed through the new PR process (dogfooding it).